Added errors and improved behaviour of the navigation editor

refs https://github.com/TryGhost/Team/issues/3432
This commit is contained in:
Jono Mingard 2023-06-19 16:48:42 +12:00
parent fce6b52bbd
commit 5331cc90e2
7 changed files with 447 additions and 250 deletions

View File

@ -3,7 +3,7 @@ import Hint from '../Hint';
import React, {useId} from 'react';
import clsx from 'clsx';
interface TextFieldProps {
export type TextFieldProps = React.InputHTMLAttributes<HTMLInputElement> & {
inputRef?: React.RefObject<HTMLInputElement>;
title?: string;
hideTitle?: boolean;
@ -18,6 +18,7 @@ interface TextFieldProps {
className?: string;
maxLength?: number;
containerClassName?: string;
hintClassName?: string;
unstyled?: boolean;
}
@ -36,6 +37,7 @@ const TextField: React.FC<TextFieldProps> = ({
className = '',
maxLength,
containerClassName = '',
hintClassName = '',
unstyled = false,
...props
}) => {
@ -61,17 +63,13 @@ const TextField: React.FC<TextFieldProps> = ({
onChange={onChange}
{...props} />;
if (title || hint) {
return (
<div className={`flex flex-col ${containerClassName}`}>
{title && <Heading className={hideTitle ? 'sr-only' : ''} grey={value ? true : false} htmlFor={id} useLabelTag={true}>{title}</Heading>}
{field}
{hint && <Hint color={error ? 'red' : ''}>{hint}</Hint>}
</div>
);
} else {
return field;
}
return (
<div className={`flex flex-col ${containerClassName}`}>
{title && <Heading className={hideTitle ? 'sr-only' : ''} grey={value ? true : false} htmlFor={id} useLabelTag={true}>{title}</Heading>}
{field}
{hint && <Hint className={hintClassName} color={error ? 'red' : ''}>{hint}</Hint>}
</div>
);
};
export default TextField;

View File

@ -1,9 +1,9 @@
import Modal from '../../../admin-x-ds/global/modal/Modal';
import NavigationEditor from './navigation/NavigationEditor';
import NavigationEditForm from './navigation/NavigationEditForm';
import NiceModal, {useModal} from '@ebay/nice-modal-react';
import TabView from '../../../admin-x-ds/global/TabView';
import useNavigationEditor, {NavigationItem} from '../../../hooks/site/useNavigationEditor';
import useSettingGroup from '../../../hooks/useSettingGroup';
import {NavigationItem} from './navigation/NavigationItemEditor';
import {getSettingValues} from '../../../utils/helpers';
const NavigationModal = NiceModal.create(() => {
@ -17,22 +17,36 @@ const NavigationModal = NiceModal.create(() => {
siteData
} = useSettingGroup();
const [navigation, secondaryNavigation] = getSettingValues<string>(
const [navigationItems, secondaryNavigationItems] = getSettingValues<string>(
localSettings,
['navigation', 'secondary_navigation']
).map(value => JSON.parse(value || '[]') as NavigationItem[]);
const navigation = useNavigationEditor({
items: navigationItems,
setItems: (items) => {
updateSetting('navigation', JSON.stringify(items));
}
});
const secondaryNavigation = useNavigationEditor({
items: secondaryNavigationItems,
setItems: items => updateSetting('secondary_navigation', JSON.stringify(items))
});
return (
<Modal
buttonsDisabled={saveState === 'saving'}
dirty={localSettings.some(setting => setting.dirty)}
scrolling={true}
size='lg'
stickyFooter={true}
title='Navigation'
onCancel={() => modal.remove()}
onOk={async () => {
await handleSave();
modal.remove();
if (navigation.validate() && secondaryNavigation.validate()) {
await handleSave();
modal.remove();
}
}}
>
<div className='-mb-8 mt-6'>
@ -41,12 +55,12 @@ const NavigationModal = NiceModal.create(() => {
{
id: 'primary-nav',
title: 'Primary navigation',
contents: <NavigationEditor baseUrl={siteData!.url} items={navigation} setItems={items => updateSetting('navigation', JSON.stringify(items))} />
contents: <NavigationEditForm baseUrl={siteData!.url} navigation={navigation} />
},
{
id: 'secondary-nav',
title: 'Secondary navigation',
contents: <NavigationEditor baseUrl={siteData!.url} items={secondaryNavigation} setItems={items => updateSetting('secondary_navigation', JSON.stringify(items))} />
contents: <NavigationEditForm baseUrl={siteData!.url} navigation={secondaryNavigation} />
}
]}
/>

View File

@ -0,0 +1,124 @@
import Button from '../../../../admin-x-ds/global/Button';
import NavigationItemEditor, {NavigationItemEditorProps} from './NavigationItemEditor';
import React, {forwardRef, useState} from 'react';
import clsx from 'clsx';
import {CSS} from '@dnd-kit/utilities';
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<{
baseUrl: string;
navigation: NavigationEditor;
}> = ({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">
<DndContext
collisionDetection={closestCenter}
onDragEnd={event => moveItem(event.active.id as string, event.over?.id as string)}
onDragStart={event => setDraggingId(event.active.id as string)}
>
<SortableContext
items={navigation.items}
strategy={verticalListSortingStrategy}
>
{navigation.items.map(item => (
<SortableItem
// eslint-disable-next-line react/no-array-index-key
key={item.id}
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
action={<Button color='green' icon="add" iconColorClass='text-white' size='sm' onClick={navigation.addItem} />}
baseUrl={baseUrl}
clearError={key => navigation.clearError(navigation.newItem.id, key)}
containerClasses="flex items-start gap-3 p-2"
dragHandleClasses="ml-2 invisible"
item={navigation.newItem}
labelPlaceholder="New item label"
textFieldClasses="w-full ml-2"
updateItem={navigation.setNewItem}
/>
</div>;
};
export default NavigationEditForm;

View File

@ -1,118 +0,0 @@
import Button from '../../../../admin-x-ds/global/Button';
import NavigationItemEditor, {NavigationItem} from './NavigationItemEditor';
import React, {useState} from 'react';
import TextField from '../../../../admin-x-ds/global/form/TextField';
import {CSS} from '@dnd-kit/utilities';
import {DndContext, DragOverlay, closestCenter} from '@dnd-kit/core';
import {SortableContext, arrayMove, useSortable, verticalListSortingStrategy} from '@dnd-kit/sortable';
type DraggableItem = NavigationItem & { id: string }
const SortableItem: React.FC<{
baseUrl: string;
item: DraggableItem;
updateItem: (item: Partial<NavigationItem>) => void;
onDelete: () => void;
}> = ({baseUrl, item, updateItem, onDelete}) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition
} = useSortable({id: item.id});
const style = {
transform: CSS.Transform.toString(transform),
transition
};
return (
<NavigationItemEditor
ref={setNodeRef}
baseUrl={baseUrl}
dragHandleProps={{...attributes, ...listeners}}
item={item}
style={style}
updateItem={updateItem}
onDelete={onDelete}
/>
);
};
const NavigationEditor: React.FC<{
baseUrl: string;
items: NavigationItem[];
setItems: (items: NavigationItem[]) => void;
}> = ({baseUrl, items, setItems}) => {
// Copy items to a local state we can reorder without changing IDs, so that drag and drop animations work nicely
const [draggableItems, setLocalDraggableItems] = useState<DraggableItem[]>(items.map((item, index) => ({...item, id: index.toString()})));
const [newItem, setNewItem] = useState<NavigationItem>({label: '', url: baseUrl});
const [draggingId, setDraggingId] = useState<string | null>(null);
const setDraggableItems = (newItems: DraggableItem[]) => {
setLocalDraggableItems(newItems);
setItems(newItems.map(({id, ...item}) => item));
};
const updateItem = (id: string, item: Partial<NavigationItem>) => {
setDraggableItems(draggableItems.map(current => (current.id === id ? {...current, ...item} : current)));
};
const addItem = () => {
if (newItem.label && newItem.url) {
setDraggableItems(draggableItems.concat({...newItem, id: draggableItems.length.toString()}));
setNewItem({label: '', url: baseUrl});
}
};
const removeItem = (id: string) => {
setDraggableItems(draggableItems.filter(item => item.id !== id));
};
const moveItem = (activeId: string, overId?: string) => {
if (activeId !== overId) {
const fromIndex = draggableItems.findIndex(item => item.id === activeId);
const toIndex = overId ? draggableItems.findIndex(item => item.id === overId) : 0;
setDraggableItems(arrayMove(draggableItems, fromIndex, toIndex));
}
setDraggingId(null);
};
return <div className="w-full">
<DndContext
collisionDetection={closestCenter}
onDragEnd={event => moveItem(event.active.id as string, event.over?.id as string)}
onDragStart={event => setDraggingId(event.active.id as string)}
>
<SortableContext
items={draggableItems}
strategy={verticalListSortingStrategy}
>
{draggableItems.map(item => (
<SortableItem
// eslint-disable-next-line react/no-array-index-key
key={item.id}
baseUrl={baseUrl}
item={item}
updateItem={updates => updateItem(item.id, updates)}
onDelete={() => removeItem(item.id)}
/>
))}
</SortableContext>
<DragOverlay>
{draggingId ? <NavigationItemEditor baseUrl={baseUrl} item={draggableItems.find(({id}) => id === draggingId)!} isDragging /> : null}
</DragOverlay>
</DndContext>
<div className="flex items-center gap-3 p-2">
<span className='inline-block w-8'></span>
<TextField className='grow' placeholder='New item label' value={newItem.label} onChange={e => setNewItem({...newItem, label: e.target.value})} />
<TextField className='ml-2 grow' value={newItem.url} onChange={e => setNewItem({...newItem, url: e.target.value})} />
<Button color='green' icon="add" iconColorClass='text-white' size='sm' onClick={addItem} />
</div>
</div>;
};
export default NavigationEditor;

View File

@ -1,129 +1,59 @@
import Button from '../../../../admin-x-ds/global/Button';
import Icon from '../../../../admin-x-ds/global/Icon';
import React, {forwardRef, useEffect, useState} from 'react';
import React, {ReactNode, forwardRef} from 'react';
import TextField from '../../../../admin-x-ds/global/form/TextField';
import clsx from 'clsx';
import validator from 'validator';
import UrlTextField from './UrlTextField';
import {EditableItem, NavigationItem, NavigationItemErrors} from '../../../../hooks/site/useNavigationEditor';
export type NavigationItem = {
label: string;
url: string;
}
type NavigationItemEditorProps = React.HTMLAttributes<HTMLDivElement> & {
export type NavigationItemEditorProps = React.HTMLAttributes<HTMLDivElement> & {
baseUrl: string;
item: NavigationItem;
item: EditableItem;
clearError?: (key: keyof NavigationItemErrors) => void;
updateItem?: (item: Partial<NavigationItem>) => void;
onDelete?: () => void;
isDragging?: boolean;
dragHandleProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
labelPlaceholder?: string
unstyled?: boolean
containerClasses?: string
dragHandleClasses?: string
textFieldClasses?: string
action?: ReactNode
}
const formatUrl = (value: string, baseUrl: string) => {
let url = value.trim();
// if we have an email address, add the mailto:
if (validator.isEmail(url)) {
return {save: `mailto:${url}`, display: `mailto:${url}`};
}
const isAnchorLink = url.match(/^#/);
if (isAnchorLink) {
return {save: url, display: url};
}
let parsedUrl: URL;
try {
parsedUrl = new URL(url, baseUrl);
} catch (e) {
return {save: url, display: url};
}
const parsedBaseUrl = new URL(baseUrl);
let isRelativeToBasePath = parsedUrl.pathname && parsedUrl.pathname.indexOf(parsedBaseUrl.pathname) === 0;
// if our path is only missing a trailing / mark it as relative
if (`${parsedUrl.pathname}/` === parsedBaseUrl.pathname) {
isRelativeToBasePath = true;
}
const isOnSameHost = parsedUrl.host === parsedBaseUrl.host;
// if relative to baseUrl, remove the base url before sending to action
if (!isAnchorLink && isOnSameHost && isRelativeToBasePath) {
url = url.replace(/^[a-zA-Z0-9-]+:/, '');
url = url.replace(/^\/\//, '');
url = url.replace(parsedBaseUrl.host, '');
url = url.replace(parsedBaseUrl.pathname, '');
// handle case where url path is same as baseUrl path but missing trailing slash
if (parsedUrl.pathname.slice(-1) !== '/') {
url = url.replace(parsedBaseUrl.pathname.slice(0, -1), '');
}
if (url !== '') {
if (!url.match(/^\//)) {
url = `/${url}`;
}
if (!url.match(/\/$/) && !url.match(/[.#?]/)) {
url = `${url}/`;
}
}
}
// we update with the relative URL but then transform it back to absolute
// for the input value. This avoids problems where the underlying relative
// value hasn't changed even though the input value has
if (url.match(/^(\/\/|#)/)) {
return {save: url, display: url};
}
if (url.match(/^[a-zA-Z0-9-]+:/) || validator.isURL(url) || validator.isURL(`${parsedBaseUrl.origin}${url}`)) {
return {save: url, display: new URL(url, baseUrl).toString()};
}
return {save: url, display: url};
};
const NavigationItemEditor = forwardRef<HTMLDivElement, NavigationItemEditorProps>(function NavigationItemEditor({baseUrl, item, updateItem, onDelete, isDragging, dragHandleProps, ...props}, ref) {
const [urlValue, setUrlValue] = useState('');
useEffect(() => {
setUrlValue(formatUrl(item.url, baseUrl).display);
}, [item.url, baseUrl]);
const updateUrl = () => {
const {save, display} = formatUrl(urlValue, baseUrl);
setUrlValue(display);
updateItem?.({url: save});
};
const containerClasses = clsx(
'flex w-full items-center gap-3 rounded border-b border-grey-200 bg-white py-4 hover:bg-grey-100',
isDragging && 'opacity-75'
);
const dragHandleClasses = clsx(
'ml-2 cursor-grab pl-2',
isDragging ? 'cursor-grabbing' : 'cursor-grap'
);
const textFieldClasses = clsx(
'grow border-b border-transparent bg-white px-2 py-0.5 hover:border-grey-300 focus:border-grey-600'
);
const NavigationItemEditor = forwardRef<HTMLDivElement, NavigationItemEditorProps>(function NavigationItemEditor({baseUrl, item, updateItem, onDelete, clearError, dragHandleProps, labelPlaceholder, unstyled, containerClasses, dragHandleClasses, textFieldClasses, action, ...props}, ref) {
return (
<div ref={ref} className={containerClasses} {...props}>
<button className={dragHandleClasses} type='button' {...dragHandleProps}>
<Icon colorClass='text-grey-500' name='hamburger' size='sm' />
</button>
<TextField className={textFieldClasses} value={item.label} unstyled onChange={e => updateItem?.({label: e.target.value})} />
<TextField className={textFieldClasses} value={urlValue} unstyled onBlur={updateUrl} onChange={e => setUrlValue(e.target.value)} />
<Button className='mr-2' icon="trash" size='sm' onClick={onDelete} />
<div className="flex flex-1">
<TextField
className={textFieldClasses}
containerClassName="w-full"
error={!!item.errors.label}
hint={item.errors.label}
hintClassName="px-2"
placeholder={labelPlaceholder}
unstyled={unstyled}
value={item.label}
onChange={e => updateItem?.({label: e.target.value})}
onKeyDown={() => clearError?.('label')}
/>
</div>
<div className="flex flex-1">
<UrlTextField
baseUrl={baseUrl}
className={textFieldClasses}
containerClassName="w-full"
error={!!item.errors.url}
hint={item.errors.url}
hintClassName="px-2"
unstyled={unstyled}
value={item.url}
onChange={value => updateItem?.({url: value})}
onKeyDown={() => clearError?.('url')}
/>
</div>
{action}
</div>
);
});

View File

@ -0,0 +1,119 @@
import React, {useEffect, useState} from 'react';
import TextField, {TextFieldProps} from '../../../../admin-x-ds/global/form/TextField';
import validator from 'validator';
const formatUrl = (value: string, baseUrl: string) => {
let url = value.trim();
if (!url) {
return {save: '/', display: baseUrl};
}
// if we have an email address, add the mailto:
if (validator.isEmail(url)) {
return {save: `mailto:${url}`, display: `mailto:${url}`};
}
const isAnchorLink = url.match(/^#/);
if (isAnchorLink) {
return {save: url, display: url};
}
let parsedUrl: URL;
try {
parsedUrl = new URL(url, baseUrl);
} catch (e) {
return {save: url, display: url};
}
const parsedBaseUrl = new URL(baseUrl);
let isRelativeToBasePath = parsedUrl.pathname && parsedUrl.pathname.indexOf(parsedBaseUrl.pathname) === 0;
// if our path is only missing a trailing / mark it as relative
if (`${parsedUrl.pathname}/` === parsedBaseUrl.pathname) {
isRelativeToBasePath = true;
}
const isOnSameHost = parsedUrl.host === parsedBaseUrl.host;
// if relative to baseUrl, remove the base url before sending to action
if (!isAnchorLink && isOnSameHost && isRelativeToBasePath) {
url = url.replace(/^[a-zA-Z0-9-]+:/, '');
url = url.replace(/^\/\//, '');
url = url.replace(parsedBaseUrl.host, '');
url = url.replace(parsedBaseUrl.pathname, '');
// handle case where url path is same as baseUrl path but missing trailing slash
if (parsedUrl.pathname.slice(-1) !== '/') {
url = url.replace(parsedBaseUrl.pathname.slice(0, -1), '');
}
if (!url.match(/^\//)) {
url = `/${url}`;
}
if (!url.match(/\/$/) && !url.match(/[.#?]/)) {
url = `${url}/`;
}
}
if (url.match(/^(\/\/|#)/)) {
return {save: url, display: url};
}
// we update with the relative URL but then transform it back to absolute
// for the input value. This avoids problems where the underlying relative
// value hasn't changed even though the input value has
return {save: url, display: new URL(url, baseUrl).toString()};
};
const UrlTextField: React.FC<Omit<TextFieldProps, 'onChange'> & {
baseUrl: string;
onChange: (value: string) => void;
}> = ({baseUrl, value, onChange, ...props}) => {
const [displayedUrl, setDisplayedUrl] = useState('');
useEffect(() => {
setDisplayedUrl(formatUrl(value || '', baseUrl).display);
}, [value, baseUrl]);
const updateUrl = () => {
const {save, display} = formatUrl(displayedUrl, baseUrl);
setDisplayedUrl(display);
onChange(save);
};
const handleFocus: React.FocusEventHandler<HTMLInputElement> = (e) => {
if (displayedUrl === baseUrl) {
// Position the cursor at the end of the input
setTimeout(() => e.target.setSelectionRange(e.target.value.length, e.target.value.length));
}
props.onFocus?.(e);
};
const handleKeyDown: React.KeyboardEventHandler<HTMLInputElement> = (e) => {
// Delete the "placeholder" value all at once
if (displayedUrl === baseUrl && ['Backspace', 'Delete'].includes(e.key)) {
setDisplayedUrl('');
}
props.onKeyDown?.(e);
};
return (
<TextField
{...props}
value={displayedUrl}
onBlur={updateUrl}
onChange={e => setDisplayedUrl(e.target.value)}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
/>
);
};
export default UrlTextField;

View File

@ -0,0 +1,130 @@
import validator from 'validator';
import {arrayMove} from '@dnd-kit/sortable';
import {useEffect, useState} from 'react';
export type NavigationItem = {
label: string;
url: string;
}
// eslint-disable-next-line no-unused-vars
export type NavigationItemErrors = { [key in keyof NavigationItem]?: string }
export type EditableItem = NavigationItem & { id: string; errors: NavigationItemErrors }
export type NavigationEditor = {
items: EditableItem[]
updateItem: (id: string, item: Partial<NavigationItem>) => void
addItem: () => void
removeItem: (id: string) => void
moveItem: (activeId: string, overId?: string) => void
newItem: EditableItem
setNewItem: (item: Partial<NavigationItem>) => void
clearError: (id: string, key: keyof NavigationItem) => void
validate: () => boolean
}
const useNavigationEditor = ({items, setItems}: {
items: NavigationItem[];
setItems: (newItems: NavigationItem[]) => void;
}): NavigationEditor => {
// Copy items to a local state we can reorder without changing IDs, so that drag and drop animations work nicely
const [editableItems, setEditableItems] = useState<EditableItem[]>(items.map((item, index) => ({...item, id: index.toString(), errors: {}})));
const [newItem, setNewItem] = useState<EditableItem>({label: '', url: '/', id: 'new', errors: {}});
const isEditingNewItem = Boolean((newItem.label && !newItem.label.match(/^\s*$/)) || newItem.url !== '/');
useEffect(() => {
const allItems = editableItems.map(({url, label}) => ({url, label}));
// If the user is adding a new item, save the new item if the form is saved
if (isEditingNewItem) {
allItems.push({url: newItem.url, label: newItem.label});
}
if (JSON.stringify(allItems) !== JSON.stringify(items)) {
setItems(allItems);
}
}, [editableItems, newItem, isEditingNewItem, items, setItems]);
const updateItem = (id: string, item: Partial<NavigationItem>) => {
setEditableItems(editableItems.map(current => (current.id === id ? {...current, ...item} : current)));
};
const addItem = () => {
if (newItem.label && newItem.url) {
setEditableItems(editableItems.concat({...newItem, id: editableItems.length.toString(), errors: {}}));
setNewItem({label: '', url: '/', id: 'new', errors: {}});
}
};
const removeItem = (id: string) => {
setEditableItems(editableItems.filter(item => item.id !== id));
};
const moveItem = (activeId: string, overId?: string) => {
if (activeId !== overId) {
const fromIndex = editableItems.findIndex(item => item.id === activeId);
const toIndex = overId ? editableItems.findIndex(item => item.id === overId) : 0;
setEditableItems(arrayMove(editableItems, fromIndex, toIndex));
}
};
const clearError = (id: string, key: keyof NavigationItem) => {
if (id === newItem.id) {
setNewItem({...newItem, errors: {...newItem.errors, [key]: undefined}});
} else {
setEditableItems(editableItems.map(current => (current.id === id ? {...current, errors: {...current.errors, [key]: undefined}} : current)));
}
};
const urlRegex = new RegExp(/^(\/|#|[a-zA-Z0-9-]+:)/);
const validateItem = (item: EditableItem) => {
const errors: NavigationItemErrors = {};
if (!item.label || item.label.match(/^\s*$/)) {
errors.label = 'You must specify a label';
}
if (!item.url || item.url.match(/\s/) || (!validator.isURL(item.url, {require_protocol: true}) && !item.url.match(urlRegex))) {
errors.url = 'You must specify a valid URL or relative path';
}
return errors;
};
return {
items: editableItems,
updateItem,
addItem,
removeItem,
moveItem,
newItem,
setNewItem: item => setNewItem({...newItem, ...item}),
clearError,
validate: () => {
const errors: { [id: string]: NavigationItemErrors } = {};
editableItems.forEach((item) => {
errors[item.id] = validateItem(item);
});
if (isEditingNewItem) {
errors[newItem.id] = validateItem(newItem);
}
if (Object.values(errors).some(error => Object.values(error).some(message => message))) {
setEditableItems(editableItems.map(item => ({...item, errors: errors[item.id] || {}})));
setNewItem({...newItem, errors: errors[newItem.id] || {}});
return false;
}
return true;
}
};
};
export default useNavigationEditor;