diff --git a/api/src/routes/org.routes.ts b/api/src/routes/org.routes.ts index 659861e07..92da6f198 100644 --- a/api/src/routes/org.routes.ts +++ b/api/src/routes/org.routes.ts @@ -21,7 +21,7 @@ router.get("/stacks/:searchText?", asyncRouter(orgController.getAllStacks)); */ router.post( "/stacks", - validator("project"), + validator("stack"), asyncRouter(orgController.createStack) ); diff --git a/api/src/validators/index.ts b/api/src/validators/index.ts index 1dd1e1549..f0b862fbf 100644 --- a/api/src/validators/index.ts +++ b/api/src/validators/index.ts @@ -9,6 +9,7 @@ import destinationStackValidator from "./destination-stack.validator.js"; import affixValidator from "./affix.validator.js"; import affixConfirmationValidator from "./affix-confirmation.validator.js"; import fileformatConfirmationValidator from "./fileformat-confirmation.validator.js"; +import stackValidator from "./stack.validator.js"; /** * Middleware function that validates the request based on the specified route. @@ -26,6 +27,7 @@ export default (route: string = "") => affix: affixValidator, affix_confirmation_validator: affixConfirmationValidator, fileformat_confirmation_validator: fileformatConfirmationValidator, + stack: stackValidator }; const validator = appValidators[route as keyof typeof appValidators]; diff --git a/api/src/validators/stack.validator.ts b/api/src/validators/stack.validator.ts new file mode 100644 index 000000000..9938217b8 --- /dev/null +++ b/api/src/validators/stack.validator.ts @@ -0,0 +1,46 @@ +import { checkSchema } from "express-validator"; +import { VALIDATION_ERRORS } from "../constants/index.js"; + +/** + * Validates the stack data. + * + * @returns {Object} The validation schema for the stack data. + */ + +export default checkSchema({ + name: { + in: "body", + isString: { + errorMessage: VALIDATION_ERRORS.STRING_REQUIRED.replace("$", "Name"), + bail: true, + }, + trim: true, + isLength: { + errorMessage: VALIDATION_ERRORS.LENGTH_LIMIT.replace("$", "Name"), + options: { + min: 1, + max: 255, + }, + bail: true, + }, + }, + description: { + in: "body", + isString: { + errorMessage: VALIDATION_ERRORS.STRING_REQUIRED.replace( + "$", + "Description" + ), + bail: true, + }, + trim: true, + isLength: { + errorMessage: VALIDATION_ERRORS.LENGTH_LIMIT.replace("$", "Description"), + options: { + min: 1, + max: 512, + }, + bail: true, + }, + }, +}); \ No newline at end of file diff --git a/ui/src/components/Common/SaveChangesModal/index.tsx b/ui/src/components/Common/SaveChangesModal/index.tsx index 1086c4b6e..42207bdea 100644 --- a/ui/src/components/Common/SaveChangesModal/index.tsx +++ b/ui/src/components/Common/SaveChangesModal/index.tsx @@ -1,11 +1,10 @@ - +// Libraries import { ModalBody, ModalHeader, ModalFooter, ButtonGroup, Button, - Paragraph } from '@contentstack/venus-components'; interface Props { @@ -13,24 +12,44 @@ interface Props { isopen: any; otherCmsTitle?: string; saveContentType: () => void; - openContentType: () => void; + openContentType?: () => void; + changeStep?: () => void; } -const SaveChangesModal = (props:Props) => { +const SaveChangesModal = (props: Props) => { return( - <> - {props?.closeModal(),props.isopen(false)}} className="text-capitalize" /> - -
- -
+ <> + { + props?.closeModal(); + props.isopen(false); + }} + className="text-capitalize" + /> + + You have unsaved changes on content type {props?.otherCmsTitle || ''}. Save your changes if you don't want to lose them. - - + + diff --git a/ui/src/components/ContentMapper/contentMapper.interface.ts b/ui/src/components/ContentMapper/contentMapper.interface.ts index cdc6ba0af..723e63474 100644 --- a/ui/src/components/ContentMapper/contentMapper.interface.ts +++ b/ui/src/components/ContentMapper/contentMapper.interface.ts @@ -91,6 +91,7 @@ export interface ContentTypesSchema { data_type?: 'text' | 'number' | 'isodate' | 'json' | 'file' | 'reference' | 'group' | 'boolean' | 'link'; field_metadata?: FieldMetadata; enum?: any; + schema?: ContentTypesSchema[] } // export interface ContentTypesSchema { // [key: string]: ContentTypeField; diff --git a/ui/src/components/ContentMapper/index.tsx b/ui/src/components/ContentMapper/index.tsx index 9f60fdd52..d3a3f7741 100644 --- a/ui/src/components/ContentMapper/index.tsx +++ b/ui/src/components/ContentMapper/index.tsx @@ -1,5 +1,5 @@ // Libraries -import { useEffect, useState, useRef } from 'react'; +import { useEffect, useState, useRef, useImperativeHandle, forwardRef } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useParams } from 'react-router-dom'; import { @@ -32,7 +32,7 @@ import { updateMigrationData, updateNewMigrationData } from '../../store/slice/m // Utilities import { CS_ENTRIES, CONTENT_MAPPING_STATUS, STATUS_ICON_Mapping } from '../../utilities/constants'; -import { validateArray } from '../../utilities/functions'; +import { isEmptyString, validateArray } from '../../utilities/functions'; // Interface import { DEFAULT_CONTENT_MAPPING_DATA, INewMigration } from '../../context/app/app.interface'; @@ -62,6 +62,7 @@ import SaveChangesModal from '../Common/SaveChangesModal'; // Styles import './index.scss'; import { MigrationResponse } from '../../services/api/service.interface'; +import { schemaType } from '../SchemaModal/schemaModal.interface'; const dummy_obj:any = { 'single_line_text':{ label : 'Single Line Textbox', @@ -199,10 +200,13 @@ const Fields: Mapping = { type ContentMapperComponentProps = { projectData: MigrationResponse; - }; -const ContentMapper = ({projectData}:ContentMapperComponentProps) => { +type ContentTypeSaveHandles = { + handleSaveContentType?: () => {}; +} + +const ContentMapper = forwardRef(({projectData}: ContentMapperComponentProps, ref: React.ForwardedRef) => { /** ALL CONTEXT HERE */ const migrationData = useSelector((state:RootState)=>state?.migration?.migrationData); @@ -266,11 +270,14 @@ const ContentMapper = ({projectData}:ContentMapperComponentProps) => { const [showFilter, setShowFilter] = useState(false); const [filteredContentTypes, setFilteredContentTypes] = useState([]) const [count, setCount] = useState(0); + const [isModalOpen, setIsModalOpen] = useState(false); + const [nestedList, setNestedList] = useState([]); + const [disabledOptions, setDisabledOptions] = useState>(new Set()); /** ALL HOOKS Here */ const { projectId = '' } = useParams(); - const ref = useRef(null); + const filterRef = useRef(null); /********** ALL USEEFFECT HERE *************/ useEffect(() => { @@ -282,7 +289,7 @@ const ContentMapper = ({projectData}:ContentMapperComponentProps) => { dispatch(updateMigrationData({ contentMappingData: DEFAULT_CONTENT_MAPPING_DATA })); } - dispatch(updateMigrationData({ contentMappingData: data })); + dispatch(updateMigrationData({ contentMappingData: data})); }) .catch((err) => { console.error(err); @@ -338,11 +345,46 @@ const ContentMapper = ({projectData}:ContentMapperComponentProps) => { // To close the filter panel on outside click useEffect(() => { document.addEventListener('click', handleClickOutside, true); + return () => { - document.removeEventListener('click', handleClickOutside, true); + document.removeEventListener('click', handleClickOutside, true); }; }, []); + // To dispatch the changed dropdown state + useEffect(() => { + const newMigrationDataObj: INewMigration = { + ...newMigrationData, + content_mapping: { + ...newMigrationData?.content_mapping, + // content_type_mapping: contentTypeMapped, + isDropDownChanged: isDropDownChanged, + otherCmsTitle: otherCmsTitle, + } + }; + + dispatch(updateNewMigrationData((newMigrationDataObj))); + }, [isDropDownChanged]); + + useEffect(()=>{ + const handlePopState = (event: PopStateEvent) => { + event.preventDefault(); + window.history.pushState(null, '', window.location.href); + handleOpenContentType(); + + }; + + if(isModalOpen || isDropDownChanged){ + window.history.pushState(null, '', window.location.href); + } + window.history.pushState(null, '', window.location.href); + window.addEventListener('popstate',handlePopState); + return () => { + window.removeEventListener('popstate', handlePopState); + }; + + },[isModalOpen]); + // Method to fetch content types const fetchContentTypes = async (searchText: string) => { const { data } = await getContentTypes(projectId || '', 0, 5000, searchContentType || ''); //org id will always present @@ -408,6 +450,8 @@ const ContentMapper = ({projectData}:ContentMapperComponentProps) => { setTableData(newTableData || []); setTotalCounts(data?.count); + + generateSourceGroupSchema(data?.fieldMapping); } catch (error) { console.error('fetchData -> error', error); } @@ -449,12 +493,11 @@ const ContentMapper = ({projectData}:ContentMapperComponentProps) => { } }; - const [isModalOpen, setIsModalOpen] = useState(false); - // Method to change the content type - const handleOpenContentType = (i: number) => { + const handleOpenContentType = async (i = 0) => { if (isDropDownChanged) { setIsModalOpen(true); + handleDropdownState(); return cbModal({ component: (props: ModalObj) => ( { }; const openContentType = (i: number) => { + // setIsModalOpen(false); + setActive(i); const otherTitle = contentTypes?.[i]?.otherCmsTitle; setOtherCmsTitle(otherTitle); @@ -652,15 +697,17 @@ const ContentMapper = ({projectData}:ContentMapperComponentProps) => { isClearable={false} options={option} isDisabled={ - data?.ContentstackFieldType === 'group' || + data?.otherCmsType === "Group" || data?.otherCmsField === 'title' || - data?.otherCmsField === 'url' + data?.otherCmsField === 'url' || + data?.otherCmsType === "reference" } /> - {data?.ContentstackFieldType !== 'group' && + {data?.otherCmsType !== 'Group' && data?.otherCmsField !== 'title' && data?.otherCmsField !== 'url' && + data?.otherCmsType !== 'reference' && { const handleFieldChange = (selectedValue: FieldTypes, rowIndex: string) => { setisDropDownCHanged(true); + const previousSelectedValue = exstingField[rowIndex]?.label; + setexsitingField((prevOptions) => ({ ...prevOptions, [rowIndex]: { label: selectedValue?.label, value: selectedValue?.value } })); + setadvancePropertise({ validationRegex: selectedValue?.value?.format, Mandatory: selectedValue?.value?.mandatory, @@ -697,12 +747,24 @@ const ContentMapper = ({projectData}:ContentMapperComponentProps) => { NonLocalizable: selectedValue?.value?.non_localizable }); - if (isDropDownChanged && isContentTypeSaved) { - setSelectedOptions((prevSelected) => { - const newValue = selectedValue?.label; - return prevSelected?.includes(newValue) ? prevSelected : [...prevSelected, newValue]; - }); - } + setDisabledOptions((prevDisabledOptions) => { + const newDisabledOptions = new Set(prevDisabledOptions); + newDisabledOptions.add(selectedValue?.label); + return newDisabledOptions; + }); + + //add selected option to array if it is not mapped to any other field + setSelectedOptions((prevSelected) => { + const newSelectedOptions = prevSelected.filter( + (item) => item !== previousSelectedValue + ); + const newValue = selectedValue?.label; + if (!newSelectedOptions.includes(newValue)) { + newSelectedOptions.push(newValue); + } + return newSelectedOptions; + }); + const updatedRows = tableData.map((row) => { if (row?.uid === rowIndex) { @@ -725,11 +787,156 @@ const ContentMapper = ({projectData}:ContentMapperComponentProps) => { setSelectedEntries(updatedRows as FieldMapType[]); }; + //function to generate group schema structure of source cms + const generateSourceGroupSchema = ( schema:any) =>{ + + let groupId = ''; + const data: any = []; + schema?.forEach((field:any) => { + if (field?.ContentstackFieldType === 'group') { + groupId = field?.uid; + data?.push({ ...field, child: [] }); + } else { + if (field?.uid?.startsWith(groupId + '.')) { + const obj = data[data?.length - 1]; + if (Object.prototype.hasOwnProperty.call(obj, 'child')) { + obj?.child?.push(field); + } else { + obj.child = [field]; + } + } else { + data.push({ ...field, child: [] }); + } + } + }); + setNestedList(data); + } + + //utility function to create option object + function getMatchingOption(value:any, matchFound:boolean, label:any) { + return matchFound ? { label, value, isDisabled: selectedOptions.includes(label) } : null + } + + //utility function to map the source cms field type to content type field type + function checkConditions(fieldTypeToMatch:any, value:any, data:any) { + + const fieldTypes = new Set(['number', 'isodate', 'file', 'reference', 'boolean', 'group', 'link','global_field']); + switch (fieldTypeToMatch) { + case 'text': + return ( + (value?.uid !== 'title' && + data?.uid !== 'title') && + (value?.uid !== 'url' && + data?.uid !== 'url') && + !fieldTypes.has(value?.data_type || '') && + !value?.field_metadata?.multiline && + !value?.enum && + !value?.field_metadata?.allow_rich_text && + !value?.field_metadata?.allow_json_rte && + !value?.field_metadata?.markdown + ); + case 'multiline': + return value?.field_metadata?.multiline === true; + case 'url': + return value?.uid === 'url'; + case 'file': + return value?.data_type === 'file'; + case 'number': + return value?.data_type === 'number' && !value?.enum; + case 'isodate': + return value?.data_type === 'isodate'; + case 'json': + return value?.data_type === 'json'; + case 'enum': + return 'enum' in value; + case 'allow_rich_text': + return value?.field_metadata?.allow_rich_text === true; + case 'Group': + return value?.data_type === 'group'; + case 'reference': + return value?.data_type === 'reference'; + case 'boolean': + return value?.data_type === 'boolean'; + default: + return false; + } + } + + //function to process the nested group structure present in contentstack content type + const processSchema = ( + value: any, + data: any, + array: any, + OptionsForRow: any[], + fieldsOfContentstack: any, + currentDisplayName = '' + ) => { + // Update the current display name with the current value's display name + const updatedDisplayName = currentDisplayName ? `${currentDisplayName} > ${value?.display_name}` : value?.display_name; + + if (value?.data_type === 'group') { + + // Check and process the group itself + if (data?.otherCmsType === 'Group' && checkConditions('Group', value, data)) { + + OptionsForRow.push(getMatchingOption(value, true, updatedDisplayName)); + + // Process nested groups within this group + for (const key of value.schema || []) { + if (key?.data_type === 'group') { + processSchema(key, data, array, OptionsForRow, fieldsOfContentstack, updatedDisplayName); + } + } + } + // Process nested schemas within the current group + for (const item of array) { + const fieldTypeToMatch = fieldsOfContentstack[item?.otherCmsType as keyof Mapping]; + if (item.id === data?.id) { + for (const key of value.schema || []) { + if (checkConditions(fieldTypeToMatch, key, item)) { + OptionsForRow.push(getMatchingOption(key, true, `${updatedDisplayName} > ${key.display_name}` || '')); + } + + // Recursively process nested groups + if (key?.data_type === 'group') { + processSchema(key, data, array, OptionsForRow, fieldsOfContentstack, updatedDisplayName); + } + } + } + } + } else { + + const fieldTypeToMatch = fieldsOfContentstack[data?.otherCmsType as keyof Mapping]; + if (!array.some((item :any)=> item.id === data?.id) && checkConditions(fieldTypeToMatch, value, data)) { + OptionsForRow.push(getMatchingOption(value, true, updatedDisplayName || '')); + } + + // Process nested schemas if value is not a group + for (const item of array) { + if (item.id === data?.id) { + for (const key of value.schema || []) { + if (checkConditions(fieldTypeToMatch, key, item)) { + OptionsForRow.push(getMatchingOption(key, true, `${updatedDisplayName} > ${key.display_name}` || '')); + } + + // Recursively process nested groups + if (key?.data_type === 'group') { + processSchema(key, data, array, OptionsForRow, fieldsOfContentstack, updatedDisplayName); + } + } + } + } + } + + return OptionsForRow; + }; + + + const SelectAccessorOfColumn = (data: FieldMapType) => { - // object for storing select options according to mapped field + // Fetch options for the current row from dummy_obj based on backupFieldType( empty stack options) const OptionsForEachRow = dummy_obj?.[data?.backupFieldType]?.options; - - // Mapping of field types + const fieldsOfContentstack: Mapping = { 'Single Line Textbox': 'text', 'Single-Line Text': 'text', @@ -738,136 +945,70 @@ const ContentMapper = ({projectData}:ContentMapperComponentProps) => { 'multiline': 'multiline', 'HTML Rich text Editor': 'allow_rich_text', 'JSON Rich Text Editor': 'json', - 'Rich Text':'json', + 'Rich Text': 'json', 'Group': 'Group', 'URL': 'url', 'file': 'file', - 'Image':'file', + 'Image': 'file', 'number': 'number', - 'Integer':'number', + 'Integer': 'number', 'Date': 'isodate', 'boolean': 'boolean', - 'Checkbox':'boolean', + 'Checkbox': 'boolean', 'link': 'link', 'reference': 'reference', 'dropdown': 'enum', - 'Droplist':'enum', - 'radio': 'enum', - //'CheckBox': 'enum' + 'Droplist': 'enum', + 'radio': 'enum' }; - - //array of options if exsting content type has selected - const OptionsForRow: optionsType[] = []; - + + const OptionsForRow: any[] = []; + + // If OtherContentType label and contentTypesList are present, set the contentTypeSchema if (OtherContentType?.label && contentTypesList) { - const ContentType: any = contentTypesList?.find( + const ContentType:any = contentTypesList?.find( ({ title }) => title === OtherContentType?.label ); - setContentTypeSchema(ContentType?.schema) + setContentTypeSchema(ContentType?.schema); } - - // If content type schema is available and valid + if (contentTypeSchema && validateArray(contentTypeSchema)) { const fieldTypeToMatch = fieldsOfContentstack[data?.otherCmsType as keyof Mapping]; - - //check if UID of source cms field is matching with contentstack content type fields + + //check if UID of souce field is matching to exsting content type field UID for (const value of contentTypeSchema) { - if (data?.uid === value?.uid) { - OptionsForRow.push({ label: value?.display_name, value: value, isDisabled: false }); + if (data?.uid === value?.uid || (data?.uid === value?.uid && data?.otherCmsType === value?.data_type)) { + OptionsForRow.push({ label: value?.display_name, value, isDisabled: false }); break; } } - // If UID does not match then check for field type - if(OptionsForRow.length === 0){ - for (const value of contentTypeSchema) { - const fieldTypes = new Set(['number', 'isodate', 'file', 'reference', 'boolean', 'group', 'link']); - - switch (fieldTypeToMatch) { - case 'text': - if ( - value?.uid !== 'title' && - value?.uid !=='url' && - !fieldTypes.has(value?.data_type || '') && - !value?.field_metadata?.multiline && - !value?.enum && - !value?.field_metadata?.allow_rich_text && - !value?.field_metadata?.allow_json_rte && - !value?.field_metadata?.markdown - ) { - OptionsForRow.push({ label: value?.display_name, value: value, isDisabled: false }); - } - break; - case 'multiline': - if (value?.field_metadata?.multiline === true) { - OptionsForRow.push({ label: value?.display_name, value: value, isDisabled: false }); - } - break; - case 'url': - if (value?.uid === 'url') { - OptionsForRow.push({ label: value?.display_name, value: value, isDisabled: false }); - } - break; - case 'file': - if (value?.data_type === 'file') { - OptionsForRow.push({ label: value?.display_name, value: value, isDisabled: false }); - } - break; - case 'number': - if (value?.data_type === 'number' && !value?.enum) { - OptionsForRow.push({ label: value?.display_name, value: value, isDisabled: false }); - } - break; - case 'isodate': - if (value?.data_type === 'isodate') { - OptionsForRow.push({ label: value?.display_name, value: value, isDisabled: false }); - } - break; - case 'json': - if (value?.data_type === 'json') { - OptionsForRow.push({ label: value?.display_name, value: value, isDisabled: false }); - } - break; - case 'enum': - if ('enum' in value) { - OptionsForRow.push({ label: value?.display_name, value: value, isDisabled: false }); - } - break; - case 'allow_rich_text': - if (value?.field_metadata?.allow_rich_text === true) { - OptionsForRow.push({ label: value?.display_name, value: value, isDisabled: false }); - } - break; - case 'Group': - if (value?.data_type === 'group') { - OptionsForRow.push({ label: value?.display_name, value: value, isDisabled: false }); - } - break; - case 'reference': - if (value?.data_type === 'reference') { - OptionsForRow.push({ label: value?.display_name, value: value, isDisabled: false }); - } - break; + + if (OptionsForRow.length === 0) { + for (const value of contentTypeSchema) { - default: - OptionsForRow.push({ - label: 'No matches found', - value: { 'No matches found': '' }, - isDisabled: false - }); - break; + const groupArray = nestedList.filter(item => + item.child && item.child.some(e => e.id === data?.id) + ); + + const array = groupArray[0]?.child || [] + if(value.data_type === 'group'){ + processSchema(value, data, array, OptionsForRow, fieldsOfContentstack) + } + else if (!array.some(item => item.id === data?.id) && checkConditions(fieldTypeToMatch, value, data)) { + + OptionsForRow.push(getMatchingOption(value, true, value?.display_name || '')); + + } } - - }} + } } - - // Variable to store length of options - const selectedOption = OptionsForRow?.length; - - //variable to store the options if exsting contentstack content type is not selected - let option:any; + + const selectedOption = OptionsForRow.length; + + let option: any; if (Array.isArray(OptionsForEachRow)) { - option = OptionsForEachRow.map((option) => ({ + option = OptionsForEachRow.map((option) => ({ label: option, value: option, })); @@ -876,60 +1017,59 @@ const ContentMapper = ({projectData}:ContentMapperComponentProps) => { label, value, })); - }else{ - option = [{ label: OptionsForEachRow, value: OptionsForEachRow }] + } else { + option = [{ label: OptionsForEachRow, value: OptionsForEachRow }]; } - - const OptionValue: any = - OptionsForRow?.length === 1 && - //disable url, title and group fields - (OptionsForRow?.[0]?.value?.uid === 'url' || OptionsForRow?.[0]?.value?.uid === 'title' ||OptionsForRow?.[0]?.value?.uid === 'group') - ? { - label: OptionsForRow?.[0]?.value?.display_name, - value: OptionsForRow?.[0]?.value, - isDisabled: true - } - : OptionsForRow?.length === 0 - ? { - label: dummy_obj[data?.ContentstackFieldType]?.label, - value: dummy_obj[data?.ContentstackFieldType]?.label, - isDisabled: data?.ContentstackFieldType === 'text' || - data?.ContentstackFieldType === 'group' || - data?.ContentstackFieldType === 'url' - } - : { - label: `${selectedOption} matches`, - value: `${selectedOption} matches`, - isDisabled: false - }; - // Adjust the options based on whether existing contentstack content type is selected - const adjustedOptions = OptionsForRow.length === 0 ? option - : OptionsForRow.map((option: optionsType) => ({ - ...option, - isDisabled: selectedOptions?.includes(option?.label ?? '') - })); + const OptionValue: any = + OptionsForRow.length === 1 && + (OptionsForRow[0]?.value?.uid === 'url' || OptionsForRow[0]?.value?.uid === 'title' || OptionsForRow[0]?.value?.data_type === 'group') + ? { + label: OptionsForRow[0]?.value?.display_name, + value: OptionsForRow[0]?.value, + isDisabled: true + } + : (OptionsForRow.length === 0 || (OptionsForRow.length > 0 && OptionsForRow.every((item)=>item.isDisabled) + && ! exstingField[data?.uid] )) + ? { + label: dummy_obj[data?.ContentstackFieldType]?.label, + value: dummy_obj[data?.ContentstackFieldType]?.label, + isDisabled: data?.ContentstackFieldType === 'text' || + data?.ContentstackFieldType === 'group' || + data?.ContentstackFieldType === 'url' + } + : { + label: `${selectedOption} matches`, + value: `${selectedOption} matches`, + isDisabled: false + }; + const adjustedOptions = (OptionsForRow.length === 0 && !contentTypeSchema) ? option : + (OptionsForRow.length > 0 && OptionsForRow.every((item)=>item.isDisabled) && OptionValue.label === dummy_obj[data?.ContentstackFieldType]?.label) ? [] + : OptionsForRow.map((option: optionsType) => ({ + ...option, + isDisabled: selectedOptions.includes(option?.label ?? '') + })); + + return (