From 346f3f951c8c296adafdc269c9d3a35dcf4af9bf Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Fri, 21 Jun 2024 08:22:19 +1200 Subject: [PATCH 01/50] Create button --- .../src/views/Tasks/TasksDashboardPage.tsx | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/datatrak-web/src/views/Tasks/TasksDashboardPage.tsx b/packages/datatrak-web/src/views/Tasks/TasksDashboardPage.tsx index 187be21d47..7c2f03c403 100644 --- a/packages/datatrak-web/src/views/Tasks/TasksDashboardPage.tsx +++ b/packages/datatrak-web/src/views/Tasks/TasksDashboardPage.tsx @@ -5,7 +5,8 @@ import React from 'react'; import styled from 'styled-components'; -import { PageContainer as BasePageContainer } from '../../components'; +import { Add } from '@material-ui/icons'; +import { PageContainer as BasePageContainer, Button } from '../../components'; import { TaskPageHeader, TasksTable } from '../../features'; const PageContainer = styled(BasePageContainer)` @@ -17,10 +18,36 @@ const PageContainer = styled(BasePageContainer)` padding-inline: 3rem; `; +const ButtonContainer = styled.div` + margin-inline-start: auto; + padding-block-end: 0.5rem; +`; + +const CreateButton = styled(Button).attrs({ + color: 'primary', + variant: 'outlined', + size: 'small', +})` + padding-inline-end: 1.2rem; + // the icon width creates the illusion of more padding on the left, so adjust the padding to compensate + padding-inline-start: 0.9rem; +`; + +const AddIcon = styled(Add)` + font-size: 1.2rem; + margin-inline-end: 0.2rem; +`; + export const TasksDashboardPage = () => { return ( - + + + + Create Task + + + ); From beddfbefa3bc8dc768bab9ecc084b85081b1e0e2 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Fri, 21 Jun 2024 08:42:52 +1200 Subject: [PATCH 02/50] Move modal to ui-components --- .../DataLibrary/component/TransformModal.jsx | 2 +- .../components/Modal/EditModal.jsx | 9 +- .../Modal/SaveVisualisationModal.jsx | 2 +- packages/admin-panel/src/editor/EditModal.jsx | 2 +- .../src/importExport/ExportModal.jsx | 2 +- .../src/importExport/ImportModal.jsx | 4 +- packages/admin-panel/src/library.js | 9 +- .../admin-panel/src/logsTable/LogsModal.jsx | 2 +- .../admin-panel/src/qrCode/QrCodeModal.jsx | 2 +- .../src/surveyResponse/FileQuestionField.jsx | 4 +- .../admin-panel/src/surveyResponse/Form.jsx | 3 +- .../ResubmitSurveyResponseModal.jsx | 3 +- .../src/widgets/ConfirmDeleteModal.jsx | 2 +- packages/admin-panel/src/widgets/index.js | 7 - .../AdminPanel/components/RejectButton.jsx | 9 +- .../src/components}/ConfirmModal.jsx | 15 +- packages/psss/src/components/index.js | 1 + .../containers/Modals/ArchiveAlertModal.jsx | 2 +- .../containers/Modals/DeleteAlertModal.jsx | 4 +- .../src/components/Modal/ImportModal.jsx | 196 ------------------ .../src/components/Modal/Modal.tsx} | 56 ++--- .../Modal/ModalCenteredContent.tsx} | 0 .../Modal/ModalContentProvider.tsx} | 28 ++- .../src/components/Modal/ModalHeader.tsx} | 24 +-- .../src/components/Modal/index.js | 7 - .../src/components/Modal/index.ts} | 0 26 files changed, 82 insertions(+), 313 deletions(-) rename packages/{ui-components/src/components/Modal => psss/src/components}/ConfirmModal.jsx (91%) delete mode 100644 packages/ui-components/src/components/Modal/ImportModal.jsx rename packages/{admin-panel/src/widgets/Modal/Modal.jsx => ui-components/src/components/Modal/Modal.tsx} (64%) rename packages/{admin-panel/src/widgets/Modal/ModalCenteredContent.jsx => ui-components/src/components/Modal/ModalCenteredContent.tsx} (100%) rename packages/{admin-panel/src/widgets/Modal/ModalContentProvider.jsx => ui-components/src/components/Modal/ModalContentProvider.tsx} (71%) rename packages/{admin-panel/src/widgets/Modal/ModalHeader.jsx => ui-components/src/components/Modal/ModalHeader.tsx} (66%) delete mode 100644 packages/ui-components/src/components/Modal/index.js rename packages/{admin-panel/src/widgets/Modal/index.js => ui-components/src/components/Modal/index.ts} (100%) diff --git a/packages/admin-panel/src/VizBuilderApp/components/DataLibrary/component/TransformModal.jsx b/packages/admin-panel/src/VizBuilderApp/components/DataLibrary/component/TransformModal.jsx index 5da6d3715a..687d2cf931 100644 --- a/packages/admin-panel/src/VizBuilderApp/components/DataLibrary/component/TransformModal.jsx +++ b/packages/admin-panel/src/VizBuilderApp/components/DataLibrary/component/TransformModal.jsx @@ -7,7 +7,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Dialog } from '@material-ui/core'; import styled from 'styled-components'; -import { ModalHeader } from '../../../../widgets'; +import { ModalHeader } from '@tupaia/ui-components'; const Wrapper = styled.div` height: 80vh; diff --git a/packages/admin-panel/src/VizBuilderApp/components/Modal/EditModal.jsx b/packages/admin-panel/src/VizBuilderApp/components/Modal/EditModal.jsx index 50754fbf48..7fc17101cb 100644 --- a/packages/admin-panel/src/VizBuilderApp/components/Modal/EditModal.jsx +++ b/packages/admin-panel/src/VizBuilderApp/components/Modal/EditModal.jsx @@ -4,11 +4,16 @@ */ import React, { useState } from 'react'; import { useParams } from 'react-router-dom'; -import { Button, Dialog } from '@tupaia/ui-components'; +import { + Button, + Dialog, + ModalContentProvider, + ModalFooter, + ModalHeader, +} from '@tupaia/ui-components'; import { DashboardItemMetadataForm } from '../DashboardItem'; import { MapOverlayMetadataForm } from '../MapOverlay'; import { DASHBOARD_ITEM_OR_MAP_OVERLAY_PARAM } from '../../constants'; -import { ModalContentProvider, ModalFooter, ModalHeader } from '../../../widgets'; export const EditModal = () => { const { dashboardItemOrMapOverlay } = useParams(); diff --git a/packages/admin-panel/src/VizBuilderApp/components/Modal/SaveVisualisationModal.jsx b/packages/admin-panel/src/VizBuilderApp/components/Modal/SaveVisualisationModal.jsx index 964c2adb75..50dbdb1c53 100644 --- a/packages/admin-panel/src/VizBuilderApp/components/Modal/SaveVisualisationModal.jsx +++ b/packages/admin-panel/src/VizBuilderApp/components/Modal/SaveVisualisationModal.jsx @@ -8,11 +8,11 @@ import PropTypes from 'prop-types'; import styled from 'styled-components'; import { Link as RouterLink, useParams } from 'react-router-dom'; import Typography from '@material-ui/core/Typography'; +import { Modal, ModalCenteredContent } from '@tupaia/ui-components'; import { DASHBOARD_ITEM_OR_MAP_OVERLAY_PARAM, MODAL_STATUS } from '../../constants'; import { useVisualisationContext, useVizConfigContext } from '../../context'; import { useSaveDashboardVisualisation, useSaveMapOverlayVisualisation } from '../../api'; import { useVizBuilderBasePath } from '../../utils'; -import { Modal, ModalCenteredContent } from '../../../widgets'; const Heading = styled(Typography).attrs({ variant: 'h3', diff --git a/packages/admin-panel/src/editor/EditModal.jsx b/packages/admin-panel/src/editor/EditModal.jsx index 55456c1fa9..9c9684dda6 100644 --- a/packages/admin-panel/src/editor/EditModal.jsx +++ b/packages/admin-panel/src/editor/EditModal.jsx @@ -8,10 +8,10 @@ import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { dismissEditor } from './actions'; import { UsedBy } from '../usedBy/UsedBy'; -import { Modal } from '../widgets'; import { useEditFiles } from './useEditFiles'; import { FieldsEditor } from './FieldsEditor'; import { withConnectedEditor } from './withConnectedEditor'; +import { Modal } from '@tupaia/ui-components'; export const EditModalComponent = withConnectedEditor( ({ diff --git a/packages/admin-panel/src/importExport/ExportModal.jsx b/packages/admin-panel/src/importExport/ExportModal.jsx index f660e22943..4981584947 100644 --- a/packages/admin-panel/src/importExport/ExportModal.jsx +++ b/packages/admin-panel/src/importExport/ExportModal.jsx @@ -5,7 +5,7 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; -import { Modal } from '../widgets'; +import { Modal } from '@tupaia/ui-components'; import { useApiContext } from '../utilities/ApiProvider'; import { ActionButton } from '../editor'; import { ExportIcon } from '../icons'; diff --git a/packages/admin-panel/src/importExport/ImportModal.jsx b/packages/admin-panel/src/importExport/ImportModal.jsx index b05be99f58..545a43945a 100644 --- a/packages/admin-panel/src/importExport/ImportModal.jsx +++ b/packages/admin-panel/src/importExport/ImportModal.jsx @@ -6,8 +6,8 @@ import React, { useState } from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; -import { FileUploadField } from '@tupaia/ui-components'; -import { InputField, Modal } from '../widgets'; +import { FileUploadField, Modal } from '@tupaia/ui-components'; +import { InputField } from '../widgets'; import { useApiContext } from '../utilities/ApiProvider'; import { DATA_CHANGE_ERROR, DATA_CHANGE_REQUEST, DATA_CHANGE_SUCCESS } from '../table/constants'; import { checkVisibilityCriteriaAreMet, labelToId } from '../utilities'; diff --git a/packages/admin-panel/src/library.js b/packages/admin-panel/src/library.js index 35cf466161..5140ac74a7 100644 --- a/packages/admin-panel/src/library.js +++ b/packages/admin-panel/src/library.js @@ -31,14 +31,7 @@ export { PrivateRoute } from './authentication'; export { getHasBESAdminAccess } from './utilities/getHasBESAdminAccess'; export * from './pages/resources'; export { ReduxAutocomplete } from './autocomplete'; -export { - IconButton, - ModalContentProvider, - Modal, - ModalFooter, - ModalHeader, - ModalCenteredContent, -} from './widgets'; +export { IconButton } from './widgets'; export { AdminPanelDataProviders } from './utilities/AdminPanelProviders'; export { useApiContext } from './utilities/ApiProvider'; export { DataChangeAction, ActionButton } from './editor'; diff --git a/packages/admin-panel/src/logsTable/LogsModal.jsx b/packages/admin-panel/src/logsTable/LogsModal.jsx index 26aa31c849..2a2dffbb3c 100644 --- a/packages/admin-panel/src/logsTable/LogsModal.jsx +++ b/packages/admin-panel/src/logsTable/LogsModal.jsx @@ -6,8 +6,8 @@ import React from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; +import { Modal } from '@tupaia/ui-components'; import { changeLogsTablePage, closeLogsModal } from './actions'; -import { Modal } from '../widgets'; import { LogsTable } from './LogsTable'; export const LogsModalComponent = ({ diff --git a/packages/admin-panel/src/qrCode/QrCodeModal.jsx b/packages/admin-panel/src/qrCode/QrCodeModal.jsx index ac4546f5cf..c8f2bff785 100644 --- a/packages/admin-panel/src/qrCode/QrCodeModal.jsx +++ b/packages/admin-panel/src/qrCode/QrCodeModal.jsx @@ -6,9 +6,9 @@ import React from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; +import { Modal } from '@tupaia/ui-components'; import { QrCodeContainer } from './QrCodeContainer'; import { closeQrCodeModal } from './actions'; -import { Modal } from '../widgets'; export const QrCodeModalComponent = ({ isOpen, onDismiss, qrCodeContents, humanReadableId }) => { return ( diff --git a/packages/admin-panel/src/surveyResponse/FileQuestionField.jsx b/packages/admin-panel/src/surveyResponse/FileQuestionField.jsx index 043ea5755a..00c6332f2d 100644 --- a/packages/admin-panel/src/surveyResponse/FileQuestionField.jsx +++ b/packages/admin-panel/src/surveyResponse/FileQuestionField.jsx @@ -7,13 +7,13 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; import styled from 'styled-components'; import generateId from 'uuid/v1'; -import { TextField } from '@tupaia/ui-components'; +import { TextField, Modal } from '@tupaia/ui-components'; import { getUniqueFileNameParts } from '@tupaia/utils'; import EditIcon from '@material-ui/icons/Edit'; import DeleteIcon from '@material-ui/icons/Delete'; import ExportIcon from '@material-ui/icons/GetApp'; import { FileUploadField } from '../widgets/InputField/FileUploadField'; -import { IconButton, Modal } from '../widgets'; +import { IconButton } from '../widgets'; import { useApiContext } from '../utilities/ApiProvider'; const Container = styled.div` diff --git a/packages/admin-panel/src/surveyResponse/Form.jsx b/packages/admin-panel/src/surveyResponse/Form.jsx index e69f9dcd61..77fbbd9270 100644 --- a/packages/admin-panel/src/surveyResponse/Form.jsx +++ b/packages/admin-panel/src/surveyResponse/Form.jsx @@ -5,10 +5,9 @@ import React, { useState, useCallback, useEffect } from 'react'; import PropTypes from 'prop-types'; -import { Button } from '@tupaia/ui-components'; +import { Button, ModalContentProvider, ModalFooter } from '@tupaia/ui-components'; import { Divider } from '@material-ui/core'; import { useGetExistingData } from './useGetExistingData'; -import { ModalContentProvider, ModalFooter } from '../widgets'; import { useResubmitSurveyResponse } from '../api/mutations/useResubmitSurveyResponse'; import { MODAL_STATUS } from './constants'; import { SurveyScreens } from './SurveyScreens'; diff --git a/packages/admin-panel/src/surveyResponse/ResubmitSurveyResponseModal.jsx b/packages/admin-panel/src/surveyResponse/ResubmitSurveyResponseModal.jsx index c4c1356c06..46a224f765 100644 --- a/packages/admin-panel/src/surveyResponse/ResubmitSurveyResponseModal.jsx +++ b/packages/admin-panel/src/surveyResponse/ResubmitSurveyResponseModal.jsx @@ -6,10 +6,9 @@ import React from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; -import { Dialog } from '@tupaia/ui-components'; +import { Dialog, ModalHeader } from '@tupaia/ui-components'; import { closeResubmitSurveyModal, onAfterMutate as onAfterMutateAction } from './actions'; import { Form } from './Form'; -import { ModalHeader } from '../widgets'; export const ResubmitSurveyResponseModalComponent = ({ isOpen, diff --git a/packages/admin-panel/src/widgets/ConfirmDeleteModal.jsx b/packages/admin-panel/src/widgets/ConfirmDeleteModal.jsx index 45199978ae..87189b2a04 100644 --- a/packages/admin-panel/src/widgets/ConfirmDeleteModal.jsx +++ b/packages/admin-panel/src/widgets/ConfirmDeleteModal.jsx @@ -7,7 +7,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import Typography from '@material-ui/core/Typography'; import styled from 'styled-components'; -import { Modal, ModalCenteredContent } from './Modal'; +import { Modal, ModalCenteredContent } from '@tupaia/ui-components'; const Heading = styled(Typography).attrs({ variant: 'h3', diff --git a/packages/admin-panel/src/widgets/index.js b/packages/admin-panel/src/widgets/index.js index 9a011f25cc..60fa40db7b 100644 --- a/packages/admin-panel/src/widgets/index.js +++ b/packages/admin-panel/src/widgets/index.js @@ -8,13 +8,6 @@ export { InputField, JsonEditorInputField } from './InputField'; export { Tabs } from './Tabs'; export { PageHeader } from './PageHeader'; export { PageBody, Footer } from '../layout'; -export { - ModalContentProvider, - Modal, - ModalFooter, - ModalHeader, - ModalCenteredContent, -} from './Modal'; export { JsonEditor, JsonTreeEditor } from './JsonEditor'; export { SecondaryNavbar } from '../layout/navigation/SecondaryNavbar'; export { ConfirmDeleteModal } from './ConfirmDeleteModal'; diff --git a/packages/lesmis/src/views/AdminPanel/components/RejectButton.jsx b/packages/lesmis/src/views/AdminPanel/components/RejectButton.jsx index 1b7c51f31f..943d9f3352 100644 --- a/packages/lesmis/src/views/AdminPanel/components/RejectButton.jsx +++ b/packages/lesmis/src/views/AdminPanel/components/RejectButton.jsx @@ -6,15 +6,10 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; import styled from 'styled-components'; import { Typography } from '@material-ui/core'; -import { - ColumnActionButton, - DataChangeAction, - useApiContext, - Modal, - ModalCenteredContent, -} from '@tupaia/admin-panel'; +import { ColumnActionButton, DataChangeAction, useApiContext } from '@tupaia/admin-panel'; import { Delete } from '@material-ui/icons'; import CircularProgress from '@material-ui/core/CircularProgress'; +import { Modal, ModalCenteredContent } from '@tupaia/ui-components'; import { useRejectSurveyResponseStatus } from '../api'; const ConfirmModalHeading = styled(Typography).attrs({ diff --git a/packages/ui-components/src/components/Modal/ConfirmModal.jsx b/packages/psss/src/components/ConfirmModal.jsx similarity index 91% rename from packages/ui-components/src/components/Modal/ConfirmModal.jsx rename to packages/psss/src/components/ConfirmModal.jsx index dfeb0b7dd1..92ebddd6d0 100644 --- a/packages/ui-components/src/components/Modal/ConfirmModal.jsx +++ b/packages/psss/src/components/ConfirmModal.jsx @@ -8,11 +8,16 @@ import PropTypes from 'prop-types'; import styled from 'styled-components'; import ReportProblem from '@material-ui/icons/ReportProblem'; import Typography from '@material-ui/core/Typography'; - -import { Button, OutlinedButton } from '../Button'; -import { Dialog, DialogFooter, DialogHeader, DialogContent } from '../Dialog'; -import { LoadingContainer } from '../Loaders'; -import { Alert } from '../Alert'; +import { + Alert, + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + LoadingContainer, + OutlinedButton, +} from '@tupaia/ui-components'; const DescriptionText = styled(Typography)` font-size: 1rem; diff --git a/packages/psss/src/components/index.js b/packages/psss/src/components/index.js index 772b55918d..60cc90c833 100644 --- a/packages/psss/src/components/index.js +++ b/packages/psss/src/components/index.js @@ -17,3 +17,4 @@ export * from './FetchLoader'; export * from './Modal'; export * from './AffectedSitesTab'; export * from './ComingSoon'; +export * from './ConfirmModal'; diff --git a/packages/psss/src/containers/Modals/ArchiveAlertModal.jsx b/packages/psss/src/containers/Modals/ArchiveAlertModal.jsx index b4684ba0a8..68f31fe4e0 100644 --- a/packages/psss/src/containers/Modals/ArchiveAlertModal.jsx +++ b/packages/psss/src/containers/Modals/ArchiveAlertModal.jsx @@ -17,11 +17,11 @@ import { DialogContent, DialogFooter, DialogHeader, - ConfirmModal, } from '@tupaia/ui-components'; import { useArchiveAlert } from '../../api/queries'; import { AlertsPanelContext } from '../../context'; +import { ConfirmModal } from '../../components'; const TickIcon = styled(CheckCircle)` font-size: 2.5rem; diff --git a/packages/psss/src/containers/Modals/DeleteAlertModal.jsx b/packages/psss/src/containers/Modals/DeleteAlertModal.jsx index 07464c1459..b90ef9124b 100644 --- a/packages/psss/src/containers/Modals/DeleteAlertModal.jsx +++ b/packages/psss/src/containers/Modals/DeleteAlertModal.jsx @@ -5,11 +5,9 @@ import React, { useState, useCallback } from 'react'; import PropTypes from 'prop-types'; - -import { ConfirmModal } from '@tupaia/ui-components'; - import { useDeleteAlert } from '../../api/queries'; import { SuccessModal } from './SuccessModal'; +import { ConfirmModal } from '../../components'; const STATUS = { INITIAL: 'initial', diff --git a/packages/ui-components/src/components/Modal/ImportModal.jsx b/packages/ui-components/src/components/Modal/ImportModal.jsx deleted file mode 100644 index 287bfc8243..0000000000 --- a/packages/ui-components/src/components/Modal/ImportModal.jsx +++ /dev/null @@ -1,196 +0,0 @@ -/* - * Tupaia - * Copyright (c) 2017 - 2021 Beyond Essential Systems Pty Ltd - */ - -import Typography from '@material-ui/core/Typography'; -import PropTypes from 'prop-types'; -import React, { useCallback, useState } from 'react'; -import styled from 'styled-components'; - -import { SmallAlert } from '../Alert'; -import { Button, OutlinedButton } from '../Button'; -import { Dialog, DialogContent, DialogFooter, DialogHeader } from '../Dialog'; -import { FileUploadField } from '../Inputs'; -import { LoadingContainer } from '../Loaders'; - -const STATUS = { - IDLE: 'idle', - LOADING: 'loading', - SUCCESS: 'success', - ERROR: 'error', -}; - -const NO_FILE_MESSAGE = 'No file chosen'; - -const Content = styled(DialogContent)` - text-align: left; - min-height: 220px; -`; - -const Heading = styled(Typography)` - margin-bottom: 18px; -`; - -export const ImportModal = ({ - isOpen, - title, - subtitle, - actionText, - loadingText, - loadingHeading, - showLoadingContainer, - onSubmit, - onClose, -}) => { - const [status, setStatus] = useState(STATUS.IDLE); - const [errorMessage, setErrorMessage] = useState(null); - const [successMessage, setSuccessMessage] = useState(null); - const [file, setFile] = useState(null); - const [fileName, setFileName] = useState(NO_FILE_MESSAGE); - - const handleSubmit = async event => { - event.preventDefault(); - setErrorMessage(null); - setStatus(STATUS.LOADING); - - try { - const { message } = await onSubmit(file); - if (showLoadingContainer && message) { - setStatus(STATUS.SUCCESS); - setSuccessMessage(message); - } else { - handleClose(); - } - } catch (error) { - setStatus(STATUS.ERROR); - setErrorMessage(error.message); - } - }; - - const handleClose = async () => { - onClose(); - - setStatus(STATUS.IDLE); - setErrorMessage(null); - setSuccessMessage(null); - setFile(null); - setFileName(NO_FILE_MESSAGE); - }; - - const handleDismiss = () => { - setStatus(STATUS.IDLE); - setErrorMessage(null); - setSuccessMessage(null); - // Deselect file when dismissing an error, this avoids an error when editing selected files - // @see https://github.com/beyondessential/tupaia-backlog/issues/1211 - setFile(null); - setFileName(NO_FILE_MESSAGE); - }; - - const ContentContainer = showLoadingContainer - ? ({ children }) => ( - - {children} - - ) - : ({ children }) => <>{children}; - - const renderContent = useCallback(() => { - switch (status) { - case STATUS.SUCCESS: - return

