From 94af9238ce44c4751a4e8dfeebb9d9b3a2096d68 Mon Sep 17 00:00:00 2001 From: Jeremy Weinstein Date: Thu, 19 Mar 2026 16:42:49 -0700 Subject: [PATCH 1/3] feat: add copy translations option to copy doc modal - only show the option if the doc has translations --- .../components/CopyDocModal/CopyDocModal.css | 4 ++ .../components/CopyDocModal/CopyDocModal.tsx | 45 ++++++++++++++++++- .../ui/components/NewDocModal/NewDocModal.tsx | 1 + 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/packages/root-cms/ui/components/CopyDocModal/CopyDocModal.css b/packages/root-cms/ui/components/CopyDocModal/CopyDocModal.css index 8c9e70e7..6c32a56b 100644 --- a/packages/root-cms/ui/components/CopyDocModal/CopyDocModal.css +++ b/packages/root-cms/ui/components/CopyDocModal/CopyDocModal.css @@ -11,6 +11,10 @@ margin-top: 16px; } +.CopyDocModal__copyTranslations { + margin-top: 16px; +} + .CopyDocModal__error { margin-top: 12px; padding: 8px 12px; diff --git a/packages/root-cms/ui/components/CopyDocModal/CopyDocModal.tsx b/packages/root-cms/ui/components/CopyDocModal/CopyDocModal.tsx index c0231460..755156a6 100644 --- a/packages/root-cms/ui/components/CopyDocModal/CopyDocModal.tsx +++ b/packages/root-cms/ui/components/CopyDocModal/CopyDocModal.tsx @@ -1,11 +1,12 @@ -import {Button} from '@mantine/core'; +import {Button, Checkbox} from '@mantine/core'; import {ContextModalProps, useModals} from '@mantine/modals'; import {showNotification} from '@mantine/notifications'; -import {useState} from 'preact/hooks'; +import {useState, useEffect} from 'preact/hooks'; import {useLocation} from 'preact-iso'; import {getSlugError, normalizeSlug} from '../../../shared/slug.js'; import {useModalTheme} from '../../hooks/useModalTheme.js'; import {cmsCopyDoc, cmsCreateDoc} from '../../utils/doc.js'; +import {batchUpdateTags, loadTranslations} from '../../utils/l10n.js'; import {SlugInput} from '../SlugInput/SlugInput.js'; import {Text} from '../Text/Text.js'; import './CopyDocModal.css'; @@ -41,11 +42,22 @@ export function CopyDocModal(modalProps: ContextModalProps) { const [error, setError] = useState(''); const [confirmOverwrite, setConfirmOverwrite] = useState(false); const [loading, setLoading] = useState(false); + const [copyTranslations, setCopyTranslations] = useState(false); + const [hasTranslations, setHasTranslations] = useState(false); const fromDocId = props.fromDocId; const fromCollectionId = fromDocId.split('/')[0]; const sourceLabel = props.fromLabel || fromDocId; + // Check if the source doc has any tagged translations. + useEffect(() => { + async function checkTranslations() { + const translationsMap = await loadTranslations({tags: [fromDocId]}); + setHasTranslations(Object.keys(translationsMap).length > 0); + } + checkTranslations(); + }, [fromDocId]); + async function onSubmit(e: Event) { e.preventDefault(); setLoading(true); @@ -75,6 +87,20 @@ export function CopyDocModal(modalProps: ContextModalProps) { } else { await cmsCopyDoc(fromDocId, toDocId, {overwrite: confirmOverwrite}); } + + // Copy translations by adding the new doc ID tag to all strings + // tagged with the old doc ID. + if (copyTranslations) { + const translationsMap = await loadTranslations({tags: [fromDocId]}); + const updates = Object.keys(translationsMap).map((hash) => ({ + hash, + tags: [toDocId], + })); + if (updates.length > 0) { + await batchUpdateTags(updates, {mode: 'union'}); + } + } + context.closeModal(id); showNotification({ title: 'Copied!', @@ -127,6 +153,20 @@ export function CopyDocModal(modalProps: ContextModalProps) { /> + {hasTranslations && ( +
+ { + const target = e.currentTarget as HTMLInputElement; + setCopyTranslations(target.checked); + }} + /> +
+ )} + {error &&
{error}
}
@@ -145,6 +185,7 @@ export function CopyDocModal(modalProps: ContextModalProps) { size="xs" color="dark" loading={loading} + disabled={!!error} > {confirmOverwrite ? 'Overwrite?' : 'Submit'} diff --git a/packages/root-cms/ui/components/NewDocModal/NewDocModal.tsx b/packages/root-cms/ui/components/NewDocModal/NewDocModal.tsx index 69d600d0..8b46fa8e 100644 --- a/packages/root-cms/ui/components/NewDocModal/NewDocModal.tsx +++ b/packages/root-cms/ui/components/NewDocModal/NewDocModal.tsx @@ -117,6 +117,7 @@ export function NewDocModal(props: NewDocModalProps) { size="xs" color="dark" loading={collection.loading || rpcLoading} + disabled={!!slugError} > Submit From 8480cb1fa1082c04d3dda6654637e7f0cab876ad Mon Sep 17 00:00:00 2001 From: Jeremy Weinstein Date: Thu, 19 Mar 2026 17:11:21 -0700 Subject: [PATCH 2/3] chore: refine error state --- .../components/CopyDocModal/CopyDocModal.tsx | 31 ++++++++++++------- .../ui/components/NewDocModal/NewDocModal.tsx | 21 ++++++++----- 2 files changed, 33 insertions(+), 19 deletions(-) diff --git a/packages/root-cms/ui/components/CopyDocModal/CopyDocModal.tsx b/packages/root-cms/ui/components/CopyDocModal/CopyDocModal.tsx index 755156a6..8cc4fb45 100644 --- a/packages/root-cms/ui/components/CopyDocModal/CopyDocModal.tsx +++ b/packages/root-cms/ui/components/CopyDocModal/CopyDocModal.tsx @@ -39,25 +39,31 @@ export function CopyDocModal(modalProps: ContextModalProps) { const {route} = useLocation(); const [toCollectionId, setToCollectionId] = useState(''); const [toSlug, setToSlug] = useState(''); + const [slugError, setSlugError] = useState(''); const [error, setError] = useState(''); const [confirmOverwrite, setConfirmOverwrite] = useState(false); const [loading, setLoading] = useState(false); const [copyTranslations, setCopyTranslations] = useState(false); - const [hasTranslations, setHasTranslations] = useState(false); + const [translationsMap, setTranslationsMap] = useState< + Record + >({}); const fromDocId = props.fromDocId; const fromCollectionId = fromDocId.split('/')[0]; const sourceLabel = props.fromLabel || fromDocId; + const hasTranslations = Object.keys(translationsMap).length > 0; // Check if the source doc has any tagged translations. useEffect(() => { - async function checkTranslations() { - const translationsMap = await loadTranslations({tags: [fromDocId]}); - setHasTranslations(Object.keys(translationsMap).length > 0); - } - checkTranslations(); + loadTranslations({tags: [fromDocId]}).then(setTranslationsMap); }, [fromDocId]); + function validateSlug(slug: string, collectionId: string) { + const cleanSlug = normalizeSlug(slug); + const slugRegex = window.__ROOT_CTX.collections[collectionId]?.slugRegex; + return cleanSlug ? getSlugError(cleanSlug, slugRegex) : ''; + } + async function onSubmit(e: Event) { e.preventDefault(); setLoading(true); @@ -68,15 +74,14 @@ export function CopyDocModal(modalProps: ContextModalProps) { setLoading(false); return; } - const cleanSlug = normalizeSlug(toSlug); - const slugRegex = window.__ROOT_CTX.collections[toCollectionId]?.slugRegex; - const slugValidationError = getSlugError(cleanSlug, slugRegex); + const slugValidationError = validateSlug(toSlug, toCollectionId); if (slugValidationError) { - setError(slugValidationError); + // SlugInput already displays this error, just return early. setLoading(false); return; } + const cleanSlug = normalizeSlug(toSlug); const toDocId = `${toCollectionId}/${cleanSlug}`; try { if (props.fields) { @@ -91,7 +96,6 @@ export function CopyDocModal(modalProps: ContextModalProps) { // Copy translations by adding the new doc ID tag to all strings // tagged with the old doc ID. if (copyTranslations) { - const translationsMap = await loadTranslations({tags: [fromDocId]}); const updates = Object.keys(translationsMap).map((hash) => ({ hash, tags: [toDocId], @@ -149,6 +153,9 @@ export function CopyDocModal(modalProps: ContextModalProps) { onChange={(newValue: {collectionId: string; slug: string}) => { setToCollectionId(newValue.collectionId); setToSlug(newValue.slug); + setSlugError(validateSlug(newValue.slug, newValue.collectionId)); + setError(''); + setConfirmOverwrite(false); }} />
@@ -185,7 +192,7 @@ export function CopyDocModal(modalProps: ContextModalProps) { size="xs" color="dark" loading={loading} - disabled={!!error} + disabled={!!error || !!slugError} > {confirmOverwrite ? 'Overwrite?' : 'Submit'} diff --git a/packages/root-cms/ui/components/NewDocModal/NewDocModal.tsx b/packages/root-cms/ui/components/NewDocModal/NewDocModal.tsx index 8b46fa8e..670e6915 100644 --- a/packages/root-cms/ui/components/NewDocModal/NewDocModal.tsx +++ b/packages/root-cms/ui/components/NewDocModal/NewDocModal.tsx @@ -21,6 +21,7 @@ export function NewDocModal(props: NewDocModalProps) { const [slug, setSlug] = useState(''); const [rpcLoading, setRpcLoading] = useState(false); const [slugError, setSlugError] = useState(''); + const [error, setError] = useState(''); const theme = useMantineTheme(); const collectionId = props.collection; const rootCollection = window.__ROOT_CTX.collections[collectionId]; @@ -29,6 +30,11 @@ export function NewDocModal(props: NewDocModalProps) { } const collection = useCollectionSchema(collectionId); + function validateSlug(slug: string) { + const cleanSlug = normalizeSlug(slug); + return cleanSlug ? getSlugError(cleanSlug, rootCollection.slugRegex) : ''; + } + function onClose() { if (props.onClose) { props.onClose(); @@ -40,15 +46,14 @@ export function NewDocModal(props: NewDocModalProps) { setRpcLoading(true); setSlugError(''); - const cleanSlug = normalizeSlug(slug); - const slugRegex = rootCollection.slugRegex; - const slugValidationError = getSlugError(cleanSlug, slugRegex); + const slugValidationError = validateSlug(slug); if (slugValidationError) { - setSlugError(slugValidationError); + // SlugInput already displays this error, just return early. setRpcLoading(false); return; } + const cleanSlug = normalizeSlug(slug); const docId = `${collectionId}/${cleanSlug}`; try { // Save the doc using the default value defined in the collection's @@ -59,7 +64,7 @@ export function NewDocModal(props: NewDocModalProps) { } await cmsCreateDoc(docId, {fields: defaultValue}); } catch (err) { - setSlugError(String(err)); + setError(String(err)); setRpcLoading(false); return; } @@ -96,10 +101,12 @@ export function NewDocModal(props: NewDocModalProps) { collectionId={collectionId} onChange={(newValue: {collectionId: string; slug: string}) => { setSlug(newValue.slug); + setSlugError(validateSlug(newValue.slug)); + setError(''); }} /> - {slugError &&
{slugError}
} + {error &&
{error}
}
From 6b4bd78ea59ba1b7005a5ed0e19b359dfefd3d4b Mon Sep 17 00:00:00 2001 From: Jeremy Weinstein Date: Sat, 21 Mar 2026 09:50:00 -0700 Subject: [PATCH 3/3] refactor: auto-copy translations on doc copy, remove checkbox (#976) Translations are now automatically copied when a doc is copied, instead of requiring the user to check a separate checkbox. This simplifies the UX by making translation copying implicit in the copy doc action. https://claude.ai/code/session_01DLygu6etCj8VrgR4Kay6MQ Co-authored-by: Claude --- .../components/CopyDocModal/CopyDocModal.css | 4 --- .../components/CopyDocModal/CopyDocModal.tsx | 31 +++++-------------- 2 files changed, 7 insertions(+), 28 deletions(-) diff --git a/packages/root-cms/ui/components/CopyDocModal/CopyDocModal.css b/packages/root-cms/ui/components/CopyDocModal/CopyDocModal.css index 6c32a56b..8c9e70e7 100644 --- a/packages/root-cms/ui/components/CopyDocModal/CopyDocModal.css +++ b/packages/root-cms/ui/components/CopyDocModal/CopyDocModal.css @@ -11,10 +11,6 @@ margin-top: 16px; } -.CopyDocModal__copyTranslations { - margin-top: 16px; -} - .CopyDocModal__error { margin-top: 12px; padding: 8px 12px; diff --git a/packages/root-cms/ui/components/CopyDocModal/CopyDocModal.tsx b/packages/root-cms/ui/components/CopyDocModal/CopyDocModal.tsx index 8cc4fb45..9242b6ee 100644 --- a/packages/root-cms/ui/components/CopyDocModal/CopyDocModal.tsx +++ b/packages/root-cms/ui/components/CopyDocModal/CopyDocModal.tsx @@ -1,4 +1,4 @@ -import {Button, Checkbox} from '@mantine/core'; +import {Button} from '@mantine/core'; import {ContextModalProps, useModals} from '@mantine/modals'; import {showNotification} from '@mantine/notifications'; import {useState, useEffect} from 'preact/hooks'; @@ -43,7 +43,6 @@ export function CopyDocModal(modalProps: ContextModalProps) { const [error, setError] = useState(''); const [confirmOverwrite, setConfirmOverwrite] = useState(false); const [loading, setLoading] = useState(false); - const [copyTranslations, setCopyTranslations] = useState(false); const [translationsMap, setTranslationsMap] = useState< Record >({}); @@ -51,9 +50,8 @@ export function CopyDocModal(modalProps: ContextModalProps) { const fromDocId = props.fromDocId; const fromCollectionId = fromDocId.split('/')[0]; const sourceLabel = props.fromLabel || fromDocId; - const hasTranslations = Object.keys(translationsMap).length > 0; - // Check if the source doc has any tagged translations. + // Load translations tagged with the source doc ID. useEffect(() => { loadTranslations({tags: [fromDocId]}).then(setTranslationsMap); }, [fromDocId]); @@ -93,16 +91,15 @@ export function CopyDocModal(modalProps: ContextModalProps) { await cmsCopyDoc(fromDocId, toDocId, {overwrite: confirmOverwrite}); } - // Copy translations by adding the new doc ID tag to all strings + // Auto-copy translations by adding the new doc ID tag to all strings // tagged with the old doc ID. - if (copyTranslations) { - const updates = Object.keys(translationsMap).map((hash) => ({ + const translationHashes = Object.keys(translationsMap); + if (translationHashes.length > 0) { + const updates = translationHashes.map((hash) => ({ hash, tags: [toDocId], })); - if (updates.length > 0) { - await batchUpdateTags(updates, {mode: 'union'}); - } + await batchUpdateTags(updates, {mode: 'union'}); } context.closeModal(id); @@ -160,20 +157,6 @@ export function CopyDocModal(modalProps: ContextModalProps) { />
- {hasTranslations && ( -
- { - const target = e.currentTarget as HTMLInputElement; - setCopyTranslations(target.checked); - }} - /> -
- )} - {error &&
{error}
}