0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-06 20:40:08 -05:00

Merge pull request #324 from logto-io/gao-init-app-form

feat(console): init app create form
This commit is contained in:
Gao Sun 2022-03-08 14:42:33 +08:00 committed by GitHub
commit 00541dda78
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 478 additions and 27 deletions

View file

@ -31,6 +31,7 @@
"pnpm": ">=6"
},
"alias": {
"html-parse-stringify": "html-parse-stringify/dist/html-parse-stringify.module.js"
"html-parse-stringify": "html-parse-stringify/dist/html-parse-stringify.module.js",
"react-hook-form": "react-hook-form/dist/index.esm.mjs"
}
}

View file

@ -25,7 +25,9 @@
"lodash.kebabcase": "^4.1.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-hook-form": "^7.27.1",
"react-i18next": "^11.15.4",
"react-modal": "^3.14.4",
"react-router-dom": "^6.2.2",
"swr": "^1.2.2"
},
@ -39,6 +41,7 @@
"@types/lodash.kebabcase": "^4.1.6",
"@types/react": "^17.0.14",
"@types/react-dom": "^17.0.9",
"@types/react-modal": "^3.13.1",
"eslint": "^8.10.0",
"lint-staged": "^11.1.1",
"parcel": "^2.3.1",

View file

@ -1,9 +1,6 @@
.app {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
inset: 0;
display: flex;
flex-direction: column;
}
@ -34,8 +31,11 @@
--color-neutral-90: #e0e3e3;
--color-on-secondary-container: #201c00;
--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;
}
$font-family: 'SF UI Text', 'SF Pro Display', sans-serif;
@ -43,9 +43,11 @@ $font-family: 'SF UI Text', 'SF Pro Display', sans-serif;
.web {
--font-title-medium: 500 16px/24px #{$font-family};
--font-heading-small: 600 24px/32px #{$font-family};
--font-heading: 600 16px/24px #{$font-family};
--font-body: normal 16px/22px #{$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};
--font-button: 500 14px/20px #{$font-family};
--font-subhead-2: 500 14px/20px #{$font-family};
}

View file

@ -1,5 +1,4 @@
import classNames from 'classnames';
import React, { ReactNode } from 'react';
import React, { ReactNode, useEffect } from 'react';
import * as styles from './index.module.scss';
@ -11,7 +10,16 @@ type Props = {
};
const AppContent = ({ children, theme }: Props) => {
return <div className={classNames(styles.app, styles.web, styles[theme])}>{children}</div>;
useEffect(() => {
const classes = [styles.web, styles[theme]].filter((value): value is string => Boolean(value));
document.body.classList.add(...classes);
return () => {
document.body.classList.remove(...classes);
};
}, [theme]);
return <div className={styles.app}>{children}</div>;
};
export default AppContent;

View file

@ -1,13 +1,15 @@
import classNames from 'classnames';
import React, { ReactNode } from 'react';
import * as styles from './index.module.scss';
type Props = {
children: ReactNode;
className?: string;
};
const Card = ({ children }: Props) => {
return <div className={styles.card}>{children}</div>;
const Card = ({ children, className }: Props) => {
return <div className={classNames(styles.card, className)}>{children}</div>;
};
export default Card;

View file

@ -0,0 +1,21 @@
@use '@/scss/underscore' as _;
.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);
color: var(--color-component-caption);
}
}

View file

