From 7e5cad8e11a5300ca449938046d2e413b4f680a1 Mon Sep 17 00:00:00 2001 From: Jono M Date: Tue, 14 Nov 2023 09:09:31 +0000 Subject: [PATCH] Added sorting to newsletters list (#18963) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refs https://github.com/TryGhost/Product/issues/4128 --- ### 🤖 Generated by Copilot at 9e300ac This pull request enhances the `SortableList` component and its related hooks in the design system, and applies them to the newsletters and tiers settings in the admin app. It also exports and imports some types and constants to improve code reusability and consistency. --------- Co-authored-by: Peter Zimon --- .../src/global/SortableList.tsx | 75 +++++++++++-------- .../src/global/Table.stories.tsx | 62 ++++++++++++++- .../src/global/TableRow.tsx | 9 ++- .../src/hooks/useSortableIndexedList.tsx | 0 apps/admin-x-design-system/src/index.ts | 5 +- apps/admin-x-settings/src/api/newsletters.ts | 1 + .../components/settings/email/Newsletters.tsx | 57 +++++++++++++- .../email/newsletters/NewslettersList.tsx | 71 +++++++++++++++--- .../membership/tiers/TierDetailModal.tsx | 3 +- .../src/hooks/site/useNavigationEditor.tsx | 2 +- 10 files changed, 229 insertions(+), 56 deletions(-) rename apps/{admin-x-settings => admin-x-design-system}/src/hooks/useSortableIndexedList.tsx (100%) 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;