From 5e15ee7eb93f6d1d5c83b8b36ce01e65d63be49f Mon Sep 17 00:00:00 2001 From: tahmidrahman-dsi Date: Thu, 2 Jan 2025 15:58:00 +0600 Subject: [PATCH 01/15] feat: add banner component in uikit --- .../components/src/Banner/Banner.stories.tsx | 87 +++++++++++++++++++ packages/components/src/Banner/Banner.tsx | 69 +++++++++++++++ packages/components/src/Banner/index.ts | 12 +++ packages/components/src/Icon/all-icons.ts | 3 +- 4 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 packages/components/src/Banner/Banner.stories.tsx create mode 100644 packages/components/src/Banner/Banner.tsx create mode 100644 packages/components/src/Banner/index.ts diff --git a/packages/components/src/Banner/Banner.stories.tsx b/packages/components/src/Banner/Banner.stories.tsx new file mode 100644 index 00000000000..509ee3596e1 --- /dev/null +++ b/packages/components/src/Banner/Banner.stories.tsx @@ -0,0 +1,87 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * OpenCRVS is also distributed under the terms of the Civil Registration + * & Healthcare Disclaimer located at http://opencrvs.org/license. + * + * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. + */ +import React from 'react' +import { Banner, IBannerProps } from './Banner' +import { Text } from '../Text' +import { Meta, StoryObj } from '@storybook/react' +import { Button } from '../Button' +import { Pill } from '../Pill' +import { Icon } from '../Icon' + +const meta: Meta = { + title: 'Layout/Banner', + argTypes: { + type: { + control: { + type: 'radio', + options: ['active', 'inactive', 'pending', 'default'] + } + } + }, + render: (args) => ( + + + + This is header + + + + + This is body + + + + + + + ) +} + +export default meta + +export const Default: StoryObj = { + args: { + type: 'default' + } +} + +export const Active: StoryObj = { + args: { + type: 'active' + } +} + +export const Pending: StoryObj = { + args: { + type: 'pending' + } +} + +export const PendingAdvanced: StoryObj = { + render: () => ( + + + + + + + + The Notifier’s identity has been verified successfully using the + National Identity system. To make edits, please remove the + verification first. + + + + + + + ) +} diff --git a/packages/components/src/Banner/Banner.tsx b/packages/components/src/Banner/Banner.tsx new file mode 100644 index 00000000000..6c609d1ed08 --- /dev/null +++ b/packages/components/src/Banner/Banner.tsx @@ -0,0 +1,69 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * OpenCRVS is also distributed under the terms of the Civil Registration + * & Healthcare Disclaimer located at http://opencrvs.org/license. + * + * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. + */ +import React from 'react' +import { Box } from '../Box' +import { Stack } from '../Stack' +import styled from 'styled-components' + +const Wrapper = styled(Box)` + padding: 0; +` +export const HeaderWrapper = styled(Stack)<{ + type: 'active' | 'inactive' | 'pending' | 'default' +}>` + padding: 8px 16px; + --background-color: ${({ type, theme }) => ` + ${type === 'active' ? theme.colors.greenLighter : ''} + ${type === 'inactive' ? theme.colors.redLighter : ''} + ${type === 'pending' ? theme.colors.orangeLighter : ''} + ${type === 'default' ? theme.colors.primaryLighter : ''} + `}; + --color: ${({ type, theme }) => ` + ${type === 'active' ? theme.colors.positiveDarker : ''} + ${type === 'inactive' ? theme.colors.negativeDarker : ''} + ${type === 'pending' ? theme.colors.neutralDarker : ''} + ${type === 'default' ? theme.colors.primaryDarker : ''} +`}; + background-color: var(--background-color); + color: var(--color); +` +const ContentWrapper = styled(Stack)` + padding: 16px; +` + +export interface IBannerProps { + type: 'active' | 'inactive' | 'pending' | 'default' +} + +const Container: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + {children} +) + +const Header: React.FC<{ children: React.ReactNode } & IBannerProps> = ({ + children, + type +}) => ( + + {children} + +) + +const Body: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + {children} +) + +const Footer: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + + {children} + +) + +export const Banner = { Container, Header, Body, Footer } diff --git a/packages/components/src/Banner/index.ts b/packages/components/src/Banner/index.ts new file mode 100644 index 00000000000..0d4e310b6ab --- /dev/null +++ b/packages/components/src/Banner/index.ts @@ -0,0 +1,12 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * OpenCRVS is also distributed under the terms of the Civil Registration + * & Healthcare Disclaimer located at http://opencrvs.org/license. + * + * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. + */ + +export * from './Banner' diff --git a/packages/components/src/Icon/all-icons.ts b/packages/components/src/Icon/all-icons.ts index 31a641db872..d49318e9170 100644 --- a/packages/components/src/Icon/all-icons.ts +++ b/packages/components/src/Icon/all-icons.ts @@ -74,6 +74,7 @@ export { PencilLine, PencilCircle, Handshake, - UserCircle + UserCircle, + Clock } from 'phosphor-react' export * from './custom-icons' From 6b765485b6aa3ba2d568b89a216bbdbf454ca943 Mon Sep 17 00:00:00 2001 From: tahmidrahman-dsi Date: Thu, 2 Jan 2025 16:01:53 +0600 Subject: [PATCH 02/15] feat: add prop `pillTheme` to obtain a dark themed Pill and document in uikit --- packages/components/src/Pill/Pill.stories.tsx | 14 ++++++++++ packages/components/src/Pill/Pill.tsx | 28 +++++++++++++++---- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/packages/components/src/Pill/Pill.stories.tsx b/packages/components/src/Pill/Pill.stories.tsx index dd1ad8d3c13..ddefb420255 100644 --- a/packages/components/src/Pill/Pill.stories.tsx +++ b/packages/components/src/Pill/Pill.stories.tsx @@ -56,3 +56,17 @@ Inactive.args = { label: 'Inactive', type: 'inactive' } + +export const ActiveDark = Template.bind({}) +ActiveDark.args = { + label: 'Active Dark', + type: 'active', + pillTheme: 'dark' +} + +export const PendingDark = Template.bind({}) +PendingDark.args = { + label: 'Pending Dark', + type: 'pending', + pillTheme: 'dark' +} diff --git a/packages/components/src/Pill/Pill.tsx b/packages/components/src/Pill/Pill.tsx index 06324ed4bfa..27f934c78b6 100644 --- a/packages/components/src/Pill/Pill.tsx +++ b/packages/components/src/Pill/Pill.tsx @@ -16,10 +16,13 @@ type IPillType = 'active' | 'inactive' | 'pending' | 'default' type IPillSize = 'small' | 'medium' +type IPillTheme = 'light' | 'dark' + export interface IPillProps { - label: string + label: React.ReactNode type?: IPillType size?: IPillSize + pillTheme?: IPillTheme } const heightMap: Record = { @@ -32,21 +35,35 @@ const fontMap: Record = { medium: 'bold16' } -const StyledPill = styled.span<{ size: IPillSize; type: IPillType }>` - --background-color: ${({ type, theme }) => ` +const StyledPill = styled.span<{ + size: IPillSize + type: IPillType + pillTheme: IPillTheme +}>` + --lighterShade: ${({ type, theme }) => ` ${type === 'active' ? theme.colors.greenLighter : ''} ${type === 'inactive' ? theme.colors.redLighter : ''} ${type === 'pending' ? theme.colors.orangeLighter : ''} ${type === 'default' ? theme.colors.primaryLighter : ''} `}; - --color: ${({ type, theme }) => ` + --darkerShade: ${({ type, theme }) => ` ${type === 'active' ? theme.colors.positiveDarker : ''} ${type === 'inactive' ? theme.colors.negativeDarker : ''} ${type === 'pending' ? theme.colors.neutralDarker : ''} ${type === 'default' ? theme.colors.primaryDarker : ''} `}; + ${({ pillTheme }) => + pillTheme === 'dark' + ? ` + --color: var(--lighterShade); + --background-color: var(--darkerShade); + ` + : ` + --color: var(--darkerShade); + --background-color: var(--lighterShade); + `} color: var(--color); background: var(--background-color); height: ${({ size }) => heightMap[size]}; @@ -61,10 +78,11 @@ export function Pill({ label, type = 'default', size = 'small', + pillTheme = 'light', ...rest }: IPillProps) { return ( - + {label} ) From fef6df5e53d239b995b79e0b51b77f93f093897e Mon Sep 17 00:00:00 2001 From: tahmidrahman-dsi Date: Thu, 2 Jan 2025 16:05:45 +0600 Subject: [PATCH 03/15] chore: update Banner story --- packages/components/src/Banner/Banner.stories.tsx | 12 +++++++++++- packages/components/src/Icon/all-icons.ts | 3 ++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/components/src/Banner/Banner.stories.tsx b/packages/components/src/Banner/Banner.stories.tsx index 509ee3596e1..9499e41e516 100644 --- a/packages/components/src/Banner/Banner.stories.tsx +++ b/packages/components/src/Banner/Banner.stories.tsx @@ -69,7 +69,17 @@ export const PendingAdvanced: StoryObj = { render: () => ( - + + + ID Pending Verification + + } + /> diff --git a/packages/components/src/Icon/all-icons.ts b/packages/components/src/Icon/all-icons.ts index d49318e9170..7a0bbde17ee 100644 --- a/packages/components/src/Icon/all-icons.ts +++ b/packages/components/src/Icon/all-icons.ts @@ -75,6 +75,7 @@ export { PencilCircle, Handshake, UserCircle, - Clock + Clock, + QrCode } from 'phosphor-react' export * from './custom-icons' From befba60ed148b8c882c4947816f5d0a447ee49cd Mon Sep 17 00:00:00 2001 From: tahmidrahman-dsi Date: Thu, 2 Jan 2025 16:38:32 +0600 Subject: [PATCH 04/15] fix: remove hardcoded `justifyContent` --- packages/components/src/Banner/Banner.stories.tsx | 2 +- packages/components/src/Banner/Banner.tsx | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/components/src/Banner/Banner.stories.tsx b/packages/components/src/Banner/Banner.stories.tsx index 9499e41e516..8287cceaa2e 100644 --- a/packages/components/src/Banner/Banner.stories.tsx +++ b/packages/components/src/Banner/Banner.stories.tsx @@ -89,7 +89,7 @@ export const PendingAdvanced: StoryObj = { verification first. - + diff --git a/packages/components/src/Banner/Banner.tsx b/packages/components/src/Banner/Banner.tsx index 6c609d1ed08..660fbbc6c0d 100644 --- a/packages/components/src/Banner/Banner.tsx +++ b/packages/components/src/Banner/Banner.tsx @@ -10,7 +10,7 @@ */ import React from 'react' import { Box } from '../Box' -import { Stack } from '../Stack' +import { IStackProps, Stack } from '../Stack' import styled from 'styled-components' const Wrapper = styled(Box)` @@ -60,8 +60,11 @@ const Body: React.FC<{ children: React.ReactNode }> = ({ children }) => ( {children} ) -const Footer: React.FC<{ children: React.ReactNode }> = ({ children }) => ( - +const Footer: React.FC<{ children: React.ReactNode } & IStackProps> = ({ + children, + ...otherProps +}) => ( + {children} ) From 1acab8553034ebfc297a5f5049fb7103ba5c8557 Mon Sep 17 00:00:00 2001 From: Riku Rouvila Date: Fri, 3 Jan 2025 10:13:58 +0200 Subject: [PATCH 05/15] Events v2: Implement optimistic file upload and FILE type input (#8246) * implement optimistic file upload and FILE type input * fix broken offline record creation * fix one type error * fix: update tests, add missing license header * fix: explicitly throw if cache not found * fix type errors * fix eslint errors * fix review page * add signing for files fetched from events service * cleanup * cleanup * use undefined instead of null in FileInput as the default value of selected file * remove default empty string value * remove fallback output * fix linter error * fix: clean up types and lint issues * fix review page exception * fix: add missing flush * fix: update test mock name * move debugger to top level of events v2 * fix: add mock for files service call * chore: add msw, mock documents api call * fix: add missing license header * refactor react query logic a bit * fix: remove unused export * fix: change cache invalidation order * fix: add flushPromises * fix: add flushPromises to top-level * fix merge * add types back * improve knip diff output --------- Co-authored-by: Markus Co-authored-by: Markus Laurila --- .github/workflows/lint-and-test.yml | 4 +- docker-compose.yml | 1 + .../components/forms/FormFieldGenerator.tsx | 164 +++-- .../inputs/FileInput/DocumentListPreview.tsx | 96 +++ .../inputs/FileInput/DocumentPreview.tsx | 159 +++++ .../forms/inputs/FileInput/FileInput.tsx | 76 ++ .../FileInput/SimpleDocumentUploader.tsx | 171 +++++ .../src/v2-events/components/forms/utils.ts | 78 +- .../v2-events/components/forms/validation.ts | 7 +- .../src/v2-events/features/debug/debug.tsx | 9 +- .../features/events/EventSelection.tsx | 8 +- .../events/actions/declare/Review.tsx | 4 +- .../features/events/components/Review.tsx | 99 ++- .../src/v2-events/features/events/fixtures.ts | 228 +++--- .../features/events/useEventFormData.ts | 24 +- .../events/useEvents/useEvents.test.tsx | 15 +- .../features/events/useEvents/useEvents.ts | 299 +++----- .../v2-events/features/files/useFileUpload.ts | 135 ++++ .../v2-events/layouts/workqueues/index.tsx | 4 +- .../client/src/v2-events/routes/config.tsx | 2 + packages/client/src/v2-events/trpc.tsx | 5 +- .../RegisterForm/DeclarationForm.test.tsx | 5 +- packages/client/typings/window.d.ts | 1 + packages/client/vite.config.ts | 2 +- .../commons/src/conditionals/conditionals.ts | 6 +- packages/commons/src/events/ActionDocument.ts | 7 +- packages/commons/src/events/ActionInput.ts | 3 +- packages/commons/src/events/FieldConfig.ts | 20 +- packages/commons/src/events/FieldValue.ts | 40 ++ packages/commons/src/events/index.ts | 1 + packages/documents/package.json | 1 + packages/documents/src/config/routes.ts | 43 +- .../src/features/getDocument/handler.ts | 22 + .../src/features/uploadDocument/handler.ts | 60 +- packages/events/package.json | 1 + packages/events/src/environment.ts | 3 +- .../events/src/router/event.actions.test.ts | 2 +- packages/events/src/router/events.get.test.ts | 4 +- packages/events/src/router/router.ts | 27 +- packages/events/src/service/config/config.ts | 2 +- packages/events/src/service/events.ts | 48 +- packages/events/src/service/files/index.ts | 154 ++++ packages/events/src/tests/msw.ts | 25 + packages/events/src/tests/setup.ts | 21 +- packages/gateway/src/config/routes.ts | 22 + packages/gateway/src/graphql/schema.d.ts | 666 ------------------ packages/gateway/src/graphql/schema.graphql | 130 ---- yarn.lock | 54 +- 48 files changed, 1612 insertions(+), 1346 deletions(-) create mode 100644 packages/client/src/v2-events/components/forms/inputs/FileInput/DocumentListPreview.tsx create mode 100644 packages/client/src/v2-events/components/forms/inputs/FileInput/DocumentPreview.tsx create mode 100644 packages/client/src/v2-events/components/forms/inputs/FileInput/FileInput.tsx create mode 100644 packages/client/src/v2-events/components/forms/inputs/FileInput/SimpleDocumentUploader.tsx create mode 100644 packages/client/src/v2-events/features/files/useFileUpload.ts create mode 100644 packages/commons/src/events/FieldValue.ts create mode 100644 packages/events/src/service/files/index.ts create mode 100644 packages/events/src/tests/msw.ts diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml index 7bb1e521315..ce3cb945e96 100644 --- a/.github/workflows/lint-and-test.yml +++ b/.github/workflows/lint-and-test.yml @@ -183,7 +183,7 @@ jobs: - name: Run knip on base branch id: knip_base run: | - npx knip --tags=-knipignore --no-exit-code --exports --reporter=markdown > knip_report.md + npx knip --tags=-knipignore --no-exit-code --exports --reporter=markdown | sed -E 's/ +/ /g' | sed -E 's/:[0-9]+:[0-9]+//' > knip_report.md TOTAL=$(grep -oP '## [A-Za-z\s]+ \(\K[0-9]+' knip_report.md | awk '{sum+=$1} END {print sum}') echo "Total $TOTAL issue(s) on base branch." echo "total=${TOTAL}" >> $GITHUB_OUTPUT @@ -192,7 +192,7 @@ jobs: - name: Run knip on PR branch id: knip_pr run: | - npx knip --tags=-knipignore --no-exit-code --exports --reporter=markdown > knip_report.md + npx knip --tags=-knipignore --no-exit-code --exports --reporter=markdown | sed -E 's/ +/ /g' | sed -E 's/:[0-9]+:[0-9]+//' > knip_report.md TOTAL=$(grep -oP '## [A-Za-z\s]+ \(\K[0-9]+' knip_report.md | awk '{sum+=$1} END {print sum}') echo "Total $TOTAL issue(s) on PR branch." echo "total=${TOTAL}" >> $GITHUB_OUTPUT diff --git a/docker-compose.yml b/docker-compose.yml index 72a894ac8a3..684e068b593 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -92,6 +92,7 @@ services: - MONGO_URL=mongodb://mongo1/events - ES_HOST=elasticsearch:9200 - COUNTRY_CONFIG_URL=http://countryconfig:3040/ + - DOCUMENTS_URL=http://documents:9050 # User facing services workflow: diff --git a/packages/client/src/v2-events/components/forms/FormFieldGenerator.tsx b/packages/client/src/v2-events/components/forms/FormFieldGenerator.tsx index 47d0c29c4c8..8ba8ad867ae 100644 --- a/packages/client/src/v2-events/components/forms/FormFieldGenerator.tsx +++ b/packages/client/src/v2-events/components/forms/FormFieldGenerator.tsx @@ -8,10 +8,32 @@ * * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. */ + /* eslint-disable */ +import { InputField } from '@client/components/form/InputField' +import { DATE, PARAGRAPH, TEXT } from '@client/forms' +import { IAdvancedSearchFormState } from '@client/search/advancedSearch/utils' +import { DateField } from '@opencrvs/components/lib/DateField' +import { Text } from '@opencrvs/components/lib/Text' +import { TextInput } from '@opencrvs/components/lib/TextInput' import * as React from 'react' + import styled, { keyframes } from 'styled-components' +import { + evalExpressionInFieldDefinition, + getConditionalActionsForField, + getDependentFields, + handleInitialValue, + hasInitialValueDependencyInfo +} from './utils' +import { Errors, getValidationErrorsForForm } from './validation' +import { + FieldConfig, + FieldValue, + FieldValueByType, + FileFieldValue +} from '@opencrvs/commons/client' import { Field, FieldProps, @@ -26,30 +48,8 @@ import { MessageDescriptor, useIntl } from 'react-intl' -import { FieldConfig } from '@opencrvs/commons' -import { TextInput } from '@opencrvs/components/lib/TextInput' -import { Text } from '@opencrvs/components/lib/Text' -import { DateField } from '@opencrvs/components/lib/DateField' -import { IAdvancedSearchFormState } from '@client/search/advancedSearch/utils' -import { - DATE, - HIDDEN, - IFormFieldValue, - IFormSectionData, - PARAGRAPH, - TEXT -} from '@client/forms' -import { InputField } from '@client/components/form/InputField' -import { - evalExpressionInFieldDefinition, - flatten, - getConditionalActionsForField, - getDependentFields, - handleInitialValue, - hasInitialValueDependencyInfo, - unflatten -} from './utils' -import { Errors, getValidationErrorsForForm } from './validation' +import { FileInput } from './inputs/FileInput/FileInput' +import { ActionFormData } from '@opencrvs/commons' const fadeIn = keyframes` from { opacity: 0; } @@ -64,27 +64,27 @@ const FormItem = styled.div<{ ignoreBottomMargin ? '0px' : '22px'}; ` -interface GeneratedInputFieldProps { - fieldDefinition: FieldConfig +interface GeneratedInputFieldProps { + fieldDefinition: FieldType fields: FieldConfig[] - values: IFormSectionData - setFieldValue: (name: string, value: IFormFieldValue) => void + values: ActionFormData + setFieldValue: (name: string, value: FieldValue | undefined) => void onClick?: () => void onChange: (e: React.ChangeEvent) => void onBlur: (e: React.FocusEvent) => void resetDependentSelectValues: (name: string) => void - value: IFormFieldValue + value: FieldValueByType[FieldType['type']] touched: boolean error: string - formData: IFormSectionData + formData: ActionFormData disabled?: boolean onUploadingStateChanged?: (isUploading: boolean) => void requiredErrorMessage?: MessageDescriptor setFieldTouched: (name: string, isTouched?: boolean) => void } -const GeneratedInputField = React.memo( - ({ +const GeneratedInputField = React.memo( + ({ fieldDefinition, onChange, onBlur, @@ -99,7 +99,7 @@ const GeneratedInputField = React.memo( requiredErrorMessage, fields, values - }) => { + }: GeneratedInputFieldProps) => { const intl = useIntl() const inputFieldProps = { @@ -133,6 +133,12 @@ const GeneratedInputField = React.memo( intl.formatMessage(fieldDefinition.placeholder) } + const handleFileChange = React.useCallback( + (value: FileFieldValue | undefined) => + setFieldValue(fieldDefinition.id, value), + [fieldDefinition.id, setFieldValue] + ) + if (fieldDefinition.type === DATE) { return ( @@ -166,17 +172,6 @@ const GeneratedInputField = React.memo( ) } - if (fieldDefinition.type === HIDDEN) { - const { error, touched, ...allowedInputProps } = inputProps - - return ( - - ) - } if (fieldDefinition.type === TEXT) { return ( @@ -190,13 +185,25 @@ const GeneratedInputField = React.memo( ) } + if (fieldDefinition.type === 'FILE') { + const value = formData[fieldDefinition.id] as FileFieldValue + return ( + + + + ) + } return
Unsupported field type {fieldDefinition.type}
} ) GeneratedInputField.displayName = 'MemoizedGeneratedInputField' -type FormData = Record +type FormData = Record const mapFieldsToValues = (fields: FieldConfig[], formData: FormData) => fields.reduce((memo, field) => { @@ -211,15 +218,15 @@ interface ExposedProps { id: string fieldsToShowValidationErrors?: FieldConfig[] setAllFieldsDirty: boolean - onChange: (values: IFormSectionData) => void - formData: Record + onChange: (values: ActionFormData) => void + formData: Record onSetTouched?: (func: ISetTouchedFunction) => void requiredErrorMessage?: MessageDescriptor onUploadingStateChanged?: (isUploading: boolean) => void initialValues?: IAdvancedSearchFormState } -type AllProps = ExposedProps & IntlShapeProps & FormikProps +type AllProps = ExposedProps & IntlShapeProps & FormikProps class FormSectionComponent extends React.Component { componentDidUpdate(prevProps: AllProps) { @@ -289,7 +296,7 @@ class FormSectionComponent extends React.Component { setFieldValuesWithDependency = ( fieldName: string, - value: IFormFieldValue + value: FieldValue | undefined ) => { const updatedValues = cloneDeep(this.props.values) set(updatedValues, fieldName, value) @@ -328,13 +335,24 @@ class FormSectionComponent extends React.Component { } render() { - const { values, fields, setFieldTouched, touched, intl, formData } = - this.props + const { + values, + fields: fieldsWithDotIds, + setFieldTouched, + touched, + intl, + formData + } = this.props const language = this.props.intl.locale const errors = this.props.errors as unknown as Errors + const fields = fieldsWithDotIds.map((field) => ({ + ...field, + id: field.id.replaceAll('.', FIELD_SEPARATOR) + })) + return (
{fields.map((field) => { @@ -348,7 +366,7 @@ class FormSectionComponent extends React.Component { const conditionalActions: string[] = getConditionalActionsForField( field, - { ...formData, ...values } + { $form: values, $now: new Date().toISOString().split('T')[0] } ) if (conditionalActions.includes('hide')) { @@ -374,7 +392,11 @@ class FormSectionComponent extends React.Component { error={isFieldDisabled ? '' : error} fields={fields} formData={formData} - touched={flatten(touched)[field.id] || false} + touched={ + makeFormikFieldIdsOpenCRVSCompatible(touched)[ + field.id + ] || false + } values={values} onUploadingStateChanged={ this.props.onUploadingStateChanged @@ -391,26 +413,50 @@ class FormSectionComponent extends React.Component { } } +/* + * Formik has a feature that automatically nests all form keys that have a dot in them. + * Because our form field ids can have dots in them, we temporarily transform those dots + * to a different character before passing the data to Formik. This function unflattens + */ +const FIELD_SEPARATOR = '____' +function makeFormFieldIdsFormikCompatible(data: Record) { + return Object.fromEntries( + Object.entries(data).map(([key, value]) => [ + key.replaceAll('.', FIELD_SEPARATOR), + value + ]) + ) +} + +function makeFormikFieldIdsOpenCRVSCompatible(data: Record) { + return Object.fromEntries( + Object.entries(data).map(([key, value]) => [ + key.replaceAll(FIELD_SEPARATOR, '.'), + value + ]) + ) +} + export const FormFieldGenerator: React.FC = (props) => { const intl = useIntl() - const nestedFormData = unflatten(props.formData) + const nestedFormData = makeFormFieldIdsFormikCompatible(props.formData) - const onChange = (values: IFormSectionData) => { - props.onChange(flatten(values)) + const onChange = (values: ActionFormData) => { + props.onChange(makeFormikFieldIdsOpenCRVSCompatible(values)) } - const initialValues = unflatten( + const initialValues = makeFormFieldIdsFormikCompatible( props.initialValues ?? mapFieldsToValues(props.fields, nestedFormData) ) return ( - + initialValues={initialValues} validate={(values) => getValidationErrorsForForm( props.fields, - flatten(values), + makeFormikFieldIdsOpenCRVSCompatible(values), props.requiredErrorMessage ) } diff --git a/packages/client/src/v2-events/components/forms/inputs/FileInput/DocumentListPreview.tsx b/packages/client/src/v2-events/components/forms/inputs/FileInput/DocumentListPreview.tsx new file mode 100644 index 00000000000..da95823844b --- /dev/null +++ b/packages/client/src/v2-events/components/forms/inputs/FileInput/DocumentListPreview.tsx @@ -0,0 +1,96 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * OpenCRVS is also distributed under the terms of the Civil Registration + * & Healthcare Disclaimer located at http://opencrvs.org/license. + * + * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. + */ +import * as React from 'react' +import styled from 'styled-components' +import { FieldValue, FileFieldValue } from '@opencrvs/commons/client' +import { Button } from '@opencrvs/components/lib/Button/Button' +import { Icon } from '@opencrvs/components/lib/Icon/Icon' +import { Link } from '@opencrvs/components/lib/Link/Link' +import { ISelectOption } from '@opencrvs/components/lib/Select' + +const Wrapper = styled.div` + max-width: 100%; + & > *:last-child { + margin-bottom: 8px; + border-bottom: 1.5px solid ${({ theme }) => theme.colors.grey100}; + } +` +const Container = styled.div` + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 4px; + border-top: 1.5px solid ${({ theme }) => theme.colors.grey100}; + height: 48px; + padding: 0px 10px; +` + +const Label = styled.div` + display: flex; + align-items: center; + justify-content: flex-start; + gap: 8px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + svg { + flex-shrink: 0; + } +` + +interface Props { + id?: string + attachment?: FileFieldValue + label?: string + onSelect: (document: FieldValue | FileFieldValue) => void + dropdownOptions?: ISelectOption[] + onDelete?: (image: FieldValue | FileFieldValue) => void +} + +export function DocumentListPreview({ + id, + attachment, + label, + onSelect, + dropdownOptions, + onDelete +}: Props) { + function getFormattedLabelForDocType(docType: string) { + const matchingOptionForDocType = + dropdownOptions && + dropdownOptions.find((option) => option.value === docType) + return matchingOptionForDocType && matchingOptionForDocType.label + } + return ( + + {attachment && label && ( + + + + + )} + + ) +} diff --git a/packages/client/src/v2-events/components/forms/inputs/FileInput/DocumentPreview.tsx b/packages/client/src/v2-events/components/forms/inputs/FileInput/DocumentPreview.tsx new file mode 100644 index 00000000000..9025e4d47a0 --- /dev/null +++ b/packages/client/src/v2-events/components/forms/inputs/FileInput/DocumentPreview.tsx @@ -0,0 +1,159 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * OpenCRVS is also distributed under the terms of the Civil Registration + * & Healthcare Disclaimer located at http://opencrvs.org/license. + * + * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. + */ + +import * as React from 'react' +import { useState } from 'react' +import styled from 'styled-components' +import { FileFieldValue } from '@opencrvs/commons' +import { AppBar } from '@opencrvs/components/lib/AppBar' +import { Button } from '@opencrvs/components/lib/Button' +import { DividerVertical } from '@opencrvs/components/lib/Divider' +import PanControls from '@opencrvs/components/lib/DocumentViewer/components/PanControls' +import PanViewer from '@opencrvs/components/lib/DocumentViewer/components/PanViewer' +import { Icon } from '@opencrvs/components/lib/Icon' +import { Stack } from '@opencrvs/components/lib/Stack' +import { getFullURL } from '@client/v2-events/features/files/useFileUpload' + +const ViewerWrapper = styled.div` + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + z-index: 4; + width: 100%; + height: 100%; + background: ${({ theme }) => theme.colors.white}; +` + +const ViewerContainer = styled.div` + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + display: flex; + align-items: center; + & img { + max-height: 80vh; + max-width: 80vw; + width: auto; + } +` + +interface IProps { + previewImage: FileFieldValue + disableDelete?: boolean + title?: string + goBack: () => void + onDelete: (image: FileFieldValue) => void + id?: string +} + +export function DocumentPreview({ + previewImage, + title, + goBack, + onDelete, + disableDelete, + id +}: IProps) { + const [zoom, setZoom] = useState(1) + const [rotation, setRotation] = useState(0) + + function zoomIn() { + setZoom((prevState) => prevState + 0.2) + } + function zoomOut() { + setZoom((prevState) => (prevState >= 1 ? prevState - 0.2 : prevState)) + } + function rotateLeft() { + setRotation((prevState) => (prevState - 90) % 360) + } + + return ( + + } + desktopRight={ + + + {!disableDelete && ( + <> + + + + )} + + + + } + desktopTitle={title} + mobileLeft={} + mobileRight={ + + + {!disableDelete && ( + + )} + + + } + mobileTitle={title} + /> + + + + + + ) +} diff --git a/packages/client/src/v2-events/components/forms/inputs/FileInput/FileInput.tsx b/packages/client/src/v2-events/components/forms/inputs/FileInput/FileInput.tsx new file mode 100644 index 00000000000..2dc21bf1336 --- /dev/null +++ b/packages/client/src/v2-events/components/forms/inputs/FileInput/FileInput.tsx @@ -0,0 +1,76 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * OpenCRVS is also distributed under the terms of the Civil Registration + * & Healthcare Disclaimer located at http://opencrvs.org/license. + * + * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. + */ + +import React, { ComponentProps } from 'react' +import { FileFieldValue } from '@opencrvs/commons/client' +import { useFileUpload } from '@client/v2-events/features/files/useFileUpload' +import { SimpleDocumentUploader } from './SimpleDocumentUploader' + +export function FileInput( + props: Omit< + ComponentProps, + 'onComplete' | 'label' | 'error' + > & { + value: FileFieldValue | undefined + onChange: (value?: FileFieldValue) => void + error?: boolean + } +) { + const { value, onChange, name, description, allowedDocType } = props + + const [file, setFile] = React.useState(value) + + const { uploadFiles } = useFileUpload(name, { + onSuccess: ({ filename }) => { + if (!file) { + throw new Error('File is not defined. This should never happen') + } + setFile({ + filename, + originalFilename: file.originalFilename, + type: file.type + }) + + onChange({ + filename, + originalFilename: file.originalFilename, + type: file.type + }) + } + }) + + return ( + { + if (newFile) { + setFile({ + filename: newFile.name, + originalFilename: newFile.name, + type: newFile.type + }) + uploadFiles(newFile) + } else { + setFile(undefined) + onChange(undefined) + } + }} + /> + ) +} + +export const FileOutput = null diff --git a/packages/client/src/v2-events/components/forms/inputs/FileInput/SimpleDocumentUploader.tsx b/packages/client/src/v2-events/components/forms/inputs/FileInput/SimpleDocumentUploader.tsx new file mode 100644 index 00000000000..84e9cfdd49e --- /dev/null +++ b/packages/client/src/v2-events/components/forms/inputs/FileInput/SimpleDocumentUploader.tsx @@ -0,0 +1,171 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * OpenCRVS is also distributed under the terms of the Civil Registration + * & Healthcare Disclaimer located at http://opencrvs.org/license. + * + * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. + */ +import React, { useState } from 'react' +import { + injectIntl, + WrappedComponentProps as IntlShapeProps, + MessageDescriptor +} from 'react-intl' +import styled from 'styled-components' +import { FieldValue, FileFieldValue } from '@opencrvs/commons/client' +import { ErrorText } from '@opencrvs/components/lib/ErrorText' +import { ImageUploader } from '@opencrvs/components/lib/ImageUploader' +import { buttonMessages, formMessages as messages } from '@client/i18n/messages' +import { DocumentPreview } from './DocumentPreview' +import { DocumentListPreview } from './DocumentListPreview' + +const DocumentUploader = styled(ImageUploader)` + color: ${({ theme }) => theme.colors.primary}; + background: ${({ theme }) => theme.colors.white}; + border: ${({ theme }) => `2px solid ${theme.colors.primary}`}; + border-radius: 4px; + ${({ theme }) => theme.fonts.bold14}; + height: 40px; + text-transform: initial; + + @media (max-width: ${({ theme }) => theme.grid.breakpoints.md}px) { + margin-left: 0px; + margin-top: 10px; + } +` + +const FieldDescription = styled.div` + margin-top: 0px; + margin-bottom: 6px; +` + +type IFullProps = { + name: string + label?: string + file?: FileFieldValue + description?: string + allowedDocType?: string[] + error?: string + disableDeleteInPreview?: boolean + onComplete: (file: File | null) => void + touched?: boolean + onUploadingStateChanged?: (isUploading: boolean) => void + requiredErrorMessage?: MessageDescriptor + previewTransformer?: (files: FileFieldValue) => FileFieldValue +} & IntlShapeProps + +function SimpleDocumentUploaderComponent({ + allowedDocType, + name, + onUploadingStateChanged, + intl, + previewTransformer, + onComplete, + label, + file, + description, + error: errorProps, + disableDeleteInPreview, + requiredErrorMessage, + touched +}: IFullProps) { + const [error, setError] = useState('') + const [previewImage, setPreviewImage] = useState(null) + const [filesBeingUploaded, setFilesBeingUploaded] = useState< + { label: string }[] + >([]) + + function handleFileChange(uploadedImage: File) { + setFilesBeingUploaded([ + ...filesBeingUploaded, + { label: uploadedImage.name } + ]) + + onUploadingStateChanged && onUploadingStateChanged(true) + + if ( + allowedDocType && + allowedDocType.length > 0 && + !allowedDocType.includes(uploadedImage.type) + ) { + onUploadingStateChanged && onUploadingStateChanged(false) + setFilesBeingUploaded([]) + const newErrorMessage = intl.formatMessage(messages.fileUploadError, { + type: allowedDocType + .map((docTypeStr) => docTypeStr.split('/').pop()) + .join(', ') + }) + + setError(newErrorMessage) + } else { + onUploadingStateChanged && onUploadingStateChanged(false) + onComplete(uploadedImage) + setError('') + setFilesBeingUploaded([]) + } + } + + function selectForPreview(selectedPreviewImage: FieldValue) { + if (previewTransformer) { + return setPreviewImage( + previewTransformer(selectedPreviewImage as FileFieldValue) + ) + } + setPreviewImage(selectedPreviewImage as FileFieldValue) + } + + function closePreviewSection() { + setPreviewImage(null) + } + + function onDelete(image: FieldValue) { + onComplete(null) + closePreviewSection() + } + + const errorMessage = + (requiredErrorMessage && intl.formatMessage(requiredErrorMessage)) || + error || + errorProps || + '' + + return ( + <> + {description && {description}} + {errorMessage && (touched || error) && ( + {errorMessage} + )} + + {previewImage && ( + + )} + {!file && ( + + {intl.formatMessage(messages.uploadFile)} + + )} + + ) +} + +export const SimpleDocumentUploader = injectIntl<'intl', IFullProps>( + SimpleDocumentUploaderComponent +) diff --git a/packages/client/src/v2-events/components/forms/utils.ts b/packages/client/src/v2-events/components/forms/utils.ts index f26a3c2fdf7..265ed82d572 100644 --- a/packages/client/src/v2-events/components/forms/utils.ts +++ b/packages/client/src/v2-events/components/forms/utils.ts @@ -8,16 +8,19 @@ * * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. */ -import { BaseField, FieldConfig, validate } from '@opencrvs/commons/client' import { - DependencyInfo, - IFormFieldValue, - IFormSectionData -} from '@client/forms' + ActionFormData, + BaseField, + ConditionalParameters, + FieldConfig, + FieldValue, + validate +} from '@opencrvs/commons/client' +import { DependencyInfo } from '@client/forms' export function handleInitialValue( field: FieldConfig, - formData: IFormSectionData + formData: ActionFormData ) { const initialValue = field.initialValue @@ -30,23 +33,16 @@ export function handleInitialValue( return initialValue } -export type FlatFormData = Record - export function getConditionalActionsForField( field: FieldConfig, - values: FlatFormData + values: ConditionalParameters ) { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (!field.conditionals) { return [] } return field.conditionals - .filter((conditional) => - validate(conditional.conditional, { - $form: values, - $now: new Date().toISOString().split('T')[0] - }) - ) + .filter((conditional) => validate(conditional.conditional, values)) .map((conditional) => conditional.type) } @@ -55,14 +51,10 @@ export function evalExpressionInFieldDefinition( /* * These are used in the eval expression */ - { $form }: { $form: FlatFormData } + { $form }: { $form: ActionFormData } ) { // eslint-disable-next-line no-eval - return eval(expression) as IFormFieldValue -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) + return eval(expression) as FieldValue } export function hasInitialValueDependencyInfo( @@ -85,47 +77,3 @@ export function getDependentFields( return field.initialValue.dependsOn.includes(fieldName) }) } - -export function flatten( - obj: Record, - parentKey = '', - separator = '.' -): Record { - const result: Record = {} - - for (const [key, value] of Object.entries(obj)) { - const newKey = parentKey ? `${parentKey}${separator}${key}` : key - - if (isRecord(value)) { - Object.assign( - result, - flatten(value as Record, newKey, separator) - ) - } else { - result[newKey] = value - } - } - - return result -} - -export function unflatten( - obj: Record, - separator = '.' -): Record> { - const result: Record> = {} - - for (const [key, value] of Object.entries(obj)) { - const keys = key.split(separator) - let current: Record> = result - - keys.forEach((part, index) => { - if (!current[part] || typeof current[part] !== 'object') { - current[part] = index === keys.length - 1 ? value : {} - } - current = current[part] as Record> - }) - } - - return result -} diff --git a/packages/client/src/v2-events/components/forms/validation.ts b/packages/client/src/v2-events/components/forms/validation.ts index d212769bdcf..d1a7246b65c 100644 --- a/packages/client/src/v2-events/components/forms/validation.ts +++ b/packages/client/src/v2-events/components/forms/validation.ts @@ -14,8 +14,9 @@ import { FieldConfig, validate } from '@opencrvs/commons/client' +import { ActionFormData } from '@opencrvs/commons' import { IValidationResult } from '@client/utils/validate' -import { FlatFormData, getConditionalActionsForField } from './utils' +import { getConditionalActionsForField } from './utils' interface IFieldErrors { errors: IValidationResult[] @@ -45,7 +46,7 @@ function isFieldDisabled(field: FieldConfig, params: ConditionalParameters) { function getValidationErrors( field: FieldConfig, - values: FlatFormData, + values: ActionFormData, requiredErrorMessage?: MessageDescriptor, checkValidationErrorsOnly?: boolean ) { @@ -90,7 +91,7 @@ function getValidationErrors( } export function getValidationErrorsForForm( fields: FieldConfig[], - values: FlatFormData, + values: ActionFormData, requiredErrorMessage?: MessageDescriptor, checkValidationErrorsOnly?: boolean ) { diff --git a/packages/client/src/v2-events/features/debug/debug.tsx b/packages/client/src/v2-events/features/debug/debug.tsx index 8f3c9b8b80e..42954c06307 100644 --- a/packages/client/src/v2-events/features/debug/debug.tsx +++ b/packages/client/src/v2-events/features/debug/debug.tsx @@ -9,14 +9,14 @@ * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. */ /* stylelint-disable */ -import React from 'react' +import { useQueryClient } from '@tanstack/react-query' import { ReactQueryDevtools } from '@tanstack/react-query-devtools' +import React from 'react' import styled from 'styled-components' -import { useQueryClient } from '@tanstack/react-query' import { v4 as uuid } from 'uuid' import { Text } from '@opencrvs/components' -import { useOnlineStatus } from '@client/utils' import { useEvents } from '@client/v2-events/features/events/useEvents/useEvents' +import { useOnlineStatus } from '@client/utils' /* Debug file should be the only transient component which will not be present in near future. */ /* eslint-disable react/jsx-no-literals */ @@ -106,9 +106,6 @@ export function Debug() { - - Local records - diff --git a/packages/client/src/v2-events/features/events/EventSelection.tsx b/packages/client/src/v2-events/features/events/EventSelection.tsx index 46a28b864cf..22bf74f5681 100644 --- a/packages/client/src/v2-events/features/events/EventSelection.tsx +++ b/packages/client/src/v2-events/features/events/EventSelection.tsx @@ -23,11 +23,10 @@ import { Icon } from '@opencrvs/components/lib/Icon' import { RadioButton } from '@opencrvs/components/lib/Radio' import { Stack } from '@opencrvs/components/lib/Stack' import { ROUTES } from '@client/v2-events/routes' -import { Debug } from '@client/v2-events/features/debug/debug' -import { useEvents } from './useEvents/useEvents' -import { useEventFormNavigation } from './useEventFormNavigation' -import { useEventFormData } from './useEventFormData' import { useEventConfigurations } from './useEventConfiguration' +import { useEventFormData } from './useEventFormData' +import { useEventFormNavigation } from './useEventFormNavigation' +import { useEvents } from './useEvents/useEvents' const messages = defineMessages({ registerNewEventTitle: { @@ -182,7 +181,6 @@ export function EventSelection() { - ) } diff --git a/packages/client/src/v2-events/features/events/actions/declare/Review.tsx b/packages/client/src/v2-events/features/events/actions/declare/Review.tsx index 81b8bcca939..ced9721922c 100644 --- a/packages/client/src/v2-events/features/events/actions/declare/Review.tsx +++ b/packages/client/src/v2-events/features/events/actions/declare/Review.tsx @@ -192,8 +192,8 @@ export function Review() { form={form} // @todo: Update to use dynamic title title={intl.formatMessage(formConfigs[0].review.title, { - firstname: form['applicant.firstname'], - surname: form['applicant.surname'] + firstname: form['applicant.firstname'] as string, + surname: form['applicant.surname'] as string })} > JSX.Element) + > +> = { + FILE: FileOutput +} + +function DefaultOutput({ value }: { value: T }) { + return <>{value.toString() || ''} +} + /** * Review component, used to display the "read" version of the form. * User can review the data and take actions like declare, reject or edit the data. @@ -170,7 +193,7 @@ function ReviewComponent({ children: React.ReactNode eventConfig: EventConfig formConfig: FormConfig - form: Record + form: ActionFormData onEdit: ({ pageId, fieldId }: { pageId: string; fieldId?: string }) => void title: string }) { @@ -222,30 +245,50 @@ function ReviewComponent({ name={'Accordion_' + page.id} > - {page.fields.map((field) => ( - { - e.stopPropagation() + {page.fields + .filter( + (field) => + // Formatters can explicitly define themselves to be null + // this means a value display row in not rendered at all + FIELD_TYPE_FORMATTERS[field.type] !== null + ) + .map((field) => { + const Output = + FIELD_TYPE_FORMATTERS[field.type] || DefaultOutput + + const hasValue = form[field.id] !== undefined + + const valueDisplay = hasValue ? ( + + ) : ( + '' + ) + + return ( + { + e.stopPropagation() - onEdit({ - pageId: page.id, - fieldId: field.id - }) - }} - > - {intl.formatMessage( - reviewMessages.changeButton - )} - - } - id={field.id} - label={intl.formatMessage(field.label)} - value={form[field.id] || ''} - /> - ))} + onEdit({ + pageId: page.id, + fieldId: field.id + }) + }} + > + {intl.formatMessage( + reviewMessages.changeButton + )} + + } + id={field.id} + label={intl.formatMessage(field.label)} + value={valueDisplay} + /> + ) + })} diff --git a/packages/client/src/v2-events/features/events/fixtures.ts b/packages/client/src/v2-events/features/events/fixtures.ts index bd3a157ce0f..dfb84a27302 100644 --- a/packages/client/src/v2-events/features/events/fixtures.ts +++ b/packages/client/src/v2-events/features/events/fixtures.ts @@ -8,7 +8,119 @@ * * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. */ -import { EventConfig } from '@opencrvs/commons/client' +import { EventConfig, FormConfig } from '@opencrvs/commons/client' + +export const DEFAULT_FORM = { + label: { + id: 'event.tennis-club-membership.action.declare.form.label', + defaultMessage: 'Tennis club membership application', + description: 'This is what this form is referred as in the system' + }, + active: true, + version: { + id: '1.0.0', + label: { + id: 'event.tennis-club-membership.action.declare.form.version.1', + defaultMessage: 'Version 1', + description: 'This is the first version of the form' + } + }, + review: { + title: { + id: 'event.tennis-club-membership.action.declare.form.review.title', + defaultMessage: 'Member declaration for {firstname} {surname}', + description: 'Title of the form to show in review page' + } + }, + pages: [ + { + id: 'applicant', + title: { + id: 'event.tennis-club-membership.action.declare.form.section.who.title', + defaultMessage: 'Who is applying for the membership?', + description: 'This is the title of the section' + }, + fields: [ + { + id: 'applicant.firstname', + type: 'TEXT', + required: true, + conditionals: [], + label: { + defaultMessage: "Applicant's first name", + description: 'This is the label for the field', + id: 'event.tennis-club-membership.action.declare.form.section.who.field.firstname.label' + } + }, + { + id: 'applicant.surname', + type: 'TEXT', + required: true, + conditionals: [], + label: { + defaultMessage: "Applicant's surname", + description: 'This is the label for the field', + id: 'event.tennis-club-membership.action.declare.form.section.who.field.surname.label' + } + }, + { + id: 'applicant.dob', + type: 'DATE', + required: true, + conditionals: [], + label: { + defaultMessage: "Applicant's date of birth", + description: 'This is the label for the field', + id: 'event.tennis-club-membership.action.declare.form.section.who.field.dob.label' + } + } + ] + }, + { + id: 'recommender', + title: { + id: 'event.tennis-club-membership.action.declare.form.section.recommender.title', + defaultMessage: 'Who is recommending the applicant?', + description: 'This is the title of the section' + }, + fields: [ + { + id: 'recommender.firstname', + type: 'TEXT', + required: true, + conditionals: [], + label: { + defaultMessage: "Recommender's first name", + description: 'This is the label for the field', + id: 'event.tennis-club-membership.action.declare.form.section.recommender.field.firstname.label' + } + }, + { + id: 'recommender.surname', + type: 'TEXT', + required: true, + conditionals: [], + label: { + defaultMessage: "Recommender's surname", + description: 'This is the label for the field', + id: 'event.tennis-club-membership.action.declare.form.section.recommender.field.surname.label' + } + }, + { + id: 'recommender.id', + type: 'TEXT', + required: true, + conditionals: [], + label: { + defaultMessage: "Recommender's membership ID", + description: 'This is the label for the field', + id: 'event.tennis-club-membership.action.declare.form.section.recommender.field.id.label' + } + } + ] + } + ] +} satisfies FormConfig /** @knipignore */ export const tennisClubMembershipEvent = { @@ -127,119 +239,7 @@ export const tennisClubMembershipEvent = { 'This is shown as the action name anywhere the user can trigger the action from', id: 'event.tennis-club-membership.action.declare.label' }, - forms: [ - { - label: { - id: 'event.tennis-club-membership.action.declare.form.label', - defaultMessage: 'Tennis club membership application', - description: 'This is what this form is referred as in the system' - }, - active: true, - version: { - id: '1.0.0', - label: { - id: 'event.tennis-club-membership.action.declare.form.version.1', - defaultMessage: 'Version 1', - description: 'This is the first version of the form' - } - }, - review: { - title: { - id: 'event.tennis-club-membership.action.declare.form.review.title', - defaultMessage: 'Member declaration for {firstname} {surname}', - description: 'Title of the form to show in review page' - } - }, - pages: [ - { - id: 'applicant', - title: { - id: 'event.tennis-club-membership.action.declare.form.section.who.title', - defaultMessage: 'Who is applying for the membership?', - description: 'This is the title of the section' - }, - fields: [ - { - id: 'applicant.firstname', - type: 'TEXT', - required: true, - conditionals: [], - label: { - defaultMessage: "Applicant's first name", - description: 'This is the label for the field', - id: 'event.tennis-club-membership.action.declare.form.section.who.field.firstname.label' - } - }, - { - id: 'applicant.surname', - type: 'TEXT', - required: true, - conditionals: [], - label: { - defaultMessage: "Applicant's surname", - description: 'This is the label for the field', - id: 'event.tennis-club-membership.action.declare.form.section.who.field.surname.label' - } - }, - { - id: 'applicant.dob', - type: 'DATE', - required: true, - conditionals: [], - label: { - defaultMessage: "Applicant's date of birth", - description: 'This is the label for the field', - id: 'event.tennis-club-membership.action.declare.form.section.who.field.dob.label' - } - } - ] - }, - { - id: 'recommender', - title: { - id: 'event.tennis-club-membership.action.declare.form.section.recommender.title', - defaultMessage: 'Who is recommending the applicant?', - description: 'This is the title of the section' - }, - fields: [ - { - id: 'recommender.firstname', - type: 'TEXT', - required: true, - conditionals: [], - label: { - defaultMessage: "Recommender's first name", - description: 'This is the label for the field', - id: 'event.tennis-club-membership.action.declare.form.section.recommender.field.firstname.label' - } - }, - { - id: 'recommender.surname', - type: 'TEXT', - required: true, - conditionals: [], - label: { - defaultMessage: "Recommender's surname", - description: 'This is the label for the field', - id: 'event.tennis-club-membership.action.declare.form.section.recommender.field.surname.label' - } - }, - { - id: 'recommender.id', - type: 'TEXT', - required: true, - conditionals: [], - label: { - defaultMessage: "Recommender's membership ID", - description: 'This is the label for the field', - id: 'event.tennis-club-membership.action.declare.form.section.recommender.field.id.label' - } - } - ] - } - ] - } - ] + forms: [DEFAULT_FORM] } ] } satisfies EventConfig diff --git a/packages/client/src/v2-events/features/events/useEventFormData.ts b/packages/client/src/v2-events/features/events/useEventFormData.ts index 3fe857ed231..ca60088ba79 100644 --- a/packages/client/src/v2-events/features/events/useEventFormData.ts +++ b/packages/client/src/v2-events/features/events/useEventFormData.ts @@ -10,21 +10,25 @@ */ import { create } from 'zustand' -import { persist, createJSONStorage } from 'zustand/middleware' -import { ActionInput } from '@opencrvs/commons/client' +import { createJSONStorage, persist } from 'zustand/middleware' +import { ActionFormData } from '@opencrvs/commons/client' import { storage } from '@client/storage' -type FormData = ActionInput['data'] - interface EventFormData { - formValues: FormData - setFormValues: (eventId: string, data: FormData) => void - getFormValues: (eventId: string) => FormData + formValues: ActionFormData + setFormValues: (eventId: string, data: ActionFormData) => void + getFormValues: (eventId: string) => ActionFormData getTouchedFields: () => Record clear: () => void eventId: string } +function removeUndefinedKeys(data: ActionFormData) { + return Object.fromEntries( + Object.entries(data).filter(([_, value]) => value !== undefined) + ) +} + export const useEventFormData = create()( persist( (set, get) => ({ @@ -32,8 +36,10 @@ export const useEventFormData = create()( eventId: '', getFormValues: (eventId: string) => get().eventId === eventId ? get().formValues : {}, - setFormValues: (eventId: string, data: FormData) => - set(() => ({ eventId, formValues: data })), + setFormValues: (eventId: string, data: ActionFormData) => { + const formValues = removeUndefinedKeys(data) + return set(() => ({ eventId, formValues })) + }, getTouchedFields: () => Object.fromEntries( Object.entries(get().formValues).map(([key, value]) => [key, true]) diff --git a/packages/client/src/v2-events/features/events/useEvents/useEvents.test.tsx b/packages/client/src/v2-events/features/events/useEvents/useEvents.test.tsx index 6d43c25bade..5c62d4df0b0 100644 --- a/packages/client/src/v2-events/features/events/useEvents/useEvents.test.tsx +++ b/packages/client/src/v2-events/features/events/useEvents/useEvents.test.tsx @@ -60,7 +60,7 @@ const createHandler = trpcHandler(async ({ request }) => { createdAt: new Date('2024-12-05T18:37:31.295Z').toISOString(), createdBy: '6733309827b97e6483877188', createdAtLocation: 'ae5be1bb-6c50-4389-a72d-4c78d19ec176', - data: [] + data: {} } ] }) @@ -140,7 +140,7 @@ describe('events that have unsynced actions', () => { }) => { server.use(http.post('/api/events/event.create', errorHandler)) createEventHook.result.current.mutate({ - type: 'birth', + type: 'TENNIS_CLUB_MEMBERSHIP', transactionId: '_TEST_TRANSACTION_' }) @@ -160,7 +160,7 @@ describe('events that have unsynced actions', () => { createEventHook }) => { await createEventHook.result.current.mutateAsync({ - type: 'birth', + type: 'TENNIS_CLUB_MEMBERSHIP', transactionId: '_TEST_TRANSACTION_' }) // Wait for backend to sync @@ -171,7 +171,6 @@ describe('events that have unsynced actions', () => { }) ) - // Store still has one event await waitFor(() => { expect(eventsHook.result.current.events.data).toHaveLength(1) }) @@ -189,7 +188,7 @@ describe('events that have unsynced actions', () => { createEventHook }) => { await createEventHook.result.current.mutateAsync({ - type: 'birth', + type: 'TENNIS_CLUB_MEMBERSHIP', transactionId: '_TEST_TRANSACTION_' }) // Wait for backend to sync @@ -215,7 +214,7 @@ test('events that have unsynced actions are treated as "outbox" ', server.use(http.post('/api/events/event.create', errorHandler)) createHook.result.current.mutate({ - type: 'birth', + type: 'TENNIS_CLUB_MEMBERSHIP', transactionId: '_TEST_FAILING_TRANSACTION_' }) @@ -242,8 +241,8 @@ test('events that have unsynced actions are treated as "outbox" ', mutation.execute(mutation.state.variables) ) ) - - await waitFor(() => expect(serverSpy.mock.calls).toHaveLength(1)) + // @TODO: Check if this change was intentional or is there some inconsistency with the cache. + await waitFor(() => expect(serverSpy.mock.calls).toHaveLength(2)) await waitFor(() => { expect(eventsHook.result.current.getOutbox()).toHaveLength(0) diff --git a/packages/client/src/v2-events/features/events/useEvents/useEvents.ts b/packages/client/src/v2-events/features/events/useEvents/useEvents.ts index 3ee4ef18b10..cdd9cfcccdd 100644 --- a/packages/client/src/v2-events/features/events/useEvents/useEvents.ts +++ b/packages/client/src/v2-events/features/events/useEvents/useEvents.ts @@ -9,16 +9,21 @@ * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. */ -import { hashKey, QueryObserver, useSuspenseQuery } from '@tanstack/react-query' +import { + hashKey, + MutationKey, + QueryObserver, + useSuspenseQuery +} from '@tanstack/react-query' import { getQueryKey } from '@trpc/react-query' import { EventDocument, CreatedAction } from '@opencrvs/commons/client' import { api, queryClient, utils } from '@client/v2-events/trpc' import { storage } from '@client/storage' -// @todo -// export function preloadData() { -// utils.config.get.ensureData() -// } +/* + * Local event storage + */ +const EVENTS_PERSISTENT_STORE_STORAGE_KEY = ['persisted-events'] function getCanonicalEventId( events: EventDocument[], @@ -49,7 +54,14 @@ function wrapMutationFnEventIdResolution( canonicalMutationFn: (params: T) => Promise ): (params: T) => Promise { return async (params: T) => { - const events = await readEventsFromStorage() + const events = queryClient.getQueryData( + EVENTS_PERSISTENT_STORE_STORAGE_KEY + ) + + if (!events) { + return canonicalMutationFn(params) + } + const id = getCanonicalEventId(events, params.eventId) if (!id) { return canonicalMutationFn(params) @@ -63,175 +75,28 @@ function wrapMutationFnEventIdResolution( } } -const EVENTS_PERSISTENT_STORE_STORAGE_KEY = ['persisted-events'] - -queryClient.setQueryDefaults(EVENTS_PERSISTENT_STORE_STORAGE_KEY, { - queryFn: async () => readEventsFromStorage() -}) - utils.event.actions.declare.setMutationDefaults(({ canonicalMutationFn }) => ({ - // This retry ensures on page reload if event have not yet synced, - // the action will be retried once - retry: 1, - retryDelay: 5000, - mutationFn: wrapMutationFnEventIdResolution(canonicalMutationFn), - onMutate: async (actionInput) => { - await queryClient.cancelQueries({ - queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY - }) - const events = await readEventsFromStorage() - const eventToUpdate = events.find( - // This hook is executed before mutationFn, so we need to check for both ids - (e) => [e.id, e.transactionId].includes(actionInput.eventId) - ) - - const eventsWithoutUpdated = events.filter( - (e) => e.id !== actionInput.eventId - ) - await writeEventsToStorage( - eventToUpdate - ? [...eventsWithoutUpdated, eventToUpdate] - : eventsWithoutUpdated - ) - await queryClient.invalidateQueries({ - queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY - }) - return { events } - }, - onSettled: async (response) => { - /* - * Updates event in store - */ - if (response) { - await queryClient.cancelQueries({ - queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY - }) - const events = await readEventsFromStorage() - const eventsWithoutNew = events.filter((e) => e.id !== response.id) - - await writeEventsToStorage([...eventsWithoutNew, response]) - return queryClient.invalidateQueries({ - queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY - }) - } - }, - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: getQueryKey(api.events.get) - }) - } + retry: true, + retryDelay: 10000, + mutationFn: wrapMutationFnEventIdResolution(canonicalMutationFn) })) utils.event.actions.draft.setMutationDefaults(({ canonicalMutationFn }) => ({ - // This retry ensures on page reload if event have not yet synced, - // the action will be retried once - retry: 1, - retryDelay: 5000, - mutationFn: wrapMutationFnEventIdResolution(canonicalMutationFn), - onMutate: async (actionInput) => { - await queryClient.cancelQueries({ - queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY - }) - const events = await readEventsFromStorage() - const eventToUpdate = events.find( - // This hook is executed before mutationFn, so we need to check for both ids - (e) => [e.id, e.transactionId].includes(actionInput.eventId) - ) - - const eventsWithoutUpdated = events.filter( - (e) => e.id !== actionInput.eventId - ) - await writeEventsToStorage( - eventToUpdate - ? [...eventsWithoutUpdated, eventToUpdate] - : eventsWithoutUpdated - ) - - await queryClient.invalidateQueries({ - queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY - }) - return { events } - }, - onSettled: async (response) => { - /* - * Updates event in store - */ - if (response) { - await queryClient.cancelQueries({ - queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY - }) - const events = await readEventsFromStorage() - const eventsWithoutNew = events.filter((e) => e.id !== response.id) - - await writeEventsToStorage([...eventsWithoutNew, response]) - return queryClient.invalidateQueries({ - queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY - }) - } - }, - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: getQueryKey(api.events.get) - }) - } + retry: true, + retryDelay: 10000, + mutationFn: wrapMutationFnEventIdResolution(canonicalMutationFn) })) -utils.event.actions.register.setMutationDefaults(({ canonicalMutationFn }) => ({ - // This retry ensures on page reload if event have not yet synced, - // the action will be retried once - retry: 1, - retryDelay: 5000, - mutationFn: wrapMutationFnEventIdResolution(canonicalMutationFn), - onMutate: async (actionInput) => { - await queryClient.cancelQueries({ - queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY - }) - const events = await readEventsFromStorage() - const eventToUpdate = events.find( - // This hook is executed before mutationFn, so we need to check for both ids - (e) => [e.id, e.transactionId].includes(actionInput.eventId) - ) - const eventsWithoutUpdated = events.filter( - (e) => e.id !== actionInput.eventId - ) - await writeEventsToStorage( - eventToUpdate - ? [...eventsWithoutUpdated, eventToUpdate] - : eventsWithoutUpdated - ) - await queryClient.invalidateQueries({ - queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY - }) - return { events } - }, - onSettled: async (response) => { - /* - * Updates event in store - */ - if (response) { - await queryClient.cancelQueries({ - queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY - }) - const events = await readEventsFromStorage() - const eventsWithoutNew = events.filter((e) => e.id !== response.id) - - await writeEventsToStorage([...eventsWithoutNew, response]) - return queryClient.invalidateQueries({ - queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY - }) - } - }, - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: getQueryKey(api.events.get) - }) - } +utils.event.actions.register.setMutationDefaults(({ canonicalMutationFn }) => ({ + retry: true, + retryDelay: 10000, + mutationFn: wrapMutationFnEventIdResolution(canonicalMutationFn) })) utils.event.create.setMutationDefaults(({ canonicalMutationFn }) => ({ mutationFn: canonicalMutationFn, - retry: 0, - onMutate: async (newEvent) => { + retry: true, + onMutate: (newEvent) => { const optimisticEvent = { id: newEvent.transactionId, type: newEvent.type, @@ -253,50 +118,74 @@ utils.event.create.setMutationDefaults(({ canonicalMutationFn }) => ({ // that the event is created when changing view for instance queryClient.setQueryData( EVENTS_PERSISTENT_STORE_STORAGE_KEY, - (old: EventDocument[]) => { - return [...old, optimisticEvent] + (events: EventDocument[]) => { + return [...events, optimisticEvent] } ) - await queryClient.cancelQueries({ - queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY - }) - - const events = await readEventsFromStorage() - - await writeEventsToStorage([...events, optimisticEvent]) return optimisticEvent }, - onSettled: async (response) => { - if (response) { - await queryClient.cancelQueries({ - queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY - }) - const events = await readEventsFromStorage() - const eventsWithoutNew = events.filter( - (e) => e.transactionId !== response.transactionId - ) + onSuccess: async (response) => { + const events = queryClient.getQueryData( + EVENTS_PERSISTENT_STORE_STORAGE_KEY + ) - await writeEventsToStorage([...eventsWithoutNew, response]) - await queryClient.invalidateQueries({ - queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY - }) + if (!events) { + return + } - await queryClient.invalidateQueries({ - queryKey: getQueryKey(api.event.get, response.transactionId, 'query') + queryClient + .getMutationCache() + .getAll() + .forEach((mutation) => { + /* + * Update ongoing mutations with the new event ID so that transaction id is not used for querying + */ + const hashQueryKey = ( + key: MutationKey | ReturnType | undefined + ) => key?.flat().join('.') + + if ( + hashQueryKey(mutation.options.mutationKey) === + hashQueryKey(getQueryKey(api.event.actions.declare)) + ) { + const variables = mutation.state.variables as Exclude< + ReturnType< + typeof api.event.actions.declare.useMutation + >['variables'], + undefined + > + + if (variables.eventId === response.transactionId) { + variables.eventId = response.id + } + } }) - } - }, - onSuccess: (data) => { + await queryClient.invalidateQueries({ + queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY + }) + queryClient.setQueryData( EVENTS_PERSISTENT_STORE_STORAGE_KEY, - (old: EventDocument[]) => - old.filter((e) => e.transactionId !== data.transactionId).concat(data) + (state: EventDocument[]) => { + return [ + ...state.filter((e) => e.transactionId !== response.transactionId), + response + ] + } ) + + await queryClient.cancelQueries({ + queryKey: getQueryKey(api.event.get, response.transactionId, 'query') + }) } })) +queryClient.setQueryDefaults(EVENTS_PERSISTENT_STORE_STORAGE_KEY, { + queryFn: readEventsFromStorage +}) + const observer = new QueryObserver(queryClient, { queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY }) @@ -313,6 +202,12 @@ observer.subscribe((observerEvent) => { event ) }) + /* + * Persist events to browser storage + */ + if (observerEvent.data) { + void writeEventsToStorage(observerEvent.data) + } }) async function readEventsFromStorage() { @@ -386,19 +281,20 @@ export function useEvents() { } function getDrafts() { - return events.data.filter( + return storedEvents.data.filter( (event) => !event.actions.some((a) => a.type === 'DECLARE') ) } function getOutbox() { const eventFromCreateMutations = filterOutboxEventsWithMutation( - events.data, + storedEvents.data, api.event.create, (event, parameters) => event.transactionId === parameters.transactionId ) + const eventFromDeclareActions = filterOutboxEventsWithMutation( - events.data, + storedEvents.data, api.event.actions.declare, (event, parameters) => { return ( @@ -409,7 +305,7 @@ export function useEvents() { ) const eventFromRegisterActions = filterOutboxEventsWithMutation( - events.data, + storedEvents.data, api.event.actions.register, (event, parameters) => { return ( @@ -460,14 +356,13 @@ export function useEvents() { }) } - const events = useSuspenseQuery({ - queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY, - queryFn: async () => readEventsFromStorage() + const storedEvents = useSuspenseQuery({ + queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY }) return { createEvent, - events, + events: storedEvents, getEvent, getEventById: api.event.get, getEvents: api.events.get, diff --git a/packages/client/src/v2-events/features/files/useFileUpload.ts b/packages/client/src/v2-events/features/files/useFileUpload.ts new file mode 100644 index 00000000000..e40bd9737d3 --- /dev/null +++ b/packages/client/src/v2-events/features/files/useFileUpload.ts @@ -0,0 +1,135 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * OpenCRVS is also distributed under the terms of the Civil Registration + * & Healthcare Disclaimer located at http://opencrvs.org/license. + * + * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. + */ + +import { useMutation } from '@tanstack/react-query' +import { v4 as uuid } from 'uuid' +import { queryClient } from '@client/v2-events/trpc' +import { getToken } from '@client/utils/authUtils' + +async function uploadFile({ + file, + transactionId +}: { + file: File + transactionId: string +}): Promise<{ url: string }> { + const formData = new FormData() + formData.append('file', file) + formData.append('transactionId', transactionId) + + const response = await fetch('/api/upload', { + method: 'POST', + headers: { + Authorization: `Bearer ${getToken()}` + }, + body: formData + }) + + if (!response.ok) { + throw new Error('File upload failed') + } + + return response +} + +const MUTATION_KEY = 'uploadFile' + +/* Must match the one defined src-sw.ts */ +const CACHE_NAME = 'workbox-runtime' + +function withPostfix(str: string, postfix: string) { + if (str.endsWith(postfix)) { + return str + } + + return str + postfix +} + +export function getFullURL(filename: string) { + const minioURL = window.config.MINIO_URL + if (minioURL && typeof minioURL === 'string') { + return new URL(filename, withPostfix(minioURL, '/')).toString() + } + + throw new Error('MINIO_URL is not defined') +} + +async function cacheFile(filename: string, file: File) { + const temporaryBlob = new Blob([file], { type: file.type }) + const cacheKeys = await caches.keys() + + const cacheKey = cacheKeys.find((key) => key.startsWith(CACHE_NAME)) + + if (!cacheKey) { + // eslint-disable-next-line no-console + console.error( + `Cache ${CACHE_NAME} not found. Is service worker running properly?` + ) + return + } + + const cache = await caches.open(cacheKey) + return cache.put( + getFullURL(filename), + new Response(temporaryBlob, { headers: { 'Content-Type': file.type } }) + ) +} + +async function removeCached(filename: string) { + const cacheKeys = await caches.keys() + const cacheKey = cacheKeys.find((key) => key.startsWith(CACHE_NAME)) + + if (!cacheKey) { + // eslint-disable-next-line no-console + console.error( + `Cache ${CACHE_NAME} not found. Is service worker running properly?` + ) + return + } + + const cache = await caches.open(cacheKey) + return cache.delete(getFullURL(filename)) +} + +queryClient.setMutationDefaults([MUTATION_KEY], { + retry: true, + retryDelay: 5000, + mutationFn: uploadFile +}) + +interface Options { + onSuccess?: (data: { filename: string }) => void +} + +export function useFileUpload(fieldId: string, options: Options = {}) { + const mutation = useMutation({ + mutationFn: uploadFile, + mutationKey: [MUTATION_KEY, fieldId], + onMutate: async ({ file, transactionId }) => { + const extension = file.name.split('.').pop() + const temporaryUrl = `${transactionId}.${extension}` + + await cacheFile(temporaryUrl, file) + + options.onSuccess?.({ filename: temporaryUrl }) + }, + onSuccess: (data) => { + void removeCached(data.url) + } + }) + + return { + getFullURL, + uploadFiles: (file: File) => { + return mutation.mutate({ file, transactionId: uuid() }) + } + } +} diff --git a/packages/client/src/v2-events/layouts/workqueues/index.tsx b/packages/client/src/v2-events/layouts/workqueues/index.tsx index a070f465769..bc822792d60 100644 --- a/packages/client/src/v2-events/layouts/workqueues/index.tsx +++ b/packages/client/src/v2-events/layouts/workqueues/index.tsx @@ -11,8 +11,8 @@ import React from 'react' -import { useNavigate } from 'react-router-dom' import { noop } from 'lodash' +import { useNavigate } from 'react-router-dom' import { AppBar, Button, @@ -23,7 +23,6 @@ import { } from '@opencrvs/components' import { Plus } from '@opencrvs/components/src/icons' import { ROUTES } from '@client/v2-events/routes' -import { Debug } from '@client/v2-events/features/debug/debug' /** * Basic frame for the workqueues. Includes the left navigation and the app bar. @@ -65,7 +64,6 @@ export function WorkqueueLayout({ children }: { children: React.ReactNode }) { skipToContentText="skip" > {children} - ) } diff --git a/packages/client/src/v2-events/routes/config.tsx b/packages/client/src/v2-events/routes/config.tsx index 096c73edc65..95b71b7b442 100644 --- a/packages/client/src/v2-events/routes/config.tsx +++ b/packages/client/src/v2-events/routes/config.tsx @@ -15,6 +15,7 @@ import { EventSelection } from '@client/v2-events/features/events/EventSelection import { EventOverviewIndex } from '@client/v2-events/features/workqueues/EventOverview/EventOverview' import { WorkqueueIndex } from '@client/v2-events/features/workqueues/Workqueue' import { TRPCProvider } from '@client/v2-events/trpc' +import { Debug } from '@client/v2-events/features/debug/debug' import * as Declare from '@client/v2-events/features/events/actions/declare' import * as Register from '@client/v2-events/features/events/actions/register' import { WorkqueueLayout, FormLayout } from '@client/v2-events/layouts' @@ -30,6 +31,7 @@ export const routesConfig = { element: ( + ), children: [ diff --git a/packages/client/src/v2-events/trpc.tsx b/packages/client/src/v2-events/trpc.tsx index a176410eefa..89a8a97e05b 100644 --- a/packages/client/src/v2-events/trpc.tsx +++ b/packages/client/src/v2-events/trpc.tsx @@ -63,11 +63,10 @@ function getQueryClient() { function createIDBPersister(idbValidKey = 'reactQuery') { return { persistClient: async (client: PersistedClient) => { - await storage.setItem(idbValidKey, JSON.stringify(client)) + await storage.setItem(idbValidKey, client) }, restoreClient: async () => { const client = await storage.getItem(idbValidKey) - return client || undefined }, removeClient: async () => { @@ -89,7 +88,7 @@ export function TRPCProvider({ children }: { children: React.ReactNode }) { client={queryClient} persistOptions={{ persister, - maxAge: 1000 * 60 * 60 * 4, + maxAge: undefined, buster: 'persisted-indexed-db', dehydrateOptions: { shouldDehydrateMutation: (mut) => { diff --git a/packages/client/src/views/RegisterForm/DeclarationForm.test.tsx b/packages/client/src/views/RegisterForm/DeclarationForm.test.tsx index 8fc1553f052..dd4d884e25c 100644 --- a/packages/client/src/views/RegisterForm/DeclarationForm.test.tsx +++ b/packages/client/src/views/RegisterForm/DeclarationForm.test.tsx @@ -92,6 +92,7 @@ describe('when user starts a new declaration', () => { let router: ReturnType beforeEach(async () => { + await flushPromises() const testApp = await createTestApp() app = testApp.app store = testApp.store @@ -383,7 +384,9 @@ describe('when user starts a new declaration', () => { await flushPromises() await goToDocumentsSection(app) }) - it('image upload field is rendered', () => { + it('image upload field is rendered', async () => { + await flushPromises() + app.update() expect(app.find('#upload_document').hostNodes()).toHaveLength(5) }) }) diff --git a/packages/client/typings/window.d.ts b/packages/client/typings/window.d.ts index fa123a9b073..3e65a7cb659 100644 --- a/packages/client/typings/window.d.ts +++ b/packages/client/typings/window.d.ts @@ -48,6 +48,7 @@ interface Window { LANGUAGES: string LOGIN_URL: string AUTH_URL: string + MINIO_URL: string MINIO_BUCKET: string COUNTRY_CONFIG_URL: string SHOW_FARAJALAND_IN_COUNTRY_LISTS: boolean diff --git a/packages/client/vite.config.ts b/packages/client/vite.config.ts index 8eb82936cd6..661b7bc3ac0 100644 --- a/packages/client/vite.config.ts +++ b/packages/client/vite.config.ts @@ -57,7 +57,7 @@ export default defineConfig(({ mode }) => { srcDir: 'src/', filename: 'src-sw.ts', devOptions: { - enabled: false, + enabled: true, type: 'module', navigateFallback: 'index.html' } diff --git a/packages/commons/src/conditionals/conditionals.ts b/packages/commons/src/conditionals/conditionals.ts index 98e7f40358e..f29c06ec06e 100644 --- a/packages/commons/src/conditionals/conditionals.ts +++ b/packages/commons/src/conditionals/conditionals.ts @@ -11,7 +11,7 @@ import { JSONSchemaType } from 'ajv' import { z } from 'zod' -import { EventDocument } from '../events' +import { ActionFormData, EventDocument } from '../events' export function Conditional() { return z.any() as z.ZodType @@ -23,10 +23,10 @@ export type ConditionalParameters = { $now: string } & ( } | { $event: EventDocument - $form: Record + $form: ActionFormData } | { - $form: Record + $form: ActionFormData } ) diff --git a/packages/commons/src/events/ActionDocument.ts b/packages/commons/src/events/ActionDocument.ts index 576f5ae17f8..7a2d2ba552f 100644 --- a/packages/commons/src/events/ActionDocument.ts +++ b/packages/commons/src/events/ActionDocument.ts @@ -10,12 +10,13 @@ */ import { ActionType } from './ActionConfig' import { z } from 'zod' +import { FieldValue } from './FieldValue' const ActionBase = z.object({ createdAt: z.string().datetime(), createdBy: z.string(), - createdAtLocation: z.string(), - data: z.record(z.string(), z.any()) + data: z.record(z.string(), FieldValue), + createdAtLocation: z.string() }) const AssignedAction = ActionBase.merge( @@ -91,3 +92,5 @@ export const ActionDocument = z.discriminatedUnion('type', [ export type ActionDocument = z.infer export type CreatedAction = z.infer + +export type ActionFormData = ActionDocument['data'] diff --git a/packages/commons/src/events/ActionInput.ts b/packages/commons/src/events/ActionInput.ts index 34a2eafdcf3..c88652a21d5 100644 --- a/packages/commons/src/events/ActionInput.ts +++ b/packages/commons/src/events/ActionInput.ts @@ -11,11 +11,12 @@ import { ActionType } from './ActionConfig' import { z } from 'zod' +import { FieldValue } from './FieldValue' const BaseActionInput = z.object({ eventId: z.string(), transactionId: z.string(), - data: z.record(z.any()) + data: z.record(z.string(), FieldValue) }) const CreateActionInput = BaseActionInput.merge( diff --git a/packages/commons/src/events/FieldConfig.ts b/packages/commons/src/events/FieldConfig.ts index e6d1881dbf6..c0291cde52d 100644 --- a/packages/commons/src/events/FieldConfig.ts +++ b/packages/commons/src/events/FieldConfig.ts @@ -11,6 +11,12 @@ import { z } from 'zod' import { TranslationConfig } from './TranslationConfig' import { Conditional } from '../conditionals/conditionals' +import { + DateFieldValue, + FileFieldValue, + ParagraphFieldValue, + TextFieldValue +} from './FieldValue' export const ConditionalTypes = { SHOW: 'SHOW', @@ -51,6 +57,7 @@ const BaseField = z.object({ .optional(), required: z.boolean().default(false).optional(), disabled: z.boolean().default(false).optional(), + hidden: z.boolean().default(false).optional(), placeholder: TranslationConfig.optional(), validation: z .array( @@ -79,6 +86,14 @@ export const FieldType = { export const fieldTypes = Object.values(FieldType) export type FieldType = (typeof fieldTypes)[number] +export type FieldValueByType = { + [FieldType.TEXT]: TextFieldValue + [FieldType.DATE]: DateFieldValue + [FieldType.PARAGRAPH]: ParagraphFieldValue + [FieldType.RADIO_GROUP]: string + [FieldType.FILE]: FileFieldValue +} + const TextField = BaseField.extend({ type: z.literal(FieldType.TEXT), options: z @@ -113,10 +128,6 @@ const File = BaseField.extend({ type: z.literal(FieldType.FILE) }).describe('File upload') -const Hidden = BaseField.extend({ - type: z.literal(FieldType.HIDDEN) -}).describe('Hidden field') - const RadioGroup = BaseField.extend({ type: z.literal(FieldType.RADIO_GROUP), options: z.array( @@ -132,7 +143,6 @@ export const FieldConfig = z.discriminatedUnion('type', [ DateField, Paragraph, RadioGroup, - Hidden, File ]) diff --git a/packages/commons/src/events/FieldValue.ts b/packages/commons/src/events/FieldValue.ts new file mode 100644 index 00000000000..9f3903a3d48 --- /dev/null +++ b/packages/commons/src/events/FieldValue.ts @@ -0,0 +1,40 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * OpenCRVS is also distributed under the terms of the Civil Registration + * & Healthcare Disclaimer located at http://opencrvs.org/license. + * + * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. + */ +import { z } from 'zod' + +const TextFieldValue = z.string() +export type TextFieldValue = z.infer + +const DateFieldValue = z.string() +export type DateFieldValue = z.infer + +const ParagraphFieldValue = z.string() +export type ParagraphFieldValue = z.infer + +export const FileFieldValue = z.object({ + filename: z.string(), + originalFilename: z.string(), + type: z.string() +}) + +export type FileFieldValue = z.infer + +const RadioGroupFieldValue = z.string() + +export const FieldValue = z.union([ + TextFieldValue, + DateFieldValue, + ParagraphFieldValue, + FileFieldValue, + RadioGroupFieldValue +]) + +export type FieldValue = z.infer diff --git a/packages/commons/src/events/index.ts b/packages/commons/src/events/index.ts index fffde9819ae..ae6d21b7f26 100644 --- a/packages/commons/src/events/index.ts +++ b/packages/commons/src/events/index.ts @@ -21,6 +21,7 @@ export * from './ActionInput' export * from './ActionDocument' export * from './EventIndex' export * from './TranslationConfig' +export * from './FieldValue' export * from './state' export * from './utils' export * from './defineConfig' diff --git a/packages/documents/package.json b/packages/documents/package.json index bd1e6ccfa60..21ce45f54df 100644 --- a/packages/documents/package.json +++ b/packages/documents/package.json @@ -16,6 +16,7 @@ "build:clean": "rm -rf build" }, "dependencies": { + "zod": "^3.23.8", "@hapi/boom": "^9.1.1", "@hapi/hapi": "^20.0.1", "@opencrvs/commons": "^1.7.0", diff --git a/packages/documents/src/config/routes.ts b/packages/documents/src/config/routes.ts index e7e27b8ac69..e278d174b97 100644 --- a/packages/documents/src/config/routes.ts +++ b/packages/documents/src/config/routes.ts @@ -8,9 +8,16 @@ * * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. */ -import { documentUploadHandler } from '@documents/features/uploadDocument/handler' +import { + fileUploadHandler, + documentUploadHandler, + fileExistsHandler +} from '@documents/features/uploadDocument/handler' import { vsExportUploaderHandler } from '@documents/features/uploadVSExportFile/handler' -import { createPreSignedUrl } from '@documents/features/getDocument/handler' +import { + createPreSignedUrl, + createPresignedUrlsInBulk +} from '@documents/features/getDocument/handler' import { svgUploadHandler } from '@documents/features/uploadSvg/handler' import { MINIO_BUCKET } from '@documents/minio/constants' @@ -25,6 +32,15 @@ export const getRoutes = () => { tags: ['api'] } }, + // get presigned URLs in bulk + { + method: 'POST', + path: `/presigned-urls`, + handler: createPresignedUrlsInBulk, + config: { + tags: ['api'] + } + }, { method: 'POST', path: '/presigned-url', @@ -33,6 +49,29 @@ export const getRoutes = () => { tags: ['api'] } }, + // check if file exists + { + method: 'GET', + path: '/files/{filename}', + handler: fileExistsHandler, + config: { + tags: ['api'] + } + }, + // upload a file to minio + { + method: 'POST', + path: '/files', + handler: fileUploadHandler, + config: { + tags: ['api'], + payload: { + allow: ['multipart/form-data'], + multipart: true, + output: 'stream' + } + } + }, // upload a document { method: 'POST', diff --git a/packages/documents/src/features/getDocument/handler.ts b/packages/documents/src/features/getDocument/handler.ts index efcb47c3c9d..bf9ad46c376 100644 --- a/packages/documents/src/features/getDocument/handler.ts +++ b/packages/documents/src/features/getDocument/handler.ts @@ -12,6 +12,7 @@ import { MINIO_BUCKET } from '@documents/minio/constants' import { signFileUrl } from '@documents/minio/sign' import * as Hapi from '@hapi/hapi' +import { z } from 'zod' export function createPreSignedUrl( request: Hapi.Request, @@ -31,3 +32,24 @@ export function createPreSignedUrl( return h.response(error).code(400) } } + +const BulkPayload = z.object({ + filenames: z.array(z.string()) +}) + +export function createPresignedUrlsInBulk( + request: Hapi.Request, + h: Hapi.ResponseToolkit +) { + const payload = BulkPayload.safeParse(request.payload) + + if (!payload.success) { + return h.response(payload.error).code(400) + } + + const response = payload.data.filenames.map((filename) => + signFileUrl(`/${MINIO_BUCKET}/${filename}`) + ) + + return h.response(response).code(200) +} diff --git a/packages/documents/src/features/uploadDocument/handler.ts b/packages/documents/src/features/uploadDocument/handler.ts index fc860f6021f..c9f6c3f8a8f 100644 --- a/packages/documents/src/features/uploadDocument/handler.ts +++ b/packages/documents/src/features/uploadDocument/handler.ts @@ -13,7 +13,10 @@ import { MINIO_BUCKET } from '@documents/minio/constants' import * as Hapi from '@hapi/hapi' import { v4 as uuid } from 'uuid' import { fromBuffer } from 'file-type' - +import { z } from 'zod' +import { Readable } from 'stream' +import { badRequest, notFound } from '@hapi/boom' +import { logger } from '@opencrvs/commons' export interface IDocumentPayload { fileData: string metaData?: Record @@ -24,6 +27,61 @@ export type IFileInfo = { mime: string } +const HapiSchema = z.object({ + filename: z.string().min(1, 'Filename is required'), + headers: z.record(z.string()), + bytes: z.number().optional() +}) + +const FileSchema = z + .custom }>((val) => { + return '_readableState' in val && 'hapi' in val + }, 'Not a readable stream or missing hapi field') + .refine( + (val) => HapiSchema.safeParse(val.hapi).success, + 'hapi does not match the required structure' + ) + +const Payload = z.object({ + file: FileSchema, + transactionId: z.string() +}) + +export async function fileUploadHandler( + request: Hapi.Request, + h: Hapi.ResponseToolkit +) { + const payload = await Payload.parseAsync(request.payload).catch((error) => { + logger.error(error) + throw badRequest('Invalid payload') + }) + + const { file, transactionId } = payload + + const extension = file.hapi.filename.split('.').pop() + const filename = `${transactionId}.${extension}` + try { + await minioClient.putObject(MINIO_BUCKET, filename, file) + } catch (error) { + logger.error(error) + throw error + } + + return filename +} + +export async function fileExistsHandler( + request: Hapi.Request, + h: Hapi.ResponseToolkit +) { + const { filename } = request.params + const exists = await minioClient.statObject(MINIO_BUCKET, filename) + if (!exists) { + return notFound('File not found') + } + return h.response().code(200) +} + export async function documentUploadHandler( request: Hapi.Request, h: Hapi.ResponseToolkit diff --git a/packages/events/package.json b/packages/events/package.json index c5fc3845e0e..41f9cf8333e 100644 --- a/packages/events/package.json +++ b/packages/events/package.json @@ -37,6 +37,7 @@ "eslint-plugin-prettier": "^4.0.0", "lint-staged": "^15.0.0", "mongodb-memory-server": "^10.1.2", + "msw": "^2.7.0", "nodemon": "^3.0.0", "prettier": "2.8.8", "testcontainers": "^10.15.0", diff --git a/packages/events/src/environment.ts b/packages/events/src/environment.ts index f1798e1b379..2e0be2011b1 100644 --- a/packages/events/src/environment.ts +++ b/packages/events/src/environment.ts @@ -14,5 +14,6 @@ import { cleanEnv, url } from 'envalid' export const env = cleanEnv(process.env, { MONGO_URL: url({ devDefault: 'mongodb://localhost/events' }), ES_HOST: url({ devDefault: 'http://localhost:9200' }), - COUNTRY_CONFIG_URL: url({ devDefault: 'http://localhost:3040' }) + COUNTRY_CONFIG_URL: url({ devDefault: 'http://localhost:3040' }), + DOCUMENTS_URL: url({ devDefault: 'http://localhost:9050' }) }) diff --git a/packages/events/src/router/event.actions.test.ts b/packages/events/src/router/event.actions.test.ts index fdb7d380f6a..55115933cff 100644 --- a/packages/events/src/router/event.actions.test.ts +++ b/packages/events/src/router/event.actions.test.ts @@ -32,7 +32,7 @@ test('actions can be added to created events', async () => { test('Action data can be retrieved', async () => { const originalEvent = await client.event.create(generator.event.create()) - const data = { name: 'John Doe', age: 42 } + const data = { name: 'John Doe', favouriteFruit: 'Banana' } await client.event.actions.declare( generator.event.actions.declare(originalEvent.id, { data diff --git a/packages/events/src/router/events.get.test.ts b/packages/events/src/router/events.get.test.ts index 0d5c04046de..ede4831789e 100644 --- a/packages/events/src/router/events.get.test.ts +++ b/packages/events/src/router/events.get.test.ts @@ -32,7 +32,7 @@ test('Returns multiple events', async () => { }) test('Returns aggregated event with updated status and values', async () => { - const initialData = { name: 'John Doe', age: 42 } + const initialData = { name: 'John Doe', favouriteFruit: 'Banana' } const event = await client.event.create(generator.event.create()) await client.event.actions.declare( generator.event.actions.declare(event.id, { @@ -46,7 +46,7 @@ test('Returns aggregated event with updated status and values', async () => { expect(initialEvents[0].status).toBe(EventStatus.DECLARED) expect(initialEvents[0].data).toEqual(initialData) - const updatedData = { name: 'John Doe', age: 43 } + const updatedData = { name: 'John Doe', favouriteFruit: 'Strawberry' } await client.event.actions.declare( generator.event.actions.declare(event.id, { data: updatedData diff --git a/packages/events/src/router/router.ts b/packages/events/src/router/router.ts index d54a5937458..e91fba9a4f4 100644 --- a/packages/events/src/router/router.ts +++ b/packages/events/src/router/router.ts @@ -21,7 +21,7 @@ import { DraftActionInput, RegisterActionInput } from '@opencrvs/commons/events' -import { getEventsConfig } from '@events/service/config/config' +import { getEventConfigurations } from '@events/service/config/config' import { addAction, createEvent, @@ -31,6 +31,7 @@ import { } from '@events/service/events' import { EventConfig, getUUID } from '@opencrvs/commons' import { getIndexedEvents } from '@events/service/indexing/indexing' +import { presignFilesInEvent } from '@events/service/files' const ContextSchema = z.object({ user: z.object({ @@ -74,12 +75,12 @@ export type AppRouter = typeof appRouter export const appRouter = router({ config: router({ get: publicProcedure.output(z.array(EventConfig)).query(async (options) => { - return getEventsConfig(options.ctx.token) + return getEventConfigurations(options.ctx.token) }) }), event: router({ create: publicProcedure.input(EventInput).mutation(async (options) => { - const config = await getEventsConfig(options.ctx.token) + const config = await getEventConfigurations(options.ctx.token) const eventIds = config.map((c) => c.id) validateEventType({ @@ -95,7 +96,7 @@ export const appRouter = router({ }) }), patch: publicProcedure.input(EventInputWithId).mutation(async (options) => { - const config = await getEventsConfig(options.ctx.token) + const config = await getEventConfigurations(options.ctx.token) const eventIds = config.map((c) => c.id) validateEventType({ @@ -105,29 +106,34 @@ export const appRouter = router({ return patchEvent(options.input) }), - get: publicProcedure.input(z.string()).query(async ({ input }) => { - return getEventById(input) + get: publicProcedure.input(z.string()).query(async ({ input, ctx }) => { + const event = await getEventById(input) + const eventWithSignedFiles = await presignFilesInEvent(event, ctx.token) + return eventWithSignedFiles }), actions: router({ notify: publicProcedure.input(NotifyActionInput).mutation((options) => { return addAction(options.input, { eventId: options.input.eventId, createdBy: options.ctx.user.id, - createdAtLocation: options.ctx.user.primaryOfficeId + createdAtLocation: options.ctx.user.primaryOfficeId, + token: options.ctx.token }) }), draft: publicProcedure.input(DraftActionInput).mutation((options) => { return addAction(options.input, { eventId: options.input.eventId, createdBy: options.ctx.user.id, - createdAtLocation: options.ctx.user.primaryOfficeId + createdAtLocation: options.ctx.user.primaryOfficeId, + token: options.ctx.token }) }), declare: publicProcedure.input(DeclareActionInput).mutation((options) => { return addAction(options.input, { eventId: options.input.eventId, createdBy: options.ctx.user.id, - createdAtLocation: options.ctx.user.primaryOfficeId + createdAtLocation: options.ctx.user.primaryOfficeId, + token: options.ctx.token }) }), register: publicProcedure @@ -144,7 +150,8 @@ export const appRouter = router({ { eventId: options.input.eventId, createdBy: options.ctx.user.id, - createdAtLocation: options.ctx.user.primaryOfficeId + createdAtLocation: options.ctx.user.primaryOfficeId, + token: options.ctx.token } ) }) diff --git a/packages/events/src/service/config/config.ts b/packages/events/src/service/config/config.ts index 2ff95c97903..5316410a7d0 100644 --- a/packages/events/src/service/config/config.ts +++ b/packages/events/src/service/config/config.ts @@ -14,7 +14,7 @@ import { EventConfig } from '@opencrvs/commons' import fetch from 'node-fetch' import { array } from 'zod' -export async function getEventsConfig(token: string) { +export async function getEventConfigurations(token: string) { const res = await fetch(new URL('/events', env.COUNTRY_CONFIG_URL), { headers: { 'Content-Type': 'application/json', diff --git a/packages/events/src/service/events.ts b/packages/events/src/service/events.ts index d3dc8672403..7e070523178 100644 --- a/packages/events/src/service/events.ts +++ b/packages/events/src/service/events.ts @@ -10,16 +10,18 @@ */ import { - EventDocument, ActionInput, - EventInput + EventDocument, + EventInput, + FileFieldValue } from '@opencrvs/commons/events' import { getClient } from '@events/storage/mongodb' import { ActionType, getUUID } from '@opencrvs/commons' import { z } from 'zod' +import { getEventConfigurations } from './config/config' +import { fileExists } from './files' import { indexEvent } from './indexing/indexing' -import * as _ from 'lodash' export const EventInputWithId = EventInput.extend({ id: z.string() @@ -50,6 +52,7 @@ export async function getEventById(id: string) { if (!event) { throw new EventNotFoundError(id) } + return event } @@ -104,11 +107,42 @@ export async function addAction( { eventId, createdBy, + token, createdAtLocation - }: { eventId: string; createdBy: string; createdAtLocation: string } + }: { + eventId: string + createdBy: string + createdAtLocation: string + token: string + } ) { const db = await getClient() const now = new Date().toISOString() + const event = await getEventById(eventId) + + const config = await getEventConfigurations(token) + + const form = config + .find((config) => config.id === event.type) + ?.actions.find((action) => action.type === input.type) + ?.forms.find((form) => form.active) + + const fieldTypes = form?.pages.flatMap((page) => page.fields) + + for (const [key, value] of Object.entries(input.data)) { + const isFile = + fieldTypes?.find((field) => field.id === key)?.type === 'FILE' + + const fileValue = FileFieldValue.safeParse(value) + + if (!isFile || !fileValue.success) { + continue + } + + if (!(await fileExists(fileValue.data!.filename, token))) { + throw new Error(`File not found: ${value}`) + } + } await db.collection('events').updateOne( { @@ -126,9 +160,9 @@ export async function addAction( } ) - const event = await getEventById(eventId) - await indexEvent(event) - return event + const updatedEvent = await getEventById(eventId) + await indexEvent(updatedEvent) + return updatedEvent } export async function patchEvent(eventInput: EventInputWithId) { diff --git a/packages/events/src/service/files/index.ts b/packages/events/src/service/files/index.ts new file mode 100644 index 00000000000..6cc589541a5 --- /dev/null +++ b/packages/events/src/service/files/index.ts @@ -0,0 +1,154 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * OpenCRVS is also distributed under the terms of the Civil Registration + * & Healthcare Disclaimer located at http://opencrvs.org/license. + * + * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. + */ +import { env } from '@events/environment' +import { + ActionDocument, + EventConfig, + logger, + EventDocument, + FileFieldValue +} from '@opencrvs/commons' +import fetch from 'node-fetch' +import { getEventConfigurations } from '@events/service/config/config' + +function getFieldDefinitionForActionDataField( + configuration: EventConfig, + actionType: ActionDocument['type'], + fieldId: string +) { + const actionConfiguration = configuration.actions.find( + (action) => action.type === actionType + ) + + if (!actionConfiguration) { + return + } + + const formConfiguration = actionConfiguration.forms.find( + (form) => form.active + ) + + if (!formConfiguration) { + logger.error('Failed to find active form configuration', { + actionType + }) + throw new Error('Failed to find active form configuration') + } + + return formConfiguration.pages + .flatMap((page) => page.fields) + .find((field) => field.id === fieldId) +} + +function getFileNameAndSignature(url: string) { + const { pathname, search } = new URL(url) + const filename = pathname.split('/').pop() + return filename + search +} + +export async function presignFilesInEvent(event: EventDocument, token: string) { + const configurations = await getEventConfigurations(token) + const configuration = configurations.find( + (config) => config.id === event.type + ) + + if (!configuration) { + logger.error('Failed to find configuration for event', { + event: event.type + }) + throw new Error('Failed to find configuration for event') + } + + const actionFileFields = event.actions.flatMap((action) => + Object.entries(action.data) + .filter( + ([fieldId]) => + getFieldDefinitionForActionDataField( + configuration, + action.type, + fieldId + )?.type === 'FILE' + ) + .map<[string, string, FileFieldValue]>(([fieldId, value]) => { + return [action.type, fieldId, value as FileFieldValue] + }) + ) + + const urls = ( + await presignFiles( + actionFileFields.map(([_, __, file]) => file.filename), + token + ) + ).map(getFileNameAndSignature) + + const actions = event.actions.map((action) => { + return { + ...action, + data: Object.fromEntries( + Object.entries(action.data).map(([fieldId, value]) => { + const fileIndex = actionFileFields.findIndex( + ([actionType, fileFieldsFieldId]) => + actionType === action.type && fieldId === fileFieldsFieldId + ) + + if (fileIndex === -1) { + return [fieldId, value] + } + return [ + fieldId, + { ...(value as FileFieldValue), filename: urls[fileIndex] } + ] + }) + ) + } + }) + + return { + ...event, + actions + } +} + +export async function fileExists(filename: string, token: string) { + const res = await fetch(new URL(`/files/${filename}`, env.DOCUMENTS_URL), { + method: 'HEAD', + headers: { + Authorization: `Bearer ${token}` + } + }) + + return res.ok +} + +async function presignFiles( + filenames: string[], + token: string +): Promise { + const res = await fetch(new URL(`/presigned-urls`, env.DOCUMENTS_URL), { + method: 'POST', + body: JSON.stringify({ filenames }), + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }) + + if (!res.ok) { + logger.error('Failed to presign files', { + filenames, + status: res.status, + text: await res.text() + }) + throw new Error('Failed to presign files') + } + + return res.json() +} diff --git a/packages/events/src/tests/msw.ts b/packages/events/src/tests/msw.ts new file mode 100644 index 00000000000..c17c34de0e3 --- /dev/null +++ b/packages/events/src/tests/msw.ts @@ -0,0 +1,25 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * OpenCRVS is also distributed under the terms of the Civil Registration + * & Healthcare Disclaimer located at http://opencrvs.org/license. + * + * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. + */ +import { http, HttpResponse, PathParams } from 'msw' +import { env } from '@events/environment' +import { setupServer } from 'msw/node' + +const handlers = [ + http.post, { filenames: string[] }>( + `${env.DOCUMENTS_URL}/presigned-urls`, + async (info) => { + const request = await info.request.json() + return HttpResponse.json(request.filenames) + } + ) +] + +export const mswServer = setupServer(...handlers) diff --git a/packages/events/src/tests/setup.ts b/packages/events/src/tests/setup.ts index b2be95905c1..48df789b96c 100644 --- a/packages/events/src/tests/setup.ts +++ b/packages/events/src/tests/setup.ts @@ -16,11 +16,12 @@ import { resetServer as resetESServer, setupServer as setupESServer } from '@events/storage/__mocks__/elasticsearch' +import { mswServer } from './msw' vi.mock('@events/storage/mongodb') vi.mock('@events/storage/elasticsearch') vi.mock('@events/service/config/config', () => ({ - getEventsConfig: () => + getEventConfigurations: () => Promise.all([ tennisClubMembershipEvent, { ...tennisClubMembershipEvent, id: 'TENNIS_CLUB_MEMBERSHIP_PREMIUM' } @@ -29,3 +30,21 @@ vi.mock('@events/service/config/config', () => ({ beforeAll(() => Promise.all([setupESServer()]), 100000) afterEach(() => Promise.all([resetMongoServer(), resetESServer()])) + +beforeAll(() => + mswServer.listen({ + onUnhandledRequest: (req) => { + const elasticRegex = /http:\/\/localhost:551\d{2}\/.*/ + + const isElasticResetCall = + req.method === 'DELETE' && elasticRegex.test(req.url) + + if (!isElasticResetCall) { + // eslint-disable-next-line no-console + console.warn(`Unmocked request: ${req.method} ${req.url}`) + } + } + }) +) +afterEach(() => mswServer.resetHandlers()) +afterAll(() => mswServer.close()) diff --git a/packages/gateway/src/config/routes.ts b/packages/gateway/src/config/routes.ts index b592f0b57fa..59d8e74762e 100644 --- a/packages/gateway/src/config/routes.ts +++ b/packages/gateway/src/config/routes.ts @@ -21,6 +21,7 @@ import sendVerifyCodeHandler, { responseSchema } from '@gateway/routes/verifyCode/handler' import { trpcProxy } from '@gateway/v2-events/event-config/routes' +import { DOCUMENTS_URL } from '@gateway/constants' export const getRoutes = () => { const routes: ServerRoute[] = [ @@ -77,6 +78,27 @@ export const getRoutes = () => { } } }, + { + method: 'POST', + path: '/upload', + handler: async (req, h) => { + if (process.env.NODE_ENV !== 'production') { + await new Promise((resolve) => + setTimeout(resolve, Math.random() * 30000) + ) + } + return h.proxy({ + uri: `${DOCUMENTS_URL}/files`, + passThrough: true + }) + }, + options: { + payload: { + output: 'data', + parse: false + } + } + }, catchAllProxy.locations, catchAllProxy.locationsSuffix, diff --git a/packages/gateway/src/graphql/schema.d.ts b/packages/gateway/src/graphql/schema.d.ts index 18acb49229b..af996141149 100644 --- a/packages/gateway/src/graphql/schema.d.ts +++ b/packages/gateway/src/graphql/schema.d.ts @@ -50,7 +50,6 @@ export interface GQLQuery { getEventsWithProgress?: GQLEventProgressResultSet getSystemRoles?: Array fetchSystem?: GQLSystem - getEvent: GQLEvent } export interface GQLMutation { @@ -108,18 +107,6 @@ export interface GQLMutation { deleteSystem?: GQLSystem bookmarkAdvancedSearch?: GQLBookMarkedSearches removeBookmarkedAdvancedSearch?: GQLBookMarkedSearches - createEvent: GQLEvent - notifyEvent: GQLEvent - declareEvent: GQLEvent - registerEvent: GQLEvent - certifyEvent: GQLEvent - issueEvent: GQLEvent - revokeEvent: GQLEvent - reinstateEvent: GQLEvent - revokeCorrectionEvent: GQLEvent - requestCorrectionEvent: GQLEvent - approveCorrectionEvent: GQLEvent - rejectCorrectionEvent: GQLEvent } export interface GQLDummy { @@ -505,14 +492,6 @@ export interface GQLSystem { settings?: GQLSystemSettings } -export interface GQLEvent { - type: string - id: string - createdAt: GQLDateTime - updatedAt: GQLDateTime - actions: Array -} - export interface GQLCorrectionInput { requester: string requesterOther?: string @@ -684,54 +663,6 @@ export interface GQLRemoveBookmarkedSeachInput { searchId: string } -export interface GQLEventInput { - type: string -} - -export interface GQLNotifyActionInput { - data: Array -} - -export interface GQLDeclareActionInput { - data: Array -} - -export interface GQLRegisterActionInput { - data: Array -} - -export interface GQLCertifyActionInput { - data: Array -} - -export interface GQLIssueActionInput { - data: Array -} - -export interface GQLRevokeActionInput { - data: Array -} - -export interface GQLReinstateActionInput { - data: Array -} - -export interface GQLRevokeCorrectionActionInput { - data: Array -} - -export interface GQLRequestCorrectionActionInput { - data: Array -} - -export interface GQLApproveCorrectionActionInput { - data: Array -} - -export interface GQLRejectCorrectionActionInput { - data: Array -} - export type GQLMap = any export interface GQLRegistration { @@ -1092,29 +1023,6 @@ export interface GQLSystemSettings { openIdProviderClaims?: string } -export type GQLDateTime = any - -export type GQLAction = - | GQLCreateAction - | GQLRegisterAction - | GQLNotifyAction - | GQLDeclareAction - -/** Use this to resolve union type Action */ -export type GQLPossibleActionTypeNames = - | 'CreateAction' - | 'RegisterAction' - | 'NotifyAction' - | 'DeclareAction' - -export interface GQLActionNameMap { - Action: GQLAction - CreateAction: GQLCreateAction - RegisterAction: GQLRegisterAction - NotifyAction: GQLNotifyAction - DeclareAction: GQLDeclareAction -} - export interface GQLAttachmentInput { _fhirID?: string contentType?: string @@ -1311,11 +1219,6 @@ export interface GQLWebhookInput { permissions: Array } -export interface GQLFieldInput { - id: string - value: GQLFieldValue -} - export interface GQLAssignmentData { practitionerId?: string firstName?: string @@ -1599,36 +1502,6 @@ export interface GQLWebhookPermission { permissions: Array } -export interface GQLCreateAction { - type: string - createdAt: GQLDateTime - createdBy: string - data: Array -} - -export interface GQLRegisterAction { - type: string - createdAt: GQLDateTime - createdBy: string - data: Array - identifiers: GQLIdentifiers -} - -export interface GQLNotifyAction { - type: string - createdAt: GQLDateTime - createdBy: string - data: Array -} - -export interface GQLDeclareAction { - type: string - createdAt: GQLDateTime - createdBy: string - data: Array - identifiers: GQLIdentifiers -} - export const enum GQLAttachmentInputStatus { approved = 'approved', validated = 'validated', @@ -1750,16 +1623,6 @@ export interface GQLAdditionalIdWithCompositionId { trackingId: string } -export interface GQLField { - id: string - value: GQLFieldValue -} - -export interface GQLIdentifiers { - trackingId: string - registrationNumber: string -} - export const enum GQLTelecomSystem { other = 'other', phone = 'phone', @@ -1860,7 +1723,6 @@ export interface GQLResolver { EventProgressResultSet?: GQLEventProgressResultSetTypeResolver SystemRole?: GQLSystemRoleTypeResolver System?: GQLSystemTypeResolver - Event?: GQLEventTypeResolver CreatedIds?: GQLCreatedIdsTypeResolver Reinstated?: GQLReinstatedTypeResolver Avatar?: GQLAvatarTypeResolver @@ -1904,11 +1766,6 @@ export interface GQLResolver { EventProgressSet?: GQLEventProgressSetTypeResolver SystemSettings?: GQLSystemSettingsTypeResolver - DateTime?: GraphQLScalarType - Action?: { - __resolveType: GQLActionTypeResolver - } - AssignmentData?: GQLAssignmentDataTypeResolver RegWorkflow?: GQLRegWorkflowTypeResolver Certificate?: GQLCertificateTypeResolver @@ -1932,18 +1789,12 @@ export interface GQLResolver { MarriageEventSearchSet?: GQLMarriageEventSearchSetTypeResolver EventProgressData?: GQLEventProgressDataTypeResolver WebhookPermission?: GQLWebhookPermissionTypeResolver - CreateAction?: GQLCreateActionTypeResolver - RegisterAction?: GQLRegisterActionTypeResolver - NotifyAction?: GQLNotifyActionTypeResolver - DeclareAction?: GQLDeclareActionTypeResolver FieldValue?: GraphQLScalarType AuditLogItemBase?: { __resolveType: GQLAuditLogItemBaseTypeResolver } AdditionalIdWithCompositionId?: GQLAdditionalIdWithCompositionIdTypeResolver - Field?: GQLFieldTypeResolver - Identifiers?: GQLIdentifiersTypeResolver } export interface GQLQueryTypeResolver { sendNotificationToAllUsers?: QueryToSendNotificationToAllUsersResolver @@ -1983,7 +1834,6 @@ export interface GQLQueryTypeResolver { getEventsWithProgress?: QueryToGetEventsWithProgressResolver getSystemRoles?: QueryToGetSystemRolesResolver fetchSystem?: QueryToFetchSystemResolver - getEvent?: QueryToGetEventResolver } export interface QueryToSendNotificationToAllUsersArgs { @@ -2585,18 +2435,6 @@ export interface QueryToFetchSystemResolver { ): TResult } -export interface QueryToGetEventArgs { - eventId: string -} -export interface QueryToGetEventResolver { - ( - parent: TParent, - args: QueryToGetEventArgs, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - export interface GQLMutationTypeResolver { requestRegistrationCorrection?: MutationToRequestRegistrationCorrectionResolver rejectRegistrationCorrection?: MutationToRejectRegistrationCorrectionResolver @@ -2652,18 +2490,6 @@ export interface GQLMutationTypeResolver { deleteSystem?: MutationToDeleteSystemResolver bookmarkAdvancedSearch?: MutationToBookmarkAdvancedSearchResolver removeBookmarkedAdvancedSearch?: MutationToRemoveBookmarkedAdvancedSearchResolver - createEvent?: MutationToCreateEventResolver - notifyEvent?: MutationToNotifyEventResolver - declareEvent?: MutationToDeclareEventResolver - registerEvent?: MutationToRegisterEventResolver - certifyEvent?: MutationToCertifyEventResolver - issueEvent?: MutationToIssueEventResolver - revokeEvent?: MutationToRevokeEventResolver - reinstateEvent?: MutationToReinstateEventResolver - revokeCorrectionEvent?: MutationToRevokeCorrectionEventResolver - requestCorrectionEvent?: MutationToRequestCorrectionEventResolver - approveCorrectionEvent?: MutationToApproveCorrectionEventResolver - rejectCorrectionEvent?: MutationToRejectCorrectionEventResolver } export interface MutationToRequestRegistrationCorrectionArgs { @@ -3501,176 +3327,6 @@ export interface MutationToRemoveBookmarkedAdvancedSearchResolver< ): TResult } -export interface MutationToCreateEventArgs { - event: GQLEventInput -} -export interface MutationToCreateEventResolver { - ( - parent: TParent, - args: MutationToCreateEventArgs, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface MutationToNotifyEventArgs { - eventId: string - input: GQLNotifyActionInput -} -export interface MutationToNotifyEventResolver { - ( - parent: TParent, - args: MutationToNotifyEventArgs, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface MutationToDeclareEventArgs { - eventId: string - input: GQLDeclareActionInput -} -export interface MutationToDeclareEventResolver { - ( - parent: TParent, - args: MutationToDeclareEventArgs, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface MutationToRegisterEventArgs { - eventId: string - input: GQLRegisterActionInput -} -export interface MutationToRegisterEventResolver { - ( - parent: TParent, - args: MutationToRegisterEventArgs, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface MutationToCertifyEventArgs { - eventId: string - input: GQLCertifyActionInput -} -export interface MutationToCertifyEventResolver { - ( - parent: TParent, - args: MutationToCertifyEventArgs, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface MutationToIssueEventArgs { - eventId: string - input: GQLIssueActionInput -} -export interface MutationToIssueEventResolver { - ( - parent: TParent, - args: MutationToIssueEventArgs, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface MutationToRevokeEventArgs { - eventId: string - input: GQLRevokeActionInput -} -export interface MutationToRevokeEventResolver { - ( - parent: TParent, - args: MutationToRevokeEventArgs, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface MutationToReinstateEventArgs { - eventId: string - input: GQLReinstateActionInput -} -export interface MutationToReinstateEventResolver< - TParent = any, - TResult = any -> { - ( - parent: TParent, - args: MutationToReinstateEventArgs, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface MutationToRevokeCorrectionEventArgs { - eventId: string - input: GQLRevokeCorrectionActionInput -} -export interface MutationToRevokeCorrectionEventResolver< - TParent = any, - TResult = any -> { - ( - parent: TParent, - args: MutationToRevokeCorrectionEventArgs, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface MutationToRequestCorrectionEventArgs { - eventId: string - input: GQLRequestCorrectionActionInput -} -export interface MutationToRequestCorrectionEventResolver< - TParent = any, - TResult = any -> { - ( - parent: TParent, - args: MutationToRequestCorrectionEventArgs, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface MutationToApproveCorrectionEventArgs { - eventId: string - input: GQLApproveCorrectionActionInput -} -export interface MutationToApproveCorrectionEventResolver< - TParent = any, - TResult = any -> { - ( - parent: TParent, - args: MutationToApproveCorrectionEventArgs, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface MutationToRejectCorrectionEventArgs { - eventId: string - input: GQLRejectCorrectionActionInput -} -export interface MutationToRejectCorrectionEventResolver< - TParent = any, - TResult = any -> { - ( - parent: TParent, - args: MutationToRejectCorrectionEventArgs, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - export interface GQLDummyTypeResolver { dummy?: DummyToDummyResolver } @@ -5647,59 +5303,6 @@ export interface SystemToSettingsResolver { ): TResult } -export interface GQLEventTypeResolver { - type?: EventToTypeResolver - id?: EventToIdResolver - createdAt?: EventToCreatedAtResolver - updatedAt?: EventToUpdatedAtResolver - actions?: EventToActionsResolver -} - -export interface EventToTypeResolver { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface EventToIdResolver { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface EventToCreatedAtResolver { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface EventToUpdatedAtResolver { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface EventToActionsResolver { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - export interface GQLCreatedIdsTypeResolver { compositionId?: CreatedIdsToCompositionIdResolver trackingId?: CreatedIdsToTrackingIdResolver @@ -8213,16 +7816,6 @@ export interface SystemSettingsToOpenIdProviderClaimsResolver< ): TResult } -export interface GQLActionTypeResolver { - (parent: TParent, context: Context, info: GraphQLResolveInfo): - | 'CreateAction' - | 'RegisterAction' - | 'NotifyAction' - | 'DeclareAction' - | Promise< - 'CreateAction' | 'RegisterAction' | 'NotifyAction' | 'DeclareAction' - > -} export interface GQLAssignmentDataTypeResolver { practitionerId?: AssignmentDataToPractitionerIdResolver firstName?: AssignmentDataToFirstNameResolver @@ -10679,216 +10272,6 @@ export interface WebhookPermissionToPermissionsResolver< ): TResult } -export interface GQLCreateActionTypeResolver { - type?: CreateActionToTypeResolver - createdAt?: CreateActionToCreatedAtResolver - createdBy?: CreateActionToCreatedByResolver - data?: CreateActionToDataResolver -} - -export interface CreateActionToTypeResolver { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface CreateActionToCreatedAtResolver { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface CreateActionToCreatedByResolver { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface CreateActionToDataResolver { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface GQLRegisterActionTypeResolver { - type?: RegisterActionToTypeResolver - createdAt?: RegisterActionToCreatedAtResolver - createdBy?: RegisterActionToCreatedByResolver - data?: RegisterActionToDataResolver - identifiers?: RegisterActionToIdentifiersResolver -} - -export interface RegisterActionToTypeResolver { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface RegisterActionToCreatedAtResolver< - TParent = any, - TResult = any -> { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface RegisterActionToCreatedByResolver< - TParent = any, - TResult = any -> { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface RegisterActionToDataResolver { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface RegisterActionToIdentifiersResolver< - TParent = any, - TResult = any -> { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface GQLNotifyActionTypeResolver { - type?: NotifyActionToTypeResolver - createdAt?: NotifyActionToCreatedAtResolver - createdBy?: NotifyActionToCreatedByResolver - data?: NotifyActionToDataResolver -} - -export interface NotifyActionToTypeResolver { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface NotifyActionToCreatedAtResolver { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface NotifyActionToCreatedByResolver { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface NotifyActionToDataResolver { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface GQLDeclareActionTypeResolver { - type?: DeclareActionToTypeResolver - createdAt?: DeclareActionToCreatedAtResolver - createdBy?: DeclareActionToCreatedByResolver - data?: DeclareActionToDataResolver - identifiers?: DeclareActionToIdentifiersResolver -} - -export interface DeclareActionToTypeResolver { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface DeclareActionToCreatedAtResolver< - TParent = any, - TResult = any -> { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface DeclareActionToCreatedByResolver< - TParent = any, - TResult = any -> { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface DeclareActionToDataResolver { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface DeclareActionToIdentifiersResolver< - TParent = any, - TResult = any -> { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - export interface GQLAuditLogItemBaseTypeResolver { (parent: TParent, context: Context, info: GraphQLResolveInfo): | 'UserAuditLogItemWithComposition' @@ -10923,52 +10306,3 @@ export interface AdditionalIdWithCompositionIdToTrackingIdResolver< info: GraphQLResolveInfo ): TResult } - -export interface GQLFieldTypeResolver { - id?: FieldToIdResolver - value?: FieldToValueResolver -} - -export interface FieldToIdResolver { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface FieldToValueResolver { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface GQLIdentifiersTypeResolver { - trackingId?: IdentifiersToTrackingIdResolver - registrationNumber?: IdentifiersToRegistrationNumberResolver -} - -export interface IdentifiersToTrackingIdResolver { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface IdentifiersToRegistrationNumberResolver< - TParent = any, - TResult = any -> { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} diff --git a/packages/gateway/src/graphql/schema.graphql b/packages/gateway/src/graphql/schema.graphql index af29cf90c26..5446faebcd7 100644 --- a/packages/gateway/src/graphql/schema.graphql +++ b/packages/gateway/src/graphql/schema.graphql @@ -147,7 +147,6 @@ type Query { sortOrder: String ): [SystemRole!] fetchSystem(clientId: ID!): System - getEvent(eventId: ID!): Event! } type Mutation { @@ -270,30 +269,6 @@ type Mutation { removeBookmarkedAdvancedSearch( removeBookmarkedSearchInput: RemoveBookmarkedSeachInput! ): BookMarkedSearches - createEvent(event: EventInput!): Event! - notifyEvent(eventId: ID!, input: NotifyActionInput!): Event! - declareEvent(eventId: ID!, input: DeclareActionInput!): Event! - registerEvent(eventId: ID!, input: RegisterActionInput!): Event! - certifyEvent(eventId: ID!, input: CertifyActionInput!): Event! - issueEvent(eventId: ID!, input: IssueActionInput!): Event! - revokeEvent(eventId: ID!, input: RevokeActionInput!): Event! - reinstateEvent(eventId: ID!, input: ReinstateActionInput!): Event! - revokeCorrectionEvent( - eventId: ID! - input: RevokeCorrectionActionInput! - ): Event! - requestCorrectionEvent( - eventId: ID! - input: RequestCorrectionActionInput! - ): Event! - approveCorrectionEvent( - eventId: ID! - input: ApproveCorrectionActionInput! - ): Event! - rejectCorrectionEvent( - eventId: ID! - input: RejectCorrectionActionInput! - ): Event! } type Dummy { @@ -642,14 +617,6 @@ type System { settings: SystemSettings } -type Event { - type: String! - id: String! - createdAt: DateTime! - updatedAt: DateTime! - actions: [Action!]! -} - input CorrectionInput { requester: String! requesterOther: String @@ -821,54 +788,6 @@ input RemoveBookmarkedSeachInput { searchId: String! } -input EventInput { - type: String! -} - -input NotifyActionInput { - data: [FieldInput!]! -} - -input DeclareActionInput { - data: [FieldInput!]! -} - -input RegisterActionInput { - data: [FieldInput!]! -} - -input CertifyActionInput { - data: [FieldInput!]! -} - -input IssueActionInput { - data: [FieldInput!]! -} - -input RevokeActionInput { - data: [FieldInput!]! -} - -input ReinstateActionInput { - data: [FieldInput!]! -} - -input RevokeCorrectionActionInput { - data: [FieldInput!]! -} - -input RequestCorrectionActionInput { - data: [FieldInput!]! -} - -input ApproveCorrectionActionInput { - data: [FieldInput!]! -} - -input RejectCorrectionActionInput { - data: [FieldInput!]! -} - scalar Map type Registration { @@ -1205,10 +1124,6 @@ type SystemSettings { openIdProviderClaims: String } -scalar DateTime - -union Action = CreateAction | RegisterAction | NotifyAction | DeclareAction - input AttachmentInput { _fhirID: ID contentType: String @@ -1405,11 +1320,6 @@ input WebhookInput { permissions: [String]! } -input FieldInput { - id: String! - value: FieldValue! -} - type AssignmentData { practitionerId: String firstName: String @@ -1692,36 +1602,6 @@ type WebhookPermission { permissions: [String!]! } -type CreateAction { - type: String! - createdAt: DateTime! - createdBy: String! - data: [Field!]! -} - -type RegisterAction { - type: String! - createdAt: DateTime! - createdBy: String! - data: [Field!]! - identifiers: Identifiers! -} - -type NotifyAction { - type: String! - createdAt: DateTime! - createdBy: String! - data: [Field!]! -} - -type DeclareAction { - type: String! - createdAt: DateTime! - createdBy: String! - data: [Field!]! - identifiers: Identifiers! -} - enum AttachmentInputStatus { approved validated @@ -1832,16 +1712,6 @@ type AdditionalIdWithCompositionId { trackingId: String! } -type Field { - id: String! - value: FieldValue! -} - -type Identifiers { - trackingId: String! - registrationNumber: String! -} - enum TelecomSystem { other phone diff --git a/yarn.lock b/yarn.lock index 226e84c38e2..3221ba107ed 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9314,7 +9314,7 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@16 || 17 || 18", "@types/react@18.3.1", "@types/react@>=16", "@types/react@^16": +"@types/react@*", "@types/react@16 || 17 || 18", "@types/react@18.3.1", "@types/react@>=16": version "18.3.1" resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.1.tgz#fed43985caa834a2084d002e4771e15dfcbdbe8e" integrity sha512-V0kuGBX3+prX+DQ/7r2qsv1NsdfnCLnTgnRJ1pYnxykBhGMz+qj+box5lq7XsO5mtZsBqpjwwTu/7wszPfMBcw== @@ -9322,6 +9322,15 @@ "@types/prop-types" "*" csstype "^3.0.2" +"@types/react@^16": + version "16.14.62" + resolved "https://registry.yarnpkg.com/@types/react/-/react-16.14.62.tgz#449e4e81caaf132d0c2c390644e577702db1dd9e" + integrity sha512-BWf7hqninZav6nerxXj+NeZT/mTpDeG6Lk2zREHAy63CrnXoOGPGtNqTFYFN/sqpSaREDP5otVV88axIXmKfGA== + dependencies: + "@types/prop-types" "*" + "@types/scheduler" "^0.16" + csstype "^3.0.2" + "@types/readdir-glob@*": version "1.1.3" resolved "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.3.tgz" @@ -9390,6 +9399,11 @@ dependencies: htmlparser2 "^8.0.0" +"@types/scheduler@^0.16": + version "0.16.8" + resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.8.tgz#ce5ace04cfeabe7ef87c0091e50752e36707deff" + integrity sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A== + "@types/semver@^7.3.4": version "7.5.8" resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.8.tgz#8268a8c57a3e4abd25c165ecd36237db7948a55e" @@ -15073,7 +15087,14 @@ fast-url-parser@1.1.3, fast-url-parser@^1.1.3: dependencies: punycode "^1.3.2" -fast-xml-parser@4.2.5, fast-xml-parser@4.4.1, fast-xml-parser@^4.1.3, fast-xml-parser@^4.2.2: +fast-xml-parser@4.2.5: + version "4.2.5" + resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-4.2.5.tgz#a6747a09296a6cb34f2ae634019bf1738f3b421f" + integrity sha512-B9/wizE4WngqQftFPmdaMYlXoJlJOYxGQOanC77fq9k8+Z0v5dDSVh+3glErdIROP//s/jgb7ZuxKfB8nVyo0g== + dependencies: + strnum "^1.0.5" + +fast-xml-parser@^4.1.3, fast-xml-parser@^4.2.2: version "4.4.1" resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-4.4.1.tgz#86dbf3f18edf8739326447bcaac31b4ae7f6514f" integrity sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw== @@ -19656,6 +19677,30 @@ msw@^2.6.8: type-fest "^4.26.1" yargs "^17.7.2" +msw@^2.7.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/msw/-/msw-2.7.0.tgz#d13ff87f7e018fc4c359800ff72ba5017033fb56" + integrity sha512-BIodwZ19RWfCbYTxWTUfTXc+sg4OwjCAgxU1ZsgmggX/7S3LdUifsbUPJs61j0rWb19CZRGY5if77duhc0uXzw== + dependencies: + "@bundled-es-modules/cookie" "^2.0.1" + "@bundled-es-modules/statuses" "^1.0.1" + "@bundled-es-modules/tough-cookie" "^0.1.6" + "@inquirer/confirm" "^5.0.0" + "@mswjs/interceptors" "^0.37.0" + "@open-draft/deferred-promise" "^2.2.0" + "@open-draft/until" "^2.1.0" + "@types/cookie" "^0.6.0" + "@types/statuses" "^2.0.4" + graphql "^16.8.1" + headers-polyfill "^4.0.2" + is-node-process "^1.2.0" + outvariant "^1.4.3" + path-to-regexp "^6.3.0" + picocolors "^1.1.1" + strict-event-emitter "^0.5.1" + type-fest "^4.26.1" + yargs "^17.7.2" + multimatch@5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/multimatch/-/multimatch-5.0.0.tgz#932b800963cea7a31a033328fa1e0c3a1874dbe6" @@ -20974,6 +21019,11 @@ picocolors@^1.1.0: resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.0.tgz#5358b76a78cde483ba5cef6a9dc9671440b27d59" integrity sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw== +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2, picomatch@^2.2.3, picomatch@^2.3.0, picomatch@^2.3.1: version "2.3.1" resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz" From 75fd1be4e7b43894a1506f58e73b7abe94213d81 Mon Sep 17 00:00:00 2001 From: tahmidrahman-dsi Date: Fri, 3 Jan 2025 15:27:50 +0600 Subject: [PATCH 06/15] fix: remove unintended export --- packages/components/src/Banner/Banner.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/src/Banner/Banner.tsx b/packages/components/src/Banner/Banner.tsx index 660fbbc6c0d..32506c0b6d5 100644 --- a/packages/components/src/Banner/Banner.tsx +++ b/packages/components/src/Banner/Banner.tsx @@ -16,7 +16,7 @@ import styled from 'styled-components' const Wrapper = styled(Box)` padding: 0; ` -export const HeaderWrapper = styled(Stack)<{ +const HeaderWrapper = styled(Stack)<{ type: 'active' | 'inactive' | 'pending' | 'default' }>` padding: 8px 16px; From 143ab9d545515054dec5aac8bebe696a84296c45 Mon Sep 17 00:00:00 2001 From: tahmidrahman-dsi Date: Fri, 3 Jan 2025 16:04:07 +0600 Subject: [PATCH 07/15] chore: update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff156ef4c59..ac7e465064d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ - **Template Selection Dropdown**: Updated print workflow to include a dropdown menu for template selection when issuing a certificate. - Auth now allows exchanging user's token for a new record-specific token [#7728](https://github.com/opencrvs/opencrvs-core/issues/7728) - A new GraphQL mutation `upsertRegistrationIdentifier` is added to allow updating the patient identifiers of a registration record such as NID [#8034](https://github.com/opencrvs/opencrvs-core/pull/8034) +- Introduced a new customisable UI component: Banner [#8276](https://github.com/opencrvs/opencrvs-core/issues/8276) ### Improvements From 4927faf55501248f413d9a9af4350d78ffac36c9 Mon Sep 17 00:00:00 2001 From: Riku Rouvila Date: Mon, 6 Jan 2025 10:40:02 +0900 Subject: [PATCH 08/15] Run client tests in parallel (#8282) --- .github/workflows/lint-and-test.yml | 161 ++++++++++++++++++++++++++-- packages/client/package.json | 3 +- packages/client/vite.config.ts | 5 +- 3 files changed, 156 insertions(+), 13 deletions(-) diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml index ce3cb945e96..22c5ee40867 100644 --- a/.github/workflows/lint-and-test.yml +++ b/.github/workflows/lint-and-test.yml @@ -7,12 +7,13 @@ # # Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. -name: Lint, run unit tests and security scans +name: Lint and run unit tests on: [pull_request] jobs: setup: + name: Setup tests permissions: contents: read pull-requests: write @@ -44,6 +45,7 @@ jobs: echo "matrix=${PACKAGES}" >> $GITHUB_OUTPUT test: + name: Test needs: setup runs-on: ubuntu-22.04 strategy: @@ -136,26 +138,171 @@ jobs: if: steps.check-scripts.outputs.skip != 'true' run: CI="" yarn install --frozen-lockfile - # TODO: Move out of the matrix to be built once and shared - name: Build common package - if: steps.check-scripts.outputs.skip != 'true' && contains(env.DEPENDENCIES, 'packages/commons') + if: matrix.package != 'packages/client' && steps.check-scripts.outputs.skip != 'true' && contains(env.DEPENDENCIES, 'packages/commons') run: cd packages/commons && yarn build - - name: Build components client and login - if: steps.check-scripts.outputs.skip != 'true' && contains(env.DEPENDENCIES, 'packages/components') + - name: Build components + if: matrix.package != 'packages/client' && steps.check-scripts.outputs.skip != 'true' && contains(env.DEPENDENCIES, 'packages/components') run: | cd packages/components && yarn build # TODO: should run parallel to unit tests as can take as much as unit tests - name: Run linting - if: steps.check-scripts.outputs.skip != 'true' && steps.check-scripts.outputs.skip-lint != 'true' + if: matrix.package != 'packages/client' && steps.check-scripts.outputs.skip != 'true' && steps.check-scripts.outputs.skip-lint != 'true' run: cd ${{ matrix.package }} && yarn lint - name: Run Unit Test - if: steps.check-scripts.outputs.skip != 'true' && steps.check-scripts.outputs.skip-test != 'true' + if: matrix.package != 'packages/client' && steps.check-scripts.outputs.skip != 'true' && steps.check-scripts.outputs.skip-test != 'true' run: cd ${{ matrix.package }} && yarn test + prepare-client-tests: + name: Prepare client tests + runs-on: ubuntu-22.04 + steps: + - name: Checking out git repo + uses: actions/checkout@v4 + + - name: Use Node.js from .nvmrc + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + + - name: Extract dependencies for client + id: extract-dependencies + run: | + DEPENDENCIES=$(node -e " + const { execSync } = require('child_process'); + const output = execSync('yarn --silent workspaces info', { encoding: 'utf-8' }); + const json = JSON.parse(output.replaceAll('@opencrvs', 'packages')); + + const getDependencies = (pkg) => + json[pkg].workspaceDependencies.concat( + json[pkg].workspaceDependencies.flatMap(getDependencies) + ); + + console.log( + getDependencies('packages/client').join(' ') + ); + ") + echo "DEPENDENCIES=${DEPENDENCIES}" >> $GITHUB_ENV + echo "Found dependencies: $DEPENDENCIES" + + - name: Remove other package directories + run: | + for dir in packages/*; do + if echo "packages/client $DEPENDENCIES" | grep -q -w "$dir"; then + echo "Skipping $dir" + else + echo "Removing $dir" + rm -rf "$dir" + fi + done + + - name: Cache Node.js dependencies + uses: actions/cache@v4 + with: + path: | + **/node_modules + ~/.cache/yarn/v6 + key: node-${{ hashFiles('**/yarn.lock', format('{0}/{1}','packages/client','package.json')) }} + restore-keys: | + ${{ runner.os }}-node- + + - name: Install dependencies + run: CI="" yarn install --frozen-lockfile + + - name: Build common package + run: cd packages/commons && yarn build + + - name: Build components + run: | + cd packages/components && yarn build + + - name: Upload filesystem as artifact + uses: actions/upload-artifact@v4.5.0 + with: + name: client + include-hidden-files: true + path: | + . + !**/node_modules + + lint-client: + name: Lint client + needs: prepare-client-tests + runs-on: ubuntu-22.04 + + steps: + - name: Download filesystem artifact + uses: actions/download-artifact@v4.1.8 + with: + name: client + path: . + + - name: Use Node.js from .nvmrc + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + + - name: Cache Node.js dependencies + uses: actions/cache@v4 + with: + path: | + **/node_modules + ~/.cache/yarn/v6 + key: node-${{ hashFiles('**/yarn.lock', format('{0}/{1}','packages/client','package.json')) }} + restore-keys: | + ${{ runner.os }}-node- + + - name: Install dependencies + run: CI="" yarn install --frozen-lockfile + + - name: Compile + run: cd packages/client && yarn test:compilation + + - name: Run linting + run: cd packages/client && yarn lint + + test-client: + name: Test client + needs: prepare-client-tests + runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: + shards: [1, 2, 3, 4, 5] + + steps: + - name: Download filesystem artifact + uses: actions/download-artifact@v4.1.8 + with: + name: client + path: . + + - name: Use Node.js from .nvmrc + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + + - name: Cache Node.js dependencies + uses: actions/cache@v4 + with: + path: | + **/node_modules + ~/.cache/yarn/v6 + key: node-${{ hashFiles('**/yarn.lock', format('{0}/{1}','packages/client','package.json')) }} + restore-keys: | + ${{ runner.os }}-node- + + - name: Install dependencies + run: CI="" yarn install --frozen-lockfile + + - name: Run Unit Test + run: cd packages/client && yarn test -- --shard $(( ${{ strategy.job-index }} + 1 ))/${{ strategy.job-total }} + lint-knip: + name: Lint unused exports with Knip runs-on: ubuntu-22.04 steps: - name: Checkout base branch diff --git a/packages/client/package.json b/packages/client/package.json index 982d4ea40b5..4f3230b97a0 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -11,9 +11,8 @@ "build": "NODE_OPTIONS=--max_old_space_size=8000 vite build", "docker:build": "docker build ../../ -f ../../Dockerfile-register -t ocrvs-client", "docker:run": "docker run -it --rm -p 5000:80 --name ocrvs-client ocrvs-client", - "test": "yarn test:compilation && NODE_OPTIONS=--max_old_space_size=8000 vitest run --coverage --silent --dangerouslyIgnoreUnhandledErrors", + "test": "NODE_OPTIONS=--max_old_space_size=8000 vitest run --silent --dangerouslyIgnoreUnhandledErrors", "test:watch": "vitest", - "open:cov": "yarn test && opener coverage/index.html", "lint": "yarn lint:css && yarn lint:ts", "lint:css": "stylelint 'src/**/*.{ts,tsx}'", "lint:ts": "eslint --fix './src/**/*.{ts,tsx}' --max-warnings=353", diff --git a/packages/client/vite.config.ts b/packages/client/vite.config.ts index 661b7bc3ac0..fd07413df5f 100644 --- a/packages/client/vite.config.ts +++ b/packages/client/vite.config.ts @@ -116,10 +116,7 @@ export default defineConfig(({ mode }) => { setupFiles: './src/setupTests.ts', testTimeout: 60000, hookTimeout: 60000, - globals: true, - coverage: { - reporter: ['text', 'json', 'html'] - } + globals: true }, server: { // to get the manifest.json and images from country-config during development time From 0b9ddfbbd97b52efe9d4102667753a7dacff3059 Mon Sep 17 00:00:00 2001 From: Pyry Rouvila Date: Mon, 6 Jan 2025 12:03:31 +0900 Subject: [PATCH 09/15] chore: run client tests on develop this assures the unit tests work on the develop branch --- .github/workflows/lint-and-test.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml index 22c5ee40867..25e802bed94 100644 --- a/.github/workflows/lint-and-test.yml +++ b/.github/workflows/lint-and-test.yml @@ -9,7 +9,11 @@ name: Lint and run unit tests -on: [pull_request] +on: + pull_request: + push: + branches: + - develop jobs: setup: From 89669db0054b42cb4200f15a77af5cdfb89a3d15 Mon Sep 17 00:00:00 2001 From: Pyry Rouvila Date: Mon, 6 Jan 2025 12:29:39 +0900 Subject: [PATCH 10/15] fix: a failing test on client's DeclarationForm (#8285) * fix: try fixing a single failing test on client * chore: re-run tests --- packages/client/src/views/RegisterForm/DeclarationForm.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/client/src/views/RegisterForm/DeclarationForm.test.tsx b/packages/client/src/views/RegisterForm/DeclarationForm.test.tsx index dd4d884e25c..3530900d407 100644 --- a/packages/client/src/views/RegisterForm/DeclarationForm.test.tsx +++ b/packages/client/src/views/RegisterForm/DeclarationForm.test.tsx @@ -228,6 +228,7 @@ describe('when user starts a new declaration', () => { it('renders list of document upload field', async () => { await flushPromises() + await waitForElement(app, '#form_section_id_documents-view-group') const fileInputs = app .find('#form_section_id_documents-view-group') .find('section') From 967dd79b9a845856cc0c0b820cf3c666bc41c831 Mon Sep 17 00:00:00 2001 From: Pyry Rouvila Date: Mon, 6 Jan 2025 12:50:49 +0900 Subject: [PATCH 11/15] chore(client): only run Knip & changelog reminder on pull requests --- .github/workflows/lint-and-test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml index 25e802bed94..06945267a0f 100644 --- a/.github/workflows/lint-and-test.yml +++ b/.github/workflows/lint-and-test.yml @@ -33,6 +33,7 @@ jobs: # forked repos cannot access secrets.GITHUB_TOKEN which causes this step # to fail continue-on-error: true + if: github.event_name == 'pull_request' with: message: > Oops! Looks like you forgot to update the changelog. @@ -308,6 +309,7 @@ jobs: lint-knip: name: Lint unused exports with Knip runs-on: ubuntu-22.04 + if: github.event_name == 'pull_request' steps: - name: Checkout base branch uses: actions/checkout@v4 From 066ecda54ecbfd1095f5683d15ba8df762fd9dc7 Mon Sep 17 00:00:00 2001 From: Pyry Rouvila Date: Mon, 6 Jan 2025 14:16:32 +0900 Subject: [PATCH 12/15] feat: allow supplying a comment with confirmRegistration (#8197) * feat: allow supplying a comment with confirmRegistration * trigger e2e * chore: update changelog --------- Co-authored-by: Riku Rouvila --- CHANGELOG.md | 2 +- .../gateway/src/features/registration/root-resolvers.ts | 5 +---- packages/gateway/src/features/registration/schema.graphql | 1 + packages/gateway/src/graphql/schema.d.ts | 1 + packages/gateway/src/graphql/schema.graphql | 1 + packages/gateway/src/workflow/index.ts | 1 + packages/workflow/src/features/registration/handler.ts | 6 ++++-- packages/workflow/src/records/fhir.ts | 7 +++++-- packages/workflow/src/records/state-transitions.ts | 7 +++++-- 9 files changed, 20 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac7e465064d..2ba52a97549 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,6 @@ ### New features -- Misc new feature - Allow configuring the default search criteria for record search [#6924](https://github.com/opencrvs/opencrvs-core/issues/6924) - Add checks to validate client and server are always on the same version. This prevents browsers with a cached or outdated client versions from making potentially invalid requests to the backend [#6695](https://github.com/opencrvs/opencrvs-core/issues/6695) - Two new statuses of record are added: `Validated` and `Correction Requested` for advanced search parameters [#6365](https://github.com/opencrvs/opencrvs-core/issues/6365) @@ -24,6 +23,7 @@ - **Template Selection Dropdown**: Updated print workflow to include a dropdown menu for template selection when issuing a certificate. - Auth now allows exchanging user's token for a new record-specific token [#7728](https://github.com/opencrvs/opencrvs-core/issues/7728) - A new GraphQL mutation `upsertRegistrationIdentifier` is added to allow updating the patient identifiers of a registration record such as NID [#8034](https://github.com/opencrvs/opencrvs-core/pull/8034) +- Updated GraphQL mutation `confirmRegistration` to allow adding a `comment` for record audit [#8197](https://github.com/opencrvs/opencrvs-core/pull/8197) - Introduced a new customisable UI component: Banner [#8276](https://github.com/opencrvs/opencrvs-core/issues/8276) ### Improvements diff --git a/packages/gateway/src/features/registration/root-resolvers.ts b/packages/gateway/src/features/registration/root-resolvers.ts index 2e801ceef67..385f40d7c5b 100644 --- a/packages/gateway/src/features/registration/root-resolvers.ts +++ b/packages/gateway/src/features/registration/root-resolvers.ts @@ -612,10 +612,7 @@ export const resolvers: GQLResolver = { } try { - const taskEntry = await confirmRegistration(id, authHeader, { - registrationNumber: details.registrationNumber, - identifiers: details.identifiers - }) + const taskEntry = await confirmRegistration(id, authHeader, details) return taskEntry.resource.id } catch (error) { diff --git a/packages/gateway/src/features/registration/schema.graphql b/packages/gateway/src/features/registration/schema.graphql index 1fbbcb56987..676a05f64ac 100644 --- a/packages/gateway/src/features/registration/schema.graphql +++ b/packages/gateway/src/features/registration/schema.graphql @@ -572,6 +572,7 @@ input IdentifierInput { input ConfirmRegistrationInput { registrationNumber: String! identifiers: [IdentifierInput!] + comment: String # For record audit } input RejectRegistrationInput { diff --git a/packages/gateway/src/graphql/schema.d.ts b/packages/gateway/src/graphql/schema.d.ts index af996141149..a0e94933792 100644 --- a/packages/gateway/src/graphql/schema.d.ts +++ b/packages/gateway/src/graphql/schema.d.ts @@ -582,6 +582,7 @@ export interface GQLReinstated { export interface GQLConfirmRegistrationInput { registrationNumber: string identifiers?: Array + comment?: string } export interface GQLRejectRegistrationInput { diff --git a/packages/gateway/src/graphql/schema.graphql b/packages/gateway/src/graphql/schema.graphql index 5446faebcd7..e54010f41eb 100644 --- a/packages/gateway/src/graphql/schema.graphql +++ b/packages/gateway/src/graphql/schema.graphql @@ -707,6 +707,7 @@ type Reinstated { input ConfirmRegistrationInput { registrationNumber: String! identifiers: [IdentifierInput!] + comment: String } input RejectRegistrationInput { diff --git a/packages/gateway/src/workflow/index.ts b/packages/gateway/src/workflow/index.ts index 317fb86fc48..841b6a3a4a4 100644 --- a/packages/gateway/src/workflow/index.ts +++ b/packages/gateway/src/workflow/index.ts @@ -246,6 +246,7 @@ export async function confirmRegistration( details: { registrationNumber: string identifiers?: IdentifierInput[] + comment?: string } ) { const res: ReadyForReviewRecord = await createRequest( diff --git a/packages/workflow/src/features/registration/handler.ts b/packages/workflow/src/features/registration/handler.ts index c9e9ec555b5..8dd7c68b7c3 100644 --- a/packages/workflow/src/features/registration/handler.ts +++ b/packages/workflow/src/features/registration/handler.ts @@ -25,7 +25,8 @@ import { SupportedPatientIdentifierCode } from '@opencrvs/commons/types' export interface EventRegistrationPayload { trackingId: string registrationNumber: string - error: string + error?: string + comment?: string identifiers?: { type: SupportedPatientIdentifierCode value: string @@ -38,7 +39,7 @@ export async function markEventAsRegisteredCallbackHandler( ) { const token = getToken(request) const compositionId = request.params.id - const { registrationNumber, error, identifiers } = + const { registrationNumber, error, comment, identifiers } = request.payload as EventRegistrationPayload if (error) { @@ -60,6 +61,7 @@ export async function markEventAsRegisteredCallbackHandler( savedRecord, registrationNumber, token, + comment, identifiers ) const event = getEventType(bundle) diff --git a/packages/workflow/src/records/fhir.ts b/packages/workflow/src/records/fhir.ts index 881d2d40959..8f0118d7abc 100644 --- a/packages/workflow/src/records/fhir.ts +++ b/packages/workflow/src/records/fhir.ts @@ -768,7 +768,10 @@ export function createWaitingForValidationTask( } } -export function createRegisterTask(previousTask: SavedTask): Task { +export function createRegisterTask( + previousTask: SavedTask, + comment?: string +): Task { const timeLoggedMSExtension = previousTask.extension.find( (e) => e.url === 'http://opencrvs.org/specs/extension/timeLoggedMS' )! @@ -779,7 +782,7 @@ export function createRegisterTask(previousTask: SavedTask): Task { 'REGISTERED' ) - const comments = previousTask?.note?.[0]?.text + const comments = comment ?? previousTask?.note?.[0]?.text return { ...registeredTask, diff --git a/packages/workflow/src/records/state-transitions.ts b/packages/workflow/src/records/state-transitions.ts index d35b3b30b94..b6ac688a16b 100644 --- a/packages/workflow/src/records/state-transitions.ts +++ b/packages/workflow/src/records/state-transitions.ts @@ -509,11 +509,14 @@ export async function toRegistered( record: WaitingForValidationRecord, registrationNumber: EventRegistrationPayload['registrationNumber'], token: string, + comment?: string, identifiers?: EventRegistrationPayload['identifiers'] ): Promise { const previousTask = getTaskFromSavedBundle(record) - const registeredTaskWithoutPractitionerExtensions = - createRegisterTask(previousTask) + const registeredTaskWithoutPractitionerExtensions = createRegisterTask( + previousTask, + comment + ) const [registeredTask, practitionerResourcesBundle] = await withPractitionerDetails( From 85bd7ecb19d30b0258c4b7398742623f3058df0e Mon Sep 17 00:00:00 2001 From: Jamil Date: Mon, 6 Jan 2025 18:37:00 +0600 Subject: [PATCH 13/15] Feat: events-v2: implement delete event (#8266) Co-authored-by: Riku Rouvila --- .../forms/inputs/FileInput/FileInput.tsx | 24 +- .../features/events/actions/declare/Pages.tsx | 17 ++ .../events/actions/declare/Review.tsx | 2 +- .../events/actions/register/Review.tsx | 2 +- .../events/useEventFormNavigation.tsx | 66 ++++- .../events/useEvents/procedures/action.ts | 106 ++++++++ .../events/useEvents/procedures/create.ts | 64 +++++ .../events/useEvents/procedures/delete.ts | 60 +++++ .../events/useEvents/procedures/persist.ts | 101 +++++++ .../events/useEvents/useEvents.test.tsx | 4 +- .../features/events/useEvents/useEvents.ts | 251 +++--------------- .../v2-events/features/files/useFileUpload.ts | 57 +++- .../src/v2-events/layouts/form/FormHeader.tsx | 41 ++- packages/commons/src/index.ts | 1 + packages/documents/src/config/routes.ts | 10 + .../src/features/deleteDocument/handler.ts | 47 ++++ .../features/uploadDocument/handler.test.ts | 3 +- .../src/features/uploadDocument/handler.ts | 36 ++- .../src/features/uploadSvg/handler.test.ts | 3 +- .../src/features/uploadSvg/handler.ts | 11 +- packages/events/src/router/router.ts | 6 + packages/events/src/service/events.ts | 67 ++++- packages/events/src/service/files/index.ts | 10 + .../events/src/service/indexing/indexing.ts | 12 + packages/gateway/src/config/routes.ts | 22 +- 25 files changed, 750 insertions(+), 273 deletions(-) create mode 100644 packages/client/src/v2-events/features/events/useEvents/procedures/action.ts create mode 100644 packages/client/src/v2-events/features/events/useEvents/procedures/create.ts create mode 100644 packages/client/src/v2-events/features/events/useEvents/procedures/delete.ts create mode 100644 packages/client/src/v2-events/features/events/useEvents/procedures/persist.ts create mode 100644 packages/documents/src/features/deleteDocument/handler.ts diff --git a/packages/client/src/v2-events/components/forms/inputs/FileInput/FileInput.tsx b/packages/client/src/v2-events/components/forms/inputs/FileInput/FileInput.tsx index 2dc21bf1336..88ef634ed71 100644 --- a/packages/client/src/v2-events/components/forms/inputs/FileInput/FileInput.tsx +++ b/packages/client/src/v2-events/components/forms/inputs/FileInput/FileInput.tsx @@ -28,21 +28,17 @@ export function FileInput( const [file, setFile] = React.useState(value) - const { uploadFiles } = useFileUpload(name, { - onSuccess: ({ filename }) => { - if (!file) { - throw new Error('File is not defined. This should never happen') - } + const { uploadFiles, deleteFile } = useFileUpload(name, { + onSuccess: ({ type, originalFilename, filename }) => { setFile({ filename, - originalFilename: file.originalFilename, - type: file.type + originalFilename: originalFilename, + type: type }) - onChange({ filename, - originalFilename: file.originalFilename, - type: file.type + originalFilename: originalFilename, + type: type }) } }) @@ -64,10 +60,12 @@ export function FileInput( type: newFile.type }) uploadFiles(newFile) - } else { - setFile(undefined) - onChange(undefined) } + if (!newFile && file) { + deleteFile(file.filename) + } + setFile(undefined) + onChange(undefined) }} /> ) diff --git a/packages/client/src/v2-events/features/events/actions/declare/Pages.tsx b/packages/client/src/v2-events/features/events/actions/declare/Pages.tsx index f05ac4107ef..c4a60dd88b5 100644 --- a/packages/client/src/v2-events/features/events/actions/declare/Pages.tsx +++ b/packages/client/src/v2-events/features/events/actions/declare/Pages.tsx @@ -61,6 +61,23 @@ export function Pages() { } }, [pageId, currentPageId, navigate, eventId]) + /* + * If the event had a temporary ID and the record got persisted while the user + * was on the declare page, we need to navigate to the event with the canonical + * ID. + */ + useEffect(() => { + const hasTemporaryId = event.id === event.transactionId + + if (eventId !== event.id && !hasTemporaryId) { + navigate( + ROUTES.V2.EVENTS.DECLARE.buildPath({ + eventId: event.id + }) + ) + } + }, [event.id, event.transactionId, eventId, navigate]) + return ( <> {modal} diff --git a/packages/client/src/v2-events/features/events/actions/declare/Review.tsx b/packages/client/src/v2-events/features/events/actions/declare/Review.tsx index ced9721922c..68631d36c5e 100644 --- a/packages/client/src/v2-events/features/events/actions/declare/Review.tsx +++ b/packages/client/src/v2-events/features/events/actions/declare/Review.tsx @@ -105,7 +105,7 @@ export function Review() { const intl = useIntl() const { goToHome } = useEventFormNavigation() - const declareMutation = events.actions.declare() + const declareMutation = events.actions.declare const [event] = events.getEvent(eventId) diff --git a/packages/client/src/v2-events/features/events/actions/register/Review.tsx b/packages/client/src/v2-events/features/events/actions/register/Review.tsx index bbe407b44de..23d5cb38e78 100644 --- a/packages/client/src/v2-events/features/events/actions/register/Review.tsx +++ b/packages/client/src/v2-events/features/events/actions/register/Review.tsx @@ -52,7 +52,7 @@ export function Review() { const [modal, openModal] = useModal() const navigate = useNavigate() const { goToHome } = useEventFormNavigation() - const registerMutation = events.actions.register() + const registerMutation = events.actions.register const [event] = events.getEvent(eventId) diff --git a/packages/client/src/v2-events/features/events/useEventFormNavigation.tsx b/packages/client/src/v2-events/features/events/useEventFormNavigation.tsx index c34c6e2a840..3c174ac8d9f 100644 --- a/packages/client/src/v2-events/features/events/useEventFormNavigation.tsx +++ b/packages/client/src/v2-events/features/events/useEventFormNavigation.tsx @@ -12,8 +12,9 @@ import React from 'react' import { defineMessages, useIntl } from 'react-intl' import { useNavigate } from 'react-router-dom' import { Button, ResponsiveModal, Stack, Text } from '@opencrvs/components' -import { useModal } from '@client/v2-events/hooks/useModal' import { ROUTES } from '@client/v2-events/routes' +import { useModal } from '@client/v2-events/hooks/useModal' +import { useEvents } from './useEvents/useEvents' const modalMessages = defineMessages({ cancel: { @@ -32,12 +33,26 @@ const modalMessages = defineMessages({ id: 'exitModal.exitWithoutSavingDescription', defaultMessage: 'You have unsaved changes on your declaration form. Are you sure you want to exit without saving?' + }, + deleteDeclarationTitle: { + id: 'register.form.modal.title.deleteDeclarationConfirm', + defaultMessage: 'Delete draft?', + description: 'Title for delete declaration confirmation modal' + }, + deleteDeclarationDescription: { + id: 'register.form.modal.desc.deleteDeclarationConfirm', + defaultMessage: `Are you certain you want to delete this draft declaration form? Please note, this action can't be undone.`, + description: 'Description for delete declaration confirmation modal' } }) export function useEventFormNavigation() { const intl = useIntl() const navigate = useNavigate() + + const events = useEvents() + const deleteEvent = events.deleteEvent + const [modal, openModal] = useModal() function goToHome() { @@ -48,7 +63,7 @@ export function useEventFormNavigation() { navigate(ROUTES.V2.EVENTS.DECLARE.REVIEW.buildPath({ eventId })) } - async function exit() { + async function exit(eventId: string) { const exitConfirm = await openModal((close) => ( ((close) => ( + { + close(null) + }} + > + {intl.formatMessage(modalMessages.cancel)} + , + + ]} + handleClose={() => close(null)} + responsive={false} + show={true} + title={intl.formatMessage(modalMessages.deleteDeclarationTitle)} + > + + + {intl.formatMessage(modalMessages.deleteDeclarationDescription)} + + + + )) + + if (deleteConfirm) { + deleteEvent.mutate({ eventId }) + } + } + + return { exit, modal, goToHome, goToReview, deleteDeclaration } } diff --git a/packages/client/src/v2-events/features/events/useEvents/procedures/action.ts b/packages/client/src/v2-events/features/events/useEvents/procedures/action.ts new file mode 100644 index 00000000000..9484b4e2454 --- /dev/null +++ b/packages/client/src/v2-events/features/events/useEvents/procedures/action.ts @@ -0,0 +1,106 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * OpenCRVS is also distributed under the terms of the Civil Registration + * & Healthcare Disclaimer located at http://opencrvs.org/license. + * + * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. + */ + +import { useMutation } from '@tanstack/react-query' +import { getMutationKey } from '@trpc/react-query' +import { api, utils } from '@client/v2-events/trpc' +import { + getEvent, + getEvents, + invalidateQueries, + persistEvents +} from './persist' + +function waitUntilEventIsCreated( + canonicalMutationFn: (params: T) => Promise +): (params: T) => Promise { + return async (params) => { + const { eventId } = params + const events = getEvents() + const event = getEvent(events, eventId) + + if (!event || event.id === event.transactionId) { + console.error( + 'Event that has not been stored yet cannot be actioned upon' + ) + throw new Error( + 'Event that has not been stored yet cannot be actioned upon' + ) + } + + return canonicalMutationFn({ ...params, eventId: event.id }) + } +} + +type Mutation = + | typeof api.event.actions.declare + | typeof api.event.actions.draft + | typeof api.event.actions.notify + | typeof api.event.actions.register + +type Procedure = + | typeof utils.event.actions.declare + | typeof utils.event.actions.draft + | typeof utils.event.actions.notify + | typeof utils.event.actions.register + +utils.event.actions.declare.setMutationDefaults(({ canonicalMutationFn }) => ({ + retry: true, + retryDelay: 10000, + mutationFn: waitUntilEventIsCreated(canonicalMutationFn) +})) + +utils.event.actions.draft.setMutationDefaults(({ canonicalMutationFn }) => ({ + retry: true, + retryDelay: 10000, + mutationFn: waitUntilEventIsCreated(canonicalMutationFn), + onSuccess: async (updatedEvent) => { + persistEvents((events) => + events.map((event) => + event.id === updatedEvent.id ? updatedEvent : event + ) + ) + return invalidateQueries() + } +})) + +utils.event.actions.register.setMutationDefaults(({ canonicalMutationFn }) => ({ + retry: true, + retryDelay: 10000, + mutationFn: waitUntilEventIsCreated(canonicalMutationFn) +})) + +utils.event.actions.notify.setMutationDefaults(({ canonicalMutationFn }) => ({ + retry: true, + retryDelay: 10000, + mutationFn: waitUntilEventIsCreated(canonicalMutationFn) +})) + +export function useEventAction

( + procedure: P, + mutation: M +) { + const mutationDefaults = procedure.getMutationDefaults() + + if (!mutationDefaults?.mutationFn) { + throw new Error( + 'No mutation fn found for operation. This should never happen' + ) + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return useMutation({ + ...mutationDefaults, + mutationKey: getMutationKey(mutation), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mutationFn: waitUntilEventIsCreated(mutationDefaults.mutationFn) + }) as ReturnType +} diff --git a/packages/client/src/v2-events/features/events/useEvents/procedures/create.ts b/packages/client/src/v2-events/features/events/useEvents/procedures/create.ts new file mode 100644 index 00000000000..16ccdb42417 --- /dev/null +++ b/packages/client/src/v2-events/features/events/useEvents/procedures/create.ts @@ -0,0 +1,64 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * OpenCRVS is also distributed under the terms of the Civil Registration + * & Healthcare Disclaimer located at http://opencrvs.org/license. + * + * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. + */ + +import { getQueryKey } from '@trpc/react-query' +import { CreatedAction, EventDocument } from '@opencrvs/commons/client' +import { api, queryClient, utils } from '@client/v2-events/trpc' +import { invalidateQueries, persistEvents } from './persist' + +utils.event.create.setMutationDefaults(({ canonicalMutationFn }) => ({ + mutationFn: canonicalMutationFn, + retry: true, + onMutate: (newEvent) => { + const optimisticEvent = { + id: newEvent.transactionId, + type: newEvent.type, + transactionId: newEvent.transactionId, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + actions: [ + { + type: 'CREATE', + createdAt: new Date().toISOString(), + createdBy: 'offline', + createdAtLocation: 'TODO', + data: {} + } satisfies CreatedAction + ] + } + + // Do this as very first synchronous operation so UI can trust + // that the event is created when changing view for instance + persistEvents((events: EventDocument[]) => { + return [...events, optimisticEvent] + }) + + return optimisticEvent + }, + onSuccess: async (response) => { + await invalidateQueries() + + persistEvents((state: EventDocument[]) => { + return [ + ...state.filter((e) => e.transactionId !== response.transactionId), + response + ] + }) + + await queryClient.cancelQueries({ + queryKey: getQueryKey(api.event.get, response.transactionId, 'query') + }) + } +})) + +export function createEvent() { + return api.event.create.useMutation({}) +} diff --git a/packages/client/src/v2-events/features/events/useEvents/procedures/delete.ts b/packages/client/src/v2-events/features/events/useEvents/procedures/delete.ts new file mode 100644 index 00000000000..c95d6833c5c --- /dev/null +++ b/packages/client/src/v2-events/features/events/useEvents/procedures/delete.ts @@ -0,0 +1,60 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * OpenCRVS is also distributed under the terms of the Civil Registration + * & Healthcare Disclaimer located at http://opencrvs.org/license. + * + * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. + */ + +import { useMutation } from '@tanstack/react-query' +import { getMutationKey } from '@trpc/react-query' +import { EventDocument } from '@opencrvs/commons/client' +import { api, utils } from '@client/v2-events/trpc' +import { getCanonicalEventId, getEvents, persistEvents } from './persist' + +function waitUntilEventIsCreated( + canonicalMutationFn: (params: { eventId: string }) => Promise +): (params: { eventId: string }) => Promise { + return async ({ eventId }) => { + const events = getEvents() + + const id = getCanonicalEventId(events, eventId) + + if (!id || id === eventId) { + throw new Error('Event that has not been stored yet cannot be deleted') + } + + return canonicalMutationFn({ eventId: id }) + } +} + +utils.event.delete.setMutationDefaults(({ canonicalMutationFn }) => ({ + retry: true, + retryDelay: 10000, + onSuccess: ({ id }) => { + persistEvents((old: EventDocument[]) => old.filter((e) => e.id !== id)) + }, + /* + * This ensures that when the application is reloaded with pending mutations in IndexedDB, the + * temporary event IDs in the requests get properly replaced with canonical IDs. + * Also check utils.event.create.onSuccess for the same logic but for when even is created. + */ + mutationFn: waitUntilEventIsCreated(canonicalMutationFn) +})) + +export function useDeleteEventMutation() { + const deleteDefaults = utils.event.delete.getMutationDefaults() + if (!deleteDefaults?.mutationFn) { + throw new Error( + 'No mutation fn found for event.delete. This should never happen' + ) + } + return useMutation({ + ...deleteDefaults, + mutationKey: getMutationKey(api.event.delete), + mutationFn: waitUntilEventIsCreated(deleteDefaults.mutationFn) + }) +} diff --git a/packages/client/src/v2-events/features/events/useEvents/procedures/persist.ts b/packages/client/src/v2-events/features/events/useEvents/procedures/persist.ts new file mode 100644 index 00000000000..97e74d4b802 --- /dev/null +++ b/packages/client/src/v2-events/features/events/useEvents/procedures/persist.ts @@ -0,0 +1,101 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * OpenCRVS is also distributed under the terms of the Civil Registration + * & Healthcare Disclaimer located at http://opencrvs.org/license. + * + * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. + */ + +import { QueryObserver, useSuspenseQuery } from '@tanstack/react-query' +import { EventDocument } from '@opencrvs/commons' +import { queryClient } from '@client/v2-events/trpc' +import { storage } from '@client/storage' +/* + * Local event storage + */ +const EVENTS_PERSISTENT_STORE_STORAGE_KEY = ['persisted-events'] + +queryClient.setQueryDefaults(EVENTS_PERSISTENT_STORE_STORAGE_KEY, { + queryFn: readEventsFromStorage +}) + +async function readEventsFromStorage() { + const data = await storage + .getItem('events') + .then((e) => e || []) + + return data +} + +async function writeEventsToStorage(events: EventDocument[]) { + return storage.setItem('events', events) +} + +export function persistEvents( + updater: (events: EventDocument[]) => EventDocument[] +) { + queryClient.setQueryData(EVENTS_PERSISTENT_STORE_STORAGE_KEY, updater) +} + +export function getCanonicalEventId( + events: EventDocument[], + eventIdOrTransactionId: string +) { + return getEvent(events, eventIdOrTransactionId)?.id +} + +export function getEvent( + events: EventDocument[], + eventIdOrTransactionId: string +) { + const event = events.find( + (e) => + e.id === eventIdOrTransactionId || + e.transactionId === eventIdOrTransactionId + ) + + return event +} + +export function getEvents() { + const events = queryClient.getQueryData( + EVENTS_PERSISTENT_STORE_STORAGE_KEY + ) + if (!events) { + throw new Error( + 'No events found in EVENTS_PERSISTENT_STORE_STORAGE_KEY query. This should never happen' + ) + } + return events +} + +export function createObserver() { + return new QueryObserver(queryClient, { + queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY + }) +} + +createObserver().subscribe((observerEvent) => { + /* + * Persist events to browser storage + */ + if (!observerEvent.data) { + return + } + void writeEventsToStorage(observerEvent.data) +}) + +export function useEventsSuspenseQuery() { + return useSuspenseQuery({ + queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY + }) +} + +export async function invalidateQueries() { + return queryClient.invalidateQueries({ + queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY + }) +} diff --git a/packages/client/src/v2-events/features/events/useEvents/useEvents.test.tsx b/packages/client/src/v2-events/features/events/useEvents/useEvents.test.tsx index 5c62d4df0b0..4cf1a474f44 100644 --- a/packages/client/src/v2-events/features/events/useEvents/useEvents.test.tsx +++ b/packages/client/src/v2-events/features/events/useEvents/useEvents.test.tsx @@ -98,7 +98,7 @@ interface TestContext { {} > declareHook: RenderHookResult< - ReturnType['actions']['declare']>, + ReturnType['actions']['declare'], {} > } @@ -122,7 +122,7 @@ beforeEach(async (testContext) => { }) const declareHookHook = renderHook( - () => eventsHook.result.current.actions.declare(), + () => eventsHook.result.current.actions.declare, { wrapper } diff --git a/packages/client/src/v2-events/features/events/useEvents/useEvents.ts b/packages/client/src/v2-events/features/events/useEvents/useEvents.ts index cdd9cfcccdd..58748814e11 100644 --- a/packages/client/src/v2-events/features/events/useEvents/useEvents.ts +++ b/packages/client/src/v2-events/features/events/useEvents/useEvents.ts @@ -9,189 +9,22 @@ * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. */ -import { - hashKey, - MutationKey, - QueryObserver, - useSuspenseQuery -} from '@tanstack/react-query' +import { hashKey } from '@tanstack/react-query' import { getQueryKey } from '@trpc/react-query' -import { EventDocument, CreatedAction } from '@opencrvs/commons/client' +import { EventDocument, getCurrentEventState } from '@opencrvs/commons/client' import { api, queryClient, utils } from '@client/v2-events/trpc' -import { storage } from '@client/storage' +import { useEventAction } from './procedures/action' +import { createEvent } from './procedures/create' +import { useDeleteEventMutation } from './procedures/delete' +import { createObserver, useEventsSuspenseQuery } from './procedures/persist' -/* - * Local event storage - */ -const EVENTS_PERSISTENT_STORE_STORAGE_KEY = ['persisted-events'] - -function getCanonicalEventId( - events: EventDocument[], - eventIdOrTransactionId: string -) { - const event = events.find( - (e) => - e.id === eventIdOrTransactionId || - e.transactionId === eventIdOrTransactionId - ) - - return event?.id -} - -/** - * Wraps a canonical mutation function to handle outdated temporary IDs. - * - * When an event is created offline and actions referring to that event - * are also created offline, there may be cases where a request in the - * buffer still uses a temporary ID to reference the event. This wrapper - * ensures that the `eventId` parameter is updated to its canonical value - * before the mutation function is called. - * - * @param {function(params: T): Promise} canonicalMutationFn - The mutation function to wrap. - * @returns {function(params: T): Promise} - A wrapped mutation function that resolves the canonical `eventId` before invocation. - */ -function wrapMutationFnEventIdResolution( - canonicalMutationFn: (params: T) => Promise -): (params: T) => Promise { - return async (params: T) => { - const events = queryClient.getQueryData( - EVENTS_PERSISTENT_STORE_STORAGE_KEY - ) - - if (!events) { - return canonicalMutationFn(params) - } - - const id = getCanonicalEventId(events, params.eventId) - if (!id) { - return canonicalMutationFn(params) - } - - const modifiedParams: T = { - ...params, - eventId: getCanonicalEventId(events, params.eventId) - } - return canonicalMutationFn(modifiedParams) - } -} - -utils.event.actions.declare.setMutationDefaults(({ canonicalMutationFn }) => ({ - retry: true, - retryDelay: 10000, - mutationFn: wrapMutationFnEventIdResolution(canonicalMutationFn) -})) - -utils.event.actions.draft.setMutationDefaults(({ canonicalMutationFn }) => ({ - retry: true, - retryDelay: 10000, - mutationFn: wrapMutationFnEventIdResolution(canonicalMutationFn) -})) - -utils.event.actions.register.setMutationDefaults(({ canonicalMutationFn }) => ({ - retry: true, - retryDelay: 10000, - mutationFn: wrapMutationFnEventIdResolution(canonicalMutationFn) -})) - -utils.event.create.setMutationDefaults(({ canonicalMutationFn }) => ({ - mutationFn: canonicalMutationFn, - retry: true, - onMutate: (newEvent) => { - const optimisticEvent = { - id: newEvent.transactionId, - type: newEvent.type, - transactionId: newEvent.transactionId, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - actions: [ - { - type: 'CREATE', - createdAt: new Date().toISOString(), - createdBy: 'offline', - createdAtLocation: 'TODO', - data: {} - } satisfies CreatedAction - ] - } - - // Do this as very first synchronous operation so UI can trust - // that the event is created when changing view for instance - queryClient.setQueryData( - EVENTS_PERSISTENT_STORE_STORAGE_KEY, - (events: EventDocument[]) => { - return [...events, optimisticEvent] - } - ) - - return optimisticEvent - }, - onSuccess: async (response) => { - const events = queryClient.getQueryData( - EVENTS_PERSISTENT_STORE_STORAGE_KEY - ) - - if (!events) { - return - } - - queryClient - .getMutationCache() - .getAll() - .forEach((mutation) => { - /* - * Update ongoing mutations with the new event ID so that transaction id is not used for querying - */ - const hashQueryKey = ( - key: MutationKey | ReturnType | undefined - ) => key?.flat().join('.') - - if ( - hashQueryKey(mutation.options.mutationKey) === - hashQueryKey(getQueryKey(api.event.actions.declare)) - ) { - const variables = mutation.state.variables as Exclude< - ReturnType< - typeof api.event.actions.declare.useMutation - >['variables'], - undefined - > - - if (variables.eventId === response.transactionId) { - variables.eventId = response.id - } - } - }) - - await queryClient.invalidateQueries({ - queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY - }) - - queryClient.setQueryData( - EVENTS_PERSISTENT_STORE_STORAGE_KEY, - (state: EventDocument[]) => { - return [ - ...state.filter((e) => e.transactionId !== response.transactionId), - response - ] - } - ) - - await queryClient.cancelQueries({ - queryKey: getQueryKey(api.event.get, response.transactionId, 'query') - }) - } -})) - -queryClient.setQueryDefaults(EVENTS_PERSISTENT_STORE_STORAGE_KEY, { - queryFn: readEventsFromStorage -}) - -const observer = new QueryObserver(queryClient, { - queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY -}) +const observer = createObserver() observer.subscribe((observerEvent) => { observerEvent.data?.forEach((event) => { + /* + * Update items data in "event by id" queries + */ queryClient.setQueryData( getQueryKey(api.event.get, event.id, 'query'), event @@ -201,27 +34,18 @@ observer.subscribe((observerEvent) => { getQueryKey(api.event.get, event.transactionId, 'query'), event ) + + /* + * Update item in workqueues + */ + utils.events.get.setData(undefined, (events) => + events?.map((ev) => + ev.id !== event.id ? ev : getCurrentEventState(event) + ) + ) }) - /* - * Persist events to browser storage - */ - if (observerEvent.data) { - void writeEventsToStorage(observerEvent.data) - } }) -async function readEventsFromStorage() { - const data = await storage - .getItem('events') - .then((e) => e || []) - - return data -} - -async function writeEventsToStorage(events: EventDocument[]) { - return storage.setItem('events', events) -} - function getPendingMutations( mutationCreator: Parameters[0] ) { @@ -260,22 +84,6 @@ function filterOutboxEventsWithMutation< } export function useEvents() { - function createEvent() { - return api.event.create.useMutation({}) - } - - function draft() { - return api.event.actions.draft.useMutation({}) - } - - function declare() { - return api.event.actions.declare.useMutation({}) - } - - function register() { - return api.event.actions.register.useMutation({}) - } - function getEvent(id: string) { return api.event.get.useSuspenseQuery(id) } @@ -356,9 +164,7 @@ export function useEvents() { }) } - const storedEvents = useSuspenseQuery({ - queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY - }) + const storedEvents = useEventsSuspenseQuery() return { createEvent, @@ -367,11 +173,22 @@ export function useEvents() { getEventById: api.event.get, getEvents: api.events.get, getDrafts, + deleteEvent: useDeleteEventMutation(), getOutbox, actions: { - draft, - declare, - register + draft: useEventAction(utils.event.actions.draft, api.event.actions.draft), + notify: useEventAction( + utils.event.actions.notify, + api.event.actions.notify + ), + declare: useEventAction( + utils.event.actions.declare, + api.event.actions.declare + ), + register: useEventAction( + utils.event.actions.register, + api.event.actions.register + ) } } } diff --git a/packages/client/src/v2-events/features/files/useFileUpload.ts b/packages/client/src/v2-events/features/files/useFileUpload.ts index e40bd9737d3..29e8b5d052a 100644 --- a/packages/client/src/v2-events/features/files/useFileUpload.ts +++ b/packages/client/src/v2-events/features/files/useFileUpload.ts @@ -11,8 +11,8 @@ import { useMutation } from '@tanstack/react-query' import { v4 as uuid } from 'uuid' -import { queryClient } from '@client/v2-events/trpc' import { getToken } from '@client/utils/authUtils' +import { queryClient } from '@client/v2-events/trpc' async function uploadFile({ file, @@ -40,7 +40,23 @@ async function uploadFile({ return response } -const MUTATION_KEY = 'uploadFile' +async function deleteFile({ filename }: { filename: string }): Promise { + const response = await fetch('/api/files/' + filename, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${getToken()}` + } + }) + + if (!response.ok) { + throw new Error('File deletation upload failed') + } + + return +} + +const UPLOAD_MUTATION_KEY = 'uploadFile' +const DELETE_MUTATION_KEY = 'deleteFile' /* Must match the one defined src-sw.ts */ const CACHE_NAME = 'workbox-runtime' @@ -99,37 +115,62 @@ async function removeCached(filename: string) { return cache.delete(getFullURL(filename)) } -queryClient.setMutationDefaults([MUTATION_KEY], { +queryClient.setMutationDefaults([DELETE_MUTATION_KEY], { + retry: true, + retryDelay: 5000, + mutationFn: deleteFile +}) +queryClient.setMutationDefaults([UPLOAD_MUTATION_KEY], { retry: true, retryDelay: 5000, mutationFn: uploadFile }) interface Options { - onSuccess?: (data: { filename: string }) => void + onSuccess?: (data: { + originalFilename: string + type: string + filename: string + }) => void } export function useFileUpload(fieldId: string, options: Options = {}) { - const mutation = useMutation({ + const upload = useMutation({ mutationFn: uploadFile, - mutationKey: [MUTATION_KEY, fieldId], + mutationKey: [UPLOAD_MUTATION_KEY, fieldId], onMutate: async ({ file, transactionId }) => { const extension = file.name.split('.').pop() const temporaryUrl = `${transactionId}.${extension}` await cacheFile(temporaryUrl, file) - options.onSuccess?.({ filename: temporaryUrl }) + options.onSuccess?.({ + ...file, + originalFilename: file.name, + type: file.type, + filename: temporaryUrl + }) }, onSuccess: (data) => { void removeCached(data.url) } }) + const del = useMutation({ + mutationFn: deleteFile, + mutationKey: [DELETE_MUTATION_KEY, fieldId], + onSuccess: (data, { filename }) => { + void removeCached(filename) + } + }) + return { getFullURL, + deleteFile: (filename: string) => { + return del.mutate({ filename }) + }, uploadFiles: (file: File) => { - return mutation.mutate({ file, transactionId: uuid() }) + return upload.mutate({ file, transactionId: uuid() }) } } } diff --git a/packages/client/src/v2-events/layouts/form/FormHeader.tsx b/packages/client/src/v2-events/layouts/form/FormHeader.tsx index f4ed35a2e84..41f650c83a1 100644 --- a/packages/client/src/v2-events/layouts/form/FormHeader.tsx +++ b/packages/client/src/v2-events/layouts/form/FormHeader.tsx @@ -15,7 +15,7 @@ import { useParams } from 'react-router-dom' import { v4 as uuid } from 'uuid' import type { TranslationConfig } from '@opencrvs/commons/events' import { DeclarationIcon } from '@opencrvs/components/lib/icons' -import { AppBar, Button, Icon } from '@opencrvs/components' +import { AppBar, Button, Icon, ToggleMenu } from '@opencrvs/components' import { useEventFormData } from '@client/v2-events//features/events/useEventFormData' import { useEvents } from '@client/v2-events//features/events/useEvents/useEvents' import { useEventFormNavigation } from '@client/v2-events//features/events/useEventFormNavigation' @@ -44,7 +44,7 @@ const messages = defineMessages({ export function FormHeader({ label }: { label: TranslationConfig }) { const intl = useIntl() - const { modal, exit, goToHome } = useEventFormNavigation() + const { modal, exit, goToHome, deleteDeclaration } = useEventFormNavigation() const events = useEvents() const formValues = useEventFormData((state) => state.formValues) const { eventId } = useParams<{ @@ -55,7 +55,7 @@ export function FormHeader({ label }: { label: TranslationConfig }) { throw new Error('Event id is required') } - const createDraft = events.actions.draft() + const createDraft = events.actions.draft const saveAndExit = useCallback(() => { createDraft.mutate({ eventId, data: formValues, transactionId: uuid() }) @@ -63,8 +63,12 @@ export function FormHeader({ label }: { label: TranslationConfig }) { }, [createDraft, eventId, formValues, goToHome]) const onExit = useCallback(async () => { - await exit() - }, [exit]) + await exit(eventId) + }, [eventId, exit]) + + const onDelete = useCallback(async () => { + await deleteDeclaration(eventId) + }, [eventId, deleteDeclaration]) return ( {intl.formatMessage(messages.exitButton)} + , + handler: onDelete + } + ]} + toggleButton={ + + } + /> {modal} } @@ -109,6 +126,20 @@ export function FormHeader({ label }: { label: TranslationConfig }) { + , + handler: onDelete + } + ]} + toggleButton={ + + } + /> + {modal} } mobileTitle={intl.formatMessage(messages.newVitalEventRegistration, { diff --git a/packages/commons/src/index.ts b/packages/commons/src/index.ts index e2aefcffdd7..912d47967bd 100644 --- a/packages/commons/src/index.ts +++ b/packages/commons/src/index.ts @@ -15,3 +15,4 @@ export * from './http' export * from './logger' export * from './search' export * from './events' +export * from './authentication' diff --git a/packages/documents/src/config/routes.ts b/packages/documents/src/config/routes.ts index e278d174b97..597b2a88b2f 100644 --- a/packages/documents/src/config/routes.ts +++ b/packages/documents/src/config/routes.ts @@ -20,6 +20,7 @@ import { } from '@documents/features/getDocument/handler' import { svgUploadHandler } from '@documents/features/uploadSvg/handler' import { MINIO_BUCKET } from '@documents/minio/constants' +import { deleteDocument } from '@documents/features/deleteDocument/handler' export const getRoutes = () => { const routes = [ @@ -103,6 +104,15 @@ export const getRoutes = () => { tags: ['api'] } }, + // delete a document + { + method: 'DELETE', + path: `/files/{filename}`, + handler: deleteDocument, + config: { + tags: ['api'] + } + }, // used for tests to check JWT auth { method: 'GET', diff --git a/packages/documents/src/features/deleteDocument/handler.ts b/packages/documents/src/features/deleteDocument/handler.ts new file mode 100644 index 00000000000..bf0acb7a7d2 --- /dev/null +++ b/packages/documents/src/features/deleteDocument/handler.ts @@ -0,0 +1,47 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * OpenCRVS is also distributed under the terms of the Civil Registration + * & Healthcare Disclaimer located at http://opencrvs.org/license. + * + * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. + */ + +import { minioClient } from '@documents/minio/client' +import { MINIO_BUCKET } from '@documents/minio/constants' +import * as Hapi from '@hapi/hapi' +import { getUserId } from '@opencrvs/commons' + +export async function deleteDocument( + request: Hapi.Request, + h: Hapi.ResponseToolkit +) { + const filename = request.params.filename + + const userId = getUserId(request.headers.authorization) + + if (!userId) + return Promise.reject( + new Error( + `request failed: Authorization token is missing or does not contain a valid user ID.` + ) + ) + + const stat = await minioClient.statObject( + MINIO_BUCKET, + 'event-attachments/' + filename + ) + const createdBy = stat.metaData['created-by'] + + if (createdBy !== userId) + return h + .response( + `request failed: user with id ${userId} does not have permission to delete this document` + ) + .code(403) + await minioClient.removeObject(MINIO_BUCKET, 'event-attachments/' + filename) + + return h.response().code(204) +} diff --git a/packages/documents/src/features/uploadDocument/handler.test.ts b/packages/documents/src/features/uploadDocument/handler.test.ts index 6fddd903404..a62206f8bf1 100644 --- a/packages/documents/src/features/uploadDocument/handler.test.ts +++ b/packages/documents/src/features/uploadDocument/handler.test.ts @@ -26,7 +26,8 @@ describe('verify document uploader handler', () => { { algorithm: 'RS256', issuer: 'opencrvs:auth-service', - audience: 'opencrvs:documents-user' + audience: 'opencrvs:documents-user', + subject: '123123' } ) diff --git a/packages/documents/src/features/uploadDocument/handler.ts b/packages/documents/src/features/uploadDocument/handler.ts index c9f6c3f8a8f..04584069e52 100644 --- a/packages/documents/src/features/uploadDocument/handler.ts +++ b/packages/documents/src/features/uploadDocument/handler.ts @@ -13,10 +13,11 @@ import { MINIO_BUCKET } from '@documents/minio/constants' import * as Hapi from '@hapi/hapi' import { v4 as uuid } from 'uuid' import { fromBuffer } from 'file-type' +import { getUserId, logger } from '@opencrvs/commons' + import { z } from 'zod' import { Readable } from 'stream' import { badRequest, notFound } from '@hapi/boom' -import { logger } from '@opencrvs/commons' export interface IDocumentPayload { fileData: string metaData?: Record @@ -51,6 +52,7 @@ export async function fileUploadHandler( request: Hapi.Request, h: Hapi.ResponseToolkit ) { + const userId = getUserId(request.headers.authorization) const payload = await Payload.parseAsync(request.payload).catch((error) => { logger.error(error) throw badRequest('Invalid payload') @@ -60,14 +62,17 @@ export async function fileUploadHandler( const extension = file.hapi.filename.split('.').pop() const filename = `${transactionId}.${extension}` - try { - await minioClient.putObject(MINIO_BUCKET, filename, file) - } catch (error) { - logger.error(error) - throw error - } - return filename + await minioClient.putObject( + MINIO_BUCKET, + 'event-attachments/' + filename, + file, + { + 'created-by': userId + } + ) + + return 'event-attachments/' + filename } export async function fileExistsHandler( @@ -75,7 +80,10 @@ export async function fileExistsHandler( h: Hapi.ResponseToolkit ) { const { filename } = request.params - const exists = await minioClient.statObject(MINIO_BUCKET, filename) + const exists = await minioClient.statObject( + MINIO_BUCKET, + 'event-attachments/' + filename + ) if (!exists) { return notFound('File not found') } @@ -86,6 +94,13 @@ export async function documentUploadHandler( request: Hapi.Request, h: Hapi.ResponseToolkit ) { + const userId = getUserId(request.headers.authorization) + if (!userId) + return Promise.reject( + new Error( + `request failed: Authorization token is missing or does not contain a valid user ID.` + ) + ) const payload = request.payload as IDocumentPayload const ref = uuid() try { @@ -95,8 +110,9 @@ export async function documentUploadHandler( const generateFileName = `${ref}.${fileType.ext}` await minioClient.putObject(MINIO_BUCKET, generateFileName, base64Decoded, { + ...payload.metaData, 'content-type': fileType.mime, - ...payload.metaData + 'created-by': userId }) return h diff --git a/packages/documents/src/features/uploadSvg/handler.test.ts b/packages/documents/src/features/uploadSvg/handler.test.ts index 1341c49a4b4..8dd9dd89efe 100644 --- a/packages/documents/src/features/uploadSvg/handler.test.ts +++ b/packages/documents/src/features/uploadSvg/handler.test.ts @@ -27,7 +27,8 @@ describe('verify svg uploader handler', () => { { algorithm: 'RS256', issuer: 'opencrvs:auth-service', - audience: 'opencrvs:documents-user' + audience: 'opencrvs:documents-user', + subject: '123123123' } ) beforeEach(async () => { diff --git a/packages/documents/src/features/uploadSvg/handler.ts b/packages/documents/src/features/uploadSvg/handler.ts index f74e27d013e..41a2fa63615 100644 --- a/packages/documents/src/features/uploadSvg/handler.ts +++ b/packages/documents/src/features/uploadSvg/handler.ts @@ -14,6 +14,7 @@ import * as Hapi from '@hapi/hapi' import { v4 as uuid } from 'uuid' import isSvg from 'is-svg' import { badRequest } from '@hapi/boom' +import { getUserId } from '@opencrvs/commons' export interface IDocumentPayload { fileData: string @@ -24,6 +25,13 @@ export async function svgUploadHandler( request: Hapi.Request, h: Hapi.ResponseToolkit ) { + const userId = getUserId(request.headers.authorization) + if (!userId) + return Promise.reject( + new Error( + `request failed: Authorization token is missing or does not contain a valid user ID.` + ) + ) const ref = uuid() const bufferData = request.payload as Buffer if (!isSvg(bufferData)) { @@ -32,7 +40,8 @@ export async function svgUploadHandler( const generateFileName = `${ref}.svg` try { await minioClient.putObject(MINIO_BUCKET, generateFileName, bufferData, { - 'content-type': 'image/svg+xml' + 'content-type': 'image/svg+xml', + 'created-by': userId }) return h diff --git a/packages/events/src/router/router.ts b/packages/events/src/router/router.ts index e91fba9a4f4..93042988af6 100644 --- a/packages/events/src/router/router.ts +++ b/packages/events/src/router/router.ts @@ -25,6 +25,7 @@ import { getEventConfigurations } from '@events/service/config/config' import { addAction, createEvent, + deleteEvent, EventInputWithId, getEventById, patchEvent @@ -111,6 +112,11 @@ export const appRouter = router({ const eventWithSignedFiles = await presignFilesInEvent(event, ctx.token) return eventWithSignedFiles }), + delete: publicProcedure + .input(z.object({ eventId: z.string() })) + .mutation(async ({ input, ctx }) => { + return deleteEvent(input.eventId, { token: ctx.token }) + }), actions: router({ notify: publicProcedure.input(NotifyActionInput).mutation((options) => { return addAction(options.input, { diff --git a/packages/events/src/service/events.ts b/packages/events/src/service/events.ts index 7e070523178..243a50274a0 100644 --- a/packages/events/src/service/events.ts +++ b/packages/events/src/service/events.ts @@ -19,9 +19,11 @@ import { import { getClient } from '@events/storage/mongodb' import { ActionType, getUUID } from '@opencrvs/commons' import { z } from 'zod' +import { deleteEventIndex, indexEvent } from './indexing/indexing' +import * as _ from 'lodash' +import { TRPCError } from '@trpc/server' import { getEventConfigurations } from './config/config' -import { fileExists } from './files' -import { indexEvent } from './indexing/indexing' +import { deleteFile, fileExists } from './files' export const EventInputWithId = EventInput.extend({ id: z.string() @@ -56,6 +58,63 @@ export async function getEventById(id: string) { return event } +export async function deleteEvent( + eventId: string, + { token }: { token: string } +) { + const db = await getClient() + + const collection = db.collection('events') + const event = await collection.findOne({ id: eventId }) + + if (!event) { + throw new EventNotFoundError(eventId) + } + + const hasNonDeletableActions = event.actions.some( + ({ type }) => type !== ActionType.CREATE && type !== ActionType.DRAFT + ) + if (hasNonDeletableActions) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Event has actions that cannot be deleted' + }) + } + + await deleteEventAttachments(token, event) + + const { id } = event + await collection.deleteOne({ id }) + await deleteEventIndex(id) + return { id } +} + +async function deleteEventAttachments(token: string, event: EventDocument) { + const config = await getEventConfigurations(token) + + const form = config + .find((config) => config.id === event.type) + ?.actions.find((action) => action.type === event.type) + ?.forms.find((form) => form.active) + + const fieldTypes = form?.pages.flatMap((page) => page.fields) + + for (const action of event.actions) { + for (const [key, value] of Object.entries(action.data)) { + const isFile = + fieldTypes?.find((field) => field.id === key)?.type === 'FILE' + + const fileValue = FileFieldValue.safeParse(value) + + if (!isFile || !fileValue.success) { + continue + } + + await deleteFile(fileValue.data.filename, token) + } + } +} + export async function createEvent({ eventInput, createdAtLocation, @@ -139,8 +198,8 @@ export async function addAction( continue } - if (!(await fileExists(fileValue.data!.filename, token))) { - throw new Error(`File not found: ${value}`) + if (!(await fileExists(fileValue.data.filename, token))) { + throw new Error(`File not found: ${fileValue.data.filename}`) } } diff --git a/packages/events/src/service/files/index.ts b/packages/events/src/service/files/index.ts index 6cc589541a5..35f87a7b379 100644 --- a/packages/events/src/service/files/index.ts +++ b/packages/events/src/service/files/index.ts @@ -117,6 +117,16 @@ export async function presignFilesInEvent(event: EventDocument, token: string) { } } +export async function deleteFile(filename: string, token: string) { + const res = await fetch(new URL(`/files/${filename}`, env.DOCUMENTS_URL), { + method: 'DELETE', + headers: { + Authorization: `Bearer ${token}` + } + }) + + return res.ok +} export async function fileExists(filename: string, token: string) { const res = await fetch(new URL(`/files/${filename}`, env.DOCUMENTS_URL), { method: 'HEAD', diff --git a/packages/events/src/service/indexing/indexing.ts b/packages/events/src/service/indexing/indexing.ts index f4540d08d1c..ef8b733f23c 100644 --- a/packages/events/src/service/indexing/indexing.ts +++ b/packages/events/src/service/indexing/indexing.ts @@ -98,6 +98,18 @@ export async function indexEvent(event: EventDocument) { }) } +export async function deleteEventIndex(eventId: string) { + const esClient = getOrCreateClient() + + const response = await esClient.delete({ + index: EVENTS_INDEX, + id: eventId, + refresh: 'wait_for' + }) + + return response +} + export async function getIndexedEvents() { const esClient = getOrCreateClient() diff --git a/packages/gateway/src/config/routes.ts b/packages/gateway/src/config/routes.ts index 59d8e74762e..8f95c8fe442 100644 --- a/packages/gateway/src/config/routes.ts +++ b/packages/gateway/src/config/routes.ts @@ -82,11 +82,6 @@ export const getRoutes = () => { method: 'POST', path: '/upload', handler: async (req, h) => { - if (process.env.NODE_ENV !== 'production') { - await new Promise((resolve) => - setTimeout(resolve, Math.random() * 30000) - ) - } return h.proxy({ uri: `${DOCUMENTS_URL}/files`, passThrough: true @@ -99,7 +94,22 @@ export const getRoutes = () => { } } }, - + { + method: 'DELETE', + path: '/files/{filename}', + handler: async (req, h) => { + return h.proxy({ + uri: `${DOCUMENTS_URL}/files/${req.params.filename}`, + passThrough: true + }) + }, + options: { + payload: { + output: 'data', + parse: false + } + } + }, catchAllProxy.locations, catchAllProxy.locationsSuffix, From 22816d57556e1e71c83aeeefc4cac196da820d81 Mon Sep 17 00:00:00 2001 From: Jamil Date: Tue, 7 Jan 2025 10:12:45 +0600 Subject: [PATCH 14/15] feat: implement `getInitialValue` and `fieldValueToString` (#8255) Co-authored-by: Riku Rouvila --- packages/client/src/src-sw.ts | 3 + .../components/forms/FormFieldGenerator.tsx | 6 +- .../inputs/FileInput/DocumentPreview.tsx | 2 +- .../src/v2-events/components/forms/utils.ts | 46 +++++ .../src/v2-events/features/debug/debug.tsx | 13 +- .../features/events/actions/declare/Pages.tsx | 20 ++- .../events/actions/declare/Review.tsx | 2 +- .../events/actions/register/Pages.tsx | 21 ++- .../events/actions/register/Review.tsx | 2 +- .../features/events/components/Pages.tsx | 3 +- .../features/events/components/Review.tsx | 7 +- .../events/registered-fields/DateField.tsx | 48 ++--- .../events/registered-fields/Paragraph.tsx | 10 +- .../events/registered-fields/RadioGroup.tsx | 24 +-- .../events/registered-fields/TextField.tsx | 38 +--- .../features/events/useEventConfiguration.ts | 15 +- .../features/events/useEventFormData.ts | 9 +- .../events/useEvents/procedures/action.ts | 70 +++++--- .../events/useEvents/procedures/create.ts | 31 +--- .../events/useEvents/procedures/delete.ts | 13 +- .../events/useEvents/procedures/persist.ts | 101 ----------- .../events/useEvents/useEvents.test.tsx | 113 ++---------- .../features/events/useEvents/useEvents.ts | 103 ++--------- .../EventOverview/EventOverview.tsx | 27 ++- .../EventOverview/components/ActionMenu.tsx | 4 +- .../EventOverview/components/EventSummary.tsx | 12 +- .../features/workqueues/Workqueue.tsx | 2 +- .../src/v2-events/hooks/useTransformer.ts | 33 ++++ .../src/v2-events/layouts/form/index.tsx | 2 +- .../src/v2-events/messages/constants.ts | 164 +----------------- packages/client/src/v2-events/trpc.tsx | 4 +- packages/commons/src/events/FieldConfig.ts | 1 + packages/commons/src/events/FieldValue.ts | 28 ++- packages/commons/src/events/defineConfig.ts | 5 +- packages/commons/src/events/utils.ts | 10 +- packages/events/src/service/events.ts | 7 +- packages/events/src/service/files/index.ts | 7 +- yarn.lock | 25 +-- 38 files changed, 330 insertions(+), 701 deletions(-) delete mode 100644 packages/client/src/v2-events/features/events/useEvents/procedures/persist.ts create mode 100644 packages/client/src/v2-events/hooks/useTransformer.ts diff --git a/packages/client/src/src-sw.ts b/packages/client/src/src-sw.ts index e30e80f4dec..fbb1bbc6201 100644 --- a/packages/client/src/src-sw.ts +++ b/packages/client/src/src-sw.ts @@ -18,6 +18,9 @@ import { registerRoute, NavigationRoute } from 'workbox-routing' import { NetworkFirst, CacheFirst } from 'workbox-strategies' import { clientsClaim } from 'workbox-core' +// eslint-disable-next-line @typescript-eslint/no-use-before-define +self.__WB_DISABLE_DEV_LOGS = true + declare let self: ServiceWorkerGlobalScope self.addEventListener('install', (event) => { diff --git a/packages/client/src/v2-events/components/forms/FormFieldGenerator.tsx b/packages/client/src/v2-events/components/forms/FormFieldGenerator.tsx index 8ba8ad867ae..a7710e49002 100644 --- a/packages/client/src/v2-events/components/forms/FormFieldGenerator.tsx +++ b/packages/client/src/v2-events/components/forms/FormFieldGenerator.tsx @@ -12,7 +12,6 @@ /* eslint-disable */ import { InputField } from '@client/components/form/InputField' import { DATE, PARAGRAPH, TEXT } from '@client/forms' -import { IAdvancedSearchFormState } from '@client/search/advancedSearch/utils' import { DateField } from '@opencrvs/components/lib/DateField' import { Text } from '@opencrvs/components/lib/Text' import { TextInput } from '@opencrvs/components/lib/TextInput' @@ -28,6 +27,7 @@ import { } from './utils' import { Errors, getValidationErrorsForForm } from './validation' +import { ActionFormData } from '@opencrvs/commons' import { FieldConfig, FieldValue, @@ -49,7 +49,6 @@ import { useIntl } from 'react-intl' import { FileInput } from './inputs/FileInput/FileInput' -import { ActionFormData } from '@opencrvs/commons' const fadeIn = keyframes` from { opacity: 0; } @@ -223,7 +222,7 @@ interface ExposedProps { onSetTouched?: (func: ISetTouchedFunction) => void requiredErrorMessage?: MessageDescriptor onUploadingStateChanged?: (isUploading: boolean) => void - initialValues?: IAdvancedSearchFormState + initialValues?: ActionFormData } type AllProps = ExposedProps & IntlShapeProps & FormikProps @@ -452,6 +451,7 @@ export const FormFieldGenerator: React.FC = (props) => { return ( + enableReinitialize={true} initialValues={initialValues} validate={(values) => getValidationErrorsForForm( diff --git a/packages/client/src/v2-events/components/forms/inputs/FileInput/DocumentPreview.tsx b/packages/client/src/v2-events/components/forms/inputs/FileInput/DocumentPreview.tsx index 9025e4d47a0..f9a0b826945 100644 --- a/packages/client/src/v2-events/components/forms/inputs/FileInput/DocumentPreview.tsx +++ b/packages/client/src/v2-events/components/forms/inputs/FileInput/DocumentPreview.tsx @@ -49,7 +49,7 @@ const ViewerContainer = styled.div` ` interface IProps { - previewImage: FileFieldValue + previewImage: Exclude disableDelete?: boolean title?: string goBack: () => void diff --git a/packages/client/src/v2-events/components/forms/utils.ts b/packages/client/src/v2-events/components/forms/utils.ts index 265ed82d572..5481e588b2c 100644 --- a/packages/client/src/v2-events/components/forms/utils.ts +++ b/packages/client/src/v2-events/components/forms/utils.ts @@ -13,10 +13,23 @@ import { BaseField, ConditionalParameters, FieldConfig, + FieldType, FieldValue, + FieldTypeToFieldValue, validate } from '@opencrvs/commons/client' + import { DependencyInfo } from '@client/forms' +import { + dateToString, + INITIAL_DATE_VALUE, + INITIAL_PARAGRAPH_VALUE, + INITIAL_RADIO_GROUP_VALUE, + INITIAL_TEXT_VALUE, + paragraphToString, + radioGroupToString, + textToString +} from '@client/v2-events/features/events/registered-fields' export function handleInitialValue( field: FieldConfig, @@ -77,3 +90,36 @@ export function getDependentFields( return field.initialValue.dependsOn.includes(fieldName) }) } + +const initialValueMapping: Record = { + [FieldType.TEXT]: INITIAL_TEXT_VALUE, + [FieldType.DATE]: INITIAL_DATE_VALUE, + [FieldType.RADIO_GROUP]: INITIAL_RADIO_GROUP_VALUE, + [FieldType.PARAGRAPH]: INITIAL_PARAGRAPH_VALUE, + [FieldType.FILE]: null, + [FieldType.HIDDEN]: null +} + +export function getInitialValues(fields: FieldConfig[]) { + return fields.reduce((initialValues, field) => { + return { ...initialValues, [field.id]: initialValueMapping[field.type] } + }, {}) +} + +export function fieldValueToString( + field: T, + value: FieldTypeToFieldValue +) { + switch (field) { + case FieldType.DATE: + return dateToString(value as FieldTypeToFieldValue<'DATE'>) + case FieldType.TEXT: + return textToString(value as FieldTypeToFieldValue<'TEXT'>) + case FieldType.PARAGRAPH: + return paragraphToString(value as FieldTypeToFieldValue<'PARAGRAPH'>) + case FieldType.RADIO_GROUP: + return radioGroupToString(value as FieldTypeToFieldValue<'RADIO_GROUP'>) + default: + return '' + } +} diff --git a/packages/client/src/v2-events/features/debug/debug.tsx b/packages/client/src/v2-events/features/debug/debug.tsx index 42954c06307..12247a9fa6a 100644 --- a/packages/client/src/v2-events/features/debug/debug.tsx +++ b/packages/client/src/v2-events/features/debug/debug.tsx @@ -60,7 +60,7 @@ export function Debug() { } const mutations = queryClient.getMutationCache().getAll() - const storedEvents = events.events + return ( <> @@ -94,17 +94,6 @@ export function Debug() { Clear React Query buffer -

  • - {/* eslint-disable-next-line no-console */} - -
  • -
  • - - Events in offline storage: {storedEvents.data.length} - -
  • diff --git a/packages/client/src/v2-events/features/events/actions/declare/Pages.tsx b/packages/client/src/v2-events/features/events/actions/declare/Pages.tsx index c4a60dd88b5..2bc020692ab 100644 --- a/packages/client/src/v2-events/features/events/actions/declare/Pages.tsx +++ b/packages/client/src/v2-events/features/events/actions/declare/Pages.tsx @@ -15,12 +15,13 @@ import { useTypedParams, useTypedSearchParams } from 'react-router-typesafe-routes/dom' -import { ActionType } from '@opencrvs/commons/client' +import { ActionType, getCurrentEventState } from '@opencrvs/commons/client' +import { useEvents } from '@client/v2-events//features/events/useEvents/useEvents' +import { Pages as PagesComponent } from '@client/v2-events/features/events/components/Pages' import { useEventConfiguration } from '@client/v2-events/features/events/useEventConfiguration' import { useEventFormNavigation } from '@client/v2-events/features/events/useEventFormNavigation' -import { useEvents } from '@client/v2-events//features/events/useEvents/useEvents' import { ROUTES } from '@client/v2-events/routes' -import { Pages as PagesComponent } from '@client/v2-events/features/events/components/Pages' +import { useEventFormData } from '@client/v2-events/features/events/useEventFormData' export function Pages() { const { eventId, pageId } = useTypedParams(ROUTES.V2.EVENTS.DECLARE.PAGES) @@ -29,12 +30,21 @@ export function Pages() { const events = useEvents() const { modal } = useEventFormNavigation() - const [event] = events.getEvent(eventId) + const formEventId = useEventFormData((state) => state.eventId) + const setFormValues = useEventFormData((state) => state.setFormValues) + const [event] = events.getEvent.useSuspenseQuery(eventId) + const currentState = getCurrentEventState(event) + + useEffect(() => { + if (formEventId !== event.id) { + setFormValues(event.id, currentState.data) + } + }, [currentState.data, event.id, formEventId, setFormValues]) const { eventConfiguration: configuration } = useEventConfiguration( event.type ) - const formPages = configuration?.actions + const formPages = configuration.actions .find((action) => action.type === ActionType.DECLARE) ?.forms.find((form) => form.active)?.pages diff --git a/packages/client/src/v2-events/features/events/actions/declare/Review.tsx b/packages/client/src/v2-events/features/events/actions/declare/Review.tsx index 68631d36c5e..30e3cc6bbda 100644 --- a/packages/client/src/v2-events/features/events/actions/declare/Review.tsx +++ b/packages/client/src/v2-events/features/events/actions/declare/Review.tsx @@ -107,7 +107,7 @@ export function Review() { const { goToHome } = useEventFormNavigation() const declareMutation = events.actions.declare - const [event] = events.getEvent(eventId) + const [event] = events.getEvent.useSuspenseQuery(eventId) const { eventConfiguration: config } = useEventConfiguration(event.type) diff --git a/packages/client/src/v2-events/features/events/actions/register/Pages.tsx b/packages/client/src/v2-events/features/events/actions/register/Pages.tsx index 6254b88b334..6be69580296 100644 --- a/packages/client/src/v2-events/features/events/actions/register/Pages.tsx +++ b/packages/client/src/v2-events/features/events/actions/register/Pages.tsx @@ -15,27 +15,36 @@ import { useTypedParams, useTypedSearchParams } from 'react-router-typesafe-routes/dom' -import { current } from '@reduxjs/toolkit' -import { ActionType } from '@opencrvs/commons/client' +import { ActionType, getCurrentEventState } from '@opencrvs/commons/client' +import { useEvents } from '@client/v2-events//features/events/useEvents/useEvents' +import { Pages as PagesComponent } from '@client/v2-events/features/events/components/Pages' import { useEventConfiguration } from '@client/v2-events/features/events/useEventConfiguration' import { useEventFormNavigation } from '@client/v2-events/features/events/useEventFormNavigation' -import { useEvents } from '@client/v2-events//features/events/useEvents/useEvents' import { ROUTES } from '@client/v2-events/routes' -import { Pages as PagesComponent } from '@client/v2-events/features/events/components/Pages' +import { useEventFormData } from '@client/v2-events/features/events/useEventFormData' export function Pages() { const { eventId, pageId } = useTypedParams(ROUTES.V2.EVENTS.REGISTER.PAGES) const [searchParams] = useTypedSearchParams(ROUTES.V2.EVENTS.REGISTER.PAGES) + const setFormValues = useEventFormData((state) => state.setFormValues) + const formEventId = useEventFormData((state) => state.eventId) const navigate = useNavigate() const events = useEvents() const { modal } = useEventFormNavigation() - const [event] = events.getEvent(eventId) + const [event] = events.getEvent.useSuspenseQuery(eventId) + const currentState = getCurrentEventState(event) + + useEffect(() => { + if (formEventId !== event.id) { + setFormValues(event.id, currentState.data) + } + }, [currentState.data, event.id, formEventId, setFormValues]) const { eventConfiguration: configuration } = useEventConfiguration( event.type ) - const formPages = configuration?.actions + const formPages = configuration.actions .find((action) => action.type === ActionType.REGISTER) ?.forms.find((form) => form.active)?.pages diff --git a/packages/client/src/v2-events/features/events/actions/register/Review.tsx b/packages/client/src/v2-events/features/events/actions/register/Review.tsx index 23d5cb38e78..2616e1578d1 100644 --- a/packages/client/src/v2-events/features/events/actions/register/Review.tsx +++ b/packages/client/src/v2-events/features/events/actions/register/Review.tsx @@ -54,7 +54,7 @@ export function Review() { const { goToHome } = useEventFormNavigation() const registerMutation = events.actions.register - const [event] = events.getEvent(eventId) + const [event] = events.getEvent.useSuspenseQuery(eventId) const { eventConfiguration: config } = useEventConfiguration(event.type) diff --git a/packages/client/src/v2-events/features/events/components/Pages.tsx b/packages/client/src/v2-events/features/events/components/Pages.tsx index a1b85f26443..ee5abeb14c0 100644 --- a/packages/client/src/v2-events/features/events/components/Pages.tsx +++ b/packages/client/src/v2-events/features/events/components/Pages.tsx @@ -16,6 +16,7 @@ import { FormPage } from '@opencrvs/commons' import { FormFieldGenerator } from '@client/v2-events/components/forms/FormFieldGenerator' import { usePagination } from '@client/v2-events/hooks/usePagination' import { useEventFormData } from '@client/v2-events/features/events/useEventFormData' +import { getInitialValues } from '@client/v2-events/components/forms/utils' /** * @@ -39,7 +40,6 @@ export function Pages({ const intl = useIntl() const getFormValues = useEventFormData((state) => state.getFormValues) - const formValues = getFormValues(eventId) const setFormValues = useEventFormData((state) => state.setFormValues) const pageIdx = formPages.findIndex((p) => p.id === pageId) @@ -51,6 +51,7 @@ export function Pages({ total } = usePagination(formPages.length, Math.max(pageIdx, 0)) const page = formPages[currentPage] + const formValues = getFormValues(eventId, getInitialValues(page.fields)) useEffect(() => { const pageChanged = formPages[currentPage].id !== pageId diff --git a/packages/client/src/v2-events/features/events/components/Review.tsx b/packages/client/src/v2-events/features/events/components/Review.tsx index 2d0471d31a5..d16ab5f4b14 100644 --- a/packages/client/src/v2-events/features/events/components/Review.tsx +++ b/packages/client/src/v2-events/features/events/components/Review.tsx @@ -256,10 +256,13 @@ function ReviewComponent({ const Output = FIELD_TYPE_FORMATTERS[field.type] || DefaultOutput - const hasValue = form[field.id] !== undefined + const value = form[field.id] + const hasValue = + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + value !== null && value !== undefined const valueDisplay = hasValue ? ( - + ) : ( '' ) diff --git a/packages/client/src/v2-events/features/events/registered-fields/DateField.tsx b/packages/client/src/v2-events/features/events/registered-fields/DateField.tsx index 9375d6bffb1..b738ac6c184 100644 --- a/packages/client/src/v2-events/features/events/registered-fields/DateField.tsx +++ b/packages/client/src/v2-events/features/events/registered-fields/DateField.tsx @@ -8,44 +8,16 @@ * * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. */ -import React from 'react' -import { useIntl } from 'react-intl' -import { useController } from 'react-hook-form' -import { - InputField, - DateField as DateFieldComponent -} from '@opencrvs/components' -import { FieldProps } from '@opencrvs/commons' +import { DateFieldValue } from '@opencrvs/commons/client' -export function DateField({ - id, - label, - options = {}, - required -}: FieldProps<'DATE'>) { - const intl = useIntl() - const { - field, - fieldState: { isTouched, error } - } = useController({ - name: id, - rules: { required: '[not i18n yet..]: This field is required' } - }) +export const INITIAL_DATE_VALUE = null - return ( - - field.onChange(val)} - /> - - ) +export const dateToString = (date: DateFieldValue) => { + if (!date) { + return '' + } + if (typeof date === 'string') { + return date + } + return (date as Date).toISOString().split('T')[0] } diff --git a/packages/client/src/v2-events/features/events/registered-fields/Paragraph.tsx b/packages/client/src/v2-events/features/events/registered-fields/Paragraph.tsx index 24e0c5509e2..59a19737399 100644 --- a/packages/client/src/v2-events/features/events/registered-fields/Paragraph.tsx +++ b/packages/client/src/v2-events/features/events/registered-fields/Paragraph.tsx @@ -8,12 +8,8 @@ * * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. */ -import React from 'react' -import { useIntl } from 'react-intl' -import { FieldProps } from '@opencrvs/commons' +import { ParagraphFieldValue } from '@opencrvs/commons/client' -export function Paragraph({ label }: FieldProps<'PARAGRAPH'>) { - const intl = useIntl() +export const INITIAL_PARAGRAPH_VALUE = '' - return

    {intl.formatMessage(label)}

    -} +export const paragraphToString = (text: ParagraphFieldValue) => text || '' diff --git a/packages/client/src/v2-events/features/events/registered-fields/RadioGroup.tsx b/packages/client/src/v2-events/features/events/registered-fields/RadioGroup.tsx index 2572d71294f..4852a7cf1dd 100644 --- a/packages/client/src/v2-events/features/events/registered-fields/RadioGroup.tsx +++ b/packages/client/src/v2-events/features/events/registered-fields/RadioGroup.tsx @@ -8,26 +8,8 @@ * * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. */ -import React from 'react' -import { - InputField, - useFormContext, - RadioGroup as RadioGroupComponent -} from '@opencrvs/components' -import { FieldProps } from '@opencrvs/commons' +import { RadioGroupFieldValue } from '@opencrvs/commons/client' -export function RadioGroup({ id, options }: FieldProps<'RADIO_GROUP'>) { - const { setValue, watch } = useFormContext() - const value = watch(id) +export const INITIAL_RADIO_GROUP_VALUE = '' - return ( - - setValue(id, val)} - /> - - ) -} +export const radioGroupToString = (value: RadioGroupFieldValue) => value || '' diff --git a/packages/client/src/v2-events/features/events/registered-fields/TextField.tsx b/packages/client/src/v2-events/features/events/registered-fields/TextField.tsx index 0730f7c5c5e..c92610ee1fe 100644 --- a/packages/client/src/v2-events/features/events/registered-fields/TextField.tsx +++ b/packages/client/src/v2-events/features/events/registered-fields/TextField.tsx @@ -8,40 +8,8 @@ * * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. */ -import React from 'react' -import { useIntl } from 'react-intl' -import { - get, - InputField, - TextInput, - useFormContext -} from '@opencrvs/components' -import { FieldProps } from '@opencrvs/commons' +import { TextFieldValue } from '@opencrvs/commons/client' -export function TextField({ id, label, required }: FieldProps<'TEXT'>) { - const intl = useIntl() - const { - register, - formState: { errors, touchedFields } - } = useFormContext() - const error = get(errors, id)?.message - const touched = get(touchedFields, id) +export const INITIAL_TEXT_VALUE = '' - return ( - - - - ) -} +export const textToString = (text: TextFieldValue) => text || '' diff --git a/packages/client/src/v2-events/features/events/useEventConfiguration.ts b/packages/client/src/v2-events/features/events/useEventConfiguration.ts index 5718cf23fd4..9f6fe018a73 100644 --- a/packages/client/src/v2-events/features/events/useEventConfiguration.ts +++ b/packages/client/src/v2-events/features/events/useEventConfiguration.ts @@ -8,6 +8,7 @@ * * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. */ +import { EventConfig } from '@opencrvs/commons/client' import { api } from '@client/v2-events/trpc' /** @@ -19,16 +20,26 @@ export function useEventConfigurations() { return config } +export function getAllFields(configuration: EventConfig) { + return configuration.actions + .flatMap((action) => action.forms.filter((form) => form.active)) + .flatMap((form) => form.pages.flatMap((page) => page.fields)) +} + /** * Fetches configured events and finds a matching event * @param eventIdentifier e.g. 'birth', 'death', 'marriage' or any configured event * @returns event configuration */ -export function useEventConfiguration(eventIdentifier: string) { +export function useEventConfiguration(eventIdentifier: string): { + eventConfiguration: EventConfig +} { const [config] = api.config.get.useSuspenseQuery() const eventConfiguration = config.find( (event) => event.id === eventIdentifier ) - + if (!eventConfiguration) { + throw new Error('Event configuration not found') + } return { eventConfiguration } } diff --git a/packages/client/src/v2-events/features/events/useEventFormData.ts b/packages/client/src/v2-events/features/events/useEventFormData.ts index ca60088ba79..7badd9ef363 100644 --- a/packages/client/src/v2-events/features/events/useEventFormData.ts +++ b/packages/client/src/v2-events/features/events/useEventFormData.ts @@ -17,7 +17,10 @@ import { storage } from '@client/storage' interface EventFormData { formValues: ActionFormData setFormValues: (eventId: string, data: ActionFormData) => void - getFormValues: (eventId: string) => ActionFormData + getFormValues: ( + eventId: string, + initialValues?: ActionFormData + ) => ActionFormData getTouchedFields: () => Record clear: () => void eventId: string @@ -34,8 +37,8 @@ export const useEventFormData = create()( (set, get) => ({ formValues: {}, eventId: '', - getFormValues: (eventId: string) => - get().eventId === eventId ? get().formValues : {}, + getFormValues: (eventId: string, initialValues?: ActionFormData) => + get().eventId === eventId ? get().formValues : initialValues ?? {}, setFormValues: (eventId: string, data: ActionFormData) => { const formValues = removeUndefinedKeys(data) return set(() => ({ eventId, formValues })) diff --git a/packages/client/src/v2-events/features/events/useEvents/procedures/action.ts b/packages/client/src/v2-events/features/events/useEvents/procedures/action.ts index 9484b4e2454..f2caf2e3285 100644 --- a/packages/client/src/v2-events/features/events/useEvents/procedures/action.ts +++ b/packages/client/src/v2-events/features/events/useEvents/procedures/action.ts @@ -11,23 +11,24 @@ import { useMutation } from '@tanstack/react-query' import { getMutationKey } from '@trpc/react-query' +import { ActionFormData } from '@opencrvs/commons' +import { EventDocument, getCurrentEventState } from '@opencrvs/commons/client' import { api, utils } from '@client/v2-events/trpc' -import { - getEvent, - getEvents, - invalidateQueries, - persistEvents -} from './persist' + +async function updateLocalEvent(updatedEvent: EventDocument) { + utils.event.get.setData(updatedEvent.id, updatedEvent) + return utils.events.get.invalidate() +} function waitUntilEventIsCreated( canonicalMutationFn: (params: T) => Promise ): (params: T) => Promise { return async (params) => { const { eventId } = params - const events = getEvents() - const event = getEvent(events, eventId) - if (!event || event.id === event.transactionId) { + const localVersion = utils.event.get.getData(eventId) + + if (!localVersion || localVersion.id === localVersion.transactionId) { console.error( 'Event that has not been stored yet cannot be actioned upon' ) @@ -36,7 +37,7 @@ function waitUntilEventIsCreated( ) } - return canonicalMutationFn({ ...params, eventId: event.id }) + return canonicalMutationFn({ ...params, eventId: localVersion.id }) } } @@ -52,36 +53,63 @@ type Procedure = | typeof utils.event.actions.notify | typeof utils.event.actions.register +function updateEventOptimistically< + T extends { eventId: string; data: ActionFormData } +>(actionType: 'DECLARE' | 'DRAFT') { + return (variables: T) => { + const localEvent = utils.event.get.getData(variables.eventId) + if (!localEvent) { + return + } + const optimisticEvent: EventDocument = { + ...localEvent, + actions: [ + ...localEvent.actions, + { + type: actionType, + data: variables.data, + createdAt: new Date().toISOString(), + createdBy: '@todo', + createdAtLocation: '@todo' + } + ] + } + utils.events.get.setData(undefined, (eventIndices) => + eventIndices + ?.filter((ei) => ei.id !== optimisticEvent.id) + .concat(getCurrentEventState(optimisticEvent)) + ) + } +} + utils.event.actions.declare.setMutationDefaults(({ canonicalMutationFn }) => ({ retry: true, retryDelay: 10000, - mutationFn: waitUntilEventIsCreated(canonicalMutationFn) + mutationFn: waitUntilEventIsCreated(canonicalMutationFn), + onSuccess: updateLocalEvent, + onMutate: updateEventOptimistically('DECLARE') })) utils.event.actions.draft.setMutationDefaults(({ canonicalMutationFn }) => ({ retry: true, retryDelay: 10000, mutationFn: waitUntilEventIsCreated(canonicalMutationFn), - onSuccess: async (updatedEvent) => { - persistEvents((events) => - events.map((event) => - event.id === updatedEvent.id ? updatedEvent : event - ) - ) - return invalidateQueries() - } + onMutate: updateEventOptimistically('DRAFT'), + onSuccess: updateLocalEvent })) utils.event.actions.register.setMutationDefaults(({ canonicalMutationFn }) => ({ retry: true, retryDelay: 10000, - mutationFn: waitUntilEventIsCreated(canonicalMutationFn) + mutationFn: waitUntilEventIsCreated(canonicalMutationFn), + onSuccess: updateLocalEvent })) utils.event.actions.notify.setMutationDefaults(({ canonicalMutationFn }) => ({ retry: true, retryDelay: 10000, - mutationFn: waitUntilEventIsCreated(canonicalMutationFn) + mutationFn: waitUntilEventIsCreated(canonicalMutationFn), + onSuccess: updateLocalEvent })) export function useEventAction

    ( diff --git a/packages/client/src/v2-events/features/events/useEvents/procedures/create.ts b/packages/client/src/v2-events/features/events/useEvents/procedures/create.ts index 16ccdb42417..a4a899952ae 100644 --- a/packages/client/src/v2-events/features/events/useEvents/procedures/create.ts +++ b/packages/client/src/v2-events/features/events/useEvents/procedures/create.ts @@ -9,10 +9,8 @@ * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. */ -import { getQueryKey } from '@trpc/react-query' -import { CreatedAction, EventDocument } from '@opencrvs/commons/client' -import { api, queryClient, utils } from '@client/v2-events/trpc' -import { invalidateQueries, persistEvents } from './persist' +import { CreatedAction, getCurrentEventState } from '@opencrvs/commons/client' +import { api, utils } from '@client/v2-events/trpc' utils.event.create.setMutationDefaults(({ canonicalMutationFn }) => ({ mutationFn: canonicalMutationFn, @@ -35,27 +33,16 @@ utils.event.create.setMutationDefaults(({ canonicalMutationFn }) => ({ ] } - // Do this as very first synchronous operation so UI can trust - // that the event is created when changing view for instance - persistEvents((events: EventDocument[]) => { - return [...events, optimisticEvent] - }) - + utils.event.get.setData(newEvent.transactionId, optimisticEvent) + utils.events.get.setData(undefined, (eventIndices) => + eventIndices?.concat(getCurrentEventState(optimisticEvent)) + ) return optimisticEvent }, onSuccess: async (response) => { - await invalidateQueries() - - persistEvents((state: EventDocument[]) => { - return [ - ...state.filter((e) => e.transactionId !== response.transactionId), - response - ] - }) - - await queryClient.cancelQueries({ - queryKey: getQueryKey(api.event.get, response.transactionId, 'query') - }) + utils.event.get.setData(response.id, response) + utils.event.get.setData(response.transactionId, response) + await utils.events.get.invalidate() } })) diff --git a/packages/client/src/v2-events/features/events/useEvents/procedures/delete.ts b/packages/client/src/v2-events/features/events/useEvents/procedures/delete.ts index c95d6833c5c..5be0e0ae4a4 100644 --- a/packages/client/src/v2-events/features/events/useEvents/procedures/delete.ts +++ b/packages/client/src/v2-events/features/events/useEvents/procedures/delete.ts @@ -11,23 +11,18 @@ import { useMutation } from '@tanstack/react-query' import { getMutationKey } from '@trpc/react-query' -import { EventDocument } from '@opencrvs/commons/client' import { api, utils } from '@client/v2-events/trpc' -import { getCanonicalEventId, getEvents, persistEvents } from './persist' function waitUntilEventIsCreated( canonicalMutationFn: (params: { eventId: string }) => Promise ): (params: { eventId: string }) => Promise { return async ({ eventId }) => { - const events = getEvents() - - const id = getCanonicalEventId(events, eventId) - - if (!id || id === eventId) { + const localVersion = utils.event.get.getData(eventId) + if (!localVersion || localVersion.id === localVersion.transactionId) { throw new Error('Event that has not been stored yet cannot be deleted') } - return canonicalMutationFn({ eventId: id }) + return canonicalMutationFn({ eventId: localVersion.id }) } } @@ -35,7 +30,7 @@ utils.event.delete.setMutationDefaults(({ canonicalMutationFn }) => ({ retry: true, retryDelay: 10000, onSuccess: ({ id }) => { - persistEvents((old: EventDocument[]) => old.filter((e) => e.id !== id)) + void utils.events.get.invalidate() }, /* * This ensures that when the application is reloaded with pending mutations in IndexedDB, the diff --git a/packages/client/src/v2-events/features/events/useEvents/procedures/persist.ts b/packages/client/src/v2-events/features/events/useEvents/procedures/persist.ts deleted file mode 100644 index 97e74d4b802..00000000000 --- a/packages/client/src/v2-events/features/events/useEvents/procedures/persist.ts +++ /dev/null @@ -1,101 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - * - * OpenCRVS is also distributed under the terms of the Civil Registration - * & Healthcare Disclaimer located at http://opencrvs.org/license. - * - * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. - */ - -import { QueryObserver, useSuspenseQuery } from '@tanstack/react-query' -import { EventDocument } from '@opencrvs/commons' -import { queryClient } from '@client/v2-events/trpc' -import { storage } from '@client/storage' -/* - * Local event storage - */ -const EVENTS_PERSISTENT_STORE_STORAGE_KEY = ['persisted-events'] - -queryClient.setQueryDefaults(EVENTS_PERSISTENT_STORE_STORAGE_KEY, { - queryFn: readEventsFromStorage -}) - -async function readEventsFromStorage() { - const data = await storage - .getItem('events') - .then((e) => e || []) - - return data -} - -async function writeEventsToStorage(events: EventDocument[]) { - return storage.setItem('events', events) -} - -export function persistEvents( - updater: (events: EventDocument[]) => EventDocument[] -) { - queryClient.setQueryData(EVENTS_PERSISTENT_STORE_STORAGE_KEY, updater) -} - -export function getCanonicalEventId( - events: EventDocument[], - eventIdOrTransactionId: string -) { - return getEvent(events, eventIdOrTransactionId)?.id -} - -export function getEvent( - events: EventDocument[], - eventIdOrTransactionId: string -) { - const event = events.find( - (e) => - e.id === eventIdOrTransactionId || - e.transactionId === eventIdOrTransactionId - ) - - return event -} - -export function getEvents() { - const events = queryClient.getQueryData( - EVENTS_PERSISTENT_STORE_STORAGE_KEY - ) - if (!events) { - throw new Error( - 'No events found in EVENTS_PERSISTENT_STORE_STORAGE_KEY query. This should never happen' - ) - } - return events -} - -export function createObserver() { - return new QueryObserver(queryClient, { - queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY - }) -} - -createObserver().subscribe((observerEvent) => { - /* - * Persist events to browser storage - */ - if (!observerEvent.data) { - return - } - void writeEventsToStorage(observerEvent.data) -}) - -export function useEventsSuspenseQuery() { - return useSuspenseQuery({ - queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY - }) -} - -export async function invalidateQueries() { - return queryClient.invalidateQueries({ - queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY - }) -} diff --git a/packages/client/src/v2-events/features/events/useEvents/useEvents.test.tsx b/packages/client/src/v2-events/features/events/useEvents/useEvents.test.tsx index 4cf1a474f44..abeb3c28496 100644 --- a/packages/client/src/v2-events/features/events/useEvents/useEvents.test.tsx +++ b/packages/client/src/v2-events/features/events/useEvents/useEvents.test.tsx @@ -103,15 +103,16 @@ interface TestContext { > } +function wrapper({ children }: PropsWithChildren) { + return {children} +} + beforeEach(async (testContext) => { queryClient.clear() serverSpy.mockClear() await storage.removeItem('events') await storage.removeItem('reactQuery') - function wrapper({ children }: PropsWithChildren) { - return {children} - } const eventsHook = renderHook(() => useEvents(), { wrapper }) await waitFor(() => expect(eventsHook.result.current).not.toBeNull(), { timeout: 3000 @@ -135,7 +136,6 @@ beforeEach(async (testContext) => { describe('events that have unsynced actions', () => { test('creating a record first stores it locally with a temporary id', async ({ - eventsHook, createEventHook }) => { server.use(http.post('/api/events/event.create', errorHandler)) @@ -144,19 +144,18 @@ describe('events that have unsynced actions', () => { transactionId: '_TEST_TRANSACTION_' }) + const getHook = renderHook( + () => useEvents().getEvent.useQuery('_TEST_TRANSACTION_'), + { wrapper } + ) + // Expect data store now to contain one event await waitFor(() => { - expect(eventsHook.result.current.events.data).toHaveLength(1) + expect(getHook.result.current.data).toBeTruthy() }) - - // It should have the transactionId as id - expect(eventsHook.result.current.events.data[0].id).toBe( - eventsHook.result.current.events.data[0].transactionId - ) }) test('temporary id is replaced with the real id when the event is synced to the backend', async ({ - eventsHook, createEventHook }) => { await createEventHook.result.current.mutateAsync({ @@ -171,96 +170,14 @@ describe('events that have unsynced actions', () => { }) ) - await waitFor(() => { - expect(eventsHook.result.current.events.data).toHaveLength(1) - }) - - // But now it's real id is different from the transactionId - await waitFor(() => - expect(eventsHook.result.current.events.data[0].id).not.toBe( - eventsHook.result.current.events.data[0].transactionId - ) + const getHook = renderHook( + () => useEvents().getEvent.useQuery('_REAL_UUID_'), + { wrapper } ) - }) - test('event that has not been declared yet is interpreted as a draft', async ({ - eventsHook, - createEventHook - }) => { - await createEventHook.result.current.mutateAsync({ - type: 'TENNIS_CLUB_MEMBERSHIP', - transactionId: '_TEST_TRANSACTION_' - }) - // Wait for backend to sync - await waitFor(() => - expect(serverSpy).toHaveBeenCalledWith({ - url: 'http://localhost:3000/api/events/event.create', - method: 'POST' - }) - ) - - // Store still has one draft + // Expect data store now to contain one event await waitFor(() => { - expect(eventsHook.result.current.getDrafts()).toHaveLength(1) - }) - }) -}) - -test('events that have unsynced actions are treated as "outbox" ', async ({ - eventsHook, - declareHook, - createEventHook: createHook -}) => { - server.use(http.post('/api/events/event.create', errorHandler)) - - createHook.result.current.mutate({ - type: 'TENNIS_CLUB_MEMBERSHIP', - transactionId: '_TEST_FAILING_TRANSACTION_' - }) - - await waitFor(() => { - expect(eventsHook.result.current.events.data).toHaveLength(1) - }) - - await waitFor(() => { - expect(eventsHook.result.current.getOutbox()).toHaveLength(1) - expect(eventsHook.result.current.getOutbox()).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: '_TEST_FAILING_TRANSACTION_' }) - ]) - ) - }) - - // Start server again, clear outbox - server.resetHandlers() - - const mutationCache = queryClient.getMutationCache().getAll() - - await Promise.all( - mutationCache.map(async (mutation) => - mutation.execute(mutation.state.variables) - ) - ) - // @TODO: Check if this change was intentional or is there some inconsistency with the cache. - await waitFor(() => expect(serverSpy.mock.calls).toHaveLength(2)) - - await waitFor(() => { - expect(eventsHook.result.current.getOutbox()).toHaveLength(0) - }) - - // Create a declaration action as well - server.use(http.post('/api/events/actions.declare', errorHandler)) - act(() => { - declareHook.result.current.mutate({ - eventId: '_REAL_UUID_', - data: {}, - transactionId: '_TEST_FAILING_ACTION_TRANSACTION_' + expect(getHook.result.current.data).toBeTruthy() }) }) - await waitFor(() => { - expect(eventsHook.result.current.getOutbox()).toHaveLength(1) - expect(eventsHook.result.current.getOutbox()).toEqual( - expect.arrayContaining([expect.objectContaining({ id: '_REAL_UUID_' })]) - ) - }) }) diff --git a/packages/client/src/v2-events/features/events/useEvents/useEvents.ts b/packages/client/src/v2-events/features/events/useEvents/useEvents.ts index 58748814e11..3bf99b76c4f 100644 --- a/packages/client/src/v2-events/features/events/useEvents/useEvents.ts +++ b/packages/client/src/v2-events/features/events/useEvents/useEvents.ts @@ -11,40 +11,11 @@ import { hashKey } from '@tanstack/react-query' import { getQueryKey } from '@trpc/react-query' -import { EventDocument, getCurrentEventState } from '@opencrvs/commons/client' +import { EventIndex } from '@opencrvs/commons/client' import { api, queryClient, utils } from '@client/v2-events/trpc' import { useEventAction } from './procedures/action' import { createEvent } from './procedures/create' import { useDeleteEventMutation } from './procedures/delete' -import { createObserver, useEventsSuspenseQuery } from './procedures/persist' - -const observer = createObserver() - -observer.subscribe((observerEvent) => { - observerEvent.data?.forEach((event) => { - /* - * Update items data in "event by id" queries - */ - queryClient.setQueryData( - getQueryKey(api.event.get, event.id, 'query'), - event - ) - - queryClient.setQueryData( - getQueryKey(api.event.get, event.transactionId, 'query'), - event - ) - - /* - * Update item in workqueues - */ - utils.events.get.setData(undefined, (events) => - events?.map((ev) => - ev.id !== event.id ? ev : getCurrentEventState(event) - ) - ) - }) -}) function getPendingMutations( mutationCreator: Parameters[0] @@ -67,10 +38,10 @@ function filterOutboxEventsWithMutation< | typeof api.event.actions.declare | typeof api.event.actions.register >( - events: EventDocument[], + events: EventIndex[], mutation: T, filter: ( - event: EventDocument, + event: EventIndex, parameters: Exclude['variables'], undefined> ) => boolean ) { @@ -84,93 +55,41 @@ function filterOutboxEventsWithMutation< } export function useEvents() { - function getEvent(id: string) { - return api.event.get.useSuspenseQuery(id) - } + const eventsList = api.events.get.useQuery().data ?? [] function getDrafts() { - return storedEvents.data.filter( - (event) => !event.actions.some((a) => a.type === 'DECLARE') - ) + return eventsList.filter((event) => event.status === 'DRAFT') } function getOutbox() { - const eventFromCreateMutations = filterOutboxEventsWithMutation( - storedEvents.data, - api.event.create, - (event, parameters) => event.transactionId === parameters.transactionId - ) - const eventFromDeclareActions = filterOutboxEventsWithMutation( - storedEvents.data, + eventsList, api.event.actions.declare, (event, parameters) => { - return ( - event.id === parameters.eventId || - event.transactionId === parameters.eventId - ) + return event.id === parameters.eventId } ) const eventFromRegisterActions = filterOutboxEventsWithMutation( - storedEvents.data, + eventsList, api.event.actions.register, (event, parameters) => { - return ( - event.id === parameters.eventId || - event.transactionId === parameters.eventId - ) + return event.id === parameters.eventId } ) - const pendingActions = getPendingMutations(api.event.actions.declare).map( - (mutation) => { - const variables = mutation.state.variables as Exclude< - ReturnType['variables'], - undefined - > - return { - eventId: variables.eventId, - action: { - type: 'DECLARE' as const, - createdAt: new Date().toISOString(), - createdBy: 'offline', - createdAtLocation: 'TODO', - data: variables.data - } - } - } - ) - - return eventFromCreateMutations + return eventFromDeclareActions .concat(eventFromDeclareActions) .concat(eventFromRegisterActions) .filter( /* uniqueById */ (e, i, arr) => arr.findIndex((a) => a.id === e.id) === i ) - .map((event) => { - return { - ...event, - actions: event.actions.concat( - pendingActions - .filter( - (a) => - a.eventId === event.id || a.eventId === event.transactionId - ) - .map((a) => a.action) - ) - } - }) } - const storedEvents = useEventsSuspenseQuery() - return { createEvent, - events: storedEvents, - getEvent, - getEventById: api.event.get, + getEvent: api.event.get, getEvents: api.events.get, getDrafts, deleteEvent: useDeleteEventMutation(), diff --git a/packages/client/src/v2-events/features/workqueues/EventOverview/EventOverview.tsx b/packages/client/src/v2-events/features/workqueues/EventOverview/EventOverview.tsx index 57e73e59e3d..61eaf42789f 100644 --- a/packages/client/src/v2-events/features/workqueues/EventOverview/EventOverview.tsx +++ b/packages/client/src/v2-events/features/workqueues/EventOverview/EventOverview.tsx @@ -8,9 +8,9 @@ * * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. */ -import React from 'react' -import { useTypedParams } from 'react-router-typesafe-routes/dom' +import React, { useEffect } from 'react' import { useSelector } from 'react-redux' +import { useTypedParams } from 'react-router-typesafe-routes/dom' import { ActionDocument, EventIndex, @@ -20,13 +20,19 @@ import { Content, ContentSize } from '@opencrvs/components/lib/Content' import { IconWithName } from '@client/v2-events/components/IconWithName' import { ROUTES } from '@client/v2-events/routes' -import { useEventConfigurations } from '@client/v2-events/features/events/useEventConfiguration' +import { + getAllFields, + useEventConfiguration, + useEventConfigurations +} from '@client/v2-events/features/events/useEventConfiguration' // eslint-disable-next-line no-restricted-imports import { getUserDetails } from '@client/profile/profileSelectors' // eslint-disable-next-line no-restricted-imports import { ProfileState } from '@client/profile/profileReducer' -import { useIntlFormatMessageWithFlattenedParams } from '@client/v2-events/features/workqueues/utils' +import { getInitialValues } from '@client/v2-events/components/forms/utils' import { useEvents } from '@client/v2-events/features/events/useEvents/useEvents' +import { useIntlFormatMessageWithFlattenedParams } from '@client/v2-events/features/workqueues/utils' +import { utils } from '@client/v2-events/trpc' import { EventHistory } from './components/EventHistory' import { EventSummary } from './components/EventSummary' @@ -38,14 +44,15 @@ import { ActionMenu } from './components/ActionMenu' export function EventOverviewIndex() { const params = useTypedParams(ROUTES.V2.EVENTS.OVERVIEW) - const { getEvents, getEventById } = useEvents() + const { getEvents, getEvent } = useEvents() const user = useSelector(getUserDetails) const [config] = useEventConfigurations() - const { data: fullEvent } = getEventById.useQuery(params.eventId) + const { data: fullEvent } = getEvent.useQuery(params.eventId) const { data: events } = getEvents.useQuery() + const event = events?.find((e) => e.id === params.eventId) if (!event || !fullEvent?.actions) { @@ -76,13 +83,17 @@ function EventOverview({ history: ActionDocument[] user: ProfileState['userDetails'] }) { + const { eventConfiguration } = useEventConfiguration(event.type) const intl = useIntlFormatMessageWithFlattenedParams() - + const initialValues = getInitialValues(getAllFields(eventConfiguration)) return ( } size={ContentSize.LARGE} - title={intl.formatMessage(summary.title, event.data)} + title={intl.formatMessage(summary.title, { + ...initialValues, + ...event.data + })} titleColor={event.id ? 'copy' : 'grey600'} topActionButtons={[]} > diff --git a/packages/client/src/v2-events/features/workqueues/EventOverview/components/ActionMenu.tsx b/packages/client/src/v2-events/features/workqueues/EventOverview/components/ActionMenu.tsx index b2a79086d95..3ef06f0f4ac 100644 --- a/packages/client/src/v2-events/features/workqueues/EventOverview/components/ActionMenu.tsx +++ b/packages/client/src/v2-events/features/workqueues/EventOverview/components/ActionMenu.tsx @@ -29,7 +29,7 @@ export function ActionMenu({ eventId }: { eventId: string }) { const events = useEvents() const navigate = useNavigate() const authentication = useAuthentication() - const [event] = events.getEvent(eventId) + const [event] = events.getEvent.useSuspenseQuery(eventId) const { eventConfiguration: configuration } = useEventConfiguration( event.type @@ -57,7 +57,7 @@ export function ActionMenu({ eventId }: { eventId: string }) { - {configuration?.actions.filter(isActionVisible).map((action) => ( + {configuration.actions.filter(isActionVisible).map((action) => ( { diff --git a/packages/client/src/v2-events/features/workqueues/EventOverview/components/EventSummary.tsx b/packages/client/src/v2-events/features/workqueues/EventOverview/components/EventSummary.tsx index 01bea5f617c..7a088c5cf29 100644 --- a/packages/client/src/v2-events/features/workqueues/EventOverview/components/EventSummary.tsx +++ b/packages/client/src/v2-events/features/workqueues/EventOverview/components/EventSummary.tsx @@ -13,6 +13,7 @@ import React from 'react' import { Summary } from '@opencrvs/components/lib/Summary' import { SummaryConfig } from '@opencrvs/commons/events' import { EventIndex } from '@opencrvs/commons/client' +import { useTransformer } from '@client/v2-events/hooks/useTransformer' /** * Based on packages/client/src/views/RecordAudit/DeclarationInfo.tsx @@ -25,20 +26,21 @@ export function EventSummary({ event: EventIndex summary: SummaryConfig }) { + const { toString } = useTransformer(event.type) + const data = toString(event.data) return ( <>

    {summary.fields.map((field) => { - const message = 'message' - return ( ) })} diff --git a/packages/client/src/v2-events/features/workqueues/Workqueue.tsx b/packages/client/src/v2-events/features/workqueues/Workqueue.tsx index 8b710441ecd..91d7eaba83d 100644 --- a/packages/client/src/v2-events/features/workqueues/Workqueue.tsx +++ b/packages/client/src/v2-events/features/workqueues/Workqueue.tsx @@ -85,7 +85,7 @@ export function WorkqueueIndex() { } const events = getEvents.useQuery() - const outbox = getOutbox().map(getCurrentEventState) + const outbox = getOutbox() const eventsWithoutOutbox = events.data?.filter( diff --git a/packages/client/src/v2-events/hooks/useTransformer.ts b/packages/client/src/v2-events/hooks/useTransformer.ts new file mode 100644 index 00000000000..c62791e2e42 --- /dev/null +++ b/packages/client/src/v2-events/hooks/useTransformer.ts @@ -0,0 +1,33 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * OpenCRVS is also distributed under the terms of the Civil Registration + * & Healthcare Disclaimer located at http://opencrvs.org/license. + * + * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. + */ + +import { ActionFormData, findPageFields } from '@opencrvs/commons/client' +import { fieldValueToString } from '@client/v2-events/components/forms/utils' +import { useEventConfiguration } from '@client/v2-events/features/events/useEventConfiguration' + +export const useTransformer = (eventType: string) => { + const { eventConfiguration } = useEventConfiguration(eventType) + + const fields = findPageFields(eventConfiguration) + + const toString = (values: ActionFormData) => { + const stringifiedValues: Record = {} + for (const [key, value] of Object.entries(values)) { + const fieldType = fields.find((field) => field.id === key)?.type + if (!fieldType) { + throw new Error(`Field not found for ${key}`) + } + stringifiedValues[key] = fieldValueToString(fieldType, value) + } + return stringifiedValues + } + return { toString } +} diff --git a/packages/client/src/v2-events/layouts/form/index.tsx b/packages/client/src/v2-events/layouts/form/index.tsx index 48e3616ca5d..bd6e76ad939 100644 --- a/packages/client/src/v2-events/layouts/form/index.tsx +++ b/packages/client/src/v2-events/layouts/form/index.tsx @@ -36,7 +36,7 @@ export function FormLayout({ const { eventId } = useTypedParams(route) const events = useEvents() - const [event] = events.getEvent(eventId) + const [event] = events.getEvent.useSuspenseQuery(eventId) const { eventConfiguration: configuration } = useEventConfiguration( event.type diff --git a/packages/client/src/v2-events/messages/constants.ts b/packages/client/src/v2-events/messages/constants.ts index 258221846b8..14ad644956d 100644 --- a/packages/client/src/v2-events/messages/constants.ts +++ b/packages/client/src/v2-events/messages/constants.ts @@ -12,162 +12,7 @@ import { defineMessages, MessageDescriptor } from 'react-intl' -interface IConstantsMessages - extends Record { - countryName: MessageDescriptor - address: MessageDescriptor - allEvents: MessageDescriptor - allStatuses: MessageDescriptor - informantContactNumber: MessageDescriptor - declaration: MessageDescriptor - declarations: MessageDescriptor - declarationArchivedOn: MessageDescriptor - declarationCollectedOn: MessageDescriptor - declarationFailedOn: MessageDescriptor - declarationInformantLabel: MessageDescriptor - applicationName: MessageDescriptor - declarationRegisteredOn: MessageDescriptor - declarationRejectedOn: MessageDescriptor - declarationRequestedCorrectionOn: MessageDescriptor - declarationStarted: MessageDescriptor - declarationStartedBy: MessageDescriptor - declarationStartedOn: MessageDescriptor - declarationState: MessageDescriptor - declarationSubmittedOn: MessageDescriptor - declarationTitle: MessageDescriptor - declarationUpdatedOn: MessageDescriptor - declarationValidatedOn: MessageDescriptor - declarationSentForExternalValidationOn: MessageDescriptor - birth: MessageDescriptor - births: MessageDescriptor - by: MessageDescriptor - certificationPaymentTitle: MessageDescriptor - certified: MessageDescriptor - collected: MessageDescriptor - collectedBy: MessageDescriptor - comment: MessageDescriptor - certificateTitle: MessageDescriptor - applicationTitle: MessageDescriptor - formDeclarationTitle: MessageDescriptor - customTimePeriod: MessageDescriptor - dateOfDeclaration: MessageDescriptor - death: MessageDescriptor - deaths: MessageDescriptor - marriage: MessageDescriptor - declared: MessageDescriptor - dob: MessageDescriptor - dod: MessageDescriptor - downloading: MessageDescriptor - downloaded: MessageDescriptor - eventDate: MessageDescriptor - eventType: MessageDescriptor - registeredAt: MessageDescriptor - registeredBy: MessageDescriptor - lastUpdated: MessageDescriptor - startedAt: MessageDescriptor - startedBy: MessageDescriptor - export: MessageDescriptor - failedToSend: MessageDescriptor - from: MessageDescriptor - gender: MessageDescriptor - id: MessageDescriptor - issuedBy: MessageDescriptor - labelLanguage: MessageDescriptor - labelPassword: MessageDescriptor - labelPhone: MessageDescriptor - labelEmail: MessageDescriptor - labelPin: MessageDescriptor - labelRole: MessageDescriptor - labelSystemRole: MessageDescriptor - last30Days: MessageDescriptor - last12Months: MessageDescriptor - lastEdited: MessageDescriptor - month: MessageDescriptor - name: MessageDescriptor - newBirthRegistration: MessageDescriptor - newDeathRegistration: MessageDescriptor - newMarriageRegistration: MessageDescriptor - noNameProvided: MessageDescriptor - noResults: MessageDescriptor - pendingConnection: MessageDescriptor - performanceTitle: MessageDescriptor - reason: MessageDescriptor - requestReason: MessageDescriptor - registered: MessageDescriptor - inReviewStatus: MessageDescriptor - incompleteStatus: MessageDescriptor - requiresUpdatesStatus: MessageDescriptor - registeredStatus: MessageDescriptor - rejected: MessageDescriptor - rejectedDays: MessageDescriptor - relationship: MessageDescriptor - requestedCorrection: MessageDescriptor - review: MessageDescriptor - search: MessageDescriptor - sending: MessageDescriptor - sentForUpdatesOn: MessageDescriptor - sentOn: MessageDescriptor - status: MessageDescriptor - submissionStatus: MessageDescriptor - timeFramesTitle: MessageDescriptor - timeInProgress: MessageDescriptor - timeReadyForReview: MessageDescriptor - timeRequireUpdates: MessageDescriptor - timeWatingApproval: MessageDescriptor - timeWaitingExternalValidation: MessageDescriptor - timeReadyToPrint: MessageDescriptor - to: MessageDescriptor - toCapitalized: MessageDescriptor - trackingId: MessageDescriptor - notificationSent: MessageDescriptor - sentForReview: MessageDescriptor - sentForValidation: MessageDescriptor - sentForUpdates: MessageDescriptor - sentForApproval: MessageDescriptor - type: MessageDescriptor - event: MessageDescriptor - update: MessageDescriptor - user: MessageDescriptor - username: MessageDescriptor - waitingToSend: MessageDescriptor - week: MessageDescriptor - location: MessageDescriptor - maleUnder18: MessageDescriptor - femaleUnder18: MessageDescriptor - maleOver18: MessageDescriptor - femaleOver18: MessageDescriptor - total: MessageDescriptor - withinTargetDays: MessageDescriptor - withinTargetDaysTo1Year: MessageDescriptor - within1YearTo5Years: MessageDescriptor - over5Years: MessageDescriptor - waitingValidated: MessageDescriptor - validated: MessageDescriptor - loadMore: MessageDescriptor - showMore: MessageDescriptor - estimatedTargetDaysRegistrationTitle: MessageDescriptor - estimatedNumberOfRegistartion: MessageDescriptor - totalRegisteredInTargetDays: MessageDescriptor - percentageOfEstimation: MessageDescriptor - averageRateOfRegistrations: MessageDescriptor - estimatedNumberOfEvents: MessageDescriptor - registeredWithinTargetd: MessageDescriptor - registeredInTargetd: MessageDescriptor - timePeriod: MessageDescriptor - totalRegistered: MessageDescriptor - viewAll: MessageDescriptor - history: MessageDescriptor - requireUpdatesLoading: MessageDescriptor - noConnection: MessageDescriptor - action: MessageDescriptor - date: MessageDescriptor - totalFileSizeExceed: MessageDescriptor - refresh: MessageDescriptor - duplicateOf: MessageDescriptor - matchedTo: MessageDescriptor -} - -const messagesToDefine: IConstantsMessages = { +export const constantsMessages = defineMessages({ action: { defaultMessage: 'Action', description: 'Action Label', @@ -982,9 +827,4 @@ const messagesToDefine: IConstantsMessages = { description: `Label for registrations within {registrationTargetDays} days to 1 year`, id: 'constants.withinTargetDaysTo1Year' } -} - -export const constantsMessages: Record< - string | number | symbol, - MessageDescriptor -> = defineMessages(messagesToDefine) +}) diff --git a/packages/client/src/v2-events/trpc.tsx b/packages/client/src/v2-events/trpc.tsx index 89a8a97e05b..b89a277b881 100644 --- a/packages/client/src/v2-events/trpc.tsx +++ b/packages/client/src/v2-events/trpc.tsx @@ -28,9 +28,7 @@ function getTrpcClient() { return api.createClient({ links: [ loggerLink({ - enabled: (op) => - process.env.NODE_ENV === 'development' || - (op.direction === 'down' && op.result instanceof Error) + enabled: (op) => op.direction === 'down' && op.result instanceof Error }), httpLink({ url: '/api/events', diff --git a/packages/commons/src/events/FieldConfig.ts b/packages/commons/src/events/FieldConfig.ts index c0291cde52d..1eacf7a4d3d 100644 --- a/packages/commons/src/events/FieldConfig.ts +++ b/packages/commons/src/events/FieldConfig.ts @@ -147,4 +147,5 @@ export const FieldConfig = z.discriminatedUnion('type', [ ]) export type FieldConfig = z.infer + export type FieldProps = Extract diff --git a/packages/commons/src/events/FieldValue.ts b/packages/commons/src/events/FieldValue.ts index 9f3903a3d48..10a47b7729a 100644 --- a/packages/commons/src/events/FieldValue.ts +++ b/packages/commons/src/events/FieldValue.ts @@ -9,25 +9,41 @@ * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. */ import { z } from 'zod' +import { FieldType } from './FieldConfig' const TextFieldValue = z.string() export type TextFieldValue = z.infer -const DateFieldValue = z.string() +const DateFieldValue = z.string().nullable() export type DateFieldValue = z.infer const ParagraphFieldValue = z.string() export type ParagraphFieldValue = z.infer -export const FileFieldValue = z.object({ - filename: z.string(), - originalFilename: z.string(), - type: z.string() -}) +export const FileFieldValue = z + .object({ + filename: z.string(), + originalFilename: z.string(), + type: z.string() + }) + .nullable() export type FileFieldValue = z.infer const RadioGroupFieldValue = z.string() +export type RadioGroupFieldValue = z.infer + +export type FieldTypeToFieldValue = T extends 'TEXT' + ? TextFieldValue + : T extends 'PARAGRAPH' + ? ParagraphFieldValue + : T extends 'DATE' + ? DateFieldValue + : T extends 'FILE' + ? FileFieldValue + : T extends 'RADIO_GROUP' + ? RadioGroupFieldValue + : never export const FieldValue = z.union([ TextFieldValue, diff --git a/packages/commons/src/events/defineConfig.ts b/packages/commons/src/events/defineConfig.ts index 94c9b142e44..d5e69089016 100644 --- a/packages/commons/src/events/defineConfig.ts +++ b/packages/commons/src/events/defineConfig.ts @@ -19,7 +19,10 @@ import { findPageFields, resolveFieldLabels } from './utils' export const defineConfig = (config: EventConfigInput) => { const parsed = EventConfig.parse(config) - const pageFields = findPageFields(parsed) + const pageFields = findPageFields(parsed).map(({ id, label }) => ({ + id, + label + })) return EventConfig.parse({ ...parsed, diff --git a/packages/commons/src/events/utils.ts b/packages/commons/src/events/utils.ts index 0f2bf3d11ad..0e02cc43cc1 100644 --- a/packages/commons/src/events/utils.ts +++ b/packages/commons/src/events/utils.ts @@ -16,6 +16,7 @@ import { flattenDeep } from 'lodash' import { EventConfigInput } from './EventConfig' import { SummaryConfigInput } from './SummaryConfig' import { WorkqueueConfigInput } from './WorkqueueConfig' +import { FieldType } from './FieldConfig' const isMetadataField = ( field: T | EventMetadataKeys @@ -26,14 +27,15 @@ const isMetadataField = ( */ export const findPageFields = ( config: EventConfigInput -): Array<{ id: string; label: TranslationConfig }> => { +): Array<{ id: string; label: TranslationConfig; type: FieldType }> => { return flattenDeep( config.actions.map(({ forms }) => forms.map(({ pages }) => pages.map(({ fields }) => - fields.map((field) => ({ - id: field.id, - label: field.label + fields.map(({ id, label, type }) => ({ + id, + label, + type })) ) ) diff --git a/packages/events/src/service/events.ts b/packages/events/src/service/events.ts index 243a50274a0..60baff78f9a 100644 --- a/packages/events/src/service/events.ts +++ b/packages/events/src/service/events.ts @@ -106,7 +106,7 @@ async function deleteEventAttachments(token: string, event: EventDocument) { const fileValue = FileFieldValue.safeParse(value) - if (!isFile || !fileValue.success) { + if (!isFile || !fileValue.success || !fileValue.data) { continue } @@ -198,7 +198,7 @@ export async function addAction( continue } - if (!(await fileExists(fileValue.data.filename, token))) { + if (fileValue.data && !(await fileExists(fileValue.data.filename, token))) { throw new Error(`File not found: ${fileValue.data.filename}`) } } @@ -215,6 +215,9 @@ export async function addAction( createdAt: now, createdAtLocation } + }, + $set: { + updatedAt: now } } ) diff --git a/packages/events/src/service/files/index.ts b/packages/events/src/service/files/index.ts index 35f87a7b379..52e1c837304 100644 --- a/packages/events/src/service/files/index.ts +++ b/packages/events/src/service/files/index.ts @@ -77,8 +77,11 @@ export async function presignFilesInEvent(event: EventDocument, token: string) { fieldId )?.type === 'FILE' ) - .map<[string, string, FileFieldValue]>(([fieldId, value]) => { - return [action.type, fieldId, value as FileFieldValue] + .filter((value): value is [string, Exclude] => { + return value[1] !== null + }) + .map(([fieldId, value]) => { + return [action.type, fieldId, value] as const }) ) diff --git a/yarn.lock b/yarn.lock index 3221ba107ed..7d25e58123f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9314,7 +9314,7 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@16 || 17 || 18", "@types/react@18.3.1", "@types/react@>=16": +"@types/react@*", "@types/react@16 || 17 || 18", "@types/react@18.3.1", "@types/react@>=16", "@types/react@^16": version "18.3.1" resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.1.tgz#fed43985caa834a2084d002e4771e15dfcbdbe8e" integrity sha512-V0kuGBX3+prX+DQ/7r2qsv1NsdfnCLnTgnRJ1pYnxykBhGMz+qj+box5lq7XsO5mtZsBqpjwwTu/7wszPfMBcw== @@ -9322,15 +9322,6 @@ "@types/prop-types" "*" csstype "^3.0.2" -"@types/react@^16": - version "16.14.62" - resolved "https://registry.yarnpkg.com/@types/react/-/react-16.14.62.tgz#449e4e81caaf132d0c2c390644e577702db1dd9e" - integrity sha512-BWf7hqninZav6nerxXj+NeZT/mTpDeG6Lk2zREHAy63CrnXoOGPGtNqTFYFN/sqpSaREDP5otVV88axIXmKfGA== - dependencies: - "@types/prop-types" "*" - "@types/scheduler" "^0.16" - csstype "^3.0.2" - "@types/readdir-glob@*": version "1.1.3" resolved "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.3.tgz" @@ -9399,11 +9390,6 @@ dependencies: htmlparser2 "^8.0.0" -"@types/scheduler@^0.16": - version "0.16.8" - resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.8.tgz#ce5ace04cfeabe7ef87c0091e50752e36707deff" - integrity sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A== - "@types/semver@^7.3.4": version "7.5.8" resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.8.tgz#8268a8c57a3e4abd25c165ecd36237db7948a55e" @@ -15087,14 +15073,7 @@ fast-url-parser@1.1.3, fast-url-parser@^1.1.3: dependencies: punycode "^1.3.2" -fast-xml-parser@4.2.5: - version "4.2.5" - resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-4.2.5.tgz#a6747a09296a6cb34f2ae634019bf1738f3b421f" - integrity sha512-B9/wizE4WngqQftFPmdaMYlXoJlJOYxGQOanC77fq9k8+Z0v5dDSVh+3glErdIROP//s/jgb7ZuxKfB8nVyo0g== - dependencies: - strnum "^1.0.5" - -fast-xml-parser@^4.1.3, fast-xml-parser@^4.2.2: +fast-xml-parser@4.2.5, fast-xml-parser@4.4.1, fast-xml-parser@^4.1.3, fast-xml-parser@^4.2.2: version "4.4.1" resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-4.4.1.tgz#86dbf3f18edf8739326447bcaac31b4ae7f6514f" integrity sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw== From 4ed93eb2fa052a014554b20b38468e80d121885c Mon Sep 17 00:00:00 2001 From: Riku Rouvila Date: Wed, 8 Jan 2025 00:05:55 +0900 Subject: [PATCH 15/15] Events v2: implement delete action for action menu, make sure delete works in the form three dot menu (#8297) --- .../features/events/actions/delete/index.tsx | 30 ++++++++++++++++ .../events/useEventFormNavigation.tsx | 1 + .../events/useEvents/procedures/delete.ts | 15 +++++++- .../EventOverview/components/ActionMenu.tsx | 35 +++++++++++-------- .../client/src/v2-events/routes/config.tsx | 5 +++ .../client/src/v2-events/routes/routes.ts | 1 + packages/client/src/v2-events/trpc.tsx | 17 ++++++--- packages/commons/src/events/ActionConfig.ts | 8 +++++ packages/events/src/service/events.ts | 8 +++-- 9 files changed, 97 insertions(+), 23 deletions(-) create mode 100644 packages/client/src/v2-events/features/events/actions/delete/index.tsx diff --git a/packages/client/src/v2-events/features/events/actions/delete/index.tsx b/packages/client/src/v2-events/features/events/actions/delete/index.tsx new file mode 100644 index 00000000000..6a1b5e4af43 --- /dev/null +++ b/packages/client/src/v2-events/features/events/actions/delete/index.tsx @@ -0,0 +1,30 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * OpenCRVS is also distributed under the terms of the Civil Registration + * & Healthcare Disclaimer located at http://opencrvs.org/license. + * + * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. + */ + +import React, { useEffect } from 'react' +import { useTypedParams } from 'react-router-typesafe-routes/dom' +import { useNavigate } from 'react-router-dom' +import { ROUTES } from '@client/v2-events/routes' +import { useEvents } from '@client/v2-events/features/events/useEvents/useEvents' + +export function DeleteEvent() { + const { eventId } = useTypedParams(ROUTES.V2.EVENTS.DELETE) + const navigate = useNavigate() + const events = useEvents() + const deleteEvent = events.deleteEvent + + useEffect(() => { + deleteEvent.mutate({ eventId }) + navigate(ROUTES.V2.path) + }, [deleteEvent, eventId, navigate]) + + return
    +} diff --git a/packages/client/src/v2-events/features/events/useEventFormNavigation.tsx b/packages/client/src/v2-events/features/events/useEventFormNavigation.tsx index 3c174ac8d9f..b7753e427ac 100644 --- a/packages/client/src/v2-events/features/events/useEventFormNavigation.tsx +++ b/packages/client/src/v2-events/features/events/useEventFormNavigation.tsx @@ -149,6 +149,7 @@ export function useEventFormNavigation() { if (deleteConfirm) { deleteEvent.mutate({ eventId }) + goToHome() } } diff --git a/packages/client/src/v2-events/features/events/useEvents/procedures/delete.ts b/packages/client/src/v2-events/features/events/useEvents/procedures/delete.ts index 5be0e0ae4a4..0a5ef9042a7 100644 --- a/packages/client/src/v2-events/features/events/useEvents/procedures/delete.ts +++ b/packages/client/src/v2-events/features/events/useEvents/procedures/delete.ts @@ -13,10 +13,18 @@ import { useMutation } from '@tanstack/react-query' import { getMutationKey } from '@trpc/react-query' import { api, utils } from '@client/v2-events/trpc' +function isTemporaryId(id: string) { + return id.startsWith('tmp-') +} + function waitUntilEventIsCreated( canonicalMutationFn: (params: { eventId: string }) => Promise ): (params: { eventId: string }) => Promise { return async ({ eventId }) => { + if (!isTemporaryId(eventId)) { + return canonicalMutationFn({ eventId: eventId }) + } + const localVersion = utils.event.get.getData(eventId) if (!localVersion || localVersion.id === localVersion.transactionId) { throw new Error('Event that has not been stored yet cannot be deleted') @@ -27,7 +35,12 @@ function waitUntilEventIsCreated( } utils.event.delete.setMutationDefaults(({ canonicalMutationFn }) => ({ - retry: true, + retry: (_, error) => { + if (error.data?.httpStatus === 404 || error.data?.httpStatus === 400) { + return false + } + return true + }, retryDelay: 10000, onSuccess: ({ id }) => { void utils.events.get.invalidate() diff --git a/packages/client/src/v2-events/features/workqueues/EventOverview/components/ActionMenu.tsx b/packages/client/src/v2-events/features/workqueues/EventOverview/components/ActionMenu.tsx index 3ef06f0f4ac..e0697877614 100644 --- a/packages/client/src/v2-events/features/workqueues/EventOverview/components/ActionMenu.tsx +++ b/packages/client/src/v2-events/features/workqueues/EventOverview/components/ActionMenu.tsx @@ -13,7 +13,7 @@ import React from 'react' import { useIntl } from 'react-intl' import { useNavigate } from 'react-router-dom' -import { validate } from '@opencrvs/commons/client' +import { validate, ActionType } from '@opencrvs/commons/client' import { type ActionConfig } from '@opencrvs/commons' import { CaretDown } from '@opencrvs/components/lib/Icon/all-icons' import { PrimaryButton } from '@opencrvs/components/lib/buttons' @@ -57,21 +57,26 @@ export function ActionMenu({ eventId }: { eventId: string }) { - {configuration.actions.filter(isActionVisible).map((action) => ( - { - if (action.type === 'CREATE' || action.type === 'CUSTOM') { - alert(`Action ${action.type} is not implemented yet.`) - return - } + {configuration.actions.filter(isActionVisible).map((action) => { + return ( + { + if ( + action.type === ActionType.CREATE || + action.type === ActionType.CUSTOM + ) { + alert(`Action ${action.type} is not implemented yet.`) + return + } - navigate(ROUTES.V2.EVENTS[action.type].buildPath({ eventId })) - }} - > - {intl.formatMessage(action.label)} - - ))} + navigate(ROUTES.V2.EVENTS[action.type].buildPath({ eventId })) + }} + > + {intl.formatMessage(action.label)} + + ) + })} diff --git a/packages/client/src/v2-events/routes/config.tsx b/packages/client/src/v2-events/routes/config.tsx index 95b71b7b442..d80b0bbb945 100644 --- a/packages/client/src/v2-events/routes/config.tsx +++ b/packages/client/src/v2-events/routes/config.tsx @@ -19,6 +19,7 @@ import { Debug } from '@client/v2-events/features/debug/debug' import * as Declare from '@client/v2-events/features/events/actions/declare' import * as Register from '@client/v2-events/features/events/actions/register' import { WorkqueueLayout, FormLayout } from '@client/v2-events/layouts' +import { DeleteEvent } from '@client/v2-events/features/events/actions/delete' import { ROUTES } from './routes' /** @@ -63,6 +64,10 @@ export const routesConfig = { path: ROUTES.V2.EVENTS.CREATE.path, element: }, + { + path: ROUTES.V2.EVENTS.DELETE.path, + element: + }, { path: ROUTES.V2.EVENTS.DECLARE.path, element: ( diff --git a/packages/client/src/v2-events/routes/routes.ts b/packages/client/src/v2-events/routes/routes.ts index b71f6250397..8d27b5cce30 100644 --- a/packages/client/src/v2-events/routes/routes.ts +++ b/packages/client/src/v2-events/routes/routes.ts @@ -26,6 +26,7 @@ export const ROUTES = { params: { eventId: string().defined() } }), CREATE: route('create'), + DELETE: route('delete/:eventId'), DECLARE: route( 'declare/:eventId', { diff --git a/packages/client/src/v2-events/trpc.tsx b/packages/client/src/v2-events/trpc.tsx index b89a277b881..f7723960b78 100644 --- a/packages/client/src/v2-events/trpc.tsx +++ b/packages/client/src/v2-events/trpc.tsx @@ -15,12 +15,13 @@ import { type PersistedClient, type Persister } from '@tanstack/react-query-persist-client' -import { httpLink, loggerLink } from '@trpc/client' +import { httpLink, loggerLink, TRPCClientError } from '@trpc/client' import { createTRPCQueryUtils, createTRPCReact } from '@trpc/react-query' import React from 'react' import superjson from 'superjson' -import { getToken } from '@client/utils/authUtils' + import { storage } from '@client/storage' +import { getToken } from '@client/utils/authUtils' export const api = createTRPCReact() @@ -89,8 +90,16 @@ export function TRPCProvider({ children }: { children: React.ReactNode }) { maxAge: undefined, buster: 'persisted-indexed-db', dehydrateOptions: { - shouldDehydrateMutation: (mut) => { - return mut.state.status !== 'success' + shouldDehydrateMutation: (mutation) => { + if (mutation.state.status === 'error') { + const error = mutation.state.error + if (error instanceof TRPCClientError && error.data?.httpStatus) { + return !error.data.httpStatus.toString().startsWith('4') + } + return true + } + + return mutation.state.status !== 'success' } } }} diff --git a/packages/commons/src/events/ActionConfig.ts b/packages/commons/src/events/ActionConfig.ts index 18478779d44..4c0230401ab 100644 --- a/packages/commons/src/events/ActionConfig.ts +++ b/packages/commons/src/events/ActionConfig.ts @@ -33,6 +33,7 @@ export const ActionType = { DETECT_DUPLICATE: 'DETECT_DUPLICATE', NOTIFY: 'NOTIFY', DECLARE: 'DECLARE', + DELETE: 'DELETE', CUSTOM: 'CUSTOM' } as const @@ -60,6 +61,12 @@ const RegisterConfig = ActionConfigBase.merge( }) ) +const DeleteConfig = ActionConfigBase.merge( + z.object({ + type: z.literal(ActionType.DELETE) + }) +) + const CustomConfig = ActionConfigBase.merge( z.object({ type: z.literal(ActionType.CUSTOM) @@ -71,6 +78,7 @@ export const ActionConfig = z.discriminatedUnion('type', [ DeclareConfig, ValidateConfig, RegisterConfig, + DeleteConfig, CustomConfig ]) diff --git a/packages/events/src/service/events.ts b/packages/events/src/service/events.ts index 60baff78f9a..6b40b19a797 100644 --- a/packages/events/src/service/events.ts +++ b/packages/events/src/service/events.ts @@ -39,12 +39,14 @@ async function getEventByTransactionId(transactionId: string) { return document } -class EventNotFoundError extends Error { +class EventNotFoundError extends TRPCError { constructor(id: string) { - super('Event not found with ID: ' + id) + super({ + code: 'NOT_FOUND', + message: `Event not found with ID: ${id}` + }) } } - export async function getEventById(id: string) { const db = await getClient()