Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
2d42389
[7418] PageNavOrder control
jvega190 Oct 8, 2025
dbb0649
[7418] Update PageNavOrder to use RadioButtons
jvega190 Oct 8, 2025
5c5bb55
[7418] Document createSortableList
jvega190 Oct 8, 2025
62b9fdb
[7418] Remove onlySelectedSortable from SortableList usage
jvega190 Oct 8, 2025
3886f65
[7418] Remove log from reorderNavItems
jvega190 Oct 8, 2025
6c83938
[7418] Update PageNavOrder radio buttons placement
jvega190 Oct 15, 2025
1ad3061
[7418] Update value type in PageNavOrder, fix handleChange prop types
jvega190 Oct 15, 2025
ed388c4
[7418] Guard against empty order and avoid sending null before/after.
jvega190 Oct 15, 2025
081f5f3
[7418] Avoid passing null to SortableList.
jvega190 Oct 15, 2025
0fde973
[7418] Update APIv2 alert message
jvega190 Oct 15, 2025
9a15552
[7418] add unsubscribe from getNavItemsOrder, guard against undefined…
jvega190 Oct 15, 2025
3f0a3bf
[7418] Adjust error type
jvega190 Oct 15, 2025
84c1935
[7418] Avoid overriding system props values
jvega190 Oct 15, 2025
d2834c5
Merge branch 'develop' of https://github.com/craftercms/studio-ui int…
jvega190 Oct 23, 2025
3d00082
Merge branch 'develop' of https://github.com/craftercms/studio-ui int…
jvega190 Mar 2, 2026
2abc622
[7418] SortableList update to support only sort selected item
jvega190 Mar 2, 2026
e773096
[7418] Validate changedOrder state when updating order
jvega190 Mar 3, 2026
f1f2799
[7418] Block value mutation when control is readonly
jvega190 Mar 3, 2026
2f9f002
[7418] Check for nullish contextItem
jvega190 Mar 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 40 additions & 30 deletions ui/app/src/components/FormsEngine/components/SortableList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export interface SortableListProps<T = unknown> {
items: TItem<T>[];
onChange(items: TItem<T>[]): void;
selectedItemId?: string;
onlySelectedSortable?: boolean;
}

const strokeSize = 2;
Expand Down Expand Up @@ -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<HTMLDivElement | null>(null);
const [state, setState] = useState<ItemState>(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 }) {
Expand Down Expand Up @@ -238,7 +241,7 @@ function SortableItem({ item, selected }: { item: TItem; selected?: boolean }) {
}
})
);
}, [item]);
}, [item, sortable]);
return (
<>
<ListItemButton
Expand All @@ -250,9 +253,11 @@ function SortableItem({ item, selected }: { item: TItem; selected?: boolean }) {
]}
selected={selected}
>
<ListItemIcon>
<DragIndicator fontSize="small" />
</ListItemIcon>
{sortable && (
<ListItemIcon>
<DragIndicator fontSize="small" />
</ListItemIcon>
)}
<ListItemText primary={item.value} />
{state.type === 'is-dragging-over' && state.closestEdge ? (
<DropIndicator edge={state.closestEdge} gap={gapBetweenItems} />
Expand All @@ -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({
Expand Down Expand Up @@ -328,7 +333,12 @@ export function SortableList({ items, onChange, selectedItemId }: SortableListPr
return (
<List sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, px: 1 }}>
{items.map((item) => (
<SortableItem key={item.key} item={item} selected={item.key === selectedItemId} />
<SortableItem
key={item.key}
item={item}
selected={item.key === selectedItemId}
sortable={onlySelectedSortable ? item.key === selectedItemId : true}
/>
))}
</List>
);
Expand Down
234 changes: 234 additions & 0 deletions ui/app/src/components/FormsEngine/controls/PageNavOrder.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/

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';

export interface PageNavOrderProps extends ControlProps {
value: boolean;
}

export function PageNavOrder(props: PageNavOrderProps) {
const { value, setValue, field, autoFocus, readonly: formReadonly } = props;
const [initialValue] = useState<boolean>(value);
const htmlId = useId();
const orderDialogState = useEnhancedDialogState();
const { formatMessage } = useIntl();
const [pagesOrderState, setPagesOrderState] = useSpreadState<{
fetching: boolean;
error: ApiResponse | null;
order: TItem<PageNavItem>[] | null;
changedOrder: boolean;
}>({
fetching: false,
error: null,
order: null,
changedOrder: false
});
Comment thread
jvega190 marked this conversation as resolved.
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 subscription = getNavItemsOrder(siteId, currentPath).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) {
Comment thread
jvega190 marked this conversation as resolved.
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<HTMLInputElement>, selected: string) => {
if (readonly) return;
setValue(selected === 'true');
};
Comment thread
jvega190 marked this conversation as resolved.
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;
// TODO: Waiting for the new v2 API to handle reordering the full items list. Currently, this only reorders
// the current item, and no other items can be re-arranged.
reorderNavItems(siteId, currentPath, previewItemPath, nextItemPath).subscribe({
next: () => {
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 } }));
}
});
};
Comment thread
jvega190 marked this conversation as resolved.

