diff --git a/packages/console/src/components/RoleUsersTransfer/SourceUsersBox.tsx b/packages/console/src/components/RoleUsersTransfer/SourceUsersBox.tsx new file mode 100644 index 000000000..973d225f6 --- /dev/null +++ b/packages/console/src/components/RoleUsersTransfer/SourceUsersBox.tsx @@ -0,0 +1,129 @@ +import type { User } from '@logto/schemas'; +import { conditionalString } 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 Checkbox from '../Checkbox'; +import Pagination from '../Pagination'; +import TextInput from '../TextInput'; +import UserAvatar from '../UserAvatar'; +import * as styles from './index.module.scss'; + +type Props = { + onAddUser: (user: User) => void; + onRemoveUser: (user: User) => void; + selectedUsers: User[]; +}; + +const pageSize = 20; +const searchDelay = 500; + +const SourceUsersBox = ({ selectedUsers, onAddUser, onRemoveUser }: Props) => { + const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); + const [pageIndex, setPageIndex] = useState(1); + const [keyword, setKeyword] = useState(''); + const timerRef = useRef(); + + const clearTimer = () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + }; + + useEffect(() => { + return () => { + clearTimer(); + }; + }, []); + + const { data } = useSWR<[User[], number], RequestError>( + `/api/users?page=${pageIndex}&page_size=${pageSize}&hideAdminUser=true${conditionalString( + keyword && `&search=${encodeURIComponent(`%${keyword}%`)}` + )}` + ); + + const [dataSource = [], totalCount] = data ?? []; + + const handleSearchInput = (event: ChangeEvent) => { + 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 ( +
+
+ } + placeholder={t('general.search_placeholder')} + onChange={handleSearchInput} + /> +
+
+ {dataSource.map((user) => { + const added = isUserAdded(user); + const { id, name, avatar } = user; + + return ( +
{ + onSelectUser(user); + })} + onClick={() => { + onSelectUser(user); + }} + > + { + onSelectUser(user); + }} + /> + +
{name ?? t('users.unnamed')}
+
+ ); + })} +
+ { + setPageIndex(page); + }} + /> +
+ ); +}; +export default SourceUsersBox; diff --git a/packages/console/src/components/RoleUsersTransfer/TargetUsersBox.tsx b/packages/console/src/components/RoleUsersTransfer/TargetUsersBox.tsx new file mode 100644 index 000000000..4ec941578 --- /dev/null +++ b/packages/console/src/components/RoleUsersTransfer/TargetUsersBox.tsx @@ -0,0 +1,52 @@ +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 ( +
+
+ + {`${selectedUsers.length} `} + {t('general.added')} + +
+
+ {selectedUsers.map((user) => { + const { id, avatar, name } = user; + + return ( +
+ +
{name ?? t('users.unnamed')}
+ { + onRemoveUser(user); + }} + > + + +
+ ); + })} +
+
+ ); +}; + +export default TargetUsersBox; diff --git a/packages/console/src/components/RoleUsersTransfer/index.module.scss b/packages/console/src/components/RoleUsersTransfer/index.module.scss new file mode 100644 index 000000000..bd134f513 --- /dev/null +++ b/packages/console/src/components/RoleUsersTransfer/index.module.scss @@ -0,0 +1,81 @@ +@use '@/scss/underscore' as _; + +.container { + border: 1px solid var(--color-border); + border-radius: 6px; + display: flex; + align-items: stretch; + overflow: hidden; + 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); + } + } +} + diff --git a/packages/console/src/components/RoleUsersTransfer/index.tsx b/packages/console/src/components/RoleUsersTransfer/index.tsx new file mode 100644 index 000000000..8d169f141 --- /dev/null +++ b/packages/console/src/components/RoleUsersTransfer/index.tsx @@ -0,0 +1,30 @@ +import type { User } from '@logto/schemas'; + +import SourceUsersBox from './SourceUsersBox'; +import TargetUsersBox from './TargetUsersBox'; +import * as styles from './index.module.scss'; + +type Props = { + value: User[]; + onChange: (value: User[]) => void; +}; + +const RoleUsersTransfer = ({ value, onChange }: Props) => { + const onAddUser = (user: User) => { + onChange([user, ...value]); + }; + + const onRemoveUser = (user: User) => { + onChange(value.filter(({ id }) => id !== user.id)); + }; + + return ( +
+ +
+ +
+ ); +}; + +export default RoleUsersTransfer;