From 9af405ad5c344fe9db089d07e84e4a2f472cbfb1 Mon Sep 17 00:00:00 2001 From: Kevin Foong <55353265+kevin9foong@users.noreply.github.com> Date: Tue, 24 Dec 2024 11:42:36 +0800 Subject: [PATCH] feat: add delete functionality for deny --- .../MagicFormBuilderContainer.tsx | 15 +++++- .../UpdateFormFieldService.ts | 12 +++++ .../mutations/useDeleteFormField.ts | 48 ++++++++++++++++- src/app/models/form.server.model.ts | 11 ++++ .../form/admin-form/admin-form.controller.ts | 52 +++++++++++++++++++ .../form/admin-form/admin-form.service.ts | 37 +++++++++++++ .../v3/admin/forms/admin-forms.form.routes.ts | 17 ++++++ src/types/form.ts | 5 ++ 8 files changed, 194 insertions(+), 3 deletions(-) diff --git a/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/FieldListDrawer/MagicFormBuilderContainer.tsx b/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/FieldListDrawer/MagicFormBuilderContainer.tsx index ac6d898624..d367b71ad8 100644 --- a/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/FieldListDrawer/MagicFormBuilderContainer.tsx +++ b/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/FieldListDrawer/MagicFormBuilderContainer.tsx @@ -27,7 +27,11 @@ import { FormErrorMessage } from '~components/FormControl/FormErrorMessage/FormE import { useAssistanceMutations } from '~features/admin-form/assistance/mutations' -import { useMagicFormBuilderStore } from '../../useMagicFormBuilderStore' +import { useDeleteFormField } from '../../mutations/useDeleteFormField' +import { + recentlyCreatedFieldIdsSelector, + useMagicFormBuilderStore, +} from '../../useMagicFormBuilderStore' const GENERATE_FORM_PLACEHOLDER = 'Describe form, fields and sections to create...' @@ -176,6 +180,9 @@ const MagicFormBuilderPopover = ({ const clearRecentlyCreatedFieldIds = useMagicFormBuilderStore( (state) => state.clearRecentlyCreatedFieldIds, ) + const recentlyCreatedFieldIds = useMagicFormBuilderStore( + recentlyCreatedFieldIdsSelector, + ) const onClickDefaults = () => { clearRecentlyCreatedFieldIds() @@ -183,6 +190,8 @@ const MagicFormBuilderPopover = ({ setTimeout(() => setIsAcceptDenyOpen(false), 100) // delay to allow popover to close before updating state } + const { deleteMultipleFormFieldsMutation } = useDeleteFormField() + return ( @@ -200,7 +209,9 @@ const MagicFormBuilderPopover = ({ { - // trigger deletion of fields with field ids in recentlyCreatedFieldIds + deleteMultipleFormFieldsMutation.mutate( + Array.from(recentlyCreatedFieldIds), + ) onClickDefaults() }} onClose={() => setIsOpen(false)} diff --git a/frontend/src/features/admin-form/create/builder-and-design/UpdateFormFieldService.ts b/frontend/src/features/admin-form/create/builder-and-design/UpdateFormFieldService.ts index 9c087f29c9..4337d6d7d5 100644 --- a/frontend/src/features/admin-form/create/builder-and-design/UpdateFormFieldService.ts +++ b/frontend/src/features/admin-form/create/builder-and-design/UpdateFormFieldService.ts @@ -127,3 +127,15 @@ export const deleteSingleFormField = async ({ }): Promise => { return ApiService.delete(`${ADMIN_FORM_ENDPOINT}/${formId}/fields/${fieldId}`) } + +export const deleteMultipleFormFields = async ({ + formId, + fieldIds, +}: { + formId: string + fieldIds: string[] +}): Promise => { + return ApiService.post(`${ADMIN_FORM_ENDPOINT}/${formId}/fields/delete`, { + fieldIds, + }) +} diff --git a/frontend/src/features/admin-form/create/builder-and-design/mutations/useDeleteFormField.ts b/frontend/src/features/admin-form/create/builder-and-design/mutations/useDeleteFormField.ts index 0267708698..eca60aa9a8 100644 --- a/frontend/src/features/admin-form/create/builder-and-design/mutations/useDeleteFormField.ts +++ b/frontend/src/features/admin-form/create/builder-and-design/mutations/useDeleteFormField.ts @@ -22,7 +22,10 @@ import { stateSelector, usePaymentStore, } from '../BuilderAndDesignDrawer/FieldListDrawer/field-panels/usePaymentStore' -import { deleteSingleFormField } from '../UpdateFormFieldService' +import { + deleteMultipleFormFields, + deleteSingleFormField, +} from '../UpdateFormFieldService' import { FieldBuilderState, setToInactiveSelector, @@ -137,6 +140,38 @@ export const useDeleteFormField = () => { }, ) + const handleDeleteMultipleFieldsSuccess = useCallback( + (_data: unknown, fieldIds: string[]) => { + queryClient.setQueryData(adminFormKey, (oldForm) => { + // Should not happen, should not be able to update field if there is no + // existing data. + if (!oldForm) throw new Error('Query should have been set') + const deletedFieldIndices = fieldIds + .map((fieldId) => + oldForm.form_fields.findIndex((ff) => ff._id === fieldId), + ) + .filter((index) => index >= 0) + oldForm.form_fields = oldForm.form_fields.filter( + (_field, index) => !deletedFieldIndices.includes(index), + ) + return oldForm + }) + setToInactive() + }, + [adminFormKey, queryClient, setToInactive], + ) + + const handleDeleteMultipleFieldsError = useCallback( + (error: Error) => { + toast.closeAll() + toast({ + description: error.message, + status: 'danger', + }) + }, + [toast], + ) + return { deleteFieldMutation: useMutation( (fieldId: string) => @@ -149,6 +184,17 @@ export const useDeleteFormField = () => { onError: handleError, }, ), + deleteMultipleFormFieldsMutation: useMutation( + (fieldIds: string[]) => + deleteMultipleFormFields({ + formId, + fieldIds, + }), + { + onSuccess: handleDeleteMultipleFieldsSuccess, + onError: handleDeleteMultipleFieldsError, + }, + ), deletePaymentFieldMutation, } } diff --git a/src/app/models/form.server.model.ts b/src/app/models/form.server.model.ts index aceaa61567..70bfff2567 100644 --- a/src/app/models/form.server.model.ts +++ b/src/app/models/form.server.model.ts @@ -1183,6 +1183,17 @@ const compileFormModel = (db: Mongoose): IFormModel => { ).exec() } + FormSchema.statics.deleteFormFieldsByIds = async function ( + formId: string, + fieldIds: string[], + ): Promise { + return this.findByIdAndUpdate( + formId, + { $pull: { form_fields: { _id: { $in: fieldIds } } } }, + { new: true, runValidators: true }, + ).exec() + } + // Updates specified form logic. FormSchema.statics.updateFormLogic = async function ( formId: string, diff --git a/src/app/modules/form/admin-form/admin-form.controller.ts b/src/app/modules/form/admin-form/admin-form.controller.ts index 7f5e87e2ac..283bd27016 100644 --- a/src/app/modules/form/admin-form/admin-form.controller.ts +++ b/src/app/modules/form/admin-form/admin-form.controller.ts @@ -2785,6 +2785,58 @@ export const handleDeleteFormField: ControllerHandler< ) } +/** + * Handler for PATCH /forms/:formId/fields/ + * @security session + * + * @returns 204 when deletion is successful + * @returns 403 when current user does not have permissions to delete form fields + * @returns 404 when form cannot be found + * @returns 410 when deleting fields of an archived form + * @returns 422 when user in session cannot be retrieved from the database + * @returns 500 when database error occurs during deletion + */ +export const handleDeleteFormFields: ControllerHandler< + { formId: string }, + { message: string } | ErrorDto, + { fieldIds: string[] } +> = (req, res) => { + const { formId } = req.params + const { fieldIds } = req.body + const sessionUserId = (req.session as AuthedSessionData).user._id + + return ( + // Step 1: Retrieve currently logged in user. + UserService.getPopulatedUserById(sessionUserId) + .andThen((user) => + // Step 2: Retrieve form with write permission check. + AuthService.getFormAfterPermissionChecks({ + user, + formId, + level: PermissionLevel.Write, + }), + ) + // Step 3: Delete form fields. + .andThen((form) => AdminFormService.deleteFormFields(form, fieldIds)) + .map(() => res.sendStatus(StatusCodes.NO_CONTENT)) + .mapErr((error) => { + logger.error({ + message: 'Error occurred when deleting form fields', + meta: { + action: 'handleDeleteFormFields', + ...createReqMeta(req), + userId: sessionUserId, + formId, + fieldIds, + }, + error, + }) + const { errorMessage, statusCode } = mapRouteError(error) + return res.status(statusCode).json({ message: errorMessage }) + }) + ) +} + /** * NOTE: Exported for testing. * Private handler for PUT /forms/:formId/end-page diff --git a/src/app/modules/form/admin-form/admin-form.service.ts b/src/app/modules/form/admin-form/admin-form.service.ts index 74545217c0..f905739fef 100644 --- a/src/app/modules/form/admin-form/admin-form.service.ts +++ b/src/app/modules/form/admin-form/admin-form.service.ts @@ -2157,6 +2157,43 @@ export const deleteFormField = ( }) } +/** + * Deletes multiple form fields from the given form by their ids. + * If any of the fieldIds does not exist, it will be ignored. + * @param form The form to delete the specified form fields for + * @param fieldIds the ids of the form fields to delete + * @returns ok(updated form) on success + * @returns err(PossibleDatabaseError) if db error is thrown during the deletion of form fields + * @returns err(FormNotFoundError) if the form cannot be found + */ +export const deleteFormFields = ( + form: IPopulatedForm, + fieldIds: string[], +): ResultAsync => { + const logMeta = { + action: 'deleteFormFields', + formId: form._id, + fieldIds, + } + + return ResultAsync.fromPromise( + FormModel.deleteFormFieldsByIds(form._id, fieldIds), + (error) => { + logger.error({ + message: 'Error occurred when deleting form fields', + meta: logMeta, + error, + }) + return transformMongoError(error) + }, + ).andThen((updatedForm) => { + if (!updatedForm) { + return errAsync(new FormNotFoundError()) + } + return okAsync(updatedForm) + }) +} + /** * Update the end page of the given form * @param formId the id of the form to update the end page for diff --git a/src/app/routes/api/v3/admin/forms/admin-forms.form.routes.ts b/src/app/routes/api/v3/admin/forms/admin-forms.form.routes.ts index aca2c4ce0d..4bec1945d2 100644 --- a/src/app/routes/api/v3/admin/forms/admin-forms.form.routes.ts +++ b/src/app/routes/api/v3/admin/forms/admin-forms.form.routes.ts @@ -188,6 +188,23 @@ AdminFormsFormRouter.put( AdminFormController.handleUpdateOptionsToRecipientsMap, ) +/** + * Deletes multiple form fields from the specified form. + * Uses POST for bulk deletion. See: https://stackoverflow.com/questions/21863326/delete-multiple-records-using-rest + * @security session + * + * @returns 204 when deletion is successful + * @returns 403 when current user does not have permissions to delete form fields + * @returns 404 when form cannot be found + * @returns 410 when deleting fields of an archived form + * @returns 422 when user in session cannot be retrieved from the database + * @returns 500 when database error occurs during deletion + */ +AdminFormsFormRouter.post( + '/:formId([a-fA-F0-9]{24})/fields/delete', + AdminFormController.handleDeleteFormFields, +) + /** * Duplicates the form field with the fieldId from the specified form * @security session diff --git a/src/types/form.ts b/src/types/form.ts index 920f0b5e1f..dc5b602956 100644 --- a/src/types/form.ts +++ b/src/types/form.ts @@ -363,6 +363,11 @@ export interface IFormModel extends Model { fieldId: string, ): Promise + deleteFormFieldsByIds( + formId: string, + fieldIds: string[], + ): Promise + deactivateById(formId: string): Promise getMetaByUserIdOrEmail(