mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-23 22:11:09 +03:00
Added errors and improved behaviour of the navigation editor
refs https://github.com/TryGhost/Team/issues/3432
This commit is contained in:
parent
fce6b52bbd
commit
5331cc90e2
@ -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;
|
||||
|
@ -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} />
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
@ -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;
|
@ -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;
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
@ -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;
|
130
ghost/admin-x-settings/src/hooks/site/useNavigationEditor.tsx
Normal file
130
ghost/admin-x-settings/src/hooks/site/useNavigationEditor.tsx
Normal 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;
|
Loading…
Reference in New Issue
Block a user