Skip to content

Commit 0b1a6d0

Browse files
authored
New declaration review page action UI (#11184)
* progress with new declaring ui * progress * progress * progress * minor fixes * cleanup * fix lint issue * progress * progress * fix lint issue * cleanup * add check for registering * add comments * refactor * fix lint issues * fix storybook tests * fix lint issues * add eslint disable rule * fix modal result handling * rename incomplete -> hasValidationErrors and add comment * try stabilizing test * add wait to fix flakyness on test? * remove timeout
1 parent 0276add commit 0b1a6d0

File tree

9 files changed

+551
-350
lines changed

9 files changed

+551
-350
lines changed
Lines changed: 353 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,353 @@
1+
/*
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* License, v. 2.0. If a copy of the MPL was not distributed with this
4+
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
5+
*
6+
* OpenCRVS is also distributed under the terms of the Civil Registration
7+
* & Healthcare Disclaimer located at http://opencrvs.org/license.
8+
*
9+
* Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS.
10+
*/
11+
import React, { useCallback } from 'react'
12+
import { useIntl } from 'react-intl'
13+
import { useTypedSearchParams } from 'react-router-typesafe-routes/dom'
14+
import { v4 as uuid } from 'uuid'
15+
import { useSelector } from 'react-redux'
16+
import {
17+
ActionType,
18+
getDeclaration,
19+
EventStatus,
20+
EventDocument,
21+
getCurrentEventState,
22+
TokenUserType,
23+
UUID,
24+
isActionAvailable,
25+
getActionConfig
26+
} from '@opencrvs/commons/client'
27+
import { PrimaryButton } from '@opencrvs/components/lib/buttons'
28+
import { DropdownMenu } from '@opencrvs/components/lib/Dropdown'
29+
import { CaretDown } from '@opencrvs/components/lib/Icon/all-icons'
30+
import { Icon } from '@opencrvs/components'
31+
import { getUserDetails } from '@client/profile/profileSelectors'
32+
import { useModal } from '@client/v2-events/hooks/useModal'
33+
import { useEvents } from '@client/v2-events/features/events/useEvents/useEvents'
34+
import { useDrafts } from '@client/v2-events/features/drafts/useDrafts'
35+
import { messages } from '@client/i18n/messages/views/action'
36+
import { ROUTES } from '@client/v2-events/routes'
37+
import { useEventFormNavigation } from '@client/v2-events/features/events/useEventFormNavigation'
38+
import { messages as formHeaderMessages } from '@client/v2-events/layouts/form/FormHeader'
39+
import { useUserAllowedActions } from '@client/v2-events/features/workqueues/EventOverview/components/useAllowedActionConfigurations'
40+
import { actionLabels } from '@client/v2-events/features/workqueues/EventOverview/components/useAllowedActionConfigurations'
41+
import { useValidatorContext } from '@client/v2-events/hooks/useValidatorContext'
42+
import { Review } from '@client/v2-events/features/events/components/Review'
43+
import { useSaveAndExitModal } from '@client/v2-events/components/SaveAndExitModal'
44+
import { validationErrorsInActionFormExist } from '@client/v2-events/components/forms/validation'
45+
import { reviewMessages } from '@client/v2-events/features/events/actions/messages'
46+
import { useActionAnnotation } from '../../useActionAnnotation'
47+
import { useEventFormData } from '../../useEventFormData'
48+
import { useRejectionModal } from '../reject/useRejectionModal'
49+
import { useEventConfiguration } from '../../useEventConfiguration'
50+
51+
// @TODO: These should be made configurable in action config, so that different event types can have different copy
52+
// This will be implemented as part of https://github.com/opencrvs/opencrvs-core/issues/10900
53+
const actionModalMessages = {
54+
[ActionType.NOTIFY]: reviewMessages.incomplete.declare,
55+
[ActionType.DECLARE]: reviewMessages.complete.declare,
56+
[ActionType.VALIDATE]: reviewMessages.complete.validate,
57+
[ActionType.REGISTER]: reviewMessages.complete.register
58+
}
59+
60+
/**
61+
* Declaration actions contain actions available on the review page of the declare flow. This can include:
62+
* - Notify (incomplete records)
63+
* - Declare (non-incomplete records)
64+
* - Validate (aka. 'direct validation', which means declare+validate actions)
65+
* - Register (aka. 'direct registration', which means declare+validate+register actions)
66+
* - Reject (only available for previously notified events)
67+
* - Save and exit
68+
* - Delete declaration
69+
*/
70+
function useDeclarationActions(event: EventDocument) {
71+
const eventType = event.type
72+
const drafts = useDrafts()
73+
const {
74+
closeActionView,
75+
deleteDeclaration,
76+
modal: deleteDeclarationModal
77+
} = useEventFormNavigation()
78+
const { eventConfiguration } = useEventConfiguration(eventType)
79+
const formConfig = getDeclaration(eventConfiguration)
80+
const validatorContext = useValidatorContext()
81+
const declaration = useEventFormData((state) => state.getFormValues())
82+
const { getAnnotation } = useActionAnnotation()
83+
const annotation = getAnnotation()
84+
const [modal, openModal] = useModal()
85+
const { rejectionModal, handleRejection } = useRejectionModal(event.id)
86+
const [{ workqueue: slug }] = useTypedSearchParams(
87+
ROUTES.V2.EVENTS.DECLARE.REVIEW
88+
)
89+
const { saveAndExitModal, handleSaveAndExit } = useSaveAndExitModal()
90+
const events = useEvents()
91+
const userDetails = useSelector(getUserDetails)
92+
93+
const mutateFns = {
94+
[ActionType.NOTIFY]: events.actions.notify.mutate,
95+
[ActionType.DECLARE]: events.actions.declare.mutate,
96+
[ActionType.VALIDATE]: events.customActions.validateOnDeclare.mutate,
97+
[ActionType.REGISTER]: events.customActions.registerOnDeclare.mutate
98+
}
99+
100+
const actionConfiguration = eventConfiguration.actions.find(
101+
(a) => a.type === ActionType.DECLARE
102+
)
103+
if (!actionConfiguration) {
104+
throw new Error('Action configuration not found')
105+
}
106+
107+
const reviewConfig = actionConfiguration.review
108+
109+
/**
110+
* hasValidationErrors is true if:
111+
* - the form has any field validation errors or
112+
* - the form is incomplete
113+
*
114+
* If hasValidationErrors is true, the user is still able to Notify an event (if they have the required scope)
115+
*/
116+
const hasValidationErrors = validationErrorsInActionFormExist({
117+
formConfig,
118+
form: declaration,
119+
annotation,
120+
context: validatorContext,
121+
reviewFields: reviewConfig.fields
122+
})
123+
124+
const { isActionAllowed } = useUserAllowedActions(eventType)
125+
const eventId = event.id
126+
127+
const onDelete = useCallback(async () => {
128+
await deleteDeclaration(eventId)
129+
}, [eventId, deleteDeclaration])
130+
131+
async function handleDeclaration(actionType: keyof typeof mutateFns) {
132+
const mutateFn = mutateFns[actionType]
133+
const msgs = actionModalMessages[actionType]
134+
const confirmedDeclaration = await openModal<boolean | null>((close) => {
135+
return (
136+
<Review.ActionModal.Accept
137+
action="Declare"
138+
close={close}
139+
copy={{
140+
// @TODO: make the messages configurable in action config?
141+
// Will be implemented as part of https://github.com/opencrvs/opencrvs-core/issues/10900
142+
...msgs.modal,
143+
onConfirm: actionLabels[actionType],
144+
eventLabel: eventConfiguration.label
145+
}}
146+
/>
147+
)
148+
})
149+
150+
if (confirmedDeclaration) {
151+
mutateFn({
152+
eventId,
153+
declaration,
154+
annotation,
155+
transactionId: uuid()
156+
})
157+
closeActionView(slug)
158+
}
159+
}
160+
161+
const eventIndex = getCurrentEventState(event, eventConfiguration)
162+
163+
/**
164+
* Logic to check whether direct declare + validate or declare + validate + register is possible.
165+
* We do this by 'looking in to the future' by applying the would-be actions to the event,
166+
* and checking if the validate and register actions are still allowed.
167+
*/
168+
function isDirectActionPossible(
169+
actionType: typeof ActionType.VALIDATE | typeof ActionType.REGISTER
170+
) {
171+
if (!userDetails) {
172+
return false
173+
}
174+
175+
const eventAfterDeclare = {
176+
...event,
177+
actions: event.actions.concat({
178+
type: ActionType.DECLARE,
179+
id: 'placeholder' as UUID,
180+
transactionId: 'placeholder' as UUID,
181+
createdByUserType: TokenUserType.enum.user,
182+
createdByRole: userDetails.role.id,
183+
declaration,
184+
annotation,
185+
createdAt: new Date().toISOString(),
186+
createdBy: userDetails.id,
187+
originalActionId: null,
188+
status: 'Accepted',
189+
createdBySignature: undefined,
190+
createdAtLocation: userDetails.primaryOffice.id as UUID
191+
})
192+
}
193+
194+
const eventIndexAfterDeclare = getCurrentEventState(
195+
eventAfterDeclare,
196+
eventConfiguration
197+
)
198+
199+
const validateActionConfig = getActionConfig({
200+
eventConfiguration,
201+
actionType: ActionType.VALIDATE
202+
})
203+
204+
if (!validateActionConfig) {
205+
return false
206+
}
207+
208+
const validateIsAvailable = isActionAvailable(
209+
validateActionConfig,
210+
eventIndexAfterDeclare,
211+
validatorContext
212+
)
213+
214+
if (actionType === ActionType.VALIDATE) {
215+
return validateIsAvailable
216+
}
217+
218+
const eventAfterValidate = {
219+
...eventAfterDeclare,
220+
actions: eventAfterDeclare.actions.concat({
221+
type: ActionType.VALIDATE,
222+
id: 'placeholder' as UUID,
223+
transactionId: 'placeholder' as UUID,
224+
createdByUserType: TokenUserType.enum.user,
225+
createdByRole: userDetails.role.id,
226+
declaration,
227+
annotation,
228+
createdAt: new Date().toISOString(),
229+
createdBy: userDetails.id,
230+
originalActionId: null,
231+
status: 'Accepted',
232+
createdBySignature: undefined,
233+
createdAtLocation: userDetails.primaryOffice.id as UUID
234+
})
235+
}
236+
237+
const registerActionConfig = getActionConfig({
238+
eventConfiguration,
239+
actionType: ActionType.REGISTER
240+
})
241+
242+
if (!registerActionConfig) {
243+
return false
244+
}
245+
246+
const eventIndexAfterValidate = getCurrentEventState(
247+
eventAfterValidate,
248+
eventConfiguration
249+
)
250+
251+
return isActionAvailable(
252+
registerActionConfig,
253+
eventIndexAfterValidate,
254+
validatorContext
255+
)
256+
}
257+
258+
return {
259+
modals: [modal, rejectionModal, saveAndExitModal, deleteDeclarationModal],
260+
actions: [
261+
{
262+
icon: 'Check' as const,
263+
label: actionLabels[ActionType.REGISTER],
264+
onClick: async () => handleDeclaration(ActionType.REGISTER),
265+
hidden: !isActionAllowed(ActionType.REGISTER),
266+
disabled:
267+
hasValidationErrors || !isDirectActionPossible(ActionType.REGISTER)
268+
},
269+
{
270+
icon: 'PaperPlaneTilt' as const,
271+
label: actionLabels[ActionType.VALIDATE],
272+
onClick: async () => handleDeclaration(ActionType.VALIDATE),
273+
hidden: !isActionAllowed(ActionType.VALIDATE),
274+
disabled:
275+
hasValidationErrors || !isDirectActionPossible(ActionType.VALIDATE)
276+
},
277+
{
278+
icon: 'UploadSimple' as const,
279+
label: actionLabels[ActionType.DECLARE],
280+
onClick: async () => handleDeclaration(ActionType.DECLARE),
281+
hidden: !isActionAllowed(ActionType.DECLARE),
282+
disabled: hasValidationErrors
283+
},
284+
{
285+
icon: 'UploadSimple' as const,
286+
label: actionLabels[ActionType.NOTIFY],
287+
onClick: async () => handleDeclaration(ActionType.NOTIFY),
288+
hidden: !isActionAllowed(ActionType.NOTIFY),
289+
disabled: false
290+
},
291+
{
292+
icon: 'FileX' as const,
293+
label: actionLabels[ActionType.REJECT],
294+
onClick: async () => handleRejection(() => closeActionView(slug)),
295+
hidden: eventIndex.status !== EventStatus.enum.NOTIFIED
296+
},
297+
{
298+
icon: 'FloppyDisk' as const,
299+
label: formHeaderMessages.saveExitButton,
300+
onClick: async () =>
301+
handleSaveAndExit(() => {
302+
drafts.submitLocalDraft()
303+
closeActionView(slug)
304+
}),
305+
hidden: false
306+
},
307+
{
308+
icon: 'Trash' as const,
309+
label: formHeaderMessages.deleteDeclaration,
310+
onClick: async () => onDelete(),
311+
hidden: false
312+
}
313+
].filter((a) => !a.hidden)
314+
}
315+
}
316+
317+
/**
318+
* Menu component available on the declaration review page.
319+
* We have tried to contain all logic to which actions are available in the declaration in this component.
320+
* */
321+
export function DeclareActionMenu({ event }: { event: EventDocument }) {
322+
const intl = useIntl()
323+
const { modals, actions } = useDeclarationActions(event)
324+
325+
return (
326+
<>
327+
<DropdownMenu id="action">
328+
<DropdownMenu.Trigger asChild>
329+
<PrimaryButton
330+
data-testid="action-dropdownMenu"
331+
icon={() => <CaretDown />}
332+
size="medium"
333+
>
334+
{intl.formatMessage(messages.action)}
335+
</PrimaryButton>
336+
</DropdownMenu.Trigger>
337+
<DropdownMenu.Content>
338+
{actions.map(({ onClick, icon, label, disabled }, index) => (
339+
<DropdownMenu.Item
340+
key={index}
341+
disabled={disabled}
342+
onClick={onClick}
343+
>
344+
<Icon color="currentColor" name={icon} size="small" />
345+
{intl.formatMessage(label)}
346+
</DropdownMenu.Item>
347+
))}
348+
</DropdownMenu.Content>
349+
</DropdownMenu>
350+
{modals}
351+
</>
352+
)
353+
}

0 commit comments

Comments
 (0)