0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-31 22:51:25 -05:00

refactor(console): add empty state to transfers (#2956)

This commit is contained in:
Xiao Yijun 2023-01-17 12:30:25 +08:00 committed by GitHub
parent 448599e8d4
commit 5f2140eb0f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 195 additions and 97 deletions

View file

@ -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);
}
}

View file

@ -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 (
<div className={styles.empty}>
{image ??
(theme === AppearanceMode.LightMode ? (
<Empty className={imageClassName} />
) : (
<EmptyDark className={imageClassName} />
))}
<div className={styles.title}>{title ?? t('errors.empty')}</div>
{description && <div className={styles.description}>{description}</div>}
{children}
</div>
);
};
export default DataEmpty;

View file

@ -5,3 +5,8 @@
.icon {
color: var(--color-text-secondary);
}
.emptyImage {
width: 128px;
height: 128px;
}

View file

@ -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<ResourceResponse[]>('/api/resources?includeScopes=true');
const { data: roleScopes = [] } = useSWR<Scope[]>(roleId && `/api/roles/${roleId}/scopes`);
const { data: allResources, error: fetchAllResourcesError } = useSWR<
ResourceResponse[],
RequestError
>('/api/resources?includeScopes=true');
const { data: roleScopes, error: fetchRoleScopesError } = useSWR<Scope[], RequestError>(
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) => {
/>
</div>
<div className={transferLayout.boxContent}>
{dataSource.map((resource) => (
<ResourceItem
key={resource.id}
resource={resource}
selectedScopes={getResourceSelectedScopes(resource)}
onSelectResource={onSelectResource}
onSelectScope={onSelectScope}
{!isLoading && !hasError && dataSource.length === 0 && (
<DataEmpty
imageClassName={styles.emptyImage}
title={t('role_details.permission.empty')}
/>
))}
)}
{dataSource.length > 0 &&
dataSource.map((resource) => (
<ResourceItem
key={resource.id}
resource={resource}
selectedScopes={getResourceSelectedScopes(resource)}
onSelectResource={onSelectResource}
onSelectScope={onSelectScope}
/>
))}
</div>
</div>
);

View file

@ -5,3 +5,8 @@
.icon {
color: var(--color-text-secondary);
}
.emptyImage {
width: 128px;
height: 128px;
}

View file

@ -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) => {
/>
</div>
<div className={transferLayout.boxContent}>
{dataSource.map((user) => {
const isSelected = isUserAdded(user);
{!isLoading && !error && dataSource.length === 0 && (
<DataEmpty imageClassName={styles.emptyImage} title={t('role_details.users.empty')} />
)}
{dataSource.length > 0 &&
dataSource.map((user) => {
const isSelected = isUserAdded(user);
return (
<SourceUserItem
key={user.id}
user={user}
isSelected={isSelected}
onSelect={() => {
onChange(
isSelected
? selectedUsers.filter(({ id }) => user.id !== id)
: [user, ...selectedUsers]
);
}}
/>
);
})}
return (
<SourceUserItem
key={user.id}
user={user}
isSelected={isSelected}
onSelect={() => {
onChange(
isSelected
? selectedUsers.filter(({ id }) => user.id !== id)
: [user, ...selectedUsers]
);
}}
/>
);
})}
</div>
<Pagination
mode="pico"

View file

@ -1,23 +1,3 @@
@use '@/scss/underscore' as _;
.tableEmptyTableData {
border-bottom: unset;
}
.tableEmpty {
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);
}
}

View file

