0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

refactor(console): refactor table component (#2738)

This commit is contained in:
Xiao Yijun 2022-12-29 21:59:30 +08:00 committed by GitHub
parent 8592d22a38
commit b403f19558
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 369 additions and 309 deletions

View file

@ -1,27 +0,0 @@
@use '@/scss/underscore' as _;
.container {
background-color: var(--color-layer-1);
border-radius: 12px;
overflow: hidden;
display: flex;
flex-direction: column;
table {
border: none;
}
.bodyTable {
flex: 1;
overflow-y: scroll;
padding-bottom: _.unit(2);
tr {
&:last-child {
td {
border-bottom: unset;
}
}
}
}
}

View file

@ -1,40 +0,0 @@
import { assert } from '@silverhand/essentials';
import classNames from 'classnames';
import type { ReactElement } from 'react';
import * as styles from './StickyHeaderTable.module.scss';
type Props = {
header: ReactElement<HTMLTableSectionElement>;
colGroup?: ReactElement<HTMLTableColElement>;
className?: string;
children: ReactElement<HTMLTableSectionElement>;
};
const StickyHeaderTable = ({ header, colGroup, className, children: body }: Props) => {
assert(header.props.tagName !== 'THEAD', new Error('Expected <thead> for the `header` prop'));
assert(
colGroup?.props.tagName !== 'COLGROUP',
new Error('Expected <colgroup> for the `colGroup` prop')
);
assert(body.props.tagName !== 'TBODY', new Error('Expected <tbody> for the `children` prop'));
return (
<div className={classNames(styles.container, className)}>
<table>
{colGroup}
{header}
</table>
<div className={styles.bodyTable}>
<table>
{colGroup}
{body}
</table>
</div>
</div>
);
};
export default StickyHeaderTable;

View file

@ -0,0 +1,75 @@
@use '@/scss/underscore' as _;
.container {
overflow: hidden;
display: flex;
flex-direction: column;
table {
border: none;
}
.headerTable {
background-color: var(--color-layer-1);
border-radius: 12px 12px 0 0;
padding: 0 _.unit(3);
thead {
tr {
th {
font: var(--font-subhead-2);
color: var(--color-text);
border-bottom: unset;
padding: _.unit(3);
text-align: left;
}
}
}
}
.bodyTable {
overflow-y: auto;
padding: 0 _.unit(3) _.unit(3);
background-color: var(--color-layer-1);
border-radius: 0 0 12px 12px;
table {
tbody {
tr {
td {
font: var(--font-body-medium);
border-top: 1px solid var(--color-divider);
border-bottom: unset;
padding: _.unit(3);
}
&.clickable {
cursor: pointer;
&:hover {
background: var(--color-hover);
td {
border-top: 1px solid transparent;
}
+ tr {
td {
border-top: 1px solid transparent;
}
}
td:first-child {
border-radius: 8px 0 0 8px;
}
td:last-child {
border-radius: 0 8px 8px 0;
}
}
}
}
}
}
}
}

View file

@ -0,0 +1,107 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
import { Fragment } from 'react';
import type { FieldPath, FieldValues } from 'react-hook-form';
import TableEmpty from './TableEmpty';
import TableError from './TableError';
import TableLoading from './TableLoading';
import * as styles from './index.module.scss';
import type { Column, RowGroup } from './types';
type Props<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
rowGroups: Array<RowGroup<TFieldValues>>;
columns: Array<Column<TFieldValues, TName>>;
rowIndexKey: TName;
onClickRow?: (row: TFieldValues) => void;
className?: string;
headerClassName?: string;
bodyClassName?: string;
isLoading?: boolean;
placeholder?: ReactNode;
errorMessage?: string;
onRetry?: () => void;
};
const Table = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
rowGroups,
columns,
rowIndexKey,
onClickRow,
className,
headerClassName,
bodyClassName,
isLoading,
placeholder,
errorMessage,
onRetry,
}: Props<TFieldValues, TName>) => {
const totalColspan = columns.reduce((result, { colSpan }) => {
return result + (colSpan ?? 1);
}, 0);
const hasData = rowGroups.some(({ data }) => data?.length);
return (
<div className={classNames(styles.container, className)}>
<table className={classNames(styles.headerTable, headerClassName)}>
<thead>
<tr>
{columns.map(({ title, colSpan, dataIndex }) => (
<th key={dataIndex} colSpan={colSpan}>
{title}
</th>
))}
</tr>
</thead>
</table>
<div className={classNames(styles.bodyTable, bodyClassName)}>
<table>
<tbody>
{isLoading && <TableLoading columns={columns.length} />}
{!hasData && errorMessage && (
<TableError columns={columns.length} content={errorMessage} onRetry={onRetry} />
)}
{!isLoading && !hasData && placeholder && (
<TableEmpty columns={columns.length}>{placeholder}</TableEmpty>
)}
{rowGroups.map(({ key, label, labelClassName, data }) => (
<Fragment key={key}>
{label && (
<tr>
<td colSpan={totalColspan} className={labelClassName}>
{label}
</td>
</tr>
)}
{data?.map((row) => (
<tr
key={row[rowIndexKey]}
className={classNames(onClickRow && styles.clickable)}
onClick={() => {
onClickRow?.(row);
}}
>
{columns.map(({ dataIndex, colSpan, className, render }) => (
<td key={dataIndex} colSpan={colSpan} className={className}>
{render(row[dataIndex], row)}
</td>
))}
</tr>
))}
</Fragment>
))}
</tbody>
</table>
</div>
</div>
);
};
export default Table;

