Skip to content

Commit

Permalink
feat(console): support permission editing (#5567)
Browse files Browse the repository at this point in the history
  • Loading branch information
xiaoyijun authored Mar 28, 2024
1 parent f83e85b commit ba966fd
Show file tree
Hide file tree
Showing 23 changed files with 356 additions and 155 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { type ScopeResponse } from '@logto/schemas';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import ReactModal from 'react-modal';

import Button from '@/ds-components/Button';
import FormField from '@/ds-components/FormField';
import ModalLayout from '@/ds-components/ModalLayout';
import TextInput from '@/ds-components/TextInput';
import * as modalStyles from '@/scss/modal.module.scss';
import { trySubmitSafe } from '@/utils/form';

type Props = {
data: ScopeResponse;
onClose: () => void;
onSubmit: (scope: ScopeResponse) => Promise<void>;
};

function EditPermissionModal({ data, onClose, onSubmit }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });

const {
handleSubmit,
register,
formState: { isSubmitting },
} = useForm<ScopeResponse>({ defaultValues: data });

const onSubmitHandler = handleSubmit(
trySubmitSafe(async (formData) => {
await onSubmit({ ...data, ...formData });
onClose();
})
);

return (
<ReactModal
shouldCloseOnEsc
isOpen={Boolean(data)}
className={modalStyles.content}
overlayClassName={modalStyles.overlay}
onRequestClose={() => {
onClose();
}}
>
<ModalLayout
title="permissions.edit_title"
footer={
<>
<Button isLoading={isSubmitting} title="general.cancel" onClick={onClose} />
<Button
isLoading={isSubmitting}
title="general.save"
type="primary"
htmlType="submit"
onClick={onSubmitHandler}
/>
</>
}
onClose={onClose}
>
<form>
<FormField title="api_resource_details.permission.name">
<TextInput readOnly value={data.name} />
</FormField>
<FormField title="api_resource_details.permission.description">
<TextInput
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
placeholder={t('api_resource_details.permission.description_placeholder')}
{...register('description')}
/>
</FormField>
</form>
</ModalLayout>
</ReactModal>
);
}

export default EditPermissionModal;
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
@include _.text-ellipsis;
}

.deleteColumn {
.actionColumn {
text-align: right;
}
}
207 changes: 132 additions & 75 deletions packages/console/src/components/PermissionsTable/index.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,29 @@
import type { AdminConsoleKey } from '@logto/phrases';
import type { ScopeResponse } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { useState } from 'react';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';

import Delete from '@/assets/icons/delete.svg';
import Plus from '@/assets/icons/plus.svg';
import PermissionsEmptyDark from '@/assets/images/permissions-empty-dark.svg';
import PermissionsEmpty from '@/assets/images/permissions-empty.svg';
import { ApiResourceDetailsTabs } from '@/consts/page-tabs';
import Button from '@/ds-components/Button';
import DynamicT from '@/ds-components/DynamicT';
import IconButton from '@/ds-components/IconButton';
import type { Props as PaginationProps } from '@/ds-components/Pagination';
import Search from '@/ds-components/Search';
import Table from '@/ds-components/Table';
import TablePlaceholder from '@/ds-components/Table/TablePlaceholder';
import type { Column } from '@/ds-components/Table/types';
import Tag from '@/ds-components/Tag';
import TextLink from '@/ds-components/TextLink';
import { Tooltip } from '@/ds-components/Tip';
import useApi from '@/hooks/use-api';
import useDocumentationUrl from '@/hooks/use-documentation-url';

import ActionsButton from '../ActionsButton';
import EmptyDataPlaceholder from '../EmptyDataPlaceholder';

import EditPermissionModal from './EditPermissionModal';
import * as styles from './index.module.scss';

