@payloadcms/ui 3.87.0 → 3.88.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"mergeFormStateFromClipboard.d.ts","sourceRoot":"","sources":["../../../src/elements/ClipboardAction/mergeFormStateFromClipboard.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,SAAS,CAAA;AAIpD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAA;AAIpD,wBAAgB,qBAAqB,CAAC,EACpC,SAAS,EACT,IAAI,EACJ,QAAQ,GACT,EAAE;IACD,SAAS,EAAE,SAAS,CAAA;IACpB,IAAI,EAAE,MAAM,CAAA;IACZ,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB,8BAyBA;AAED,wBAAgB,2BAA2B,CAAC,EAC1C,iBAAiB,EAAE,aAAa,EAChC,SAAS,EACT,IAAI,EACJ,QAAQ,GACT,EAAE;IACD,iBAAiB,EAAE,kBAAkB,CAAA;IACrC,SAAS,EAAE,SAAS,CAAA;IACpB,IAAI,EAAE,MAAM,CAAA;IACZ,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB,aA0IA"}
1
+ {"version":3,"file":"mergeFormStateFromClipboard.d.ts","sourceRoot":"","sources":["../../../src/elements/ClipboardAction/mergeFormStateFromClipboard.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,SAAS,CAAA;AAIpD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAA;AAYpD,wBAAgB,qBAAqB,CAAC,EACpC,SAAS,EACT,IAAI,EACJ,QAAQ,GACT,EAAE;IACD,SAAS,EAAE,SAAS,CAAA;IACpB,IAAI,EAAE,MAAM,CAAA;IACZ,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB,8BAyBA;AAED,wBAAgB,2BAA2B,CAAC,EAC1C,iBAAiB,EAAE,aAAa,EAChC,SAAS,EACT,IAAI,EACJ,QAAQ,GACT,EAAE;IACD,iBAAiB,EAAE,kBAAkB,CAAA;IACrC,SAAS,EAAE,SAAS,CAAA;IACpB,IAAI,EAAE,MAAM,CAAA;IACZ,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB,aA0IA"}
@@ -1,5 +1,12 @@
1
1
  import ObjectIdImport from 'bson-objectid';
2
2
  const ObjectId = 'default' in ObjectIdImport ? ObjectIdImport.default : ObjectIdImport;
3
+ // Strict path-prefix check: matches `prefix` exactly, or any descendant path (`prefix.`).
4
+ // Required because numeric row indices share digit prefixes (`.1` vs `.10`/`.11`), and
5
+ // field names can also share textual prefixes (`children` vs `childrenOther`). A loose
6
+ // `startsWith(prefix)` leaks unrelated keys into clipboard / cleanup operations.
7
+ function isStrictPathPrefix(key, prefix) {
8
+ return key === prefix || key.startsWith(`${prefix}.`);
9
+ }
3
10
  export function reduceFormStateByPath({
4
11
  formState,
5
12
  path,
@@ -8,7 +15,7 @@ export function reduceFormStateByPath({
8
15
  const filteredState = {};
9
16
  const prefix = typeof rowIndex !== 'number' ? path : `${path}.${rowIndex}`;
10
17
  for (const key in formState) {
11
- if (!key.startsWith(prefix)) {
18
+ if (!isStrictPathPrefix(key, prefix)) {
12
19
  continue;
13
20
  }
14
21
  const {
@@ -78,7 +85,7 @@ export function mergeFormStateFromClipboard({
78
85
  formState[path].initialValue = 1;
79
86
  formState[path].disableFormData = true;
80
87
  for (const fieldPath in formState) {
81
- if (fieldPath !== path && !fieldPath.startsWith(lastRenderedPath) && fieldPath.startsWith(path)) {
88
+ if (fieldPath !== path && !isStrictPathPrefix(fieldPath, lastRenderedPath) && isStrictPathPrefix(fieldPath, path)) {
82
89
  delete formState[fieldPath];
83
90
  }
84
91
  }
@@ -89,7 +96,7 @@ export function mergeFormStateFromClipboard({
89
96
  // When pasting into a specific row, skip only the direct row ID to avoid overwriting
90
97
  // the target row's identity. Nested IDs (array item IDs within the block) should
91
98
  // still be processed so they get regenerated below, preventing server-side duplication.
92
- if (!pasteIntoField && clipboardPath === `${pathToReplace}.id` || !clipboardPath.startsWith(pathToReplace)) {
99
+ if (!pasteIntoField && clipboardPath === `${pathToReplace}.id` || !isStrictPathPrefix(clipboardPath, pathToReplace)) {
93
100
  continue;
94
101
  }
95
102
  const newPath = clipboardPath.replace(pathToReplace, targetSegment);
@@ -1 +1 @@
1
- {"version":3,"file":"mergeFormStateFromClipboard.js","names":["ObjectIdImport","ObjectId","default","reduceFormStateByPath","formState","path","rowIndex","filteredState","prefix","key","startsWith","customComponents","_","validate","__","field","Array","isArray","rows","map","row","serializableRow","mergeFormStateFromClipboard","dataFromClipboard","clipboardData","type","typeFromClipboard","data","pathFromClipboard","rowIndexFromClipboard","copyFromField","pasteIntoField","fromRowToField","pathToReplace","targetSegment","lastRenderedPath","rowIDFromClipboard","value","hasRows","length","id","isLoading","initialValue","disableFormData","fieldPath","idReplacements","Map","clipboardPath","newPath","replace","undefined","endsWith","oldID","isValid","newID","toHexString","set","relativePath","segments","split","parseInt","parentFieldPath","slice","join","fullParentPath","parentRows","isNaN","targetParts","lastPart","rowIndexFromTarget"],"sources":["../../../src/elements/ClipboardAction/mergeFormStateFromClipboard.ts"],"sourcesContent":["import type { FieldState, FormState } from 'payload'\n\nimport ObjectIdImport from 'bson-objectid'\n\nimport type { ClipboardPasteData } from './types.js'\n\nconst ObjectId = 'default' in ObjectIdImport ? ObjectIdImport.default : ObjectIdImport\n\nexport function reduceFormStateByPath({\n formState,\n path,\n rowIndex,\n}: {\n formState: FormState\n path: string\n rowIndex?: number\n}) {\n const filteredState: Record<string, FieldState> = {}\n const prefix = typeof rowIndex !== 'number' ? path : `${path}.${rowIndex}`\n\n for (const key in formState) {\n if (!key.startsWith(prefix)) {\n continue\n }\n\n const { customComponents: _, validate: __, ...field } = formState[key]\n\n if (Array.isArray(field.rows)) {\n field.rows = field.rows.map((row) => {\n if (!row || typeof row !== 'object') {\n return row\n }\n const { customComponents: _, ...serializableRow } = row\n return serializableRow\n })\n }\n\n filteredState[key] = field\n }\n\n return filteredState\n}\n\nexport function mergeFormStateFromClipboard({\n dataFromClipboard: clipboardData,\n formState,\n path,\n rowIndex,\n}: {\n dataFromClipboard: ClipboardPasteData\n formState: FormState\n path: string\n rowIndex?: number\n}) {\n const {\n type: typeFromClipboard,\n data: dataFromClipboard,\n path: pathFromClipboard,\n rowIndex: rowIndexFromClipboard,\n } = clipboardData\n\n const copyFromField = typeof rowIndexFromClipboard !== 'number'\n const pasteIntoField = typeof rowIndex !== 'number'\n const fromRowToField = !copyFromField && pasteIntoField\n const isArray = typeFromClipboard === 'array'\n\n let pathToReplace: string\n if (copyFromField && pasteIntoField) {\n pathToReplace = pathFromClipboard\n } else if (copyFromField) {\n pathToReplace = `${pathFromClipboard}.${rowIndex}`\n } else {\n pathToReplace = `${pathFromClipboard}.${rowIndexFromClipboard}`\n }\n\n let targetSegment: string\n if (!pasteIntoField) {\n targetSegment = `${path}.${rowIndex}`\n } else if (fromRowToField) {\n targetSegment = `${path}.0`\n } else {\n targetSegment = path\n }\n\n if (fromRowToField) {\n const lastRenderedPath = `${path}.0`\n const rowIDFromClipboard = dataFromClipboard[`${pathToReplace}.id`]?.value as string\n const hasRows = formState[path].rows?.length\n\n formState[path].rows = [\n {\n ...(hasRows && isArray ? formState[path].rows[0] : {}),\n id: rowIDFromClipboard,\n isLoading: false,\n lastRenderedPath,\n },\n ]\n formState[path].value = 1\n formState[path].initialValue = 1\n formState[path].disableFormData = true\n\n for (const fieldPath in formState) {\n if (\n fieldPath !== path &&\n !fieldPath.startsWith(lastRenderedPath) &&\n fieldPath.startsWith(path)\n ) {\n delete formState[fieldPath]\n }\n }\n }\n\n // Map to track old IDs to new IDs for regenerating nested IDs\n const idReplacements: Map<string, string> = new Map()\n\n for (const clipboardPath in dataFromClipboard) {\n // When pasting into a specific row, skip only the direct row ID to avoid overwriting\n // the target row's identity. Nested IDs (array item IDs within the block) should\n // still be processed so they get regenerated below, preventing server-side duplication.\n if (\n (!pasteIntoField && clipboardPath === `${pathToReplace}.id`) ||\n !clipboardPath.startsWith(pathToReplace)\n ) {\n continue\n }\n\n const newPath = clipboardPath.replace(pathToReplace, targetSegment)\n\n const customComponents = isArray ? formState[newPath]?.customComponents : undefined\n const validate = isArray ? formState[newPath]?.validate : undefined\n\n // If this is an ID field, generate a new ID to prevent duplicates\n if (clipboardPath.endsWith('.id') && dataFromClipboard[clipboardPath]?.value) {\n const oldID = dataFromClipboard[clipboardPath].value as string\n if (typeof oldID === 'string' && ObjectId.isValid(oldID)) {\n const newID = new ObjectId().toHexString()\n idReplacements.set(clipboardPath, newID)\n\n formState[newPath] = {\n customComponents,\n validate,\n ...dataFromClipboard[clipboardPath],\n initialValue: newID,\n value: newID,\n }\n continue\n }\n }\n\n formState[newPath] = {\n customComponents,\n validate,\n ...dataFromClipboard[clipboardPath],\n }\n }\n\n // Update parent field rows with new IDs\n for (const [clipboardPath, newID] of idReplacements) {\n const relativePath = clipboardPath.replace(`${pathToReplace}.`, '')\n const segments = relativePath.split('.')\n\n if (segments.length >= 2) {\n const rowIndex = parseInt(segments[segments.length - 2], 10)\n const parentFieldPath = segments.slice(0, segments.length - 2).join('.')\n const fullParentPath = parentFieldPath ? `${targetSegment}.${parentFieldPath}` : targetSegment\n\n if (formState[fullParentPath] && Array.isArray(formState[fullParentPath].rows)) {\n const parentRows = formState[fullParentPath].rows\n if (!isNaN(rowIndex) && parentRows[rowIndex]) {\n parentRows[rowIndex].id = newID\n }\n }\n } else if (segments.length === 1 && segments[0] === 'id') {\n // Top-level block ID - extract field path and row index from targetSegment\n const targetParts = targetSegment.split('.')\n const lastPart = targetParts[targetParts.length - 1]\n const rowIndexFromTarget = !isNaN(parseInt(lastPart, 10)) ? parseInt(lastPart, 10) : 0\n const fieldPath = !isNaN(parseInt(lastPart, 10))\n ? targetParts.slice(0, -1).join('.')\n : targetSegment\n\n if (formState[fieldPath] && Array.isArray(formState[fieldPath].rows)) {\n const rows = formState[fieldPath].rows\n if (rows[rowIndexFromTarget]) {\n rows[rowIndexFromTarget].id = newID\n }\n }\n }\n }\n\n return formState\n}\n"],"mappings":"AAEA,OAAOA,cAAA,MAAoB;AAI3B,MAAMC,QAAA,GAAW,aAAaD,cAAA,GAAiBA,cAAA,CAAeE,OAAO,GAAGF,cAAA;AAExE,OAAO,SAASG,sBAAsB;EACpCC,SAAS;EACTC,IAAI;EACJC;AAAQ,CAKT;EACC,MAAMC,aAAA,GAA4C,CAAC;EACnD,MAAMC,MAAA,GAAS,OAAOF,QAAA,KAAa,WAAWD,IAAA,GAAO,GAAGA,IAAA,IAAQC,QAAA,EAAU;EAE1E,KAAK,MAAMG,GAAA,IAAOL,SAAA,EAAW;IAC3B,IAAI,CAACK,GAAA,CAAIC,UAAU,CAACF,MAAA,GAAS;MAC3B;IACF;IAEA,MAAM;MAAEG,gBAAA,EAAkBC,CAAC;MAAEC,QAAA,EAAUC,EAAE;MAAE,GAAGC;IAAA,CAAO,GAAGX,SAAS,CAACK,GAAA,CAAI;IAEtE,IAAIO,KAAA,CAAMC,OAAO,CAACF,KAAA,CAAMG,IAAI,GAAG;MAC7BH,KAAA,CAAMG,IAAI,GAAGH,KAAA,CAAMG,IAAI,CAACC,GAAG,CAAEC,GAAA;QAC3B,IAAI,CAACA,GAAA,IAAO,OAAOA,GAAA,KAAQ,UAAU;UACnC,OAAOA,GAAA;QACT;QACA,MAAM;UAAET,gBAAA,EAAkBC,CAAC;UAAE,GAAGS;QAAA,CAAiB,GAAGD,GAAA;QACpD,OAAOC,eAAA;MACT;IACF;IAEAd,aAAa,CAACE,GAAA,CAAI,GAAGM,KAAA;EACvB;EAEA,OAAOR,aAAA;AACT;AAEA,OAAO,SAASe,4BAA4B;EAC1CC,iBAAA,EAAmBC,aAAa;EAChCpB,SAAS;EACTC,IAAI;EACJC;AAAQ,CAMT;EACC,MAAM;IACJmB,IAAA,EAAMC,iBAAiB;IACvBC,IAAA,EAAMJ,iBAAiB;IACvBlB,IAAA,EAAMuB,iBAAiB;IACvBtB,QAAA,EAAUuB;EAAqB,CAChC,GAAGL,aAAA;EAEJ,MAAMM,aAAA,GAAgB,OAAOD,qBAAA,KAA0B;EACvD,MAAME,cAAA,GAAiB,OAAOzB,QAAA,KAAa;EAC3C,MAAM0B,cAAA,GAAiB,CAACF,aAAA,IAAiBC,cAAA;EACzC,MAAMd,OAAA,GAAUS,iBAAA,KAAsB;EAEtC,IAAIO,aAAA;EACJ,IAAIH,aAAA,IAAiBC,cAAA,EAAgB;IACnCE,aAAA,GAAgBL,iBAAA;EAClB,OAAO,IAAIE,aAAA,EAAe;IACxBG,aAAA,GAAgB,GAAGL,iBAAA,IAAqBtB,QAAA,EAAU;EACpD,OAAO;IACL2B,aAAA,GAAgB,GAAGL,iBAAA,IAAqBC,qBAAA,EAAuB;EACjE;EAEA,IAAIK,aAAA;EACJ,IAAI,CAACH,cAAA,EAAgB;IACnBG,aAAA,GAAgB,GAAG7B,IAAA,IAAQC,QAAA,EAAU;EACvC,OAAO,IAAI0B,cAAA,EAAgB;IACzBE,aAAA,GAAgB,GAAG7B,IAAA,IAAQ;EAC7B,OAAO;IACL6B,aAAA,GAAgB7B,IAAA;EAClB;EAEA,IAAI2B,cAAA,EAAgB;IAClB,MAAMG,gBAAA,GAAmB,GAAG9B,IAAA,IAAQ;IACpC,MAAM+B,kBAAA,GAAqBb,iBAAiB,CAAC,GAAGU,aAAA,KAAkB,CAAC,EAAEI,KAAA;IACrE,MAAMC,OAAA,GAAUlC,SAAS,CAACC,IAAA,CAAK,CAACa,IAAI,EAAEqB,MAAA;IAEtCnC,SAAS,CAACC,IAAA,CAAK,CAACa,IAAI,GAAG,CACrB;MACE,IAAIoB,OAAA,IAAWrB,OAAA,GAAUb,SAAS,CAACC,IAAA,CAAK,CAACa,IAAI,CAAC,EAAE,GAAG,CAAC,CAAC;MACrDsB,EAAA,EAAIJ,kBAAA;MACJK,SAAA,EAAW;MACXN;IACF,EACD;IACD/B,SAAS,CAACC,IAAA,CAAK,CAACgC,KAAK,GAAG;IACxBjC,SAAS,CAACC,IAAA,CAAK,CAACqC,YAAY,GAAG;IAC/BtC,SAAS,CAACC,IAAA,CAAK,CAACsC,eAAe,GAAG;IAElC,KAAK,MAAMC,SAAA,IAAaxC,SAAA,EAAW;MACjC,IACEwC,SAAA,KAAcvC,IAAA,IACd,CAACuC,SAAA,CAAUlC,UAAU,CAACyB,gBAAA,KACtBS,SAAA,CAAUlC,UAAU,CAACL,IAAA,GACrB;QACA,OAAOD,SAAS,CAACwC,SAAA,CAAU;MAC7B;IACF;EACF;EAEA;EACA,MAAMC,cAAA,GAAsC,IAAIC,GAAA;EAEhD,KAAK,MAAMC,aAAA,IAAiBxB,iBAAA,EAAmB;IAC7C;IACA;IACA;IACA,IACE,CAAEQ,cAAA,IAAkBgB,aAAA,KAAkB,GAAGd,aAAA,KAAkB,IAC3D,CAACc,aAAA,CAAcrC,UAAU,CAACuB,aAAA,GAC1B;MACA;IACF;IAEA,MAAMe,OAAA,GAAUD,aAAA,CAAcE,OAAO,CAAChB,aAAA,EAAeC,aAAA;IAErD,MAAMvB,gBAAA,GAAmBM,OAAA,GAAUb,SAAS,CAAC4C,OAAA,CAAQ,EAAErC,gBAAA,GAAmBuC,SAAA;IAC1E,MAAMrC,QAAA,GAAWI,OAAA,GAAUb,SAAS,CAAC4C,OAAA,CAAQ,EAAEnC,QAAA,GAAWqC,SAAA;IAE1D;IACA,IAAIH,aAAA,CAAcI,QAAQ,CAAC,UAAU5B,iBAAiB,CAACwB,aAAA,CAAc,EAAEV,KAAA,EAAO;MAC5E,MAAMe,KAAA,GAAQ7B,iBAAiB,CAACwB,aAAA,CAAc,CAACV,KAAK;MACpD,IAAI,OAAOe,KAAA,KAAU,YAAYnD,QAAA,CAASoD,OAAO,CAACD,KAAA,GAAQ;QACxD,MAAME,KAAA,GAAQ,IAAIrD,QAAA,GAAWsD,WAAW;QACxCV,cAAA,CAAeW,GAAG,CAACT,aAAA,EAAeO,KAAA;QAElClD,SAAS,CAAC4C,OAAA,CAAQ,GAAG;UACnBrC,gBAAA;UACAE,QAAA;UACA,GAAGU,iBAAiB,CAACwB,aAAA,CAAc;UACnCL,YAAA,EAAcY,KAAA;UACdjB,KAAA,EAAOiB;QACT;QACA;MACF;IACF;IAEAlD,SAAS,CAAC4C,OAAA,CAAQ,GAAG;MACnBrC,gBAAA;MACAE,QAAA;MACA,GAAGU,iBAAiB,CAACwB,aAAA;IACvB;EACF;EAEA;EACA,KAAK,MAAM,CAACA,aAAA,EAAeO,KAAA,CAAM,IAAIT,cAAA,EAAgB;IACnD,MAAMY,YAAA,GAAeV,aAAA,CAAcE,OAAO,CAAC,GAAGhB,aAAA,GAAgB,EAAE;IAChE,MAAMyB,QAAA,GAAWD,YAAA,CAAaE,KAAK,CAAC;IAEpC,IAAID,QAAA,CAASnB,MAAM,IAAI,GAAG;MACxB,MAAMjC,QAAA,GAAWsD,QAAA,CAASF,QAAQ,CAACA,QAAA,CAASnB,MAAM,GAAG,EAAE,EAAE;MACzD,MAAMsB,eAAA,GAAkBH,QAAA,CAASI,KAAK,CAAC,GAAGJ,QAAA,CAASnB,MAAM,GAAG,GAAGwB,IAAI,CAAC;MACpE,MAAMC,cAAA,GAAiBH,eAAA,GAAkB,GAAG3B,aAAA,IAAiB2B,eAAA,EAAiB,GAAG3B,aAAA;MAEjF,IAAI9B,SAAS,CAAC4D,cAAA,CAAe,IAAIhD,KAAA,CAAMC,OAAO,CAACb,SAAS,CAAC4D,cAAA,CAAe,CAAC9C,IAAI,GAAG;QAC9E,MAAM+C,UAAA,GAAa7D,SAAS,CAAC4D,cAAA,CAAe,CAAC9C,IAAI;QACjD,IAAI,CAACgD,KAAA,CAAM5D,QAAA,KAAa2D,UAAU,CAAC3D,QAAA,CAAS,EAAE;UAC5C2D,UAAU,CAAC3D,QAAA,CAAS,CAACkC,EAAE,GAAGc,KAAA;QAC5B;MACF;IACF,OAAO,IAAII,QAAA,CAASnB,MAAM,KAAK,KAAKmB,QAAQ,CAAC,EAAE,KAAK,MAAM;MACxD;MACA,MAAMS,WAAA,GAAcjC,aAAA,CAAcyB,KAAK,CAAC;MACxC,MAAMS,QAAA,GAAWD,WAAW,CAACA,WAAA,CAAY5B,MAAM,GAAG,EAAE;MACpD,MAAM8B,kBAAA,GAAqB,CAACH,KAAA,CAAMN,QAAA,CAASQ,QAAA,EAAU,OAAOR,QAAA,CAASQ,QAAA,EAAU,MAAM;MACrF,MAAMxB,SAAA,GAAY,CAACsB,KAAA,CAAMN,QAAA,CAASQ,QAAA,EAAU,OACxCD,WAAA,CAAYL,KAAK,CAAC,GAAG,CAAC,GAAGC,IAAI,CAAC,OAC9B7B,aAAA;MAEJ,IAAI9B,SAAS,CAACwC,SAAA,CAAU,IAAI5B,KAAA,CAAMC,OAAO,CAACb,SAAS,CAACwC,SAAA,CAAU,CAAC1B,IAAI,GAAG;QACpE,MAAMA,IAAA,GAAOd,SAAS,CAACwC,SAAA,CAAU,CAAC1B,IAAI;QACtC,IAAIA,IAAI,CAACmD,kBAAA,CAAmB,EAAE;UAC5BnD,IAAI,CAACmD,kBAAA,CAAmB,CAAC7B,EAAE,GAAGc,KAAA;QAChC;MACF;IACF;EACF;EAEA,OAAOlD,SAAA;AACT","ignoreList":[]}
1
+ {"version":3,"file":"mergeFormStateFromClipboard.js","names":["ObjectIdImport","ObjectId","default","isStrictPathPrefix","key","prefix","startsWith","reduceFormStateByPath","formState","path","rowIndex","filteredState","customComponents","_","validate","__","field","Array","isArray","rows","map","row","serializableRow","mergeFormStateFromClipboard","dataFromClipboard","clipboardData","type","typeFromClipboard","data","pathFromClipboard","rowIndexFromClipboard","copyFromField","pasteIntoField","fromRowToField","pathToReplace","targetSegment","lastRenderedPath","rowIDFromClipboard","value","hasRows","length","id","isLoading","initialValue","disableFormData","fieldPath","idReplacements","Map","clipboardPath","newPath","replace","undefined","endsWith","oldID","isValid","newID","toHexString","set","relativePath","segments","split","parseInt","parentFieldPath","slice","join","fullParentPath","parentRows","isNaN","targetParts","lastPart","rowIndexFromTarget"],"sources":["../../../src/elements/ClipboardAction/mergeFormStateFromClipboard.ts"],"sourcesContent":["import type { FieldState, FormState } from 'payload'\n\nimport ObjectIdImport from 'bson-objectid'\n\nimport type { ClipboardPasteData } from './types.js'\n\nconst ObjectId = 'default' in ObjectIdImport ? ObjectIdImport.default : ObjectIdImport\n\n// Strict path-prefix check: matches `prefix` exactly, or any descendant path (`prefix.`).\n// Required because numeric row indices share digit prefixes (`.1` vs `.10`/`.11`), and\n// field names can also share textual prefixes (`children` vs `childrenOther`). A loose\n// `startsWith(prefix)` leaks unrelated keys into clipboard / cleanup operations.\nfunction isStrictPathPrefix(key: string, prefix: string): boolean {\n return key === prefix || key.startsWith(`${prefix}.`)\n}\n\nexport function reduceFormStateByPath({\n formState,\n path,\n rowIndex,\n}: {\n formState: FormState\n path: string\n rowIndex?: number\n}) {\n const filteredState: Record<string, FieldState> = {}\n const prefix = typeof rowIndex !== 'number' ? path : `${path}.${rowIndex}`\n\n for (const key in formState) {\n if (!isStrictPathPrefix(key, prefix)) {\n continue\n }\n\n const { customComponents: _, validate: __, ...field } = formState[key]\n\n if (Array.isArray(field.rows)) {\n field.rows = field.rows.map((row) => {\n if (!row || typeof row !== 'object') {\n return row\n }\n const { customComponents: _, ...serializableRow } = row\n return serializableRow\n })\n }\n\n filteredState[key] = field\n }\n\n return filteredState\n}\n\nexport function mergeFormStateFromClipboard({\n dataFromClipboard: clipboardData,\n formState,\n path,\n rowIndex,\n}: {\n dataFromClipboard: ClipboardPasteData\n formState: FormState\n path: string\n rowIndex?: number\n}) {\n const {\n type: typeFromClipboard,\n data: dataFromClipboard,\n path: pathFromClipboard,\n rowIndex: rowIndexFromClipboard,\n } = clipboardData\n\n const copyFromField = typeof rowIndexFromClipboard !== 'number'\n const pasteIntoField = typeof rowIndex !== 'number'\n const fromRowToField = !copyFromField && pasteIntoField\n const isArray = typeFromClipboard === 'array'\n\n let pathToReplace: string\n if (copyFromField && pasteIntoField) {\n pathToReplace = pathFromClipboard\n } else if (copyFromField) {\n pathToReplace = `${pathFromClipboard}.${rowIndex}`\n } else {\n pathToReplace = `${pathFromClipboard}.${rowIndexFromClipboard}`\n }\n\n let targetSegment: string\n if (!pasteIntoField) {\n targetSegment = `${path}.${rowIndex}`\n } else if (fromRowToField) {\n targetSegment = `${path}.0`\n } else {\n targetSegment = path\n }\n\n if (fromRowToField) {\n const lastRenderedPath = `${path}.0`\n const rowIDFromClipboard = dataFromClipboard[`${pathToReplace}.id`]?.value as string\n const hasRows = formState[path].rows?.length\n\n formState[path].rows = [\n {\n ...(hasRows && isArray ? formState[path].rows[0] : {}),\n id: rowIDFromClipboard,\n isLoading: false,\n lastRenderedPath,\n },\n ]\n formState[path].value = 1\n formState[path].initialValue = 1\n formState[path].disableFormData = true\n\n for (const fieldPath in formState) {\n if (\n fieldPath !== path &&\n !isStrictPathPrefix(fieldPath, lastRenderedPath) &&\n isStrictPathPrefix(fieldPath, path)\n ) {\n delete formState[fieldPath]\n }\n }\n }\n\n // Map to track old IDs to new IDs for regenerating nested IDs\n const idReplacements: Map<string, string> = new Map()\n\n for (const clipboardPath in dataFromClipboard) {\n // When pasting into a specific row, skip only the direct row ID to avoid overwriting\n // the target row's identity. Nested IDs (array item IDs within the block) should\n // still be processed so they get regenerated below, preventing server-side duplication.\n if (\n (!pasteIntoField && clipboardPath === `${pathToReplace}.id`) ||\n !isStrictPathPrefix(clipboardPath, pathToReplace)\n ) {\n continue\n }\n\n const newPath = clipboardPath.replace(pathToReplace, targetSegment)\n\n const customComponents = isArray ? formState[newPath]?.customComponents : undefined\n const validate = isArray ? formState[newPath]?.validate : undefined\n\n // If this is an ID field, generate a new ID to prevent duplicates\n if (clipboardPath.endsWith('.id') && dataFromClipboard[clipboardPath]?.value) {\n const oldID = dataFromClipboard[clipboardPath].value as string\n if (typeof oldID === 'string' && ObjectId.isValid(oldID)) {\n const newID = new ObjectId().toHexString()\n idReplacements.set(clipboardPath, newID)\n\n formState[newPath] = {\n customComponents,\n validate,\n ...dataFromClipboard[clipboardPath],\n initialValue: newID,\n value: newID,\n }\n continue\n }\n }\n\n formState[newPath] = {\n customComponents,\n validate,\n ...dataFromClipboard[clipboardPath],\n }\n }\n\n // Update parent field rows with new IDs\n for (const [clipboardPath, newID] of idReplacements) {\n const relativePath = clipboardPath.replace(`${pathToReplace}.`, '')\n const segments = relativePath.split('.')\n\n if (segments.length >= 2) {\n const rowIndex = parseInt(segments[segments.length - 2], 10)\n const parentFieldPath = segments.slice(0, segments.length - 2).join('.')\n const fullParentPath = parentFieldPath ? `${targetSegment}.${parentFieldPath}` : targetSegment\n\n if (formState[fullParentPath] && Array.isArray(formState[fullParentPath].rows)) {\n const parentRows = formState[fullParentPath].rows\n if (!isNaN(rowIndex) && parentRows[rowIndex]) {\n parentRows[rowIndex].id = newID\n }\n }\n } else if (segments.length === 1 && segments[0] === 'id') {\n // Top-level block ID - extract field path and row index from targetSegment\n const targetParts = targetSegment.split('.')\n const lastPart = targetParts[targetParts.length - 1]\n const rowIndexFromTarget = !isNaN(parseInt(lastPart, 10)) ? parseInt(lastPart, 10) : 0\n const fieldPath = !isNaN(parseInt(lastPart, 10))\n ? targetParts.slice(0, -1).join('.')\n : targetSegment\n\n if (formState[fieldPath] && Array.isArray(formState[fieldPath].rows)) {\n const rows = formState[fieldPath].rows\n if (rows[rowIndexFromTarget]) {\n rows[rowIndexFromTarget].id = newID\n }\n }\n }\n }\n\n return formState\n}\n"],"mappings":"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","ignoreList":[]}
@@ -1,6 +1,6 @@
1
1
  import ObjectIdImport from 'bson-objectid';
2
2
  import { describe, expect, it } from 'vitest';
3
- import { mergeFormStateFromClipboard } from './mergeFormStateFromClipboard.js';
3
+ import { mergeFormStateFromClipboard, reduceFormStateByPath } from './mergeFormStateFromClipboard.js';
4
4
  const ObjectId = 'default' in ObjectIdImport ? ObjectIdImport.default : ObjectIdImport;
5
5
  describe('mergeFormStateFromClipboard', () => {
6
6
  describe('block ID regeneration', () => {
@@ -537,5 +537,349 @@ describe('mergeFormStateFromClipboard', () => {
537
537
  expect(result['disableSort.0.text'].value).toEqual('row one');
538
538
  });
539
539
  });
540
+ describe('prefix collision with multi-digit sibling indices', () => {
541
+ it('reduceFormStateByPath should not leak siblings whose indices share a digit prefix', () => {
542
+ // Array with 13 rows. Copying row 1 must filter out rows 10/11/12, whose
543
+ // paths all begin with the substring `children.1`.
544
+ const formState = {
545
+ children: {
546
+ valid: true,
547
+ value: 13,
548
+ rows: Array.from({
549
+ length: 13
550
+ }, () => ({
551
+ isLoading: false
552
+ }))
553
+ },
554
+ 'children.1.id': {
555
+ value: 'id-1',
556
+ valid: true
557
+ },
558
+ 'children.1.title': {
559
+ value: 'Row 1',
560
+ valid: true
561
+ },
562
+ 'children.10.id': {
563
+ value: 'id-10',
564
+ valid: true
565
+ },
566
+ 'children.10.title': {
567
+ value: 'Row 10',
568
+ valid: true
569
+ },
570
+ 'children.11.id': {
571
+ value: 'id-11',
572
+ valid: true
573
+ },
574
+ 'children.11.title': {
575
+ value: 'Row 11',
576
+ valid: true
577
+ },
578
+ 'children.12.id': {
579
+ value: 'id-12',
580
+ valid: true
581
+ },
582
+ 'children.12.title': {
583
+ value: 'Row 12',
584
+ valid: true
585
+ }
586
+ };
587
+ const filtered = reduceFormStateByPath({
588
+ formState,
589
+ path: 'children',
590
+ rowIndex: 1
591
+ });
592
+ const filteredKeys = Object.keys(filtered).sort();
593
+ expect(filteredKeys).toEqual(['children.1.id', 'children.1.title']);
594
+ expect(filtered['children.10.id']).toBeUndefined();
595
+ expect(filtered['children.11.id']).toBeUndefined();
596
+ expect(filtered['children.12.id']).toBeUndefined();
597
+ });
598
+ it('reduceFormStateByPath should not leak field-name siblings sharing a textual prefix', () => {
599
+ // `children` vs `childrenOther`: loose prefix matching pulls the wrong field's state.
600
+ const formState = {
601
+ children: {
602
+ valid: true,
603
+ value: 1,
604
+ rows: [{
605
+ isLoading: false
606
+ }]
607
+ },
608
+ 'children.0.id': {
609
+ value: 'children-row',
610
+ valid: true
611
+ },
612
+ childrenOther: {
613
+ valid: true,
614
+ value: 1,
615
+ rows: [{
616
+ isLoading: false
617
+ }]
618
+ },
619
+ 'childrenOther.0.id': {
620
+ value: 'other-row',
621
+ valid: true
622
+ }
623
+ };
624
+ const filtered = reduceFormStateByPath({
625
+ formState,
626
+ path: 'children'
627
+ });
628
+ // The field's own key carries `rows` and `value`, so it must survive the filter.
629
+ expect(filtered.children).toBeDefined();
630
+ expect(filtered['children.0.id']).toBeDefined();
631
+ expect(filtered.childrenOther).toBeUndefined();
632
+ expect(filtered['childrenOther.0.id']).toBeUndefined();
633
+ });
634
+ it('paste should not create out-of-bounds rows from a leaked clipboard payload', () => {
635
+ // Simulates the end-to-end Copy/Paste flow against an array with 13 rows. Even if a
636
+ // pre-fix clipboard leaked sibling rows (.10/.11/.12) when copying row 1, the merge
637
+ // filter must drop them rather than rewriting them to .50/.51/.52 when pasting into
638
+ // row 5.
639
+ const formState = {
640
+ children: {
641
+ valid: true,
642
+ value: 13,
643
+ rows: Array.from({
644
+ length: 13
645
+ }, () => ({
646
+ id: new ObjectId().toHexString(),
647
+ isLoading: false
648
+ }))
649
+ },
650
+ 'children.5.id': {
651
+ value: new ObjectId().toHexString(),
652
+ valid: true
653
+ }
654
+ };
655
+ const sourceID = new ObjectId().toHexString();
656
+ const clipboardData = {
657
+ type: 'array',
658
+ path: 'children',
659
+ fields: [],
660
+ rowIndex: 1,
661
+ data: {
662
+ 'children.1.id': {
663
+ value: sourceID,
664
+ valid: true
665
+ },
666
+ 'children.1.title': {
667
+ value: 'Row 1 title',
668
+ valid: true
669
+ },
670
+ // Leaked siblings — these must be dropped, not rewritten to .50/.51/.52.
671
+ 'children.10.id': {
672
+ value: new ObjectId().toHexString(),
673
+ valid: true
674
+ },
675
+ 'children.10.title': {
676
+ value: 'Leaked Row 10',
677
+ valid: true
678
+ },
679
+ 'children.11.id': {
680
+ value: new ObjectId().toHexString(),
681
+ valid: true
682
+ },
683
+ 'children.11.title': {
684
+ value: 'Leaked Row 11',
685
+ valid: true
686
+ },
687
+ 'children.12.id': {
688
+ value: new ObjectId().toHexString(),
689
+ valid: true
690
+ },
691
+ 'children.12.title': {
692
+ value: 'Leaked Row 12',
693
+ valid: true
694
+ }
695
+ }
696
+ };
697
+ const result = mergeFormStateFromClipboard({
698
+ dataFromClipboard: clipboardData,
699
+ formState,
700
+ path: 'children',
701
+ rowIndex: 5
702
+ });
703
+ // Target row populated from the source row.
704
+ expect(result['children.5.title']?.value).toEqual('Row 1 title');
705
+ // No phantom out-of-bounds rows. These would correspond to `.10`/`.11`/`.12` being
706
+ // rewritten through `String.replace('children.1', 'children.5')`.
707
+ expect(result['children.50']).toBeUndefined();
708
+ expect(result['children.51']).toBeUndefined();
709
+ expect(result['children.52']).toBeUndefined();
710
+ expect(result['children.50.id']).toBeUndefined();
711
+ expect(result['children.51.id']).toBeUndefined();
712
+ expect(result['children.52.id']).toBeUndefined();
713
+ expect(result['children.50.title']).toBeUndefined();
714
+ expect(result['children.51.title']).toBeUndefined();
715
+ expect(result['children.52.title']).toBeUndefined();
716
+ });
717
+ it('row-to-field cleanup should not delete unrelated fields with a textual prefix collision', () => {
718
+ // `path` = 'children', `lastRenderedPath` = 'children.0'. A sibling field
719
+ // `childrenOther.0.title` must NOT be deleted by the cleanup loop.
720
+ const formState = {
721
+ children: {
722
+ valid: true,
723
+ value: 0,
724
+ rows: [{
725
+ isLoading: false
726
+ }]
727
+ },
728
+ 'children.0.id': {
729
+ value: new ObjectId().toHexString(),
730
+ valid: true
731
+ },
732
+ childrenOther: {
733
+ valid: true,
734
+ value: 1,
735
+ rows: [{
736
+ isLoading: false
737
+ }]
738
+ },
739
+ 'childrenOther.0.id': {
740
+ value: new ObjectId().toHexString(),
741
+ valid: true
742
+ },
743
+ 'childrenOther.0.title': {
744
+ value: 'Unrelated field',
745
+ valid: true
746
+ }
747
+ };
748
+ const sourceID = new ObjectId().toHexString();
749
+ const clipboardData = {
750
+ type: 'array',
751
+ path: 'someOtherArray',
752
+ fields: [],
753
+ rowIndex: 0,
754
+ data: {
755
+ 'someOtherArray.0.id': {
756
+ value: sourceID,
757
+ valid: true
758
+ },
759
+ 'someOtherArray.0.title': {
760
+ value: 'Pasted row',
761
+ valid: true
762
+ }
763
+ }
764
+ };
765
+ const result = mergeFormStateFromClipboard({
766
+ dataFromClipboard: clipboardData,
767
+ formState,
768
+ path: 'children'
769
+ });
770
+ // Unrelated `childrenOther.*` paths survive the cleanup.
771
+ expect(result.childrenOther).toBeDefined();
772
+ expect(result['childrenOther.0.id']).toBeDefined();
773
+ expect(result['childrenOther.0.title']?.value).toEqual('Unrelated field');
774
+ });
775
+ });
776
+ describe('exact path matches', () => {
777
+ // Blocks store the block type under the row key itself (`ctas.1`), with no trailing
778
+ // segment. Tightening the prefix check must keep matching that exact key, otherwise
779
+ // copy drops the block type and paste never restores it.
780
+ it('reduceFormStateByPath should keep the exact row key while filtering digit-prefix siblings', () => {
781
+ const formState = {
782
+ ctas: {
783
+ valid: true,
784
+ value: 13,
785
+ rows: Array.from({
786
+ length: 13
787
+ }, () => ({
788
+ blockType: 'callToAction',
789
+ isLoading: false
790
+ }))
791
+ },
792
+ 'ctas.1': {
793
+ value: 'callToAction',
794
+ valid: true
795
+ },
796
+ 'ctas.1.id': {
797
+ value: 'id-1',
798
+ valid: true
799
+ },
800
+ 'ctas.10': {
801
+ value: 'callToAction',
802
+ valid: true
803
+ },
804
+ 'ctas.10.id': {
805
+ value: 'id-10',
806
+ valid: true
807
+ },
808
+ 'ctas.11': {
809
+ value: 'callToAction',
810
+ valid: true
811
+ },
812
+ 'ctas.11.id': {
813
+ value: 'id-11',
814
+ valid: true
815
+ }
816
+ };
817
+ const filtered = reduceFormStateByPath({
818
+ formState,
819
+ path: 'ctas',
820
+ rowIndex: 1
821
+ });
822
+ const filteredKeys = Object.keys(filtered).sort();
823
+ expect(filteredKeys).toEqual(['ctas.1', 'ctas.1.id']);
824
+ expect(filtered['ctas.1']?.value).toEqual('callToAction');
825
+ });
826
+ it('paste should copy the exact row key across to the target row', () => {
827
+ const sourceBlockID = new ObjectId().toHexString();
828
+ const targetBlockID = new ObjectId().toHexString();
829
+ const formState = {
830
+ ctas: {
831
+ valid: true,
832
+ value: 2,
833
+ initialValue: 2,
834
+ rows: [{
835
+ id: sourceBlockID,
836
+ blockType: 'callToAction',
837
+ isLoading: false
838
+ }, {
839
+ id: targetBlockID,
840
+ blockType: 'content',
841
+ isLoading: false
842
+ }]
843
+ },
844
+ 'ctas.1': {
845
+ value: 'content',
846
+ valid: true
847
+ },
848
+ 'ctas.1.id': {
849
+ value: targetBlockID,
850
+ valid: true
851
+ }
852
+ };
853
+ const clipboardData = {
854
+ type: 'blocks',
855
+ path: 'ctas',
856
+ blocks: [],
857
+ rowIndex: 0,
858
+ data: {
859
+ 'ctas.0': {
860
+ value: 'callToAction',
861
+ valid: true
862
+ },
863
+ 'ctas.0.id': {
864
+ value: sourceBlockID,
865
+ valid: true
866
+ },
867
+ 'ctas.0.label': {
868
+ value: 'Source label',
869
+ valid: true
870
+ }
871
+ }
872
+ };
873
+ const result = mergeFormStateFromClipboard({
874
+ dataFromClipboard: clipboardData,
875
+ formState,
876
+ path: 'ctas',
877
+ rowIndex: 1
878
+ });
879
+ // The block type lives at the exact row key, so pasting must overwrite it.
880
+ expect(result['ctas.1']?.value).toEqual('callToAction');
881
+ expect(result['ctas.1.label']?.value).toEqual('Source label');
882
+ });
883
+ });
540
884
  });
541
885
  //# sourceMappingURL=mergeFormStateFromClipboard.spec.js.map