AdminX Portal setting forms (#17201)

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

Styles was not applied to AdminX Portal settings forms. Also a couple of new components had to be added for easer future form design and implementation.
This commit is contained in:
Peter Zimon 2023-07-04 15:18:19 +02:00 committed by GitHub
parent 80e65d4978
commit 47a9eaadcc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 337 additions and 32 deletions

View File

@ -39,7 +39,7 @@ function App({ghostVersion, officialThemes, setDirty}: AppProps) {
<Sidebar />
</div>
</div>
<div className="flex-auto pt-[3vmin] md:ml-[300px] md:pt-[55px]">
<div className="flex-auto pt-[3vmin] md:ml-[300px] md:pt-[85px]">
<Settings />
</div>
</div>

View File

@ -37,6 +37,8 @@ type HeadingLabelProps = {
level?: never,
grey?: boolean } & HeadingBaseProps & React.LabelHTMLAttributes<HTMLLabelElement>
export const Heading6Styles = 'text-2xs font-semibold uppercase tracking-wide';
const Heading: React.FC<Heading1to5Props | Heading6Props | HeadingLabelProps> = ({
level,
children,
@ -52,7 +54,7 @@ const Heading: React.FC<Heading1to5Props | Heading6Props | HeadingLabelProps> =
}
const newElement = `${useLabelTag ? 'label' : `h${level}`}`;
styles += (level === 6 || useLabelTag) ? (` block text-2xs font-semibold uppercase tracking-wide ${(grey && 'text-grey-700')}`) : ' ';
styles += (level === 6 || useLabelTag) ? (` block text-2xs ${Heading6Styles} ${(grey && 'text-grey-700')}`) : ' ';
const Element = React.createElement(newElement, {className: styles + ' ' + className, key: 'heading-elem', ...props}, children);

View File

@ -3,7 +3,7 @@ import Hint from '../Hint';
import React, {useEffect, useId, useState} from 'react';
import Separator from '../Separator';
interface CheckboxProps {
export interface CheckboxProps {
title?: string;
label: string;
value: string;
@ -11,6 +11,7 @@ interface CheckboxProps {
disabled?: boolean;
error?: boolean;
hint?: React.ReactNode;
key?: string;
checked?: boolean;
separator?: boolean;
}
@ -36,7 +37,7 @@ const Checkbox: React.FC<CheckboxProps> = ({title, label, value, onChange, disab
<label className={`flex cursor-pointer items-start ${title && '-mb-1 mt-1'}`} htmlFor={id}>
<input
checked={isChecked}
className="relative float-left mt-[3px] h-4 w-4 appearance-none border-2 border-solid border-grey-300 outline-none checked:border-green checked:bg-green checked:after:absolute checked:after:-mt-px checked:after:ml-[3px] checked:after:block checked:after:h-[11px] checked:after:w-[6px] checked:after:rotate-45 checked:after:border-[2px] checked:after:border-l-0 checked:after:border-t-0 checked:after:border-solid checked:after:border-white checked:after:bg-transparent checked:after:content-[''] hover:cursor-pointer focus:shadow-none focus:transition-[border-color_0.2s] dark:border-grey-600 dark:checked:border-green dark:checked:bg-green"
className="relative float-left mt-[3px] h-4 w-4 appearance-none border-2 border-solid border-grey-200 bg-grey-200 outline-none checked:border-black checked:bg-black checked:after:absolute checked:after:-mt-px checked:after:ml-[3px] checked:after:block checked:after:h-[11px] checked:after:w-[6px] checked:after:rotate-45 checked:after:border-[2px] checked:after:border-l-0 checked:after:border-t-0 checked:after:border-solid checked:after:border-white checked:after:bg-transparent checked:after:content-[''] hover:cursor-pointer focus:shadow-none focus:transition-[border-color_0.2s] dark:border-grey-600 dark:checked:border-green dark:checked:bg-green"
disabled={disabled}
id={id}
type='checkbox'

View File

@ -0,0 +1,106 @@
import type {Meta, StoryObj} from '@storybook/react';
import CheckboxGroup from './CheckboxGroup';
const meta = {
title: 'GLobal / Form / Checkbox group',
component: CheckboxGroup,
tags: ['autodocs']
} satisfies Meta<typeof CheckboxGroup>;
export default meta;
type Story = StoryObj<typeof CheckboxGroup>;
export const Default: Story = {
args: {
checkboxes: [
{
onChange: () => {},
label: 'Kevin',
value: 'kevin'
},
{
onChange: () => {},
label: 'Minci',
value: 'minci'
},
{
onChange: () => {},
label: 'Conker',
value: 'conker'
}
]
}
};
export const WithTitle: Story = {
args: {
title: 'Gimme pets',
checkboxes: [
{
onChange: () => {},
label: 'Kevin',
value: 'kevin'
},
{
onChange: () => {},
label: 'Minci',
value: 'minci'
},
{
onChange: () => {},
label: 'Conker',
value: 'conker'
}
]
}
};
export const WithTitleAndHint: Story = {
args: {
title: 'Gimme pets',
checkboxes: [
{
onChange: () => {},
label: 'Kevin',
value: 'kevin'
},
{
onChange: () => {},
label: 'Minci',
value: 'minci'
},
{
onChange: () => {},
label: 'Conker',
value: 'conker'
}
],
hint: 'Who you gonna pet?'
}
};
export const Error: Story = {
args: {
title: 'Gimme pets',
error: true,
checkboxes: [
{
onChange: () => {},
label: 'Kevin',
value: 'kevin'
},
{
onChange: () => {},
label: 'Minci',
value: 'minci'
},
{
onChange: () => {},
label: 'Conker',
value: 'conker'
}
],
hint: 'Please select one'
}
};

View File

@ -0,0 +1,34 @@
import Checkbox, {CheckboxProps} from './Checkbox';
import Heading from '../Heading';
import Hint from '../Hint';
import React from 'react';
interface CheckboxGroupProps {
title?: string;
checkboxes?: CheckboxProps[];
hint?: string;
error?: boolean;
}
const CheckboxGroup: React.FC<CheckboxGroupProps> = ({
title,
checkboxes,
hint,
error
}) => {
return (
<div>
{title && <Heading level={6}>{title}</Heading>}
<div className='mt-2 flex flex-col gap-1'>
{checkboxes?.map(({key, ...props}) => (
<Checkbox key={key} {...props} />
))}
</div>
<div className={`flex flex-col ${hint && 'mb-2'}`}>
{hint && <Hint color={error ? 'red' : ''}>{hint}</Hint>}
</div>
</div>
);
};
export default CheckboxGroup;

View File

@ -0,0 +1,41 @@
import type {Meta, StoryObj} from '@storybook/react';
import * as CheckboxGroupStories from './CheckboxGroup.stories';
import * as TextFieldStories from './TextField.stories';
import CheckboxGroup from './CheckboxGroup';
import Form from './Form';
import TextField from './TextField';
const meta = {
title: 'Global / Form / Form (group)',
component: Form,
tags: ['autodocs']
} satisfies Meta<typeof Form>;
export default meta;
type Story = StoryObj<typeof Form>;
const formElements = <>
<CheckboxGroup {...CheckboxGroupStories.WithTitleAndHint.args} />
<TextField {...TextFieldStories.WithHeading.args} />
</>;
export const Default: Story = {
args: {
children: formElements
}
};
export const SmallGap: Story = {
args: {
children: formElements,
gap: 'sm'
}
};
export const LargeGap: Story = {
args: {
children: formElements,
gap: 'lg'
}
};

View File

@ -0,0 +1,52 @@
import React from 'react';
import clsx from 'clsx';
interface FormProps {
gap?: 'sm' | 'md' | 'lg';
marginTop?: boolean;
marginBottom?: boolean;
children?: React.ReactNode;
}
/**
* A container to group form elements
*/
const Form: React.FC<FormProps> = ({
gap = 'md',
marginTop = false,
marginBottom = true,
children
}) => {
let classes = clsx(
'flex flex-col',
(gap === 'sm' && 'gap-4'),
(gap === 'md' && 'gap-8'),
(gap === 'lg' && 'gap-11')
);
if (marginBottom) {
classes = clsx(
classes,
(gap === 'sm' && 'mb-4'),
(gap === 'md' && 'mb-8'),
(gap === 'lg' && 'mb-11')
);
}
if (marginTop) {
classes = clsx(
classes,
(gap === 'sm' && 'mt-4'),
(gap === 'md' && 'mt-8'),
(gap === 'lg' && 'mt-11')
);
}
return (
<div className={classes}>
{children}
</div>
);
};
export default Form;

View File

@ -40,6 +40,13 @@ export const WithLabel: Story = {
}
};
export const HeadingStyleLabel: Story = {
args: {
label: 'Heading style label',
labelStyle: 'heading'
}
};
export const WithLabelAndHint: Story = {
args: {
label: 'Check me',

View File

@ -1,5 +1,6 @@
import React, {useId} from 'react';
import Separator from '../Separator';
import {Heading6Styles} from '../Heading';
type ToggleSizes = 'sm' | 'md' | 'lg';
type ToggleDirections = 'ltr' | 'rtl';
@ -11,13 +12,24 @@ interface ToggleProps {
error?: boolean;
size?: ToggleSizes;
label?: React.ReactNode;
labelStyle?: 'heading' | 'value';
separator?: boolean;
direction?: ToggleDirections;
hint?: React.ReactNode;
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
}
const Toggle: React.FC<ToggleProps> = ({size, direction, label, hint, separator, error, checked, onChange}) => {
const Toggle: React.FC<ToggleProps> = ({
size,
direction,
label,
labelStyle = 'value',
hint,
separator,
error,
checked,
onChange
}) => {
const id = useId();
let sizeStyles = '';
@ -39,6 +51,10 @@ const Toggle: React.FC<ToggleProps> = ({size, direction, label, hint, separator,
break;
}
if (labelStyle === 'heading') {
direction = 'rtl';
}
return (
<div>
<div className={`group flex items-start gap-2 ${direction === 'rtl' && 'justify-between'} ${separator && 'pb-2'}`}>
@ -50,7 +66,12 @@ const Toggle: React.FC<ToggleProps> = ({size, direction, label, hint, separator,
onChange={onChange} />
{label &&
<label className={`flex flex-col hover:cursor-pointer ${direction === 'rtl' && 'order-1'} ${labelStyles}`} htmlFor={id}>
<span>{label}</span>
{
labelStyle === 'heading' ?
<span className={`${Heading6Styles} mt-1`}>{label}</span>
:
<span>{label}</span>
}
{hint && <span className={`text-xs ${error ? 'text-red' : 'text-grey-700'}`}>{hint}</span>}
</label>
}

View File

@ -1,3 +1,4 @@
import Form from '../../../../admin-x-ds/global/form/Form';
import React, {FocusEventHandler, useContext, useState} from 'react';
import TextField from '../../../../admin-x-ds/global/form/TextField';
import {Setting, SettingValue} from '../../../../types/api';
@ -33,9 +34,9 @@ const AccountPage: React.FC<{
setValue(parseEmailAddress(settingValue));
};
return <>
return <Form marginTop>
<TextField title='Support email address' value={value} onBlur={updateSupportAddress} onChange={e => setValue(e.target.value)} />
</>;
</Form>;
};
export default AccountPage;

View File

@ -1,3 +1,4 @@
import Form from '../../../../admin-x-ds/global/form/Form';
import React from 'react';
import Select from '../../../../admin-x-ds/global/form/Select';
import TextField from '../../../../admin-x-ds/global/form/TextField';
@ -11,10 +12,11 @@ const LookAndFeel: React.FC<{
}> = ({localSettings, updateSetting}) => {
const [portalButton, portalButtonStyle, portalButtonSignupText] = getSettingValues(localSettings, ['portal_button', 'portal_button_style', 'portal_button_signup_text']);
return <>
return <Form marginTop>
<Toggle
checked={Boolean(portalButton)}
label='Show portal button'
labelStyle='heading'
onChange={e => updateSetting('portal_button', e.target.checked)}
/>
<Select
@ -27,7 +29,7 @@ const LookAndFeel: React.FC<{
title='Portal button style'
onSelect={option => updateSetting('portal_button_style', option)}
/>
{portalButtonStyle?.toString()?.includes('icon') && <div>TODO: icon picker</div>}
{portalButtonStyle?.toString()?.includes('icon') && <div className='red text-sm'>TODO: icon picker</div>}
{portalButtonStyle?.toString()?.includes('text') &&
<TextField
title='Signup button text'
@ -35,7 +37,7 @@ const LookAndFeel: React.FC<{
onChange={e => updateSetting('portal_button_signup_text', e.target.value)}
/>
}
</>;
</Form>;
};
export default LookAndFeel;

View File

@ -1,7 +1,8 @@
import Checkbox from '../../../../admin-x-ds/global/form/Checkbox';
import Heading from '../../../../admin-x-ds/global/Heading';
import CheckboxGroup from '../../../../admin-x-ds/global/form/CheckboxGroup';
import Form from '../../../../admin-x-ds/global/form/Form';
import React, {useContext} from 'react';
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
import {CheckboxProps} from '../../../../admin-x-ds/global/form/Checkbox';
import {Setting, SettingValue, Tier} from '../../../../types/api';
import {SettingsContext} from '../../../providers/SettingsProvider';
import {checkStripeEnabled, getSettingValues} from '../../../../utils/helpers';
@ -34,42 +35,79 @@ const SignupOptions: React.FC<{
const isStripeEnabled = checkStripeEnabled(localSettings, config!);
return <>
let tiersCheckboxes: CheckboxProps[] = [
{
checked: (portalPlans.includes('free')),
disabled: isDisabled,
label: 'Free',
value: 'free',
onChange: () => {
togglePlan('free');
}
}
];
if (isStripeEnabled) {
localTiers.forEach((tier) => {
tiersCheckboxes.push({
checked: (tier.visibility === 'public'),
label: tier.name,
value: tier.id,
onChange: (checked => updateTier({...tier, visibility: checked ? 'public' : 'none'}))
});
});
}
return <Form marginTop>
<Toggle
checked={Boolean(portalName)}
disabled={isDisabled}
label='Display name in signup form'
labelStyle='heading'
onChange={e => updateSetting('portal_name', e.target.checked)}
/>
<Heading level={6} grey>Tiers available at signup</Heading>
<Checkbox checked={portalPlans.includes('free')} disabled={isDisabled} label='Free' value='free' onChange={() => togglePlan('free')} />
{isStripeEnabled && localTiers.map(tier => (
<Checkbox
checked={tier.visibility === 'public'}
label={tier.name}
value={tier.id}
onChange={checked => updateTier({...tier, visibility: checked ? 'public' : 'none'})}
/>
))}
<CheckboxGroup
checkboxes={tiersCheckboxes}
title='Tiers available at startup'
/>
{isStripeEnabled && localTiers.some(tier => tier.visibility === 'public') && (
<>
<Heading level={6} grey>Prices available at signup</Heading>
<Checkbox checked={portalPlans.includes('monthly')} disabled={isDisabled} label='Monthly' value='monthly' onChange={() => togglePlan('monthly')} />
<Checkbox checked={portalPlans.includes('yearly')} disabled={isDisabled} label='Yearly' value='yearly' onChange={() => togglePlan('yearly')} />
</>
<CheckboxGroup
checkboxes={[
{
checked: portalPlans.includes('monthly'),
disabled: isDisabled,
label: 'Monthly',
value: 'monthly',
onChange: () => {
togglePlan('monthly');
}
},
{
checked: portalPlans.includes('yearly'),
disabled: isDisabled,
label: 'Yearly',
value: 'yearly',
onChange: () => {
togglePlan('yearly');
}
}
]}
title='Prices available at signup'
/>
)}
<div>TODO: Display notice at signup (Koenig)</div>
<div className='red text-sm'>TODO: Display notice at signup (Koenig)</div>
<Toggle
checked={Boolean(portalSignupCheckboxRequired)}
disabled={isDisabled}
label='Require agreement'
labelStyle='heading'
onChange={e => updateSetting('portal_signup_checkbox_required', e.target.checked)}
/>
</>;
</Form>;
};
export default SignupOptions;