From 84bbcd3e3b1792ca12b3866b3e1e1e97a4f8213d Mon Sep 17 00:00:00 2001 From: Estela Silva Date: Fri, 24 May 2024 17:17:04 -0300 Subject: [PATCH 1/3] feat: field/question name is now editable --- .../1716472223-add-unique-id-to-questions.ts | 75 +++++++++++++++++++ src/schema/mutation/editForm.mutation.ts | 13 +++- src/utils/form/addField.ts | 14 +++- src/utils/form/extractFields.ts | 9 +++ src/utils/form/getPreviousQuestion.ts | 15 ++-- src/utils/form/getQuestion.ts | 11 +-- src/utils/form/metadata.helper.ts | 3 + src/utils/form/onUpdateFieldName.ts | 57 ++++++++++++++ src/utils/form/removeField.ts | 15 ++-- src/utils/form/replaceField.ts | 15 +++- 10 files changed, 203 insertions(+), 24 deletions(-) create mode 100644 migrations/1716472223-add-unique-id-to-questions.ts create mode 100644 src/utils/form/onUpdateFieldName.ts diff --git a/migrations/1716472223-add-unique-id-to-questions.ts b/migrations/1716472223-add-unique-id-to-questions.ts new file mode 100644 index 000000000..afa212576 --- /dev/null +++ b/migrations/1716472223-add-unique-id-to-questions.ts @@ -0,0 +1,75 @@ +import { Form } from '@models'; +import { startDatabaseForMigration } from '../src/utils/migrations/database.helper'; +import { logger } from '@services/logger.service'; +import { v4 as uuidv4 } from 'uuid'; + +/** + * Sample function of up migration + */ +export const up = async () => { + await startDatabaseForMigration(); + // Update forms fields adding a new unique id for each field (question form) + const coreForms = await Form.find({ core: true }).select( + 'fields name resource' + ); + + for (const coreForm of coreForms) { + // Start updating core forms + const coreFieldsToSave = []; + for (let field of coreForm.fields) { + field = { + ...field, + oid: uuidv4(), + }; + coreFieldsToSave.push(field); + } + coreForm.fields = coreFieldsToSave; + await coreForm.save(); + logger.info( + `Form [${coreForm.name}]: updated fields with a unique id for each.` + ); + // Update child forms (keep core fields with same id) + const childForms = await Form.find({ + core: { $ne: true }, + resource: coreForm.resource, + }).select('fields name resource'); + if (childForms) { + for (const child of childForms) { + const childFieldsToSave = []; + for (let childField of child.fields) { + if (!childField.isCore) { + childField = { + ...childField, + oid: uuidv4(), + }; + } else { + const coreId = coreForm.fields.find( + (field) => field.name === childField.name + ).oid; + childField = { + ...childField, + oid: coreId, + }; + } + childFieldsToSave.push(childField); + } + child.fields = childFieldsToSave; + await child.save(); + logger.info( + `Form [${child.name}] (child form): updated fields with a unique id for each.` + ); + } + } + } +}; + +/** + * Sample function of down migration + * + * @returns just migrate data. + */ +export const down = async () => { + /* + Code you downgrade script here! + */ +}; diff --git a/src/schema/mutation/editForm.mutation.ts b/src/schema/mutation/editForm.mutation.ts index 48cb9aaf5..2fb39d924 100644 --- a/src/schema/mutation/editForm.mutation.ts +++ b/src/schema/mutation/editForm.mutation.ts @@ -27,6 +27,7 @@ import { logger } from '@services/logger.service'; import checkDefaultFields from '@utils/form/checkDefaultFields'; import { graphQLAuthCheck } from '@schema/shared'; import { Context } from '@server/apollo/context'; +import { onUpdateFieldName } from '@utils/form/onUpdateFieldName'; /** * List of keys of the structure's object which we want to inherit to the children forms when they are modified on the core form @@ -332,6 +333,7 @@ export default { // Update structure const newStructure = JSON.parse(template.structure); // Get the inheriting form's structure replaceField( + field.oid, field.name, newStructure, structure, @@ -426,7 +428,7 @@ export default { if (!field.generated) { // Remove from structure const templateStructure = JSON.parse(template.structure); - removeField(templateStructure, field.name); + removeField(templateStructure, field.oid, field.name); template.structure = JSON.stringify(templateStructure); } } @@ -442,7 +444,12 @@ export default { if (!field.generated) { // Add to structure const templateStructure = JSON.parse(template.structure); - addField(templateStructure, field.name, structure); + addField( + templateStructure, + field.oid, + field.name, + structure + ); template.structure = JSON.stringify(templateStructure); } } @@ -529,6 +536,8 @@ export default { new: true, }); + // Check if any field name was updated to also update records and aggregation/layouts + await onUpdateFieldName(form, update.fields); // Return updated form return resForm; } catch (err) { diff --git a/src/utils/form/addField.ts b/src/utils/form/addField.ts index 2e26d96b0..3b49d2a4f 100644 --- a/src/utils/form/addField.ts +++ b/src/utils/form/addField.ts @@ -9,14 +9,20 @@ import { getQuestionPosition } from './getQuestionPosition'; * Function by induction. * * @param structure structure of the form to edit + * @param oid unique id of the field to search for * @param name name of the field to search for * @param template structure of the core template */ -export const addField = (structure: any, name: string, template: any): void => { - const templateQuestion = getQuestion(template, name); +export const addField = ( + structure: any, + oid: string, + name: string, + template: any +): void => { + const templateQuestion = getQuestion(template, oid, name); try { - const templatePreviousQuestion = getPreviousQuestion(template, name); - const templateNextQuestion = getNextQuestion(template, name); + const templatePreviousQuestion = getPreviousQuestion(template, oid, name); + const templateNextQuestion = getNextQuestion(template, oid); if (templatePreviousQuestion) { const { parent, index } = getQuestionPosition( structure, diff --git a/src/utils/form/extractFields.ts b/src/utils/form/extractFields.ts index f92ef84b6..5c45b5fca 100644 --- a/src/utils/form/extractFields.ts +++ b/src/utils/form/extractFields.ts @@ -2,6 +2,7 @@ import { GraphQLError } from 'graphql/error'; import { getFieldType } from './getFieldType'; import i18next from 'i18next'; import { validateGraphQLFieldName } from '@utils/validators'; +import { v4 as uuidv4 } from 'uuid'; /** * Push in fields array all detected fields in the json structure of object. @@ -30,6 +31,7 @@ export const extractFields = async (object, fields, core): Promise => { const field = { type, name: element.valueName, + oid: uuidv4(), unique: !!element.unique, isRequired: !!element.isRequired, showOnXlsxTemplate: !element.omitOnXlsxTemplate, @@ -38,6 +40,9 @@ export const extractFields = async (object, fields, core): Promise => { ...(element.hasOwnProperty('defaultValue') ? { defaultValue: element.defaultValue } : {}), + ...(element.oldName && element.oldName !== element.valueName + ? { oldName: element.oldName } + : {}), }; // ** Resource ** if (element.type === 'resource' || element.type === 'resources') { @@ -217,8 +222,12 @@ export const extractFields = async (object, fields, core): Promise => { fields.push({ type: 'text', name: `${element.valueName}_comment`, + oid: uuidv4(), isCore: core, generated: true, + ...(element.oldName && element.oldName !== element.valueName + ? { oldName: element.oldName } + : {}), }); } // ** Users ** diff --git a/src/utils/form/getPreviousQuestion.ts b/src/utils/form/getPreviousQuestion.ts index 13c8dcb0f..baa4738dc 100644 --- a/src/utils/form/getPreviousQuestion.ts +++ b/src/utils/form/getPreviousQuestion.ts @@ -1,22 +1,27 @@ /** - * Gets the previous question, from a question name. + * Gets the previous question, from a question unique id. * * @param structure parent structure. + * @param oid question unique id. * @param name question name. * @returns Previous question if exists. */ -export const getPreviousQuestion = (structure: any, name: string): any => { +export const getPreviousQuestion = ( + structure: any, + oid: string, + name: string +): any => { if (structure.pages) { for (const page of structure.pages) { - const question = getPreviousQuestion(page, name); + const question = getPreviousQuestion(page, oid, name); if (question) return question; } } else if (structure.elements) { for (const elementIndex in structure.elements) { const element = structure.elements[elementIndex]; if (element.type === 'panel') { - if (element.name === name) return element; - const question = getPreviousQuestion(element, name); + if (element.oid === oid) return element; + const question = getPreviousQuestion(element, oid, name); if (question) return question; } else { if (element.valueName === name) { diff --git a/src/utils/form/getQuestion.ts b/src/utils/form/getQuestion.ts index 4c83dd0a1..767801401 100644 --- a/src/utils/form/getQuestion.ts +++ b/src/utils/form/getQuestion.ts @@ -3,22 +3,23 @@ * Function by induction. * * @param structure structure of the form to search on - * @param name name of the field to search for + * @param oid unique id of the field to search for + * @param name name of the field * @returns question definition */ -export const getQuestion = (structure: any, name: string): any => { +export const getQuestion = (structure: any, oid: string, name: string): any => { // Loop on elements to find the right question if (structure.pages) { for (const page of structure.pages) { - const question = getQuestion(page, name); + const question = getQuestion(page, oid, name); if (question) return question; } } else if (structure.elements) { for (const elementIndex in structure.elements) { const element = structure.elements[elementIndex]; if (element.type === 'panel') { - if (element.name === name) return element; - const question = getQuestion(element, name); + if (element.oid === oid) return element; + const question = getQuestion(element, oid, name); if (question) return question; } else { if (element.valueName === name) { diff --git a/src/utils/form/metadata.helper.ts b/src/utils/form/metadata.helper.ts index a5ec76f5c..429b3dd64 100644 --- a/src/utils/form/metadata.helper.ts +++ b/src/utils/form/metadata.helper.ts @@ -3,11 +3,13 @@ import mongoose from 'mongoose'; import { sortBy } from 'lodash'; import extendAbilityForRecords from '@security/extendAbilityForRecords'; import { accessibleBy } from '@casl/mongoose'; +import { v4 as uuidv4 } from 'uuid'; export type Metadata = { automated?: boolean; name: string; type?: string; + oid?: string; editor?: string; filter?: { defaultOperator?: string; operators: string[] }; canSee?: boolean; @@ -254,6 +256,7 @@ export const getMetaData = async ( const fieldMeta: Metadata = { name: field.name, type: field.type, + oid: uuidv4(), editor: null, usedIn: forms .filter((form) => form.fields.find((x) => x.name === field.name)) diff --git a/src/utils/form/onUpdateFieldName.ts b/src/utils/form/onUpdateFieldName.ts new file mode 100644 index 000000000..7f130df18 --- /dev/null +++ b/src/utils/form/onUpdateFieldName.ts @@ -0,0 +1,57 @@ +import { Record, Resource } from '@models'; +// import i18next from 'i18next'; + +/** + * Check if any field name was updated to also update records and aggregation/layouts + * + * @param form form updated + * @param fields list of fields + */ +export const onUpdateFieldName = async ( + form: any, + fields: any[] +): Promise => { + for (const field of fields) { + if (field.hasOwnProperty('oldName') && field.oldName) { + const oldName = field.oldName; + const newName = field.name; + // Update records data + await Record.updateMany( + { + resource: form.resource, + [`data.${oldName}`]: { $exists: true }, + }, + { + $rename: { + [`data.${oldName}`]: `data.${newName}`, + }, + } + ); + // Update layouts source fields + // TODO: update pipelines + // const resources = await Resource.find({ + // resource: form.resource, + // layouts: { $exists: true }, + // }); + // if (resources) { + // for (const resource of resources) { + // resource.layouts.forEach((layout) => { + // layout.query.fields.forEach((field) => ) + // }); + // } + // } + // Update aggregations source fields + // TODO: update pipelines and fix it + await Resource.updateMany( + { + resource: form.resource, + }, + { + $rename: { + [`aggregations.sourceFields.${oldName}`]: `aggregations.sourceFields.${newName}`, + }, + } + ); + } + } +}; diff --git a/src/utils/form/removeField.ts b/src/utils/form/removeField.ts index 529f890ad..1b138f1ca 100644 --- a/src/utils/form/removeField.ts +++ b/src/utils/form/removeField.ts @@ -1,22 +1,27 @@ /** - * Remove field from structure and depending on the field name passed. + * Remove field from structure and depending on the field unique id and name passed. * Function by induction. * * @param structure structure of the form to edit - * @param name name of the field to search for + * @param oid unique id of the field to search for + * @param name name of the field * @returns {boolean} status of request. */ -export const removeField = (structure: any, name: string): boolean => { +export const removeField = ( + structure: any, + oid: string, + name: string +): boolean => { // Loop on elements to find the right question if (structure.pages) { for (const page of structure.pages) { - if (removeField(page, name)) return true; + if (removeField(page, oid, name)) return true; } } else if (structure.elements) { for (const elementIndex in structure.elements) { const element = structure.elements[elementIndex]; if (element.type === 'panel') { - if (removeField(element, name)) return true; + if (removeField(element, oid, name)) return true; } else { if (element.valueName === name) { // Remove from structure diff --git a/src/utils/form/replaceField.ts b/src/utils/form/replaceField.ts index 25ef6ba78..1b8618be8 100644 --- a/src/utils/form/replaceField.ts +++ b/src/utils/form/replaceField.ts @@ -5,13 +5,15 @@ import { preserveChildProperties } from './preserveChildProperties'; * Check if the structure is correct and replace the chosen field by the corresponding one in the referenceStructure. * Function by induction. * - * @param fieldName name of the field to search for + * @param fieldId unique id of the field to search for + * @param fieldName name of the field * @param editedStructure structure of the form that will be edited * @param referenceStructure structure which should be used as a reference to change field value * @param prevReferenceStructure structure which represent the previous state of the reference structure * @returns {boolean} status of request */ export const replaceField = ( + fieldId: string, fieldName: string, editedStructure: any, referenceStructure: any, @@ -22,6 +24,7 @@ export const replaceField = ( for (const page of editedStructure.pages) { if ( replaceField( + fieldId, fieldName, page, referenceStructure, @@ -37,6 +40,7 @@ export const replaceField = ( if (element.type === 'panel') { if ( replaceField( + fieldId, fieldName, element, referenceStructure, @@ -45,10 +49,15 @@ export const replaceField = ( ) return true; } else { - if (element.valueName === fieldName) { - const referenceField = getQuestion(referenceStructure, fieldName); + if (element.oid === fieldId) { + const referenceField = getQuestion( + referenceStructure, + fieldId, + fieldName + ); const prevReferenceField = getQuestion( prevReferenceStructure, + fieldId, fieldName ); // If the edited structure's field has different properties than From ec312a679b13fe909fc4e105a5f078df4dfdd0ac Mon Sep 17 00:00:00 2001 From: Estela Silva Date: Tue, 4 Jun 2024 17:19:07 +0200 Subject: [PATCH 2/3] update aggregations and layouts fields on update field name --- src/utils/form/onUpdateFieldName.ts | 178 +++++++++++++++++++++++----- 1 file changed, 151 insertions(+), 27 deletions(-) diff --git a/src/utils/form/onUpdateFieldName.ts b/src/utils/form/onUpdateFieldName.ts index 7f130df18..7b317c71e 100644 --- a/src/utils/form/onUpdateFieldName.ts +++ b/src/utils/form/onUpdateFieldName.ts @@ -1,5 +1,50 @@ +/* eslint-disable @typescript-eslint/no-loop-func */ import { Record, Resource } from '@models'; -// import i18next from 'i18next'; + +let oldName = ''; +let newName = ''; +let updated = false; + +/** + * Interact over a filter array to update each filter object inside + * + * @param filterArray to be interacted over and update each object inside + * @returns if any updated was made + */ +const updateNestedFilters = (filterArray: any) => { + if (filterArray.filters) { + let filtersUpdated = false; + for (const nestedFilter of filterArray.filters) { + filtersUpdated = updateNestedFilters(nestedFilter); + } + return filtersUpdated; + } else if (filterArray.field && filterArray.field === oldName) { + filterArray.field = newName; + return true; + } +}; + +/** + * UPdated filters, updating the field name on filters arrays and object + * + * @param filters filters array or object to look for fields to update + * @returns updated filters + */ +const updateFilters = (filters: any) => { + for (const filter of filters) { + if (filter.filters) { + let filtersUpdated = false; + for (const nestedFilter of filter.filters) { + filtersUpdated = updateNestedFilters(nestedFilter); + } + updated = filtersUpdated ? true : updated; + } else if (filter.field && filter.field === oldName) { + filter.field = newName; + updated = true; + } + } + return filters; +}; /** * Check if any field name was updated to also update records and aggregation/layouts @@ -13,8 +58,8 @@ export const onUpdateFieldName = async ( ): Promise => { for (const field of fields) { if (field.hasOwnProperty('oldName') && field.oldName) { - const oldName = field.oldName; - const newName = field.name; + oldName = field.oldName; + newName = field.name; // Update records data await Record.updateMany( { @@ -27,31 +72,110 @@ export const onUpdateFieldName = async ( }, } ); - // Update layouts source fields - // TODO: update pipelines - // const resources = await Resource.find({ - // resource: form.resource, - // layouts: { $exists: true }, - // }); - // if (resources) { - // for (const resource of resources) { - // resource.layouts.forEach((layout) => { - // layout.query.fields.forEach((field) => ) - // }); - // } - // } - // Update aggregations source fields - // TODO: update pipelines and fix it - await Resource.updateMany( - { - resource: form.resource, - }, - { - $rename: { - [`aggregations.sourceFields.${oldName}`]: `aggregations.sourceFields.${newName}`, - }, + + // Get resources + const resources = await Resource.find({ _id: form.resource }); + // Iterate through each resource and update aggregations and layouts (if needed) + for (const resource of resources) { + // Iterate through each aggregation + for (const aggregation of resource.aggregations) { + // Update sourceFields that contains the field + if (aggregation.sourceFields?.includes(oldName)) { + // Rename the field in sourceFields + aggregation.sourceFields = aggregation.sourceFields.filter( + (item: any) => item !== oldName + ); + aggregation.sourceFields.push(newName); + updated = true; + } + + // Update aggregations pipelines + if (aggregation.pipeline.length) { + for (const stage of aggregation.pipeline) { + switch (stage.type) { + case 'filter': + stage.form.filters = updateFilters(stage.form.filters); + break; + case 'sort': + if (stage.form.field === oldName) { + stage.form.field = newName; + updated = true; + } + break; + // TODO: stages user, group, label and unwind + case 'user': + break; + case 'group': + break; + case 'label': + break; + case 'unwind': + break; + default: + break; + } + } + if (updated) { + // Necessary because mongoose can't detect the modifications in the nested property aggregations + resource.markModified('aggregations'); + } + } } - ); + + // Iterate through each layout + for (const layout of resource.layouts) { + // Update fields that contains the field + const fieldIndex = layout.query.fields?.findIndex( + (layoutField: any) => layoutField.name === oldName + ); + if (fieldIndex !== -1) { + // Rename the field in the layout query fields + layout.query.fields[fieldIndex] = { + ...layout.query.fields[fieldIndex], + name: newName, + }; + updated = true; + } + + // Update filters that contains the field + layout.query.filter.filters = updateFilters( + layout.query.filter.filters + ); + + // Update sort rules that contains the field + for (const sort of layout.query.sort) { + if (sort.field === oldName) { + sort.field = newName; + updated = true; + } + } + + // Update style rules that contains the field + for (const style of layout.query.style) { + if (style.filter) { + style.filter.filters = updateFilters(style.filter.filters); + } + if (style.fields) { + if (style.fields.includes(oldName)) { + style.fields = style.fields.filter( + (item: any) => item !== oldName + ); + style.fields.push(newName); + updated = true; + } + } + } + + if (updated) { + // Necessary because mongoose can't detect the modifications in the nested property layouts + resource.markModified('layouts'); + } + } + // If any aggregation or layout was updated, save the resource + if (updated) { + await resource.save(); + } + } } } }; From 60af74aa50d742cb1a63cfa3c4d943cf75e3c22c Mon Sep 17 00:00:00 2001 From: Estela Silva Date: Wed, 5 Jun 2024 16:05:10 +0200 Subject: [PATCH 3/3] remove oldName property from structure and fields and finish aggregation pipeline stages rename --- src/schema/mutation/editForm.mutation.ts | 34 +++++++++++++++++++-- src/utils/form/onUpdateFieldName.ts | 38 ++++++++++++++++++++++-- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/src/schema/mutation/editForm.mutation.ts b/src/schema/mutation/editForm.mutation.ts index 2fb39d924..05d861c38 100644 --- a/src/schema/mutation/editForm.mutation.ts +++ b/src/schema/mutation/editForm.mutation.ts @@ -532,12 +532,42 @@ export default { update.$push = { versions: version._id }; } - const resForm = await Form.findByIdAndUpdate(args.id, update, { + let resForm = await Form.findByIdAndUpdate(args.id, update, { new: true, }); // Check if any field name was updated to also update records and aggregation/layouts - await onUpdateFieldName(form, update.fields); + const updatedFieldName = await onUpdateFieldName(form, update.fields); + if (updatedFieldName) { + //Remove oldName property from form structure and fields + const structure = JSON.parse(update.structure); + const fields = update.fields.map((field: any) => { + if (field.oldName) { + delete field.oldName; + } + return field; + }); + const pages = structure.pages.map((page: any) => { + page.elements = page.elements.map((element: any) => { + if (element.oldName) { + delete element.oldName; + } + return element; + }); + return page; + }); + structure.pages = pages; + resForm = await Form.findByIdAndUpdate( + args.id, + { + structure: JSON.stringify(structure), + fields: fields, + }, + { + new: true, + } + ); + } // Return updated form return resForm; } catch (err) { diff --git a/src/utils/form/onUpdateFieldName.ts b/src/utils/form/onUpdateFieldName.ts index 7b317c71e..e12652f7a 100644 --- a/src/utils/form/onUpdateFieldName.ts +++ b/src/utils/form/onUpdateFieldName.ts @@ -51,11 +51,12 @@ const updateFilters = (filters: any) => { * * @param form form updated * @param fields list of fields + * @returns if updates were made because of field names */ export const onUpdateFieldName = async ( form: any, fields: any[] -): Promise => { +): Promise => { for (const field of fields) { if (field.hasOwnProperty('oldName') && field.oldName) { oldName = field.oldName; @@ -102,14 +103,45 @@ export const onUpdateFieldName = async ( updated = true; } break; - // TODO: stages user, group, label and unwind case 'user': + if (stage.form.field === oldName) { + stage.form.field = newName; + updated = true; + } break; case 'group': + for (const groupBy of stage.form.groupBy) { + if (groupBy.field === oldName) { + groupBy.field = newName; + updated = true; + } + if (groupBy.expression.field === oldName) { + groupBy.expression.field = newName; + updated = true; + } + } + for (const addFields of stage.form.addFields) { + if (addFields.expression.field === oldName) { + addFields.expression.field = newName; + updated = true; + } + } break; case 'label': + if (stage.form.field === oldName) { + stage.form.field = newName; + updated = true; + } + if (stage.form.copyFrom === oldName) { + stage.form.copyFrom = newName; + updated = true; + } break; case 'unwind': + if (stage.form.field === oldName) { + stage.form.field = newName; + updated = true; + } break; default: break; @@ -175,7 +207,9 @@ export const onUpdateFieldName = async ( if (updated) { await resource.save(); } + return true; } } } + return false; };