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:
parent
71e5da6216
commit
0cc32cadf3
17 changed files with 153 additions and 28 deletions
|
@ -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"
|
||||
|
|
|
@ -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};
|
||||
|
|
|
@ -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)}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -26,7 +26,7 @@
|
|||
cursor: pointer;
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-left: _.unit(6);
|
||||
margin-left: _.unit(8);
|
||||
}
|
||||
|
||||
&.checked {
|
||||
|
|
|
@ -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;
|
||||
|
|
36
packages/console/src/components/TextInput/index.module.scss
Normal file
36
packages/console/src/components/TextInput/index.module.scss
Normal 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);
|
||||
}
|
||||
}
|
23
packages/console/src/components/TextInput/index.tsx
Normal file
23
packages/console/src/components/TextInput/index.tsx
Normal 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;
|
|
@ -20,3 +20,12 @@
|
|||
.form {
|
||||
margin-top: _.unit(8);
|
||||
}
|
||||
|
||||
.textField {
|
||||
max-width: 556px;
|
||||
}
|
||||
|
||||
.submit {
|
||||
margin-top: _.unit(8);
|
||||
text-align: right;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
}
|
||||
|
||||
.subtitle {
|
||||
font: var(--font-body);
|
||||
font: var(--font-body-2);
|
||||
color: var(--color-text-default);
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translateX(-50%, -50%);
|
||||
transform: translate(-50%, -50%);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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
7
pnpm-lock.yaml
generated
|
@ -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'}
|
||||
|
|
Loading…
Add table
Reference in a new issue