type SearchProps = {
Expand All @@ -32,27 +33,54 @@ type SearchProps = {
};

type Props = {
/** List of permissions to be displayed in the table. */
scopes?: ScopeResponse[];
/** Whether the table is loading data or not. */
isLoading: boolean;
/** Error message to be displayed when the table fails to load data. */
errorMessage?: string;
/** The translation key of the create button. */
createButtonTitle: AdminConsoleKey;
deleteButtonTitle?: AdminConsoleKey;
/** Whether the table is read-only or not.
* If true, the table will not display the create button and action buttons (editing & deletion).
*/
isReadOnly?: boolean;
/** Whether the API column is visible or not.
* The API column displays the API resource that the permission belongs to.
*/
isApiColumnVisible?: boolean;
/** Whether the create guide is visible or not.
* If true, the table will display a placeholder guiding the user to create a new permission if no permissions are found.
*/
isCreateGuideVisible?: boolean;
/** Pagination related props, used to navigate through the permissions in the table. */
pagination?: PaginationProps;
/** Search related props, used to filter the permissions in the table. */
search: SearchProps;
/** Function that will be called when the create button is clicked. */
createHandler: () => void;
deleteHandler: (ScopeResponse: ScopeResponse) => void;
/** Callback function that will be called when a permission is going to be deleted. */
deleteHandler: (scope: ScopeResponse) => void;
/** Function that will be called when the retry button is click. */
retryHandler: () => void;
/** Callback function that will be called when the permission is updated (edited). */
onPermissionUpdated: () => void;
/** Specify deletion related text */
deletionText: {
/** Delete button title in the action list */
actionButton: AdminConsoleKey;
/** Confirmation content in the deletion confirmation modal */
confirmation: AdminConsoleKey;
/** Confirmation button title in the deletion confirmation modal */
confirmButton: AdminConsoleKey;
};
};

function PermissionsTable({
scopes,
isLoading,
errorMessage,
createButtonTitle,
deleteButtonTitle = 'general.delete',
isReadOnly = false,
isApiColumnVisible = false,
isCreateGuideVisible = false,
Expand All @@ -61,9 +89,21 @@ function PermissionsTable({
createHandler,
deleteHandler,
retryHandler,
onPermissionUpdated,
deletionText,
}: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { getDocumentationUrl } = useDocumentationUrl();
const [editingScope, setEditingScope] = useState<ScopeResponse>();

const api = useApi();

const handleEdit = async (scope: ScopeResponse) => {
const patchApiEndpoint = `api/resources/${scope.resourceId}/scopes/${scope.id}`;
await api.patch(patchApiEndpoint, { json: scope });
toast.success(t('permissions.updated'));
onPermissionUpdated();
};

const nameColumn: Column<ScopeResponse> = {
title: t('permissions.name_column'),
Expand Down Expand Up @@ -93,101 +133,118 @@ function PermissionsTable({
),
};

const deleteColumn: Column<ScopeResponse> = {
const actionColumn: Column<ScopeResponse> = {
title: null,
dataIndex: 'delete',
colSpan: 2,
className: styles.deleteColumn,
dataIndex: 'action',
colSpan: 1,
className: styles.actionColumn,
render: (scope) =>
/**
* When the table is read-only, hide the delete button rather than the whole column to keep the table column spaces.
*/
isReadOnly ? null : (
<Tooltip content={<DynamicT forKey={deleteButtonTitle} />}>
<IconButton
onClick={() => {
deleteHandler(scope);
}}
>
<Delete />
</IconButton>
</Tooltip>
<ActionsButton
fieldName="permissions.name_column"
deleteConfirmation={deletionText.confirmation}
textOverrides={{
edit: 'permissions.edit',
delete: deletionText.actionButton,
deleteConfirmation: deletionText.confirmButton,
}}
onDelete={() => {
deleteHandler(scope);
}}
onEdit={() => {
setEditingScope(scope);
}}
/>
),
};

const columns = [
nameColumn,
descriptionColumn,
conditional(isApiColumnVisible && apiColumn),
deleteColumn,
actionColumn,
// eslint-disable-next-line unicorn/prefer-native-coercion-functions
].filter((column): column is Column<ScopeResponse> => Boolean(column));

return (
<Table
className={styles.permissionTable}
rowIndexKey="id"
rowGroups={[{ key: 'scopes', data: scopes }]}
columns={columns}
filter={
<div className={styles.filter}>
<Search
defaultValue={keyword}
isClearable={Boolean(keyword)}
placeholder={t(
isApiColumnVisible
? 'permissions.search_placeholder'
: 'permissions.search_placeholder_without_api'
)}
onSearch={searchHandler}
onClearSearch={clearSearchHandler}
/>
{!isReadOnly && (
<Button
title={createButtonTitle}
className={styles.createButton}
type="primary"
size="large"
icon={<Plus />}
onClick={() => {
createHandler();
}}
<>
<Table
className={styles.permissionTable}
rowIndexKey="id"
rowGroups={[{ key: 'scopes', data: scopes }]}
columns={columns}
filter={
<div className={styles.filter}>
<Search
defaultValue={keyword}
isClearable={Boolean(keyword)}
placeholder={t(
isApiColumnVisible
? 'permissions.search_placeholder'
: 'permissions.search_placeholder_without_api'
)}
onSearch={searchHandler}
onClearSearch={clearSearchHandler}
/>
)}
</div>
}
isLoading={isLoading}
pagination={pagination}
placeholder={
!isReadOnly && isCreateGuideVisible ? (
<TablePlaceholder
image={<PermissionsEmpty />}
imageDark={<PermissionsEmptyDark />}
title="permissions.placeholder_title"
description="permissions.placeholder_description"
learnMoreLink={{
href: getDocumentationUrl('/docs/recipes/rbac/manage-permissions-and-roles'),
targetBlank: 'noopener',
}}
action={
{!isReadOnly && (
<Button
title={createButtonTitle}
className={styles.createButton}
type="primary"
size="large"
icon={<Plus />}
onClick={() => {
createHandler();
}}
/>
}
/>
) : (
<EmptyDataPlaceholder />
)
}
errorMessage={errorMessage}
onRetry={retryHandler}
/>
)}
</div>
}
isLoading={isLoading}
pagination={pagination}
placeholder={
!isReadOnly && isCreateGuideVisible ? (
<TablePlaceholder
image={<PermissionsEmpty />}
imageDark={<PermissionsEmptyDark />}
title="permissions.placeholder_title"
description="permissions.placeholder_description"
learnMoreLink={{
href: getDocumentationUrl('/docs/recipes/rbac/manage-permissions-and-roles'),
targetBlank: 'noopener',
}}
action={
<Button
title={createButtonTitle}
type="primary"
size="large"
icon={<Plus />}
onClick={() => {
createHandler();
}}
/>
}
/>
) : (
<EmptyDataPlaceholder />
)
}
errorMessage={errorMessage}
onRetry={retryHandler}
/>
{editingScope && (
<EditPermissionModal
data={editingScope}
onClose={() => {
setEditingScope(undefined);
}}
onSubmit={handleEdit}
/>
)}
</>
);
}

Expand Down
Loading

0 comments on commit ba966fd

Please sign in to comment.