0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-20 21:32:31 -05:00

refactor(console): role permissions transfer component (#2914)

This commit is contained in:
Xiao Yijun 2023-01-12 22:07:27 +08:00 committed by GitHub
parent f66fad07b9
commit ce0b2c1cd2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 279 additions and 305 deletions

View file

@ -1,116 +0,0 @@
import type { ResourceResponse, ScopeResponse } 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 TextInput from '../TextInput';
import ResourceItem from './components/ResourceItem';
import * as styles from './index.module.scss';
import type { DetailedResourceResponse } from './types';
type Props = {
excludeScopeIds: string[];
selectedPermissions: ScopeResponse[];
onChange: (value: ScopeResponse[]) => void;
};
const SourcePermissionsBox = ({ excludeScopeIds, selectedPermissions, onChange }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { data = [] } = useSWR<ResourceResponse[]>(`/api/resources?includeScopes=true`);
const [keyword, setKeyword] = useState('');
const handleSearchInput = (event: ChangeEvent<HTMLInputElement>) => {
setKeyword(event.target.value);
};
const isPermissionAdded = (scope: ScopeResponse) =>
selectedPermissions.findIndex(({ id }) => id === scope.id) >= 0;
const onSelectPermission = (scope: ScopeResponse) => {
const permissionAdded = isPermissionAdded(scope);
if (permissionAdded) {
onChange(selectedPermissions.filter(({ id }) => id !== scope.id));
return;
}
onChange([scope, ...selectedPermissions]);
};
const onSelectResource = ({ scopes }: DetailedResourceResponse) => {
const isAllSelected = scopes.every((scope) => isPermissionAdded(scope));
const scopesIds = new Set(scopes.map(({ id }) => id));
const basePermissions = selectedPermissions.filter(({ id }) => !scopesIds.has(id));
if (isAllSelected) {
onChange(basePermissions);
return;
}
onChange([...scopes, ...basePermissions]);
};
const getResourceSelectedPermissions = ({ scopes }: DetailedResourceResponse) =>
scopes.filter((scope) => selectedPermissions.findIndex(({ id }) => id === scope.id) >= 0);
const resources = data
.filter(({ scopes }) => scopes.some(({ id }) => !excludeScopeIds.includes(id)))
.map(({ scopes, ...resource }) => ({
...resource,
scopes: scopes
.filter(({ id }) => !excludeScopeIds.includes(id))
.map((scope) => ({
...scope,
resource,
})),
}));
const dataSource =
conditional(
keyword &&
resources
.filter(({ name, scopes }) => {
return name.includes(keyword) || scopes.some(({ name }) => name.includes(keyword));
})
.map(({ scopes, ...resource }) => ({
...resource,
scopes: scopes.filter(
({ name, resource }) => name.includes(keyword) || resource.name.includes(keyword)
),
}))
.filter(({ scopes }) => scopes.length > 0)
) ?? resources;
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((resource) => (
<ResourceItem
key={resource.id}
resource={resource}
selectedPermissions={getResourceSelectedPermissions(resource)}
onSelectResource={onSelectResource}
onSelectPermission={onSelectPermission}
/>
))}
</div>
</div>
);
};
export default SourcePermissionsBox;

View file

@ -1,38 +0,0 @@
import type { ScopeResponse } from '@logto/schemas';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import TargetPermissionItem from './components/TargetPermissionItem';
import * as styles from './index.module.scss';
type Props = {
selectedScopes: ScopeResponse[];
onRemovePermission: (scope: ScopeResponse) => void;
};
const TargetPermissionsBox = ({ selectedScopes, onRemovePermission }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
return (
<div className={styles.box}>
<div className={classNames(styles.top, styles.added)}>
<span>{t('role_details.permission.added_text', { value: selectedScopes.length })}</span>
</div>
<div className={styles.content}>
{selectedScopes.map((scope) => {
return (
<TargetPermissionItem
key={scope.id}
scope={scope}
onDelete={() => {
onRemovePermission(scope);
}}
/>
);
})}
</div>
</div>
);
};
export default TargetPermissionsBox;

View file

@ -1,41 +0,0 @@
import type { ScopeResponse } from '@logto/schemas';
import Checkbox from '@/components/Checkbox';
import { onKeyDownHandler } from '@/utilities/a11y';
import * as styles from './index.module.scss';
type Props = {
scope: ScopeResponse;
isSelected: boolean;
onSelectPermission: (scope: ScopeResponse) => void;
};
const SourcePermissionItem = ({ scope, isSelected, onSelectPermission }: Props) => {
const { name } = scope;
return (
<div
className={styles.sourcePermissionItem}
role="button"
tabIndex={0}
onKeyDown={onKeyDownHandler(() => {
onSelectPermission(scope);
})}
onClick={() => {
onSelectPermission(scope);
}}
>
<Checkbox
checked={isSelected}
disabled={false}
onChange={() => {
onSelectPermission(scope);
}}
/>
<div className={styles.name}>{name}</div>
</div>
);
};
export default SourcePermissionItem;

