mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-02-10 23:36:14 -05:00
Added AdminX navigation settings components to design system (#17312)
refs https://github.com/TryGhost/Product/issues/3349 - Moved URLTextField to AdminX design system - Factored out sortable list into a design system component
This commit is contained in:
parent
8fd9d92944
commit
57a851227c
6 changed files with 251 additions and 124 deletions
|
@ -0,0 +1,54 @@
|
||||||
|
import {useArgs} from '@storybook/preview-api';
|
||||||
|
import type {Meta, StoryObj} from '@storybook/react';
|
||||||
|
|
||||||
|
import SortableList, {SortableListProps} from './SortableList';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import {arrayMove} from '@dnd-kit/sortable';
|
||||||
|
import {useState} from 'react';
|
||||||
|
|
||||||
|
const Wrapper = (props: SortableListProps<any> & {updateArgs: (args: Partial<SortableListProps<any>>) => void}) => {
|
||||||
|
// Seems like Storybook recreates items on every render, so we need to keep our own state
|
||||||
|
const [items, setItems] = useState(props.items);
|
||||||
|
|
||||||
|
return <SortableList {...props} items={items} onMove={(activeId, overId) => {
|
||||||
|
if (activeId !== overId) {
|
||||||
|
const fromIndex = items.findIndex(item => item.id === activeId);
|
||||||
|
const toIndex = overId ? items.findIndex(item => item.id === overId) : 0;
|
||||||
|
setItems(arrayMove(items, fromIndex, toIndex));
|
||||||
|
// But still update the args so that the storybook panel updates
|
||||||
|
props.updateArgs({items: arrayMove(items, fromIndex, toIndex)});
|
||||||
|
}
|
||||||
|
}} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Global / Sortable List',
|
||||||
|
component: SortableList,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
render: function Component(args) {
|
||||||
|
const [, updateArgs] = useArgs();
|
||||||
|
return <Wrapper {...args} updateArgs={updateArgs} />;
|
||||||
|
}
|
||||||
|
} satisfies Meta<typeof SortableList>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof SortableList>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
items: [{id: 'first item'}, {id: 'second item'}, {id: 'third item'}],
|
||||||
|
renderItem: item => <span className="self-center">{item.id}</span>
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CustomContainer: Story = {
|
||||||
|
args: {
|
||||||
|
items: [{id: 'first item'}, {id: 'second item'}, {id: 'third item'}],
|
||||||
|
renderItem: item => <span className="self-center">{item.id}</span>,
|
||||||
|
container: ({setRef, isDragging, dragHandleAttributes, dragHandleListeners, style, children}) => (
|
||||||
|
<div ref={setRef} className={clsx('mb-2 rounded border border-grey-200 p-4', isDragging && 'bg-grey-50')} style={style} {...dragHandleAttributes} {...dragHandleListeners}>
|
||||||
|
Drag this whole row! Item: {children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
108
apps/admin-x-settings/src/admin-x-ds/global/SortableList.tsx
Normal file
108
apps/admin-x-settings/src/admin-x-ds/global/SortableList.tsx
Normal file
|
@ -0,0 +1,108 @@
|
||||||
|
import Icon from './Icon';
|
||||||
|
import React, {ReactNode, useState} from 'react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import {CSS} from '@dnd-kit/utilities';
|
||||||
|
import {DndContext, DragOverlay, DraggableAttributes, closestCenter} from '@dnd-kit/core';
|
||||||
|
import {SortableContext, useSortable, verticalListSortingStrategy} from '@dnd-kit/sortable';
|
||||||
|
|
||||||
|
export interface SortableItemContainerProps {
|
||||||
|
setRef?: (element: HTMLElement | null) => void;
|
||||||
|
isDragging: boolean;
|
||||||
|
dragHandleAttributes?: DraggableAttributes;
|
||||||
|
dragHandleListeners?: Record<string, Function>;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const DefaultContainer: React.FC<SortableItemContainerProps> = ({setRef, isDragging, dragHandleAttributes, dragHandleListeners, style, children}) => (
|
||||||
|
<div
|
||||||
|
ref={setRef}
|
||||||
|
className={clsx(
|
||||||
|
'flex w-full items-start gap-3 rounded border-b border-grey-200 bg-white py-4 hover:bg-grey-100',
|
||||||
|
isDragging && 'opacity-75'
|
||||||
|
)}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className={clsx(
|
||||||
|
'ml-2 h-7 pl-2',
|
||||||
|
isDragging ? 'cursor-grabbing' : 'cursor-grab'
|
||||||
|
)}
|
||||||
|
type='button'
|
||||||
|
{...dragHandleAttributes}
|
||||||
|
{...dragHandleListeners}
|
||||||
|
>
|
||||||
|
<Icon colorClass='text-grey-500' name='hamburger' size='sm' />
|
||||||
|
</button>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SortableItem: React.FC<{
|
||||||
|
id: string
|
||||||
|
children: ReactNode;
|
||||||
|
container: (props: SortableItemContainerProps) => ReactNode;
|
||||||
|
}> = ({id, children, container}) => {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition
|
||||||
|
} = useSortable({id});
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition
|
||||||
|
};
|
||||||
|
|
||||||
|
return container({
|
||||||
|
setRef: setNodeRef,
|
||||||
|
isDragging: false,
|
||||||
|
dragHandleAttributes: attributes,
|
||||||
|
dragHandleListeners: listeners,
|
||||||
|
style,
|
||||||
|
children
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SortableListProps<Item extends {id: string}> {
|
||||||
|
items: Item[];
|
||||||
|
onMove: (id: string, overId: string) => void;
|
||||||
|
renderItem: (item: Item) => ReactNode;
|
||||||
|
container?: (props: SortableItemContainerProps) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SortableList = <Item extends {id: string}>({
|
||||||
|
items,
|
||||||
|
onMove,
|
||||||
|
renderItem,
|
||||||
|
container = props => <DefaultContainer {...props} />
|
||||||
|
}: SortableListProps<Item>) => {
|
||||||
|
const [draggingId, setDraggingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={event => onMove(event.active.id as string, event.over?.id as string)}
|
||||||
|
onDragStart={event => setDraggingId(event.active.id as string)}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={items}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
{items.map(item => (
|
||||||
|
<SortableItem key={item.id} container={container} id={item.id}>{renderItem(item)}</SortableItem>
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
<DragOverlay>
|
||||||
|
{draggingId ? container({
|
||||||
|
isDragging: true,
|
||||||
|
children: renderItem(items.find(({id}) => id === draggingId)!)
|
||||||
|
}) : null}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SortableList;
|
|
@ -0,0 +1,48 @@
|
||||||
|
import {useArgs} from '@storybook/preview-api';
|
||||||
|
import type {Meta, StoryObj} from '@storybook/react';
|
||||||
|
|
||||||
|
import URLTextField from './URLTextField';
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Global / Form / URL Textfield',
|
||||||
|
component: URLTextField,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
args: {
|
||||||
|
baseUrl: 'https://my.site'
|
||||||
|
}
|
||||||
|
} satisfies Meta<typeof URLTextField>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof URLTextField>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
placeholder: 'Enter something'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithValue: Story = {
|
||||||
|
render: function Component(args) {
|
||||||
|
const [, updateArgs] = useArgs();
|
||||||
|
|
||||||
|
return <URLTextField {...args} onChange={value => updateArgs({value})} />;
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
placeholder: 'Enter something',
|
||||||
|
value: '/test/'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EmailAddress: Story = {
|
||||||
|
args: {
|
||||||
|
placeholder: 'Enter something',
|
||||||
|
value: 'mailto:test@my.site'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AnchorLink: Story = {
|
||||||
|
args: {
|
||||||
|
placeholder: 'Enter something',
|
||||||
|
value: '#test'
|
||||||
|
}
|
||||||
|
};
|
|
@ -1,5 +1,5 @@
|
||||||
import React, {useEffect, useState} from 'react';
|
import React, {useEffect, useState} from 'react';
|
||||||
import TextField, {TextFieldProps} from '../../../../admin-x-ds/global/form/TextField';
|
import TextField, {TextFieldProps} from './TextField';
|
||||||
import validator from 'validator';
|
import validator from 'validator';
|
||||||
|
|
||||||
const formatUrl = (value: string, baseUrl: string) => {
|
const formatUrl = (value: string, baseUrl: string) => {
|
||||||
|
@ -74,7 +74,16 @@ const formatUrl = (value: string, baseUrl: string) => {
|
||||||
return {save: url, display: new URL(url, baseUrl).toString()};
|
return {save: url, display: new URL(url, baseUrl).toString()};
|
||||||
};
|
};
|
||||||
|
|
||||||
const UrlTextField: React.FC<Omit<TextFieldProps, 'onChange'> & {
|
/**
|
||||||
|
* A text field that displays and saves relative URLs as absolute relative to a given base URL (probably the site URL).
|
||||||
|
*
|
||||||
|
* - URLs for the current site are displayed as absolute (e.g. `https://my.site/test/`) but saved as relative (e.g. `/test/`)
|
||||||
|
* - URLs on other sites are displayed and saved as absolute (e.g. `https://other.site/test/`)
|
||||||
|
* - Email addresses are displayed and saved as "mailto:" URLs (e.g. `mailto:test@my.site`)
|
||||||
|
* - Anchor links are displayed and saved as-is (e.g. `#test`)
|
||||||
|
* - Values that don't look like URLs are displayed and saved as-is (e.g. `test`)
|
||||||
|
*/
|
||||||
|
const URLTextField: React.FC<Omit<TextFieldProps, 'onChange'> & {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
}> = ({baseUrl, value, onChange, ...props}) => {
|
}> = ({baseUrl, value, onChange, ...props}) => {
|
||||||
|
@ -121,4 +130,4 @@ const UrlTextField: React.FC<Omit<TextFieldProps, 'onChange'> & {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default UrlTextField;
|
export default URLTextField;
|
|
@ -1,119 +1,34 @@
|
||||||
import Button from '../../../../admin-x-ds/global/Button';
|
import Button from '../../../../admin-x-ds/global/Button';
|
||||||
import NavigationItemEditor, {NavigationItemEditorProps} from './NavigationItemEditor';
|
import NavigationItemEditor from './NavigationItemEditor';
|
||||||
import React, {forwardRef, useState} from 'react';
|
import React from 'react';
|
||||||
import clsx from 'clsx';
|
import SortableList from '../../../../admin-x-ds/global/SortableList';
|
||||||
import {CSS} from '@dnd-kit/utilities';
|
import {NavigationEditor} from '../../../../hooks/site/useNavigationEditor';
|
||||||
import {DndContext, DragOverlay, closestCenter} from '@dnd-kit/core';
|
|
||||||
import {EditableItem, NavigationEditor, NavigationItem} from '../../../../hooks/site/useNavigationEditor';
|
|
||||||
import {SortableContext, useSortable, verticalListSortingStrategy} from '@dnd-kit/sortable';
|
|
||||||
|
|
||||||
const ExistingItem = forwardRef<HTMLDivElement, NavigationItemEditorProps & { isDragging?: boolean, onDelete?: () => void }>(function ExistingItemEditor({isDragging, onDelete, ...props}, ref) {
|
|
||||||
const containerClasses = clsx(
|
|
||||||
'flex w-full items-start gap-3 rounded border-b border-grey-200 bg-white py-4 hover:bg-grey-100',
|
|
||||||
isDragging && 'opacity-75'
|
|
||||||
);
|
|
||||||
|
|
||||||
const dragHandleClasses = clsx(
|
|
||||||
'ml-2 h-7 pl-2',
|
|
||||||
isDragging ? 'cursor-grabbing' : 'cursor-grab'
|
|
||||||
);
|
|
||||||
|
|
||||||
const textFieldClasses = clsx(
|
|
||||||
'w-full border-b border-transparent bg-white px-2 py-0.5 hover:border-grey-300 focus:border-grey-600'
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NavigationItemEditor
|
|
||||||
ref={ref}
|
|
||||||
action={<Button className='mr-2' icon="trash" size='sm' onClick={onDelete} />}
|
|
||||||
containerClasses={containerClasses}
|
|
||||||
dragHandleClasses={dragHandleClasses}
|
|
||||||
textFieldClasses={textFieldClasses}
|
|
||||||
unstyled
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const SortableItem: React.FC<{
|
|
||||||
baseUrl: string;
|
|
||||||
item: EditableItem;
|
|
||||||
clearError?: (key: keyof NavigationItem) => void;
|
|
||||||
updateItem: (item: Partial<NavigationItem>) => void;
|
|
||||||
onDelete: () => void;
|
|
||||||
}> = ({baseUrl, item, clearError, updateItem, onDelete}) => {
|
|
||||||
const {
|
|
||||||
attributes,
|
|
||||||
listeners,
|
|
||||||
setNodeRef,
|
|
||||||
transform,
|
|
||||||
transition
|
|
||||||
} = useSortable({id: item.id});
|
|
||||||
|
|
||||||
const style = {
|
|
||||||
transform: CSS.Transform.toString(transform),
|
|
||||||
transition
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ExistingItem
|
|
||||||
ref={setNodeRef}
|
|
||||||
baseUrl={baseUrl}
|
|
||||||
clearError={clearError}
|
|
||||||
dragHandleProps={{...attributes, ...listeners}}
|
|
||||||
item={item}
|
|
||||||
style={style}
|
|
||||||
updateItem={updateItem}
|
|
||||||
onDelete={onDelete}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const NavigationEditForm: React.FC<{
|
const NavigationEditForm: React.FC<{
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
navigation: NavigationEditor;
|
navigation: NavigationEditor;
|
||||||
}> = ({baseUrl, navigation}) => {
|
}> = ({baseUrl, navigation}) => {
|
||||||
const [draggingId, setDraggingId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const moveItem = (activeId: string, overId?: string) => {
|
|
||||||
navigation.moveItem(activeId, overId);
|
|
||||||
setDraggingId(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
return <div className="w-full">
|
return <div className="w-full">
|
||||||
<DndContext
|
<SortableList
|
||||||
collisionDetection={closestCenter}
|
items={navigation.items}
|
||||||
onDragEnd={event => moveItem(event.active.id as string, event.over?.id as string)}
|
renderItem={item => (
|
||||||
onDragStart={event => setDraggingId(event.active.id as string)}
|
<NavigationItemEditor
|
||||||
>
|
action={<Button className='mr-2' icon="trash" size='sm' onClick={() => navigation.removeItem(item.id)} />}
|
||||||
<SortableContext
|
baseUrl={baseUrl}
|
||||||
items={navigation.items}
|
clearError={key => navigation.clearError(item.id, key)}
|
||||||
strategy={verticalListSortingStrategy}
|
item={item}
|
||||||
>
|
updateItem={updates => navigation.updateItem(item.id, updates)}
|
||||||
{navigation.items.map(item => (
|
unstyled
|
||||||
<SortableItem
|
/>
|
||||||
// eslint-disable-next-line react/no-array-index-key
|
)}
|
||||||
key={item.id}
|
onMove={navigation.moveItem}
|
||||||
baseUrl={baseUrl}
|
/>
|
||||||
clearError={key => navigation.clearError(item.id, key)}
|
|
||||||
item={item}
|
|
||||||
updateItem={updates => navigation.updateItem(item.id, updates)}
|
|
||||||
onDelete={() => navigation.removeItem(item.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</SortableContext>
|
|
||||||
<DragOverlay>
|
|
||||||
{draggingId ? <ExistingItem baseUrl={baseUrl} item={navigation.items.find(({id}) => id === draggingId)!} isDragging /> : null}
|
|
||||||
</DragOverlay>
|
|
||||||
</DndContext>
|
|
||||||
|
|
||||||
<NavigationItemEditor
|
<NavigationItemEditor
|
||||||
action={<Button color='green' data-testid="add-button" icon="add" iconColorClass='text-white' size='sm' onClick={navigation.addItem} />}
|
action={<Button className='self-center' color='green' data-testid="add-button" icon="add" iconColorClass='text-white' size='sm' onClick={navigation.addItem} />}
|
||||||
baseUrl={baseUrl}
|
baseUrl={baseUrl}
|
||||||
|
className="p-2 pl-9"
|
||||||
clearError={key => navigation.clearError(navigation.newItem.id, key)}
|
clearError={key => navigation.clearError(navigation.newItem.id, key)}
|
||||||
containerClasses="flex items-start gap-3 p-2"
|
|
||||||
data-testid="new-navigation-item"
|
data-testid="new-navigation-item"
|
||||||
dragHandleClasses="ml-2 invisible"
|
|
||||||
item={navigation.newItem}
|
item={navigation.newItem}
|
||||||
labelPlaceholder="New item label"
|
labelPlaceholder="New item label"
|
||||||
textFieldClasses="w-full ml-2"
|
textFieldClasses="w-full ml-2"
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import Icon from '../../../../admin-x-ds/global/Icon';
|
import React, {ReactNode} from 'react';
|
||||||
import React, {ReactNode, forwardRef} from 'react';
|
|
||||||
import TextField from '../../../../admin-x-ds/global/form/TextField';
|
import TextField from '../../../../admin-x-ds/global/form/TextField';
|
||||||
import UrlTextField from './UrlTextField';
|
import URLTextField from '../../../../admin-x-ds/global/form/URLTextField';
|
||||||
|
import clsx from 'clsx';
|
||||||
import {EditableItem, NavigationItem, NavigationItemErrors} from '../../../../hooks/site/useNavigationEditor';
|
import {EditableItem, NavigationItem, NavigationItemErrors} from '../../../../hooks/site/useNavigationEditor';
|
||||||
|
|
||||||
export type NavigationItemEditorProps = React.HTMLAttributes<HTMLDivElement> & {
|
export type NavigationItemEditorProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||||
|
@ -9,23 +9,16 @@ export type NavigationItemEditorProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||||
item: EditableItem;
|
item: EditableItem;
|
||||||
clearError?: (key: keyof NavigationItemErrors) => void;
|
clearError?: (key: keyof NavigationItemErrors) => void;
|
||||||
updateItem?: (item: Partial<NavigationItem>) => void;
|
updateItem?: (item: Partial<NavigationItem>) => void;
|
||||||
onDelete?: () => void;
|
|
||||||
dragHandleProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
|
|
||||||
labelPlaceholder?: string
|
labelPlaceholder?: string
|
||||||
unstyled?: boolean
|
unstyled?: boolean
|
||||||
containerClasses?: string
|
|
||||||
dragHandleClasses?: string
|
|
||||||
textFieldClasses?: string
|
textFieldClasses?: string
|
||||||
action?: ReactNode
|
action?: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
const NavigationItemEditor = forwardRef<HTMLDivElement, NavigationItemEditorProps>(function NavigationItemEditor({baseUrl, item, updateItem, onDelete, clearError, dragHandleProps, labelPlaceholder, unstyled, containerClasses, dragHandleClasses, textFieldClasses, action, ...props}, ref) {
|
const NavigationItemEditor: React.FC<NavigationItemEditorProps> = ({baseUrl, item, updateItem, clearError, labelPlaceholder, unstyled, textFieldClasses, action, className, ...props}) => {
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className={containerClasses} data-testid='navigation-item-editor' {...props}>
|
<div className={clsx('flex w-full items-start gap-3', className)} data-testid='navigation-item-editor' {...props}>
|
||||||
<button className={dragHandleClasses} type='button' {...dragHandleProps}>
|
<div className="flex flex-1 pt-1">
|
||||||
<Icon colorClass='text-grey-500' name='hamburger' size='sm' />
|
|
||||||
</button>
|
|
||||||
<div className="flex flex-1">
|
|
||||||
<TextField
|
<TextField
|
||||||
className={textFieldClasses}
|
className={textFieldClasses}
|
||||||
containerClassName="w-full"
|
containerClassName="w-full"
|
||||||
|
@ -41,8 +34,8 @@ const NavigationItemEditor = forwardRef<HTMLDivElement, NavigationItemEditorProp
|
||||||
onKeyDown={() => clearError?.('label')}
|
onKeyDown={() => clearError?.('label')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1">
|
<div className="flex flex-1 pt-1">
|
||||||
<UrlTextField
|
<URLTextField
|
||||||
baseUrl={baseUrl}
|
baseUrl={baseUrl}
|
||||||
className={textFieldClasses}
|
className={textFieldClasses}
|
||||||
containerClassName="w-full"
|
containerClassName="w-full"
|
||||||
|
@ -60,6 +53,6 @@ const NavigationItemEditor = forwardRef<HTMLDivElement, NavigationItemEditorProp
|
||||||
{action}
|
{action}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
};
|
||||||
|
|
||||||
export default NavigationItemEditor;
|
export default NavigationItemEditor;
|
||||||
|
|
Loading…
Add table
Reference in a new issue