mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-24 11:22:19 +03:00
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:
parent
80e65d4978
commit
47a9eaadcc
@ -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>
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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'
|
||||
|
@ -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'
|
||||
}
|
||||
};
|
@ -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;
|
@ -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'
|
||||
}
|
||||
};
|
52
apps/admin-x-settings/src/admin-x-ds/global/form/Form.tsx
Normal file
52
apps/admin-x-settings/src/admin-x-ds/global/form/Form.tsx
Normal 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;
|
@ -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',
|
||||
|
@ -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>
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user