View file

@ -1,47 +0,0 @@
@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;
}
.icon {
color: var(--color-text-secondary);
}
}
}

View file

@ -1,30 +0,0 @@
import type { ScopeResponse } from '@logto/schemas';
import SourcePermissionsBox from './SourcePermissionsBox';
import TargetPermissionsBox from './TargetPermissionsBox';
import * as styles from './index.module.scss';
type Props = {
value: ScopeResponse[];
onChange: (value: ScopeResponse[]) => void;
excludeScopeIds?: string[];
};
const RolePermissionsTransfer = ({ excludeScopeIds = [], value, onChange }: Props) => (
<div className={styles.container}>
<SourcePermissionsBox
excludeScopeIds={excludeScopeIds}
selectedPermissions={value}
onChange={onChange}
/>
<div className={styles.verticalBar} />
<TargetPermissionsBox
selectedScopes={value}
onRemovePermission={(scope) => {
onChange(value.filter(({ id }) => id !== scope.id));
}}
/>
</div>
);
export default RolePermissionsTransfer;

View file

@ -24,7 +24,8 @@
@include _.text-ellipsis;
}
.permissionInfo {
.scopeInfo {
flex-shrink: 0;
font: var(--font-body-medium);
color: var(--color-text-secondary);
margin-left: _.unit(2);

View file

@ -9,25 +9,20 @@ import Checkbox from '@/components/Checkbox';
import { onKeyDownHandler } from '@/utilities/a11y';
import type { DetailedResourceResponse } from '../../types';
import SourcePermissionItem from '../SourcePermissionItem';
import SourceScopeItem from '../SourceScopeItem';
import * as styles from './index.module.scss';
type Props = {
resource: DetailedResourceResponse;
selectedPermissions: ScopeResponse[];
selectedScopes: ScopeResponse[];
onSelectResource: (resource: DetailedResourceResponse) => void;
onSelectPermission: (scope: ScopeResponse) => void;
onSelectScope: (scope: ScopeResponse) => void;
};
const ResourceItem = ({
resource,
selectedPermissions,
onSelectResource,
onSelectPermission,
}: Props) => {
const ResourceItem = ({ resource, selectedScopes, onSelectResource, onSelectScope }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { name, scopes } = resource;
const selectedScopesCount = selectedPermissions.length;
const selectedScopesCount = selectedScopes.length;
const totalScopesCount = scopes.length;
const [isScopesInvisible, setIsScopesInvisible] = useState(true);
@ -59,18 +54,18 @@ const ResourceItem = ({
<CaretExpanded className={styles.caret} />
)}
<div className={styles.name}>{name}</div>
<div className={styles.permissionInfo}>
<div className={styles.scopeInfo}>
({t('role_details.permission.api_permission_count', { value: scopes.length })})
</div>
</div>
</div>
<div className={classNames(isScopesInvisible && styles.invisible)}>
{scopes.map((scope) => (
<SourcePermissionItem
<SourceScopeItem
key={scope.id}
scope={scope}
isSelected={selectedPermissions.findIndex(({ id }) => scope.id === id) >= 0}
onSelectPermission={onSelectPermission}
isSelected={selectedScopes.findIndex(({ id }) => scope.id === id) >= 0}
onSelect={onSelectScope}
/>
))}
</div>

View file

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

View file

@ -0,0 +1,39 @@
import type { ScopeResponse } from '@logto/schemas';
import Checkbox from '@/components/Checkbox';
import { onKeyDownHandler } from '@/utilities/a11y';
import * as styles from './index.module.scss';
type Props = {
scope: ScopeResponse;
isSelected: boolean;
onSelect: (scope: ScopeResponse) => void;
};
const SourceScopeItem = ({ scope, scope: { name }, isSelected, onSelect }: Props) => (
<div className={styles.sourceScopeItem}>
<Checkbox
checked={isSelected}
disabled={false}
onChange={() => {
onSelect(scope);
}}
/>
<div
className={styles.name}
role="button"
tabIndex={0}
onKeyDown={onKeyDownHandler(() => {
onSelect(scope);
})}
onClick={() => {
onSelect(scope);
}}
>
{name}
</div>
</div>
);
export default SourceScopeItem;

View file

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

View file

@ -0,0 +1,127 @@
import type { ResourceResponse, Scope, ScopeResponse } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import type { ChangeEvent } from 'react';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import useSWR from 'swr';
import Search from '@/assets/images/search.svg';
import type { DetailedResourceResponse } from '@/components/RoleScopesTransfer/types';
import TextInput from '@/components/TextInput';
import * as transferLayout from '@/scss/transfer.module.scss';
import ResourceItem from '../ResourceItem';
import * as styles from './index.module.scss';
type Props = {
roleId?: string;
selectedScopes: ScopeResponse[];
onChange: (value: ScopeResponse[]) => void;
};
const SourceScopesBox = ({ roleId, selectedScopes, onChange }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { data = [] } = useSWR<ResourceResponse[]>('/api/resources?includeScopes=true');
const { data: roleScopes = [] } = useSWR<Scope[]>(roleId && `/api/roles/${roleId}/scopes`);
const [keyword, setKeyword] = useState('');
const handleSearchInput = (event: ChangeEvent<HTMLInputElement>) => {
setKeyword(event.target.value);
};
const isScopeAdded = (scope: ScopeResponse) =>
selectedScopes.findIndex(({ id }) => id === scope.id) >= 0;
const onSelectScope = (scope: ScopeResponse) => {
onChange(
isScopeAdded(scope)
? selectedScopes.filter(({ id }) => id !== scope.id)
: [scope, ...selectedScopes]
);
};
const onSelectResource = ({ scopes: resourceScopes }: DetailedResourceResponse) => {
const isAllSelected = resourceScopes.every((scope) => isScopeAdded(scope));
const resourceScopesIds = new Set(resourceScopes.map(({ id }) => id));
const selectedScopesExcludeResourceScopes = selectedScopes.filter(
({ id }) => !resourceScopesIds.has(id)
);
onChange(
isAllSelected
? selectedScopesExcludeResourceScopes
: [...resourceScopes, ...selectedScopesExcludeResourceScopes]
);
};
const getResourceSelectedScopes = ({ scopes }: DetailedResourceResponse) =>
scopes.filter((scope) => selectedScopes.findIndex(({ id }) => id === scope.id) >= 0);
const resources: DetailedResourceResponse[] = useMemo(() => {
const excludeScopeIds = new Set(roleScopes.map(({ id }) => id));
return data
.filter(({ scopes }) => scopes.some(({ id }) => !excludeScopeIds.has(id)))
.map(({ scopes, ...resource }) => ({
...resource,
scopes: scopes
.filter(({ id }) => !excludeScopeIds.has(id))
.map((scope) => ({
...scope,
resource,
})),
}));
}, [data, roleScopes]);
const dataSource = useMemo(() => {
const lowerCasedKeyword = keyword.toLowerCase();
return (
conditional(
lowerCasedKeyword &&
resources
.filter(({ name, scopes }) => {
return (
name.toLowerCase().includes(lowerCasedKeyword) ||
scopes.some(({ name }) => name.toLowerCase().includes(lowerCasedKeyword))
);
})
.map(({ scopes, ...resource }) => ({
...resource,
scopes: scopes.filter(
({ name, resource }) =>
name.toLocaleLowerCase().includes(lowerCasedKeyword) ||
resource.name.toLocaleLowerCase().includes(lowerCasedKeyword)
),
}))
.filter(({ scopes }) => scopes.length > 0)
) ?? resources
);
}, [keyword, resources]);
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((resource) => (
<ResourceItem
key={resource.id}
resource={resource}
selectedScopes={getResourceSelectedScopes(resource)}
onSelectResource={onSelectResource}
onSelectScope={onSelectScope}
/>
))}
</div>
</div>
);
};
export default SourceScopesBox;

View file

@ -1,6 +1,6 @@
@use '@/scss/underscore' as _;
.targetPermissionItem {
.targetScopeItem {
display: flex;
align-items: center;
padding: _.unit(1.5) _.unit(4);
@ -28,4 +28,8 @@
.icon {
color: var(--color-text-secondary);
}
&:hover {
background: var(--color-hover);
}
}

View file

@ -1,5 +1,4 @@
import type { ScopeResponse } from '@logto/schemas';
import type { Key } from 'react';
import Close from '@/assets/images/close.svg';
import IconButton from '@/components/IconButton';
@ -7,28 +6,33 @@ import IconButton from '@/components/IconButton';
import * as styles from './index.module.scss';
export type Props = {
key: Key;
scope: ScopeResponse;
onDelete: () => void;
onDelete: (scope: ScopeResponse) => void;
};
const TargetPermissionItem = ({ key, scope, onDelete }: Props) => {
const TargetScopeItem = ({ scope, onDelete }: Props) => {
const {
name,
resource: { name: resourceName },
} = scope;
return (
<div key={key} className={styles.targetPermissionItem}>
<div className={styles.targetScopeItem}>
<div className={styles.title}>
<div className={styles.name}>{name}</div>
<div className={styles.resourceName}>{`(${resourceName})`}</div>
</div>
<IconButton size="small" iconClassName={styles.icon} onClick={onDelete}>
<IconButton
size="small"
iconClassName={styles.icon}
onClick={() => {
onDelete(scope);
}}
>
<Close />
</IconButton>
</div>
);
};
export default TargetPermissionItem;
export default TargetScopeItem;

View file

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

View file

@ -0,0 +1,39 @@
import type { ScopeResponse } from '@logto/schemas';
import { useTranslation } from 'react-i18next';
import * as transferLayout from '@/scss/transfer.module.scss';
import TargetScopeItem from '../TargetScopeItem';
import * as styles from './index.module.scss';
type Props = {
selectedScopes: ScopeResponse[];
onChange: (value: ScopeResponse[]) => void;
};
const TargetScopesBox = ({ selectedScopes, onChange }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
return (
<div className={transferLayout.box}>
<div className={transferLayout.boxTopBar}>
<span className={styles.added}>
{t('role_details.permission.added_text', { value: selectedScopes.length })}
</span>
</div>
<div className={transferLayout.boxContent}>
{selectedScopes.map((scope) => (
<TargetScopeItem
key={scope.id}
scope={scope}
onDelete={(scope) => {
onChange(selectedScopes.filter(({ id }) => id !== scope.id));
}}
/>
))}
</div>
</div>
);
};
export default TargetScopesBox;

View file

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

View file

@ -0,0 +1,24 @@
import type { ScopeResponse } from '@logto/schemas';
import classNames from 'classnames';
import * as transferLayout from '@/scss/transfer.module.scss';
import SourceScopesBox from './components/SourceScopesBox';
import TargetScopesBox from './components/TargetScopesBox';
import * as styles from './index.module.scss';
type Props = {
roleId?: string;
value: ScopeResponse[];
onChange: (value: ScopeResponse[]) => void;
};
const RoleScopesTransfer = ({ roleId, value, onChange }: Props) => (
<div className={classNames(transferLayout.container, styles.roleScopesTransfer)}>
<SourceScopesBox roleId={roleId} selectedScopes={value} onChange={onChange} />
<div className={transferLayout.verticalBar} />
<TargetScopesBox selectedScopes={value} onChange={onChange} />
</div>
);
export default RoleScopesTransfer;

View file

@ -7,17 +7,16 @@ import ReactModal from 'react-modal';
import Button from '@/components/Button';
import FormField from '@/components/FormField';
import ModalLayout from '@/components/ModalLayout';
import RolePermissionsTransfer from '@/components/RolePermissionsTransfer';
import RoleScopesTransfer from '@/components/RoleScopesTransfer';
import useApi from '@/hooks/use-api';
import * as modalStyles from '@/scss/modal.module.scss';
type Props = {
roleId: string;
excludeScopeIds: string[];
onClose: (success?: boolean) => void;
};
const AssignPermissionsModal = ({ roleId, excludeScopeIds, onClose }: Props) => {
const AssignPermissionsModal = ({ roleId, onClose }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const [isSubmitting, setIsSubmitting] = useState(false);
@ -71,9 +70,9 @@ const AssignPermissionsModal = ({ roleId, excludeScopeIds, onClose }: Props) =>
onClose={onClose}
>
<FormField title="role_details.permission.assign_form_filed">
<RolePermissionsTransfer
<RoleScopesTransfer
roleId={roleId}
value={scopes}
excludeScopeIds={excludeScopeIds}
onChange={(scopes) => {
setScopes(scopes);
}}

View file

@ -81,7 +81,6 @@ const RolePermissions = () => {
)}
{isAssignPermissionsModalOpen && (
<AssignPermissionsModal
excludeScopeIds={scopes?.map(({ id }) => id) ?? []}
roleId={roleId}
onClose={(success) => {
if (success) {

View file

@ -5,7 +5,7 @@ import { Controller, useForm } from 'react-hook-form';
import Button from '@/components/Button';
import FormField from '@/components/FormField';
import ModalLayout from '@/components/ModalLayout';
import RolePermissionsTransfer from '@/components/RolePermissionsTransfer';
import RoleScopesTransfer from '@/components/RoleScopesTransfer';
import TextInput from '@/components/TextInput';
import useApi from '@/hooks/use-api';
@ -80,7 +80,7 @@ const CreateRoleForm = ({ onClose }: Props) => {
name="scopes"
defaultValue={[]}
render={({ field: { value, onChange } }) => (
<RolePermissionsTransfer value={value} onChange={onChange} />
<RoleScopesTransfer value={value} onChange={onChange} />
)}
/>
</FormField>