{successMessage}

; - case STATUS.ERROR: - return ( - <> - An error has occurred. - - {errorMessage} - - - ); - default: - return ( - <> -

{subtitle}

-
- { - setFileName(newName); - setFile(target.files[0]); - }} - name="file-upload" - fileName={fileName} - /> - - - ); - } - }, [status, successMessage, errorMessage, subtitle, fileName]); - - const renderButtons = useCallback(() => { - switch (status) { - case STATUS.SUCCESS: - return ; - case STATUS.ERROR: - return ( - <> - Dismiss - - - ); - default: - return ( - <> - - - - ); - } - }, [status, file, handleDismiss, handleClose, handleSubmit]); - - return ( - - - - {renderContent()} - - {renderButtons()} - - ); -}; - -ImportModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - title: PropTypes.string, - subtitle: PropTypes.string, - actionText: PropTypes.string, - loadingText: PropTypes.string, - loadingHeading: PropTypes.string, - showLoadingContainer: PropTypes.bool, - onSubmit: PropTypes.func.isRequired, - onClose: PropTypes.func.isRequired, -}; - -ImportModal.defaultProps = { - title: 'Import', - subtitle: '', - actionText: 'Import', - loadingText: 'Importing', - loadingHeading: 'Importing data', - showLoadingContainer: false, -}; diff --git a/packages/admin-panel/src/widgets/Modal/Modal.jsx b/packages/ui-components/src/components/Modal/Modal.tsx similarity index 64% rename from packages/admin-panel/src/widgets/Modal/Modal.jsx rename to packages/ui-components/src/components/Modal/Modal.tsx index 59b48c8982..fe884e9f18 100644 --- a/packages/admin-panel/src/widgets/Modal/Modal.jsx +++ b/packages/ui-components/src/components/Modal/Modal.tsx @@ -4,9 +4,9 @@ */ import React from 'react'; import styled from 'styled-components'; -import PropTypes from 'prop-types'; -import { DialogFooter as BaseDialogFooter, Button } from '@tupaia/ui-components'; -import { Dialog } from '@material-ui/core'; +import { ButtonProps, Dialog, DialogProps } from '@material-ui/core'; +import { DialogFooter as BaseDialogFooter } from '../Dialog'; +import { Button } from '../Button'; import { ModalContentProvider } from './ModalContentProvider'; import { ModalHeader } from './ModalHeader'; @@ -16,6 +16,23 @@ export const ModalFooter = styled(BaseDialogFooter)` padding-inline: 1.9rem; `; +type ButtonT = ButtonProps & { + id: string; + text: string; + component?: React.ElementType; + to?: string; +}; + +interface ModalProps extends Omit { + children: React.ReactNode; + isOpen: boolean; + onClose: () => void; + title: string; + isLoading?: boolean; + errorMessage?: string; + buttons?: ButtonT[]; +} + export const Modal = ({ children, isOpen, @@ -23,9 +40,9 @@ export const Modal = ({ title, isLoading, errorMessage, - buttons, + buttons = [], ...muiDialogProps -}) => { +}: ModalProps) => { return ( @@ -47,6 +64,7 @@ export const Modal = ({ to, }) => ( */} - + +
+ ( + + + + )} + /> + ( + + + + )} + /> + {/* */} + +
); }; diff --git a/packages/datatrak-web/src/views/SurveySelectPage.tsx b/packages/datatrak-web/src/views/SurveySelectPage.tsx index 60c548afcc..d552d5b2fc 100644 --- a/packages/datatrak-web/src/views/SurveySelectPage.tsx +++ b/packages/datatrak-web/src/views/SurveySelectPage.tsx @@ -12,6 +12,7 @@ import { ListItemType, Button } from '../components'; import { useCurrentUserContext, useProjectSurveys } from '../api'; import { HEADER_HEIGHT } from '../constants'; import { CountrySelector, GroupedSurveyList, useUserCountries } from '../features'; +import { Survey } from '../types'; const Container = styled(Paper).attrs({ variant: 'outlined', @@ -82,7 +83,7 @@ const Subheader = styled(Typography).attrs({ export const SurveySelectPage = () => { const navigate = useNavigate(); - const [selectedSurvey, setSelectedSurvey] = useState(null); + const [selectedSurvey, setSelectedSurvey] = useState(null); const { countries, selectedCountry, @@ -91,7 +92,7 @@ export const SurveySelectPage = () => { isLoading: isLoadingCountries, } = useUserCountries(); const navigateToSurvey = () => { - navigate(`/survey/${selectedCountry?.code}/${selectedSurvey?.value}`); + navigate(`/survey/${selectedCountry?.code}/${selectedSurvey}`); }; const { mutate: updateUser, isLoading: isUpdatingUser } = useEditUser(navigateToSurvey); const user = useCurrentUserContext(); From 459ad7a0439d31b6f8c2f2970f87065f64195ddc Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Fri, 21 Jun 2024 12:44:25 +1200 Subject: [PATCH 14/50] Styling entity selector --- .../features/EntitySelector/EntitySelector.tsx | 17 +++++++++-------- .../src/features/EntitySelector/SearchField.tsx | 15 +++++++++++++-- .../src/features/Questions/EntityQuestion.tsx | 2 +- .../src/features/Tasks/CreateTaskModal.tsx | 17 +++++++++++++---- 4 files changed, 36 insertions(+), 15 deletions(-) diff --git a/packages/datatrak-web/src/features/EntitySelector/EntitySelector.tsx b/packages/datatrak-web/src/features/EntitySelector/EntitySelector.tsx index cb53bc018e..a2619043e3 100644 --- a/packages/datatrak-web/src/features/EntitySelector/EntitySelector.tsx +++ b/packages/datatrak-web/src/features/EntitySelector/EntitySelector.tsx @@ -6,13 +6,7 @@ import React, { useState } from 'react'; import styled from 'styled-components'; import { useFormContext } from 'react-hook-form'; -import { - FormHelperText, - FormLabel, - FormLabelProps, - Typography, - TypographyProps, -} from '@material-ui/core'; +import { FormHelperText, FormLabel, FormLabelProps, TypographyProps } from '@material-ui/core'; import { Country, SurveyScreenComponentConfig } from '@tupaia/types'; import { SpinningLoader, useDebounce } from '@tupaia/ui-components'; import { useEntityById, useProjectEntities } from '../../api'; @@ -137,7 +131,11 @@ export const EntitySelector = ({ return ( - {showLegend && } + {showLegend && ( + + )}
{showSearchInput && ( )} {errors && errors[name!] && *{errors[name!].message}} diff --git a/packages/datatrak-web/src/features/EntitySelector/SearchField.tsx b/packages/datatrak-web/src/features/EntitySelector/SearchField.tsx index 0920751ee4..65a7153fe2 100644 --- a/packages/datatrak-web/src/features/EntitySelector/SearchField.tsx +++ b/packages/datatrak-web/src/features/EntitySelector/SearchField.tsx @@ -64,8 +64,18 @@ type SearchFieldProps = TextFieldProps & { }; export const SearchField = React.forwardRef((props, ref) => { - const { name, label, id, searchValue, onChangeSearch, isDirty, invalid, detailLabel, required } = - props; + const { + name, + label, + id, + searchValue, + onChangeSearch, + isDirty, + invalid, + detailLabel, + required, + inputProps, + } = props; const displayValue = isDirty ? searchValue : ''; @@ -109,6 +119,7 @@ export const SearchField = React.forwardRef((p ) : null, }} + inputProps={inputProps} /> ); }); diff --git a/packages/datatrak-web/src/features/Questions/EntityQuestion.tsx b/packages/datatrak-web/src/features/Questions/EntityQuestion.tsx index 8e3f836569..cf612ca988 100644 --- a/packages/datatrak-web/src/features/Questions/EntityQuestion.tsx +++ b/packages/datatrak-web/src/features/Questions/EntityQuestion.tsx @@ -43,7 +43,7 @@ export const EntityQuestion = ({ data={formData} countryCode={countryCode} showRecentEntities - showSearchInput={isReviewScreen || isResponseScreen} + showSearchInput={!isReviewScreen && !isResponseScreen} legend={label} legendProps={{ component: Typography, diff --git a/packages/datatrak-web/src/features/Tasks/CreateTaskModal.tsx b/packages/datatrak-web/src/features/Tasks/CreateTaskModal.tsx index 80e4d88759..f4d5140c3c 100644 --- a/packages/datatrak-web/src/features/Tasks/CreateTaskModal.tsx +++ b/packages/datatrak-web/src/features/Tasks/CreateTaskModal.tsx @@ -34,20 +34,29 @@ const ListSelectWrapper = styled.div` .list-wrapper { height: 15rem; max-height: 15rem; + padding: 1rem; } .entity-selector-content { - height: 20rem; - max-height: 20rem; + padding-block: 1rem; border: 1px solid ${({ theme }) => theme.palette.divider}; border-radius: 3px; - padding-inline: 1rem; + .MuiFormControl-root { + width: auto; + margin-inline: 1rem; + padding-block-end: 1rem; + border-bottom: 1px solid ${({ theme }) => theme.palette.divider}; + } + .list-wrapper { + border-top: 0; + margin-block-start: 0; + padding-block-start: 0; + } } .entity-selector-content, .list-wrapper { margin-block-start: 0.5rem; - padding-block: 1rem; } `; From ec8f0fce5b20c7bd3e2f2dab31515dd4778eab52 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Mon, 24 Jun 2024 13:31:23 +1200 Subject: [PATCH 15/50] Due date --- packages/datatrak-web/package.json | 1 + .../{ => CreateTaskModal}/CreateTaskModal.tsx | 108 +++++++++--------- .../Tasks/CreateTaskModal/EntityInput.tsx | 68 +++++++++++ .../CreateTaskModal/RepeatScheduleInput.tsx | 91 +++++++++++++++ .../features/Tasks/CreateTaskModal/index.ts | 6 + .../DueDateFilter.tsx => DueDatePicker.tsx} | 60 +++++++--- .../features/Tasks/TasksTable/TasksTable.tsx | 4 +- .../src/types/react-table-config.d.ts | 1 - yarn.lock | 1 + 9 files changed, 270 insertions(+), 70 deletions(-) rename packages/datatrak-web/src/features/Tasks/{ => CreateTaskModal}/CreateTaskModal.tsx (59%) create mode 100644 packages/datatrak-web/src/features/Tasks/CreateTaskModal/EntityInput.tsx create mode 100644 packages/datatrak-web/src/features/Tasks/CreateTaskModal/RepeatScheduleInput.tsx create mode 100644 packages/datatrak-web/src/features/Tasks/CreateTaskModal/index.ts rename packages/datatrak-web/src/features/Tasks/{TasksTable/DueDateFilter.tsx => DueDatePicker.tsx} (56%) diff --git a/packages/datatrak-web/package.json b/packages/datatrak-web/package.json index be5744f9a8..8e03afbb16 100644 --- a/packages/datatrak-web/package.json +++ b/packages/datatrak-web/package.json @@ -14,6 +14,7 @@ "@material-ui/core": "^4.9.11", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "^4.0.0-alpha.57", + "@material-ui/pickers": "^3.2.10", "@material-ui/styles": "^4.9.10", "@testing-library/react-hooks": "^8.0.1", "@tupaia/expression-parser": "workspace:*", diff --git a/packages/datatrak-web/src/features/Tasks/CreateTaskModal.tsx b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx similarity index 59% rename from packages/datatrak-web/src/features/Tasks/CreateTaskModal.tsx rename to packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx index f4d5140c3c..4abdd6312e 100644 --- a/packages/datatrak-web/src/features/Tasks/CreateTaskModal.tsx +++ b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx @@ -7,12 +7,11 @@ import React from 'react'; import styled from 'styled-components'; import { useForm, Controller, FormProvider } from 'react-hook-form'; import { Modal } from '@tupaia/ui-components'; -import { QuestionType } from '@tupaia/types'; -import { CountrySelector, useUserCountries } from '../CountrySelector'; -import { GroupedSurveyList } from '../GroupedSurveyList'; -import { EntitySelector } from '../EntitySelector'; -import { useCurrentUserContext, useSurvey } from '../../api'; -import { getAllSurveyComponents } from '../Survey'; +import { CountrySelector, useUserCountries } from '../../CountrySelector'; +import { GroupedSurveyList } from '../../GroupedSurveyList'; +import { DueDatePicker } from '../DueDatePicker'; +import { RepeatScheduleInput } from './RepeatScheduleInput'; +import { EntityInput } from './EntityInput'; const CountrySelectorWrapper = styled.div` margin-inline-start: auto; @@ -21,12 +20,18 @@ const CountrySelectorWrapper = styled.div` const Form = styled.form` .MuiFormLabel-root { font-weight: ${({ theme }) => theme.typography.fontWeightMedium}; - margin-block-end: 0; + margin-block-end: 0.2rem; font-size: 0.875rem; } .MuiFormLabel-asterisk { color: ${({ theme }) => theme.palette.error.main}; } + .MuiInputBase-root { + font-size: 0.875rem; + } + .MuiOutlinedInput-input { + padding-block: 0.9rem; + } `; const ListSelectWrapper = styled.div` @@ -53,10 +58,15 @@ const ListSelectWrapper = styled.div` padding-block-start: 0; } } +`; - .entity-selector-content, - .list-wrapper { - margin-block-start: 0.5rem; +const InputRow = styled.div` + display: flex; + > * { + flex: 1; + &:first-child { + margin-inline-end: 1rem; + } } `; @@ -66,27 +76,14 @@ interface CreateTaskModalProps { } export const CreateTaskModal = ({ open, onClose }: CreateTaskModalProps) => { - const user = useCurrentUserContext(); - const formContext = useForm(); - const { handleSubmit, control, watch } = formContext; + const formContext = useForm({ + mode: 'onChange', + }); + const { handleSubmit, control } = formContext; const { countries, isLoading, selectedCountry, updateSelectedCountry } = useUserCountries(); const onSubmit = data => {}; - const surveyCode = watch('survey'); - const { data: survey, isLoading: isLoadingSurvey } = useSurvey(surveyCode); - const getPrimaryEntityQuestionConfig = () => { - if (!survey) return null; - const flattenedQuestions = getAllSurveyComponents(survey.screens ?? []); - const primaryEntityQuestion = flattenedQuestions.find( - question => question.type === QuestionType.PrimaryEntity, - ); - return primaryEntityQuestion?.config ?? {}; - }; - - const primaryEntityQuestionConfig = getPrimaryEntityQuestionConfig(); - ``; - return ( @@ -99,7 +96,7 @@ export const CreateTaskModal = ({ open, onClose }: CreateTaskModalProps) => {
( @@ -124,33 +121,42 @@ export const CreateTaskModal = ({ open, onClose }: CreateTaskModalProps) => { rules={{ required: 'Required' }} render={({ onChange, value, ref }, { invalid }) => ( - )} /> + + ( + + )} + /> + ( + + )} + /> + + {/* */}
diff --git a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/EntityInput.tsx b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/EntityInput.tsx new file mode 100644 index 0000000000..594291efb2 --- /dev/null +++ b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/EntityInput.tsx @@ -0,0 +1,68 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import React from 'react'; +import { useWatch } from 'react-hook-form'; +import { Country, QuestionType } from '@tupaia/types'; +import { EntitySelector } from '../../EntitySelector'; +import { useCurrentUserContext, useSurvey } from '../../../api'; +import { getAllSurveyComponents } from '../../Survey'; + +interface EntityInputProps { + onChange: (value: string) => void; + value: string; + invalid: boolean; + selectedCountry?: Country | null; + ref?: React.Ref; +} + +export const EntityInput = ({ + onChange, + value, + invalid, + selectedCountry, + ref, +}: EntityInputProps) => { + const { surveyCode } = useWatch('surveyCode'); + const user = useCurrentUserContext(); + const { data: survey, isLoading: isLoadingSurvey } = useSurvey(surveyCode); + const getPrimaryEntityQuestionConfig = () => { + if (!survey) return null; + const flattenedQuestions = getAllSurveyComponents(survey.screens ?? []); + const primaryEntityQuestion = flattenedQuestions.find( + question => question.type === QuestionType.PrimaryEntity, + ); + return primaryEntityQuestion?.config ?? {}; + }; + + const primaryEntityQuestionConfig = getPrimaryEntityQuestionConfig(); + + return ( + + ); +}; diff --git a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/RepeatScheduleInput.tsx b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/RepeatScheduleInput.tsx new file mode 100644 index 0000000000..07bef65488 --- /dev/null +++ b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/RepeatScheduleInput.tsx @@ -0,0 +1,91 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import React from 'react'; +import { Select, MenuItem, FormControl, FormLabel } from '@material-ui/core'; +import { format, lastDayOfMonth } from 'date-fns'; +import { useWatch } from 'react-hook-form'; + +const useRepeatScheduleOptions = dueDate => { + const noRepeat = { + label: 'Does not repeat', + value: '', + }; + + if (!dueDate) { + return [noRepeat]; + } + + const dueDateObject = new Date(dueDate); + + const dayOfWeek = format(dueDateObject, 'EEEE'); + const dateOfMonth = format(dueDateObject, 'do'); + + const month = format(dueDateObject, 'MMM'); + + const lastDateOfMonth = format(lastDayOfMonth(dueDateObject), 'do'); + + const isLastDayOfMonth = dateOfMonth === lastDateOfMonth; + + // If the due date is the last day of the month, we don't need to show the date, just always repeat on the last day. Otherwise, show the date. + // In the case of February, if the selected date is, for example, the 29th/30th/31st of June, we would repeat on the last day of the month. + const monthlyOption = isLastDayOfMonth + ? 'Monthly on the last day' + : `Monthly on the ${dateOfMonth}`; + + return [ + noRepeat, + { + label: 'Daily', + value: 'daily', + }, + { + label: `Weekly on ${dayOfWeek}`, + value: 'weekly', + }, + { + label: monthlyOption, + value: 'monthly', + }, + { + label: `Yearly on ${dateOfMonth} of ${month}`, + value: 'yearly', + }, + ]; +}; + +interface RepeatScheduleInputProps { + value: string; + onChange: ( + value: React.ChangeEvent<{ + name?: string | undefined; + value: unknown; + }>, + ) => void; +} + +export const RepeatScheduleInput = ({ value = '', onChange }: RepeatScheduleInputProps) => { + const { dueDate } = useWatch('dueDate'); + const repeatScheduleOptions = useRepeatScheduleOptions(dueDate); + return ( + + Repeating task + + + ); +}; diff --git a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/index.ts b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/index.ts new file mode 100644 index 0000000000..1c6b767975 --- /dev/null +++ b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/index.ts @@ -0,0 +1,6 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +export { CreateTaskModal } from './CreateTaskModal'; diff --git a/packages/datatrak-web/src/features/Tasks/TasksTable/DueDateFilter.tsx b/packages/datatrak-web/src/features/Tasks/DueDatePicker.tsx similarity index 56% rename from packages/datatrak-web/src/features/Tasks/TasksTable/DueDateFilter.tsx rename to packages/datatrak-web/src/features/Tasks/DueDatePicker.tsx index 88aa88eceb..e75e720f4b 100644 --- a/packages/datatrak-web/src/features/Tasks/TasksTable/DueDateFilter.tsx +++ b/packages/datatrak-web/src/features/Tasks/DueDatePicker.tsx @@ -13,6 +13,10 @@ const Wrapper = styled.div` } .MuiInputBase-input { padding-inline-end: 0; + padding-inline-start: 1rem; + font-size: inherit; + line-height: normal; + color: inherit; } .MuiInputAdornment-positionEnd { margin-inline-start: 0; @@ -20,46 +24,65 @@ const Wrapper = styled.div` .MuiOutlinedInput-adornedEnd { padding-inline-end: 0; } + .MuiFormLabel-root { + margin-block-end: 0.25rem; + } + .MuiSvgIcon-root { + font-size: 1rem; + } `; const DATE_FORMAT = 'yyyy-MM-dd HH:mm:ss'; -interface DueDateFilterProps { - filter: { value: string } | undefined; +interface DueDatePickerProps { + value?: string | null; onChange: (value: string | null) => void; + disablePast?: boolean; + fullWidth?: boolean; + required?: boolean; + label?: string; + inputRef?: React.Ref; } -export const DueDateFilter = ({ filter, onChange }: DueDateFilterProps) => { - const [date, setDate] = useState(filter?.value ?? null); +export const DueDatePicker = ({ + value, + onChange, + label, + disablePast, + fullWidth, + required, + inputRef, +}: DueDatePickerProps) => { + const [date, setDate] = useState(value ?? null); // update in local state to be the end of the selected date // this is also to handle invalid dates, so the filter doesn't get updated until a valid date is selected/entered - const updateSelectedDate = (value: string | null) => { - if (!value) return setDate(null); - if (!isValid(new Date(value))) return; - const endOfDay = new Date(value).setHours(23, 59, 59, 999); + const updateSelectedDate = (newValue: string | null) => { + if (!newValue) return setDate(null); + if (!isValid(new Date(newValue))) return; + const endOfDay = new Date(newValue).setHours(23, 59, 59, 999); const newDate = format(endOfDay, DATE_FORMAT); setDate(newDate); }; - // if the date is updated, update the filter + // if the date is updated, update the value useEffect(() => { - if (date === filter?.value) return; + if (date === value) return; onChange(date); }, [date]); - // if the filter is updated, update the local state. This is to handle, for example, dates that are updated from the URL params + // if the value is updated, update the local state. This is to handle, for example, dates that are updated from the URL params useEffect(() => { - if (filter?.value === date) return; + if (value === date) return; - setDate(filter?.value ?? null); - }, [filter?.value]); + setDate(value ?? null); + }, [value]); const getLocaleDateFormat = () => { const localeCode = window.navigator.language; const parts = new Intl.DateTimeFormat(localeCode).formatToParts(); return parts - .map(({ type, value }) => { + .map(({ type, value: partValue }) => { switch (type) { case 'year': return 'yyyy'; @@ -68,7 +91,7 @@ export const DueDateFilter = ({ filter, onChange }: DueDateFilterProps) => { case 'day': return 'dd'; default: - return value; + return partValue; } }) .join(''); @@ -85,6 +108,11 @@ export const DueDateFilter = ({ filter, onChange }: DueDateFilterProps) => { position: 'end', }} placeholder={placeholder} + label={label} + disablePast={disablePast} + fullWidth={fullWidth} + required={required} + inputRef={inputRef} /> ); diff --git a/packages/datatrak-web/src/features/Tasks/TasksTable/TasksTable.tsx b/packages/datatrak-web/src/features/Tasks/TasksTable/TasksTable.tsx index 956f88debb..ec553c3119 100644 --- a/packages/datatrak-web/src/features/Tasks/TasksTable/TasksTable.tsx +++ b/packages/datatrak-web/src/features/Tasks/TasksTable/TasksTable.tsx @@ -13,9 +13,9 @@ import { Button } from '../../../components'; import { useCurrentUserContext, useTasks } from '../../../api'; import { displayDate } from '../../../utils'; import { ROUTES } from '../../../constants'; +import { DueDatePicker } from '../DueDatePicker'; import { StatusPill } from '../StatusPill'; import { StatusFilter } from './StatusFilter'; -import { DueDateFilter } from './DueDateFilter'; type Task = DatatrakWebTasksRequest.ResBody['tasks'][0]; @@ -110,7 +110,7 @@ const COLUMNS = [ accessor: row => displayDate(row.dueDate), id: 'due_date', filterable: true, - Filter: DueDateFilter, + Filter: DueDatePicker, disableResizing: true, }, { 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 d739fad5e7..4923ef55b9 100644 --- a/packages/ui-components/src/types/react-table-config.d.ts +++ b/packages/ui-components/src/types/react-table-config.d.ts @@ -3,7 +3,6 @@ import { UseSortByColumnProps, UseSortByOptions, UseSortByState, - UseTableRowProps as BaseUseTableRowProps, } from 'react-table'; type RowT = Record & { diff --git a/yarn.lock b/yarn.lock index 88845f8c59..4b1e0f2f02 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12057,6 +12057,7 @@ __metadata: "@material-ui/core": ^4.9.11 "@material-ui/icons": ^4.9.1 "@material-ui/lab": ^4.0.0-alpha.57 + "@material-ui/pickers": ^3.2.10 "@material-ui/styles": ^4.9.10 "@mui/types": ^7.2.4 "@testing-library/react": 12.1.2 From 1a23d18ff6c1b8b498ad8f84cfbcae103ca54acf Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Tue, 25 Jun 2024 14:57:07 +1200 Subject: [PATCH 16/50] WIP --- packages/database/src/DatabaseModel.js | 5 +- packages/database/src/TupaiaDatabase.js | 3 + .../src/modelClasses/PermissionGroup.js | 11 +++ packages/database/src/modelClasses/User.js | 8 ++ packages/datatrak-web-server/examples.http | 5 ++ .../datatrak-web-server/src/app/createApp.ts | 3 + .../src/routes/SurveyUsersRoute.ts | 83 +++++++++++++++++++ .../src/routes/UserRoute.ts | 3 +- .../datatrak-web-server/src/routes/index.ts | 1 + packages/datatrak-web-server/src/types.ts | 4 + .../datatrak-web/src/api/queries/index.ts | 1 + .../src/api/queries/useSurveyUsers.ts | 31 +++++++ .../features/Leaderboard/LeaderboardTable.tsx | 2 +- .../Tasks/CreateTaskModal/AssigneeInput.tsx | 54 ++++++++++++ .../Tasks/CreateTaskModal/CreateTaskModal.tsx | 18 +++- .../CreateTaskModal/RepeatScheduleInput.tsx | 1 + .../src/features/Tasks/DueDatePicker.tsx | 4 + .../src/layout/UserMenu/DrawerMenu.tsx | 2 +- .../src/layout/UserMenu/UserInfo.tsx | 4 +- .../DeleteAccountSection/UserDetails.tsx | 2 +- .../server-boilerplate/src/models/User.ts | 1 + .../src/models/UserEntityPermission.ts | 7 +- .../datatrak-web-server/SurveyUsersRequest.ts | 17 ++++ .../datatrak-web-server/UserRequest.ts | 2 +- .../requests/datatrak-web-server/index.ts | 1 + packages/types/src/types/requests/index.ts | 1 + 26 files changed, 260 insertions(+), 14 deletions(-) create mode 100644 packages/datatrak-web-server/src/routes/SurveyUsersRoute.ts create mode 100644 packages/datatrak-web/src/api/queries/useSurveyUsers.ts create mode 100644 packages/datatrak-web/src/features/Tasks/CreateTaskModal/AssigneeInput.tsx create mode 100644 packages/types/src/types/requests/datatrak-web-server/SurveyUsersRequest.ts diff --git a/packages/database/src/DatabaseModel.js b/packages/database/src/DatabaseModel.js index 02c355ded1..821c669d56 100644 --- a/packages/database/src/DatabaseModel.js +++ b/packages/database/src/DatabaseModel.js @@ -78,7 +78,9 @@ export class DatabaseModel { if (!this.fieldNames) { const schema = await this.fetchSchema(); - this.fieldNames = Object.keys(schema); + const customColumnSelectors = this.customColumnSelectors || {}; + + this.fieldNames = [...Object.keys(schema), ...Object.keys(customColumnSelectors)]; } return this.fieldNames; } @@ -120,6 +122,7 @@ export class DatabaseModel { // 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.fullyQualifyColumn(fieldName); const customSelector = this.customColumnSelectors && this.customColumnSelectors[fieldName]; diff --git a/packages/database/src/TupaiaDatabase.js b/packages/database/src/TupaiaDatabase.js index 18d2d4887c..d2b20802d6 100644 --- a/packages/database/src/TupaiaDatabase.js +++ b/packages/database/src/TupaiaDatabase.js @@ -514,6 +514,7 @@ export class TupaiaDatabase { */ function buildQuery(connection, queryConfig, where = {}, options = {}) { const { recordType, queryMethod, queryMethodParameter } = queryConfig; + let query = connection(recordType); // Query starts as just the table, but will be built up // If an innerQuery is defined, make the outer query wrap it @@ -676,6 +677,7 @@ function addWhereClause(connection, baseQuery, where) { } const columnKey = getColSelector(connection, key); + const columnSelector = castAs ? connection.raw(`??::${castAs}`, [columnKey]) : columnKey; const { args = [comparator, sanitizeComparisonValue(comparator, comparisonValue)] } = value; @@ -740,6 +742,7 @@ function getColSelector(connection, inputColStr) { return connection.raw(`COALESCE(${identifiers})`, bindings); } const casePattern = /^CASE/; + if (casePattern.test(inputColStr)) { return connection.raw(inputColStr); } diff --git a/packages/database/src/modelClasses/PermissionGroup.js b/packages/database/src/modelClasses/PermissionGroup.js index ae542f1588..033589a32f 100644 --- a/packages/database/src/modelClasses/PermissionGroup.js +++ b/packages/database/src/modelClasses/PermissionGroup.js @@ -34,6 +34,17 @@ export class PermissionGroupRecord extends DatabaseRecord { permissionGroupTree.map(treeItemFields => this.model.generateInstance(treeItemFields)), ); } + + async getAncestors() { + const permissionGroupTree = await this.model.database.findWithParents( + this.constructor.databaseRecord, + this.id, + ); + + return Promise.all( + permissionGroupTree.map(treeItemFields => this.model.generateInstance(treeItemFields)), + ); + } } export class PermissionGroupModel extends DatabaseModel { diff --git a/packages/database/src/modelClasses/User.js b/packages/database/src/modelClasses/User.js index 79a61e0ba4..fd87496f81 100644 --- a/packages/database/src/modelClasses/User.js +++ b/packages/database/src/modelClasses/User.js @@ -53,6 +53,14 @@ export class UserModel extends DatabaseModel { return user; } + customColumnSelectors = { + full_name: () => + `CASE + WHEN last_name IS NULL THEN first_name + ELSE first_name || ' ' || last_name + END`, + }; + emailVerifiedStatuses = { UNVERIFIED: 'unverified', VERIFIED: 'verified', diff --git a/packages/datatrak-web-server/examples.http b/packages/datatrak-web-server/examples.http index a30fb2dc92..3031017e47 100644 --- a/packages/datatrak-web-server/examples.http +++ b/packages/datatrak-web-server/examples.http @@ -52,3 +52,8 @@ content-type: {{contentType}} ### Get survey GET {{host}}/surveys/TAR HTTP/1.1 content-type: {{contentType}} + + +### Get survey users +GET {{host}}/users/TAR HTTP/1.1 +content-type: {{contentType}} diff --git a/packages/datatrak-web-server/src/app/createApp.ts b/packages/datatrak-web-server/src/app/createApp.ts index 8227e79a5c..fc71cd252f 100644 --- a/packages/datatrak-web-server/src/app/createApp.ts +++ b/packages/datatrak-web-server/src/app/createApp.ts @@ -47,6 +47,8 @@ import { GenerateLoginTokenRequest, TasksRequest, TasksRoute, + SurveyUsersRequest, + SurveyUsersRoute, } from '../routes'; import { attachAccessPolicy } from './middleware'; @@ -82,6 +84,7 @@ export async function createApp() { .get('activityFeed', handleWith(ActivityFeedRoute)) .get('tasks', handleWith(TasksRoute)) .get('surveyResponse/:id', handleWith(SingleSurveyResponseRoute)) + .get('users/:surveyCode', handleWith(SurveyUsersRoute)) // Forward auth requests to web-config .use('signup', forwardRequest(WEB_CONFIG_API_URL, { authHandlerProvider })) .use('resendEmail', forwardRequest(WEB_CONFIG_API_URL, { authHandlerProvider })) diff --git a/packages/datatrak-web-server/src/routes/SurveyUsersRoute.ts b/packages/datatrak-web-server/src/routes/SurveyUsersRoute.ts new file mode 100644 index 0000000000..66459c1d91 --- /dev/null +++ b/packages/datatrak-web-server/src/routes/SurveyUsersRoute.ts @@ -0,0 +1,83 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { Request } from 'express'; +import { Route } from '@tupaia/server-boilerplate'; +import { DatatrakWebSurveyUsersRequest } from '@tupaia/types'; + +export type SurveyUsersRequest = Request< + DatatrakWebSurveyUsersRequest.Params, + DatatrakWebSurveyUsersRequest.ResBody, + DatatrakWebSurveyUsersRequest.ReqBody, + DatatrakWebSurveyUsersRequest.ReqQuery +>; + +const DEFAULT_PAGE_SIZE = 100; + +export class SurveyUsersRoute extends Route { + public async buildResponse() { + const { models, params, query } = this.req; + const { surveyCode } = params; + + const { filter } = query; + + const survey = await models.survey.findOne({ code: surveyCode }); + + if (!survey) { + throw new Error(`Survey with code ${surveyCode} not found`); + } + + const { permission_group_id: permissionGroupId, country_ids: countryIds } = survey; + + if (!permissionGroupId) { + return []; + } + + // get the permission group + const permissionGroup = await models.permissionGroup.findById(permissionGroupId); + + if (!permissionGroup) { + throw new Error(`Permission group with id ${permissionGroupId} not found`); + } + + // get the ancestors of the permission group + const permissionGroupWithAncestors = await permissionGroup.getAncestors(); + + // get the user entity permissions for the permission group and its ancestors + let userEntityPermissions = await models.userEntityPermission.find({ + permission_group_id: permissionGroupWithAncestors.map(p => p.id), + }); + + // filter the user entity permissions by country + if (countryIds) { + const countries = await models.country.find({ id: countryIds }); + // the countryIds are not the same as entityIds so we need to get the country codes and filter by them instead + const countryCodes = countries.map(c => c.code); + userEntityPermissions = userEntityPermissions.filter(uep => { + // @ts-ignore + if (!uep.entity_code) return false; + // @ts-ignore + return countryCodes.includes(uep.entity_code); + }); + } + + const userIds = userEntityPermissions.map(uep => uep.user_id); + + const users = await models.user.find( + { id: userIds, ...filter }, + { + sort: ['full_name ASC'], + limit: DEFAULT_PAGE_SIZE, + }, + ); + const userData = users.map(user => ({ + id: user.id, + name: user.full_name, + })); + + // only return the id and name of the users + return userData; + } +} diff --git a/packages/datatrak-web-server/src/routes/UserRoute.ts b/packages/datatrak-web-server/src/routes/UserRoute.ts index 42803ca0ce..5211769758 100644 --- a/packages/datatrak-web-server/src/routes/UserRoute.ts +++ b/packages/datatrak-web-server/src/routes/UserRoute.ts @@ -28,6 +28,7 @@ export class UserRoute extends Route { const { id, + full_name: fullName, first_name: firstName, last_name: lastName, email, @@ -57,7 +58,7 @@ export class UserRoute extends Route { } return { - userName: `${firstName} ${lastName}`, + fullName, firstName, lastName, email, diff --git a/packages/datatrak-web-server/src/routes/index.ts b/packages/datatrak-web-server/src/routes/index.ts index 5d52969cae..be6748506c 100644 --- a/packages/datatrak-web-server/src/routes/index.ts +++ b/packages/datatrak-web-server/src/routes/index.ts @@ -22,3 +22,4 @@ export { ActivityFeedRequest, ActivityFeedRoute } from './ActivityFeedRoute'; export { EntitiesRequest, EntitiesRoute } from './EntitiesRoute'; export { GenerateLoginTokenRequest, GenerateLoginTokenRoute } from './GenerateLoginTokenRoute'; export { TasksRequest, TasksRoute } from './TasksRoute'; +export { SurveyUsersRequest, SurveyUsersRoute } from './SurveyUsersRoute'; diff --git a/packages/datatrak-web-server/src/types.ts b/packages/datatrak-web-server/src/types.ts index a0f22c169a..0081748abb 100644 --- a/packages/datatrak-web-server/src/types.ts +++ b/packages/datatrak-web-server/src/types.ts @@ -9,9 +9,11 @@ import { FeedItemModel, OneTimeLoginModel, OptionModel, + PermissionGroupModel, SurveyModel, SurveyResponseModel, TaskModel, + UserEntityPermissionModel, UserModel, } from '@tupaia/server-boilerplate'; @@ -25,4 +27,6 @@ export interface DatatrakWebServerModelRegistry extends ModelRegistry { readonly oneTimeLogin: OneTimeLoginModel; readonly option: OptionModel; readonly task: TaskModel; + readonly permissionGroup: PermissionGroupModel; + readonly userEntityPermission: UserEntityPermissionModel; } diff --git a/packages/datatrak-web/src/api/queries/index.ts b/packages/datatrak-web/src/api/queries/index.ts index 499a76cb80..8bc25ddf34 100644 --- a/packages/datatrak-web/src/api/queries/index.ts +++ b/packages/datatrak-web/src/api/queries/index.ts @@ -21,3 +21,4 @@ export { useActivityFeed, useCurrentProjectActivityFeed } from './useActivityFee export { useProjectSurveys } from './useProjectSurveys'; export { useEntities } from './useEntities'; export { useTasks } from './useTasks'; +export { useSurveyUsers } from './useSurveyUsers'; diff --git a/packages/datatrak-web/src/api/queries/useSurveyUsers.ts b/packages/datatrak-web/src/api/queries/useSurveyUsers.ts new file mode 100644 index 0000000000..c96aacfbf4 --- /dev/null +++ b/packages/datatrak-web/src/api/queries/useSurveyUsers.ts @@ -0,0 +1,31 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { useQuery } from 'react-query'; +import { DatatrakWebSurveyUsersRequest } from '@tupaia/types'; +import { get } from '../api'; +import { Survey } from '../../types'; + +export const useSurveyUsers = (surveyCode?: Survey['code'], searchTerm?: string) => { + return useQuery( + ['surveyUsers', surveyCode, searchTerm], + (): Promise => + get(`users/${surveyCode}`, { + params: { + filter: searchTerm + ? { + full_name: { + comparator: 'ilike', + comparisonValue: `${searchTerm}%`, + }, + } + : undefined, + }, + }), + { + enabled: !!surveyCode, + }, + ); +}; diff --git a/packages/datatrak-web/src/features/Leaderboard/LeaderboardTable.tsx b/packages/datatrak-web/src/features/Leaderboard/LeaderboardTable.tsx index c5db50f901..5b846f5e1c 100644 --- a/packages/datatrak-web/src/features/Leaderboard/LeaderboardTable.tsx +++ b/packages/datatrak-web/src/features/Leaderboard/LeaderboardTable.tsx @@ -120,7 +120,7 @@ export const LeaderboardTable = ({ - {user?.userName} + {user?.fullName} {userRewards?.coconuts} diff --git a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/AssigneeInput.tsx b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/AssigneeInput.tsx new file mode 100644 index 0000000000..8980cc3bb4 --- /dev/null +++ b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/AssigneeInput.tsx @@ -0,0 +1,54 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import React, { useState } from 'react'; +import throttle from 'lodash.throttle'; +import { useWatch } from 'react-hook-form'; +import { DatatrakWebSurveyUsersRequest } from '@tupaia/types'; +import { Autocomplete } from '../../../components'; +import { useSurveyUsers } from '../../../api'; + +type User = DatatrakWebSurveyUsersRequest.ResBody[0]; + +interface AssigneeInputProps { + value: string | null; + onChange: (value: User | null) => void; + inputRef?: React.Ref; +} + +export const AssigneeInput = ({ value, onChange, inputRef }: AssigneeInputProps) => { + const [searchValue, setSearchValue] = useState(''); + const { surveyCode } = useWatch('surveyCode'); + + const { data: users = [] } = useSurveyUsers(surveyCode, searchValue); + + const onChangeAssignee = (_e, newSelection: User | null) => { + onChange(newSelection); + }; + + const options = + users?.map(user => ({ + ...user, + value: user.id, + label: user.name, + })) ?? []; + + return ( + { + setSearchValue(newValue); + }, 200)} + inputValue={searchValue} + getOptionLabel={option => option.name} + getOptionSelected={(option, selectedOption) => option.id === selectedOption?.id} + placeholder="Search..." + /> + ); +}; diff --git a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx index 4abdd6312e..d076ec59b9 100644 --- a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx +++ b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx @@ -12,6 +12,7 @@ import { GroupedSurveyList } from '../../GroupedSurveyList'; import { DueDatePicker } from '../DueDatePicker'; import { RepeatScheduleInput } from './RepeatScheduleInput'; import { EntityInput } from './EntityInput'; +import { AssigneeInput } from './AssigneeInput'; const CountrySelectorWrapper = styled.div` margin-inline-start: auto; @@ -62,11 +63,10 @@ const ListSelectWrapper = styled.div` const InputRow = styled.div` display: flex; + justify-content: space-between; > * { - flex: 1; - &:first-child { - margin-inline-end: 1rem; - } + width: 48%; + margin-block-end: 0; } `; @@ -157,6 +157,16 @@ export const CreateTaskModal = ({ open, onClose }: CreateTaskModalProps) => { /> + + ( + + )} + /> + + {/* */} diff --git a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/RepeatScheduleInput.tsx b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/RepeatScheduleInput.tsx index 07bef65488..da2c1136cc 100644 --- a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/RepeatScheduleInput.tsx +++ b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/RepeatScheduleInput.tsx @@ -34,6 +34,7 @@ const useRepeatScheduleOptions = dueDate => { ? 'Monthly on the last day' : `Monthly on the ${dateOfMonth}`; + // TODO: When saving, add some logic here when we handle recurring tasks return [ noRepeat, { diff --git a/packages/datatrak-web/src/features/Tasks/DueDatePicker.tsx b/packages/datatrak-web/src/features/Tasks/DueDatePicker.tsx index e75e720f4b..989ba59625 100644 --- a/packages/datatrak-web/src/features/Tasks/DueDatePicker.tsx +++ b/packages/datatrak-web/src/features/Tasks/DueDatePicker.tsx @@ -8,6 +8,9 @@ import styled from 'styled-components'; import { DatePicker } from '@tupaia/ui-components'; const Wrapper = styled.div` + .MuiFormControl-root { + margin-block-end: 0; + } .MuiButtonBase-root.MuiIconButton-root { color: ${props => props.theme.palette.primary.main}; } @@ -26,6 +29,7 @@ const Wrapper = styled.div` } .MuiFormLabel-root { margin-block-end: 0.25rem; + line-height: 1.2; } .MuiSvgIcon-root { font-size: 1rem; diff --git a/packages/datatrak-web/src/layout/UserMenu/DrawerMenu.tsx b/packages/datatrak-web/src/layout/UserMenu/DrawerMenu.tsx index 85bfaa4525..d091b172fa 100644 --- a/packages/datatrak-web/src/layout/UserMenu/DrawerMenu.tsx +++ b/packages/datatrak-web/src/layout/UserMenu/DrawerMenu.tsx @@ -105,7 +105,7 @@ export const DrawerMenu = ({ menuOpen, onCloseMenu, openProjectModal }: DrawerMe > - {user.userName && {user.userName}} + {user.fullName && {user.fullName}} {user.project?.name && ( {user.project.name} diff --git a/packages/datatrak-web/src/layout/UserMenu/UserInfo.tsx b/packages/datatrak-web/src/layout/UserMenu/UserInfo.tsx index a8f89e2a6a..9fe9ed4a2f 100644 --- a/packages/datatrak-web/src/layout/UserMenu/UserInfo.tsx +++ b/packages/datatrak-web/src/layout/UserMenu/UserInfo.tsx @@ -60,13 +60,13 @@ const AuthButtons = styled.div` * This is the displayed user name OR the login/register buttons on desktop */ export const UserInfo = () => { - const { isLoggedIn, projectId, userName } = useCurrentUserContext(); + const { isLoggedIn, projectId, fullName } = useCurrentUserContext(); return ( {isLoggedIn ? ( - {userName} + {fullName} {projectId && } ) : ( diff --git a/packages/datatrak-web/src/views/AccountSettingsPage/DeleteAccountSection/UserDetails.tsx b/packages/datatrak-web/src/views/AccountSettingsPage/DeleteAccountSection/UserDetails.tsx index 339b6cc974..b36a670cf7 100644 --- a/packages/datatrak-web/src/views/AccountSettingsPage/DeleteAccountSection/UserDetails.tsx +++ b/packages/datatrak-web/src/views/AccountSettingsPage/DeleteAccountSection/UserDetails.tsx @@ -64,7 +64,7 @@ export const UserDetails = () => { return (
- {user.userName} + {user.fullName} {user.email}
diff --git a/packages/server-boilerplate/src/models/User.ts b/packages/server-boilerplate/src/models/User.ts index 8a687c812d..a37a12638e 100644 --- a/packages/server-boilerplate/src/models/User.ts +++ b/packages/server-boilerplate/src/models/User.ts @@ -9,6 +9,7 @@ import { Model } from './types'; export interface UserRecord extends UserAccount, BaseUserRecord { getData: () => Promise>; + full_name: string; } export interface UserModel extends Model {} diff --git a/packages/server-boilerplate/src/models/UserEntityPermission.ts b/packages/server-boilerplate/src/models/UserEntityPermission.ts index d44ec0e910..774a04f45b 100644 --- a/packages/server-boilerplate/src/models/UserEntityPermission.ts +++ b/packages/server-boilerplate/src/models/UserEntityPermission.ts @@ -6,12 +6,15 @@ import { UserEntityPermissionModel as BaseUserEntityPermissionModel, UserEntityPermissionRecord as BaseUserEntityPermissionRecord, } from '@tupaia/database'; -import { UserEntityPermission } from '@tupaia/types'; +import { Entity, PermissionGroup, UserEntityPermission } from '@tupaia/types'; import { Model } from './types'; export interface UserEntityPermissionRecord extends UserEntityPermission, - BaseUserEntityPermissionRecord {} + BaseUserEntityPermissionRecord { + entity_code?: Entity['code']; + permission_group_name?: PermissionGroup['name']; +} export interface UserEntityPermissionModel extends Model {} diff --git a/packages/types/src/types/requests/datatrak-web-server/SurveyUsersRequest.ts b/packages/types/src/types/requests/datatrak-web-server/SurveyUsersRequest.ts new file mode 100644 index 0000000000..868bd41cfd --- /dev/null +++ b/packages/types/src/types/requests/datatrak-web-server/SurveyUsersRequest.ts @@ -0,0 +1,17 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +export type Params = Record; + +type UserResponse = { + id: string; + name: string; +}; + +export type ResBody = UserResponse[]; +export type ReqBody = Record; +export interface ReqQuery { + filter?: Record; +} diff --git a/packages/types/src/types/requests/datatrak-web-server/UserRequest.ts b/packages/types/src/types/requests/datatrak-web-server/UserRequest.ts index 6e9320e5e1..87699714b8 100644 --- a/packages/types/src/types/requests/datatrak-web-server/UserRequest.ts +++ b/packages/types/src/types/requests/datatrak-web-server/UserRequest.ts @@ -9,7 +9,7 @@ import { Country } from '../../models'; export type Params = Record; export interface ResBody { id?: string; - userName?: string; + fullName?: string; firstName?: string; lastName?: string; email?: string; diff --git a/packages/types/src/types/requests/datatrak-web-server/index.ts b/packages/types/src/types/requests/datatrak-web-server/index.ts index b0f7d57ffc..bd322b21a7 100644 --- a/packages/types/src/types/requests/datatrak-web-server/index.ts +++ b/packages/types/src/types/requests/datatrak-web-server/index.ts @@ -17,3 +17,4 @@ export * as DatatrakWebActivityFeedRequest from './ActivityFeedRequest'; export * as DatatrakWebGenerateLoginTokenRequest from './GenerateLoginTokenRequest'; export * as DatatrakWebEntityDescendantsRequest from './EntityDescendantsRequest'; export * as DatatrakWebTasksRequest from './TasksRequest'; +export * as DatatrakWebSurveyUsersRequest from './SurveyUsersRequest'; diff --git a/packages/types/src/types/requests/index.ts b/packages/types/src/types/requests/index.ts index e62566e1e0..2fbd6b10b7 100644 --- a/packages/types/src/types/requests/index.ts +++ b/packages/types/src/types/requests/index.ts @@ -20,6 +20,7 @@ export { DatatrakWebGenerateLoginTokenRequest, DatatrakWebEntityDescendantsRequest, DatatrakWebTasksRequest, + DatatrakWebSurveyUsersRequest, } from './datatrak-web-server'; export { TupaiaWebChangePasswordRequest, From 90a7f385ed9c1af4aadba77cbbe9de2e86bc045f Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Tue, 25 Jun 2024 15:56:39 +1200 Subject: [PATCH 17/50] WIP --- packages/database/src/DatabaseModel.js | 20 +++++++++++++------ .../src/routes/SurveyUsersRoute.ts | 13 +++++++++++- .../src/api/queries/useSurveyUsers.ts | 2 +- .../Tasks/CreateTaskModal/AssigneeInput.tsx | 2 +- 4 files changed, 28 insertions(+), 9 deletions(-) diff --git a/packages/database/src/DatabaseModel.js b/packages/database/src/DatabaseModel.js index 821c669d56..f31367c1c0 100644 --- a/packages/database/src/DatabaseModel.js +++ b/packages/database/src/DatabaseModel.js @@ -125,9 +125,9 @@ export class DatabaseModel { return fieldNames.map(fieldName => { const qualifiedName = this.fullyQualifyColumn(fieldName); - const customSelector = this.customColumnSelectors && this.customColumnSelectors[fieldName]; - if (customSelector) { - return { [fieldName]: customSelector(qualifiedName) }; + const customColumnSelector = this.getColumnSelector(fieldName, qualifiedName); + if (customColumnSelector) { + return { [fieldName]: customColumnSelector }; } return qualifiedName; }); @@ -151,6 +151,14 @@ export class DatabaseModel { return { ...options, ...customQueryOptions }; } + getColumnSelector(fieldName, qualifiedName) { + const customSelector = this.customColumnSelectors && this.customColumnSelectors[fieldName]; + if (customSelector) { + return customSelector(qualifiedName); + } + return null; + } + async getDbConditions(dbConditions = {}) { const fieldNames = await this.fetchFieldNames(); const fullyQualifiedConditions = {}; @@ -165,9 +173,9 @@ export class DatabaseModel { // Don't touch RAW conditions fullyQualifiedConditions[field] = value; } else { - const fullyQualifiedField = fieldNames.includes(field) - ? this.fullyQualifyColumn(field) - : field; + const qualifiedName = this.fullyQualifyColumn(field); + const fieldSelector = this.getColumnSelector(field, qualifiedName) ?? qualifiedName; + const fullyQualifiedField = fieldNames.includes(field) ? fieldSelector : field; fullyQualifiedConditions[fullyQualifiedField] = value; } } diff --git a/packages/datatrak-web-server/src/routes/SurveyUsersRoute.ts b/packages/datatrak-web-server/src/routes/SurveyUsersRoute.ts index 66459c1d91..ccc318b57d 100644 --- a/packages/datatrak-web-server/src/routes/SurveyUsersRoute.ts +++ b/packages/datatrak-web-server/src/routes/SurveyUsersRoute.ts @@ -6,6 +6,7 @@ import { Request } from 'express'; import { Route } from '@tupaia/server-boilerplate'; import { DatatrakWebSurveyUsersRequest } from '@tupaia/types'; +import { QUERY_CONJUNCTIONS } from '@tupaia/database'; export type SurveyUsersRequest = Request< DatatrakWebSurveyUsersRequest.Params, @@ -16,6 +17,8 @@ export type SurveyUsersRequest = Request< const DEFAULT_PAGE_SIZE = 100; +const E2E_USER = 'test_e2e@beyondessential.com.au'; + export class SurveyUsersRoute extends Route { public async buildResponse() { const { models, params, query } = this.req; @@ -66,7 +69,15 @@ export class SurveyUsersRoute extends Route { const userIds = userEntityPermissions.map(uep => uep.user_id); const users = await models.user.find( - { id: userIds, ...filter }, + { + id: userIds, + ...filter, + // exclude the e2e user and any user with a tupaia.org email, as these are api-client users + email: { comparator: '!=', comparisonValue: E2E_USER }, + [QUERY_CONJUNCTIONS.AND]: { + email: { comparator: 'not like', comparisonValue: '%@tupaia.org' }, + }, + }, { sort: ['full_name ASC'], limit: DEFAULT_PAGE_SIZE, diff --git a/packages/datatrak-web/src/api/queries/useSurveyUsers.ts b/packages/datatrak-web/src/api/queries/useSurveyUsers.ts index c96aacfbf4..7b4b6a6aec 100644 --- a/packages/datatrak-web/src/api/queries/useSurveyUsers.ts +++ b/packages/datatrak-web/src/api/queries/useSurveyUsers.ts @@ -11,7 +11,7 @@ import { Survey } from '../../types'; export const useSurveyUsers = (surveyCode?: Survey['code'], searchTerm?: string) => { return useQuery( ['surveyUsers', surveyCode, searchTerm], - (): Promise => + (): Promise => get(`users/${surveyCode}`, { params: { filter: searchTerm diff --git a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/AssigneeInput.tsx b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/AssigneeInput.tsx index 8980cc3bb4..eee53a0bf5 100644 --- a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/AssigneeInput.tsx +++ b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/AssigneeInput.tsx @@ -46,7 +46,7 @@ export const AssigneeInput = ({ value, onChange, inputRef }: AssigneeInputProps) setSearchValue(newValue); }, 200)} inputValue={searchValue} - getOptionLabel={option => option.name} + getOptionLabel={option => option.label} getOptionSelected={(option, selectedOption) => option.id === selectedOption?.id} placeholder="Search..." /> From 6d22957603463c7b95d821c2fc3ae87e65f00e1f Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Tue, 25 Jun 2024 16:26:21 +1200 Subject: [PATCH 18/50] assignee input --- .../datatrak-web-server/src/app/createApp.ts | 2 +- .../src/routes/SurveyUsersRoute.ts | 27 +++++++------------ .../src/api/queries/useSurveyUsers.ts | 14 ++++++---- .../Tasks/CreateTaskModal/AssigneeInput.tsx | 12 ++++++--- .../Tasks/CreateTaskModal/CreateTaskModal.tsx | 9 ++++++- 5 files changed, 37 insertions(+), 27 deletions(-) diff --git a/packages/datatrak-web-server/src/app/createApp.ts b/packages/datatrak-web-server/src/app/createApp.ts index fc71cd252f..6d253c204f 100644 --- a/packages/datatrak-web-server/src/app/createApp.ts +++ b/packages/datatrak-web-server/src/app/createApp.ts @@ -84,7 +84,7 @@ export async function createApp() { .get('activityFeed', handleWith(ActivityFeedRoute)) .get('tasks', handleWith(TasksRoute)) .get('surveyResponse/:id', handleWith(SingleSurveyResponseRoute)) - .get('users/:surveyCode', handleWith(SurveyUsersRoute)) + .get('users/:surveyCode/:countryCode', handleWith(SurveyUsersRoute)) // Forward auth requests to web-config .use('signup', forwardRequest(WEB_CONFIG_API_URL, { authHandlerProvider })) .use('resendEmail', forwardRequest(WEB_CONFIG_API_URL, { authHandlerProvider })) diff --git a/packages/datatrak-web-server/src/routes/SurveyUsersRoute.ts b/packages/datatrak-web-server/src/routes/SurveyUsersRoute.ts index ccc318b57d..5b8654afb0 100644 --- a/packages/datatrak-web-server/src/routes/SurveyUsersRoute.ts +++ b/packages/datatrak-web-server/src/routes/SurveyUsersRoute.ts @@ -5,7 +5,7 @@ import { Request } from 'express'; import { Route } from '@tupaia/server-boilerplate'; -import { DatatrakWebSurveyUsersRequest } from '@tupaia/types'; +import { DatatrakWebSurveyUsersRequest, EntityType } from '@tupaia/types'; import { QUERY_CONJUNCTIONS } from '@tupaia/database'; export type SurveyUsersRequest = Request< @@ -22,7 +22,7 @@ const E2E_USER = 'test_e2e@beyondessential.com.au'; export class SurveyUsersRoute extends Route { public async buildResponse() { const { models, params, query } = this.req; - const { surveyCode } = params; + const { surveyCode, countryCode } = params; const { filter } = query; @@ -32,7 +32,7 @@ export class SurveyUsersRoute extends Route { throw new Error(`Survey with code ${surveyCode} not found`); } - const { permission_group_id: permissionGroupId, country_ids: countryIds } = survey; + const { permission_group_id: permissionGroupId } = survey; if (!permissionGroupId) { return []; @@ -48,24 +48,17 @@ export class SurveyUsersRoute extends Route { // get the ancestors of the permission group const permissionGroupWithAncestors = await permissionGroup.getAncestors(); + const entity = await models.entity.findOne({ + country_code: countryCode, + type: EntityType.country, + }); + // get the user entity permissions for the permission group and its ancestors - let userEntityPermissions = await models.userEntityPermission.find({ + const userEntityPermissions = await models.userEntityPermission.find({ permission_group_id: permissionGroupWithAncestors.map(p => p.id), + entity_id: entity.id, }); - // filter the user entity permissions by country - if (countryIds) { - const countries = await models.country.find({ id: countryIds }); - // the countryIds are not the same as entityIds so we need to get the country codes and filter by them instead - const countryCodes = countries.map(c => c.code); - userEntityPermissions = userEntityPermissions.filter(uep => { - // @ts-ignore - if (!uep.entity_code) return false; - // @ts-ignore - return countryCodes.includes(uep.entity_code); - }); - } - const userIds = userEntityPermissions.map(uep => uep.user_id); const users = await models.user.find( diff --git a/packages/datatrak-web/src/api/queries/useSurveyUsers.ts b/packages/datatrak-web/src/api/queries/useSurveyUsers.ts index 7b4b6a6aec..966d416e84 100644 --- a/packages/datatrak-web/src/api/queries/useSurveyUsers.ts +++ b/packages/datatrak-web/src/api/queries/useSurveyUsers.ts @@ -4,15 +4,19 @@ */ import { useQuery } from 'react-query'; -import { DatatrakWebSurveyUsersRequest } from '@tupaia/types'; +import { Country, DatatrakWebSurveyUsersRequest } from '@tupaia/types'; import { get } from '../api'; import { Survey } from '../../types'; -export const useSurveyUsers = (surveyCode?: Survey['code'], searchTerm?: string) => { +export const useSurveyUsers = ( + surveyCode?: Survey['code'], + countryCode?: Country['code'], + searchTerm?: string, +) => { return useQuery( - ['surveyUsers', surveyCode, searchTerm], + ['surveyUsers', surveyCode, countryCode, searchTerm], (): Promise => - get(`users/${surveyCode}`, { + get(`users/${surveyCode}/${countryCode}`, { params: { filter: searchTerm ? { @@ -25,7 +29,7 @@ export const useSurveyUsers = (surveyCode?: Survey['code'], searchTerm?: string) }, }), { - enabled: !!surveyCode, + enabled: !!surveyCode && !!countryCode, }, ); }; diff --git a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/AssigneeInput.tsx b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/AssigneeInput.tsx index eee53a0bf5..77d0123e91 100644 --- a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/AssigneeInput.tsx +++ b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/AssigneeInput.tsx @@ -5,7 +5,7 @@ import React, { useState } from 'react'; import throttle from 'lodash.throttle'; import { useWatch } from 'react-hook-form'; -import { DatatrakWebSurveyUsersRequest } from '@tupaia/types'; +import { Country, DatatrakWebSurveyUsersRequest } from '@tupaia/types'; import { Autocomplete } from '../../../components'; import { useSurveyUsers } from '../../../api'; @@ -15,13 +15,19 @@ interface AssigneeInputProps { value: string | null; onChange: (value: User | null) => void; inputRef?: React.Ref; + selectedCountry?: Country | null; } -export const AssigneeInput = ({ value, onChange, inputRef }: AssigneeInputProps) => { +export const AssigneeInput = ({ + value, + onChange, + inputRef, + selectedCountry, +}: AssigneeInputProps) => { const [searchValue, setSearchValue] = useState(''); const { surveyCode } = useWatch('surveyCode'); - const { data: users = [] } = useSurveyUsers(surveyCode, searchValue); + const { data: users = [] } = useSurveyUsers(surveyCode, selectedCountry?.code, searchValue); const onChangeAssignee = (_e, newSelection: User | null) => { onChange(newSelection); diff --git a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx index d076ec59b9..390e29a139 100644 --- a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx +++ b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx @@ -64,6 +64,7 @@ const ListSelectWrapper = styled.div` const InputRow = styled.div` display: flex; justify-content: space-between; + margin-block-end: 1rem; > * { width: 48%; margin-block-end: 0; @@ -162,7 +163,13 @@ export const CreateTaskModal = ({ open, onClose }: CreateTaskModalProps) => { name="assigneeId" control={control} render={({ ref, value, onChange, ...field }) => ( - + )} /> From 42bee49178e0794954382852844699d9556d1ad5 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Tue, 25 Jun 2024 16:32:56 +1200 Subject: [PATCH 19/50] Add loading state and save user id --- .../features/Tasks/CreateTaskModal/AssigneeInput.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/AssigneeInput.tsx b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/AssigneeInput.tsx index 77d0123e91..f783c2d46c 100644 --- a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/AssigneeInput.tsx +++ b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/AssigneeInput.tsx @@ -13,7 +13,7 @@ type User = DatatrakWebSurveyUsersRequest.ResBody[0]; interface AssigneeInputProps { value: string | null; - onChange: (value: User | null) => void; + onChange: (value: User['id'] | null) => void; inputRef?: React.Ref; selectedCountry?: Country | null; } @@ -27,10 +27,14 @@ export const AssigneeInput = ({ const [searchValue, setSearchValue] = useState(''); const { surveyCode } = useWatch('surveyCode'); - const { data: users = [] } = useSurveyUsers(surveyCode, selectedCountry?.code, searchValue); + const { data: users = [], isLoading } = useSurveyUsers( + surveyCode, + selectedCountry?.code, + searchValue, + ); const onChangeAssignee = (_e, newSelection: User | null) => { - onChange(newSelection); + onChange(newSelection?.id ?? null); }; const options = @@ -55,6 +59,7 @@ export const AssigneeInput = ({ getOptionLabel={option => option.label} getOptionSelected={(option, selectedOption) => option.id === selectedOption?.id} placeholder="Search..." + loading={isLoading} /> ); }; From 8e7c924f37460fa94fc1e96f6591e6bda17ba420 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Tue, 25 Jun 2024 16:35:09 +1200 Subject: [PATCH 20/50] Styling repeat scheduler --- .../Tasks/CreateTaskModal/RepeatScheduleInput.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/RepeatScheduleInput.tsx b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/RepeatScheduleInput.tsx index da2c1136cc..7e5f280a60 100644 --- a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/RepeatScheduleInput.tsx +++ b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/RepeatScheduleInput.tsx @@ -3,9 +3,16 @@ * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ import React from 'react'; -import { Select, MenuItem, FormControl, FormLabel } from '@material-ui/core'; +import { Select as BaseSelect, MenuItem, FormControl, FormLabel } from '@material-ui/core'; import { format, lastDayOfMonth } from 'date-fns'; import { useWatch } from 'react-hook-form'; +import styled from 'styled-components'; + +const Select = styled(BaseSelect)` + &.Mui-disabled { + background-color: ${({ theme }) => theme.palette.background.default}; + } +`; const useRepeatScheduleOptions = dueDate => { const noRepeat = { From aed01e9c3626f3aa8235a7d1f69710e8f0243eec Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Tue, 25 Jun 2024 16:39:14 +1200 Subject: [PATCH 21/50] Comments placholder --- .../Tasks/CreateTaskModal/CreateTaskModal.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx index 390e29a139..4013db1e39 100644 --- a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx +++ b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx @@ -6,7 +6,7 @@ import React from 'react'; import styled from 'styled-components'; import { useForm, Controller, FormProvider } from 'react-hook-form'; -import { Modal } from '@tupaia/ui-components'; +import { Modal, TextField } from '@tupaia/ui-components'; import { CountrySelector, useUserCountries } from '../../CountrySelector'; import { GroupedSurveyList } from '../../GroupedSurveyList'; import { DueDatePicker } from '../DueDatePicker'; @@ -71,6 +71,17 @@ const InputRow = styled.div` } `; +const CommentsInput = styled(TextField).attrs({ + multiline: true, + variant: 'outlined', + fullWidth: true, + rows: 4, +})` + .MuiOutlinedInput-inputMultiline { + padding-inline: 1rem; + } +`; + interface CreateTaskModalProps { open: boolean; onClose: () => void; @@ -174,6 +185,8 @@ export const CreateTaskModal = ({ open, onClose }: CreateTaskModalProps) => { /> + {/** This is a placeholder for when we add in comments functionality */} + {/* */} From c6e7e980f0be1d4b484abf405774141c8d3e88b9 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Tue, 25 Jun 2024 16:44:48 +1200 Subject: [PATCH 22/50] Styling --- .../Tasks/CreateTaskModal/CreateTaskModal.tsx | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx index 4013db1e39..0389635511 100644 --- a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx +++ b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx @@ -15,7 +15,11 @@ import { EntityInput } from './EntityInput'; import { AssigneeInput } from './AssigneeInput'; const CountrySelectorWrapper = styled.div` - margin-inline-start: auto; + display: flex; + justify-content: flex-end; + .MuiInputBase-input.MuiSelect-selectMenu { + font-size: 0.75rem; + } `; const Form = styled.form` @@ -33,10 +37,16 @@ const Form = styled.form` .MuiOutlinedInput-input { padding-block: 0.9rem; } + input::placeholder { + color: ${({ theme }) => theme.palette.text.secondary}; + } + .MuiOutlinedInput-notchedOutline { + border-color: ${({ theme }) => theme.palette.divider}; + } `; const ListSelectWrapper = styled.div` - margin-block-end: 1rem; + margin-block-end: 1.8rem; .list-wrapper { height: 15rem; max-height: 15rem; @@ -64,7 +74,7 @@ const ListSelectWrapper = styled.div` const InputRow = styled.div` display: flex; justify-content: space-between; - margin-block-end: 1rem; + margin-block-end: 1.2rem; > * { width: 48%; margin-block-end: 0; @@ -98,15 +108,15 @@ export const CreateTaskModal = ({ open, onClose }: CreateTaskModalProps) => { return ( - - -
+ + + Date: Tue, 25 Jun 2024 17:05:06 +1200 Subject: [PATCH 23/50] WIP --- .../Tasks/CreateTaskModal/CreateTaskModal.tsx | 34 +++++++++++++++++-- .../CreateTaskModal/RepeatScheduleInput.tsx | 8 ++++- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx index 0389635511..81b25d71bd 100644 --- a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx +++ b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx @@ -13,6 +13,7 @@ import { DueDatePicker } from '../DueDatePicker'; import { RepeatScheduleInput } from './RepeatScheduleInput'; import { EntityInput } from './EntityInput'; import { AssigneeInput } from './AssigneeInput'; +import { ButtonProps } from '@material-ui/core'; const CountrySelectorWrapper = styled.div` display: flex; @@ -101,13 +102,40 @@ export const CreateTaskModal = ({ open, onClose }: CreateTaskModalProps) => { const formContext = useForm({ mode: 'onChange', }); - const { handleSubmit, control } = formContext; + const { + handleSubmit, + control, + formState: { isValid }, + } = formContext; const { countries, isLoading, selectedCountry, updateSelectedCountry } = useUserCountries(); - const onSubmit = data => {}; + const onSubmit = data => { + console.log(data); + }; + + const buttons: { + text: string; + onClick: () => void; + variant?: ButtonProps['variant']; // typing here because simply giving 'outlined' as default value is causing a type mismatch error + id: string; + disabled?: boolean; + }[] = [ + { + text: 'Cancel', + onClick: onClose, + variant: 'outlined', + id: 'cancel', + }, + { + text: 'Save', + onClick: handleSubmit(onSubmit), + id: 'save', + disabled: !isValid, + }, + ]; return ( - + diff --git a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/RepeatScheduleInput.tsx b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/RepeatScheduleInput.tsx index 7e5f280a60..6c6831b0b1 100644 --- a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/RepeatScheduleInput.tsx +++ b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/RepeatScheduleInput.tsx @@ -2,7 +2,7 @@ * Tupaia * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ -import React from 'react'; +import React, { useEffect } from 'react'; import { Select as BaseSelect, MenuItem, FormControl, FormLabel } from '@material-ui/core'; import { format, lastDayOfMonth } from 'date-fns'; import { useWatch } from 'react-hook-form'; @@ -76,6 +76,12 @@ interface RepeatScheduleInputProps { export const RepeatScheduleInput = ({ value = '', onChange }: RepeatScheduleInputProps) => { const { dueDate } = useWatch('dueDate'); const repeatScheduleOptions = useRepeatScheduleOptions(dueDate); + + useEffect(() => { + if (!dueDate) { + onChange(null); + } + }, [dueDate]); return ( Repeating task From 926805db2b25d40fcbe2dc90e6fc893817505309 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Fri, 5 Jul 2024 13:32:25 +1200 Subject: [PATCH 24/50] Create task route --- .../constructNewRecordValidationRules.js | 35 +++++++++--- .../datatrak-web-server/src/app/createApp.ts | 3 + .../src/routes/CreateTaskRoute.ts | 57 +++++++++++++++++++ .../datatrak-web-server/src/routes/index.ts | 1 + .../datatrak-web-server/CreateTaskRequest.ts | 17 ++++++ .../requests/datatrak-web-server/index.ts | 1 + packages/types/src/types/requests/index.ts | 1 + 7 files changed, 107 insertions(+), 8 deletions(-) create mode 100644 packages/datatrak-web-server/src/routes/CreateTaskRoute.ts create mode 100644 packages/types/src/types/requests/datatrak-web-server/CreateTaskRequest.ts diff --git a/packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js b/packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js index 567a1a7f90..740d90ae5c 100644 --- a/packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js +++ b/packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js @@ -446,26 +446,45 @@ export const constructForSingle = (models, recordType) => { 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'); + (value, { repeat_schedule: repeatSchedule }) => { + if (repeatSchedule) { + if (value) { + throw new Error('Non-recurring tasks must not have a due date'); + } + return true; } + if (!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'); + (value, { due_date: dueDate }) => { + // If the task has a due date, the repeat schedule is empty + if (dueDate) { + if (value) { + throw new Error('Non-recurring tasks cannot have a repeat schedule'); + } + return true; + } + + if (!value) { + throw new Error('Repeat schedule is required for recurring tasks'); } return true; }, ], status: [ (value, { repeat_schedule: repeatSchedule }) => { - if (repeatSchedule) return true; + // If the task is recurring, the status is empty + if (repeatSchedule) { + if (value) { + throw new Error('Recurring tasks cannot have a status'); + } + return true; + } + if (!value) { - throw new Error('Status is required'); + throw new Error('Status is required for non-recurring tasks'); } return true; }, diff --git a/packages/datatrak-web-server/src/app/createApp.ts b/packages/datatrak-web-server/src/app/createApp.ts index 6d253c204f..dd87e04f20 100644 --- a/packages/datatrak-web-server/src/app/createApp.ts +++ b/packages/datatrak-web-server/src/app/createApp.ts @@ -49,6 +49,8 @@ import { TasksRoute, SurveyUsersRequest, SurveyUsersRoute, + CreateTaskRequest, + CreateTaskRoute, } from '../routes'; import { attachAccessPolicy } from './middleware'; @@ -85,6 +87,7 @@ export async function createApp() { .get('tasks', handleWith(TasksRoute)) .get('surveyResponse/:id', handleWith(SingleSurveyResponseRoute)) .get('users/:surveyCode/:countryCode', handleWith(SurveyUsersRoute)) + .post('tasks', handleWith(CreateTaskRoute)) // Forward auth requests to web-config .use('signup', forwardRequest(WEB_CONFIG_API_URL, { authHandlerProvider })) .use('resendEmail', forwardRequest(WEB_CONFIG_API_URL, { authHandlerProvider })) diff --git a/packages/datatrak-web-server/src/routes/CreateTaskRoute.ts b/packages/datatrak-web-server/src/routes/CreateTaskRoute.ts new file mode 100644 index 0000000000..4bbf146f3a --- /dev/null +++ b/packages/datatrak-web-server/src/routes/CreateTaskRoute.ts @@ -0,0 +1,57 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { Request } from 'express'; +import { Route } from '@tupaia/server-boilerplate'; +import { DatatrakWebCreateTaskRequest, TaskStatus } from '@tupaia/types'; + +export type CreateTaskRequest = Request< + DatatrakWebCreateTaskRequest.Params, + DatatrakWebCreateTaskRequest.ResBody, + DatatrakWebCreateTaskRequest.ReqBody, + DatatrakWebCreateTaskRequest.ReqQuery +>; + +export class CreateTaskRoute extends Route { + public async buildResponse() { + const { models, body, ctx } = this.req; + + const { surveyCode, entityId, assigneeId, dueDate, repeatSchedule } = body; + + const survey = await models.survey.findOne({ code: surveyCode }); + if (!survey) { + throw new Error('Survey not found'); + } + + const taskDetails: { + survey_id: string; + entity_id: string; + assignee_id?: string; + due_date?: string | null; + repeat_schedule?: string; + status?: TaskStatus; + } = { + survey_id: survey.id, + entity_id: entityId, + assignee_id: assigneeId, + }; + + if (repeatSchedule) { + // if task is repeating, clear due date + taskDetails.repeat_schedule = JSON.stringify({ + // TODO: format this correctly when recurring tasks are implemented + frequency: repeatSchedule, + }); + taskDetails.due_date = null; + } else { + // apply status and due date only if not a repeating task + taskDetails.due_date = dueDate; + + taskDetails.status = TaskStatus.to_do; + } + + return ctx.services.central.createResource('tasks', {}, taskDetails); + } +} diff --git a/packages/datatrak-web-server/src/routes/index.ts b/packages/datatrak-web-server/src/routes/index.ts index be6748506c..b76aa5a76f 100644 --- a/packages/datatrak-web-server/src/routes/index.ts +++ b/packages/datatrak-web-server/src/routes/index.ts @@ -23,3 +23,4 @@ export { EntitiesRequest, EntitiesRoute } from './EntitiesRoute'; export { GenerateLoginTokenRequest, GenerateLoginTokenRoute } from './GenerateLoginTokenRoute'; export { TasksRequest, TasksRoute } from './TasksRoute'; export { SurveyUsersRequest, SurveyUsersRoute } from './SurveyUsersRoute'; +export { CreateTaskRequest, CreateTaskRoute } from './CreateTaskRoute'; diff --git a/packages/types/src/types/requests/datatrak-web-server/CreateTaskRequest.ts b/packages/types/src/types/requests/datatrak-web-server/CreateTaskRequest.ts new file mode 100644 index 0000000000..d5d440241b --- /dev/null +++ b/packages/types/src/types/requests/datatrak-web-server/CreateTaskRequest.ts @@ -0,0 +1,17 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { Entity, Survey, UserAccount } from '../../models'; + +export type Params = Record; +export type ResBody = Record; +export type ReqQuery = Record; +export type ReqBody = { + assigneeId?: UserAccount['id']; + dueDate?: string; + entityId: Entity['id']; + repeatSchedule?: string; + surveyCode: Survey['code']; +}; diff --git a/packages/types/src/types/requests/datatrak-web-server/index.ts b/packages/types/src/types/requests/datatrak-web-server/index.ts index bd322b21a7..1fd0678abf 100644 --- a/packages/types/src/types/requests/datatrak-web-server/index.ts +++ b/packages/types/src/types/requests/datatrak-web-server/index.ts @@ -18,3 +18,4 @@ export * as DatatrakWebGenerateLoginTokenRequest from './GenerateLoginTokenReque export * as DatatrakWebEntityDescendantsRequest from './EntityDescendantsRequest'; export * as DatatrakWebTasksRequest from './TasksRequest'; export * as DatatrakWebSurveyUsersRequest from './SurveyUsersRequest'; +export * as DatatrakWebCreateTaskRequest from './CreateTaskRequest'; diff --git a/packages/types/src/types/requests/index.ts b/packages/types/src/types/requests/index.ts index 2fbd6b10b7..6d5aede341 100644 --- a/packages/types/src/types/requests/index.ts +++ b/packages/types/src/types/requests/index.ts @@ -21,6 +21,7 @@ export { DatatrakWebEntityDescendantsRequest, DatatrakWebTasksRequest, DatatrakWebSurveyUsersRequest, + DatatrakWebCreateTaskRequest, } from './datatrak-web-server'; export { TupaiaWebChangePasswordRequest, From 4eaf58ec13c89945fefdee2e54457b0bcfd4c3b0 Mon Sep 17 00:00:00 2001 From: alexd-bes <129009580+alexd-bes@users.noreply.github.com> Date: Fri, 5 Jul 2024 13:32:37 +1200 Subject: [PATCH 25/50] Create task workflow --- .../datatrak-web/src/api/mutations/index.ts | 1 + .../src/api/mutations/useCreateTask.ts | 25 +++++ .../src/api/mutations/useEditUser.ts | 1 + .../EntitySelector/EntitySelector.tsx | 76 +++++++------ .../src/features/GroupedSurveyList.tsx | 5 +- .../Tasks/CreateTaskModal/CreateTaskModal.tsx | 106 +++++++++++------- .../Tasks/CreateTaskModal/EntityInput.tsx | 18 +-- .../CreateTaskModal/RepeatScheduleInput.tsx | 5 +- .../src/features/Tasks/DueDatePicker.tsx | 13 ++- 9 files changed, 160 insertions(+), 90 deletions(-) create mode 100644 packages/datatrak-web/src/api/mutations/useCreateTask.ts diff --git a/packages/datatrak-web/src/api/mutations/index.ts b/packages/datatrak-web/src/api/mutations/index.ts index 7bd41ae67c..e89e81f267 100644 --- a/packages/datatrak-web/src/api/mutations/index.ts +++ b/packages/datatrak-web/src/api/mutations/index.ts @@ -16,3 +16,4 @@ export { useRequestDeleteAccount } from './useRequestDeleteAccount'; export { useOneTimeLogin } from './useOneTimeLogin'; export * from './useExportSurveyResponses'; export { useTupaiaRedirect } from './useTupaiaRedirect'; +export { useCreateTask } from './useCreateTask'; diff --git a/packages/datatrak-web/src/api/mutations/useCreateTask.ts b/packages/datatrak-web/src/api/mutations/useCreateTask.ts new file mode 100644 index 0000000000..4efb1485d6 --- /dev/null +++ b/packages/datatrak-web/src/api/mutations/useCreateTask.ts @@ -0,0 +1,25 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { useMutation, useQueryClient } from 'react-query'; +import { DatatrakWebCreateTaskRequest } from '@tupaia/types'; +import { post } from '../api'; + +export const useCreateTask = (onSuccess?: () => void) => { + const queryClient = useQueryClient(); + return useMutation( + (data: DatatrakWebCreateTaskRequest.ReqBody) => { + return post('tasks', { + data, + }); + }, + { + onSuccess: () => { + queryClient.invalidateQueries('tasks'); + if (onSuccess) onSuccess(); + }, + }, + ); +}; diff --git a/packages/datatrak-web/src/api/mutations/useEditUser.ts b/packages/datatrak-web/src/api/mutations/useEditUser.ts index ce44980794..1ea167a0dc 100644 --- a/packages/datatrak-web/src/api/mutations/useEditUser.ts +++ b/packages/datatrak-web/src/api/mutations/useEditUser.ts @@ -45,6 +45,7 @@ export const useEditUser = (onSuccess?: () => void) => { // If the user changes their project, we need to invalidate the entity descendants query so that recent entities are updated if they change back to the previous project without refreshing the page if (variables.projectId) { queryClient.invalidateQueries('entityDescendants'); + queryClient.invalidateQueries('tasks'); } if (onSuccess) onSuccess(); }, diff --git a/packages/datatrak-web/src/features/EntitySelector/EntitySelector.tsx b/packages/datatrak-web/src/features/EntitySelector/EntitySelector.tsx index a2619043e3..2ccfbe6d29 100644 --- a/packages/datatrak-web/src/features/EntitySelector/EntitySelector.tsx +++ b/packages/datatrak-web/src/features/EntitySelector/EntitySelector.tsx @@ -51,7 +51,7 @@ interface EntitySelectorProps { onChange: (value: string) => void; value: string; ref: any; - invalid: boolean; + invalid?: boolean; }; showLegend: boolean; projectCode?: string; @@ -130,42 +130,44 @@ export const EntitySelector = ({ const showLoader = isLoading || ((isLoadingSearchResults || !isFetched) && !disableSearch); return ( - - {showLegend && ( - - )} -
- {showSearchInput && ( - + <> + + {showLegend && ( + )} - {errors && errors[name!] && *{errors[name!].message}} - {showLoader ? ( - - ) : ( - - )} -
-
+
+ {showSearchInput && ( + + )} + {showLoader ? ( + + ) : ( + + )} +
+ + {errors && errors[name!] && {errors[name!].message}} + ); }; diff --git a/packages/datatrak-web/src/features/GroupedSurveyList.tsx b/packages/datatrak-web/src/features/GroupedSurveyList.tsx index 8a9f5eacb4..b5465e0096 100644 --- a/packages/datatrak-web/src/features/GroupedSurveyList.tsx +++ b/packages/datatrak-web/src/features/GroupedSurveyList.tsx @@ -4,7 +4,7 @@ */ import React, { useEffect } from 'react'; import styled from 'styled-components'; -import { FormLabelProps } from '@material-ui/core'; +import { FormHelperText, FormLabelProps } from '@material-ui/core'; import { Country } from '@tupaia/types'; import { ListItemType, SelectList, SurveyFolderIcon, SurveyIcon } from '../components'; import { Survey } from '../types'; @@ -35,6 +35,7 @@ interface GroupedSurveyListProps { labelProps?: FormLabelProps & { component?: React.ElementType; }; + error?: string; } export const GroupedSurveyList = ({ @@ -43,6 +44,7 @@ export const GroupedSurveyList = ({ selectedCountry, label, labelProps, + error, }: GroupedSurveyListProps) => { const user = useCurrentUserContext(); const { data: surveys } = useProjectSurveys(user?.projectId, selectedCountry?.name); @@ -106,6 +108,7 @@ export const GroupedSurveyList = ({ label={label} labelProps={labelProps} /> + {error && {error}} ); }; diff --git a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx index 81b25d71bd..da0258963e 100644 --- a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx +++ b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx @@ -3,17 +3,18 @@ * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ -import React from 'react'; +import React, { useEffect } from 'react'; import styled from 'styled-components'; import { useForm, Controller, FormProvider } from 'react-hook-form'; import { Modal, TextField } from '@tupaia/ui-components'; +import { ButtonProps } from '@material-ui/core'; +import { useCreateTask } from '../../../api'; import { CountrySelector, useUserCountries } from '../../CountrySelector'; import { GroupedSurveyList } from '../../GroupedSurveyList'; import { DueDatePicker } from '../DueDatePicker'; import { RepeatScheduleInput } from './RepeatScheduleInput'; import { EntityInput } from './EntityInput'; import { AssigneeInput } from './AssigneeInput'; -import { ButtonProps } from '@material-ui/core'; const CountrySelectorWrapper = styled.div` display: flex; @@ -44,6 +45,13 @@ const Form = styled.form` .MuiOutlinedInput-notchedOutline { border-color: ${({ theme }) => theme.palette.divider}; } + .MuiInputBase-root.Mui-error { + background-color: transparent; + } + .loading-screen { + border: none; + background-color: ${({ theme }) => theme.palette.background.paper}; + } `; const ListSelectWrapper = styled.div` @@ -100,18 +108,22 @@ interface CreateTaskModalProps { export const CreateTaskModal = ({ open, onClose }: CreateTaskModalProps) => { const formContext = useForm({ - mode: 'onChange', + mode: 'all', }); const { handleSubmit, control, + setValue, + reset, formState: { isValid }, } = formContext; - const { countries, isLoading, selectedCountry, updateSelectedCountry } = useUserCountries(); - const onSubmit = data => { - console.log(data); + const onCloseModal = () => { + reset(); + onClose(); }; + const { countries, selectedCountry, updateSelectedCountry } = useUserCountries(); + const { mutate: createTask, isLoading: isSaving } = useCreateTask(onCloseModal); const buttons: { text: string; @@ -122,22 +134,28 @@ export const CreateTaskModal = ({ open, onClose }: CreateTaskModalProps) => { }[] = [ { text: 'Cancel', - onClick: onClose, + onClick: onCloseModal, variant: 'outlined', id: 'cancel', + disabled: isSaving, }, { text: 'Save', - onClick: handleSubmit(onSubmit), + onClick: handleSubmit(createTask), id: 'save', - disabled: !isValid, + disabled: !isValid || isSaving, }, ]; + useEffect(() => { + setValue('surveyCode', null, { shouldValidate: true }); + setValue('entityId', null, { shouldValidate: true }); + }, [selectedCountry?.code]); + return ( - + - + { ( + rules={{ required: '*Required' }} + render={({ onChange, value }, { invalid }) => ( { required: true, color: 'primary', component: 'label', + error: invalid, }} + error={invalid ? '*Required' : undefined} /> )} /> ( - - - - )} + rules={{ required: '*Required' }} + render={({ onChange, value, ref, name }, { invalid }) => { + return ( + + + + ); + }} /> ( - - )} + defaultValue={new Date()} + render={({ ref, value, onChange, ...field }, { invalid }) => { + return ( + + ); + }} /> { {/** This is a placeholder for when we add in comments functionality */} - {/* */} diff --git a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/EntityInput.tsx b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/EntityInput.tsx index 594291efb2..39019332ec 100644 --- a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/EntityInput.tsx +++ b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/EntityInput.tsx @@ -13,17 +13,19 @@ import { getAllSurveyComponents } from '../../Survey'; interface EntityInputProps { onChange: (value: string) => void; value: string; - invalid: boolean; selectedCountry?: Country | null; - ref?: React.Ref; + inputRef?: React.Ref; + name: string; + invalid?: boolean; } export const EntityInput = ({ onChange, value, - invalid, selectedCountry, - ref, + inputRef, + name, + invalid, }: EntityInputProps) => { const { surveyCode } = useWatch('surveyCode'); const user = useCurrentUserContext(); @@ -41,14 +43,13 @@ export const EntityInput = ({ return ( ); diff --git a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/RepeatScheduleInput.tsx b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/RepeatScheduleInput.tsx index 6c6831b0b1..9a4c01bed5 100644 --- a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/RepeatScheduleInput.tsx +++ b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/RepeatScheduleInput.tsx @@ -69,7 +69,7 @@ interface RepeatScheduleInputProps { value: React.ChangeEvent<{ name?: string | undefined; value: unknown; - }>, + }> | null, ) => void; } @@ -82,12 +82,13 @@ export const RepeatScheduleInput = ({ value = '', onChange }: RepeatScheduleInpu onChange(null); } }, [dueDate]); + return ( Repeating task