|
1 | 1 | /** |
2 | 2 | * WordPress dependencies |
3 | 3 | */ |
4 | | -import { __, sprintf } from '@wordpress/i18n'; |
| 4 | +import { __, _n, sprintf } from '@wordpress/i18n'; |
5 | 5 | import { |
6 | 6 | useState, |
7 | 7 | useEffect, |
@@ -63,6 +63,54 @@ const COLOR_SETTINGS_DEFAULTS = { |
63 | 63 | link: false, |
64 | 64 | }; |
65 | 65 |
|
| 66 | +// Local-only stable ID for React keys on palette rows. Entries that come |
| 67 | +// from the server (initial load, save response) get a `__cbtRowId` added |
| 68 | +// when we seed local state; entries the user adds get one at creation. |
| 69 | +// Stripped from the payload before sending to the server. |
| 70 | +let nextRowIdCounter = 0; |
| 71 | +const newRowId = () => `cbt-row-${ ++nextRowIdCounter }`; |
| 72 | + |
| 73 | +const augmentPaletteWithIds = ( entries ) => |
| 74 | + entries.map( ( entry ) => ( { |
| 75 | + ...entry, |
| 76 | + __cbtRowId: entry.__cbtRowId || newRowId(), |
| 77 | + } ) ); |
| 78 | + |
| 79 | +const stripPaletteIds = ( entries ) => |
| 80 | + entries.map( ( { __cbtRowId: _id, ...rest } ) => rest ); |
| 81 | + |
| 82 | +const palettesEqual = ( a, b ) => { |
| 83 | + if ( a.length !== b.length ) { |
| 84 | + return false; |
| 85 | + } |
| 86 | + for ( let i = 0; i < a.length; i++ ) { |
| 87 | + if ( |
| 88 | + a[ i ].slug !== b[ i ].slug || |
| 89 | + a[ i ].name !== b[ i ].name || |
| 90 | + a[ i ].color !== b[ i ].color |
| 91 | + ) { |
| 92 | + return false; |
| 93 | + } |
| 94 | + } |
| 95 | + return true; |
| 96 | +}; |
| 97 | + |
| 98 | +// Find the next free index for an auto-generated `new-color-N` slug so |
| 99 | +// Add → Remove → Add doesn't produce duplicate slugs. |
| 100 | +const nextNewColorIndex = ( entries ) => { |
| 101 | + let max = 0; |
| 102 | + for ( const entry of entries ) { |
| 103 | + const match = /^new-color-(\d+)$/.exec( entry.slug || '' ); |
| 104 | + if ( match ) { |
| 105 | + const n = parseInt( match[ 1 ], 10 ); |
| 106 | + if ( n > max ) { |
| 107 | + max = n; |
| 108 | + } |
| 109 | + } |
| 110 | + } |
| 111 | + return max + 1; |
| 112 | +}; |
| 113 | + |
66 | 114 | const ColorSettingsPanel = ( { value, onChange } ) => { |
67 | 115 | const update = ( key ) => ( next ) => |
68 | 116 | onChange( { ...value, [ key ]: next } ); |
@@ -250,7 +298,8 @@ const PalettePanel = ( { value, onChange } ) => { |
250 | 298 | onChange( [ |
251 | 299 | ...value, |
252 | 300 | { |
253 | | - slug: `new-color-${ value.length + 1 }`, |
| 301 | + __cbtRowId: newRowId(), |
| 302 | + slug: `new-color-${ nextNewColorIndex( value ) }`, |
254 | 303 | name: __( 'New color', 'create-block-theme' ), |
255 | 304 | color: '#000000', |
256 | 305 | }, |
@@ -296,7 +345,7 @@ const PalettePanel = ( { value, onChange } ) => { |
296 | 345 | <ItemGroup isBordered isSeparated> |
297 | 346 | { value.map( ( entry, index ) => ( |
298 | 347 | <div |
299 | | - key={ index } |
| 348 | + key={ entry.__cbtRowId } |
300 | 349 | ref={ |
301 | 350 | index === value.length - 1 |
302 | 351 | ? lastRowRef |
@@ -501,22 +550,17 @@ const getCustomizedSections = ( userGlobalStyles, edits ) => { |
501 | 550 | ); |
502 | 551 | }; |
503 | 552 |
|
504 | | -// Per-field dirty diff between the modal's working state and the last-saved |
505 | | -// snapshot from the server. Returns the number of fields that differ — |
506 | | -// surfaced in the Update button label. |
507 | | -const countChanges = ( current, snapshot ) => { |
508 | | - let count = 0; |
| 553 | +// Returns the set of color-settings keys whose current value differs from |
| 554 | +// the last-saved snapshot. Used to drive both the Update-button counter |
| 555 | +// and the minimal-patch payload sent to the server. |
| 556 | +const getDirtyColorKeys = ( current, snapshot ) => { |
| 557 | + const keys = []; |
509 | 558 | for ( const key of COLOR_SETTINGS_KEYS ) { |
510 | | - if ( current.colorSettings[ key ] !== snapshot.colorSettings[ key ] ) { |
511 | | - count += 1; |
| 559 | + if ( current[ key ] !== snapshot[ key ] ) { |
| 560 | + keys.push( key ); |
512 | 561 | } |
513 | 562 | } |
514 | | - if ( |
515 | | - JSON.stringify( current.palette ) !== JSON.stringify( snapshot.palette ) |
516 | | - ) { |
517 | | - count += 1; |
518 | | - } |
519 | | - return count; |
| 563 | + return keys; |
520 | 564 | }; |
521 | 565 |
|
522 | 566 | export const EditThemeSettingsModal = ( { onRequestClose } ) => { |
@@ -549,58 +593,101 @@ export const EditThemeSettingsModal = ( { onRequestClose } ) => { |
549 | 593 |
|
550 | 594 | const themeColor = themeData?.theme_json?.settings?.color; |
551 | 595 |
|
| 596 | + // `snapshot` is the server-canonical state — palette entries here have |
| 597 | + // NO `__cbtRowId` since the server never sees that key. Working state |
| 598 | + // (`palette`) is augmented with row IDs for stable React keys. |
552 | 599 | const initialState = useMemo( |
553 | 600 | () => ( { |
554 | 601 | colorSettings: pickColorSettings( themeColor ), |
555 | | - palette: themeColor?.palette ? [ ...themeColor.palette ] : [], |
| 602 | + palette: Array.isArray( themeColor?.palette ) |
| 603 | + ? themeColor.palette |
| 604 | + : [], |
556 | 605 | } ), |
557 | 606 | [ themeColor ] |
558 | 607 | ); |
559 | 608 |
|
560 | 609 | const [ colorSettings, setColorSettings ] = useState( |
561 | 610 | initialState.colorSettings |
562 | 611 | ); |
563 | | - const [ palette, setPalette ] = useState( initialState.palette ); |
| 612 | + const [ palette, setPalette ] = useState( () => |
| 613 | + augmentPaletteWithIds( initialState.palette ) |
| 614 | + ); |
564 | 615 | const [ snapshot, setSnapshot ] = useState( initialState ); |
565 | 616 | const [ isSaving, setIsSaving ] = useState( false ); |
566 | 617 |
|
567 | | - // Reseed local state when the server-side theme data refreshes (initial |
568 | | - // load, and after a successful save invalidates the resolver). |
| 618 | + const dirtyColorKeys = useMemo( |
| 619 | + () => getDirtyColorKeys( colorSettings, snapshot.colorSettings ), |
| 620 | + [ colorSettings, snapshot.colorSettings ] |
| 621 | + ); |
| 622 | + const strippedPalette = useMemo( |
| 623 | + () => stripPaletteIds( palette ), |
| 624 | + [ palette ] |
| 625 | + ); |
| 626 | + const paletteDirty = useMemo( |
| 627 | + () => ! palettesEqual( strippedPalette, snapshot.palette ), |
| 628 | + [ strippedPalette, snapshot.palette ] |
| 629 | + ); |
| 630 | + const changeCount = dirtyColorKeys.length + ( paletteDirty ? 1 : 0 ); |
| 631 | + const isDirty = changeCount > 0; |
| 632 | + |
| 633 | + const hasEmptySlug = palette.some( |
| 634 | + ( entry ) => ! entry.slug || ! entry.slug.trim() |
| 635 | + ); |
| 636 | + |
| 637 | + // Reseed local state from refreshed server data — but only if the user |
| 638 | + // hasn't started editing in this session. Otherwise a mid-flight cache |
| 639 | + // invalidation (e.g. another resolver triggers it) would silently wipe |
| 640 | + // pending edits. |
569 | 641 | useEffect( () => { |
| 642 | + if ( isDirty ) { |
| 643 | + return; |
| 644 | + } |
570 | 645 | setColorSettings( initialState.colorSettings ); |
571 | | - setPalette( initialState.palette ); |
| 646 | + setPalette( augmentPaletteWithIds( initialState.palette ) ); |
572 | 647 | setSnapshot( initialState ); |
| 648 | + // `isDirty` is intentionally excluded from deps: we only want to |
| 649 | + // reseed when the server-side data changes, not when the user's |
| 650 | + // edits transition the dirty flag. |
| 651 | + // eslint-disable-next-line react-hooks/exhaustive-deps |
573 | 652 | }, [ initialState ] ); |
574 | 653 |
|
575 | | - const changeCount = countChanges( { colorSettings, palette }, snapshot ); |
576 | | - const isDirty = changeCount > 0; |
577 | | - |
578 | 654 | const handleUpdateClick = async () => { |
| 655 | + // Build a minimal patch: only the keys the user touched in this |
| 656 | + // session. Avoids overwriting fields another tab/CLI may have |
| 657 | + // edited concurrently (server-side merge is RFC 7396 last-writes- |
| 658 | + // wins on lists, so sending an unchanged palette would still |
| 659 | + // clobber concurrent palette edits). |
| 660 | + const colorPayload = {}; |
| 661 | + for ( const key of dirtyColorKeys ) { |
| 662 | + colorPayload[ key ] = colorSettings[ key ]; |
| 663 | + } |
| 664 | + if ( paletteDirty ) { |
| 665 | + colorPayload.palette = strippedPalette; |
| 666 | + } |
| 667 | + if ( Object.keys( colorPayload ).length === 0 ) { |
| 668 | + return; |
| 669 | + } |
| 670 | + |
579 | 671 | setIsSaving( true ); |
580 | 672 | try { |
581 | 673 | // The endpoint returns `{ status, theme_json: <merged> }` on |
582 | | - // success. Reseed from the merged theme.json directly instead of |
583 | | - // relying on a refetch: `getCurrentTheme` is entity-record-backed |
584 | | - // and `invalidateResolution` doesn't reliably re-fetch it before |
585 | | - // the user sees the (now-stale) dirty count. |
| 674 | + // success. Reseed snapshot from the merged theme.json directly |
| 675 | + // instead of relying on a refetch: `getCurrentTheme` is |
| 676 | + // entity-record-backed and `invalidateResolution` doesn't |
| 677 | + // reliably re-fetch it before the user sees the dirty count. |
586 | 678 | const response = await postUpdateThemeSettings( { |
587 | | - settings: { |
588 | | - color: { |
589 | | - ...colorSettings, |
590 | | - palette, |
591 | | - }, |
592 | | - }, |
| 679 | + settings: { color: colorPayload }, |
593 | 680 | } ); |
594 | 681 | const savedColor = response?.theme_json?.settings?.color || {}; |
595 | 682 | const nextColorSettings = pickColorSettings( savedColor ); |
596 | | - const nextPalette = Array.isArray( savedColor.palette ) |
597 | | - ? [ ...savedColor.palette ] |
| 683 | + const nextPaletteRaw = Array.isArray( savedColor.palette ) |
| 684 | + ? savedColor.palette |
598 | 685 | : []; |
599 | 686 | setColorSettings( nextColorSettings ); |
600 | | - setPalette( nextPalette ); |
| 687 | + setPalette( augmentPaletteWithIds( nextPaletteRaw ) ); |
601 | 688 | setSnapshot( { |
602 | 689 | colorSettings: nextColorSettings, |
603 | | - palette: nextPalette, |
| 690 | + palette: nextPaletteRaw, |
604 | 691 | } ); |
605 | 692 | createSuccessNotice( |
606 | 693 | __( 'Theme settings saved.', 'create-block-theme' ), |
@@ -646,11 +733,27 @@ export const EditThemeSettingsModal = ( { onRequestClose } ) => { |
646 | 733 | const updateLabel = isDirty |
647 | 734 | ? sprintf( |
648 | 735 | /* translators: %d: number of pending changes */ |
649 | | - __( 'Update (%d changes)', 'create-block-theme' ), |
| 736 | + _n( |
| 737 | + 'Update (%d change)', |
| 738 | + 'Update (%d changes)', |
| 739 | + changeCount, |
| 740 | + 'create-block-theme' |
| 741 | + ), |
650 | 742 | changeCount |
651 | 743 | ) |
652 | 744 | : __( 'Update', 'create-block-theme' ); |
653 | 745 |
|
| 746 | + // Use Intl.ListFormat so the separator (and final "and") match the |
| 747 | + // user's locale instead of being hard-coded English punctuation. |
| 748 | + // Falls back to a plain ", " join in environments without it. |
| 749 | + const sectionList = |
| 750 | + typeof Intl !== 'undefined' && Intl.ListFormat |
| 751 | + ? new Intl.ListFormat( undefined, { |
| 752 | + style: 'long', |
| 753 | + type: 'conjunction', |
| 754 | + } ).format( customizedSections ) |
| 755 | + : customizedSections.join( ', ' ); |
| 756 | + |
654 | 757 | return ( |
655 | 758 | <Modal |
656 | 759 | size="large" |
@@ -683,7 +786,7 @@ export const EditThemeSettingsModal = ( { onRequestClose } ) => { |
683 | 786 | 'You have changes in the Site Editor that haven’t been written to theme.json: <list>%s</list>.', |
684 | 787 | 'create-block-theme' |
685 | 788 | ), |
686 | | - customizedSections.join( ', ' ) |
| 789 | + sectionList |
687 | 790 | ), |
688 | 791 | { list: <strong /> } |
689 | 792 | ) } |
@@ -713,8 +816,17 @@ export const EditThemeSettingsModal = ( { onRequestClose } ) => { |
713 | 816 | <Button |
714 | 817 | variant="primary" |
715 | 818 | onClick={ handleUpdateClick } |
716 | | - disabled={ ! isDirty || isSaving } |
| 819 | + disabled={ ! isDirty || isSaving || hasEmptySlug } |
717 | 820 | isBusy={ isSaving } |
| 821 | + label={ |
| 822 | + hasEmptySlug |
| 823 | + ? __( |
| 824 | + 'Every palette entry needs a slug.', |
| 825 | + 'create-block-theme' |
| 826 | + ) |
| 827 | + : undefined |
| 828 | + } |
| 829 | + showTooltip={ hasEmptySlug } |
718 | 830 | > |
719 | 831 | { updateLabel } |
720 | 832 | </Button> |
|
0 commit comments