0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

refactor(console): role users transfer component (#2912)

This commit is contained in:
Xiao Yijun 2023-01-12 16:23:37 +08:00 committed by GitHub
parent 17879ad293
commit d5826ccd4d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 302 additions and 289 deletions

View file

@ -1,135 +0,0 @@
import type { User } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import type { ChangeEvent } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import useSWR from 'swr';
import Search from '@/assets/images/search.svg';
import type { RequestError } from '@/hooks/use-api';
import { onKeyDownHandler } from '@/utilities/a11y';
import { buildUrl } from '@/utilities/url';
import Checkbox from '../Checkbox';
import Pagination from '../Pagination';
import TextInput from '../TextInput';
import UserAvatar from '../UserAvatar';
import * as styles from './index.module.scss';
type Props = {
roleId: string;
onAddUser: (user: User) => void;
onRemoveUser: (user: User) => void;
selectedUsers: User[];
};
const pageSize = 20;
const searchDelay = 500;
const SourceUsersBox = ({ roleId, selectedUsers, onAddUser, onRemoveUser }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const [pageIndex, setPageIndex] = useState(1);
const [keyword, setKeyword] = useState('');
const timerRef = useRef<NodeJS.Timeout>();
const clearTimer = () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
};
useEffect(() => {
return () => {
clearTimer();
};
}, []);
const url = buildUrl('/api/users', {
excludeRoleId: roleId,
hideAdminUser: 'true',
page: String(pageIndex),
page_size: String(pageSize),
...conditional(keyword && { search: `%${keyword}%` }),
});
const { data } = useSWR<[User[], number], RequestError>(url);
const [dataSource = [], totalCount] = data ?? [];
const handleSearchInput = (event: ChangeEvent<HTMLInputElement>) => {
clearTimer();
// eslint-disable-next-line @silverhand/fp/no-mutation
timerRef.current = setTimeout(() => {
setPageIndex(1);
setKeyword(event.target.value);
}, searchDelay);
};
const isUserAdded = (user: User) => selectedUsers.findIndex(({ id }) => id === user.id) >= 0;
const onSelectUser = (user: User) => {
const userAdded = isUserAdded(user);
if (userAdded) {
onRemoveUser(user);
return;
}
onAddUser(user);
};
return (
<div className={styles.box}>
<div className={styles.top}>
<TextInput
className={styles.search}
icon={<Search className={styles.icon} />}
placeholder={t('general.search_placeholder')}
onChange={handleSearchInput}
/>
</div>
<div className={styles.content}>
{dataSource.map((user) => {
const added = isUserAdded(user);
const { id, name, avatar } = user;
return (
<div
key={id}
role="button"
tabIndex={0}
className={styles.item}
onKeyDown={onKeyDownHandler(() => {
onSelectUser(user);
})}
onClick={() => {
onSelectUser(user);
}}
>
<Checkbox
checked={added}
disabled={false}
onChange={() => {
onSelectUser(user);
}}
/>
<UserAvatar className={styles.avatar} url={avatar} />
<div className={styles.name}>{name ?? t('users.unnamed')}</div>
</div>
);
})}
</div>
<Pagination
mode="pico"
pageIndex={pageIndex}
totalCount={totalCount}
pageSize={pageSize}
className={styles.pagination}
onChange={(page) => {
setPageIndex(page);
}}
/>
</div>
);
};
export default SourceUsersBox;

View file

@ -1,52 +0,0 @@
import type { User } from '@logto/schemas';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import Close from '@/assets/images/close.svg';
import IconButton from '../IconButton';
import UserAvatar from '../UserAvatar';
import * as styles from './index.module.scss';
type Props = {
selectedUsers: User[];
onRemoveUser: (user: User) => void;
};
const TargetUsersBox = ({ selectedUsers, onRemoveUser }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
return (
<div className={styles.box}>
<div className={classNames(styles.top, styles.added)}>
<span>
{`${selectedUsers.length} `}
{t('general.added')}
</span>
</div>
<div className={styles.content}>
{selectedUsers.map((user) => {
const { id, avatar, name } = user;
return (
<div key={id} className={styles.item}>
<UserAvatar className={styles.avatar} url={avatar} />
<div className={styles.name}>{name ?? t('users.unnamed')}</div>
<IconButton
size="small"
iconClassName={styles.icon}
onClick={() => {
onRemoveUser(user);
}}
>
<Close />
</IconButton>
</div>
);
})}
</div>
</div>
);
};
export default TargetUsersBox;

View file

