mirror of
https://github.com/logto-io/logto.git
synced 2025-01-06 20:40:08 -05:00
refactor(console): remove role transfer component
This commit is contained in:
parent
658f5f1423
commit
f98b43c57f
14 changed files with 34 additions and 418 deletions
|
@ -25,7 +25,6 @@ type ApplicationItemProps = {
|
|||
entity: Application;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-unused-modules -- will use in the next pull request
|
||||
export function ApplicationItem({ entity }: ApplicationItemProps) {
|
||||
return (
|
||||
<>
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
.search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
|
@ -1,136 +0,0 @@
|
|||
import type { Application, User } from '@logto/schemas';
|
||||
import { ApplicationType, RoleType } from '@logto/schemas';
|
||||
import { conditional } from '@silverhand/essentials';
|
||||
import classNames from 'classnames';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import Search from '@/assets/icons/search.svg';
|
||||
import EmptyDataPlaceholder from '@/components/EmptyDataPlaceholder';
|
||||
import { defaultPageSize } from '@/consts';
|
||||
import Pagination from '@/ds-components/Pagination';
|
||||
import TextInput from '@/ds-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, formatSearchKeyword } from '@/utils/url';
|
||||
|
||||
import SourceEntityItem from '../SourceEntityItem';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props<T> = {
|
||||
roleId: string;
|
||||
roleType: RoleType;
|
||||
onChange: (value: T[]) => void;
|
||||
selectedEntities: T[];
|
||||
};
|
||||
|
||||
const pageSize = defaultPageSize;
|
||||
|
||||
function SourceEntitiesBox<T extends User | Application>({
|
||||
roleId,
|
||||
roleType,
|
||||
selectedEntities,
|
||||
onChange,
|
||||
}: Props<T>) {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const [page, setPage] = useState(1);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const debounce = useDebounce();
|
||||
|
||||
const commonSearchParams = {
|
||||
page: String(page),
|
||||
page_size: String(pageSize),
|
||||
...conditional(keyword && { search: formatSearchKeyword(keyword) }),
|
||||
};
|
||||
|
||||
const { data, error } = useSWR<[Props<T>['selectedEntities'], number], RequestError>(
|
||||
roleType === RoleType.User
|
||||
? buildUrl('api/users', {
|
||||
excludeRoleId: roleId,
|
||||
...commonSearchParams,
|
||||
})
|
||||
: buildUrl(`api/applications`, {
|
||||
excludeRoleId: roleId,
|
||||
...commonSearchParams,
|
||||
'search.type': ApplicationType.MachineToMachine,
|
||||
'mode.type': 'exact',
|
||||
})
|
||||
);
|
||||
|
||||
const isLoading = !data && !error;
|
||||
|
||||
const [dataSource = [], totalCount] = data ?? [];
|
||||
|
||||
const handleSearchInput = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
debounce(() => {
|
||||
setPage(1);
|
||||
setKeyword(event.target.value);
|
||||
});
|
||||
};
|
||||
|
||||
const isEntityAdded = (entity: User | Application) =>
|
||||
selectedEntities.findIndex(({ id }) => id === entity.id) >= 0;
|
||||
|
||||
const isEmpty = !isLoading && !error && dataSource.length === 0;
|
||||
|
||||
return (
|
||||
<div className={transferLayout.box}>
|
||||
<div className={transferLayout.boxTopBar}>
|
||||
<TextInput
|
||||
className={styles.search}
|
||||
icon={<Search className={styles.icon} />}
|
||||
placeholder={t('general.search_placeholder')}
|
||||
onChange={handleSearchInput}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(transferLayout.boxContent, isEmpty && transferLayout.emptyBoxContent)}
|
||||
>
|
||||
{isEmpty ? (
|
||||
<EmptyDataPlaceholder
|
||||
size="small"
|
||||
title={t(
|
||||
roleType === RoleType.User
|
||||
? 'role_details.users.empty'
|
||||
: 'role_details.applications.empty'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
dataSource.map((entity) => {
|
||||
const isSelected = isEntityAdded(entity);
|
||||
|
||||
return (
|
||||
<SourceEntityItem
|
||||
key={entity.id}
|
||||
entity={entity}
|
||||
isSelected={isSelected}
|
||||
onSelect={() => {
|
||||
onChange(
|
||||
isSelected
|
||||
? selectedEntities.filter(({ id }) => entity.id !== id)
|
||||
: [entity, ...selectedEntities]
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<Pagination
|
||||
mode="pico"
|
||||
page={page}
|
||||
totalCount={totalCount}
|
||||
pageSize={pageSize}
|
||||
className={transferLayout.boxPagination}
|
||||
onChange={(page) => {
|
||||
setPage(page);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default SourceEntitiesBox;
|
|
@ -1,31 +0,0 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: _.unit(2.5) _.unit(4);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
.icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1 1 0;
|
||||
font: var(--font-body-2);
|
||||
@include _.text-ellipsis;
|
||||
margin-left: _.unit(2);
|
||||
max-width: fit-content;
|
||||
}
|
||||
|
||||
.suspended {
|
||||
margin-left: _.unit(1);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
}
|
|
@ -1,55 +0,0 @@
|
|||
import type { User, Application } from '@logto/schemas';
|
||||
import { ApplicationType } from '@logto/schemas';
|
||||
|
||||
import ApplicationIcon from '@/components/ApplicationIcon';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import Checkbox from '@/ds-components/Checkbox';
|
||||
import SuspendedTag from '@/pages/Users/components/SuspendedTag';
|
||||
import { onKeyDownHandler } from '@/utils/a11y';
|
||||
import { getUserTitle } from '@/utils/user';
|
||||
|
||||
import { isUser } from '../../utils';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props<T> = {
|
||||
entity: T;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
};
|
||||
|
||||
function SourceEntityItem<T extends User | Application>({
|
||||
entity,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: Props<T>) {
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={styles.item}
|
||||
onKeyDown={onKeyDownHandler(() => {
|
||||
onSelect();
|
||||
})}
|
||||
onClick={() => {
|
||||
onSelect();
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={() => {
|
||||
onSelect();
|
||||
}}
|
||||
/>
|
||||
{isUser(entity) ? (
|
||||
<UserAvatar hasTooltip user={entity} size="micro" />
|
||||
) : (
|
||||
<ApplicationIcon type={ApplicationType.MachineToMachine} className={styles.icon} />
|
||||
)}
|
||||
<div className={styles.title}>{isUser(entity) ? getUserTitle(entity) : entity.name}</div>
|
||||
{isUser(entity) && entity.isSuspended && <SuspendedTag className={styles.suspended} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SourceEntityItem;
|
|
@ -1,3 +0,0 @@
|
|||
.added {
|
||||
font: var(--font-label-2);
|
||||
}
|
|
@ -1,41 +0,0 @@
|
|||
import type { User, Application } from '@logto/schemas';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as transferLayout from '@/scss/transfer.module.scss';
|
||||
|
||||
import TargetEntityItem from '../TargetEntityItem';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props<T> = {
|
||||
selectedEntities: T[];
|
||||
onChange: (value: T[]) => void;
|
||||
};
|
||||
|
||||
function TargetEntitiesBox<T extends User | Application>({ selectedEntities, onChange }: Props<T>) {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
return (
|
||||
<div className={transferLayout.box}>
|
||||
<div className={transferLayout.boxTopBar}>
|
||||
<span className={styles.added}>
|
||||
{`${selectedEntities.length} `}
|
||||
{t('general.added')}
|
||||
</span>
|
||||
</div>
|
||||
<div className={transferLayout.boxContent}>
|
||||
{selectedEntities.map((entity) => (
|
||||
<TargetEntityItem
|
||||
key={entity.id}
|
||||
entity={entity}
|
||||
onDelete={() => {
|
||||
onChange(selectedEntities.filter(({ id }) => id !== entity.id));
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TargetEntitiesBox;
|
|
@ -1,37 +0,0 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: _.unit(2) _.unit(3) _.unit(2) _.unit(4);
|
||||
user-select: none;
|
||||
|
||||
.meta {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
|
||||
.icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1 1 0;
|
||||
font: var(--font-body-2);
|
||||
@include _.text-ellipsis;
|
||||
margin-left: _.unit(2);
|
||||
max-width: fit-content;
|
||||
}
|
||||
|
||||
.suspended {
|
||||
margin: 0 _.unit(1);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
}
|
|
@ -1,44 +0,0 @@
|
|||
import type { User, Application } from '@logto/schemas';
|
||||
import { ApplicationType } from '@logto/schemas';
|
||||
|
||||
import Close from '@/assets/icons/close.svg';
|
||||
import ApplicationIcon from '@/components/ApplicationIcon';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import IconButton from '@/ds-components/IconButton';
|
||||
import SuspendedTag from '@/pages/Users/components/SuspendedTag';
|
||||
import { getUserTitle } from '@/utils/user';
|
||||
|
||||
import { isUser } from '../../utils';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props<T> = {
|
||||
entity: T;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
function TargetEntityItem<T extends User | Application>({ entity, onDelete }: Props<T>) {
|
||||
return (
|
||||
<div className={styles.item}>
|
||||
<div className={styles.meta}>
|
||||
{isUser(entity) ? (
|
||||
<UserAvatar hasTooltip user={entity} size="micro" />
|
||||
) : (
|
||||
<ApplicationIcon type={ApplicationType.MachineToMachine} className={styles.icon} />
|
||||
)}
|
||||
<div className={styles.title}>{isUser(entity) ? getUserTitle(entity) : entity.name}</div>
|
||||
{isUser(entity) && entity.isSuspended && <SuspendedTag className={styles.suspended} />}
|
||||
</div>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<Close />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TargetEntityItem;
|
|
@ -1,5 +0,0 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.rolesTransfer {
|
||||
height: 360px;
|
||||
}
|
|
@ -1,37 +0,0 @@
|
|||
import type { Application, User, RoleType } from '@logto/schemas';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import * as transferLayout from '@/scss/transfer.module.scss';
|
||||
|
||||
import SourceEntitiesBox from './components/SourceEntitiesBox';
|
||||
import TargetEntitiesBox from './components/TargetEntitiesBox';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props<T> = {
|
||||
roleId: string;
|
||||
roleType: RoleType;
|
||||
value: T[];
|
||||
onChange: (value: T[]) => void;
|
||||
};
|
||||
|
||||
function RoleEntitiesTransfer<T extends User | Application>({
|
||||
roleId,
|
||||
roleType,
|
||||
value,
|
||||
onChange,
|
||||
}: Props<T>) {
|
||||
return (
|
||||
<div className={classNames(transferLayout.container, styles.rolesTransfer)}>
|
||||
<SourceEntitiesBox
|
||||
roleId={roleId}
|
||||
roleType={roleType}
|
||||
selectedEntities={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<div className={transferLayout.verticalBar} />
|
||||
<TargetEntitiesBox selectedEntities={value} onChange={onChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RoleEntitiesTransfer;
|
|
@ -1,4 +0,0 @@
|
|||
import type { User, Application } from '@logto/schemas';
|
||||
|
||||
export const isUser = (entity: User | Application): entity is User =>
|
||||
'customData' in entity || 'identities' in entity;
|
|
@ -1,16 +1,20 @@
|
|||
import { type Application, RoleType, type User } from '@logto/schemas';
|
||||
import { type Application, RoleType, type User, ApplicationType } from '@logto/schemas';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ReactModal from 'react-modal';
|
||||
|
||||
import RoleEntitiesTransfer from '@/components/RoleEntitiesTransfer';
|
||||
import EntitiesTransfer from '@/components/EntitiesTransfer';
|
||||
import { ApplicationItem, UserItem } from '@/components/EntitiesTransfer/components/EntityItem';
|
||||
import Button from '@/ds-components/Button';
|
||||
import FormField from '@/ds-components/FormField';
|
||||
import ModalLayout from '@/ds-components/ModalLayout';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import * as modalStyles from '@/scss/modal.module.scss';
|
||||
|
||||
const isUserEntity = (entity: User | Application): entity is User =>
|
||||
'customData' in entity || 'identities' in entity;
|
||||
|
||||
type Props = {
|
||||
roleId: string;
|
||||
roleType: RoleType;
|
||||
|
@ -27,6 +31,7 @@ function AssignRoleModal<T extends Application | User>({
|
|||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [entities, setEntities] = useState<T[]>([]);
|
||||
const isUser = roleType === RoleType.User;
|
||||
|
||||
const api = useApi();
|
||||
|
||||
|
@ -38,7 +43,7 @@ function AssignRoleModal<T extends Application | User>({
|
|||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await (roleType === RoleType.User
|
||||
await (isUser
|
||||
? api.post(`api/roles/${roleId}/users`, {
|
||||
json: { userIds: entities.map(({ id }) => id) },
|
||||
})
|
||||
|
@ -47,7 +52,7 @@ function AssignRoleModal<T extends Application | User>({
|
|||
}));
|
||||
toast.success(
|
||||
t(
|
||||
roleType === RoleType.User
|
||||
isUser
|
||||
? 'role_details.users.users_assigned'
|
||||
: 'role_details.applications.applications_assigned'
|
||||
)
|
||||
|
@ -70,12 +75,10 @@ function AssignRoleModal<T extends Application | User>({
|
|||
>
|
||||
<ModalLayout
|
||||
title={
|
||||
roleType === RoleType.User
|
||||
? 'role_details.users.assign_title'
|
||||
: 'role_details.applications.assign_title'
|
||||
isUser ? 'role_details.users.assign_title' : 'role_details.applications.assign_title'
|
||||
}
|
||||
subtitle={
|
||||
roleType === RoleType.User
|
||||
isUser
|
||||
? 'role_details.users.assign_subtitle'
|
||||
: 'role_details.applications.assign_subtitle'
|
||||
}
|
||||
|
@ -97,7 +100,7 @@ function AssignRoleModal<T extends Application | User>({
|
|||
disabled={entities.length === 0}
|
||||
htmlType="submit"
|
||||
title={
|
||||
roleType === RoleType.User
|
||||
isUser
|
||||
? 'role_details.users.confirm_assign'
|
||||
: 'role_details.applications.confirm_assign'
|
||||
}
|
||||
|
@ -111,18 +114,33 @@ function AssignRoleModal<T extends Application | User>({
|
|||
>
|
||||
<FormField
|
||||
title={
|
||||
roleType === RoleType.User
|
||||
isUser
|
||||
? 'role_details.users.assign_users_field'
|
||||
: 'role_details.applications.assign_applications_field'
|
||||
}
|
||||
>
|
||||
<RoleEntitiesTransfer
|
||||
roleId={roleId}
|
||||
roleType={roleType}
|
||||
value={entities}
|
||||
onChange={(value) => {
|
||||
setEntities(value);
|
||||
<EntitiesTransfer
|
||||
searchProps={{
|
||||
pathname: isUser ? 'api/users' : 'api/applications',
|
||||
parameters: {
|
||||
excludeRoleId: roleId,
|
||||
...(isUser
|
||||
? { 'search.type': ApplicationType.MachineToMachine, 'mode.type': 'exact' }
|
||||
: {}),
|
||||
},
|
||||
}}
|
||||
selectedEntities={entities}
|
||||
emptyPlaceholder={
|
||||
isUser ? 'role_details.users.empty' : 'role_details.applications.empty'
|
||||
}
|
||||
renderEntity={(entity) =>
|
||||
isUserEntity(entity) ? (
|
||||
<UserItem entity={entity} />
|
||||
) : (
|
||||
<ApplicationItem entity={entity} />
|
||||
)
|
||||
}
|
||||
onChange={setEntities}
|
||||
/>
|
||||
</FormField>
|
||||
</ModalLayout>
|
||||
|
|
|
@ -233,7 +233,6 @@ const showLowercase = (
|
|||
*
|
||||
* @param search The search config object.
|
||||
* @param searchFields Allowed and default search fields (columns).
|
||||
* @param isCaseSensitive Should perform case sensitive search or not.
|
||||
* @returns The SQL token that includes the all condition checks.
|
||||
* @throws TypeError error if fields in `search` do not match the `searchFields`, or invalid condition found (e.g. the value is empty).
|
||||
*/
|
||||
|
|
Loading…
Reference in a new issue