@ -0,0 +1,27 @@
import { I18nKey } from '@logto/phrases';
import React, { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import * as styles from './index.module.scss';
type Props = {
title: I18nKey;
children: ReactNode;
isRequired?: boolean;
};
const FormField = ({ title, children, isRequired }: Props) => {
const { t } = useTranslation();
return (
<>
<div className={styles.headline}>
<div className={styles.title}>{t(title)}</div>
{isRequired && <div className={styles.required}>{t('admin_console.form.required')}</div>}
</div>
{children}
</>
);
};
export default FormField;

View file

@ -0,0 +1,40 @@
import classNames from 'classnames';
import React, { ReactNode } from 'react';
import * as styles from './index.module.scss';
const Check = () => (
<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="1" width="18" height="18" rx="9" fill="#4F37F9" />
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8.31476 13.858L5.13295 10.441C4.95568 10.253 4.95568 9.947 5.13295 9.757L5.77568 9.074C5.95295 8.886 6.24113 8.886 6.4184 9.074L8.63657 11.466L13.5811 6.141C13.7584 5.953 14.0465 5.953 14.2238 6.141L14.8665 6.825C15.0438 7.013 15.0438 7.32 14.8665 7.507L8.95748 13.858C8.78021 14.046 8.49203 14.046 8.31476 13.858Z"
fill="white"
/>
</svg>
);
export type Props = {
value: string;
title: string;
name?: string;
children?: ReactNode;
isChecked?: boolean;
onClick?: () => void;
};
const Radio = ({ value, title, name, children, isChecked, onClick }: Props) => {
return (
<div className={classNames(styles.radio, isChecked && styles.checked)} onClick={onClick}>
<input readOnly type="radio" name={name} value={value} checked={isChecked} />
<div className={classNames(styles.headline, !children && styles.center)}>
<div className={styles.title}>{title}</div>
<Check />
</div>
{children}
</div>
);
};
export default Radio;

View file

@ -0,0 +1,68 @@
@use '@/scss/underscore' as _;
.radioGroup {
display: flex;
&:not(:first-child) {
margin-top: _.unit(3);
}
svg {
opacity: 0%;
}
> .radio {
position: relative;
flex: 1;
max-width: 220px;
padding: _.unit(5);
display: flex;
flex-direction: column;
border-radius: _.unit(4);
border: 1px solid var(--color-neutral-90);
outline: none;
user-select: none;
cursor: pointer;
&:not(:first-child) {
margin-left: _.unit(6);
}
&.checked {
border-color: var(--color-primary);
outline: 1px solid var(--color-primary);
svg {
opacity: 100%;
}
}
.headline {
flex: 1;
display: flex;
justify-content: space-between;
&.center {
align-items: center;
}
> *:not(:first-child) {
margin-left: _.unit(3);
}
}
.title {
font: var(--font-heading);
color: var(--color-component-text);
}
input[type='radio'] {
appearance: none;
position: absolute;
margin: 0;
width: 0;
height: 0;
}
}
}

View file

@ -0,0 +1,34 @@
import React, { Children, cloneElement, isValidElement, ReactNode } from 'react';
import Radio, { Props as RadioProps } from './Radio';
import * as styles from './index.module.scss';
type Props = {
name: string;
children: ReactNode;
value: string;
onChange?: (value: string) => void;
};
const RadioGroup = ({ name, children, value, onChange }: Props) => {
return (
<div className={styles.radioGroup}>
{Children.map(children, (child) => {
if (!isValidElement(child) || child.type !== Radio) {
return child;
}
return cloneElement<RadioProps>(child, {
name,
isChecked: value === child.props.value,
onClick: () => {
onChange?.(child.props.value);
},
});
})}
</div>
);
};
export default RadioGroup;
export { default as Radio } from './Radio';

View file

@ -0,0 +1,12 @@
import React, { SVGProps } from 'react';
const Close = (props: SVGProps<SVGSVGElement>) => (
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M7.05727 7.05703C7.57797 6.53633 8.42219 6.53633 8.94289 7.05703L16.0001 14.1142L23.0573 7.05703C23.578 6.53633 24.4222 6.53633 24.9429 7.05703C25.4636 7.57773 25.4636 8.42195 24.9429 8.94265L17.8857 15.9998L24.9429 23.057C25.4636 23.5777 25.4636 24.4219 24.9429 24.9426C24.4222 25.4633 23.578 25.4633 23.0573 24.9426L16.0001 17.8855L8.94289 24.9426C8.42219 25.4633 7.57797 25.4633 7.05727 24.9426C6.53657 24.4219 6.53657 23.5777 7.05727 23.057L14.1145 15.9998L7.05727 8.94265C6.53657 8.42195 6.53657 7.57773 7.05727 7.05703Z"
fill="#333333"
/>
</svg>
);
export default Close;

View file

@ -1,7 +1,9 @@
import React from 'react';
import ReactDOM from 'react-dom';
import ReactModal from 'react-modal';
import App from './App';
const app = document.querySelector('#app');
ReactModal.setAppElement('#app');
ReactDOM.render(<App />, app);

View file

@ -0,0 +1,22 @@
@use '@/scss/underscore' as _;
.card {
padding: _.unit(8);
}
.headline {
display: flex;
justify-content: space-between;
> *:not(:first-child) {
margin-left: _.unit(3);
}
> svg {
cursor: pointer;
}
}
.form {
margin-top: _.unit(8);
}

View file

@ -0,0 +1,62 @@
import { ApplicationType } from '@logto/schemas';
import React from 'react';
import { useController, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import Card from '@/components/Card';
import CardTitle from '@/components/CardTitle';
import FormField from '@/components/FormField';
import RadioGroup, { Radio } from '@/components/RadioGroup';
import Close from '@/icons/Close';
import { applicationTypeI18nKey } from '@/types/applications';
import TypeDescription from '../TypeDescription';
import * as styles from './index.module.scss';
type FormData = {
type: ApplicationType;
name: string;
description?: string;
};
type Props = {
onClose?: () => void;
};
const CreateForm = ({ onClose }: Props) => {
const { handleSubmit, control } = useForm<FormData>();
const {
field: { onChange, value },
} = useController({ name: 'type', control });
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const onSubmit = handleSubmit((data) => {
console.log(data);
});
return (
<Card className={styles.card}>
<div className={styles.headline}>
<CardTitle title="applications.create" subtitle="applications.subtitle" />
<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}>
{Object.values(ApplicationType).map((value) => (
<Radio key={value} title={t(`${applicationTypeI18nKey[value]}.title`)} value={value}>
<TypeDescription
subtitle={t(`${applicationTypeI18nKey[value]}.subtitle`)}
description={t(`${applicationTypeI18nKey[value]}.description`)}
/>
</Radio>
))}
</RadioGroup>
</FormField>
<button type="submit">Submit</button>
</form>
</Card>
);
};
export default CreateForm;

View file

@ -0,0 +1,17 @@
@use '@/scss/underscore' as _;
.subtitle,
.description {
margin-top: _.unit(3);
flex: 2;
}
.subtitle {
font: var(--font-body);
color: var(--color-text-default);
}
.description {
font: var(--font-caption);
color: var(--color-foggy);
}

View file

@ -0,0 +1,19 @@
import React from 'react';
import * as styles from './index.module.scss';
type Props = {
subtitle: string;
description: string;
};
const TypeDescription = ({ subtitle, description }: Props) => {
return (
<>
<div className={styles.subtitle}>{subtitle}</div>
<div className={styles.description}>{description}</div>
</>
);
};
export default TypeDescription;

View file

@ -1,6 +1,7 @@
import { Application } from '@logto/schemas';
import React from 'react';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import Modal from 'react-modal';
import useSWR from 'swr';
import Button from '@/components/Button';
@ -9,12 +10,15 @@ import CardTitle from '@/components/CardTitle';
import CopyToClipboard from '@/components/CopyToClipboard';
import ImagePlaceholder from '@/components/ImagePlaceholder';
import ItemPreview from '@/components/ItemPreview';
import * as modalStyles from '@/scss/modal.module.scss';
import { RequestError } from '@/swr';
import { applicationTypeI18nKey } from '@/types/applications';
import CreateForm from './components/CreateForm';
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 isLoading = !data && !error;
@ -23,7 +27,23 @@ const Applications = () => {
<Card>
<div className={styles.headline}>
<CardTitle title="applications.title" subtitle="applications.subtitle" />
<Button disabled title="admin_console.applications.create" />
<Button
title="admin_console.applications.create"
onClick={() => {
setIsCreateFormOpen(true);
}}
/>
<Modal
isOpen={isCreateFormOpen}
className={modalStyles.content}
overlayClassName={modalStyles.overlay}
>
<CreateForm
onClose={() => {
setIsCreateFormOpen(false);
}}
/>
</Modal>
</div>
<table className={styles.table}>
<thead>
@ -48,7 +68,7 @@ const Applications = () => {
<td>
<ItemPreview
title={name}
subtitle={String(t(applicationTypeI18nKey[type]))}
subtitle={t(`${applicationTypeI18nKey[type]}.title`)}
icon={<ImagePlaceholder />}
/>
</td>

View file

@ -0,0 +1,13 @@
.content {
position: absolute;
left: 50%;
top: 50%;
transform: translateX(-50%, -50%);
outline: none;
}
.overlay {
position: fixed;
background: rgba(0, 0, 0, 40%);
inset: 0;
}

View file

@ -1,8 +1,7 @@
import { AdminConsoleKey } from '@logto/phrases';
import { ApplicationType } from '@logto/schemas';
export const applicationTypeI18nKey: Record<ApplicationType, AdminConsoleKey> = {
export const applicationTypeI18nKey = Object.freeze({
[ApplicationType.Native]: 'applications.type.native',
[ApplicationType.SPA]: 'applications.type.spa',
[ApplicationType.Traditional]: 'applications.type.tranditional',
};
[ApplicationType.Traditional]: 'applications.type.traditional',
} as const);

View file

@ -19,6 +19,9 @@ const translation = {
copying: 'Copying',
copied: 'Copied',
},
form: {
required: 'Required',
},
tab_sections: {
overview: 'Overview',
resource_management: 'Resource Management',
@ -44,11 +47,24 @@ 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',
client_id: 'Client ID',
type: {
native: 'Native App',
spa: 'Single Page App',
tranditional: 'Tranditional Web App',
native: {
title: 'Native',
subtitle: 'Mobile, desktop, CLI and smart device apps running natively.',
description: 'E.g.: iOS, Electron, Apple TV apps',
},
spa: {
title: 'Single Page App',
subtitle: 'A JavaScript front-end app that uses an API.',
description: 'E.g.: Angular, React, Vue',
},
traditional: {
title: 'Tranditional Web',
subtitle: 'Traditional web app using redirects.',
description: 'E.g.: Node.js, Express, ASP.NET, Java, PHP',
},
},
},
api_resources: {

View file

@ -21,6 +21,9 @@ const translation = {
copying: '拷贝中',
copied: '已拷贝',
},
form: {
required: '必填',
},
tab_sections: {
overview: '概览',
resource_management: '资源管理',
@ -46,11 +49,24 @@ 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',
client_id: 'Client ID',
type: {
native: 'Native App',
spa: 'Single Page App',
tranditional: 'Tranditional Web App',
native: {
title: 'Native',
subtitle: 'Mobile, desktop, CLI and smart device apps running natively.',
description: 'E.g.: iOS, Electron, Apple TV apps',
},
spa: {
title: 'Single Page App',
subtitle: 'A JavaScript front-end app that uses an API.',
description: 'E.g.: Angular, React, Vue',
},
traditional: {
title: 'Tranditional Web',
subtitle: 'Traditional web app using redirects.',
description: 'E.g.: Node.js, Express, ASP.NET, Java, PHP',
},
},
},
api_resources: {

View file

@ -1,9 +1,6 @@
.content {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
inset: 0;
background: var(--color-background);
color: var(--color-body);
}

View file

@ -32,6 +32,7 @@ importers:
'@types/lodash.kebabcase': ^4.1.6
'@types/react': ^17.0.14
'@types/react-dom': ^17.0.9
'@types/react-modal': ^3.13.1
classnames: ^2.3.1
eslint: ^8.10.0
i18next: ^21.6.12
@ -44,7 +45,9 @@ importers:
prettier: ^2.3.2
react: ^17.0.2
react-dom: ^17.0.2
react-hook-form: ^7.27.1
react-i18next: ^11.15.4
react-modal: ^3.14.4
react-router-dom: ^6.2.2
stylelint: ^13.13.1
swr: ^1.2.2
@ -59,7 +62,9 @@ importers:
lodash.kebabcase: 4.1.1
react: 17.0.2
react-dom: 17.0.2_react@17.0.2
react-hook-form: 7.27.1_react@17.0.2
react-i18next: 11.15.4_2c37a602a29bb6bd53f3de707a8cfcc5
react-modal: 3.14.4_react-dom@17.0.2+react@17.0.2
react-router-dom: 6.2.2_react-dom@17.0.2+react@17.0.2
swr: 1.2.2_react@17.0.2
devDependencies:
@ -72,6 +77,7 @@ importers:
'@types/lodash.kebabcase': 4.1.6
'@types/react': 17.0.37
'@types/react-dom': 17.0.11
'@types/react-modal': 3.13.1
eslint: 8.10.0
lint-staged: 11.2.6
parcel: 2.3.1_postcss@8.4.6
@ -3566,6 +3572,12 @@ packages:
'@types/react': 17.0.37
dev: true
/@types/react-modal/3.13.1:
resolution: {integrity: sha512-iY/gPvTDIy6Z+37l+ibmrY+GTV4KQTHcCyR5FIytm182RQS69G5ps4PH2FxtC7bAQ2QRHXMevsBgck7IQruHNg==}
dependencies:
'@types/react': 17.0.37
dev: true
/@types/react-router-dom/5.3.2:
resolution: {integrity: sha512-ELEYRUie2czuJzaZ5+ziIp9Hhw+juEw8b7C11YNA4QdLCVbQ3qLi2l4aq8XnlqM7V31LZX8dxUuFUCrzHm6sqQ==}
dependencies:
@ -6004,6 +6016,10 @@ packages:
clone-regexp: 2.2.0
dev: true
/exenv/1.2.2:
resolution: {integrity: sha1-KueOhdmJQVhnCwPUe+wfA72Ru50=}
dev: false
/exit/0.1.2:
resolution: {integrity: sha1-BjJjj42HfMghB9MKD/8aF8uhzQw=}
engines: {node: '>= 0.8.0'}
@ -11202,6 +11218,15 @@ packages:
scheduler: 0.20.2
dev: false
/react-hook-form/7.27.1_react@17.0.2:
resolution: {integrity: sha512-N3a7A6zIQ8DJeThisVZGtOUabTbJw+7DHJidmB9w8m3chckv2ZWKb5MHps9d2pPJqmCDoWe53Bos56bYmJms5w==}
engines: {node: '>=12.22.0'}
peerDependencies:
react: ^16.8.0 || ^17
dependencies:
react: 17.0.2
dev: false
/react-i18next/11.15.4_2c37a602a29bb6bd53f3de707a8cfcc5:
resolution: {integrity: sha512-jKJNAcVcbPGK+yrTcXhLblgPY16n6NbpZZL3Mk8nswj1v3ayIiUBVDU09SgqnT+DluyQBS97hwSvPU5yVFG0yg==}
peerDependencies:
@ -11251,6 +11276,25 @@ packages:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
dev: true
/react-lifecycles-compat/3.0.4:
resolution: {integrity: sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==}
dev: false
/react-modal/3.14.4_react-dom@17.0.2+react@17.0.2:
resolution: {integrity: sha512-8surmulejafYCH9wfUmFyj4UfbSJwjcgbS9gf3oOItu4Hwd6ivJyVBETI0yHRhpJKCLZMUtnhzk76wXTsNL6Qg==}
engines: {node: '>=8'}
peerDependencies:
react: ^0.14.0 || ^15.0.0 || ^16 || ^17
react-dom: ^0.14.0 || ^15.0.0 || ^16 || ^17
dependencies:
exenv: 1.2.2
prop-types: 15.8.1
react: 17.0.2
react-dom: 17.0.2_react@17.0.2
react-lifecycles-compat: 3.0.4
warning: 4.0.3
dev: false
/react-refresh/0.9.0:
resolution: {integrity: sha512-Gvzk7OZpiqKSkxsQvO/mbTN1poglhmAV7gR/DdIrRrSMXraRQQlfikRJOr3Nb9GTMPC5kof948Zy6jJZIFtDvQ==}
engines: {node: '>=0.10.0'}
@ -13245,6 +13289,12 @@ packages:
makeerror: 1.0.12
dev: true
/warning/4.0.3:
resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==}
dependencies:
loose-envify: 1.4.0
dev: false
/wcwidth/1.0.1:
resolution: {integrity: sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g=}
dependencies: