diff --git a/packages/admin-panel/src/editor/FieldsEditor.jsx b/packages/admin-panel/src/editor/FieldsEditor.jsx
index 5d92aa62bb..c60d0c5ba8 100644
--- a/packages/admin-panel/src/editor/FieldsEditor.jsx
+++ b/packages/admin-panel/src/editor/FieldsEditor.jsx
@@ -49,6 +49,9 @@ export const FieldsEditor = ({ fields, recordData, onEditField, onSetFormFile })
if (accessor) {
return accessor(recordData);
}
+ if (editConfig.sourceKey) {
+ return recordData[editConfig.sourceKey];
+ }
return recordData[source];
};
@@ -123,11 +126,7 @@ export const FieldsEditor = ({ fields, recordData, onEditField, onSetFormFile })
return [...result, field];
}, []);
- return (
-
- {visibleFormItems.map(getFieldInput)}
-
- );
+ return {visibleFormItems.map(getFieldInput)};
};
FieldsEditor.propTypes = {
diff --git a/packages/admin-panel/src/pages/resources/ResourcePage.jsx b/packages/admin-panel/src/pages/resources/ResourcePage.jsx
index f8cdf66eb7..4ee422e40f 100644
--- a/packages/admin-panel/src/pages/resources/ResourcePage.jsx
+++ b/packages/admin-panel/src/pages/resources/ResourcePage.jsx
@@ -153,7 +153,6 @@ ResourcePage.propTypes = {
exportConfig: PropTypes.object,
deleteConfig: PropTypes.object,
ExportModalComponent: PropTypes.elementType,
- TableComponent: PropTypes.elementType,
LinksComponent: PropTypes.elementType,
title: PropTypes.string,
baseFilter: PropTypes.object,
@@ -179,7 +178,6 @@ ResourcePage.defaultProps = {
exportConfig: {},
deleteConfig: {},
ExportModalComponent: null,
- TableComponent: null,
LinksComponent: null,
onProcessDataForSave: null,
title: null,
diff --git a/packages/admin-panel/src/routes/projects/projects.js b/packages/admin-panel/src/routes/projects/projects.js
index feb653beee..827144f03c 100644
--- a/packages/admin-panel/src/routes/projects/projects.js
+++ b/packages/admin-panel/src/routes/projects/projects.js
@@ -112,16 +112,17 @@ const NEW_PROJECT_COLUMNS = [
},
...CREATE_FIELDS,
{
- Header: 'Country code(s)',
+ Header: 'Countries',
source: 'country.code',
Filter: ArrayFilter,
Cell: ({ value }) => prettyArray(value),
editConfig: {
optionsEndpoint: 'countries',
- optionLabelKey: 'country.code',
+ optionLabelKey: 'country.name',
optionValueKey: 'country.id',
sourceKey: 'countries',
- allowMultipleValues: true,
+ pageSize: 'ALL',
+ type: 'checkboxList',
},
},
{
diff --git a/packages/admin-panel/src/routes/surveys/dataMapping.js b/packages/admin-panel/src/routes/surveys/dataMapping.js
index 4bcf1e3cf1..6e908d485f 100644
--- a/packages/admin-panel/src/routes/surveys/dataMapping.js
+++ b/packages/admin-panel/src/routes/surveys/dataMapping.js
@@ -28,7 +28,7 @@ const FIELDS = [
},
},
{
- Header: 'Data service sonfiguration',
+ Header: 'Data service configuration',
source: 'service_config',
Cell: DataSourceConfigView,
editConfig: DATA_ELEMENT_FIELD_EDIT_CONFIG,
diff --git a/packages/admin-panel/src/routes/surveys/surveys.js b/packages/admin-panel/src/routes/surveys/surveys.js
index 6f7886fb4b..e3d8b50b0c 100644
--- a/packages/admin-panel/src/routes/surveys/surveys.js
+++ b/packages/admin-panel/src/routes/surveys/surveys.js
@@ -54,14 +54,15 @@ const SURVEY_FIELDS = {
country_ids: {
Header: 'Countries',
source: 'countryNames', // TODO: cleanup as part of RN-910
+ required: true,
editConfig: {
+ type: 'checkboxList',
sourceKey: 'countryNames',
optionsEndpoint: 'countries',
optionLabelKey: 'name',
optionValueKey: 'name',
- allowMultipleValues: true,
labelTooltip: 'Select the countries this survey should be available in',
- required: true,
+ pageSize: 'ALL',
},
},
permission_group_id: {
diff --git a/packages/admin-panel/src/routes/users/permissions.js b/packages/admin-panel/src/routes/users/permissions.js
index 02bd3d7d18..5358aefb4f 100644
--- a/packages/admin-panel/src/routes/users/permissions.js
+++ b/packages/admin-panel/src/routes/users/permissions.js
@@ -7,6 +7,21 @@ import { getPluralForm } from '../../pages/resources/resourceName';
const RESOURCE_NAME = { singular: 'permission' };
+const EntityField = {
+ Header: 'Entity',
+ source: 'entity.name',
+ editConfig: {
+ optionsEndpoint: 'entities',
+ type: 'checkboxList',
+ baseFilter: { type: 'country' },
+ optionLabelKey: 'entity.name',
+ optionValueKey: 'entity.id',
+ allowMultipleValues: true,
+ labelTooltip: 'Select the countries for which this permission applies',
+ pageSize: 'ALL',
+ sourceKey: 'entity_id',
+ },
+};
export const PERMISSIONS_ENDPOINT = 'userEntityPermissions';
export const PERMISSIONS_COLUMNS = [
{
@@ -77,15 +92,7 @@ const CREATE_CONFIG = {
allowMultipleValues: true,
},
},
- {
- Header: 'Entity',
- source: 'entity.name',
- editConfig: {
- optionsEndpoint: 'entities',
- baseFilter: { type: 'country' },
- allowMultipleValues: true,
- },
- },
+ EntityField,
{
Header: 'Permission group',
source: 'permission_group.name',
diff --git a/packages/admin-panel/src/routes/visualisations/dashboardRelations.js b/packages/admin-panel/src/routes/visualisations/dashboardRelations.js
index f11ec7dbd8..d92835d207 100644
--- a/packages/admin-panel/src/routes/visualisations/dashboardRelations.js
+++ b/packages/admin-panel/src/routes/visualisations/dashboardRelations.js
@@ -8,10 +8,10 @@ import { prettyArray } from '../../utilities';
export const RESOURCE_NAME = { singular: 'dashboard relation' };
-// export for use on users page
export const DASHBOARD_RELATION_ENDPOINT = 'dashboardRelations';
-export const DASHBOARD_RELATION_COLUMNS = [
- {
+
+export const FIELDS = {
+ DASHBOARD_CODE: {
Header: 'Dashboard code',
source: 'dashboard.code',
editConfig: {
@@ -21,7 +21,7 @@ export const DASHBOARD_RELATION_COLUMNS = [
sourceKey: 'dashboard_id',
},
},
- {
+ DASHBOARD_ITEM_CODE: {
Header: 'Dashboard item code',
source: 'dashboard_item.code',
editConfig: {
@@ -31,7 +31,7 @@ export const DASHBOARD_RELATION_COLUMNS = [
sourceKey: 'child_id',
},
},
- {
+ PERMISSION_GROUPS: {
Header: 'Permission groups',
source: 'permission_groups',
Filter: ArrayFilter,
@@ -44,7 +44,7 @@ export const DASHBOARD_RELATION_COLUMNS = [
allowMultipleValues: true,
},
},
- {
+ ENTITY_TYPES: {
Header: 'Entity types',
source: 'entity_types',
Filter: ArrayFilter,
@@ -58,13 +58,13 @@ export const DASHBOARD_RELATION_COLUMNS = [
secondaryLabel: 'Input the entity types you want. e.g: ‘country’, ‘sub_district’',
},
},
- {
+ ATTRIBUTES_FILTER: {
Header: 'Attributes filter',
source: 'attributes_filter',
type: 'jsonTooltip',
editConfig: { type: 'jsonEditor' },
},
- {
+ PROJECT_CODES: {
Header: 'Project codes',
source: 'project_codes',
Filter: ArrayFilter,
@@ -77,10 +77,20 @@ export const DASHBOARD_RELATION_COLUMNS = [
allowMultipleValues: true,
},
},
- {
+ SORT_ORDER: {
Header: 'Sort order',
source: 'sort_order',
},
+};
+
+export const DASHBOARD_RELATION_COLUMNS = [
+ FIELDS.DASHBOARD_CODE,
+ FIELDS.DASHBOARD_ITEM_CODE,
+ FIELDS.PERMISSION_GROUPS,
+ FIELDS.ENTITY_TYPES,
+ FIELDS.ATTRIBUTES_FILTER,
+ FIELDS.PROJECT_CODES,
+ FIELDS.SORT_ORDER,
];
const COLUMNS = [
diff --git a/packages/admin-panel/src/routes/visualisations/dashboards.js b/packages/admin-panel/src/routes/visualisations/dashboards.js
index 8f26d3a049..6c8a00dc85 100644
--- a/packages/admin-panel/src/routes/visualisations/dashboards.js
+++ b/packages/admin-panel/src/routes/visualisations/dashboards.js
@@ -3,7 +3,11 @@
* Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd
*/
-import { RESOURCE_NAME as DASHBOARD_RELATION_RESOURCE_NAME } from './dashboardRelations';
+import {
+ DASHBOARD_RELATION_ENDPOINT,
+ FIELDS as DASHBOARD_RELATION_FIELDS,
+ RESOURCE_NAME as DASHBOARD_RELATION_RESOURCE_NAME,
+} from './dashboardRelations';
const RESOURCE_NAME = { singular: 'dashboard' };
@@ -61,44 +65,10 @@ const RELATION_FIELDS = [
source: 'dashboard_item.code',
editable: false,
},
- {
- Header: 'Permission groups',
- source: 'permission_groups',
- editConfig: {
- optionsEndpoint: 'permissionGroups',
- optionLabelKey: 'name',
- optionValueKey: 'name',
- sourceKey: 'permission_groups',
- allowMultipleValues: true,
- },
- },
- {
- Header: 'Entity types',
- source: 'entity_types',
- editConfig: {
- type: 'autocomplete',
- allowMultipleValues: true,
- canCreateNewOptions: true,
- optionLabelKey: 'entityTypes',
- optionValueKey: 'entityTypes',
- secondaryLabel: 'Input the entity types you want. e.g. ‘country’, ‘sub_district’',
- },
- },
- {
- Header: 'Project codes',
- source: 'project_codes',
- editConfig: {
- optionsEndpoint: 'projects',
- optionLabelKey: 'code',
- optionValueKey: 'code',
- sourceKey: 'project_codes',
- allowMultipleValues: true,
- },
- },
- {
- Header: 'Sort order',
- source: 'sort_order',
- },
+ DASHBOARD_RELATION_FIELDS.PERMISSION_GROUPS,
+ DASHBOARD_RELATION_FIELDS.ENTITY_TYPES,
+ DASHBOARD_RELATION_FIELDS.PROJECT_CODES,
+ DASHBOARD_RELATION_FIELDS.SORT_ORDER,
];
const RELATION_COLUMNS = [
@@ -108,10 +78,17 @@ const RELATION_COLUMNS = [
type: 'edit',
source: 'id',
actionConfig: {
- editEndpoint: 'dashboardRelations',
+ editEndpoint: DASHBOARD_RELATION_ENDPOINT,
fields: RELATION_FIELDS,
},
},
+ {
+ Header: 'Delete',
+ type: 'delete',
+ actionConfig: {
+ endpoint: DASHBOARD_RELATION_ENDPOINT,
+ },
+ },
];
const CREATE_CONFIG = {
diff --git a/packages/admin-panel/src/routes/visualisations/mapOverlayGroupRelations.js b/packages/admin-panel/src/routes/visualisations/mapOverlayGroupRelations.js
index ba07e79bcf..4a4c730436 100644
--- a/packages/admin-panel/src/routes/visualisations/mapOverlayGroupRelations.js
+++ b/packages/admin-panel/src/routes/visualisations/mapOverlayGroupRelations.js
@@ -7,11 +7,10 @@ const RESOURCE_NAME = { singular: 'map overlay group relation' };
export const RELATION_ENDPOINT = 'mapOverlayGroupRelations';
-const FIELDS = [
- {
+export const FIELDS = {
+ MAP_OVERLAY_GROUP_CODE: {
Header: 'Map overlay group code',
source: 'map_overlay_group.code',
-
editConfig: {
optionsEndpoint: 'mapOverlayGroups',
optionLabelKey: 'map_overlay_group.code',
@@ -19,23 +18,9 @@ const FIELDS = [
sourceKey: 'map_overlay_group_id',
},
},
- {
- Header: 'Child ID',
- source: 'child_id',
-
- editConfig: {
- optionsEndpoint: 'mapOverlays',
- optionLabelKey: 'mapOverlay.id',
- optionValueKey: 'mapOverlay.id',
- canCreateNewOptions: true,
- sourceKey: 'child_id',
- },
- },
- {
+ CHILD_TYPE: {
Header: 'Child type',
- width: 160,
source: 'child_type',
-
editConfig: {
options: [
{
@@ -49,29 +34,68 @@ const FIELDS = [
],
},
},
- {
+ CHILD_CODE: {
+ Header: 'Child code',
+ source: 'child_code',
+ },
+ CHILD_MAP_OVERLAY_CODE: {
+ Header: 'Child map overlay code',
+ id: 'child_map_overlay_code',
+ source: 'child_code',
+ editConfig: {
+ optionsEndpoint: 'mapOverlays',
+ optionLabelKey: 'mapOverlay.code',
+ optionValueKey: 'mapOverlay.id',
+ sourceKey: 'child_id',
+ visibilityCriteria: { child_type: 'mapOverlay' },
+ },
+ },
+ CHILD_MAP_OVERLAY_GROUP_CODE: {
+ Header: 'Child map overlay group code',
+ id: 'child_map_overlay_group_code',
+ source: 'child_code',
+ editConfig: {
+ optionsEndpoint: 'mapOverlayGroups',
+ optionLabelKey: 'mapOverlayGroups.code',
+ optionValueKey: 'mapOverlayGroups.id',
+ sourceKey: 'child_id',
+ visibilityCriteria: { child_type: 'mapOverlayGroup' },
+ },
+ },
+ SORT_ORDER: {
Header: 'Sort order',
source: 'sort_order',
},
+};
+
+const EDIT_FIELDS = [
+ FIELDS.MAP_OVERLAY_GROUP_CODE,
+ FIELDS.CHILD_TYPE,
+ FIELDS.CHILD_MAP_OVERLAY_CODE,
+ FIELDS.CHILD_MAP_OVERLAY_GROUP_CODE,
+ FIELDS.SORT_ORDER,
];
const COLUMNS = [
- ...FIELDS,
+ FIELDS.MAP_OVERLAY_GROUP_CODE,
+ FIELDS.CHILD_TYPE,
+ FIELDS.CHILD_CODE,
+ FIELDS.SORT_ORDER,
{
Header: 'Edit',
type: 'edit',
source: 'id',
actionConfig: {
title: `Edit ${RESOURCE_NAME.singular}`,
- editEndpoint: 'mapOverlayGroupRelations',
- fields: FIELDS,
+ editEndpoint: RELATION_ENDPOINT,
+ fields: EDIT_FIELDS,
},
},
{
Header: 'Delete',
type: 'delete',
actionConfig: {
- endpoint: 'mapOverlayGroupRelations',
+ endpoint: RELATION_ENDPOINT,
},
},
];
@@ -79,7 +103,7 @@ const COLUMNS = [
const CREATE_CONFIG = {
actionConfig: {
editEndpoint: RELATION_ENDPOINT,
- fields: FIELDS,
+ fields: EDIT_FIELDS,
},
};
diff --git a/packages/admin-panel/src/routes/visualisations/mapOverlayGroups.js b/packages/admin-panel/src/routes/visualisations/mapOverlayGroups.js
index ec5291c371..f28661ef5c 100644
--- a/packages/admin-panel/src/routes/visualisations/mapOverlayGroups.js
+++ b/packages/admin-panel/src/routes/visualisations/mapOverlayGroups.js
@@ -3,6 +3,8 @@
* Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd
*/
+import { FIELDS as RELATION_FIELDS, RELATION_ENDPOINT } from './mapOverlayGroupRelations';
+
const RESOURCE_NAME = { singular: 'map overlay group' };
const MAP_OVERLAY_GROUPS_ENDPOINT = 'mapOverlayGroups';
@@ -36,50 +38,35 @@ const COLUMNS = [
},
];
-export const RELATION_FIELDS = [
- {
- Header: 'Child ID',
- source: 'child_id',
-
- editConfig: {
- optionsEndpoint: 'mapOverlays',
- optionLabelKey: 'mapOverlay.id',
- optionValueKey: 'mapOverlay.id',
- sourceKey: 'child_id',
- },
- },
+export const RELATION_COLUMNS = [
+ RELATION_FIELDS.CHILD_TYPE,
+ RELATION_FIELDS.CHILD_CODE,
+ RELATION_FIELDS.SORT_ORDER,
{
- Header: 'Child type',
- source: 'child_type',
-
- editConfig: {
- options: [
- {
- label: 'Map overlay',
- value: 'mapOverlay',
- },
+ Header: 'Edit',
+ type: 'edit',
+ source: 'id',
+ actionConfig: {
+ title: 'Edit map overlay group relation',
+ editEndpoint: RELATION_ENDPOINT,
+ fields: [
{
- label: 'Map overlay group',
- value: 'mapOverlayGroup',
+ Header: 'Map overlay group code',
+ source: 'map_overlay_group.code',
+ editable: false,
},
+ RELATION_FIELDS.CHILD_TYPE,
+ RELATION_FIELDS.CHILD_MAP_OVERLAY_CODE,
+ RELATION_FIELDS.CHILD_MAP_OVERLAY_GROUP_CODE,
+ RELATION_FIELDS.SORT_ORDER,
],
},
},
{
- Header: 'Sort order',
- source: 'sort_order',
- },
-];
-
-export const RELATION_COLUMNS = [
- ...RELATION_FIELDS,
- {
- Header: 'Edit',
- type: 'edit',
- source: 'id',
+ Header: 'Delete',
+ type: 'delete',
actionConfig: {
- editEndpoint: 'mapOverlayGroupRelations',
- fields: RELATION_FIELDS,
+ endpoint: RELATION_ENDPOINT,
},
},
];
@@ -99,7 +86,7 @@ export const mapOverlayGroups = {
createConfig: CREATE_CONFIG,
nestedViews: [
{
- title: 'Map Overlay Group Relations',
+ title: 'Map overlay group relations',
columns: RELATION_COLUMNS,
endpoint: 'mapOverlayGroups/{id}/mapOverlayGroupRelations',
path: '/:id/map-overlay-group-relations',
diff --git a/packages/admin-panel/src/table/ColumnFilter.jsx b/packages/admin-panel/src/table/ColumnFilter.jsx
deleted file mode 100644
index df144900e6..0000000000
--- a/packages/admin-panel/src/table/ColumnFilter.jsx
+++ /dev/null
@@ -1,35 +0,0 @@
-/*
- * Tupaia
- * Copyright (c) 2017 - 2020 Beyond Essential Systems Pty Ltd
- */
-
-import React from 'react';
-import { TextField } from '@tupaia/ui-components';
-import PropTypes from 'prop-types';
-import { labelToId } from '../utilities';
-
-export const ColumnFilter = ({ column, filter, onChange }) => (
- onChange(event.target.value)}
- id={`dataTableColumnFilter-${labelToId(column?.id)}`}
- />
-);
-
-ColumnFilter.propTypes = {
- column: PropTypes.shape({
- id: PropTypes.string,
- }),
- filter: PropTypes.shape({
- value: PropTypes.string,
- }),
- onChange: PropTypes.func,
-};
-
-ColumnFilter.defaultProps = {
- column: null,
- filter: null,
- onChange: null,
-};
diff --git a/packages/admin-panel/src/table/DataFetchingTable/Cells.jsx b/packages/admin-panel/src/table/DataFetchingTable/Cells.jsx
index 61a686504c..906d17facc 100644
--- a/packages/admin-panel/src/table/DataFetchingTable/Cells.jsx
+++ b/packages/admin-panel/src/table/DataFetchingTable/Cells.jsx
@@ -5,46 +5,9 @@
import React from 'react';
import PropTypes from 'prop-types';
-import styled, { css } from 'styled-components';
-import { TableCell as MuiTableCell } from '@material-ui/core';
+import styled from 'styled-components';
import { Link } from 'react-router-dom';
-const Cell = styled(MuiTableCell)`
- font-size: 0.75rem;
- padding: 0;
- border: none;
- position: relative;
- &:first-child {
- padding-inline-start: 1.5rem;
- }
- &:last-child {
- padding-inline-end: 1rem;
- }
-`;
-
-const CellContentWrapper = styled.div`
- padding: 0.7rem;
- ${({ $isButtonColumn }) =>
- $isButtonColumn &&
- css`
- padding-inline: 0;
- padding-block: 0;
- text-align: center;
- `}
- height: 100%;
- display: flex;
- align-items: center;
-
- tr:not(:last-child) & {
- border-bottom: 1px solid ${({ theme }) => theme.palette.grey[400]};
- }
- td:first-child & {
- padding-inline-start: 0.2rem;
- }
-
- line-height: 1.5;
-`;
-
// Flex does not support ellipsis so we need to have another container to handle the ellipsis
const CellContentContainer = styled.div`
white-space: nowrap;
@@ -53,84 +16,6 @@ const CellContentContainer = styled.div`
width: 100%;
`;
-const HeaderCell = styled(Cell)`
- color: ${({ theme }) => theme.palette.text.secondary};
- font-weight: ${({ theme }) => theme.typography.fontWeightMedium};
- background-color: ${({ theme }) => theme.palette.background.paper};
- border-bottom: 1px solid ${({ theme }) => theme.palette.grey[400]};
- padding-block: 0.7rem;
- padding-inline: 0.7rem 0;
- display: flex;
- position: initial; // override this because we have 2 sticky header rows so we will apply sticky to the thead element
- background-color: ${({ theme }) => theme.palette.background.paper};
- .MuiTableSortLabel-icon {
- opacity: 0.5;
- }
- .MuiTableSortLabel-active .MuiTableSortLabel-icon {
- opacity: 1;
- }
-`;
-
-const ColResize = styled.div.attrs({
- onClick: e => {
- // suppress other events when resizing
- e.preventDefault();
- e.stopPropagation();
- },
-})`
- width: 2rem;
- height: 100%;
- cursor: col-resize;
-`;
-
-export const HeaderDisplayCell = ({ children, canResize, getResizerProps, ...props }) => {
- return (
-
- {children}
- {canResize && }
-
- );
-};
-
-HeaderDisplayCell.propTypes = {
- children: PropTypes.node,
- width: PropTypes.string,
- canResize: PropTypes.bool,
- getResizerProps: PropTypes.func,
-};
-
-HeaderDisplayCell.defaultProps = {
- width: null,
- children: null,
- canResize: false,
- getResizerProps: () => {},
-};
-
-export const TableCell = ({ children, width, isButtonColumn, url, ...props }) => {
- return (
-
-
-
- {children}
-
-
- |
- );
-};
-
-TableCell.propTypes = {
- children: PropTypes.node.isRequired,
- width: PropTypes.number,
- isButtonColumn: PropTypes.bool,
- url: PropTypes.string,
-};
-
-TableCell.defaultProps = {
- width: null,
- isButtonColumn: false,
- url: null,
-};
-
const CellLink = styled(Link)`
color: inherit;
text-decoration: none;
@@ -162,7 +47,6 @@ export const DisplayCell = ({
getNestedViewLink,
basePath,
isButtonColumn,
- ...props
}) => {
const generateLink = () => {
if (isButtonColumn || (!detailUrl && !getNestedViewLink)) return null;
@@ -176,9 +60,9 @@ export const DisplayCell = ({
};
const url = generateLink();
return (
-
+
{children}
-
+
);
};
diff --git a/packages/admin-panel/src/table/DataFetchingTable/DataFetchingTable.jsx b/packages/admin-panel/src/table/DataFetchingTable/DataFetchingTable.jsx
index 9e16e4fe92..e7eb941249 100644
--- a/packages/admin-panel/src/table/DataFetchingTable/DataFetchingTable.jsx
+++ b/packages/admin-panel/src/table/DataFetchingTable/DataFetchingTable.jsx
@@ -5,20 +5,10 @@
import React, { memo, useEffect, useMemo } from 'react';
import styled from 'styled-components';
import { connect } from 'react-redux';
-import { useTable, usePagination, useSortBy, useResizeColumns, useFlexLayout } from 'react-table';
-import {
- TableHead,
- TableContainer as MuiTableContainer,
- TableRow,
- TableBody,
- Table,
- Typography,
- TableSortLabel,
-} from '@material-ui/core';
-import { KeyboardArrowDown } from '@material-ui/icons';
+import { Typography } from '@material-ui/core';
import queryString from 'query-string';
import PropTypes from 'prop-types';
-import { Alert } from '@tupaia/ui-components';
+import { Alert, FilterableTable } from '@tupaia/ui-components';
import { generateConfigForColumnType } from '../columnTypes';
import { getIsFetchingData, getTableState } from '../selectors';
import { getIsChangingDataOnServer } from '../../dataChangeListener';
@@ -27,16 +17,13 @@ import {
changeFilters,
changePage,
changePageSize,
- changeResizedColumns,
changeSorting,
clearError,
confirmAction,
refreshData,
} from '../actions';
-import { FilterCell } from './FilterCell';
-import { Pagination } from './Pagination';
-import { DisplayCell, HeaderDisplayCell } from './Cells';
import { ConfirmDeleteModal } from '../../widgets';
+import { DisplayCell } from './Cells';
const ErrorAlert = styled(Alert).attrs({
severity: 'error',
@@ -44,28 +31,6 @@ const ErrorAlert = styled(Alert).attrs({
margin: 0.5rem;
`;
-const TableContainer = styled(MuiTableContainer)`
- position: relative;
- flex: 1;
- overflow: auto;
- table {
- min-width: 45rem;
- }
- // Because we want two header rows to be sticky, we need to set the position of the thead to sticky
- thead {
- position: sticky;
- top: 0;
- z-index: 2;
- background-color: ${({ theme }) => theme.palette.background.paper};
- }
- tr {
- display: flex;
-
- .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline {
- border-color: ${({ theme }) => theme.palette.primary.main};
- }
-`;
-
const Wrapper = styled.div`
display: flex;
flex-direction: column;
@@ -87,12 +52,26 @@ const MessageWrapper = styled.div`
background-color: rgba(255, 255, 255, 0.5);
`;
+const ButtonCell = styled.div`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+`;
+
+const SingleButtonWrapper = styled.div`
+ width: ${({ $width }) => $width}px;
+ .cell-content:has(&) {
+ padding-block: 0;
+ padding-inline-end: 0;
+ }
+`;
+
const formatColumnForReactTable = (originalColumn, reduxId) => {
const { source, type, actionConfig, filterable, ...restOfColumn } = originalColumn;
const id = source || type;
return {
id,
- accessor: id?.includes('.') ? row => row[source] : id, // react-table doesn't like .'s
+ accessor: id?.includes('.') ? originalRow => originalRow[source] : id, // react-table doesn't like .'s
actionConfig,
reduxId,
type,
@@ -134,69 +113,56 @@ const DataFetchingTableComponent = memo(
baseFilter,
basePath,
resourceName,
- defaultSorting,
- actionLabel = 'Action',
+ actionLabel,
}) => {
- const formattedColumns = useMemo(
- () => columns.map(column => formatColumnForReactTable(column)),
- [JSON.stringify(columns)],
- );
+ const formattedColumns = useMemo(() => {
+ const cols = columns.map(column => formatColumnForReactTable(column));
+ // for the columns that are not buttons, display them using a custom wrapper
+ const nonButtonColumns = cols
+ .filter(col => !col.isButtonColumn)
+ .map(col => ({
+ ...col,
+ // eslint-disable-next-line react/prop-types
+ Cell: ({ value, ...props }) => (
+
+ {/** Columns can have custom Cells. If they do, render these, otherwise render the value */}
+ {col.Cell ? col.Cell({ value, ...props }) : value}
+
+ ),
+ }));
+ const buttonColumns = cols.filter(col => col.isButtonColumn);
+ if (!buttonColumns.length) return nonButtonColumns;
- const memoisedData = useMemo(() => data, [JSON.stringify(data)]);
-
- const {
- getTableProps,
- getTableBodyProps,
- headerGroups,
- prepareRow,
- rows,
- pageCount,
- gotoPage,
- setPageSize,
- visibleColumns,
- setSortBy,
- // Get the state from the instance
- state: { pageIndex: tablePageIndex, pageSize: tablePageSize, sortBy: tableSorting },
- } = useTable(
- {
- columns: formattedColumns,
- data: memoisedData,
- initialState: {
- pageIndex,
- pageSize,
- sortBy: sorting,
- hiddenColumns: columns
- .filter(column => column.show === false)
- .map(column => column.source ?? column.type),
+ const buttonWidths = buttonColumns.reduce((acc, { width }) => acc + (width || 60), 0);
+ // Group all button columns into a single column so they can be displayed together under a single header
+ const singleButtonColumn = {
+ Header: actionLabel || 'Action',
+ maxWidth: buttonWidths,
+ width: buttonWidths,
+ // eslint-disable-next-line react/prop-types
+ Cell: ({ row }) => {
+ return (
+
+ {buttonColumns.map(({ Cell, accessor, ...col }) => {
+ return (
+
+ |
+
+ );
+ })}
+
+ );
},
- manualPagination: true,
- pageCount: numberOfPages,
- manualSortBy: true,
- },
- useSortBy,
- usePagination,
- useFlexLayout,
- useResizeColumns,
- );
-
- // Listen for changes in pagination and use the state to fetch our new data
- useEffect(() => {
- onPageChange(tablePageIndex);
- }, [tablePageIndex]);
-
- useEffect(() => {
- onPageSizeChange(tablePageSize);
- gotoPage(0);
- }, [tablePageSize]);
-
- useEffect(() => {
- onSortedChange(tableSorting);
- gotoPage(0);
- }, [tableSorting]);
-
- useEffect(() => {
- onRefreshData();
- }, [filters, pageIndex, pageSize, sorting]);
+ };
+ return [...nonButtonColumns, singleButtonColumn];
+ }, [JSON.stringify(columns)]);
useEffect(() => {
if (!isChangingDataOnServer && !errorMessage) {
@@ -214,23 +180,16 @@ const DataFetchingTableComponent = memo(
} else {
initialiseTable();
}
- gotoPage(0);
- setSortBy(defaultSorting ?? []); // reset sorting when table is re-initialised
}, [endpoint, JSON.stringify(baseFilter)]);
- const onChangeFilters = newFilters => {
- onFilteredChange(newFilters);
- gotoPage(0);
- };
+ useEffect(() => {
+ onRefreshData();
+ }, [filters, pageIndex, pageSize, JSON.stringify(sorting)]);
const isLoading = isFetchingData || isChangingDataOnServer;
- const displayFilterRow = visibleColumns.some(column => column.filterable !== false);
-
const { singular = 'record' } = resourceName;
- const actionColumns = visibleColumns.filter(column => column.isButtonColumn);
-
return (
{errorMessage && {errorMessage}}
@@ -244,119 +203,25 @@ const DataFetchingTableComponent = memo(
No data to display
)}
-
-
-
- {headerGroups.map(({ getHeaderGroupProps, headers }, index) => (
- // eslint-disable-next-line react/no-array-index-key
-
- {headers.map(
- (
- {
- getHeaderProps,
- render,
- isSorted,
- isSortedDesc,
- getSortByToggleProps,
- canSort,
- getResizerProps,
- canResize,
- isButtonColumn,
- },
- i,
- ) => {
- // we will make a separate 'Actions' header
- if (isButtonColumn) return null;
- return (
-
- {render('Header')}
- {canSort && (
-
- )}
-
- );
- },
- )}
- {actionColumns.length > 0 && (
- 1
- ? actionColumns.reduce((acc, column) => acc + column.totalWidth, 0)
- : 'auto'
- }
- isButtonColumn
- >
- {actionLabel}
-
- )}
-
- ))}
-
- {displayFilterRow &&
- visibleColumns.map(column => {
- return (
-
- );
- })}
-
-
-
- {rows.map((row, index) => {
- prepareRow(row);
- return (
- // eslint-disable-next-line react/no-array-index-key
-
- {row.cells.map(({ getCellProps, render }, i) => {
- const col = visibleColumns[i];
- return (
-
- {render('Cell')}
-
- );
- })}
-
- );
- })}
-
-
-
- column.show === false)
+ .map(column => column.source ?? column.type)}
+ onChangePage={onPageChange}
+ onChangePageSize={onPageSizeChange}
+ onChangeSorting={onSortedChange}
+ refreshData={onRefreshData}
+ errorMessage={errorMessage}
totalRecords={totalRecords}
/>
@@ -387,24 +252,19 @@ DataFetchingTableComponent.propTypes = {
isFetchingData: PropTypes.bool.isRequired,
isChangingDataOnServer: PropTypes.bool.isRequired,
numberOfPages: PropTypes.number,
- TableComponent: PropTypes.elementType,
onCancelAction: PropTypes.func.isRequired,
onConfirmAction: PropTypes.func.isRequired,
onFilteredChange: PropTypes.func.isRequired,
onPageChange: PropTypes.func.isRequired,
onPageSizeChange: PropTypes.func.isRequired,
onRefreshData: PropTypes.func.isRequired,
- onResizedChange: PropTypes.func.isRequired,
onSortedChange: PropTypes.func.isRequired,
initialiseTable: PropTypes.func.isRequired,
pageIndex: PropTypes.number.isRequired,
pageSize: PropTypes.number.isRequired,
- reduxId: PropTypes.string.isRequired,
- resizedColumns: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
sorting: PropTypes.array.isRequired,
nestingLevel: PropTypes.number,
deleteConfig: PropTypes.object,
- actionColumns: PropTypes.arrayOf(PropTypes.shape({})),
totalRecords: PropTypes.number,
detailUrl: PropTypes.string,
getHasNestedView: PropTypes.func,
@@ -413,7 +273,6 @@ DataFetchingTableComponent.propTypes = {
baseFilter: PropTypes.object,
basePath: PropTypes.string,
resourceName: PropTypes.object,
- defaultSorting: PropTypes.array,
actionLabel: PropTypes.string,
};
@@ -424,8 +283,6 @@ DataFetchingTableComponent.defaultProps = {
numberOfPages: 0,
nestingLevel: 0,
deleteConfig: {},
- TableComponent: undefined,
- actionColumns: [],
totalRecords: 0,
detailUrl: '',
getHasNestedView: null,
@@ -433,7 +290,6 @@ DataFetchingTableComponent.defaultProps = {
baseFilter: null,
basePath: '',
resourceName: {},
- defaultSorting: [],
actionLabel: 'Action',
};
@@ -453,7 +309,6 @@ const mapDispatchToProps = (dispatch, { reduxId }) => ({
dispatch(changePageSize(reduxId, newPageSize, newPageIndex)),
onSortedChange: newSorting => dispatch(changeSorting(reduxId, newSorting)),
onFilteredChange: newFilters => dispatch(changeFilters(reduxId, newFilters)),
- onResizedChange: newResized => dispatch(changeResizedColumns(reduxId, newResized)),
});
const mergeProps = (stateProps, { dispatch, ...dispatchProps }, ownProps) => {
@@ -469,6 +324,7 @@ const mergeProps = (stateProps, { dispatch, ...dispatchProps }, ownProps) => {
const onRefreshData = () =>
dispatch(refreshData(reduxId, endpoint, columns, baseFilter, stateProps));
const initialiseTable = (filters = defaultFilters) => {
+ dispatch(changePageSize(reduxId, 20, 0));
dispatch(changeSorting(reduxId, defaultSorting));
dispatch(changeFilters(reduxId, filters)); // will trigger a data fetch afterwards
dispatch(clearError());
diff --git a/packages/admin-panel/src/table/DataFetchingTable/FilterCell.jsx b/packages/admin-panel/src/table/DataFetchingTable/FilterCell.jsx
deleted file mode 100644
index c0ad5ad4f4..0000000000
--- a/packages/admin-panel/src/table/DataFetchingTable/FilterCell.jsx
+++ /dev/null
@@ -1,81 +0,0 @@
-/*
- * Tupaia
- * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd
- */
-import React from 'react';
-import PropTypes from 'prop-types';
-import styled from 'styled-components';
-import { DefaultFilter } from '../columnTypes/columnFilters';
-import { HeaderDisplayCell } from './Cells';
-
-const FilterWrapper = styled.div`
- .MuiFormControl-root {
- margin-block-end: 0;
- }
- .MuiInputBase-input,
- .MuiOutlinedInput-root {
- font-size: inherit;
- }
- .MuiSvgIcon-root {
- font-size: 1.25rem;
- }
- .MuiInputBase-input {
- padding-block: 0.6rem;
- padding-inline: 0.6rem;
- line-height: 1.2;
- }
- .MuiAutocomplete-popperDisablePortal,
- .MuiPaper-root,
- .MuiAutocomplete-option,
- .MuiAutocomplete-popperDisablePortal,
- .MuiPaper-root,
- .MuiAutocomplete-option {
- font-size: inherit;
- }
- .MuiAutocomplete-listbox {
- padding-block: 0.3rem;
- }
- .MuiAutocomplete-option {
- padding-block: 0.5rem;
- }
-`;
-
-export const FilterCell = ({ column, filters, onFilteredChange, ...props }) => {
- const { id, Filter } = column;
- const existingFilter = filters?.find(f => f.id === id);
- const handleUpdate = value => {
- const updatedFilters = existingFilter
- ? filters.map(f => (f.id === id ? { ...f, value } : f))
- : [...filters, { id, value }];
-
- onFilteredChange(updatedFilters);
- };
- if (!column.filterable) return ;
-
- return (
-
-
- {Filter ? (
-
- ) : (
- handleUpdate(e.target.value)}
- aria-label={`Search ${column.Header}`}
- />
- )}
-
-
- );
-};
-
-FilterCell.propTypes = {
- filters: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
- onFilteredChange: PropTypes.func.isRequired,
- column: PropTypes.shape({
- id: PropTypes.string.isRequired,
- Header: PropTypes.string.isRequired,
- Filter: PropTypes.func,
- filterable: PropTypes.bool,
- }).isRequired,
-};
diff --git a/packages/admin-panel/src/table/ExpansionContainer.jsx b/packages/admin-panel/src/table/ExpansionContainer.jsx
deleted file mode 100644
index d8297896cf..0000000000
--- a/packages/admin-panel/src/table/ExpansionContainer.jsx
+++ /dev/null
@@ -1,72 +0,0 @@
-/**
- * Tupaia MediTrak
- * Copyright (c) 2018 Beyond Essential Systems Pty Ltd
- */
-import React from 'react';
-import styled from 'styled-components';
-
-const Container = styled.div`
- position: relative;
- padding: 0 20px 20px;
-
- .ReactTable {
- margin-top: 12px;
- margin-bottom: 12px;
- z-index: 1;
- }
-
- .rt-thead.-header {
- border-top: none;
- border-left: none;
- border-right: none;
- }
-
- .rt-tr {
- padding-right: 5px;
- }
-`;
-
-const SHADOW = 'rgba(0, 0, 0, 0.2)';
-
-const Top = styled.div`
- position: absolute;
- top: -72px;
- left: 0;
- right: 0;
- height: 8px;
- box-shadow: 0 -6px 10px ${SHADOW};
-`;
-
-const Bottom = styled(Top)`
- top: auto;
- bottom: 0;
- box-shadow: 0 6px 10px ${SHADOW};
-`;
-
-const Left = styled.div`
- position: absolute;
- left: 0;
- bottom: 0;
- top: -72px;
- width: 8px;
- content: '';
- box-shadow: -6px 0 10px ${SHADOW};
-`;
-
-const Right = styled(Left)`
- right: 0;
- left: auto;
- content: '';
- box-shadow: 6px 0 10px ${SHADOW};
-`;
-
-// eslint-disable-next-line react/prop-types
-export const ExpansionContainer = ({ children }) => (
-
-
-
-
- {children}
-
-
-);
diff --git a/packages/admin-panel/src/table/actions.js b/packages/admin-panel/src/table/actions.js
index 6c3921f7e4..02604f2376 100644
--- a/packages/admin-panel/src/table/actions.js
+++ b/packages/admin-panel/src/table/actions.js
@@ -12,15 +12,12 @@ import {
ACTION_CONFIRM,
ACTION_REQUEST,
CLEAR_ERROR,
- COLUMNS_RESIZE,
DATA_CHANGE_ERROR,
DATA_CHANGE_REQUEST,
DATA_CHANGE_SUCCESS,
DATA_FETCH_ERROR,
DATA_FETCH_REQUEST,
DATA_FETCH_SUCCESS,
- EXPANSIONS_CHANGE,
- EXPANSIONS_TAB_CHANGE,
FILTERS_CHANGE,
PAGE_INDEX_CHANGE,
PAGE_SIZE_CHANGE,
@@ -42,31 +39,12 @@ export const changePageSize = (reduxId, pageSize, pageIndex) => ({
reduxId,
});
-export const changeExpansions = (reduxId, expansions) => ({
- type: EXPANSIONS_CHANGE,
- expansions,
- reduxId,
-});
-
-export const changeExpansionsTab = (reduxId, rowId, tabValue) => ({
- type: EXPANSIONS_TAB_CHANGE,
- reduxId,
- rowId,
- tabValue,
-});
-
export const changeFilters = (reduxId, filters) => ({
type: FILTERS_CHANGE,
filters,
reduxId,
});
-export const changeResizedColumns = (reduxId, resizedColumns) => ({
- type: COLUMNS_RESIZE,
- resizedColumns,
- reduxId,
-});
-
export const changeSorting = (reduxId, sorting) => ({
type: SORTING_CHANGE,
sorting,
diff --git a/packages/admin-panel/src/table/constants.js b/packages/admin-panel/src/table/constants.js
index 4c36037f98..198546cdcf 100644
--- a/packages/admin-panel/src/table/constants.js
+++ b/packages/admin-panel/src/table/constants.js
@@ -16,9 +16,6 @@ export const DATA_CHANGE_ERROR = 'DATA_CHANGE_ERROR';
export const PAGE_INDEX_CHANGE = 'PAGE_INDEX_CHANGE';
export const PAGE_SIZE_CHANGE = 'PAGE_SIZE_CHANGE';
export const FILTERS_CHANGE = 'FILTERS_CHANGE';
-export const EXPANSIONS_CHANGE = 'EXPANSIONS_CHANGE';
-export const EXPANSIONS_TAB_CHANGE = 'EXPANSIONS_TAB_CHANGE';
-export const COLUMNS_RESIZE = 'COLUMNS_RESIZE';
export const SORTING_CHANGE = 'SORTING_CHANGE';
export const CLEAR_ERROR = 'CLEAR_ERROR';
@@ -40,7 +37,4 @@ export const DEFAULT_TABLE_STATE = {
pageSize: 20,
filters: [],
sorting: [],
- expansions: {},
- expansionTabStates: {},
- resizedColumns: [],
};
diff --git a/packages/admin-panel/src/table/reducer.js b/packages/admin-panel/src/table/reducer.js
index ad2d9bbb1f..d7fbbb9f5a 100644
--- a/packages/admin-panel/src/table/reducer.js
+++ b/packages/admin-panel/src/table/reducer.js
@@ -16,9 +16,6 @@ import {
PAGE_INDEX_CHANGE,
PAGE_SIZE_CHANGE,
FILTERS_CHANGE,
- EXPANSIONS_CHANGE,
- EXPANSIONS_TAB_CHANGE,
- COLUMNS_RESIZE,
SORTING_CHANGE,
DATA_CHANGE_REQUEST,
DEFAULT_TABLE_STATE,
@@ -77,17 +74,12 @@ const stateChanges = {
}),
[FILTERS_CHANGE]: payload => ({
...payload,
+ pageIndex: 0,
}),
- [EXPANSIONS_CHANGE]: payload => payload,
- [EXPANSIONS_TAB_CHANGE]: ({ rowId, tabValue }, currentState) => ({
- expansionTabStates: {
- ...currentState.expansionTabStates,
- [rowId]: tabValue,
- },
- }),
- [COLUMNS_RESIZE]: payload => payload,
+
[SORTING_CHANGE]: payload => ({
...payload,
+ pageIndex: 0,
}),
[CLEAR_ERROR]: () => ({
errorMessage: DEFAULT_TABLE_STATE.errorMessage,
diff --git a/packages/admin-panel/src/theme/theme.js b/packages/admin-panel/src/theme/theme.js
index 9cd4c7627b..2e9f25a92c 100644
--- a/packages/admin-panel/src/theme/theme.js
+++ b/packages/admin-panel/src/theme/theme.js
@@ -47,7 +47,7 @@ const palette = {
300: COLORS.GREY_E2,
400: COLORS.GREY_DE,
500: COLORS.GREY_9F,
- 600: COLORS.GREY_72,
+ 600: COLORS.GREY_B8,
},
background: {
default: COLORS.LIGHTGREY,
@@ -180,8 +180,15 @@ const overrides = {
'@global': {
label: {
fontWeight: 500,
- '& .MuiSvgIcon-root': {
- color: palette.text.secondary, // tooltip icon color
+ },
+ },
+ },
+ MuiSvgIcon: {
+ root: {
+ 'label &': {
+ color: palette.text.secondary,
+ '&.checkbox': {
+ fill: 'transparent',
},
},
},
diff --git a/packages/admin-panel/src/widgets/Checkbox/Checkbox.jsx b/packages/admin-panel/src/widgets/Checkbox/Checkbox.jsx
new file mode 100644
index 0000000000..10d1a6a0a1
--- /dev/null
+++ b/packages/admin-panel/src/widgets/Checkbox/Checkbox.jsx
@@ -0,0 +1,15 @@
+/*
+ * Tupaia
+ * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd
+ */
+
+import React from 'react';
+import styled from 'styled-components';
+import MuiCheckbox from '@material-ui/core/Checkbox';
+import { CheckboxCheckedIcon } from './CheckboxCheckedIcon';
+import { CheckboxUncheckedIcon } from './CheckboxUncheckedIcon';
+
+export const Checkbox = styled(MuiCheckbox).attrs(props => ({
+ icon: ,
+ checkedIcon: ,
+}))``;
diff --git a/packages/admin-panel/src/widgets/Checkbox/CheckboxCheckedIcon.jsx b/packages/admin-panel/src/widgets/Checkbox/CheckboxCheckedIcon.jsx
new file mode 100644
index 0000000000..7c1d0a4749
--- /dev/null
+++ b/packages/admin-panel/src/widgets/Checkbox/CheckboxCheckedIcon.jsx
@@ -0,0 +1,28 @@
+/*
+ * Tupaia
+ * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd
+ */
+import React from 'react';
+import { SvgIcon, useTheme } from '@material-ui/core';
+
+export const CheckboxCheckedIcon = props => {
+ const theme = useTheme();
+ return (
+
+
+
+
+
+ );
+};
diff --git a/packages/admin-panel/src/widgets/Checkbox/CheckboxUncheckedIcon.jsx b/packages/admin-panel/src/widgets/Checkbox/CheckboxUncheckedIcon.jsx
new file mode 100644
index 0000000000..b11e5a0bd4
--- /dev/null
+++ b/packages/admin-panel/src/widgets/Checkbox/CheckboxUncheckedIcon.jsx
@@ -0,0 +1,24 @@
+/*
+ * Tupaia
+ * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd
+ */
+import React from 'react';
+import { SvgIcon } from '@material-ui/core';
+import { useTheme } from 'styled-components';
+
+export const CheckboxUncheckedIcon = props => {
+ const theme = useTheme();
+ return (
+
+
+
+ );
+};
diff --git a/packages/admin-panel/src/widgets/Checkbox/index.js b/packages/admin-panel/src/widgets/Checkbox/index.js
new file mode 100644
index 0000000000..5704832350
--- /dev/null
+++ b/packages/admin-panel/src/widgets/Checkbox/index.js
@@ -0,0 +1,6 @@
+/*
+ * Tupaia
+ * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd
+ */
+
+export { Checkbox } from './Checkbox';
diff --git a/packages/admin-panel/src/widgets/InputField/CheckboxListField.jsx b/packages/admin-panel/src/widgets/InputField/CheckboxListField.jsx
new file mode 100644
index 0000000000..882116b822
--- /dev/null
+++ b/packages/admin-panel/src/widgets/InputField/CheckboxListField.jsx
@@ -0,0 +1,245 @@
+/*
+ * Tupaia
+ * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd
+ */
+
+import React, { useMemo, useState } from 'react';
+import PropTypes from 'prop-types';
+import { useQuery } from 'react-query';
+import styled from 'styled-components';
+import {
+ FormControl as MuiFormControl,
+ FormControlLabel,
+ FormGroup as MuiFormGroup,
+ FormLabel,
+ TextField,
+ Typography,
+} from '@material-ui/core';
+import { Search } from '@material-ui/icons';
+import { InputLabel } from '@tupaia/ui-components';
+import { get } from '../../VizBuilderApp/api/api';
+import { Checkbox } from '../Checkbox';
+import { convertSearchTermToFilter, useDebounce } from '../../utilities';
+
+const useOptions = (
+ endpoint,
+ baseFilter,
+ searchTerm,
+ labelColumn,
+ valueColumn,
+ distinct = null,
+ pageSize,
+) => {
+ return useQuery(
+ ['options', endpoint, baseFilter, searchTerm, labelColumn, valueColumn, distinct, pageSize],
+ async () => {
+ const filter = convertSearchTermToFilter({ ...baseFilter, [labelColumn]: searchTerm });
+ return get(endpoint, {
+ params: {
+ filter: JSON.stringify(filter),
+ sort: JSON.stringify([`${labelColumn} ASC`]),
+ columns: JSON.stringify([labelColumn, valueColumn]),
+ pageSize,
+ distinct,
+ },
+ });
+ },
+ { enabled: !!endpoint },
+ );
+};
+
+const Container = styled.div`
+ height: 100%;
+ max-height: 15rem;
+ overflow-y: auto;
+ border: 1px solid ${({ theme }) => theme.palette.grey['400']};
+ padding-inline: 1rem;
+ padding-block-start: 0.2rem;
+ border-radius: 4px;
+ margin-block-start: 0.5rem;
+ margin-block-end: 1.2rem;
+`;
+
+const FormGroup = styled(MuiFormGroup)`
+ flex: 1;
+`;
+
+const Legend = styled(FormLabel).attrs({
+ component: 'legend',
+})`
+ &.Mui-focused {
+ color: ${({ theme }) => theme.palette.text.primary};
+ }
+`;
+
+const FormControl = styled(MuiFormControl)`
+ width: 100%;
+ .MuiSvgIcon-fontSizeSmall {
+ font-size: 1rem;
+ }
+`;
+
+const SearchField = styled(TextField).attrs({
+ fullWidth: true,
+ placeholder: 'Search',
+ color: 'primary',
+ ariaLabel: 'Search',
+ InputProps: {
+ startAdornment: ,
+ },
+})`
+ margin-block-end: 0.5rem;
+ font-size: 0.875rem;
+ .MuiSvgIcon-root {
+ font-size: 1rem;
+ color: ${({ theme }) => theme.palette.grey['600']};
+ }
+ .MuiInputBase-input {
+ padding-block: 0.6rem;
+ padding-inline-start: 0.2rem;
+ }
+ .MuiInput-underline {
+ &:before {
+ border-bottom: 1px solid ${({ theme }) => theme.palette.grey['400']};
+ }
+ &:hover {
+ &:before {
+ border-color: ${({ theme }) => theme.palette.text.secondary};
+ border-width: 1px;
+ }
+ }
+ &:focus-visible {
+ outline: none;
+ &:after {
+ border-width: 1px;
+ }
+ }
+ }
+`;
+
+// this wraps the legend and any tooltip
+const LegendWrapper = styled.div`
+ display: flex;
+`;
+
+const NoResults = styled(Typography)`
+ margin-block-end: 0.5rem;
+`;
+
+export const CheckboxListField = ({
+ endpoint,
+ baseFilter,
+ optionLabelKey,
+ optionValueKey,
+ label,
+ required,
+ distinct = null,
+ pageSize = 10,
+ value,
+ onChange,
+ tooltip,
+ error,
+}) => {
+ const [searchTerm, setSearchTerm] = useState('');
+ const debouncedSearchTerm = useDebounce(searchTerm, 300);
+
+ const { data: options, isLoading } = useOptions(
+ endpoint,
+ baseFilter,
+ debouncedSearchTerm,
+ optionLabelKey,
+ optionValueKey,
+ distinct,
+ pageSize,
+ );
+
+ const selectedValue = value || [];
+
+ const handleOnChangeValue = checkedValue => {
+ let updatedValue = [...selectedValue];
+ if (selectedValue.includes(checkedValue)) {
+ updatedValue = selectedValue.filter(v => v !== checkedValue);
+ } else {
+ updatedValue = [...selectedValue, checkedValue];
+ }
+ onChange(updatedValue);
+ };
+
+ const optionsList = useMemo(() => {
+ return (
+ options?.map(option => ({
+ ...option,
+ checked: selectedValue?.includes(option[optionValueKey]),
+ value: option[optionValueKey],
+ label: option[optionLabelKey],
+ })) ?? []
+ );
+ }, [options, selectedValue]);
+
+ return (
+
+
+
+
+
+
+ setSearchTerm(event.target.value)} />
+
+ {isLoading && Loading...
}
+ {!optionsList?.length && !isLoading && No results found}
+
+ {optionsList?.map(option => (
+ handleOnChangeValue(option.value)}
+ checked={option.checked === true}
+ />
+ }
+ label={option[optionLabelKey]}
+ />
+ ))}
+
+
+
+ );
+};
+
+CheckboxListField.propTypes = {
+ endpoint: PropTypes.string.isRequired,
+ baseFilter: PropTypes.object,
+ optionLabelKey: PropTypes.string.isRequired,
+ optionValueKey: PropTypes.string.isRequired,
+ distinct: PropTypes.bool,
+ label: PropTypes.string.isRequired,
+ required: PropTypes.bool,
+ pageSize: PropTypes.number,
+ value: PropTypes.array,
+ onChange: PropTypes.func.isRequired,
+ tooltip: PropTypes.string,
+ error: PropTypes.bool,
+};
+
+CheckboxListField.defaultProps = {
+ baseFilter: {},
+ distinct: null,
+ required: false,
+ pageSize: 10,
+ value: [],
+ tooltip: null,
+ error: false,
+};
diff --git a/packages/admin-panel/src/widgets/InputField/InputField.jsx b/packages/admin-panel/src/widgets/InputField/InputField.jsx
index b3101b3636..0f25efd201 100644
--- a/packages/admin-panel/src/widgets/InputField/InputField.jsx
+++ b/packages/admin-panel/src/widgets/InputField/InputField.jsx
@@ -16,7 +16,7 @@ const getInputType = ({ options, optionsEndpoint, type }) => {
if (options && type !== 'radio') {
return 'enum';
}
- if (optionsEndpoint) {
+ if (optionsEndpoint && type !== 'checkboxList') {
return 'autocomplete';
}
return type;
diff --git a/packages/admin-panel/src/widgets/InputField/registerInputFields.jsx b/packages/admin-panel/src/widgets/InputField/registerInputFields.jsx
index f5acfbf893..764ba2a5f1 100644
--- a/packages/admin-panel/src/widgets/InputField/registerInputFields.jsx
+++ b/packages/admin-panel/src/widgets/InputField/registerInputFields.jsx
@@ -25,6 +25,9 @@ import { ReduxAutocomplete } from '../../autocomplete';
import { JsonInputField } from './JsonInputField';
import { JsonEditor } from './JsonEditor';
import { FileUploadField } from './FileUploadField';
+import { CheckboxListField } from './CheckboxListField';
+import { CheckboxUncheckedIcon } from '../Checkbox/CheckboxUncheckedIcon';
+import { CheckboxCheckedIcon } from '../Checkbox/CheckboxCheckedIcon';
// "InputField" is treated as a dynamic factory, where different input types can be supported
// depending on what is injected at runtime. This is the standard set of injections, which is the
@@ -91,6 +94,25 @@ export const registerInputFields = () => {
required={props.required}
/>
));
+ registerInputField('checkboxList', props => (
+ props.onChange(props.inputKey, inputValue)}
+ disabled={props.disabled}
+ baseFilter={props.baseFilter}
+ pageSize={props.pageSize}
+ tooltip={props.labelTooltip}
+ distinct={props.distinct}
+ />
+ ));
registerInputField('json', props => (
{
helperText={props.secondaryLabel}
tooltip={props.labelTooltip}
required={props.required}
- color="secondary"
+ icon={}
+ checkedIcon={}
/>
));
diff --git a/packages/central-server/examples.http b/packages/central-server/examples.http
index 75a4fa96af..d19bec7a89 100644
--- a/packages/central-server/examples.http
+++ b/packages/central-server/examples.http
@@ -49,6 +49,24 @@ GET {{baseUrl}}/export/optionSet/5c625e01f013d60db42a5763 HTTP/1.1
Authorization: {{user-authorization}}
Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
+### Get all map overlay groups
+
+GET {{baseUrl}}/mapOverlayGroups
+content-type: {{contentType}}
+Authorization: {{user-authorization}}
+
+### Get all map overlay group relations
+
+GET {{baseUrl}}/mapOverlayGroupRelations
+content-type: {{contentType}}
+Authorization: {{user-authorization}}
+
+### Get map overlay group relation by ID
+
+GET {{baseUrl}}/mapOverlayGroupRelations/5f2c7ddb61f76a513a0000bf
+content-type: {{contentType}}
+Authorization: {{user-authorization}}
+
### Get survey responses
GET {{baseUrl}}/surveyResponses?pageSize=5&columns=["entity.name", "country.name"] HTTP/2.0
@@ -94,3 +112,10 @@ Authorization: {{user-authorization}}
GET {{baseUrl}}/{{version}}/entityHierarchy/5e9d06e261f76a30c4000002 HTTP/2.0
content-type: {{contentType}}
Authorization: {{user-authorization}}
+
+
+### Get tasks
+
+GET {{baseUrl}}/tasks?columns=["task_status","assignee_name"]
+content-type: {{contentType}}
+Authorization: {{user-authorization}}
diff --git a/packages/central-server/src/apiV2/GETHandler/GETHandler.js b/packages/central-server/src/apiV2/GETHandler/GETHandler.js
index 6749d16291..92a006f6da 100644
--- a/packages/central-server/src/apiV2/GETHandler/GETHandler.js
+++ b/packages/central-server/src/apiV2/GETHandler/GETHandler.js
@@ -7,8 +7,8 @@ import { respond } from '@tupaia/utils';
import { CRUDHandler } from '../CRUDHandler';
import {
getQueryOptionsForColumns,
- fullyQualifyColumnSelector,
processColumns,
+ processColumnSelector,
processColumnSelectorKeys,
generateLinkHeader,
} from './helpers';
@@ -78,14 +78,14 @@ export class GETHandler extends CRUDHandler {
// add any user requested sorting to the start of the sort clause
if (sortString) {
const sortKeys = JSON.parse(sortString);
- const fullyQualifiedSortKeys = sortKeys.map(sortKey =>
- fullyQualifyColumnSelector(this.models, sortKey, this.recordType),
+ const processedSortKeys = sortKeys.map(sortKey =>
+ processColumnSelector(this.models, sortKey, this.recordType),
);
// if 'distinct', we can't order by any columns that aren't included in the distinct selection
if (distinct) {
- dbQueryOptions.sort = fullyQualifiedSortKeys;
+ dbQueryOptions.sort = processedSortKeys;
} else {
- dbQueryOptions.sort.unshift(...fullyQualifiedSortKeys);
+ dbQueryOptions.sort.unshift(...processedSortKeys);
}
}
diff --git a/packages/central-server/src/apiV2/GETHandler/helpers.js b/packages/central-server/src/apiV2/GETHandler/helpers.js
index 0b057fc85b..09e9000e50 100644
--- a/packages/central-server/src/apiV2/GETHandler/helpers.js
+++ b/packages/central-server/src/apiV2/GETHandler/helpers.js
@@ -55,7 +55,7 @@ export const fullyQualifyColumnSelector = (models, unprocessedColumnSelector, ba
export const processColumnSelectorKeys = (models, object, recordType) => {
const processedObject = {};
Object.entries(object).forEach(([columnSelector, value]) => {
- processedObject[fullyQualifyColumnSelector(models, columnSelector, recordType)] = value;
+ processedObject[processColumnSelector(models, columnSelector, recordType)] = value;
});
return processedObject;
};
diff --git a/packages/central-server/src/apiV2/dashboardRelations/GETDashboardRelations.js b/packages/central-server/src/apiV2/dashboardRelations/GETDashboardRelations.js
index 7b41ffb714..3a385404ab 100644
--- a/packages/central-server/src/apiV2/dashboardRelations/GETDashboardRelations.js
+++ b/packages/central-server/src/apiV2/dashboardRelations/GETDashboardRelations.js
@@ -4,6 +4,7 @@
*/
import { RECORDS } from '@tupaia/database';
+import { fullyQualifyColumnSelector } from '../GETHandler/helpers';
import { GETHandler } from '../GETHandler';
import { assertAnyPermissions, assertBESAdminAccess } from '../../permissions';
import { assertDashboardGetPermissions } from '../dashboards';
@@ -34,6 +35,19 @@ export class GETDashboardRelations extends GETHandler {
},
};
+ getDbQueryCriteria() {
+ const { filter: filterString } = this.req.query;
+ const filter = filterString ? JSON.parse(filterString) : {};
+ const processedObject = {};
+ Object.entries(filter).forEach(([columnSelector, value]) => {
+ // We don't want to use the customColumnSelectors for dashboard relations since they are not
+ // compatible with the database query so just use fullyQualifyColumnSelector
+ processedObject[fullyQualifyColumnSelector(this.models, columnSelector, this.recordType)] =
+ value;
+ });
+ return processedObject;
+ }
+
async findSingleRecord(dashboardRelationId, options) {
const dashboardRelation = await super.findSingleRecord(dashboardRelationId, options);
diff --git a/packages/central-server/src/apiV2/index.js b/packages/central-server/src/apiV2/index.js
index e7d0e91733..bf2a97dc7f 100644
--- a/packages/central-server/src/apiV2/index.js
+++ b/packages/central-server/src/apiV2/index.js
@@ -144,6 +144,7 @@ import {
GETDashboardMailingListEntries,
} from './dashboardMailingListEntries';
import { EditEntityHierarchy, GETEntityHierarchy } from './entityHierarchy';
+import { CreateTask, EditTask, GETTasks } from './tasks';
// quick and dirty permission wrapper for open endpoints
const allowAnyone = routeHandler => (req, res, next) => {
@@ -267,7 +268,7 @@ apiV2.get(
apiV2.get('/entityHierarchy/:recordId?', useRouteHandler(GETEntityHierarchy));
apiV2.get('/landingPages/:recordId?', useRouteHandler(GETLandingPages));
apiV2.get('/suggestSurveyCode', catchAsyncErrors(suggestSurveyCode));
-
+apiV2.get('/tasks/:recordId?', useRouteHandler(GETTasks));
/**
* POST routes
*/
@@ -314,7 +315,7 @@ apiV2.post('/landingPages', useRouteHandler(CreateLandingPage));
apiV2.post('/surveys', multipartJson(), useRouteHandler(CreateSurvey));
apiV2.post('/dhisInstances', useRouteHandler(BESAdminCreateHandler));
apiV2.post('/supersetInstances', useRouteHandler(BESAdminCreateHandler));
-
+apiV2.post('/tasks', useRouteHandler(CreateTask));
/**
* PUT routes
*/
@@ -352,6 +353,7 @@ apiV2.put('/landingPages/:recordId', useRouteHandler(EditLandingPage));
apiV2.put('/surveys/:recordId', multipartJson(), useRouteHandler(EditSurvey));
apiV2.put('/dhisInstances/:recordId', useRouteHandler(BESAdminEditHandler));
apiV2.put('/supersetInstances/:recordId', useRouteHandler(BESAdminEditHandler));
+apiV2.put('/tasks/:recordId', useRouteHandler(EditTask));
/**
* DELETE routes
diff --git a/packages/central-server/src/apiV2/mapOverlayGroupRelations/GETMapOverlayGroupRelations.js b/packages/central-server/src/apiV2/mapOverlayGroupRelations/GETMapOverlayGroupRelations.js
index cf825d1674..38eeabbffb 100644
--- a/packages/central-server/src/apiV2/mapOverlayGroupRelations/GETMapOverlayGroupRelations.js
+++ b/packages/central-server/src/apiV2/mapOverlayGroupRelations/GETMapOverlayGroupRelations.js
@@ -30,10 +30,6 @@ export class GETMapOverlayGroupRelations extends GETHandler {
nearTableKey: 'map_overlay_group_relation.map_overlay_group_id',
farTableKey: 'map_overlay_group.id',
},
- map_overlay: {
- nearTableKey: 'map_overlay_group_relation.child_id',
- farTableKey: 'map_overlay.id',
- },
};
async findSingleRecord(mapOverlayGroupRelationId, options) {
@@ -54,6 +50,17 @@ export class GETMapOverlayGroupRelations extends GETHandler {
return mapOverlayGroupRelation;
}
+ async getDbQueryOptions() {
+ const { multiJoin, sort, ...restOfOptions } = await super.getDbQueryOptions();
+ return {
+ ...restOfOptions,
+ // Strip table prefix from `child_code` as it’s a `customColumn`
+ sort: sort.map(s => s.replace('map_overlay_group_relation.child_code', 'child_code')),
+ // Appending the multi-join from the Record class so that we can fetch the `child_code`
+ multiJoin: multiJoin.concat(this.models.mapOverlayGroupRelation.DatabaseRecordClass.joins),
+ };
+ }
+
async getPermissionsFilter(criteria, options) {
const dbConditions = await createMapOverlayGroupRelationDBFilter(
this.accessPolicy,
diff --git a/packages/central-server/src/apiV2/tasks/CreateTask.js b/packages/central-server/src/apiV2/tasks/CreateTask.js
new file mode 100644
index 0000000000..d1eda45751
--- /dev/null
+++ b/packages/central-server/src/apiV2/tasks/CreateTask.js
@@ -0,0 +1,26 @@
+/**
+ * Tupaia
+ * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd
+ */
+import { CreateHandler } from '../CreateHandler';
+import { assertAnyPermissions, assertBESAdminAccess } from '../../permissions';
+import { assertUserHasPermissionToCreateTask } from './assertTaskPermissions';
+/**
+ * Handles POST endpoints:
+ * - /tasks
+ */
+
+export class CreateTask extends CreateHandler {
+ async assertUserHasAccess() {
+ const createPermissionChecker = accessPolicy =>
+ assertUserHasPermissionToCreateTask(accessPolicy, this.models, this.newRecordData);
+
+ await this.assertPermissions(
+ assertAnyPermissions([assertBESAdminAccess, createPermissionChecker]),
+ );
+ }
+
+ async createRecord() {
+ await this.insertRecord();
+ }
+}
diff --git a/packages/central-server/src/apiV2/tasks/EditTask.js b/packages/central-server/src/apiV2/tasks/EditTask.js
new file mode 100644
index 0000000000..543a051620
--- /dev/null
+++ b/packages/central-server/src/apiV2/tasks/EditTask.js
@@ -0,0 +1,20 @@
+/**
+ * Tupaia
+ * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd
+ */
+
+import { EditHandler } from '../EditHandler';
+import { assertAnyPermissions, assertBESAdminAccess } from '../../permissions';
+import { assertUserCanEditTask } from './assertTaskPermissions';
+
+export class EditTask extends EditHandler {
+ async assertUserHasAccess() {
+ const permissionChecker = accessPolicy =>
+ assertUserCanEditTask(accessPolicy, this.models, this.recordId, this.updatedFields);
+ await this.assertPermissions(assertAnyPermissions([assertBESAdminAccess, permissionChecker]));
+ }
+
+ async editRecord() {
+ await this.updateRecord();
+ }
+}
diff --git a/packages/central-server/src/apiV2/tasks/GETTasks.js b/packages/central-server/src/apiV2/tasks/GETTasks.js
new file mode 100644
index 0000000000..846a2a7c01
--- /dev/null
+++ b/packages/central-server/src/apiV2/tasks/GETTasks.js
@@ -0,0 +1,40 @@
+/**
+ * Tupaia
+ * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd
+ */
+
+import { assertAnyPermissions, assertBESAdminAccess } from '../../permissions';
+import { GETHandler } from '../GETHandler';
+import { assertUserHasTaskPermissions, createTaskDBFilter } from './assertTaskPermissions';
+
+export class GETTasks extends GETHandler {
+ permissionsFilteredInternally = true;
+
+ async getPermissionsFilter(criteria, options) {
+ return createTaskDBFilter(this.accessPolicy, this.models, criteria, options);
+ }
+
+ async findSingleRecord(projectId, options) {
+ const taskPermissionChecker = accessPolicy =>
+ assertUserHasTaskPermissions(accessPolicy, this.models, projectId);
+ await this.assertPermissions(
+ assertAnyPermissions([assertBESAdminAccess, taskPermissionChecker]),
+ );
+
+ return super.findSingleRecord(projectId, options);
+ }
+
+ async getDbQueryOptions() {
+ const { multiJoin, sort, ...restOfOptions } = await super.getDbQueryOptions();
+
+ return {
+ ...restOfOptions,
+ // Strip table prefix from `task_status` and `assignee_name` as these are customColumns
+ sort: sort.map(s =>
+ s.replace('task.task_status', 'task_status').replace('task.assignee_name', 'assignee_name'),
+ ),
+ // Appending the multi-join from the Record class so that we can fetch the `task_status` and `assignee_name`
+ multiJoin: multiJoin.concat(this.models.task.DatabaseRecordClass.joins),
+ };
+ }
+}
diff --git a/packages/central-server/src/apiV2/tasks/assertTaskPermissions.js b/packages/central-server/src/apiV2/tasks/assertTaskPermissions.js
new file mode 100644
index 0000000000..b0cfb93a19
--- /dev/null
+++ b/packages/central-server/src/apiV2/tasks/assertTaskPermissions.js
@@ -0,0 +1,123 @@
+/**
+ * Tupaia
+ * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd
+ */
+
+import { RECORDS } from '@tupaia/database';
+import { hasBESAdminAccess } from '../../permissions';
+import { fetchCountryCodesByPermissionGroupId, mergeFilter, mergeMultiJoin } from '../utilities';
+
+const getUserSurveys = async (models, accessPolicy, projectId) => {
+ const query = {};
+ if (projectId) {
+ query.project_id = projectId;
+ }
+ const userSurveys = await models.survey.findByAccessPolicy(accessPolicy, query, {
+ columns: ['id'],
+ });
+ return userSurveys;
+};
+
+export const createTaskDBFilter = async (accessPolicy, models, criteria, options) => {
+ if (hasBESAdminAccess(accessPolicy)) {
+ return { dbConditions: criteria, dbOptions: options };
+ }
+ const { projectId, ...dbConditions } = { ...criteria };
+ const dbOptions = { ...options };
+
+ const countryCodesByPermissionGroupId = await fetchCountryCodesByPermissionGroupId(
+ accessPolicy,
+ models,
+ );
+
+ const surveys = await getUserSurveys(models, accessPolicy, projectId);
+
+ dbConditions['entity.country_code'] = mergeFilter(
+ {
+ comparator: 'IN',
+ comparisonValue: Object.values(countryCodesByPermissionGroupId).flat(),
+ },
+ dbConditions['entity.country_code'],
+ );
+
+ dbConditions['task.survey_id'] = mergeFilter(
+ {
+ comparator: 'IN',
+ comparisonValue: surveys.map(survey => survey.id),
+ },
+ dbConditions['task.survey_id'],
+ );
+
+ dbOptions.multiJoin = mergeMultiJoin(
+ [
+ {
+ joinWith: RECORDS.ENTITY,
+ joinCondition: [`${RECORDS.ENTITY}.id`, `${RECORDS.TASK}.entity_id`],
+ },
+ ],
+ dbOptions.multiJoin,
+ );
+ return { dbConditions, dbOptions };
+};
+
+export const assertUserHasTaskPermissions = async (accessPolicy, models, taskId) => {
+ const task = await models.task.findById(taskId);
+ if (!task) {
+ throw new Error(`No task found with id ${taskId}`);
+ }
+
+ const entity = await task.entity();
+ if (!accessPolicy.allows(entity.country_code)) {
+ throw new Error('Need to have access to the country of the task');
+ }
+
+ const userSurveys = await getUserSurveys(models, accessPolicy);
+ const survey = userSurveys.find(({ id }) => id === task.survey_id);
+ if (!survey) {
+ throw new Error('Need to have access to the survey of the task');
+ }
+
+ return true;
+};
+
+export const assertUserHasPermissionToCreateTask = async (accessPolicy, models, taskData) => {
+ const { entity_id: entityId, survey_id: surveyId } = taskData;
+
+ const entity = await models.entity.findById(entityId);
+ if (!entity) {
+ throw new Error(`No entity found with id ${entityId}`);
+ }
+
+ if (!accessPolicy.allows(entity.country_code)) {
+ throw new Error('Need to have access to the country of the task');
+ }
+
+ const userSurveys = await getUserSurveys(models, accessPolicy);
+ const survey = userSurveys.find(({ id }) => id === surveyId);
+ if (!survey) {
+ throw new Error('Need to have access to the survey of the task');
+ }
+
+ return true;
+};
+
+export const assertUserCanEditTask = async (accessPolicy, models, taskId, newRecordData) => {
+ await assertUserHasTaskPermissions(accessPolicy, models, taskId);
+ if (newRecordData.entity_id) {
+ const entity = await models.entity.findById(newRecordData.entity_id);
+ if (!entity) {
+ throw new Error(`No entity found with id ${newRecordData.entity_id}`);
+ }
+ if (!accessPolicy.allows(entity.country_code)) {
+ throw new Error('Need to have access to the new entity of the task');
+ }
+ }
+ if (newRecordData.survey_id) {
+ const userSurveys = await getUserSurveys(models, accessPolicy);
+ const survey = userSurveys.find(({ id }) => id === newRecordData.survey_id);
+ if (!survey) {
+ throw new Error('Need to have access to the new survey of the task');
+ }
+ }
+ return true;
+};
diff --git a/packages/central-server/src/apiV2/tasks/index.js b/packages/central-server/src/apiV2/tasks/index.js
new file mode 100644
index 0000000000..d3d91ef92c
--- /dev/null
+++ b/packages/central-server/src/apiV2/tasks/index.js
@@ -0,0 +1,8 @@
+/**
+ * Tupaia
+ * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd
+ */
+
+export { GETTasks } from './GETTasks';
+export { CreateTask } from './CreateTask';
+export { EditTask } from './EditTask';
diff --git a/packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js b/packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js
index ff31ea6122..567a1a7f90 100644
--- a/packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js
+++ b/packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js
@@ -440,6 +440,37 @@ export const constructForSingle = (models, recordType) => {
code: [isAString],
config: [hasContent],
};
+ case RECORDS.TASK:
+ return {
+ entity_id: [constructRecordExistsWithId(models.entity)],
+ survey_id: [constructRecordExistsWithId(models.survey)],
+ assignee_id: [constructIsEmptyOr(constructRecordExistsWithId(models.user))],
+ due_date: [
+ (value, { status }) => {
+ if (status !== 'repeating' && !value) {
+ throw new Error('Due date is required for non-recurring tasks');
+ }
+ return true;
+ },
+ ],
+ repeat_schedule: [
+ (value, { status }) => {
+ if (status === 'repeating' && !value) {
+ throw new Error('Repeat frequency is required for recurring tasks');
+ }
+ return true;
+ },
+ ],
+ status: [
+ (value, { repeat_schedule: repeatSchedule }) => {
+ if (repeatSchedule) return true;
+ if (!value) {
+ throw new Error('Status is required');
+ }
+ return true;
+ },
+ ],
+ };
default:
throw new ValidationError(`${recordType} is not a valid POST endpoint`);
}
diff --git a/packages/central-server/src/tests/apiV2/tasks/CreateTask.test.js b/packages/central-server/src/tests/apiV2/tasks/CreateTask.test.js
new file mode 100644
index 0000000000..c7c79ea9a0
--- /dev/null
+++ b/packages/central-server/src/tests/apiV2/tasks/CreateTask.test.js
@@ -0,0 +1,139 @@
+/**
+ * Tupaia
+ * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd
+ */
+
+import { expect } from 'chai';
+import {
+ buildAndInsertSurveys,
+ findOrCreateDummyCountryEntity,
+ findOrCreateDummyRecord,
+ generateId,
+} from '@tupaia/database';
+import { TestableApp, resetTestData } from '../../testUtilities';
+import { BES_ADMIN_PERMISSION_GROUP } from '../../../permissions';
+
+describe('Permissions checker for CreateTask', async () => {
+ const BES_ADMIN_POLICY = {
+ DL: [BES_ADMIN_PERMISSION_GROUP],
+ };
+
+ const DEFAULT_POLICY = {
+ TO: ['Donor'],
+ };
+
+ const app = new TestableApp();
+ const { models } = app;
+ let surveys;
+ const facilities = [
+ {
+ id: generateId(),
+ code: 'TEST_FACILITY_1',
+ name: 'Test Facility 1',
+ country_code: 'TO',
+ },
+ {
+ id: generateId(),
+ code: 'TEST_FACILITY_2',
+ name: 'Test Facility 2',
+ country_code: 'DL',
+ },
+ ];
+ const assignee = {
+ id: generateId(),
+ first_name: 'Peter',
+ last_name: 'Pan',
+ };
+
+ const BASE_TASK = {
+ assignee_id: assignee.id,
+ repeat_schedule: '{}',
+ due_date: new Date('2021-12-31'),
+ status: 'to_do',
+ };
+
+ before(async () => {
+ const { country: tongaCountry } = await findOrCreateDummyCountryEntity(models, {
+ code: 'TO',
+ name: 'Tonga',
+ });
+
+ const { country: dlCountry } = await findOrCreateDummyCountryEntity(models, {
+ code: 'DL',
+ name: 'Demo Land',
+ });
+
+ const donorPermission = await findOrCreateDummyRecord(models.permissionGroup, {
+ name: 'Donor',
+ });
+ const BESAdminPermission = await findOrCreateDummyRecord(models.permissionGroup, {
+ name: 'Admin',
+ });
+
+ await Promise.all(facilities.map(facility => findOrCreateDummyRecord(models.entity, facility)));
+
+ surveys = await buildAndInsertSurveys(models, [
+ {
+ code: 'TEST_SURVEY_1',
+ name: 'Test Survey 1',
+ permission_group_id: BESAdminPermission.id,
+ country_ids: [tongaCountry.id, dlCountry.id],
+ },
+ {
+ code: 'TEST_SURVEY_2',
+ name: 'Test Survey 2',
+ permission_group_id: donorPermission.id,
+ country_ids: [tongaCountry.id],
+ },
+ ]);
+
+ await findOrCreateDummyRecord(models.user, assignee);
+ });
+
+ afterEach(() => {
+ app.revokeAccess();
+ });
+
+ after(async () => {
+ await resetTestData();
+ });
+
+ describe('POST /tasks', async () => {
+ it('Sufficient permissions: allows a user to create a task if they have BES Admin permission', async () => {
+ await app.grantAccess(BES_ADMIN_POLICY);
+ const { body: result } = await app.post('tasks', {
+ body: {
+ ...BASE_TASK,
+ entity_id: facilities[0].id,
+ survey_id: surveys[0].survey.id,
+ },
+ });
+
+ expect(result.message).to.equal('Successfully created tasks');
+ });
+
+ it('Insufficient permissions: Does not allow user to create a task for an entity they do not have access to', async () => {
+ await app.grantAccess(DEFAULT_POLICY);
+ const { body: result } = await app.post('tasks', {
+ body: {
+ ...BASE_TASK,
+ entity_id: facilities[1].id,
+ survey_id: surveys[1].survey.id,
+ },
+ });
+ expect(result).to.have.keys('error');
+ });
+
+ it('Insufficient permissions: Does not allow user to create a task for a survey they do not have access to', async () => {
+ await app.grantAccess(DEFAULT_POLICY);
+ const { body: result } = await app.post('tasks', {
+ body: {
+ ...BASE_TASK,
+ entity_id: facilities[0].id,
+ survey_id: surveys[0].survey.id,
+ },
+ });
+ expect(result).to.have.keys('error');
+ });
+ });
+});
diff --git a/packages/central-server/src/tests/apiV2/tasks/EditTask.test.js b/packages/central-server/src/tests/apiV2/tasks/EditTask.test.js
new file mode 100644
index 0000000000..1bda0247a9
--- /dev/null
+++ b/packages/central-server/src/tests/apiV2/tasks/EditTask.test.js
@@ -0,0 +1,211 @@
+/**
+ * Tupaia
+ * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd
+ */
+
+import { expect } from 'chai';
+import {
+ buildAndInsertSurveys,
+ findOrCreateDummyCountryEntity,
+ findOrCreateDummyRecord,
+ generateId,
+} from '@tupaia/database';
+import { TestableApp, resetTestData } from '../../testUtilities';
+import { BES_ADMIN_PERMISSION_GROUP } from '../../../permissions';
+
+const rollbackRecordChange = async (models, records) => {
+ await Promise.all(records.map(record => models.task.delete({ id: record.id })));
+};
+
+const createTasks = async (tasksToCreate, models) => {
+ await Promise.all(tasksToCreate.map(task => findOrCreateDummyRecord(models.task, task)));
+};
+
+describe('Permissions checker for EditTask', async () => {
+ const BES_ADMIN_POLICY = {
+ DL: [BES_ADMIN_PERMISSION_GROUP],
+ TO: [BES_ADMIN_PERMISSION_GROUP],
+ };
+
+ const DEFAULT_POLICY = {
+ DL: ['Donor'],
+ };
+
+ const app = new TestableApp();
+ const { models } = app;
+ let surveys;
+ const facilities = [
+ {
+ id: generateId(),
+ code: 'TEST_FACILITY_1',
+ name: 'Test Facility 1',
+ country_code: 'TO',
+ },
+ {
+ id: generateId(),
+ code: 'TEST_FACILITY_2',
+ name: 'Test Facility 2',
+ country_code: 'DL',
+ },
+ ];
+
+ const assignee = {
+ id: generateId(),
+ first_name: 'Peter',
+ last_name: 'Pan',
+ };
+
+ const dueDate = new Date('2021-12-31');
+
+ let tasks;
+
+ before(async () => {
+ const { country: tongaCountry } = await findOrCreateDummyCountryEntity(models, {
+ code: 'TO',
+ name: 'Tonga',
+ });
+
+ const { country: dlCountry } = await findOrCreateDummyCountryEntity(models, {
+ code: 'DL',
+ name: 'Demo Land',
+ });
+
+ const donorPermission = await findOrCreateDummyRecord(models.permissionGroup, {
+ name: 'Donor',
+ });
+ const BESAdminPermission = await findOrCreateDummyRecord(models.permissionGroup, {
+ name: 'Admin',
+ });
+
+ await Promise.all(facilities.map(facility => findOrCreateDummyRecord(models.entity, facility)));
+
+ surveys = await buildAndInsertSurveys(models, [
+ {
+ code: 'TEST_SURVEY_1',
+ name: 'Test Survey 1',
+ permission_group_id: BESAdminPermission.id,
+ country_ids: [tongaCountry.id, dlCountry.id],
+ },
+ {
+ code: 'TEST_SURVEY_2',
+ name: 'Test Survey 2',
+ permission_group_id: donorPermission.id,
+ country_ids: [dlCountry.id],
+ },
+ ]);
+
+ tasks = [
+ {
+ id: generateId(),
+ survey_id: surveys[0].survey.id,
+ entity_id: facilities[0].id,
+ due_date: dueDate,
+ status: 'to_do',
+ },
+ {
+ id: generateId(),
+ survey_id: surveys[1].survey.id,
+ entity_id: facilities[1].id,
+ assignee_id: assignee.id,
+ due_date: dueDate,
+ status: 'to_do',
+ },
+ ];
+
+ await findOrCreateDummyRecord(models.user, assignee);
+ });
+
+ beforeEach(async () => {
+ await createTasks(tasks, models);
+ });
+
+ afterEach(async () => {
+ await rollbackRecordChange(models, tasks);
+ app.revokeAccess();
+ });
+
+ after(async () => {
+ await resetTestData();
+ });
+
+ describe('PUT /tasks/:id', async () => {
+ it('Sufficient permissions: allows a user to edit a task if they have BES Admin permission', async () => {
+ await app.grantAccess(BES_ADMIN_POLICY);
+ await app.put(`tasks/${tasks[1].id}`, {
+ body: {
+ entity_id: facilities[0].id,
+ survey_id: surveys[0].survey.id,
+ },
+ });
+ const result = await models.task.find({
+ id: tasks[1].id,
+ });
+ expect(result[0].entity_id).to.equal(facilities[0].id);
+ expect(result[0].survey_id).to.equal(surveys[0].survey.id);
+ });
+
+ it('Sufficient permissions: allows a user to edit a task if they have access to the task, and entity and survey are not being updated', async () => {
+ await app.grantAccess(DEFAULT_POLICY);
+ await app.put(`tasks/${tasks[1].id}`, {
+ body: {
+ status: 'completed',
+ },
+ });
+ const result = await models.task.find({
+ id: tasks[1].id,
+ });
+ expect(result[0].status).to.equal('completed');
+ });
+
+ it('Sufficient permissions: allows a user to edit a task if they have access to the task, and the entity and survey that are being linked to the task', async () => {
+ await app.grantAccess({
+ DL: ['Donor'],
+ TO: ['Donor'],
+ });
+ await app.put(`tasks/${tasks[1].id}`, {
+ body: {
+ survey_id: surveys[1].survey.id,
+ entity_id: facilities[1].id,
+ },
+ });
+ const result = await models.task.find({
+ id: tasks[1].id,
+ });
+ expect(result[0].entity_id).to.equal(facilities[1].id);
+ expect(result[0].survey_id).to.equal(surveys[1].survey.id);
+ });
+
+ it('Insufficient permissions: throws an error if the user does not have access to the task being edited', async () => {
+ await app.grantAccess(DEFAULT_POLICY);
+ const { body: result } = await app.put(`tasks/${tasks[0].id}`, {
+ body: {
+ status: 'completed',
+ },
+ });
+ expect(result).to.have.keys('error');
+ expect(result.error).to.include('Need to have access to the country of the task');
+ });
+
+ it('Insufficient permissions: throws an error if the user does not have access to the survey being linked to the task', async () => {
+ await app.grantAccess(DEFAULT_POLICY);
+ const { body: result } = await app.put(`tasks/${tasks[1].id}`, {
+ body: {
+ survey_id: surveys[0].survey.id,
+ },
+ });
+ expect(result).to.have.keys('error');
+ expect(result.error).to.include('Need to have access to the new survey of the task');
+ });
+
+ it('Insufficient permissions: throws an error if the user does not have access to the entity being linked to the task', async () => {
+ await app.grantAccess(DEFAULT_POLICY);
+ const { body: result } = await app.put(`tasks/${tasks[1].id}`, {
+ body: {
+ entity_id: facilities[0].id,
+ },
+ });
+ expect(result).to.have.keys('error');
+ expect(result.error).to.include('Need to have access to the new entity of the task');
+ });
+ });
+});
diff --git a/packages/central-server/src/tests/apiV2/tasks/GETTasks.test.js b/packages/central-server/src/tests/apiV2/tasks/GETTasks.test.js
new file mode 100644
index 0000000000..9f6be0c21b
--- /dev/null
+++ b/packages/central-server/src/tests/apiV2/tasks/GETTasks.test.js
@@ -0,0 +1,198 @@
+/**
+ * Tupaia
+ * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd
+ */
+
+import { expect } from 'chai';
+import {
+ buildAndInsertSurveys,
+ findOrCreateDummyCountryEntity,
+ findOrCreateDummyRecord,
+ generateId,
+} from '@tupaia/database';
+import { TestableApp, resetTestData } from '../../testUtilities';
+import { BES_ADMIN_PERMISSION_GROUP } from '../../../permissions';
+
+describe('Permissions checker for GETTasks', async () => {
+ const BES_ADMIN_POLICY = {
+ DL: [BES_ADMIN_PERMISSION_GROUP],
+ };
+
+ const DEFAULT_POLICY = {
+ DL: ['Donor'],
+ TO: ['Donor'],
+ };
+
+ const PUBLIC_POLICY = {
+ DL: ['Public'],
+ };
+
+ const app = new TestableApp();
+ const { models } = app;
+ let tasks;
+
+ before(async () => {
+ const { country: tongaCountry } = await findOrCreateDummyCountryEntity(models, {
+ code: 'TO',
+ name: 'Tonga',
+ });
+
+ const { country: dlCountry } = await findOrCreateDummyCountryEntity(models, {
+ code: 'DL',
+ name: 'Demo Land',
+ });
+
+ const donorPermission = await findOrCreateDummyRecord(models.permissionGroup, {
+ name: 'Donor',
+ });
+ const BESAdminPermission = await findOrCreateDummyRecord(models.permissionGroup, {
+ name: 'Admin',
+ });
+
+ const facilities = [
+ {
+ id: generateId(),
+ code: 'TEST_FACILITY_1',
+ name: 'Test Facility 1',
+ country_code: tongaCountry.code,
+ },
+ {
+ id: generateId(),
+ code: 'TEST_FACILITY_2',
+ name: 'Test Facility 2',
+ country_code: dlCountry.code,
+ },
+ ];
+
+ await Promise.all(facilities.map(facility => findOrCreateDummyRecord(models.entity, facility)));
+
+ const surveys = await buildAndInsertSurveys(models, [
+ {
+ code: 'TEST_SURVEY_1',
+ name: 'Test Survey 1',
+ permission_group_id: BESAdminPermission.id,
+ country_ids: [tongaCountry.id, dlCountry.id],
+ },
+ {
+ code: 'TEST_SURVEY_2',
+ name: 'Test Survey 2',
+ permission_group_id: donorPermission.id,
+ country_ids: [tongaCountry.id, dlCountry.id],
+ },
+ ]);
+
+ const assignee = {
+ id: generateId(),
+ first_name: 'Minnie',
+ last_name: 'Mouse',
+ };
+ await findOrCreateDummyRecord(models.user, assignee);
+
+ const dueDate = new Date('2021-12-31');
+
+ tasks = [
+ {
+ id: generateId(),
+ survey_id: surveys[0].survey.id,
+ entity_id: facilities[0].id,
+ due_date: dueDate,
+ status: 'to_do',
+ repeat_schedule: null,
+ },
+ {
+ id: generateId(),
+ survey_id: surveys[1].survey.id,
+ entity_id: facilities[1].id,
+ assignee_id: assignee.id,
+ due_date: null,
+ repeat_schedule: '{}',
+ status: null,
+ },
+ ];
+
+ await Promise.all(
+ tasks.map(task =>
+ findOrCreateDummyRecord(
+ models.task,
+ {
+ 'task.id': task.id,
+ },
+ task,
+ ),
+ ),
+ );
+ });
+
+ afterEach(() => {
+ app.revokeAccess();
+ });
+
+ after(async () => {
+ await resetTestData();
+ });
+
+ describe('GET /tasks/:id', async () => {
+ it('Sufficient permissions: returns a requested task when user has BES admin permissions', async () => {
+ await app.grantAccess(BES_ADMIN_POLICY);
+ const { body: result } = await app.get(`tasks/${tasks[0].id}`);
+ expect(result.id).to.equal(tasks[0].id);
+ });
+
+ it('Sufficient permissions: returns a requested task when user has permissions', async () => {
+ await app.grantAccess(DEFAULT_POLICY);
+ const { body: result } = await app.get(`tasks/${tasks[1].id}`);
+
+ expect(result.id).to.equal(tasks[1].id);
+ });
+
+ it('Insufficient permissions: throws an error if requesting task when user does not have permissions', async () => {
+ await app.grantAccess(DEFAULT_POLICY);
+ const { body: result } = await app.get(`tasks/${tasks[0].id}`);
+
+ expect(result).to.have.keys('error');
+ });
+ });
+
+ describe('GET /tasks', async () => {
+ it('Sufficient permissions: returns all tasks if the user has BES admin access', async () => {
+ const columnsString = JSON.stringify([
+ 'id',
+ 'task_status',
+ 'assignee_name',
+ 'due_date',
+ 'repeat_schedule',
+ ]);
+ await app.grantAccess(BES_ADMIN_POLICY);
+ const { body: results } = await app.get(`tasks?columns=${columnsString}`);
+ expect(results.length).to.equal(tasks.length);
+
+ results.forEach((result, index) => {
+ const task = tasks[index];
+ expect(result.id).to.equal(task.id);
+ if (task.assignee_id) {
+ expect(result.assignee_name).to.equal('Minnie Mouse');
+ }
+ if (task.status === 'to_do') {
+ expect(result.task_status).to.equal('overdue');
+ } else expect(result.task_status).to.equal('repeating');
+ });
+ });
+
+ it('Sufficient permissions: returns tasks when user has permissions', async () => {
+ const columnsString = JSON.stringify(['assignee_name', 'id']);
+ await app.grantAccess(DEFAULT_POLICY);
+ const { body: results } = await app.get(`tasks?columns=${columnsString}`);
+
+ expect(results.length).to.equal(1);
+ expect(results[0].assignee_name).to.equal('Minnie Mouse');
+ expect(results[0].id).to.equal(tasks[1].id);
+ });
+
+ it('Insufficient permissions: returns an empty array if users do not have access to any tasks', async () => {
+ await app.grantAccess(PUBLIC_POLICY);
+ const { body: results } = await app.get('tasks');
+
+ expect(results).to.be.empty;
+ });
+ });
+});
diff --git a/packages/database/package.json b/packages/database/package.json
index 97abedd1e8..be3254d692 100644
--- a/packages/database/package.json
+++ b/packages/database/package.json
@@ -36,6 +36,7 @@
"@tupaia/auth": "workspace:*",
"@tupaia/tsutils": "workspace:*",
"@tupaia/utils": "workspace:*",
+ "date-fns": "^2.29.2",
"db-migrate": "^0.11.5",
"db-migrate-pg": "^1.2.2",
"dotenv": "^16.4.5",
diff --git a/packages/database/src/DatabaseModel.js b/packages/database/src/DatabaseModel.js
index 75a7b60295..02c355ded1 100644
--- a/packages/database/src/DatabaseModel.js
+++ b/packages/database/src/DatabaseModel.js
@@ -4,6 +4,7 @@
*/
import { DatabaseError, reduceToDictionary } from '@tupaia/utils';
import { runDatabaseFunctionInBatches } from './utilities/runDatabaseFunctionInBatches';
+import { QUERY_CONJUNCTIONS } from './TupaiaDatabase';
export class DatabaseModel {
otherModels = {};
@@ -76,6 +77,7 @@ export class DatabaseModel {
async fetchFieldNames() {
if (!this.fieldNames) {
const schema = await this.fetchSchema();
+
this.fieldNames = Object.keys(schema);
}
return this.fieldNames;
@@ -100,16 +102,26 @@ export class DatabaseModel {
// A helper for the 'xById' methods, which disambiguates the id field to ensure joins are handled
getIdClause(id) {
return {
- [`${this.databaseRecord}.id`]: id,
+ [this.fullyQualifyColumn('id')]: id,
};
}
+ // A helper function to ensure that we're using fully qualified column names to avoid ambiguous references when joins are being used
+ fullyQualifyColumn(column) {
+ if (column.includes('.')) {
+ // Already fully qualified
+ return column;
+ }
+
+ return `${this.databaseRecord}.${column}`;
+ }
+
async getColumnsForQuery() {
// Alias field names to the table to prevent errors when joining other tables
// with same column names.
const fieldNames = await this.fetchFieldNames();
return fieldNames.map(fieldName => {
- const qualifiedName = `${this.databaseRecord}.${fieldName}`;
+ const qualifiedName = this.fullyQualifyColumn(fieldName);
const customSelector = this.customColumnSelectors && this.customColumnSelectors[fieldName];
if (customSelector) {
return { [fieldName]: customSelector(qualifiedName) };
@@ -136,6 +148,30 @@ export class DatabaseModel {
return { ...options, ...customQueryOptions };
}
+ async getDbConditions(dbConditions = {}) {
+ const fieldNames = await this.fetchFieldNames();
+ const fullyQualifiedConditions = {};
+
+ const whereClauses = Object.entries(dbConditions);
+ for (let i = 0; i < whereClauses.length; i++) {
+ const [field, value] = whereClauses[i];
+ if (field === QUERY_CONJUNCTIONS.AND || field === QUERY_CONJUNCTIONS.OR) {
+ // Recursively proccess AND and OR conditions
+ fullyQualifiedConditions[field] = await this.getDbConditions(value);
+ } else if (field === QUERY_CONJUNCTIONS.RAW) {
+ // Don't touch RAW conditions
+ fullyQualifiedConditions[field] = value;
+ } else {
+ const fullyQualifiedField = fieldNames.includes(field)
+ ? this.fullyQualifyColumn(field)
+ : field;
+ fullyQualifiedConditions[fullyQualifiedField] = value;
+ }
+ }
+
+ return fullyQualifiedConditions;
+ }
+
/**
* @param {...any} args
* @returns {Promise} Count of records matching args
@@ -171,7 +207,12 @@ export class DatabaseModel {
async findOne(dbConditions, customQueryOptions = {}) {
const queryOptions = await this.getQueryOptions(customQueryOptions);
- const result = await this.database.findOne(this.databaseRecord, dbConditions, queryOptions);
+ const processedDbConditions = await this.getDbConditions(dbConditions);
+ const result = await this.database.findOne(
+ this.databaseRecord,
+ processedDbConditions,
+ queryOptions,
+ );
if (!result) return null;
return this.generateInstance(result);
}
@@ -184,7 +225,12 @@ export class DatabaseModel {
*/
async find(dbConditions, customQueryOptions = {}) {
const queryOptions = await this.getQueryOptions(customQueryOptions);
- const dbResults = await this.database.find(this.databaseRecord, dbConditions, queryOptions);
+ const processedDbConditions = await this.getDbConditions(dbConditions);
+ const dbResults = await this.database.find(
+ this.databaseRecord,
+ processedDbConditions,
+ queryOptions,
+ );
return Promise.all(dbResults.map(result => this.generateInstance(result)));
}
diff --git a/packages/database/src/DatabaseRecord.js b/packages/database/src/DatabaseRecord.js
index a4a23d4bd9..fdfc32de11 100644
--- a/packages/database/src/DatabaseRecord.js
+++ b/packages/database/src/DatabaseRecord.js
@@ -2,7 +2,7 @@
* Tupaia
* Copyright (c) 2017 - 2020 Beyond Essential Systems Pty Ltd
*/
-import { TypeValidationError, stripFields } from '@tupaia/utils';
+import { stripFields, TypeValidationError } from '@tupaia/utils';
export class DatabaseRecord {
static databaseRecord = null; // The database table name
@@ -33,42 +33,41 @@ export class DatabaseRecord {
*/
static fieldValidators = new Map();
- /*
- Joins are executed on every model query and give developers the ability to
- add extra fields that are necessary for a model to be meaningful.
-
- format:
- static joins = [
- {
- fields: {
- ['field code in joined database']: 'field code to map to in model',
- },
- joinWith: 'table to join with',
- joinCondition: [`{table to join with}.id`, `${this.databaseRecord}.${field to join on}`],
- }
- ]
-
- eg:
- static joins = [
- {
- fields: {
- code: 'country_code',
- },
- joinWith: RECORDS.COUNTRY,
- joinCondition: [`${RECORDS.COUNTRY}.id`, `${this.databaseRecord}.country_id`],
- },
- {
- fields: {
- code: 'foo_bar_field',
- },
- joinWith: RECORDS.FOO_BAR,
- joinCondition: [`${RECORDS.FOO_BAR}.id`, `${this.databaseRecord}.foo_bar_id`],
- }
- ]
-
- Will add the fields `country_code` and `foo_bar_field` to the model using the value
- from the join query.
- */
+ /**
+ * Joins are executed on every model query and give developers the ability to
+ * add extra fields that are necessary for a model to be meaningful.
+ *
+ * Format:
+ * ```js
+ * static joins = [
+ * {
+ * fields: {
+ * ['field code in joined database']: 'field code to map to in model',
+ * },
+ * joinWith: 'table to join with',
+ * joinCondition: [`{table to join with}.id`, `${this.databaseRecord}.${field to join on}`],
+ * }
+ * ]
+ * ```
+ *
+ * @example Add the fields `country_code` and `foo_bar_field` to the model using the value from the join query.
+ * static joins = [
+ * {
+ * fields: {
+ * code: 'country_code',
+ * },
+ * joinWith: RECORDS.COUNTRY,
+ * joinCondition: [`${RECORDS.COUNTRY}.id`, `${this.databaseRecord}.country_id`],
+ * },
+ * {
+ * fields: {
+ * code: 'foo_bar_field',
+ * },
+ * joinWith: RECORDS.FOO_BAR,
+ * joinCondition: [`${RECORDS.FOO_BAR}.id`, `${this.databaseRecord}.foo_bar_id`],
+ * }
+ * ]
+ */
static joins = [];
constructor(model, fieldValues) {
diff --git a/packages/database/src/TupaiaDatabase.js b/packages/database/src/TupaiaDatabase.js
index 7d4be24721..18d2d4887c 100644
--- a/packages/database/src/TupaiaDatabase.js
+++ b/packages/database/src/TupaiaDatabase.js
@@ -58,6 +58,18 @@ const COMPARATORS = {
ILIKE: 'ilike',
};
+/**
+ * We only support specific functions in SELECT statements to avoid SQL injection.
+ *
+ * @privateRemarks Because of the (appropriately) conservative way Knex handles identifiers in
+ * parameterised queries, supported functions may need custom handling in {@link getColSelector}.
+ * Otherwise, Knex will attempt to interpret function calls as identifiers. For example,
+ * `COALESCE(foo, bar)` would otherwise become `"COALESCE(FOO", "bar)"`.
+ *
+ * @see getColSelector
+ */
+const supportedFunctions = ['ST_AsGeoJSON', 'COALESCE'];
+
// no math here, just hand-tuned to be as low as possible while
// keeping all the tests passing
const HANDLER_DEBOUNCE_DURATION = 250;
@@ -536,11 +548,22 @@ function buildQuery(connection, queryConfig, where = {}, options = {}) {
return { [alias]: connection.raw(`??::${castAs}`, [alias]) };
}
- // special case to handle selecting geojson - avoid generic handling of functions to keep
- // out sql injection vulnerabilities
- if (selector.includes('ST_AsGeoJSON')) {
- const [, columnSelector] = selector.match(/ST_AsGeoJSON\((.*)\)/);
- return { [alias]: connection.raw('ST_AsGeoJSON(??)', [columnSelector]) };
+ // Special case to handle allowlisted SQL functions, namely for selecting GeoJSON and COALESCE
+ // attributes. Avoid generic handling of functions to keep out SQL injection vulnerabilities.
+ for (const func of supportedFunctions) {
+ if (selector.includes(func)) {
+ const [, argsString] = selector.match(new RegExp(`${func}\\((.*)\\)`));
+ const args = argsString.split(',').map(arg => arg.trim());
+ return {
+ [alias]: connection.raw(`${func}(${args.map(() => '??').join(',')})`, [...args]),
+ };
+ }
+ }
+
+ // Special case to handle CASE statements, otherwise they get interpreted as column names
+ const CASE_PATTERN = /^CASE/;
+ if (CASE_PATTERN.test(selector)) {
+ return { [alias]: connection.raw(selector) };
}
return { [alias]: connection.raw('??', [selector]) };
@@ -683,17 +706,43 @@ function addJoin(baseQuery, recordType, joinOptions) {
});
}
+/**
+ * @privateRemarks
+ * This sanitisation step fails if the input uses both JSON operators and the `COALESCE` function.
+ *
+ * @see supportedFunctions
+ */
function getColSelector(connection, inputColStr) {
const jsonOperatorPattern = /->>?/g;
- if (!jsonOperatorPattern.test(inputColStr)) return inputColStr;
+ if (jsonOperatorPattern.test(inputColStr)) {
+ const params = inputColStr.split(jsonOperatorPattern);
+ const allButFirst = params.slice(1);
+ const lastIndexOfLookupAsText = inputColStr.lastIndexOf('->>');
+ const lastIndexOfLookupAsJson = inputColStr.lastIndexOf('->');
+ const selector = lastIndexOfLookupAsText >= lastIndexOfLookupAsJson ? '#>>' : '#>';
- const params = inputColStr.split(jsonOperatorPattern);
- const allButFirst = params.slice(1);
- const lastIndexOfLookupAsText = inputColStr.lastIndexOf('->>');
- const lastIndexOfLookupAsJson = inputColStr.lastIndexOf('->');
- const selector = lastIndexOfLookupAsText >= lastIndexOfLookupAsJson ? '#>>' : '#>';
+ // Turn `config->item->>colour` into `config #>> '{item,colour}'`
+ // For some reason, Knex fails when we try to convert it to `config->'item'->>'colour'`
+ return connection.raw(`?? ${selector} '{${allButFirst.map(() => '??').join(',')}}'`, params);
+ }
+
+ /**
+ * Special handling of COALESCE() - one of the {@link supportedFunctions} - to treat its arguments
+ * as identifiers individually rather than trying to treat ‘COALESCE(foo, bar)’ as a single
+ * identifier.
+ */
+ const coalescePattern = /^COALESCE\(.+\)$/;
+ if (coalescePattern.test(inputColStr)) {
+ const [, argsString] = inputColStr.match(/^COALESCE\((.+)\)$/);
+ const bindings = argsString.split(',').map(arg => arg.trim());
+ const identifiers = bindings.map(() => '??');
+
+ return connection.raw(`COALESCE(${identifiers})`, bindings);
+ }
+ const casePattern = /^CASE/;
+ if (casePattern.test(inputColStr)) {
+ return connection.raw(inputColStr);
+ }
- // Turn `config->item->>colour` into `config #>> '{item,colour}'`
- // For some reason, Knex fails when we try to convert it to `config->'item'->>'colour'`
- return connection.raw(`?? ${selector} '{${allButFirst.map(() => '??').join(',')}}'`, params);
+ return inputColStr;
}
diff --git a/packages/database/src/migrations/20240617005418-CreateTaskTable-modifies-schema.js b/packages/database/src/migrations/20240617005418-CreateTaskTable-modifies-schema.js
new file mode 100644
index 0000000000..ea0ec10b69
--- /dev/null
+++ b/packages/database/src/migrations/20240617005418-CreateTaskTable-modifies-schema.js
@@ -0,0 +1,84 @@
+'use strict';
+
+var dbm;
+var type;
+var seed;
+
+const createFK = (columnName, table, shouldCascade) => {
+ const rules = shouldCascade
+ ? {
+ onDelete: 'CASCADE',
+ onUpdate: 'CASCADE',
+ }
+ : {};
+ return {
+ name: `task_${columnName}_fk`,
+ table,
+ mapping: 'id',
+ rules,
+ };
+};
+
+const createStatusEnum = db => {
+ return db.runSql(`
+ DROP TYPE IF EXISTS TASK_STATUS;
+ CREATE TYPE TASK_STATUS AS ENUM('to_do', 'cancelled', 'completed');
+
+ `);
+};
+
+const createTaskTable = db => {
+ return db.createTable('task', {
+ columns: {
+ id: { type: 'text', primaryKey: true },
+ survey_id: {
+ type: 'text',
+ notNull: true,
+ foreignKey: createFK('survey_id', 'survey', true),
+ },
+ entity_id: {
+ type: 'text',
+ notNull: true,
+ foreignKey: createFK('entity_id', 'entity', true),
+ },
+ assignee_id: {
+ type: 'text',
+ foreignKey: createFK('assignee_id', 'user_account', false),
+ },
+ repeat_schedule: { type: 'jsonb' },
+ due_date: { type: 'timestamp' },
+ status: { type: 'TASK_STATUS' },
+ },
+ ifNotExists: true,
+ });
+};
+
+/**
+ * We receive the dbmigrate dependency from dbmigrate initially.
+ * This enables us to not have to rely on NODE_PATH.
+ */
+exports.setup = function (options, seedLink) {
+ dbm = options.dbmigrate;
+ type = dbm.dataType;
+ seed = seedLink;
+};
+
+exports.up = async function (db) {
+ await createStatusEnum(db);
+ await createTaskTable(db);
+
+ return db.runSql(`
+ CREATE INDEX task_survey_id_idx ON task USING btree (survey_id);
+ CREATE INDEX task_entity_id_idx ON task USING btree (entity_id);
+ CREATE INDEX task_assignee_id_idx ON task USING btree (assignee_id);
+ `);
+};
+
+exports.down = async function (db) {
+ await db.dropTable('task');
+ return db.runSql('DROP TYPE TASK_STATUS;');
+};
+
+exports._meta = {
+ version: 1,
+};
diff --git a/packages/database/src/modelClasses/MapOverlayGroupRelation.js b/packages/database/src/modelClasses/MapOverlayGroupRelation.js
index 5d15d9efdd..08ddad8d06 100644
--- a/packages/database/src/modelClasses/MapOverlayGroupRelation.js
+++ b/packages/database/src/modelClasses/MapOverlayGroupRelation.js
@@ -1,11 +1,12 @@
-/**
+/*
* Tupaia
- * Copyright (c) 2017 - 2020 Beyond Essential Systems Pty Ltd
+ * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd
*/
import { DatabaseModel } from '../DatabaseModel';
import { DatabaseRecord } from '../DatabaseRecord';
import { RECORDS } from '../records';
+import { JOIN_TYPES } from '../TupaiaDatabase';
const MAP_OVERLAY = 'mapOverlay';
const MAP_OVERLAY_GROUP = 'mapOverlayGroup';
@@ -17,6 +18,26 @@ const RELATION_CHILD_TYPES = {
export class MapOverlayGroupRelationRecord extends DatabaseRecord {
static databaseRecord = RECORDS.MAP_OVERLAY_GROUP_RELATION;
+ static joins = [
+ {
+ joinType: JOIN_TYPES.LEFT,
+ joinWith: RECORDS.MAP_OVERLAY,
+ joinAs: 'child_map_overlay',
+ joinCondition: [`${RECORDS.MAP_OVERLAY_GROUP_RELATION}.child_id`, `child_map_overlay.id`],
+ fields: { code: 'code' },
+ },
+ {
+ joinType: JOIN_TYPES.LEFT,
+ joinWith: RECORDS.MAP_OVERLAY_GROUP,
+ joinAs: 'child_map_overlay_group',
+ joinCondition: [
+ `${RECORDS.MAP_OVERLAY_GROUP_RELATION}.child_id`,
+ `child_map_overlay_group.id`,
+ ],
+ fields: { code: 'code' },
+ },
+ ];
+
async findChildRelations() {
return this.model.find({ map_overlay_group_id: this.child_id });
}
@@ -31,6 +52,10 @@ export class MapOverlayGroupRelationModel extends DatabaseModel {
return RELATION_CHILD_TYPES;
}
+ customColumnSelectors = {
+ child_code: () => 'COALESCE(child_map_overlay.code, child_map_overlay_group.code)',
+ };
+
async findTopLevelMapOverlayGroupRelations() {
const rootMapOverlayGroup = await this.otherModels.mapOverlayGroup.findRootMapOverlayGroup();
diff --git a/packages/database/src/modelClasses/Task.js b/packages/database/src/modelClasses/Task.js
new file mode 100644
index 0000000000..394b261e68
--- /dev/null
+++ b/packages/database/src/modelClasses/Task.js
@@ -0,0 +1,74 @@
+/**
+ * Tupaia
+ * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd
+ */
+
+import { format } from 'date-fns';
+import { DatabaseModel } from '../DatabaseModel';
+import { DatabaseRecord } from '../DatabaseRecord';
+import { RECORDS } from '../records';
+import { JOIN_TYPES } from '../TupaiaDatabase';
+
+export class TaskRecord extends DatabaseRecord {
+ static databaseRecord = RECORDS.TASK;
+
+ static joins = [
+ {
+ joinWith: RECORDS.ENTITY,
+ joinCondition: ['entity_id', `${RECORDS.ENTITY}.id`],
+ fields: { code: 'entity_code', name: 'entity_name', country_code: 'entity_country_code' },
+ },
+ {
+ joinWith: RECORDS.USER_ACCOUNT,
+ joinAs: 'assignee',
+ joinType: JOIN_TYPES.LEFT,
+ joinCondition: ['assignee_id', 'assignee.id'],
+ fields: { first_name: 'assignee_first_name', last_name: 'assignee_last_name' },
+ },
+ {
+ joinWith: RECORDS.SURVEY,
+ joinCondition: ['survey_id', `${RECORDS.SURVEY}.id`],
+ fields: { name: 'survey_name', code: 'survey_code' },
+ },
+ ];
+
+ async entity() {
+ return this.otherModels.entity.findById(this.entity_id);
+ }
+
+ async assignee() {
+ return this.otherModels.userAccount.findById(this.assignee_id);
+ }
+
+ async survey() {
+ return this.otherModels.survey.findById(this.survey_id);
+ }
+}
+
+export class TaskModel extends DatabaseModel {
+ get DatabaseRecordClass() {
+ return TaskRecord;
+ }
+
+ customColumnSelectors = {
+ task_status: () =>
+ `CASE
+ WHEN status = 'cancelled' then 'cancelled'
+ WHEN status = 'completed' then 'completed'
+ WHEN (status = 'to_do' OR status IS NULL) THEN
+ CASE
+ WHEN repeat_schedule IS NOT NULL THEN 'repeating'
+ WHEN due_date IS NULL THEN 'to_do'
+ WHEN due_date < '${format(new Date(), 'yyyy-MM-dd')}' THEN 'overdue'
+ ELSE 'to_do'
+ END
+ ELSE 'to_do'
+ END`,
+ assignee_name: () =>
+ `CASE
+ WHEN assignee_id IS NULL THEN NULL
+ WHEN assignee.last_name IS NULL THEN assignee.first_name
+ ELSE assignee.first_name || ' ' || assignee.last_name
+ END`,
+ };
+}
diff --git a/packages/database/src/modelClasses/index.js b/packages/database/src/modelClasses/index.js
index c2239a8cb9..57e288b195 100644
--- a/packages/database/src/modelClasses/index.js
+++ b/packages/database/src/modelClasses/index.js
@@ -59,6 +59,7 @@ import { DataServiceEntityModel } from './DataServiceEntity';
import { DhisInstanceModel } from './DhisInstance';
import { DataElementDataServiceModel } from './DataElementDataService';
import { SupersetInstanceModel } from './SupersetInstance';
+import { TaskModel } from './Task';
// export all models to be used in constructing a ModelRegistry
export const modelClasses = {
@@ -114,6 +115,7 @@ export const modelClasses = {
SurveyScreen: SurveyScreenModel,
SurveyScreenComponent: SurveyScreenComponentModel,
SyncGroupLog: SyncGroupLogModel,
+ Task: TaskModel,
User: UserModel,
UserEntityPermission: UserEntityPermissionModel,
UserFavouriteDashboardItem: UserFavouriteDashboardItemModel,
@@ -181,3 +183,4 @@ export {
export { DashboardRelationRecord, DashboardRelationModel } from './DashboardRelation';
export { OneTimeLoginRecord, OneTimeLoginModel } from './OneTimeLogin';
export { AnswerModel, AnswerRecord } from './Answer';
+export { TaskModel, TaskRecord } from './Task';
diff --git a/packages/database/src/records.js b/packages/database/src/records.js
index 2db952c5fc..1fecc79aa4 100644
--- a/packages/database/src/records.js
+++ b/packages/database/src/records.js
@@ -62,6 +62,7 @@ export const RECORDS = {
SURVEY_SCREEN: 'survey_screen',
SURVEY: 'survey',
SYNC_GROUP_LOG: 'sync_group_log',
+ TASK: 'task',
USER_ACCOUNT: 'user_account',
USER_ENTITY_PERMISSION: 'user_entity_permission',
USER_FAVOURITE_DASHBOARD_ITEM: 'user_favourite_dashboard_item',
diff --git a/packages/types/src/schemas/schemas.ts b/packages/types/src/schemas/schemas.ts
index f379252461..d8d562a033 100644
--- a/packages/types/src/schemas/schemas.ts
+++ b/packages/types/src/schemas/schemas.ts
@@ -85698,6 +85698,117 @@ export const SyncGroupLogUpdateSchema = {
"additionalProperties": false
}
+export const TaskSchema = {
+ "type": "object",
+ "properties": {
+ "assignee_id": {
+ "type": "string"
+ },
+ "due_date": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "entity_id": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "repeat_schedule": {
+ "type": "object",
+ "properties": {}
+ },
+ "status": {
+ "enum": [
+ "cancelled",
+ "completed",
+ "to_do"
+ ],
+ "type": "string"
+ },
+ "survey_id": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false,
+ "required": [
+ "entity_id",
+ "id",
+ "survey_id"
+ ]
+}
+
+export const TaskCreateSchema = {
+ "type": "object",
+ "properties": {
+ "assignee_id": {
+ "type": "string"
+ },
+ "due_date": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "entity_id": {
+ "type": "string"
+ },
+ "repeat_schedule": {
+ "type": "object",
+ "properties": {}
+ },
+ "status": {
+ "enum": [
+ "cancelled",
+ "completed",
+ "to_do"
+ ],
+ "type": "string"
+ },
+ "survey_id": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false,
+ "required": [
+ "entity_id",
+ "survey_id"
+ ]
+}
+
+export const TaskUpdateSchema = {
+ "type": "object",
+ "properties": {
+ "assignee_id": {
+ "type": "string"
+ },
+ "due_date": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "entity_id": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "repeat_schedule": {
+ "type": "object",
+ "properties": {}
+ },
+ "status": {
+ "enum": [
+ "cancelled",
+ "completed",
+ "to_do"
+ ],
+ "type": "string"
+ },
+ "survey_id": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+}
+
export const TupaiaWebSessionSchema = {
"type": "object",
"properties": {
@@ -86238,6 +86349,15 @@ export const VerifiedEmailSchema = {
"type": "string"
}
+export const TaskStatusSchema = {
+ "enum": [
+ "cancelled",
+ "completed",
+ "to_do"
+ ],
+ "type": "string"
+}
+
export const SyncGroupSyncStatusSchema = {
"enum": [
"ERROR",
diff --git a/packages/types/src/types/models.ts b/packages/types/src/types/models.ts
index f51b288794..d2cf4857b8 100644
--- a/packages/types/src/types/models.ts
+++ b/packages/types/src/types/models.ts
@@ -1533,6 +1533,32 @@ export interface SyncGroupLogUpdate {
'sync_group_code'?: string;
'timestamp'?: Date | null;
}
+export interface Task {
+ 'assignee_id'?: string | null;
+ 'due_date'?: Date | null;
+ 'entity_id': string;
+ 'id': string;
+ 'repeat_schedule'?: {} | null;
+ 'status'?: TaskStatus | null;
+ 'survey_id': string;
+}
+export interface TaskCreate {
+ 'assignee_id'?: string | null;
+ 'due_date'?: Date | null;
+ 'entity_id': string;
+ 'repeat_schedule'?: {} | null;
+ 'status'?: TaskStatus | null;
+ 'survey_id': string;
+}
+export interface TaskUpdate {
+ 'assignee_id'?: string | null;
+ 'due_date'?: Date | null;
+ 'entity_id'?: string;
+ 'id'?: string;
+ 'repeat_schedule'?: {} | null;
+ 'status'?: TaskStatus | null;
+ 'survey_id'?: string;
+}
export interface TupaiaWebSession {
'access_policy': {};
'access_token': string;
@@ -1665,6 +1691,11 @@ export enum VerifiedEmail {
'new_user' = 'new_user',
'verified' = 'verified',
}
+export enum TaskStatus {
+ 'to_do' = 'to_do',
+ 'cancelled' = 'cancelled',
+ 'completed' = 'completed',
+}
export enum SyncGroupSyncStatus {
'IDLE' = 'IDLE',
'SYNCING' = 'SYNCING',
diff --git a/packages/ui-components/src/components/FilterableTable/Cells.tsx b/packages/ui-components/src/components/FilterableTable/Cells.tsx
new file mode 100644
index 0000000000..a8c442048b
--- /dev/null
+++ b/packages/ui-components/src/components/FilterableTable/Cells.tsx
@@ -0,0 +1,131 @@
+/*
+ * Tupaia
+ * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd
+ */
+
+import React, { ReactNode } from 'react';
+import styled from 'styled-components';
+import { TableCell as MuiTableCell } from '@material-ui/core';
+import { Link } from 'react-router-dom';
+import { TableResizerProps } from 'react-table';
+
+const Cell = styled(MuiTableCell)<{
+ $maxWidth?: number;
+}>`
+ font-size: 0.75rem;
+ padding: 0;
+ border: none;
+ position: relative;
+ max-width: ${({ $maxWidth }) => ($maxWidth ? `${$maxWidth}px` : 'auto')};
+ &:first-child {
+ padding-inline-start: 1.5rem;
+ }
+ &:last-child {
+ padding-inline-end: 1rem;
+ }
+`;
+
+const CellContentWrapper = styled.div`
+ padding: 0.7rem;
+ height: 100%;
+ display: flex;
+ align-items: center;
+
+ tr:not(:last-child) & {
+ border-bottom: 1px solid ${({ theme }) => theme.palette.grey[400]};
+ }
+ td:first-child & {
+ padding-inline-start: 0.2rem;
+ }
+
+ line-height: 1.5;
+`;
+
+// Flex does not support ellipsis so we need to have another container to handle the ellipsis
+const CellContentContainer = styled.div`
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ width: 100%;
+`;
+
+const HeaderCell = styled(Cell)`
+ color: ${({ theme }) => theme.palette.text.secondary};
+ font-weight: ${({ theme }) => theme.typography.fontWeightMedium};
+ background-color: ${({ theme }) => theme.palette.background.paper};
+ border-bottom: 1px solid ${({ theme }) => theme.palette.grey[400]};
+ padding-block: 0.7rem;
+ padding-inline: 0.7rem 0;
+ display: flex;
+ position: initial; // override this because we have 2 sticky header rows so we will apply sticky to the thead element
+ background-color: ${({ theme }) => theme.palette.background.paper};
+ .MuiTableSortLabel-icon {
+ opacity: 0.5;
+ }
+ .MuiTableSortLabel-active .MuiTableSortLabel-icon {
+ opacity: 1;
+ }
+`;
+
+const CellLink = styled(Link)`
+ color: inherit;
+ text-decoration: none;
+ &:hover {
+ tr:has(&) td > * {
+ background-color: ${({ theme }) => `${theme.palette.primary.main}18`}; // 18 is 10% opacity
+ }
+ }
+`;
+
+const ColResize = styled.div.attrs({
+ onClick: (e: React.DragEvent) => {
+ // suppress other events when resizing
+ e.preventDefault();
+ e.stopPropagation();
+ },
+})`
+ width: 2rem;
+ height: 100%;
+ cursor: col-resize;
+`;
+
+export interface HeaderDisplayCellProps {
+ children?: ReactNode;
+ canResize?: boolean;
+ getResizerProps?: (props?: Partial) => TableResizerProps;
+ maxWidth?: number;
+}
+
+export const HeaderDisplayCell = ({
+ children,
+ canResize,
+ getResizerProps = () => ({}),
+ maxWidth,
+ ...props
+}: HeaderDisplayCellProps) => {
+ return (
+
+ {children}
+ {canResize && }
+
+ );
+};
+
+interface TableCellProps {
+ children: ReactNode;
+ width?: string;
+ row: Record;
+ maxWidth?: number;
+}
+export const TableCell = ({ children, width, row, maxWidth, ...props }: TableCellProps) => {
+ const url = row?.original?.url;
+ return (
+
+
+
+ {children}
+
+
+ |
+ );
+};
diff --git a/packages/ui-components/src/components/FilterableTable/FilterCell.tsx b/packages/ui-components/src/components/FilterableTable/FilterCell.tsx
new file mode 100644
index 0000000000..cbe6ee77b5
--- /dev/null
+++ b/packages/ui-components/src/components/FilterableTable/FilterCell.tsx
@@ -0,0 +1,113 @@
+/*
+ * Tupaia
+ * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd
+ */
+import React from 'react';
+import styled from 'styled-components';
+import { HeaderDisplayCell, HeaderDisplayCellProps } from './Cells';
+import { TextField } from '../Inputs';
+import { Search } from '@material-ui/icons';
+import { ColumnInstance } from 'react-table';
+
+const FilterWrapper = styled.div`
+ .MuiFormControl-root {
+ margin-block-end: 0;
+ }
+ .MuiInputBase-input,
+ .MuiOutlinedInput-root {
+ font-size: inherit;
+ }
+ .MuiSvgIcon-root {
+ font-size: 1.25rem;
+ }
+ .MuiInputBase-input {
+ padding-block: 0.6rem;
+ padding-inline: 0.6rem;
+ line-height: 1.2;
+ }
+ .MuiAutocomplete-popperDisablePortal,
+ .MuiPaper-root,
+ .MuiAutocomplete-option,
+ .MuiAutocomplete-popperDisablePortal,
+ .MuiPaper-root,
+ .MuiAutocomplete-option {
+ font-size: inherit;
+ }
+ .MuiAutocomplete-listbox {
+ padding-block: 0.3rem;
+ }
+ .MuiAutocomplete-option {
+ padding-block: 0.5rem;
+ }
+`;
+
+export const DefaultFilter = styled(TextField).attrs(props => ({
+ InputProps: {
+ ...props.InputProps,
+ startAdornment: ,
+ },
+ placeholder: 'Search...',
+}))`
+ margin-block-end: 0;
+ font-size: inherit;
+ width: 100%;
+ min-width: 6rem;
+ max-width: 100%;
+ // The following is overriding the padding in ui-components to make sure all filters match styling
+ .MuiInputBase-input,
+ .MuiInputBase-input.MuiAutocomplete-input.MuiInputBase-inputAdornedEnd {
+ padding-block: 0.6rem;
+ padding-inline: 0.2rem;
+ }
+ .MuiInputBase-root,
+ .MuiAutocomplete-inputRoot.MuiInputBase-adornedEnd.MuiOutlinedInput-adornedEnd {
+ padding-inline-start: 0.3rem;
+ }
+ .MuiSvgIcon-root {
+ color: ${({ theme }) => theme.palette.text.secondary};
+ }
+`;
+
+export type Filters = Record[];
+
+export interface FilterCellProps extends Partial {
+ column: ColumnInstance> & {
+ Filter?: React.ComponentType<{
+ column: ColumnInstance>;
+ filter?: any;
+ onChange: (value: any) => void;
+ }>;
+ filterable?: boolean;
+ };
+ filters: Filters;
+ onChangeFilters: (filters: Filters) => void;
+}
+
+export const FilterCell = ({ column, filters, onChangeFilters, ...props }: FilterCellProps) => {
+ const { id, Filter } = column;
+ const existingFilter = filters?.find(f => f.id === id);
+ const handleUpdate = (value: any) => {
+ const updatedFilters = existingFilter
+ ? filters.map(f => (f.id === id ? { ...f, value } : f))
+ : [...filters, { id, value }];
+
+ onChangeFilters(updatedFilters);
+ };
+ if (!column.filterable) return ;
+
+ return (
+
+
+ {Filter ? (
+
+ ) : (
+ handleUpdate(e.target.value)}
+ aria-label={`Search ${column.Header}`}
+ />
+ )}
+
+
+ );
+};
diff --git a/packages/ui-components/src/components/FilterableTable/FilterableTable.tsx b/packages/ui-components/src/components/FilterableTable/FilterableTable.tsx
new file mode 100644
index 0000000000..901cef2cfc
--- /dev/null
+++ b/packages/ui-components/src/components/FilterableTable/FilterableTable.tsx
@@ -0,0 +1,223 @@
+/**
+ * Tupaia
+ * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd
+ */
+
+import React, { useMemo } from 'react';
+import {
+ TableContainer as MuiTableContainer,
+ Table,
+ TableBody,
+ TableHead,
+ TableRow,
+ TableSortLabel,
+} from '@material-ui/core';
+import styled from 'styled-components';
+import { Column, useFlexLayout, useResizeColumns, useTable, SortingRule } from 'react-table';
+import { KeyboardArrowDown } from '@material-ui/icons';
+import { HeaderDisplayCell, TableCell } from './Cells';
+import { FilterCell, FilterCellProps, Filters } from './FilterCell';
+import { Pagination } from './Pagination';
+
+const TableContainer = styled(MuiTableContainer)`
+ position: relative;
+ flex: 1;
+ overflow: auto;
+ table {
+ min-width: 45rem;
+ }
+ // Because we want two header rows to be sticky, we need to set the position of the thead to sticky
+ thead {
+ position: sticky;
+ top: 0;
+ z-index: 2;
+ background-color: ${({ theme }) => theme.palette.background.paper};
+ }
+ tr {
+ display: flex;
+
+ .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline {
+ border-color: ${({ theme }) => theme.palette.primary.main};
+ }
+`;
+
+type SortBy = {
+ id: string;
+ desc: boolean;
+};
+
+interface FilterableTableProps {
+ columns: Column>[];
+ data?: Record[];
+ pageIndex?: number;
+ pageSize?: number;
+ sorting?: SortBy[];
+ numberOfPages?: number;
+ hiddenColumns?: string[];
+ onChangePage: (pageIndex: number) => void;
+ onChangePageSize: (pageSize: number) => void;
+ onChangeSorting: (sorting: SortingRule>[]) => void;
+ refreshData: () => void;
+ isLoading: boolean;
+ errorMessage: string;
+ onChangeFilters: FilterCellProps['onChangeFilters'];
+ filters?: Filters;
+ totalRecords: number;
+}
+
+export const FilterableTable = ({
+ columns,
+ data,
+ pageIndex = 0,
+ pageSize = 20,
+ sorting = [],
+ numberOfPages = 1,
+ hiddenColumns = [],
+ onChangePage,
+ onChangePageSize,
+ onChangeSorting,
+ onChangeFilters,
+ filters = [],
+ totalRecords,
+}: FilterableTableProps) => {
+ const memoisedData = useMemo(() => data ?? [], [data]);
+ const {
+ getTableProps,
+ getTableBodyProps,
+ headerGroups,
+ prepareRow,
+ rows,
+ pageCount,
+ visibleColumns,
+ // Get the state from the instance
+ } = useTable(
+ {
+ columns,
+ data: memoisedData,
+ initialState: {
+ sortBy: sorting,
+ hiddenColumns,
+ },
+ manualPagination: true,
+ pageCount: numberOfPages,
+ manualSortBy: true,
+ },
+ useFlexLayout,
+ useResizeColumns,
+ );
+
+ const displayFilterRow = visibleColumns.some(column => column.filterable !== false);
+
+ const updateSorting = (id: string) => {
+ const currentSorting = sorting.find(sort => sort.id === id);
+ const getNewSorting = () => {
+ // if the column is not sorted, add it to the sorting array, ascending first
+ if (!currentSorting) return [...sorting, { id, desc: false }];
+ // If the column is sorted descending, remove it from the sorting array, so it can have an 'off' state
+ if (currentSorting.desc) return sorting.filter(sort => sort.id !== id);
+ // If the column is sorted ascending, toggle it to descending
+ return sorting.map(sort => (sort.id === id ? { ...sort, desc: !sort.desc } : sort));
+ };
+
+ const newSorting = getNewSorting();
+
+ onChangeSorting(newSorting);
+ };
+
+ const getSortedConfig = (id: string) => {
+ return sorting.find(sort => sort.id === id);
+ };
+
+ return (
+ <>
+
+
+
+ {headerGroups.map(({ getHeaderGroupProps, headers }, index) => (
+ // eslint-disable-next-line react/no-array-index-key
+
+ {headers.map(
+ ({
+ getHeaderProps,
+ render,
+ disableSortBy,
+ getResizerProps,
+ canResize,
+ maxWidth,
+ id,
+ }) => {
+ const sortedConfig = getSortedConfig(id);
+ return (
+
+ {render('Header')}
+ {!disableSortBy && (
+ updateSorting(id)}
+ />
+ )}
+
+ );
+ },
+ )}
+
+ ))}
+ {displayFilterRow && (
+
+ {visibleColumns.map(column => {
+ return (
+
+ );
+ })}
+
+ )}
+
+
+ {rows.map(row => {
+ prepareRow(row);
+ return (
+
+ {row.cells.map(({ getCellProps, render }, i) => {
+ const col = visibleColumns[i];
+ return (
+
+ {render('Cell')}
+
+ );
+ })}
+
+ );
+ })}
+
+
+
+
+ >
+ );
+};
diff --git a/packages/admin-panel/src/table/DataFetchingTable/Pagination.jsx b/packages/ui-components/src/components/FilterableTable/Pagination.tsx
similarity index 51%
rename from packages/admin-panel/src/table/DataFetchingTable/Pagination.jsx
rename to packages/ui-components/src/components/FilterableTable/Pagination.tsx
index 869d73930a..4018e8c36d 100644
--- a/packages/admin-panel/src/table/DataFetchingTable/Pagination.jsx
+++ b/packages/ui-components/src/components/FilterableTable/Pagination.tsx
@@ -3,9 +3,8 @@
* Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd
*/
import React from 'react';
-import PropTypes from 'prop-types';
import styled from 'styled-components';
-import { Pagination as UIPagination } from '@tupaia/ui-components';
+import { Pagination as UIPagination } from '../Pagination';
const Wrapper = styled.div`
.pagination-wrapper {
@@ -18,31 +17,35 @@ const Wrapper = styled.div`
}
`;
-export const Pagination = ({ page, pageCount, gotoPage, pageSize, setPageSize, totalRecords }) => {
+interface PaginationProps {
+ page: number;
+ pageCount: number;
+ onChangePage: (page: number) => void;
+ pageSize: number;
+ onChangePageSize: (pageSize: number) => void;
+ totalRecords: number;
+}
+
+export const Pagination = ({
+ page,
+ pageCount,
+ onChangePage,
+ pageSize,
+ onChangePageSize,
+ totalRecords,
+}: PaginationProps) => {
if (!totalRecords) return null;
- const handleChangePage = newPage => {
- gotoPage(newPage);
- };
return (
);
};
-
-Pagination.propTypes = {
- page: PropTypes.number.isRequired,
- pageCount: PropTypes.number.isRequired,
- gotoPage: PropTypes.func.isRequired,
- pageSize: PropTypes.number.isRequired,
- setPageSize: PropTypes.func.isRequired,
- totalRecords: PropTypes.number.isRequired,
-};
diff --git a/packages/ui-components/src/components/FilterableTable/index.ts b/packages/ui-components/src/components/FilterableTable/index.ts
new file mode 100644
index 0000000000..46e0977b1c
--- /dev/null
+++ b/packages/ui-components/src/components/FilterableTable/index.ts
@@ -0,0 +1,6 @@
+/**
+ * Tupaia
+ * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd
+ */
+
+export { FilterableTable } from './FilterableTable';
diff --git a/packages/ui-components/src/components/Inputs/InputLabel.tsx b/packages/ui-components/src/components/Inputs/InputLabel.tsx
index ff17ba1b10..3904faf2c1 100644
--- a/packages/ui-components/src/components/Inputs/InputLabel.tsx
+++ b/packages/ui-components/src/components/Inputs/InputLabel.tsx
@@ -55,6 +55,7 @@ interface InputLabelProps {
className?: string;
htmlFor?: string;
TooltipIcon?: ComponentType;
+ labelProps?: Record;
}
export const InputLabel = ({
@@ -64,13 +65,14 @@ export const InputLabel = ({
className,
htmlFor,
TooltipIcon = HelpOutline,
+ labelProps = {},
}: InputLabelProps) => {
// If no label, don't render anything, so there isn't an empty label tag in the DOM
if (!label) return null;
return (
// allows us to pass in a custom element to render as, e.g. a span if it is going to be contained in a label element, for example when using MUI's TextField component. Otherwise defaults to a label element so that it can be a standalone label
<>
-
+
{label}
{tooltip && (
diff --git a/packages/ui-components/src/components/index.ts b/packages/ui-components/src/components/index.ts
index 5173f316ec..a325f03fb1 100644
--- a/packages/ui-components/src/components/index.ts
+++ b/packages/ui-components/src/components/index.ts
@@ -23,6 +23,7 @@ export * from './EnvBanner';
export * from './ErrorBoundary';
export * from './FavouriteButton';
export * from './FetchLoader';
+export * from './FilterableTable';
export * from './HomeButton';
export * from './HorizontalTree';
export * from './IconButton';
diff --git a/packages/ui-components/src/types/react-table-config.d.ts b/packages/ui-components/src/types/react-table-config.d.ts
index f35c1b6256..d739fad5e7 100644
--- a/packages/ui-components/src/types/react-table-config.d.ts
+++ b/packages/ui-components/src/types/react-table-config.d.ts
@@ -3,18 +3,31 @@ import {
UseSortByColumnProps,
UseSortByOptions,
UseSortByState,
+ UseTableRowProps as BaseUseTableRowProps,
} from 'react-table';
+type RowT = Record & {
+ url?: string;
+};
+
declare module 'react-table' {
- export interface TableOptions
+ export interface TableOptions>
extends UseExpandedOptions,
UseSortByOptions,
+ UsePaginationOptions,
// note that having Record here allows you to add anything to the options, this matches the spirit of the
// underlying js library, but might be cleaner if it's replaced by a more specific type that matches your
// feature set, this is a safe default.
Record {}
- export interface TableState extends UseSortByState {}
+ export interface TableState>
+ extends UseSortByState,
+ UsePaginationState {}
- export interface ColumnInstance extends UseSortByColumnProps {}
+ export interface ColumnInstance>
+ extends UseSortByColumnProps,
+ UseResizeColumnsColumnProps {
+ filterable?: boolean;
+ disableSortBy?: boolean;
+ }
}
diff --git a/yarn.lock b/yarn.lock
index ccd1940394..7e16ce827b 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -12005,6 +12005,7 @@ __metadata:
"@tupaia/auth": "workspace:*"
"@tupaia/tsutils": "workspace:*"
"@tupaia/utils": "workspace:*"
+ date-fns: ^2.29.2
db-migrate: ^0.11.5
db-migrate-pg: ^1.2.2
dotenv: ^16.4.5