@ -1,37 +1,18 @@
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 DataEmpty from '@/components/DataEmpty';
import type { Props as DataEmptyProps } from '@/components/DataEmpty';
import * as styles from './TableEmpty.module.scss';
type Props = {
title?: string;
description?: string;
image?: ReactNode;
children?: ReactNode;
type Props = DataEmptyProps & {
columns: number;
};
const TableEmpty = ({ title, description, image, children, columns }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const theme = useTheme();
return (
<tr>
<td colSpan={columns} className={styles.tableEmptyTableData}>
<div className={styles.tableEmpty}>
{image ?? (theme === AppearanceMode.LightMode ? <Empty /> : <EmptyDark />)}
<div className={styles.title}>{title ?? t('errors.empty')}</div>
{description && <div className={styles.description}>{description}</div>}
{children}
</div>
</td>
</tr>
);
};
const TableEmpty = ({ columns, ...emptyProps }: Props) => (
<tr>
<td colSpan={columns} className={styles.tableEmptyTableData}>
<DataEmpty {...emptyProps} />
</td>
</tr>
);
export default TableEmpty;

View file

@ -5,3 +5,8 @@
.icon {
color: var(--color-text-secondary);
}
.emptyImage {
width: 128px;
height: 128px;
}

View file

@ -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) => {
/>
</div>
<div className={transferLayout.boxContent}>
{dataSource.map((role) => {
const isSelected = isRoleSelected(role);
{!isLoading && !error && dataSource.length === 0 && (
<DataEmpty imageClassName={styles.emptyImage} title={t('user_details.roles.empty')} />
)}
{dataSource.length > 0 &&
dataSource.map((role) => {
const isSelected = isRoleSelected(role);
return (
<SourceRoleItem
key={role.id}
role={role}
isSelected={isSelected}
onSelect={() => {
onChange(
isSelected
? selectedRoles.filter(({ id }) => id !== role.id)
: [role, ...selectedRoles]
);
}}
/>
);
})}
return (
<SourceRoleItem
key={role.id}
role={role}
isSelected={isSelected}
onSelect={() => {
onChange(
isSelected
? selectedRoles.filter(({ id }) => id !== role.id)
: [role, ...selectedRoles]
);
}}
/>
);
})}
</div>
<Pagination
mode="pico"

View file

@ -25,6 +25,7 @@ const role_details = {
deletion_description:
'If this permission is deleted, the affected user with this role will lose the access granted by this permission.', // UNTRANSLATED
permission_deleted: 'The permission "{{name}}" was successfully removed from this role!', // UNTRANSLATED
empty: 'No permission available', // UNTRANSLATED
},
users: {
assign_button: 'Assign Users', // UNTRANSLATED
@ -40,6 +41,7 @@ const role_details = {
assign_users_field: 'Assign users', // UNTRANSLATED
confirm_assign: 'Assign users', // UNTRANSLATED
users_assigned: 'The selected users were successfully assigned to this role!', // UNTRANSLATED
empty: 'No user available', // UNTRANSLATED
},
};

View file

@ -58,6 +58,7 @@ const user_details = {
confirm_assign: 'Assign roles', // UNTRANSLATED
role_assigned: 'Successfully assigned role(s)', // UNTRANSLATED
search: 'Search by role name, description or ID', // UNTRANSLATED
empty: 'No role available', // UNTRANSLATED
},
};

View file

@ -25,6 +25,7 @@ const role_details = {
deletion_description:
'If this permission is deleted, the affected user with this role will lose the access granted by this permission.',
permission_deleted: 'The permission "{{name}}" was successfully removed from this role!',
empty: 'No permission available',
},
users: {
assign_button: 'Assign Users',
@ -40,6 +41,7 @@ const role_details = {
assign_users_field: 'Assign users',
confirm_assign: 'Assign users',
users_assigned: 'The selected users were successfully assigned to this role!',
empty: 'No user available',
},
};

View file

@ -56,6 +56,7 @@ const user_details = {
confirm_assign: 'Assign roles',
role_assigned: 'Successfully assigned role(s)',
search: 'Search by role name, description or ID',
empty: 'No role available',
},
};

View file

@ -25,6 +25,7 @@ const role_details = {
deletion_description:
'If this permission is deleted, the affected user with this role will lose the access granted by this permission.', // UNTRANSLATED
permission_deleted: 'The permission "{{name}}" was successfully removed from this role!', // UNTRANSLATED
empty: 'No permission available', // UNTRANSLATED
},
users: {
assign_button: 'Assign Users', // UNTRANSLATED
@ -40,6 +41,7 @@ const role_details = {
assign_users_field: 'Assign users', // UNTRANSLATED
confirm_assign: 'Assign users', // UNTRANSLATED
users_assigned: 'The selected users were successfully assigned to this role!', // UNTRANSLATED
empty: 'No user available', // UNTRANSLATED
},
};

View file

@ -58,6 +58,7 @@ const user_details = {
confirm_assign: 'Assign roles', // UNTRANSLATED
role_assigned: 'Successfully assigned role(s)', // UNTRANSLATED
search: 'Search by role name, description or ID', // UNTRANSLATED
empty: 'No role available', // UNTRANSLATED
},
};

View file

@ -25,6 +25,7 @@ const role_details = {
deletion_description:
'If this permission is deleted, the affected user with this role will lose the access granted by this permission.', // UNTRANSLATED
permission_deleted: 'The permission "{{name}}" was successfully removed from this role!', // UNTRANSLATED
empty: 'No permission available', // UNTRANSLATED
},
users: {
assign_button: 'Assign Users', // UNTRANSLATED
@ -40,6 +41,7 @@ const role_details = {
assign_users_field: 'Assign users', // UNTRANSLATED
confirm_assign: 'Assign users', // UNTRANSLATED
users_assigned: 'The selected users were successfully assigned to this role!', // UNTRANSLATED
empty: 'No user available', // UNTRANSLATED
},
};

