0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-04-01 02:41:39 -05:00

Added sorting to newsletters list (#18963)

refs https://github.com/TryGhost/Product/issues/4128

---

<!-- Leave the line below if you'd like GitHub Copilot to generate a
summary from your commit -->
<!--
copilot:summary
-->
### <samp>🤖 Generated by Copilot at 9e300ac</samp>

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 <peter.zimon@gmail.com>
This commit is contained in:
Jono M 2023-11-14 09:09:31 +00:00 committed by GitHub
parent 5eb4e3330c
commit 7e5cad8e11
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 229 additions and 56 deletions

View file

@ -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<SortableItemContainerProps, 'isDragging' | 'dragHandleAttributes' | 'dragHandleListeners' | 'dragHandleClass'> & React.HTMLAttributes<HTMLButtonElement>
export const DragIndicator: React.FC<DragIndicatorProps> = ({isDragging, dragHandleAttributes, dragHandleListeners, dragHandleClass, className, ...props}) => (
<button
className={clsx(
'opacity-50 group-hover:opacity-100',
isDragging ? 'cursor-grabbing' : 'cursor-grab',
dragHandleClass,
className
)}
type='button'
{...dragHandleAttributes}
{...dragHandleListeners}
{...props}
>
<Icon colorClass='text-grey-500' name='hamburger' size='sm' />
</button>
);
const DefaultContainer: React.FC<SortableItemContainerProps> = ({
setRef,
isDragging,
dragHandleAttributes,
dragHandleListeners,
dragHandleClass,
style,
separator,
children
children,
...props
}) => (
<div
ref={setRef}
@ -40,18 +58,7 @@ const DefaultContainer: React.FC<SortableItemContainerProps> = ({
)}
style={style}
>
<button
className={clsx(
'h-7 opacity-50 group-hover:opacity-100',
isDragging ? 'cursor-grabbing' : 'cursor-grab',
dragHandleClass
)}
type='button'
{...dragHandleAttributes}
{...dragHandleListeners}
>
<Icon colorClass='text-grey-500' name='hamburger' size='sm' />
</button>
<DragIndicator className='h-7' isDragging={isDragging} {...props} />
{children}
</div>
);
@ -77,6 +84,7 @@ const SortableItem: React.FC<{
};
return container({
id,
setRef: setNodeRef,
isDragging: false,
separator: separator,
@ -98,6 +106,8 @@ export interface SortableListProps<Item extends {id: string}> 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 = <Item extends {id: string}>({
onMove,
renderItem,
container = props => <DefaultContainer {...props} />,
wrapper: Wrapper = React.Fragment,
dragOverlayWrapper,
...props
}: SortableListProps<Item>) => {
const [draggingId, setDraggingId] = useState<string | null>(null);
@ -130,16 +142,19 @@ const SortableList = <Item extends {id: string}>({
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}
>
{items.map(item => (
<SortableItem key={item.id} container={container} dragHandleClass={dragHandleClass} id={item.id} separator={itemSeparator}>{renderItem(item)}</SortableItem>
))}
</SortableContext>
<DragOverlay>
<Wrapper>
<SortableContext
items={items}
strategy={verticalListSortingStrategy}
>
{items.map(item => (
<SortableItem key={item.id} container={container} dragHandleClass={dragHandleClass} id={item.id} separator={itemSeparator}>{renderItem(item)}</SortableItem>
))}
</SortableContext>
</Wrapper>
<DragOverlay wrapperElement={dragOverlayWrapper}>
{draggingId ? container({
id: draggingId,
isDragging: true,
children: renderItem(items.find(({id}) => id === draggingId)!)
}) : null}

View file

@ -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
}
};
};
// Components for Sortable example
const SortableContainer: React.FC<Partial<SortableItemContainerProps>> = ({setRef, isDragging, style, children, ...props}) => {
const container = (
<TableRow ref={setRef} className={isDragging ? 'opacity-75' : ''} style={style} hideActions>
{(props.dragHandleAttributes || isDragging) && <TableCell className='w-10'>
<DragIndicator className='h-5' isDragging={isDragging || false} {...props} />
</TableCell>}
{children}
</TableRow>
);
if (isDragging) {
return <Table>{container}</Table>;
} else {
return container;
}
};
const SortableItem: React.FC<{id: string; item: string}> = ({id, item}) => {
return (
<>
<TableCell className='whitespace-nowrap'>{id}.</TableCell>
<TableCell className='w-full'>{item}</TableCell>
</>
);
};
const SortableTable = () => {
const list = useSortableIndexedList({
items: ['First', 'Second'],
setItems: () => {},
blank: '',
canAddNewItem: () => false
});
return <SortableList
container={props => <SortableContainer {...props} />}
items={list.items}
renderItem={item => <SortableItem {...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: () => <SortableTable />
};

View file

@ -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<TableRowProps> = ({id, action, hideActions, className, testId, separator, bgOnHover = true, onClick, children}) => {
const TableRow = forwardRef<HTMLTableRowElement, TableRowProps>(function TableRow({id, action, hideActions, className, style, testId, separator, bgOnHover = true, onClick, children}, ref) {
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
onClick?.(e);
};
@ -33,7 +34,7 @@ const TableRow: React.FC<TableRowProps> = ({id, action, hideActions, className,
);
return (
<tr className={tableRowClasses} data-testid={testId} id={id} onClick={handleClick}>
<tr ref={ref} className={tableRowClasses} data-testid={testId} id={id} style={style} onClick={handleClick}>
{children}
{action &&
<td className={`w-[1%] whitespace-nowrap p-0 hover:cursor-pointer`}>
@ -44,6 +45,6 @@ const TableRow: React.FC<TableRowProps> = ({id, action, hideActions, className,
}
</tr>
);
};
});
export default TableRow;

View file

@ -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';

View file

@ -47,6 +47,7 @@ export interface NewslettersResponseType {
}
const dataType = 'NewslettersResponseType';
export const newslettersDataType = dataType;
export const useBrowseNewsletters = createInfiniteQuery<NewslettersResponseType & {isEnd: boolean}>({
dataType,

View file

@ -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<Newsletter[]>(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<InfiniteData<NewslettersResponseType>>([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: (<NewslettersList newsletters={newsletters?.filter(newsletter => newsletter.status === 'active') || []} />)
contents: (<NewslettersList newsletters={sortedActiveNewsletters} isSortable onSort={onSort} />)
},
{
id: 'archived-newsletters',
title: 'Archived',
contents: (<NewslettersList newsletters={newsletters?.filter(newsletter => newsletter.status !== 'active') || []} />)
contents: (<NewslettersList newsletters={archivedNewsletters} />)
}
];

View file

@ -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<Partial<SortableItemContainerProps>> = ({
id,
setRef,
isDragging,
style,
children,
...props
}) => {
const {updateRoute} = useRouting();
const showDetails = () => {
updateRoute({route: `newsletters/${id}`});
};
const container = (
<TableRow
ref={setRef}
action={<Button color='green' label='Edit' link onClick={showDetails} />}
className={isDragging ? 'opacity-75' : ''}
style={style}
hideActions
onClick={showDetails}
>
{(props.dragHandleAttributes || isDragging) && <TableCell className='w-10 !align-middle' >
<DragIndicator className='h-10' isDragging={isDragging || false} {...props} />
</TableCell>}
{children}
</TableRow>
);
if (isDragging) {
return <Table>{container}</Table>;
} 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 (
<TableRow action={<Button color='green' label='Edit' link onClick={showDetails} />} hideActions onClick={showDetails}>
<TableCell onClick={showDetails}>
<>
<TableCell className='w-full' onClick={showDetails}>
<div className={`flex grow flex-col`}>
<span className='font-medium'>{newsletter.name}</span>
<span className='mt-0.5 text-xs leading-tight text-grey-700'>{newsletter.description || 'No description'}</span>
</div>
</TableCell>
<TableCell className='hidden md:!visible md:!table-cell' onClick={showDetails}>
<TableCell className='hidden md:!visible md:!table-cell md:min-w-[11rem]' onClick={showDetails}>
<div className={`flex grow flex-col`}>
<span>{numberWithCommas(newsletter.count?.active_members || 0) }</span>
<span className='mt-0.5 whitespace-nowrap text-xs leading-tight text-grey-700'>Subscribers</span>
</div>
</TableCell>
<TableCell className='hidden md:!visible md:!table-cell' onClick={showDetails}>
<TableCell className='hidden md:!visible md:!table-cell md:min-w-[11rem]' onClick={showDetails}>
<div className={`flex grow flex-col`}>
<span>{numberWithCommas(newsletter.count?.posts || 0)}</span>
<span className='mt-0.5 whitespace-nowrap text-xs leading-tight text-grey-700'>Delivered</span>
</div>
</TableCell>
</TableRow>
</>
);
};
const NewslettersList: React.FC<NewslettersListProps> = ({newsletters}) => {
if (newsletters.length) {
const NewslettersList: React.FC<NewslettersListProps> = ({newsletters, isSortable, onSort}) => {
if (newsletters.length && isSortable) {
return <SortableList
container={props => <NewsletterItemContainer {...props} />}
items={newsletters}
renderItem={item => <NewsletterItem newsletter={item} />}
wrapper={Table}
onMove={(id, overId) => onSort?.(id, overId)}
/>;
} else if (newsletters.length) {
return <Table>
{newsletters.map(newsletter => <NewsletterItem key={newsletter.id} newsletter={newsletter} />)}
{newsletters.map(newsletter => (
<NewsletterItemContainer id={newsletter.id}>
<NewsletterItem newsletter={newsletter} />
</NewsletterItemContainer>
))}
</Table>;
} else {
return <NoValueLabel icon='mail-block'>

View file

@ -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';

View file

@ -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;