diff --git a/packages/console/src/components/DataEmpty/index.module.scss b/packages/console/src/components/DataEmpty/index.module.scss
new file mode 100644
index 000000000..534df66cc
--- /dev/null
+++ b/packages/console/src/components/DataEmpty/index.module.scss
@@ -0,0 +1,19 @@
+@use '@/scss/underscore' as _;
+
+.empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: _.unit(4) 0;
+
+ .title {
+ font: var(--font-subhead-2);
+ margin-bottom: _.unit(2);
+ }
+
+ .description {
+ font: var(--font-body-medium);
+ color: var(--color-neutral-50);
+ margin-bottom: _.unit(2);
+ }
+}
diff --git a/packages/console/src/components/DataEmpty/index.tsx b/packages/console/src/components/DataEmpty/index.tsx
new file mode 100644
index 000000000..949cbf84e
--- /dev/null
+++ b/packages/console/src/components/DataEmpty/index.tsx
@@ -0,0 +1,39 @@
+import { AppearanceMode } from '@logto/schemas';
+import type { ReactNode } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import EmptyDark from '@/assets/images/table-empty-dark.svg';
+import Empty from '@/assets/images/table-empty.svg';
+import { useTheme } from '@/hooks/use-theme';
+
+import * as styles from './index.module.scss';
+
+export type Props = {
+ title?: string;
+ description?: string;
+ image?: ReactNode;
+ children?: ReactNode;
+ imageClassName?: string;
+};
+
+const DataEmpty = ({ title, description, image, imageClassName, children }: Props) => {
+ const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
+
+ const theme = useTheme();
+
+ return (
+
+ {image ??
+ (theme === AppearanceMode.LightMode ? (
+
+ ) : (
+
+ ))}
+
{title ?? t('errors.empty')}
+ {description &&
{description}
}
+ {children}
+
+ );
+};
+
+export default DataEmpty;
diff --git a/packages/console/src/components/RoleScopesTransfer/components/SourceScopesBox/index.module.scss b/packages/console/src/components/RoleScopesTransfer/components/SourceScopesBox/index.module.scss
index b6084458b..622fdba2e 100644
--- a/packages/console/src/components/RoleScopesTransfer/components/SourceScopesBox/index.module.scss
+++ b/packages/console/src/components/RoleScopesTransfer/components/SourceScopesBox/index.module.scss
@@ -5,3 +5,8 @@
.icon {
color: var(--color-text-secondary);
}
+
+.emptyImage {
+ width: 128px;
+ height: 128px;
+}
diff --git a/packages/console/src/components/RoleScopesTransfer/components/SourceScopesBox/index.tsx b/packages/console/src/components/RoleScopesTransfer/components/SourceScopesBox/index.tsx
index d706cb477..16fe9d8e4 100644
--- a/packages/console/src/components/RoleScopesTransfer/components/SourceScopesBox/index.tsx
+++ b/packages/console/src/components/RoleScopesTransfer/components/SourceScopesBox/index.tsx
@@ -6,8 +6,10 @@ import { useTranslation } from 'react-i18next';
import useSWR from 'swr';
import Search from '@/assets/images/search.svg';
+import DataEmpty from '@/components/DataEmpty';
import type { DetailedResourceResponse } from '@/components/RoleScopesTransfer/types';
import TextInput from '@/components/TextInput';
+import type { RequestError } from '@/hooks/use-api';
import * as transferLayout from '@/scss/transfer.module.scss';
import ResourceItem from '../ResourceItem';
@@ -21,8 +23,20 @@ type Props = {
const SourceScopesBox = ({ roleId, selectedScopes, onChange }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
- const { data = [] } = useSWR('/api/resources?includeScopes=true');
- const { data: roleScopes = [] } = useSWR(roleId && `/api/roles/${roleId}/scopes`);
+
+ const { data: allResources, error: fetchAllResourcesError } = useSWR<
+ ResourceResponse[],
+ RequestError
+ >('/api/resources?includeScopes=true');
+
+ const { data: roleScopes, error: fetchRoleScopesError } = useSWR(
+ roleId && `/api/roles/${roleId}/scopes`
+ );
+
+ const isLoading =
+ (!allResources && !fetchAllResourcesError) || (!roleScopes && !fetchRoleScopesError);
+
+ const hasError = Boolean(fetchAllResourcesError) || Boolean(fetchRoleScopesError);
const [keyword, setKeyword] = useState('');
@@ -58,9 +72,13 @@ const SourceScopesBox = ({ roleId, selectedScopes, onChange }: Props) => {
scopes.filter((scope) => selectedScopes.findIndex(({ id }) => id === scope.id) >= 0);
const resources: DetailedResourceResponse[] = useMemo(() => {
+ if (!allResources || !roleScopes) {
+ return [];
+ }
+
const excludeScopeIds = new Set(roleScopes.map(({ id }) => id));
- return data
+ return allResources
.filter(({ scopes }) => scopes.some(({ id }) => !excludeScopeIds.has(id)))
.map(({ scopes, ...resource }) => ({
...resource,
@@ -71,7 +89,7 @@ const SourceScopesBox = ({ roleId, selectedScopes, onChange }: Props) => {
resource,
})),
}));
- }, [data, roleScopes]);
+ }, [allResources, roleScopes]);
const dataSource = useMemo(() => {
const lowerCasedKeyword = keyword.toLowerCase();
@@ -110,15 +128,22 @@ const SourceScopesBox = ({ roleId, selectedScopes, onChange }: Props) => {
/>
- {dataSource.map((resource) => (
-
- ))}
+ )}
+ {dataSource.length > 0 &&
+ dataSource.map((resource) => (
+
+ ))}
);
diff --git a/packages/console/src/components/RoleUsersTransfer/components/SourceUsersBox/index.module.scss b/packages/console/src/components/RoleUsersTransfer/components/SourceUsersBox/index.module.scss
index b6084458b..622fdba2e 100644
--- a/packages/console/src/components/RoleUsersTransfer/components/SourceUsersBox/index.module.scss
+++ b/packages/console/src/components/RoleUsersTransfer/components/SourceUsersBox/index.module.scss
@@ -5,3 +5,8 @@
.icon {
color: var(--color-text-secondary);
}
+
+.emptyImage {
+ width: 128px;
+ height: 128px;
+}
diff --git a/packages/console/src/components/RoleUsersTransfer/components/SourceUsersBox/index.tsx b/packages/console/src/components/RoleUsersTransfer/components/SourceUsersBox/index.tsx
index 2d446e4ea..a26fd2132 100644
--- a/packages/console/src/components/RoleUsersTransfer/components/SourceUsersBox/index.tsx
+++ b/packages/console/src/components/RoleUsersTransfer/components/SourceUsersBox/index.tsx
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
import useSWR from 'swr';
import Search from '@/assets/images/search.svg';
+import DataEmpty from '@/components/DataEmpty';
import Pagination from '@/components/Pagination';
import TextInput from '@/components/TextInput';
import { defaultPageSize } from '@/consts';
@@ -40,7 +41,9 @@ const SourceUsersBox = ({ roleId, selectedUsers, onChange }: Props) => {
...conditional(keyword && { search: formatKeyword(keyword) }),
});
- const { data } = useSWR<[User[], number], RequestError>(url);
+ const { data, error } = useSWR<[User[], number], RequestError>(url);
+
+ const isLoading = !data && !error;
const [dataSource = [], totalCount] = data ?? [];
@@ -64,24 +67,28 @@ const SourceUsersBox = ({ roleId, selectedUsers, onChange }: Props) => {
/>
- {dataSource.map((user) => {
- const isSelected = isUserAdded(user);
+ {!isLoading && !error && dataSource.length === 0 && (
+
+ )}
+ {dataSource.length > 0 &&
+ dataSource.map((user) => {
+ const isSelected = isUserAdded(user);
- return (
- {
- onChange(
- isSelected
- ? selectedUsers.filter(({ id }) => user.id !== id)
- : [user, ...selectedUsers]
- );
- }}
- />
- );
- })}
+ return (
+ {
+ onChange(
+ isSelected
+ ? selectedUsers.filter(({ id }) => user.id !== id)
+ : [user, ...selectedUsers]
+ );
+ }}
+ />
+ );
+ })}
{
- const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
- const theme = useTheme();
-
- return (
-
-
-
- {image ?? (theme === AppearanceMode.LightMode ? : )}
- {title ?? t('errors.empty')}
- {description && {description} }
- {children}
-
- |
-
- );
-};
+const TableEmpty = ({ columns, ...emptyProps }: Props) => (
+
+
+
+ |
+
+);
export default TableEmpty;
diff --git a/packages/console/src/components/UserRolesTransfer/components/SourceRolesBox/index.module.scss b/packages/console/src/components/UserRolesTransfer/components/SourceRolesBox/index.module.scss
index b6084458b..622fdba2e 100644
--- a/packages/console/src/components/UserRolesTransfer/components/SourceRolesBox/index.module.scss
+++ b/packages/console/src/components/UserRolesTransfer/components/SourceRolesBox/index.module.scss
@@ -5,3 +5,8 @@
.icon {
color: var(--color-text-secondary);
}
+
+.emptyImage {
+ width: 128px;
+ height: 128px;
+}
diff --git a/packages/console/src/components/UserRolesTransfer/components/SourceRolesBox/index.tsx b/packages/console/src/components/UserRolesTransfer/components/SourceRolesBox/index.tsx
index 49ff2e904..a1fc72e47 100644
--- a/packages/console/src/components/UserRolesTransfer/components/SourceRolesBox/index.tsx
+++ b/packages/console/src/components/UserRolesTransfer/components/SourceRolesBox/index.tsx
@@ -6,8 +6,10 @@ import { useTranslation } from 'react-i18next';
import useSWR from 'swr';
import Search from '@/assets/images/search.svg';
+import DataEmpty from '@/components/DataEmpty';
import Pagination from '@/components/Pagination';
import TextInput from '@/components/TextInput';
+import type { RequestError } from '@/hooks/use-api';
import useDebounce from '@/hooks/use-debounce';
import * as transferLayout from '@/scss/transfer.module.scss';
import { buildUrl } from '@/utilities/url';
@@ -39,7 +41,9 @@ const SourceRolesBox = ({ userId, selectedRoles, onChange }: Props) => {
...conditional(keyword && { search: `%${keyword}%` }),
});
- const { data } = useSWR<[RoleResponse[], number]>(url);
+ const { data, error } = useSWR<[RoleResponse[], number], RequestError>(url);
+
+ const isLoading = !data && !error;
const [dataSource = [], totalCount] = data ?? [];
@@ -64,24 +68,28 @@ const SourceRolesBox = ({ userId, selectedRoles, onChange }: Props) => {
/>
- {dataSource.map((role) => {
- const isSelected = isRoleSelected(role);
+ {!isLoading && !error && dataSource.length === 0 && (
+
+ )}
+ {dataSource.length > 0 &&
+ dataSource.map((role) => {
+ const isSelected = isRoleSelected(role);
- return (
- {
- onChange(
- isSelected
- ? selectedRoles.filter(({ id }) => id !== role.id)
- : [role, ...selectedRoles]
- );
- }}
- />
- );
- })}
+ return (
+ {
+ onChange(
+ isSelected
+ ? selectedRoles.filter(({ id }) => id !== role.id)
+ : [role, ...selectedRoles]
+ );
+ }}
+ />
+ );
+ })}