0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-31 22:51:25 -05:00

feat(console): app create form (#325)

* feat(console): app type description

* refactor(console): remove unused code

* feat(console): complete app create form inputs

* feat(console): create app form submit

* fix(console): update per review
This commit is contained in:
Gao Sun 2022-03-08 15:13:45 +08:00 committed by GitHub
parent 71e5da6216
commit 0cc32cadf3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 153 additions and 28 deletions

View file

@ -22,6 +22,7 @@
"classnames": "^2.3.1",
"i18next": "^21.6.12",
"i18next-browser-languagedetector": "^6.1.3",
"ky": "^0.30.0",
"lodash.kebabcase": "^4.1.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",
@ -70,7 +71,10 @@
]
}
}
]
],
"rules": {
"react/button-has-type": "off"
}
},
"stylelint": {
"extends": "@silverhand/eslint-config-react/.stylelintrc"

View file

@ -7,6 +7,7 @@
.light {
--color-card-background: #fff;
--color-surface: #fbfdfd;
--color-on-surface: #eff1f1;
--color-on-surface-variant: #47464e;
--color-primary: #4f37f9;
@ -31,12 +32,14 @@
--color-neutral-70: #a9acac;
--color-neutral-90: #e0e3e3;
--color-on-secondary-container: #201c00;
--color-component-border: #c4c7c7;
--color-component-caption: #747778;
--color-component-text: #191c1d;
--color-outline: #78767f;
--color-table-row-selected: rgba(0, 0, 0, 2%);
--color-text-default: #202223;
--color-foggy: #888;
--color-disabled: #c4c7c7;
}
$font-family: 'SF UI Text', 'SF Pro Display', sans-serif;
@ -47,6 +50,7 @@ $font-family: 'SF UI Text', 'SF Pro Display', sans-serif;
--font-heading-small: 600 24px/32px #{$font-family};
--font-heading: 600 16px/24px #{$font-family};
--font-body: normal 16px/22px #{$font-family};
--font-body-2: normal 14px/20px #{$font-family};
--font-small-text: normal 12px/16px #{$font-family};
--font-caption: normal 14px/20px #{$font-family};
--font-caption-bold: 600 14px/20px #{$font-family};

View file

@ -6,18 +6,25 @@ import { useTranslation } from 'react-i18next';
import * as styles from './index.module.scss';
type Props = Omit<HTMLProps<HTMLButtonElement>, 'type' | 'size' | 'title'> & {
htmlType?: 'button' | 'submit' | 'reset';
title: I18nKey;
type?: 'primary' | 'danger';
size?: 'small' | 'medium' | 'large';
};
const Button = ({ type = 'primary', size = 'medium', title, ...rest }: Props) => {
const Button = ({
htmlType = 'button',
type = 'primary',
size = 'medium',
title,
...rest
}: Props) => {
const { t } = useTranslation();
return (
<button
className={classNames(styles.button, styles[type], styles[size])}
type="button"
type={htmlType}
{...rest}
>
{t(title)}

View file

@ -1,21 +1,23 @@
@use '@/scss/underscore' as _;
.field {
&:not(:first-child) {
margin-top: _.unit(6);
}
}
.headline {
display: flex;
justify-content: space-between;
margin-bottom: _.unit(1);
&:not(:first-child) {
margin-top: _.unit(6);
}
.title {
font: var(--font-caption-bold);
color: var(--color-component-text);
}
.required {
font: var(--font-body);
font: var(--font-body-2);
color: var(--color-component-caption);
}
}

View file

@ -1,4 +1,5 @@
import { I18nKey } from '@logto/phrases';
import classNames from 'classnames';
import React, { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
@ -8,19 +9,20 @@ type Props = {
title: I18nKey;
children: ReactNode;
isRequired?: boolean;
className?: string;
};
const FormField = ({ title, children, isRequired }: Props) => {
const FormField = ({ title, children, isRequired, className }: Props) => {
const { t } = useTranslation();
return (
<>
<div className={classNames(styles.field, className)}>
<div className={styles.headline}>
<div className={styles.title}>{t(title)}</div>
{isRequired && <div className={styles.required}>{t('admin_console.form.required')}</div>}
</div>
{children}
</>
</div>
);
};

View file

@ -26,7 +26,7 @@
cursor: pointer;
&:not(:first-child) {
margin-left: _.unit(6);
margin-left: _.unit(8);
}
&.checked {

View file

@ -12,7 +12,7 @@ type Props = {
const RadioGroup = ({ name, children, value, onChange }: Props) => {
return (
<div className={styles.radioGroup}>
<div className={styles.radioGroup} tabIndex={0}>
{Children.map(children, (child) => {
if (!isValidElement(child) || child.type !== Radio) {
return child;

View file

@ -0,0 +1,36 @@
@use '@/scss/underscore' as _;
.container {
input {
width: 100%;
appearance: none;
padding: _.unit(2) _.unit(3);
color: var(--color-component-text);
border-radius: 6px;
border: 1px solid var(--color-component-border);
outline: 2px solid transparent;
font: var(--font-body-2);
transition-property: outline, border;
transition-timing-function: ease-in-out;
transition-duration: 0.2s;
&::placeholder {
color: var(--color-component-caption);
}
&:focus {
border-color: var(--color-primary);
outline-color: var(--color-primary-80);
}
&:disabled {
background: var(--color-surface);
color: var(--color-disabled);
border-color: var(--color-disabled);
}
}
&.error input {
border-color: var(--color-error);
}
}

View file

@ -0,0 +1,23 @@
import classNames from 'classnames';
import React, { forwardRef, HTMLProps } from 'react';
import * as styles from './index.module.scss';
// https://github.com/yannickcr/eslint-plugin-react/issues/2856
/* eslint-disable react/require-default-props */
type Props = HTMLProps<HTMLInputElement> & {
hasError?: boolean;
};
/* eslint-enable react/require-default-props */
const TextInput = forwardRef<HTMLInputElement, Props>(
({ hasError = false, ...rest }, reference) => {
return (
<div className={classNames(styles.container, hasError && styles.error)}>
<input type="text" {...rest} ref={reference} />
</div>
);
}
);
export default TextInput;

View file

@ -20,3 +20,12 @@
.form {
margin-top: _.unit(8);
}
.textField {
max-width: 556px;
}
.submit {
margin-top: _.unit(8);
text-align: right;
}

View file

@ -1,12 +1,15 @@
import { ApplicationType } from '@logto/schemas';
import { Application, ApplicationType } from '@logto/schemas';
import ky from 'ky';
import React from 'react';
import { useController, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import Button from '@/components/Button';
import Card from '@/components/Card';
import CardTitle from '@/components/CardTitle';
import FormField from '@/components/FormField';
import RadioGroup, { Radio } from '@/components/RadioGroup';
import TextInput from '@/components/TextInput';
import Close from '@/icons/Close';
import { applicationTypeI18nKey } from '@/types/applications';
@ -20,29 +23,35 @@ type FormData = {
};
type Props = {
onClose?: () => void;
onClose?: (createdApp?: Application) => void;
};
const CreateForm = ({ onClose }: Props) => {
const { handleSubmit, control } = useForm<FormData>();
const { handleSubmit, control, register } = useForm<FormData>();
const {
field: { onChange, value },
} = useController({ name: 'type', control });
field: { onChange, value, name },
} = useController({ name: 'type', control, rules: { required: true } });
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const onSubmit = handleSubmit((data) => {
console.log(data);
const onSubmit = handleSubmit(async (data) => {
try {
const createdApp = await ky.post('/api/applications', { json: data }).json<Application>();
onClose?.(createdApp);
} catch (error: unknown) {
console.error(error);
}
});
return (
<Card className={styles.card}>
<div className={styles.headline}>
<CardTitle title="applications.create" subtitle="applications.subtitle" />
<Close onClick={onClose} />
<Close onClick={() => onClose?.()} />
</div>
<form className={styles.form} onSubmit={onSubmit}>
<FormField title="admin_console.applications.select_application_type">
<RadioGroup name="application_type" value={value} onChange={onChange}>
<RadioGroup name={name} value={value} onChange={onChange}>
{Object.values(ApplicationType).map((value) => (
<Radio key={value} title={t(`${applicationTypeI18nKey[value]}.title`)} value={value}>
<TypeDescription
@ -53,7 +62,22 @@ const CreateForm = ({ onClose }: Props) => {
))}
</RadioGroup>
</FormField>
<button type="submit">Submit</button>
<FormField
isRequired
title="admin_console.applications.application_name"
className={styles.textField}
>
<TextInput {...register('name', { required: true })} />
</FormField>
<FormField
title="admin_console.applications.application_description"
className={styles.textField}
>
<TextInput />
</FormField>
<div className={styles.submit} {...register('description')}>
<Button htmlType="submit" title="admin_console.applications.create" size="large" />
</div>
</form>
</Card>
);

View file

@ -7,7 +7,7 @@
}
.subtitle {
font: var(--font-body);
font: var(--font-body-2);
color: var(--color-text-default);
}

View file

@ -1,4 +1,5 @@
import { Application } from '@logto/schemas';
import { conditional } from '@silverhand/essentials/lib/utilities/conditional.js';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import Modal from 'react-modal';
@ -20,7 +21,7 @@ import * as styles from './index.module.scss';
const Applications = () => {
const [isCreateFormOpen, setIsCreateFormOpen] = useState(false);
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { data, error } = useSWR<Application[], RequestError>('/api/applications');
const { data, error, mutate } = useSWR<Application[], RequestError>('/api/applications');
const isLoading = !data && !error;
return (
@ -39,8 +40,12 @@ const Applications = () => {
overlayClassName={modalStyles.overlay}
>
<CreateForm
onClose={() => {
onClose={(createdApp) => {
setIsCreateFormOpen(false);
if (createdApp) {
void mutate(conditional(data && [...data, createdApp]));
}
}}
/>
</Modal>

View file

@ -2,7 +2,7 @@
position: absolute;
left: 50%;
top: 50%;
transform: translateX(-50%, -50%);
transform: translate(-50%, -50%);
outline: none;
}

View file

@ -50,7 +50,8 @@ const translation = {
'Setup a mobile, single page or traditional application to use Logto for authentication.',
create: 'Create Application',
application_name: 'Application Name',
select_application_type: 'Select an application type',
application_description: 'Application Description',
select_application_type: 'Select an Application Type',
client_id: 'Client ID',
type: {
native: {

View file

@ -52,6 +52,7 @@ const translation = {
'Setup a mobile, single page or traditional application to use Logto for authentication.',
create: 'Create Application',
application_name: 'Application Name',
application_description: 'Application Description',
select_application_type: 'Select an application type',
client_id: 'Client ID',
type: {

7
pnpm-lock.yaml generated
View file

@ -37,6 +37,7 @@ importers:
eslint: ^8.10.0
i18next: ^21.6.12
i18next-browser-languagedetector: ^6.1.3
ky: ^0.30.0
lint-staged: ^11.1.1
lodash.kebabcase: ^4.1.1
parcel: ^2.3.1
@ -59,6 +60,7 @@ importers:
classnames: 2.3.1
i18next: 21.6.12
i18next-browser-languagedetector: 6.1.3
ky: 0.30.0
lodash.kebabcase: 4.1.1
react: 17.0.2
react-dom: 17.0.2_react@17.0.2
@ -8770,6 +8772,11 @@ packages:
engines: {node: '>=12'}
dev: false
/ky/0.30.0:
resolution: {integrity: sha512-X/u76z4JtDVq10u1JA5UQfatPxgPaVDMYTrgHyiTpGN2z4TMEJkIHsoSBBSg9SWZEIXTKsi9kHgiQ9o3Y/4yog==}
engines: {node: '>=12'}
dev: false
/lerna/4.0.0:
resolution: {integrity: sha512-DD/i1znurfOmNJb0OBw66NmNqiM8kF6uIrzrJ0wGE3VNdzeOhz9ziWLYiRaZDGGwgbcjOo6eIfcx9O5Qynz+kg==}
engines: {node: '>= 10.18.0'}