0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-04-07 23:01:25 -05:00

refactor(console): refactor tenant creation form (#4826)

This commit is contained in:
Xiao Yijun 2023-11-08 12:46:59 +08:00 committed by GitHub
parent 7b19837ffc
commit a3de4fa6bb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 226 additions and 30 deletions

View file

@ -7,6 +7,7 @@ import TenantLandingPageImageDark from '@/assets/images/tenant-landing-page-dark
import TenantLandingPageImage from '@/assets/images/tenant-landing-page.svg';
import { type TenantResponse } from '@/cloud/types/router';
import CreateTenantModal from '@/components/CreateTenantModal';
import { isDevFeaturesEnabled } from '@/consts/env';
import { TenantsContext } from '@/contexts/TenantsProvider';
import Button from '@/ds-components/Button';
import DynamicT from '@/ds-components/DynamicT';
@ -52,7 +53,7 @@ function TenantLandingPageContent({ className }: Props) {
/>
</div>
<CreateTenantModal
skipPlanSelection
skipPlanSelection={!isDevFeaturesEnabled}
isOpen={isCreateModalOpen}
onClose={async (tenant?: TenantResponse) => {
if (tenant) {

View file

@ -0,0 +1,25 @@
@use '@/scss/underscore' as _;
.container {
display: flex;
flex-direction: column;
gap: _.unit(3);
padding: _.unit(2.5) _.unit(3);
}
.tag {
align-self: flex-start;
}
.description {
font: var(--font-body-2);
color: var(--color-text-secondary);
}
.hint {
font: var(--font-body-3);
}
.planNameTag {
margin-left: _.unit(1);
}

View file

@ -0,0 +1,61 @@
import { type AdminConsoleKey } from '@logto/phrases';
import { TenantTag } from '@logto/schemas/lib/models/tenants.js';
import TenantEnvTag from '@/components/TenantEnvTag';
import Divider from '@/ds-components/Divider';
import DynamicT from '@/ds-components/DynamicT';
import Tag from '@/ds-components/Tag';
import { ReservedPlanName } from '@/types/subscriptions';
import * as styles from './index.module.scss';
type Props = {
tag: TenantTag;
};
const descriptionMap: Record<TenantTag, AdminConsoleKey> = {
[TenantTag.Development]: 'tenants.create_modal.development_description',
[TenantTag.Production]: 'tenants.create_modal.production_description',
// Todo @xiaoyijun Remove deprecated tag
[TenantTag.Staging]: 'tenants.create_modal.production_description',
};
const availableProductionPlanNames = [
ReservedPlanName.Free,
ReservedPlanName.Hobby,
ReservedPlanName.Pro,
];
function EnvTagOptionContent({ tag }: Props) {
// Todo @xiaoyijun Deprecated tag
if (tag === TenantTag.Staging) {
return null;
}
return (
<div className={styles.container}>
<TenantEnvTag isAbbreviated={false} tag={tag} size="large" className={styles.tag} />
<div className={styles.description}>
<DynamicT forKey={descriptionMap[tag]} />
</div>
<Divider />
<div className={styles.hint}>
{tag === TenantTag.Development && (
<DynamicT forKey="tenants.create_modal.development_hint" />
)}
{tag === TenantTag.Production && (
<>
<DynamicT forKey="tenants.create_modal.available_plan" />
{availableProductionPlanNames.map((planName) => (
<Tag key={planName} variant="cell" size="small" className={styles.planNameTag}>
{planName}
</Tag>
))}
</>
)}
</div>
</div>
);
}
export default EnvTagOptionContent;

View file

@ -6,3 +6,18 @@
margin-top: _.unit(0.5);
}
.regionOptions {
font: var(--font-label-2);
.comingSoon {
margin-left: _.unit(1);
font: var(--font-body-2);
color: var(--color-text-secondary);
}
}
.envTagRadioGroup {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: _.unit(4);
}

View file

@ -1,7 +1,6 @@
import type { AdminConsoleKey } from '@logto/phrases';
import { Theme } from '@logto/schemas';
import { TenantTag } from '@logto/schemas/models';
import { trySafe } from '@silverhand/essentials';
import { useState } from 'react';
import { Controller, FormProvider, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
@ -11,7 +10,9 @@ import CreateTenantHeaderIconDark from '@/assets/icons/create-tenant-header-dark
import CreateTenantHeaderIcon from '@/assets/icons/create-tenant-header.svg';
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
import { type TenantResponse } from '@/cloud/types/router';
import { isDevFeaturesEnabled } from '@/consts/env';
import Button from '@/ds-components/Button';
import DangerousRaw from '@/ds-components/DangerousRaw';
import FormField from '@/ds-components/FormField';
import ModalLayout from '@/ds-components/ModalLayout';
import RadioGroup, { Radio } from '@/ds-components/RadioGroup';
@ -19,6 +20,7 @@ import TextInput from '@/ds-components/TextInput';
import useTheme from '@/hooks/use-theme';
import * as modalStyles from '@/scss/modal.module.scss';
import EnvTagOptionContent from './EnvTagOptionContent';
import SelectTenantPlanModal from './SelectTenantPlanModal';
import * as styles from './index.module.scss';
import { type CreateTenantData } from './type';
@ -26,10 +28,12 @@ import { type CreateTenantData } from './type';
type Props = {
isOpen: boolean;
onClose: (tenant?: TenantResponse) => void;
// Todo @xiaoyijun delete this prop when dev tenant feature is ready
// eslint-disable-next-line react/boolean-prop-naming
skipPlanSelection?: boolean;
};
// Todo @xiaoyijun remove when dev tenant feature is ready
const tagOptions: Array<{ title: AdminConsoleKey; value: TenantTag }> = [
{
title: 'tenants.settings.environment_tag_development',
@ -45,6 +49,8 @@ const tagOptions: Array<{ title: AdminConsoleKey; value: TenantTag }> = [
},
];
const availableTags = [TenantTag.Development, TenantTag.Production];
function CreateTenantModal({ isOpen, onClose, skipPlanSelection = false }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const [tenantData, setTenantData] = useState<CreateTenantData>();
@ -66,18 +72,37 @@ function CreateTenantModal({ isOpen, onClose, skipPlanSelection = false }: Props
const cloudApi = useCloudApi();
const createTenant = async (data: CreateTenantData) => {
void trySafe(async () => {
const { name, tag } = data;
const newTenant = await cloudApi.post('/api/tenants', { body: { name, tag } });
onClose(newTenant);
});
const { name, tag } = data;
const newTenant = await cloudApi.post('/api/tenants', { body: { name, tag } });
onClose(newTenant);
};
/**
* Note: create tenant directly if it's from landing page,
* since we want the user to get into the console as soon as possible
*/
const onCreateClick = handleSubmit(skipPlanSelection ? createTenant : setTenantData);
const onCreateClick = handleSubmit(async (data: CreateTenantData) => {
/**
* Todo @xiaoyijun remove the original logic when dev tenant feature is ready
*/
if (!isDevFeaturesEnabled) {
/**
* Note: create tenant directly if it's from landing page,
* since we want the user to get into the console as soon as possible
*/
if (skipPlanSelection) {
await createTenant(data);
return;
}
setTenantData(data);
return;
}
const { tag } = data;
if (tag === TenantTag.Development) {
await createTenant(data);
return;
}
setTenantData(data);
});
return (
<Modal
@ -109,6 +134,7 @@ function CreateTenantModal({ isOpen, onClose, skipPlanSelection = false }: Props
onClick={onCreateClick}
/>
}
size="large"
onClose={onClose}
>
<FormProvider {...methods}>
@ -120,23 +146,75 @@ function CreateTenantModal({ isOpen, onClose, skipPlanSelection = false }: Props
error={Boolean(errors.name)}
/>
</FormField>
<FormField title="tenants.settings.environment_tag">
<Controller
control={control}
name="tag"
rules={{ required: true }}
render={({ field: { onChange, value, name } }) => (
<RadioGroup type="small" value={value} name={name} onChange={onChange}>
{tagOptions.map(({ value: optionValue, title }) => (
<Radio key={optionValue} title={title} value={optionValue} />
))}
</RadioGroup>
)}
/>
<div className={styles.description}>
{t('tenants.settings.environment_tag_description')}
</div>
</FormField>
{isDevFeaturesEnabled && (
<FormField title="tenants.settings.tenant_region">
<RadioGroup type="small" value="eu" name="region">
<Radio
title={
<DangerousRaw>
<span className={styles.regionOptions}>🇪🇺 EU</span>
</DangerousRaw>
}
value="eu"
/>
<Radio
isDisabled
title={
<DangerousRaw>
<span className={styles.regionOptions}>
🇺🇸 US
<span className={styles.comingSoon}>{`(${t('general.coming_soon')})`}</span>
</span>
</DangerousRaw>
}
value="us"
/>
</RadioGroup>
</FormField>
)}
{!isDevFeaturesEnabled && (
<FormField title="tenants.settings.environment_tag">
<Controller
control={control}
name="tag"
rules={{ required: true }}
render={({ field: { onChange, value, name } }) => (
<RadioGroup type="small" value={value} name={name} onChange={onChange}>
{tagOptions.map(({ value: optionValue, title }) => (
<Radio key={optionValue} title={title} value={optionValue} />
))}
</RadioGroup>
)}
/>
<div className={styles.description}>
{t('tenants.settings.environment_tag_description')}
</div>
</FormField>
)}
{isDevFeaturesEnabled && (
<FormField title="tenants.create_modal.tenant_usage_purpose">
<Controller
control={control}
name="tag"
rules={{ required: true }}
render={({ field: { onChange, value, name } }) => (
<RadioGroup
type="card"
className={styles.envTagRadioGroup}
value={value}
name={name}
onChange={onChange}
>
{availableTags.map((tag) => (
<Radio key={tag} value={tag}>
<EnvTagOptionContent tag={tag} />
</Radio>
))}
</RadioGroup>
)}
/>
</FormField>
)}
</FormProvider>
<SelectTenantPlanModal
tenantData={tenantData}

View file

@ -275,6 +275,12 @@
outline: unset;
}
.small.disabled {
cursor: not-allowed;
color: var(--color-text);
background-color: var(--color-bg-state-disabled);
}
.compact.disabled {
cursor: not-allowed;
background-color: var(--color-layer-2);

View file

@ -78,4 +78,9 @@
}
}
}
&.small {
font: var(--font-label-3);
padding: _.unit(0.25) _.unit(1.5);
}
}

View file

@ -11,6 +11,7 @@ export type Props = Pick<HTMLProps<HTMLDivElement>, 'className' | 'onClick'> & {
type?: 'property' | 'state' | 'result';
status?: 'info' | 'success' | 'alert' | 'error';
variant?: 'plain' | 'outlined' | 'cell';
size?: 'medium' | 'small';
children: ReactNode;
};
@ -23,6 +24,7 @@ function Tag({
type = 'property',
status = 'info',
variant = 'outlined',
size = 'medium',
className,
children,
...rest
@ -30,7 +32,10 @@ function Tag({
const ResultIcon = conditional(type === 'result' && ResultIconMap[status]);
return (
<div className={classNames(styles.tag, styles[status], styles[variant], className)} {...rest}>
<div
className={classNames(styles.tag, styles[status], styles[variant], styles[size], className)}
{...rest}
>
{type === 'state' && <div className={styles.icon} />}
{ResultIcon && <ResultIcon className={classNames(styles.icon, styles.resultIcon)} />}
{children}