From 8216eea87ebab59dd8f92b294617c65b297c24b0 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Mon, 15 Jul 2024 16:28:44 +1200 Subject: [PATCH 1/7] fix(adminPanel): RN-1390: Fix sorting on survey responses table --- .../DataFetchingTable/DataFetchingTable.jsx | 2 +- packages/admin-panel/src/table/constants.js | 2 +- .../FilterableTable/FilterableTable.tsx | 21 ++++++++----------- 3 files changed, 11 insertions(+), 14 deletions(-) diff --git a/packages/admin-panel/src/table/DataFetchingTable/DataFetchingTable.jsx b/packages/admin-panel/src/table/DataFetchingTable/DataFetchingTable.jsx index a5b868645b..8ad76ee700 100644 --- a/packages/admin-panel/src/table/DataFetchingTable/DataFetchingTable.jsx +++ b/packages/admin-panel/src/table/DataFetchingTable/DataFetchingTable.jsx @@ -162,7 +162,7 @@ const DataFetchingTableComponent = memo( const getSortingToUse = () => { // If there is no sorting, return the default sorting, if it exists, otherwise return an empty array - if (!sorting || sorting.length === 0) return defaultSorting || []; + if (!sorting) return defaultSorting ?? []; return sorting; }; diff --git a/packages/admin-panel/src/table/constants.js b/packages/admin-panel/src/table/constants.js index 3d42f9597b..214c65b58b 100644 --- a/packages/admin-panel/src/table/constants.js +++ b/packages/admin-panel/src/table/constants.js @@ -34,5 +34,5 @@ export const DEFAULT_TABLE_STATE = { fetchId: null, pageIndex: 0, pageSize: 20, - sorting: [], + sorting: null, }; diff --git a/packages/ui-components/src/components/FilterableTable/FilterableTable.tsx b/packages/ui-components/src/components/FilterableTable/FilterableTable.tsx index 901cef2cfc..ea9a33e0b4 100644 --- a/packages/ui-components/src/components/FilterableTable/FilterableTable.tsx +++ b/packages/ui-components/src/components/FilterableTable/FilterableTable.tsx @@ -108,20 +108,17 @@ export const FilterableTable = ({ const displayFilterRow = visibleColumns.some(column => column.filterable !== false); - const updateSorting = (id: string) => { + const updateSorting = (id: string, isDesc: boolean) => { 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)); - }; + if (!currentSorting) { + return onChangeSorting([{ id, desc: false }]); + } - const newSorting = getNewSorting(); + if (isDesc) { + return onChangeSorting([]); + } - onChangeSorting(newSorting); + return onChangeSorting([{ id, desc: true }]); }; const getSortedConfig = (id: string) => { @@ -161,7 +158,7 @@ export const FilterableTable = ({ active={!!sortedConfig} direction={sortedConfig?.desc ? 'asc' : 'desc'} IconComponent={KeyboardArrowDown} - onClick={() => updateSorting(id)} + onClick={() => updateSorting(id, sortedConfig?.desc)} /> )} From b103f6fa58fd40b92b4cbe28619afeeca4bc0222 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Mon, 15 Jul 2024 16:34:54 +1200 Subject: [PATCH 2/7] fix(uiComponents): Fix type error --- .../src/components/FilterableTable/FilterableTable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui-components/src/components/FilterableTable/FilterableTable.tsx b/packages/ui-components/src/components/FilterableTable/FilterableTable.tsx index ea9a33e0b4..7c2ceae9d0 100644 --- a/packages/ui-components/src/components/FilterableTable/FilterableTable.tsx +++ b/packages/ui-components/src/components/FilterableTable/FilterableTable.tsx @@ -108,7 +108,7 @@ export const FilterableTable = ({ const displayFilterRow = visibleColumns.some(column => column.filterable !== false); - const updateSorting = (id: string, isDesc: boolean) => { + const updateSorting = (id: string, isDesc?: boolean) => { const currentSorting = sorting.find(sort => sort.id === id); if (!currentSorting) { return onChangeSorting([{ id, desc: false }]); From f94fa3d324f7d82f002a4a37600f08f0d747ae9e Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Tue, 16 Jul 2024 14:27:01 +1200 Subject: [PATCH 3/7] fix(adminPanel): Remove required state for password when editing a user --- .../admin-panel/src/routes/users/users.jsx | 112 +++++++++++------- 1 file changed, 67 insertions(+), 45 deletions(-) diff --git a/packages/admin-panel/src/routes/users/users.jsx b/packages/admin-panel/src/routes/users/users.jsx index 7d48206c3a..71d80d137e 100644 --- a/packages/admin-panel/src/routes/users/users.jsx +++ b/packages/admin-panel/src/routes/users/users.jsx @@ -26,35 +26,35 @@ const VerifiedCell = ({ value }) => { ); }; -const EDIT_FIELDS = [ - { +const FIELDS = { + first_name: { Header: 'First Name', source: 'first_name', required: true, }, - { + last_name: { Header: 'Last Name', source: 'last_name', required: true, }, - { + email: { Header: 'Email address', source: 'email', required: true, }, - { + phone: { Header: 'Phone number', source: 'mobile_number', }, - { + position: { Header: 'Position', source: 'position', }, - { + employer: { Header: 'Employer', source: 'employer', }, - { + verified: { Header: 'Verified', source: 'verified_email', editConfig: { @@ -70,15 +70,71 @@ const EDIT_FIELDS = [ ], }, }, - { + password: { Header: 'Password', source: 'password', - required: true, hideValue: true, editConfig: { type: 'password', }, }, +}; + +const EDIT_FIELDS = [ + FIELDS.first_name, + FIELDS.last_name, + FIELDS.email, + FIELDS.phone, + FIELDS.position, + FIELDS.employer, + FIELDS.verified, + FIELDS.password, +]; + +const CREATE_FIELDS = [ + FIELDS.first_name, + FIELDS.last_name, + FIELDS.email, + FIELDS.phone, + FIELDS.position, + FIELDS.employer, + FIELDS.verified, + { + ...FIELDS.password, + required: true, + }, + { + Header: 'Country', + source: 'countryName', + required: true, + editConfig: { + sourceKey: 'countryName', + optionsEndpoint: 'countries', + optionLabelKey: 'name', + optionValueKey: 'name', + labelTooltip: 'Select the country to grant this user access to', + }, + }, + { + Header: 'Permission group', + source: 'permissionGroupName', + required: true, + editConfig: { + sourceKey: 'permissionGroupName', + optionsEndpoint: 'permissionGroups', + optionLabelKey: 'name', + optionValueKey: 'name', + labelTooltip: 'Select the permission group to grant this user access to', + }, + }, + { + Header: 'API Client (not required for most users, see README of admin-panel for usage)', + source: 'is_api_client', + type: 'boolean', + editConfig: { + type: 'boolean', + }, + }, ]; const COLUMNS = [ @@ -128,41 +184,7 @@ const IMPORT_CONFIG = { const CREATE_CONFIG = { actionConfig: { editEndpoint: 'users', - fields: [ - ...EDIT_FIELDS, - { - Header: 'Country', - source: 'countryName', - required: true, - editConfig: { - sourceKey: 'countryName', - optionsEndpoint: 'countries', - optionLabelKey: 'name', - optionValueKey: 'name', - labelTooltip: 'Select the country to grant this user access to', - }, - }, - { - Header: 'Permission group', - source: 'permissionGroupName', - required: true, - editConfig: { - sourceKey: 'permissionGroupName', - optionsEndpoint: 'permissionGroups', - optionLabelKey: 'name', - optionValueKey: 'name', - labelTooltip: 'Select the permission group to grant this user access to', - }, - }, - { - Header: 'API Client (not required for most users, see README of admin-panel for usage)', - source: 'is_api_client', - type: 'boolean', - editConfig: { - type: 'boolean', - }, - }, - ], + fields: CREATE_FIELDS, }, }; From 8afe362b1f9a090c96eff6e79121153b909f036e Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Thu, 18 Jul 2024 11:35:17 +1200 Subject: [PATCH 4/7] fix(visualisations): RN-1334: Fix UTC date difference in end date (#5700) * Add default end date to params for querying data * Set start date as beginning of selected date and end date as end of selected date * Fix check on overlay query * Make default dates work for map overlays --------- Co-authored-by: Andrew --- .../VizBuilderApp/api/queries/useReportPreview.js | 5 ++++- .../services/SurveyResponseDataTableService.ts | 8 ++++++-- .../src/api/queries/useMapOverlayReport.ts | 13 ++++++++++--- packages/tupaia-web/src/api/queries/useReport.ts | 6 ++++-- packages/web-config-server/src/apiV1/measureData.js | 6 +++++- 5 files changed, 29 insertions(+), 9 deletions(-) diff --git a/packages/admin-panel/src/VizBuilderApp/api/queries/useReportPreview.js b/packages/admin-panel/src/VizBuilderApp/api/queries/useReportPreview.js index da479710a6..8aecefc140 100644 --- a/packages/admin-panel/src/VizBuilderApp/api/queries/useReportPreview.js +++ b/packages/admin-panel/src/VizBuilderApp/api/queries/useReportPreview.js @@ -2,6 +2,7 @@ * Tupaia * Copyright (c) 2017 - 2021 Beyond Essential Systems Pty Ltd */ +import moment from 'moment'; import { useQuery } from 'react-query'; import { post } from '../api'; import { DEFAULT_REACT_QUERY_OPTIONS } from '../constants'; @@ -21,12 +22,14 @@ export const useReportPreview = ({ useQuery( ['fetchReportPreviewData', visualisation], async () => { + const today = moment().format('YYYY-MM-DD'); + const endDateToUse = endDate ?? today; // default to today if no end date is provided, so that we are getting data in the user's timezone, not UTC const response = await post('fetchReportPreviewData', { params: { entityCode: location, hierarchy: project, startDate, - endDate, + endDate: endDateToUse, dashboardItemOrMapOverlay, previewMode, permissionGroup: visualisation.permissionGroup || visualisation.reportPermissionGroup, diff --git a/packages/data-table-server/src/dataTableService/services/SurveyResponseDataTableService.ts b/packages/data-table-server/src/dataTableService/services/SurveyResponseDataTableService.ts index 7e3ef23fd6..0e4dace195 100644 --- a/packages/data-table-server/src/dataTableService/services/SurveyResponseDataTableService.ts +++ b/packages/data-table-server/src/dataTableService/services/SurveyResponseDataTableService.ts @@ -99,16 +99,20 @@ export class SurveyResponseDataTableService extends DataTableService< }, {} as Record); if (params.startDate && !params.endDate) { + // set the start date to the beginning of the day to include all responses on that day + const startDate = new Date(params.startDate).setHours(0, 0, 0, 0); filter['survey_response.data_time'] = { comparator: '>=', - comparisonValue: new Date(params.startDate), + comparisonValue: new Date(startDate), }; } if (!params.startDate && params.endDate) { + // set the end date to the end of the day so that it includes all responses on that day - this is because the endDate comes through as just the date, not the time, and so gets automatically set to midnight at the start of the day, so won't include responses from the full day if the date selected is in the period where UTC is behind the local time + const endDate = new Date(params.endDate).setHours(23, 59, 59, 999); filter['survey_response.data_time'] = { comparator: '<=', - comparisonValue: new Date(params.endDate), + comparisonValue: new Date(endDate), }; } diff --git a/packages/tupaia-web/src/api/queries/useMapOverlayReport.ts b/packages/tupaia-web/src/api/queries/useMapOverlayReport.ts index d039369cee..90ff37a873 100644 --- a/packages/tupaia-web/src/api/queries/useMapOverlayReport.ts +++ b/packages/tupaia-web/src/api/queries/useMapOverlayReport.ts @@ -2,6 +2,7 @@ * Tupaia * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd */ +import moment, { Moment } from 'moment'; import { useQuery } from 'react-query'; import { momentToDateString } from '@tupaia/utils'; import { TupaiaWebMapOverlaysRequest } from '@tupaia/types'; @@ -13,7 +14,6 @@ import { } from '@tupaia/ui-map-components'; import { get } from '../api'; import { EntityCode, ProjectCode } from '../../types'; -import { Moment } from 'moment'; type SingleMapOverlayItem = TupaiaWebMapOverlaysRequest.TranslatedMapOverlay; @@ -63,6 +63,8 @@ const formatMapOverlayData = (data: any) => { }; }; +const DEFAULT_START_DATE = '2015-01-01'; + export const useMapOverlayReport = ( projectCode?: ProjectCode, entityCode?: EntityCode, @@ -73,9 +75,14 @@ export const useMapOverlayReport = ( }, keepPreviousData?: boolean, ) => { + const today = moment(); + const startDateToUse = params?.startDate ?? moment(DEFAULT_START_DATE); // default to 2015-01-01 if no start date is provided + const endDateToUse = params?.endDate ?? today; // default to today if no end date is provided, so that the default end date is always in the user's timezone, not UTC + // convert moment dates to date strings for the endpoint to use - const startDate = params?.startDate ? momentToDateString(params.startDate) : undefined; - const endDate = params?.startDate ? momentToDateString(params.endDate) : undefined; + const endDate = momentToDateString(endDateToUse); + + const startDate = momentToDateString(startDateToUse); const mapOverlayCode = mapOverlay?.code; const isLegacy = mapOverlay?.legacy ? 'true' : 'false'; diff --git a/packages/tupaia-web/src/api/queries/useReport.ts b/packages/tupaia-web/src/api/queries/useReport.ts index 03a1227efa..48319bc5f6 100644 --- a/packages/tupaia-web/src/api/queries/useReport.ts +++ b/packages/tupaia-web/src/api/queries/useReport.ts @@ -3,7 +3,7 @@ * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd * */ -import { Moment } from 'moment'; +import moment, { Moment } from 'moment'; import { useQuery } from 'react-query'; import { formatDateForApi, getBrowserTimeZone } from '@tupaia/utils'; import { TupaiaWebReportRequest } from '@tupaia/types'; @@ -27,9 +27,11 @@ export const useReport = ( ) => { const { dashboardCode, projectCode, entityCode, itemCode, startDate, endDate, legacy, ...rest } = params; + const today = moment(); + const endDateToUse = endDate ?? today; // default to today if no end date is provided, so that the default end date is always in the user's timezone, not UTC const timeZone = getBrowserTimeZone(); const formattedStartDate = formatDateForApi(startDate, null); - const formattedEndDate = formatDateForApi(endDate, null); + const formattedEndDate = formatDateForApi(endDateToUse, null); const endPoint = legacy ? 'legacyDashboardReport' : 'report'; return useQuery( [ diff --git a/packages/web-config-server/src/apiV1/measureData.js b/packages/web-config-server/src/apiV1/measureData.js index d9119b334e..e543e0e725 100644 --- a/packages/web-config-server/src/apiV1/measureData.js +++ b/packages/web-config-server/src/apiV1/measureData.js @@ -266,7 +266,11 @@ export default class extends DataAggregatingRouteHandler { const { periodGranularity } = measureBuilderConfig || {}; const { startDate, endDate } = this.query; - const dates = periodGranularity ? getDateRange(periodGranularity, startDate, endDate) : {}; + let dates = periodGranularity ? getDateRange(periodGranularity, startDate, endDate) : {}; + + if (startDate && endDate && !periodGranularity) { + dates = getDateRange('day', startDate, endDate); + } const baseOptions = { ...restOfPresentationConfig, From 99082f6f2df7148d10d26f54a331e4819766a2ac Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Fri, 19 Jul 2024 08:02:01 +1200 Subject: [PATCH 5/7] tweak(adminPanel): RN-1255: Bulk error handling for importing survey questions (#5641) * WIP * Styling * Fix build error * Update error handling * Update TupaiaApi.js * PR fixes * Fix merge issue with error handling * Include error extra fields in tooltip * Fix config errors * Fix modal buttons on error * Handle error details for config questions * Use close button on import error --------- Co-authored-by: Andrew --- packages/admin-panel/src/editor/EditModal.jsx | 63 ++-- packages/admin-panel/src/editor/actions.js | 7 +- packages/admin-panel/src/editor/reducer.js | 19 +- .../src/editor/withConnectedEditor.js | 4 +- .../src/importExport/ExportModal.jsx | 16 +- .../src/importExport/ImportModal.jsx | 40 ++- .../admin-panel/src/logsTable/LogsModal.jsx | 10 +- packages/admin-panel/src/logsTable/reducer.js | 8 +- .../resources/editSurvey/EditSurveyPage.jsx | 48 ++- .../admin-panel/src/surveyResponse/Form.jsx | 9 +- packages/admin-panel/src/theme/colors.js | 2 +- packages/admin-panel/src/theme/theme.js | 17 +- .../admin-panel/src/widgets/Modal/Modal.jsx | 18 +- .../widgets/Modal/ModalContentProvider.jsx | 100 +++++- .../ConfigImporter/ConfigImporter.js | 15 +- .../ConfigValidator/ConfigValidator.js | 4 +- .../Validator/JsonFieldValidator.js | 8 +- .../importSurveys/importSurveyQuestions.js | 304 ++++++++++-------- .../AdminPanel/components/RejectButton.jsx | 10 +- .../ui-components/src/components/Alert.tsx | 9 +- .../src/components/Icons/Error.tsx | 30 ++ .../src/components/Icons/index.ts | 1 + .../src/components/TooltipIconButton.tsx | 57 ++++ .../ui-components/src/components/index.ts | 1 + packages/utils/src/errors.js | 32 +- packages/utils/src/request.js | 16 +- .../utils/src/validation/ObjectValidator.js | 4 +- .../src/validation/validatorFunctions.js | 3 +- 28 files changed, 545 insertions(+), 310 deletions(-) create mode 100644 packages/ui-components/src/components/Icons/Error.tsx create mode 100644 packages/ui-components/src/components/TooltipIconButton.tsx diff --git a/packages/admin-panel/src/editor/EditModal.jsx b/packages/admin-panel/src/editor/EditModal.jsx index 43923f2e70..6e38bdd9ea 100644 --- a/packages/admin-panel/src/editor/EditModal.jsx +++ b/packages/admin-panel/src/editor/EditModal.jsx @@ -15,7 +15,7 @@ import { withConnectedEditor } from './withConnectedEditor'; import { useValidationScroll } from './useValidationScroll'; export const EditModalComponent = ({ - errorMessage, + error, isOpen, isLoading, onDismiss, @@ -49,26 +49,47 @@ export const EditModalComponent = ({ onEditField, validationErrors, ); - const buttons = [ - { - onClick: onDismiss, - text: errorMessage ? dismissButtonText : cancelButtonText, - disabled: isLoading, - variant: 'outlined', - id: 'form-button-cancel', - }, - { - onClick: onSaveWithTouched, - id: 'form-button-save', - text: saveButtonText, - disabled: !!errorMessage || isLoading || isUnchanged, - }, - ]; + + const getButtons = () => { + if (error) { + return [ + { + onClick: onDismiss, + text: dismissButtonText, + disabled: isLoading, + variant: 'contained', + id: 'form-button-cancel', + }, + ]; + } + return [ + { + onClick: onDismiss, + text: cancelButtonText, + disabled: isLoading, + variant: 'outlined', + id: 'form-button-cancel', + }, + { + onClick: onSaveWithTouched, + id: 'form-button-save', + text: saveButtonText, + disabled: !!error || isLoading || isUnchanged, + }, + ]; + }; + + const buttons = getButtons(); const generateModalTitle = () => { if (title) return title; - if (isLoading) return ''; if (!resourceName) return isNew ? 'Add' : 'Edit'; + if (error) { + const capitalisedResourceName = `${resourceName.charAt(0).toUpperCase()}${resourceName.slice( + 1, + )}`; + return `${capitalisedResourceName} error`; + } if (isNew) return `Add ${resourceName}`; return `Edit ${resourceName}`; }; @@ -77,7 +98,7 @@ export const EditModalComponent = ({ return ( (dispatch, getState) => { }; export const saveEdits = - (endpoint, editedFields, isNew, filesByFieldKey = {}, onSuccess) => + (endpoint, editedFields, isNew, filesByFieldKey = {}, onSuccess, onError) => async (dispatch, getState, { api }) => { try { const { recordData, fields } = getEditorState(getState()); @@ -245,8 +245,11 @@ export const saveEdits = } catch (error) { dispatch({ type: EDITOR_ERROR, - errorMessage: error.message, + error, }); + if (onError) { + onError(error); + } } }; diff --git a/packages/admin-panel/src/editor/reducer.js b/packages/admin-panel/src/editor/reducer.js index 5503c738de..77eaa4ac44 100644 --- a/packages/admin-panel/src/editor/reducer.js +++ b/packages/admin-panel/src/editor/reducer.js @@ -19,7 +19,7 @@ import { } from './constants'; const defaultState = { - errorMessage: '', + error: null, isOpen: false, isLoading: false, endpoint: null, @@ -42,7 +42,7 @@ const stateChanges = { }, [EDITOR_DATA_EDIT_BEGIN]: payload => ({ isLoading: true, - errorMessage: '', + error: null, ...payload, }), [EDITOR_DATA_FETCH_SUCCESS]: payload => ({ @@ -57,16 +57,15 @@ const stateChanges = { isLoading: false, ...payload, }), - [EDITOR_DISMISS]: (payload, { errorMessage }) => { - if (errorMessage) { - return { errorMessage: defaultState.errorMessage }; // If there is an error, dismiss it + [EDITOR_DISMISS]: (payload, { error }) => { + if (error) { + return { error: defaultState.error }; // If there is an error, dismiss it } return defaultState; // If no error, dismiss the whole modal and clear its state }, - [LOAD_EDITOR]: payload => ({ - ...payload, - errorMessage: '', - }), + [LOAD_EDITOR]: payload => { + return { ...payload, error: null }; + }, [OPEN_EDIT_MODAL]: ({ recordId }) => ({ recordId, isOpen: true }), [EDITOR_FIELD_EDIT]: ( { fieldKey, newValue, otherValidationErrorsToClear = [] }, @@ -89,7 +88,7 @@ const stateChanges = { [SET_VALIDATION_ERRORS]: payload => ({ validationErrors: payload, }), - [RESET_EDITS]: () => ({ editedFields: {}, errorMessage: '' }), + [RESET_EDITS]: () => ({ editedFields: {}, error: null }), }; export const reducer = createReducer(defaultState, stateChanges); diff --git a/packages/admin-panel/src/editor/withConnectedEditor.js b/packages/admin-panel/src/editor/withConnectedEditor.js index 13128b1597..173b6be4b6 100644 --- a/packages/admin-panel/src/editor/withConnectedEditor.js +++ b/packages/admin-panel/src/editor/withConnectedEditor.js @@ -61,13 +61,13 @@ const mergeProps = ( ...editedFields, }, // Include edits in visible record data isNew, - onSave: (files, onSuccess) => { + onSave: (files, onSuccess, onError) => { // If there is no record data, this is a new record let fieldValuesToSave = isNew ? { ...initialValues, ...editedFields } : { ...editedFields }; if (onProcessDataForSave) { fieldValuesToSave = onProcessDataForSave(fieldValuesToSave, recordData); } - dispatch(saveEdits(endpoint, fieldValuesToSave, isNew, files, onSuccess)); + dispatch(saveEdits(endpoint, fieldValuesToSave, isNew, files, onSuccess, onError)); }, usedByConfig, }; diff --git a/packages/admin-panel/src/importExport/ExportModal.jsx b/packages/admin-panel/src/importExport/ExportModal.jsx index f660e22943..1c7a2c3803 100644 --- a/packages/admin-panel/src/importExport/ExportModal.jsx +++ b/packages/admin-panel/src/importExport/ExportModal.jsx @@ -30,25 +30,25 @@ export const ExportModal = React.memo( }) => { const api = useApiContext(); const [status, setStatus] = useState(STATUS.IDLE); - const [errorMessage, setErrorMessage] = useState(null); + const [error, setError] = useState(null); const [isOpen, setIsOpen] = useState(false); const handleOpen = () => setIsOpen(true); const handleDismiss = () => { setStatus(STATUS.IDLE); - setErrorMessage(null); + setError(null); }; const handleClose = () => { setStatus(STATUS.IDLE); - setErrorMessage(null); + setError(null); setIsOpen(false); }; const handleSubmit = async event => { event.preventDefault(); - setErrorMessage(null); + setError(null); setStatus(STATUS.LOADING); try { @@ -63,9 +63,9 @@ export const ExportModal = React.memo( } else { handleClose(); } - } catch (error) { + } catch (e) { setStatus(STATUS.ERROR); - setErrorMessage(error.message); + setError(e); } }; @@ -120,8 +120,8 @@ export const ExportModal = React.memo( onClose={handleClose} isOpen={isOpen} disableBackdropClick - title={errorMessage ? 'Error' : title} - errorMessage={errorMessage} + title={error ? 'Error' : title} + error={error} isLoading={status === STATUS.LOADING} buttons={buttons} > diff --git a/packages/admin-panel/src/importExport/ImportModal.jsx b/packages/admin-panel/src/importExport/ImportModal.jsx index b697d95da8..c2a5d1feb2 100644 --- a/packages/admin-panel/src/importExport/ImportModal.jsx +++ b/packages/admin-panel/src/importExport/ImportModal.jsx @@ -42,7 +42,7 @@ export const ImportModalComponent = React.memo( const api = useApiContext(); const [status, setStatus] = useState(STATUS.IDLE); const [finishedMessage, setFinishedMessage] = useState(null); - const [errorMessage, setErrorMessage] = useState(null); + const [error, setError] = useState(null); const [isOpen, setIsOpen] = useState(false); const [values, setValues] = useState({}); const [files, setFiles] = useState([]); @@ -59,7 +59,7 @@ export const ImportModalComponent = React.memo( const handleDismiss = () => { setStatus(STATUS.IDLE); - setErrorMessage(null); + setError(null); setFinishedMessage(null); setFiles([]); setFileName(null); @@ -67,7 +67,7 @@ export const ImportModalComponent = React.memo( const handleClose = () => { setStatus(STATUS.IDLE); - setErrorMessage(null); + setError(null); setFinishedMessage(null); setIsOpen(false); setValues({}); @@ -77,7 +77,7 @@ export const ImportModalComponent = React.memo( const handleSubmit = async event => { event.preventDefault(); - setErrorMessage(null); + setError(null); setFinishedMessage(null); setStatus(STATUS.LOADING); changeRequest(); @@ -100,21 +100,23 @@ export const ImportModalComponent = React.memo( setFinishedMessage(getFinishedMessage(response)); } changeSuccess(); - } catch (error) { + } catch (e) { + // Print a more descriptive network timeout error + // TODO: Remove this after https://github.com/beyondessential/tupaia-backlog/issues/1009 is fixed + const errorMessage = + e.message === 'Network request timed out' + ? 'Request timed out, but may have still succeeded. Please wait 2 minutes and check to see if the data has changed' + : e.message; setStatus(STATUS.ERROR); setFinishedMessage(null); - setErrorMessage(error.message); + setError({ + ...e, + message: errorMessage, + }); changeError(); } }; - // Print a more descriptive network timeout error - // TODO: Remove this after https://github.com/beyondessential/tupaia-backlog/issues/1009 is fixed - const fileErrorMessage = - errorMessage === 'Network request timed out' - ? 'Request timed out, but may have still succeeded. Please wait 2 minutes and check to see if the data has changed' - : errorMessage; - const getButtons = () => { switch (status) { case STATUS.TIMEOUT: @@ -128,13 +130,9 @@ export const ImportModalComponent = React.memo( case STATUS.ERROR: return [ { - text: 'Dismiss', + text: 'Close', onClick: handleDismiss, - variant: 'outlined', - }, - { - text: confirmButtonText, - disabled: true, + variant: 'contained', }, ]; default: @@ -174,8 +172,8 @@ export const ImportModalComponent = React.memo( onClose={handleClose} isOpen={isOpen} disableBackdropClick - title={fileErrorMessage ? 'Error' : title} - errorMessage={fileErrorMessage} + title={error ? 'Error' : title} + error={error} isLoading={status === STATUS.LOADING} buttons={buttons} > diff --git a/packages/admin-panel/src/logsTable/LogsModal.jsx b/packages/admin-panel/src/logsTable/LogsModal.jsx index 26aa31c849..366b313fef 100644 --- a/packages/admin-panel/src/logsTable/LogsModal.jsx +++ b/packages/admin-panel/src/logsTable/LogsModal.jsx @@ -11,7 +11,7 @@ import { Modal } from '../widgets'; import { LogsTable } from './LogsTable'; export const LogsModalComponent = ({ - errorMessage, + error, logs, logsCount, page, @@ -29,14 +29,14 @@ export const LogsModalComponent = ({ disableBackdropClick maxWidth="xl" title={title} - errorMessage={errorMessage} + error={error} isLoading={isLoading} buttons={[ { disabled: isLoading, variant: 'outlined', onClick: onDismiss, - text: errorMessage ? 'Dismiss' : 'Cancel', + text: error ? 'Dismiss' : 'Cancel', }, ]} > @@ -52,7 +52,7 @@ export const LogsModalComponent = ({ }; LogsModalComponent.propTypes = { - errorMessage: PropTypes.string, + error: PropTypes.object, isLoading: PropTypes.bool, isOpen: PropTypes.bool.isRequired, onDismiss: PropTypes.func.isRequired, @@ -66,7 +66,7 @@ LogsModalComponent.propTypes = { LogsModalComponent.defaultProps = { isLoading: false, - errorMessage: null, + error: null, logsCount: null, title: 'Logs', }; diff --git a/packages/admin-panel/src/logsTable/reducer.js b/packages/admin-panel/src/logsTable/reducer.js index 7e07b2f764..c467efa3d8 100644 --- a/packages/admin-panel/src/logsTable/reducer.js +++ b/packages/admin-panel/src/logsTable/reducer.js @@ -13,7 +13,7 @@ import { } from './constants'; const defaultState = { - errorMessage: '', + error: null, isLoading: false, isOpen: false, logs: [], @@ -36,9 +36,9 @@ const stateChanges = { [LOGS_DISMISS]: () => ({ ...defaultState, }), - [LOGS_ERROR]: (payload, { errorMessage }) => { - if (errorMessage) { - return { errorMessage: defaultState.errorMessage }; // If there is an error, dismiss it + [LOGS_ERROR]: (payload, { error }) => { + if (error) { + return { error: defaultState.error }; // If there is an error, dismiss it } return defaultState; // If no error, dismiss the whole modal and clear its state }, diff --git a/packages/admin-panel/src/pages/resources/editSurvey/EditSurveyPage.jsx b/packages/admin-panel/src/pages/resources/editSurvey/EditSurveyPage.jsx index 975149e15b..603e7c6d1d 100644 --- a/packages/admin-panel/src/pages/resources/editSurvey/EditSurveyPage.jsx +++ b/packages/admin-panel/src/pages/resources/editSurvey/EditSurveyPage.jsx @@ -2,13 +2,13 @@ * Tupaia * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import keyBy from 'lodash.keyby'; import { connect } from 'react-redux'; import { useNavigate, useParams } from 'react-router'; import styled from 'styled-components'; -import { Alert, Button, SpinningLoader } from '@tupaia/ui-components'; +import { Button, SpinningLoader } from '@tupaia/ui-components'; import { Breadcrumbs } from '../../../layout'; import { useItemDetails } from '../../../api/queries/useResourceDetails'; import { withConnectedEditor, useValidationScroll } from '../../../editor'; @@ -16,7 +16,8 @@ import { useEditFiles } from '../../../editor/useEditFiles'; import { FileUploadField } from '../../../widgets/InputField/FileUploadField'; import { FieldsEditor } from '../../../editor/FieldsEditor'; import { dismissEditor, loadEditor, resetEdits } from '../../../editor/actions'; -import { useLinkToPreviousSearchState, useLinkWithSearchState } from '../../../utilities'; +import { Modal } from '../../../widgets'; +import { useLinkToPreviousSearchState } from '../../../utilities'; const Wrapper = styled.div` overflow: hidden; @@ -82,14 +83,10 @@ const StickyFooter = styled.div` padding: 1.25rem; `; -const ErrorAlert = styled(Alert)` - display: ${({ $show }) => ($show ? 'flex' : 'none')}; -`; - const EditSurveyPageComponent = withConnectedEditor( ({ parent, - errorMessage, + error, displayProperty, getDisplayValue, fields, @@ -102,7 +99,7 @@ const EditSurveyPageComponent = withConnectedEditor( resetEditorToDefaultState, validationErrors, }) => { - const errorAlertRef = useRef(null); + const [errorModalOpen, setErrorModalOpen] = useState(false); const navigate = useNavigate(); const { '*': unusedParam, locale, ...params } = useParams(); const { data: details } = useItemDetails(params, parent); @@ -118,6 +115,8 @@ const EditSurveyPageComponent = withConnectedEditor( // need to explicity state the path here because using '../../' doesn't apply the search state const { to, newState } = useLinkToPreviousSearchState('/surveys'); + const openErrorModal = () => setErrorModalOpen(true); + const navigateBack = () => { navigate(to, { state: newState, @@ -125,7 +124,7 @@ const EditSurveyPageComponent = withConnectedEditor( resetEditorToDefaultState(); }; const handleSave = () => { - onSave(files, navigateBack); + onSave(files, navigateBack, openErrorModal); }; const { onEditWithTouched, onSaveWithTouched } = useValidationScroll( @@ -181,13 +180,6 @@ const EditSurveyPageComponent = withConnectedEditor( ? null : recordData?.surveyQuestions; - // on error, scroll to the error alert - useEffect(() => { - if (errorMessage && errorAlertRef.current) { - errorAlertRef.current.scrollIntoView({ behavior: 'smooth' }); - } - }, [errorMessage]); - return ( {isLoading && } - - {errorMessage} - +
+
+ setErrorModalOpen(false)} + error={error} + title="Survey error" + buttons={[ + { + text: 'Close', + onClick: () => setErrorModalOpen(false), + }, + ]} + /> ); }, diff --git a/packages/admin-panel/src/surveyResponse/Form.jsx b/packages/admin-panel/src/surveyResponse/Form.jsx index e69f9dcd61..a904216c30 100644 --- a/packages/admin-panel/src/surveyResponse/Form.jsx +++ b/packages/admin-panel/src/surveyResponse/Form.jsx @@ -21,7 +21,7 @@ export const Form = ({ surveyResponseId, onDismiss, onAfterMutate }) => { const [resubmitStatus, setResubmitStatus] = useState(MODAL_STATUS.INITIAL); const [selectedEntity, setSelectedEntity] = useState({}); - const [resubmitErrorMessage, setResubmitErrorMessage] = useState(null); + const [resubmitError, setResubmitError] = useState(null); const useResubmitResponse = () => { // Swap filesByQuestionCode to filesByUniqueFileName. @@ -42,7 +42,7 @@ export const Form = ({ surveyResponseId, onDismiss, onAfterMutate }) => { await resubmitResponse(); } catch (e) { setResubmitStatus(MODAL_STATUS.ERROR); - setResubmitErrorMessage(e.message); + setResubmitError(e); return; } setResubmitStatus(MODAL_STATUS.SUCCESS); @@ -50,7 +50,6 @@ export const Form = ({ surveyResponseId, onDismiss, onAfterMutate }) => { }); const { data, isLoading: isFetching, error: fetchError } = useGetExistingData(surveyResponseId); - const fetchErrorMessage = fetchError?.message; useEffect(() => { if (!data) { @@ -62,7 +61,7 @@ export const Form = ({ surveyResponseId, onDismiss, onAfterMutate }) => { const handleDismissError = () => { setResubmitStatus(MODAL_STATUS.INITIAL); - setResubmitErrorMessage(null); + setResubmitError(null); }; const onSetFormFile = (questionCode, file) => { @@ -115,7 +114,7 @@ export const Form = ({ surveyResponseId, onDismiss, onAfterMutate }) => { <> {!isFetching && !isResubmitSuccess && ( <> diff --git a/packages/admin-panel/src/theme/colors.js b/packages/admin-panel/src/theme/colors.js index 2051402cf3..094c93cb90 100644 --- a/packages/admin-panel/src/theme/colors.js +++ b/packages/admin-panel/src/theme/colors.js @@ -21,7 +21,7 @@ export const DARK_BLUE = '#328DE5'; export const DARK_GREEN = '#00972E'; export const LIGHT_BLUE = '#99D6FF'; -export const LIGHT_RED = '#FEE2E2'; +export const LIGHT_RED = '#F76853'; export const LIGHT_ORANGE = '#FFECE1'; // Greys (based on first 2 letters of hex code) diff --git a/packages/admin-panel/src/theme/theme.js b/packages/admin-panel/src/theme/theme.js index edd7167fb6..d9dd5f4ca4 100644 --- a/packages/admin-panel/src/theme/theme.js +++ b/packages/admin-panel/src/theme/theme.js @@ -20,7 +20,7 @@ const palette = { }, error: { main: COLORS.RED, - light: COLORS.LIGHT_RED, + light: `${COLORS.LIGHT_RED}1A`, // 10% opacity }, warning: { main: COLORS.RED, @@ -182,6 +182,21 @@ const overrides = { }, }, }, + MuiAlert: { + standardError: { + border: `1px solid ${COLORS.LIGHT_RED}`, + color: palette.text.primary, + paddingBlock: '0', + '& > .MuiAlert-icon > .MuiSvgIcon-root': { + padding: 0, + fontSize: '1rem', + marginBlockEnd: 0, + }, + }, + message: { + paddingBlock: '0.5rem', + }, + }, MuiCssBaseline: { '@global': { label: { diff --git a/packages/admin-panel/src/widgets/Modal/Modal.jsx b/packages/admin-panel/src/widgets/Modal/Modal.jsx index 59b48c8982..9a0eed83e2 100644 --- a/packages/admin-panel/src/widgets/Modal/Modal.jsx +++ b/packages/admin-panel/src/widgets/Modal/Modal.jsx @@ -22,14 +22,22 @@ export const Modal = ({ onClose, title, isLoading, - errorMessage, + error, buttons, ...muiDialogProps }) => { + const getModalTitle = () => { + if (error) { + return title || 'Error'; + } + return title; + }; + + const modalTitle = getModalTitle(); return ( - - + + {children} {buttons?.length > 0 && ( @@ -72,7 +80,7 @@ Modal.propTypes = { onClose: PropTypes.func.isRequired, title: PropTypes.string.isRequired, isLoading: PropTypes.bool, - errorMessage: PropTypes.string, + error: PropTypes.object, buttons: PropTypes.arrayOf( PropTypes.shape({ onClick: PropTypes.func.isRequired, @@ -90,6 +98,6 @@ Modal.propTypes = { Modal.defaultProps = { buttons: [], - errorMessage: '', + error: null, isLoading: false, }; diff --git a/packages/admin-panel/src/widgets/Modal/ModalContentProvider.jsx b/packages/admin-panel/src/widgets/Modal/ModalContentProvider.jsx index 9539a2677d..f4c2e6fe6e 100644 --- a/packages/admin-panel/src/widgets/Modal/ModalContentProvider.jsx +++ b/packages/admin-panel/src/widgets/Modal/ModalContentProvider.jsx @@ -5,10 +5,12 @@ import React from 'react'; import styled from 'styled-components'; -import { SmallAlert } from '@tupaia/ui-components'; +import { SmallAlert, TooltipIconButton } from '@tupaia/ui-components'; import Typography from '@material-ui/core/Typography'; +import HelpOutline from '@material-ui/icons/HelpOutline'; import PropTypes from 'prop-types'; import { DialogContent } from '@material-ui/core'; +import * as COLORS from '../../theme/colors'; const Content = styled(DialogContent)` text-align: left; @@ -23,34 +25,106 @@ const Content = styled(DialogContent)` `; const Heading = styled(Typography)` - margin-bottom: 18px; + margin-bottom: 1.1rem; + font-size: ${props => props.theme.typography.body1.fontSize}; `; -export const ModalContentProvider = ({ isLoading, errorMessage, children }) => { +const Alert = styled(SmallAlert).attrs({ + severity: 'error', + variant: 'standard', +})` + width: 100%; +`; + +const AlertWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: flex-end; + + & + & { + margin-block-start: 0.5rem; + } + + .MuiSvgIcon-root { + color: ${COLORS.LIGHT_RED}; + margin-block-end: 0.3rem; + } + .tooltip-icon:hover { + svg { + fill: ${COLORS.RED}; + } + } +`; + +const ErrorsWrapper = styled.div` + max-width: 25rem; + margin: 0 auto; +`; + +const Error = ({ message, details }) => { + return ( + + {details && } + {message} + + ); +}; + +Error.propTypes = { + message: PropTypes.string.isRequired, + details: PropTypes.string, +}; + +Error.defaultProps = { + details: null, +}; + +export const ModalContentProvider = ({ isLoading, error, children }) => { + const getHeading = () => { + if (!error) return null; + const { extraFields } = error; + if ( + !extraFields?.errorDetails?.errors?.length || + extraFields?.errorDetails?.errors?.length === 1 + ) + return 'The below error has occurred:'; + return 'The below errors have occurred:'; + }; + + const heading = getHeading(); + + const getErrorsToDisplay = () => { + if (!error) return []; + const { message, extraFields } = error; + if (!extraFields?.errorDetails?.errors?.length) return [{ message }]; + return extraFields.errorDetails.errors; + }; + + const errors = getErrorsToDisplay(); return ( {isLoading && 'Please be patient, this can take some time...'} - {!!errorMessage && ( - <> - An error has occurred. - - {errorMessage} - - + {error?.message && ( + + {heading} + {errors.map(({ message, extraFields }) => ( + + ))} + )} {/* If loading or error message, don't show children */} - {!isLoading && !errorMessage && children} + {!isLoading && !error && children} ); }; ModalContentProvider.propTypes = { isLoading: PropTypes.bool.isRequired, - errorMessage: PropTypes.string, + error: PropTypes.object, children: PropTypes.node, }; ModalContentProvider.defaultProps = { - errorMessage: null, + error: null, children: null, }; diff --git a/packages/central-server/src/apiV2/import/importSurveys/ConfigImporter/ConfigImporter.js b/packages/central-server/src/apiV2/import/importSurveys/ConfigImporter/ConfigImporter.js index 7573e2d408..44fb3a209e 100644 --- a/packages/central-server/src/apiV2/import/importSurveys/ConfigImporter/ConfigImporter.js +++ b/packages/central-server/src/apiV2/import/importSurveys/ConfigImporter/ConfigImporter.js @@ -12,14 +12,8 @@ import { processConditionConfig } from './processConditionConfig'; import { processAutocompleteConfig } from './processAutocompleteConfig'; import { processEntityConfig } from './processEntityConfig'; -const { - CODE_GENERATOR, - ARITHMETIC, - CONDITION, - AUTOCOMPLETE, - ENTITY, - PRIMARY_ENTITY, -} = ANSWER_TYPES; +const { CODE_GENERATOR, ARITHMETIC, CONDITION, AUTOCOMPLETE, ENTITY, PRIMARY_ENTITY } = + ANSWER_TYPES; export class ConfigImporter { parse = convertCellToJson; @@ -38,10 +32,11 @@ export class ConfigImporter { * * @param {number} rowIndex * @param {string} componentId + * @param {function} constructError * @throws {Error} */ - async add(rowIndex, componentId) { - await this.validator.validate(rowIndex); + async add(rowIndex, componentId, constructError) { + await this.validator.validate(rowIndex, constructError); this.rowIndexToComponentId[rowIndex] = componentId; } diff --git a/packages/central-server/src/apiV2/import/importSurveys/Validator/ConfigValidator/ConfigValidator.js b/packages/central-server/src/apiV2/import/importSurveys/Validator/ConfigValidator/ConfigValidator.js index b4df26ec72..4f18cfcb8d 100644 --- a/packages/central-server/src/apiV2/import/importSurveys/Validator/ConfigValidator/ConfigValidator.js +++ b/packages/central-server/src/apiV2/import/importSurveys/Validator/ConfigValidator/ConfigValidator.js @@ -19,11 +19,11 @@ export class ConfigValidator extends BaseValidator { this.constructorArgs = constructorArgs; } - async validate(rowIndex) { + async validate(rowIndex, constructError) { const questionType = this.getQuestion(rowIndex).type; const Validator = this.getValidator(questionType); - return new Validator(...this.constructorArgs).validate(rowIndex); + return new Validator(...this.constructorArgs).validate(rowIndex, constructError); } getValidator = questionType => { diff --git a/packages/central-server/src/apiV2/import/importSurveys/Validator/JsonFieldValidator.js b/packages/central-server/src/apiV2/import/importSurveys/Validator/JsonFieldValidator.js index a1dc706bca..aed4d19165 100644 --- a/packages/central-server/src/apiV2/import/importSurveys/Validator/JsonFieldValidator.js +++ b/packages/central-server/src/apiV2/import/importSurveys/Validator/JsonFieldValidator.js @@ -7,13 +7,17 @@ export class JsonFieldValidator extends BaseValidator { /** * @param {number} rowIndex + * @param {function} constructError */ - async validate(rowIndex) { + async validate(rowIndex, constructError) { const config = this.getConfig(rowIndex); const fieldValidators = this.getFieldValidators(rowIndex); const otherFieldValidators = this.getOtherFieldValidators(); - await new ObjectValidator(fieldValidators, otherFieldValidators).validate(config); + await new ObjectValidator(fieldValidators, otherFieldValidators).validate( + config, + constructError, + ); return true; } diff --git a/packages/central-server/src/apiV2/import/importSurveys/importSurveyQuestions.js b/packages/central-server/src/apiV2/import/importSurveys/importSurveyQuestions.js index 9c70756c1c..2226d31ef3 100644 --- a/packages/central-server/src/apiV2/import/importSurveys/importSurveyQuestions.js +++ b/packages/central-server/src/apiV2/import/importSurveys/importSurveyQuestions.js @@ -5,7 +5,13 @@ import xlsx from 'xlsx'; -import { DatabaseError, UploadError, ImportValidationError, ObjectValidator } from '@tupaia/utils'; +import { + DatabaseError, + UploadError, + ImportValidationError, + ObjectValidator, + MultiValidationError, +} from '@tupaia/utils'; import { deleteScreensForSurvey, deleteOrphanQuestions } from '../../../dataAccessors'; import { ANSWER_TYPES, NON_DATA_ELEMENT_ANSWER_TYPES } from '../../../database/models/Answer'; import { splitStringOnComma, splitOnNewLinesOrCommas } from '../../utilities'; @@ -103,169 +109,183 @@ export async function importSurveysQuestions({ models, file, survey, dataGroup, let currentScreen; let currentSurveyScreenComponent; let hasSeenDateOfDataQuestion = false; + const errors = []; // An array to hold all errors, allowing multiple errors to be thrown at once const questionCodes = []; // An array to hold all question codes, allowing duplicate checking const configImporter = new ConfigImporter(models, rows); const objectValidator = new ObjectValidator(constructQuestionValidators(models)); let hasPrimaryEntityQuestion = false; for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) { - const row = rows[rowIndex]; - const constructImportValidationError = (message, field) => - new ImportValidationError(message, excelRowNumber, field); + // Validate rows + try { + const row = rows[rowIndex]; - if (row.type === SURVEY_METADATA) { - await validateSurveyMetadataRow(rows, rowIndex, constructImportValidationError); - await processSurveyMetadataRow(models, rows, rowIndex, survey.id); - continue; - } + const questionObject = row; + const excelRowNumber = rowIndex + 2; // +2 to make up for header and 0 index - const questionObject = row; - const excelRowNumber = rowIndex + 2; // +2 to make up for header and 0 index + const constructImportValidationError = (message, field, { details }) => { + return new ImportValidationError(message, excelRowNumber, field, undefined, details); + }; - // Validate rows - await objectValidator.validate(questionObject, constructImportValidationError); - // Validate no duplicate codes - if ( - questionObject.code && - questionObject.code.length > 0 && - questionCodes.includes(questionObject.code) - ) { - throw new ImportValidationError('Question code is not unique', excelRowNumber); - } - // Validate no second primary entity question - if (questionObject.type === ANSWER_TYPES.PRIMARY_ENTITY) { - if (hasPrimaryEntityQuestion) { - throw new ImportValidationError( - `Only one ${ANSWER_TYPES.PRIMARY_ENTITY} question allowed`, - excelRowNumber, - ); + if (row.type === SURVEY_METADATA) { + await validateSurveyMetadataRow(rows, rowIndex, constructImportValidationError); + await processSurveyMetadataRow(models, rows, rowIndex, survey.id); + continue; } - hasPrimaryEntityQuestion = true; - } - questionCodes.push(questionObject.code); - - // Validate max one date of data/submission date question - if (questionObject.type === 'DateOfData' || questionObject.type === 'SubmissionDate') { - if (hasSeenDateOfDataQuestion) { - // Previously had another submission date question - throw new ImportValidationError( - 'Only one DateOfData/SubmissionDate question allowed', - excelRowNumber, + + await objectValidator.validate(questionObject, constructImportValidationError); + + // Validate no duplicate codes + if ( + questionObject.code && + questionObject.code.length > 0 && + questionCodes.includes(questionObject.code) + ) { + throw new ImportValidationError('Question code is not unique', excelRowNumber); + } + // Validate no second primary entity question + if (questionObject.type === ANSWER_TYPES.PRIMARY_ENTITY) { + if (hasPrimaryEntityQuestion) { + throw new ImportValidationError( + `Only one ${ANSWER_TYPES.PRIMARY_ENTITY} question allowed`, + excelRowNumber, + ); + } + hasPrimaryEntityQuestion = true; + } + questionCodes.push(questionObject.code); + + // Validate max one date of data/submission date question + if (questionObject.type === 'DateOfData' || questionObject.type === 'SubmissionDate') { + if (hasSeenDateOfDataQuestion) { + // Previously had another submission date question + throw new ImportValidationError( + 'Only one DateOfData/SubmissionDate question allowed', + excelRowNumber, + ); + } + hasSeenDateOfDataQuestion = true; + } + + // Extract question details from spreadsheet row + const { + code, + type, + name, + text, + questionLabel, + detail, + detailLabel, + options, + optionLabels, + optionColors, + newScreen, + visibilityCriteria, + validationCriteria, + optionSet, + hook, + } = questionObject; + + let dataElement; + if (!NON_DATA_ELEMENT_ANSWER_TYPES.includes(type)) { + dataElement = await updateOrCreateDataElementInGroup( + models, + code, + dataGroup, + permissionGroup, ); } - hasSeenDateOfDataQuestion = true; - } - // Extract question details from spreadsheet row - const { - code, - type, - name, - text, - questionLabel, - detail, - detailLabel, - options, - optionLabels, - optionColors, - newScreen, - visibilityCriteria, - validationCriteria, - optionSet, - hook, - } = questionObject; - - let dataElement; - if (!NON_DATA_ELEMENT_ANSWER_TYPES.includes(type)) { - dataElement = await updateOrCreateDataElementInGroup( - models, + // Compose question based on details from spreadsheet + const questionToUpsert = { code, - dataGroup, - permissionGroup, - ); - } + type, + name, + text, + detail, + hook, + options: processOptions(options, optionLabels, optionColors, type), + option_set_id: await processOptionSetName(models, optionSet, excelRowNumber, tabName), + data_element_id: dataElement && dataElement.id, + }; - // Compose question based on details from spreadsheet - const questionToUpsert = { - code, - type, - name, - text, - detail, - hook, - options: processOptions(options, optionLabels, optionColors, type), - option_set_id: await processOptionSetName(models, optionSet, excelRowNumber, tabName), - data_element_id: dataElement && dataElement.id, - }; - - // Either create or update the question depending on if there exists a matching code - let question; - if (code) { - question = await models.question.updateOrCreate({ code }, questionToUpsert); - } else { - // No code in spreadsheet, can't match so just create a new question - question = await models.question.create(questionToUpsert); - } + // Either create or update the question depending on if there exists a matching code + let question; + if (code) { + question = await models.question.updateOrCreate({ code }, questionToUpsert); + } else { + // No code in spreadsheet, can't match so just create a new question + question = await models.question.create(questionToUpsert); + } - // Generate the screen and screen component - const shouldStartNewScreen = caseAndSpaceInsensitiveEquals(newScreen, 'yes'); - if (!currentScreen || shouldStartNewScreen) { - // Spreadsheet indicates this question starts a new screen - // Create a new survey screen - currentScreen = await models.surveyScreen.create({ - survey_id: survey.id, - screen_number: currentScreen ? currentScreen.screen_number + 1 : 1, // Next screen - }); - // Clear existing survey screen component - currentSurveyScreenComponent = undefined; - } + // Generate the screen and screen component + const shouldStartNewScreen = caseAndSpaceInsensitiveEquals(newScreen, 'yes'); + if (!currentScreen || shouldStartNewScreen) { + // Spreadsheet indicates this question starts a new screen + // Create a new survey screen + currentScreen = await models.surveyScreen.create({ + survey_id: survey.id, + screen_number: currentScreen ? currentScreen.screen_number + 1 : 1, // Next screen + }); + // Clear existing survey screen component + currentSurveyScreenComponent = undefined; + } - // Create a new survey screen component to display this question - const visibilityCriteriaObject = await convertCellToJson( - visibilityCriteria, - splitStringOnComma, - ); - const processedVisibilityCriteria = {}; - await Promise.all( - Object.entries(visibilityCriteriaObject).map(async ([questionCode, answers]) => { - if (questionCode === VIS_CRITERIA_CONJUNCTION) { - // This is the special _conjunction key, extract the 'and' or the 'or' from answers, - // i.e. { conjunction: ['and'] } -> { conjunction: 'and' } - const [conjunctionType] = answers; - processedVisibilityCriteria[VIS_CRITERIA_CONJUNCTION] = conjunctionType; - } else if (questionCode === 'hidden') { - processedVisibilityCriteria.hidden = answers[0] === 'true'; - } else { - const { id: questionId } = await models.question.findOne({ - code: questionCode, - }); - processedVisibilityCriteria[questionId] = answers; - } - }), - ); + // Create a new survey screen component to display this question + const visibilityCriteriaObject = await convertCellToJson( + visibilityCriteria, + splitStringOnComma, + ); + const processedVisibilityCriteria = {}; + await Promise.all( + Object.entries(visibilityCriteriaObject).map(async ([questionCode, answers]) => { + if (questionCode === VIS_CRITERIA_CONJUNCTION) { + // This is the special _conjunction key, extract the 'and' or the 'or' from answers, + // i.e. { conjunction: ['and'] } -> { conjunction: 'and' } + const [conjunctionType] = answers; + processedVisibilityCriteria[VIS_CRITERIA_CONJUNCTION] = conjunctionType; + } else if (questionCode === 'hidden') { + processedVisibilityCriteria.hidden = answers[0] === 'true'; + } else { + const { id: questionId } = await models.question.findOne({ + code: questionCode, + }); + processedVisibilityCriteria[questionId] = answers; + } + }), + ); - currentSurveyScreenComponent = await models.surveyScreenComponent.create({ - screen_id: currentScreen.id, - question_id: question.id, - component_number: currentSurveyScreenComponent - ? currentSurveyScreenComponent.component_number + 1 - : 1, - visibility_criteria: JSON.stringify(processedVisibilityCriteria), - validation_criteria: JSON.stringify( - convertCellToJson(validationCriteria, processValidationCriteriaValue), - ), - question_label: questionLabel, - detail_label: detailLabel, - }); + currentSurveyScreenComponent = await models.surveyScreenComponent.create({ + screen_id: currentScreen.id, + question_id: question.id, + component_number: currentSurveyScreenComponent + ? currentSurveyScreenComponent.component_number + 1 + : 1, + visibility_criteria: JSON.stringify(processedVisibilityCriteria), + validation_criteria: JSON.stringify( + convertCellToJson(validationCriteria, processValidationCriteriaValue), + ), + question_label: questionLabel, + detail_label: detailLabel, + }); - try { const componentId = currentSurveyScreenComponent.id; - await configImporter.add(rowIndex, componentId); - } catch (error) { - const validationError = constructImportValidationError(error.message, 'config'); - throw validationError; + await configImporter.add(rowIndex, componentId, constructImportValidationError); + } catch (e) { + errors.push(e); + continue; } } + if (errors.length > 0) { + throw new MultiValidationError( + 'Errors occurred while importing questions', + errors.map(({ message, extraFields }) => ({ + message, + extraFields, + })), + ); + } + await configImporter.import(); // Clear any orphaned questions (i.e. questions no longer included in a survey) diff --git a/packages/lesmis/src/views/AdminPanel/components/RejectButton.jsx b/packages/lesmis/src/views/AdminPanel/components/RejectButton.jsx index 1b7c51f31f..676c53e15c 100644 --- a/packages/lesmis/src/views/AdminPanel/components/RejectButton.jsx +++ b/packages/lesmis/src/views/AdminPanel/components/RejectButton.jsx @@ -25,12 +25,12 @@ const ConfirmModalHeading = styled(Typography).attrs({ margin-bottom: 0.5rem; `; -const RejectConfirmModal = ({ isOpen, onClose, onConfirm, errorMessage, translate, isLoading }) => ( +const RejectConfirmModal = ({ isOpen, onClose, onConfirm, error, translate, isLoading }) => ( { @@ -105,7 +105,7 @@ export const getRejectButton = translate => { isOpen={isOpen} onClose={() => setIsOpen(false)} onConfirm={() => handleClickReject(props)} - errorMessage={error?.message} + error={error} translate={translate} isLoading={isLoading} /> diff --git a/packages/ui-components/src/components/Alert.tsx b/packages/ui-components/src/components/Alert.tsx index 6a3aa56a32..6ace97a69f 100644 --- a/packages/ui-components/src/components/Alert.tsx +++ b/packages/ui-components/src/components/Alert.tsx @@ -7,6 +7,7 @@ import React, { forwardRef } from 'react'; import MuiAlert, { AlertProps } from '@material-ui/lab/Alert'; import styled from 'styled-components'; import { CheckCircle, Warning } from '@material-ui/icons'; +import { Error } from './Icons'; const StyledAlert = styled(MuiAlert)` border-radius: 0; @@ -22,7 +23,6 @@ const StyledAlert = styled(MuiAlert)` &.MuiAlert-standardError { background: ${props => props.theme.palette.error.light}; - color: ${props => props.theme.palette.error.main}; } `; @@ -33,7 +33,7 @@ export const Alert = forwardRef( severity = 'success', iconMapping = { success: , - error: , + error: , warning: , }, ...props @@ -53,7 +53,8 @@ export const Alert = forwardRef( const StyledSmallAlert = styled(StyledAlert)` font-size: 0.875rem; border-radius: 3px; - padding: 0.5rem 1.25rem 0.5rem 1rem; + padding-block: 0; + padding-inline: 1rem; box-shadow: none; .MuiAlert-icon { @@ -69,7 +70,7 @@ export const SmallAlert = ({ iconMapping = { success: , - error: , + error: , warning: , }, diff --git a/packages/ui-components/src/components/Icons/Error.tsx b/packages/ui-components/src/components/Icons/Error.tsx new file mode 100644 index 0000000000..71cf56c5c9 --- /dev/null +++ b/packages/ui-components/src/components/Icons/Error.tsx @@ -0,0 +1,30 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import React from 'react'; +import { SvgIcon, SvgIconProps } from '@material-ui/core'; + +export const Error = (props: SvgIconProps) => ( + + + + + +); diff --git a/packages/ui-components/src/components/Icons/index.ts b/packages/ui-components/src/components/Icons/index.ts index f1013ab2f8..afbf19d396 100644 --- a/packages/ui-components/src/components/Icons/index.ts +++ b/packages/ui-components/src/components/Icons/index.ts @@ -12,4 +12,5 @@ export * from './WarningCloud'; export * from './Virus'; export * from './TupaiaIcon'; export * from './ExportIcon'; +export * from './Error'; export * from './FilePicker'; diff --git a/packages/ui-components/src/components/TooltipIconButton.tsx b/packages/ui-components/src/components/TooltipIconButton.tsx new file mode 100644 index 0000000000..3d119498da --- /dev/null +++ b/packages/ui-components/src/components/TooltipIconButton.tsx @@ -0,0 +1,57 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + */ + +import React, { ComponentType } from 'react'; +import { InfoOutlined } from '@material-ui/icons'; +import styled from 'styled-components'; +import { Tooltip as BaseTooltip } from './Tooltip'; + +const TooltipWrapper = styled.span` + pointer-events: auto; + cursor: pointer; + margin-left: 0.4em; + border: 1px solid transparent; + display: flex; + align-items: center; + height: 100%; + width: 1rem; + svg { + height: 100%; + width: 100%; + } + &:hover, + &:focus { + svg { + fill: ${props => props.theme.palette.primary.main}; + } + } +`; + +const Tooltip = styled(BaseTooltip)` + & .MuiTooltip-tooltip { + background-color: ${props => props.theme.palette.text.primary}; + border-radius: 3px; + font-weight: ${props => props.theme.typography.fontWeightRegular}; + font-size: 0.69rem; + .MuiTooltip-arrow { + color: ${props => props.theme.palette.text.primary}; + } + } +`; + +interface TooltipIconButtonProps { + tooltip: string; + Icon?: ComponentType; +} + +export const TooltipIconButton = ({ tooltip, Icon = InfoOutlined }: TooltipIconButtonProps) => { + return ( + + + + + + ); +}; diff --git a/packages/ui-components/src/components/index.ts b/packages/ui-components/src/components/index.ts index a325f03fb1..c3a5dfd092 100644 --- a/packages/ui-components/src/components/index.ts +++ b/packages/ui-components/src/components/index.ts @@ -46,4 +46,5 @@ export * from './Toast'; export * from './Toolbar'; export * from './Tooltip'; export * from './UserMessage'; +export * from './TooltipIconButton'; export * from './Pagination'; diff --git a/packages/utils/src/errors.js b/packages/utils/src/errors.js index b54fdeed98..91649da2e2 100644 --- a/packages/utils/src/errors.js +++ b/packages/utils/src/errors.js @@ -34,7 +34,11 @@ export class HttpError extends Error { export class DatabaseError extends RespondingError { constructor(message, originalError) { - super(`Database error: ${message}${originalError ? ` - ${originalError.message}` : ''}`, 500); + super( + `Database error: ${message}${originalError ? ` - ${originalError.message}` : ''}`, + 500, + originalError.extraFields, + ); } } @@ -128,8 +132,9 @@ export class UploadError extends RespondingError { } export class ValidationError extends RespondingError { - constructor(message) { - super(message, 400); + constructor(message, details = null) { + const extraFields = details ? { details } : null; + super(message, 400, extraFields); } } @@ -154,14 +159,15 @@ export class TypeValidationError extends ValidationError { } export class ImportValidationError extends ValidationError { - constructor(message, rowNumber = undefined, field = undefined, tabName = undefined) { - const errorMessage = - 'Import failed' + - `${tabName !== undefined ? ` in tab ${tabName}` : ''}` + - `${rowNumber !== undefined ? ` at row ${rowNumber}` : ''}` + - `${field !== undefined ? ` on the field '${field}'` : ''}` + - ` with the message '${message}'`; - super(errorMessage); + constructor(message, rowNumber = undefined, field = undefined, tabName = undefined, details) { + const tabError = tabName !== undefined ? `Tab ${tabName}` : ''; + const rowError = rowNumber !== undefined ? `Row ${rowNumber}` : ''; + const fieldError = field !== undefined ? `field '${field}'` : ''; + + const errors = [tabError, rowError, fieldError].filter(e => e).join(', '); + + const errorMessage = `${errors}. ${message}`; + super(errorMessage, details); } } @@ -191,10 +197,10 @@ export class CustomError extends RespondingError { ...jsonFields, ...extraJsonFields, }; - const { responseText, responseStatus, description } = json; + const { responseText, responseStatus, description, ...restOfJson } = json; const { status, details } = responseText; const errorMessage = status || responseText; - super(errorMessage, responseStatus, { description, status, details }); + super(errorMessage, responseStatus, { description, status, details, ...restOfJson }); } } diff --git a/packages/utils/src/request.js b/packages/utils/src/request.js index 6246a19c64..b34cb3eeb7 100644 --- a/packages/utils/src/request.js +++ b/packages/utils/src/request.js @@ -92,12 +92,15 @@ export const asynchronouslyFetchValuesForObject = async objectSpecification => { return returnObject; }; -const throwCustomError = (status, errorMessage) => { +const throwCustomError = (status, errorMessage, errorDetails) => { const statusCode = status || 500; - throw new CustomError({ - responseStatus: statusCode, - responseText: errorMessage, - }); + throw new CustomError( + { + responseStatus: statusCode, + responseText: errorMessage, + }, + { errorDetails }, + ); }; export const verifyResponseStatus = async response => { @@ -116,7 +119,8 @@ export const verifyResponseStatus = async response => { throwCustomError(response.status, responseJson.message); } if (responseJson.error) { - throwCustomError(response.status, responseJson.error); + const { error: errorMessage, ...restOfError } = responseJson; + throwCustomError(response.status, errorMessage, restOfError); } } }; diff --git a/packages/utils/src/validation/ObjectValidator.js b/packages/utils/src/validation/ObjectValidator.js index 548fa99eba..d0d9d4d8d3 100644 --- a/packages/utils/src/validation/ObjectValidator.js +++ b/packages/utils/src/validation/ObjectValidator.js @@ -68,7 +68,7 @@ async function runValidators(validators, object, fieldKey, constructError) { await validators[j](object[fieldKey], object, fieldKey); } catch (error) { throw constructError - ? constructError(error.message, fieldKey) + ? constructError(error.message, fieldKey, error.extraFields || {}) : new ValidationError( `Invalid content for field "${fieldKey}" causing message "${error.message}"`, ); @@ -82,7 +82,7 @@ function runValidatorsSync(validators, object, fieldKey, constructError) { validators[j](object[fieldKey], object, fieldKey); } catch (error) { throw constructError - ? constructError(error.message, fieldKey) + ? constructError(error.message, fieldKey, error.details) : new ValidationError( `Invalid content for field "${fieldKey}" causing message "${error.message}"`, ); diff --git a/packages/utils/src/validation/validatorFunctions.js b/packages/utils/src/validation/validatorFunctions.js index 1e1e782feb..965a86871a 100644 --- a/packages/utils/src/validation/validatorFunctions.js +++ b/packages/utils/src/validation/validatorFunctions.js @@ -157,7 +157,8 @@ export const isValidPassword = password => { export const constructIsOneOf = options => value => { if (!options.includes(value)) { throw new ValidationError( - `${value} is not an accepted value. Accepted values: "${options.join('", "')}"`, + `${value} is not an accepted value.`, + `Accepted values: "${options.join('", "')}"`, ); } }; From f9c1c2e14c5eaea02859672908bb99638f3f46e0 Mon Sep 17 00:00:00 2001 From: Jasper Lai <33956381+jaskfla@users.noreply.github.com> Date: Fri, 19 Jul 2024 09:19:32 +1200 Subject: [PATCH 6/7] feat(adminPanel): RN-1248: Drag & drop file upload (#5713) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * experiment: zero-dep component * add dropzone * use dropzone * fix label styling * remove unused code * fix tooltip alignment * icon colour * default to single file again * show errors when file rejected * name and ID * remove unused variable * remove usages of retired props * fix undefined var * working implementation! * fix spacing semantics * dynamic clear selection button * spacing & letterspacing tweaks * add/remove files individually * more consistent `transition-duration` * remove `console.log` Co-authored-by: alexd-bes <129009580+alexd-bes@users.noreply.github.com> * remove use of `isDragReject` * comment implementation idiosyncrasies * clearer symbol name for icon * simplify CSS * fix `onDropAccepted` using old state * fix `ImportModal`’s `onChange` * fix file removal * use new change handler signature * update stories * fix stories * finish merge * fix looks-like-file-is-selected mode * fix supported files functionality * add missing `name` prop * fix component in review/response screens * remove rougue console log * initialise variable closer to usage * no fractional bytes * re-remove unused parameters * workaround `TypeError` in Chromium * fix import modal uploads * unwrap redundant `Fragment` * fix `accept` format for file picker * echo fix in storybook * fix typing (restore `labelProps`) --------- Co-authored-by: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Co-authored-by: Andrew --- .../src/importExport/ImportModal.jsx | 23 +- .../resources/editSurvey/EditSurveyPage.jsx | 10 +- .../src/surveyResponse/FileQuestionField.jsx | 1 + .../widgets/InputField/CheckboxListField.jsx | 10 +- .../widgets/InputField/FileUploadField.jsx | 48 +-- .../src/features/Questions/FileQuestion.tsx | 11 +- packages/ui-components/package.json | 1 + .../src/components/Inputs/FileUploadField.tsx | 362 ++++++++++++------ .../src/components/Inputs/InputLabel.tsx | 12 +- .../src/components/Modal/ImportModal.jsx | 20 +- .../stories/inputs/fileUploadField.stories.js | 52 +-- yarn.lock | 30 ++ 12 files changed, 332 insertions(+), 248 deletions(-) diff --git a/packages/admin-panel/src/importExport/ImportModal.jsx b/packages/admin-panel/src/importExport/ImportModal.jsx index c2a5d1feb2..4346a40ca1 100644 --- a/packages/admin-panel/src/importExport/ImportModal.jsx +++ b/packages/admin-panel/src/importExport/ImportModal.jsx @@ -1,6 +1,6 @@ -/** - * Tupaia MediTrak - * Copyright (c) 2018 Beyond Essential Systems Pty Ltd +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ import React, { useState } from 'react'; @@ -37,7 +37,6 @@ export const ImportModalComponent = React.memo( getFinishedMessage, confirmButtonText, cancelButtonText, - uploadButtonText, }) => { const api = useApiContext(); const [status, setStatus] = useState(STATUS.IDLE); @@ -46,7 +45,6 @@ export const ImportModalComponent = React.memo( const [isOpen, setIsOpen] = useState(false); const [values, setValues] = useState({}); const [files, setFiles] = useState([]); - const [fileName, setFileName] = useState(null); const handleOpen = () => setIsOpen(true); @@ -62,7 +60,6 @@ export const ImportModalComponent = React.memo( setError(null); setFinishedMessage(null); setFiles([]); - setFileName(null); }; const handleClose = () => { @@ -72,7 +69,6 @@ export const ImportModalComponent = React.memo( setIsOpen(false); setValues({}); setFiles([]); - setFileName(null); }; const handleSubmit = async event => { @@ -157,15 +153,6 @@ export const ImportModalComponent = React.memo( const buttons = getButtons(); - const onChangeFile = (event, newName) => { - setFileName(newName); - if (event?.target?.files?.length > 0) { - setFiles(Array.from(event.target.files)); - } else { - setFiles([]); - } - }; - return ( <> setFiles(newFiles ?? [])} name="file-upload" - fileName={fileName} multiple={actionConfig.multiple} - textOnButton={uploadButtonText} /> )} diff --git a/packages/admin-panel/src/pages/resources/editSurvey/EditSurveyPage.jsx b/packages/admin-panel/src/pages/resources/editSurvey/EditSurveyPage.jsx index 603e7c6d1d..bce2bbc0ea 100644 --- a/packages/admin-panel/src/pages/resources/editSurvey/EditSurveyPage.jsx +++ b/packages/admin-panel/src/pages/resources/editSurvey/EditSurveyPage.jsx @@ -11,7 +11,7 @@ import styled from 'styled-components'; import { Button, SpinningLoader } from '@tupaia/ui-components'; import { Breadcrumbs } from '../../../layout'; import { useItemDetails } from '../../../api/queries/useResourceDetails'; -import { withConnectedEditor, useValidationScroll } from '../../../editor'; +import { useValidationScroll, withConnectedEditor } from '../../../editor'; import { useEditFiles } from '../../../editor/useEditFiles'; import { FileUploadField } from '../../../widgets/InputField/FileUploadField'; import { FieldsEditor } from '../../../editor/FieldsEditor'; @@ -201,8 +201,12 @@ const EditSurveyPageComponent = withConnectedEditor( onChange={({ fileName, file }) => handleSetFormFile('surveyQuestions', { fileName, file }) } - accept=".xlsx,.xls,.csv" - initialFileName={initialFileName} + accept={{ + 'application/vnd.ms-excel': ['.xls'], + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], + 'text/csv': ['.csv'], + }} + fileName={initialFileName} label="Survey questions" />
diff --git a/packages/admin-panel/src/surveyResponse/FileQuestionField.jsx b/packages/admin-panel/src/surveyResponse/FileQuestionField.jsx index 043ea5755a..612e97ddd6 100644 --- a/packages/admin-panel/src/surveyResponse/FileQuestionField.jsx +++ b/packages/admin-panel/src/surveyResponse/FileQuestionField.jsx @@ -80,6 +80,7 @@ const AttachModal = ({ isOpen, onClose, maxSizeInBytes, onAttachFile, title }) = label="Select a file" showFileSize maxSizeInBytes={maxSizeInBytes} + name="file-question-field" onChange={handleSelectFile} />
diff --git a/packages/admin-panel/src/widgets/InputField/CheckboxListField.jsx b/packages/admin-panel/src/widgets/InputField/CheckboxListField.jsx index af992b028f..7c2fd144b5 100644 --- a/packages/admin-panel/src/widgets/InputField/CheckboxListField.jsx +++ b/packages/admin-panel/src/widgets/InputField/CheckboxListField.jsx @@ -181,15 +181,7 @@ export const CheckboxListField = ({ return ( - + diff --git a/packages/admin-panel/src/widgets/InputField/FileUploadField.jsx b/packages/admin-panel/src/widgets/InputField/FileUploadField.jsx index 31086b4cdc..34241c0151 100644 --- a/packages/admin-panel/src/widgets/InputField/FileUploadField.jsx +++ b/packages/admin-panel/src/widgets/InputField/FileUploadField.jsx @@ -1,48 +1,26 @@ /* * Tupaia - * Copyright (c) 2017 - 2020 Beyond Essential Systems Pty Ltd + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ -import React, { useEffect, useState } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; import { FileUploadField as BaseFileUploadField } from '@tupaia/ui-components'; export const FileUploadField = ({ + accept, onChange, name, label, helperText, - textOnButton, - showFileSize, + fileName, maxSizeInBytes, - initialFileName = null, - accept = '*', }) => { - const [fileName, setFileName] = useState(initialFileName); - - const handleChange = async (event, newFileName, files) => { - setFileName(newFileName); - + const handleChange = async files => { const [file] = files || []; - if (!file) { - onChange({ - fileName: null, - file: null, - }); - return; - } - - onChange({ - fileName: newFileName, - file, - }); + onChange(file ? { fileName: file.name, file } : { fileName: null, file: null }); }; - // Allows programmatic setting of the file name - useEffect(() => { - setFileName(initialFileName); - }, [initialFileName]); - return ( @@ -63,20 +39,16 @@ FileUploadField.propTypes = { name: PropTypes.string.isRequired, label: PropTypes.string, helperText: PropTypes.string, - textOnButton: PropTypes.string, - showFileSize: PropTypes.bool, maxSizeInBytes: PropTypes.number, - initialFileName: PropTypes.string, - accept: PropTypes.string, + fileName: PropTypes.string, + accept: PropTypes.objectOf(PropTypes.arrayOf(PropTypes.string)), }; FileUploadField.defaultProps = { onChange: () => {}, label: null, helperText: null, - textOnButton: null, - showFileSize: false, maxSizeInBytes: null, - initialFileName: null, - accept: '*', + fileName: null, + accept: null, }; diff --git a/packages/datatrak-web/src/features/Questions/FileQuestion.tsx b/packages/datatrak-web/src/features/Questions/FileQuestion.tsx index 978c650f33..86fb30e7f9 100644 --- a/packages/datatrak-web/src/features/Questions/FileQuestion.tsx +++ b/packages/datatrak-web/src/features/Questions/FileQuestion.tsx @@ -1,6 +1,6 @@ /* * Tupaia - * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ import React from 'react'; @@ -8,6 +8,7 @@ import styled from 'styled-components'; import { FileUploadField } from '@tupaia/ui-components'; import { SurveyQuestionInputProps } from '../../types'; import { InputHelperText } from '../../components'; +import { useSurveyForm } from '../Survey'; const MAX_FILE_SIZE_BYTES = 20 * 1024 * 1024; // 20 MB @@ -47,8 +48,10 @@ export const FileQuestion = ({ detailLabel, controllerProps: { onChange, value: selectedFile, name }, }: SurveyQuestionInputProps) => { - const handleChange = async (_e, _name, files) => { - if (!files) { + const { isResponseScreen, isReviewScreen } = useSurveyForm(); + + const handleChange = async (files: File[] | FileList | null) => { + if (!files || files.length === 0) { onChange(null); return; } @@ -70,9 +73,9 @@ export const FileQuestion = ({ label={label!} helperText={detailLabel!} maxSizeInBytes={MAX_FILE_SIZE_BYTES} - showFileSize FormHelperTextComponent={InputHelperText} required={required} + disabled={isResponseScreen || isReviewScreen} /> ); diff --git a/packages/ui-components/package.json b/packages/ui-components/package.json index 2cf0f58778..4002fce439 100644 --- a/packages/ui-components/package.json +++ b/packages/ui-components/package.json @@ -44,6 +44,7 @@ "react": "^16.13.1", "react-ace": "^10.1.0", "react-dom": "^16.13.1", + "react-dropzone": "^14.2.3", "react-hook-form": "^6.15.1", "react-router-dom": "^5.1.2", "react-router-dom-v6": "npm:react-router-dom@6.3.0", diff --git a/packages/ui-components/src/components/Inputs/FileUploadField.tsx b/packages/ui-components/src/components/Inputs/FileUploadField.tsx index ef4d078a5c..a1cf6d9d99 100644 --- a/packages/ui-components/src/components/Inputs/FileUploadField.tsx +++ b/packages/ui-components/src/components/Inputs/FileUploadField.tsx @@ -1,172 +1,290 @@ /* * Tupaia - * Copyright (c) 2017 - 2020 Beyond Essential Systems Pty Ltd + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ -import React, { useRef, useState } from 'react'; -import { FormLabel } from '@material-ui/core'; -import styled from 'styled-components'; -import MuiFormHelperText from '@material-ui/core/FormHelperText'; +import React, { useEffect, useState } from 'react'; +import styled, { css } from 'styled-components'; +import { FormHelperText, useTheme } from '@material-ui/core'; +import { useDropzone } from 'react-dropzone'; +import { FilePicker as FilePickerIcon } from '../Icons'; import { Button } from '../Button'; -import { FlexStart } from '../Layout'; -import { FilePicker } from '../Icons'; import { InputLabel } from './InputLabel'; -const HiddenFileInput = styled.input` - display: none; // Hide the input element without applying other styles - setting it to be small and position absolute causes the form to crash when the input is clicked +const LabelAndTooltip = styled.div` + align-items: center; + display: flex; + margin-block-end: 0.25rem; `; -const FileNameAndFileSize = styled.span` - font-size: ${props => props.theme.typography.body2.fontSize}; +const Uploader = styled.div` + border-radius: 0.1875rem; + border: 0.0625rem dashed ${({ theme }) => theme.palette.grey['400']}; + > * { + padding-block: 0.875rem; + padding-inline: 1.1rem; + } +`; + +const Dropzone = styled.div<{ $isDragActive: boolean; $isDragReject: boolean }>` + cursor: pointer; + flex-direction: column; + inline-size: 100%; + padding-block-start: 1.25rem; + text-align: center; + transition: background-color 100ms ease; + + :hover, + :active, + :focus-visible { + background-color: #f4f9ff; + } + + ${({ $isDragActive }) => { + if ($isDragActive) + return css` + background-color: #f4f9ff; + `; + }} +`; + +const PrimaryLabel = styled.p` + font-size: ${({ theme }) => theme.typography.body2.fontSize}; + font-weight: 500; +`; + +const SecondaryLabel = styled.p` + color: ${({ theme }) => theme.palette.grey['600']}; + font-size: 0.6875rem; + font-weight: 400; + letter-spacing: 0.03333em; +`; + +const ChooseFileButton = styled.span` + color: ${({ theme }) => theme.palette.primary.main}; + &:hover { + text-decoration: underline; + } +`; + +const SelectedFileList = styled.ul<{ $doesNeedBorder: boolean }>` + list-style-type: none; + margin: 0; + + /* + * Workaround for accessibility issue with VoiceOver. + * See https://gerardkcohen.me/writing/2017/voiceover-list-style-type.html + */ + li::before { + content: '\\200B'; /* zero-width space */ + } + + // If top border is always shown, it collides with the top-border of the parent when file + // dropzone is hidden + ${({ $doesNeedBorder }) => + $doesNeedBorder && + css` + border-block-start: 0.0625rem dashed ${({ theme }) => theme.palette.grey['400']}; + `} `; -const FileUploadContainer = styled(FlexStart)` - margin-top: 1rem; +const SelectedFileListItem = styled.li` + font-size: ${({ theme }) => theme.typography.body2.fontSize}; + line-height: 1.2rem; // Match .MuiInputBase-input `; const RemoveButton = styled(Button).attrs({ variant: 'text', color: 'default', })` + display: inline-block; font-weight: 400; - text-decoration: underline; - padding: 0; - margin-left: 0.8rem; + margin-block: 0; + padding-block: 0; + padding-inline: 1.1rem; + transition: color 100ms ease; .MuiButton-label { - font-size: 0.7rem; + text-decoration: underline; + font-size: 0.6875rem; + } + &:hover { + background-color: transparent; + color: ${({ theme }) => theme.palette.primary.main}; } `; const humanFileSize = (sizeInBytes: number) => { const i = sizeInBytes === 0 ? 0 : Math.floor(Math.log(sizeInBytes) / Math.log(1024)); - return `${(sizeInBytes / 1024 ** i).toFixed(2)} ${['B', 'kB', 'MB', 'GB', 'TB'][i]}`; + const valueAsFloat = (sizeInBytes / 1024 ** i).toFixed(2); + const unit = ['B', 'kB', 'MB', 'GB', 'TB'][i]; + const value = unit === 'B' ? sizeInBytes : valueAsFloat; + + return ( + <> + {value} {unit} + + ); }; -interface FileUploadFieldProps { - onChange: ( - event: React.ChangeEvent | null, - fileName?: string, - files?: FileList | null | undefined, - ) => void; +export interface FileUploadFieldProps { + /** + * The parent of {@link FileUploadField} manages state for which files are staged to be uploaded. + * This change handler propagates state changes in the {@link FileUploadField} to its parent. + */ + onChange: (files: File[] | FileList | null) => void; name: string; - fileName: string; + /** + * In some places, such as DataTrak’s survey review screen, we use a read-only (disabled) version + * of the {@link FileUploadField} with a “pre-selected“ file. Since ``’s value cannot be + * programmatically set (except to ''), we simply show a filename so it appears as if a file is + * selected. + */ + fileName?: string; multiple?: boolean; - textOnButton?: string; label?: string; tooltip?: string; helperText?: string; - showFileSize?: boolean; maxSizeInBytes?: number; FormHelperTextComponent?: React.ElementType; required?: boolean; - buttonVariant?: 'text' | 'outlined' | 'contained'; - accept?: string; + accept?: Record; + /** + * Puts this component in a read-only mode. This hides the dropzone entirely. Use this prop in + * tandem with the `fileName` prop to programmatically show a `FileUploadField` that looks like it + * has a file selected. + */ + disabled?: boolean; } export const FileUploadField = ({ - onChange = () => {}, - name, - fileName, - multiple = false, - textOnButton, - label, - tooltip, + onChange, + FormHelperTextComponent = 'p', + accept, helperText, - showFileSize = false, + label, maxSizeInBytes, - FormHelperTextComponent = 'p', - required, - buttonVariant = 'contained', - accept = '*', + multiple = false, + name, + required = false, + tooltip, + disabled = false, + fileName, }: FileUploadFieldProps) => { - const inputEl = useRef(null); - const text = textOnButton || `Choose file${multiple ? 's' : ''}`; - - const [error, setError] = useState(null); - const [sizeInBytes, setSizeInBytes] = useState(null); - - const handleChange = (event: React.ChangeEvent) => { - let newName; - const input = inputEl.current; - - if (!input || !input.files) return; - - if (input.files.length > 1) { - newName = `${input.files.length} files selected`; - } else { - newName = event.target.value.split('\\').pop(); - } - - if (maxSizeInBytes) { - for (const file of Array.from(input.files)) { - const { size: newSizeInBytes } = file; - if (newSizeInBytes > maxSizeInBytes) { - setSizeInBytes(null); - setError( - `Error: file is too large: ${humanFileSize( - newSizeInBytes, - )}. Max file size: ${humanFileSize(maxSizeInBytes)}`, - ); - onChange(event, undefined, null); - return; - } - } - } - - if (multiple) { - onChange(event, newName, input.files); - // We don't support file size label if multiple - } else { - const [file] = Array.from(input.files); - setSizeInBytes(file.size); - onChange(event, newName, input.files); - } - setError(null); + if (disabled) + return ( + + + + {fileName ?? File uploader disabled} + Remove + + + + ); + + /** + * `useDropzone` can provide an `acceptedFiles` array, but it provides no way to programmatically + * add/remove elements. We manage file selection state manually for some custom behaviour: + * + * 1. dropping files ADDS to the selection (rather than replacing it); and + * 2. files may be removed individually from the selection. + * + * This array uses set semantics. See {@link onDropAccepted}. + */ + const [files, setFiles] = useState([]); + const hasFileSelected = files.length > 0; + + /** + * Unions the newly selected files with the existing selection. + * + * @privateRemarks Cannot rely on `file.name` as a unique identifier, because it is only the + * basename. The user may select two files with the same basename from different folders. + * Duplicate detection in this function stringifies the whole `File` object because it is unlikely + * that two different files will have the same name, size AND last-modified date. + * + * @param acceptedFiles The newly selected files, provided by `react-dropzone` + */ + const onDropAccepted = (acceptedFiles: File[]) => { + const deduped = acceptedFiles.filter( + acceptedFile => + !files.map(file => JSON.stringify(file)).includes(JSON.stringify(acceptedFile)), + ); + const newFiles = files.concat(deduped); + setFiles(newFiles); }; - const removeFile = () => { - const input = inputEl.current; - if (input) { - input.value = ''; - onChange(null); - setSizeInBytes(null); - } + const removeFile = (fileToRemove: File) => { + const newFiles = files.filter(file => JSON.stringify(file) !== JSON.stringify(fileToRemove)); + setFiles(newFiles); }; + /** Propagates file selection changes to parent */ + useEffect(() => { + onChange(files); + }, [files]); + + const { fileRejections, getInputProps, getRootProps, isDragActive } = useDropzone({ + // Explicitly fall back to undefined, because null causes TypeError in Chromium + accept: accept ?? undefined, + disabled, + maxSize: maxSizeInBytes, + multiple, + onDropAccepted, + }); + + const { palette } = useTheme(); + + const fileOrFiles = multiple ? 'files' : 'file'; + const getDropzoneLabel = () => { + if (isDragActive) return `Drop ${fileOrFiles} here`; + return ( + <> + Drag & drop or choose {fileOrFiles} to upload + + ); + }; + + const acceptedFileExtensions = accept + ? Object.values(accept) + .flat() + .map(str => str.trim()) + : null; + const acceptedFileTypesLabel = acceptedFileExtensions?.join(' ') ?? 'any'; + return ( <> - - - - - - {!fileName && ( - - )} - - - {error && {error}} - {helperText && ( - {helperText} + + + + + {(!hasFileSelected || multiple) && ( + + + + {getDropzoneLabel()} + Supported file types: {acceptedFileTypesLabel} + )} - - {fileName && ( - - {fileName} {showFileSize && sizeInBytes && `(${humanFileSize(sizeInBytes)})`} - Remove - + {hasFileSelected && ( + + {files.map(file => ( + + {file.name} ({humanFileSize(file.size)}) + removeFile(file)}>Remove + + ))} + + )} + + {fileRejections.map(({ file, errors }) => + errors.map(error => ( + + ‘{file.name}’ not allowed – {error.message} + + )), + )} + {helperText && ( + {helperText} )} ); diff --git a/packages/ui-components/src/components/Inputs/InputLabel.tsx b/packages/ui-components/src/components/Inputs/InputLabel.tsx index 2059148e1a..05eacd6fa5 100644 --- a/packages/ui-components/src/components/Inputs/InputLabel.tsx +++ b/packages/ui-components/src/components/Inputs/InputLabel.tsx @@ -1,6 +1,6 @@ /* * Tupaia - * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ import React, { ComponentType } from 'react'; @@ -54,14 +54,14 @@ const LabelWrapper = styled(FlexCenter)` `; interface InputLabelProps { - label?: string | React.ReactNode; + label?: React.ReactNode; tooltip?: string; as?: string | ComponentType; className?: string; htmlFor?: string; TooltipIcon?: ComponentType; applyWrapper?: boolean; - labelProps?: Record; + labelProps?: Record; } export const InputLabel = ({ @@ -77,10 +77,12 @@ export const InputLabel = ({ // If no label, don't render anything, so there isn't an empty label tag in the DOM if (!label) return null; - // wrapper won't work correctly when using TextField, so we need to conditionally apply it + // Wrapper won’t work correctly when using TextField, so we need to conditionally apply it const Wrapper = applyWrapper ? LabelWrapper : React.Fragment; 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 + // 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