mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-24 11:22:19 +03:00
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:
parent
3960847ab6
commit
a725f9d6c2
@ -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>
|
||||
|
36
apps/admin-x-settings/src/admin-x-ds/global/ListHeading.tsx
Normal file
36
apps/admin-x-settings/src/admin-x-ds/global/ListHeading.tsx
Normal 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;
|
@ -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'
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
|
@ -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>;
|
||||
|
@ -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}
|
||||
|
Loading…
Reference in New Issue
Block a user