@ -0,0 +1,28 @@
@use '@/scss/underscore' as _;
.item {
display: flex;
align-items: center;
padding: _.unit(2.5) _.unit(4);
cursor: pointer;
user-select: none;
.avatar {
width: 20px;
height: 20px;
border-radius: 4px;
object-fit: cover;
flex-shrink: 0;
margin-right: _.unit(2);
}
.name {
flex: 1 1 0;
font: var(--font-body-medium);
@include _.text-ellipsis;
}
&:hover {
background: var(--color-hover);
}
}

View file

@ -0,0 +1,44 @@
import type { User } from '@logto/schemas';
import { useTranslation } from 'react-i18next';
import Checkbox from '@/components/Checkbox';
import UserAvatar from '@/components/UserAvatar';
import { onKeyDownHandler } from '@/utilities/a11y';
import * as styles from './index.module.scss';
type Props = {
user: User;
isSelected: boolean;
onSelect: () => void;
};
const SourceUserItem = ({ user: { avatar, name }, isSelected, onSelect }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
return (
<div
role="button"
tabIndex={0}
className={styles.item}
onKeyDown={onKeyDownHandler(() => {
onSelect();
})}
onClick={() => {
onSelect();
}}
>
<Checkbox
checked={isSelected}
disabled={false}
onChange={() => {
onSelect();
}}
/>
<UserAvatar className={styles.avatar} url={avatar} />
<div className={styles.name}>{name ?? t('users.unnamed')}</div>
</div>
);
};
export default SourceUserItem;

View file

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

View file

@ -0,0 +1,99 @@
import type { User } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import type { ChangeEvent } from 'react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import useSWR from 'swr';
import Search from '@/assets/images/search.svg';
import Pagination from '@/components/Pagination';
import TextInput from '@/components/TextInput';
import { defaultPageSize } from '@/consts';
import type { RequestError } from '@/hooks/use-api';
import useDebounce from '@/hooks/use-debounce';
import { formatKeyword } from '@/hooks/use-table-search-params';
import * as transferLayout from '@/scss/transfer.module.scss';
import { buildUrl } from '@/utilities/url';
import SourceUserItem from '../SourceUserItem';
import * as styles from './index.module.scss';
type Props = {
roleId: string;
onChange: (value: User[]) => void;
selectedUsers: User[];
};
const searchDelay = 500;
const SourceUsersBox = ({ roleId, selectedUsers, onChange }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const [pageIndex, setPageIndex] = useState(1);
const [keyword, setKeyword] = useState('');
const debounce = useDebounce();
const url = buildUrl('/api/users', {
excludeRoleId: roleId,
hideAdminUser: String(true),
page: String(pageIndex),
page_size: String(defaultPageSize),
...conditional(keyword && { search: formatKeyword(keyword) }),
});
const { data } = useSWR<[User[], number], RequestError>(url);
const [dataSource = [], totalCount] = data ?? [];
const handleSearchInput = (event: ChangeEvent<HTMLInputElement>) => {
debounce(() => {
setPageIndex(1);
setKeyword(event.target.value);
}, searchDelay);
};
const isUserAdded = (user: User) => selectedUsers.findIndex(({ id }) => id === user.id) >= 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={transferLayout.boxContent}>
{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]
);
}}
/>
);
})}
</div>
<Pagination
mode="pico"
pageIndex={pageIndex}
totalCount={totalCount}
pageSize={defaultPageSize}
className={transferLayout.boxPagination}
onChange={(page) => {
setPageIndex(page);
}}
/>
</div>
);
};
export default SourceUsersBox;

View file

@ -0,0 +1,31 @@
@use '@/scss/underscore' as _;
.item {
display: flex;
align-items: center;
padding: _.unit(2.5) _.unit(4);
user-select: none;
.avatar {
width: 20px;
height: 20px;
border-radius: 4px;
object-fit: cover;
flex-shrink: 0;
margin-right: _.unit(2);
}
.name {
flex: 1 1 0;
font: var(--font-body-medium);
@include _.text-ellipsis;
}
.icon {
color: var(--color-text-secondary);
}
&:hover {
background: var(--color-hover);
}
}

View file

@ -0,0 +1,35 @@
import type { User } from '@logto/schemas';
import { useTranslation } from 'react-i18next';
import Close from '@/assets/images/close.svg';
import IconButton from '@/components/IconButton';
import UserAvatar from '@/components/UserAvatar';
import * as styles from './index.module.scss';
type Props = {
user: User;
onDelete: () => void;
};
const TargetUserItem = ({ user: { avatar, name }, onDelete }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
return (
<div className={styles.item}>
<UserAvatar className={styles.avatar} url={avatar} />
<div className={styles.name}>{name ?? t('users.unnamed')}</div>
<IconButton
size="small"
iconClassName={styles.icon}
onClick={() => {
onDelete();
}}
>
<Close />
</IconButton>
</div>
);
};
export default TargetUserItem;

