Updated history staff search (#18108)

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

- newsletter searchfield was showing a "Clear" button once it had content. It needed to be using standard React dropdown instead
- modals needed an option to make the header sticky so it's more versatile. It's now used in the History modal

---------

Co-authored-by: Jono Mingard <reason.koan@gmail.com>
This commit is contained in:
Peter Zimon 2023-09-20 13:28:29 +03:00 committed by GitHub
parent 474923ba8a
commit b4bcc193a4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 248 additions and 86 deletions

View File

@ -2,9 +2,11 @@ import CreatableSelect from 'react-select/creatable';
import Heading from '../Heading';
import Hint from '../Hint';
import React, {useId, useMemo} from 'react';
import clsx from 'clsx';
import {DropdownIndicatorProps, GroupBase, MultiValue, OptionProps, OptionsOrGroups, default as ReactSelect, components} from 'react-select';
export type MultiSelectColor = 'grey' | 'black' | 'green' | 'pink';
type FieldStyles = 'text' | 'dropdown';
export type MultiSelectOption = {
value: string;
@ -20,6 +22,8 @@ interface MultiSelectProps {
error?: boolean;
placeholder?: string;
color?: MultiSelectColor
size?: 'sm' | 'md';
fieldStyle?: FieldStyles;
hint?: string;
onChange: (selected: MultiValue<MultiSelectOption>) => void;
canCreate?: boolean;
@ -40,11 +44,16 @@ const multiValueColor = (color?: MultiSelectColor) => {
}
};
const DropdownIndicator: React.FC<DropdownIndicatorProps<MultiSelectOption, true> & {clearBg: boolean}> = ({clearBg, ...props}) => (
<components.DropdownIndicator {...props}>
<div className={`absolute top-[14px] block h-2 w-2 rotate-45 border-[1px] border-l-0 border-t-0 border-grey-900 content-[''] dark:border-grey-400 ${clearBg ? 'right-0' : 'right-4'} `}></div>
</components.DropdownIndicator>
);
const DropdownIndicator: React.FC<DropdownIndicatorProps<MultiSelectOption, true> & {clearBg: boolean, fieldStyle: FieldStyles}> = ({clearBg, fieldStyle, ...props}) => {
if (fieldStyle === 'text') {
return <></>;
}
return (
<components.DropdownIndicator {...props}>
<div className={`absolute top-[14px] block h-2 w-2 rotate-45 border-[1px] border-l-0 border-t-0 border-grey-900 content-[''] dark:border-grey-400 ${clearBg ? 'right-0' : 'right-4'} `}></div>
</components.DropdownIndicator>
);
};
const Option: React.FC<OptionProps<MultiSelectOption, true>> = ({children, ...optionProps}) => (
<components.Option {...optionProps}>
@ -58,6 +67,8 @@ const MultiSelect: React.FC<MultiSelectProps> = ({
error = false,
placeholder,
color = 'grey',
size = 'md',
fieldStyle = 'dropdown',
hint = '',
options,
values,
@ -67,12 +78,27 @@ const MultiSelect: React.FC<MultiSelectProps> = ({
}) => {
const id = useId();
const controlClasses = clsx(
size === 'sm' ? 'min-h-[36px] py-1 text-sm' : 'min-h-[40px] py-2',
'w-full cursor-pointer appearance-none border-b dark:text-white',
fieldStyle === 'dropdown' ? 'cursor-pointer' : 'cursor-text',
!clearBg && 'bg-grey-75 px-[10px] dark:bg-grey-950',
'outline-none',
error ? 'border-red' : 'border-grey-500 hover:border-grey-700 dark:border-grey-800 dark:hover:border-grey-700',
(title && !clearBg) && 'mt-2'
);
const optionClasses = clsx(
size === 'sm' ? 'text-sm' : '',
'px-3 py-[6px] hover:cursor-pointer hover:bg-grey-100 dark:text-white dark:hover:bg-grey-900'
);
const customClasses = {
control: `w-full cursor-pointer appearance-none min-h-[40px] border-b dark:text-white ${!clearBg && 'bg-grey-75 dark:bg-grey-950 px-[10px]'} py-2 outline-none ${error ? 'border-red' : 'border-grey-500 hover:border-grey-700 dark:border-grey-800 dark:hover:border-grey-700'} ${(title && !clearBg) && 'mt-2'}`,
control: controlClasses,
valueContainer: 'gap-1',
placeHolder: 'text-grey-500 dark:text-grey-800',
menu: 'shadow py-2 rounded-b z-50 bg-white dark:bg-black dark:border dark:border-grey-900',
option: 'hover:cursor-pointer hover:bg-grey-100 px-3 py-[6px] dark:text-white dark:hover:bg-grey-900',
menu: 'shadow py-2 rounded-b z-[10000] bg-white dark:bg-black dark:border dark:border-grey-900',
option: optionClasses,
multiValue: (optionColor?: MultiSelectColor) => `rounded-sm items-center text-[14px] py-px pl-2 pr-1 gap-1.5 ${multiValueColor(optionColor || color)}`,
noOptionsMessage: 'p-3 text-grey-600',
groupHeading: 'py-[6px] px-3 text-2xs font-semibold uppercase tracking-wide text-grey-700'
@ -81,8 +107,8 @@ const MultiSelect: React.FC<MultiSelectProps> = ({
const dropdownIndicatorComponent = useMemo(() => {
// TODO: fix "Component definition is missing display name"
// eslint-disable-next-line react/display-name
return (ddiProps: DropdownIndicatorProps<MultiSelectOption, true>) => <DropdownIndicator {...ddiProps} clearBg={clearBg} />;
}, [clearBg]);
return (ddiProps: DropdownIndicatorProps<MultiSelectOption, true>) => <DropdownIndicator {...ddiProps} clearBg={clearBg} fieldStyle={fieldStyle} />;
}, [clearBg, fieldStyle]);
return (
<div className='flex flex-col'>

View File

@ -1,8 +1,9 @@
import React, {useId, useMemo} from 'react';
import ReactSelect, {DropdownIndicatorProps, OptionProps, Props, components} from 'react-select';
import ReactSelect, {ClearIndicatorProps, DropdownIndicatorProps, OptionProps, Props, components} from 'react-select';
import Heading from '../Heading';
import Hint from '../Hint';
import Icon from '../Icon';
import clsx from 'clsx';
export interface SelectOption {
@ -27,6 +28,7 @@ export interface SelectControlClasses {
option?: string;
noOptionsMessage?: string;
groupHeading?: string;
clearIndicator?: string;
}
export interface SelectProps extends Props<SelectOption, false> {
@ -54,6 +56,13 @@ const DropdownIndicator: React.FC<DropdownIndicatorProps<SelectOption, false> &
</components.DropdownIndicator>
);
const ClearIndicator: React.FC<ClearIndicatorProps<SelectOption, false>> = props => (
<components.ClearIndicator {...props}>
<Icon className='mr-2' name='close' size='xs' />
{/* <div className={`pr-2 text-xl leading-none text-grey-900 dark:text-grey-400`}>&times;</div> */}
</components.ClearIndicator>
);
const Option: React.FC<OptionProps<SelectOption, false>> = ({children, ...optionProps}) => (
<components.Option {...optionProps}>
<span data-testid="select-option">{children}</span>
@ -98,7 +107,7 @@ const Select: React.FC<SelectProps> = ({
const customClasses = {
control: clsx(
controlClasses?.control,
'min-h-[40px] w-full cursor-pointer appearance-none outline-none dark:text-white',
'min-h-[40px] w-full cursor-pointer appearance-none pr-4 outline-none dark:text-white',
size === 'xs' ? 'py-0 text-xs' : 'py-2',
border && 'border-b',
!clearBg && 'bg-grey-75 px-[10px] dark:bg-grey-950',
@ -114,7 +123,8 @@ const Select: React.FC<SelectProps> = ({
),
option: clsx('px-3 py-[6px] hover:cursor-pointer hover:bg-grey-100 dark:text-white dark:hover:bg-grey-900', controlClasses?.option),
noOptionsMessage: clsx('p-3 text-grey-600', controlClasses?.noOptionsMessage),
groupHeading: clsx('px-3 py-[6px] text-2xs font-semibold uppercase tracking-wide text-grey-700', controlClasses?.groupHeading)
groupHeading: clsx('px-3 py-[6px] text-2xs font-semibold uppercase tracking-wide text-grey-700', controlClasses?.groupHeading),
clearIndicator: clsx('', controlClasses?.clearIndicator)
};
const dropdownIndicatorComponent = useMemo(() => {
@ -143,9 +153,10 @@ const Select: React.FC<SelectProps> = ({
menu: () => customClasses.menu,
option: () => customClasses.option,
noOptionsMessage: () => customClasses.noOptionsMessage,
groupHeading: () => customClasses.groupHeading
groupHeading: () => customClasses.groupHeading,
clearIndicator: () => customClasses.clearIndicator
}}
components={{DropdownIndicator: dropdownIndicatorComponent, Option}}
components={{DropdownIndicator: dropdownIndicatorComponent, Option, ClearIndicator}}
inputId={id}
isClearable={false}
options={options}

View File

@ -161,6 +161,20 @@ const longContent = (
</>
);
export const StickyHeader: Story = {
args: {
size: 'md',
stickyHeader: true,
onOk: () => {
alert('Clicked OK!');
},
onCancel: undefined,
title: 'Sticky header',
stickyFooter: true,
children: longContent
}
};
export const StickyFooter: Story = {
args: {
size: 'md',

View File

@ -37,6 +37,7 @@ export interface ModalProps {
backDrop?: boolean;
backDropClick?: boolean;
stickyFooter?: boolean;
stickyHeader?:boolean;
scrolling?: boolean;
dirty?: boolean;
animate?: boolean;
@ -67,6 +68,7 @@ const Modal: React.FC<ModalProps> = ({
backDrop = true,
backDropClick = true,
stickyFooter = false,
stickyHeader = false,
scrolling = true,
dirty = false,
animate = true,
@ -114,6 +116,8 @@ const Modal: React.FC<ModalProps> = ({
let buttons: ButtonProps[] = [];
let footerClasses, contentClasses;
const removeModal = () => {
confirmIfDirty(dirty, () => {
modal.remove();
@ -161,46 +165,120 @@ const Modal: React.FC<ModalProps> = ({
);
let paddingClasses = '';
let headerClasses = clsx(
(!topRightContent || topRightContent === 'close') ? '' : 'flex items-center justify-between gap-5'
);
if (stickyHeader) {
headerClasses = clsx(
headerClasses,
'sticky top-0 z-[200] -mb-4 bg-white !pb-4 dark:bg-black'
);
}
switch (size) {
case 'sm':
modalClasses += ' max-w-[480px] ';
backdropClasses += ' p-4 md:p-[8vmin]';
modalClasses = clsx(
modalClasses,
'max-w-[480px]'
);
backdropClasses = clsx(
backdropClasses,
'p-4 md:p-[8vmin]'
);
paddingClasses = 'p-8';
headerClasses = clsx(
headerClasses,
'-inset-x-8'
);
break;
case 'md':
modalClasses += ' max-w-[720px] ';
backdropClasses += ' p-4 md:p-[8vmin]';
modalClasses = clsx(
modalClasses,
'max-w-[720px]'
);
backdropClasses = clsx(
backdropClasses,
'p-4 md:p-[8vmin]'
);
paddingClasses = 'p-8';
headerClasses = clsx(
headerClasses,
'-inset-x-8'
);
break;
case 'lg':
modalClasses += ' max-w-[1020px] ';
backdropClasses += ' p-4 md:p-[4vmin]';
modalClasses = clsx(
modalClasses,
'max-w-[1020px]'
);
backdropClasses = clsx(
backdropClasses,
'p-4 md:p-[4vmin]'
);
paddingClasses = 'p-8';
headerClasses = clsx(
headerClasses,
'-inset-x-8'
);
break;
case 'xl':
modalClasses += ' max-w-[1240px] ';
backdropClasses += ' p-4 md:p-[3vmin]';
modalClasses = clsx(
modalClasses,
'max-w-[1240px]0'
);
backdropClasses = clsx(
backdropClasses,
'p-4 md:p-[3vmin]'
);
paddingClasses = 'p-10';
headerClasses = clsx(
headerClasses,
'-inset-x-10 -top-10'
);
break;
case 'full':
modalClasses += ' h-full ';
backdropClasses += ' p-4 md:p-[3vmin]';
modalClasses = clsx(
modalClasses,
'h-full'
);
backdropClasses = clsx(
backdropClasses,
'p-4 md:p-[3vmin]'
);
paddingClasses = 'p-10';
headerClasses = clsx(
headerClasses,
'-inset-x-10'
);
break;
case 'bleed':
modalClasses += ' h-full ';
modalClasses = clsx(
modalClasses,
'h-full'
);
paddingClasses = 'p-10';
headerClasses = clsx(
headerClasses,
'-inset-x-10'
);
break;
default:
backdropClasses += ' p-4 md:p-[8vmin]';
backdropClasses = clsx(
backdropClasses,
'p-4 md:p-[8vmin]'
);
paddingClasses = 'p-8';
headerClasses = clsx(
headerClasses,
'-inset-x-8'
);
break;
}
@ -208,16 +286,34 @@ const Modal: React.FC<ModalProps> = ({
paddingClasses = 'p-0';
}
// Set bottom padding for backdrop when the menu is on
backdropClasses += ' max-[800px]:!pb-20';
modalClasses = clsx(
modalClasses
);
let footerClasses = clsx(
headerClasses = clsx(
headerClasses,
paddingClasses,
'pb-0'
);
contentClasses = clsx(
paddingClasses,
'py-0'
);
// Set bottom padding for backdrop when the menu is on
backdropClasses = clsx(
backdropClasses,
'max-[800px]:!pb-20'
);
footerClasses = clsx(
`${paddingClasses} ${stickyFooter ? 'py-6' : 'pt-0'}`,
'flex w-full items-center justify-between'
);
let contentClasses = clsx(
paddingClasses,
contentClasses = clsx(
contentClasses,
((size === 'full' || size === 'bleed') && 'grow')
);
@ -273,22 +369,20 @@ const Modal: React.FC<ModalProps> = ({
formSheet && 'bg-[rgba(98,109,121,0.08)]'
)}></div>
<section className={modalClasses} data-testid={testId} style={modalStyles}>
{!topRightContent || topRightContent === 'close' ?
(<header className={headerClasses}>
{title && <Heading level={3}>{title}</Heading>}
<div className={`${topRightContent !== 'close' && 'md:!invisible md:!hidden'} ${hideXOnMobile && 'hidden'} absolute right-6 top-6`}>
<Button className='-m-2 cursor-pointer p-2 opacity-50 hover:opacity-100' icon='close' iconColorClass='text-black dark:text-white' size='sm' unstyled onClick={removeModal} />
</div>
</header>)
:
(<header className={headerClasses}>
{title && <Heading level={3}>{title}</Heading>}
{topRightContent}
</header>)}
<div className={contentClasses}>
<div className='h-full'>
{!topRightContent || topRightContent === 'close' ?
(<>
{title && <Heading level={3}>{title}</Heading>}
<div className={`${topRightContent !== 'close' && 'md:!invisible md:!hidden'} ${hideXOnMobile && 'hidden'} absolute right-6 top-6`}>
<Button className='-m-2 cursor-pointer p-2 opacity-50 hover:opacity-100' icon='close' iconColorClass='text-black dark:text-white' size='sm' unstyled onClick={removeModal} />
</div>
</>)
:
(<div className='flex items-center justify-between gap-5'>
{title && <Heading level={3}>{title}</Heading>}
{topRightContent}
</div>)}
{children}
</div>
{children}
</div>
{footerContent}
</section>

View File

@ -5,9 +5,10 @@ import InfiniteScrollListener from '../../../admin-x-ds/global/InfiniteScrollLis
import List from '../../../admin-x-ds/global/List';
import ListItem from '../../../admin-x-ds/global/ListItem';
import Modal from '../../../admin-x-ds/global/modal/Modal';
import MultiSelect from '../../../admin-x-ds/global/form/MultiSelect';
import NiceModal, {useModal} from '@ebay/nice-modal-react';
import NoValueLabel from '../../../admin-x-ds/global/NoValueLabel';
import Popover from '../../../admin-x-ds/global/Popover';
import Select, {SelectOption} from '../../../admin-x-ds/global/form/Select';
import Toggle from '../../../admin-x-ds/global/form/Toggle';
import ToggleGroup from '../../../admin-x-ds/global/form/ToggleGroup';
import useRouting from '../../../hooks/useRouting';
@ -70,13 +71,20 @@ const HistoryFilter: React.FC<{
excludedResources: string[];
toggleEventType: (event: string, included: boolean) => void;
toggleResourceType: (resource: string, included: boolean) => void;
}> = ({userId, excludedEvents, excludedResources, toggleEventType, toggleResourceType}) => {
}> = ({excludedEvents, excludedResources, toggleEventType, toggleResourceType}) => {
const {updateRoute} = useRouting();
const {users} = useStaffUsers();
const [searchedStaff, setSearchStaff] = useState<SelectOption | null>();
const resetStaff = () => {
setSearchStaff(null);
};
const userOptions = users.map(user => ({label: user.name, value: user.id}));
return (
<div className='flex items-center gap-4'>
<Popover position='right' trigger={<Button label='Filter' link />}>
<Popover position='right' trigger={<Button color='outline' label='Filter' size='sm' />}>
<div className='flex w-[220px] flex-col gap-8 p-5'>
<ToggleGroup>
<HistoryFilterToggle excludedItems={excludedEvents} item='added' label='Added' toggleItem={toggleEventType} />
@ -92,17 +100,23 @@ const HistoryFilter: React.FC<{
</ToggleGroup>
</div>
</Popover>
{userId ?
<Button label='Clear search' link onClick={() => updateRoute('history/view')} /> :
<div className='w-[200px]'>
<MultiSelect
options={users.map(user => ({label: user.name, value: user.id}))}
placeholder='Search staff'
values={[]}
onChange={([option]) => updateRoute(`history/view/${option.value}`)}
/>
</div>
}
<div className='w-[200px]'>
<Select
options={userOptions}
placeholder='Search staff'
value={searchedStaff}
isClearable
onSelect={(value) => {
if (value) {
setSearchStaff(userOptions.find(option => option.value === value)!);
updateRoute(`history/view/${value}`);
} else {
resetStaff();
updateRoute('history/view');
}
}}
/>
</div>
</div>
);
};
@ -195,6 +209,7 @@ const HistoryModal = NiceModal.create<RoutingModalProps>(({params}) => {
scrolling={true}
size='md'
stickyFooter={true}
stickyHeader={true}
testId='history-modal'
title='History'
topRightContent={<HistoryFilter
@ -211,23 +226,30 @@ const HistoryModal = NiceModal.create<RoutingModalProps>(({params}) => {
>
<div className='relative -mb-8 mt-6'>
<List hint={data?.isEnd ? 'End of history log' : undefined}>
<InfiniteScrollListener offset={250} onTrigger={fetchNext} />
{data?.actions.map(action => !action.skip && <ListItem
avatar={<HistoryAvatar action={action} />}
detail={[
new Date(action.created_at).toLocaleDateString('default', {year: 'numeric', month: 'short', day: '2-digit'}),
new Date(action.created_at).toLocaleTimeString('default', {hour: '2-digit', minute: '2-digit', second: '2-digit'})
].join(' | ')}
title={
<div className='text-sm'>
{getActionTitle(action)}{isBulkAction(action) ? '' : ': '}
{!isBulkAction(action) && <HistoryActionDescription action={action} />}
{action.count ? <> {action.count} times</> : null}
<span> &mdash; by {action.actor?.name || action.actor?.slug}</span>
</div>
}
separator
/>)}
{data?.actions ? <>
<InfiniteScrollListener offset={250} onTrigger={fetchNext} />
{data?.actions.map(action => !action.skip && <ListItem
avatar={<HistoryAvatar action={action} />}
detail={[
new Date(action.created_at).toLocaleDateString('default', {year: 'numeric', month: 'short', day: '2-digit'}),
new Date(action.created_at).toLocaleTimeString('default', {hour: '2-digit', minute: '2-digit', second: '2-digit'})
].join(' | ')}
title={
<div className='text-sm'>
{getActionTitle(action)}{isBulkAction(action) ? '' : ': '}
{!isBulkAction(action) && <HistoryActionDescription action={action} />}
{action.count ? <> {action.count} times</> : null}
<span> &mdash; by {action.actor?.name || action.actor?.slug}</span>
</div>
}
separator
/>)}
</>
:
<NoValueLabel>
No entries found.
</NoValueLabel>
}
</List>
</div>
</Modal>

View File

@ -2,22 +2,17 @@ import Button from '../../../admin-x-ds/global/Button';
import React from 'react';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import useRouting from '../../../hooks/useRouting';
import {getSettingValues} from '../../../api/settings';
import {useGlobalData} from '../../providers/GlobalDataProvider';
const Portal: React.FC<{ keywords: string[] }> = ({keywords}) => {
const {updateRoute} = useRouting();
const {settings} = useGlobalData();
const openPreviewModal = () => {
updateRoute('portal/edit');
};
const [membersSignupAccess] = getSettingValues<string>(settings, ['members_signup_access']);
return (
<SettingGroup
customButtons={<Button color='green' disabled={membersSignupAccess === 'none'} label='Customize' link linkWithPadding onClick={openPreviewModal}/>}
customButtons={<Button color='green' label='Customize' link linkWithPadding onClick={openPreviewModal}/>}
description="Customize members modal signup flow"
keywords={keywords}
navid='portal'

View File

@ -316,7 +316,7 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
</div>
</Form>
</div>
<div className='sticky top-[94px] hidden shrink-0 basis-[380px] min-[920px]:!visible min-[920px]:!block'>
<div className='sticky top-[96px] hidden shrink-0 basis-[380px] min-[920px]:!visible min-[920px]:!block'>
<TierDetailPreview isFreeTier={isFreeTier} tier={formState} />
</div>
</div>

View File

@ -58,7 +58,7 @@ const NavigationModal = NiceModal.create(() => {
}
}}
>
<div className='-mb-8 mt-6'>
<div className='mt-6'>
<TabView
selectedTab={selectedTab}
tabs={[