mirror of
https://github.com/logto-io/logto.git
synced 2025-01-06 20:40:08 -05:00
refactor(console): refactor table component (#2738)
This commit is contained in:
parent
8592d22a38
commit
b403f19558
11 changed files with 369 additions and 309 deletions
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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;
|
|
75
packages/console/src/components/Table/index.module.scss
Normal file
75
packages/console/src/components/Table/index.module.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
107
packages/console/src/components/Table/index.tsx
Normal file
107
packages/console/src/components/Table/index.tsx
Normal 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;
|
20
packages/console/src/components/Table/types.ts
Normal file
20
packages/console/src/components/Table/types.ts
Normal 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[];
|
||||||
|
};
|
|
@ -12,14 +12,10 @@ import CardTitle from '@/components/CardTitle';
|
||||||
import CopyToClipboard from '@/components/CopyToClipboard';
|
import CopyToClipboard from '@/components/CopyToClipboard';
|
||||||
import ItemPreview from '@/components/ItemPreview';
|
import ItemPreview from '@/components/ItemPreview';
|
||||||
import Pagination from '@/components/Pagination';
|
import Pagination from '@/components/Pagination';
|
||||||
import StickyHeaderTable from '@/components/Table/StickyHeaderTable';
|
import Table from '@/components/Table';
|
||||||
import TableEmpty from '@/components/Table/TableEmpty';
|
|
||||||
import TableError from '@/components/Table/TableError';
|
|
||||||
import TableLoading from '@/components/Table/TableLoading';
|
|
||||||
import type { RequestError } from '@/hooks/use-api';
|
import type { RequestError } from '@/hooks/use-api';
|
||||||
import * as modalStyles from '@/scss/modal.module.scss';
|
import * as modalStyles from '@/scss/modal.module.scss';
|
||||||
import * as resourcesStyles from '@/scss/resources.module.scss';
|
import * as resourcesStyles from '@/scss/resources.module.scss';
|
||||||
import * as tableStyles from '@/scss/table.module.scss';
|
|
||||||
import { applicationTypeI18nKey } from '@/types/applications';
|
import { applicationTypeI18nKey } from '@/types/applications';
|
||||||
|
|
||||||
import CreateForm from './components/CreateForm';
|
import CreateForm from './components/CreateForm';
|
||||||
|
@ -89,34 +85,34 @@ const Applications = () => {
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
<StickyHeaderTable
|
<Table
|
||||||
className={resourcesStyles.table}
|
className={resourcesStyles.table}
|
||||||
header={
|
rowGroups={[{ key: 'applications', data: applications }]}
|
||||||
<thead>
|
rowIndexKey="id"
|
||||||
<tr>
|
isLoading={isLoading}
|
||||||
<th>{t('applications.application_name')}</th>
|
errorMessage={error?.body?.message ?? error?.message}
|
||||||
<th>{t('applications.app_id')}</th>
|
columns={[
|
||||||
</tr>
|
{
|
||||||
</thead>
|
title: t('applications.application_name'),
|
||||||
}
|
dataIndex: 'name',
|
||||||
colGroup={
|
colSpan: 6,
|
||||||
<colgroup>
|
render: (name, { type, id }) => (
|
||||||
<col className={styles.applicationName} />
|
<ItemPreview
|
||||||
<col />
|
title={name}
|
||||||
</colgroup>
|
subtitle={t(`${applicationTypeI18nKey[type]}.title`)}
|
||||||
}
|
icon={<ApplicationIcon className={styles.icon} type={type} />}
|
||||||
>
|
to={buildDetailsPathname(id)}
|
||||||
<tbody>
|
|
||||||
{!data && error && (
|
|
||||||
<TableError
|
|
||||||
columns={2}
|
|
||||||
content={error.body?.message ?? error.message}
|
|
||||||
onRetry={async () => mutate(undefined, true)}
|
|
||||||
/>
|
/>
|
||||||
)}
|
),
|
||||||
{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
|
<Button
|
||||||
title="applications.create"
|
title="applications.create"
|
||||||
type="outline"
|
type="outline"
|
||||||
|
@ -127,31 +123,12 @@ const Applications = () => {
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</TableEmpty>
|
}
|
||||||
)}
|
onClickRow={({ id }) => {
|
||||||
{applications?.map(({ id, name, type }) => (
|
|
||||||
<tr
|
|
||||||
key={id}
|
|
||||||
className={tableStyles.clickable}
|
|
||||||
onClick={() => {
|
|
||||||
navigate(buildDetailsPathname(id));
|
navigate(buildDetailsPathname(id));
|
||||||
}}
|
}}
|
||||||
>
|
onRetry={async () => mutate(undefined, true)}
|
||||||
<td>
|
|
||||||
<ItemPreview
|
|
||||||
title={name}
|
|
||||||
subtitle={t(`${applicationTypeI18nKey[type]}.title`)}
|
|
||||||
icon={<ApplicationIcon className={styles.icon} type={type} />}
|
|
||||||
to={buildDetailsPathname(id)}
|
|
||||||
/>
|
/>
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<CopyToClipboard value={id} variant="text" />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</StickyHeaderTable>
|
|
||||||
<Pagination
|
<Pagination
|
||||||
pageIndex={pageIndex}
|
pageIndex={pageIndex}
|
||||||
totalCount={totalCount}
|
totalCount={totalCount}
|
||||||
|
|
|
@ -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);
|
|
||||||
}
|
|
|
@ -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;
|
|
|
@ -33,16 +33,19 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.form {
|
.container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow-y: auto;
|
border-top: 1px solid var(--color-divider);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.tableWrapper {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
border-top: 1px solid var(--color-divider);
|
|
||||||
overflow-y: auto;
|
|
||||||
|
|
||||||
.customValuesColumn {
|
.customValuesColumn {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -58,32 +61,9 @@
|
||||||
height: 16px;
|
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 {
|
.sectionTitle {
|
||||||
@include _.subhead-cap;
|
@include _.subhead-cap-small;
|
||||||
|
color: var(--color-neutral-variant-60);
|
||||||
background-color: var(--color-layer-light);
|
background-color: var(--color-layer-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -94,7 +74,44 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.sectionBuiltInText {
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,7 @@ import type { SignInExperience, Translation } from '@logto/schemas';
|
||||||
import cleanDeep from 'clean-deep';
|
import cleanDeep from 'clean-deep';
|
||||||
import deepmerge from 'deepmerge';
|
import deepmerge from 'deepmerge';
|
||||||
import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
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 { toast } from 'react-hot-toast';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import useSWR, { useSWRConfig } from 'swr';
|
import useSWR, { useSWRConfig } from 'swr';
|
||||||
|
@ -16,6 +16,8 @@ import Delete from '@/assets/images/delete.svg';
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import ConfirmModal from '@/components/ConfirmModal';
|
import ConfirmModal from '@/components/ConfirmModal';
|
||||||
import IconButton from '@/components/IconButton';
|
import IconButton from '@/components/IconButton';
|
||||||
|
import Table from '@/components/Table';
|
||||||
|
import Textarea from '@/components/Textarea';
|
||||||
import { Tooltip } from '@/components/Tip';
|
import { Tooltip } from '@/components/Tip';
|
||||||
import useApi, { RequestError } from '@/hooks/use-api';
|
import useApi, { RequestError } from '@/hooks/use-api';
|
||||||
import useUiLanguages from '@/hooks/use-ui-languages';
|
import useUiLanguages from '@/hooks/use-ui-languages';
|
||||||
|
@ -25,8 +27,7 @@ import {
|
||||||
} from '@/pages/SignInExperience/utils/language';
|
} from '@/pages/SignInExperience/utils/language';
|
||||||
import type { CustomPhraseResponse } from '@/types/custom-phrase';
|
import type { CustomPhraseResponse } from '@/types/custom-phrase';
|
||||||
|
|
||||||
import EditSection from './EditSection';
|
import * as styles from './LanguageDetails.module.scss';
|
||||||
import * as style from './LanguageDetails.module.scss';
|
|
||||||
import { LanguageEditorContext } from './use-language-editor-context';
|
import { LanguageEditorContext } from './use-language-editor-context';
|
||||||
|
|
||||||
const emptyUiTranslation = createEmptyUiTranslation();
|
const emptyUiTranslation = createEmptyUiTranslation();
|
||||||
|
@ -69,16 +70,15 @@ const LanguageDetails = () => {
|
||||||
[customPhrase]
|
[customPhrase]
|
||||||
);
|
);
|
||||||
|
|
||||||
const formMethods = useForm<Translation>({
|
|
||||||
defaultValues: defaultFormValues,
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
setValue,
|
setValue,
|
||||||
|
register,
|
||||||
formState: { isSubmitting, isDirty, dirtyFields },
|
formState: { isSubmitting, isDirty, dirtyFields },
|
||||||
} = formMethods;
|
} = useForm<Translation>({
|
||||||
|
defaultValues: defaultFormValues,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
/**
|
/**
|
||||||
|
@ -133,6 +133,7 @@ const LanguageDetails = () => {
|
||||||
}, [api, globalMutate, isDefaultLanguage, languages, selectedLanguage, setSelectedLanguage]);
|
}, [api, globalMutate, isDefaultLanguage, languages, selectedLanguage, setSelectedLanguage]);
|
||||||
|
|
||||||
const onSubmit = handleSubmit(async (formData: Translation) => {
|
const onSubmit = handleSubmit(async (formData: Translation) => {
|
||||||
|
console.log(formData);
|
||||||
const updatedCustomPhrase = await upsertCustomPhrase(selectedLanguage, formData);
|
const updatedCustomPhrase = await upsertCustomPhrase(selectedLanguage, formData);
|
||||||
void mutate(updatedCustomPhrase);
|
void mutate(updatedCustomPhrase);
|
||||||
toast.success(t('general.saved'));
|
toast.success(t('general.saved'));
|
||||||
|
@ -151,13 +152,13 @@ const LanguageDetails = () => {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.languageDetails}>
|
<div className={styles.languageDetails}>
|
||||||
<div className={style.title}>
|
<div className={styles.title}>
|
||||||
<div className={style.languageInfo}>
|
<div className={styles.languageInfo}>
|
||||||
{uiLanguageNameMapping[selectedLanguage]}
|
{uiLanguageNameMapping[selectedLanguage]}
|
||||||
<span>{selectedLanguage}</span>
|
<span>{selectedLanguage}</span>
|
||||||
{isBuiltIn && (
|
{isBuiltIn && (
|
||||||
<span className={style.builtInFlag}>
|
<span className={styles.builtInFlag}>
|
||||||
{t('sign_in_exp.others.manage_language.logto_provided')}
|
{t('sign_in_exp.others.manage_language.logto_provided')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
@ -174,26 +175,42 @@ const LanguageDetails = () => {
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<form
|
<div className={styles.container}>
|
||||||
className={style.form}
|
<Table
|
||||||
onSubmit={async (event) => {
|
className={styles.content}
|
||||||
// Note: Avoid propagating the 'submit' event to the outer sign-in-experience form.
|
headerClassName={styles.tableWrapper}
|
||||||
event.stopPropagation();
|
bodyClassName={styles.tableWrapper}
|
||||||
|
rowIndexKey="phraseKey"
|
||||||
return onSubmit(event);
|
rowGroups={translationEntries.map(([groupKey, value]) => ({
|
||||||
}}
|
key: groupKey,
|
||||||
>
|
label: groupKey,
|
||||||
<div className={style.content}>
|
labelClassName: styles.sectionTitle,
|
||||||
<table>
|
data: Object.entries(flattenTranslation(value)).map(([phraseKey, value]) => ({
|
||||||
<thead>
|
phraseKey,
|
||||||
<tr>
|
sourceValue: value,
|
||||||
<th>{t('sign_in_exp.others.manage_language.key')}</th>
|
fieldKey: `${groupKey}.${phraseKey}`,
|
||||||
<th>{t('sign_in_exp.others.manage_language.logto_source_values')}</th>
|
})),
|
||||||
<th>
|
}))}
|
||||||
<span className={style.customValuesColumn}>
|
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')}
|
{t('sign_in_exp.others.manage_language.custom_values')}
|
||||||
<Tooltip
|
<Tooltip
|
||||||
anchorClassName={style.clearButton}
|
anchorClassName={styles.clearButton}
|
||||||
content={t('sign_in_exp.others.manage_language.clear_all_tip')}
|
content={t('sign_in_exp.others.manage_language.clear_all_tip')}
|
||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
|
@ -206,32 +223,29 @@ const LanguageDetails = () => {
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Clear className={style.clearIcon} />
|
<Clear className={styles.clearIcon} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</span>
|
</span>
|
||||||
</th>
|
),
|
||||||
</tr>
|
dataIndex: 'fieldKey',
|
||||||
</thead>
|
render: (fieldKey) => (
|
||||||
<tbody>
|
<Textarea className={styles.sectionInputArea} {...register(fieldKey)} />
|
||||||
<FormProvider {...formMethods}>
|
),
|
||||||
{translationEntries.map(([key, value]) => (
|
className: styles.inputCell,
|
||||||
<EditSection key={key} dataKey={key} data={flattenTranslation(value)} />
|
},
|
||||||
))}
|
]}
|
||||||
</FormProvider>
|
/>
|
||||||
</tbody>
|
<div className={styles.footer}>
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div className={style.footer}>
|
|
||||||
<Button
|
<Button
|
||||||
isLoading={isSubmitting}
|
isLoading={isSubmitting}
|
||||||
htmlType="submit"
|
|
||||||
type="primary"
|
type="primary"
|
||||||
size="large"
|
size="large"
|
||||||
title="general.save"
|
title="general.save"
|
||||||
|
onClick={async () => onSubmit()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
isOpen={isDeletionAlertOpen}
|
isOpen={isDeletionAlertOpen}
|
||||||
title={
|
title={
|
||||||
|
|
|
@ -33,11 +33,6 @@ table {
|
||||||
padding: _.unit(3);
|
padding: _.unit(3);
|
||||||
border-bottom: 1px solid var(--color-divider);
|
border-bottom: 1px solid var(--color-divider);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
|
||||||
&:first-child,
|
|
||||||
&:last-child {
|
|
||||||
padding: _.unit(3) _.unit(8);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -45,12 +40,7 @@ table {
|
||||||
td {
|
td {
|
||||||
font: var(--font-body-medium);
|
font: var(--font-body-medium);
|
||||||
border-bottom: 1px solid var(--color-divider);
|
border-bottom: 1px solid var(--color-divider);
|
||||||
padding: _.unit(3) _.unit(2);
|
padding: _.unit(3);
|
||||||
|
|
||||||
&:first-child,
|
|
||||||
&:last-child {
|
|
||||||
padding: _.unit(3) _.unit(8);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue