diff --git a/apps/admin-x-settings/src/admin-x-ds/global/SortableList.tsx b/apps/admin-x-settings/src/admin-x-ds/global/SortableList.tsx index 37a7ae2953..fb209a92c0 100644 --- a/apps/admin-x-settings/src/admin-x-ds/global/SortableList.tsx +++ b/apps/admin-x-settings/src/admin-x-ds/global/SortableList.tsx @@ -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 { +export interface SortableListProps extends HTMLProps { 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 = ({ items, onMove, renderItem, - container = props => + container = props => , + ...props }: SortableListProps) => { const [draggingId, setDraggingId] = useState(null); return ( - onMove(event.active.id as string, event.over?.id as string)} - onDragStart={event => setDraggingId(event.active.id as string)} - > - + onMove(event.active.id as string, event.over?.id as string)} + onDragStart={event => setDraggingId(event.active.id as string)} > - {items.map(item => ( - {renderItem(item)} - ))} - - - {draggingId ? container({ - isDragging: true, - children: renderItem(items.find(({id}) => id === draggingId)!) - }) : null} - - + + {items.map(item => ( + {renderItem(item)} + ))} + + + {draggingId ? container({ + isDragging: true, + children: renderItem(items.find(({id}) => id === draggingId)!) + }) : null} + + + ); }; diff --git a/apps/admin-x-settings/src/components/settings/membership/tiers/TierDetailModal.tsx b/apps/admin-x-settings/src/components/settings/membership/tiers/TierDetailModal.tsx index 15563c175f..c1e9cac23a 100644 --- a/apps/admin-x-settings/src/components/settings/membership/tiers/TierDetailModal.tsx +++ b/apps/admin-x-settings/src/components/settings/membership/tiers/TierDetailModal.tsx @@ -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 = ({tier}) => { } } }); + const benefits = useSortableIndexedList({ + items: formState.benefits || [], + setItems: newBenefits => updateForm(state => ({...state, benefits: newBenefits})), + blank: '', + canAddNewItem: item => !!item + }); return { @@ -103,7 +112,26 @@ const TierDetailModal: React.FC = ({tier}) => {
- TBD +
+ benefits.updateItem(id, e.target.value)} + /> +
} + onMove={benefits.moveItem} + /> +
+ benefits.setNewItem(e.target.value)} + /> +
diff --git a/apps/admin-x-settings/src/hooks/site/useNavigationEditor.tsx b/apps/admin-x-settings/src/hooks/site/useNavigationEditor.tsx index f097b214ca..7e65e939a1 100644 --- a/apps/admin-x-settings/src/hooks/site/useNavigationEditor.tsx +++ b/apps/admin-x-settings/src/hooks/site/useNavigationEditor.tsx @@ -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(items.map((item, index) => ({...item, id: index.toString(), errors: {}}))); - const [newItem, setNewItem] = useState({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>({ + 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) => { - 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; } }; }; diff --git a/apps/admin-x-settings/src/hooks/useSortableIndexedList.tsx b/apps/admin-x-settings/src/hooks/useSortableIndexedList.tsx new file mode 100644 index 0000000000..dbed340178 --- /dev/null +++ b/apps/admin-x-settings/src/hooks/useSortableIndexedList.tsx @@ -0,0 +1,83 @@ +import {arrayMove} from '@dnd-kit/sortable'; +import {useEffect, useState} from 'react'; + +export type SortableIndexedList = { + 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 = ({items, setItems, blank, canAddNewItem}: { + items: Item[]; + setItems: (newItems: Item[]) => void; + blank: Item + canAddNewItem: (item: Item) => boolean +}): SortableIndexedList => { + // Copy items to a local state we can reorder without changing IDs, so that drag and drop animations work nicely + const [editableItems, setEditableItems] = useState>(items.map((item, index) => ({item, id: index.toString()}))); + + const [newItem, setNewItem] = useState(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;