mirror of
https://github.com/logto-io/logto.git
synced 2025-02-24 22:05:56 -05:00
refactor(console): improve table loading experience (#4920)
This commit is contained in:
parent
3da6694fef
commit
3a6985a84e
10 changed files with 79 additions and 68 deletions
|
@ -1,6 +1,12 @@
|
||||||
@use '@/scss/underscore' as _;
|
@use '@/scss/underscore' as _;
|
||||||
|
|
||||||
.loading {
|
.rect {
|
||||||
|
@include _.shimmering-animation;
|
||||||
|
height: 26px;
|
||||||
|
max-width: 344px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
.itemPreview {
|
.itemPreview {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
@ -31,8 +37,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.rect {
|
.rect {
|
||||||
@include _.shimmering-animation;
|
|
||||||
height: 32px;
|
height: 32px;
|
||||||
max-width: 344px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,15 +1,34 @@
|
||||||
import * as styles from './TableLoading.module.scss';
|
import * as styles from './index.module.scss';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
columnSpans: number[];
|
columnSpans: number[];
|
||||||
|
/** For the compact inline style table */
|
||||||
|
isCompact?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function TableLoading({ columnSpans }: Props) {
|
function Skeleton({ columnSpans, isCompact }: Props) {
|
||||||
|
if (isCompact) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{Array.from({ length: 2 }).map((_, rowIndex) => (
|
||||||
|
// eslint-disable-next-line react/no-array-index-key
|
||||||
|
<tr key={`row-${rowIndex}`}>
|
||||||
|
{columnSpans.map((colSpan, columnIndex) => (
|
||||||
|
// eslint-disable-next-line react/no-array-index-key
|
||||||
|
<td key={columnIndex} colSpan={colSpan}>
|
||||||
|
<div className={styles.rect} />
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{Array.from({ length: 8 }).map((_, rowIndex) => (
|
{Array.from({ length: 8 }).map((_, rowIndex) => (
|
||||||
// eslint-disable-next-line react/no-array-index-key
|
// eslint-disable-next-line react/no-array-index-key
|
||||||
<tr key={`row-${rowIndex}`} className={styles.loading}>
|
<tr key={`row-${rowIndex}`} className={styles.row}>
|
||||||
<td colSpan={columnSpans[0]}>
|
<td colSpan={columnSpans[0]}>
|
||||||
<div className={styles.itemPreview}>
|
<div className={styles.itemPreview}>
|
||||||
<div className={styles.avatar} />
|
<div className={styles.avatar} />
|
||||||
|
@ -31,4 +50,4 @@ function TableLoading({ columnSpans }: Props) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TableLoading;
|
export default Skeleton;
|
|
@ -9,9 +9,9 @@ import Pagination from '@/ds-components/Pagination';
|
||||||
|
|
||||||
import OverlayScrollbar from '../OverlayScrollbar';
|
import OverlayScrollbar from '../OverlayScrollbar';
|
||||||
|
|
||||||
|
import Skeleton from './Skeleton';
|
||||||
import TableEmptyWrapper from './TableEmptyWrapper';
|
import TableEmptyWrapper from './TableEmptyWrapper';
|
||||||
import TableError from './TableError';
|
import TableError from './TableError';
|
||||||
import TableLoading from './TableLoading';
|
|
||||||
import * as styles from './index.module.scss';
|
import * as styles from './index.module.scss';
|
||||||
import type { Column, RowGroup } from './types';
|
import type { Column, RowGroup } from './types';
|
||||||
|
|
||||||
|
@ -35,6 +35,7 @@ export type Props<
|
||||||
placeholder?: ReactNode;
|
placeholder?: ReactNode;
|
||||||
loadingSkeleton?: ReactNode;
|
loadingSkeleton?: ReactNode;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
|
/** The inline style table that is usually embedded in other card containers, has rounded-corner border */
|
||||||
hasBorder?: boolean;
|
hasBorder?: boolean;
|
||||||
onRetry?: () => void;
|
onRetry?: () => void;
|
||||||
/** A footer that will be rendered on the bottom-left of the table. */
|
/** A footer that will be rendered on the bottom-left of the table. */
|
||||||
|
@ -110,7 +111,10 @@ function Table<
|
||||||
<tbody>
|
<tbody>
|
||||||
{isLoading &&
|
{isLoading &&
|
||||||
(loadingSkeleton ?? (
|
(loadingSkeleton ?? (
|
||||||
<TableLoading columnSpans={columns.map(({ colSpan }) => colSpan ?? 1)} />
|
<Skeleton
|
||||||
|
isCompact={hasBorder}
|
||||||
|
columnSpans={columns.map(({ colSpan }) => colSpan ?? 1)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
{hasError && (
|
{hasError && (
|
||||||
<TableError columns={columns.length} content={errorMessage} onRetry={onRetry} />
|
<TableError columns={columns.length} content={errorMessage} onRetry={onRetry} />
|
||||||
|
|
|
@ -27,10 +27,11 @@ const pathname = '/organizations';
|
||||||
const apiPathname = 'api/organizations';
|
const apiPathname = 'api/organizations';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
isLoading: boolean;
|
||||||
onCreate: () => void;
|
onCreate: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function OrganizationsTable({ onCreate }: Props) {
|
function OrganizationsTable({ isLoading, onCreate }: Props) {
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const { data: response, error } = useSWR<[OrganizationWithFeatured[], number], RequestError>(
|
const { data: response, error } = useSWR<[OrganizationWithFeatured[], number], RequestError>(
|
||||||
|
@ -42,14 +43,14 @@ function OrganizationsTable({ onCreate }: Props) {
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||||
const isLoading = !response && !error;
|
const isTableLoading = isLoading || (!response && !error);
|
||||||
const [data, totalCount] = response ?? [[], 0];
|
const [data, totalCount] = response ?? [[], 0];
|
||||||
const { navigate } = useTenantPathname();
|
const { navigate } = useTenantPathname();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table
|
<Table
|
||||||
className={pageLayout.table}
|
className={pageLayout.table}
|
||||||
isLoading={isLoading}
|
isLoading={isTableLoading}
|
||||||
placeholder={<EmptyDataPlaceholder onCreate={onCreate} />}
|
placeholder={<EmptyDataPlaceholder onCreate={onCreate} />}
|
||||||
rowGroups={[{ key: 'data', data }]}
|
rowGroups={[{ key: 'data', data }]}
|
||||||
rowClickHandler={({ id }) => {
|
rowClickHandler={({ id }) => {
|
||||||
|
|
|
@ -1,14 +1,11 @@
|
||||||
import { type AdminConsoleKey } from '@logto/phrases';
|
import { type AdminConsoleKey } from '@logto/phrases';
|
||||||
import classNames from 'classnames';
|
|
||||||
import { type FieldValues, type FieldPath } from 'react-hook-form';
|
import { type FieldValues, type FieldPath } from 'react-hook-form';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import CirclePlus from '@/assets/icons/circle-plus.svg';
|
import CirclePlus from '@/assets/icons/circle-plus.svg';
|
||||||
import Plus from '@/assets/icons/plus.svg';
|
import Plus from '@/assets/icons/plus.svg';
|
||||||
import EmptyDataPlaceholder from '@/components/EmptyDataPlaceholder';
|
|
||||||
import Button from '@/ds-components/Button';
|
import Button from '@/ds-components/Button';
|
||||||
import DynamicT from '@/ds-components/DynamicT';
|
import DynamicT from '@/ds-components/DynamicT';
|
||||||
import { Ring as Spinner } from '@/ds-components/Spinner';
|
|
||||||
import Table from '@/ds-components/Table';
|
import Table from '@/ds-components/Table';
|
||||||
import { type Column } from '@/ds-components/Table/types';
|
import { type Column } from '@/ds-components/Table/types';
|
||||||
|
|
||||||
|
@ -49,12 +46,8 @@ function TemplateTable<
|
||||||
isLoading,
|
isLoading,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
}: Props<TFieldValues, TName>) {
|
}: Props<TFieldValues, TName>) {
|
||||||
const hasData = !isLoading && data.length > 0;
|
|
||||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||||
|
const noData = !isLoading && data.length === 0;
|
||||||
if (isLoading) {
|
|
||||||
<Spinner className={styles.spinner} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={styles.section}>
|
<section className={styles.section}>
|
||||||
|
@ -63,10 +56,19 @@ function TemplateTable<
|
||||||
<DynamicT forKey={name} interpolation={{ count: 2 }} />
|
<DynamicT forKey={name} interpolation={{ count: 2 }} />
|
||||||
</header>
|
</header>
|
||||||
)}
|
)}
|
||||||
{hasData && (
|
{onAdd && noData && (
|
||||||
|
<>
|
||||||
|
{name && (
|
||||||
|
<div className={styles.empty}>
|
||||||
|
{t('organizations.empty_placeholder', { entity: String(t(name)).toLowerCase() })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button icon={<Plus />} title="general.add" onClick={onAdd} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!noData && (
|
||||||
<Table
|
<Table
|
||||||
hasBorder
|
hasBorder
|
||||||
placeholder={<EmptyDataPlaceholder />}
|
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
rowGroups={[
|
rowGroups={[
|
||||||
{
|
{
|
||||||
|
@ -94,16 +96,6 @@ function TemplateTable<
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{onAdd && !hasData && (
|
|
||||||
<>
|
|
||||||
{name && (
|
|
||||||
<div className={classNames(styles.empty)}>
|
|
||||||
{t('organizations.empty_placeholder', { entity: String(t(name)).toLowerCase() })}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Button icon={<Plus />} title="general.add" onClick={onAdd} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,8 +31,8 @@ function Organizations({ tab }: Props) {
|
||||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||||
const { navigate } = useTenantPathname();
|
const { navigate } = useTenantPathname();
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const { configs } = useConfigs();
|
const { configs, isLoading: isLoadingConfigs } = useConfigs();
|
||||||
const isInitialSetup = !configs?.organizationCreated;
|
const isInitialSetup = !isLoadingConfigs && !configs?.organizationCreated;
|
||||||
|
|
||||||
const handleCreate = useCallback(() => {
|
const handleCreate = useCallback(() => {
|
||||||
if (isInitialSetup) {
|
if (isInitialSetup) {
|
||||||
|
@ -85,7 +85,7 @@ function Organizations({ tab }: Props) {
|
||||||
{t('organizations.organization_template')}
|
{t('organizations.organization_template')}
|
||||||
</TabNavItem>
|
</TabNavItem>
|
||||||
</TabNav>
|
</TabNav>
|
||||||
{!tab && <OrganizationsTable onCreate={handleCreate} />}
|
{!tab && <OrganizationsTable isLoading={isLoadingConfigs} onCreate={handleCreate} />}
|
||||||
{tab === 'template' && <Settings />}
|
{tab === 'template' && <Settings />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -124,21 +124,6 @@ function SigningKeys() {
|
||||||
rowIndexKey="id"
|
rowIndexKey="id"
|
||||||
rowGroups={[{ key: 'signing_keys', data }]}
|
rowGroups={[{ key: 'signing_keys', data }]}
|
||||||
columns={tableColumns}
|
columns={tableColumns}
|
||||||
loadingSkeleton={
|
|
||||||
<>
|
|
||||||
{Array.from({ length: 2 }).map((_, rowIndex) => (
|
|
||||||
// eslint-disable-next-line react/no-array-index-key
|
|
||||||
<tr key={`skeleton-row-${rowIndex}`}>
|
|
||||||
{tableColumns.map(({ colSpan }, columnIndex) => (
|
|
||||||
// eslint-disable-next-line react/no-array-index-key
|
|
||||||
<td key={columnIndex} colSpan={colSpan}>
|
|
||||||
<div className={styles.bone} />
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField title={`tenants.signing_keys.rotate_${isPrivateKey ? 'private' : 'cookie'}_keys`}>
|
<FormField title={`tenants.signing_keys.rotate_${isPrivateKey ? 'private' : 'cookie'}_keys`}>
|
||||||
|
|
|
@ -61,7 +61,7 @@ function UserMfaVerifications({ userId }: Props) {
|
||||||
{t(mfaVerifications?.length ? 'field_description' : 'field_description_empty')}
|
{t(mfaVerifications?.length ? 'field_description' : 'field_description_empty')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(Boolean(mfaVerifications?.length) || error) && (
|
{(isLoading || Boolean(mfaVerifications?.length) || error) && (
|
||||||
<Table
|
<Table
|
||||||
hasBorder
|
hasBorder
|
||||||
rowGroups={[{ key: 'mfaVerifications', data: mfaVerifications }]}
|
rowGroups={[{ key: 'mfaVerifications', data: mfaVerifications }]}
|
||||||
|
|
|
@ -81,16 +81,20 @@ function UserSocialIdentities({ userId, identities, onDelete }: Props) {
|
||||||
});
|
});
|
||||||
}, [connectorGroups, identities, t]);
|
}, [connectorGroups, identities, t]);
|
||||||
|
|
||||||
|
const hasConnectors = Boolean(displayConnectors && displayConnectors.length > 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
{!isLoading && !error && (
|
||||||
<div className={styles.description}>
|
<div className={styles.description}>
|
||||||
{t(
|
{t(
|
||||||
displayConnectors && displayConnectors.length > 0
|
hasConnectors
|
||||||
? 'user_details.connectors.connected'
|
? 'user_details.connectors.connected'
|
||||||
: 'user_details.connectors.not_connected'
|
: 'user_details.connectors.not_connected'
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{displayConnectors && displayConnectors.length > 0 && (
|
)}
|
||||||
|
{(isLoading || hasConnectors || error) && (
|
||||||
<Table
|
<Table
|
||||||
hasBorder
|
hasBorder
|
||||||
rowGroups={[{ key: 'identities', data: displayConnectors }]}
|
rowGroups={[{ key: 'identities', data: displayConnectors }]}
|
||||||
|
|
|
@ -55,10 +55,11 @@ function UserSsoIdentities({ ssoIdentities }: Props) {
|
||||||
);
|
);
|
||||||
}, [data, ssoIdentities]);
|
}, [data, ssoIdentities]);
|
||||||
|
|
||||||
const hasLinkedSsoIdentities = displaySsoConnectors && displaySsoConnectors.length > 0;
|
const hasLinkedSsoIdentities = Boolean(displaySsoConnectors && displaySsoConnectors.length > 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
{!isLoading && !error && (
|
||||||
<div className={styles.description}>
|
<div className={styles.description}>
|
||||||
{t(
|
{t(
|
||||||
hasLinkedSsoIdentities
|
hasLinkedSsoIdentities
|
||||||
|
@ -66,7 +67,8 @@ function UserSsoIdentities({ ssoIdentities }: Props) {
|
||||||
: 'user_details.sso_connectors.not_connected'
|
: 'user_details.sso_connectors.not_connected'
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{hasLinkedSsoIdentities && (
|
)}
|
||||||
|
{(isLoading || hasLinkedSsoIdentities || error) && (
|
||||||
<Table
|
<Table
|
||||||
hasBorder
|
hasBorder
|
||||||
rowGroups={[{ key: 'ssoIdentity', data: displaySsoConnectors }]}
|
rowGroups={[{ key: 'ssoIdentity', data: displaySsoConnectors }]}
|
||||||
|
|
Loading…
Add table
Reference in a new issue