diff --git a/apps/admin-x-design-system/src/global/SortableList.tsx b/apps/admin-x-design-system/src/global/SortableList.tsx index a4a2b26eed..9209f5e319 100644 --- a/apps/admin-x-design-system/src/global/SortableList.tsx +++ b/apps/admin-x-design-system/src/global/SortableList.tsx @@ -1,14 +1,15 @@ +import {DndContext, DragOverlay, DraggableAttributes, closestCenter} from '@dnd-kit/core'; +import {SortableContext, useSortable, verticalListSortingStrategy} from '@dnd-kit/sortable'; +import {CSS} from '@dnd-kit/utilities'; +import clsx from 'clsx'; +import React, {ElementType, HTMLProps, ReactNode, useState} from 'react'; import Heading from './Heading'; import Hint from './Hint'; import Icon from './Icon'; -import React, {HTMLProps, ReactNode, useState} from 'react'; import Separator from './Separator'; -import clsx from 'clsx'; -import {CSS} from '@dnd-kit/utilities'; -import {DndContext, DragOverlay, DraggableAttributes, closestCenter} from '@dnd-kit/core'; -import {SortableContext, useSortable, verticalListSortingStrategy} from '@dnd-kit/sortable'; export interface SortableItemContainerProps { + id: string; setRef?: (element: HTMLElement | null) => void; isDragging: boolean; dragHandleAttributes?: DraggableAttributes; @@ -21,15 +22,32 @@ export interface SortableItemContainerProps { separator?: boolean; } +export type DragIndicatorProps = Pick & React.HTMLAttributes + +export const DragIndicator: React.FC = ({isDragging, dragHandleAttributes, dragHandleListeners, dragHandleClass, className, ...props}) => ( + +); + const DefaultContainer: React.FC = ({ setRef, isDragging, - dragHandleAttributes, - dragHandleListeners, - dragHandleClass, style, separator, - children + children, + ...props }) => (
= ({ )} style={style} > - + {children}
); @@ -77,6 +84,7 @@ const SortableItem: React.FC<{ }; return container({ + id, setRef: setNodeRef, isDragging: false, separator: separator, @@ -98,6 +106,8 @@ export interface SortableListProps extends HTMLProps< onMove: (id: string, overId: string) => void; renderItem: (item: Item) => ReactNode; container?: (props: SortableItemContainerProps) => ReactNode; + wrapper?: ElementType; + dragOverlayWrapper?: keyof JSX.IntrinsicElements; } /** @@ -113,6 +123,8 @@ const SortableList = ({ onMove, renderItem, container = props => , + wrapper: Wrapper = React.Fragment, + dragOverlayWrapper, ...props }: SortableListProps) => { const [draggingId, setDraggingId] = useState(null); @@ -130,16 +142,19 @@ const SortableList = ({ onDragEnd={event => onMove(event.active.id as string, event.over?.id as string)} onDragStart={event => setDraggingId(event.active.id as string)} > - - {items.map(item => ( - {renderItem(item)} - ))} - - + + + {items.map(item => ( + {renderItem(item)} + ))} + + + {draggingId ? container({ + id: draggingId, isDragging: true, children: renderItem(items.find(({id}) => id === draggingId)!) }) : null} diff --git a/apps/admin-x-design-system/src/global/Table.stories.tsx b/apps/admin-x-design-system/src/global/Table.stories.tsx index bbadc5d21a..4bd3565080 100644 --- a/apps/admin-x-design-system/src/global/Table.stories.tsx +++ b/apps/admin-x-design-system/src/global/Table.stories.tsx @@ -1,11 +1,13 @@ -import {ReactNode} from 'react'; import type {Meta, StoryObj} from '@storybook/react'; +import {ReactNode} from 'react'; -import * as TableRowStories from './TableRow.stories'; +import {useSortableIndexedList} from '..'; +import SortableList, {DragIndicator, SortableItemContainerProps} from './SortableList'; import Table from './Table'; import TableCell from './TableCell'; import TableHead from './TableHead'; import TableRow from './TableRow'; +import * as TableRowStories from './TableRow.stories'; const meta = { title: 'Global / Table', @@ -95,4 +97,58 @@ export const Loading: Story = { hint: 'This is a hint', hintSeparator: true } -}; \ No newline at end of file +}; + +// Components for Sortable example + +const SortableContainer: React.FC> = ({setRef, isDragging, style, children, ...props}) => { + const container = ( + + {(props.dragHandleAttributes || isDragging) && + + } + {children} + + ); + + if (isDragging) { + return {container}
; + } else { + return container; + } +}; + +const SortableItem: React.FC<{id: string; item: string}> = ({id, item}) => { + return ( + <> + {id}. + {item} + + ); +}; + +const SortableTable = () => { + const list = useSortableIndexedList({ + items: ['First', 'Second'], + setItems: () => {}, + blank: '', + canAddNewItem: () => false + }); + + return } + items={list.items} + renderItem={item => } + wrapper={Table} + onMove={list.moveItem} + />; +}; + +/** + * Example of combining Table and SortableList to create a sortable table. + * This is a little complex as each type of container/item needs to be overridden + * to end up with the correct table->tbody->tr->td structure. + */ +export const Sortable: Story = { + render: () => +}; diff --git a/apps/admin-x-design-system/src/global/TableRow.tsx b/apps/admin-x-design-system/src/global/TableRow.tsx index d7831f3455..f13a1fd254 100644 --- a/apps/admin-x-design-system/src/global/TableRow.tsx +++ b/apps/admin-x-design-system/src/global/TableRow.tsx @@ -1,11 +1,12 @@ import clsx from 'clsx'; -import React from 'react'; +import React, {forwardRef} from 'react'; export interface TableRowProps { id?: string; action?: React.ReactNode; hideActions?: boolean; className?: string; + style?: React.CSSProperties; testId?: string; /** @@ -18,7 +19,7 @@ export interface TableRowProps { children?: React.ReactNode; } -const TableRow: React.FC = ({id, action, hideActions, className, testId, separator, bgOnHover = true, onClick, children}) => { +const TableRow = forwardRef(function TableRow({id, action, hideActions, className, style, testId, separator, bgOnHover = true, onClick, children}, ref) { const handleClick = (e: React.MouseEvent) => { onClick?.(e); }; @@ -33,7 +34,7 @@ const TableRow: React.FC = ({id, action, hideActions, className, ); return ( - + {children} {action && @@ -44,6 +45,6 @@ const TableRow: React.FC = ({id, action, hideActions, className, } ); -}; +}); export default TableRow; diff --git a/apps/admin-x-settings/src/hooks/useSortableIndexedList.tsx b/apps/admin-x-design-system/src/hooks/useSortableIndexedList.tsx similarity index 100% rename from apps/admin-x-settings/src/hooks/useSortableIndexedList.tsx rename to apps/admin-x-design-system/src/hooks/useSortableIndexedList.tsx diff --git a/apps/admin-x-design-system/src/index.ts b/apps/admin-x-design-system/src/index.ts index fab19ffc54..284b8e3b42 100644 --- a/apps/admin-x-design-system/src/index.ts +++ b/apps/admin-x-design-system/src/index.ts @@ -106,8 +106,8 @@ export {default as Popover} from './global/Popover'; export type {PopoverProps} from './global/Popover'; export {default as Separator} from './global/Separator'; export type {SeparatorProps} from './global/Separator'; -export {default as SortableList} from './global/SortableList'; -export type {SortableListProps} from './global/SortableList'; +export {DragIndicator, default as SortableList} from './global/SortableList'; +export type {DragIndicatorProps, SortableItemContainerProps, SortableListProps} from './global/SortableList'; export {default as StickyFooter} from './global/StickyFooter'; export type {StickyFooterProps} from './global/StickyFooter'; export {default as TabView} from './global/TabView'; @@ -149,6 +149,7 @@ export type {StripeButtonProps} from './settings/StripeButton'; export {default as useGlobalDirtyState} from './hooks/useGlobalDirtyState'; export {usePagination} from './hooks/usePagination'; export type {PaginationData} from './hooks/usePagination'; +export {default as useSortableIndexedList} from './hooks/useSortableIndexedList'; export {debounce} from './utils/debounce'; export {confirmIfDirty} from './utils/modals'; diff --git a/apps/admin-x-settings/src/api/newsletters.ts b/apps/admin-x-settings/src/api/newsletters.ts index b59fed8f10..63f8b43444 100644 --- a/apps/admin-x-settings/src/api/newsletters.ts +++ b/apps/admin-x-settings/src/api/newsletters.ts @@ -47,6 +47,7 @@ export interface NewslettersResponseType { } const dataType = 'NewslettersResponseType'; +export const newslettersDataType = dataType; export const useBrowseNewsletters = createInfiniteQuery({ dataType, diff --git a/apps/admin-x-settings/src/components/settings/email/Newsletters.tsx b/apps/admin-x-settings/src/components/settings/email/Newsletters.tsx index 69ef92d885..d648686ce0 100644 --- a/apps/admin-x-settings/src/components/settings/email/Newsletters.tsx +++ b/apps/admin-x-settings/src/components/settings/email/Newsletters.tsx @@ -7,7 +7,9 @@ import useQueryParams from '../../../hooks/useQueryParams'; import useRouting from '../../../hooks/useRouting'; import {APIError} from '../../../utils/errors'; import {Button, ConfirmationModal, TabView, withErrorBoundary} from '@tryghost/admin-x-design-system'; -import {useBrowseNewsletters, useVerifyNewsletterEmail} from '../../../api/newsletters'; +import {InfiniteData, useQueryClient} from '@tanstack/react-query'; +import {Newsletter, NewslettersResponseType, newslettersDataType, useBrowseNewsletters, useEditNewsletter, useVerifyNewsletterEmail} from '../../../api/newsletters'; +import {arrayMove} from '@dnd-kit/sortable'; const NavigateToNewsletter = ({id, children}: {id: string; children: ReactNode}) => { const modal = useModal(); @@ -25,12 +27,20 @@ const Newsletters: React.FC<{ keywords: string[] }> = ({keywords}) => { updateRoute('newsletters/new'); }; const [selectedTab, setSelectedTab] = useState('active-newsletters'); - const {data: {newsletters, meta, isEnd} = {}, fetchNextPage} = useBrowseNewsletters(); + const {data: {newsletters: apiNewsletters, meta, isEnd} = {}, fetchNextPage} = useBrowseNewsletters(); + const {mutateAsync: editNewsletter} = useEditNewsletter(); + const queryClient = useQueryClient(); const verifyEmailToken = useQueryParams().getParam('verifyEmail'); const {mutateAsync: verifyEmail} = useVerifyNewsletterEmail(); const handleError = useHandleError(); + const [newsletters, setNewsletters] = useState(apiNewsletters || []); + + useEffect(() => { + setNewsletters(apiNewsletters || []); + }, [apiNewsletters]); + useEffect(() => { if (!verifyEmailToken) { return; @@ -72,16 +82,55 @@ const Newsletters: React.FC<{ keywords: string[] }> = ({keywords}) => { }} /> ); + const sortedActiveNewsletters = newsletters.filter(n => n.status === 'active').sort((a, b) => a.sort_order - b.sort_order) || []; + const archivedNewsletters = newsletters.filter(newsletter => newsletter.status !== 'active'); + + const onSort = async (id: string, overId?: string) => { + const fromIndex = sortedActiveNewsletters.findIndex(newsletter => newsletter.id === id); + const toIndex = sortedActiveNewsletters.findIndex(newsletter => newsletter.id === overId) || 0; + const newSortOrder = arrayMove(sortedActiveNewsletters, fromIndex, toIndex); + + const updatedActiveNewsletters = newSortOrder.map((newsletter, index) => ( + newsletter.sort_order === index ? null : {...newsletter, sort_order: index} + )).filter((newsletter): newsletter is Newsletter => !!newsletter); + + const updatedArchivedNewsletters = archivedNewsletters.map((newsletter, index) => ( + newsletter.sort_order === index + sortedActiveNewsletters.length ? null : {...newsletter, sort_order: index} + )).filter((newsletter): newsletter is Newsletter => !!newsletter); + + const orderUpdatedNewsletters = [...updatedActiveNewsletters, ...updatedArchivedNewsletters].sort((a, b) => a.sort_order - b.sort_order); + + // Set the new order in local state and cache first so that the UI updates immediately + setNewsletters(newsletters.map(newsletter => orderUpdatedNewsletters.find(n => n.id === newsletter.id) || newsletter)); + queryClient.setQueriesData>([newslettersDataType], (currentData) => { + if (!currentData) { + return; + } + + return { + ...currentData, + pages: currentData.pages.map(page => ({ + ...page, + newsletters: page.newsletters.map(newsletter => orderUpdatedNewsletters.find(n => n.id === newsletter.id) || newsletter) + })) + }; + }); + + for (const newsletter of orderUpdatedNewsletters) { + await editNewsletter(newsletter); + } + }; + const tabs = [ { id: 'active-newsletters', title: 'Active', - contents: ( newsletter.status === 'active') || []} />) + contents: () }, { id: 'archived-newsletters', title: 'Archived', - contents: ( newsletter.status !== 'active') || []} />) + contents: () } ]; diff --git a/apps/admin-x-settings/src/components/settings/email/newsletters/NewslettersList.tsx b/apps/admin-x-settings/src/components/settings/email/newsletters/NewslettersList.tsx index 0ee272a927..b64af3fbe4 100644 --- a/apps/admin-x-settings/src/components/settings/email/newsletters/NewslettersList.tsx +++ b/apps/admin-x-settings/src/components/settings/email/newsletters/NewslettersList.tsx @@ -1,13 +1,52 @@ import React from 'react'; import useRouting from '../../../../hooks/useRouting'; -import {Button, NoValueLabel, Table, TableCell, TableRow} from '@tryghost/admin-x-design-system'; +import {Button, DragIndicator, NoValueLabel, SortableItemContainerProps, SortableList, Table, TableCell, TableRow} from '@tryghost/admin-x-design-system'; import {Newsletter} from '../../../../api/newsletters'; import {numberWithCommas} from '../../../../utils/helpers'; interface NewslettersListProps { - newsletters: Newsletter[] + newsletters: Newsletter[]; + isSortable?: boolean; + onSort?: (activeId: string, overId?: string) => void; } +const NewsletterItemContainer: React.FC> = ({ + id, + setRef, + isDragging, + style, + children, + ...props +}) => { + const {updateRoute} = useRouting(); + + const showDetails = () => { + updateRoute({route: `newsletters/${id}`}); + }; + + const container = ( + } + className={isDragging ? 'opacity-75' : ''} + style={style} + hideActions + onClick={showDetails} + > + {(props.dragHandleAttributes || isDragging) && + + } + {children} + + ); + + if (isDragging) { + return {container}
; + } else { + return container; + } +}; + const NewsletterItem: React.FC<{newsletter: Newsletter}> = ({newsletter}) => { const {updateRoute} = useRouting(); @@ -16,33 +55,45 @@ const NewsletterItem: React.FC<{newsletter: Newsletter}> = ({newsletter}) => { }; return ( - } hideActions onClick={showDetails}> - + <> +
{newsletter.name} {newsletter.description || 'No description'}
- +
{numberWithCommas(newsletter.count?.active_members || 0) } Subscribers
- +
{numberWithCommas(newsletter.count?.posts || 0)} Delivered
-
+ ); }; -const NewslettersList: React.FC = ({newsletters}) => { - if (newsletters.length) { +const NewslettersList: React.FC = ({newsletters, isSortable, onSort}) => { + if (newsletters.length && isSortable) { + return } + items={newsletters} + renderItem={item => } + wrapper={Table} + onMove={(id, overId) => onSort?.(id, overId)} + />; + } else if (newsletters.length) { return - {newsletters.map(newsletter => )} + {newsletters.map(newsletter => ( + + + + ))}
; } else { return 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 0ea4664120..5b8edac3a3 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 @@ -5,8 +5,7 @@ import useForm, {ErrorMessages} from '../../../../hooks/useForm'; import useHandleError from '../../../../utils/api/handleError'; import useRouting from '../../../../hooks/useRouting'; import useSettingGroup from '../../../../hooks/useSettingGroup'; -import useSortableIndexedList from '../../../../hooks/useSortableIndexedList'; -import {Button, ButtonProps, ConfirmationModal, CurrencyField, Form, Heading, Icon, Modal, Select, SortableList, TextField, Toggle, URLTextField, showToast} from '@tryghost/admin-x-design-system'; +import {Button, ButtonProps, ConfirmationModal, CurrencyField, Form, Heading, Icon, Modal, Select, SortableList, TextField, Toggle, URLTextField, showToast, useSortableIndexedList} from '@tryghost/admin-x-design-system'; import {RoutingModalProps} from '../../../providers/RoutingProvider'; import {Tier, useAddTier, useBrowseTiers, useEditTier} from '../../../../api/tiers'; import {currencies, currencySelectGroups, validateCurrencyAmount} from '../../../../utils/currency'; diff --git a/apps/admin-x-settings/src/hooks/site/useNavigationEditor.tsx b/apps/admin-x-settings/src/hooks/site/useNavigationEditor.tsx index 1605b38bbe..a5c8b7ce8b 100644 --- a/apps/admin-x-settings/src/hooks/site/useNavigationEditor.tsx +++ b/apps/admin-x-settings/src/hooks/site/useNavigationEditor.tsx @@ -1,5 +1,5 @@ -import useSortableIndexedList from '../useSortableIndexedList'; import validator from 'validator'; +import {useSortableIndexedList} from '@tryghost/admin-x-design-system'; export type NavigationItem = { label: string;