View file

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

View file

@ -0,0 +1,40 @@
import type { User } from '@logto/schemas';
import { useTranslation } from 'react-i18next';
import * as transferLayout from '@/scss/transfer.module.scss';
import TargetUserItem from '../TargetUserItem';
import * as styles from './index.module.scss';
type Props = {
selectedUsers: User[];
onChange: (value: User[]) => void;
};
const TargetUsersBox = ({ selectedUsers, onChange }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
return (
<div className={transferLayout.box}>
<div className={transferLayout.boxTopBar}>
<span className={styles.added}>
{`${selectedUsers.length} `}
{t('general.added')}
</span>
</div>
<div className={transferLayout.boxContent}>
{selectedUsers.map((user) => (
<TargetUserItem
key={user.id}
user={user}
onDelete={() => {
onChange(selectedUsers.filter(({ id }) => id !== user.id));
}}
/>
))}
</div>
</div>
);
};
export default TargetUsersBox;

View file

@ -1,81 +1,5 @@
@use '@/scss/underscore' as _;
.container {
border: 1px solid var(--color-border);
border-radius: 6px;
display: flex;
align-items: stretch;
overflow: hidden;
.roleUsersTransfer {
height: 360px;
.verticalBar {
@include _.vertical-bar;
}
.box {
flex: 1 1 0;
overflow: hidden;
display: flex;
flex-direction: column;
height: 100%;
.top {
height: 52px;
border-bottom: 1px solid var(--color-border);
display: flex;
align-items: center;
padding: 0 _.unit(4);
.search {
width: 100%;
}
&.added {
font: var(--font-label-large);
}
}
.content {
flex: 1 1 0;
overflow-y: auto;
.item {
display: flex;
align-items: center;
padding: _.unit(2.5) _.unit(4);
cursor: pointer;
user-select: none;
.avatar {
width: 20px;
height: 20px;
border-radius: 4px;
object-fit: cover;
flex-shrink: 0;
margin-right: _.unit(2);
}
.name {
flex: 1 1 0;
font: var(--font-body-medium);
@include _.text-ellipsis;
}
&:hover {
background: var(--color-hover);
}
}
}
.pagination {
height: 40px;
padding-right: _.unit(4);
border-top: 1px solid var(--color-border);
}
.icon {
color: var(--color-text-secondary);
}
}
}

View file

@ -1,7 +1,10 @@
import type { User } from '@logto/schemas';
import classNames from 'classnames';
import SourceUsersBox from './SourceUsersBox';
import TargetUsersBox from './TargetUsersBox';
import * as transferLayout from '@/scss/transfer.module.scss';
import SourceUsersBox from './components/SourceUsersBox';
import TargetUsersBox from './components/TargetUsersBox';
import * as styles from './index.module.scss';
type Props = {
@ -10,27 +13,12 @@ type Props = {
onChange: (value: User[]) => void;
};
const RoleUsersTransfer = ({ roleId, value, onChange }: Props) => {
const onAddUser = (user: User) => {
onChange([user, ...value]);
};
const onRemoveUser = (user: User) => {
onChange(value.filter(({ id }) => id !== user.id));
};
return (
<div className={styles.container}>
<SourceUsersBox
roleId={roleId}
selectedUsers={value}
onAddUser={onAddUser}
onRemoveUser={onRemoveUser}
/>
<div className={styles.verticalBar} />
<TargetUsersBox selectedUsers={value} onRemoveUser={onRemoveUser} />
</div>
);
};
const RoleUsersTransfer = ({ roleId, value, onChange }: Props) => (
<div className={classNames(transferLayout.container, styles.roleUsersTransfer)}>
<SourceUsersBox roleId={roleId} selectedUsers={value} onChange={onChange} />
<div className={transferLayout.verticalBar} />
<TargetUsersBox selectedUsers={value} onChange={onChange} />
</div>
);
export default RoleUsersTransfer;

View file

@ -25,7 +25,7 @@ const AssignUsersModal = ({ roleId, isRemindSkip = false, onClose }: Props) => {
const api = useApi();
const handleAssign = async () => {
if (users.length === 0) {
if (isLoading || users.length === 0) {
return;
}
@ -70,6 +70,7 @@ const AssignUsersModal = ({ roleId, isRemindSkip = false, onClose }: Props) => {
/>
<Button
isLoading={isLoading}
disabled={users.length === 0}
htmlType="submit"
title="role_details.users.confirm_assign"
size="large"