From a9b06703e39dc771328389c3c46a73b1aeec7077 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nils=20Pl=C3=BCtzer?= Date: Tue, 9 Jun 2026 11:25:47 +0200 Subject: [PATCH 1/5] Add bulk actions for rename, workflow, tags, and properties management --- docs/how-to-guides/index.md | 1 + .../manage-folder-contents-in-bulk.md | 77 ++++++ packages/client/news/+update-subjects.feature | 1 + .../client/news/+workflow-transition.feature | 1 + .../client/src/restapi/workflow/create.ts | 8 +- packages/client/src/validation/content.ts | 1 + .../contents/components/ContentsActions.tsx | 8 +- .../ContentsTable/ContentsTable.tsx | 27 +- .../PropertiesModal/PropertiesModal.tsx | 239 ++++++++++++++++++ .../components/RenameModal/RenameModal.tsx | 162 ++++++++++++ .../components/TagsModal/TagsModal.tsx | 231 +++++++++++++++++ .../WorkflowModal/WorkflowModal.tsx | 198 +++++++++++++++ packages/contents/helpers/batch.ts | 34 +++ packages/contents/helpers/properties.test.ts | 133 ++++++++++ packages/contents/helpers/properties.ts | 109 ++++++++ packages/contents/helpers/rename.test.ts | 67 +++++ packages/contents/helpers/rename.ts | 32 +++ packages/contents/helpers/tags.test.ts | 56 ++++ packages/contents/helpers/tags.ts | 59 +++++ packages/contents/helpers/workflow.test.ts | 45 ++++ packages/contents/helpers/workflow.ts | 31 +++ packages/contents/index.ts | 20 ++ packages/contents/locales/en/common.json | 49 +++- packages/contents/locales/it/common.json | 49 +++- .../news/+contents-object-actions.feature | 1 + packages/contents/providers/contents.tsx | 30 +++ packages/contents/routes/contents.tsx | 20 +- packages/contents/routes/properties.tsx | 69 +++++ packages/contents/routes/rename.tsx | 27 ++ packages/contents/routes/tags.tsx | 48 ++++ packages/contents/routes/workflow.tsx | 77 ++++++ 31 files changed, 1876 insertions(+), 34 deletions(-) create mode 100644 docs/how-to-guides/manage-folder-contents-in-bulk.md create mode 100644 packages/client/news/+update-subjects.feature create mode 100644 packages/client/news/+workflow-transition.feature create mode 100644 packages/contents/components/PropertiesModal/PropertiesModal.tsx create mode 100644 packages/contents/components/RenameModal/RenameModal.tsx create mode 100644 packages/contents/components/TagsModal/TagsModal.tsx create mode 100644 packages/contents/components/WorkflowModal/WorkflowModal.tsx create mode 100644 packages/contents/helpers/batch.ts create mode 100644 packages/contents/helpers/properties.test.ts create mode 100644 packages/contents/helpers/properties.ts create mode 100644 packages/contents/helpers/rename.test.ts create mode 100644 packages/contents/helpers/rename.ts create mode 100644 packages/contents/helpers/tags.test.ts create mode 100644 packages/contents/helpers/tags.ts create mode 100644 packages/contents/helpers/workflow.test.ts create mode 100644 packages/contents/helpers/workflow.ts create mode 100644 packages/contents/news/+contents-object-actions.feature create mode 100644 packages/contents/routes/properties.tsx create mode 100644 packages/contents/routes/rename.tsx create mode 100644 packages/contents/routes/tags.tsx create mode 100644 packages/contents/routes/workflow.tsx diff --git a/docs/how-to-guides/index.md b/docs/how-to-guides/index.md index b42b76e4c..83089fa9c 100644 --- a/docs/how-to-guides/index.md +++ b/docs/how-to-guides/index.md @@ -30,4 +30,5 @@ icons configure-plate-code-block-languages bind-metadata-fields-to-plate-text-blocks custom-content-types +manage-folder-contents-in-bulk ``` diff --git a/docs/how-to-guides/manage-folder-contents-in-bulk.md b/docs/how-to-guides/manage-folder-contents-in-bulk.md new file mode 100644 index 000000000..39faa3949 --- /dev/null +++ b/docs/how-to-guides/manage-folder-contents-in-bulk.md @@ -0,0 +1,77 @@ +--- +myst: + html_meta: + "description": "How to rename, change state, tag, and edit properties for one or more items in the Plone Aurora folder contents view" + "property=og:description": "How to rename, change state, tag, and edit properties for one or more items in the Plone Aurora folder contents view" + "property=og:title": "Manage folder contents in bulk" + "keywords": "Plone Aurora, contents, rename, workflow, tags, properties, bulk" +--- + +# Manage folder contents in bulk + +This guide shows you how to rename items, change their workflow state, edit their tags, and edit their properties from the folder contents view. +Each action works on one or several items at once. + +## Open the folder contents view + +Navigate to a folder and open its contents view at `/@@contents` followed by the folder path, for example `/@@contents/my-folder`. +The view lists the items in the folder together with a toolbar of actions. + +## Select the items to act on + +Select one or more items with the checkboxes in the first column. +Use the checkbox in the table header to select or clear all items on the current page. + +The {guilabel}`Rename`, {guilabel}`Change state`, {guilabel}`Tags`, and {guilabel}`Properties` actions stay disabled until you select at least one item. +Each action applies to the current selection. + +## Rename items + +1. Select the items to rename. +2. Select {guilabel}`Rename` in the toolbar. +3. Edit the short name, the title, or both for each item in the dialog. +4. Select {guilabel}`Rename` in the dialog to apply the changes. + +The short name is the last segment of the item's URL. + +```{warning} +Changing the short name changes the item's URL. +Existing links to the old URL no longer resolve unless a redirect is in place. +``` + +## Change the workflow state + +1. Select the items whose state you want to change. +2. Select {guilabel}`Change state` in the toolbar. +3. Choose a transition from the list. +4. Optionally add a comment. +5. Optionally select {guilabel}`Apply to contained items` to apply the transition recursively. +6. Select {guilabel}`Change state` in the dialog to apply the transition. + +The list offers only the transitions that are available for every selected item. +If the selected items share no common transition, the dialog tells you so, and you cannot apply a transition. + +## Edit tags + +1. Select the items to tag. +2. Select {guilabel}`Tags` in the toolbar. +3. Remove a tag by selecting the {guilabel}`×` next to it. +4. Add a tag by typing it in the input and pressing {kbd}`Enter`. +5. Select {guilabel}`Save tags` to apply the changes. + +The field shows the tags already present on the selection. +The input suggests existing tags from the site. +Adding a tag applies it to every selected item, and removing a tag removes it from every selected item. + +## Edit properties + +1. Select the items whose properties you want to edit. +2. Select {guilabel}`Properties` in the toolbar. +3. Edit any of the publishing date, expiration date, rights, creators, or {guilabel}`Exclude from navigation`. +4. Select {guilabel}`Save properties` to apply the changes. + +The dialog saves only the fields you change. +Fields you leave untouched keep their current values on each item. + +When you select several items whose values differ for a field, that field shows {guilabel}`Mixed values`. +Leave it untouched to keep each item's own value, or set a value to apply it to every selected item. diff --git a/packages/client/news/+update-subjects.feature b/packages/client/news/+update-subjects.feature new file mode 100644 index 000000000..e177aedcc --- /dev/null +++ b/packages/client/news/+update-subjects.feature @@ -0,0 +1 @@ +Allowed `updateContent` to set the `subjects` (tags) field. @nils-pzr diff --git a/packages/client/news/+workflow-transition.feature b/packages/client/news/+workflow-transition.feature new file mode 100644 index 000000000..c81fd48e4 --- /dev/null +++ b/packages/client/news/+workflow-transition.feature @@ -0,0 +1 @@ +Added an optional `transition` argument to `createWorkflow` so any workflow transition can be triggered, not only `publish`. @nils-pzr diff --git a/packages/client/src/restapi/workflow/create.ts b/packages/client/src/restapi/workflow/create.ts index 234a105a5..c22c2b11d 100644 --- a/packages/client/src/restapi/workflow/create.ts +++ b/packages/client/src/restapi/workflow/create.ts @@ -7,6 +7,7 @@ import type { RequestResponse } from '../types'; export const createWorkflowArgsSchema = z.object({ path: z.string(), + transition: z.string().optional(), data: createWorkflowDataSchema.optional(), }); @@ -14,10 +15,11 @@ export type CreateWorkflowArgs = z.infer; export async function createWorkflow( this: PloneClient, - { path, data }: CreateWorkflowArgs, + { path, transition, data }: CreateWorkflowArgs, ): Promise> { const validatedArgs = createWorkflowArgsSchema.parse({ path, + transition, data, }); @@ -26,7 +28,9 @@ export async function createWorkflow( config: this.config, }; - const workflowPath = `${validatedArgs.path}/@workflow/publish`; + const workflowPath = `${validatedArgs.path}/@workflow/${ + validatedArgs.transition ?? 'publish' + }`; return apiRequest('post', workflowPath, options); } diff --git a/packages/client/src/validation/content.ts b/packages/client/src/validation/content.ts index 58e870902..d108772f5 100644 --- a/packages/client/src/validation/content.ts +++ b/packages/client/src/validation/content.ts @@ -137,6 +137,7 @@ export const updateContentDataSchema = z .optional(), relatedItems: z.array(RelatedItemPayloadSchema).optional(), rights: z.string().nullable().optional(), + subjects: z.array(z.string()).optional(), table_of_contents: z.boolean().nullable().optional(), title: z.string().optional(), versioning_enabled: z.boolean().optional(), diff --git a/packages/contents/components/ContentsActions.tsx b/packages/contents/components/ContentsActions.tsx index 554a4208e..f9b7aed8b 100644 --- a/packages/contents/components/ContentsActions.tsx +++ b/packages/contents/components/ContentsActions.tsx @@ -20,10 +20,10 @@ import { useContentsContext } from '../providers/contents'; type Props = { upload: () => void | Promise; - rename: () => Promise; - workflow: () => Promise; - tags: () => Promise; - properties: () => Promise; + rename: () => void | Promise; + workflow: () => void | Promise; + tags: () => void | Promise; + properties: () => void | Promise; cut: (item?: Brain) => void; copy: (item?: Brain) => void; paste: () => Promise; diff --git a/packages/contents/components/ContentsTable/ContentsTable.tsx b/packages/contents/components/ContentsTable/ContentsTable.tsx index e04d79747..ccba63d29 100644 --- a/packages/contents/components/ContentsTable/ContentsTable.tsx +++ b/packages/contents/components/ContentsTable/ContentsTable.tsx @@ -62,11 +62,6 @@ interface ContentsTableProps { indexes: TableIndexes; onSelectIndex: (index: string) => void; sortItems: (index: string) => void; - upload: () => Promise; - rename: () => Promise; - workflow: () => Promise; - tags: () => Promise; - properties: () => Promise; // cut: (item?: object) => Promise; // copy: (item?: object) => Promise; // paste: () => Promise; @@ -89,11 +84,6 @@ export function ContentsTable({ indexes: baseIndexes, onSelectIndex, sortItems, - upload, - rename, - workflow, - tags, - properties, // cut, // copy, // paste, @@ -111,6 +101,10 @@ export function ContentsTable({ setShowDelete, setItemsToDelete, setShowUpload, + setShowRename, + setShowWorkflow, + setShowTags, + setShowProperties, showToast, } = useContentsContext(); const fetcher = useFetcher(); @@ -169,6 +163,11 @@ export function ContentsTable({ setShowUpload(true); }; + const openRename = () => setShowRename(true); + const openWorkflow = () => setShowWorkflow(true); + const openTags = () => setShowTags(true); + const openProperties = () => setShowProperties(true); + const orderItem = async (id: string, delta: number | 'bottom' | 'top') => { await fetcher.submit( { @@ -547,10 +546,10 @@ export function ContentsTable({ {!isMobileScreenSize && ( ; + }>(); + const { revalidate } = useRevalidator(); + const { + showProperties, + setShowProperties, + selected, + setSelected, + showToast, + } = useContentsContext(); + + const items = useMemo(() => [...selected], [selected]); + const initial = useMemo( + () => computeInitialStates(dataFetcher.data?.items ?? []), + [dataFetcher.data], + ); + + const [values, setValues] = useState>({}); + + useEffect(() => { + if (showProperties) { + const params = new URLSearchParams(); + items.forEach((item) => params.append('path', item['@id'])); + dataFetcher.load(`/@@contents/@@properties?${params.toString()}`); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [showProperties]); + + useEffect(() => { + if (dataFetcher.data) { + setValues({ + effective: initial.effective.value ?? null, + expires: initial.expires.value ?? null, + rights: initial.rights.value ?? '', + creators: (initial.creators.value ?? []).join(', '), + exclude_from_nav: initial.exclude_from_nav.value ?? false, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dataFetcher.data]); + + useEffect(() => { + if (fetcher.state === 'idle' && fetcher.data) { + const data = fetcher.data; + if (data?.ok?.length > 0) { + const toast: ToastItem = { + title: + data.ok.length === 1 + ? t('contents.actions.properties_saved', { + title: data.ok[0].title, + }) + : t('contents.actions.properties_saved_multiple', { + number: data.ok.length, + }), + icon: , + }; + showToast(toast); + revalidate(); + } + if (data?.errors?.length > 0) { + data.errors.forEach((e: any) => { + showToast({ + title: `${t('contents.error')} ${e.__error?.status} - ${e.__error?.data?.type}`, + description: e.__error?.data?.message, + icon: , + className: 'error', + }); + }); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fetcher.state]); + + if (!showProperties) return null; + + const close = () => { + setSelected('none'); + setShowProperties(false); + }; + + const setField = (field: PropertyField, value: any) => { + setValues({ ...values, [field]: value }); + }; + + const mixedPlaceholder = (field: PropertyField) => + initial[field].mixed ? t('contents.modal_properties.mixed') : undefined; + + const confirm = () => { + const body = buildPropertiesPatch(initial, { + effective: values.effective ?? null, + expires: values.expires ?? null, + rights: values.rights ?? '', + creators: values.creators ?? '', + exclude_from_nav: !!values.exclude_from_nav, + }); + if (Object.keys(body).length === 0) { + close(); + return; + } + fetcher.submit( + { + items: items.map((i) => ({ '@id': i['@id'], title: i.title })), + data: body, + } as SubmitTarget, + { + method: 'PATCH', + encType: 'application/json', + action: '/@@contents/@@properties', + }, + ); + close(); + }; + + return ( + + + + {t('contents.modal_properties.title')} + +
+ setField('effective', v)} + /> + setField('expires', v)} + /> + + setField('rights', e.target.value)} + /> + + + setField('creators', e.target.value)} + /> + + + +
+ + +
+
+
+
+ ); +} + +function Field({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( + + ); +} diff --git a/packages/contents/components/RenameModal/RenameModal.tsx b/packages/contents/components/RenameModal/RenameModal.tsx new file mode 100644 index 000000000..c968ae956 --- /dev/null +++ b/packages/contents/components/RenameModal/RenameModal.tsx @@ -0,0 +1,162 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useFetcher, useRevalidator, type SubmitTarget } from 'react-router'; +import { Heading } from 'react-aria-components'; +import { useTranslation } from 'react-i18next'; +import { Button, Dialog, Input, Modal } from '@plone/components/quanta'; +import { CloseIcon, RenameIcon } from '@plone/components/Icons'; +import { type ToastItem } from '@plone/layout/config/toast'; +import { useContentsContext } from '../../providers/contents'; +import { buildRenamePayload, type RenameEdit } from '../../helpers/rename'; + +export default function RenameModal() { + const { t } = useTranslation(); + const fetcher = useFetcher(); + const { revalidate } = useRevalidator(); + const { showRename, setShowRename, selected, setSelected, showToast } = + useContentsContext(); + + const items = useMemo(() => [...selected], [selected]); + const [rows, setRows] = useState([]); + + useEffect(() => { + if (showRename) { + setRows( + items.map((item) => ({ + '@id': item['@id'], + originalId: item.id, + originalTitle: item.title ?? '', + id: item.id, + title: item.title ?? '', + })), + ); + } + }, [showRename, items]); + + useEffect(() => { + if (fetcher.state === 'idle' && fetcher.data) { + const data = fetcher.data; + if (data?.ok?.length > 0) { + const toast: ToastItem = { + title: + data.ok.length === 1 + ? t('contents.actions.renamed', { title: data.ok[0].title }) + : t('contents.actions.renamed_multiple', { + number: data.ok.length, + }), + icon: , + }; + showToast(toast); + revalidate(); + } + if (data?.errors?.length > 0) { + data.errors.forEach((e: any) => { + showToast({ + title: `${t('contents.error')} ${e.__error?.status} - ${e.__error?.data?.type}`, + description: e.__error?.data?.message, + icon: , + className: 'error', + }); + }); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fetcher.state]); + + if (!showRename) return null; + + const close = () => { + setSelected('none'); + setShowRename(false); + }; + + const updateRow = (index: number, field: 'id' | 'title', value: string) => { + setRows( + rows.map((row, i) => (i === index ? { ...row, [field]: value } : row)), + ); + }; + + const confirm = () => { + const itemsToRename = buildRenamePayload(rows); + if (itemsToRename.length === 0) { + close(); + return; + } + fetcher.submit({ items: itemsToRename } as unknown as SubmitTarget, { + method: 'PATCH', + encType: 'application/json', + action: '/@@contents/@@rename', + }); + close(); + }; + + return ( + + + + {t('contents.modal_rename.title')} + +
+ + + + + + + + + {rows.map((row, index) => ( + + + + + ))} + +
+ {t('contents.modal_rename.columns.name')} + + {t('contents.modal_rename.columns.title')} +
+ updateRow(index, 'id', e.target.value)} + aria-label={`${t('contents.modal_rename.columns.name')}: ${row.originalId}`} + /> + + + updateRow(index, 'title', e.target.value) + } + aria-label={`${t('contents.modal_rename.columns.title')}: ${row.originalTitle}`} + /> +
+
+ + +
+
+
+
+ ); +} diff --git a/packages/contents/components/TagsModal/TagsModal.tsx b/packages/contents/components/TagsModal/TagsModal.tsx new file mode 100644 index 000000000..119a82137 --- /dev/null +++ b/packages/contents/components/TagsModal/TagsModal.tsx @@ -0,0 +1,231 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useFetcher, useRevalidator, type SubmitTarget } from 'react-router'; +import { Heading } from 'react-aria-components'; +import { useTranslation } from 'react-i18next'; +import { Button, Dialog, Input, Modal } from '@plone/components/quanta'; +import { CloseIcon, TagIcon } from '@plone/components/Icons'; +import { type ToastItem } from '@plone/layout/config/toast'; +import { useContentsContext } from '../../providers/contents'; +import { buildTagsPayload, unionSubjects } from '../../helpers/tags'; + +export default function TagsModal() { + const { t } = useTranslation(); + const fetcher = useFetcher(); + const vocabularyFetcher = useFetcher<{ vocabulary: string[] }>(); + const { revalidate } = useRevalidator(); + const { showTags, setShowTags, selected, setSelected, showToast } = + useContentsContext(); + + const items = useMemo(() => [...selected], [selected]); + const existingTags = useMemo(() => unionSubjects(items), [items]); + + const [added, setAdded] = useState([]); + const [removed, setRemoved] = useState([]); + const [input, setInput] = useState(''); + + useEffect(() => { + if (showTags) { + setAdded([]); + setRemoved([]); + setInput(''); + vocabularyFetcher.load('/@@contents/@@tags'); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [showTags]); + + useEffect(() => { + if (fetcher.state === 'idle' && fetcher.data) { + const data = fetcher.data; + if (data?.ok?.length > 0) { + const toast: ToastItem = { + title: + data.ok.length === 1 + ? t('contents.actions.tagged', { title: data.ok[0].title }) + : t('contents.actions.tagged_multiple', { + number: data.ok.length, + }), + icon: , + }; + showToast(toast); + revalidate(); + } + if (data?.errors?.length > 0) { + data.errors.forEach((e: any) => { + showToast({ + title: `${t('contents.error')} ${e.__error?.status} - ${e.__error?.data?.type}`, + description: e.__error?.data?.message, + icon: , + className: 'error', + }); + }); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fetcher.state]); + + if (!showTags) return null; + + const vocabulary = vocabularyFetcher.data?.vocabulary ?? []; + const visibleExisting = existingTags.filter((tag) => !removed.includes(tag)); + + const close = () => { + setSelected('none'); + setShowTags(false); + }; + + const addTag = (tag: string) => { + const value = tag.trim(); + if (!value) return; + if (removed.includes(value)) { + setRemoved(removed.filter((t) => t !== value)); + } else if (!existingTags.includes(value) && !added.includes(value)) { + setAdded([...added, value]); + } + setInput(''); + }; + + const removeExisting = (tag: string) => setRemoved([...removed, tag]); + const removeAdded = (tag: string) => setAdded(added.filter((t) => t !== tag)); + + const confirm = () => { + const itemsToTag = buildTagsPayload(items, added, removed); + if (itemsToTag.length === 0) { + close(); + return; + } + fetcher.submit({ items: itemsToTag } as unknown as SubmitTarget, { + method: 'PATCH', + encType: 'application/json', + action: '/@@contents/@@tags', + }); + close(); + }; + + return ( + + + + {t('contents.modal_tags.title')} + +
+ + {t('contents.modal_tags.current_label')} + +
+ {visibleExisting.map((tag) => ( + removeExisting(tag)} + /> + ))} + {added.map((tag) => ( + removeAdded(tag)} + /> + ))} + {visibleExisting.length === 0 && added.length === 0 && ( + + {t('contents.modal_tags.empty')} + + )} +
+ + + setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + addTag(input); + } + }} + aria-label={t('contents.modal_tags.add_label')} + /> +

+ {t('contents.modal_tags.add_hint')} +

+ + {vocabulary.map((tag) => ( + + +
+ + +
+
+
+
+ ); +} + +function TagChip({ + label, + added, + onRemove, +}: { + label: string; + added?: boolean; + onRemove: () => void; +}) { + const { t } = useTranslation(); + return ( + + {label} + + + ); +} diff --git a/packages/contents/components/WorkflowModal/WorkflowModal.tsx b/packages/contents/components/WorkflowModal/WorkflowModal.tsx new file mode 100644 index 000000000..b9bab53c1 --- /dev/null +++ b/packages/contents/components/WorkflowModal/WorkflowModal.tsx @@ -0,0 +1,198 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useFetcher, useRevalidator } from 'react-router'; +import { Heading } from 'react-aria-components'; +import { useTranslation } from 'react-i18next'; +import { Button, Dialog, Input, Modal } from '@plone/components/quanta'; +import { CloseIcon, StateIcon } from '@plone/components/Icons'; +import { type ToastItem } from '@plone/layout/config/toast'; +import { useContentsContext } from '../../providers/contents'; +import type { TransitionOption } from '../../helpers/workflow'; + +interface WorkflowData { + transitions: TransitionOption[]; + states: Array<{ id: string; title: string }>; +} + +export default function WorkflowModal() { + const { t } = useTranslation(); + const fetcher = useFetcher(); + const dataFetcher = useFetcher(); + const { revalidate } = useRevalidator(); + const { showWorkflow, setShowWorkflow, selected, setSelected, showToast } = + useContentsContext(); + + const items = useMemo(() => [...selected], [selected]); + const [transition, setTransition] = useState(''); + const [comment, setComment] = useState(''); + const [includeChildren, setIncludeChildren] = useState(false); + + useEffect(() => { + if (showWorkflow) { + setTransition(''); + setComment(''); + setIncludeChildren(false); + const params = new URLSearchParams(); + items.forEach((item) => params.append('path', item['@id'])); + dataFetcher.load(`/@@contents/@@workflow?${params.toString()}`); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [showWorkflow]); + + useEffect(() => { + if (fetcher.state === 'idle' && fetcher.data) { + const data = fetcher.data; + if (data?.ok?.length > 0) { + const toast: ToastItem = { + title: + data.ok.length === 1 + ? t('contents.actions.state_changed', { title: data.ok[0].title }) + : t('contents.actions.state_changed_multiple', { + number: data.ok.length, + }), + icon: , + }; + showToast(toast); + revalidate(); + } + if (data?.errors?.length > 0) { + data.errors.forEach((e: any) => { + showToast({ + title: `${t('contents.error')} ${e.__error?.status} - ${e.__error?.data?.type}`, + description: e.__error?.data?.message, + icon: , + className: 'error', + }); + }); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fetcher.state]); + + if (!showWorkflow) return null; + + const transitions = dataFetcher.data?.transitions ?? []; + const states = dataFetcher.data?.states ?? []; + const stateSummary = summarizeStates(states); + const loadingTransitions = dataFetcher.state !== 'idle'; + + const close = () => { + setSelected('none'); + setShowWorkflow(false); + }; + + const confirm = () => { + if (!transition) return; + fetcher.submit( + { + items: items.map((i) => ({ '@id': i['@id'], title: i.title })), + transition, + comment, + include_children: includeChildren, + }, + { + method: 'POST', + encType: 'application/json', + action: '/@@contents/@@workflow', + }, + ); + close(); + }; + + return ( + + + + {t('contents.modal_workflow.title')} + +
+ {stateSummary && ( +

+ {t('contents.modal_workflow.current_state')}: {stateSummary} +

+ )} + + + + + + + +
+ + +
+
+
+
+ ); +} + +function summarizeStates(states: Array<{ id: string; title: string }>): string { + if (states.length === 0) return ''; + const counts = new Map(); + states.forEach((s) => counts.set(s.title, (counts.get(s.title) ?? 0) + 1)); + return [...counts.entries()] + .map(([title, count]) => (count > 1 ? `${title} (${count})` : title)) + .join(', '); +} diff --git a/packages/contents/helpers/batch.ts b/packages/contents/helpers/batch.ts new file mode 100644 index 000000000..e90a5ff69 --- /dev/null +++ b/packages/contents/helpers/batch.ts @@ -0,0 +1,34 @@ +import { HandleCatchedError } from './Errors'; + +export interface BatchResult { + ok: T[]; + errors: Array; +} + +// Runs the same mutation over every item and reports per-item success and +// failure, so one bad item never aborts the rest of the selection. +export async function settleItems( + items: T[], + run: (item: T) => Promise, + errorLabel: string, +): Promise> { + const ok: T[] = []; + const errors: BatchResult['errors'] = []; + let responses: PromiseSettledResult[] = []; + + try { + responses = await Promise.allSettled(items.map(run)); + } catch (e) { + HandleCatchedError(e, errorLabel); + } + + responses.forEach((response, i) => { + if (response.status === 'fulfilled') { + ok.push(items[i]); + } else { + errors.push({ ...items[i], __error: response.reason }); + } + }); + + return { ok, errors }; +} diff --git a/packages/contents/helpers/properties.test.ts b/packages/contents/helpers/properties.test.ts new file mode 100644 index 000000000..b61299705 --- /dev/null +++ b/packages/contents/helpers/properties.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest'; +import { + buildPropertiesPatch, + cleanSentinelDate, + computeInitialStates, + fieldState, +} from './properties'; + +describe('cleanSentinelDate', () => { + it('keeps a real date', () => { + expect(cleanSentinelDate('2026-01-01T10:00:00+00:00')).toBe( + '2026-01-01T10:00:00+00:00', + ); + }); + + it('treats far-past and far-future sentinels as empty', () => { + expect(cleanSentinelDate('1969-12-31T00:00:00+00:00')).toBeNull(); + expect(cleanSentinelDate('2499-12-31T00:00:00+00:00')).toBeNull(); + expect(cleanSentinelDate('6767-12-31T01:00:00+00:00')).toBeNull(); + }); + + it('treats empty, None and non-strings as empty', () => { + expect(cleanSentinelDate('')).toBeNull(); + expect(cleanSentinelDate('None')).toBeNull(); + expect(cleanSentinelDate(null)).toBeNull(); + }); +}); + +describe('fieldState', () => { + it('reports a common value when all items agree', () => { + expect(fieldState(['a', 'a'])).toEqual({ mixed: false, value: 'a' }); + }); + + it('reports mixed when values differ', () => { + expect(fieldState(['a', 'b'])).toEqual({ mixed: true, value: null }); + }); + + it('treats null and undefined as the same empty value', () => { + expect(fieldState([null, undefined])).toEqual({ + mixed: false, + value: null, + }); + }); + + it('compares array values structurally', () => { + expect(fieldState([['admin'], ['admin']])).toEqual({ + mixed: false, + value: ['admin'], + }); + expect(fieldState([['admin'], ['editor']])).toEqual({ + mixed: true, + value: null, + }); + }); +}); + +describe('computeInitialStates', () => { + it('computes per-field states for a mixed selection', () => { + const states = computeInitialStates([ + { rights: 'CC', exclude_from_nav: true, creators: ['admin'] }, + { rights: 'CC', exclude_from_nav: false, creators: ['admin'] }, + ]); + expect(states.rights).toEqual({ mixed: false, value: 'CC' }); + expect(states.exclude_from_nav).toEqual({ mixed: true, value: null }); + expect(states.creators).toEqual({ mixed: false, value: ['admin'] }); + }); +}); + +describe('buildPropertiesPatch', () => { + const baseForm = { + effective: null as string | null, + expires: null as string | null, + rights: '', + creators: '', + exclude_from_nav: false, + }; + + it('returns nothing when the form matches the initial values', () => { + const initial = computeInitialStates([ + { + effective: '2026-01-01T10:00:00+00:00', + rights: 'CC', + creators: ['admin'], + exclude_from_nav: false, + }, + ]); + const form = { + ...baseForm, + effective: '2026-01-01T10:00:00+00:00', + rights: 'CC', + creators: 'admin', + }; + expect(buildPropertiesPatch(initial, form)).toEqual({}); + }); + + it('does not resend a date that only changed format', () => { + const initial = computeInitialStates([ + { effective: '2026-01-01T10:00:00+00:00' }, + ]); + const form = { ...baseForm, effective: '2026-01-01T10:00:00.000Z' }; + expect(buildPropertiesPatch(initial, form)).toEqual({}); + }); + + it('sends only the changed field', () => { + const initial = computeInitialStates([ + { effective: '2026-01-01T10:00:00+00:00', rights: 'CC' }, + ]); + const form = { + ...baseForm, + effective: '2026-01-01T10:00:00+00:00', + rights: 'CC BY', + }; + expect(buildPropertiesPatch(initial, form)).toEqual({ rights: 'CC BY' }); + }); + + it('clears a date or rights value with null', () => { + const initial = computeInitialStates([ + { effective: '2026-01-01T10:00:00+00:00', rights: 'CC' }, + ]); + expect(buildPropertiesPatch(initial, baseForm)).toEqual({ + effective: null, + rights: null, + }); + }); + + it('parses creators from the comma-separated input', () => { + const initial = computeInitialStates([{ creators: ['admin'] }]); + const form = { ...baseForm, creators: 'admin, editor' }; + expect(buildPropertiesPatch(initial, form)).toEqual({ + creators: ['admin', 'editor'], + }); + }); +}); diff --git a/packages/contents/helpers/properties.ts b/packages/contents/helpers/properties.ts new file mode 100644 index 000000000..37fb6028b --- /dev/null +++ b/packages/contents/helpers/properties.ts @@ -0,0 +1,109 @@ +export const PROPERTY_FIELDS = [ + 'effective', + 'expires', + 'rights', + 'creators', + 'exclude_from_nav', +] as const; + +export type PropertyField = (typeof PROPERTY_FIELDS)[number]; + +export interface PropertyItem { + effective?: string | null; + expires?: string | null; + rights?: string | null; + creators?: string[] | null; + exclude_from_nav?: boolean | null; +} + +// Unset effective/expires come back as sentinel dates whose year varies by Plone version. +export function cleanSentinelDate(value: unknown): string | null { + if (typeof value !== 'string' || value === '' || value === 'None') + return null; + const year = Number(value.slice(0, 4)); + if (!Number.isFinite(year) || year < 1970 || year > 2400) return null; + return value; +} + +export interface FieldState { + mixed: boolean; + value: T | null; +} + +export type InitialStates = { + [K in PropertyField]: FieldState; +}; + +const norm = (v: unknown): string => JSON.stringify(v ?? null); + +export function fieldState( + values: Array, +): FieldState { + if (values.length === 0) return { mixed: false, value: null }; + const first = norm(values[0]); + const allSame = values.every((v) => norm(v) === first); + return { mixed: !allSame, value: allSame ? (values[0] ?? null) : null }; +} + +export function computeInitialStates(items: PropertyItem[]): InitialStates { + return { + effective: fieldState(items.map((i) => i.effective)), + expires: fieldState(items.map((i) => i.expires)), + rights: fieldState(items.map((i) => i.rights)), + creators: fieldState(items.map((i) => i.creators)), + exclude_from_nav: fieldState(items.map((i) => i.exclude_from_nav)), + }; +} + +export interface PropertyForm { + effective: string | null; + expires: string | null; + rights: string; + creators: string; + exclude_from_nav: boolean; +} + +const instant = (value: string | null | undefined): string => { + if (!value) return ''; + const time = new Date(value).getTime(); + return Number.isNaN(time) ? value : String(time); +}; + +const sameStrings = (a: string[], b: string[]): boolean => + a.length === b.length && a.every((value, i) => value === b[i]); + +// Diff against loaded values rather than tracking change events: the date picker emits a change while normalizing on load. +export function buildPropertiesPatch( + initial: InitialStates, + form: PropertyForm, +): Record { + const patch: Record = {}; + + if (instant(form.effective) !== instant(initial.effective.value)) { + patch.effective = form.effective || null; + } + if (instant(form.expires) !== instant(initial.expires.value)) { + patch.expires = form.expires || null; + } + + const initialRights = initial.rights.value ?? ''; + if (form.rights !== initialRights) { + patch.rights = form.rights === '' ? null : form.rights; + } + + const currentCreators = form.creators + .split(',') + .map((c) => c.trim()) + .filter(Boolean); + if (!sameStrings(currentCreators, initial.creators.value ?? [])) { + patch.creators = currentCreators; + } + + if ( + Boolean(form.exclude_from_nav) !== Boolean(initial.exclude_from_nav.value) + ) { + patch.exclude_from_nav = Boolean(form.exclude_from_nav); + } + + return patch; +} diff --git a/packages/contents/helpers/rename.test.ts b/packages/contents/helpers/rename.test.ts new file mode 100644 index 000000000..b904c09ef --- /dev/null +++ b/packages/contents/helpers/rename.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { buildRenamePayload, type RenameEdit } from './rename'; + +const edit = (overrides: Partial = {}): RenameEdit => ({ + '@id': '/folder/doc', + originalId: 'doc', + originalTitle: 'Doc', + id: 'doc', + title: 'Doc', + ...overrides, +}); + +describe('buildRenamePayload', () => { + it('drops rows where nothing changed', () => { + expect(buildRenamePayload([edit()])).toEqual([]); + }); + + it('includes only the changed title', () => { + expect(buildRenamePayload([edit({ title: 'New title' })])).toEqual([ + { + '@id': '/folder/doc', + data: { title: 'New title' }, + title: 'New title', + }, + ]); + }); + + it('includes only the changed id', () => { + expect(buildRenamePayload([edit({ id: 'new-doc' })])).toEqual([ + { '@id': '/folder/doc', data: { id: 'new-doc' }, title: 'Doc' }, + ]); + }); + + it('includes both id and title when both changed', () => { + expect( + buildRenamePayload([edit({ id: 'new-doc', title: 'New title' })]), + ).toEqual([ + { + '@id': '/folder/doc', + data: { id: 'new-doc', title: 'New title' }, + title: 'New title', + }, + ]); + }); + + it('keeps only the rows that changed in a mixed selection', () => { + const result = buildRenamePayload([ + edit({ + '@id': '/folder/a', + originalId: 'a', + id: 'a', + originalTitle: 'A', + title: 'A', + }), + edit({ + '@id': '/folder/b', + originalId: 'b', + id: 'b-renamed', + originalTitle: 'B', + title: 'B', + }), + ]); + expect(result).toEqual([ + { '@id': '/folder/b', data: { id: 'b-renamed' }, title: 'B' }, + ]); + }); +}); diff --git a/packages/contents/helpers/rename.ts b/packages/contents/helpers/rename.ts new file mode 100644 index 000000000..99c0e1a44 --- /dev/null +++ b/packages/contents/helpers/rename.ts @@ -0,0 +1,32 @@ +export interface RenameEdit { + '@id': string; + originalId: string; + originalTitle: string; + id: string; + title: string; +} + +export interface RenameItemPayload { + '@id': string; + data: { id?: string; title?: string }; + title: string; +} + +export function buildRenamePayload(edits: RenameEdit[]): RenameItemPayload[] { + return edits + .map((edit) => { + const data: RenameItemPayload['data'] = {}; + if (edit.id !== edit.originalId) { + data.id = edit.id; + } + if (edit.title !== edit.originalTitle) { + data.title = edit.title; + } + return { + '@id': edit['@id'], + data, + title: edit.title || edit.id, + }; + }) + .filter((payload) => 'id' in payload.data || 'title' in payload.data); +} diff --git a/packages/contents/helpers/tags.test.ts b/packages/contents/helpers/tags.test.ts new file mode 100644 index 000000000..23ab0f68c --- /dev/null +++ b/packages/contents/helpers/tags.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { applyTagChanges, buildTagsPayload, unionSubjects } from './tags'; + +describe('unionSubjects', () => { + it('collects, de-duplicates and sorts tags across items', () => { + const items = [ + { '@id': '/a', subjects: ['news', 'event'] }, + { '@id': '/b', subjects: ['event', 'press'] }, + { '@id': '/c', subjects: null }, + ]; + expect(unionSubjects(items)).toEqual(['event', 'news', 'press']); + }); + + it('reads the brain `Subject` field when present', () => { + const items = [ + { '@id': '/a', Subject: ['news'] }, + { '@id': '/b', Subject: ['event'] }, + ]; + expect(unionSubjects(items)).toEqual(['event', 'news']); + }); +}); + +describe('applyTagChanges', () => { + it('adds and removes without duplicating', () => { + expect(applyTagChanges(['news'], ['news', 'event'], ['old'])).toEqual([ + 'news', + 'event', + ]); + }); + + it('treats missing current subjects as empty', () => { + expect(applyTagChanges(null, ['news'], [])).toEqual(['news']); + }); + + it('removes a tag', () => { + expect(applyTagChanges(['news', 'event'], [], ['event'])).toEqual(['news']); + }); +}); + +describe('buildTagsPayload', () => { + it('keeps only items whose subjects change', () => { + const items = [ + { '@id': '/a', title: 'A', subjects: ['news'] }, + { '@id': '/b', title: 'B', subjects: ['news', 'event'] }, + ]; + const result = buildTagsPayload(items, ['news'], ['event']); + expect(result).toEqual([ + { '@id': '/b', title: 'B', data: { subjects: ['news'] } }, + ]); + }); + + it('returns nothing when there are no changes', () => { + const items = [{ '@id': '/a', title: 'A', subjects: ['news'] }]; + expect(buildTagsPayload(items, [], [])).toEqual([]); + }); +}); diff --git a/packages/contents/helpers/tags.ts b/packages/contents/helpers/tags.ts new file mode 100644 index 000000000..09b4658fc --- /dev/null +++ b/packages/contents/helpers/tags.ts @@ -0,0 +1,59 @@ +export interface TaggedItem { + '@id': string; + // Brains expose keywords as `Subject`, content as `subjects`; read either. + Subject?: string[] | null; + subjects?: string[] | null; +} + +export interface TagsItemPayload { + '@id': string; + title: string; + data: { subjects: string[] }; +} + +export function itemSubjects(item: TaggedItem): string[] { + return item.Subject ?? item.subjects ?? []; +} + +export function unionSubjects(items: TaggedItem[]): string[] { + const all = new Set(); + items.forEach((item) => itemSubjects(item).forEach((s) => all.add(s))); + return [...all].sort((a, b) => a.localeCompare(b)); +} + +export function applyTagChanges( + current: string[] | null | undefined, + added: string[], + removed: string[], +): string[] { + const next = new Set(current ?? []); + removed.forEach((tag) => next.delete(tag)); + added.forEach((tag) => next.add(tag)); + return [...next]; +} + +export function buildTagsPayload( + items: Array, + added: string[], + removed: string[], +): TagsItemPayload[] { + return items + .map((item) => { + const current = itemSubjects(item); + const subjects = applyTagChanges(current, added, removed); + return { + '@id': item['@id'], + title: item.title ?? item['@id'], + data: { subjects }, + changed: !sameSet(current, subjects), + }; + }) + .filter((p) => p.changed) + .map(({ changed, ...payload }) => payload); +} + +function sameSet(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false; + const setB = new Set(b); + return a.every((x) => setB.has(x)); +} diff --git a/packages/contents/helpers/workflow.test.ts b/packages/contents/helpers/workflow.test.ts new file mode 100644 index 000000000..ed7e97203 --- /dev/null +++ b/packages/contents/helpers/workflow.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { commonTransitions, transitionId } from './workflow'; + +const transition = (id: string, title = id) => ({ + '@id': `http://site/doc/@workflow/${id}`, + title, +}); + +describe('transitionId', () => { + it('extracts the last path segment', () => { + expect(transitionId(transition('publish'))).toBe('publish'); + }); +}); + +describe('commonTransitions', () => { + it('returns the intersection across items', () => { + const result = commonTransitions([ + [transition('publish', 'Publish'), transition('submit', 'Submit')], + [transition('publish', 'Publish'), transition('retract', 'Retract')], + ]); + expect(result).toEqual([{ id: 'publish', title: 'Publish' }]); + }); + + it('returns all transitions for a single item', () => { + const result = commonTransitions([ + [transition('publish', 'Publish'), transition('submit', 'Submit')], + ]); + expect(result).toEqual([ + { id: 'publish', title: 'Publish' }, + { id: 'submit', title: 'Submit' }, + ]); + }); + + it('returns empty when items share no transition', () => { + const result = commonTransitions([ + [transition('publish')], + [transition('retract')], + ]); + expect(result).toEqual([]); + }); + + it('returns empty for an empty selection', () => { + expect(commonTransitions([])).toEqual([]); + }); +}); diff --git a/packages/contents/helpers/workflow.ts b/packages/contents/helpers/workflow.ts new file mode 100644 index 000000000..d554a2eb2 --- /dev/null +++ b/packages/contents/helpers/workflow.ts @@ -0,0 +1,31 @@ +export interface Transition { + '@id': string; + title: string; +} + +export interface TransitionOption { + id: string; + title: string; +} + +export function transitionId(transition: Transition): string { + return transition['@id'].split('/').pop() || transition['@id']; +} + +export function commonTransitions(perItem: Transition[][]): TransitionOption[] { + if (perItem.length === 0) return []; + + const maps = perItem.map( + (transitions) => + new Map(transitions.map((t) => [transitionId(t), t.title])), + ); + const [first, ...rest] = maps; + + const result: TransitionOption[] = []; + for (const [id, title] of first) { + if (rest.every((m) => m.has(id))) { + result.push({ id, title }); + } + } + return result; +} diff --git a/packages/contents/index.ts b/packages/contents/index.ts index 3d8dbc276..c7361507a 100644 --- a/packages/contents/index.ts +++ b/packages/contents/index.ts @@ -21,6 +21,26 @@ export default function install(config: ConfigType) { type: 'route', file: '@plone/contents/routes/upload.tsx', }, + { + path: '@@rename/*', + type: 'route', + file: '@plone/contents/routes/rename.tsx', + }, + { + path: '@@workflow/*', + type: 'route', + file: '@plone/contents/routes/workflow.tsx', + }, + { + path: '@@tags/*', + type: 'route', + file: '@plone/contents/routes/tags.tsx', + }, + { + path: '@@properties/*', + type: 'route', + file: '@plone/contents/routes/properties.tsx', + }, { path: '@@order/*', type: 'route', diff --git a/packages/contents/locales/en/common.json b/packages/contents/locales/en/common.json index e7155fb38..a8f6f95f6 100644 --- a/packages/contents/locales/en/common.json +++ b/packages/contents/locales/en/common.json @@ -32,7 +32,15 @@ "move_to_top_folder": "Move to top folder", "move_to_bottom_folder": "Move to bottom folder", "uploaded": "Content '{{title}}' successfully uploaded", - "uploaded_multiple": "{{number}} contents successfully uploaded" + "uploaded_multiple": "{{number}} contents successfully uploaded", + "renamed": "Content '{{title}}' successfully renamed", + "renamed_multiple": "{{number}} contents successfully renamed", + "tagged": "Tags of '{{title}}' successfully updated", + "tagged_multiple": "Tags of {{number}} contents successfully updated", + "properties_saved": "Properties of '{{title}}' successfully updated", + "properties_saved_multiple": "Properties of {{number}} contents successfully updated", + "state_changed": "State of '{{title}}' successfully changed", + "state_changed_multiple": "State of {{number}} contents successfully changed" }, "item": { "expired": "Expired", @@ -93,6 +101,45 @@ }, "description": "This action cannot be undone.", "confirm": "Delete" + }, + "modal_rename": { + "title": "Rename items", + "confirm": "Rename", + "columns": { + "name": "Short name", + "title": "Title" + } + }, + "modal_tags": { + "title": "Edit tags", + "confirm": "Save tags", + "current_label": "Tags", + "add_label": "Add a tag", + "add_placeholder": "Type a tag…", + "add_hint": "Press Enter to add. Suggestions come from existing tags.", + "empty": "No tags on the selected items.", + "remove": "Remove tag {{tag}}" + }, + "modal_properties": { + "title": "Edit properties", + "confirm": "Save properties", + "mixed": "Mixed values", + "effective": "Publishing date", + "expires": "Expiration date", + "rights": "Rights", + "creators": "Creators", + "creators_hint": "Comma-separated user names", + "exclude_from_nav": "Exclude from navigation" + }, + "modal_workflow": { + "title": "Change state", + "confirm": "Change state", + "current_state": "Current state", + "transition": "Transition", + "select_transition": "Select a transition…", + "no_common_transitions": "The selected items share no common transition.", + "comment": "Comment", + "include_children": "Apply to contained items" } } } diff --git a/packages/contents/locales/it/common.json b/packages/contents/locales/it/common.json index d23f9ebba..dc8fa5c25 100644 --- a/packages/contents/locales/it/common.json +++ b/packages/contents/locales/it/common.json @@ -32,7 +32,15 @@ "move_to_top_folder": "Sposta in cima alla cartella", "move_to_bottom_folder": "Sposta in fondo alla cartella", "uploaded": "Contenuto '{{title}}' caricato con successo", - "uploaded_multiple": "{{number}} contenuti caricati con successo" + "uploaded_multiple": "{{number}} contenuti caricati con successo", + "renamed": "Contenuto '{{title}}' rinominato con successo", + "renamed_multiple": "{{number}} contenuti rinominati con successo", + "tagged": "Etichette di '{{title}}' aggiornate con successo", + "tagged_multiple": "Etichette di {{number}} contenuti aggiornate con successo", + "properties_saved": "Proprietà di '{{title}}' aggiornate con successo", + "properties_saved_multiple": "Proprietà di {{number}} contenuti aggiornate con successo", + "state_changed": "Stato di '{{title}}' cambiato con successo", + "state_changed_multiple": "Stato di {{number}} contenuti cambiato con successo" }, "item": { "expired": "Scaduto", @@ -93,6 +101,45 @@ }, "description": "Questa azione non può essere annullata.", "confirm": "Procedi con l'eliminazione" + }, + "modal_rename": { + "title": "Rinomina elementi", + "confirm": "Rinomina", + "columns": { + "name": "Nome breve", + "title": "Titolo" + } + }, + "modal_tags": { + "title": "Modifica etichette", + "confirm": "Salva etichette", + "current_label": "Etichette", + "add_label": "Aggiungi un'etichetta", + "add_placeholder": "Scrivi un'etichetta…", + "add_hint": "Premi Invio per aggiungere. I suggerimenti provengono dalle etichette esistenti.", + "empty": "Nessuna etichetta sugli elementi selezionati.", + "remove": "Rimuovi etichetta {{tag}}" + }, + "modal_properties": { + "title": "Modifica proprietà", + "confirm": "Salva proprietà", + "mixed": "Valori misti", + "effective": "Data di pubblicazione", + "expires": "Data di scadenza", + "rights": "Diritti", + "creators": "Autori", + "creators_hint": "Nomi utente separati da virgola", + "exclude_from_nav": "Escludi dalla navigazione" + }, + "modal_workflow": { + "title": "Cambia stato", + "confirm": "Cambia stato", + "current_state": "Stato attuale", + "transition": "Transizione", + "select_transition": "Seleziona una transizione…", + "no_common_transitions": "Gli elementi selezionati non hanno transizioni in comune.", + "comment": "Commento", + "include_children": "Applica agli elementi contenuti" } } } diff --git a/packages/contents/news/+contents-object-actions.feature b/packages/contents/news/+contents-object-actions.feature new file mode 100644 index 000000000..3ea5d4210 --- /dev/null +++ b/packages/contents/news/+contents-object-actions.feature @@ -0,0 +1 @@ +Implemented the Rename, Change state (workflow), Tags, and Properties bulk actions in the folder contents view, replacing the previous placeholder buttons. @nils-pzr diff --git a/packages/contents/providers/contents.tsx b/packages/contents/providers/contents.tsx index 1608beb67..ea65d8f07 100644 --- a/packages/contents/providers/contents.tsx +++ b/packages/contents/providers/contents.tsx @@ -32,6 +32,14 @@ interface ContentsContext { setShowUpload: (s: boolean) => void; pendingDropFiles: FileEntry[]; setPendingDropFiles: (files: FileEntry[]) => void; + showRename: boolean; + setShowRename: (s: boolean) => void; + showWorkflow: boolean; + setShowWorkflow: (s: boolean) => void; + showTags: boolean; + setShowTags: (s: boolean) => void; + showProperties: boolean; + setShowProperties: (s: boolean) => void; contentTitle: string; contentPath: string; showToast: (c: ToastItem) => void; @@ -48,6 +56,14 @@ const ContentsContext = createContext({ setShowUpload: () => {}, pendingDropFiles: [], setPendingDropFiles: () => {}, + showRename: false, + setShowRename: () => {}, + showWorkflow: false, + setShowWorkflow: () => {}, + showTags: false, + setShowTags: () => {}, + showProperties: false, + setShowProperties: () => {}, contentTitle: '', contentPath: '/', showToast: (t: ToastItem) => {}, @@ -86,6 +102,12 @@ export function ContentsProvider(props: ContentsProviderProps) { const [showUpload, setShowUpload] = useState(false); const [pendingDropFiles, setPendingDropFiles] = useState([]); + //object actions + const [showRename, setShowRename] = useState(false); + const [showWorkflow, setShowWorkflow] = useState(false); + const [showTags, setShowTags] = useState(false); + const [showProperties, setShowProperties] = useState(false); + //show toast const showToast = (queueElement: ToastItem) => { config @@ -106,6 +128,14 @@ export function ContentsProvider(props: ContentsProviderProps) { setShowUpload, pendingDropFiles, setPendingDropFiles, + showRename, + setShowRename, + showWorkflow, + setShowWorkflow, + showTags, + setShowTags, + showProperties, + setShowProperties, contentTitle, contentPath, showToast, diff --git a/packages/contents/routes/contents.tsx b/packages/contents/routes/contents.tsx index bdb86db5c..004ac0dfc 100644 --- a/packages/contents/routes/contents.tsx +++ b/packages/contents/routes/contents.tsx @@ -13,6 +13,10 @@ import Indexes, { defaultIndexes } from '../components/Indexes'; import { ContentsProvider } from '../providers/contents'; import DeleteModal from '../components/DeleteModal/DeleteModal'; import UploadModal from '../components/UploadModal/UploadModal'; +import RenameModal from '../components/RenameModal/RenameModal'; +import WorkflowModal from '../components/WorkflowModal/WorkflowModal'; +import TagsModal from '../components/TagsModal/TagsModal'; +import PropertiesModal from '../components/PropertiesModal/PropertiesModal'; import ErrorToast from '@plone/layout/components/Toast/ErrorToast'; import type { TableIndexes } from '../types'; @@ -92,12 +96,6 @@ export default function Contents() { const navigate = useNavigate(); const [indexes, setIndexes] = useState(DEFAULT_TABLE_INDEXES); - const upload = () => Promise.resolve(); - const properties = () => Promise.resolve(); - const workflow = () => Promise.resolve(); - const tags = () => Promise.resolve(); - const rename = () => Promise.resolve(); - const onSortItems = (_: any, { value }: { value: string }) => { const [sort_on, sort_order] = value.split('|'); const params = new URLSearchParams(window.location.search); @@ -124,6 +122,10 @@ export default function Contents() { + + + + onSortItems(undefined, { value: id })} - upload={upload} - rename={rename} - workflow={workflow} - tags={tags} - properties={properties} - // addableTypes={props.addableTypes} /> diff --git a/packages/contents/routes/properties.tsx b/packages/contents/routes/properties.tsx new file mode 100644 index 000000000..2364bc3de --- /dev/null +++ b/packages/contents/routes/properties.tsx @@ -0,0 +1,69 @@ +import { + data, + RouterContextProvider, + type ActionFunctionArgs, + type LoaderFunctionArgs, +} from 'react-router'; +import { requireAuthCookie } from '@plone/react-router'; +import { ploneClientContext } from '@plone/aurora/app/middleware.server'; +import { HandleCatchedError } from '../helpers/Errors'; +import { settleItems } from '../helpers/batch'; +import { cleanSentinelDate, type PropertyItem } from '../helpers/properties'; + +// The folder listing only carries catalog metadata, with sentinel dates and no +// `rights`, so load the full objects to pre-fill accurate values. +export async function loader({ + request, + context, +}: LoaderFunctionArgs) { + await requireAuthCookie(request); + const cli = context.get(ploneClientContext); + + const paths = new URL(request.url).searchParams.getAll('path'); + const items: Array = []; + + try { + const results = await Promise.allSettled( + paths.map((path) => cli.getContent({ path })), + ); + results.forEach((r) => { + if (r.status === 'fulfilled') { + const c = r.value.data; + items.push({ + '@id': c['@id'], + effective: cleanSentinelDate(c.effective), + expires: cleanSentinelDate(c.expires), + rights: c.rights ?? null, + creators: c.creators ?? null, + exclude_from_nav: c.exclude_from_nav ?? null, + }); + } + }); + } catch (e) { + HandleCatchedError(e, 'Error loading properties'); + } + + return data({ items }, 200); +} + +interface PropertiesPayload { + items: Array<{ '@id': string; title: string }>; + data: Record; +} + +export async function action({ + request, + context, +}: ActionFunctionArgs) { + await requireAuthCookie(request); + const cli = context.get(ploneClientContext); + const payload: PropertiesPayload = await request.json(); + + const { ok, errors } = await settleItems( + payload.items, + (item) => cli.updateContent({ path: item['@id'], data: payload.data }), + 'Error on properties update', + ); + + return data({ ok, errors }, 200); +} diff --git a/packages/contents/routes/rename.tsx b/packages/contents/routes/rename.tsx new file mode 100644 index 000000000..804dc9d85 --- /dev/null +++ b/packages/contents/routes/rename.tsx @@ -0,0 +1,27 @@ +import { + data, + RouterContextProvider, + type ActionFunctionArgs, +} from 'react-router'; +import { requireAuthCookie } from '@plone/react-router'; +import { ploneClientContext } from '@plone/aurora/app/middleware.server'; +import { settleItems } from '../helpers/batch'; +import type { RenameItemPayload } from '../helpers/rename'; + +export async function action({ + request, + context, +}: ActionFunctionArgs) { + await requireAuthCookie(request); + + const cli = context.get(ploneClientContext); + const payload: { items: RenameItemPayload[] } = await request.json(); + + const { ok, errors } = await settleItems( + payload.items, + (item) => cli.updateContent({ path: item['@id'], data: item.data }), + 'Error on rename', + ); + + return data({ ok, errors }, 200); +} diff --git a/packages/contents/routes/tags.tsx b/packages/contents/routes/tags.tsx new file mode 100644 index 000000000..ecbcf0752 --- /dev/null +++ b/packages/contents/routes/tags.tsx @@ -0,0 +1,48 @@ +import { + data, + RouterContextProvider, + type ActionFunctionArgs, + type LoaderFunctionArgs, +} from 'react-router'; +import { requireAuthCookie } from '@plone/react-router'; +import { ploneClientContext } from '@plone/aurora/app/middleware.server'; +import { HandleCatchedError } from '../helpers/Errors'; +import { settleItems } from '../helpers/batch'; +import type { TagsItemPayload } from '../helpers/tags'; + +export async function loader({ + request, + context, +}: LoaderFunctionArgs) { + await requireAuthCookie(request); + const cli = context.get(ploneClientContext); + + let vocabulary: string[] = []; + try { + const result = await cli.getVocabulary({ + path: 'plone.app.vocabularies.Keywords', + }); + vocabulary = result.data.items.map((item) => item.token); + } catch (e) { + HandleCatchedError(e, 'Error loading keywords vocabulary'); + } + + return data({ vocabulary }, 200); +} + +export async function action({ + request, + context, +}: ActionFunctionArgs) { + await requireAuthCookie(request); + const cli = context.get(ploneClientContext); + const payload: { items: TagsItemPayload[] } = await request.json(); + + const { ok, errors } = await settleItems( + payload.items, + (item) => cli.updateContent({ path: item['@id'], data: item.data }), + 'Error on tags update', + ); + + return data({ ok, errors }, 200); +} diff --git a/packages/contents/routes/workflow.tsx b/packages/contents/routes/workflow.tsx new file mode 100644 index 000000000..461a3a7af --- /dev/null +++ b/packages/contents/routes/workflow.tsx @@ -0,0 +1,77 @@ +import { + data, + RouterContextProvider, + type ActionFunctionArgs, + type LoaderFunctionArgs, +} from 'react-router'; +import { requireAuthCookie } from '@plone/react-router'; +import { ploneClientContext } from '@plone/aurora/app/middleware.server'; +import { HandleCatchedError } from '../helpers/Errors'; +import { settleItems } from '../helpers/batch'; +import { commonTransitions, type Transition } from '../helpers/workflow'; + +export async function loader({ + request, + context, +}: LoaderFunctionArgs) { + await requireAuthCookie(request); + const cli = context.get(ploneClientContext); + + const paths = new URL(request.url).searchParams.getAll('path'); + let transitions: ReturnType = []; + const states: Array<{ id: string; title: string }> = []; + + try { + const results = await Promise.allSettled( + paths.map((path) => cli.getWorkflow({ path })), + ); + const perItem: Transition[][] = []; + results.forEach((r) => { + if (r.status === 'fulfilled') { + perItem.push( + (r.value.data.transitions ?? []) as unknown as Transition[], + ); + if (r.value.data.state) { + states.push(r.value.data.state); + } + } + }); + transitions = commonTransitions(perItem); + } catch (e) { + HandleCatchedError(e, 'Error loading workflow transitions'); + } + + return data({ transitions, states }, 200); +} + +interface WorkflowPayload { + items: Array<{ '@id': string; title: string }>; + transition: string; + comment?: string; + include_children?: boolean; +} + +export async function action({ + request, + context, +}: ActionFunctionArgs) { + await requireAuthCookie(request); + const cli = context.get(ploneClientContext); + const payload: WorkflowPayload = await request.json(); + + const { ok, errors } = await settleItems( + payload.items, + (item) => + cli.createWorkflow({ + path: item['@id'], + transition: payload.transition, + data: { + comment: payload.comment || undefined, + include_children: payload.include_children || undefined, + }, + }), + 'Error on workflow transition', + ); + + return data({ ok, errors }, 200); +} From 7ddaf33302ce0081df65dd5b4c4a264b7fbe7f59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nils=20Pl=C3=BCtzer?= Date: Tue, 9 Jun 2026 13:56:06 +0200 Subject: [PATCH 2/5] #108 - Address documentation review feedback --- .../manage-folder-contents-in-bulk.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/how-to-guides/manage-folder-contents-in-bulk.md b/docs/how-to-guides/manage-folder-contents-in-bulk.md index 39faa3949..ea317bd4a 100644 --- a/docs/how-to-guides/manage-folder-contents-in-bulk.md +++ b/docs/how-to-guides/manage-folder-contents-in-bulk.md @@ -14,13 +14,13 @@ Each action works on one or several items at once. ## Open the folder contents view -Navigate to a folder and open its contents view at `/@@contents` followed by the folder path, for example `/@@contents/my-folder`. +Navigate to a folder, then select the {guilabel}`Contents` button — the folder icon — in the toolbar. The view lists the items in the folder together with a toolbar of actions. ## Select the items to act on -Select one or more items with the checkboxes in the first column. -Use the checkbox in the table header to select or clear all items on the current page. +To select items to act on, check their checkboxes in the first column. +To either select or deselect all items on the current page, check the checkbox in the table header. The {guilabel}`Rename`, {guilabel}`Change state`, {guilabel}`Tags`, and {guilabel}`Properties` actions stay disabled until you select at least one item. Each action applies to the current selection. @@ -29,14 +29,14 @@ Each action applies to the current selection. 1. Select the items to rename. 2. Select {guilabel}`Rename` in the toolbar. -3. Edit the short name, the title, or both for each item in the dialog. +3. Edit the {guilabel}`Short name`, the {guilabel}`Title`, or both for each item in the dialog. 4. Select {guilabel}`Rename` in the dialog to apply the changes. -The short name is the last segment of the item's URL. +The {guilabel}`Short name` is the last segment of the item's URL. ```{warning} -Changing the short name changes the item's URL. -Existing links to the old URL no longer resolve unless a redirect is in place. +Changing the {guilabel}`Short name` changes the item's URL. +Links and bookmarks that point to the old URL stop working. ``` ## Change the workflow state @@ -49,7 +49,7 @@ Existing links to the old URL no longer resolve unless a redirect is in place. 6. Select {guilabel}`Change state` in the dialog to apply the transition. The list offers only the transitions that are available for every selected item. -If the selected items share no common transition, the dialog tells you so, and you cannot apply a transition. +If the selected items share no common transition, the dialog tells you so, and you can't apply a transition. ## Edit tags @@ -67,7 +67,7 @@ Adding a tag applies it to every selected item, and removing a tag removes it fr 1. Select the items whose properties you want to edit. 2. Select {guilabel}`Properties` in the toolbar. -3. Edit any of the publishing date, expiration date, rights, creators, or {guilabel}`Exclude from navigation`. +3. Edit any of the {guilabel}`Publishing date`, {guilabel}`Expiration date`, {guilabel}`Rights`, {guilabel}`Creators`, or {guilabel}`Exclude from navigation`. 4. Select {guilabel}`Save properties` to apply the changes. The dialog saves only the fields you change. From 69f644c56422bcaf23b5fe2117b4aedf335c3ac2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nils=20Pl=C3=BCtzer?= Date: Thu, 11 Jun 2026 09:57:24 +0200 Subject: [PATCH 3/5] Refine UI components and enhance documentation for contents management --- docs/_static/icons/contents.svg | 3 + .../manage-folder-contents-in-bulk.md | 10 ++- .../PropertiesModal/PropertiesModal.tsx | 39 +++++++---- .../components/RenameModal/RenameModal.tsx | 14 +++- .../components/TagsModal/TagsModal.tsx | 70 +++++++++++-------- .../WorkflowModal/WorkflowModal.tsx | 65 +++++++++-------- 6 files changed, 126 insertions(+), 75 deletions(-) create mode 100644 docs/_static/icons/contents.svg diff --git a/docs/_static/icons/contents.svg b/docs/_static/icons/contents.svg new file mode 100644 index 000000000..ff56bf713 --- /dev/null +++ b/docs/_static/icons/contents.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/how-to-guides/manage-folder-contents-in-bulk.md b/docs/how-to-guides/manage-folder-contents-in-bulk.md index ea317bd4a..8f6bb58f6 100644 --- a/docs/how-to-guides/manage-folder-contents-in-bulk.md +++ b/docs/how-to-guides/manage-folder-contents-in-bulk.md @@ -14,7 +14,7 @@ Each action works on one or several items at once. ## Open the folder contents view -Navigate to a folder, then select the {guilabel}`Contents` button — the folder icon — in the toolbar. +Navigate to a folder, then select the {guilabel}`Contents` button Contents in the toolbar. The view lists the items in the folder together with a toolbar of actions. ## Select the items to act on @@ -34,10 +34,14 @@ Each action applies to the current selection. The {guilabel}`Short name` is the last segment of the item's URL. -```{warning} +````{warning} Changing the {guilabel}`Short name` changes the item's URL. -Links and bookmarks that point to the old URL stop working. +Existing links to the old URL no longer resolve unless a redirect is in place. + +```{todo} +Replace the above sentence with a reference to the to-be-documented and to-be-created redirect feature in Aurora. ``` +```` ## Change the workflow state diff --git a/packages/contents/components/PropertiesModal/PropertiesModal.tsx b/packages/contents/components/PropertiesModal/PropertiesModal.tsx index 707cf1b72..e45cd2045 100644 --- a/packages/contents/components/PropertiesModal/PropertiesModal.tsx +++ b/packages/contents/components/PropertiesModal/PropertiesModal.tsx @@ -4,6 +4,7 @@ import { Heading } from 'react-aria-components'; import { useTranslation } from 'react-i18next'; import { Button, + Checkbox, DateTimePicker, Dialog, Input, @@ -170,6 +171,11 @@ export default function PropertiesModal() { value={values.rights ?? ''} placeholder={mixedPlaceholder('rights')} onChange={(e) => setField('rights', e.target.value)} + className={` + rounded-lg border border-quanta-silver bg-quanta-air px-3 py-2 + hover:bg-quanta-air + focus:border-quanta-sapphire + `} /> @@ -181,21 +187,28 @@ export default function PropertiesModal() { t('contents.modal_properties.creators_hint') } onChange={(e) => setField('creators', e.target.value)} + className={` + rounded-lg border border-quanta-silver bg-quanta-air px-3 py-2 + hover:bg-quanta-air + focus:border-quanta-sapphire + `} /> - + setField('exclude_from_nav', checked)} + > + + {t('contents.modal_properties.exclude_from_nav')} + {initial.exclude_from_nav.mixed && ( + + {' '} + ({t('contents.modal_properties.mixed')}) + + )} + +
-
+ ); + })} +
+ - - + +
diff --git a/packages/contents/components/WorkflowModal/WorkflowModal.tsx b/packages/contents/components/WorkflowModal/WorkflowModal.tsx index 65accd0d0..42aef281c 100644 --- a/packages/contents/components/WorkflowModal/WorkflowModal.tsx +++ b/packages/contents/components/WorkflowModal/WorkflowModal.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import { useFetcher, useRevalidator } from 'react-router'; -import { Heading } from 'react-aria-components'; +import { Button as RACButton, Heading } from 'react-aria-components'; import { useTranslation } from 'react-i18next'; import { Button, @@ -10,7 +10,7 @@ import { Modal, Select, } from '@plone/components/quanta'; -import { CloseIcon, StateIcon } from '@plone/components/Icons'; +import { ArrowrightIcon, CloseIcon, StateIcon } from '@plone/components/Icons'; import { type ToastItem } from '@plone/layout/config/toast'; import { useContentsContext } from '../../providers/contents'; import type { TransitionOption } from '../../helpers/workflow'; @@ -108,13 +108,26 @@ export default function WorkflowModal() { return ( - - {t('contents.modal_workflow.title')} - -
+
+ + {t('contents.modal_workflow.title')} + + + + +
+
{stateSummary && (

{t('contents.modal_workflow.current_state')}: {stateSummary} @@ -123,8 +136,6 @@ export default function WorkflowModal() {

setComment(e.target.value)} aria-label={t('contents.modal_workflow.comment')} - className={` - rounded-lg border border-quanta-silver bg-quanta-air px-3 py-2 - hover:bg-quanta-air - focus:border-quanta-sapphire - `} + className="h-11 w-full rounded-lg px-3" /> @@ -169,15 +176,7 @@ export default function WorkflowModal() { -
- +
diff --git a/packages/contents/locales/en/common.json b/packages/contents/locales/en/common.json index a8f6f95f6..706b59f38 100644 --- a/packages/contents/locales/en/common.json +++ b/packages/contents/locales/en/common.json @@ -103,12 +103,10 @@ "confirm": "Delete" }, "modal_rename": { - "title": "Rename items", + "title": "Rename selection", "confirm": "Rename", - "columns": { - "name": "Short name", - "title": "Title" - } + "name": "Name", + "url": "URL" }, "modal_tags": { "title": "Edit tags", @@ -121,7 +119,7 @@ "remove": "Remove tag {{tag}}" }, "modal_properties": { - "title": "Edit properties", + "title": "Selection properties", "confirm": "Save properties", "mixed": "Mixed values", "effective": "Publishing date", @@ -136,7 +134,7 @@ "confirm": "Change state", "current_state": "Current state", "transition": "Transition", - "select_transition": "Select a transition…", + "select_transition": "Select new state…", "no_common_transitions": "The selected items share no common transition.", "comment": "Comment", "include_children": "Apply to contained items" diff --git a/packages/contents/locales/it/common.json b/packages/contents/locales/it/common.json index dc8fa5c25..1902c70a2 100644 --- a/packages/contents/locales/it/common.json +++ b/packages/contents/locales/it/common.json @@ -103,12 +103,10 @@ "confirm": "Procedi con l'eliminazione" }, "modal_rename": { - "title": "Rinomina elementi", + "title": "Rinomina selezione", "confirm": "Rinomina", - "columns": { - "name": "Nome breve", - "title": "Titolo" - } + "name": "Nome", + "url": "URL" }, "modal_tags": { "title": "Modifica etichette", @@ -121,7 +119,7 @@ "remove": "Rimuovi etichetta {{tag}}" }, "modal_properties": { - "title": "Modifica proprietà", + "title": "Proprietà della selezione", "confirm": "Salva proprietà", "mixed": "Valori misti", "effective": "Data di pubblicazione", @@ -136,7 +134,7 @@ "confirm": "Cambia stato", "current_state": "Stato attuale", "transition": "Transizione", - "select_transition": "Seleziona una transizione…", + "select_transition": "Seleziona nuovo stato…", "no_common_transitions": "Gli elementi selezionati non hanno transizioni in comune.", "comment": "Commento", "include_children": "Applica agli elementi contenuti" diff --git a/packages/contents/routes/layout.test.tsx b/packages/contents/routes/layout.test.tsx index 1cd3f9bcc..c9cd3b9a5 100644 --- a/packages/contents/routes/layout.test.tsx +++ b/packages/contents/routes/layout.test.tsx @@ -34,6 +34,7 @@ describe('Contents layout loader', () => { expect(result).toEqual({ locale: 'en', + path: '/news', content: { '@id': '/plone/news', title: 'News', diff --git a/styles/config/vocabularies/Base/accept.txt b/styles/config/vocabularies/Base/accept.txt deleted file mode 100644 index e69de29bb..000000000 diff --git a/styles/config/vocabularies/Base/reject.txt b/styles/config/vocabularies/Base/reject.txt deleted file mode 100644 index e69de29bb..000000000 diff --git a/styles/config/vocabularies/Plone/accept.txt b/styles/config/vocabularies/Plone/accept.txt deleted file mode 100644 index 93505eab4..000000000 --- a/styles/config/vocabularies/Plone/accept.txt +++ /dev/null @@ -1,25 +0,0 @@ --{0,1}plone-{0,1} --{0,1}volto-{0,1} -`plone.restapi` -`plone.volto` -[Aa]sync -[Bb]ackend -CMSUI -CommonJS -JavaScript -npm -nvm -Pastanaga -Plate -Plone -pluggab(le|ility) -programatically -Public UI -Razzle -RichText -Sass -transpile[dr]{0,1} -Vite -Volto -Vue -Zope diff --git a/styles/config/vocabularies/Plone/reject.txt b/styles/config/vocabularies/Plone/reject.txt deleted file mode 100644 index 761bf5306..000000000 --- a/styles/config/vocabularies/Plone/reject.txt +++ /dev/null @@ -1,5 +0,0 @@ -[^.]js -NodeJS -[Pp]re-requisite -plate\.js -platejs