Improve messaging and error handling (#20078)
ref DES-228 This PR updates messaging and error handling in order to make Ghost calmer and friendlier. High level summary of the changes: - Removed all onBlur validation in Settings -> now it’s possible to just click around without being warned to fill mandatory fields - Removed lot of technical errors like `ValidationError: Validation (isEmpty) failed for locale` - Completely removed the red background toast notifications, it was aggressive and raw esp. on the top - Removed some unnecessary notifications (e.g. when removing a webhook, the removal already communicates the result) - Now we show field errors on submitting forms, and in case of an error we show a “Retry” button in Settings too. This allowed to remove a lot of unnecessary error messages, like the big error message on the top, plus it’s consistent with the patterns outside Settings. - Notification style is white now with filled color icons which makes everything much calmer and more refined. - Removes redundant copy (e.g. "successful(ly)") from notifications --------- Co-authored-by: Sodbileg Gansukh <sodbileg.gansukh@gmail.com>
This commit is contained in:
parent
842290cbef
commit
770f657ae9
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24" id="Alert-Triangle--Streamline-Ultimate"><desc>Alert Triangle Streamline Icon: https://streamlinehq.com</desc><path d="m23.77 20.57 -10 -19A2 2 0 0 0 12 0.5a2 2 0 0 0 -1.77 1.07l-10 19a2 2 0 0 0 0.06 2A2 2 0 0 0 2 23.5h20a2 2 0 0 0 1.77 -2.93ZM11 8.5a1 1 0 0 1 2 0v6a1 1 0 0 1 -2 0ZM12.05 20a1.53 1.53 0 0 1 -1.52 -1.47A1.48 1.48 0 0 1 12 17a1.53 1.53 0 0 1 1.52 1.47A1.48 1.48 0 0 1 12.05 20Z" fill="currentColor" stroke-width="1"></path></svg>
|
After Width: | Height: | Size: 528 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24" id="Information-Circle--Streamline-Ultimate"><desc>Information Circle Streamline Icon: https://streamlinehq.com</desc><path d="M12 0a12 12 0 1 0 12 12A12 12 0 0 0 12 0Zm0.25 5a1.5 1.5 0 1 1 -1.5 1.5 1.5 1.5 0 0 1 1.5 -1.5Zm2.25 13.5h-4a1 1 0 0 1 0 -2h0.75a0.25 0.25 0 0 0 0.25 -0.25v-4.5a0.25 0.25 0 0 0 -0.25 -0.25h-0.75a1 1 0 0 1 0 -2h1a2 2 0 0 1 2 2v4.75a0.25 0.25 0 0 0 0.25 0.25h0.75a1 1 0 0 1 0 2Z" fill="currentcolor" stroke-width="1"></path></svg>
|
After Width: | Height: | Size: 538 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24" id="Check-Circle-1--Streamline-Ultimate"><desc>Check Circle 1 Streamline Icon: https://streamlinehq.com</desc><path d="M12 0a12 12 0 1 0 12 12A12 12 0 0 0 12 0Zm6.93 8.2 -6.85 9.29a1 1 0 0 1 -1.43 0.19l-4.89 -3.91a1 1 0 0 1 -0.15 -1.41A1 1 0 0 1 7 12.21l4.08 3.26L17.32 7a1 1 0 0 1 1.39 -0.21 1 1 0 0 1 0.22 1.41Z" fill="currentcolor" stroke-width="1"></path></svg>
|
After Width: | Height: | Size: 448 B |
@ -3,7 +3,7 @@ import React from 'react';
|
||||
|
||||
const icons: Record<string, {ReactComponent: React.FC<React.SVGProps<SVGSVGElement>>}> = import.meta.glob('../assets/icons/*.svg', {eager: true});
|
||||
|
||||
export type IconSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'custom' | number;
|
||||
export type IconSize = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'custom' | number;
|
||||
|
||||
export interface IconProps {
|
||||
name: string;
|
||||
@ -36,6 +36,9 @@ const Icon: React.FC<IconProps> = ({name, size = 'md', colorClass = '', classNam
|
||||
switch (size) {
|
||||
case 'custom':
|
||||
break;
|
||||
case '2xs':
|
||||
styles = 'w-2 h-2';
|
||||
break;
|
||||
case 'xs':
|
||||
styles = 'w-3 h-3';
|
||||
break;
|
||||
|
@ -36,12 +36,50 @@ type Story = StoryObj<typeof ToastContainer>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
title: 'Toast title',
|
||||
message: 'Hello notification in a toast'
|
||||
}
|
||||
};
|
||||
|
||||
export const TitleOnly: Story = {
|
||||
args: {
|
||||
title: 'Hello notification in a toast'
|
||||
}
|
||||
};
|
||||
|
||||
export const MinWidth: Story = {
|
||||
args: {
|
||||
title: 'Min toast'
|
||||
}
|
||||
};
|
||||
|
||||
export const TitleWithIcon: Story = {
|
||||
args: {
|
||||
title: 'Hello notification in a toast',
|
||||
type: 'info',
|
||||
options: {
|
||||
duration: Infinity
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const MessageOnly: Story = {
|
||||
args: {
|
||||
message: 'Hey, this is a message in a toast. Almost like a message in a bottle.'
|
||||
}
|
||||
};
|
||||
|
||||
export const Info: Story = {
|
||||
args: {
|
||||
title: 'Toast title',
|
||||
message: 'Hello success message in a toast',
|
||||
type: 'info'
|
||||
}
|
||||
};
|
||||
|
||||
export const Success: Story = {
|
||||
args: {
|
||||
title: 'Toast title',
|
||||
message: 'Hello success message in a toast',
|
||||
type: 'success'
|
||||
}
|
||||
@ -49,13 +87,26 @@ export const Success: Story = {
|
||||
|
||||
export const Error: Story = {
|
||||
args: {
|
||||
title: 'Toast title',
|
||||
message: 'Hello error message in a toast',
|
||||
type: 'error'
|
||||
}
|
||||
};
|
||||
|
||||
export const Infinite: Story = {
|
||||
args: {
|
||||
title: 'Toast title',
|
||||
message: 'Hello error message in a toast',
|
||||
type: 'error',
|
||||
options: {
|
||||
duration: Infinity
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const PageError: Story = {
|
||||
args: {
|
||||
title: 'Toast title',
|
||||
message: 'This is a page error which should not be automatically dismissed.',
|
||||
type: 'pageError'
|
||||
}
|
||||
|
@ -3,9 +3,10 @@ import React from 'react';
|
||||
import {Toast as HotToast, ToastOptions, toast} from 'react-hot-toast';
|
||||
import Icon from './Icon';
|
||||
|
||||
export type ToastType = 'neutral' | 'success' | 'error' | 'pageError';
|
||||
export type ToastType = 'neutral' | 'info' | 'success' | 'error' | 'pageError';
|
||||
|
||||
export interface ShowToastProps {
|
||||
title?: React.ReactNode;
|
||||
message?: React.ReactNode;
|
||||
type?: ToastType;
|
||||
icon?: React.ReactNode | string;
|
||||
@ -31,35 +32,41 @@ const Toast: React.FC<ToastProps> = ({
|
||||
children,
|
||||
props
|
||||
}) => {
|
||||
let iconColorClass = 'text-grey-500';
|
||||
|
||||
switch (props?.type) {
|
||||
case 'info':
|
||||
props.icon = props.icon || 'info-fill';
|
||||
iconColorClass = 'text-grey-500';
|
||||
break;
|
||||
case 'success':
|
||||
props.icon = props.icon || 'check-circle';
|
||||
props.icon = props.icon || 'success-fill';
|
||||
iconColorClass = 'text-green';
|
||||
break;
|
||||
case 'error':
|
||||
props.icon = props.icon || 'warning';
|
||||
props.icon = props.icon || 'error-fill';
|
||||
iconColorClass = 'text-red';
|
||||
break;
|
||||
}
|
||||
|
||||
const classNames = clsx(
|
||||
'z-[90] flex items-start justify-between gap-6 rounded px-4 py-3 text-sm font-medium text-white',
|
||||
(props?.type === 'success' || props?.type === 'neutral') && 'w-[300px] bg-black dark:bg-grey-950',
|
||||
props?.type === 'error' && 'w-[300px] bg-red',
|
||||
props?.options?.position === 'top-center' && 'w-full max-w-[520px] bg-red',
|
||||
'relative z-[90] mb-[14px] ml-[6px] flex min-w-[272px] items-start justify-between gap-3 rounded-lg bg-white p-4 text-sm text-black shadow-md-heavy dark:bg-grey-925 dark:text-white',
|
||||
props?.options?.position === 'top-center' ? 'max-w-[520px]' : 'max-w-[320px]',
|
||||
t.visible ? (props?.options?.position === 'top-center' ? 'animate-toaster-top-in' : 'animate-toaster-in') : 'animate-toaster-out'
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={classNames} data-testid={`toast-${props?.type}`}>
|
||||
<div className='flex items-start gap-3'>
|
||||
<div className='mr-7 flex items-start gap-[10px]'>
|
||||
{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)}
|
||||
<div className='mt-px'><Icon className='grow' colorClass={iconColorClass} name={props.icon} size='sm' /></div> : props.icon)}
|
||||
{children}
|
||||
</div>
|
||||
<button className='cursor-pointer' type='button' onClick={() => {
|
||||
<button className='absolute right-5 top-5 -mr-1.5 -mt-1.5 cursor-pointer rounded-full p-2 text-grey-700 hover:text-black dark:hover:text-white' type='button' onClick={() => {
|
||||
toast.dismiss(t.id);
|
||||
}}>
|
||||
<div className='mt-1'>
|
||||
<Icon colorClass='text-white' name='close' size='xs' />
|
||||
<div>
|
||||
<Icon colorClass='stroke-2' name='close' size='2xs' />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
@ -69,6 +76,7 @@ const Toast: React.FC<ToastProps> = ({
|
||||
export default Toast;
|
||||
|
||||
export const showToast = ({
|
||||
title,
|
||||
message,
|
||||
type = 'neutral',
|
||||
icon = '',
|
||||
@ -93,7 +101,12 @@ export const showToast = ({
|
||||
icon: icon,
|
||||
options: options
|
||||
}} t={t}>
|
||||
{message}
|
||||
<div>
|
||||
{title && <span className='mt-px block text-md font-semibold leading-tighter tracking-[0.1px]'>{title}</span>}
|
||||
{message &&
|
||||
<div className={`text-grey-900 dark:text-grey-300 ${title ? 'mt-1' : ''}`}>{message}</div>
|
||||
}
|
||||
</div>
|
||||
</Toast>
|
||||
),
|
||||
{
|
||||
|
@ -97,6 +97,7 @@ module.exports = {
|
||||
xs: '0 0 1px rgba(0,0,0,0.04), 0 1px 3px rgba(0,0,0,0.03), 0 8px 10px -12px rgba(0,0,0,.1)',
|
||||
sm: '0 0 1px rgba(0,0,0,.12), 0 1px 6px rgba(0,0,0,0.03), 0 8px 10px -8px rgba(0,0,0,.1)',
|
||||
md: '0 0 1px rgba(0,0,0,0.12), 0 1px 6px rgba(0,0,0,0.03), 0 8px 10px -8px rgba(0,0,0,0.05), 0px 24px 37px -21px rgba(0, 0, 0, 0.05)',
|
||||
'md-heavy': '0 0 1px rgba(0,0,0,0.22), 0 1px 6px rgba(0,0,0,0.15), 0 8px 10px -8px rgba(0,0,0,0.16), 0px 24px 37px -21px rgba(0, 0, 0, 0.46)',
|
||||
lg: '0 0 7px rgba(0, 0, 0, 0.08), 0 2.1px 2.2px -5px rgba(0, 0, 0, 0.011), 0 5.1px 5.3px -5px rgba(0, 0, 0, 0.016), 0 9.5px 10px -5px rgba(0, 0, 0, 0.02), 0 17px 17.9px -5px rgba(0, 0, 0, 0.024), 0 31.8px 33.4px -5px rgba(0, 0, 0, 0.029), 0 76px 80px -5px rgba(0, 0, 0, 0.04)',
|
||||
xl: '0 2.8px 2.2px rgba(0, 0, 0, 0.02), 0 6.7px 5.3px rgba(0, 0, 0, 0.028), 0 12.5px 10px rgba(0, 0, 0, 0.035), 0 22.3px 17.9px rgba(0, 0, 0, 0.042), 0 41.8px 33.4px rgba(0, 0, 0, 0.05), 0 100px 80px rgba(0, 0, 0, 0.07)',
|
||||
inner: 'inset 0 0 4px 0 rgb(0 0 0 / 0.08)',
|
||||
@ -106,32 +107,26 @@ module.exports = {
|
||||
keyframes: {
|
||||
toasterIn: {
|
||||
'0.00%': {
|
||||
opacity: '0',
|
||||
transform: 'translateX(-232.05px)'
|
||||
transform: 'translateY(100%)'
|
||||
},
|
||||
'26.52%': {
|
||||
opacity: '0.5',
|
||||
transform: 'translateX(5.90px)'
|
||||
transform: 'translateY(-3.90px)'
|
||||
},
|
||||
'63.26%': {
|
||||
opacity: '1',
|
||||
transform: 'translateX(-1.77px)'
|
||||
transform: 'translateY(1.2px)'
|
||||
},
|
||||
'100.00%': {
|
||||
transform: 'translateX(0px)'
|
||||
transform: 'translateY(0px)'
|
||||
}
|
||||
},
|
||||
toasterTopIn: {
|
||||
'0.00%': {
|
||||
opacity: '0',
|
||||
transform: 'translateY(-82px)'
|
||||
},
|
||||
'26.52%': {
|
||||
opacity: '0.5',
|
||||
transform: 'translateY(5.90px)'
|
||||
},
|
||||
'63.26%': {
|
||||
opacity: '1',
|
||||
transform: 'translateY(-1.77px)'
|
||||
},
|
||||
'100.00%': {
|
||||
@ -264,7 +259,7 @@ module.exports = {
|
||||
sm: '0.3rem',
|
||||
DEFAULT: '0.4rem',
|
||||
md: '0.6rem',
|
||||
lg: '0.7rem',
|
||||
lg: '0.8rem',
|
||||
xl: '1.2rem',
|
||||
'2xl': '1.6rem',
|
||||
'3xl': '2.4rem',
|
||||
@ -274,7 +269,7 @@ module.exports = {
|
||||
'2xs': '1.0rem',
|
||||
base: '1.4rem',
|
||||
xs: '1.2rem',
|
||||
sm: '1.32rem',
|
||||
sm: '1.3rem',
|
||||
md: '1.40rem',
|
||||
lg: '1.65rem',
|
||||
xl: '2rem',
|
||||
|
@ -85,6 +85,7 @@ const useForm = <State>({initialState, savingDelay, savedDelay = 2000, onSave, o
|
||||
// function to save the changed settings via API
|
||||
const handleSave = useCallback<SaveHandler>(async (options = {}) => {
|
||||
if (!validate()) {
|
||||
setSaveState('error');
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -122,10 +123,26 @@ const useForm = <State>({initialState, savingDelay, savedDelay = 2000, onSave, o
|
||||
setSaveState('unsaved');
|
||||
}, []);
|
||||
|
||||
let okColor: ButtonColor = 'black';
|
||||
if (saveState === 'saved') {
|
||||
okColor = 'green';
|
||||
} else if (saveState === 'error') {
|
||||
okColor = 'red';
|
||||
}
|
||||
|
||||
let okLabel = '';
|
||||
if (saveState === 'saved') {
|
||||
okLabel = 'Saved';
|
||||
} else if (saveState === 'saving') {
|
||||
okLabel = 'Saving...';
|
||||
} else if (saveState === 'error') {
|
||||
okLabel = 'Retry';
|
||||
}
|
||||
|
||||
const okProps: OkProps = {
|
||||
disabled: saveState === 'saving',
|
||||
color: saveState === 'saved' ? 'green' : 'black',
|
||||
label: saveState === 'saved' ? 'Saved' : (saveState === 'saving' ? 'Saving...' : undefined)
|
||||
color: okColor,
|
||||
label: okLabel || undefined
|
||||
};
|
||||
|
||||
return {
|
||||
|
@ -46,17 +46,17 @@ const useHandleError = () => {
|
||||
} else if (error instanceof ValidationError && error.data?.errors[0]) {
|
||||
showToast({
|
||||
message: error.data.errors[0].context || error.data.errors[0].message,
|
||||
type: 'pageError'
|
||||
type: 'error'
|
||||
});
|
||||
} else if (error instanceof APIError) {
|
||||
showToast({
|
||||
message: error.message,
|
||||
type: 'pageError'
|
||||
type: 'error'
|
||||
});
|
||||
} else {
|
||||
showToast({
|
||||
message: 'Something went wrong, please try again.',
|
||||
type: 'pageError'
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
}, [sentryDSN]);
|
||||
|
@ -21,8 +21,8 @@ const DangerZone: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
||||
try {
|
||||
await deleteAllContent(null);
|
||||
showToast({
|
||||
type: 'success',
|
||||
message: 'All content deleted from database.'
|
||||
title: 'All content deleted from database.',
|
||||
type: 'success'
|
||||
});
|
||||
modal?.remove();
|
||||
await client.refetchQueries();
|
||||
|
@ -184,8 +184,11 @@ const CustomIntegrations: React.FC<{integrations: Integration[]}> = ({integratio
|
||||
await deleteIntegration(integration.id);
|
||||
confirmModal?.remove();
|
||||
showToast({
|
||||
message: 'Integration deleted',
|
||||
type: 'success'
|
||||
title: 'Integration deleted',
|
||||
type: 'info',
|
||||
options: {
|
||||
position: 'bottom-left'
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
|
@ -4,12 +4,11 @@ import React, {useEffect, useState} from 'react';
|
||||
import WebhooksTable from './WebhooksTable';
|
||||
import {APIError} from '@tryghost/admin-x-framework/errors';
|
||||
import {APIKey, useRefreshAPIKey} from '@tryghost/admin-x-framework/api/apiKeys';
|
||||
import {ConfirmationModal, Form, ImageUpload, Modal, TextField, showToast} from '@tryghost/admin-x-design-system';
|
||||
import {ConfirmationModal, Form, ImageUpload, Modal, TextField} from '@tryghost/admin-x-design-system';
|
||||
import {Integration, useBrowseIntegrations, useEditIntegration} from '@tryghost/admin-x-framework/api/integrations';
|
||||
import {RoutingModalProps, useRouting} from '@tryghost/admin-x-framework/routing';
|
||||
import {getGhostPaths} from '@tryghost/admin-x-framework/helpers';
|
||||
import {getImageUrl, useUploadImage} from '@tryghost/admin-x-framework/api/images';
|
||||
import {toast} from 'react-hot-toast';
|
||||
import {useForm, useHandleError} from '@tryghost/admin-x-framework/hooks';
|
||||
|
||||
const CustomIntegrationModalContent: React.FC<{integration: Integration}> = ({integration}) => {
|
||||
@ -37,7 +36,7 @@ const CustomIntegrationModalContent: React.FC<{integration: Integration}> = ({in
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formState.name) {
|
||||
newErrors.name = 'Name is required.';
|
||||
newErrors.name = 'Enter integration title';
|
||||
}
|
||||
|
||||
return newErrors;
|
||||
@ -88,16 +87,10 @@ const CustomIntegrationModalContent: React.FC<{integration: Integration}> = ({in
|
||||
okLabel={okProps.label || 'Save & close'}
|
||||
size='md'
|
||||
testId='custom-integration-modal'
|
||||
title={formState.name}
|
||||
title={formState.name || 'Custom integration'}
|
||||
stickyFooter
|
||||
onOk={async () => {
|
||||
toast.remove();
|
||||
if (!(await handleSave({fakeWhenUnchanged: true}))) {
|
||||
showToast({
|
||||
type: 'pageError',
|
||||
message: 'Can\'t save integration, please double check that you\'ve filled all mandatory fields.'
|
||||
});
|
||||
}
|
||||
await handleSave({fakeWhenUnchanged: true});
|
||||
}}
|
||||
>
|
||||
<div className='mt-7 flex w-full flex-col gap-7 md:flex-row'>
|
||||
|
@ -62,7 +62,7 @@ const PinturaModal = NiceModal.create(() => {
|
||||
|
||||
showToast({
|
||||
type: 'success',
|
||||
message: `Pintura ${form} uploaded successfully`
|
||||
title: `Pintura ${form} uploaded`
|
||||
});
|
||||
} catch (e) {
|
||||
setUploadingState({js: false, css: false});
|
||||
|
@ -32,8 +32,8 @@ const SlackModal = NiceModal.create(() => {
|
||||
if (await handleSave()) {
|
||||
await testSlack(null);
|
||||
showToast({
|
||||
message: 'Check your Slack channel for the test message',
|
||||
type: 'neutral'
|
||||
title: 'Check your Slack channel for the test message',
|
||||
type: 'info'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
@ -1,9 +1,8 @@
|
||||
import NiceModal, {useModal} from '@ebay/nice-modal-react';
|
||||
import React from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import validator from 'validator';
|
||||
import webhookEventOptions from './webhookEventOptions';
|
||||
import {Form, Modal, Select, TextField, showToast} from '@tryghost/admin-x-design-system';
|
||||
import {Form, Modal, Select, TextField} from '@tryghost/admin-x-design-system';
|
||||
import {Webhook, useCreateWebhook, useEditWebhook} from '@tryghost/admin-x-framework/api/webhooks';
|
||||
import {useForm, useHandleError} from '@tryghost/admin-x-framework/hooks';
|
||||
|
||||
@ -59,14 +58,8 @@ const WebhookModal: React.FC<WebhookModalProps> = ({webhook, integrationId}) =>
|
||||
title='Add webhook'
|
||||
formSheet
|
||||
onOk={async () => {
|
||||
toast.remove();
|
||||
if (await handleSave()) {
|
||||
modal.remove();
|
||||
} else {
|
||||
showToast({
|
||||
type: 'pageError',
|
||||
message: 'Can\'t save webhook, please double check that you\'ve filled all mandatory fields.'
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
@ -22,7 +22,7 @@ const WebhooksTable: React.FC<{integration: Integration}> = ({integration}) => {
|
||||
confirmModal?.remove();
|
||||
showToast({
|
||||
message: 'Webhook deleted',
|
||||
type: 'success'
|
||||
type: 'info'
|
||||
});
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
|
@ -32,8 +32,8 @@ const BetaFeatures: React.FC = () => {
|
||||
setRedirectsUploading(true);
|
||||
await uploadRedirects(file);
|
||||
showToast({
|
||||
type: 'success',
|
||||
message: 'Redirects uploaded successfully'
|
||||
title: 'Redirects uploaded',
|
||||
type: 'success'
|
||||
});
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
@ -58,7 +58,7 @@ const BetaFeatures: React.FC = () => {
|
||||
await uploadRoutes(file);
|
||||
showToast({
|
||||
type: 'success',
|
||||
message: 'Routes uploaded successfully'
|
||||
title: 'Routes uploaded'
|
||||
});
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
|
@ -65,7 +65,7 @@ const MigrationOptions: React.FC = () => {
|
||||
await deleteAllContent(null);
|
||||
showToast({
|
||||
type: 'success',
|
||||
message: 'All content deleted from database.'
|
||||
title: 'All content deleted from database.'
|
||||
});
|
||||
modal?.remove();
|
||||
await client.refetchQueries();
|
||||
|
@ -1,10 +1,9 @@
|
||||
import NiceModal, {useModal} from '@ebay/nice-modal-react';
|
||||
import React, {useEffect} from 'react';
|
||||
import {Form, LimitModal, Modal, TextArea, TextField, Toggle, showToast} from '@tryghost/admin-x-design-system';
|
||||
import {Form, LimitModal, Modal, TextArea, TextField, Toggle} from '@tryghost/admin-x-design-system';
|
||||
import {HostLimitError, useLimiter} from '../../../../hooks/useLimiter';
|
||||
import {RoutingModalProps, useRouting} from '@tryghost/admin-x-framework/routing';
|
||||
import {numberWithCommas} from '../../../../utils/helpers';
|
||||
import {toast} from 'react-hot-toast';
|
||||
import {useAddNewsletter} from '@tryghost/admin-x-framework/api/newsletters';
|
||||
import {useBrowseMembers} from '@tryghost/admin-x-framework/api/members';
|
||||
import {useForm, useHandleError} from '@tryghost/admin-x-framework/hooks';
|
||||
@ -78,14 +77,8 @@ const AddNewsletterModal: React.FC<RoutingModalProps> = () => {
|
||||
testId='add-newsletter-modal'
|
||||
title='Create newsletter'
|
||||
onOk={async () => {
|
||||
toast.remove();
|
||||
if (await handleSave()) {
|
||||
modal.remove();
|
||||
} else {
|
||||
showToast({
|
||||
type: 'pageError',
|
||||
message: 'Can\'t save newsletter, please double check that you\'ve filled all mandatory fields.'
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
@ -140,7 +140,7 @@ const Sidebar: React.FC<{
|
||||
modal?.remove();
|
||||
showToast({
|
||||
type: 'success',
|
||||
message: 'Newsletter archived successfully'
|
||||
message: 'Newsletter archived'
|
||||
});
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
@ -173,7 +173,7 @@ const Sidebar: React.FC<{
|
||||
modal?.remove();
|
||||
showToast({
|
||||
type: 'success',
|
||||
message: 'Newsletter reactivated successfully'
|
||||
message: 'Newsletter reactivated'
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -528,7 +528,7 @@ const NewsletterDetailModalContent: React.FC<{newsletter: Newsletter; onlyOne: b
|
||||
showToast({
|
||||
icon: 'email',
|
||||
message: toastMessage,
|
||||
type: 'neutral'
|
||||
type: 'info'
|
||||
});
|
||||
}
|
||||
},
|
||||
@ -581,12 +581,7 @@ const NewsletterDetailModalContent: React.FC<{newsletter: Newsletter; onlyOne: b
|
||||
testId='newsletter-modal'
|
||||
title='Newsletter'
|
||||
onOk={async () => {
|
||||
if (!(await handleSave({fakeWhenUnchanged: true}))) {
|
||||
showToast({
|
||||
type: 'pageError',
|
||||
message: 'Can\'t save newsletter, please double check that you\'ve filled all mandatory fields.'
|
||||
});
|
||||
}
|
||||
await handleSave({fakeWhenUnchanged: true});
|
||||
}}
|
||||
/>;
|
||||
};
|
||||
|
@ -74,7 +74,7 @@ const InviteUserModal = NiceModal.create(() => {
|
||||
const roles = rolesQuery.data.roles;
|
||||
const assignableRoles = assignableRolesQuery.data.roles;
|
||||
|
||||
let okLabel = 'Send invitation now';
|
||||
let okLabel = 'Send invitation';
|
||||
if (saveState === 'saving') {
|
||||
okLabel = 'Sending...';
|
||||
} else if (saveState === 'saved') {
|
||||
@ -123,7 +123,8 @@ const InviteUserModal = NiceModal.create(() => {
|
||||
setSaveState('saved');
|
||||
|
||||
showToast({
|
||||
message: `Invitation successfully sent to ${email}`,
|
||||
title: `Invitation sent`,
|
||||
message: `${email}`,
|
||||
type: 'success'
|
||||
});
|
||||
|
||||
@ -131,18 +132,19 @@ const InviteUserModal = NiceModal.create(() => {
|
||||
updateRoute('staff?tab=invited');
|
||||
} catch (e) {
|
||||
setSaveState('error');
|
||||
let message = (<span><strong>Your invitation failed to send.</strong><br/>If the problem persists, <a href="https://ghost.org/contact"><u>contact support</u>.</a>.</span>);
|
||||
let title = 'Failed to send invitation';
|
||||
let message = (<span>If the problem persists, <a href="https://ghost.org/contact"><u>contact support</u>.</a>.</span>);
|
||||
if (e instanceof APIError) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let data = e.data as any; // we have unknown data types in the APIError/error classes
|
||||
if (data?.errors?.[0]?.type === 'EmailError') {
|
||||
message = (<span><strong>Your invitation failed to send</strong><br/>Please check your Mailgun configuration. If the problem persists, <a href="https://ghost.org/contact"><u>contact support</u>.</a></span>);
|
||||
message = (<span>Check your Mailgun configuration.</span>);
|
||||
}
|
||||
}
|
||||
showToast({
|
||||
title,
|
||||
message,
|
||||
type: 'neutral',
|
||||
icon: 'warning'
|
||||
type: 'error'
|
||||
});
|
||||
handleError(e, {withToast: false});
|
||||
return;
|
||||
@ -178,12 +180,17 @@ const InviteUserModal = NiceModal.create(() => {
|
||||
});
|
||||
});
|
||||
|
||||
if (!!errors.email) {
|
||||
okLabel = 'Retry';
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
afterClose={() => {
|
||||
updateRoute('staff');
|
||||
}}
|
||||
cancelLabel=''
|
||||
okColor={saveState === 'error' || !!errors.email ? 'red' : 'black'}
|
||||
okLabel={okLabel}
|
||||
testId='invite-user-modal'
|
||||
title='Invite a new staff user'
|
||||
|
@ -13,8 +13,20 @@ const PublicationLanguage: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
||||
handleCancel,
|
||||
updateSetting,
|
||||
focusRef,
|
||||
errors,
|
||||
clearError,
|
||||
handleEditingChange
|
||||
} = useSettingGroup();
|
||||
} = useSettingGroup({
|
||||
onValidate: () => {
|
||||
if (!publicationLanguage) {
|
||||
return {
|
||||
publicationLanguage: 'Enter a value'
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
});
|
||||
|
||||
const [publicationLanguage] = getSettingValues(localSettings, ['locale']) as string[];
|
||||
|
||||
@ -42,12 +54,14 @@ const PublicationLanguage: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
||||
const inputFields = (
|
||||
<SettingGroupContent columns={1}>
|
||||
<TextField
|
||||
hint={hint}
|
||||
error={!!errors.publicationLanguage}
|
||||
hint={errors.publicationLanguage || hint}
|
||||
inputRef={focusRef}
|
||||
placeholder="Site language"
|
||||
title='Site language'
|
||||
value={publicationLanguage}
|
||||
onChange={handleLanguageChange}
|
||||
onKeyDown={() => clearError('password')}
|
||||
/>
|
||||
</SettingGroupContent>
|
||||
);
|
||||
|
@ -16,7 +16,6 @@ import {HostLimitError, useLimiter} from '../../../hooks/useLimiter';
|
||||
import {RoutingModalProps, useRouting} from '@tryghost/admin-x-framework/routing';
|
||||
import {User, canAccessSettings, hasAdminAccess, isAdminUser, isAuthorOrContributor, isEditorUser, isOwnerUser, useDeleteUser, useEditUser, useMakeOwner} from '@tryghost/admin-x-framework/api/users';
|
||||
import {getImageUrl, useUploadImage} from '@tryghost/admin-x-framework/api/images';
|
||||
import {toast} from 'react-hot-toast';
|
||||
import {useGlobalData} from '../../providers/GlobalDataProvider';
|
||||
import {validateFacebookUrl, validateTwitterUrl} from '../../../utils/socialUrls';
|
||||
|
||||
@ -36,7 +35,7 @@ const validators: Record<string, (u: Partial<User>) => string> = {
|
||||
},
|
||||
email: ({email}) => {
|
||||
const valid = validator.isEmail(email || '');
|
||||
return valid ? '' : 'Please enter a valid email address';
|
||||
return valid ? '' : 'Enter a valid email address';
|
||||
},
|
||||
url: ({url}) => {
|
||||
const valid = !url || validator.isURL(url);
|
||||
@ -52,7 +51,7 @@ const validators: Record<string, (u: Partial<User>) => string> = {
|
||||
},
|
||||
website: ({website}) => {
|
||||
const valid = !website || (validator.isURL(website) && website.length <= 2000);
|
||||
return valid ? '' : 'Website is not a valid url';
|
||||
return valid ? '' : 'Enter a valid URL';
|
||||
},
|
||||
facebook: ({facebook}) => {
|
||||
try {
|
||||
@ -192,7 +191,7 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
|
||||
setFormState(() => updatedUserData);
|
||||
modal?.remove();
|
||||
showToast({
|
||||
message: _user.status === 'inactive' ? 'User un-suspended' : 'User suspended',
|
||||
title: _user.status === 'inactive' ? 'User un-suspended' : 'User suspended',
|
||||
type: 'success'
|
||||
});
|
||||
} catch (e) {
|
||||
@ -220,7 +219,7 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
|
||||
mainModal?.remove();
|
||||
navigateOnClose();
|
||||
showToast({
|
||||
message: 'User deleted',
|
||||
title: 'User deleted',
|
||||
type: 'success'
|
||||
});
|
||||
} catch (e) {
|
||||
@ -241,7 +240,7 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
|
||||
await makeOwner(user.id);
|
||||
modal?.remove();
|
||||
showToast({
|
||||
message: 'Ownership transferred',
|
||||
title: 'Ownership transferred',
|
||||
type: 'success'
|
||||
});
|
||||
} catch (e) {
|
||||
@ -361,14 +360,7 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
|
||||
stickyFooter={true}
|
||||
testId='user-detail-modal'
|
||||
onOk={async () => {
|
||||
toast.remove();
|
||||
|
||||
if (!(await handleSave({fakeWhenUnchanged: true}))) {
|
||||
showToast({
|
||||
type: 'pageError',
|
||||
message: 'Can\'t save user, please double check that you\'ve filled all mandatory fields.'
|
||||
});
|
||||
}
|
||||
await (handleSave({fakeWhenUnchanged: true}));
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
|
@ -127,7 +127,8 @@ const UserInviteActions: React.FC<{invite: UserInvite}> = ({invite}) => {
|
||||
setRevokeState('progress');
|
||||
await deleteInvite(invite.id);
|
||||
showToast({
|
||||
message: `Invitation revoked (${invite.email})`,
|
||||
title: `Invitation revoked`,
|
||||
message: invite.email,
|
||||
type: 'success'
|
||||
});
|
||||
} catch (e) {
|
||||
@ -151,7 +152,8 @@ const UserInviteActions: React.FC<{invite: UserInvite}> = ({invite}) => {
|
||||
roleId: invite.role_id
|
||||
});
|
||||
showToast({
|
||||
message: `Invitation resent! (${invite.email})`,
|
||||
title: `Invitation resent`,
|
||||
message: invite.email,
|
||||
type: 'success'
|
||||
});
|
||||
} catch (e) {
|
||||
|
@ -218,8 +218,8 @@ const ChangePasswordForm: React.FC<{user: User}> = ({user}) => {
|
||||
} catch (e) {
|
||||
setSaveState('');
|
||||
showToast({
|
||||
type: 'pageError',
|
||||
message: e instanceof ValidationError ? e.message : `Couldn't update password. Please try again.`
|
||||
type: 'error',
|
||||
title: e instanceof ValidationError ? e.message : `Couldn't update password. Please try again.`
|
||||
});
|
||||
handleError(e, {withToast: false});
|
||||
}
|
||||
|
@ -5,7 +5,7 @@ import {UserDetailProps} from '../UserDetailModal';
|
||||
import {hasAdminAccess} from '@tryghost/admin-x-framework/api/users';
|
||||
import {useGlobalData} from '../../../providers/GlobalDataProvider';
|
||||
|
||||
const BasicInputs: React.FC<UserDetailProps> = ({errors, validateField, clearError, user, setUserData}) => {
|
||||
const BasicInputs: React.FC<UserDetailProps> = ({errors, clearError, user, setUserData}) => {
|
||||
const {currentUser} = useGlobalData();
|
||||
|
||||
return (
|
||||
@ -16,9 +16,6 @@ const BasicInputs: React.FC<UserDetailProps> = ({errors, validateField, clearErr
|
||||
maxLength={191}
|
||||
title="Full name"
|
||||
value={user.name}
|
||||
onBlur={(e) => {
|
||||
validateField('name', e.target.value);
|
||||
}}
|
||||
onChange={(e) => {
|
||||
setUserData({...user, name: e.target.value});
|
||||
}}
|
||||
@ -30,9 +27,6 @@ const BasicInputs: React.FC<UserDetailProps> = ({errors, validateField, clearErr
|
||||
maxLength={191}
|
||||
title="Email"
|
||||
value={user.email}
|
||||
onBlur={(e) => {
|
||||
validateField('email', e.target.value);
|
||||
}}
|
||||
onChange={(e) => {
|
||||
setUserData({...user, email: e.target.value});
|
||||
}}
|
||||
|
@ -2,6 +2,7 @@ import CustomHeader from './CustomHeader';
|
||||
import {SettingGroup, SettingGroupContent, TextArea, TextField} from '@tryghost/admin-x-design-system';
|
||||
import {UserDetailProps} from '../UserDetailModal';
|
||||
import {facebookHandleToUrl, facebookUrlToHandle, twitterHandleToUrl, twitterUrlToHandle, validateFacebookUrl, validateTwitterUrl} from '../../../../utils/socialUrls';
|
||||
|
||||
import {useState} from 'react';
|
||||
|
||||
export const DetailsInputs: React.FC<UserDetailProps> = ({errors, clearError, validateField, user, setUserData}) => {
|
||||
@ -16,22 +17,19 @@ export const DetailsInputs: React.FC<UserDetailProps> = ({errors, clearError, va
|
||||
maxLength={65535}
|
||||
title="Location"
|
||||
value={user.location || ''}
|
||||
onBlur={(e) => {
|
||||
validateField('location', e.target.value);
|
||||
}}
|
||||
onChange={(e) => {
|
||||
setUserData({...user, location: e.target.value});
|
||||
}}
|
||||
onKeyDown={() => clearError('location')} />
|
||||
<TextField
|
||||
error={!!errors?.url}
|
||||
hint={errors?.url || 'Have a website or blog other than this one? Link it!'}
|
||||
error={!!errors?.website}
|
||||
hint={errors?.website || 'Have a website or blog other than this one? Link it!'}
|
||||
maxLength={2000}
|
||||
title="Website"
|
||||
value={user.website || ''}
|
||||
onBlur={(e) => {
|
||||
validateField('url', e.target.value);
|
||||
}}
|
||||
// onBlur={(e) => {
|
||||
// validateField('url', e.target.value);
|
||||
// }}
|
||||
onChange={(e) => {
|
||||
setUserData({...user, website: e.target.value});
|
||||
}}
|
||||
@ -76,9 +74,6 @@ export const DetailsInputs: React.FC<UserDetailProps> = ({errors, clearError, va
|
||||
maxLength={65535}
|
||||
title="Bio"
|
||||
value={user.bio || ''}
|
||||
onBlur={(e) => {
|
||||
validateField('bio', e.target.value);
|
||||
}}
|
||||
onChange={(e) => {
|
||||
setUserData({...user, bio: e.target.value});
|
||||
}}
|
||||
|
@ -640,8 +640,9 @@ const AddOfferModal = () => {
|
||||
if (!isErrorsEmpty) {
|
||||
toast.remove();
|
||||
showToast({
|
||||
type: 'pageError',
|
||||
message: 'Can\'t save offer, please double check that you\'ve filled all mandatory fields correctly'
|
||||
title: 'Can\'t save offer',
|
||||
type: 'info',
|
||||
message: 'Make sure you filled all required fields'
|
||||
});
|
||||
return;
|
||||
}
|
||||
@ -649,13 +650,7 @@ const AddOfferModal = () => {
|
||||
try {
|
||||
if (await handleSave({force: true})) {
|
||||
return;
|
||||
} else {
|
||||
toast.remove();
|
||||
showToast({
|
||||
type: 'pageError',
|
||||
message: 'Can\'t save offer, please double check that you\'ve filled all mandatory fields correctly'
|
||||
});
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
let message;
|
||||
|
||||
@ -664,10 +659,13 @@ const AddOfferModal = () => {
|
||||
}
|
||||
|
||||
toast.remove();
|
||||
showToast({
|
||||
type: 'pageError',
|
||||
message: message || 'Something went wrong while saving the offer, please try again'
|
||||
});
|
||||
if (message) {
|
||||
showToast({
|
||||
title: 'Can\'t save offer',
|
||||
type: 'error',
|
||||
message: message || 'Please try again later'
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>;
|
||||
|
@ -67,7 +67,7 @@ const Sidebar: React.FC<{
|
||||
modal?.remove();
|
||||
showToast({
|
||||
type: 'success',
|
||||
message: 'Offer archived successfully'
|
||||
title: 'Offer archived'
|
||||
});
|
||||
updateRoute('offers/edit');
|
||||
} catch (e) {
|
||||
@ -88,7 +88,7 @@ const Sidebar: React.FC<{
|
||||
modal?.remove();
|
||||
showToast({
|
||||
type: 'success',
|
||||
message: 'Offer reactivated successfully'
|
||||
title: 'Offer reactivated'
|
||||
});
|
||||
updateRoute('offers/edit');
|
||||
} catch (e) {
|
||||
@ -284,12 +284,6 @@ const EditOfferModal: React.FC<{id: string}> = ({id}) => {
|
||||
try {
|
||||
if (await handleSave({force: true})) {
|
||||
return;
|
||||
} else {
|
||||
toast.remove();
|
||||
showToast({
|
||||
type: 'pageError',
|
||||
message: 'Can\'t save offer, please double check that you\'ve filled all mandatory fields correctly'
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
let message;
|
||||
@ -299,10 +293,13 @@ const EditOfferModal: React.FC<{id: string}> = ({id}) => {
|
||||
}
|
||||
|
||||
toast.remove();
|
||||
showToast({
|
||||
type: 'pageError',
|
||||
message: message || 'Something went wrong while saving the offer, please try again'
|
||||
});
|
||||
if (message) {
|
||||
showToast({
|
||||
title: 'Can\'t save offer',
|
||||
type: 'error',
|
||||
message: 'Please try again later'
|
||||
});
|
||||
}
|
||||
}
|
||||
}} /> : null;
|
||||
};
|
||||
|
@ -212,8 +212,8 @@ export const OffersIndexModal = () => {
|
||||
onClick: () => {
|
||||
if (paidActiveTiers.length === 0) {
|
||||
showToast({
|
||||
type: 'neutral',
|
||||
message: 'You must have an active tier to create an offer.'
|
||||
type: 'info',
|
||||
title: 'You must have an active tier to create an offer.'
|
||||
});
|
||||
} else {
|
||||
updateRoute('offers/new');
|
||||
|
@ -131,8 +131,8 @@ const AddRecommendationModal: React.FC<RoutingModalProps & AddRecommendationModa
|
||||
} catch (e) {
|
||||
const message = e instanceof AlreadyExistsError ? e.message : 'Something went wrong while checking this URL, please try again.';
|
||||
showToast({
|
||||
type: 'pageError',
|
||||
message
|
||||
type: 'error',
|
||||
title: message
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -27,7 +27,7 @@ const AddRecommendationModalConfirm: React.FC<AddRecommendationModalProps> = ({r
|
||||
await addRecommendation(state);
|
||||
modal.remove();
|
||||
showToast({
|
||||
message: 'Successfully added a recommendation',
|
||||
title: 'Recommendation added',
|
||||
type: 'success'
|
||||
});
|
||||
trackEvent('Recommendation Added', {
|
||||
@ -38,14 +38,6 @@ const AddRecommendationModalConfirm: React.FC<AddRecommendationModalProps> = ({r
|
||||
onSaveError: handleError,
|
||||
onValidate: (state) => {
|
||||
const newErrors = validateDescriptionForm(state);
|
||||
|
||||
if (Object.keys(newErrors).length !== 0) {
|
||||
showToast({
|
||||
type: 'pageError',
|
||||
message: 'Can\'t add recommendation, please double check that you\'ve filled all mandatory fields correctly.'
|
||||
});
|
||||
}
|
||||
|
||||
return newErrors;
|
||||
}
|
||||
});
|
||||
@ -119,8 +111,8 @@ const AddRecommendationModalConfirm: React.FC<AddRecommendationModalProps> = ({r
|
||||
await handleSave({force: true});
|
||||
} catch (e) {
|
||||
showToast({
|
||||
type: 'pageError',
|
||||
message: 'Something went wrong when adding this recommendation, please try again.'
|
||||
type: 'error',
|
||||
title: 'Something went wrong when adding this recommendation, please try again.'
|
||||
});
|
||||
}
|
||||
}}
|
||||
|
@ -34,14 +34,6 @@ const EditRecommendationModal: React.FC<RoutingModalProps & EditRecommendationMo
|
||||
onSaveError: handleError,
|
||||
onValidate: (state) => {
|
||||
const newErrors = validateDescriptionForm(state);
|
||||
|
||||
if (Object.keys(newErrors).length !== 0) {
|
||||
showToast({
|
||||
type: 'pageError',
|
||||
message: 'Can\'t edit recommendation, please double check that you\'ve filled all mandatory fields correctly.'
|
||||
});
|
||||
}
|
||||
|
||||
return newErrors;
|
||||
}
|
||||
});
|
||||
@ -63,13 +55,10 @@ const EditRecommendationModal: React.FC<RoutingModalProps & EditRecommendationMo
|
||||
try {
|
||||
await deleteRecommendation(recommendation);
|
||||
deleteModal?.remove();
|
||||
showToast({
|
||||
message: 'Successfully deleted the recommendation',
|
||||
type: 'success'
|
||||
});
|
||||
} catch (e) {
|
||||
showToast({
|
||||
message: 'Failed to delete the recommendation. Please try again later.',
|
||||
title: 'Failed to delete the recommendation',
|
||||
message: 'Please try again later.',
|
||||
type: 'error'
|
||||
});
|
||||
handleError(e, {withToast: false});
|
||||
@ -101,8 +90,9 @@ const EditRecommendationModal: React.FC<RoutingModalProps & EditRecommendationMo
|
||||
await handleSave({force: true});
|
||||
} catch (e) {
|
||||
showToast({
|
||||
type: 'pageError',
|
||||
message: 'One or more fields have errors, please double check that you\'ve filled all mandatory fields.'
|
||||
title: 'Something went wrong',
|
||||
type: 'error',
|
||||
message: 'Please try again later.'
|
||||
});
|
||||
}
|
||||
}}
|
||||
|
@ -21,6 +21,8 @@ const AccountPage: React.FC<{
|
||||
if (!supportAddress) {
|
||||
setError('members_support_address', 'Please enter an email address');
|
||||
return;
|
||||
} else {
|
||||
setError('members_support_address', '');
|
||||
}
|
||||
|
||||
let settingValue = emailDomain && supportAddress === `noreply@${emailDomain}` ? 'noreply' : supportAddress;
|
||||
|
@ -5,7 +5,7 @@ import PortalPreview from './PortalPreview';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import SignupOptions from './SignupOptions';
|
||||
import useQueryParams from '../../../../hooks/useQueryParams';
|
||||
import {ConfirmationModal, PreviewModalContent, Tab, TabView, showToast} from '@tryghost/admin-x-design-system';
|
||||
import {ConfirmationModal, PreviewModalContent, Tab, TabView} from '@tryghost/admin-x-design-system';
|
||||
import {Dirtyable, useForm, useHandleError} from '@tryghost/admin-x-framework/hooks';
|
||||
import {Setting, SettingValue, getSettingValues, useEditSettings} from '@tryghost/admin-x-framework/api/settings';
|
||||
import {Tier, useBrowseTiers, useEditTier} from '@tryghost/admin-x-framework/api/tiers';
|
||||
@ -229,12 +229,7 @@ const PortalModal: React.FC = () => {
|
||||
testId='portal-modal'
|
||||
title='Portal'
|
||||
onOk={async () => {
|
||||
if (Object.values(errors).filter(Boolean).length) {
|
||||
showToast({
|
||||
type: 'pageError',
|
||||
message: 'Can\'t save settings, please double check that you\'ve filled all mandatory fields.'
|
||||
});
|
||||
} else {
|
||||
if (!Object.values(errors).filter(Boolean).length) {
|
||||
await handleSave({force: true});
|
||||
}
|
||||
}}
|
||||
|
@ -225,8 +225,9 @@ const Direct: React.FC<{onClose: () => void}> = ({onClose}) => {
|
||||
} catch (e) {
|
||||
if (e instanceof JSONError) {
|
||||
showToast({
|
||||
type: 'pageError',
|
||||
message: 'Failed to save settings. Please check you copied both keys correctly.'
|
||||
title: 'Failed to save settings',
|
||||
type: 'error',
|
||||
message: 'Check you copied both keys correctly'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
@ -9,7 +9,6 @@ import {RoutingModalProps, useRouting} from '@tryghost/admin-x-framework/routing
|
||||
import {Tier, useAddTier, useBrowseTiers, useEditTier} from '@tryghost/admin-x-framework/api/tiers';
|
||||
import {currencies, currencySelectGroups, validateCurrencyAmount} from '../../../../utils/currency';
|
||||
import {getSettingValues, useEditSettings} from '@tryghost/admin-x-framework/api/settings';
|
||||
import {toast} from 'react-hot-toast';
|
||||
|
||||
export type TierFormState = Partial<Omit<Tier, 'trial_days'>> & {
|
||||
trial_days: string;
|
||||
@ -155,7 +154,7 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
|
||||
confirmModal?.remove();
|
||||
showToast({
|
||||
type: 'success',
|
||||
message: `Tier ${tier.active ? 'archived' : 'reactivated'} successfully`
|
||||
title: `Tier ${tier.active ? 'archived' : 'reactivated'}`
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -195,15 +194,7 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
|
||||
title={(tier ? (tier.active ? 'Edit tier' : 'Edit archived tier') : 'New tier')}
|
||||
stickyFooter
|
||||
onOk={async () => {
|
||||
toast.remove();
|
||||
|
||||
if (!await handleSave({fakeWhenUnchanged: true})) {
|
||||
showToast({
|
||||
type: 'pageError',
|
||||
message: 'Can\'t save tier, please double check that you\'ve filled all mandatory fields.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
await handleSave({fakeWhenUnchanged: true});
|
||||
}}
|
||||
>
|
||||
<div className='-mb-8 mt-8 flex items-start gap-8'>
|
||||
|
@ -215,7 +215,7 @@ const AnnouncementBarModal: React.FC = () => {
|
||||
onOk={async () => {
|
||||
if (!(await handleSave({fakeWhenUnchanged: true}))) {
|
||||
showToast({
|
||||
type: 'pageError',
|
||||
type: 'error',
|
||||
message: 'An error occurred while saving your changes. Please try again.'
|
||||
});
|
||||
}
|
||||
|
@ -169,7 +169,7 @@ const ThemeToolbar: React.FC<ThemeToolbarProps> = ({
|
||||
|
||||
let title = 'Upload successful';
|
||||
let prompt = <>
|
||||
<strong>{uploadedTheme.name}</strong> uploaded successfully.
|
||||
<strong>{uploadedTheme.name}</strong> uploaded
|
||||
</>;
|
||||
|
||||
if (!uploadedTheme.active) {
|
||||
@ -184,7 +184,7 @@ const ThemeToolbar: React.FC<ThemeToolbarProps> = ({
|
||||
|
||||
title = `Upload successful with ${hasErrors ? 'errors' : 'warnings'}`;
|
||||
prompt = <>
|
||||
The theme <strong>"{uploadedTheme.name}"</strong> was installed successfully but we detected some {hasErrors ? 'errors' : 'warnings'}.
|
||||
The theme <strong>"{uploadedTheme.name}"</strong> was installed but we detected some {hasErrors ? 'errors' : 'warnings'}.
|
||||
</>;
|
||||
|
||||
if (!uploadedTheme.active) {
|
||||
@ -351,8 +351,9 @@ const ChangeThemeModal: React.FC<ChangeThemeModalProps> = ({source, themeRef}) =
|
||||
if (data?.themes[0]) {
|
||||
await activateTheme(data.themes[0].name);
|
||||
showToast({
|
||||
title: 'Theme activated',
|
||||
type: 'success',
|
||||
message: <div><span className='capitalize'>{data.themes[0].name}</span> is now your active theme.</div>
|
||||
message: <div><span className='capitalize'>{data.themes[0].name}</span> is now your active theme</div>
|
||||
});
|
||||
}
|
||||
confirmModal?.remove();
|
||||
|
@ -52,8 +52,9 @@ const ThemeActions: React.FC<ThemeActionProps> = ({
|
||||
try {
|
||||
await activateTheme(theme.name);
|
||||
showToast({
|
||||
title: 'Theme activated',
|
||||
type: 'success',
|
||||
message: <div><span className='capitalize'>{theme.name}</span> is now your active theme.</div>
|
||||
message: <div><span className='capitalize'>{theme.name}</span> is now your active theme</div>
|
||||
});
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
|
@ -87,6 +87,7 @@ const ThemeInstalledModal: React.FC<{
|
||||
const updatedTheme = resData.themes[0];
|
||||
|
||||
showToast({
|
||||
title: 'Theme activated',
|
||||
type: 'success',
|
||||
message: <div><span className='capitalize'>{updatedTheme.name}</span> is now your active theme.</div>
|
||||
});
|
||||
|
@ -2,9 +2,8 @@ import React, {useEffect, useRef, useState} from 'react';
|
||||
import {ErrorMessages, OkProps, SaveHandler, SaveState, useForm, useHandleError} from '@tryghost/admin-x-framework/hooks';
|
||||
import {Setting, SettingValue, useEditSettings} from '@tryghost/admin-x-framework/api/settings';
|
||||
import {SiteData} from '@tryghost/admin-x-framework/api/site';
|
||||
import {showToast, useGlobalDirtyState} from '@tryghost/admin-x-design-system';
|
||||
import {toast} from 'react-hot-toast';
|
||||
import {useGlobalData} from '../components/providers/GlobalDataProvider';
|
||||
import {useGlobalDirtyState} from '@tryghost/admin-x-design-system';
|
||||
|
||||
interface LocalSetting extends Setting {
|
||||
dirty?: boolean;
|
||||
@ -107,15 +106,10 @@ const useSettingGroup = ({savingDelay, onValidate}: {savingDelay?: number; onVal
|
||||
focusRef,
|
||||
siteData,
|
||||
handleSave: async () => {
|
||||
toast.remove();
|
||||
const result = await handleSave();
|
||||
if (result) {
|
||||
setEditing(false);
|
||||
} else {
|
||||
showToast({
|
||||
type: 'pageError',
|
||||
message: 'Can\'t save settings! One or more fields have errors, please double check that you\'ve filled all mandatory fields.'
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
@ -100,7 +100,7 @@ test.describe('Slack integration', async () => {
|
||||
await slackModal.getByLabel('Username').fill('My site');
|
||||
await slackModal.getByRole('button', {name: 'Send test notification'}).click();
|
||||
|
||||
await expect(page.getByTestId('toast-neutral')).toHaveText(/Check your Slack channel for the test message/);
|
||||
await expect(page.getByTestId('toast-info')).toHaveText(/Check your Slack channel for the test message/);
|
||||
|
||||
expect(lastApiRequests.editSettings?.body).toEqual({
|
||||
settings: [
|
||||
|
@ -24,7 +24,7 @@ test.describe('Labs', async () => {
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(`${__dirname}/../../utils/files/redirects.yml`);
|
||||
|
||||
await expect(page.getByTestId('toast-success')).toContainText('Redirects uploaded successfully');
|
||||
await expect(page.getByTestId('toast-success')).toContainText('Redirects uploaded');
|
||||
|
||||
expect(lastApiRequests.uploadRedirects).toBeTruthy();
|
||||
|
||||
@ -56,7 +56,7 @@ test.describe('Labs', async () => {
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(`${__dirname}/../../utils/files/routes.yml`);
|
||||
|
||||
await expect(page.getByTestId('toast-success')).toContainText('Routes uploaded successfully');
|
||||
await expect(page.getByTestId('toast-success')).toContainText('Routes uploaded');
|
||||
|
||||
expect(lastApiRequests.uploadRoutes).toBeTruthy();
|
||||
|
||||
|
@ -27,7 +27,6 @@ test.describe('Newsletter settings', async () => {
|
||||
const modal = page.getByTestId('add-newsletter-modal');
|
||||
await modal.getByRole('button', {name: 'Create'}).click();
|
||||
|
||||
await expect(page.getByTestId('toast-error')).toHaveText(/Can't save newsletter/);
|
||||
await expect(modal).toHaveText(/Name is required/);
|
||||
|
||||
// Shouldn't be necessary, but without these Playwright doesn't click Create the second time for some reason
|
||||
@ -70,7 +69,6 @@ test.describe('Newsletter settings', async () => {
|
||||
await modal.getByPlaceholder('Weekly Roundup').fill('');
|
||||
await modal.getByRole('button', {name: 'Save'}).click();
|
||||
|
||||
await expect(page.getByTestId('toast-error')).toHaveText(/Can't save newsletter/);
|
||||
await expect(modal).toHaveText(/Name is required/);
|
||||
|
||||
await modal.getByPlaceholder('Weekly Roundup').fill('Updated newsletter');
|
||||
@ -116,14 +114,13 @@ test.describe('Newsletter settings', async () => {
|
||||
await modal.getByLabel('Sender email').fill('not-an-email');
|
||||
await modal.getByRole('button', {name: 'Save'}).click();
|
||||
|
||||
await expect(page.getByTestId('toast-error')).toHaveText(/Can't save newsletter/);
|
||||
await expect(modal).toHaveText(/Invalid email/);
|
||||
|
||||
await modal.getByLabel('Sender email').fill('test@test.com');
|
||||
await modal.getByRole('button', {name: 'Save'}).click();
|
||||
|
||||
await expect(page.getByTestId('toast-neutral')).toHaveCount(1);
|
||||
await expect(page.getByTestId('toast-neutral')).toHaveText(/sent a confirmation email to the new address/);
|
||||
await expect(page.getByTestId('toast-info')).toHaveCount(1);
|
||||
await expect(page.getByTestId('toast-info')).toHaveText(/sent a confirmation email to the new address/);
|
||||
});
|
||||
});
|
||||
|
||||
@ -194,14 +191,13 @@ test.describe('Newsletter settings', async () => {
|
||||
await replyToEmail.fill('not-an-email');
|
||||
await modal.getByRole('button', {name: 'Save'}).click();
|
||||
|
||||
await expect(page.getByTestId('toast-error')).toHaveText(/Can't save newsletter/);
|
||||
await expect(modal).toHaveText(/Invalid email/);
|
||||
|
||||
await replyToEmail.fill('test@test.com');
|
||||
await modal.getByRole('button', {name: 'Save'}).click();
|
||||
|
||||
await expect(page.getByTestId('toast-neutral')).toHaveCount(1);
|
||||
await expect(page.getByTestId('toast-neutral')).toHaveText(/sent a confirmation email to the new address/);
|
||||
await expect(page.getByTestId('toast-info')).toHaveCount(1);
|
||||
await expect(page.getByTestId('toast-info')).toHaveText(/sent a confirmation email to the new address/);
|
||||
});
|
||||
});
|
||||
|
||||
@ -241,13 +237,11 @@ test.describe('Newsletter settings', async () => {
|
||||
// Error case #1: add invalid email address
|
||||
await senderEmail.fill('Harry Potter');
|
||||
await modal.getByRole('button', {name: 'Save'}).click();
|
||||
await expect(page.getByTestId('toast-error').first()).toHaveText(/Can't save newsletter/);
|
||||
await expect(modal).toHaveText(/Invalid email/);
|
||||
|
||||
// Error case #2: the sender email address doesn't match the custom sending domain
|
||||
await senderEmail.fill('harry@potter.com');
|
||||
await modal.getByRole('button', {name: 'Save'}).click();
|
||||
await expect(page.getByTestId('toast-error').first()).toHaveText(/Can't save newsletter/);
|
||||
await expect(modal).toHaveText(/Email must end with @customdomain.com/);
|
||||
|
||||
// But can have any address on the same domain, without verification
|
||||
@ -294,8 +288,8 @@ test.describe('Newsletter settings', async () => {
|
||||
|
||||
// There is a verification popup for the new reply-to address
|
||||
await modal.getByRole('button', {name: 'Save'}).click();
|
||||
await expect(page.getByTestId('toast-neutral')).toHaveCount(1);
|
||||
await expect(page.getByTestId('toast-neutral')).toHaveText(/sent a confirmation email to the new address/);
|
||||
await expect(page.getByTestId('toast-info')).toHaveCount(1);
|
||||
await expect(page.getByTestId('toast-info')).toHaveText(/sent a confirmation email to the new address/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -38,7 +38,7 @@ test.describe('User invitations', async () => {
|
||||
|
||||
// Validation failures
|
||||
|
||||
await modal.getByRole('button', {name: 'Send invitation now'}).click();
|
||||
await modal.getByRole('button', {name: 'Send invitation'}).click();
|
||||
await expect(modal).toContainText('Please enter a valid email address');
|
||||
|
||||
// Reset error with keydown event
|
||||
@ -47,24 +47,24 @@ test.describe('User invitations', async () => {
|
||||
|
||||
await modal.getByLabel('Email address').fill('test');
|
||||
await expect(modal).not.toContainText('Please enter a valid email address');
|
||||
await modal.getByRole('button', {name: 'Send invitation now'}).click();
|
||||
await modal.getByRole('button', {name: 'Send invitation'}).click();
|
||||
await expect(modal).toContainText('Please enter a valid email address');
|
||||
|
||||
await modal.getByLabel('Email address').fill('author@test.com');
|
||||
await modal.getByRole('button', {name: 'Send invitation now'}).click();
|
||||
await modal.getByRole('button', {name: 'Retry'}).click();
|
||||
await expect(modal).toContainText('A user with that email address already exists.');
|
||||
|
||||
await modal.getByLabel('Email address').fill('invitee@test.com');
|
||||
await modal.getByRole('button', {name: 'Send invitation now'}).click();
|
||||
await modal.getByRole('button', {name: 'Retry'}).click();
|
||||
await expect(modal).toContainText('A user with that email address was already invited.');
|
||||
|
||||
// Successful invitation
|
||||
|
||||
await modal.getByLabel('Email address').fill('newuser@test.com');
|
||||
await modal.locator('input[value=author]').check();
|
||||
await modal.getByRole('button', {name: 'Send invitation now'}).click();
|
||||
await modal.getByRole('button', {name: 'Retry'}).click();
|
||||
|
||||
await expect(page.getByTestId('toast-success')).toHaveText(/Invitation successfully sent to newuser@test\.com/);
|
||||
await expect(page.getByTestId('toast-success')).toHaveText(/Invitation sent/);
|
||||
|
||||
await section.getByRole('tab', {name: 'Invited'}).click();
|
||||
|
||||
@ -103,7 +103,7 @@ test.describe('User invitations', async () => {
|
||||
|
||||
await listItem.getByRole('button', {name: 'Resend'}).click();
|
||||
|
||||
await expect(page.getByTestId('toast-success')).toHaveText(/Invitation resent! \(invitee@test\.com\)/);
|
||||
await expect(page.getByTestId('toast-success')).toHaveText(/Invitation resent/);
|
||||
|
||||
// Resending works by deleting and re-adding the invite
|
||||
|
||||
@ -138,7 +138,7 @@ test.describe('User invitations', async () => {
|
||||
|
||||
await listItem.getByRole('button', {name: 'Revoke'}).click();
|
||||
|
||||
await expect(page.getByTestId('toast-success')).toHaveText(/Invitation revoked \(invitee@test\.com\)/);
|
||||
await expect(page.getByTestId('toast-success')).toHaveText(/Invitation revoked/);
|
||||
|
||||
expect(lastApiRequests.deleteInvite?.url).toMatch(new RegExp(`/invites/${responseFixtures.invites.invites[0].id}`));
|
||||
});
|
||||
|
@ -40,7 +40,7 @@ test.describe('User profile', async () => {
|
||||
|
||||
await modal.getByLabel('Email').fill('test');
|
||||
await modal.getByRole('button', {name: 'Save & close'}).click();
|
||||
await expect(modal).toContainText('Please enter a valid email address');
|
||||
await expect(modal).toContainText('Enter a valid email address');
|
||||
|
||||
await modal.getByLabel('Location').fill(new Array(195).join('a'));
|
||||
await modal.getByRole('button', {name: 'Save & close'}).click();
|
||||
@ -51,7 +51,7 @@ test.describe('User profile', async () => {
|
||||
await expect(modal).toContainText('Bio is too long');
|
||||
|
||||
await modal.getByLabel('Website').fill('not-a-website');
|
||||
await modal.getByLabel('Website').blur();
|
||||
await modal.getByRole('button', {name: 'Save & close'}).click();
|
||||
await expect(modal).toContainText('Enter a valid URL');
|
||||
|
||||
const facebookInput = modal.getByLabel('Facebook profile');
|
||||
|
@ -104,8 +104,11 @@ test.describe('Offers Modal', () => {
|
||||
await modal.getByRole('button', {name: 'New offer'}).click();
|
||||
const addModal = page.getByTestId('add-offer-modal');
|
||||
await addModal.getByRole('button', {name: 'Publish'}).click();
|
||||
|
||||
await expect(page.getByTestId('toast-error')).toContainText(/Can't save offer, please double check that you've filled all mandatory fields./);
|
||||
const sidebar = addModal.getByTestId('add-offer-sidebar');
|
||||
await expect(sidebar).toContainText(/Name is required/);
|
||||
await expect(sidebar).toContainText(/Code is required/);
|
||||
await expect(sidebar).toContainText(/Enter an amount greater than 0./);
|
||||
await expect(sidebar).toContainText(/Display title is required/);
|
||||
});
|
||||
|
||||
test('Errors if the offer code is already taken', async ({page}) => {
|
||||
@ -163,7 +166,6 @@ test.describe('Offers Modal', () => {
|
||||
await modal.getByRole('button', {name: 'New offer'}).click();
|
||||
const addModal = page.getByTestId('add-offer-modal');
|
||||
await addModal.getByRole('button', {name: 'Publish'}).click();
|
||||
await expect(page.getByTestId('toast-error')).toContainText(/Can't save offer, please double check that you've filled all mandatory fields./);
|
||||
const sidebar = addModal.getByTestId('add-offer-sidebar');
|
||||
await expect(sidebar).toContainText(/Name is required/);
|
||||
await expect(sidebar).toContainText(/Code is required/);
|
||||
@ -238,7 +240,6 @@ test.describe('Offers Modal', () => {
|
||||
await offerUpdateModal.getByPlaceholder('black-friday').fill('');
|
||||
await offerUpdateModal.getByRole('button', {name: 'Save'}).click();
|
||||
|
||||
await expect(page.getByTestId('toast-error')).toContainText(/Can't save offer, please double check that you've filled all mandatory fields./);
|
||||
await expect(offerUpdateModal).toContainText(/Please enter a code/);
|
||||
|
||||
await offerUpdateModal.getByPlaceholder('black-friday').fill('black-friday-offer');
|
||||
|
@ -171,7 +171,6 @@ test.describe('Recommendations', async () => {
|
||||
expect(confirmation).toContainText('Your recommendation Recommendation 1 title will no longer be visible to your audience.');
|
||||
|
||||
await confirmation.getByRole('button', {name: 'Delete'}).click();
|
||||
await expect(page.getByTestId('toast-success')).toContainText('Successfully deleted the recommendation');
|
||||
|
||||
expect(lastApiRequests.deleteRecommendation).toBeTruthy();
|
||||
});
|
||||
|
@ -20,7 +20,6 @@ test.describe('Tier settings', async () => {
|
||||
|
||||
await modal.getByRole('button', {name: 'Save & close'}).click();
|
||||
|
||||
await expect(page.getByTestId('toast-error')).toHaveText(/Can't save tier/);
|
||||
await expect(modal).toHaveText(/Enter a name for the tier/);
|
||||
await expect(modal).toHaveText(/Amount must be at least \$1/);
|
||||
|
||||
@ -106,7 +105,6 @@ test.describe('Tier settings', async () => {
|
||||
await modal.getByLabel('Name').fill('');
|
||||
await modal.getByRole('button', {name: 'Save & close'}).click();
|
||||
|
||||
await expect(page.getByTestId('toast-error')).toHaveText(/Can't save tier/);
|
||||
await expect(modal).toHaveText(/Enter a name for the tier/);
|
||||
|
||||
// Valid values
|
||||
|
@ -53,7 +53,7 @@ test.describe('Theme settings', async () => {
|
||||
|
||||
await modal.getByRole('button', {name: 'Install Headline'}).click();
|
||||
|
||||
await expect(page.getByTestId('confirmation-modal')).toHaveText(/successfully installed/);
|
||||
await expect(page.getByTestId('confirmation-modal')).toHaveText(/installed/);
|
||||
|
||||
await page.getByRole('button', {name: 'Activate'}).click();
|
||||
|
||||
|
@ -250,7 +250,7 @@ export default class PublishManagement extends Component {
|
||||
yield this.publishTask.perform({taskName: 'revertToDraftTask'});
|
||||
|
||||
const postType = capitalize(this.args.post.displayName);
|
||||
this.notifications.showNotification(`${postType} successfully reverted to a draft.`, {type: 'success'});
|
||||
this.notifications.showNotification(`${postType} reverted to a draft.`, {type: 'success'});
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
|
@ -35,13 +35,13 @@ export default class GhMembersNoMembersComponent extends Component {
|
||||
|
||||
this.notifications.showNotification('Member added',
|
||||
{
|
||||
description: 'You\'ve successfully added yourself as a member.'
|
||||
description: 'You\'ve added yourself as a member.'
|
||||
}
|
||||
);
|
||||
|
||||
// force update the member count; this otherwise only updates every minute
|
||||
yield this.membersCountCache.count({});
|
||||
|
||||
|
||||
return member;
|
||||
} catch (error) {
|
||||
if (error) {
|
||||
|
@ -1,30 +1,36 @@
|
||||
<article class="gh-notification gh-notification-passive {{this.typeClass}}" {{on "animationend" this.closeOnFadeOut}} ...attributes>
|
||||
<div class="gh-notification-icon">
|
||||
{{#if @message.icon}}
|
||||
{{svg-jar @message.icon}}
|
||||
{{else}}
|
||||
{{#if (eq @message.type "success")}}
|
||||
{{svg-jar "check-circle"}}
|
||||
{{else if (eq @message.type "error")}}
|
||||
{{svg-jar "warning-stroke"}}
|
||||
{{else if (eq @message.type "warn")}}
|
||||
{{svg-jar "warning-stroke"}}
|
||||
{{else}}
|
||||
{{svg-jar "check-circle"}}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="gh-notification-content" data-test-text="notification-content">
|
||||
<span class="gh-notification-title">{{@message.message}}</span>
|
||||
|
||||
<div class="gh-notification-header">
|
||||
<div class="gh-notification-icon">
|
||||
{{#if @message.icon}}
|
||||
{{svg-jar @message.icon}}
|
||||
{{else}}
|
||||
{{#if (eq @message.type "success")}}
|
||||
{{svg-jar "check-circle-filled"}}
|
||||
{{else if (eq @message.type "error")}}
|
||||
{{svg-jar "warning-circle-filled"}}
|
||||
{{else if (eq @message.type "warn")}}
|
||||
{{svg-jar "warning-circle-filled"}}
|
||||
{{else}}
|
||||
{{svg-jar "check-circle-filled"}}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</div>
|
||||
<div>
|
||||
<span class="gh-notification-title">{{@message.message}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{#if (or @message.description @message.actions)}}
|
||||
<div class="gh-notification-details">
|
||||
{{#if @message.description}}
|
||||
<p>{{@message.description}}</p>
|
||||
{{/if}}
|
||||
|
||||
{{#if @message.actions}}
|
||||
<span class="gh-notification-actions">{{@message.actions}}</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
<button class="gh-notification-close" data-test-button="close-notification" type="button" {{on "click" this.closeNotification}}>
|
||||
{{svg-jar "close"}}<span class="hidden">Close</span>
|
||||
</button>
|
||||
|
@ -19,7 +19,7 @@ export default class LogoutMemberModal extends Component {
|
||||
yield this.ajax.delete(url, options);
|
||||
|
||||
this.args.data.afterLogout?.();
|
||||
this.notifications.showNotification(`${this.member.name || this.member.email} has been successfully signed out from all devices.`, {type: 'success'});
|
||||
this.notifications.showNotification(`${this.member.name || this.member.email} has been signed out from all devices.`, {type: 'success'});
|
||||
this.args.close(true);
|
||||
return true;
|
||||
} catch (e) {
|
||||
|
@ -40,7 +40,7 @@ export default class RestoreRevisionModal extends Component {
|
||||
updateTitle();
|
||||
updateEditor();
|
||||
|
||||
this.notifications.showNotification('Revision successfully restored.', {type: 'success'});
|
||||
this.notifications.showNotification('Revision restored.', {type: 'success'});
|
||||
|
||||
closePostHistoryModal();
|
||||
|
||||
|
@ -21,28 +21,28 @@ function tpl(str, data) {
|
||||
|
||||
const messages = {
|
||||
deleted: {
|
||||
single: '{Type} deleted successfully',
|
||||
multiple: '{count} {type}s deleted successfully'
|
||||
single: '{Type} deleted',
|
||||
multiple: '{count} {type}s deleted'
|
||||
},
|
||||
unpublished: {
|
||||
single: '{Type} successfully reverted to a draft',
|
||||
multiple: '{count} {type}s successfully reverted to drafts'
|
||||
single: '{Type} reverted to a draft',
|
||||
multiple: '{count} {type}s reverted to drafts'
|
||||
},
|
||||
accessUpdated: {
|
||||
single: '{Type} access successfully updated',
|
||||
multiple: '{Type} access successfully updated for {count} {type}s'
|
||||
single: '{Type} access updated',
|
||||
multiple: '{Type} access updated for {count} {type}s'
|
||||
},
|
||||
tagsAdded: {
|
||||
single: 'Tags added successfully',
|
||||
multiple: 'Tags added successfully to {count} {type}s'
|
||||
single: 'Tags added',
|
||||
multiple: 'Tags added to {count} {type}s'
|
||||
},
|
||||
tagAdded: {
|
||||
single: 'Tag added successfully',
|
||||
multiple: 'Tag added successfully to {count} {type}s'
|
||||
single: 'Tag added',
|
||||
multiple: 'Tag added to {count} {type}s'
|
||||
},
|
||||
duplicated: {
|
||||
single: '{Type} duplicated successfully',
|
||||
multiple: '{count} {type}s duplicated successfully'
|
||||
single: '{Type} duplicated',
|
||||
multiple: '{count} {type}s duplicated'
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -15,7 +15,6 @@ import moment from 'moment-timezone';
|
||||
import {GENERIC_ERROR_MESSAGE} from '../services/notifications';
|
||||
import {action, computed} from '@ember/object';
|
||||
import {alias, mapBy} from '@ember/object/computed';
|
||||
import {capitalize} from '@ember/string';
|
||||
import {captureMessage} from '@sentry/ember';
|
||||
import {dropTask, enqueueTask, restartableTask, task, taskGroup, timeout} from 'ember-concurrency';
|
||||
import {htmlSafe} from '@ember/template';
|
||||
@ -1266,7 +1265,7 @@ export default class LexicalEditorController extends Controller {
|
||||
let actions, type, path;
|
||||
|
||||
if (status === 'published' || status === 'scheduled') {
|
||||
type = capitalize(this.get('post.displayName'));
|
||||
type = this.get('post.displayName');
|
||||
path = this.get('post.url');
|
||||
actions = `<a href="${path}" target="_blank">View ${type}</a>`;
|
||||
}
|
||||
|
@ -1031,6 +1031,10 @@ input:focus,
|
||||
background: var(--lightgrey);
|
||||
}
|
||||
|
||||
.gh-notification-close:hover svg {
|
||||
stroke: #FFF;
|
||||
}
|
||||
|
||||
/* Opacity needed to display correctly in Safari */
|
||||
::selection {
|
||||
background: rgba(88, 101, 116, 0.99);
|
||||
@ -1415,7 +1419,7 @@ Onboarding checklist: Share publication modal */
|
||||
opacity: .8;
|
||||
}
|
||||
|
||||
.gh-share-links li a {
|
||||
.gh-share-links li a {
|
||||
border: 1px solid #394047;
|
||||
}
|
||||
|
||||
|
@ -5,7 +5,7 @@
|
||||
.gh-notifications {
|
||||
position: absolute;
|
||||
bottom: 30px;
|
||||
left: 30px;
|
||||
left: 24px;
|
||||
z-index: 7000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -14,26 +14,33 @@
|
||||
/* Base notification style */
|
||||
.gh-notification {
|
||||
position: relative;
|
||||
display: flex;
|
||||
margin-top: 8px;
|
||||
padding: 4px 8px;
|
||||
width: 286px;
|
||||
background: var(--black);
|
||||
border-radius: 6px;
|
||||
box-shadow:
|
||||
0 1.1px 2.3px rgba(0, 0, 0, 0.028),
|
||||
0 3.8px 7.8px rgba(0, 0, 0, 0.042),
|
||||
0 17px 35px -7px rgba(0, 0, 0, 0.11)
|
||||
;
|
||||
color: #fff;
|
||||
min-width: 272px;
|
||||
max-width: 320px;
|
||||
background: var(--white);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0px 0px 1px rgba(0, 0, 0, 0.28), 0px 100px 80px rgba(0, 0, 0, 0.0112458), 0px 41.7776px 33.4221px rgba(0, 0, 0, 0.0161557), 0px 22.3363px 17.869px rgba(0, 0, 0, 0.02), 0px 12.5216px 10.0172px rgba(0, 0, 0, 0.0238443), 0px 6.6501px 5.32008px rgba(0, 0, 0, 0.0287542), 0px 2.76726px 2.21381px rgba(0, 0, 0, 0.04);
|
||||
color: var(--black);
|
||||
font-size: 1.3rem;
|
||||
line-height: 1.25em;
|
||||
opacity: 1.0;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
@media (max-width: 1240px) {
|
||||
.gh-notification {
|
||||
min-width: 232px;
|
||||
}
|
||||
}
|
||||
|
||||
.gh-notification-icon {
|
||||
margin: 10px 0 0 6px;
|
||||
margin-top: 1px;
|
||||
line-height: 0;
|
||||
color: #30CF43;
|
||||
}
|
||||
|
||||
:is(.gh-notification-error, .gh-notification-warn) .gh-notification-icon {
|
||||
color: #F50B23;
|
||||
}
|
||||
|
||||
.gh-notification-icon svg {
|
||||
@ -41,24 +48,24 @@
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.gh-notification-icon svg path {
|
||||
stroke-width: 1.5px;
|
||||
stroke: #fff;
|
||||
}
|
||||
|
||||
.gh-notification-content {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 9px 15px 10px 10px;
|
||||
padding: 16px;
|
||||
border-radius: 3px;
|
||||
max-width: 215px;
|
||||
margin-right: 28px;
|
||||
}
|
||||
|
||||
.gh-notification-content p span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gh-notification-header {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.gh-notification-title {
|
||||
display: block;
|
||||
margin-top: 1px;
|
||||
@ -66,14 +73,18 @@
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.gh-notification-details {
|
||||
margin: -18px 16px 16px 42px;
|
||||
}
|
||||
|
||||
.gh-notification p {
|
||||
margin: 6px 0 0;
|
||||
margin: 8px 0 0;
|
||||
padding: 0;
|
||||
line-height: 1.35em;
|
||||
}
|
||||
|
||||
.gh-notification a {
|
||||
color: #fff;
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
font-weight: 400;
|
||||
}
|
||||
@ -84,7 +95,7 @@
|
||||
}
|
||||
|
||||
.gh-notification-actions {
|
||||
margin-top: 6px;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 2px;
|
||||
display: flex;
|
||||
}
|
||||
@ -92,12 +103,17 @@
|
||||
.gh-notification-actions a {
|
||||
display: inline-block;
|
||||
margin-right: 10px;
|
||||
color: var(--darkgrey);
|
||||
}
|
||||
|
||||
.gh-notification-actions a:hover {
|
||||
color: var(--black);
|
||||
}
|
||||
|
||||
.gh-notification-close {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
padding: 8px;
|
||||
background: none;
|
||||
border-radius: 999px;
|
||||
@ -109,16 +125,20 @@
|
||||
.gh-notification-close svg {
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
stroke: #fff;
|
||||
stroke: #7C8B9A;
|
||||
}
|
||||
|
||||
.gh-notification-close:hover svg {
|
||||
stroke: #394047;
|
||||
}
|
||||
|
||||
.gh-notification-close svg path {
|
||||
stroke-width: 2px;
|
||||
}
|
||||
|
||||
.gh-notification-close:hover {
|
||||
/* .gh-notification-close:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
} */
|
||||
|
||||
.gh-notification-passive {
|
||||
animation: notification-fade-in-spring, fade-out;
|
||||
@ -134,19 +154,16 @@
|
||||
|
||||
@keyframes notification-fade-in-spring {
|
||||
0.00% {
|
||||
opacity: 0;
|
||||
transform: translateX(-232.05px);
|
||||
transform: translateY(100%);
|
||||
}
|
||||
26.52% {
|
||||
opacity: 0.5;
|
||||
transform: translateX(5.90px);
|
||||
transform: translateY(-3.90px);
|
||||
}
|
||||
63.26% {
|
||||
transform: translateX(-1.77px);
|
||||
opacity: 1;
|
||||
transform: translateY(1.2px);
|
||||
}
|
||||
100.00% {
|
||||
transform: translateX(0px);
|
||||
transform: translateY(0px);
|
||||
}
|
||||
}
|
||||
|
||||
@ -338,4 +355,4 @@
|
||||
.gh-update-banner a {
|
||||
font-weight: 700;
|
||||
color: var(--green-l2);
|
||||
}
|
||||
}
|
||||
|
10
ghost/admin/public/assets/icons/check-circle-filled.svg
Normal file
10
ghost/admin/public/assets/icons/check-circle-filled.svg
Normal file
@ -0,0 +1,10 @@
|
||||
<svg width="14" height="15" viewBox="0 0 14 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_3_6)">
|
||||
<path d="M7 0.5C5.61553 0.5 4.26216 0.910543 3.11101 1.67971C1.95987 2.44888 1.06266 3.54213 0.532846 4.82122C0.00303299 6.1003 -0.13559 7.50776 0.134506 8.86563C0.404603 10.2235 1.07129 11.4708 2.05026 12.4497C3.02922 13.4287 4.2765 14.0954 5.63437 14.3655C6.99224 14.6356 8.39971 14.497 9.67879 13.9672C10.9579 13.4373 12.0511 12.5401 12.8203 11.389C13.5895 10.2378 14 8.88447 14 7.5C14 5.64348 13.2625 3.86301 11.9498 2.55025C10.637 1.2375 8.85652 0.5 7 0.5ZM11.0425 5.28333L7.04667 10.7025C7.00017 10.7659 6.94135 10.8192 6.87374 10.8592C6.80612 10.8993 6.73111 10.9253 6.6532 10.9356C6.57529 10.946 6.4961 10.9405 6.42037 10.9195C6.34464 10.8985 6.27393 10.8624 6.2125 10.8133L3.36 8.5325C3.30008 8.48436 3.25026 8.42486 3.21341 8.3574C3.17656 8.28995 3.1534 8.21587 3.14527 8.13944C3.13714 8.06301 3.14419 7.98572 3.16603 7.91202C3.18786 7.83832 3.22405 7.76967 3.2725 7.71C3.36943 7.59238 3.50855 7.51736 3.66009 7.50101C3.81163 7.48466 3.96354 7.52826 4.08334 7.6225L6.46334 9.52417L10.1033 4.58333C10.1952 4.46033 10.3318 4.3785 10.4836 4.35557C10.6354 4.33263 10.7901 4.37046 10.9142 4.46083C10.9772 4.50596 11.0306 4.56319 11.0712 4.62917C11.1119 4.69516 11.1389 4.76859 11.1509 4.84516C11.1628 4.92173 11.1594 4.99992 11.1408 5.07515C11.1222 5.15039 11.0888 5.22116 11.0425 5.28333Z" fill="currentColor"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_3_6">
|
||||
<rect width="14" height="14" fill="white" transform="translate(0 0.5)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 1.5 KiB |
10
ghost/admin/public/assets/icons/info-circle-filled.svg
Normal file
10
ghost/admin/public/assets/icons/info-circle-filled.svg
Normal file
@ -0,0 +1,10 @@
|
||||
<svg width="14" height="15" viewBox="0 0 14 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_12_50)">
|
||||
<path d="M7 0.5C5.61553 0.5 4.26216 0.910543 3.11101 1.67971C1.95987 2.44888 1.06266 3.54213 0.532846 4.82122C0.00303299 6.1003 -0.13559 7.50776 0.134506 8.86563C0.404603 10.2235 1.07129 11.4708 2.05026 12.4497C3.02922 13.4287 4.2765 14.0954 5.63437 14.3655C6.99224 14.6356 8.39971 14.497 9.67879 13.9672C10.9579 13.4373 12.0511 12.5401 12.8203 11.389C13.5895 10.2378 14 8.88447 14 7.5C14 5.64348 13.2625 3.86301 11.9498 2.55025C10.637 1.2375 8.85652 0.5 7 0.5ZM7.14584 3.41667C7.3189 3.41667 7.48807 3.46798 7.63196 3.56413C7.77585 3.66028 7.88801 3.79693 7.95423 3.95682C8.02046 4.1167 8.03779 4.29264 8.00402 4.46237C7.97026 4.6321 7.88693 4.78801 7.76456 4.91039C7.64218 5.03276 7.48627 5.11609 7.31654 5.14985C7.14681 5.18362 6.97087 5.16629 6.81099 5.10006C6.6511 5.03383 6.51445 4.92168 6.4183 4.77779C6.32215 4.6339 6.27084 4.46473 6.27084 4.29167C6.27084 4.0596 6.36302 3.83704 6.52712 3.67295C6.69121 3.50885 6.91377 3.41667 7.14584 3.41667ZM8.45834 11.2917H6.125C5.97029 11.2917 5.82192 11.2302 5.71252 11.1208C5.60313 11.0114 5.54167 10.863 5.54167 10.7083C5.54167 10.5536 5.60313 10.4053 5.71252 10.2959C5.82192 10.1865 5.97029 10.125 6.125 10.125H6.5625C6.60118 10.125 6.63827 10.1096 6.66562 10.0823C6.69297 10.0549 6.70834 10.0178 6.70834 9.97917V7.35417C6.70834 7.33502 6.70457 7.31605 6.69724 7.29836C6.68991 7.28066 6.67917 7.26459 6.66562 7.25105C6.65208 7.2375 6.636 7.22676 6.61831 7.21943C6.60062 7.21211 6.58166 7.20833 6.5625 7.20833H6.125C5.97029 7.20833 5.82192 7.14687 5.71252 7.03748C5.60313 6.92808 5.54167 6.77971 5.54167 6.625C5.54167 6.47029 5.60313 6.32192 5.71252 6.21252C5.82192 6.10313 5.97029 6.04167 6.125 6.04167H6.70834C7.01776 6.04167 7.3145 6.16458 7.5333 6.38338C7.75209 6.60217 7.875 6.89891 7.875 7.20833V9.97917C7.875 10.0178 7.89037 10.0549 7.91772 10.0823C7.94507 10.1096 7.98216 10.125 8.02084 10.125H8.45834C8.61305 10.125 8.76142 10.1865 8.87082 10.2959C8.98021 10.4053 9.04167 10.5536 9.04167 10.7083C9.04167 10.863 8.98021 11.0114 8.87082 11.1208C8.76142 11.2302 8.61305 11.2917 8.45834 11.2917Z" fill="currentColor"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_12_50">
|
||||
<rect width="14" height="14" fill="white" transform="translate(0 0.5)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 2.3 KiB |
10
ghost/admin/public/assets/icons/warning-circle-filled.svg
Normal file
10
ghost/admin/public/assets/icons/warning-circle-filled.svg
Normal file
@ -0,0 +1,10 @@
|
||||
<svg width="14" height="15" viewBox="0 0 14 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_12_38)">
|
||||
<path d="M6.99988 0.499816C5.12613 0.529052 3.33898 1.29378 2.02405 2.62898C1.36955 3.28339 0.853135 4.06257 0.505429 4.92031C0.157723 5.77805 -0.0141905 6.69689 -0.000116184 7.62232C-0.000884783 8.52619 0.176676 9.42133 0.522395 10.2565C0.868115 11.0916 1.3752 11.8504 2.01461 12.4892C2.65401 13.1281 3.41318 13.6345 4.24861 13.9795C5.08405 14.3245 5.97934 14.5014 6.88322 14.4998H6.99988C8.87242 14.4829 10.6616 13.7231 11.9742 12.3875C13.2868 11.052 14.0154 9.24987 13.9999 7.37732C14.0016 6.4633 13.8208 5.55814 13.4681 4.71491C13.1154 3.87169 12.5979 3.10737 11.9459 2.4668C11.2939 1.82622 10.5206 1.32227 9.67126 0.984503C8.82194 0.646737 7.91373 0.481953 6.99988 0.499816ZM6.12488 10.1481C6.12011 10.0309 6.13931 9.91386 6.18129 9.80425C6.22327 9.69465 6.28716 9.59475 6.36906 9.51068C6.45095 9.4266 6.54913 9.3601 6.65759 9.31525C6.76606 9.2704 6.88253 9.24813 6.99988 9.24982C7.22964 9.25114 7.45003 9.34102 7.61519 9.50074C7.78035 9.66047 7.87754 9.87773 7.88655 10.1073C7.89127 10.2228 7.87258 10.3381 7.8316 10.4462C7.79063 10.5543 7.72821 10.653 7.64811 10.7364C7.56801 10.8198 7.47188 10.8861 7.36549 10.9314C7.25911 10.9766 7.14467 10.9999 7.02905 10.9998C6.79724 11.0031 6.57326 10.916 6.4045 10.7571C6.23575 10.5981 6.13547 10.3797 6.12488 10.1481ZM6.41655 7.81482V4.31482C6.41655 4.16011 6.47801 4.01173 6.58741 3.90234C6.6968 3.79294 6.84517 3.73148 6.99988 3.73148C7.15459 3.73148 7.30297 3.79294 7.41236 3.90234C7.52176 4.01173 7.58322 4.16011 7.58322 4.31482V7.81482C7.58322 7.96953 7.52176 8.1179 7.41236 8.22729C7.30297 8.33669 7.15459 8.39815 6.99988 8.39815C6.84517 8.39815 6.6968 8.33669 6.58741 8.22729C6.47801 8.1179 6.41655 7.96953 6.41655 7.81482Z" fill="currentColor"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_12_38">
|
||||
<rect width="14" height="14" fill="white" transform="translate(0 0.5)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 1.9 KiB |
Loading…
Reference in New Issue
Block a user