return (
<FormsEngineField htmlFor={htmlId} field={field}>
<Box display="flex" flexDirection="row" gap={2}>
<RadioGroup
row
value={String(value)}
onChange={handleChange}
sx={{ display: 'inline-flex' }}
autoFocus={autoFocus}
>
<FormControlLabel
value="false"
control={<Radio />}
label={<FormattedMessage defaultMessage="No" />}
disabled={readonly}
/>
<FormControlLabel
value="true"
control={<Radio />}
label={<FormattedMessage defaultMessage="Yes" />}
disabled={readonly}
/>
</RadioGroup>

{value && (
<Button
variant="text"
startIcon={<ChangeCircleOutlinedIcon />}
onClick={() => orderDialogState.onOpen()}
disabled={readonly}
sx={{ flex: 'none' }}
>
<FormattedMessage defaultMessage="Edit Order" />
</Button>
)}
Comment thread
jvega190 marked this conversation as resolved.
</Box>
<EnhancedDialog
open={orderDialogState.open}
onClose={orderDialogState.onClose}
maxWidth="sm"
title={<FormattedMessage defaultMessage="Edit Navigation Order" />}
>
<DialogBody>
<Typography variant="body2">
<FormattedMessage
defaultMessage={'Drag and Drop "{page}" to the desired location in the navigation structure.'}
values={{
page: contextItem?.label ?? ''
}}
/>
Comment thread
jvega190 marked this conversation as resolved.
</Typography>
{/* TODO: Remove after switching to API v2. */}
<Alert severity="warning" sx={{ mt: 2 }}>
Development draft. Waiting for 'content/reorder-items' new v2 API to be implemented.
</Alert>
<Paper elevation={0} sx={{ mt: 2 }}>
<SortableList
items={pagesOrderState.order ?? []}
selectedItemId={currentPath}
onlySelectedSortable={true}
onChange={(fields: TItem<PageNavItem>[]) =>
setPagesOrderState({
order: fields,
changedOrder: true
})
}
/>
</Paper>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</DialogBody>
<DialogFooter>
<SecondaryButton onClick={() => orderDialogState.onClose()}>
<FormattedMessage defaultMessage="Cancel" />
</SecondaryButton>
<PrimaryButton autoFocus onClick={handleUpdateOrder}>
{pagesOrderState.changedOrder ? (
<FormattedMessage defaultMessage="Save" />
) : (
<FormattedMessage defaultMessage="Close" />
)}
</PrimaryButton>
</DialogFooter>
</EnhancedDialog>
</FormsEngineField>
);
}

/**
* Converts an array of `PageNavItem` objects into an array of sortable items (`TItem<PageNavItem>`).
* 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<PageNavItem>[]} - The transformed array of sortable items.
*/
function createSortableItemList(order: PageNavItem[]): TItem<PageNavItem>[] {
return order.map((item) => ({
key: item.id,
value: item.name,
data: item
}));
}

export default PageNavOrder;
2 changes: 1 addition & 1 deletion ui/app/src/components/FormsEngine/lib/controlMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export const controlMap: Record<BuiltInControlType, ElementType> = {
'locale-selector': null,
'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')),
Expand Down
24 changes: 24 additions & 0 deletions ui/app/src/services/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1390,3 +1390,27 @@ export function fetchContentByCommitId(site: string, path: string, commitId: str
})
);
}

export interface PageNavItem {
order: number;
name: string;
id: string;
disabled: string;
placeInNav: string;
}

export function getNavItemsOrder(site: string, path: string, order: string = 'default'): Observable<PageNavItem[]> {
const qs = toQueryString({ site, path, order });
return get(`/studio/api/1/services/api/1/content/get-item-orders.json${qs}`).pipe(
map((response) => response?.response?.order),
catchError(errorSelectorApi1)
);
}

export function reorderNavItems(site: string, path: string, before: string, after: string) {
const qs = toQueryString({ site, path, before, after });
return get(`/studio/api/1/services/api/1/content/reorder-items.json${qs}`).pipe(
map((response) => response?.response?.orderValue),
catchError(errorSelectorApi1)
);
}