@@ -3,7 +3,10 @@ import type { FormState } from 'payload'
33import ObjectIdImport from 'bson-objectid'
44import { describe , expect , it } from 'vitest'
55
6- import { mergeFormStateFromClipboard } from './mergeFormStateFromClipboard.js'
6+ import {
7+ mergeFormStateFromClipboard ,
8+ reduceFormStateByPath ,
9+ } from './mergeFormStateFromClipboard.js'
710import type { ClipboardPasteData } from './types.js'
811
912const ObjectId = (
@@ -549,4 +552,222 @@ describe('mergeFormStateFromClipboard', () => {
549552 expect ( result [ 'disableSort.0.text' ] . value ) . toEqual ( 'row one' )
550553 } )
551554 } )
555+
556+ describe ( 'prefix collision with multi-digit sibling indices' , ( ) => {
557+ it ( 'reduceFormStateByPath should not leak siblings whose indices share a digit prefix' , ( ) => {
558+ // Array with 13 rows. Copying row 1 must filter out rows 10/11/12, whose
559+ // paths all begin with the substring `children.1`.
560+ const formState : FormState = {
561+ children : {
562+ valid : true ,
563+ value : 13 ,
564+ rows : Array . from ( { length : 13 } , ( ) => ( { isLoading : false } ) ) ,
565+ } ,
566+ 'children.1.id' : { value : 'id-1' , valid : true } ,
567+ 'children.1.title' : { value : 'Row 1' , valid : true } ,
568+ 'children.10.id' : { value : 'id-10' , valid : true } ,
569+ 'children.10.title' : { value : 'Row 10' , valid : true } ,
570+ 'children.11.id' : { value : 'id-11' , valid : true } ,
571+ 'children.11.title' : { value : 'Row 11' , valid : true } ,
572+ 'children.12.id' : { value : 'id-12' , valid : true } ,
573+ 'children.12.title' : { value : 'Row 12' , valid : true } ,
574+ }
575+
576+ const filtered = reduceFormStateByPath ( {
577+ formState,
578+ path : 'children' ,
579+ rowIndex : 1 ,
580+ } )
581+
582+ const filteredKeys = Object . keys ( filtered ) . sort ( )
583+ expect ( filteredKeys ) . toEqual ( [ 'children.1.id' , 'children.1.title' ] )
584+ expect ( filtered [ 'children.10.id' ] ) . toBeUndefined ( )
585+ expect ( filtered [ 'children.11.id' ] ) . toBeUndefined ( )
586+ expect ( filtered [ 'children.12.id' ] ) . toBeUndefined ( )
587+ } )
588+
589+ it ( 'reduceFormStateByPath should not leak field-name siblings sharing a textual prefix' , ( ) => {
590+ // `children` vs `childrenOther`: loose prefix matching pulls the wrong field's state.
591+ const formState : FormState = {
592+ children : { valid : true , value : 1 , rows : [ { isLoading : false } ] } ,
593+ 'children.0.id' : { value : 'children-row' , valid : true } ,
594+ childrenOther : { valid : true , value : 1 , rows : [ { isLoading : false } ] } ,
595+ 'childrenOther.0.id' : { value : 'other-row' , valid : true } ,
596+ }
597+
598+ const filtered = reduceFormStateByPath ( { formState, path : 'children' } )
599+
600+ // The field's own key carries `rows` and `value`, so it must survive the filter.
601+ expect ( filtered . children ) . toBeDefined ( )
602+ expect ( filtered [ 'children.0.id' ] ) . toBeDefined ( )
603+ expect ( filtered . childrenOther ) . toBeUndefined ( )
604+ expect ( filtered [ 'childrenOther.0.id' ] ) . toBeUndefined ( )
605+ } )
606+
607+ it ( 'paste should not create out-of-bounds rows from a leaked clipboard payload' , ( ) => {
608+ // Simulates the end-to-end Copy/Paste flow against an array with 13 rows. Even if a
609+ // pre-fix clipboard leaked sibling rows (.10/.11/.12) when copying row 1, the merge
610+ // filter must drop them rather than rewriting them to .50/.51/.52 when pasting into
611+ // row 5.
612+ const formState : FormState = {
613+ children : {
614+ valid : true ,
615+ value : 13 ,
616+ rows : Array . from ( { length : 13 } , ( ) => ( {
617+ id : new ObjectId ( ) . toHexString ( ) ,
618+ isLoading : false ,
619+ } ) ) ,
620+ } ,
621+ 'children.5.id' : { value : new ObjectId ( ) . toHexString ( ) , valid : true } ,
622+ }
623+
624+ const sourceID = new ObjectId ( ) . toHexString ( )
625+ const clipboardData : ClipboardPasteData = {
626+ type : 'array' ,
627+ path : 'children' ,
628+ fields : [ ] ,
629+ rowIndex : 1 ,
630+ data : {
631+ 'children.1.id' : { value : sourceID , valid : true } ,
632+ 'children.1.title' : { value : 'Row 1 title' , valid : true } ,
633+ // Leaked siblings — these must be dropped, not rewritten to .50/.51/.52.
634+ 'children.10.id' : { value : new ObjectId ( ) . toHexString ( ) , valid : true } ,
635+ 'children.10.title' : { value : 'Leaked Row 10' , valid : true } ,
636+ 'children.11.id' : { value : new ObjectId ( ) . toHexString ( ) , valid : true } ,
637+ 'children.11.title' : { value : 'Leaked Row 11' , valid : true } ,
638+ 'children.12.id' : { value : new ObjectId ( ) . toHexString ( ) , valid : true } ,
639+ 'children.12.title' : { value : 'Leaked Row 12' , valid : true } ,
640+ } ,
641+ }
642+
643+ const result = mergeFormStateFromClipboard ( {
644+ dataFromClipboard : clipboardData ,
645+ formState,
646+ path : 'children' ,
647+ rowIndex : 5 ,
648+ } )
649+
650+ // Target row populated from the source row.
651+ expect ( result [ 'children.5.title' ] ?. value ) . toEqual ( 'Row 1 title' )
652+
653+ // No phantom out-of-bounds rows. These would correspond to `.10`/`.11`/`.12` being
654+ // rewritten through `String.replace('children.1', 'children.5')`.
655+ expect ( result [ 'children.50' ] ) . toBeUndefined ( )
656+ expect ( result [ 'children.51' ] ) . toBeUndefined ( )
657+ expect ( result [ 'children.52' ] ) . toBeUndefined ( )
658+ expect ( result [ 'children.50.id' ] ) . toBeUndefined ( )
659+ expect ( result [ 'children.51.id' ] ) . toBeUndefined ( )
660+ expect ( result [ 'children.52.id' ] ) . toBeUndefined ( )
661+ expect ( result [ 'children.50.title' ] ) . toBeUndefined ( )
662+ expect ( result [ 'children.51.title' ] ) . toBeUndefined ( )
663+ expect ( result [ 'children.52.title' ] ) . toBeUndefined ( )
664+ } )
665+
666+ it ( 'row-to-field cleanup should not delete unrelated fields with a textual prefix collision' , ( ) => {
667+ // `path` = 'children', `lastRenderedPath` = 'children.0'. A sibling field
668+ // `childrenOther.0.title` must NOT be deleted by the cleanup loop.
669+ const formState : FormState = {
670+ children : { valid : true , value : 0 , rows : [ { isLoading : false } ] } ,
671+ 'children.0.id' : { value : new ObjectId ( ) . toHexString ( ) , valid : true } ,
672+ childrenOther : { valid : true , value : 1 , rows : [ { isLoading : false } ] } ,
673+ 'childrenOther.0.id' : { value : new ObjectId ( ) . toHexString ( ) , valid : true } ,
674+ 'childrenOther.0.title' : { value : 'Unrelated field' , valid : true } ,
675+ }
676+
677+ const sourceID = new ObjectId ( ) . toHexString ( )
678+ const clipboardData : ClipboardPasteData = {
679+ type : 'array' ,
680+ path : 'someOtherArray' ,
681+ fields : [ ] ,
682+ rowIndex : 0 ,
683+ data : {
684+ 'someOtherArray.0.id' : { value : sourceID , valid : true } ,
685+ 'someOtherArray.0.title' : { value : 'Pasted row' , valid : true } ,
686+ } ,
687+ }
688+
689+ const result = mergeFormStateFromClipboard ( {
690+ dataFromClipboard : clipboardData ,
691+ formState,
692+ path : 'children' ,
693+ } )
694+
695+ // Unrelated `childrenOther.*` paths survive the cleanup.
696+ expect ( result . childrenOther ) . toBeDefined ( )
697+ expect ( result [ 'childrenOther.0.id' ] ) . toBeDefined ( )
698+ expect ( result [ 'childrenOther.0.title' ] ?. value ) . toEqual ( 'Unrelated field' )
699+ } )
700+ } )
701+
702+ describe ( 'exact path matches' , ( ) => {
703+ // Blocks store the block type under the row key itself (`ctas.1`), with no trailing
704+ // segment. Tightening the prefix check must keep matching that exact key, otherwise
705+ // copy drops the block type and paste never restores it.
706+ it ( 'reduceFormStateByPath should keep the exact row key while filtering digit-prefix siblings' , ( ) => {
707+ const formState : FormState = {
708+ ctas : {
709+ valid : true ,
710+ value : 13 ,
711+ rows : Array . from ( { length : 13 } , ( ) => ( {
712+ blockType : 'callToAction' ,
713+ isLoading : false ,
714+ } ) ) ,
715+ } ,
716+ 'ctas.1' : { value : 'callToAction' , valid : true } ,
717+ 'ctas.1.id' : { value : 'id-1' , valid : true } ,
718+ 'ctas.10' : { value : 'callToAction' , valid : true } ,
719+ 'ctas.10.id' : { value : 'id-10' , valid : true } ,
720+ 'ctas.11' : { value : 'callToAction' , valid : true } ,
721+ 'ctas.11.id' : { value : 'id-11' , valid : true } ,
722+ }
723+
724+ const filtered = reduceFormStateByPath ( { formState, path : 'ctas' , rowIndex : 1 } )
725+
726+ const filteredKeys = Object . keys ( filtered ) . sort ( )
727+ expect ( filteredKeys ) . toEqual ( [ 'ctas.1' , 'ctas.1.id' ] )
728+ expect ( filtered [ 'ctas.1' ] ?. value ) . toEqual ( 'callToAction' )
729+ } )
730+
731+ it ( 'paste should copy the exact row key across to the target row' , ( ) => {
732+ const sourceBlockID = new ObjectId ( ) . toHexString ( )
733+ const targetBlockID = new ObjectId ( ) . toHexString ( )
734+
735+ const formState : FormState = {
736+ ctas : {
737+ valid : true ,
738+ value : 2 ,
739+ initialValue : 2 ,
740+ rows : [
741+ { id : sourceBlockID , blockType : 'callToAction' , isLoading : false } ,
742+ { id : targetBlockID , blockType : 'content' , isLoading : false } ,
743+ ] ,
744+ } ,
745+ 'ctas.1' : { value : 'content' , valid : true } ,
746+ 'ctas.1.id' : { value : targetBlockID , valid : true } ,
747+ }
748+
749+ const clipboardData : ClipboardPasteData = {
750+ type : 'blocks' ,
751+ path : 'ctas' ,
752+ blocks : [ ] ,
753+ rowIndex : 0 ,
754+ data : {
755+ 'ctas.0' : { value : 'callToAction' , valid : true } ,
756+ 'ctas.0.id' : { value : sourceBlockID , valid : true } ,
757+ 'ctas.0.label' : { value : 'Source label' , valid : true } ,
758+ } ,
759+ }
760+
761+ const result = mergeFormStateFromClipboard ( {
762+ dataFromClipboard : clipboardData ,
763+ formState,
764+ path : 'ctas' ,
765+ rowIndex : 1 ,
766+ } )
767+
768+ // The block type lives at the exact row key, so pasting must overwrite it.
769+ expect ( result [ 'ctas.1' ] ?. value ) . toEqual ( 'callToAction' )
770+ expect ( result [ 'ctas.1.label' ] ?. value ) . toEqual ( 'Source label' )
771+ } )
772+ } )
552773} )
0 commit comments