Admin X Tiers settings refinements (#17322)

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

Soratable list needed stylistic updates and simplifications both in navigation and benefits list.
This commit is contained in:
Peter Zimon 2023-07-12 16:51:14 +02:00 committed by GitHub
parent 3960847ab6
commit a725f9d6c2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 152 additions and 76 deletions

View File

@ -1,5 +1,6 @@
import Heading from './Heading';
import Hint from './Hint';
import ListHeading from './ListHeading';
import React from 'react';
import Separator from './Separator';
import clsx from 'clsx';
@ -33,28 +34,11 @@ const List: React.FC<ListProps> = ({title, titleSeparator, children, actions, hi
className
);
let heading;
if (title) {
const headingTitle = <Heading grey={true} level={6}>{title}</Heading>;
heading = actions ? (
<div className='flex items-end justify-between gap-2'>
{headingTitle}
{actions}
</div>
) : headingTitle;
}
return (
<>
{pageTitle && <Heading>{pageTitle}</Heading>}
<section className={listClasses}>
{(!pageTitle && title) &&
<div className='flex flex-col items-stretch gap-1'>
{heading}
{titleSeparator && <Separator />}
</div>
}
<ListHeading actions={actions} title={title} titleSeparator={!pageTitle && titleSeparator} />
<div className='flex flex-col'>
{children}
</div>

View File

@ -0,0 +1,36 @@
import Heading from './Heading';
import React from 'react';
import Separator from './Separator';
interface ListHeadingProps {
title?: React.ReactNode;
actions?: React.ReactNode;
titleSeparator?: boolean;
}
const ListHeading: React.FC<ListHeadingProps> = ({title, actions, titleSeparator}) => {
let heading;
if (title) {
const headingTitle = <Heading grey={true} level={6}>{title}</Heading>;
heading = actions ? (
<div className='flex items-end justify-between gap-2'>
{headingTitle}
{actions}
</div>
) : headingTitle;
}
if (heading || titleSeparator) {
return (
<div className='flex flex-col items-stretch gap-1'>
{heading}
{titleSeparator && <Separator />}
</div>
);
}
return <></>;
};
export default ListHeading;

View File

@ -22,7 +22,7 @@ const Wrapper = (props: SortableListProps<any> & {updateArgs: (args: Partial<Sor
};
const meta = {
title: 'Global / Sortable List',
title: 'Global / List / Sortable',
component: SortableList,
tags: ['autodocs'],
render: function Component(args) {
@ -36,8 +36,11 @@ type Story = StoryObj<typeof SortableList>;
export const Default: Story = {
args: {
title: 'Sortable list',
titleSeparator: true,
items: [{id: 'first item'}, {id: 'second item'}, {id: 'third item'}],
renderItem: item => <span className="self-center">{item.id}</span>
renderItem: item => <span className="self-center">{item.id}</span>,
hint: 'Drag items to order'
}
};

View File

@ -1,5 +1,8 @@
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';
@ -10,23 +13,36 @@ export interface SortableItemContainerProps {
isDragging: boolean;
dragHandleAttributes?: DraggableAttributes;
dragHandleListeners?: Record<string, Function>;
dragHandleClass?: string;
style?: React.CSSProperties;
children: ReactNode
children: ReactNode;
separator?: boolean;
}
const DefaultContainer: React.FC<SortableItemContainerProps> = ({setRef, isDragging, dragHandleAttributes, dragHandleListeners, style, children}) => (
const DefaultContainer: React.FC<SortableItemContainerProps> = ({
setRef,
isDragging,
dragHandleAttributes,
dragHandleListeners,
dragHandleClass,
style,
separator,
children
}) => (
<div
ref={setRef}
className={clsx(
'flex w-full items-start gap-3 rounded border-b border-grey-200 bg-white py-4 hover:bg-grey-100',
'group flex w-full items-center gap-3 bg-white py-1',
separator && 'border-b border-grey-200',
isDragging && 'opacity-75'
)}
style={style}
>
<button
className={clsx(
'ml-2 h-7 pl-2',
isDragging ? 'cursor-grabbing' : 'cursor-grab'
'h-7 opacity-50 group-hover:opacity-100',
isDragging ? 'cursor-grabbing' : 'cursor-grab',
dragHandleClass
)}
type='button'
{...dragHandleAttributes}
@ -41,8 +57,10 @@ const DefaultContainer: React.FC<SortableItemContainerProps> = ({setRef, isDragg
const SortableItem: React.FC<{
id: string
children: ReactNode;
separator?: boolean;
dragHandleClass?: string;
container: (props: SortableItemContainerProps) => ReactNode;
}> = ({id, children, container}) => {
}> = ({id, children, separator, dragHandleClass, container}) => {
const {
attributes,
listeners,
@ -59,6 +77,8 @@ const SortableItem: React.FC<{
return container({
setRef: setNodeRef,
isDragging: false,
separator: separator,
dragHandleClass: dragHandleClass,
dragHandleAttributes: attributes,
dragHandleListeners: listeners,
style,
@ -67,17 +87,27 @@ const SortableItem: React.FC<{
};
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;
title?: string;
titleSeparator?: boolean;
hint?: React.ReactNode;
items: Item[];
itemSeparator?: boolean;
dragHandleClass?: string;
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}>({
title,
titleSeparator,
hint,
items,
itemSeparator = true,
dragHandleClass,
onMove,
renderItem,
container = props => <DefaultContainer {...props} />,
@ -85,28 +115,41 @@ const SortableList = <Item extends {id: string}>({
}: SortableListProps<Item>) => {
const [draggingId, setDraggingId] = useState<string | null>(null);
if (!items.length) {
return <></>;
}
return (
<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)}
>
<SortableContext
items={items}
strategy={verticalListSortingStrategy}
{title && <Heading level={6} separator={titleSeparator} grey>{title}</Heading>}
<div className={`${title && titleSeparator ? '-mt-2' : ''}`}>
<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} dragHandleClass={dragHandleClass} id={item.id} separator={itemSeparator}>{renderItem(item)}</SortableItem>
))}
</SortableContext>
<DragOverlay>
{draggingId ? container({
isDragging: true,
children: renderItem(items.find(({id}) => id === draggingId)!)
}) : null}
</DragOverlay>
</DndContext>
</div>
{hint &&
<>
{!itemSeparator && <Separator />}
<Hint>{hint}</Hint>
</>
}
</div>
);
};

View File

@ -1,10 +1,11 @@
import Heading from '../Heading';
import React from 'react';
import Separator from '../Separator';
import clsx from 'clsx';
interface FormProps {
title?: string;
gap?: 'sm' | 'md' | 'lg';
grouped?: boolean;
gap?: 'none' | 'sm' | 'md' | 'lg';
marginTop?: boolean;
marginBottom?: boolean;
children?: React.ReactNode;
@ -15,6 +16,7 @@ interface FormProps {
*/
const Form: React.FC<FormProps> = ({
title,
grouped = false,
gap = 'md',
marginTop = false,
marginBottom = true,
@ -45,14 +47,19 @@ const Form: React.FC<FormProps> = ({
);
}
if (grouped) {
classes = clsx(
classes,
'rounded-sm border border-grey-200 p-7'
);
}
return (
<div className={classes}>
{title &&
(<div className='-mb-4'>
<div className='text-sm font-semibold text-grey-800'>{title}</div>
<Separator />
</div>)}
{children}
<div className={!title ? classes : ''}>
{title && <Heading className={`${grouped && 'pb-1'}`} level={6} separator={!grouped} grey>{title}</Heading>}
<div className={title ? classes : ''}>
{children}
</div>
</div>
);
};

View File

@ -1,6 +1,7 @@
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 Icon from '../../../../admin-x-ds/global/Icon';
import Modal from '../../../../admin-x-ds/global/modal/Modal';
import NiceModal, {useModal} from '@ebay/nice-modal-react';
import React from 'react';
@ -72,8 +73,8 @@ const TierDetailModal: React.FC<TierDetailModalProps> = ({tier}) => {
}}
>
<div className='mt-8 flex items-start gap-10'>
<div className='flex grow flex-col gap-10'>
<Form title='Basic'>
<div className='flex grow flex-col gap-5'>
<Form title='Basic' grouped>
<TextField
placeholder='Bronze'
title='Name'
@ -118,26 +119,31 @@ const TierDetailModal: React.FC<TierDetailModalProps> = ({tier}) => {
</div>
</Form>
<Form title='Benefits'>
<Form gap='none' title='Benefits' grouped>
<SortableList
items={benefits.items}
renderItem={({id, item}) => <div className='flex'>
itemSeparator={false}
renderItem={({id, item}) => <div className='relative flex w-full items-center gap-5'>
<div className='absolute left-[-32px] top-[7px] flex h-6 w-6 items-center justify-center bg-white group-hover:hidden'><Icon name='check' size='sm' /></div>
<TextField
placeholder='Expert analysis'
className='grow border-b border-grey-500 py-2 focus:border-grey-800 group-hover:border-grey-600'
value={item}
unstyled
onChange={e => benefits.updateItem(id, e.target.value)}
/>
<Button icon='trash' onClick={() => benefits.removeItem(id)} />
<Button className='absolute right-0 top-1' icon='trash' iconColorClass='opacity-0 group-hover:opacity-100' size='sm' onClick={() => benefits.removeItem(id)} />
</div>}
onMove={benefits.moveItem}
/>
<div className="flex">
<div className="relative flex items-center gap-3">
<Icon name='check' size='sm' />
<TextField
placeholder='Expert analysis'
className='grow'
placeholder='New benefit'
value={benefits.newItem}
onChange={e => benefits.setNewItem(e.target.value)}
/>
<Button icon="add" onClick={() => benefits.addItem()} />
<Button className='absolute right-0 top-1' color='green' icon="add" iconColorClass='text-white' size='sm' onClick={() => benefits.addItem()} />
</div>
</Form>
</div>

View File

@ -8,17 +8,17 @@ const NavigationEditForm: React.FC<{
baseUrl: string;
navigation: NavigationEditor;
}> = ({baseUrl, navigation}) => {
return <div className="w-full">
return <div className="w-full pt-2">
<SortableList
items={navigation.items}
itemSeparator={false}
renderItem={item => (
<NavigationItemEditor
action={<Button className='mr-2' icon="trash" size='sm' onClick={() => navigation.removeItem(item.id)} />}
action={<Button className='self-center' icon="trash" size='sm' onClick={() => navigation.removeItem(item.id)} />}
baseUrl={baseUrl}
clearError={key => navigation.clearError(item.id, key)}
item={item}
updateItem={updates => navigation.updateItem(item.id, updates)}
unstyled
/>
)}
onMove={navigation.moveItem}
@ -26,12 +26,11 @@ const NavigationEditForm: React.FC<{
<NavigationItemEditor
action={<Button className='self-center' color='green' data-testid="add-button" icon="add" iconColorClass='text-white' size='sm' onClick={navigation.addItem} />}
baseUrl={baseUrl}
className="p-2 pl-9"
className="mt-1 pl-7"
clearError={key => navigation.clearError(navigation.newItem.id, key)}
data-testid="new-navigation-item"
item={navigation.newItem}
labelPlaceholder="New item label"
textFieldClasses="w-full ml-2"
updateItem={navigation.setNewItem}
/>
</div>;

View File

@ -21,10 +21,9 @@ const NavigationItemEditor: React.FC<NavigationItemEditorProps> = ({baseUrl, ite
<div className="flex flex-1 pt-1">
<TextField
className={textFieldClasses}
containerClassName="w-full"
containerClassName="grow"
error={!!item.errors.label}
hint={item.errors.label}
hintClassName="px-2"
placeholder={labelPlaceholder}
title='Label'
unstyled={unstyled}
@ -38,10 +37,9 @@ const NavigationItemEditor: React.FC<NavigationItemEditorProps> = ({baseUrl, ite
<URLTextField
baseUrl={baseUrl}
className={textFieldClasses}
containerClassName="w-full"
containerClassName="grow"
error={!!item.errors.url}
hint={item.errors.url}
hintClassName="px-2"
title='URL'
unstyled={unstyled}
value={item.url}