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/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..f629eaac7 --- /dev/null +++ b/docs/how-to-guides/manage-folder-contents-in-bulk.md @@ -0,0 +1,83 @@ +--- +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, 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 + +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. + +## Rename items + +1. Select the items to rename. +2. Select {guilabel}`Rename` in the toolbar. +3. Edit the {guilabel}`Name`, the {guilabel}`URL`, or both for each item in the dialog. +4. Select the {guilabel}`→` button to apply the changes. + +The {guilabel}`Name` is the title of the item. +The {guilabel}`URL` is the last segment of the item's URL. + +````{note} +Changing the {guilabel}`URL` changes the item's URL. +Aurora automatically redirects the old URL to the new one, so existing links and bookmarks keep working. + +```{todo} +Document the URL management control panel once it is available. +See [URL management: redirect control panel + documentation](https://github.com/plone/aurora/issues/121). +``` +```` + +## 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 the {guilabel}`→` button 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 can't 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 {guilabel}`Publishing date`, {guilabel}`Expiration date`, {guilabel}`Rights`, {guilabel}`Creators`, or {guilabel}`Exclude from navigation`. +4. Select the {guilabel}`→` button 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 && ( div]:w-full + [&>div>button[disabled]]:hidden + [&>div>div:first-child]:w-full + [&>div>div:first-child_button]:w-9 + [&>div>div:first-child_button]:bg-transparent! + [&>div>div:first-child_button]:shadow-none! + [&>div>div:first-child_svg]:h-5! + [&>div>div:first-child_svg]:w-5! + [&>div>div:first-child_svg]:shrink-0 + [&>div>div:first-child_svg]:text-quanta-sapphire +`; + +export default function PropertiesModal() { + const { t } = useTranslation(); + const fetcher = useFetcher(); + const dataFetcher = useFetcher<{ + items: Array; + }>(); + 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)} + className={dateFieldClasses} + /> + setField('expires', v)} + className={dateFieldClasses} + /> + +