diff --git a/ui/app/src/components/FormsEngine/components/SortableList.tsx b/ui/app/src/components/FormsEngine/components/SortableList.tsx index 3920ffe1f7..2480263c1a 100644 --- a/ui/app/src/components/FormsEngine/components/SortableList.tsx +++ b/ui/app/src/components/FormsEngine/components/SortableList.tsx @@ -46,6 +46,7 @@ export interface SortableListProps { items: TItem[]; onChange(items: TItem[]): void; selectedItemId?: string; + onlySelectedSortable?: boolean; } const strokeSize = 2; @@ -162,37 +163,39 @@ function DragPreview({ item }: { item: TItem }) { ); } -function SortableItem({ item, selected }: { item: TItem; selected?: boolean }) { +function SortableItem({ item, selected, sortable = true }: { item: TItem; selected?: boolean; sortable?: boolean }) { const ref = useRef(null); const [state, setState] = useState(idle); useEffect(() => { const element = ref.current; invariant(element); return combine( - draggable({ - element, - getInitialData() { - return getItemData(item); - }, - onGenerateDragPreview({ nativeSetDragImage }) { - setCustomNativeDragPreview({ - nativeSetDragImage, - getOffset: pointerOutsideOfPreview({ - x: '16px', - y: '8px' - }), - render({ container }) { - setState({ type: 'preview', container }); + sortable + ? draggable({ + element, + getInitialData() { + return getItemData(item); + }, + onGenerateDragPreview({ nativeSetDragImage }) { + setCustomNativeDragPreview({ + nativeSetDragImage, + getOffset: pointerOutsideOfPreview({ + x: '16px', + y: '8px' + }), + render({ container }) { + setState({ type: 'preview', container }); + } + }); + }, + onDragStart() { + setState({ type: 'is-dragging' }); + }, + onDrop() { + setState(idle); } - }); - }, - onDragStart() { - setState({ type: 'is-dragging' }); - }, - onDrop() { - setState(idle); - } - }), + }) + : () => {}, dropTargetForElements({ element, canDrop({ source }) { @@ -238,7 +241,7 @@ function SortableItem({ item, selected }: { item: TItem; selected?: boolean }) { } }) ); - }, [item]); + }, [item, sortable]); return ( <> - - - + {sortable && ( + + + + )} {state.type === 'is-dragging-over' && state.closestEdge ? ( @@ -263,7 +268,7 @@ function SortableItem({ item, selected }: { item: TItem; selected?: boolean }) { ); } -export function SortableList({ items, onChange, selectedItemId }: SortableListProps) { +export function SortableList({ items, onChange, selectedItemId, onlySelectedSortable }: SortableListProps) { const onChangeRef = useUpdateRefs(onChange); useEffect(() => { return monitorForElements({ @@ -328,7 +333,12 @@ export function SortableList({ items, onChange, selectedItemId }: SortableListPr return ( {items.map((item) => ( - + ))} ); diff --git a/ui/app/src/components/FormsEngine/controls/PageNavOrder.tsx b/ui/app/src/components/FormsEngine/controls/PageNavOrder.tsx new file mode 100644 index 0000000000..309c19bae0 --- /dev/null +++ b/ui/app/src/components/FormsEngine/controls/PageNavOrder.tsx @@ -0,0 +1,237 @@ +/* + * Copyright (C) 2007-2025 Crafter Software Corporation. All Rights Reserved. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as published by + * the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import type { ControlProps } from '../types'; +import FormsEngineField from '../components/FormsEngineField'; +import React, { type ChangeEvent, useEffect, useId, useState } from 'react'; +import { FormattedMessage, useIntl } from 'react-intl'; +import ChangeCircleOutlinedIcon from '@mui/icons-material/ChangeCircleOutlined'; +import Button from '@mui/material/Button'; +import Box from '@mui/material/Box'; +import { EnhancedDialog } from '../../EnhancedDialog'; +import useEnhancedDialogState from '../../../hooks/useEnhancedDialogState'; +import useActiveSiteId from '../../../hooks/useActiveSiteId'; +import { getNavItemsOrder, type PageNavItem, reorderNavItems } from '../../../services/content'; +import { DialogBody } from '../../DialogBody'; +import Typography from '@mui/material/Typography'; +import useSpreadState from '../../../hooks/useSpreadState'; +import { ApiResponse } from '../../../models'; +import { SortableList, type TItem } from '../components/SortableList'; +import { useItemContext } from '../lib/formsEngineContext'; +import { DialogFooter } from '../../DialogFooter'; +import SecondaryButton from '../../SecondaryButton'; +import PrimaryButton from '../../PrimaryButton'; +import { pushErrorDialog } from '../../../utils/system'; +import { useDispatch } from 'react-redux'; +import Paper from '@mui/material/Paper'; +import useUpdateRefs from '../../../hooks/useUpdateRefs'; +import { showSystemNotification } from '../../../state/actions/system'; +import RadioGroup from '@mui/material/RadioGroup'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Radio from '@mui/material/Radio'; +import Alert from '@mui/material/Alert'; +import { isFieldReadOnly } from '../lib/formUtils'; +import { getParentPath } from '../../../utils/path'; +import { nou } from '../../../utils/object'; + +export interface PageNavOrderProps extends ControlProps { + value: boolean; +} + +export function PageNavOrder(props: PageNavOrderProps) { + const { value, setValue, field, autoFocus, readonly: formReadonly } = props; + const [initialValue] = useState(value); + const htmlId = useId(); + const orderDialogState = useEnhancedDialogState(); + const { formatMessage } = useIntl(); + const [pagesOrderState, setPagesOrderState] = useSpreadState<{ + fetching: boolean; + error: ApiResponse | null; + order: TItem[] | null; + changedOrder: boolean; + }>({ + fetching: false, + error: null, + order: null, + changedOrder: false + }); + const contextItem = useItemContext(); + const currentPath = contextItem?.path; + const siteId = useActiveSiteId(); + const dispatch = useDispatch(); + const effectRefs = useUpdateRefs({ + initialValue, + contextItem + }); + const readonly: boolean = isFieldReadOnly(field, formReadonly); + + useEffect(() => { + if (currentPath) { + setPagesOrderState({ fetching: true, error: null }); + const parentPath = getParentPath(currentPath); + const subscription = getNavItemsOrder(siteId, parentPath).subscribe({ + next: (order) => { + const newOrder = createSortableItemList(order); + // If the initialValue is false, then it means that we'll be adding this page to the navigation (since it won't + // be in the order response). + if (!effectRefs.current.initialValue) { + newOrder.push({ + key: currentPath, + value: effectRefs.current.contextItem?.label || currentPath + }); + } + setPagesOrderState({ fetching: false, order: newOrder }); + }, + error: ({ response }) => setPagesOrderState({ fetching: false, error: response.response }) + }); + return () => subscription.unsubscribe(); + } + }, [siteId, setPagesOrderState, currentPath, effectRefs]); + + const handleChange = (_event: ChangeEvent, selected: string) => { + if (readonly) return; + setValue(selected === 'true'); + }; + const handleUpdateOrder = () => { + orderDialogState.onClose(); + if (!pagesOrderState.changedOrder) return; + + // If no current path, or no nav items order, then nothing to reorder. + if (!currentPath || !pagesOrderState.order || !pagesOrderState.order.length) return; + const currentItemIndex = pagesOrderState.order.findIndex((item) => item.key === currentPath); + // If the current item is not found in the order, then nothing to reorder. + if (currentItemIndex === -1) return; + const previewItemIndex = currentItemIndex - 1; + const nextItemIndex = currentItemIndex + 1; + const previewItemPath = previewItemIndex >= 0 ? pagesOrderState.order[previewItemIndex]?.key : undefined; + const nextItemPath = + nextItemIndex < pagesOrderState.order.length ? pagesOrderState.order[nextItemIndex]?.key : undefined; + const type = nou(previewItemPath) ? 'addBefore' : nou(nextItemPath) ? 'addAfter' : 'addBefore'; + const referencePath = nou(previewItemPath) ? nextItemPath : nou(nextItemPath) ? previewItemPath : nextItemPath; + reorderNavItems(siteId, type, referencePath).subscribe({ + next: ({ order }) => { + setPagesOrderState({ changedOrder: false }); + // TODO: After implementing additional fields support, this needs to set the order value for the current item. + dispatch(showSystemNotification({ message: formatMessage({ defaultMessage: 'Navigation items reordered.' }) })); + }, + error: ({ response }) => { + dispatch(pushErrorDialog({ props: { error: response.response } })); + } + }); + }; + + return ( + + + + } + label={} + disabled={readonly} + /> + } + label={} + disabled={readonly} + /> + + + {value && ( + + )} + + } + > + + + + + {/* TODO: Remove after switching to API v2. */} + + Development draft. Waiting for 'content/reorder-items' new v2 API to be implemented. + + + []) => + setPagesOrderState({ + order: fields, + changedOrder: true + }) + } + /> + + + + orderDialogState.onClose()}> + + + + {pagesOrderState.changedOrder ? ( + + ) : ( + + )} + + + + + ); +} + +/** + * Converts an array of `PageNavItem` objects into an array of sortable items (`TItem`). + * This function is used to transform navigation items into a format compatible with the `SortableList` component. + * + * @param order {PageNavItem[]} - The array of navigation items to be converted. + * @returns {TItem[]} - The transformed array of sortable items. + */ +function createSortableItemList(order: PageNavItem[]): TItem[] { + return order.map((item) => ({ + key: item.path, + value: item.label, + data: item + })); +} + +export default PageNavOrder; diff --git a/ui/app/src/components/FormsEngine/lib/controlMap.ts b/ui/app/src/components/FormsEngine/lib/controlMap.ts index e003a4c4cf..368a84358d 100644 --- a/ui/app/src/components/FormsEngine/lib/controlMap.ts +++ b/ui/app/src/components/FormsEngine/lib/controlMap.ts @@ -73,7 +73,7 @@ export const controlMap: Record = { 'locale-selector': lazy(() => import('../controls/LocaleSelector')), 'node-selector': lazy(() => import('../controls/NodeSelector')), 'numeric-input': lazy(() => import('../controls/Numeric')), - 'page-nav-order': null, + 'page-nav-order': lazy(() => import('../controls/PageNavOrder')), repeat: lazy(() => import('../controls/Repeat')), rte: lazy(() => import('../controls/RichTextEditor')), textarea: lazy(() => import('../controls/Textarea')), diff --git a/ui/app/src/services/content.ts b/ui/app/src/services/content.ts index a230b8f175..e8f9630a18 100644 --- a/ui/app/src/services/content.ts +++ b/ui/app/src/services/content.ts @@ -1380,3 +1380,21 @@ export function fetchContentByCommitId(site: string, path: string, commitId: str }) ); } + +export interface PageNavItem { + path: string; + order: number; + label: string; +} + +export function getNavItemsOrder(siteId: string, parentPath: string): Observable { + const qs = toQueryString({ parentPath }); + return get(`/studio/api/2/content/${siteId}/order${qs}`).pipe(map((response) => response?.response?.items)); +} + +export function reorderNavItems(siteId: string, type: 'addBefore' | 'addAfter', referencePath: string) { + return postJSON(`/studio/api/2/content/${siteId}/order/reorder`, { + type, + referencePath + }).pipe(map((response) => response.response)); +}