Refactored limit=all queries to be paginated in AdminX (#18324)

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

---

### <samp>🤖 Generated by Copilot at 0095d23</samp>

The pull request adds support for asynchronous and creatable select
inputs in various components, using the `react-select` and
`@tanstack/react-query` libraries. It also adds pagination features to
the newsletters and tiers lists, using a `Button` component and infinite
queries. It refactors and fixes the type and null handling of the select
inputs and their options, using the `SelectOption` type and the
`useFilterableApi` and `debounce` hooks. It removes some unnecessary
props from the browse queries, and adds a new custom hook
`useDefaultRecipientsOptions` for the default recipients component. It
updates the stories and modals to use the new select inputs and options.
This commit is contained in:
Jono M 2023-09-25 14:03:47 +01:00 committed by GitHub
parent c4773b946b
commit 0e35baaf01
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
100 changed files with 668 additions and 374 deletions

View File

@ -1,9 +1,11 @@
import AsyncCreatableSelect from 'react-select/async-creatable';
import AsyncSelect from 'react-select/async';
import CreatableSelect from 'react-select/creatable'; import CreatableSelect from 'react-select/creatable';
import Heading from '../Heading'; import Heading from '../Heading';
import Hint from '../Hint'; import Hint from '../Hint';
import React, {useId, useMemo} from 'react'; import React, {useId, useMemo} from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import {DropdownIndicatorProps, GroupBase, MultiValue, OptionProps, OptionsOrGroups, default as ReactSelect, components} from 'react-select'; import {DropdownIndicatorProps, GroupBase, MultiValue, OptionProps, OptionsOrGroups, Props, default as ReactSelect, components} from 'react-select';
export type MultiSelectColor = 'grey' | 'black' | 'green' | 'pink'; export type MultiSelectColor = 'grey' | 'black' | 'green' | 'pink';
type FieldStyles = 'text' | 'dropdown'; type FieldStyles = 'text' | 'dropdown';
@ -14,9 +16,22 @@ export type MultiSelectOption = {
color?: MultiSelectColor; color?: MultiSelectColor;
} }
interface MultiSelectProps { export type LoadOptions = (inputValue: string, callback: (options: OptionsOrGroups<MultiSelectOption, GroupBase<MultiSelectOption>>) => void) => void
type MultiSelectOptionProps = {
async: true;
defaultOptions: boolean | OptionsOrGroups<MultiSelectOption, GroupBase<MultiSelectOption>>;
loadOptions: LoadOptions;
options?: never;
} | {
async?: false;
options: OptionsOrGroups<MultiSelectOption, GroupBase<MultiSelectOption>>; options: OptionsOrGroups<MultiSelectOption, GroupBase<MultiSelectOption>>;
values: MultiSelectOption[]; defaultOptions?: never;
loadOptions?: never;
}
type MultiSelectProps = MultiSelectOptionProps & {
values: MultiValue<MultiSelectOption>;
title?: string; title?: string;
clearBg?: boolean; clearBg?: boolean;
error?: boolean; error?: boolean;
@ -70,7 +85,10 @@ const MultiSelect: React.FC<MultiSelectProps> = ({
size = 'md', size = 'md',
fieldStyle = 'dropdown', fieldStyle = 'dropdown',
hint = '', hint = '',
async,
options, options,
defaultOptions,
loadOptions,
values, values,
onChange, onChange,
canCreate = false, canCreate = false,
@ -110,60 +128,37 @@ const MultiSelect: React.FC<MultiSelectProps> = ({
return (ddiProps: DropdownIndicatorProps<MultiSelectOption, true>) => <DropdownIndicator {...ddiProps} clearBg={clearBg} fieldStyle={fieldStyle} />; return (ddiProps: DropdownIndicatorProps<MultiSelectOption, true>) => <DropdownIndicator {...ddiProps} clearBg={clearBg} fieldStyle={fieldStyle} />;
}, [clearBg, fieldStyle]); }, [clearBg, fieldStyle]);
const commonOptions: Props<MultiSelectOption, true> = {
classNames: {
menuList: () => 'z-50',
valueContainer: () => customClasses.valueContainer,
control: () => customClasses.control,
placeholder: () => customClasses.placeHolder,
menu: () => customClasses.menu,
option: () => customClasses.option,
multiValue: ({data}) => customClasses.multiValue(data.color),
noOptionsMessage: () => customClasses.noOptionsMessage,
groupHeading: () => customClasses.groupHeading
},
closeMenuOnSelect: false,
components: {DropdownIndicator: dropdownIndicatorComponent, Option},
inputId: id,
isClearable: false,
placeholder: placeholder ? placeholder : '',
value: values,
isMulti: true,
unstyled: true,
onChange,
...props
};
return ( return (
<div className='flex flex-col'> <div className='flex flex-col'>
{title && <Heading htmlFor={id} grey useLabelTag>{title}</Heading>} {title && <Heading htmlFor={id} grey useLabelTag>{title}</Heading>}
{ {
canCreate ? async ?
<CreatableSelect (canCreate ? <AsyncCreatableSelect {...commonOptions} defaultOptions={defaultOptions} loadOptions={loadOptions} /> : <AsyncSelect {...commonOptions} defaultOptions={defaultOptions} loadOptions={loadOptions} />) :
classNames={{ (canCreate ? <CreatableSelect {...commonOptions} options={options} /> : <ReactSelect {...commonOptions} options={options} />)
menuList: () => 'z-50',
valueContainer: () => customClasses.valueContainer,
control: () => customClasses.control,
placeholder: () => customClasses.placeHolder,
menu: () => customClasses.menu,
option: () => customClasses.option,
multiValue: ({data}) => customClasses.multiValue(data.color),
noOptionsMessage: () => customClasses.noOptionsMessage,
groupHeading: () => customClasses.groupHeading
}}
closeMenuOnSelect={false}
components={{DropdownIndicator: dropdownIndicatorComponent, Option}}
inputId={id}
isClearable={false}
options={options}
placeholder={placeholder ? placeholder : ''}
value={values}
isMulti
unstyled
onChange={onChange}
{...props}
/>
:
<ReactSelect
classNames={{
menuList: () => 'z-50',
valueContainer: () => customClasses.valueContainer,
control: () => customClasses.control,
placeholder: () => customClasses.placeHolder,
menu: () => customClasses.menu,
option: () => customClasses.option,
multiValue: ({data}) => customClasses.multiValue(data.color),
noOptionsMessage: () => customClasses.noOptionsMessage,
groupHeading: () => customClasses.groupHeading
}}
closeMenuOnSelect={false}
components={{DropdownIndicator: dropdownIndicatorComponent, Option}}
inputId={id}
isClearable={false}
options={options}
placeholder={placeholder ? placeholder : ''}
value={values}
isMulti
unstyled
onChange={onChange}
{...props}
/>
} }
{hint && <Hint color={error ? 'red' : ''}>{hint}</Hint>} {hint && <Hint color={error ? 'red' : ''}>{hint}</Hint>}
</div> </div>

View File

@ -78,7 +78,7 @@ export const WithSelectedOption: Story = {
args: { args: {
title: 'Title', title: 'Title',
options: selectOptions, options: selectOptions,
selectedOption: 'option-3', selectedOption: selectOptions.find(option => option.value === 'option-3'),
hint: 'Here\'s some hint' hint: 'Here\'s some hint'
} }
}; };

View File

@ -1,9 +1,9 @@
import React, {useId, useMemo} from 'react'; import AsyncSelect from 'react-select/async';
import ReactSelect, {ClearIndicatorProps, DropdownIndicatorProps, OptionProps, Props, components} from 'react-select';
import Heading from '../Heading'; import Heading from '../Heading';
import Hint from '../Hint'; import Hint from '../Hint';
import Icon from '../Icon'; import Icon from '../Icon';
import React, {useId, useMemo} from 'react';
import ReactSelect, {ClearIndicatorProps, DropdownIndicatorProps, GroupBase, OptionProps, OptionsOrGroups, Props, components} from 'react-select';
import clsx from 'clsx'; import clsx from 'clsx';
export interface SelectOption { export interface SelectOption {
@ -31,14 +31,28 @@ export interface SelectControlClasses {
clearIndicator?: string; clearIndicator?: string;
} }
export interface SelectProps extends Props<SelectOption, false> { export type LoadOptions = (inputValue: string, callback: (options: OptionsOrGroups<SelectOption, GroupBase<SelectOption>>) => void) => void
type SelectOptionProps = {
async: true;
defaultOptions: boolean | OptionsOrGroups<SelectOption, GroupBase<SelectOption>>;
loadOptions: LoadOptions;
options?: never;
} | {
async?: false;
options: OptionsOrGroups<SelectOption, GroupBase<SelectOption>>;
defaultOptions?: never;
loadOptions?: never;
}
export type SelectProps = Props<SelectOption, false> & SelectOptionProps & {
async?: boolean;
title?: string; title?: string;
hideTitle?: boolean; hideTitle?: boolean;
size?: 'xs' | 'md'; size?: 'xs' | 'md';
prompt?: string; prompt?: string;
options: SelectOption[] | SelectOptionGroup[]; selectedOption?: SelectOption
selectedOption?: string onSelect: (option: SelectOption | null) => void;
onSelect: (value: string | undefined) => void;
error?:boolean; error?:boolean;
hint?: React.ReactNode; hint?: React.ReactNode;
clearBg?: boolean; clearBg?: boolean;
@ -71,6 +85,7 @@ const Option: React.FC<OptionProps<SelectOption, false>> = ({children, ...option
); );
const Select: React.FC<SelectProps> = ({ const Select: React.FC<SelectProps> = ({
async,
title, title,
hideTitle, hideTitle,
size = 'md', size = 'md',
@ -133,19 +148,8 @@ const Select: React.FC<SelectProps> = ({
}; };
}, [clearBg]); }, [clearBg]);
const individualOptions = options.flatMap((option) => { const customProps = {
if ('options' in option) { classNames: {
return option.options;
}
return option;
});
const select = (
<>
{title && <Heading className={hideTitle ? 'sr-only' : ''} grey={selectedOption || !prompt ? true : false} htmlFor={id} useLabelTag={true}>{title}</Heading>}
<div className={containerClasses}>
<ReactSelect<SelectOption, false>
classNames={{
menuList: () => 'z-[300]', menuList: () => 'z-[300]',
valueContainer: () => customClasses.valueContainer, valueContainer: () => customClasses.valueContainer,
control: () => customClasses.control, control: () => customClasses.control,
@ -155,17 +159,25 @@ const Select: React.FC<SelectProps> = ({
noOptionsMessage: () => customClasses.noOptionsMessage, noOptionsMessage: () => customClasses.noOptionsMessage,
groupHeading: () => customClasses.groupHeading, groupHeading: () => customClasses.groupHeading,
clearIndicator: () => customClasses.clearIndicator clearIndicator: () => customClasses.clearIndicator
}} },
components={{DropdownIndicator: dropdownIndicatorComponent, Option, ClearIndicator}} components: {DropdownIndicator: dropdownIndicatorComponent, Option, ClearIndicator},
inputId={id} inputId: id,
isClearable={false} isClearable: false,
options={options} options: options,
placeholder={prompt ? prompt : ''} placeholder: prompt ? prompt : '',
value={individualOptions.find(option => option.value === selectedOption)} value: selectedOption,
unstyled unstyled,
onChange={option => onSelect(option?.value)} onChange: onSelect
{...props} };
/>
const select = (
<>
{title && <Heading className={hideTitle ? 'sr-only' : ''} grey={selectedOption || !prompt ? true : false} htmlFor={id} useLabelTag={true}>{title}</Heading>}
<div className={containerClasses}>
{async ?
<AsyncSelect<SelectOption, false> {...customProps} {...props} /> :
<ReactSelect<SelectOption, false> {...customProps} {...props} />
}
</div> </div>
{hint && <Hint color={error ? 'red' : ''}>{hint}</Hint>} {hint && <Hint color={error ? 'red' : ''}>{hint}</Hint>}
</> </>

View File

@ -108,7 +108,11 @@ export const PreviewModalContent: React.FC<PreviewModalProps> = ({
let toolbarLeft = (<></>); let toolbarLeft = (<></>);
if (previewToolbarURLs) { if (previewToolbarURLs) {
toolbarLeft = ( toolbarLeft = (
<Select options={previewToolbarURLs!} selectedOption={selectedURL} onSelect={url => url && onSelectURL?.(url)} /> <Select
options={previewToolbarURLs!}
selectedOption={previewToolbarURLs!.find(option => option.value === selectedURL)}
onSelect={option => option && onSelectURL?.(option.value)}
/>
); );
} else if (previewToolbarTabs) { } else if (previewToolbarTabs) {
toolbarLeft = <TabView toolbarLeft = <TabView

View File

@ -1,7 +1,7 @@
import {ExternalLink, InternalLink} from '../components/providers/RoutingProvider'; import {ExternalLink, InternalLink} from '../components/providers/RoutingProvider';
import {InfiniteData} from '@tanstack/react-query'; import {InfiniteData} from '@tanstack/react-query';
import {JSONObject} from './config'; import {JSONObject} from './config';
import {Meta, createInfiniteQuery} from '../utils/apiRequests'; import {Meta, createInfiniteQuery} from '../utils/api/hooks';
// Types // Types

View File

@ -1,5 +1,5 @@
import {IntegrationsResponseType, integrationsDataType} from './integrations'; import {IntegrationsResponseType, integrationsDataType} from './integrations';
import {createMutation} from '../utils/apiRequests'; import {createMutation} from '../utils/api/hooks';
// Types // Types

View File

@ -1,4 +1,4 @@
import {createQuery} from '../utils/apiRequests'; import {createQuery} from '../utils/api/hooks';
export type JSONValue = string|number|boolean|null|Date|JSONObject|JSONArray; export type JSONValue = string|number|boolean|null|Date|JSONObject|JSONArray;
export interface JSONObject { [key: string]: JSONValue } export interface JSONObject { [key: string]: JSONValue }

View File

@ -1,5 +1,5 @@
import {Setting} from './settings'; import {Setting} from './settings';
import {createMutation, createQuery} from '../utils/apiRequests'; import {createMutation, createQuery} from '../utils/api/hooks';
type CustomThemeSettingData = type CustomThemeSettingData =
{ type: 'text', value: string | null, default: string | null } | { type: 'text', value: string | null, default: string | null } |

View File

@ -1,4 +1,4 @@
import {createMutation} from '../utils/apiRequests'; import {createMutation} from '../utils/api/hooks';
import {downloadFromEndpoint} from '../utils/helpers'; import {downloadFromEndpoint} from '../utils/helpers';
export const useImportContent = createMutation<unknown, File>({ export const useImportContent = createMutation<unknown, File>({

View File

@ -1,4 +1,4 @@
import {Meta, createMutation} from '../utils/apiRequests'; import {Meta, createMutation} from '../utils/api/hooks';
export type emailVerification = { export type emailVerification = {
token: string; token: string;

View File

@ -1,4 +1,4 @@
import {useFetchApi} from '../utils/apiRequests'; import {useFetchApi} from '../utils/api/hooks';
export type GhostSiteResponse = { export type GhostSiteResponse = {
site: { site: {

View File

@ -1,4 +1,4 @@
import {createMutation} from '../utils/apiRequests'; import {createMutation} from '../utils/api/hooks';
export interface FilesResponseType { export interface FilesResponseType {
files: { files: {

View File

@ -1,4 +1,4 @@
import {createMutation} from '../utils/apiRequests'; import {createMutation} from '../utils/api/hooks';
export interface ImagesResponseType { export interface ImagesResponseType {
images: { images: {

View File

@ -1,5 +1,5 @@
import {APIKey} from './apiKeys'; import {APIKey} from './apiKeys';
import {Meta, createMutation, createQuery} from '../utils/apiRequests'; import {Meta, createMutation, createQuery} from '../utils/api/hooks';
import {Webhook} from './webhooks'; import {Webhook} from './webhooks';
// Types // Types

View File

@ -1,4 +1,4 @@
import {Meta, createMutation, createQuery} from '../utils/apiRequests'; import {Meta, createMutation, createQuery} from '../utils/api/hooks';
export interface UserInvite { export interface UserInvite {
created_at: string; created_at: string;

View File

@ -1,4 +1,4 @@
import {Meta, createQuery} from '../utils/apiRequests'; import {Meta, createQuery} from '../utils/api/hooks';
export type Label = { export type Label = {
id: string; id: string;
@ -17,6 +17,5 @@ const dataType = 'LabelsResponseType';
export const useBrowseLabels = createQuery<LabelsResponseType>({ export const useBrowseLabels = createQuery<LabelsResponseType>({
dataType, dataType,
path: '/labels/', path: '/labels/'
defaultSearchParams: {limit: 'all'}
}); });

View File

@ -1,4 +1,4 @@
import {Meta, createQuery} from '../utils/apiRequests'; import {Meta, createQuery} from '../utils/api/hooks';
export type Member = { export type Member = {
id: string; id: string;

View File

@ -1,4 +1,4 @@
import {Meta, createPaginatedQuery} from '../utils/apiRequests'; import {Meta, createPaginatedQuery} from '../utils/api/hooks';
export type Mention = { export type Mention = {
id: string; id: string;

View File

@ -1,4 +1,6 @@
import {Meta, createMutation, createQuery} from '../utils/apiRequests'; import {InfiniteData} from '@tanstack/react-query';
import {Meta, createInfiniteQuery, createMutation} from '../utils/api/hooks';
import {insertToQueryCache, updateQueryCache} from '../utils/api/updateQueries';
export type Newsletter = { export type Newsletter = {
id: string; id: string;
@ -46,10 +48,24 @@ export interface NewslettersResponseType {
const dataType = 'NewslettersResponseType'; const dataType = 'NewslettersResponseType';
export const useBrowseNewsletters = createQuery<NewslettersResponseType>({ export const useBrowseNewsletters = createInfiniteQuery<NewslettersResponseType & {isEnd: boolean}>({
dataType, dataType,
path: '/newsletters/', path: '/newsletters/',
defaultSearchParams: {include: 'count.active_members,count.posts', limit: 'all'} defaultSearchParams: {include: 'count.active_members,count.posts', limit: '20'},
defaultNextPageParams: (lastPage, otherParams) => ({
...otherParams,
page: (lastPage.meta?.pagination.next || 1).toString()
}),
returnData: (originalData) => {
const {pages} = originalData as InfiniteData<NewslettersResponseType>;
const newsletters = pages.flatMap(page => page.newsletters);
return {
newsletters: newsletters,
meta: pages.at(-1)!.meta,
isEnd: pages.at(-1)!.newsletters.length < (pages.at(-1)!.meta?.pagination.limit || 0)
};
}
}); });
export const useAddNewsletter = createMutation<NewslettersResponseType, Partial<Newsletter> & {opt_in_existing: boolean}>({ export const useAddNewsletter = createMutation<NewslettersResponseType, Partial<Newsletter> & {opt_in_existing: boolean}>({
@ -60,10 +76,7 @@ export const useAddNewsletter = createMutation<NewslettersResponseType, Partial<
searchParams: payload => ({opt_in_existing: payload.opt_in_existing.toString(), include: 'count.active_members,count.posts'}), searchParams: payload => ({opt_in_existing: payload.opt_in_existing.toString(), include: 'count.active_members,count.posts'}),
updateQueries: { updateQueries: {
dataType, dataType,
update: (newData, currentData) => (currentData && { update: insertToQueryCache('newsletters')
...(currentData as NewslettersResponseType),
newsletters: (currentData as NewslettersResponseType).newsletters.concat(newData.newsletters)
})
} }
}); });
@ -78,12 +91,6 @@ export const useEditNewsletter = createMutation<NewslettersEditResponseType, New
defaultSearchParams: {include: 'count.active_members,count.posts'}, defaultSearchParams: {include: 'count.active_members,count.posts'},
updateQueries: { updateQueries: {
dataType, dataType,
update: (newData, currentData) => (currentData && { update: updateQueryCache('newsletters')
...(currentData as NewslettersResponseType),
newsletters: (currentData as NewslettersResponseType).newsletters.map((newsletter) => {
const newNewsletter = newData.newsletters.find(({id}) => id === newsletter.id);
return newNewsletter || newsletter;
})
})
} }
}); });

View File

@ -1,4 +1,4 @@
import {apiUrl, useFetchApi} from '../utils/apiRequests'; import {apiUrl, useFetchApi} from '../utils/api/hooks';
export type OembedResponse = { export type OembedResponse = {
metadata: { metadata: {

View File

@ -1,4 +1,4 @@
import {Meta, createQuery} from '../utils/apiRequests'; import {Meta, createQuery} from '../utils/api/hooks';
export type Offer = { export type Offer = {
id: string; id: string;
@ -30,6 +30,5 @@ const dataType = 'OffersResponseType';
export const useBrowseOffers = createQuery<OffersResponseType>({ export const useBrowseOffers = createQuery<OffersResponseType>({
dataType, dataType,
path: '/offers/', path: '/offers/'
defaultSearchParams: {limit: 'all'}
}); });

View File

@ -1,4 +1,4 @@
import {Meta, createQuery} from '../utils/apiRequests'; import {Meta, createQuery} from '../utils/api/hooks';
export type Post = { export type Post = {
id: string; id: string;

View File

@ -1,5 +1,5 @@
import {InfiniteData} from '@tanstack/react-query'; import {InfiniteData} from '@tanstack/react-query';
import {Meta, apiUrl, createInfiniteQuery, createMutation, useFetchApi} from '../utils/apiRequests'; import {Meta, apiUrl, createInfiniteQuery, createMutation, useFetchApi} from '../utils/api/hooks';
export type Recommendation = { export type Recommendation = {
id: string id: string

View File

@ -1,4 +1,4 @@
import {createMutation} from '../utils/apiRequests'; import {createMutation} from '../utils/api/hooks';
import {downloadFromEndpoint} from '../utils/helpers'; import {downloadFromEndpoint} from '../utils/helpers';
export const useUploadRedirects = createMutation<unknown, File>({ export const useUploadRedirects = createMutation<unknown, File>({

View File

@ -1,4 +1,4 @@
import {createQuery} from '../utils/apiRequests'; import {createQuery} from '../utils/api/hooks';
export type ReferrerHistoryItem = { export type ReferrerHistoryItem = {
date: string, date: string,

View File

@ -1,4 +1,4 @@
import {Meta, createQuery} from '../utils/apiRequests'; import {Meta, createQuery} from '../utils/api/hooks';
export type UserRoleType = 'Owner' | 'Administrator' | 'Editor' | 'Author' | 'Contributor'; export type UserRoleType = 'Owner' | 'Administrator' | 'Editor' | 'Author' | 'Contributor';

View File

@ -1,4 +1,4 @@
import {createMutation} from '../utils/apiRequests'; import {createMutation} from '../utils/api/hooks';
import {downloadFromEndpoint} from '../utils/helpers'; import {downloadFromEndpoint} from '../utils/helpers';
export const useUploadRoutes = createMutation<unknown, File>({ export const useUploadRoutes = createMutation<unknown, File>({

View File

@ -1,5 +1,5 @@
import {Config} from './config'; import {Config} from './config';
import {Meta, createMutation, createQuery} from '../utils/apiRequests'; import {Meta, createMutation, createQuery} from '../utils/api/hooks';
// Types // Types

View File

@ -1,4 +1,4 @@
import {createQuery} from '../utils/apiRequests'; import {createQuery} from '../utils/api/hooks';
// Types // Types

View File

@ -1,4 +1,4 @@
import {createMutation} from '../utils/apiRequests'; import {createMutation} from '../utils/api/hooks';
export const useTestSlack = createMutation<unknown, null>({ export const useTestSlack = createMutation<unknown, null>({
method: 'POST', method: 'POST',

View File

@ -1,4 +1,4 @@
import {Meta, createMutation, createPaginatedQuery} from '../utils/apiRequests'; import {Meta, createMutation, createPaginatedQuery} from '../utils/api/hooks';
export type staffToken = { export type staffToken = {
id: string; id: string;

View File

@ -1,4 +1,4 @@
import {createMutation, createQuery} from '../utils/apiRequests'; import {createMutation, createQuery} from '../utils/api/hooks';
import {customThemeSettingsDataType} from './customThemeSettings'; import {customThemeSettingsDataType} from './customThemeSettings';
// Types // Types

View File

@ -1,4 +1,6 @@
import {Meta, createMutation, createQuery} from '../utils/apiRequests'; import {InfiniteData} from '@tanstack/react-query';
import {Meta, createInfiniteQuery, createMutation} from '../utils/api/hooks';
import {updateQueryCache} from '../utils/api/updateQueries';
// Types // Types
@ -29,11 +31,23 @@ export interface TiersResponseType {
const dataType = 'TiersResponseType'; const dataType = 'TiersResponseType';
export const useBrowseTiers = createQuery<TiersResponseType>({ export const useBrowseTiers = createInfiniteQuery<TiersResponseType & {isEnd: boolean}>({
dataType, dataType,
path: '/tiers/', path: '/tiers/',
defaultSearchParams: { defaultSearchParams: {limit: '20'},
limit: 'all' defaultNextPageParams: (lastPage, otherParams) => ({
...otherParams,
page: (lastPage.meta?.pagination.next || 1).toString()
}),
returnData: (originalData) => {
const {pages} = originalData as InfiniteData<TiersResponseType>;
const tiers = pages.flatMap(page => page.tiers);
return {
tiers,
meta: pages.at(-1)!.meta,
isEnd: pages.at(-1)!.tiers.length < (pages.at(-1)!.meta?.pagination.limit || 0)
};
} }
}); });
@ -51,13 +65,7 @@ export const useEditTier = createMutation<TiersResponseType, Tier>({
body: tier => ({tiers: [tier]}), body: tier => ({tiers: [tier]}),
updateQueries: { updateQueries: {
dataType, dataType,
update: (newData, currentData) => (currentData && { update: updateQueryCache('tiers')
...(currentData as TiersResponseType),
tiers: (currentData as TiersResponseType).tiers.map((tier) => {
const newTier = newData.tiers.find(({id}) => id === tier.id);
return newTier || tier;
})
})
} }
}); });

View File

@ -1,5 +1,7 @@
import {Meta, createMutation, createQuery} from '../utils/apiRequests'; import {InfiniteData} from '@tanstack/react-query';
import {Meta, createInfiniteQuery, createMutation, createQuery} from '../utils/api/hooks';
import {UserRole} from './roles'; import {UserRole} from './roles';
import {deleteFromQueryCache, updateQueryCache} from '../utils/api/updateQueries';
// Types // Types
@ -62,18 +64,24 @@ export interface DeleteUserResponse {
const dataType = 'UsersResponseType'; const dataType = 'UsersResponseType';
const updateUsers = (newData: UsersResponseType, currentData: unknown) => ({ export const useBrowseUsers = createInfiniteQuery<UsersResponseType & {isEnd: boolean}>({
...(currentData as UsersResponseType),
users: (currentData as UsersResponseType).users.map((user) => {
const newUser = newData.users.find(({id}) => id === user.id);
return newUser || user;
})
});
export const useBrowseUsers = createQuery<UsersResponseType>({
dataType, dataType,
path: '/users/', path: '/users/',
defaultSearchParams: {limit: 'all', include: 'roles'} defaultSearchParams: {limit: '100', include: 'roles'},
defaultNextPageParams: (lastPage, otherParams) => ({
...otherParams,
page: (lastPage.meta?.pagination.next || 1).toString()
}),
returnData: (originalData) => {
const {pages} = originalData as InfiniteData<UsersResponseType>;
const users = pages.flatMap(page => page.users);
return {
users: users,
meta: pages.at(-1)!.meta,
isEnd: pages.at(-1)!.users.length < (pages.at(-1)!.meta?.pagination.limit || 0)
};
}
}); });
export const useCurrentUser = createQuery<User>({ export const useCurrentUser = createQuery<User>({
@ -90,7 +98,7 @@ export const useEditUser = createMutation<UsersResponseType, User>({
searchParams: () => ({include: 'roles'}), searchParams: () => ({include: 'roles'}),
updateQueries: { updateQueries: {
dataType, dataType,
update: updateUsers update: updateQueryCache('users')
} }
}); });
@ -99,10 +107,7 @@ export const useDeleteUser = createMutation<DeleteUserResponse, string>({
path: id => `/users/${id}/`, path: id => `/users/${id}/`,
updateQueries: { updateQueries: {
dataType, dataType,
update: (_, currentData, id) => ({ update: deleteFromQueryCache('users')
...(currentData as UsersResponseType),
users: (currentData as UsersResponseType).users.filter(user => user.id !== id)
})
} }
}); });
@ -129,7 +134,7 @@ export const useMakeOwner = createMutation<UsersResponseType, string>({
}), }),
updateQueries: { updateQueries: {
dataType, dataType,
update: updateUsers update: updateQueryCache('users')
} }
}); });

View File

@ -1,5 +1,5 @@
import {IntegrationsResponseType, integrationsDataType} from './integrations'; import {IntegrationsResponseType, integrationsDataType} from './integrations';
import {Meta, createMutation} from '../utils/apiRequests'; import {Meta, createMutation} from '../utils/api/hooks';
// Types // Types

View File

@ -11,10 +11,13 @@ import Popover from '../../../admin-x-ds/global/Popover';
import Select, {SelectOption} from '../../../admin-x-ds/global/form/Select'; import Select, {SelectOption} from '../../../admin-x-ds/global/form/Select';
import Toggle from '../../../admin-x-ds/global/form/Toggle'; import Toggle from '../../../admin-x-ds/global/form/Toggle';
import ToggleGroup from '../../../admin-x-ds/global/form/ToggleGroup'; import ToggleGroup from '../../../admin-x-ds/global/form/ToggleGroup';
import useFilterableApi from '../../../hooks/useFilterableApi';
import useRouting from '../../../hooks/useRouting'; import useRouting from '../../../hooks/useRouting';
import useStaffUsers from '../../../hooks/useStaffUsers';
import {Action, getActionTitle, getContextResource, getLinkTarget, isBulkAction, useBrowseActions} from '../../../api/actions'; import {Action, getActionTitle, getContextResource, getLinkTarget, isBulkAction, useBrowseActions} from '../../../api/actions';
import {LoadOptions} from '../../../admin-x-ds/global/form/MultiSelect';
import {RoutingModalProps} from '../../providers/RoutingProvider'; import {RoutingModalProps} from '../../providers/RoutingProvider';
import {User} from '../../../api/users';
import {debounce} from '../../../utils/debounce';
import {generateAvatarColor, getInitials} from '../../../utils/helpers'; import {generateAvatarColor, getInitials} from '../../../utils/helpers';
import {useCallback, useState} from 'react'; import {useCallback, useState} from 'react';
@ -73,15 +76,19 @@ const HistoryFilter: React.FC<{
toggleResourceType: (resource: string, included: boolean) => void; toggleResourceType: (resource: string, included: boolean) => void;
}> = ({excludedEvents, excludedResources, toggleEventType, toggleResourceType}) => { }> = ({excludedEvents, excludedResources, toggleEventType, toggleResourceType}) => {
const {updateRoute} = useRouting(); const {updateRoute} = useRouting();
const {users} = useStaffUsers(); const usersApi = useFilterableApi<User, 'users', 'name'>({path: '/users/', filterKey: 'name', responseKey: 'users'});
const loadOptions: LoadOptions = async (input, callback) => {
const users = await usersApi.loadData(input);
callback(users.map(user => ({label: user.name, value: user.id})));
};
const [searchedStaff, setSearchStaff] = useState<SelectOption | null>(); const [searchedStaff, setSearchStaff] = useState<SelectOption | null>();
const resetStaff = () => { const resetStaff = () => {
setSearchStaff(null); setSearchStaff(null);
}; };
const userOptions = users.map(user => ({label: user.name, value: user.id}));
return ( return (
<div className='flex items-center gap-4'> <div className='flex items-center gap-4'>
<Popover position='right' trigger={<Button color='outline' label='Filter' size='sm' />}> <Popover position='right' trigger={<Button color='outline' label='Filter' size='sm' />}>
@ -102,14 +109,16 @@ const HistoryFilter: React.FC<{
</Popover> </Popover>
<div className='w-[200px]'> <div className='w-[200px]'>
<Select <Select
options={userOptions} loadOptions={debounce(loadOptions, 500)}
placeholder='Search staff' placeholder='Search staff'
value={searchedStaff} value={searchedStaff}
async
defaultOptions
isClearable isClearable
onSelect={(value) => { onSelect={(option) => {
if (value) { if (option) {
setSearchStaff(userOptions.find(option => option.value === value)!); setSearchStaff(option);
updateRoute(`history/view/${value}`); updateRoute(`history/view/${option.value}`);
} else { } else {
resetStaff(); resetStaff();
updateRoute('history/view'); updateRoute('history/view');

View File

@ -8,7 +8,7 @@ import NoValueLabel from '../../../admin-x-ds/global/NoValueLabel';
import React, {useState} from 'react'; import React, {useState} from 'react';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup'; import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import TabView from '../../../admin-x-ds/global/TabView'; import TabView from '../../../admin-x-ds/global/TabView';
import handleError from '../../../utils/handleError'; import handleError from '../../../utils/api/handleError';
import useRouting from '../../../hooks/useRouting'; import useRouting from '../../../hooks/useRouting';
import {ReactComponent as AmpIcon} from '../../../assets/icons/amp.svg'; import {ReactComponent as AmpIcon} from '../../../assets/icons/amp.svg';
import {ReactComponent as FirstPromoterIcon} from '../../../assets/icons/firstpromoter.svg'; import {ReactComponent as FirstPromoterIcon} from '../../../assets/icons/firstpromoter.svg';

View File

@ -4,7 +4,7 @@ import Modal from '../../../../admin-x-ds/global/modal/Modal';
import NiceModal, {useModal} from '@ebay/nice-modal-react'; import NiceModal, {useModal} from '@ebay/nice-modal-react';
import React, {useEffect, useState} from 'react'; import React, {useEffect, useState} from 'react';
import TextField from '../../../../admin-x-ds/global/form/TextField'; import TextField from '../../../../admin-x-ds/global/form/TextField';
import handleError from '../../../../utils/handleError'; import handleError from '../../../../utils/api/handleError';
import useRouting from '../../../../hooks/useRouting'; import useRouting from '../../../../hooks/useRouting';
import {HostLimitError, useLimiter} from '../../../../hooks/useLimiter'; import {HostLimitError, useLimiter} from '../../../../hooks/useLimiter';
import {RoutingModalProps} from '../../../providers/RoutingProvider'; import {RoutingModalProps} from '../../../providers/RoutingProvider';

View File

@ -4,7 +4,7 @@ import Modal from '../../../../admin-x-ds/global/modal/Modal';
import NiceModal from '@ebay/nice-modal-react'; import NiceModal from '@ebay/nice-modal-react';
import TextField from '../../../../admin-x-ds/global/form/TextField'; import TextField from '../../../../admin-x-ds/global/form/TextField';
import Toggle from '../../../../admin-x-ds/global/form/Toggle'; import Toggle from '../../../../admin-x-ds/global/form/Toggle';
import handleError from '../../../../utils/handleError'; import handleError from '../../../../utils/api/handleError';
import useRouting from '../../../../hooks/useRouting'; import useRouting from '../../../../hooks/useRouting';
import {ReactComponent as Icon} from '../../../../assets/icons/amp.svg'; import {ReactComponent as Icon} from '../../../../assets/icons/amp.svg';
import {Setting, getSettingValues, useEditSettings} from '../../../../api/settings'; import {Setting, getSettingValues, useEditSettings} from '../../../../api/settings';

View File

@ -7,7 +7,7 @@ import NiceModal, {useModal} from '@ebay/nice-modal-react';
import React, {useEffect, useState} from 'react'; import React, {useEffect, useState} from 'react';
import TextField from '../../../../admin-x-ds/global/form/TextField'; import TextField from '../../../../admin-x-ds/global/form/TextField';
import WebhooksTable from './WebhooksTable'; import WebhooksTable from './WebhooksTable';
import handleError from '../../../../utils/handleError'; import handleError from '../../../../utils/api/handleError';
import useForm from '../../../../hooks/useForm'; import useForm from '../../../../hooks/useForm';
import useRouting from '../../../../hooks/useRouting'; import useRouting from '../../../../hooks/useRouting';
import {APIKey, useRefreshAPIKey} from '../../../../api/apiKeys'; import {APIKey, useRefreshAPIKey} from '../../../../api/apiKeys';

View File

@ -4,7 +4,7 @@ import Modal from '../../../../admin-x-ds/global/modal/Modal';
import NiceModal from '@ebay/nice-modal-react'; import NiceModal from '@ebay/nice-modal-react';
import TextField from '../../../../admin-x-ds/global/form/TextField'; import TextField from '../../../../admin-x-ds/global/form/TextField';
import Toggle from '../../../../admin-x-ds/global/form/Toggle'; import Toggle from '../../../../admin-x-ds/global/form/Toggle';
import handleError from '../../../../utils/handleError'; import handleError from '../../../../utils/api/handleError';
import useRouting from '../../../../hooks/useRouting'; import useRouting from '../../../../hooks/useRouting';
import {ReactComponent as Icon} from '../../../../assets/icons/firstpromoter.svg'; import {ReactComponent as Icon} from '../../../../assets/icons/firstpromoter.svg';
import {Setting, getSettingValues, useEditSettings} from '../../../../api/settings'; import {Setting, getSettingValues, useEditSettings} from '../../../../api/settings';

View File

@ -4,7 +4,7 @@ import IntegrationHeader from './IntegrationHeader';
import Modal from '../../../../admin-x-ds/global/modal/Modal'; import Modal from '../../../../admin-x-ds/global/modal/Modal';
import NiceModal from '@ebay/nice-modal-react'; import NiceModal from '@ebay/nice-modal-react';
import Toggle from '../../../../admin-x-ds/global/form/Toggle'; import Toggle from '../../../../admin-x-ds/global/form/Toggle';
import handleError from '../../../../utils/handleError'; import handleError from '../../../../utils/api/handleError';
import pinturaScreenshot from '../../../../assets/images/pintura-screenshot.png'; import pinturaScreenshot from '../../../../assets/images/pintura-screenshot.png';
import useRouting from '../../../../hooks/useRouting'; import useRouting from '../../../../hooks/useRouting';
import {ReactComponent as Icon} from '../../../../assets/icons/pintura.svg'; import {ReactComponent as Icon} from '../../../../assets/icons/pintura.svg';

View File

@ -3,7 +3,7 @@ import IntegrationHeader from './IntegrationHeader';
import Modal from '../../../../admin-x-ds/global/modal/Modal'; import Modal from '../../../../admin-x-ds/global/modal/Modal';
import NiceModal from '@ebay/nice-modal-react'; import NiceModal from '@ebay/nice-modal-react';
import Toggle from '../../../../admin-x-ds/global/form/Toggle'; import Toggle from '../../../../admin-x-ds/global/form/Toggle';
import handleError from '../../../../utils/handleError'; import handleError from '../../../../utils/api/handleError';
import useRouting from '../../../../hooks/useRouting'; import useRouting from '../../../../hooks/useRouting';
import {ReactComponent as Icon} from '../../../../assets/icons/unsplash.svg'; import {ReactComponent as Icon} from '../../../../assets/icons/unsplash.svg';
import {Setting, getSettingValues, useEditSettings} from '../../../../api/settings'; import {Setting, getSettingValues, useEditSettings} from '../../../../api/settings';

View File

@ -4,7 +4,7 @@ import NiceModal, {useModal} from '@ebay/nice-modal-react';
import React from 'react'; import React from 'react';
import Select from '../../../../admin-x-ds/global/form/Select'; import Select from '../../../../admin-x-ds/global/form/Select';
import TextField from '../../../../admin-x-ds/global/form/TextField'; import TextField from '../../../../admin-x-ds/global/form/TextField';
import handleError from '../../../../utils/handleError'; import handleError from '../../../../utils/api/handleError';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import useForm from '../../../../hooks/useForm'; import useForm from '../../../../hooks/useForm';
import validator from 'validator'; import validator from 'validator';
@ -94,11 +94,11 @@ const WebhookModal: React.FC<WebhookModalProps> = ({webhook, integrationId}) =>
hint={errors.event} hint={errors.event}
options={webhookEventOptions} options={webhookEventOptions}
prompt='Select an event' prompt='Select an event'
selectedOption={formState.event} selectedOption={webhookEventOptions.flatMap(group => group.options).find(option => option.value === formState.event)}
title='Event' title='Event'
hideTitle hideTitle
onSelect={(event) => { onSelect={(option) => {
updateForm(state => ({...state, event})); updateForm(state => ({...state, event: option?.value}));
clearError('event'); clearError('event');
}} }}
/> />

View File

@ -6,7 +6,7 @@ import TableCell from '../../../../admin-x-ds/global/TableCell';
import TableHead from '../../../../admin-x-ds/global/TableHead'; import TableHead from '../../../../admin-x-ds/global/TableHead';
import TableRow from '../../../../admin-x-ds/global/TableRow'; import TableRow from '../../../../admin-x-ds/global/TableRow';
import WebhookModal from './WebhookModal'; import WebhookModal from './WebhookModal';
import handleError from '../../../../utils/handleError'; import handleError from '../../../../utils/api/handleError';
import {Integration} from '../../../../api/integrations'; import {Integration} from '../../../../api/integrations';
import {getWebhookEventLabel} from './webhookEventOptions'; import {getWebhookEventLabel} from './webhookEventOptions';
import {showToast} from '../../../../admin-x-ds/global/Toast'; import {showToast} from '../../../../admin-x-ds/global/Toast';

View File

@ -6,7 +6,7 @@ import List from '../../../../admin-x-ds/global/List';
import ListItem from '../../../../admin-x-ds/global/ListItem'; import ListItem from '../../../../admin-x-ds/global/ListItem';
import Modal from '../../../../admin-x-ds/global/modal/Modal'; import Modal from '../../../../admin-x-ds/global/modal/Modal';
import NiceModal from '@ebay/nice-modal-react'; import NiceModal from '@ebay/nice-modal-react';
import handleError from '../../../../utils/handleError'; import handleError from '../../../../utils/api/handleError';
import useRouting from '../../../../hooks/useRouting'; import useRouting from '../../../../hooks/useRouting';
import {ReactComponent as ArrowRightIcon} from '../../../../admin-x-ds/assets/icons/arrow-right.svg'; import {ReactComponent as ArrowRightIcon} from '../../../../admin-x-ds/assets/icons/arrow-right.svg';
import {ReactComponent as Icon} from '../../../../assets/icons/zapier.svg'; import {ReactComponent as Icon} from '../../../../assets/icons/zapier.svg';

View File

@ -4,7 +4,7 @@ import FileUpload from '../../../../admin-x-ds/global/form/FileUpload';
import LabItem from './LabItem'; import LabItem from './LabItem';
import List from '../../../../admin-x-ds/global/List'; import List from '../../../../admin-x-ds/global/List';
import React, {useState} from 'react'; import React, {useState} from 'react';
import handleError from '../../../../utils/handleError'; import handleError from '../../../../utils/api/handleError';
import useRouting from '../../../../hooks/useRouting'; import useRouting from '../../../../hooks/useRouting';
import {downloadRedirects, useUploadRedirects} from '../../../../api/redirects'; import {downloadRedirects, useUploadRedirects} from '../../../../api/redirects';
import {downloadRoutes, useUploadRoutes} from '../../../../api/routes'; import {downloadRoutes, useUploadRoutes} from '../../../../api/routes';

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import Toggle from '../../../../admin-x-ds/global/form/Toggle'; import Toggle from '../../../../admin-x-ds/global/form/Toggle';
import handleError from '../../../../utils/handleError'; import handleError from '../../../../utils/api/handleError';
import {ConfigResponseType, configDataType} from '../../../../api/config'; import {ConfigResponseType, configDataType} from '../../../../api/config';
import {getSettingValue, useEditSettings} from '../../../../api/settings'; import {getSettingValue, useEditSettings} from '../../../../api/settings';
import {useGlobalData} from '../../../providers/GlobalDataProvider'; import {useGlobalData} from '../../../providers/GlobalDataProvider';

View File

@ -5,7 +5,7 @@ import LabItem from './LabItem';
import List from '../../../../admin-x-ds/global/List'; import List from '../../../../admin-x-ds/global/List';
import NiceModal, {useModal} from '@ebay/nice-modal-react'; import NiceModal, {useModal} from '@ebay/nice-modal-react';
import React, {useState} from 'react'; import React, {useState} from 'react';
import handleError from '../../../../utils/handleError'; import handleError from '../../../../utils/api/handleError';
import {downloadAllContent, useDeleteAllContent, useImportContent} from '../../../../api/db'; import {downloadAllContent, useDeleteAllContent, useImportContent} from '../../../../api/db';
import {showToast} from '../../../../admin-x-ds/global/Toast'; import {showToast} from '../../../../admin-x-ds/global/Toast';
import {useQueryClient} from '@tanstack/react-query'; import {useQueryClient} from '@tanstack/react-query';

View File

@ -3,13 +3,11 @@ import React, {useState} from 'react';
import Select from '../../../admin-x-ds/global/form/Select'; import Select from '../../../admin-x-ds/global/form/Select';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup'; import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupContent'; import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupContent';
import useDefaultRecipientsOptions from './useDefaultRecipientsOptions';
import useSettingGroup from '../../../hooks/useSettingGroup'; import useSettingGroup from '../../../hooks/useSettingGroup';
import {GroupBase, MultiValue} from 'react-select'; import {MultiValue} from 'react-select';
import {getOptionLabel} from '../../../utils/helpers'; import {getOptionLabel} from '../../../utils/helpers';
import {getSettingValues} from '../../../api/settings'; import {getSettingValues} from '../../../api/settings';
import {useBrowseLabels} from '../../../api/labels';
import {useBrowseOffers} from '../../../api/offers';
import {useBrowseTiers} from '../../../api/tiers';
import {withErrorBoundary} from '../../../admin-x-ds/global/ErrorBoundary'; import {withErrorBoundary} from '../../../admin-x-ds/global/ErrorBoundary';
type RefipientValueArgs = { type RefipientValueArgs = {
@ -39,16 +37,6 @@ const RECIPIENT_FILTER_OPTIONS = [{
value: 'none' value: 'none'
}]; }];
const SIMPLE_SEGMENT_OPTIONS: MultiSelectOption[] = [{
label: 'Free members',
value: 'status:free',
color: 'green'
}, {
label: 'Paid members',
value: 'status:-free',
color: 'pink'
}];
function getDefaultRecipientValue({ function getDefaultRecipientValue({
defaultEmailRecipients, defaultEmailRecipients,
defaultEmailRecipientsFilter defaultEmailRecipientsFilter
@ -88,9 +76,7 @@ const DefaultRecipients: React.FC<{ keywords: string[] }> = ({keywords}) => {
defaultEmailRecipientsFilter defaultEmailRecipientsFilter
})); }));
const {data: {tiers} = {}} = useBrowseTiers(); const {loadOptions, selectedSegments, setSelectedSegments} = useDefaultRecipientsOptions(selectedOption, defaultEmailRecipientsFilter);
const {data: {labels} = {}} = useBrowseLabels();
const {data: {offers} = {}} = useBrowseOffers();
const setDefaultRecipientValue = (value: string) => { const setDefaultRecipientValue = (value: string) => {
if (['visibility', 'disabled'].includes(value)) { if (['visibility', 'disabled'].includes(value)) {
@ -115,34 +101,9 @@ const DefaultRecipients: React.FC<{ keywords: string[] }> = ({keywords}) => {
setSelectedOption(value); setSelectedOption(value);
}; };
const segmentOptionGroups: GroupBase<MultiSelectOption>[] = [ const updateSelectedSegments = (selected: MultiValue<MultiSelectOption>) => {
{ setSelectedSegments(selected);
options: SIMPLE_SEGMENT_OPTIONS
},
{
label: 'Active Tiers',
options: tiers?.filter(({active, type}) => active && type !== 'free').map(tier => ({value: tier.id, label: tier.name, color: 'black'})) || []
},
{
label: 'Archived Tiers',
options: tiers?.filter(({active}) => !active).map(tier => ({value: tier.id, label: tier.name, color: 'black'})) || []
},
{
label: 'Labels',
options: labels?.map(label => ({value: `label:${label.slug}`, label: label.name, color: 'grey'})) || []
},
{
label: 'Offers',
options: offers?.map(offer => ({value: `offer_redemptions:${offer.id}`, label: offer.name, color: 'black'})) || []
}
];
const filters = defaultEmailRecipientsFilter?.split(',') || [];
const selectedSegments = segmentOptionGroups
.flatMap(({options}) => options)
.filter(({value}) => filters.includes(value));
const setSelectedSegments = (selected: MultiValue<MultiSelectOption>) => {
if (selected.length) { if (selected.length) {
const selectedGroups = selected?.map(({value}) => value).join(','); const selectedGroups = selected?.map(({value}) => value).join(',');
updateSetting('editor_default_email_recipients_filter', selectedGroups); updateSetting('editor_default_email_recipients_filter', selectedGroups);
@ -169,21 +130,23 @@ const DefaultRecipients: React.FC<{ keywords: string[] }> = ({keywords}) => {
<Select <Select
hint='Who should be able to subscribe to your site?' hint='Who should be able to subscribe to your site?'
options={RECIPIENT_FILTER_OPTIONS} options={RECIPIENT_FILTER_OPTIONS}
selectedOption={selectedOption} selectedOption={RECIPIENT_FILTER_OPTIONS.find(option => option.value === selectedOption)}
title="Default Newsletter recipients" title="Default Newsletter recipients"
onSelect={(value) => { onSelect={(option) => {
if (value) { if (option) {
setDefaultRecipientValue(value); setDefaultRecipientValue(option.value);
} }
}} }}
/> />
{(selectedOption === 'segment') && ( {(selectedOption === 'segment') && selectedSegments && (
<MultiSelect <MultiSelect
options={segmentOptionGroups.filter(group => group.options.length > 0)} loadOptions={loadOptions}
title='Filter' title='Filter'
values={selectedSegments} values={selectedSegments}
async
clearBg clearBg
onChange={setSelectedSegments} defaultOptions
onChange={updateSelectedSegments}
/> />
)} )}
</SettingGroupContent> </SettingGroupContent>

View File

@ -4,7 +4,7 @@ import React from 'react';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup'; import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupContent'; import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupContent';
import Toggle from '../../../admin-x-ds/global/form/Toggle'; import Toggle from '../../../admin-x-ds/global/form/Toggle';
import handleError from '../../../utils/handleError'; import handleError from '../../../utils/api/handleError';
import useRouting from '../../../hooks/useRouting'; import useRouting from '../../../hooks/useRouting';
import {Setting, getSettingValues, useEditSettings} from '../../../api/settings'; import {Setting, getSettingValues, useEditSettings} from '../../../api/settings';
import {useGlobalData} from '../../providers/GlobalDataProvider'; import {useGlobalData} from '../../providers/GlobalDataProvider';

View File

@ -5,7 +5,7 @@ import Select from '../../../admin-x-ds/global/form/Select';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup'; import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupContent'; import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupContent';
import TextField from '../../../admin-x-ds/global/form/TextField'; import TextField from '../../../admin-x-ds/global/form/TextField';
import handleError from '../../../utils/handleError'; import handleError from '../../../utils/api/handleError';
import useSettingGroup from '../../../hooks/useSettingGroup'; import useSettingGroup from '../../../hooks/useSettingGroup';
import {getSettingValues, useEditSettings} from '../../../api/settings'; import {getSettingValues, useEditSettings} from '../../../api/settings';
import {withErrorBoundary} from '../../../admin-x-ds/global/ErrorBoundary'; import {withErrorBoundary} from '../../../admin-x-ds/global/ErrorBoundary';
@ -65,10 +65,10 @@ const MailGun: React.FC<{ keywords: string[] }> = ({keywords}) => {
<div className='grid grid-cols-[120px_auto] gap-x-3 gap-y-6'> <div className='grid grid-cols-[120px_auto] gap-x-3 gap-y-6'>
<Select <Select
options={MAILGUN_REGIONS} options={MAILGUN_REGIONS}
selectedOption={mailgunRegion} selectedOption={MAILGUN_REGIONS.find(option => option.value === mailgunRegion)}
title="Mailgun region" title="Mailgun region"
onSelect={(value) => { onSelect={(option) => {
updateSetting('mailgun_base_url', value || null); updateSetting('mailgun_base_url', option?.value || null);
}} }}
/> />
<TextField <TextField

View File

@ -13,7 +13,7 @@ const Newsletters: React.FC<{ keywords: string[] }> = ({keywords}) => {
updateRoute('newsletters/add'); updateRoute('newsletters/add');
}; };
const [selectedTab, setSelectedTab] = useState('active-newsletters'); const [selectedTab, setSelectedTab] = useState('active-newsletters');
const {data: {newsletters} = {}} = useBrowseNewsletters(); const {data: {newsletters, isEnd} = {}, fetchNextPage} = useBrowseNewsletters();
const buttons = ( const buttons = (
<Button color='green' label='Add newsletter' link linkWithPadding onClick={() => { <Button color='green' label='Add newsletter' link linkWithPadding onClick={() => {
@ -43,6 +43,7 @@ const Newsletters: React.FC<{ keywords: string[] }> = ({keywords}) => {
title='Newsletters' title='Newsletters'
> >
<TabView selectedTab={selectedTab} tabs={tabs} onTabChange={setSelectedTab} /> <TabView selectedTab={selectedTab} tabs={tabs} onTabChange={setSelectedTab} />
{isEnd === false && <Button label='Load more' link onClick={() => fetchNextPage()} />}
</SettingGroup> </SettingGroup>
); );
}; };

View File

@ -6,7 +6,7 @@ import React, {useEffect} from 'react';
import TextArea from '../../../../admin-x-ds/global/form/TextArea'; import TextArea from '../../../../admin-x-ds/global/form/TextArea';
import TextField from '../../../../admin-x-ds/global/form/TextField'; import TextField from '../../../../admin-x-ds/global/form/TextField';
import Toggle from '../../../../admin-x-ds/global/form/Toggle'; import Toggle from '../../../../admin-x-ds/global/form/Toggle';
import handleError from '../../../../utils/handleError'; import handleError from '../../../../utils/api/handleError';
import useForm from '../../../../hooks/useForm'; import useForm from '../../../../hooks/useForm';
import useRouting from '../../../../hooks/useRouting'; import useRouting from '../../../../hooks/useRouting';
import {HostLimitError, useLimiter} from '../../../../hooks/useLimiter'; import {HostLimitError, useLimiter} from '../../../../hooks/useLimiter';

View File

@ -19,7 +19,7 @@ import TextArea from '../../../../admin-x-ds/global/form/TextArea';
import TextField from '../../../../admin-x-ds/global/form/TextField'; import TextField from '../../../../admin-x-ds/global/form/TextField';
import Toggle from '../../../../admin-x-ds/global/form/Toggle'; import Toggle from '../../../../admin-x-ds/global/form/Toggle';
import ToggleGroup from '../../../../admin-x-ds/global/form/ToggleGroup'; import ToggleGroup from '../../../../admin-x-ds/global/form/ToggleGroup';
import handleError from '../../../../utils/handleError'; import handleError from '../../../../utils/api/handleError';
import useFeatureFlag from '../../../../hooks/useFeatureFlag'; import useFeatureFlag from '../../../../hooks/useFeatureFlag';
import useForm, {ErrorMessages} from '../../../../hooks/useForm'; import useForm, {ErrorMessages} from '../../../../hooks/useForm';
import useRouting from '../../../../hooks/useRouting'; import useRouting from '../../../../hooks/useRouting';
@ -160,7 +160,12 @@ const Sidebar: React.FC<{
onChange={e => updateNewsletter({sender_email: e.target.value})} onChange={e => updateNewsletter({sender_email: e.target.value})}
onKeyDown={() => clearError('sender_email')} onKeyDown={() => clearError('sender_email')}
/> />
<Select options={replyToEmails} selectedOption={newsletter.sender_reply_to} title="Reply-to email" onSelect={value => updateNewsletter({sender_reply_to: value})}/> <Select
options={replyToEmails}
selectedOption={replyToEmails.find(option => option.value === newsletter.sender_reply_to)}
title="Reply-to email"
onSelect={option => updateNewsletter({sender_reply_to: option?.value})}
/>
</Form> </Form>
<Form className='mt-6' gap='sm' margins='lg' title='Member settings'> <Form className='mt-6' gap='sm' margins='lg' title='Member settings'>
<Toggle <Toggle
@ -308,8 +313,8 @@ const Sidebar: React.FC<{
<Select <Select
disabled={!newsletter.show_post_title_section} disabled={!newsletter.show_post_title_section}
options={fontOptions} options={fontOptions}
selectedOption={newsletter.title_font_category} selectedOption={fontOptions.find(option => option.value === newsletter.title_font_category)}
onSelect={value => updateNewsletter({title_font_category: value})} onSelect={option => updateNewsletter({title_font_category: option?.value})}
/> />
</div> </div>
<ButtonGroup buttons={[ <ButtonGroup buttons={[
@ -359,9 +364,9 @@ const Sidebar: React.FC<{
/>} />}
<Select <Select
options={fontOptions} options={fontOptions}
selectedOption={newsletter.body_font_category} selectedOption={fontOptions.find(option => option.value === newsletter.body_font_category)}
title='Body style' title='Body style'
onSelect={value => updateNewsletter({body_font_category: value})} onSelect={option => updateNewsletter({body_font_category: option?.value})}
/> />
<Toggle <Toggle
checked={newsletter.show_feature_image} checked={newsletter.show_feature_image}
@ -510,9 +515,15 @@ const NewsletterDetailModalContent: React.FC<{newsletter: Newsletter; onlyOne: b
}; };
const NewsletterDetailModal: React.FC<RoutingModalProps> = ({params}) => { const NewsletterDetailModal: React.FC<RoutingModalProps> = ({params}) => {
const {data: {newsletters} = {}} = useBrowseNewsletters(); const {data: {newsletters, isEnd} = {}, fetchNextPage} = useBrowseNewsletters();
const newsletter = newsletters?.find(({id}) => id === params?.id); const newsletter = newsletters?.find(({id}) => id === params?.id);
useEffect(() => {
if (!newsletter && !isEnd) {
fetchNextPage();
}
}, [fetchNextPage, isEnd, newsletter]);
if (newsletter) { if (newsletter) {
return <NewsletterDetailModalContent newsletter={newsletter} onlyOne={newsletters!.length === 1} />; return <NewsletterDetailModalContent newsletter={newsletter} onlyOne={newsletters!.length === 1} />;
} else { } else {

View File

@ -0,0 +1,100 @@
import useFilterableApi from '../../../hooks/useFilterableApi';
import {GroupBase, MultiValue} from 'react-select';
import {Label} from '../../../api/labels';
import {LoadOptions, MultiSelectOption} from '../../../admin-x-ds/global/form/MultiSelect';
import {Offer} from '../../../api/offers';
import {Tier} from '../../../api/tiers';
import {debounce} from '../../../utils/debounce';
import {isObjectId} from '../../../utils/helpers';
import {useEffect, useState} from 'react';
const SIMPLE_SEGMENT_OPTIONS: MultiSelectOption[] = [{
label: 'Free members',
value: 'status:free',
color: 'green'
}, {
label: 'Paid members',
value: 'status:-free',
color: 'pink'
}];
const useDefaultRecipientsOptions = (selectedOption: string, defaultEmailRecipientsFilter?: string | null) => {
const tiers = useFilterableApi<Tier, 'tiers', 'name'>({path: '/tiers/', filterKey: 'name', responseKey: 'tiers'});
const labels = useFilterableApi<Label, 'labels', 'name'>({path: '/labels/', filterKey: 'name', responseKey: 'labels'});
const offers = useFilterableApi<Offer, 'offers', 'name'>({path: '/offers/', filterKey: 'name', responseKey: 'offers'});
const [selectedSegments, setSelectedSegments] = useState<MultiValue<MultiSelectOption> | null>(null);
const tierOption = (tier: Tier): MultiSelectOption => ({value: tier.id, label: tier.name, color: 'black'});
const labelOption = (label: Label): MultiSelectOption => ({value: `label:${label.slug}`, label: label.name, color: 'grey'});
const offerOption = (offer: Offer): MultiSelectOption => ({value: `offer_redemptions:${offer.id}`, label: offer.name, color: 'black'});
const loadOptions: LoadOptions = async (input, callback) => {
const [tiersData, labelsData, offersData] = await Promise.all([tiers.loadData(input), labels.loadData(input), offers.loadData(input)]);
const segmentOptionGroups: GroupBase<MultiSelectOption>[] = [
{
options: SIMPLE_SEGMENT_OPTIONS.filter(({label}) => label.toLowerCase().includes(input.toLowerCase()))
},
{
label: 'Active Tiers',
options: tiersData.filter(({active, type}) => active && type !== 'free').map(tierOption) || []
},
{
label: 'Archived Tiers',
options: tiersData.filter(({active}) => !active).map(tierOption) || []
},
{
label: 'Labels',
options: labelsData.map(labelOption) || []
},
{
label: 'Offers',
options: offersData.map(offerOption) || []
}
];
if (selectedSegments === null) {
initSelectedSegments();
}
callback(segmentOptionGroups.filter(group => group.options.length > 0));
};
const initSelectedSegments = async () => {
const filters = defaultEmailRecipientsFilter?.split(',') || [];
const tierIds: string[] = [], labelIds: string[] = [], offerIds: string[] = [];
for (const filter of filters) {
if (filter.startsWith('label:')) {
labelIds.push(filter.replace('label:', ''));
} else if (filter.startsWith('offer_redemptions:')) {
offerIds.push(filter.replace('offer_redemptions:', ''));
} else if (isObjectId(filter)) {
tierIds.push(filter);
}
}
const options = await Promise.all([
tiers.loadInitialValues(tierIds).then(data => data.map(tierOption)),
labels.loadInitialValues(labelIds).then(data => data.map(labelOption)),
offers.loadInitialValues(offerIds).then(data => data.map(offerOption))
]).then(results => results.flat());
setSelectedSegments(filters.map(filter => options.find(option => option.value === filter)!));
};
useEffect(() => {
if (selectedOption === 'segment') {
loadOptions('', () => {});
}
}, [selectedOption]); // eslint-disable-line react-hooks/exhaustive-deps
return {
loadOptions: debounce(loadOptions, 500),
selectedSegments,
setSelectedSegments
};
};
export default useDefaultRecipientsOptions;

View File

@ -3,7 +3,7 @@ import React from 'react';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup'; import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupContent'; import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupContent';
import TextField from '../../../admin-x-ds/global/form/TextField'; import TextField from '../../../admin-x-ds/global/form/TextField';
import handleError from '../../../utils/handleError'; import handleError from '../../../utils/api/handleError';
import usePinturaEditor from '../../../hooks/usePinturaEditor'; import usePinturaEditor from '../../../hooks/usePinturaEditor';
import useSettingGroup from '../../../hooks/useSettingGroup'; import useSettingGroup from '../../../hooks/useSettingGroup';
import {ReactComponent as FacebookLogo} from '../../../admin-x-ds/assets/images/facebook-logo.svg'; import {ReactComponent as FacebookLogo} from '../../../admin-x-ds/assets/images/facebook-logo.svg';

View File

@ -2,7 +2,7 @@ import Modal from '../../../admin-x-ds/global/modal/Modal';
import NiceModal from '@ebay/nice-modal-react'; import NiceModal from '@ebay/nice-modal-react';
import Radio from '../../../admin-x-ds/global/form/Radio'; import Radio from '../../../admin-x-ds/global/form/Radio';
import TextField from '../../../admin-x-ds/global/form/TextField'; import TextField from '../../../admin-x-ds/global/form/TextField';
import handleError from '../../../utils/handleError'; import handleError from '../../../utils/api/handleError';
import useRouting from '../../../hooks/useRouting'; import useRouting from '../../../hooks/useRouting';
import validator from 'validator'; import validator from 'validator';
import {HostLimitError, useLimiter} from '../../../hooks/useLimiter'; import {HostLimitError, useLimiter} from '../../../hooks/useLimiter';

View File

@ -49,7 +49,7 @@ const TimeZone: React.FC<{ keywords: string[] }> = ({keywords}) => {
const [publicationTimezone] = getSettingValues(localSettings, ['timezone']) as string[]; const [publicationTimezone] = getSettingValues(localSettings, ['timezone']) as string[];
const timezoneOptions = timezoneData.map((tzOption: TimezoneDataDropdownOption) => { const timezoneOptions: Array<{value: string; label: string}> = timezoneData.map((tzOption: TimezoneDataDropdownOption) => {
return { return {
value: tzOption.name, value: tzOption.name,
label: tzOption.label label: tzOption.label
@ -76,9 +76,9 @@ const TimeZone: React.FC<{ keywords: string[] }> = ({keywords}) => {
<Select <Select
hint={<Hint timezone={publicationTimezone} />} hint={<Hint timezone={publicationTimezone} />}
options={timezoneOptions} options={timezoneOptions}
selectedOption={publicationTimezone} selectedOption={timezoneOptions.find(option => option.value === publicationTimezone)}
title="Site timezone" title="Site timezone"
onSelect={handleTimezoneChange} onSelect={option => handleTimezoneChange(option?.value)}
/> />
</SettingGroupContent> </SettingGroupContent>
); );

View File

@ -3,7 +3,7 @@ import React from 'react';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup'; import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupContent'; import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupContent';
import TextField from '../../../admin-x-ds/global/form/TextField'; import TextField from '../../../admin-x-ds/global/form/TextField';
import handleError from '../../../utils/handleError'; import handleError from '../../../utils/api/handleError';
import usePinturaEditor from '../../../hooks/usePinturaEditor'; import usePinturaEditor from '../../../hooks/usePinturaEditor';
import useSettingGroup from '../../../hooks/useSettingGroup'; import useSettingGroup from '../../../hooks/useSettingGroup';
import {ReactComponent as TwitterLogo} from '../../../admin-x-ds/assets/images/twitter-logo.svg'; import {ReactComponent as TwitterLogo} from '../../../admin-x-ds/assets/images/twitter-logo.svg';

View File

@ -15,7 +15,7 @@ import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupConten
import TextField from '../../../admin-x-ds/global/form/TextField'; import TextField from '../../../admin-x-ds/global/form/TextField';
import Toggle from '../../../admin-x-ds/global/form/Toggle'; import Toggle from '../../../admin-x-ds/global/form/Toggle';
import clsx from 'clsx'; import clsx from 'clsx';
import handleError from '../../../utils/handleError'; import handleError from '../../../utils/api/handleError';
import useFeatureFlag from '../../../hooks/useFeatureFlag'; import useFeatureFlag from '../../../hooks/useFeatureFlag';
import usePinturaEditor from '../../../hooks/usePinturaEditor'; import usePinturaEditor from '../../../hooks/usePinturaEditor';
import useRouting from '../../../hooks/useRouting'; import useRouting from '../../../hooks/useRouting';
@ -722,9 +722,15 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
}; };
const UserDetailModal: React.FC<RoutingModalProps> = ({params}) => { const UserDetailModal: React.FC<RoutingModalProps> = ({params}) => {
const {users} = useStaffUsers(); const {users, hasNextPage, fetchNextPage} = useStaffUsers();
const user = users.find(({slug}) => slug === params?.slug); const user = users.find(({slug}) => slug === params?.slug);
useEffect(() => {
if (!user && !hasNextPage) {
fetchNextPage();
}
}, [fetchNextPage, hasNextPage, user]);
if (user) { if (user) {
return <UserDetailModalContent user={user} />; return <UserDetailModalContent user={user} />;
} else { } else {

View File

@ -7,7 +7,7 @@ import React, {useState} from 'react';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup'; import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import TabView from '../../../admin-x-ds/global/TabView'; import TabView from '../../../admin-x-ds/global/TabView';
import clsx from 'clsx'; import clsx from 'clsx';
import handleError from '../../../utils/handleError'; import handleError from '../../../utils/api/handleError';
import useRouting from '../../../hooks/useRouting'; import useRouting from '../../../hooks/useRouting';
import useStaffUsers from '../../../hooks/useStaffUsers'; import useStaffUsers from '../../../hooks/useStaffUsers';
import {User, hasAdminAccess, isContributorUser, isEditorUser} from '../../../api/users'; import {User, hasAdminAccess, isContributorUser, isEditorUser} from '../../../api/users';

View File

@ -2,7 +2,7 @@ import Button from '../../../../admin-x-ds/global/Button';
import Heading from '../../../../admin-x-ds/global/Heading'; import Heading from '../../../../admin-x-ds/global/Heading';
import SettingGroup from '../../../../admin-x-ds/settings/SettingGroup'; import SettingGroup from '../../../../admin-x-ds/settings/SettingGroup';
import TextField from '../../../../admin-x-ds/global/form/TextField'; import TextField from '../../../../admin-x-ds/global/form/TextField';
import handleError from '../../../../utils/handleError'; import handleError from '../../../../utils/api/handleError';
import {User, useUpdatePassword} from '../../../../api/users'; import {User, useUpdatePassword} from '../../../../api/users';
import {ValidationError} from '../../../../utils/errors'; import {ValidationError} from '../../../../utils/errors';
import {showToast} from '../../../../admin-x-ds/global/Toast'; import {showToast} from '../../../../admin-x-ds/global/Toast';

View File

@ -136,19 +136,19 @@ const Access: React.FC<{ keywords: string[] }> = ({keywords}) => {
<Select <Select
hint='Who should be able to subscribe to your site?' hint='Who should be able to subscribe to your site?'
options={MEMBERS_SIGNUP_ACCESS_OPTIONS} options={MEMBERS_SIGNUP_ACCESS_OPTIONS}
selectedOption={membersSignupAccess} selectedOption={MEMBERS_SIGNUP_ACCESS_OPTIONS.find(option => option.value === membersSignupAccess)}
title="Subscription access" title="Subscription access"
onSelect={(value) => { onSelect={(option) => {
updateSetting('members_signup_access', value || null); updateSetting('members_signup_access', option?.value || null);
}} }}
/> />
<Select <Select
hint='When a new post is created, who should have access?' hint='When a new post is created, who should have access?'
options={DEFAULT_CONTENT_VISIBILITY_OPTIONS} options={DEFAULT_CONTENT_VISIBILITY_OPTIONS}
selectedOption={defaultContentVisibility} selectedOption={DEFAULT_CONTENT_VISIBILITY_OPTIONS.find(option => option.value === defaultContentVisibility)}
title="Default post access" title="Default post access"
onSelect={(value) => { onSelect={(option) => {
updateSetting('default_content_visibility', value || null); updateSetting('default_content_visibility', option?.value || null);
}} }}
/> />
{defaultContentVisibility === 'tiers' && ( {defaultContentVisibility === 'tiers' && (
@ -164,10 +164,10 @@ const Access: React.FC<{ keywords: string[] }> = ({keywords}) => {
<Select <Select
hint='Who can comment on posts?' hint='Who can comment on posts?'
options={COMMENTS_ENABLED_OPTIONS} options={COMMENTS_ENABLED_OPTIONS}
selectedOption={commentsEnabled} selectedOption={COMMENTS_ENABLED_OPTIONS.find(option => option.value === commentsEnabled)}
title="Commenting" title="Commenting"
onSelect={(value) => { onSelect={(option) => {
updateSetting('comments_enabled', value || null); updateSetting('comments_enabled', option?.value || null);
}} }}
/> />
</SettingGroupContent> </SettingGroupContent>

View File

@ -1,3 +1,4 @@
import Button from '../../../admin-x-ds/global/Button';
import React, {useState} from 'react'; import React, {useState} from 'react';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup'; import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import StripeButton from '../../../admin-x-ds/settings/StripeButton'; import StripeButton from '../../../admin-x-ds/settings/StripeButton';
@ -26,7 +27,7 @@ const StripeConnectedButton: React.FC<{className?: string; onClick: () => void;}
const Tiers: React.FC<{ keywords: string[] }> = ({keywords}) => { const Tiers: React.FC<{ keywords: string[] }> = ({keywords}) => {
const [selectedTab, setSelectedTab] = useState('active-tiers'); const [selectedTab, setSelectedTab] = useState('active-tiers');
const {settings, config} = useGlobalData(); const {settings, config} = useGlobalData();
const {data: {tiers} = {}} = useBrowseTiers(); const {data: {tiers, isEnd} = {}, fetchNextPage} = useBrowseTiers();
const activeTiers = getActiveTiers(tiers || []); const activeTiers = getActiveTiers(tiers || []);
const archivedTiers = getArchivedTiers(tiers || []); const archivedTiers = getArchivedTiers(tiers || []);
const {updateRoute} = useRouting(); const {updateRoute} = useRouting();
@ -80,6 +81,7 @@ const Tiers: React.FC<{ keywords: string[] }> = ({keywords}) => {
</div> </div>
{content} {content}
{isEnd === false && <Button label='Load more' link onClick={() => fetchNextPage()} />}
</SettingGroup> </SettingGroup>
); );
}; };

View File

@ -102,8 +102,8 @@ const TipsOrDonations: React.FC<{ keywords: string[] }> = ({keywords}) => {
containerClassName='w-14' containerClassName='w-14'
fullWidth={false} fullWidth={false}
options={currencySelectGroups()} options={currencySelectGroups()}
selectedOption={donationsCurrency} selectedOption={currencySelectGroups().flatMap(group => group.options).find(option => option.value === donationsCurrency)}
onSelect={currency => updateSetting('donations_currency', currency || 'USD')} onSelect={option => updateSetting('donations_currency', option?.value || 'USD')}
/> />
)} )}
title='Suggested amount' title='Suggested amount'

View File

@ -8,7 +8,6 @@ import {MultiSelectOption} from '../../../../admin-x-ds/global/form/MultiSelect'
import {MultiValue} from 'react-select'; import {MultiValue} from 'react-select';
import {generateCode} from '../../../../utils/generateEmbedCode'; import {generateCode} from '../../../../utils/generateEmbedCode';
import {getSettingValues} from '../../../../api/settings'; import {getSettingValues} from '../../../../api/settings';
import {useBrowseLabels} from '../../../../api/labels';
import {useEffect, useState} from 'react'; import {useEffect, useState} from 'react';
import {useGlobalData} from '../../../providers/GlobalDataProvider'; import {useGlobalData} from '../../../providers/GlobalDataProvider';
@ -25,7 +24,6 @@ const EmbedSignupFormModal = NiceModal.create(() => {
const {config} = useGlobalData(); const {config} = useGlobalData();
const {localSettings, siteData} = useSettingGroup(); const {localSettings, siteData} = useSettingGroup();
const [accentColor, title, description, locale, labs, icon] = getSettingValues<string>(localSettings, ['accent_color', 'title', 'description', 'locale', 'labs', 'icon']); const [accentColor, title, description, locale, labs, icon] = getSettingValues<string>(localSettings, ['accent_color', 'title', 'description', 'locale', 'labs', 'icon']);
const {data: labels} = useBrowseLabels();
const [customColor, setCustomColor] = useState<{active: boolean}>({active: false}); const [customColor, setCustomColor] = useState<{active: boolean}>({active: false});
if (labs) { if (labs) {
@ -113,7 +111,6 @@ const EmbedSignupFormModal = NiceModal.create(() => {
handleLabelClick={addSelectedLabel} handleLabelClick={addSelectedLabel}
handleLayoutSelect={setSelectedLayout} handleLayoutSelect={setSelectedLayout}
isCopied={isCopied} isCopied={isCopied}
labels={labels?.labels || []}
selectedColor={selectedColor} selectedColor={selectedColor}
selectedLabels={selectedLabels} selectedLabels={selectedLabels}
selectedLayout={selectedLayout} selectedLayout={selectedLayout}

View File

@ -3,13 +3,15 @@ import ColorIndicator from '../../../../admin-x-ds/global/form/ColorIndicator';
import ColorPicker from '../../../../admin-x-ds/global/form/ColorPicker'; import ColorPicker from '../../../../admin-x-ds/global/form/ColorPicker';
import Form from '../../../../admin-x-ds/global/form/Form'; import Form from '../../../../admin-x-ds/global/form/Form';
import Heading from '../../../../admin-x-ds/global/Heading'; import Heading from '../../../../admin-x-ds/global/Heading';
import MultiSelect, {MultiSelectOption} from '../../../../admin-x-ds/global/form/MultiSelect'; import MultiSelect, {LoadOptions, MultiSelectOption} from '../../../../admin-x-ds/global/form/MultiSelect';
import Radio from '../../../../admin-x-ds/global/form/Radio'; import Radio from '../../../../admin-x-ds/global/form/Radio';
import React from 'react'; import React from 'react';
import StickyFooter from '../../../../admin-x-ds/global/StickyFooter'; import StickyFooter from '../../../../admin-x-ds/global/StickyFooter';
import TextArea from '../../../../admin-x-ds/global/form/TextArea'; import TextArea from '../../../../admin-x-ds/global/form/TextArea';
import useFilterableApi from '../../../../hooks/useFilterableApi';
import {Label} from '../../../../api/labels'; import {Label} from '../../../../api/labels';
import {MultiValue} from 'react-select'; import {MultiValue} from 'react-select';
import {debounce} from '../../../../utils/debounce';
export type SelectedLabelTypes = { export type SelectedLabelTypes = {
label: string; label: string;
@ -20,7 +22,6 @@ type SidebarProps = {
selectedColor?: string; selectedColor?: string;
accentColor?: string; accentColor?: string;
handleColorToggle: (e: string) => void; handleColorToggle: (e: string) => void;
labels?: Label[];
handleLabelClick: (selected: MultiValue<MultiSelectOption>) => void; handleLabelClick: (selected: MultiValue<MultiSelectOption>) => void;
selectedLabels?: SelectedLabelTypes[]; selectedLabels?: SelectedLabelTypes[];
embedScript: string; embedScript: string;
@ -36,7 +37,6 @@ const EmbedSignupSidebar: React.FC<SidebarProps> = ({selectedLayout,
accentColor, accentColor,
handleColorToggle, handleColorToggle,
selectedColor, selectedColor,
labels,
selectedLabels, selectedLabels,
handleLabelClick, handleLabelClick,
embedScript, embedScript,
@ -45,12 +45,13 @@ const EmbedSignupSidebar: React.FC<SidebarProps> = ({selectedLayout,
customColor, customColor,
setCustomColor, setCustomColor,
isCopied}) => { isCopied}) => {
const labelOptions = labels ? labels.map((l) => { const {loadData} = useFilterableApi<Label>({path: '/labels/', filterKey: 'name', responseKey: 'labels'});
return {
label: l?.name, const loadOptions: LoadOptions = async (input, callback) => {
value: l?.name const labels = await loadData(input);
callback(labels.map(label => ({label: label.name, value: label.name})));
}; };
}).filter(Boolean) : [];
return ( return (
<div className='flex h-[calc(100vh-16vmin)] max-h-[645px] flex-col justify-between overflow-y-scroll border-l border-grey-200 p-6 pb-0 dark:border-grey-900'> <div className='flex h-[calc(100vh-16vmin)] max-h-[645px] flex-col justify-between overflow-y-scroll border-l border-grey-200 p-6 pb-0 dark:border-grey-900'>
<div> <div>
@ -125,12 +126,14 @@ const EmbedSignupSidebar: React.FC<SidebarProps> = ({selectedLayout,
} }
<MultiSelect <MultiSelect
canCreate={true}
hint='Will be applied to all members signing up via this form' hint='Will be applied to all members signing up via this form'
options={labelOptions} loadOptions={debounce(loadOptions, 500)}
placeholder='Pick one or more labels (optional)' placeholder='Pick one or more labels (optional)'
title='Labels at signup' title='Labels at signup'
values={selectedLabels || []} values={selectedLabels || []}
async
canCreate
defaultOptions
onChange={handleLabelClick} onChange={handleLabelClick}
/> />
<TextArea <TextArea

View File

@ -7,7 +7,7 @@ import Select from '../../../../admin-x-ds/global/form/Select';
import TextField from '../../../../admin-x-ds/global/form/TextField'; import TextField from '../../../../admin-x-ds/global/form/TextField';
import Toggle from '../../../../admin-x-ds/global/form/Toggle'; import Toggle from '../../../../admin-x-ds/global/form/Toggle';
import clsx from 'clsx'; import clsx from 'clsx';
import handleError from '../../../../utils/handleError'; import handleError from '../../../../utils/api/handleError';
import {ReactComponent as PortalIcon1} from '../../../../assets/icons/portal-icon-1.svg'; import {ReactComponent as PortalIcon1} from '../../../../assets/icons/portal-icon-1.svg';
import {ReactComponent as PortalIcon2} from '../../../../assets/icons/portal-icon-2.svg'; import {ReactComponent as PortalIcon2} from '../../../../assets/icons/portal-icon-2.svg';
import {ReactComponent as PortalIcon3} from '../../../../assets/icons/portal-icon-3.svg'; import {ReactComponent as PortalIcon3} from '../../../../assets/icons/portal-icon-3.svg';
@ -67,6 +67,12 @@ const LookAndFeel: React.FC<{
setUploadedIcon(undefined); setUploadedIcon(undefined);
}; };
const portalButtonOptions = [
{value: 'icon-and-text', label: 'Icon and text'},
{value: 'icon-only', label: 'Icon only'},
{value: 'text-only', label: 'Text only'}
];
return <div className='mt-7'><Form> return <div className='mt-7'><Form>
<Toggle <Toggle
checked={Boolean(portalButton)} checked={Boolean(portalButton)}
@ -75,14 +81,10 @@ const LookAndFeel: React.FC<{
onChange={e => updateSetting('portal_button', e.target.checked)} onChange={e => updateSetting('portal_button', e.target.checked)}
/> />
<Select <Select
options={[ options={portalButtonOptions}
{value: 'icon-and-text', label: 'Icon and text'}, selectedOption={portalButtonOptions.find(option => option.value === portalButtonStyle)}
{value: 'icon-only', label: 'Icon only'},
{value: 'text-only', label: 'Text only'}
]}
selectedOption={portalButtonStyle as string}
title='Portal button style' title='Portal button style'
onSelect={option => updateSetting('portal_button_style', option || null)} onSelect={option => updateSetting('portal_button_style', option?.value || null)}
/> />
{portalButtonStyle?.toString()?.includes('icon') && {portalButtonStyle?.toString()?.includes('icon') &&
<div className='flex flex-col gap-2'> <div className='flex flex-col gap-2'>

View File

@ -81,10 +81,10 @@ const PortalLinks: React.FC = () => {
<span className='inline-block w-[240px] shrink-0'>Tier</span> <span className='inline-block w-[240px] shrink-0'>Tier</span>
<Select <Select
options={tierOptions} options={tierOptions}
selectedOption={selectedTier} selectedOption={tierOptions.find(option => option.value === selectedTier)}
onSelect={(value) => { onSelect={(option) => {
if (value) { if (option) {
setSelectedTier(value); setSelectedTier(option?.value);
} }
}} }}
/> />

View File

@ -6,7 +6,7 @@ import PortalPreview from './PortalPreview';
import React, {useEffect, useState} from 'react'; import React, {useEffect, useState} from 'react';
import SignupOptions from './SignupOptions'; import SignupOptions from './SignupOptions';
import TabView, {Tab} from '../../../../admin-x-ds/global/TabView'; import TabView, {Tab} from '../../../../admin-x-ds/global/TabView';
import handleError from '../../../../utils/handleError'; import handleError from '../../../../utils/api/handleError';
import useForm, {Dirtyable} from '../../../../hooks/useForm'; import useForm, {Dirtyable} from '../../../../hooks/useForm';
import useQueryParams from '../../../../hooks/useQueryParams'; import useQueryParams from '../../../../hooks/useQueryParams';
import useRouting from '../../../../hooks/useRouting'; import useRouting from '../../../../hooks/useRouting';

View File

@ -13,7 +13,7 @@ import StripeLogo from '../../../../assets/images/stripe-emblem.svg';
import TextArea from '../../../../admin-x-ds/global/form/TextArea'; import TextArea from '../../../../admin-x-ds/global/form/TextArea';
import TextField from '../../../../admin-x-ds/global/form/TextField'; import TextField from '../../../../admin-x-ds/global/form/TextField';
import Toggle from '../../../../admin-x-ds/global/form/Toggle'; import Toggle from '../../../../admin-x-ds/global/form/Toggle';
import handleError from '../../../../utils/handleError'; import handleError from '../../../../utils/api/handleError';
import useRouting from '../../../../hooks/useRouting'; import useRouting from '../../../../hooks/useRouting';
import useSettingGroup from '../../../../hooks/useSettingGroup'; import useSettingGroup from '../../../../hooks/useSettingGroup';
import {JSONError} from '../../../../utils/errors'; import {JSONError} from '../../../../utils/errors';
@ -64,7 +64,7 @@ const Connect: React.FC = () => {
const saveTier = async () => { const saveTier = async () => {
const {data} = await fetchActiveTiers(); const {data} = await fetchActiveTiers();
const tier = data?.tiers[0]; const tier = data?.pages[0].tiers[0];
if (tier) { if (tier) {
tier.monthly_price = 500; tier.monthly_price = 500;

View File

@ -13,7 +13,7 @@ import TextField from '../../../../admin-x-ds/global/form/TextField';
import TierDetailPreview from './TierDetailPreview'; import TierDetailPreview from './TierDetailPreview';
import Toggle from '../../../../admin-x-ds/global/form/Toggle'; import Toggle from '../../../../admin-x-ds/global/form/Toggle';
import URLTextField from '../../../../admin-x-ds/global/form/URLTextField'; import URLTextField from '../../../../admin-x-ds/global/form/URLTextField';
import handleError from '../../../../utils/handleError'; import handleError from '../../../../utils/api/handleError';
import useForm from '../../../../hooks/useForm'; import useForm from '../../../../hooks/useForm';
import useRouting from '../../../../hooks/useRouting'; import useRouting from '../../../../hooks/useRouting';
import useSettingGroup from '../../../../hooks/useSettingGroup'; import useSettingGroup from '../../../../hooks/useSettingGroup';
@ -223,9 +223,9 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
containerClassName='font-medium' containerClassName='font-medium'
controlClasses={{menu: 'w-14'}} controlClasses={{menu: 'w-14'}}
options={currencySelectGroups()} options={currencySelectGroups()}
selectedOption={formState.currency} selectedOption={currencySelectGroups().flatMap(group => group.options).find(option => option.value === formState.currency)}
size='xs' size='xs'
onSelect={currency => updateForm(state => ({...state, currency}))} onSelect={option => updateForm(state => ({...state, currency: option?.value}))}
/> />
</div> </div>
</div> </div>
@ -339,15 +339,21 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
}; };
const TierDetailModal: React.FC<RoutingModalProps> = ({params}) => { const TierDetailModal: React.FC<RoutingModalProps> = ({params}) => {
const {data: {tiers} = {}} = useBrowseTiers(); const {data: {tiers, isEnd} = {}, fetchNextPage} = useBrowseTiers();
let tier: Tier | undefined; let tier: Tier | undefined;
useEffect(() => {
if (params?.id && !tier && !isEnd) {
fetchNextPage();
}
}, [fetchNextPage, isEnd, params?.id, tier]);
if (params?.id) { if (params?.id) {
tier = tiers?.find(({id}) => id === params?.id); tier = tiers?.find(({id}) => id === params?.id);
if (!tier) { if (!tier) {
return; return null;
} }
} }

View File

@ -7,7 +7,7 @@ import StickyFooter from '../../../admin-x-ds/global/StickyFooter';
import TabView, {Tab} from '../../../admin-x-ds/global/TabView'; import TabView, {Tab} from '../../../admin-x-ds/global/TabView';
import ThemePreview from './designAndBranding/ThemePreview'; import ThemePreview from './designAndBranding/ThemePreview';
import ThemeSettings from './designAndBranding/ThemeSettings'; import ThemeSettings from './designAndBranding/ThemeSettings';
import handleError from '../../../utils/handleError'; import handleError from '../../../utils/api/handleError';
import useForm from '../../../hooks/useForm'; import useForm from '../../../hooks/useForm';
import useRouting from '../../../hooks/useRouting'; import useRouting from '../../../hooks/useRouting';
import {CustomThemeSetting, useBrowseCustomThemeSettings, useEditCustomThemeSettings} from '../../../api/customThemeSettings'; import {CustomThemeSetting, useBrowseCustomThemeSettings, useEditCustomThemeSettings} from '../../../api/customThemeSettings';

View File

@ -12,7 +12,7 @@ import React, {useEffect, useState} from 'react';
import TabView from '../../../admin-x-ds/global/TabView'; import TabView from '../../../admin-x-ds/global/TabView';
import ThemeInstalledModal from './theme/ThemeInstalledModal'; import ThemeInstalledModal from './theme/ThemeInstalledModal';
import ThemePreview from './theme/ThemePreview'; import ThemePreview from './theme/ThemePreview';
import handleError from '../../../utils/handleError'; import handleError from '../../../utils/api/handleError';
import useRouting from '../../../hooks/useRouting'; import useRouting from '../../../hooks/useRouting';
import {HostLimitError, useLimiter} from '../../../hooks/useLimiter'; import {HostLimitError, useLimiter} from '../../../hooks/useLimiter';
import {InstalledTheme, Theme, ThemesInstallResponseType, useBrowseThemes, useInstallTheme, useUploadTheme} from '../../../api/themes'; import {InstalledTheme, Theme, ThemesInstallResponseType, useBrowseThemes, useInstallTheme, useUploadTheme} from '../../../api/themes';

View File

@ -6,7 +6,7 @@ import React, {useRef, useState} from 'react';
import SettingGroupContent from '../../../../admin-x-ds/settings/SettingGroupContent'; import SettingGroupContent from '../../../../admin-x-ds/settings/SettingGroupContent';
import TextField from '../../../../admin-x-ds/global/form/TextField'; import TextField from '../../../../admin-x-ds/global/form/TextField';
import UnsplashSearchModal from '../../../../utils/unsplash/UnsplashSearchModal'; import UnsplashSearchModal from '../../../../utils/unsplash/UnsplashSearchModal';
import handleError from '../../../../utils/handleError'; import handleError from '../../../../utils/api/handleError';
import usePinturaEditor from '../../../../hooks/usePinturaEditor'; import usePinturaEditor from '../../../../hooks/usePinturaEditor';
import {SettingValue, getSettingValues} from '../../../../api/settings'; import {SettingValue, getSettingValues} from '../../../../api/settings';
import {debounce} from '../../../../utils/debounce'; import {debounce} from '../../../../utils/debounce';

View File

@ -7,7 +7,7 @@ import Select from '../../../../admin-x-ds/global/form/Select';
import SettingGroupContent from '../../../../admin-x-ds/settings/SettingGroupContent'; import SettingGroupContent from '../../../../admin-x-ds/settings/SettingGroupContent';
import TextField from '../../../../admin-x-ds/global/form/TextField'; import TextField from '../../../../admin-x-ds/global/form/TextField';
import Toggle from '../../../../admin-x-ds/global/form/Toggle'; import Toggle from '../../../../admin-x-ds/global/form/Toggle';
import handleError from '../../../../utils/handleError'; import handleError from '../../../../utils/api/handleError';
import {CustomThemeSetting} from '../../../../api/customThemeSettings'; import {CustomThemeSetting} from '../../../../api/customThemeSettings';
import {getImageUrl, useUploadImage} from '../../../../api/images'; import {getImageUrl, useUploadImage} from '../../../../api/images';
import {humanizeSettingKey} from '../../../../api/settings'; import {humanizeSettingKey} from '../../../../api/settings';
@ -52,9 +52,9 @@ const ThemeSetting: React.FC<{
<Select <Select
hint={setting.description} hint={setting.description}
options={setting.options.map(option => ({label: option, value: option}))} options={setting.options.map(option => ({label: option, value: option}))}
selectedOption={setting.value} selectedOption={{label: setting.value, value: setting.value}}
title={humanizeSettingKey(setting.key)} title={humanizeSettingKey(setting.key)}
onSelect={value => setSetting(value || null)} onSelect={option => setSetting(option?.value || null)}
/> />
); );
case 'color': case 'color':

View File

@ -3,7 +3,7 @@ import Modal from '../../../../admin-x-ds/global/modal/Modal';
import NiceModal, {useModal} from '@ebay/nice-modal-react'; import NiceModal, {useModal} from '@ebay/nice-modal-react';
import React from 'react'; import React from 'react';
import RecommendationReasonForm from './RecommendationReasonForm'; import RecommendationReasonForm from './RecommendationReasonForm';
import handleError from '../../../../utils/handleError'; import handleError from '../../../../utils/api/handleError';
import useForm from '../../../../hooks/useForm'; import useForm from '../../../../hooks/useForm';
import useRouting from '../../../../hooks/useRouting'; import useRouting from '../../../../hooks/useRouting';
import {EditOrAddRecommendation, useAddRecommendation} from '../../../../api/recommendations'; import {EditOrAddRecommendation, useAddRecommendation} from '../../../../api/recommendations';

View File

@ -3,7 +3,7 @@ import Modal from '../../../../admin-x-ds/global/modal/Modal';
import NiceModal, {useModal} from '@ebay/nice-modal-react'; import NiceModal, {useModal} from '@ebay/nice-modal-react';
import React from 'react'; import React from 'react';
import RecommendationReasonForm from './RecommendationReasonForm'; import RecommendationReasonForm from './RecommendationReasonForm';
import handleError from '../../../../utils/handleError'; import handleError from '../../../../utils/api/handleError';
import useForm from '../../../../hooks/useForm'; import useForm from '../../../../hooks/useForm';
import useRouting from '../../../../hooks/useRouting'; import useRouting from '../../../../hooks/useRouting';
import {Recommendation, useDeleteRecommendation, useEditRecommendation} from '../../../../api/recommendations'; import {Recommendation, useDeleteRecommendation, useEditRecommendation} from '../../../../api/recommendations';

View File

@ -6,7 +6,7 @@ import Menu from '../../../../admin-x-ds/global/Menu';
import ModalPage from '../../../../admin-x-ds/global/modal/ModalPage'; import ModalPage from '../../../../admin-x-ds/global/modal/ModalPage';
import NiceModal from '@ebay/nice-modal-react'; import NiceModal from '@ebay/nice-modal-react';
import React from 'react'; import React from 'react';
import handleError from '../../../../utils/handleError'; import handleError from '../../../../utils/api/handleError';
import {Theme, isActiveTheme, isDefaultTheme, isDeletableTheme, useActivateTheme, useDeleteTheme} from '../../../../api/themes'; import {Theme, isActiveTheme, isDefaultTheme, isDeletableTheme, useActivateTheme, useDeleteTheme} from '../../../../api/themes';
import {downloadFile, getGhostPaths} from '../../../../utils/helpers'; import {downloadFile, getGhostPaths} from '../../../../utils/helpers';

View File

@ -4,7 +4,7 @@ import List from '../../../../admin-x-ds/global/List';
import ListItem from '../../../../admin-x-ds/global/ListItem'; import ListItem from '../../../../admin-x-ds/global/ListItem';
import NiceModal from '@ebay/nice-modal-react'; import NiceModal from '@ebay/nice-modal-react';
import React, {ReactNode, useState} from 'react'; import React, {ReactNode, useState} from 'react';
import handleError from '../../../../utils/handleError'; import handleError from '../../../../utils/api/handleError';
import {ConfirmationModalContent} from '../../../../admin-x-ds/global/modal/ConfirmationModal'; import {ConfirmationModalContent} from '../../../../admin-x-ds/global/modal/ConfirmationModal';
import {InstalledTheme, ThemeProblem, useActivateTheme} from '../../../../api/themes'; import {InstalledTheme, ThemeProblem, useActivateTheme} from '../../../../api/themes';
import {showToast} from '../../../../admin-x-ds/global/Toast'; import {showToast} from '../../../../admin-x-ds/global/Toast';

View File

@ -0,0 +1,66 @@
import {Meta, apiUrl, useFetchApi} from '../utils/api/hooks';
import {useRef} from 'react';
const escapeNqlString = (value: string) => {
return '\'' + value.replace(/'/g, '\\\'') + '\'';
};
const useFilterableApi = <
Data extends {id: string} & {[Key in FilterKey]: string},
ResponseKey extends string = string,
FilterKey extends string = string
>({path, filterKey, responseKey, limit = 20}: {
path: string
filterKey: FilterKey
responseKey: ResponseKey
limit?: number
}) => {
const fetchApi = useFetchApi();
const result = useRef<{
data?: Data[];
allLoaded?: boolean;
lastInput?: string;
}>({});
const loadData = async (input: string) => {
if ((result.current.allLoaded || result.current.lastInput === input) && result.current.data) {
return result.current.data.filter(item => item[filterKey]?.toLowerCase().includes(input.toLowerCase()));
}
const response = await fetchApi<{meta?: Meta} & {[k in ResponseKey]: Data[]}>(apiUrl(path, {
filter: input ? `${filterKey}:~${escapeNqlString(input)}` : '',
limit: limit.toString()
}));
result.current.data = response[responseKey];
result.current.allLoaded = !input && !response.meta?.pagination.next;
result.current.lastInput = input;
return response[responseKey];
};
return {
loadData,
loadInitialValues: async (ids: string[]) => {
await loadData('');
const data = [...(result.current.data || [])];
const missingIds = ids.filter(id => !result.current.data?.find(({id: dataId}) => dataId === id));
if (missingIds.length) {
const additionalData = await fetchApi<{meta?: Meta} & {[k in ResponseKey]: Data[]}>(apiUrl(path, {
filter: `id:[${missingIds.join(',')}]`,
limit: 'all'
}));
data.push(...additionalData[responseKey]);
}
return ids.map(id => data.find(({id: dataId}) => dataId === id)!);
}
};
};
export default useFilterableApi;

View File

@ -61,7 +61,7 @@ export const useLimiter = () => {
enabled: false enabled: false
}); });
const {refetch: fetchNewsletters} = useBrowseNewsletters({ const {refetch: fetchNewsletters} = useBrowseNewsletters({
searchParams: {filter: 'status:active', limit: 'all'}, searchParams: {filter: 'status:active', limit: '1'},
enabled: false enabled: false
}); });
@ -74,16 +74,17 @@ export const useLimiter = () => {
}, [config.hostSettings?.billing]); }, [config.hostSettings?.billing]);
return useMemo(() => { return useMemo(() => {
const limits = config.hostSettings?.limits as LimiterLimits; if (!LimitService || !config.hostSettings?.limits || isLoading) {
if (!LimitService || !limits || isLoading) {
return; return;
} }
const limits = {...config.hostSettings.limits} as LimiterLimits;
const limiter = new LimitService(); const limiter = new LimitService();
if (limits.staff) { if (limits.staff) {
limits.staff.currentCountQuery = async () => { limits.staff.currentCountQuery = async () => {
// useStaffUsers will only return the first 100 users by default, but we can assume
// that either there's no limit or the limit is <100
const staffUsers = users.filter(u => u.status !== 'inactive' && !contributorUsers.includes(u)); const staffUsers = users.filter(u => u.status !== 'inactive' && !contributorUsers.includes(u));
const staffInvites = invites.filter(i => i.role !== 'Contributor'); const staffInvites = invites.filter(i => i.role !== 'Contributor');
@ -100,8 +101,8 @@ export const useLimiter = () => {
if (limits.newsletters) { if (limits.newsletters) {
limits.newsletters.currentCountQuery = async () => { limits.newsletters.currentCountQuery = async () => {
const {data: {newsletters} = {newsletters: []}} = await fetchNewsletters(); const {data: {pages} = {pages: []}} = await fetchNewsletters();
return newsletters?.length || 0; return pages[0].meta?.pagination.total || 0;
}; };
} }

View File

@ -1,4 +1,4 @@
import {Meta} from '../utils/apiRequests'; import {Meta} from '../utils/api/hooks';
import {useEffect, useState} from 'react'; import {useEffect, useState} from 'react';
export interface PaginationData { export interface PaginationData {

View File

@ -1,5 +1,5 @@
import React, {useEffect, useRef, useState} from 'react'; import React, {useEffect, useRef, useState} from 'react';
import handleError from '../utils/handleError'; import handleError from '../utils/api/handleError';
import useForm, {ErrorMessages, SaveState} from './useForm'; import useForm, {ErrorMessages, SaveState} from './useForm';
import useGlobalDirtyState from './useGlobalDirtyState'; import useGlobalDirtyState from './useGlobalDirtyState';
import {Setting, SettingValue, useEditSettings} from '../api/settings'; import {Setting, SettingValue, useEditSettings} from '../api/settings';

View File

@ -14,6 +14,8 @@ export type UsersHook = {
contributorUsers: User[]; contributorUsers: User[];
currentUser: User|null; currentUser: User|null;
isLoading: boolean; isLoading: boolean;
hasNextPage?: boolean;
fetchNextPage: () => void;
}; };
function getUsersByRole(users: User[], role: string): User[] { function getUsersByRole(users: User[], role: string): User[] {
@ -30,7 +32,7 @@ function getOwnerUser(users: User[]): User {
const useStaffUsers = (): UsersHook => { const useStaffUsers = (): UsersHook => {
const {currentUser} = useGlobalData(); const {currentUser} = useGlobalData();
const {data: {users} = {users: []}, isLoading: usersLoading} = useBrowseUsers(); const {data: {users, isEnd} = {users: []}, isLoading: usersLoading, fetchNextPage} = useBrowseUsers();
const {data: {invites} = {invites: []}, isLoading: invitesLoading} = useBrowseInvites(); const {data: {invites} = {invites: []}, isLoading: invitesLoading} = useBrowseInvites();
const {data: {roles} = {}, isLoading: rolesLoading} = useBrowseRoles(); const {data: {roles} = {}, isLoading: rolesLoading} = useBrowseRoles();
@ -58,7 +60,9 @@ const useStaffUsers = (): UsersHook => {
contributorUsers, contributorUsers,
currentUser, currentUser,
invites: mappedInvites, invites: mappedInvites,
isLoading: usersLoading || invitesLoading || rolesLoading isLoading: usersLoading || invitesLoading || rolesLoading,
hasNextPage: isEnd,
fetchNextPage
}; };
}; };

View File

@ -1,6 +1,6 @@
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import {APIError, ValidationError} from './errors'; import {APIError, ValidationError} from '../errors';
import {showToast} from '../admin-x-ds/global/Toast'; import {showToast} from '../../admin-x-ds/global/Toast';
/** /**
* Generic error handling for API calls. This is enabled by default for queries (can be disabled by * Generic error handling for API calls. This is enabled by default for queries (can be disabled by

View File

@ -1,4 +1,4 @@
import {APIError, EmailError, ErrorResponse, HostLimitError, JSONError, MaintenanceError, RequestEntityTooLargeError, ServerUnreachableError, ThemeValidationError, UnsupportedMediaTypeError, ValidationError, VersionMismatchError} from './errors'; import {APIError, EmailError, ErrorResponse, HostLimitError, JSONError, MaintenanceError, RequestEntityTooLargeError, ServerUnreachableError, ThemeValidationError, UnsupportedMediaTypeError, ValidationError, VersionMismatchError} from '../errors';
const handleResponse = async (response: Response) => { const handleResponse = async (response: Response) => {
if (response.status === 0) { if (response.status === 0) {

View File

@ -1,12 +1,12 @@
import * as Sentry from '@sentry/react'; import * as Sentry from '@sentry/react';
import handleError from './handleError'; import handleError from './handleError';
import handleResponse from './handleResponse'; import handleResponse from './handleResponse';
import {APIError, MaintenanceError, ServerUnreachableError, TimeoutError} from './errors'; import {APIError, MaintenanceError, ServerUnreachableError, TimeoutError} from '../errors';
import {QueryClient, UseInfiniteQueryOptions, UseQueryOptions, useInfiniteQuery, useMutation, useQuery, useQueryClient} from '@tanstack/react-query'; import {QueryClient, UseInfiniteQueryOptions, UseQueryOptions, useInfiniteQuery, useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import {getGhostPaths} from './helpers'; import {getGhostPaths} from '../helpers';
import {useEffect, useMemo} from 'react'; import {useEffect, useMemo} from 'react';
import {usePage, usePagination} from '../hooks/usePagination'; import {usePage, usePagination} from '../../hooks/usePagination';
import {useSentryDSN, useServices} from '../components/providers/ServiceProvider'; import {useSentryDSN, useServices} from '../../components/providers/ServiceProvider';
export interface Meta { export interface Meta {
pagination: { pagination: {
@ -33,7 +33,8 @@ export const useFetchApi = () => {
const {ghostVersion} = useServices(); const {ghostVersion} = useServices();
const sentrydsn = useSentryDSN(); const sentrydsn = useSentryDSN();
return async (endpoint: string | URL, options: RequestOptions = {}) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any
return async <Response = any>(endpoint: string | URL, options: RequestOptions = {}) => {
// By default, we set the Content-Type header to application/json // By default, we set the Content-Type header to application/json
const defaultHeaders: Record<string, string> = { const defaultHeaders: Record<string, string> = {
'app-pragma': 'no-cache', 'app-pragma': 'no-cache',
@ -91,7 +92,7 @@ export const useFetchApi = () => {
Sentry.captureMessage('Request took multiple attempts', {extra: {attempts, retryingMs, endpoint: endpoint.toString()}}); Sentry.captureMessage('Request took multiple attempts', {extra: {attempts, retryingMs, endpoint: endpoint.toString()}});
} }
return handleResponse(response); return handleResponse(response) as Response;
} catch (error) { } catch (error) {
retryingMs = Date.now() - startTime; retryingMs = Date.now() - startTime;
@ -160,7 +161,7 @@ export const createQuery = <ResponseData>(options: QueryOptions<ResponseData>) =
const data = useMemo(() => ( const data = useMemo(() => (
(result.data && options.returnData) ? options.returnData(result.data) : result.data) (result.data && options.returnData) ? options.returnData(result.data) : result.data)
, [result]); , [result.data]);
useEffect(() => { useEffect(() => {
if (result.error && query.defaultErrorHandler !== false) { if (result.error && query.defaultErrorHandler !== false) {
@ -217,25 +218,28 @@ export const createPaginatedQuery = <ResponseData extends {meta?: Meta}>(options
type InfiniteQueryOptions<ResponseData> = Omit<QueryOptions<ResponseData>, 'returnData'> & { type InfiniteQueryOptions<ResponseData> = Omit<QueryOptions<ResponseData>, 'returnData'> & {
returnData: NonNullable<QueryOptions<ResponseData>['returnData']> returnData: NonNullable<QueryOptions<ResponseData>['returnData']>
defaultNextPageParams?: (data: ResponseData, params: Record<string, string>) => Record<string, string>;
} }
type InfiniteQueryHookOptions<ResponseData> = UseInfiniteQueryOptions<ResponseData> & { type InfiniteQueryHookOptions<ResponseData> = UseInfiniteQueryOptions<ResponseData> & {
searchParams?: Record<string, string>; searchParams?: Record<string, string>;
defaultErrorHandler?: boolean; defaultErrorHandler?: boolean;
getNextPageParams: (data: ResponseData, params: Record<string, string>) => Record<string, string>|undefined; getNextPageParams?: (data: ResponseData, params: Record<string, string>) => Record<string, string> | undefined;
}; };
export const createInfiniteQuery = <ResponseData>(options: InfiniteQueryOptions<ResponseData>) => ({searchParams, getNextPageParams, ...query}: InfiniteQueryHookOptions<ResponseData>) => { export const createInfiniteQuery = <ResponseData>(options: InfiniteQueryOptions<ResponseData>) => ({searchParams, getNextPageParams, ...query}: InfiniteQueryHookOptions<ResponseData> = {}) => {
const fetchApi = useFetchApi(); const fetchApi = useFetchApi();
const nextPageParams = getNextPageParams || options.defaultNextPageParams || (() => ({}));
const result = useInfiniteQuery<ResponseData>({ const result = useInfiniteQuery<ResponseData>({
queryKey: [options.dataType, apiUrl(options.path, searchParams || options.defaultSearchParams)], queryKey: [options.dataType, apiUrl(options.path, searchParams || options.defaultSearchParams)],
queryFn: ({pageParam}) => fetchApi(apiUrl(options.path, pageParam || searchParams || options.defaultSearchParams)), queryFn: ({pageParam}) => fetchApi(apiUrl(options.path, pageParam || searchParams || options.defaultSearchParams)),
getNextPageParam: data => getNextPageParams(data, searchParams || options.defaultSearchParams || {}), getNextPageParam: data => nextPageParams(data, searchParams || options.defaultSearchParams || {}),
...query ...query
}); });
const data = useMemo(() => result.data && options.returnData(result.data), [result]); const data = useMemo(() => result.data && options.returnData(result.data), [result.data]);
useEffect(() => { useEffect(() => {
if (result.error && query.defaultErrorHandler !== false) { if (result.error && query.defaultErrorHandler !== false) {
@ -280,7 +284,7 @@ const mutate = <ResponseData, Payload>({fetchApi, path, payload, searchParams, o
requestBody = JSON.stringify(generatedBody); requestBody = JSON.stringify(generatedBody);
} }
return fetchApi(url, { return fetchApi<ResponseData>(url, {
body: requestBody, body: requestBody,
...requestOptions ...requestOptions
}); });

View File

@ -0,0 +1,86 @@
import {InfiniteData} from '@tanstack/react-query';
export const insertToQueryCache = <ResponseData>(field: string, recordsToInsert?: (response: ResponseData) => unknown[]) => {
return (newData: ResponseData, currentData: unknown) => {
if (!currentData) {
return currentData;
}
const getRecords = recordsToInsert || ((response: ResponseData) => (response as Record<string, unknown[]>)[field]);
if (typeof currentData === 'object' && 'pages' in currentData) {
const {pages} = currentData as InfiniteData<ResponseData>;
const lastPage = pages.at(-1)!;
return {
...currentData,
pages: pages.slice(0, -1).concat({
...lastPage,
[field]: (lastPage as Record<string, unknown[]>)[field].concat(getRecords(newData))
})
};
}
return {
...currentData,
[field]: (currentData as Record<string, unknown[]>)[field].concat(getRecords(newData))
};
};
};
export const updateQueryCache = <ResponseData>(field: string, updatedRecords?: (response: ResponseData) => Record<string, unknown>) => {
return (newData: ResponseData, currentData: unknown) => {
if (!currentData) {
return currentData;
}
const getRecords = updatedRecords || ((response: ResponseData) => {
const records = (response as Record<string, {id: string}[]>)[field];
return records.reduce((result, record) => ({...result, [record.id]: record}), {} as Record<string, unknown>);
});
const updated = getRecords(newData);
if (typeof currentData === 'object' && 'pages' in currentData) {
const {pages} = currentData as InfiniteData<ResponseData>;
return {
...currentData,
pages: pages.map(page => ({
...page,
[field]: (page as Record<string, {id: string}[]>)[field].map(current => updated[current.id] || current)
}))
};
}
return {
...currentData,
[field]: (currentData as Record<string, {id: string}[]>)[field].map(current => updated[current.id] || current)
};
};
};
export const deleteFromQueryCache = <ResponseData, Payload>(field: string, idsFromPayload?: (payload: Payload) => string[]) => {
return (_: ResponseData, currentData: unknown, payload: Payload) => {
if (!currentData) {
return currentData;
}
const deletedIds = idsFromPayload?.(payload) || [payload as string];
if (typeof currentData === 'object' && 'pages' in currentData) {
const {pages} = currentData as InfiniteData<ResponseData>;
return {
...currentData,
pages: pages.map(page => ({
...page,
[field]: (page as Record<string, {id: string}[]>)[field].filter(current => !deletedIds.includes(current.id))
}))
};
}
return {
...currentData,
[field]: (currentData as Record<string, {id: string}[]>)[field].filter(current => !deletedIds.includes(current.id))
};
};
};

View File

@ -78,3 +78,7 @@ export function downloadFromEndpoint(path: string) {
export function numberWithCommas(x: number) { export function numberWithCommas(x: number) {
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
} }
export function isObjectId(value: string) {
return /^[a-z0-9]{24}$/.test(value);
}

View File

@ -58,9 +58,9 @@ test.describe('Default recipient settings', async () => {
test('Supports selecting specific tiers, labels and offers', async ({page}) => { test('Supports selecting specific tiers, labels and offers', async ({page}) => {
const {lastApiRequests} = await mockApi({page, requests: { const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests, ...globalDataRequests,
browseTiers: {method: 'GET', path: '/tiers/?limit=all', response: responseFixtures.tiers}, browseTiers: {method: 'GET', path: '/tiers/?filter=&limit=20', response: responseFixtures.tiers},
browseLabels: {method: 'GET', path: '/labels/?limit=all', response: responseFixtures.labels}, browseLabels: {method: 'GET', path: '/labels/?filter=&limit=20', response: responseFixtures.labels},
browseOffers: {method: 'GET', path: '/offers/?limit=all', response: responseFixtures.offers}, browseOffers: {method: 'GET', path: '/offers/?filter=&limit=20', response: responseFixtures.offers},
editSettings: {method: 'PUT', path: '/settings/', response: updatedSettingsResponse([ editSettings: {method: 'PUT', path: '/settings/', response: updatedSettingsResponse([
{ {
key: 'editor_default_email_recipients', key: 'editor_default_email_recipients',

View File

@ -5,7 +5,7 @@ test.describe('Newsletter settings', async () => {
test('Supports creating a new newsletter', async ({page}) => { test('Supports creating a new newsletter', async ({page}) => {
const {lastApiRequests} = await mockApi({page, requests: { const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests, ...globalDataRequests,
browseNewsletters: {method: 'GET', path: '/newsletters/?include=count.active_members%2Ccount.posts&limit=all', response: responseFixtures.newsletters}, browseNewsletters: {method: 'GET', path: '/newsletters/?include=count.active_members%2Ccount.posts&limit=20', response: responseFixtures.newsletters},
addNewsletter: {method: 'POST', path: '/newsletters/?opt_in_existing=true&include=count.active_members%2Ccount.posts', response: {newsletters: [{ addNewsletter: {method: 'POST', path: '/newsletters/?opt_in_existing=true&include=count.active_members%2Ccount.posts', response: {newsletters: [{
id: 'new-newsletter', id: 'new-newsletter',
name: 'New newsletter', name: 'New newsletter',
@ -48,7 +48,7 @@ test.describe('Newsletter settings', async () => {
test('Supports updating a newsletter', async ({page}) => { test('Supports updating a newsletter', async ({page}) => {
const {lastApiRequests} = await mockApi({page, requests: { const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests, ...globalDataRequests,
browseNewsletters: {method: 'GET', path: '/newsletters/?include=count.active_members%2Ccount.posts&limit=all', response: responseFixtures.newsletters}, browseNewsletters: {method: 'GET', path: '/newsletters/?include=count.active_members%2Ccount.posts&limit=20', response: responseFixtures.newsletters},
editNewsletter: {method: 'PUT', path: `/newsletters/${responseFixtures.newsletters.newsletters[0].id}/?include=count.active_members%2Ccount.posts`, response: { editNewsletter: {method: 'PUT', path: `/newsletters/${responseFixtures.newsletters.newsletters[0].id}/?include=count.active_members%2Ccount.posts`, response: {
newsletters: [{ newsletters: [{
...responseFixtures.newsletters.newsletters[0], ...responseFixtures.newsletters.newsletters[0],
@ -93,7 +93,7 @@ test.describe('Newsletter settings', async () => {
test('Displays a prompt when email verification is required', async ({page}) => { test('Displays a prompt when email verification is required', async ({page}) => {
await mockApi({page, requests: { await mockApi({page, requests: {
...globalDataRequests, ...globalDataRequests,
browseNewsletters: {method: 'GET', path: '/newsletters/?include=count.active_members%2Ccount.posts&limit=all', response: responseFixtures.newsletters}, browseNewsletters: {method: 'GET', path: '/newsletters/?include=count.active_members%2Ccount.posts&limit=20', response: responseFixtures.newsletters},
editNewsletter: {method: 'PUT', path: `/newsletters/${responseFixtures.newsletters.newsletters[0].id}/?include=count.active_members%2Ccount.posts`, response: { editNewsletter: {method: 'PUT', path: `/newsletters/${responseFixtures.newsletters.newsletters[0].id}/?include=count.active_members%2Ccount.posts`, response: {
newsletters: [responseFixtures.newsletters.newsletters[0]], newsletters: [responseFixtures.newsletters.newsletters[0]],
meta: { meta: {
@ -120,7 +120,7 @@ test.describe('Newsletter settings', async () => {
test('Supports archiving newsletters', async ({page}) => { test('Supports archiving newsletters', async ({page}) => {
const activate = await mockApi({page, requests: { const activate = await mockApi({page, requests: {
...globalDataRequests, ...globalDataRequests,
browseNewsletters: {method: 'GET', path: '/newsletters/?include=count.active_members%2Ccount.posts&limit=all', response: responseFixtures.newsletters}, browseNewsletters: {method: 'GET', path: '/newsletters/?include=count.active_members%2Ccount.posts&limit=20', response: responseFixtures.newsletters},
editNewsletter: {method: 'PUT', path: `/newsletters/${responseFixtures.newsletters.newsletters[1].id}/?include=count.active_members%2Ccount.posts`, response: { editNewsletter: {method: 'PUT', path: `/newsletters/${responseFixtures.newsletters.newsletters[1].id}/?include=count.active_members%2Ccount.posts`, response: {
newsletters: [{ newsletters: [{
...responseFixtures.newsletters.newsletters[1], ...responseFixtures.newsletters.newsletters[1],
@ -157,7 +157,7 @@ test.describe('Newsletter settings', async () => {
const archive = await mockApi({page, requests: { const archive = await mockApi({page, requests: {
...globalDataRequests, ...globalDataRequests,
browseNewsletters: {method: 'GET', path: '/newsletters/?include=count.active_members%2Ccount.posts&limit=all', response: responseFixtures.newsletters}, browseNewsletters: {method: 'GET', path: '/newsletters/?include=count.active_members%2Ccount.posts&limit=20', response: responseFixtures.newsletters},
editNewsletter: {method: 'PUT', path: `/newsletters/${responseFixtures.newsletters.newsletters[0].id}/?include=count.active_members%2Ccount.posts`, response: { editNewsletter: {method: 'PUT', path: `/newsletters/${responseFixtures.newsletters.newsletters[0].id}/?include=count.active_members%2Ccount.posts`, response: {
newsletters: [{ newsletters: [{
...responseFixtures.newsletters.newsletters[0], ...responseFixtures.newsletters.newsletters[0],
@ -206,7 +206,7 @@ test.describe('Newsletter settings', async () => {
} }
} }
}, },
browseNewsletters: {method: 'GET', path: '/newsletters/?include=count.active_members%2Ccount.posts&limit=all', response: responseFixtures.newsletters} browseNewsletters: {method: 'GET', path: '/newsletters/?include=count.active_members%2Ccount.posts&limit=20', response: responseFixtures.newsletters}
}}); }});
await page.goto('/'); await page.goto('/');

View File

@ -7,7 +7,7 @@ test.describe('User actions', async () => {
const {lastApiRequests} = await mockApi({page, requests: { const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests, ...globalDataRequests,
browseUsers: {method: 'GET', path: '/users/?limit=all&include=roles', response: responseFixtures.users}, browseUsers: {method: 'GET', path: '/users/?limit=100&include=roles', response: responseFixtures.users},
editUser: {method: 'PUT', path: `/users/${userToEdit.id}/?include=roles`, response: { editUser: {method: 'PUT', path: `/users/${userToEdit.id}/?include=roles`, response: {
users: [{ users: [{
...userToEdit, ...userToEdit,
@ -50,7 +50,7 @@ test.describe('User actions', async () => {
const {lastApiRequests} = await mockApi({page, requests: { const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests, ...globalDataRequests,
browseUsers: {method: 'GET', path: '/users/?limit=all&include=roles', response: { browseUsers: {method: 'GET', path: '/users/?limit=100&include=roles', response: {
users: [ users: [
...responseFixtures.users.users.filter(user => user.email !== 'author@test.com'), ...responseFixtures.users.users.filter(user => user.email !== 'author@test.com'),
{ {
@ -103,7 +103,7 @@ test.describe('User actions', async () => {
const {lastApiRequests} = await mockApi({page, requests: { const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests, ...globalDataRequests,
browseUsers: {method: 'GET', path: '/users/?limit=all&include=roles', response: responseFixtures.users}, browseUsers: {method: 'GET', path: '/users/?limit=100&include=roles', response: responseFixtures.users},
deleteUser: {method: 'DELETE', path: `/users/${authorUser.id}/`, response: {}} deleteUser: {method: 'DELETE', path: `/users/${authorUser.id}/`, response: {}}
}}); }});
@ -151,7 +151,7 @@ test.describe('User actions', async () => {
const {lastApiRequests} = await mockApi({page, requests: { const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests, ...globalDataRequests,
browseUsers: {method: 'GET', path: '/users/?limit=all&include=roles', response: responseFixtures.users}, browseUsers: {method: 'GET', path: '/users/?limit=100&include=roles', response: responseFixtures.users},
editUser: {method: 'PUT', path: /^\/users\/\w{24}\/\?include=roles$/, response: responseFixtures.users}, editUser: {method: 'PUT', path: /^\/users\/\w{24}\/\?include=roles$/, response: responseFixtures.users},
makeOwner: {method: 'PUT', path: '/users/owner/', response: makeOwnerResponse} makeOwner: {method: 'PUT', path: '/users/owner/', response: makeOwnerResponse}
}}); }});
@ -206,7 +206,7 @@ test.describe('User actions', async () => {
await mockApi({page, requests: { await mockApi({page, requests: {
...globalDataRequests, ...globalDataRequests,
...limitRequests, ...limitRequests,
browseUsers: {method: 'GET', path: '/users/?limit=all&include=roles', response: { browseUsers: {method: 'GET', path: '/users/?limit=100&include=roles', response: {
users: [ users: [
...responseFixtures.users.users.filter(user => user.email !== 'author@test.com'), ...responseFixtures.users.users.filter(user => user.email !== 'author@test.com'),
{ {

View File

@ -8,7 +8,7 @@ test.describe('User invitations', async () => {
const {lastApiRequests} = await mockApi({page, requests: { const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests, ...globalDataRequests,
browseUsers: {method: 'GET', path: '/users/?limit=all&include=roles', response: responseFixtures.users}, browseUsers: {method: 'GET', path: '/users/?limit=100&include=roles', response: responseFixtures.users},
browseInvites: {method: 'GET', path: '/invites/', response: responseFixtures.invites}, browseInvites: {method: 'GET', path: '/invites/', response: responseFixtures.invites},
browseRoles: {method: 'GET', path: '/roles/?limit=all', response: responseFixtures.roles}, browseRoles: {method: 'GET', path: '/roles/?limit=all', response: responseFixtures.roles},
browseAssignableRoles: {method: 'GET', path: '/roles/?limit=all&permissions=assign', response: responseFixtures.roles}, browseAssignableRoles: {method: 'GET', path: '/roles/?limit=all&permissions=assign', response: responseFixtures.roles},
@ -61,7 +61,7 @@ test.describe('User invitations', async () => {
test('Supports resending invitations', async ({page}) => { test('Supports resending invitations', async ({page}) => {
const {lastApiRequests} = await mockApi({page, requests: { const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests, ...globalDataRequests,
browseUsers: {method: 'GET', path: '/users/?limit=all&include=roles', response: responseFixtures.users}, browseUsers: {method: 'GET', path: '/users/?limit=100&include=roles', response: responseFixtures.users},
browseInvites: {method: 'GET', path: '/invites/', response: responseFixtures.invites}, browseInvites: {method: 'GET', path: '/invites/', response: responseFixtures.invites},
deleteInvite: {method: 'DELETE', path: `/invites/${responseFixtures.invites.invites[0].id}/`, response: {}}, deleteInvite: {method: 'DELETE', path: `/invites/${responseFixtures.invites.invites[0].id}/`, response: {}},
addInvite: {method: 'POST', path: '/invites/', response: responseFixtures.invites} addInvite: {method: 'POST', path: '/invites/', response: responseFixtures.invites}
@ -97,7 +97,7 @@ test.describe('User invitations', async () => {
test('Supports revoking invitations', async ({page}) => { test('Supports revoking invitations', async ({page}) => {
const {lastApiRequests} = await mockApi({page, requests: { const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests, ...globalDataRequests,
browseUsers: {method: 'GET', path: '/users/?limit=all&include=roles', response: responseFixtures.users}, browseUsers: {method: 'GET', path: '/users/?limit=100&include=roles', response: responseFixtures.users},
browseInvites: {method: 'GET', path: '/invites/', response: responseFixtures.invites}, browseInvites: {method: 'GET', path: '/invites/', response: responseFixtures.invites},
deleteInvite: {method: 'DELETE', path: `/invites/${responseFixtures.invites.invites[0].id}/`, response: {}} deleteInvite: {method: 'DELETE', path: `/invites/${responseFixtures.invites.invites[0].id}/`, response: {}}
}}); }});

View File

@ -7,7 +7,7 @@ test.describe('User profile', async () => {
const {lastApiRequests} = await mockApi({page, requests: { const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests, ...globalDataRequests,
browseUsers: {method: 'GET', path: '/users/?limit=all&include=roles', response: responseFixtures.users}, browseUsers: {method: 'GET', path: '/users/?limit=100&include=roles', response: responseFixtures.users},
editUser: {method: 'PUT', path: `/users/${userToEdit.id}/?include=roles`, response: { editUser: {method: 'PUT', path: `/users/${userToEdit.id}/?include=roles`, response: {
users: [{ users: [{
...userToEdit, ...userToEdit,
@ -68,7 +68,7 @@ test.describe('User profile', async () => {
test('Supports changing password', async ({page}) => { test('Supports changing password', async ({page}) => {
const {lastApiRequests} = await mockApi({page, requests: { const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests, ...globalDataRequests,
browseUsers: {method: 'GET', path: '/users/?limit=all&include=roles', response: responseFixtures.users}, browseUsers: {method: 'GET', path: '/users/?limit=100&include=roles', response: responseFixtures.users},
updatePassword: {method: 'PUT', path: '/users/password/', response: {}} updatePassword: {method: 'PUT', path: '/users/password/', response: {}}
}}); }});
@ -109,7 +109,7 @@ test.describe('User profile', async () => {
const {lastApiRequests} = await mockApi({page, requests: { const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests, ...globalDataRequests,
browseUsers: {method: 'GET', path: '/users/?limit=all&include=roles', response: responseFixtures.users}, browseUsers: {method: 'GET', path: '/users/?limit=100&include=roles', response: responseFixtures.users},
uploadImage: {method: 'POST', path: '/images/upload/', response: {images: [{url: 'http://example.com/image.png', ref: null}]}}, uploadImage: {method: 'POST', path: '/images/upload/', response: {images: [{url: 'http://example.com/image.png', ref: null}]}},
editUser: {method: 'PUT', path: `/users/${userToEdit.id}/?include=roles`, response: { editUser: {method: 'PUT', path: `/users/${userToEdit.id}/?include=roles`, response: {
users: [{ users: [{

View File

@ -5,7 +5,7 @@ test.describe('User roles', async () => {
test('Shows users under their role', async ({page}) => { test('Shows users under their role', async ({page}) => {
await mockApi({page, requests: { await mockApi({page, requests: {
...globalDataRequests, ...globalDataRequests,
browseUsers: {method: 'GET', path: '/users/?limit=all&include=roles', response: responseFixtures.users} browseUsers: {method: 'GET', path: '/users/?limit=100&include=roles', response: responseFixtures.users}
}}); }});
await page.goto('/'); await page.goto('/');
@ -42,7 +42,7 @@ test.describe('User roles', async () => {
const {lastApiRequests} = await mockApi({page, requests: { const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests, ...globalDataRequests,
browseUsers: {method: 'GET', path: '/users/?limit=all&include=roles', response: responseFixtures.users}, browseUsers: {method: 'GET', path: '/users/?limit=100&include=roles', response: responseFixtures.users},
browseRoles: {method: 'GET', path: '/roles/?limit=all', response: responseFixtures.roles}, browseRoles: {method: 'GET', path: '/roles/?limit=all', response: responseFixtures.roles},
browseAssignableRoles: {method: 'GET', path: '/roles/?limit=all&permissions=assign', response: responseFixtures.roles}, browseAssignableRoles: {method: 'GET', path: '/roles/?limit=all&permissions=assign', response: responseFixtures.roles},
editUser: {method: 'PUT', path: `/users/${userToEdit.id}/?include=roles`, response: { editUser: {method: 'PUT', path: `/users/${userToEdit.id}/?include=roles`, response: {

View File

@ -46,7 +46,7 @@ test.describe('Access settings', async () => {
test('Supports selecting specific tiers', async ({page}) => { test('Supports selecting specific tiers', async ({page}) => {
const {lastApiRequests} = await mockApi({page, requests: { const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests, ...globalDataRequests,
browseTiers: {method: 'GET', path: '/tiers/?limit=all', response: responseFixtures.tiers}, browseTiers: {method: 'GET', path: '/tiers/?limit=20', response: responseFixtures.tiers},
editSettings: {method: 'PUT', path: '/settings/', response: updatedSettingsResponse([ editSettings: {method: 'PUT', path: '/settings/', response: updatedSettingsResponse([
{key: 'default_content_visibility', value: 'tiers'}, {key: 'default_content_visibility', value: 'tiers'},
{key: 'default_content_visibility_tiers', value: JSON.stringify(responseFixtures.tiers.tiers.map(tier => tier.id))} {key: 'default_content_visibility_tiers', value: JSON.stringify(responseFixtures.tiers.tiers.map(tier => tier.id))}

View File

@ -13,7 +13,7 @@ test.describe('Tier settings', async () => {
await mockApi({page, requests: { await mockApi({page, requests: {
...globalDataRequests, ...globalDataRequests,
browseSettings: {...globalDataRequests.browseSettings, response: settingsWithStripe}, browseSettings: {...globalDataRequests.browseSettings, response: settingsWithStripe},
browseTiers: {method: 'GET', path: '/tiers/?limit=all', response: responseFixtures.tiers} browseTiers: {method: 'GET', path: '/tiers/?limit=20', response: responseFixtures.tiers}
}}); }});
await page.goto('/'); await page.goto('/');
@ -55,7 +55,7 @@ test.describe('Tier settings', async () => {
...globalDataRequests, ...globalDataRequests,
addTier: {method: 'POST', path: '/tiers/', response: {tiers: [newTier]}}, addTier: {method: 'POST', path: '/tiers/', response: {tiers: [newTier]}},
// This request will be reloaded after the new tier is added // This request will be reloaded after the new tier is added
browseTiers: {method: 'GET', path: '/tiers/?limit=all', response: {tiers: [...responseFixtures.tiers.tiers, newTier]}} browseTiers: {method: 'GET', path: '/tiers/?limit=20', response: {tiers: [...responseFixtures.tiers.tiers, newTier]}}
}}); }});
await modal.getByRole('button', {name: 'Save & close'}).click(); await modal.getByRole('button', {name: 'Save & close'}).click();
@ -77,7 +77,7 @@ test.describe('Tier settings', async () => {
const {lastApiRequests} = await mockApi({page, requests: { const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests, ...globalDataRequests,
browseSettings: {...globalDataRequests.browseSettings, response: settingsWithStripe}, browseSettings: {...globalDataRequests.browseSettings, response: settingsWithStripe},
browseTiers: {method: 'GET', path: '/tiers/?limit=all', response: responseFixtures.tiers}, browseTiers: {method: 'GET', path: '/tiers/?limit=20', response: responseFixtures.tiers},
editTier: {method: 'PUT', path: `/tiers/${responseFixtures.tiers.tiers[1].id}/`, response: { editTier: {method: 'PUT', path: `/tiers/${responseFixtures.tiers.tiers[1].id}/`, response: {
tiers: [{ tiers: [{
...responseFixtures.tiers.tiers[1], ...responseFixtures.tiers.tiers[1],
@ -140,7 +140,7 @@ test.describe('Tier settings', async () => {
const {lastApiRequests} = await mockApi({page, requests: { const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests, ...globalDataRequests,
browseSettings: {...globalDataRequests.browseSettings, response: settingsWithStripe}, browseSettings: {...globalDataRequests.browseSettings, response: settingsWithStripe},
browseTiers: {method: 'GET', path: '/tiers/?limit=all', response: responseFixtures.tiers}, browseTiers: {method: 'GET', path: '/tiers/?limit=20', response: responseFixtures.tiers},
editTier: {method: 'PUT', path: `/tiers/${responseFixtures.tiers.tiers[0].id}/`, response: { editTier: {method: 'PUT', path: `/tiers/${responseFixtures.tiers.tiers[0].id}/`, response: {
tiers: [{ tiers: [{
...responseFixtures.tiers.tiers[0], ...responseFixtures.tiers.tiers[0],

View File

@ -55,10 +55,10 @@ export const globalDataRequests = {
}; };
export const limitRequests = { export const limitRequests = {
browseUsers: {method: 'GET', path: '/users/?limit=all&include=roles', response: responseFixtures.users}, browseUsers: {method: 'GET', path: '/users/?limit=100&include=roles', response: responseFixtures.users},
browseInvites: {method: 'GET', path: '/invites/', response: responseFixtures.invites}, browseInvites: {method: 'GET', path: '/invites/', response: responseFixtures.invites},
browseRoles: {method: 'GET', path: '/roles/?limit=all', response: responseFixtures.roles}, browseRoles: {method: 'GET', path: '/roles/?limit=all', response: responseFixtures.roles},
browseNewslettersLimit: {method: 'GET', path: '/newsletters/?filter=status%3Aactive&limit=all', response: responseFixtures.newsletters} browseNewslettersLimit: {method: 'GET', path: '/newsletters/?filter=status%3Aactive&limit=1', response: responseFixtures.newsletters}
}; };
export async function mockApi<Requests extends Record<string, MockRequestConfig>>({page, requests}: {page: Page, requests: Requests}) { export async function mockApi<Requests extends Record<string, MockRequestConfig>>({page, requests}: {page: Page, requests: Requests}) {