import { jsx, jsxs } from 'react/jsx-runtime'; import * as React from 'react'; import { DndContext, DragOverlay, useDroppable } from '@dnd-kit/core'; import { SortableContext, rectSwappingStrategy, arraySwap, useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { useForm, useIsDesktop, useField } from '@strapi/admin/strapi-admin'; import { Flex, Typography, Box, Grid, Menu, Modal, IconButton, Link } from '@strapi/design-system'; import { Plus, Drag, Pencil, Cross, Cog } from '@strapi/icons'; import { generateNKeysBetween } from 'fractional-indexing'; import { produce } from 'immer'; import { useIntl } from 'react-intl'; import { NavLink } from 'react-router-dom'; import { styled } from 'styled-components'; import { getTranslation } from '../../utils/translations.mjs'; import { ComponentIcon } from '../ComponentIcon.mjs'; import { EditFieldForm } from './EditFieldForm.mjs'; const GRID_COLUMNS = 12; /* ------------------------------------------------------------------------------------------------- * Drag and Drop * -----------------------------------------------------------------------------------------------*/ const DroppableContainer = ({ id, children })=>{ const droppable = useDroppable({ id }); return children(droppable); }; const SortableItem = ({ id, children })=>{ const { attributes, setNodeRef, transform, transition, isDragging } = useSortable({ id }); const style = { transform: CSS.Transform.toString({ x: transform?.x ?? 0, y: transform?.y ?? 0, // Avoid any scaling animations which can visually "squish" or // "stretch" neighbouring cards in mixed-width grids (4/8/12 cols). scaleX: 1, scaleY: 1 }), transition, height: '100%', opacity: isDragging ? 0.6 : 1, outlineOffset: 2 }; return /*#__PURE__*/ jsx("div", { ref: setNodeRef, style: style, ...attributes, children: children }); }; /** * Compute uids and formName for drag and drop items for the incoming layout */ const createDragAndDropContainersFromLayout = (layout)=>{ return layout.map((row, containerIndex)=>({ ...row, // Use unique ids for drag and drop items dndId: `container-${containerIndex}`, children: row.children.map((child, childIndex)=>({ ...child, dndId: `container-${containerIndex}-child-${childIndex}`, // The formName must be recomputed each time an item is moved formName: `layout.${containerIndex}.children.${childIndex}` })) })); }; const Fields = ({ attributes, fieldSizes, components, metadatas = {} })=>{ const { formatMessage } = useIntl(); const layout = useForm('Fields', (state)=>state.values.layout ?? []); const onChange = useForm('Fields', (state)=>state.onChange); const addFieldRow = useForm('Fields', (state)=>state.addFieldRow); const removeFieldRow = useForm('Fields', (state)=>state.removeFieldRow); const existingFields = layout.map((row)=>row.children.map((field)=>field.name)).flat(); /** * Get the fields that are not already in the layout * But also check that they are visible before we give users * the option to display them. e.g. `id` is not visible. */ const remainingFields = Object.entries(metadatas).reduce((acc, current)=>{ const [name, { visible, ...field }] = current; if (!existingFields.includes(name) && visible === true) { const type = attributes[name]?.type; const size = type ? fieldSizes[type] : GRID_COLUMNS; acc.push({ ...field, label: field.label ?? name, name, size }); } return acc; }, []); const handleRemoveField = (rowIndex, fieldIndex)=>()=>{ if (layout[rowIndex].children.length === 1) { removeFieldRow(`layout`, rowIndex); } else { onChange(`layout.${rowIndex}.children`, [ ...layout[rowIndex].children.slice(0, fieldIndex), ...layout[rowIndex].children.slice(fieldIndex + 1) ]); } }; const handleAddField = (field)=>()=>{ addFieldRow('layout', { children: [ field ] }); }; const [containers, setContainers] = React.useState(()=>createDragAndDropContainersFromLayout(layout)); const [activeDragItem, setActiveDragItem] = React.useState(null); /** * Finds either the parent container id or the child id within a container */ function findContainer(id, containersAsDictionary) { // If the id is a key, then it is the parent container if (id in containersAsDictionary) { return id; } // Otherwise, it is a child inside a container return Object.keys(containersAsDictionary).find((key)=>containersAsDictionary[key].children.find((child)=>child.dndId === id)); } /** * Gets an item from a container based on its id */ const getItemFromContainer = (id, container)=>{ return container.children.find((item)=>id === item.dndId); }; /** * Gets the containers as dictionary for quick lookup */ const getContainersAsDictionary = ()=>{ return Object.fromEntries(containers.map((container)=>[ container.dndId, container ])); }; /** * Recomputes the empty space in the grid */ const createContainersWithSpacers = (layout)=>{ return layout.map((row)=>({ ...row, children: row.children.filter((field)=>field.name !== TEMP_FIELD_NAME) })).filter((row)=>row.children.length > 0).map((row)=>{ const totalSpaceTaken = row.children.reduce((acc, curr)=>acc + curr.size, 0); if (totalSpaceTaken < GRID_COLUMNS) { const [spacerKey] = generateNKeysBetween(row.children.at(-1)?.__temp_key__, undefined, 1); return { ...row, children: [ ...row.children, { name: TEMP_FIELD_NAME, size: GRID_COLUMNS - totalSpaceTaken, __temp_key__: spacerKey } ] }; } return row; }); }; /** * When layout changes (e.g. when a field size is changed or the containers are reordered) * we need to update the ids and form names */ React.useEffect(()=>{ const containers = createDragAndDropContainersFromLayout(layout); setContainers(containers); }, [ layout, setContainers ]); return /*#__PURE__*/ jsx(DndContext, { onDragStart: (event)=>{ const containersAsDictionary = getContainersAsDictionary(); const activeContainer = findContainer(event.active.id, containersAsDictionary); if (!activeContainer) return; const activeItem = getItemFromContainer(event.active.id, containersAsDictionary[activeContainer]); if (activeItem) { setActiveDragItem(activeItem); } }, onDragOver: ({ active, over })=>{ const containersAsDictionary = getContainersAsDictionary(); const activeContainer = findContainer(active.id, containersAsDictionary); const overContainer = findContainer(over?.id ?? '', containersAsDictionary); const activeContainerIndex = containers.findIndex((container)=>container.dndId === activeContainer); const overContainerIndex = containers.findIndex((container)=>container.dndId === overContainer); if (!activeContainer || !overContainer) { return; } const draggedItem = getItemFromContainer(active.id, containersAsDictionary[activeContainer]); const overItem = getItemFromContainer(over?.id ?? '', containersAsDictionary[overContainer]); const overIndex = containersAsDictionary[overContainer].children.findIndex((item)=>item.dndId === over?.id); const activeIndex = containersAsDictionary[activeContainer].children.findIndex((item)=>item.dndId === active.id); if (!draggedItem) return; // Handle a full width field being dragged if (draggedItem?.size === GRID_COLUMNS) { // Swap the items in the containers const update = produce(containers, (draft)=>{ draft[activeContainerIndex].children = containers[overContainerIndex].children; draft[overContainerIndex].children = containers[activeContainerIndex].children; }); setContainers(update); return; } /** * Handle an item being dragged from one container to another, * the item is removed from its current container, and then added to its new container * An item can only be added in a container if there is enough space */ const update = produce(containers, (draft)=>{ draft[activeContainerIndex].children = draft[activeContainerIndex].children.filter((item)=>item.dndId !== active.id); const targetChildren = draft[overContainerIndex].children; const spaceTaken = targetChildren.reduce((acc, curr)=>{ if (curr.name === TEMP_FIELD_NAME) { return acc; } return acc + curr.size; }, 0); const isNotEnoughSpace = spaceTaken + draggedItem.size > GRID_COLUMNS; const canSwapSameSizeItem = overItem && overItem.name !== TEMP_FIELD_NAME && overItem.size === draggedItem.size && activeIndex !== -1 && overIndex !== -1; const canCreateNewRowForItem = activeContainerIndex !== overContainerIndex && GRID_COLUMNS - spaceTaken === 0; const isHoveringOverSpacer = overItem?.name === TEMP_FIELD_NAME; /** * Not enough space in the hovered row * * We still want: * - ability to swap items of the same size * - ability to create a single extra row to host the dragged item * when surrounding rows are full */ if (isNotEnoughSpace) { // Try a simple swap when target item is of the same size if (canSwapSameSizeItem) { const sourceChildren = draft[activeContainerIndex].children; // Put the hovered item back where the dragged item came from sourceChildren.splice(activeIndex, 0, overItem); // Swap the hovered item with the dragged one in the target row const draftOverIndex = targetChildren.findIndex((item)=>item.dndId === overItem.dndId); if (draftOverIndex !== -1) { targetChildren.splice(draftOverIndex, 1, draggedItem); } return; } // If there is absolutely no space left in the target row and the // dragged item comes from a different row, add it to a new row if (canCreateNewRowForItem) { // Default: insert a new row after the row being hovered (between that row and the // next). For row 0, use index 0 instead so fields that are not full-width can still // be moved above the first row—there is no separate drop area above it in the UI. const insertIndex = overContainerIndex === 0 ? 0 : overContainerIndex + 1; /** * When inserting *after* the hovered row, reuse the following row if it only * contains spacers. Skip when inserting at index 0 — draft[0] is the hovered row. */ if (insertIndex > overContainerIndex) { const existingRow = draft[insertIndex]; if (existingRow) { const nonTempChildren = existingRow.children.filter((child)=>child.name !== TEMP_FIELD_NAME); const isNextRowEmpty = nonTempChildren.length === 0; // If the row directly after is empty (only spacers), reuse it // instead of creating yet another row. if (isNextRowEmpty) { existingRow.children = [ draggedItem ]; return; } } } const newContainerPrototype = draft[overContainerIndex]; const newContainerId = `container-${draft.length}`; draft.splice(insertIndex, 0, { ...newContainerPrototype, dndId: newContainerId, children: [ draggedItem ] }); return; } } // There is enough room in the target row if (isHoveringOverSpacer) { // We are over an invisible spacer, replace it with the dragged item targetChildren.splice(overIndex, 1, draggedItem); return; } // There is room for the item in the container, drop it targetChildren.splice(overIndex, 0, draggedItem); }); setContainers(update); }, onDragEnd: (event)=>{ const { active, over } = event; const { id } = active; const overId = over?.id; const containersAsDictionary = getContainersAsDictionary(); const activeContainer = findContainer(id, containersAsDictionary); const overContainer = findContainer(overId, containersAsDictionary); if (!activeContainer || !overContainer) { return; } const activeIndex = containersAsDictionary[activeContainer].children.findIndex((children)=>children.dndId === id); const overIndex = containersAsDictionary[overContainer].children.findIndex((children)=>children.dndId === overId); const movedContainerItems = produce(containersAsDictionary, (draft)=>{ if (activeIndex >= 0 && overIndex >= 0 && activeIndex !== overIndex && activeContainer === overContainer) { // Move items around inside their own container draft[activeContainer].children = arraySwap(draft[activeContainer].children, activeIndex, overIndex); } }); // Remove properties the server does not expect before updating the form const updatedContainers = Object.values(movedContainerItems); const updatedContainersWithSpacers = createContainersWithSpacers(updatedContainers); const updatedLayout = updatedContainersWithSpacers.map(({ dndId: _dndId, children, ...container })=>({ ...container, children: children.map(({ dndId: _dndId, formName: _formName, ...child })=>child) })); // Update the layout onChange('layout', updatedLayout); setActiveDragItem(null); }, children: /*#__PURE__*/ jsxs(Flex, { paddingTop: 6, direction: "column", alignItems: "stretch", gap: 4, children: [ /*#__PURE__*/ jsxs(Flex, { alignItems: "flex-start", direction: "column", justifyContent: "space-between", children: [ /*#__PURE__*/ jsx(Typography, { fontWeight: "bold", children: formatMessage({ id: getTranslation('containers.list.displayedFields'), defaultMessage: 'Displayed fields' }) }), /*#__PURE__*/ jsx(Typography, { variant: "pi", textColor: "neutral600", children: formatMessage({ id: 'containers.SettingPage.editSettings.description', defaultMessage: 'Drag & drop the fields to build the layout' }) }) ] }), /*#__PURE__*/ jsx(Box, { padding: 4, hasRadius: true, borderStyle: "dashed", borderWidth: "1px", borderColor: "neutral300", children: /*#__PURE__*/ jsxs(Flex, { direction: "column", alignItems: "stretch", gap: 2, children: [ containers.map((container, containerIndex)=>/*#__PURE__*/ jsx(SortableContext, { id: container.dndId, items: container.children.map((child)=>({ id: child.dndId })), strategy: rectSwappingStrategy, children: /*#__PURE__*/ jsx(DroppableContainer, { id: container.dndId, children: ({ setNodeRef })=>/*#__PURE__*/ jsx(Grid.Root, { ref: setNodeRef, gap: 2, children: container.children.map((child, childIndex)=>/*#__PURE__*/ jsx(Grid.Item, { col: child.size, xs: 12, direction: "column", alignItems: "stretch", children: /*#__PURE__*/ jsx(SortableItem, { id: child.dndId, children: /*#__PURE__*/ jsx(Field, { attribute: attributes[child.name], components: components, name: child.formName, onRemoveField: handleRemoveField(containerIndex, childIndex), dndId: child.dndId }) }) }, child.dndId)) }, container.dndId) }) }, container.dndId)), /*#__PURE__*/ jsx(DragOverlay, { children: activeDragItem ? /*#__PURE__*/ jsx(Field, { attribute: attributes[activeDragItem.name], components: components, name: activeDragItem.formName, dndId: activeDragItem.dndId }) : null }), /*#__PURE__*/ jsxs(Menu.Root, { children: [ /*#__PURE__*/ jsx(Menu.Trigger, { startIcon: /*#__PURE__*/ jsx(Plus, {}), endIcon: null, disabled: remainingFields.length === 0, fullWidth: true, variant: "secondary", children: formatMessage({ id: getTranslation('containers.SettingPage.add.field'), defaultMessage: 'Insert another field' }) }), /*#__PURE__*/ jsx(Menu.Content, { children: remainingFields.map((field)=>/*#__PURE__*/ jsx(Menu.Item, { onSelect: handleAddField(field), children: field.label }, field.name)) }) ] }) ] }) }) ] }) }); }; const TEMP_FIELD_NAME = '_TEMP_'; /** * Displays a field in the layout with drag options, also * opens a modal to edit the details of said field. */ const Field = ({ attribute, components, name, onRemoveField, dndId })=>{ const isDesktop = useIsDesktop(); const [isModalOpen, setIsModalOpen] = React.useState(false); const { formatMessage } = useIntl(); const { value } = useField(name); const { listeners, setActivatorNodeRef } = useSortable({ id: dndId }); const handleRemoveField = (e)=>{ e.preventDefault(); e.stopPropagation(); if (onRemoveField) { onRemoveField?.(e); } }; const onEditFieldMeta = (e)=>{ e.preventDefault(); e.stopPropagation(); setIsModalOpen(true); }; if (!value) { return null; } if (value.name === TEMP_FIELD_NAME) { return /*#__PURE__*/ jsx(Flex, { tag: "span", height: "100%", style: { opacity: 0 } }); } if (!attribute) { return null; } return /*#__PURE__*/ jsxs(Modal.Root, { open: isModalOpen, onOpenChange: setIsModalOpen, children: [ /*#__PURE__*/ jsxs(Flex, { borderColor: "neutral150", background: "neutral100", hasRadius: true, gap: 3, cursor: "pointer", onClick: ()=>{ setIsModalOpen(true); }, position: "relative", children: [ isDesktop && /*#__PURE__*/ jsx(DragButton, { ref: setActivatorNodeRef, tag: "span", withTooltip: false, label: formatMessage({ id: getTranslation('components.DraggableCard.move.field'), defaultMessage: 'Move {item}' }, { item: value.label }), ...listeners, children: /*#__PURE__*/ jsx(Drag, {}) }), /*#__PURE__*/ jsxs(Flex, { direction: "column", alignItems: "flex-start", grow: 1, overflow: "hidden", children: [ /*#__PURE__*/ jsxs(Flex, { gap: 3, justifyContent: "space-between", width: "100%", children: [ /*#__PURE__*/ jsx(Typography, { ellipsis: true, fontWeight: "bold", children: value.label }), /*#__PURE__*/ jsxs(Flex, { position: "relative", children: [ /*#__PURE__*/ jsx(IconButton, { type: "button", variant: "ghost", background: "transparent", onClick: onEditFieldMeta, withTooltip: false, label: formatMessage({ id: getTranslation('components.DraggableCard.edit.field'), defaultMessage: 'Edit {item}' }, { item: value.label }), children: /*#__PURE__*/ jsx(Pencil, {}) }), /*#__PURE__*/ jsx(IconButton, { type: "button", variant: "ghost", onClick: handleRemoveField, background: "transparent", withTooltip: false, label: formatMessage({ id: getTranslation('components.DraggableCard.delete.field'), defaultMessage: 'Delete {item}' }, { item: value.label }), children: /*#__PURE__*/ jsx(Cross, {}) }) ] }) ] }), attribute?.type === 'component' ? /*#__PURE__*/ jsxs(Flex, { paddingTop: 3, paddingRight: 3, paddingBottom: 3, paddingLeft: 0, alignItems: "flex-start", direction: "column", gap: 2, width: "100%", children: [ /*#__PURE__*/ jsx(Grid.Root, { gap: 4, width: "100%", children: components[attribute.component].layout.map((row)=>row.map(({ size, ...field })=>/*#__PURE__*/ jsx(Grid.Item, { col: size, xs: 12, direction: "column", alignItems: "stretch", children: /*#__PURE__*/ jsx(Flex, { alignItems: "center", background: "neutral0", paddingTop: 2, paddingBottom: 2, paddingLeft: 3, paddingRight: 3, hasRadius: true, borderColor: "neutral200", children: /*#__PURE__*/ jsx(Typography, { textColor: "neutral800", children: field.name }) }) }, field.name))) }), /*#__PURE__*/ jsx(Link, { // used to stop the edit form from appearing when we click here. onClick: (e)=>e.stopPropagation(), startIcon: /*#__PURE__*/ jsx(Cog, {}), tag: NavLink, to: `../components/${attribute.component}/configurations/edit`, children: formatMessage({ id: getTranslation('components.FieldItem.linkToComponentLayout'), defaultMessage: "Set the component's layout" }) }) ] }) : null, attribute?.type === 'dynamiczone' ? /*#__PURE__*/ jsx(Flex, { paddingTop: 3, paddingRight: 3, paddingBottom: 3, paddingLeft: 0, alignItems: "flex-start", gap: 2, width: "100%", wrap: "wrap", children: attribute?.components.map((uid)=>/*#__PURE__*/ jsxs(ComponentLink, { // used to stop the edit form from appearing when we click here. onClick: (e)=>e.stopPropagation(), to: `../components/${uid}/configurations/edit`, children: [ /*#__PURE__*/ jsx(ComponentIcon, { icon: components[uid].settings.icon }), /*#__PURE__*/ jsx(Typography, { fontSize: 1, textColor: "neutral600", fontWeight: "bold", children: components[uid].settings.displayName }) ] }, uid)) }) : null ] }) ] }), value.name !== TEMP_FIELD_NAME && /*#__PURE__*/ jsx(EditFieldForm, { attribute: attribute, name: name, onClose: ()=>setIsModalOpen(false) }) ] }); }; const DragButton = styled(IconButton)` height: unset; align-self: stretch; display: flex; align-items: center; padding: 0; border: none; background-color: transparent; border-radius: 0px; border-right: 1px solid ${({ theme })=>theme.colors.neutral150}; cursor: all-scroll; svg { width: 1.2rem; height: 1.2rem; } `; const ComponentLink = styled(NavLink)` display: flex; flex-direction: column; align-items: center; gap: ${({ theme })=>theme.spaces[1]}; padding: ${(props)=>props.theme.spaces[2]}; border: 1px solid ${({ theme })=>theme.colors.neutral200}; background: ${({ theme })=>theme.colors.neutral0}; width: 14rem; border-radius: ${({ theme })=>theme.borderRadius}; text-decoration: none; &:focus, &:hover { ${({ theme })=>` background-color: ${theme.colors.primary100}; border-color: ${theme.colors.primary200}; ${Typography} { color: ${theme.colors.primary600}; } `} /* > ComponentIcon */ > div:first-child { background: ${({ theme })=>theme.colors.primary200}; color: ${({ theme })=>theme.colors.primary600}; svg { path { fill: ${({ theme })=>theme.colors.primary600}; } } } } `; export { Fields, SortableItem, TEMP_FIELD_NAME }; //# sourceMappingURL=Fields.mjs.map