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:
parent
e74c0cbc92
commit
475c9d5ae8
15 changed files with 608 additions and 2 deletions
|
@ -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;
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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);
|
||||
}
|
|
@ -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;
|
|
@ -0,0 +1,9 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
|
@ -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;
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -0,0 +1,3 @@
|
|||
.added {
|
||||
font: var(--font-label-2);
|
||||
}
|
|
@ -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;
|
|
@ -0,0 +1,3 @@
|
|||
.dataTransferBox {
|
||||
height: 360px;
|
||||
}
|
65
packages/console/src/ds-components/DataTransferBox/index.tsx
Normal file
65
packages/console/src/ds-components/DataTransferBox/index.tsx
Normal 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;
|
17
packages/console/src/ds-components/DataTransferBox/type.ts
Normal file
17
packages/console/src/ds-components/DataTransferBox/type.ts
Normal 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>;
|
Loading…
Add table
Reference in a new issue