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:
Peter Zimon 2024-05-14 09:31:19 +02:00 committed by GitHub
parent 842290cbef
commit 770f657ae9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
63 changed files with 391 additions and 317 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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;

View File

@ -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'
}

View File

@ -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>
),
{

View File

@ -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',

View File

@ -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 {

View File

@ -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]);

View File

@ -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();

View File

@ -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);

View File

@ -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'>

View File

@ -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});

View File

@ -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'
});
}
};

View File

@ -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.'
});
}
}}
>

View File

@ -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);

View File

@ -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);

View File

@ -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();

View File

@ -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.'
});
}
}}
>

View File

@ -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});
}}
/>;
};

View File

@ -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'

View File

@ -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>
);

View File

@ -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>

View File

@ -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) {

View File

@ -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});
}

View File

@ -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});
}}

View File

@ -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});
}}

View File

@ -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'
});
}
}
}}
/>;

View File

@ -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;
};

View File

@ -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');

View File

@ -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
});
}

View File

@ -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.'
});
}
}}

View File

@ -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.'
});
}
}}

View File

@ -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;

View File

@ -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});
}
}}

View File

@ -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;
}

View File

@ -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'>

View File

@ -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.'
});
}

View File

@ -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>&quot;{uploadedTheme.name}&quot;</strong> was installed successfully but we detected some {hasErrors ? 'errors' : 'warnings'}.
The theme <strong>&quot;{uploadedTheme.name}&quot;</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();

View File

@ -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);

View File

@ -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>
});

View File

@ -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;
},

View File

@ -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: [

View File

@ -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();

View File

@ -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/);
});
});
});

View File

@ -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}`));
});

View File

@ -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');

View File

@ -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');

View File

@ -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();
});

View File

@ -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

View File

@ -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();

View File

@ -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) {

View File

@ -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) {

View File

@ -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>

View File

@ -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) {

View File

@ -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();

View File

@ -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'
}
};

View File

@ -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>`;
}

View File

@ -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;
}

View File

@ -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);
}
}

View 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

View 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

View 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