View file

@ -0,0 +1,20 @@
import type { Key, ReactNode } from 'react';
import type { FieldPath, FieldPathValue, FieldValues } from 'react-hook-form';
export type Column<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
title: ReactNode;
dataIndex: TName;
render: (value: FieldPathValue<TFieldValues, TName>, row: TFieldValues) => ReactNode;
colSpan?: number;
className?: string;
};
export type RowGroup<TFieldValues extends FieldValues = FieldValues> = {
key: Key;
label?: ReactNode;
labelClassName?: string;
data?: TFieldValues[];
};

View file

@ -12,14 +12,10 @@ import CardTitle from '@/components/CardTitle';
import CopyToClipboard from '@/components/CopyToClipboard';
import ItemPreview from '@/components/ItemPreview';
import Pagination from '@/components/Pagination';
import StickyHeaderTable from '@/components/Table/StickyHeaderTable';
import TableEmpty from '@/components/Table/TableEmpty';
import TableError from '@/components/Table/TableError';
import TableLoading from '@/components/Table/TableLoading';
import Table from '@/components/Table';
import type { RequestError } from '@/hooks/use-api';
import * as modalStyles from '@/scss/modal.module.scss';
import * as resourcesStyles from '@/scss/resources.module.scss';
import * as tableStyles from '@/scss/table.module.scss';
import { applicationTypeI18nKey } from '@/types/applications';
import CreateForm from './components/CreateForm';
@ -89,34 +85,34 @@ const Applications = () => {
/>
</Modal>
</div>
<StickyHeaderTable
<Table
className={resourcesStyles.table}
header={
<thead>
<tr>
<th>{t('applications.application_name')}</th>
<th>{t('applications.app_id')}</th>
</tr>
</thead>
}
colGroup={
<colgroup>
<col className={styles.applicationName} />
<col />
</colgroup>
}
>
<tbody>
{!data && error && (
<TableError
columns={2}
content={error.body?.message ?? error.message}
onRetry={async () => mutate(undefined, true)}
rowGroups={[{ key: 'applications', data: applications }]}
rowIndexKey="id"
isLoading={isLoading}
errorMessage={error?.body?.message ?? error?.message}
columns={[
{
title: t('applications.application_name'),
dataIndex: 'name',
colSpan: 6,
render: (name, { type, id }) => (
<ItemPreview
title={name}
subtitle={t(`${applicationTypeI18nKey[type]}.title`)}
icon={<ApplicationIcon className={styles.icon} type={type} />}
to={buildDetailsPathname(id)}
/>
)}
{isLoading && <TableLoading columns={2} />}
{applications?.length === 0 && (
<TableEmpty columns={2}>
),
},
{
title: t('applications.app_id'),
dataIndex: 'id',
colSpan: 10,
render: (id) => <CopyToClipboard value={id} variant="text" />,
},
]}
placeholder={
<Button
title="applications.create"
type="outline"
@ -127,31 +123,12 @@ const Applications = () => {
});
}}
/>
</TableEmpty>
)}
{applications?.map(({ id, name, type }) => (
<tr
key={id}
className={tableStyles.clickable}
onClick={() => {
}
onClickRow={({ id }) => {
navigate(buildDetailsPathname(id));
}}
>
<td>
<ItemPreview
title={name}
subtitle={t(`${applicationTypeI18nKey[type]}.title`)}
icon={<ApplicationIcon className={styles.icon} type={type} />}
to={buildDetailsPathname(id)}
onRetry={async () => mutate(undefined, true)}
/>
</td>
<td>
<CopyToClipboard value={id} variant="text" />
</td>
</tr>
))}
</tbody>
</StickyHeaderTable>
<Pagination
pageIndex={pageIndex}
totalCount={totalCount}

View file

@ -1,31 +0,0 @@
@use '@/scss/underscore' as _;
.sectionTitle {
@include _.subhead-cap-small;
color: var(--color-neutral-variant-60);
background-color: var(--color-layer-light);
padding: _.unit(1) 0;
}
.sectionDataKey {
padding: _.unit(4) _.unit(5);
font: var(--font-body-medium);
color: var(--color-text);
}
.sectionBuiltInText {
padding: _.unit(2) _.unit(3);
border-radius: 6px;
border: 1px solid var(--color-border);
color: var(--color-text);
background: var(--color-layer-2);
}
.inputCell {
position: relative;
}
.sectionInputArea {
position: absolute;
inset: _.unit(2) _.unit(5);
}

View file

@ -1,42 +0,0 @@
import type { Translation } from '@logto/schemas';
import { useFormContext } from 'react-hook-form';
import Textarea from '@/components/Textarea';
import * as style from './EditSection.module.scss';
type EditSectionProps = {
dataKey: string;
data: Record<string, string>;
};
const EditSection = ({ dataKey, data }: EditSectionProps) => {
const { register } = useFormContext<Translation>();
return (
<>
<tr>
<td colSpan={3} className={style.sectionTitle}>
{dataKey}
</td>
</tr>
{Object.entries(data).map(([field, value]) => {
const fieldKey = `${dataKey}.${field}`;
return (
<tr key={fieldKey}>
<td className={style.sectionDataKey}>{field}</td>
<td>
<div className={style.sectionBuiltInText}>{value}</div>
</td>
<td className={style.inputCell}>
<Textarea className={style.sectionInputArea} {...register(fieldKey)} />
</td>
</tr>
);
})}
</>
);
};
export default EditSection;

View file

@ -33,16 +33,19 @@
}
}
.form {
.container {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
border-top: 1px solid var(--color-divider);
overflow: hidden;
.tableWrapper {
padding: 0;
}
.content {
flex: 1;
border-top: 1px solid var(--color-divider);
overflow-y: auto;
.customValuesColumn {
display: flex;
@ -58,32 +61,9 @@
height: 16px;
}
> table {
border: none;
> thead {
position: sticky;
top: 0;
// Note: cells with `position: relative` style will overlap this sticky header, add a z-index to fix it.
z-index: 1;
tr > th {
padding: _.unit(1) _.unit(5);
font: var(--font-label-large);
color: var(--color-text);
background-color: var(--color-layer-1);
}
}
> tbody > tr > td {
padding: _.unit(2) _.unit(5);
border: none;
word-wrap: break-word;
}
}
.sectionTitle {
@include _.subhead-cap;
@include _.subhead-cap-small;
color: var(--color-neutral-variant-60);
background-color: var(--color-layer-light);
}
@ -94,7 +74,44 @@
}
.sectionBuiltInText {
padding: _.unit(2) 0;
padding: _.unit(2) _.unit(3);
border-radius: 6px;
border: 1px solid var(--color-border);
color: var(--color-text);
background: var(--color-layer-2);
}
.inputCell {
position: relative;
}
.sectionInputArea {
position: absolute;
inset: _.unit(2) _.unit(5);
}
table {
border: none;
thead {
tr > th {
padding: _.unit(1) _.unit(5);
font: var(--font-label-large);
color: var(--color-text);
background-color: var(--color-layer-1);
border-bottom: 1px solid var(--color-divider);
}
}
tbody {
tr {
td {
padding: _.unit(2) _.unit(5);
border: none;
word-wrap: break-word;
}
}
}
}
}

View file

@ -6,7 +6,7 @@ import type { SignInExperience, Translation } from '@logto/schemas';
import cleanDeep from 'clean-deep';
import deepmerge from 'deepmerge';
import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import useSWR, { useSWRConfig } from 'swr';
@ -16,6 +16,8 @@ import Delete from '@/assets/images/delete.svg';
import Button from '@/components/Button';
import ConfirmModal from '@/components/ConfirmModal';
import IconButton from '@/components/IconButton';
import Table from '@/components/Table';
import Textarea from '@/components/Textarea';
import { Tooltip } from '@/components/Tip';
import useApi, { RequestError } from '@/hooks/use-api';
import useUiLanguages from '@/hooks/use-ui-languages';
@ -25,8 +27,7 @@ import {
} from '@/pages/SignInExperience/utils/language';
import type { CustomPhraseResponse } from '@/types/custom-phrase';
import EditSection from './EditSection';
import * as style from './LanguageDetails.module.scss';
import * as styles from './LanguageDetails.module.scss';
import { LanguageEditorContext } from './use-language-editor-context';
const emptyUiTranslation = createEmptyUiTranslation();
@ -69,16 +70,15 @@ const LanguageDetails = () => {
[customPhrase]
);
const formMethods = useForm<Translation>({
defaultValues: defaultFormValues,
});
const {
handleSubmit,
reset,
setValue,
register,
formState: { isSubmitting, isDirty, dirtyFields },
} = formMethods;
} = useForm<Translation>({
defaultValues: defaultFormValues,
});
useEffect(() => {
/**
@ -133,6 +133,7 @@ const LanguageDetails = () => {
}, [api, globalMutate, isDefaultLanguage, languages, selectedLanguage, setSelectedLanguage]);
const onSubmit = handleSubmit(async (formData: Translation) => {
console.log(formData);
const updatedCustomPhrase = await upsertCustomPhrase(selectedLanguage, formData);
void mutate(updatedCustomPhrase);
toast.success(t('general.saved'));
@ -151,13 +152,13 @@ const LanguageDetails = () => {
]);
return (
<div className={style.languageDetails}>
<div className={style.title}>
<div className={style.languageInfo}>
<div className={styles.languageDetails}>
<div className={styles.title}>
<div className={styles.languageInfo}>
{uiLanguageNameMapping[selectedLanguage]}
<span>{selectedLanguage}</span>
{isBuiltIn && (
<span className={style.builtInFlag}>
<span className={styles.builtInFlag}>
{t('sign_in_exp.others.manage_language.logto_provided')}
</span>
)}
@ -174,26 +175,42 @@ const LanguageDetails = () => {
</Tooltip>
)}
</div>
<form
className={style.form}
onSubmit={async (event) => {
// Note: Avoid propagating the 'submit' event to the outer sign-in-experience form.
event.stopPropagation();
return onSubmit(event);
}}
>
<div className={style.content}>
<table>
<thead>
<tr>
<th>{t('sign_in_exp.others.manage_language.key')}</th>
<th>{t('sign_in_exp.others.manage_language.logto_source_values')}</th>
<th>
<span className={style.customValuesColumn}>
<div className={styles.container}>
<Table
className={styles.content}
headerClassName={styles.tableWrapper}
bodyClassName={styles.tableWrapper}
rowIndexKey="phraseKey"
rowGroups={translationEntries.map(([groupKey, value]) => ({
key: groupKey,
label: groupKey,
labelClassName: styles.sectionTitle,
data: Object.entries(flattenTranslation(value)).map(([phraseKey, value]) => ({
phraseKey,
sourceValue: value,
fieldKey: `${groupKey}.${phraseKey}`,
})),
}))}
columns={[
{
title: t('sign_in_exp.others.manage_language.key'),
dataIndex: 'phraseKey',
render: (phraseKey) => phraseKey,
className: styles.sectionDataKey,
},
{
title: t('sign_in_exp.others.manage_language.logto_source_values'),
dataIndex: 'sourceValue',
render: (sourceValue) => (
<div className={styles.sectionBuiltInText}>{sourceValue}</div>
),
},
{
title: (
<span className={styles.customValuesColumn}>
{t('sign_in_exp.others.manage_language.custom_values')}
<Tooltip
anchorClassName={style.clearButton}
anchorClassName={styles.clearButton}
content={t('sign_in_exp.others.manage_language.clear_all_tip')}
>
<IconButton
@ -206,32 +223,29 @@ const LanguageDetails = () => {
}
}}
>
<Clear className={style.clearIcon} />
<Clear className={styles.clearIcon} />
</IconButton>
</Tooltip>
</span>
</th>
</tr>
</thead>
<tbody>
<FormProvider {...formMethods}>
{translationEntries.map(([key, value]) => (
<EditSection key={key} dataKey={key} data={flattenTranslation(value)} />
))}
</FormProvider>
</tbody>
</table>
</div>
<div className={style.footer}>
),
dataIndex: 'fieldKey',
render: (fieldKey) => (
<Textarea className={styles.sectionInputArea} {...register(fieldKey)} />
),
className: styles.inputCell,
},
]}
/>
<div className={styles.footer}>
<Button
isLoading={isSubmitting}
htmlType="submit"
type="primary"
size="large"
title="general.save"
onClick={async () => onSubmit()}
/>
</div>
</form>
</div>
<ConfirmModal
isOpen={isDeletionAlertOpen}
title={

View file

@ -33,11 +33,6 @@ table {
padding: _.unit(3);
border-bottom: 1px solid var(--color-divider);
text-align: left;
&:first-child,
&:last-child {
padding: _.unit(3) _.unit(8);
}
}
}
@ -45,12 +40,7 @@ table {
td {
font: var(--font-body-medium);
border-bottom: 1px solid var(--color-divider);
padding: _.unit(3) _.unit(2);
&:first-child,
&:last-child {
padding: _.unit(3) _.unit(8);
}
padding: _.unit(3);
}
}
}