Added sortable tier benefit editing in AdminX (#17315)
refs https://github.com/TryGhost/Product/issues/3580
This commit is contained in:
parent
249c7fbff6
commit
36f4a72531
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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]'>
|
||||
|
@ -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;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
83
apps/admin-x-settings/src/hooks/useSortableIndexedList.tsx
Normal file
83
apps/admin-x-settings/src/hooks/useSortableIndexedList.tsx
Normal 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;
|
Loading…
Reference in New Issue
Block a user