0
Fork 0
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:
Gao Sun 2023-10-23 16:34:41 +08:00
parent 658f5f1423
commit f98b43c57f
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
14 changed files with 34 additions and 418 deletions

View file

@ -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 (
<>

View file

@ -1,7 +0,0 @@
.search {
width: 100%;
}
.icon {
color: var(--color-text-secondary);
}

View file

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

View file

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

View file

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

View file

@ -1,3 +0,0 @@
.added {
font: var(--font-label-2);
}

View file

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

View file

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

View file

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

View file

@ -1,5 +0,0 @@
@use '@/scss/underscore' as _;
.rolesTransfer {
height: 360px;
}

View file

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

View file

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

View file

@ -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>

View file

@ -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).
*/