Added sortable tier benefit editing in AdminX (#17315)

refs https://github.com/TryGhost/Product/issues/3580
This commit is contained in:
Jono M 2023-07-12 17:49:32 +12:00 committed by GitHub
parent 249c7fbff6
commit 36f4a72531
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 178 additions and 73 deletions

View File

@ -1,5 +1,5 @@
import Icon from './Icon';
import React, {ReactNode, useState} from 'react';
import React, {HTMLProps, ReactNode, useState} from 'react';
import clsx from 'clsx';
import {CSS} from '@dnd-kit/utilities';
import {DndContext, DragOverlay, DraggableAttributes, closestCenter} from '@dnd-kit/core';
@ -66,42 +66,48 @@ const SortableItem: React.FC<{
});
};
export interface SortableListProps<Item extends {id: string}> {
export interface SortableListProps<Item extends {id: string}> extends HTMLProps<HTMLDivElement> {
items: Item[];
onMove: (id: string, overId: string) => void;
renderItem: (item: Item) => ReactNode;
container?: (props: SortableItemContainerProps) => ReactNode;
}
/**
* Note: For lists which don't have an ID, you can use `useSortableIndexedList` to give items a consistent index-based ID.
*/
const SortableList = <Item extends {id: string}>({
items,
onMove,
renderItem,
container = props => <DefaultContainer {...props} />
container = props => <DefaultContainer {...props} />,
...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}
<div {...props}>
<DndContext
collisionDetection={closestCenter}
onDragEnd={event => onMove(event.active.id as string, event.over?.id as string)}
onDragStart={event => setDraggingId(event.active.id as string)}
>
{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>
<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>
</div>
);
};

View File

@ -1,13 +1,16 @@
import Button from '../../../../admin-x-ds/global/Button';
import Form from '../../../../admin-x-ds/global/form/Form';
import Heading from '../../../../admin-x-ds/global/Heading';
import Modal from '../../../../admin-x-ds/global/modal/Modal';
import NiceModal, {useModal} from '@ebay/nice-modal-react';
import React from 'react';
import SortableList from '../../../../admin-x-ds/global/SortableList';
import TextField from '../../../../admin-x-ds/global/form/TextField';
import TierDetailPreview from './TierDetailPreview';
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
import useForm from '../../../../hooks/useForm';
import useRouting from '../../../../hooks/useRouting';
import useSortableIndexedList from '../../../../hooks/useSortableIndexedList';
import {Tier} from '../../../../types/api';
import {useTiers} from '../../../providers/ServiceProvider';
@ -41,6 +44,12 @@ const TierDetailModal: React.FC<TierDetailModalProps> = ({tier}) => {
}
}
});
const benefits = useSortableIndexedList({
items: formState.benefits || [],
setItems: newBenefits => updateForm(state => ({...state, benefits: newBenefits})),
blank: '',
canAddNewItem: item => !!item
});
return <Modal
afterClose={() => {
@ -103,7 +112,26 @@ const TierDetailModal: React.FC<TierDetailModalProps> = ({tier}) => {
</Form>
<Form title='Benefits'>
TBD
<SortableList
items={benefits.items}
renderItem={({id, item}) => <div className='flex'>
<TextField
placeholder='Expert analysis'
value={item}
onChange={e => benefits.updateItem(id, e.target.value)}
/>
<Button icon='trash' onClick={() => benefits.removeItem(id)} />
</div>}
onMove={benefits.moveItem}
/>
<div className="flex">
<TextField
placeholder='Expert analysis'
value={benefits.newItem}
onChange={e => benefits.setNewItem(e.target.value)}
/>
<Button icon="add" onClick={() => benefits.addItem()} />
</div>
</Form>
</div>
<div className='sticky top-[77px] shrink-0 basis-[380px]'>

View File

@ -1,6 +1,5 @@
import useSortableIndexedList from '../useSortableIndexedList';
import validator from 'validator';
import {arrayMove} from '@dnd-kit/sortable';
import {useEffect, useState} from 'react';
export type NavigationItem = {
label: string;
@ -27,28 +26,16 @@ 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 list = useSortableIndexedList<Omit<EditableItem, 'id'>>({
items: items.map(item => ({...item, errors: {}})),
setItems: newItems => setItems(newItems.map(({url, label}) => ({url, label}))),
blank: {label: '', url: '/', errors: {}},
canAddNewItem: newItem => Boolean((newItem.label && !newItem.label.match(/^\s*$/)) || newItem.url !== '/')
});
const urlRegex = new RegExp(/^(\/|#|[a-zA-Z0-9-]+:)/);
const validateItem = (item: EditableItem) => {
const validateItem = (item: NavigationItem) => {
const errors: NavigationItemErrors = {};
if (!item.label || item.label.match(/^\s*$/)) {
@ -63,70 +50,71 @@ const useNavigationEditor = ({items, setItems}: {
};
const updateItem = (id: string, item: Partial<NavigationItem>) => {
setEditableItems(editableItems.map(current => (current.id === id ? {...current, ...item} : current)));
const currentItem = list.items.find(current => current.id === id)!;
list.updateItem(id, {...currentItem.item, ...item});
};
const addItem = () => {
const errors = validateItem(newItem);
const errors = validateItem(list.newItem);
if (Object.values(errors).some(message => message)) {
setNewItem({...newItem, errors});
list.setNewItem({...list.newItem, errors});
} else {
setEditableItems(editableItems.concat({...newItem, id: editableItems.length.toString(), errors: {}}));
setNewItem({label: '', url: '/', id: 'new', errors: {}});
list.addItem();
}
};
const removeItem = (id: string) => {
setEditableItems(editableItems.filter(item => item.id !== id));
list.removeItem(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));
}
list.moveItem(activeId, overId);
};
const newItemId = 'new';
const clearError = (id: string, key: keyof NavigationItem) => {
if (id === newItem.id) {
setNewItem({...newItem, errors: {...newItem.errors, [key]: undefined}});
if (id === newItemId) {
list.setNewItem({...list.newItem, errors: {...list.newItem.errors, [key]: undefined}});
} else {
setEditableItems(editableItems.map(current => (current.id === id ? {...current, errors: {...current.errors, [key]: undefined}} : current)));
const currentItem = list.items.find(current => current.id === id)!.item;
list.updateItem(id, {...currentItem, errors: {...currentItem.errors, [key]: undefined}});
}
};
return {
items: editableItems,
items: list.items.map(({item, id}) => ({...item, id})),
updateItem,
addItem,
removeItem,
moveItem,
newItem,
setNewItem: item => setNewItem({...newItem, ...item}),
newItem: {...list.newItem, id: newItemId},
setNewItem: item => list.setNewItem({...list.newItem, ...item}),
clearError,
validate: () => {
const errors: { [id: string]: NavigationItemErrors } = {};
let isValid = true;
editableItems.forEach((item) => {
errors[item.id] = validateItem(item);
list.items.forEach(({item, id}) => {
let errors = validateItem(item);
if (Object.values(errors).some(message => message)) {
isValid = false;
list.updateItem(id, {...item, errors});
}
});
if (isEditingNewItem) {
errors[newItem.id] = validateItem(newItem);
const newItemErrors = validateItem(list.newItem);
if (Object.values(newItemErrors).some(message => message)) {
isValid = false;
list.setNewItem({...list.newItem, errors: newItemErrors});
}
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;
return isValid;
}
};
};

View File

@ -0,0 +1,83 @@
import {arrayMove} from '@dnd-kit/sortable';
import {useEffect, useState} from 'react';
export type SortableIndexedList<Item> = {
items: Array<{ item: Item; id: string }>;
updateItem: (id: string, item: Item) => void;
addItem: () => void;
removeItem: (id: string) => void;
moveItem: (activeId: string, overId?: string) => void;
newItem: Item;
setNewItem: (item: Item) => void;
}
const useSortableIndexedList = <Item extends unknown>({items, setItems, blank, canAddNewItem}: {
items: Item[];
setItems: (newItems: Item[]) => void;
blank: Item
canAddNewItem: (item: Item) => boolean
}): SortableIndexedList<Item> => {
// Copy items to a local state we can reorder without changing IDs, so that drag and drop animations work nicely
const [editableItems, setEditableItems] = useState<Array<{ item: Item; id: string }>>(items.map((item, index) => ({item, id: index.toString()})));
const [newItem, setNewItem] = useState<Item>(blank);
useEffect(() => {
const allItems = editableItems.map(({item}) => item);
// If the user is adding a new item, save the new item if the form is saved
if (canAddNewItem(newItem)) {
allItems.push(newItem);
}
if (JSON.stringify(allItems) !== JSON.stringify(items)) {
setItems(allItems);
}
}, [editableItems, newItem, items, setItems, canAddNewItem]);
const updateItem = (id: string, item: Item) => {
const updatedItems = editableItems.map(current => (current.id === id ? {...current, item} : current));
setEditableItems(updatedItems);
setItems(updatedItems.map(updatedItem => updatedItem.item));
};
const addItem = () => {
if (canAddNewItem(newItem)) {
const maxId = editableItems.reduce((max, current) => Math.max(max, parseInt(current.id)), 0);
const updatedItems = editableItems.concat({item: newItem, id: (maxId + 1).toString()});
setEditableItems(updatedItems);
setItems(updatedItems.map(updatedItem => updatedItem.item));
setNewItem(blank);
}
};
const removeItem = (id: string) => {
const updatedItems = editableItems.filter(item => item.id !== id);
setEditableItems(updatedItems);
setItems(updatedItems.map(updatedItem => updatedItem.item));
};
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;
const updatedItems = arrayMove(editableItems, fromIndex, toIndex);
setEditableItems(updatedItems);
setItems(updatedItems.map(updatedItem => updatedItem.item));
}
};
return {
items: editableItems,
updateItem,
addItem,
removeItem,
moveItem,
newItem,
setNewItem
};
};
export default useSortableIndexedList;