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 Heading from '../Heading';
import Hint from '../Hint';
import React, {useId, useMemo} from 'react';
import clsx from 'clsx';
import {DropdownIndicatorProps, GroupBase, MultiValue, OptionProps, OptionsOrGroups, default as ReactSelect, components} from 'react-select';
import {DropdownIndicatorProps, GroupBase, MultiValue, OptionProps, OptionsOrGroups, Props, default as ReactSelect, components} from 'react-select';
export type MultiSelectColor = 'grey' | 'black' | 'green' | 'pink';
type FieldStyles = 'text' | 'dropdown';
@ -14,9 +16,22 @@ export type MultiSelectOption = {
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>>;
values: MultiSelectOption[];
defaultOptions?: never;
loadOptions?: never;
}
type MultiSelectProps = MultiSelectOptionProps & {
values: MultiValue<MultiSelectOption>;
title?: string;
clearBg?: boolean;
error?: boolean;
@ -70,7 +85,10 @@ const MultiSelect: React.FC<MultiSelectProps> = ({
size = 'md',
fieldStyle = 'dropdown',
hint = '',
async,
options,
defaultOptions,
loadOptions,
values,
onChange,
canCreate = false,
@ -110,60 +128,37 @@ const MultiSelect: React.FC<MultiSelectProps> = ({
return (ddiProps: DropdownIndicatorProps<MultiSelectOption, true>) => <DropdownIndicator {...ddiProps} clearBg={clearBg} fieldStyle={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 (
<div className='flex flex-col'>
{title && <Heading htmlFor={id} grey useLabelTag>{title}</Heading>}
{
canCreate ?
<CreatableSelect
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}
/>
:
<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}
/>
async ?
(canCreate ? <AsyncCreatableSelect {...commonOptions} defaultOptions={defaultOptions} loadOptions={loadOptions} /> : <AsyncSelect {...commonOptions} defaultOptions={defaultOptions} loadOptions={loadOptions} />) :
(canCreate ? <CreatableSelect {...commonOptions} options={options} /> : <ReactSelect {...commonOptions} options={options} />)
}
{hint && <Hint color={error ? 'red' : ''}>{hint}</Hint>}
</div>

View File

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

View File

@ -1,9 +1,9 @@
import React, {useId, useMemo} from 'react';
import ReactSelect, {ClearIndicatorProps, DropdownIndicatorProps, OptionProps, Props, components} from 'react-select';
import AsyncSelect from 'react-select/async';
import Heading from '../Heading';
import Hint from '../Hint';
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';
export interface SelectOption {
@ -31,14 +31,28 @@ export interface SelectControlClasses {
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;
hideTitle?: boolean;
size?: 'xs' | 'md';
prompt?: string;
options: SelectOption[] | SelectOptionGroup[];
selectedOption?: string
onSelect: (value: string | undefined) => void;
selectedOption?: SelectOption
onSelect: (option: SelectOption | null) => void;
error?:boolean;
hint?: React.ReactNode;
clearBg?: boolean;
@ -71,6 +85,7 @@ const Option: React.FC<OptionProps<SelectOption, false>> = ({children, ...option
);
const Select: React.FC<SelectProps> = ({
async,
title,
hideTitle,
size = 'md',
@ -133,39 +148,36 @@ const Select: React.FC<SelectProps> = ({
};
}, [clearBg]);
const individualOptions = options.flatMap((option) => {
if ('options' in option) {
return option.options;
}
return option;
});
const customProps = {
classNames: {
menuList: () => 'z-[300]',
valueContainer: () => customClasses.valueContainer,
control: () => customClasses.control,
placeholder: () => customClasses.placeHolder,
menu: () => customClasses.menu,
option: () => customClasses.option,
noOptionsMessage: () => customClasses.noOptionsMessage,
groupHeading: () => customClasses.groupHeading,
clearIndicator: () => customClasses.clearIndicator
},
components: {DropdownIndicator: dropdownIndicatorComponent, Option, ClearIndicator},
inputId: id,
isClearable: false,
options: options,
placeholder: prompt ? prompt : '',
value: selectedOption,
unstyled,
onChange: onSelect
};
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]',
valueContainer: () => customClasses.valueContainer,
control: () => customClasses.control,
placeholder: () => customClasses.placeHolder,
menu: () => customClasses.menu,
option: () => customClasses.option,
noOptionsMessage: () => customClasses.noOptionsMessage,
groupHeading: () => customClasses.groupHeading,
clearIndicator: () => customClasses.clearIndicator
}}
components={{DropdownIndicator: dropdownIndicatorComponent, Option, ClearIndicator}}
inputId={id}
isClearable={false}
options={options}
placeholder={prompt ? prompt : ''}
value={individualOptions.find(option => option.value === selectedOption)}
unstyled
onChange={option => onSelect(option?.value)}
{...props}
/>
{async ?
<AsyncSelect<SelectOption, false> {...customProps} {...props} /> :
<ReactSelect<SelectOption, false> {...customProps} {...props} />
}
</div>
{hint && <Hint color={error ? 'red' : ''}>{hint}</Hint>}
</>

View File

@ -108,7 +108,11 @@ export const PreviewModalContent: React.FC<PreviewModalProps> = ({
let toolbarLeft = (<></>);
if (previewToolbarURLs) {
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) {
toolbarLeft = <TabView

View File

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

View File

@ -1,5 +1,5 @@
import {IntegrationsResponseType, integrationsDataType} from './integrations';
import {createMutation} from '../utils/apiRequests';
import {createMutation} from '../utils/api/hooks';
// 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 interface JSONObject { [key: string]: JSONValue }

View File

@ -1,5 +1,5 @@
import {Setting} from './settings';
import {createMutation, createQuery} from '../utils/apiRequests';
import {createMutation, createQuery} from '../utils/api/hooks';
type CustomThemeSettingData =
{ 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';
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 = {
token: string;

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import {APIKey} from './apiKeys';
import {Meta, createMutation, createQuery} from '../utils/apiRequests';
import {Meta, createMutation, createQuery} from '../utils/api/hooks';
import {Webhook} from './webhooks';
// 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 {
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 = {
id: string;
@ -17,6 +17,5 @@ const dataType = 'LabelsResponseType';
export const useBrowseLabels = createQuery<LabelsResponseType>({
dataType,
path: '/labels/',
defaultSearchParams: {limit: 'all'}
path: '/labels/'
});

View File

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

View File

@ -1,4 +1,4 @@
import {Meta, createPaginatedQuery} from '../utils/apiRequests';
import {Meta, createPaginatedQuery} from '../utils/api/hooks';
export type Mention = {
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 = {
id: string;
@ -46,10 +48,24 @@ export interface NewslettersResponseType {
const dataType = 'NewslettersResponseType';
export const useBrowseNewsletters = createQuery<NewslettersResponseType>({
export const useBrowseNewsletters = createInfiniteQuery<NewslettersResponseType & {isEnd: boolean}>({
dataType,
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}>({
@ -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'}),
updateQueries: {
dataType,
update: (newData, currentData) => (currentData && {
...(currentData as NewslettersResponseType),
newsletters: (currentData as NewslettersResponseType).newsletters.concat(newData.newsletters)
})
update: insertToQueryCache('newsletters')
}
});
@ -78,12 +91,6 @@ export const useEditNewsletter = createMutation<NewslettersEditResponseType, New
defaultSearchParams: {include: 'count.active_members,count.posts'},
updateQueries: {
dataType,
update: (newData, currentData) => (currentData && {
...(currentData as NewslettersResponseType),
newsletters: (currentData as NewslettersResponseType).newsletters.map((newsletter) => {
const newNewsletter = newData.newsletters.find(({id}) => id === newsletter.id);
return newNewsletter || newsletter;
})
})
update: updateQueryCache('newsletters')
}
});

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
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 = {
id: string

View File

@ -1,4 +1,4 @@
import {createMutation} from '../utils/apiRequests';
import {createMutation} from '../utils/api/hooks';
import {downloadFromEndpoint} from '../utils/helpers';
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 = {
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';

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import {createMutation} from '../utils/apiRequests';
import {createMutation} from '../utils/api/hooks';
export const useTestSlack = createMutation<unknown, null>({
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 = {
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';
// 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
@ -29,11 +31,23 @@ export interface TiersResponseType {
const dataType = 'TiersResponseType';
export const useBrowseTiers = createQuery<TiersResponseType>({
export const useBrowseTiers = createInfiniteQuery<TiersResponseType & {isEnd: boolean}>({
dataType,
path: '/tiers/',
defaultSearchParams: {
limit: 'all'
defaultSearchParams: {limit: '20'},
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]}),
updateQueries: {
dataType,
update: (newData, currentData) => (currentData && {
...(currentData as TiersResponseType),
tiers: (currentData as TiersResponseType).tiers.map((tier) => {
const newTier = newData.tiers.find(({id}) => id === tier.id);
return newTier || tier;
})
})
update: updateQueryCache('tiers')
}
});

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 {deleteFromQueryCache, updateQueryCache} from '../utils/api/updateQueries';
// Types
@ -62,18 +64,24 @@ export interface DeleteUserResponse {
const dataType = 'UsersResponseType';
const updateUsers = (newData: UsersResponseType, currentData: unknown) => ({
...(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>({
export const useBrowseUsers = createInfiniteQuery<UsersResponseType & {isEnd: boolean}>({
dataType,
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>({
@ -90,7 +98,7 @@ export const useEditUser = createMutation<UsersResponseType, User>({
searchParams: () => ({include: 'roles'}),
updateQueries: {
dataType,
update: updateUsers
update: updateQueryCache('users')
}
});
@ -99,10 +107,7 @@ export const useDeleteUser = createMutation<DeleteUserResponse, string>({
path: id => `/users/${id}/`,
updateQueries: {
dataType,
update: (_, currentData, id) => ({
...(currentData as UsersResponseType),
users: (currentData as UsersResponseType).users.filter(user => user.id !== id)
})
update: deleteFromQueryCache('users')
}
});
@ -129,7 +134,7 @@ export const useMakeOwner = createMutation<UsersResponseType, string>({
}),
updateQueries: {
dataType,
update: updateUsers
update: updateQueryCache('users')
}
});

View File

@ -1,5 +1,5 @@
import {IntegrationsResponseType, integrationsDataType} from './integrations';
import {Meta, createMutation} from '../utils/apiRequests';
import {Meta, createMutation} from '../utils/api/hooks';
// 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 Toggle from '../../../admin-x-ds/global/form/Toggle';
import ToggleGroup from '../../../admin-x-ds/global/form/ToggleGroup';
import useFilterableApi from '../../../hooks/useFilterableApi';
import useRouting from '../../../hooks/useRouting';
import useStaffUsers from '../../../hooks/useStaffUsers';
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 {User} from '../../../api/users';
import {debounce} from '../../../utils/debounce';
import {generateAvatarColor, getInitials} from '../../../utils/helpers';
import {useCallback, useState} from 'react';
@ -73,15 +76,19 @@ const HistoryFilter: React.FC<{
toggleResourceType: (resource: string, included: boolean) => void;
}> = ({excludedEvents, excludedResources, toggleEventType, toggleResourceType}) => {
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 resetStaff = () => {
setSearchStaff(null);
};
const userOptions = users.map(user => ({label: user.name, value: user.id}));
return (
<div className='flex items-center gap-4'>
<Popover position='right' trigger={<Button color='outline' label='Filter' size='sm' />}>
@ -102,14 +109,16 @@ const HistoryFilter: React.FC<{
</Popover>
<div className='w-[200px]'>
<Select
options={userOptions}
loadOptions={debounce(loadOptions, 500)}
placeholder='Search staff'
value={searchedStaff}
async
defaultOptions
isClearable
onSelect={(value) => {
if (value) {
setSearchStaff(userOptions.find(option => option.value === value)!);
updateRoute(`history/view/${value}`);
onSelect={(option) => {
if (option) {
setSearchStaff(option);
updateRoute(`history/view/${option.value}`);
} else {
resetStaff();
updateRoute('history/view');

View File

@ -8,7 +8,7 @@ import NoValueLabel from '../../../admin-x-ds/global/NoValueLabel';
import React, {useState} from 'react';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
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 {ReactComponent as AmpIcon} from '../../../assets/icons/amp.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 React, {useEffect, useState} from 'react';
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 {HostLimitError, useLimiter} from '../../../../hooks/useLimiter';
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 TextField from '../../../../admin-x-ds/global/form/TextField';
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 {ReactComponent as Icon} from '../../../../assets/icons/amp.svg';
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 TextField from '../../../../admin-x-ds/global/form/TextField';
import WebhooksTable from './WebhooksTable';
import handleError from '../../../../utils/handleError';
import handleError from '../../../../utils/api/handleError';
import useForm from '../../../../hooks/useForm';
import useRouting from '../../../../hooks/useRouting';
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 TextField from '../../../../admin-x-ds/global/form/TextField';
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 {ReactComponent as Icon} from '../../../../assets/icons/firstpromoter.svg';
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 NiceModal from '@ebay/nice-modal-react';
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 useRouting from '../../../../hooks/useRouting';
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 NiceModal from '@ebay/nice-modal-react';
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 {ReactComponent as Icon} from '../../../../assets/icons/unsplash.svg';
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 Select from '../../../../admin-x-ds/global/form/Select';
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 useForm from '../../../../hooks/useForm';
import validator from 'validator';
@ -94,11 +94,11 @@ const WebhookModal: React.FC<WebhookModalProps> = ({webhook, integrationId}) =>
hint={errors.event}
options={webhookEventOptions}
prompt='Select an event'
selectedOption={formState.event}
selectedOption={webhookEventOptions.flatMap(group => group.options).find(option => option.value === formState.event)}
title='Event'
hideTitle
onSelect={(event) => {
updateForm(state => ({...state, event}));
onSelect={(option) => {
updateForm(state => ({...state, event: option?.value}));
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 TableRow from '../../../../admin-x-ds/global/TableRow';
import WebhookModal from './WebhookModal';
import handleError from '../../../../utils/handleError';
import handleError from '../../../../utils/api/handleError';
import {Integration} from '../../../../api/integrations';
import {getWebhookEventLabel} from './webhookEventOptions';
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 Modal from '../../../../admin-x-ds/global/modal/Modal';
import NiceModal from '@ebay/nice-modal-react';
import handleError from '../../../../utils/handleError';
import handleError from '../../../../utils/api/handleError';
import useRouting from '../../../../hooks/useRouting';
import {ReactComponent as ArrowRightIcon} from '../../../../admin-x-ds/assets/icons/arrow-right.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 List from '../../../../admin-x-ds/global/List';
import React, {useState} from 'react';
import handleError from '../../../../utils/handleError';
import handleError from '../../../../utils/api/handleError';
import useRouting from '../../../../hooks/useRouting';
import {downloadRedirects, useUploadRedirects} from '../../../../api/redirects';
import {downloadRoutes, useUploadRoutes} from '../../../../api/routes';

View File

@ -1,6 +1,6 @@
import React from 'react';
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 {getSettingValue, useEditSettings} from '../../../../api/settings';
import {useGlobalData} from '../../../providers/GlobalDataProvider';

View File

@ -5,7 +5,7 @@ import LabItem from './LabItem';
import List from '../../../../admin-x-ds/global/List';
import NiceModal, {useModal} from '@ebay/nice-modal-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 {showToast} from '../../../../admin-x-ds/global/Toast';
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 SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupContent';
import useDefaultRecipientsOptions from './useDefaultRecipientsOptions';
import useSettingGroup from '../../../hooks/useSettingGroup';
import {GroupBase, MultiValue} from 'react-select';
import {MultiValue} from 'react-select';
import {getOptionLabel} from '../../../utils/helpers';
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';
type RefipientValueArgs = {
@ -39,16 +37,6 @@ const RECIPIENT_FILTER_OPTIONS = [{
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({
defaultEmailRecipients,
defaultEmailRecipientsFilter
@ -88,9 +76,7 @@ const DefaultRecipients: React.FC<{ keywords: string[] }> = ({keywords}) => {
defaultEmailRecipientsFilter
}));
const {data: {tiers} = {}} = useBrowseTiers();
const {data: {labels} = {}} = useBrowseLabels();
const {data: {offers} = {}} = useBrowseOffers();
const {loadOptions, selectedSegments, setSelectedSegments} = useDefaultRecipientsOptions(selectedOption, defaultEmailRecipientsFilter);
const setDefaultRecipientValue = (value: string) => {
if (['visibility', 'disabled'].includes(value)) {
@ -115,34 +101,9 @@ const DefaultRecipients: React.FC<{ keywords: string[] }> = ({keywords}) => {
setSelectedOption(value);
};
const segmentOptionGroups: GroupBase<MultiSelectOption>[] = [
{
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 updateSelectedSegments = (selected: MultiValue<MultiSelectOption>) => {
setSelectedSegments(selected);
const filters = defaultEmailRecipientsFilter?.split(',') || [];
const selectedSegments = segmentOptionGroups
.flatMap(({options}) => options)
.filter(({value}) => filters.includes(value));
const setSelectedSegments = (selected: MultiValue<MultiSelectOption>) => {
if (selected.length) {
const selectedGroups = selected?.map(({value}) => value).join(',');
updateSetting('editor_default_email_recipients_filter', selectedGroups);
@ -169,21 +130,23 @@ const DefaultRecipients: React.FC<{ keywords: string[] }> = ({keywords}) => {
<Select
hint='Who should be able to subscribe to your site?'
options={RECIPIENT_FILTER_OPTIONS}
selectedOption={selectedOption}
selectedOption={RECIPIENT_FILTER_OPTIONS.find(option => option.value === selectedOption)}
title="Default Newsletter recipients"
onSelect={(value) => {
if (value) {
setDefaultRecipientValue(value);
onSelect={(option) => {
if (option) {
setDefaultRecipientValue(option.value);
}
}}
/>
{(selectedOption === 'segment') && (
{(selectedOption === 'segment') && selectedSegments && (
<MultiSelect
options={segmentOptionGroups.filter(group => group.options.length > 0)}
loadOptions={loadOptions}
title='Filter'
values={selectedSegments}
async
clearBg
onChange={setSelectedSegments}
defaultOptions
onChange={updateSelectedSegments}
/>
)}
</SettingGroupContent>

View File

@ -4,7 +4,7 @@ import React from 'react';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupContent';
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 {Setting, getSettingValues, useEditSettings} from '../../../api/settings';
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 SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupContent';
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 {getSettingValues, useEditSettings} from '../../../api/settings';
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'>
<Select
options={MAILGUN_REGIONS}
selectedOption={mailgunRegion}
selectedOption={MAILGUN_REGIONS.find(option => option.value === mailgunRegion)}
title="Mailgun region"
onSelect={(value) => {
updateSetting('mailgun_base_url', value || null);
onSelect={(option) => {
updateSetting('mailgun_base_url', option?.value || null);
}}
/>
<TextField

View File

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

View File

@ -6,7 +6,7 @@ import React, {useEffect} from 'react';
import TextArea from '../../../../admin-x-ds/global/form/TextArea';
import TextField from '../../../../admin-x-ds/global/form/TextField';
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 useRouting from '../../../../hooks/useRouting';
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 Toggle from '../../../../admin-x-ds/global/form/Toggle';
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 useForm, {ErrorMessages} from '../../../../hooks/useForm';
import useRouting from '../../../../hooks/useRouting';
@ -160,7 +160,12 @@ const Sidebar: React.FC<{
onChange={e => updateNewsletter({sender_email: e.target.value})}
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 className='mt-6' gap='sm' margins='lg' title='Member settings'>
<Toggle
@ -308,8 +313,8 @@ const Sidebar: React.FC<{
<Select
disabled={!newsletter.show_post_title_section}
options={fontOptions}
selectedOption={newsletter.title_font_category}
onSelect={value => updateNewsletter({title_font_category: value})}
selectedOption={fontOptions.find(option => option.value === newsletter.title_font_category)}
onSelect={option => updateNewsletter({title_font_category: option?.value})}
/>
</div>
<ButtonGroup buttons={[
@ -359,9 +364,9 @@ const Sidebar: React.FC<{
/>}
<Select
options={fontOptions}
selectedOption={newsletter.body_font_category}
selectedOption={fontOptions.find(option => option.value === newsletter.body_font_category)}
title='Body style'
onSelect={value => updateNewsletter({body_font_category: value})}
onSelect={option => updateNewsletter({body_font_category: option?.value})}
/>
<Toggle
checked={newsletter.show_feature_image}
@ -510,9 +515,15 @@ const NewsletterDetailModalContent: React.FC<{newsletter: Newsletter; onlyOne: b
};
const NewsletterDetailModal: React.FC<RoutingModalProps> = ({params}) => {
const {data: {newsletters} = {}} = useBrowseNewsletters();
const {data: {newsletters, isEnd} = {}, fetchNextPage} = useBrowseNewsletters();
const newsletter = newsletters?.find(({id}) => id === params?.id);
useEffect(() => {
if (!newsletter && !isEnd) {
fetchNextPage();
}
}, [fetchNextPage, isEnd, newsletter]);
if (newsletter) {
return <NewsletterDetailModalContent newsletter={newsletter} onlyOne={newsletters!.length === 1} />;
} 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 SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupContent';
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 useSettingGroup from '../../../hooks/useSettingGroup';
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 Radio from '../../../admin-x-ds/global/form/Radio';
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 validator from 'validator';
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 timezoneOptions = timezoneData.map((tzOption: TimezoneDataDropdownOption) => {
const timezoneOptions: Array<{value: string; label: string}> = timezoneData.map((tzOption: TimezoneDataDropdownOption) => {
return {
value: tzOption.name,
label: tzOption.label
@ -76,9 +76,9 @@ const TimeZone: React.FC<{ keywords: string[] }> = ({keywords}) => {
<Select
hint={<Hint timezone={publicationTimezone} />}
options={timezoneOptions}
selectedOption={publicationTimezone}
selectedOption={timezoneOptions.find(option => option.value === publicationTimezone)}
title="Site timezone"
onSelect={handleTimezoneChange}
onSelect={option => handleTimezoneChange(option?.value)}
/>
</SettingGroupContent>
);

View File

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

View File

@ -7,7 +7,7 @@ import React, {useState} from 'react';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import TabView from '../../../admin-x-ds/global/TabView';
import clsx from 'clsx';
import handleError from '../../../utils/handleError';
import handleError from '../../../utils/api/handleError';
import useRouting from '../../../hooks/useRouting';
import useStaffUsers from '../../../hooks/useStaffUsers';
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 SettingGroup from '../../../../admin-x-ds/settings/SettingGroup';
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 {ValidationError} from '../../../../utils/errors';
import {showToast} from '../../../../admin-x-ds/global/Toast';

View File

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

View File

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

View File

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

View File

@ -8,7 +8,6 @@ import {MultiSelectOption} from '../../../../admin-x-ds/global/form/MultiSelect'
import {MultiValue} from 'react-select';
import {generateCode} from '../../../../utils/generateEmbedCode';
import {getSettingValues} from '../../../../api/settings';
import {useBrowseLabels} from '../../../../api/labels';
import {useEffect, useState} from 'react';
import {useGlobalData} from '../../../providers/GlobalDataProvider';
@ -25,7 +24,6 @@ const EmbedSignupFormModal = NiceModal.create(() => {
const {config} = useGlobalData();
const {localSettings, siteData} = useSettingGroup();
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});
if (labs) {
@ -113,7 +111,6 @@ const EmbedSignupFormModal = NiceModal.create(() => {
handleLabelClick={addSelectedLabel}
handleLayoutSelect={setSelectedLayout}
isCopied={isCopied}
labels={labels?.labels || []}
selectedColor={selectedColor}
selectedLabels={selectedLabels}
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 Form from '../../../../admin-x-ds/global/form/Form';
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 React from 'react';
import StickyFooter from '../../../../admin-x-ds/global/StickyFooter';
import TextArea from '../../../../admin-x-ds/global/form/TextArea';
import useFilterableApi from '../../../../hooks/useFilterableApi';
import {Label} from '../../../../api/labels';
import {MultiValue} from 'react-select';
import {debounce} from '../../../../utils/debounce';
export type SelectedLabelTypes = {
label: string;
@ -20,7 +22,6 @@ type SidebarProps = {
selectedColor?: string;
accentColor?: string;
handleColorToggle: (e: string) => void;
labels?: Label[];
handleLabelClick: (selected: MultiValue<MultiSelectOption>) => void;
selectedLabels?: SelectedLabelTypes[];
embedScript: string;
@ -36,7 +37,6 @@ const EmbedSignupSidebar: React.FC<SidebarProps> = ({selectedLayout,
accentColor,
handleColorToggle,
selectedColor,
labels,
selectedLabels,
handleLabelClick,
embedScript,
@ -45,12 +45,13 @@ const EmbedSignupSidebar: React.FC<SidebarProps> = ({selectedLayout,
customColor,
setCustomColor,
isCopied}) => {
const labelOptions = labels ? labels.map((l) => {
return {
label: l?.name,
value: l?.name
};
}).filter(Boolean) : [];
const {loadData} = useFilterableApi<Label>({path: '/labels/', filterKey: 'name', responseKey: 'labels'});
const loadOptions: LoadOptions = async (input, callback) => {
const labels = await loadData(input);
callback(labels.map(label => ({label: label.name, value: label.name})));
};
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>
@ -125,12 +126,14 @@ const EmbedSignupSidebar: React.FC<SidebarProps> = ({selectedLayout,
}
<MultiSelect
canCreate={true}
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)'
title='Labels at signup'
values={selectedLabels || []}
async
canCreate
defaultOptions
onChange={handleLabelClick}
/>
<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 Toggle from '../../../../admin-x-ds/global/form/Toggle';
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 PortalIcon2} from '../../../../assets/icons/portal-icon-2.svg';
import {ReactComponent as PortalIcon3} from '../../../../assets/icons/portal-icon-3.svg';
@ -67,6 +67,12 @@ const LookAndFeel: React.FC<{
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>
<Toggle
checked={Boolean(portalButton)}
@ -75,14 +81,10 @@ const LookAndFeel: React.FC<{
onChange={e => updateSetting('portal_button', e.target.checked)}
/>
<Select
options={[
{value: 'icon-and-text', label: 'Icon and text'},
{value: 'icon-only', label: 'Icon only'},
{value: 'text-only', label: 'Text only'}
]}
selectedOption={portalButtonStyle as string}
options={portalButtonOptions}
selectedOption={portalButtonOptions.find(option => option.value === portalButtonStyle)}
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') &&
<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>
<Select
options={tierOptions}
selectedOption={selectedTier}
onSelect={(value) => {
if (value) {
setSelectedTier(value);
selectedOption={tierOptions.find(option => option.value === selectedTier)}
onSelect={(option) => {
if (option) {
setSelectedTier(option?.value);
}
}}
/>

View File

@ -6,7 +6,7 @@ import PortalPreview from './PortalPreview';
import React, {useEffect, useState} from 'react';
import SignupOptions from './SignupOptions';
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 useQueryParams from '../../../../hooks/useQueryParams';
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 TextField from '../../../../admin-x-ds/global/form/TextField';
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 useSettingGroup from '../../../../hooks/useSettingGroup';
import {JSONError} from '../../../../utils/errors';
@ -64,7 +64,7 @@ const Connect: React.FC = () => {
const saveTier = async () => {
const {data} = await fetchActiveTiers();
const tier = data?.tiers[0];
const tier = data?.pages[0].tiers[0];
if (tier) {
tier.monthly_price = 500;

View File

@ -13,7 +13,7 @@ import TextField from '../../../../admin-x-ds/global/form/TextField';
import TierDetailPreview from './TierDetailPreview';
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
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 useRouting from '../../../../hooks/useRouting';
import useSettingGroup from '../../../../hooks/useSettingGroup';
@ -223,9 +223,9 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
containerClassName='font-medium'
controlClasses={{menu: 'w-14'}}
options={currencySelectGroups()}
selectedOption={formState.currency}
selectedOption={currencySelectGroups().flatMap(group => group.options).find(option => option.value === formState.currency)}
size='xs'
onSelect={currency => updateForm(state => ({...state, currency}))}
onSelect={option => updateForm(state => ({...state, currency: option?.value}))}
/>
</div>
</div>
@ -339,15 +339,21 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
};
const TierDetailModal: React.FC<RoutingModalProps> = ({params}) => {
const {data: {tiers} = {}} = useBrowseTiers();
const {data: {tiers, isEnd} = {}, fetchNextPage} = useBrowseTiers();
let tier: Tier | undefined;
useEffect(() => {
if (params?.id && !tier && !isEnd) {
fetchNextPage();
}
}, [fetchNextPage, isEnd, params?.id, tier]);
if (params?.id) {
tier = tiers?.find(({id}) => id === params?.id);
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 ThemePreview from './designAndBranding/ThemePreview';
import ThemeSettings from './designAndBranding/ThemeSettings';
import handleError from '../../../utils/handleError';
import handleError from '../../../utils/api/handleError';
import useForm from '../../../hooks/useForm';
import useRouting from '../../../hooks/useRouting';
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 ThemeInstalledModal from './theme/ThemeInstalledModal';
import ThemePreview from './theme/ThemePreview';
import handleError from '../../../utils/handleError';
import handleError from '../../../utils/api/handleError';
import useRouting from '../../../hooks/useRouting';
import {HostLimitError, useLimiter} from '../../../hooks/useLimiter';
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 TextField from '../../../../admin-x-ds/global/form/TextField';
import UnsplashSearchModal from '../../../../utils/unsplash/UnsplashSearchModal';
import handleError from '../../../../utils/handleError';
import handleError from '../../../../utils/api/handleError';
import usePinturaEditor from '../../../../hooks/usePinturaEditor';
import {SettingValue, getSettingValues} from '../../../../api/settings';
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 TextField from '../../../../admin-x-ds/global/form/TextField';
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 {getImageUrl, useUploadImage} from '../../../../api/images';
import {humanizeSettingKey} from '../../../../api/settings';
@ -52,9 +52,9 @@ const ThemeSetting: React.FC<{
<Select
hint={setting.description}
options={setting.options.map(option => ({label: option, value: option}))}
selectedOption={setting.value}
selectedOption={{label: setting.value, value: setting.value}}
title={humanizeSettingKey(setting.key)}
onSelect={value => setSetting(value || null)}
onSelect={option => setSetting(option?.value || null)}
/>
);
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 React from 'react';
import RecommendationReasonForm from './RecommendationReasonForm';
import handleError from '../../../../utils/handleError';
import handleError from '../../../../utils/api/handleError';
import useForm from '../../../../hooks/useForm';
import useRouting from '../../../../hooks/useRouting';
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 React from 'react';
import RecommendationReasonForm from './RecommendationReasonForm';
import handleError from '../../../../utils/handleError';
import handleError from '../../../../utils/api/handleError';
import useForm from '../../../../hooks/useForm';
import useRouting from '../../../../hooks/useRouting';
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 NiceModal from '@ebay/nice-modal-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 {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 NiceModal from '@ebay/nice-modal-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 {InstalledTheme, ThemeProblem, useActivateTheme} from '../../../../api/themes';
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
});
const {refetch: fetchNewsletters} = useBrowseNewsletters({
searchParams: {filter: 'status:active', limit: 'all'},
searchParams: {filter: 'status:active', limit: '1'},
enabled: false
});
@ -74,16 +74,17 @@ export const useLimiter = () => {
}, [config.hostSettings?.billing]);
return useMemo(() => {
const limits = config.hostSettings?.limits as LimiterLimits;
if (!LimitService || !limits || isLoading) {
if (!LimitService || !config.hostSettings?.limits || isLoading) {
return;
}
const limits = {...config.hostSettings.limits} as LimiterLimits;
const limiter = new LimitService();
if (limits.staff) {
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 staffInvites = invites.filter(i => i.role !== 'Contributor');
@ -100,8 +101,8 @@ export const useLimiter = () => {
if (limits.newsletters) {
limits.newsletters.currentCountQuery = async () => {
const {data: {newsletters} = {newsletters: []}} = await fetchNewsletters();
return newsletters?.length || 0;
const {data: {pages} = {pages: []}} = await fetchNewsletters();
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';
export interface PaginationData {

View File

@ -1,5 +1,5 @@
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 useGlobalDirtyState from './useGlobalDirtyState';
import {Setting, SettingValue, useEditSettings} from '../api/settings';

View File

@ -14,6 +14,8 @@ export type UsersHook = {
contributorUsers: User[];
currentUser: User|null;
isLoading: boolean;
hasNextPage?: boolean;
fetchNextPage: () => void;
};
function getUsersByRole(users: User[], role: string): User[] {
@ -30,7 +32,7 @@ function getOwnerUser(users: User[]): User {
const useStaffUsers = (): UsersHook => {
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: {roles} = {}, isLoading: rolesLoading} = useBrowseRoles();
@ -58,7 +60,9 @@ const useStaffUsers = (): UsersHook => {
contributorUsers,
currentUser,
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 {APIError, ValidationError} from './errors';
import {showToast} from '../admin-x-ds/global/Toast';
import {APIError, ValidationError} from '../errors';
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

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) => {
if (response.status === 0) {

View File

@ -1,12 +1,12 @@
import * as Sentry from '@sentry/react';
import handleError from './handleError';
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 {getGhostPaths} from './helpers';
import {getGhostPaths} from '../helpers';
import {useEffect, useMemo} from 'react';
import {usePage, usePagination} from '../hooks/usePagination';
import {useSentryDSN, useServices} from '../components/providers/ServiceProvider';
import {usePage, usePagination} from '../../hooks/usePagination';
import {useSentryDSN, useServices} from '../../components/providers/ServiceProvider';
export interface Meta {
pagination: {
@ -33,7 +33,8 @@ export const useFetchApi = () => {
const {ghostVersion} = useServices();
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
const defaultHeaders: Record<string, string> = {
'app-pragma': 'no-cache',
@ -91,7 +92,7 @@ export const useFetchApi = () => {
Sentry.captureMessage('Request took multiple attempts', {extra: {attempts, retryingMs, endpoint: endpoint.toString()}});
}
return handleResponse(response);
return handleResponse(response) as Response;
} catch (error) {
retryingMs = Date.now() - startTime;
@ -160,7 +161,7 @@ export const createQuery = <ResponseData>(options: QueryOptions<ResponseData>) =
const data = useMemo(() => (
(result.data && options.returnData) ? options.returnData(result.data) : result.data)
, [result]);
, [result.data]);
useEffect(() => {
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'> & {
returnData: NonNullable<QueryOptions<ResponseData>['returnData']>
defaultNextPageParams?: (data: ResponseData, params: Record<string, string>) => Record<string, string>;
}
type InfiniteQueryHookOptions<ResponseData> = UseInfiniteQueryOptions<ResponseData> & {
searchParams?: Record<string, string>;
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 nextPageParams = getNextPageParams || options.defaultNextPageParams || (() => ({}));
const result = useInfiniteQuery<ResponseData>({
queryKey: [options.dataType, apiUrl(options.path, 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
});
const data = useMemo(() => result.data && options.returnData(result.data), [result]);
const data = useMemo(() => result.data && options.returnData(result.data), [result.data]);
useEffect(() => {
if (result.error && query.defaultErrorHandler !== false) {
@ -280,7 +284,7 @@ const mutate = <ResponseData, Payload>({fetchApi, path, payload, searchParams, o
requestBody = JSON.stringify(generatedBody);
}
return fetchApi(url, {
return fetchApi<ResponseData>(url, {
body: requestBody,
...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) {
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}) => {
const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests,
browseTiers: {method: 'GET', path: '/tiers/?limit=all', response: responseFixtures.tiers},
browseLabels: {method: 'GET', path: '/labels/?limit=all', response: responseFixtures.labels},
browseOffers: {method: 'GET', path: '/offers/?limit=all', response: responseFixtures.offers},
browseTiers: {method: 'GET', path: '/tiers/?filter=&limit=20', response: responseFixtures.tiers},
browseLabels: {method: 'GET', path: '/labels/?filter=&limit=20', response: responseFixtures.labels},
browseOffers: {method: 'GET', path: '/offers/?filter=&limit=20', response: responseFixtures.offers},
editSettings: {method: 'PUT', path: '/settings/', response: updatedSettingsResponse([
{
key: 'editor_default_email_recipients',

View File

@ -5,7 +5,7 @@ test.describe('Newsletter settings', async () => {
test('Supports creating a new newsletter', async ({page}) => {
const {lastApiRequests} = await mockApi({page, requests: {
...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: [{
id: 'new-newsletter',
name: 'New newsletter',
@ -48,7 +48,7 @@ test.describe('Newsletter settings', async () => {
test('Supports updating a newsletter', async ({page}) => {
const {lastApiRequests} = await mockApi({page, requests: {
...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: {
newsletters: [{
...responseFixtures.newsletters.newsletters[0],
@ -93,7 +93,7 @@ test.describe('Newsletter settings', async () => {
test('Displays a prompt when email verification is required', async ({page}) => {
await mockApi({page, requests: {
...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: {
newsletters: [responseFixtures.newsletters.newsletters[0]],
meta: {
@ -120,7 +120,7 @@ test.describe('Newsletter settings', async () => {
test('Supports archiving newsletters', async ({page}) => {
const activate = await mockApi({page, requests: {
...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: {
newsletters: [{
...responseFixtures.newsletters.newsletters[1],
@ -157,7 +157,7 @@ test.describe('Newsletter settings', async () => {
const archive = await mockApi({page, requests: {
...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: {
newsletters: [{
...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('/');

View File

@ -7,7 +7,7 @@ test.describe('User actions', async () => {
const {lastApiRequests} = await mockApi({page, requests: {
...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: {
users: [{
...userToEdit,
@ -50,7 +50,7 @@ test.describe('User actions', async () => {
const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests,
browseUsers: {method: 'GET', path: '/users/?limit=all&include=roles', response: {
browseUsers: {method: 'GET', path: '/users/?limit=100&include=roles', response: {
users: [
...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: {
...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: {}}
}});
@ -151,7 +151,7 @@ test.describe('User actions', async () => {
const {lastApiRequests} = await mockApi({page, requests: {
...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},
makeOwner: {method: 'PUT', path: '/users/owner/', response: makeOwnerResponse}
}});
@ -206,7 +206,7 @@ test.describe('User actions', async () => {
await mockApi({page, requests: {
...globalDataRequests,
...limitRequests,
browseUsers: {method: 'GET', path: '/users/?limit=all&include=roles', response: {
browseUsers: {method: 'GET', path: '/users/?limit=100&include=roles', response: {
users: [
...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: {
...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},
browseRoles: {method: 'GET', path: '/roles/?limit=all', 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}) => {
const {lastApiRequests} = await mockApi({page, requests: {
...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},
deleteInvite: {method: 'DELETE', path: `/invites/${responseFixtures.invites.invites[0].id}/`, response: {}},
addInvite: {method: 'POST', path: '/invites/', response: responseFixtures.invites}
@ -97,7 +97,7 @@ test.describe('User invitations', async () => {
test('Supports revoking invitations', async ({page}) => {
const {lastApiRequests} = await mockApi({page, requests: {
...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},
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: {
...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: {
users: [{
...userToEdit,
@ -68,7 +68,7 @@ test.describe('User profile', async () => {
test('Supports changing password', async ({page}) => {
const {lastApiRequests} = await mockApi({page, requests: {
...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: {}}
}});
@ -109,7 +109,7 @@ test.describe('User profile', async () => {
const {lastApiRequests} = await mockApi({page, requests: {
...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}]}},
editUser: {method: 'PUT', path: `/users/${userToEdit.id}/?include=roles`, response: {
users: [{

View File

@ -5,7 +5,7 @@ test.describe('User roles', async () => {
test('Shows users under their role', async ({page}) => {
await mockApi({page, requests: {
...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('/');
@ -42,7 +42,7 @@ test.describe('User roles', async () => {
const {lastApiRequests} = await mockApi({page, requests: {
...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},
browseAssignableRoles: {method: 'GET', path: '/roles/?limit=all&permissions=assign', response: responseFixtures.roles},
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}) => {
const {lastApiRequests} = await mockApi({page, requests: {
...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([
{key: 'default_content_visibility', value: 'tiers'},
{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: {
...globalDataRequests,
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('/');
@ -55,7 +55,7 @@ test.describe('Tier settings', async () => {
...globalDataRequests,
addTier: {method: 'POST', path: '/tiers/', response: {tiers: [newTier]}},
// 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();
@ -77,7 +77,7 @@ test.describe('Tier settings', async () => {
const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests,
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: {
tiers: [{
...responseFixtures.tiers.tiers[1],
@ -140,7 +140,7 @@ test.describe('Tier settings', async () => {
const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests,
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: {
tiers: [{
...responseFixtures.tiers.tiers[0],

View File

@ -55,10 +55,10 @@ export const globalDataRequests = {
};
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},
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}) {