View file

@ -55,6 +55,7 @@ const user_details = {
confirm_assign: 'Assign roles', // UNTRANSLATED
role_assigned: 'Successfully assigned role(s)', // UNTRANSLATED
search: 'Search by role name, description or ID', // UNTRANSLATED
empty: 'No role available', // UNTRANSLATED
},
};

View file

@ -25,6 +25,7 @@ const role_details = {
deletion_description:
'If this permission is deleted, the affected user with this role will lose the access granted by this permission.', // UNTRANSLATED
permission_deleted: 'The permission "{{name}}" was successfully removed from this role!', // UNTRANSLATED
empty: 'No permission available', // UNTRANSLATED
},
users: {
assign_button: 'Assign Users', // UNTRANSLATED
@ -40,6 +41,7 @@ const role_details = {
assign_users_field: 'Assign users', // UNTRANSLATED
confirm_assign: 'Assign users', // UNTRANSLATED
users_assigned: 'The selected users were successfully assigned to this role!', // UNTRANSLATED
empty: 'No user available', // UNTRANSLATED
},
};

View file

@ -56,6 +56,7 @@ const user_details = {
confirm_assign: 'Assign roles', // UNTRANSLATED
role_assigned: 'Successfully assigned role(s)', // UNTRANSLATED
search: 'Search by role name, description or ID', // UNTRANSLATED
empty: 'No role available', // UNTRANSLATED
},
};

View file

@ -25,6 +25,7 @@ const role_details = {
deletion_description:
'If this permission is deleted, the affected user with this role will lose the access granted by this permission.', // UNTRANSLATED
permission_deleted: 'The permission "{{name}}" was successfully removed from this role!', // UNTRANSLATED
empty: 'No permission available', // UNTRANSLATED
},
users: {
assign_button: 'Assign Users', // UNTRANSLATED
@ -40,6 +41,7 @@ const role_details = {
assign_users_field: 'Assign users', // UNTRANSLATED
confirm_assign: 'Assign users', // UNTRANSLATED
users_assigned: 'The selected users were successfully assigned to this role!', // UNTRANSLATED
empty: 'No user available', // UNTRANSLATED
},
};

View file

@ -58,6 +58,7 @@ const user_details = {
confirm_assign: 'Assign roles', // UNTRANSLATED
role_assigned: 'Successfully assigned role(s)', // UNTRANSLATED
search: 'Search by role name, description or ID', // UNTRANSLATED
empty: 'No role available', // UNTRANSLATED
},
};

View file

@ -25,6 +25,7 @@ const role_details = {
deletion_description:
'If this permission is deleted, the affected user with this role will lose the access granted by this permission.', // UNTRANSLATED
permission_deleted: 'The permission "{{name}}" was successfully removed from this role!', // UNTRANSLATED
empty: 'No permission available', // UNTRANSLATED
},
users: {
assign_button: 'Assign Users', // UNTRANSLATED
@ -40,6 +41,7 @@ const role_details = {
assign_users_field: 'Assign users', // UNTRANSLATED
confirm_assign: 'Assign users', // UNTRANSLATED
users_assigned: 'The selected users were successfully assigned to this role!', // UNTRANSLATED
empty: 'No user available', // UNTRANSLATED
},
};

View file

@ -56,6 +56,7 @@ const user_details = {
confirm_assign: 'Assign roles', // UNTRANSLATED
role_assigned: 'Successfully assigned role(s)', // UNTRANSLATED
search: 'Search by role name, description or ID', // UNTRANSLATED
empty: 'No role available', // UNTRANSLATED
},
};

View file

@ -25,6 +25,7 @@ const role_details = {
deletion_description:
'If this permission is deleted, the affected user with this role will lose the access granted by this permission.', // UNTRANSLATED
permission_deleted: 'The permission "{{name}}" was successfully removed from this role!', // UNTRANSLATED
empty: 'No permission available', // UNTRANSLATED
},
users: {
assign_button: 'Assign Users', // UNTRANSLATED
@ -40,6 +41,7 @@ const role_details = {
assign_users_field: 'Assign users', // UNTRANSLATED
confirm_assign: 'Assign users', // UNTRANSLATED
users_assigned: 'The selected users were successfully assigned to this role!', // UNTRANSLATED
empty: 'No user available', // UNTRANSLATED
},
};

View file

@ -54,6 +54,7 @@ const user_details = {
confirm_assign: 'Assign roles', // UNTRANSLATED
role_assigned: 'Successfully assigned role(s)', // UNTRANSLATED
search: 'Search by role name, description or ID', // UNTRANSLATED
empty: 'No role available', // UNTRANSLATED
},
};