0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-24 22:05:56 -05:00

feat(console): implement DataTransferBox ds-component (#5229)

* feat(console): implement DataTransferBox ds-component

implement DataTransferBox ds-component

* chore(console): add some comments

add some comments
This commit is contained in:
simeng-li 2024-01-19 21:05:31 +08:00 committed by GitHub
parent e74c0cbc92
commit 475c9d5ae8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 608 additions and 2 deletions

View file

@ -7,6 +7,10 @@ import SourceScopesBox from './components/SourceScopesBox';
import TargetScopesBox from './components/TargetScopesBox';
import * as styles from './index.module.scss';
/**
* @deprecated Use `@/ds-component/DataTransferBox` instead.
*/
type Props = {
roleId?: string;
roleType: RoleType;

View file

@ -17,6 +17,7 @@ export type ConfirmModalProps = {
children: ReactNode;
className?: string;
title?: AdminConsoleKey | ReactElement<typeof DangerousRaw>;
subtitle?: AdminConsoleKey | ReactElement<typeof DangerousRaw>;
confirmButtonType?: ButtonType;
confirmButtonText?: AdminConsoleKey | ReactElement<typeof DangerousRaw>;
cancelButtonText?: AdminConsoleKey | ReactElement<typeof DangerousRaw>;
@ -33,6 +34,7 @@ function ConfirmModal({
children,
className,
title = 'general.reminder',
subtitle,
confirmButtonType = 'danger',
confirmButtonText = 'general.confirm',
cancelButtonText = 'general.cancel',
@ -54,6 +56,9 @@ function ConfirmModal({
>
<ModalLayout
title={title}
subtitle={subtitle}
className={classNames(styles.content, className)}
size={size}
footer={
<>
{isCancelButtonVisible && onCancel && (
@ -70,8 +75,6 @@ function ConfirmModal({
)}
</>
}
className={classNames(styles.content, className)}
size={size}
onClose={onCancel}
>
{children}

View file

@ -0,0 +1,20 @@
@use '@/scss/underscore' as _;
.dataItem {
display: flex;
align-items: center;
padding: _.unit(1.5) _.unit(4);
.name {
font: var(--font-body-2);
padding: _.unit(1) _.unit(2);
border-radius: 6px;
background: var(--color-neutral-95);
@include _.text-ellipsis;
cursor: pointer;
}
.icon {
color: var(--color-text-secondary);
}
}

View file

@ -0,0 +1,40 @@
import Checkbox from '@/ds-components/Checkbox';
import { onKeyDownHandler } from '@/utils/a11y';
import type { DataEntry } from '../type';
import * as styles from './index.module.scss';
type Props<TEntry extends DataEntry> = {
data: TEntry;
isSelected: boolean;
onSelect: (data: TEntry) => void;
};
function SourceDataItem<TEntry extends DataEntry>({ data, isSelected, onSelect }: Props<TEntry>) {
return (
<div className={styles.dataItem}>
<Checkbox
checked={isSelected}
onChange={() => {
onSelect(data);
}}
/>
<div
className={styles.name}
role="button"
tabIndex={0}
onKeyDown={onKeyDownHandler(() => {
onSelect(data);
})}
onClick={() => {
onSelect(data);
}}
>
{data.name}
</div>
</div>
);
}
export default SourceDataItem;

View file

@ -0,0 +1,43 @@
@use '@/scss/underscore' as _;
.groupItem {
user-select: none;
.title {
display: flex;
align-items: center;
padding: _.unit(1.5) _.unit(4);
.group {
flex: 1;
display: flex;
align-items: center;
cursor: pointer;
overflow: hidden;
.caret {
margin-right: _.unit(2);
}
.name {
font: var(--font-label-2);
@include _.text-ellipsis;
}
.dataInfo {
flex-shrink: 0;
font: var(--font-body-2);
color: var(--color-text-secondary);
margin-left: _.unit(2);
}
}
}
.invisible {
display: none;
}
}
.dataList {
padding-left: _.unit(10);
}

View file

@ -0,0 +1,97 @@
import classNames from 'classnames';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import CaretExpanded from '@/assets/icons/caret-expanded.svg';
import CaretFolded from '@/assets/icons/caret-folded.svg';
import Checkbox from '@/ds-components/Checkbox';
import IconButton from '@/ds-components/IconButton';
import { onKeyDownHandler } from '@/utils/a11y';
import SourceDataItem from '../SourceDataItem';
import { type DataEntry, type DataGroup, type SelectedDataEntry } from '../type';
import * as styles from './index.module.scss';
type Props<TEntry extends DataEntry> = {
dataGroup: DataGroup<TEntry>;
selectedGroupDataList: Array<SelectedDataEntry<TEntry>>;
onSelectDataGroup: (group: DataGroup<TEntry>) => void;
onSelectData: (data: TEntry) => void;
};
/**
* SourceGroupItem is a component that renders a group of data in the source panel.
*
* e.g. API resource scopes grouped under the same API resource.
*
* @param dataGroup - The data group to be rendered. e.g. resource with scopes
* @param selectedGroupDataList - The list of selected data in the group.
* @param onSelectDataGroup - The callback function to select the whole group.
* @param onSelectData - The callback function to select a single data within the group.
*/
function SourceGroupItem<TEntry extends DataEntry>({
dataGroup,
selectedGroupDataList,
onSelectDataGroup,
onSelectData,
}: Props<TEntry>) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { groupName, groupId, dataList } = dataGroup;
const selectedDataIdSet = new Set(selectedGroupDataList.map(({ id }) => id));
const selectedCount = selectedDataIdSet.size;
const totalCount = dataList.length;
const [isDataListHidden, setIsDataListHidden] = useState(true);
return (
<div className={styles.groupItem}>
<div className={styles.title}>
<Checkbox
checked={selectedCount === totalCount}
indeterminate={selectedCount > 0 && selectedCount < totalCount}
onChange={() => {
onSelectDataGroup(dataGroup);
}}
/>
<div
role="button"
tabIndex={0}
className={styles.group}
onKeyDown={onKeyDownHandler(() => {
setIsDataListHidden(!isDataListHidden);
})}
onClick={() => {
setIsDataListHidden(!isDataListHidden);
}}
>
<IconButton size="medium" className={styles.caret}>
{isDataListHidden ? <CaretFolded /> : <CaretExpanded />}
</IconButton>
<div className={styles.name}>{groupName}</div>
<div className={styles.dataInfo}>
({t('role_details.permission.api_permission_count', { count: dataList.length })})
</div>
</div>
</div>
<div className={classNames(isDataListHidden && styles.invisible, styles.dataList)}>
{dataList.map((data) => (
<SourceDataItem
key={data.id}
data={data}
isSelected={selectedDataIdSet.has(data.id)}
onSelect={() => {
onSelectData({
...data,
groupName,
groupId,
});
}}
/>
))}
</div>
</div>
);
}
export default SourceGroupItem;

View file

@ -0,0 +1,9 @@
@use '@/scss/underscore' as _;
.search {
width: 100%;
}
.icon {
color: var(--color-text-secondary);
}

View file

@ -0,0 +1,191 @@
import classNames from 'classnames';
import { useState, type ChangeEvent, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import Search from '@/assets/icons/search.svg';
import EmptyDataPlaceholder from '@/components/EmptyDataPlaceholder';
import TextInput from '@/ds-components/TextInput';
import * as transferLayout from '@/scss/transfer.module.scss';
import SourceDataItem from '../SourceDataItem';
import SourceGroupItem from '../SourceGroupItem';
import { type DataEntry, type DataGroup, type SelectedDataEntry } from '../type';
import * as styles from './index.module.scss';
const appendUnique = <T extends DataEntry>(list: T[], items: T | T[]) => {
const newEntries = Array.isArray(items) ? items : [items];
return [...list, ...newEntries.filter((item) => list.every(({ id }) => id !== item.id))];
};
type Props<TEntry extends DataEntry> = {
selectedData: Array<SelectedDataEntry<TEntry>>;
setSelectedData: (dataList: Array<SelectedDataEntry<TEntry>>) => void;
availableDataList?: TEntry[];
availableDataGroups?: Array<DataGroup<TEntry>>;
};
function SourcePanel<TEntry extends DataEntry>({
selectedData,
setSelectedData,
availableDataList,
availableDataGroups,
}: Props<TEntry>) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
// Keyword search
const [keyword, setKeyword] = useState('');
const handleSearchInput = useCallback((event: ChangeEvent<HTMLInputElement>) => {
setKeyword(event.target.value);
}, []);
const isDataEntrySelected = useCallback(
(data: TEntry) => selectedData.findIndex(({ id }) => id === data.id) >= 0,
[selectedData]
);
// Get all the selected data by group
const getSelectedDataInGroup = useCallback(
({ dataList }: DataGroup<TEntry>) =>
selectedData.filter(({ id }) => dataList.some((data) => data.id === id)),
[selectedData]
);
// Toggle the data Entry selection status
const onSelectData = useCallback(
(data: SelectedDataEntry<TEntry>) => {
if (isDataEntrySelected(data)) {
setSelectedData(selectedData.filter(({ id }) => id !== data.id));
return;
}
setSelectedData(appendUnique(selectedData, data));
},
[isDataEntrySelected, selectedData, setSelectedData]
);
// Toggle the data group selection status
const onSelectDataGroup = useCallback(
({ groupName, groupId, dataList }: DataGroup<TEntry>) => {
const isAllSelected = dataList.every((data) => isDataEntrySelected(data));
// If all the data entities in the group are selected, remove them from the selected data list
if (isAllSelected) {
setSelectedData(
selectedData.filter(
({ id: selectedDataId }) =>
!dataList.some(({ id: groupDataId }) => groupDataId === selectedDataId)
)
);
return;
}
// Add all the data entities in the group to the selected data list
setSelectedData(
appendUnique(
selectedData,
dataList.map((data) => ({ ...data, groupName, groupId }))
)
);
},
[isDataEntrySelected, selectedData, setSelectedData]
);
// Get the keyword filtered available dataList
const filteredAvailableDataList = useMemo(() => {
if (!availableDataList) {
return;
}
const lowerCasedKeyword = keyword.toLowerCase();
if (!lowerCasedKeyword) {
return availableDataList;
}
return availableDataList.filter(({ name }) => name.toLowerCase().includes(lowerCasedKeyword));
}, [availableDataList, keyword]);
// Get the keyword filtered available dataGroups
const filteredAvailableDataGroups = useMemo(() => {
if (!availableDataGroups) {
return;
}
const lowerCasedKeyword = keyword.toLowerCase();
if (!lowerCasedKeyword) {
return availableDataGroups;
}
return (
availableDataGroups
.map((dataGroup) => {
// If the group name matches the keyword, return all the data in the group
if (dataGroup.groupName.toLowerCase().includes(lowerCasedKeyword)) {
return dataGroup;
}
// If the group name doesn't match the keyword, return the dataEntry name filtered dataList
return {
...dataGroup,
dataList: dataGroup.dataList.filter(({ name }) =>
lowerCasedKeyword ? name.toLowerCase().includes(lowerCasedKeyword) : true
),
};
})
// Filter out the dataGroups if the group name doesn't match the keyword and none of the dataEntry name matches the keyword
.filter(
(dataGroup) =>
dataGroup.groupName.toLowerCase().includes(lowerCasedKeyword) ||
dataGroup.dataList.length > 0
)
);
}, [availableDataGroups, keyword]);
const isEmpty = !filteredAvailableDataList?.length && !filteredAvailableDataGroups?.length;
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('role_details.permission.empty')} />
) : (
<>
{filteredAvailableDataGroups?.map((dataGroup) => (
<SourceGroupItem
key={dataGroup.groupId}
dataGroup={dataGroup}
selectedGroupDataList={getSelectedDataInGroup(dataGroup)}
onSelectData={onSelectData}
onSelectDataGroup={onSelectDataGroup}
/>
))}
{filteredAvailableDataList?.map((data) => (
<SourceDataItem
key={data.id}
data={data}
isSelected={isDataEntrySelected(data)}
onSelect={onSelectData}
/>
))}
</>
)}
</div>
</div>
);
}
export default SourcePanel;

View file

@ -0,0 +1,34 @@
@use '@/scss/underscore' as _;
.targetDataItem {
display: flex;
align-items: center;
padding: _.unit(1.5) _.unit(3) _.unit(1.5) _.unit(4);
.title {
flex: 1 1 0;
display: flex;
align-items: center;
font: var(--font-body-2);
overflow: hidden;
.name {
flex-shrink: 0;
max-width: 204px;
padding: _.unit(1) _.unit(2);
border-radius: 6px;
background: var(--color-neutral-95);
@include _.text-ellipsis;
}
.groupName {
margin: 0 _.unit(2);
color: var(--color-text-secondary);
@include _.text-ellipsis;
}
}
&:hover {
background: var(--color-hover);
}
}

View file

@ -0,0 +1,35 @@
import Close from '@/assets/icons/close.svg';
import IconButton from '@/ds-components/IconButton';
import { type DataEntry, type SelectedDataEntry } from '../type';
import * as styles from './index.module.scss';
type Props<TEntry extends DataEntry> = {
data: SelectedDataEntry<TEntry>;
onDelete: (data: SelectedDataEntry<TEntry>) => void;
};
function TargetDataItem<TEntry extends DataEntry>({ data, onDelete }: Props<TEntry>) {
const { name } = data;
const groupName = 'groupName' in data ? data.groupName : undefined;
return (
<div className={styles.targetDataItem}>
<div className={styles.title}>
<div className={styles.name}>{name}</div>
{groupName && <div className={styles.groupName}>{groupName}</div>}
</div>
<IconButton
size="small"
onClick={() => {
onDelete(data);
}}
>
<Close />
</IconButton>
</div>
);
}
export default TargetDataItem;

View file

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

View file

@ -0,0 +1,42 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import * as transferLayout from '@/scss/transfer.module.scss';
import TargetDataItem from '../TargetDataItem';
import { type DataEntry, type SelectedDataEntry } from '../type';
import * as styles from './index.module.scss';
type Props<TEntry extends DataEntry> = {
selectedData: Array<SelectedDataEntry<TEntry>>;
setSelectedData: (dataList: Array<SelectedDataEntry<TEntry>>) => void;
};
function TargetPanel<TEntry extends DataEntry>({ selectedData, setSelectedData }: Props<TEntry>) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const onDelete = useCallback(
({ id }: TEntry) => {
setSelectedData(selectedData.filter(({ id: selectedDataId }) => selectedDataId !== id));
},
[selectedData, setSelectedData]
);
return (
<div className={transferLayout.box}>
<div className={transferLayout.boxTopBar}>
<span className={styles.added}>
{t('role_details.permission.added_text', { count: selectedData.length })}
</span>
</div>
<div className={transferLayout.boxContent}>
{selectedData.map((data) => (
<TargetDataItem key={data.id} data={data} onDelete={onDelete} />
))}
</div>
</div>
);
}
export default TargetPanel;

View file

@ -0,0 +1,3 @@
.dataTransferBox {
height: 360px;
}

View file

@ -0,0 +1,65 @@
import { type AdminConsoleKey } from '@logto/phrases';
import classNames from 'classnames';
import { type ReactElement } from 'react';
import FormField from '@/ds-components/FormField';
import * as transferLayout from '@/scss/transfer.module.scss';
import type DangerousRaw from '../DangerousRaw';
import SourcePanel from './SourcePanel';
import TargetPanel from './TargetPanel';
import * as styles from './index.module.scss';
import { type DataEntry, type DataGroup, type SelectedDataEntry } from './type';
/**
* DataTransferBox is a component that allows users to select data from a list of available data in a form of a list or a tree.
*
* @param title - The title of the TransferBox. It can be a string or a React element.
* @param selectedData - The list of selected data.
* @param setSelectedData - The callback function to set the selected data.
* @param availableDataList - The list of available data. (List form)
* @param availableDataGroups - The list of available data groups. (Single level tree form)
*/
type Props<TEntry extends DataEntry> = {
title: AdminConsoleKey | ReactElement<typeof DangerousRaw>;
selectedData: Array<SelectedDataEntry<TEntry>>;
setSelectedData: (dataList: Array<SelectedDataEntry<TEntry>>) => void;
availableDataList?: TEntry[];
availableDataGroups?: Array<DataGroup<TEntry>>;
className?: string;
containerClassName?: string;
};
function DataTransferBox<TEntry extends DataEntry = DataEntry>({
title,
selectedData,
setSelectedData,
availableDataList,
availableDataGroups,
className,
containerClassName,
}: Props<TEntry>) {
return (
<FormField title={title} className={className}>
<div
className={classNames(transferLayout.container, styles.dataTransferBox, containerClassName)}
>
<SourcePanel
{...{
selectedData,
setSelectedData,
availableDataList,
availableDataGroups,
}}
/>
<div className={transferLayout.verticalBar} />
<TargetPanel {...{ selectedData, setSelectedData }} />
</div>
</FormField>
);
}
// eslint-disable-next-line import/no-unused-modules -- will be used in the following PR
export default DataTransferBox;

View file

@ -0,0 +1,17 @@
export type DataEntry = {
id: string;
name: string;
};
export type DataGroup<T extends DataEntry> = {
groupId: string;
groupName: string;
dataList: T[];
};
type DataWithGroupInfo<T extends DataEntry> = T & {
groupId: string;
groupName: string;
};
export type SelectedDataEntry<T extends DataEntry> = T | DataWithGroupInfo<T>;