Merge branch 'main' into main

This commit is contained in:
qwerzl 2023-09-25 18:59:59 -05:00 committed by GitHub
commit f59813db48
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
217 changed files with 4281 additions and 1286 deletions

View File

@ -2,6 +2,8 @@ import React from 'react';
import '../src/styles/demo.css';
import type { Preview } from "@storybook/react";
import '../src/admin-x-ds/providers/DesignSystemProvider';
import DesignSystemProvider from '../src/admin-x-ds/providers/DesignSystemProvider';
const preview: Preview = {
parameters: {
@ -29,7 +31,9 @@ const preview: Preview = {
background: (scheme === 'dark' ? '#131416' : '')
}}>
{/* 👇 Decorators in Storybook also accept a function. Replace <Story/> with Story() to enable it */}
<Story />
<DesignSystemProvider>
<Story />
</DesignSystemProvider>
</div>);
},
],

View File

@ -43,6 +43,7 @@
"@dnd-kit/core": "6.0.8",
"@dnd-kit/sortable": "7.0.2",
"@ebay/nice-modal-react": "1.2.10",
"@sentry/react": "7.70.0",
"@tanstack/react-query": "4.35.3",
"@tryghost/color-utils": "0.1.24",
"@tryghost/limit-service": "^1.2.10",
@ -55,7 +56,7 @@
"validator": "7.2.0"
},
"devDependencies": {
"@playwright/test": "1.38.0",
"@playwright/test": "1.38.1",
"@storybook/addon-essentials": "7.4.0",
"@storybook/addon-interactions": "7.4.0",
"@storybook/addon-links": "7.4.0",

View File

@ -1,9 +1,11 @@
import DesignSystemProvider from './admin-x-ds/providers/DesignSystemProvider';
import GlobalDataProvider from './components/providers/GlobalDataProvider';
import MainContent from './MainContent';
import NiceModal from '@ebay/nice-modal-react';
import RoutingProvider, {ExternalLink} from './components/providers/RoutingProvider';
import clsx from 'clsx';
import {DefaultHeaderTypes} from './utils/unsplash/UnsplashTypes';
import {ErrorBoundary} from '@sentry/react';
import {GlobalDirtyStateProvider} from './hooks/useGlobalDirtyState';
import {OfficialTheme, ServicesProvider} from './components/providers/ServiceProvider';
import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
@ -15,9 +17,12 @@ interface AppProps {
officialThemes: OfficialTheme[];
zapierTemplates: ZapierTemplate[];
externalNavigate: (link: ExternalLink) => void;
toggleFeatureFlag: (flag: string, enabled: boolean) => void;
darkMode?: boolean;
unsplashConfig: DefaultHeaderTypes
sentryDSN: string | null;
onUpdate: (dataType: string, response: unknown) => void;
onInvalidate: (dataType: string) => void;
onDelete: (dataType: string, id: string) => void;
}
const queryClient = new QueryClient({
@ -30,33 +35,45 @@ const queryClient = new QueryClient({
}
});
function App({ghostVersion, officialThemes, zapierTemplates, externalNavigate, toggleFeatureFlag, darkMode = false, unsplashConfig}: AppProps) {
function SentryErrorBoundary({children}: {children: React.ReactNode}) {
return (
<ErrorBoundary>
{children}
</ErrorBoundary>
);
}
function App({ghostVersion, officialThemes, zapierTemplates, externalNavigate, darkMode = false, unsplashConfig, sentryDSN, onUpdate, onInvalidate, onDelete}: AppProps) {
const appClassName = clsx(
'admin-x-settings h-[100vh] w-full overflow-y-auto overflow-x-hidden',
darkMode && 'dark'
);
return (
<QueryClientProvider client={queryClient}>
<ServicesProvider ghostVersion={ghostVersion} officialThemes={officialThemes} toggleFeatureFlag={toggleFeatureFlag} unsplashConfig={unsplashConfig} zapierTemplates={zapierTemplates}>
<GlobalDataProvider>
<RoutingProvider externalNavigate={externalNavigate}>
<GlobalDirtyStateProvider>
<div className={appClassName} id="admin-x-root" style={{
height: '100vh',
width: '100%'
}}
>
<Toaster />
<NiceModal.Provider>
<MainContent />
</NiceModal.Provider>
</div>
</GlobalDirtyStateProvider>
</RoutingProvider>
</GlobalDataProvider>
</ServicesProvider>
</QueryClientProvider>
<SentryErrorBoundary>
<QueryClientProvider client={queryClient}>
<ServicesProvider ghostVersion={ghostVersion} officialThemes={officialThemes} sentryDSN={sentryDSN} unsplashConfig={unsplashConfig} zapierTemplates={zapierTemplates} onDelete={onDelete} onInvalidate={onInvalidate} onUpdate={onUpdate}>
<GlobalDataProvider>
<RoutingProvider externalNavigate={externalNavigate}>
<GlobalDirtyStateProvider>
<DesignSystemProvider>
<div className={appClassName} id="admin-x-root" style={{
height: '100vh',
width: '100%'
}}
>
<Toaster />
<NiceModal.Provider>
<MainContent />
</NiceModal.Provider>
</div>
</DesignSystemProvider>
</GlobalDirtyStateProvider>
</RoutingProvider>
</GlobalDataProvider>
</ServicesProvider>
</QueryClientProvider>
</SentryErrorBoundary>
);
}

View File

@ -1,3 +1,4 @@
import * as Sentry from '@sentry/react';
import Banner from './Banner';
import React, {ComponentType, ErrorInfo, ReactNode} from 'react';
@ -17,7 +18,10 @@ class ErrorBoundary extends React.Component<{children: ReactNode, name: ReactNod
}
componentDidCatch(error: unknown, info: ErrorInfo) {
// TODO: Log to Sentry
Sentry.withScope((scope) => {
scope.setTag('adminX settings component-', info.componentStack);
Sentry.captureException(error);
});
// eslint-disable-next-line no-console
console.error(error);
// eslint-disable-next-line no-console

View File

@ -41,11 +41,11 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({size, color,
switch (color) {
case 'light':
styles += ' border-white/20 before:bg-white ';
styles += ' border-white/20 before:bg-white dark:border-black/10 dark:before:bg-black ';
break;
case 'dark':
default:
styles += ' border-black/10 before:bg-black ';
styles += ' border-black/10 before:bg-black dark:border-white/20 dark:before:bg-white ';
break;
}

View File

@ -4,6 +4,7 @@ import clsx from 'clsx';
export type Tab<ID = string> = {
id: ID;
title: string;
counter?: number | null;
/**
* Optional, so you can just use the tabs to other views
@ -51,21 +52,26 @@ function TabView<ID extends string = string>({
<section>
<div className={containerClasses} role='tablist'>
{tabs.map(tab => (
<button
key={tab.id}
aria-selected={selectedTab === tab.id}
className={clsx(
'-m-b-px cursor-pointer appearance-none whitespace-nowrap py-1 text-sm transition-all after:invisible after:block after:h-px after:overflow-hidden after:font-bold after:text-transparent after:content-[attr(title)] dark:text-white',
border && 'border-b-[3px]',
selectedTab === tab.id && border ? 'border-black dark:border-white' : 'border-transparent hover:border-grey-500',
selectedTab === tab.id && 'font-bold'
)}
id={tab.id}
role='tab'
title={tab.title}
type="button"
onClick={handleTabChange}
>{tab.title}</button>
<div>
<button
key={tab.id}
aria-selected={selectedTab === tab.id}
className={clsx(
'-m-b-px cursor-pointer appearance-none whitespace-nowrap py-1 text-sm transition-all after:invisible after:block after:h-px after:overflow-hidden after:font-bold after:text-transparent after:content-[attr(title)] dark:text-white',
border && 'border-b-[3px]',
selectedTab === tab.id && border ? 'border-black dark:border-white' : 'border-transparent hover:border-grey-500',
selectedTab === tab.id && 'font-bold'
)}
id={tab.id}
role='tab'
title={tab.title}
type="button"
onClick={handleTabChange}
>
{tab.title}
{(typeof tab.counter === 'number') && <span className='ml-1.5 rounded-full bg-grey-200 px-1.5 py-[2px] text-xs font-normal text-grey-800 dark:bg-grey-900 dark:text-grey-300'>{tab.counter}</span>}
</button>
</div>
))}
</div>
{tabs.map((tab) => {

View File

@ -8,6 +8,11 @@ import clsx from 'clsx';
import {LoadingIndicator} from './LoadingIndicator';
import {PaginationData} from '../../hooks/usePagination';
export interface ShowMoreData {
hasMore: boolean;
loadMore: () => void;
}
interface TableProps {
/**
* If the table is the primary content on a page (e.g. Members table) then you can set a pagetitle to be consistent
@ -21,6 +26,7 @@ interface TableProps {
className?: string;
isLoading?: boolean;
pagination?: PaginationData;
showMore?: ShowMoreData;
}
const OptionalPagination = ({pagination}: {pagination?: PaginationData}) => {
@ -31,7 +37,19 @@ const OptionalPagination = ({pagination}: {pagination?: PaginationData}) => {
return <Pagination {...pagination}/>;
};
const Table: React.FC<TableProps> = ({header, children, borderTop, hint, hintSeparator, pageTitle, className, pagination, isLoading}) => {
const OptionalShowMore = ({showMore}: {showMore?: ShowMoreData}) => {
if (!showMore || !showMore.hasMore) {
return null;
}
return (
<div className={`mt-1 flex items-center gap-2 text-xs text-green`}>
<button type='button' onClick={showMore.loadMore}>Show all</button>
</div>
);
};
const Table: React.FC<TableProps> = ({header, children, borderTop, hint, hintSeparator, pageTitle, className, pagination, showMore, isLoading}) => {
const tableClasses = clsx(
(borderTop || pageTitle) && 'border-t border-grey-300',
'w-full',
@ -109,12 +127,13 @@ const Table: React.FC<TableProps> = ({header, children, borderTop, hint, hintSep
{isLoading && <LoadingIndicator delay={200} size='lg' style={loadingStyle} />}
{(hint || pagination) &&
{(hint || pagination || showMore) &&
<div className="-mt-px">
{(hintSeparator || pagination) && <Separator />}
<div className="flex flex-col-reverse items-start justify-between gap-1 pt-2 md:flex-row md:items-center md:gap-0 md:pt-0">
<Hint>{hint ?? ' '}</Hint>
<OptionalPagination pagination={pagination} />
<OptionalShowMore showMore={showMore} />
</div>
</div>}
</div>

View File

@ -28,7 +28,7 @@ const TableRow: React.FC<TableRowProps> = ({id, action, hideActions, className,
'group/table-row',
bgOnHover && 'hover:bg-gradient-to-r hover:from-white hover:to-grey-50 dark:hover:from-black dark:hover:to-grey-950',
onClick && 'cursor-pointer',
separator ? 'border-b border-grey-100 last-of-type:border-b-transparent hover:border-grey-200 dark:border-grey-900 dark:hover:border-grey-800' : 'border-y border-none first-of-type:hover:border-t-transparent',
separator ? 'border-b border-grey-100 last-of-type:border-b-transparent hover:border-grey-200 dark:border-grey-950 dark:hover:border-grey-900' : 'border-y border-none first-of-type:hover:border-t-transparent',
className
);

View File

@ -45,7 +45,7 @@ const Toast: React.FC<ToastProps> = ({
);
return (
<div className={classNames} data-testid='toast'>
<div className={classNames} data-testid={`toast-${props?.type}`}>
<div className='flex items-start gap-3'>
{props?.icon && (typeof props.icon === 'string' ?
<div className='mt-0.5'><Icon className='grow' colorClass={props.type === 'success' ? 'text-green' : 'text-white'} name={props.icon} size='sm' /></div> : props.icon)}

View File

@ -5,6 +5,7 @@ import React, {forwardRef, useEffect, useId, useRef, useState} from 'react';
import clsx from 'clsx';
import {EditorView} from '@codemirror/view';
import {Extension} from '@codemirror/state';
import {useFocusContext} from '../../providers/DesignSystemProvider';
export interface CodeEditorProps extends Omit<ReactCodeMirrorProps, 'value' | 'onChange' | 'extensions'> {
title?: string;
@ -43,6 +44,15 @@ const CodeEditorView = forwardRef<ReactCodeMirrorRef, CodeEditorProps>(function
const sizeRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(100);
const [resolvedExtensions, setResolvedExtensions] = React.useState<Extension[] | null>(null);
const {setFocusState} = useFocusContext();
const handleFocus = () => {
setFocusState(true);
};
const handleBlur = () => {
setFocusState(false);
};
useEffect(() => {
Promise.all(extensions).then(setResolvedExtensions);
@ -76,7 +86,9 @@ const CodeEditorView = forwardRef<ReactCodeMirrorRef, CodeEditorProps>(function
height={height === 'full' ? '100%' : height}
theme={theme}
value={value}
onBlur={handleBlur}
onChange={onChange}
onFocus={handleFocus}
{...props}
/>
{title && <Heading className={'order-1 !text-grey-700 peer-focus:!text-black'} htmlFor={id} useLabelTag={true}>{title}</Heading>}

View File

@ -1,4 +1,6 @@
import * as Sentry from '@sentry/react';
import React, {ReactNode, Suspense, useCallback, useMemo} from 'react';
import {useFocusContext} from '../../providers/DesignSystemProvider';
export interface HtmlEditorProps {
value?: string
@ -90,22 +92,30 @@ const KoenigWrapper: React.FC<HtmlEditorProps & { editor: EditorResource }> = ({
nodes
}) => {
const onError = useCallback((error: unknown) => {
// ensure we're still showing errors in development
try {
Sentry.captureException({
error,
tags: {lexical: true},
contexts: {
koenig: {
version: window['@tryghost/koenig-lexical']?.version
}
}
});
} catch (e) {
// if this fails, Sentry is probably not initialized
console.error(e); // eslint-disable-line
}
console.error(error); // eslint-disable-line
// Pass down Sentry from Ember?
// if (this.config.sentry_dsn) {
// Sentry.captureException(error, {
// tags: {lexical: true},
// contexts: {
// koenig: {
// version: window['@tryghost/koenig-lexical']?.version
// }
// }
// });
// }
// don't rethrow, Lexical will attempt to gracefully recover
}, []);
const {setFocusState} = useFocusContext();
const handleBlur = () => {
if (onBlur) {
onBlur();
}
setFocusState(false);
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const koenig = useMemo(() => new Proxy({} as { [key: string]: any }, {
@ -153,7 +163,7 @@ const KoenigWrapper: React.FC<HtmlEditorProps & { editor: EditorResource }> = ({
placeholderClassName='koenig-lexical-editor-input-placeholder'
placeholderText={placeholder}
singleParagraph={true}
onBlur={onBlur}
onBlur={handleBlur}
>
<koenig.HtmlOutputPlugin html={value} setHtml={handleSetHtml} />
</koenig.KoenigComposableEditor>
@ -173,9 +183,13 @@ const HtmlEditor: React.FC<HtmlEditorProps & {
editorUrl: config.editor.url,
editorVersion: config.editor.version
}), [config.editor.url, config.editor.version]);
const {setFocusState} = useFocusContext();
// this is not ideal, we need to add a focus plugin inside the Koenig editor package to handle this properly
const handleFocus = () => {
setFocusState(true);
};
return <div className={className || 'w-full'}>
<div className="koenig-react-editor w-full [&_*]:!font-inherit [&_*]:!text-inherit">
<div className="koenig-react-editor w-full [&_*]:!font-inherit [&_*]:!text-inherit" onFocus={handleFocus}>
<ErrorHandler>
<Suspense fallback={<p className="koenig-react-editor-loading">Loading editor...</p>}>
<KoenigWrapper {...props} editor={editorResource} />

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',
@ -107,8 +122,8 @@ const Select: React.FC<SelectProps> = ({
const customClasses = {
control: clsx(
controlClasses?.control,
'min-h-[40px] w-full cursor-pointer appearance-none pr-4 outline-none dark:text-white',
size === 'xs' ? 'py-0 text-xs' : 'py-2',
'min-h-[40px] w-full cursor-pointer appearance-none outline-none dark:text-white',
size === 'xs' ? 'py-0 pr-2 text-xs' : 'py-2 pr-4',
border && 'border-b',
!clearBg && 'bg-grey-75 px-[10px] dark:bg-grey-950',
error ? 'border-red' : 'border-grey-500 hover:border-grey-700 dark:border-grey-800 dark:hover:border-grey-700',
@ -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,
placeholder: prompt ? prompt : '',
value: selectedOption,
unstyled: true,
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

@ -3,6 +3,7 @@ import React, {useId} from 'react';
import Heading from '../Heading';
import Hint from '../Hint';
import clsx from 'clsx';
import {useFocusContext} from '../../providers/DesignSystemProvider';
type ResizeOptions = 'both' | 'vertical' | 'horizontal' | 'none';
type FontStyles = 'sans' | 'mono';
@ -40,6 +41,15 @@ const TextArea: React.FC<TextAreaProps> = ({
...props
}) => {
const id = useId();
const {setFocusState} = useFocusContext();
const handleFocus = () => {
setFocusState(true);
};
const handleBlur = () => {
setFocusState(false);
};
let styles = clsx(
'peer order-2 rounded-sm border px-3 py-2 dark:text-white',
@ -78,7 +88,9 @@ const TextArea: React.FC<TextAreaProps> = ({
placeholder={placeholder}
rows={rows}
value={value}
onBlur={handleBlur}
onChange={onChange}
onFocus={handleFocus}
{...props}>
</textarea>
{title && <Heading className={'order-1 !text-grey-700 peer-focus:!text-black dark:!text-grey-300 dark:peer-focus:!text-white'} htmlFor={id} useLabelTag={true}>{title}</Heading>}

View File

@ -10,7 +10,11 @@ const meta = {
title: 'Global / Form / Textfield',
component: TextField,
tags: ['autodocs'],
decorators: [(_story: () => ReactNode) => (<div style={{maxWidth: '400px'}}>{_story()}</div>)],
decorators: [(_story: () => ReactNode) => (
<div style={{maxWidth: '400px'}}>
{_story()}
</div>
)],
argTypes: {
hint: {
control: 'text'

View File

@ -2,6 +2,7 @@ import Heading from '../Heading';
import Hint from '../Hint';
import React, {useId} from 'react';
import clsx from 'clsx';
import {useFocusContext} from '../../providers/DesignSystemProvider';
export type TextFieldProps = React.InputHTMLAttributes<HTMLInputElement> & {
inputRef?: React.RefObject<HTMLInputElement>;
@ -50,6 +51,18 @@ const TextField: React.FC<TextFieldProps> = ({
...props
}) => {
const id = useId();
const {setFocusState} = useFocusContext();
const handleFocus = () => {
setFocusState(true);
};
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
if (onBlur) {
onBlur(e);
}
setFocusState(false);
};
const disabledBorderClasses = border && 'border-grey-300 dark:border-grey-900';
const enabledBorderClasses = border && 'border-grey-500 hover:border-grey-700 focus:border-black dark:border-grey-800 dark:hover:border-grey-700 dark:focus:border-grey-500';
@ -77,8 +90,9 @@ const TextField: React.FC<TextFieldProps> = ({
placeholder={placeholder}
type={type}
value={value}
onBlur={onBlur}
onBlur={handleBlur}
onChange={onChange}
onFocus={handleFocus}
{...props} />;
if (rightPlaceholder) {
@ -120,7 +134,7 @@ const TextField: React.FC<TextFieldProps> = ({
</div>
);
} else {
return field;
return (field);
}
};

View File

@ -1,6 +1,7 @@
import React, {useEffect, useState} from 'react';
import TextField, {TextFieldProps} from './TextField';
import validator from 'validator';
import {useFocusContext} from '../../providers/DesignSystemProvider';
const formatUrl = (value: string, baseUrl?: string) => {
let url = value.trim();
@ -99,19 +100,32 @@ const formatUrl = (value: string, baseUrl?: string) => {
*/
const URLTextField: React.FC<Omit<TextFieldProps, 'onChange'> & {
baseUrl?: string;
transformPathWithoutSlash?: boolean;
onChange: (value: string) => void;
}> = ({baseUrl, value, onChange, ...props}) => {
}> = ({baseUrl, value, transformPathWithoutSlash, onChange, ...props}) => {
const [displayedUrl, setDisplayedUrl] = useState('');
const {setFocusState} = useFocusContext();
useEffect(() => {
setDisplayedUrl(formatUrl(value || '', baseUrl).display);
}, [value, baseUrl]);
const updateUrl = () => {
const {save, display} = formatUrl(displayedUrl, baseUrl);
let urls = formatUrl(displayedUrl, baseUrl);
setDisplayedUrl(display);
onChange(save);
// If the user entered something like "bla", try to parse it as a relative URL
// If parsing as "/bla" results in a valid URL, use that instead
if (transformPathWithoutSlash && !urls.display.includes('//')) {
const candidate = formatUrl('/' + displayedUrl, baseUrl);
if (candidate.display.includes('//')) {
urls = candidate;
}
}
setDisplayedUrl(urls.display);
onChange(urls.save);
setFocusState(false);
};
const handleFocus: React.FocusEventHandler<HTMLInputElement> = (e) => {
@ -121,6 +135,7 @@ const URLTextField: React.FC<Omit<TextFieldProps, 'onChange'> & {
}
props.onFocus?.(e);
setFocusState(true);
};
const handleKeyDown: React.KeyboardEventHandler<HTMLInputElement> = (e) => {

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

@ -0,0 +1,37 @@
// FocusContext.tsx
import React, {createContext, useContext, useState} from 'react';
interface DesignSystemContextType {
isAnyTextFieldFocused: boolean;
setFocusState: (value: boolean) => void;
}
const DesignSystemContext = createContext<DesignSystemContextType | undefined>(undefined);
export const useFocusContext = () => {
const context = useContext(DesignSystemContext);
if (!context) {
throw new Error('useFocusContext must be used within a FocusProvider');
}
return context;
};
interface DesignSystemProviderProps {
children: React.ReactNode;
}
const DesignSystemProvider: React.FC<DesignSystemProviderProps> = ({children}) => {
const [isAnyTextFieldFocused, setIsAnyTextFieldFocused] = useState(false);
const setFocusState = (value: boolean) => {
setIsAnyTextFieldFocused(value);
};
return (
<DesignSystemContext.Provider value={{isAnyTextFieldFocused, setFocusState}}>
{children}
</DesignSystemContext.Provider>
);
};
export default DesignSystemProvider;

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
@ -74,10 +74,12 @@ export const useBrowseActions = createInfiniteQuery<ActionsResponseType>({
}
});
const meta = pages.at(-1)!.meta;
return {
actions: actions.reverse(),
meta: pages.at(-1)!.meta,
isEnd: pages.at(-1)!.actions.length < pages.at(-1)!.meta.pagination.limit
meta,
isEnd: meta ? meta.pagination.pages === meta.pagination.page : true
};
}
});

View File

@ -1,5 +1,5 @@
import {IntegrationsResponseType, integrationsDataType} from './integrations';
import {createMutation} from '../utils/apiRequests';
import {createMutation} from '../utils/api/hooks';
// Types
@ -23,6 +23,7 @@ export const useRefreshAPIKey = createMutation<IntegrationsResponseType, {integr
path: ({integrationId, apiKeyId}) => `/integrations/${integrationId}/api_key/${apiKeyId}/refresh/`,
body: ({integrationId}) => ({integrations: [{id: integrationId}]}),
updateQueries: {
emberUpdateType: 'createOrUpdate',
dataType: integrationsDataType,
update: (newData, currentData) => (currentData && {
...(currentData as IntegrationsResponseType),

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 }
@ -40,6 +40,10 @@ export type Config = {
billing?: {
enabled?: boolean
url?: string
},
pintura?: {
js?: string
css?: string
}
}

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 } |
@ -40,6 +40,7 @@ export const useEditCustomThemeSettings = createMutation<CustomThemeSettingsResp
body: settings => ({custom_theme_settings: settings}),
updateQueries: {
emberUpdateType: 'skip',
dataType,
update: newData => newData
}

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;
@ -16,6 +16,7 @@ export const verifyEmailToken = createMutation<EmailVerificationResponseType, em
body: ({token}) => ({token}),
updateQueries: {
dataType,
emberUpdateType: 'createOrUpdate',
update: newData => ({
...newData,
settings: newData.settings

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
@ -41,6 +41,7 @@ export const useCreateIntegration = createMutation<IntegrationsResponseType, Par
searchParams: () => ({include: 'api_keys,webhooks'}),
updateQueries: {
dataType,
emberUpdateType: 'createOrUpdate',
update: (newData, currentData) => (currentData && {
...(currentData as IntegrationsResponseType),
integrations: (currentData as IntegrationsResponseType).integrations.concat(newData.integrations)
@ -55,6 +56,7 @@ export const useEditIntegration = createMutation<IntegrationsResponseType, Integ
searchParams: () => ({include: 'api_keys,webhooks'}),
updateQueries: {
dataType,
emberUpdateType: 'createOrUpdate',
update: (newData, currentData) => (currentData && {
...(currentData as IntegrationsResponseType),
integrations: (currentData as IntegrationsResponseType).integrations.map((integration) => {
@ -70,6 +72,7 @@ export const useDeleteIntegration = createMutation<unknown, string>({
path: id => `/integrations/${id}/`,
updateQueries: {
dataType,
emberUpdateType: 'delete',
update: (_, currentData, id) => ({
...(currentData as IntegrationsResponseType),
integrations: (currentData as IntegrationsResponseType).integrations.filter(user => user.id !== id)

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;
@ -37,6 +37,7 @@ export const useAddInvite = createMutation<InvitesResponseType, {email: string,
}),
updateQueries: {
dataType,
emberUpdateType: 'createOrUpdate',
// Assume that all invite queries should include this new one
update: (newData, currentData) => (currentData && {
...(currentData as InvitesResponseType),
@ -53,6 +54,7 @@ export const useDeleteInvite = createMutation<unknown, string>({
method: 'DELETE',
updateQueries: {
dataType,
emberUpdateType: 'delete',
update: (_, currentData, id) => ({
...(currentData as InvitesResponseType),
invites: (currentData as InvitesResponseType).invites.filter(invite => invite.id !== id)

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,25 @@ 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: '50'},
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);
const meta = pages.at(-1)!.meta;
return {
newsletters: newsletters,
meta,
isEnd: meta ? meta.pagination.pages === meta.pagination.page : true
};
}
});
export const useAddNewsletter = createMutation<NewslettersResponseType, Partial<Newsletter> & {opt_in_existing: boolean}>({
@ -60,10 +77,8 @@ 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)
})
emberUpdateType: 'createOrUpdate',
update: insertToQueryCache('newsletters')
}
});
@ -78,12 +93,7 @@ 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;
})
})
emberUpdateType: 'createOrUpdate',
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,4 +1,5 @@
import {Meta, apiUrl, createMutation, createPaginatedQuery, useFetchApi} from '../utils/apiRequests';
import {InfiniteData} from '@tanstack/react-query';
import {Meta, apiUrl, createInfiniteQuery, createMutation, useFetchApi} from '../utils/api/hooks';
export type Recommendation = {
id: string
@ -28,16 +29,29 @@ export interface RecommendationDeleteResponseType {}
const dataType = 'RecommendationResponseType';
export const useBrowseRecommendations = createPaginatedQuery<RecommendationResponseType>({
export const useBrowseRecommendations = createInfiniteQuery<RecommendationResponseType>({
dataType,
path: '/recommendations/',
defaultSearchParams: {}
returnData: (originalData) => {
const {pages} = originalData as InfiniteData<RecommendationResponseType>;
let recommendations = pages.flatMap(page => page.recommendations);
// Remove duplicates
recommendations = recommendations.filter((recommendation, index) => {
return recommendations.findIndex(({id}) => id === recommendation.id) === index;
});
return {
recommendations,
meta: pages[pages.length - 1].meta
};
}
});
export const useDeleteRecommendation = createMutation<RecommendationDeleteResponseType, Recommendation>({
method: 'DELETE',
path: recommendation => `/recommendations/${recommendation.id}/`,
// Clear all queries because pagination needs to be re-checked
invalidateQueries: {
dataType
}
@ -47,15 +61,9 @@ export const useEditRecommendation = createMutation<RecommendationEditResponseTy
method: 'PUT',
path: recommendation => `/recommendations/${recommendation.id}/`,
body: recommendation => ({recommendations: [recommendation]}),
updateQueries: {
dataType,
update: (newData, currentData) => (currentData && {
...(currentData as RecommendationResponseType),
recommendations: (currentData as RecommendationResponseType).recommendations.map((recommendation) => {
const newRecommendation = newData.recommendations.find(({id}) => id === recommendation.id);
return newRecommendation || recommendation;
})
})
invalidateQueries: {
dataType
}
});
@ -64,7 +72,6 @@ export const useAddRecommendation = createMutation<RecommendationResponseType, P
path: () => '/recommendations/',
body: ({...recommendation}) => ({recommendations: [recommendation]}),
// Clear all queries because pagination needs to be re-checked
invalidateQueries: {
dataType
}

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

@ -0,0 +1,19 @@
import {createQuery} from '../utils/api/hooks';
export type ReferrerHistoryItem = {
date: string,
signups: number,
source: string|null,
paid_conversions: number
};
export interface ReferrerHistoryResponseType {
stats: ReferrerHistoryItem[];
}
const dataType = 'ReferrerHistoryResponseType';
export const useReferrerHistory = createQuery<ReferrerHistoryResponseType>({
dataType,
path: '/stats/referrers/'
});

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
@ -35,6 +35,7 @@ export const useEditSettings = createMutation<SettingsResponseType, Setting[]>({
body: settings => ({settings: settings.map(({key, value}) => ({key, value}))}),
updateQueries: {
dataType,
emberUpdateType: 'createOrUpdate',
update: newData => ({
...newData,
settings: newData.settings

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
@ -54,6 +54,7 @@ export const useActivateTheme = createMutation<ThemesResponseType, string>({
path: name => `/themes/${name}/activate/`,
updateQueries: {
dataType,
emberUpdateType: 'createOrUpdate',
update: (newData: ThemesResponseType, currentData: unknown) => ({
...(currentData as ThemesResponseType),
themes: (currentData as ThemesResponseType).themes.map((theme) => {
@ -77,6 +78,7 @@ export const useDeleteTheme = createMutation<unknown, string>({
path: name => `/themes/${name}/`,
updateQueries: {
dataType,
emberUpdateType: 'delete',
update: (_, currentData, name) => ({
...(currentData as ThemesResponseType),
themes: (currentData as ThemesResponseType).themes.filter(theme => theme.name !== name)
@ -90,6 +92,7 @@ export const useInstallTheme = createMutation<ThemesInstallResponseType, string>
searchParams: repo => ({source: 'github', ref: repo}),
updateQueries: {
dataType,
emberUpdateType: 'createOrUpdate',
// Assume that all invite queries should include this new one
update: (newData, currentData) => (currentData && {
...(currentData as ThemesResponseType),
@ -111,6 +114,7 @@ export const useUploadTheme = createMutation<ThemesInstallResponseType, {file: F
},
updateQueries: {
dataType,
emberUpdateType: 'createOrUpdate',
// Assume that all invite queries should include this new one
update: (newData, currentData) => (currentData && {
...(currentData as ThemesResponseType),

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'
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);
const meta = pages.at(-1)!.meta;
return {
tiers,
meta,
isEnd: meta ? meta.pagination.pages === meta.pagination.page : true
};
}
});
@ -51,13 +65,8 @@ 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;
})
})
emberUpdateType: 'createOrUpdate',
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
@ -28,6 +30,7 @@ export type User = {
paid_subscription_canceled_notification: boolean;
paid_subscription_started_notification: boolean;
mention_notifications: boolean;
recommendation_notifications: boolean;
milestone_notifications: boolean;
roles: UserRole[];
url: string;
@ -61,18 +64,25 @@ 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);
const meta = pages.at(-1)!.meta;
return {
users: users,
meta,
isEnd: meta ? meta.pagination.pages === meta.pagination.page : true
};
}
});
export const useCurrentUser = createQuery<User>({
@ -89,7 +99,8 @@ export const useEditUser = createMutation<UsersResponseType, User>({
searchParams: () => ({include: 'roles'}),
updateQueries: {
dataType,
update: updateUsers
emberUpdateType: 'createOrUpdate',
update: updateQueryCache('users')
}
});
@ -98,10 +109,8 @@ 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)
})
emberUpdateType: 'delete',
update: deleteFromQueryCache('users')
}
});
@ -128,7 +137,8 @@ export const useMakeOwner = createMutation<UsersResponseType, string>({
}),
updateQueries: {
dataType,
update: updateUsers
emberUpdateType: 'createOrUpdate',
update: updateQueryCache('users')
}
});
@ -154,6 +164,10 @@ export function isContributorUser(user: User) {
return user.roles.some(role => role.name === 'Contributor');
}
export function isAuthorOrContributor(user: User) {
return isAuthorUser(user) || isContributorUser(user);
}
export function canAccessSettings(user: User) {
return isOwnerUser(user) || isAdminUser(user) || isEditorUser(user);
}

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
@ -31,6 +31,7 @@ export const useCreateWebhook = createMutation<WebhooksResponseType, Partial<Web
body: webhook => ({webhooks: [webhook]}),
updateQueries: {
dataType: integrationsDataType,
emberUpdateType: 'createOrUpdate',
update: (newData, currentData) => (currentData && {
...(currentData as IntegrationsResponseType),
integrations: (currentData as IntegrationsResponseType).integrations.map((integration) => {
@ -52,6 +53,7 @@ export const useEditWebhook = createMutation<WebhooksResponseType, Webhook>({
body: webhook => ({webhooks: [webhook]}),
updateQueries: {
dataType: integrationsDataType,
emberUpdateType: 'createOrUpdate',
update: (newData, currentData) => (currentData && {
...(currentData as IntegrationsResponseType),
integrations: (currentData as IntegrationsResponseType).integrations.map(integration => ({
@ -69,6 +71,7 @@ export const useDeleteWebhook = createMutation<unknown, string>({
path: id => `/webhooks/${id}/`,
updateQueries: {
dataType: integrationsDataType,
emberUpdateType: 'createOrUpdate',
update: (_, currentData, id) => ({
...(currentData as IntegrationsResponseType),
integrations: (currentData as IntegrationsResponseType).integrations.map(integration => ({

View File

@ -12,6 +12,7 @@ import {searchKeywords as generalSearchKeywords} from './settings/general/Genera
import {getSettingValues} from '../api/settings';
import {searchKeywords as membershipSearchKeywords} from './settings/membership/MembershipSettings';
import {searchKeywords as siteSearchKeywords} from './settings/site/SiteSettings';
import {useFocusContext} from '../admin-x-ds/providers/DesignSystemProvider';
import {useGlobalData} from './providers/GlobalDataProvider';
import {useSearch} from './providers/ServiceProvider';
@ -19,11 +20,12 @@ const Sidebar: React.FC = () => {
const {filter, setFilter} = useSearch();
const {updateRoute} = useRouting();
const searchInputRef = useRef<HTMLInputElement | null>(null);
const {isAnyTextFieldFocused} = useFocusContext();
// Focus in on search field when pressing CMD+K/CTRL+K
useEffect(() => {
const handleKeyPress = (e: KeyboardEvent) => {
if (e.key === '/') {
if (e.key === '/' && !isAnyTextFieldFocused) {
e?.preventDefault();
if (searchInputRef.current) {
searchInputRef.current.focus();
@ -34,7 +36,7 @@ const Sidebar: React.FC = () => {
return () => {
window.removeEventListener('keydown', handleKeyPress);
};
}, []);
});
// Auto-focus on searchfield on page load
useEffect(() => {
@ -73,7 +75,7 @@ const Sidebar: React.FC = () => {
</div>
<div className="no-scrollbar hidden pt-10 tablet:!visible tablet:!block tablet:h-[calc(100vh-5vmin-84px-64px)] tablet:w-[240px] tablet:overflow-y-auto" id='admin-x-settings-sidebar'>
<SettingNavSection keywords={Object.values(generalSearchKeywords).flat()} title="General">
<SettingNavItem keywords={generalSearchKeywords.titleAndDescription} navid='title-and-description' title="Title and description" onClick={handleSectionClick} />
<SettingNavItem keywords={generalSearchKeywords.titleAndDescription} navid='title-and-description' title="Title & description" onClick={handleSectionClick} />
<SettingNavItem keywords={generalSearchKeywords.timeZone} navid='timezone' title="Timezone" onClick={handleSectionClick} />
<SettingNavItem keywords={generalSearchKeywords.publicationLanguage} navid='publication-language' title="Publication language" onClick={handleSectionClick} />
<SettingNavItem keywords={generalSearchKeywords.metadata} navid='metadata' title="Meta data" onClick={handleSectionClick} />
@ -85,7 +87,7 @@ const Sidebar: React.FC = () => {
</SettingNavSection>
<SettingNavSection keywords={Object.values(siteSearchKeywords).flat()} title="Site">
<SettingNavItem keywords={siteSearchKeywords.design} navid='design' title="Branding and design" onClick={handleSectionClick} />
<SettingNavItem keywords={siteSearchKeywords.design} navid='design' title="Design & branding" onClick={handleSectionClick} />
<SettingNavItem keywords={siteSearchKeywords.navigation} navid='navigation' title="Navigation" onClick={handleSectionClick} />
<SettingNavItem keywords={siteSearchKeywords.announcementBar} navid='announcement-bar' title="Announcement bar" onClick={handleSectionClick} />
</SettingNavSection>

View File

@ -18,7 +18,10 @@ interface ServicesContextProps {
zapierTemplates: ZapierTemplate[];
search: SearchService;
unsplashConfig: DefaultHeaderTypes;
toggleFeatureFlag: (flag: string, enabled: boolean) => void;
sentryDSN: string | null;
onUpdate: (dataType: string, response: unknown) => void;
onInvalidate: (dataType: string) => void;
onDelete: (dataType: string, id: string) => void;
}
interface ServicesProviderProps {
@ -26,8 +29,11 @@ interface ServicesProviderProps {
ghostVersion: string;
zapierTemplates: ZapierTemplate[];
officialThemes: OfficialTheme[];
toggleFeatureFlag: (flag: string, enabled: boolean) => void;
unsplashConfig: DefaultHeaderTypes;
sentryDSN: string | null;
onUpdate: (dataType: string, response: unknown) => void;
onInvalidate: (dataType: string) => void;
onDelete: (dataType: string, id: string) => void;
}
const ServicesContext = createContext<ServicesContextProps>({
@ -35,17 +41,20 @@ const ServicesContext = createContext<ServicesContextProps>({
officialThemes: [],
zapierTemplates: [],
search: {filter: '', setFilter: () => {}, checkVisible: () => true},
toggleFeatureFlag: () => {},
unsplashConfig: {
Authorization: '',
'Accept-Version': '',
'Content-Type': '',
'App-Pragma': '',
'X-Unsplash-Cache': true
}
},
sentryDSN: null,
onUpdate: () => {},
onInvalidate: () => {},
onDelete: () => {}
});
const ServicesProvider: React.FC<ServicesProviderProps> = ({children, ghostVersion, zapierTemplates, officialThemes, toggleFeatureFlag, unsplashConfig}) => {
const ServicesProvider: React.FC<ServicesProviderProps> = ({children, ghostVersion, zapierTemplates, officialThemes, unsplashConfig, sentryDSN, onUpdate, onInvalidate, onDelete}) => {
const search = useSearchService();
return (
@ -55,7 +64,10 @@ const ServicesProvider: React.FC<ServicesProviderProps> = ({children, ghostVersi
zapierTemplates,
search,
unsplashConfig,
toggleFeatureFlag
sentryDSN,
onUpdate,
onInvalidate,
onDelete
}}>
{children}
</ServicesContext.Provider>
@ -69,3 +81,5 @@ export const useServices = () => useContext(ServicesContext);
export const useOfficialThemes = () => useServices().officialThemes;
export const useSearch = () => useServices().search;
export const useSentryDSN = () => useServices().sentryDSN;

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,6 +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/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';
@ -172,12 +173,16 @@ const CustomIntegrations: React.FC<{integrations: Integration[]}> = ({integratio
okColor: 'red',
okLabel: 'Delete Integration',
onOk: async (confirmModal) => {
await deleteIntegration(integration.id);
confirmModal?.remove();
showToast({
message: 'Integration deleted',
type: 'success'
});
try {
await deleteIntegration(integration.id);
confirmModal?.remove();
showToast({
message: 'Integration deleted',
type: 'success'
});
} catch (e) {
handleError(e);
}
}
});
}}

View File

@ -4,6 +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/api/handleError';
import useRouting from '../../../../hooks/useRouting';
import {HostLimitError, useLimiter} from '../../../../hooks/useLimiter';
import {RoutingModalProps} from '../../../providers/RoutingProvider';
@ -40,9 +41,13 @@ const AddIntegrationModal: React.FC<RoutingModalProps> = () => {
testId='add-integration-modal'
title='Add integration'
onOk={async () => {
const data = await createIntegration({name});
modal.remove();
updateRoute({route: `integrations/show/${data.integrations[0].id}`});
try {
const data = await createIntegration({name});
modal.remove();
updateRoute({route: `integrations/show/${data.integrations[0].id}`});
} catch (e) {
handleError(e);
}
}}
>
<div className='mt-5'>

View File

@ -4,6 +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/api/handleError';
import useRouting from '../../../../hooks/useRouting';
import {ReactComponent as Icon} from '../../../../assets/icons/amp.svg';
import {Setting, getSettingValues, useEditSettings} from '../../../../api/settings';
@ -30,7 +31,11 @@ const AmpModal = NiceModal.create(() => {
{key: 'amp', value: enabled},
{key: 'amp_gtag_id', value: trackingId}
];
await editSettings(updates);
try {
await editSettings(updates);
} catch (e) {
handleError(e);
}
};
return (
@ -81,4 +86,4 @@ const AmpModal = NiceModal.create(() => {
);
});
export default AmpModal;
export default AmpModal;

View File

@ -7,6 +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/api/handleError';
import useForm from '../../../../hooks/useForm';
import useRouting from '../../../../hooks/useRouting';
import {APIKey, useRefreshAPIKey} from '../../../../api/apiKeys';
@ -30,6 +31,7 @@ const CustomIntegrationModalContent: React.FC<{integration: Integration}> = ({in
onSave: async () => {
await editIntegration(formState);
},
onSaveError: handleError,
onValidate: () => {
const newErrors: Record<string, string> = {};
@ -64,9 +66,13 @@ const CustomIntegrationModalContent: React.FC<{integration: Integration}> = ({in
prompt: `You can regenerate ${name} API Key any time, but any scripts or applications using it will need to be updated.`,
okLabel: `Regenerate ${name} API Key`,
onOk: async (confirmModal) => {
await refreshAPIKey({integrationId: integration.id, apiKeyId: apiKey.id});
setRegenerated(true);
confirmModal?.remove();
try {
await refreshAPIKey({integrationId: integration.id, apiKeyId: apiKey.id});
setRegenerated(true);
confirmModal?.remove();
} catch (e) {
handleError(e);
}
}
});
};
@ -104,8 +110,12 @@ const CustomIntegrationModalContent: React.FC<{integration: Integration}> = ({in
width='100px'
onDelete={() => updateForm(state => ({...state, icon_image: null}))}
onUpload={async (file) => {
const imageUrl = getImageUrl(await uploadImage({file}));
updateForm(state => ({...state, icon_image: imageUrl}));
try {
const imageUrl = getImageUrl(await uploadImage({file}));
updateForm(state => ({...state, icon_image: imageUrl}));
} catch (e) {
handleError(e);
}
}}
>
Upload icon

View File

@ -4,6 +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/api/handleError';
import useRouting from '../../../../hooks/useRouting';
import {ReactComponent as Icon} from '../../../../assets/icons/firstpromoter.svg';
import {Setting, getSettingValues, useEditSettings} from '../../../../api/settings';
@ -53,9 +54,13 @@ const FirstpromoterModal = NiceModal.create(() => {
testId='firstpromoter-modal'
title=''
onOk={async () => {
await handleSave();
updateRoute('integrations');
modal.remove();
try {
await handleSave();
updateRoute('integrations');
modal.remove();
} catch (e) {
handleError(e);
}
}}
>
<IntegrationHeader

View File

@ -4,6 +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/api/handleError';
import pinturaScreenshot from '../../../../assets/images/pintura-screenshot.png';
import useRouting from '../../../../hooks/useRouting';
import {ReactComponent as Icon} from '../../../../assets/icons/pintura.svg';
@ -68,10 +69,7 @@ const PinturaModal = NiceModal.create(() => {
});
} catch (e) {
setUploadingState({js: false, css: false});
showToast({
type: 'pageError',
message: `Can't upload Pintura ${form}!`
});
handleError(e);
}
};

View File

@ -3,6 +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/api/handleError';
import useRouting from '../../../../hooks/useRouting';
import {ReactComponent as Icon} from '../../../../assets/icons/unsplash.svg';
import {Setting, getSettingValues, useEditSettings} from '../../../../api/settings';
@ -19,7 +20,11 @@ const UnsplashModal = NiceModal.create(() => {
const updates: Setting[] = [
{key: 'unsplash', value: (e.target.checked)}
];
await editSettings(updates);
try {
await editSettings(updates);
} catch (error) {
handleError(error);
}
};
return (

View File

@ -4,6 +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/api/handleError';
import toast from 'react-hot-toast';
import useForm from '../../../../hooks/useForm';
import validator from 'validator';
@ -30,6 +31,7 @@ const WebhookModal: React.FC<WebhookModalProps> = ({webhook, integrationId}) =>
await createWebhook({...formState, integration_id: integrationId});
}
},
onSaveError: handleError,
onValidate: () => {
const newErrors: Record<string, string> = {};
@ -92,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,6 +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/api/handleError';
import {Integration} from '../../../../api/integrations';
import {getWebhookEventLabel} from './webhookEventOptions';
import {showToast} from '../../../../admin-x-ds/global/Toast';
@ -21,12 +22,16 @@ const WebhooksTable: React.FC<{integration: Integration}> = ({integration}) => {
okColor: 'red',
okLabel: 'Delete Webhook',
onOk: async (confirmModal) => {
await deleteWebhook(id);
confirmModal?.remove();
showToast({
message: 'Webhook deleted',
type: 'success'
});
try {
await deleteWebhook(id);
confirmModal?.remove();
showToast({
message: 'Webhook deleted',
type: 'success'
});
} catch (e) {
handleError(e);
}
}
});
};

View File

@ -6,6 +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/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';
@ -57,9 +58,13 @@ const ZapierModal = NiceModal.create(() => {
prompt: 'You will need to locate the Ghost App within your Zapier account and click on "Reconnect" to enter the new Admin API Key.',
okLabel: 'Regenerate Admin API Key',
onOk: async (confirmModal) => {
await refreshAPIKey({integrationId: integration.id, apiKeyId: adminApiKey.id});
setRegenerated(true);
confirmModal?.remove();
try {
await refreshAPIKey({integrationId: integration.id, apiKeyId: adminApiKey.id});
setRegenerated(true);
confirmModal?.remove();
} catch (e) {
handleError(e);
}
}
});
};

View File

@ -4,6 +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/api/handleError';
import useRouting from '../../../../hooks/useRouting';
import {downloadRedirects, useUploadRedirects} from '../../../../api/redirects';
import {downloadRoutes, useUploadRoutes} from '../../../../api/routes';
@ -35,13 +36,18 @@ const BetaFeatures: React.FC = () => {
<FileUpload
id='upload-redirects'
onUpload={async (file) => {
setRedirectsUploading(true);
await uploadRedirects(file);
showToast({
type: 'success',
message: 'Redirects uploaded successfully'
});
setRedirectsUploading(false);
try {
setRedirectsUploading(true);
await uploadRedirects(file);
showToast({
type: 'success',
message: 'Redirects uploaded successfully'
});
} catch (e) {
handleError(e);
} finally {
setRedirectsUploading(false);
}
}}
>
<Button color='grey' label={redirectsUploading ? 'Uploading ...' : 'Upload redirects file'} size='sm' tag='div' />
@ -55,13 +61,18 @@ const BetaFeatures: React.FC = () => {
<FileUpload
id='upload-routes'
onUpload={async (file) => {
setRoutesUploading(true);
await uploadRoutes(file);
showToast({
type: 'success',
message: 'Routes uploaded successfully'
});
setRoutesUploading(false);
try {
setRoutesUploading(true);
await uploadRoutes(file);
showToast({
type: 'success',
message: 'Routes uploaded successfully'
});
} catch (e) {
handleError(e);
} finally {
setRoutesUploading(false);
}
}}
>
<Button color='grey' label={routesUploading ? 'Uploading ...' : 'Upload routes file'} size='sm' tag='div' />

View File

@ -1,34 +1,36 @@
import React from 'react';
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
import handleError from '../../../../utils/api/handleError';
import {ConfigResponseType, configDataType} from '../../../../api/config';
import {getSettingValue, useEditSettings} from '../../../../api/settings';
import {useGlobalData} from '../../../providers/GlobalDataProvider';
import {useQueryClient} from '@tanstack/react-query';
import {useServices} from '../../../providers/ServiceProvider';
const FeatureToggle: React.FC<{ flag: string; }> = ({flag}) => {
const {settings} = useGlobalData();
const labs = JSON.parse(getSettingValue<string>(settings, 'labs') || '{}');
const {mutateAsync: editSettings} = useEditSettings();
const client = useQueryClient();
const {toggleFeatureFlag} = useServices();
return <Toggle checked={labs[flag]} onChange={async () => {
const newValue = !labs[flag];
await editSettings([{
key: 'labs',
value: JSON.stringify({...labs, [flag]: newValue})
}]);
toggleFeatureFlag(flag, newValue);
client.setQueriesData([configDataType], current => ({
config: {
...(current as ConfigResponseType).config,
labs: {
...(current as ConfigResponseType).config.labs,
[flag]: newValue
try {
await editSettings([{
key: 'labs',
value: JSON.stringify({...labs, [flag]: newValue})
}]);
client.setQueriesData([configDataType], current => ({
config: {
...(current as ConfigResponseType).config,
labs: {
...(current as ConfigResponseType).config.labs,
[flag]: newValue
}
}
}
}));
}));
} catch (e) {
handleError(e);
}
}} />;
};

View File

@ -5,6 +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/api/handleError';
import {downloadAllContent, useDeleteAllContent, useImportContent} from '../../../../api/db';
import {showToast} from '../../../../admin-x-ds/global/Toast';
import {useQueryClient} from '@tanstack/react-query';
@ -18,16 +19,22 @@ const ImportModalContent = () => {
id="import-file"
onUpload={async (file) => {
setUploading(true);
await importContent(file);
modal.remove();
NiceModal.show(ConfirmationModal, {
title: 'Import in progress',
prompt: `Your import is being processed, and you'll receive a confirmation email as soon as it's complete. Usually this only takes a few minutes, but larger imports may take longer.`,
cancelLabel: '',
okLabel: 'Got it',
onOk: confirmModal => confirmModal?.remove(),
formSheet: false
});
try {
await importContent(file);
modal.remove();
NiceModal.show(ConfirmationModal, {
title: 'Import in progress',
prompt: `Your import is being processed, and you'll receive a confirmation email as soon as it's complete. Usually this only takes a few minutes, but larger imports may take longer.`,
cancelLabel: '',
okLabel: 'Got it',
onOk: confirmModal => confirmModal?.remove(),
formSheet: false
});
} catch (e) {
handleError(e);
} finally {
setUploading(false);
}
}}
>
<div className="cursor-pointer bg-grey-75 p-10 text-center dark:bg-grey-950">
@ -56,12 +63,16 @@ const MigrationOptions: React.FC = () => {
okColor: 'red',
okLabel: 'Delete',
onOk: async () => {
await deleteAllContent(null);
showToast({
type: 'success',
message: 'All content deleted from database.'
});
await client.refetchQueries();
try {
await deleteAllContent(null);
showToast({
type: 'success',
message: 'All content deleted from database.'
});
await client.refetchQueries();
} catch (e) {
handleError(e);
}
}
});
};

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,6 +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/api/handleError';
import useRouting from '../../../hooks/useRouting';
import {Setting, getSettingValues, useEditSettings} from '../../../api/settings';
import {useGlobalData} from '../../providers/GlobalDataProvider';
@ -27,7 +28,11 @@ const EnableNewsletters: React.FC<{ keywords: string[] }> = ({keywords}) => {
updates.push({key: 'editor_default_email_recipients_filter', value: null});
}
await editSettings(updates);
try {
await editSettings(updates);
} catch (error) {
handleError(error);
}
};
const enableToggle = (

View File

@ -5,6 +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/api/handleError';
import useSettingGroup from '../../../hooks/useSettingGroup';
import {getSettingValues, useEditSettings} from '../../../api/settings';
import {withErrorBoundary} from '../../../admin-x-ds/global/ErrorBoundary';
@ -64,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
@ -113,7 +114,12 @@ const MailGun: React.FC<{ keywords: string[] }> = ({keywords}) => {
// resulting in the mailgun base url remaining null
// this should not fire if the user has changed the region or if the region is already set
if (!mailgunRegion) {
await editSettings([{key: 'mailgun_base_url', value: MAILGUN_REGIONS[0].value}]);
try {
await editSettings([{key: 'mailgun_base_url', value: MAILGUN_REGIONS[0].value}]);
} catch (e) {
handleError(e);
return;
}
}
handleSave();
}}

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, meta, isEnd} = {}, fetchNextPage} = useBrowseNewsletters();
const buttons = (
<Button color='green' label='Add newsletter' link linkWithPadding onClick={() => {
@ -43,6 +43,11 @@ const Newsletters: React.FC<{ keywords: string[] }> = ({keywords}) => {
title='Newsletters'
>
<TabView selectedTab={selectedTab} tabs={tabs} onTabChange={setSelectedTab} />
{isEnd === false && <Button
label={`Load more (showing ${newsletters?.length || 0}/${meta?.pagination.total || 0} newsletters)`}
link
onClick={() => fetchNextPage()}
/>}
</SettingGroup>
);
};

View File

@ -6,6 +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/api/handleError';
import useForm from '../../../../hooks/useForm';
import useRouting from '../../../../hooks/useRouting';
import {HostLimitError, useLimiter} from '../../../../hooks/useLimiter';
@ -39,6 +40,7 @@ const AddNewsletterModal: React.FC<RoutingModalProps> = () => {
updateRoute({route: `newsletters/show/${response.newsletters[0].id}`});
},
onSaveError: handleError,
onValidate: () => {
const newErrors: Record<string, string> = {};

View File

@ -19,6 +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/api/handleError';
import useFeatureFlag from '../../../../hooks/useFeatureFlag';
import useForm, {ErrorMessages} from '../../../../hooks/useForm';
import useRouting from '../../../../hooks/useRouting';
@ -84,12 +85,16 @@ const Sidebar: React.FC<{
okLabel: 'Archive',
okColor: 'red',
onOk: async (modal) => {
await editNewsletter({...newsletter, status: 'archived'});
modal?.remove();
showToast({
type: 'success',
message: 'Newsletter archived successfully'
});
try {
await editNewsletter({...newsletter, status: 'archived'});
modal?.remove();
showToast({
type: 'success',
message: 'Newsletter archived successfully'
});
} catch (e) {
handleError(e);
}
}
});
} else {
@ -155,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
@ -212,8 +222,12 @@ const Sidebar: React.FC<{
updateNewsletter({header_image: null});
}}
onUpload={async (file) => {
const imageUrl = getImageUrl(await uploadImage({file}));
updateNewsletter({header_image: imageUrl});
try {
const imageUrl = getImageUrl(await uploadImage({file}));
updateNewsletter({header_image: imageUrl});
} catch (e) {
handleError(e);
}
}}
>
<Icon colorClass='text-grey-700 dark:text-grey-300' name='picture' />
@ -299,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={[
@ -350,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}
@ -447,6 +461,7 @@ const NewsletterDetailModalContent: React.FC<{newsletter: Newsletter; onlyOne: b
modal.remove();
}
},
onSaveError: handleError,
onValidate: () => {
const newErrors: Record<string, string> = {};
@ -500,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

@ -13,52 +13,7 @@ interface NewslettersListProps {
}
const NewsletterItem: React.FC<{newsletter: Newsletter}> = ({newsletter}) => {
// const {mutateAsync: editNewsletter} = useEditNewsletter();
const {updateRoute} = useRouting();
// const limiter = useLimiter();
// const action = newsletter.status === 'active' ? (
// <Button color='red' disabled={onlyOne} label='Archive' link onClick={() => {
// NiceModal.show(ConfirmationModal, {
// title: 'Archive newsletter',
// prompt: <>
// <p>Your newsletter <strong>{newsletter.name}</strong> will no longer be visible to members or available as an option when publishing new posts.</p>
// <p>Existing posts previously sent as this newsletter will remain unchanged.</p>
// </>,
// okLabel: 'Archive',
// onOk: async (modal) => {
// await editNewsletter({...newsletter, status: 'archived'});
// modal?.remove();
// }
// });
// }} />
// ) : (
// <Button color='green' label='Activate' link onClick={async () => {
// try {
// await limiter?.errorIfWouldGoOverLimit('newsletters');
// } catch (error) {
// if (error instanceof HostLimitError) {
// NiceModal.show(LimitModal, {
// prompt: error.message || `Your current plan doesn't support more newsletters.`
// });
// return;
// } else {
// throw error;
// }
// }
// NiceModal.show(ConfirmationModal, {
// title: 'Reactivate newsletter',
// prompt: <>
// Reactivating <strong>{newsletter.name}</strong> will immediately make it visible to members and re-enable it as an option when publishing new posts.
// </>,
// okLabel: 'Reactivate',
// onOk: async (modal) => {
// await editNewsletter({...newsletter, status: 'active'});
// modal?.remove();
// }
// });
// }} />
// );
const showDetails = () => {
updateRoute({route: `newsletters/show/${newsletter.id}`});

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,6 +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/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';
@ -23,20 +24,16 @@ const Facebook: React.FC<{ keywords: string[] }> = ({keywords}) => {
} = useSettingGroup();
const {mutateAsync: uploadImage} = useUploadImage();
const [pintura] = getSettingValues<boolean>(localSettings, ['pintura']);
// const [unsplashEnabled] = getSettingValues<boolean>(localSettings, ['unsplash']);
const [pinturaJsUrl] = getSettingValues<string>(localSettings, ['pintura_js_url']);
const [pinturaCssUrl] = getSettingValues<string>(localSettings, ['pintura_css_url']);
// const [showUnsplash, setShowUnsplash] = useState<boolean>(false);
const pinturaEnabled = Boolean(pintura) && Boolean(pinturaJsUrl) && Boolean(pinturaCssUrl);
const editor = usePinturaEditor(
{config: {
jsUrl: pinturaJsUrl || '',
cssUrl: pinturaCssUrl || ''
},
disabled: !pinturaEnabled}
}}
);
const [
@ -52,8 +49,12 @@ const Facebook: React.FC<{ keywords: string[] }> = ({keywords}) => {
};
const handleImageUpload = async (file: File) => {
const imageUrl = getImageUrl(await uploadImage({file}));
updateSetting('og_image', imageUrl);
try {
const imageUrl = getImageUrl(await uploadImage({file}));
updateSetting('og_image', imageUrl);
} catch (e) {
handleError(e);
}
};
const handleImageDelete = () => {
@ -86,7 +87,7 @@ const Facebook: React.FC<{ keywords: string[] }> = ({keywords}) => {
imageURL={facebookImage}
pintura={
{
isEnabled: pinturaEnabled,
isEnabled: editor.isEnabled,
openEditor: async () => editor.openEditor({
image: facebookImage || '',
handleSave: async (file:File) => {

View File

@ -2,6 +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/api/handleError';
import useRouting from '../../../hooks/useRouting';
import validator from 'validator';
import {HostLimitError, useLimiter} from '../../../hooks/useLimiter';
@ -119,6 +120,7 @@ const InviteUserModal = NiceModal.create(() => {
message: `Failed to send invitation to ${email}`,
type: 'error'
});
handleError(e, {withToast: false});
return;
}
};

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,6 +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/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';
@ -23,18 +24,15 @@ const Twitter: React.FC<{ keywords: string[] }> = ({keywords}) => {
} = useSettingGroup();
const {mutateAsync: uploadImage} = useUploadImage();
const [pintura] = getSettingValues<boolean>(localSettings, ['pintura']);
const [pinturaJsUrl] = getSettingValues<string>(localSettings, ['pintura_js_url']);
const [pinturaCssUrl] = getSettingValues<string>(localSettings, ['pintura_css_url']);
const pinturaEnabled = Boolean(pintura) && Boolean(pinturaJsUrl) && Boolean(pinturaCssUrl);
const editor = usePinturaEditor(
{config: {
jsUrl: pinturaJsUrl || '',
cssUrl: pinturaCssUrl || ''
},
disabled: !pinturaEnabled}
}}
);
const [
@ -53,8 +51,8 @@ const Twitter: React.FC<{ keywords: string[] }> = ({keywords}) => {
try {
const imageUrl = getImageUrl(await uploadImage({file}));
updateSetting('twitter_image', imageUrl);
} catch (err) {
// TODO: handle error
} catch (e) {
handleError(e);
}
};
@ -86,7 +84,7 @@ const Twitter: React.FC<{ keywords: string[] }> = ({keywords}) => {
imageURL={twitterImage}
pintura={
{
isEnabled: pinturaEnabled,
isEnabled: editor.isEnabled,
openEditor: async () => editor.openEditor({
image: twitterImage || '',
handleSave: async (file:File) => {

View File

@ -15,6 +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/api/handleError';
import useFeatureFlag from '../../../hooks/useFeatureFlag';
import usePinturaEditor from '../../../hooks/usePinturaEditor';
import useRouting from '../../../hooks/useRouting';
@ -23,7 +24,7 @@ import validator from 'validator';
import {DetailsInputs} from './DetailsInputs';
import {HostLimitError, useLimiter} from '../../../hooks/useLimiter';
import {RoutingModalProps} from '../../providers/RoutingProvider';
import {User, canAccessSettings, hasAdminAccess, isAdminUser, isOwnerUser, useDeleteUser, useEditUser, useMakeOwner} from '../../../api/users';
import {User, canAccessSettings, hasAdminAccess, isAdminUser, isAuthorOrContributor, isEditorUser, isOwnerUser, useDeleteUser, useEditUser, useMakeOwner} from '../../../api/users';
import {genStaffToken, getStaffToken} from '../../../api/staffToken';
import {getImageUrl, useUploadImage} from '../../../api/images';
import {getSettingValues} from '../../../api/settings';
@ -177,6 +178,7 @@ const Details: React.FC<UserDetailProps> = ({errors, validators, user, setUserDa
const EmailNotificationsInputs: React.FC<UserDetailProps> = ({user, setUserData}) => {
const hasWebmentions = useFeatureFlag('webmentions');
const hasRecommendations = useFeatureFlag('recommendations');
const {currentUser} = useGlobalData();
return (
@ -200,6 +202,15 @@ const EmailNotificationsInputs: React.FC<UserDetailProps> = ({user, setUserData}
setUserData?.({...user, mention_notifications: e.target.checked});
}}
/>}
{hasRecommendations && <Toggle
checked={user.recommendation_notifications}
direction='rtl'
hint='Every time another publisher recommends you to their audience'
label='Recommendations'
onChange={(e) => {
setUserData?.({...user, recommendation_notifications: e.target.checked});
}}
/>}
<Toggle
checked={user.free_member_signup_notification}
direction='rtl'
@ -278,9 +289,13 @@ const StaffToken: React.FC<UserDetailProps> = () => {
okLabel: 'Regenerate your Staff Access Token',
okColor: 'red',
onOk: async (modal) => {
const newAPI = await newApiKey([]);
setToken(newAPI?.apiKey?.secret || '');
modal?.remove();
try {
const newAPI = await newApiKey([]);
setToken(newAPI?.apiKey?.secret || '');
modal?.remove();
} catch (e) {
handleError(e);
}
}
});
};
@ -329,17 +344,14 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
// Pintura integration
const {settings} = useGlobalData();
const [pintura] = getSettingValues<boolean>(settings, ['pintura']);
const [pinturaJsUrl] = getSettingValues<string>(settings, ['pintura_js_url']);
const [pinturaCssUrl] = getSettingValues<string>(settings, ['pintura_css_url']);
const pinturaEnabled = Boolean(pintura) && Boolean(pinturaJsUrl) && Boolean(pinturaCssUrl);
const editor = usePinturaEditor(
{config: {
jsUrl: pinturaJsUrl || '',
cssUrl: pinturaCssUrl || ''
},
disabled: !pinturaEnabled}
}}
);
const navigateOnClose = useCallback(() => {
@ -395,13 +407,17 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
..._user,
status: _user.status === 'inactive' ? 'active' : 'inactive'
};
await updateUser(updatedUserData);
setUserData(updatedUserData);
modal?.remove();
showToast({
message: _user.status === 'inactive' ? 'User un-suspended' : 'User suspended',
type: 'success'
});
try {
await updateUser(updatedUserData);
setUserData(updatedUserData);
modal?.remove();
showToast({
message: _user.status === 'inactive' ? 'User un-suspended' : 'User suspended',
type: 'success'
});
} catch (e) {
handleError(e);
}
}
});
};
@ -418,13 +434,17 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
okLabel: 'Delete user',
okColor: 'red',
onOk: async (modal) => {
await deleteUser(_user?.id);
modal?.remove();
mainModal?.remove();
showToast({
message: 'User deleted',
type: 'success'
});
try {
await deleteUser(_user?.id);
modal?.remove();
mainModal?.remove();
showToast({
message: 'User deleted',
type: 'success'
});
} catch (e) {
handleError(e);
}
}
});
};
@ -436,12 +456,16 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
okLabel: 'Yep — I\'m sure',
okColor: 'red',
onOk: async (modal) => {
await makeOwner(user.id);
modal?.remove();
showToast({
message: 'Ownership transferred',
type: 'success'
});
try {
await makeOwner(user.id);
modal?.remove();
showToast({
message: 'Ownership transferred',
type: 'success'
});
} catch (e) {
handleError(e);
}
}
});
};
@ -462,8 +486,8 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
});
break;
}
} catch (err) {
// TODO: handle error
} catch (e) {
handleError(e);
}
};
@ -482,6 +506,7 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
}
};
const showMenu = hasAdminAccess(currentUser) || (isEditorUser(currentUser) && isAuthorOrContributor(user));
let menuItems: MenuItem[] = [];
if (isOwnerUser(currentUser) && isAdminUser(userData) && userData.status !== 'inactive') {
@ -492,7 +517,10 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
});
}
if (userData.id !== currentUser.id) {
if (userData.id !== currentUser.id && (
(hasAdminAccess(currentUser) && !isOwnerUser(user)) ||
(isEditorUser(currentUser) && isAuthorOrContributor(user))
)) {
let suspendUserLabel = userData.status === 'inactive' ? 'Un-suspend user' : 'Suspend user';
menuItems.push({
@ -528,7 +556,7 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
}
const coverButtonContainerClassName = clsx(
hasAdminAccess(currentUser) ? (
showMenu ? (
userData.cover_image ? 'relative ml-10 mr-[106px] flex translate-y-[-80px] gap-3 md:ml-0 md:justify-end' : 'relative -mb-8 ml-10 mr-[106px] flex translate-y-[358px] md:ml-0 md:translate-y-[268px] md:justify-end'
) : (
userData.cover_image ? 'relative ml-10 flex max-w-4xl translate-y-[-80px] gap-3 md:mx-auto md:justify-end' : 'relative -mb-8 ml-10 flex max-w-4xl translate-y-[358px] md:mx-auto md:translate-y-[268px] md:justify-end'
@ -621,7 +649,7 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
imageURL={userData.cover_image || ''}
pintura={
{
isEnabled: pinturaEnabled,
isEnabled: editor.isEnabled,
openEditor: async () => editor.openEditor({
image: userData.cover_image || '',
handleSave: async (file:File) => {
@ -638,7 +666,7 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
handleImageUpload('cover_image', file);
}}
>Upload cover image</ImageUpload>
{hasAdminAccess(currentUser) && <div className="absolute bottom-12 right-12 z-10">
{showMenu && <div className="absolute bottom-12 right-12 z-10">
<Menu items={menuItems} position='right' trigger={<UserMenuTrigger />}></Menu>
</div>}
<div className={`${!canAccessSettings(currentUser) ? 'mx-10 pl-0 md:max-w-[50%] min-[920px]:ml-[calc((100vw-920px)/2)] min-[920px]:max-w-[460px]' : 'max-w-[50%] pl-12'} relative flex flex-col items-start gap-4 pb-60 pt-10 md:flex-row md:items-center md:pb-7 md:pt-60`}>
@ -653,7 +681,7 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
imageURL={userData.profile_image}
pintura={
{
isEnabled: pinturaEnabled,
isEnabled: editor.isEnabled,
openEditor: async () => editor.openEditor({
image: userData.profile_image || '',
handleSave: async (file:File) => {
@ -694,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,6 +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/api/handleError';
import useRouting from '../../../hooks/useRouting';
import useStaffUsers from '../../../hooks/useStaffUsers';
import {User, hasAdminAccess, isContributorUser, isEditorUser} from '../../../api/users';
@ -127,13 +128,18 @@ const UserInviteActions: React.FC<{invite: UserInvite}> = ({invite}) => {
label={revokeActionLabel}
link={true}
onClick={async () => {
setRevokeState('progress');
await deleteInvite(invite.id);
setRevokeState('');
showToast({
message: `Invitation revoked (${invite.email})`,
type: 'success'
});
try {
setRevokeState('progress');
await deleteInvite(invite.id);
showToast({
message: `Invitation revoked (${invite.email})`,
type: 'success'
});
} catch (e) {
handleError(e);
} finally {
setRevokeState('');
}
}}
/>
<Button
@ -142,17 +148,22 @@ const UserInviteActions: React.FC<{invite: UserInvite}> = ({invite}) => {
label={resendActionLabel}
link={true}
onClick={async () => {
setResendState('progress');
await deleteInvite(invite.id);
await addInvite({
email: invite.email,
roleId: invite.role_id
});
setResendState('');
showToast({
message: `Invitation resent! (${invite.email})`,
type: 'success'
});
try {
setResendState('progress');
await deleteInvite(invite.id);
await addInvite({
email: invite.email,
roleId: invite.role_id
});
showToast({
message: `Invitation resent! (${invite.email})`,
type: 'success'
});
} catch (e) {
handleError(e);
} finally {
setResendState('');
}
}}
/>
</div>
@ -195,12 +206,16 @@ const InvitesUserList: React.FC<InviteListProps> = ({users}) => {
const Users: React.FC<{ keywords: string[], highlight?: boolean }> = ({keywords, highlight = true}) => {
const {
totalUsers,
users,
ownerUser,
adminUsers,
editorUsers,
authorUsers,
contributorUsers,
invites
invites,
hasNextPage,
fetchNextPage
} = useStaffUsers();
const {updateRoute} = useRouting();
@ -255,6 +270,11 @@ const Users: React.FC<{ keywords: string[], highlight?: boolean }> = ({keywords,
>
<Owner user={ownerUser} />
<TabView selectedTab={selectedTab} tabs={tabs} onTabChange={setSelectedTab} />
{hasNextPage && <Button
label={`Load more (showing ${users.length}/${totalUsers} users)`}
link
onClick={() => fetchNextPage()}
/>}
</SettingGroup>
);
};

View File

@ -2,6 +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/api/handleError';
import {User, useUpdatePassword} from '../../../../api/users';
import {ValidationError} from '../../../../utils/errors';
import {showToast} from '../../../../admin-x-ds/global/Toast';
@ -223,6 +224,7 @@ const ChangePasswordForm: React.FC<{user: User}> = ({user}) => {
type: 'pageError',
message: e instanceof ValidationError ? e.message : `Couldn't update password. Please try again.`
});
handleError(e, {withToast: false});
}
}}
/>

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, meta, isEnd} = {}, fetchNextPage} = useBrowseTiers();
const activeTiers = getActiveTiers(tiers || []);
const archivedTiers = getArchivedTiers(tiers || []);
const {updateRoute} = useRouting();
@ -80,6 +81,11 @@ const Tiers: React.FC<{ keywords: string[] }> = ({keywords}) => {
</div>
{content}
{isEnd === false && <Button
label={`Load more (showing ${tiers?.length || 0}/${meta?.pagination.total || 0} tiers)`}
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,6 +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/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';
@ -52,9 +53,13 @@ const LookAndFeel: React.FC<{
const [uploadedIcon, setUploadedIcon] = useState(isDefaultIcon ? undefined : currentIcon);
const handleImageUpload = async (file: File) => {
const imageUrl = getImageUrl(await uploadImage({file}));
updateSetting('portal_button_icon', imageUrl);
setUploadedIcon(imageUrl);
try {
const imageUrl = getImageUrl(await uploadImage({file}));
updateSetting('portal_button_icon', imageUrl);
setUploadedIcon(imageUrl);
} catch (e) {
handleError(e);
}
};
const handleImageDelete = () => {
@ -62,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)}
@ -70,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,14 +6,14 @@ 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/api/handleError';
import useForm, {Dirtyable} from '../../../../hooks/useForm';
import useQueryParams from '../../../../hooks/useQueryParams';
import useRouting from '../../../../hooks/useRouting';
import {PreviewModalContent} from '../../../../admin-x-ds/global/modal/PreviewModal';
import {Setting, SettingValue, useEditSettings} from '../../../../api/settings';
import {Setting, SettingValue, getSettingValues, useEditSettings} from '../../../../api/settings';
import {Tier, getPaidActiveTiers, useBrowseTiers, useEditTier} from '../../../../api/tiers';
import {fullEmailAddress} from '../../../../api/site';
import {getSettingValues} from '../../../../api/settings';
import {useGlobalData} from '../../../providers/GlobalDataProvider';
import {verifyEmailToken} from '../../../../api/emailVerification';
@ -106,6 +106,7 @@ const PortalModal: React.FC = () => {
cancelLabel: '',
onOk: confirmModal => confirmModal?.remove()
});
handleError(e, {withToast: false});
}
};
if (verifyEmail) {
@ -139,7 +140,9 @@ const PortalModal: React.FC = () => {
onOk: confirmModal => confirmModal?.remove()
});
}
}
},
onSaveError: handleError
});
const [errors, setErrors] = useState<Record<string, string | undefined>>({});

View File

@ -13,6 +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/api/handleError';
import useRouting from '../../../../hooks/useRouting';
import useSettingGroup from '../../../../hooks/useSettingGroup';
import {JSONError} from '../../../../utils/errors';
@ -63,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;
@ -86,7 +87,8 @@ const Connect: React.FC = () => {
// no-op: will try saving again as stripe is not ready
continue;
} else {
throw e;
handleError(e);
return;
}
}
}
@ -111,8 +113,10 @@ const Connect: React.FC = () => {
if (e instanceof JSONError && e.data?.errors) {
setError('Invalid secure key');
return;
} else {
handleError(e);
return;
}
throw error;
}
} else {
setError('Please enter a secure key');
@ -169,9 +173,13 @@ const Connected: React.FC<{onClose?: () => void}> = ({onClose}) => {
from this site. This will automatically turn off paid memberships on this site.</>),
okLabel: hasActiveStripeSubscriptions ? '' : 'Disconnect',
onOk: async (modal) => {
await deleteStripeSettings(null);
modal?.remove();
onClose?.();
try {
await deleteStripeSettings(null);
modal?.remove();
onClose?.();
} catch (e) {
handleError(e);
}
}
});
};

View File

@ -12,6 +12,8 @@ import SortableList from '../../../../admin-x-ds/global/SortableList';
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/api/handleError';
import useForm from '../../../../hooks/useForm';
import useRouting from '../../../../hooks/useRouting';
import useSettingGroup from '../../../../hooks/useSettingGroup';
@ -69,7 +71,8 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
}
modal.remove();
}
},
onSaveError: handleError
});
const validators = {
@ -220,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>
@ -269,7 +272,15 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
/>
</div>
</div>
<TextField hint='Redirect to this URL after signup for premium membership' placeholder={siteData?.url} title='Welcome page' />
<URLTextField
baseUrl={siteData?.url}
hint='Redirect to this URL after signup for premium membership'
placeholder={siteData?.url}
title='Welcome page'
value={formState.welcome_page_url || ''}
transformPathWithoutSlash
onChange={value => updateForm(state => ({...state, welcome_page_url: value || null}))}
/>
</>)}
</Form>
@ -328,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,6 +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/api/handleError';
import useForm from '../../../hooks/useForm';
import useRouting from '../../../hooks/useRouting';
import {CustomThemeSetting, useBrowseCustomThemeSettings, useEditCustomThemeSettings} from '../../../api/customThemeSettings';
@ -116,7 +117,8 @@ const DesignModal: React.FC = () => {
const {settings: newSettings} = await editSettings(formState.settings.filter(setting => setting.dirty));
updateForm(state => ({...state, settings: newSettings}));
}
}
},
onSaveError: handleError
});
useEffect(() => {

View File

@ -13,11 +13,11 @@ const DesignSetting: React.FC<{ keywords: string[] }> = ({keywords}) => {
return (
<SettingGroup
customButtons={<Button color='green' label='Customize' link linkWithPadding onClick={openPreviewModal}/>}
description="Customize the look and feel of your site"
description="Customize the theme, colors, and layout of your site"
keywords={keywords}
navid='design'
testId='design'
title="Branding and design"
title="Design & branding"
/>
);
};

View File

@ -1,29 +1,90 @@
import Button from '../../../admin-x-ds/global/Button';
import IncomingRecommendations from './recommendations/IncomingRecommendations';
import Link from '../../../admin-x-ds/global/Link';
import IncomingRecommendationList from './recommendations/IncomingRecommendationList';
import React, {useState} from 'react';
import RecommendationList from './recommendations/RecommendationList';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import TabView from '../../../admin-x-ds/global/TabView';
import useRouting from '../../../hooks/useRouting';
import useSettingGroup from '../../../hooks/useSettingGroup';
import {ShowMoreData} from '../../../admin-x-ds/global/Table';
import {useBrowseMentions} from '../../../api/mentions';
import {useBrowseRecommendations} from '../../../api/recommendations';
import {useReferrerHistory} from '../../../api/referrers';
import {withErrorBoundary} from '../../../admin-x-ds/global/ErrorBoundary';
const Recommendations: React.FC<{ keywords: string[] }> = ({keywords}) => {
const {
saveState,
siteData,
handleSave
} = useSettingGroup();
const {pagination, data: {recommendations} = {}, isLoading} = useBrowseRecommendations({
// Fetch "Your recommendations"
const {data: {meta: recommendationsMeta, recommendations} = {}, isLoading: areRecommendationsLoading, hasNextPage, fetchNextPage} = useBrowseRecommendations({
searchParams: {
include: 'count.clicks,count.subscribers'
include: 'count.clicks,count.subscribers',
limit: '5'
},
// We first load 5, then load 100 at a time (= show all, but without using the dangerous 'all' limit)
getNextPageParams: (lastPage, otherParams) => {
if (!lastPage.meta) {
return;
}
const {limit, page, pages} = lastPage.meta.pagination;
if (page >= pages) {
return;
}
const newPage = limit < 100 ? 1 : (page + 1);
return {
...otherParams,
page: newPage.toString(),
limit: '100'
};
},
keepPreviousData: true
});
const showMoreRecommendations: ShowMoreData = {
hasMore: !!hasNextPage,
loadMore: fetchNextPage
};
// Fetch "Recommending you" (mentions & stats)
const {data: {mentions} = {}, pagination: mentionsPagination, isLoading: areMentionsLoading} = useBrowseMentions({
searchParams: {
limit: '5',
filter: `source:~$'/.well-known/recommendations.json'+verified:true`,
order: 'created_at desc'
}
});
const {data: {stats: mentionsStats} = {}, isLoading: areSourcesLoading} = useReferrerHistory({});
// Select "Your recommendations" by default
const [selectedTab, setSelectedTab] = useState('your-recommendations');
const tabs = [
{
id: 'your-recommendations',
title: `Your recommendations`,
counter: recommendationsMeta?.pagination?.total,
contents: <RecommendationList isLoading={areRecommendationsLoading} recommendations={recommendations ?? []} showMore={showMoreRecommendations}/>
},
{
id: 'recommending-you',
title: `Recommending you`,
counter: mentionsPagination?.total,
contents: <IncomingRecommendationList isLoading={areMentionsLoading || areSourcesLoading} mentions={mentions ?? []} pagination={mentionsPagination} stats={mentionsStats ?? []}/>
}
];
const groupDescription = (
<>Recommend any publication you think your audience will find valuable, and find out when others are recommending you.</>
);
// Add a new recommendation
const {updateRoute} = useRouting();
const openAddNewRecommendationModal = () => {
updateRoute('recommendations/add');
@ -35,25 +96,6 @@ const Recommendations: React.FC<{ keywords: string[] }> = ({keywords}) => {
}} />
);
const recommendationsURL = `${siteData?.url.replace(/\/$/, '')}/#/portal/recommendations`;
const tabs = [
{
id: 'your-recommendations',
title: 'Your recommendations',
contents: (<RecommendationList isLoading={isLoading} pagination={pagination} recommendations={recommendations ?? []}/>)
},
{
id: 'recommending-you',
title: 'Recommending you',
contents: (<IncomingRecommendations />)
}
];
const groupDescription = (
<>Share favorite sites with your audience after they subscribe. {(pagination && pagination.total && pagination.total > 0) && <Link href={recommendationsURL} target='_blank'>Preview</Link>}</>
);
return (
<SettingGroup
customButtons={buttons}

View File

@ -12,9 +12,10 @@ 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/api/handleError';
import useRouting from '../../../hooks/useRouting';
import {HostLimitError, useLimiter} from '../../../hooks/useLimiter';
import {InstalledTheme, Theme, useBrowseThemes, useInstallTheme, useUploadTheme} from '../../../api/themes';
import {InstalledTheme, Theme, ThemesInstallResponseType, useBrowseThemes, useInstallTheme, useUploadTheme} from '../../../api/themes';
import {OfficialTheme} from '../../providers/ServiceProvider';
interface ThemeToolbarProps {
@ -71,7 +72,18 @@ const ThemeToolbar: React.FC<ThemeToolbarProps> = ({
file: File;
onActivate?: () => void
}) => {
const data = await uploadTheme({file});
let data: ThemesInstallResponseType | undefined;
try {
data = await uploadTheme({file});
} catch (e) {
handleError(e);
}
if (!data) {
return;
}
const uploadedTheme = data.themes[0];
let title = 'Upload successful';
@ -247,8 +259,18 @@ const ChangeThemeModal = () => {
prompt = <>By clicking below, <strong>{selectedTheme.name}</strong> will automatically be activated as the theme for your site.</>;
} else {
setInstalling(true);
const data = await installTheme(selectedTheme.ref);
setInstalling(false);
let data: ThemesInstallResponseType | undefined;
try {
data = await installTheme(selectedTheme.ref);
} catch (e) {
handleError(e);
} finally {
setInstalling(false);
}
if (!data) {
return;
}
const newlyInstalledTheme = data.themes[0];

View File

@ -6,6 +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/api/handleError';
import usePinturaEditor from '../../../../hooks/usePinturaEditor';
import {SettingValue, getSettingValues} from '../../../../api/settings';
import {debounce} from '../../../../utils/debounce';
@ -25,7 +26,6 @@ const BrandSettings: React.FC<{ values: BrandSettingValues, updateSetting: (key:
const {mutateAsync: uploadImage} = useUploadImage();
const [siteDescription, setSiteDescription] = useState(values.description);
const {settings} = useGlobalData();
const [pintura] = getSettingValues<boolean>(settings, ['pintura']);
const [unsplashEnabled] = getSettingValues<boolean>(settings, ['unsplash']);
const [pinturaJsUrl] = getSettingValues<string>(settings, ['pintura_js_url']);
const [pinturaCssUrl] = getSettingValues<string>(settings, ['pintura_css_url']);
@ -38,18 +38,13 @@ const BrandSettings: React.FC<{ values: BrandSettingValues, updateSetting: (key:
}, 500)
);
const pinturaEnabled = Boolean(pintura) && Boolean(pinturaJsUrl) && Boolean(pinturaCssUrl);
const editor = usePinturaEditor(
{config: {
jsUrl: pinturaJsUrl || '',
cssUrl: pinturaCssUrl || ''
},
disabled: !pinturaEnabled}
}}
);
// check if pintura !false and pintura_js_url and pintura_css_url are not '' or null or undefined
return (
<div className='mt-7'>
<SettingGroupContent>
@ -91,7 +86,11 @@ const BrandSettings: React.FC<{ values: BrandSettingValues, updateSetting: (key:
width={values.icon ? '66px' : '150px'}
onDelete={() => updateSetting('icon', null)}
onUpload={async (file) => {
updateSetting('icon', getImageUrl(await uploadImage({file})));
try {
updateSetting('icon', getImageUrl(await uploadImage({file})));
} catch (e) {
handleError(e);
}
}}
>
Upload icon
@ -109,7 +108,11 @@ const BrandSettings: React.FC<{ values: BrandSettingValues, updateSetting: (key:
imageURL={values.logo || ''}
onDelete={() => updateSetting('logo', null)}
onUpload={async (file) => {
updateSetting('logo', getImageUrl(await uploadImage({file})));
try {
updateSetting('logo', getImageUrl(await uploadImage({file})));
} catch (e) {
handleError(e);
}
}}
>
Upload logo
@ -126,11 +129,15 @@ const BrandSettings: React.FC<{ values: BrandSettingValues, updateSetting: (key:
openUnsplash={() => setShowUnsplash(true)}
pintura={
{
isEnabled: pinturaEnabled,
isEnabled: editor.isEnabled,
openEditor: async () => editor.openEditor({
image: values.coverImage || '',
handleSave: async (file:File) => {
updateSetting('cover_image', getImageUrl(await uploadImage({file})));
try {
updateSetting('cover_image', getImageUrl(await uploadImage({file})));
} catch (e) {
handleError(e);
}
}
})
}
@ -139,7 +146,11 @@ const BrandSettings: React.FC<{ values: BrandSettingValues, updateSetting: (key:
unsplashEnabled={true}
onDelete={() => updateSetting('cover_image', null)}
onUpload={async (file) => {
updateSetting('cover_image', getImageUrl(await uploadImage({file})));
try {
updateSetting('cover_image', getImageUrl(await uploadImage({file})));
} catch (e) {
handleError(e);
}
}}
>
Upload cover

View File

@ -7,6 +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/api/handleError';
import {CustomThemeSetting} from '../../../../api/customThemeSettings';
import {getImageUrl, useUploadImage} from '../../../../api/images';
import {humanizeSettingKey} from '../../../../api/settings';
@ -18,8 +19,12 @@ const ThemeSetting: React.FC<{
const {mutateAsync: uploadImage} = useUploadImage();
const handleImageUpload = async (file: File) => {
const imageUrl = getImageUrl(await uploadImage({file}));
setSetting(imageUrl);
try {
const imageUrl = getImageUrl(await uploadImage({file}));
setSetting(imageUrl);
} catch (e) {
handleError(e);
}
};
switch (setting.type) {
@ -47,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

@ -48,8 +48,15 @@ const AddRecommendationModal: React.FC<RoutingModalProps & AddRecommendationModa
throw new AlreadyExistsError('A recommendation with this URL already exists.');
}
// Check if it s a Ghost site or not
let externalGhostSite = validatedUrl.protocol === 'https:' ? (await queryExternalGhostSite('https://' + validatedUrl.host)) : null;
// Check if it's a Ghost site or not:
// 1. Check the full path first. This is the most common use case, and also helps to cover Ghost sites that are hosted on a subdirectory
// 2. If needed, check the origin. This helps to cover cases where the recommendation URL is a subpage or a post URL of the Ghost site
let externalGhostSite = null;
externalGhostSite = await queryExternalGhostSite(validatedUrl.toString());
if (!externalGhostSite && validatedUrl.pathname !== '' && validatedUrl.pathname !== '/') {
externalGhostSite = await queryExternalGhostSite(validatedUrl.origin);
}
// Use the hostname as fallback title
const defaultTitle = validatedUrl.hostname.replace('www.', '');

View File

@ -3,6 +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/api/handleError';
import useForm from '../../../../hooks/useForm';
import useRouting from '../../../../hooks/useRouting';
import {EditOrAddRecommendation, useAddRecommendation} from '../../../../api/recommendations';
@ -31,6 +32,7 @@ const AddRecommendationModalConfirm: React.FC<AddRecommendationModalProps> = ({r
});
updateRoute('recommendations');
},
onSaveError: handleError,
onValidate: () => {
const newErrors: Record<string, string> = {};
if (!formState.title) {

Some files were not shown because too many files have changed in this diff Show More