0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat(console): organization roles table

This commit is contained in:
Gao Sun 2023-10-18 13:16:08 +08:00
parent d4d2a7256e
commit 8754d86610
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
4 changed files with 392 additions and 0 deletions

View file

@ -0,0 +1,165 @@
import { type AdminConsoleKey } from '@logto/phrases';
import classNames from 'classnames';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import Close from '@/assets/icons/close.svg';
import { onKeyDownHandler } from '@/utils/a11y';
import Dropdown, { DropdownItem } from '../Dropdown';
import IconButton from '../IconButton';
import Tag from '../Tag';
import * as styles from './index.module.scss';
export type Option<T> = {
value: T;
title?: string;
};
type Props<T> = {
className?: string;
value: Array<Option<T>>;
options: Array<Option<T>>;
onSearch: (keyword: string) => void;
onChange: (value: Array<Option<T>>) => void;
isReadOnly?: boolean;
error?: string | boolean;
placeholder?: AdminConsoleKey;
};
function MultiSelect<T extends string>({
className,
value,
options,
onSearch,
onChange,
isReadOnly,
error,
placeholder,
}: Props<T>) {
const inputRef = useRef<HTMLInputElement>(null);
const selectRef = useRef<HTMLDivElement>(null);
const [keyword, setKeyword] = useState('');
const [isInputFocused, setIsInputFocused] = useState(false);
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
// Search on keyword changes
useEffect(() => {
if (isInputFocused) {
onSearch(keyword);
}
}, [keyword, isInputFocused, onSearch]);
const handleSelect = (option: Option<T>) => {
if (value.some(({ value }) => value === option.value)) {
return;
}
onChange([...value, option]);
inputRef.current?.focus();
};
const handleDelete = (option: Option<T>) => {
onChange(value.filter(({ value }) => value !== option.value));
};
// https://exogen.github.io/blog/focus-state/
useEffect(() => {
if (document.hasFocus() && inputRef.current?.contains(document.activeElement)) {
setIsInputFocused(true);
}
}, []);
const isOpen = !isReadOnly && isInputFocused;
const filteredOptions = options.filter(({ value: current }) => {
return !value.some(({ value }) => value === current);
});
return (
<div
ref={selectRef}
className={classNames(
styles.select,
styles.multiple,
isOpen && styles.open,
isReadOnly && styles.readOnly,
Boolean(error) && styles.error,
className
)}
role="button"
tabIndex={0}
onKeyDown={onKeyDownHandler(() => {
if (!isReadOnly) {
inputRef.current?.focus();
}
})}
onClick={() => {
if (!isReadOnly) {
inputRef.current?.focus();
}
}}
>
{value.map((option) => {
const { value, title } = option;
return (
<Tag
key={value}
variant="cell"
className={styles.tag}
onClick={(event) => {
event.stopPropagation();
}}
>
{title ?? value}
<IconButton
className={styles.delete}
size="small"
onClick={() => {
handleDelete(option);
}}
>
<Close className={styles.close} />
</IconButton>
</Tag>
);
})}
<input
ref={inputRef}
type="text"
placeholder={placeholder && String(t(placeholder))}
value={keyword}
onChange={({ currentTarget: { value } }) => {
setKeyword(value);
}}
onFocus={() => {
setIsInputFocused(true);
}}
onBlur={() => {
setIsInputFocused(false);
}}
/>
<Dropdown
isFullWidth
noOverlay
isOpen={isOpen}
className={styles.dropdown}
anchorRef={selectRef}
>
{filteredOptions.length === 0 && <div className={styles.noResult}>No result</div>}
{filteredOptions.map(({ value, title }) => (
<DropdownItem
key={value}
onClick={(event) => {
event.preventDefault();
handleSelect({ value, title });
}}
>
{title ?? value}
</DropdownItem>
))}
</Dropdown>
</div>
);
}
export default MultiSelect;

View file

@ -0,0 +1,132 @@
import { type OrganizationScope } from '@logto/schemas';
import { useEffect, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import ReactModal from 'react-modal';
import useSWR from 'swr';
import { defaultPageSize } from '@/consts';
import Button from '@/ds-components/Button';
import FormField from '@/ds-components/FormField';
import ModalLayout from '@/ds-components/ModalLayout';
import MultiSelect, { type Option } from '@/ds-components/Select/MultiSelect';
import TextInput from '@/ds-components/TextInput';
import useApi, { type RequestError } from '@/hooks/use-api';
import * as modalStyles from '@/scss/modal.module.scss';
import { buildUrl } from '@/utils/url';
type Props = {
isOpen: boolean;
onFinish: () => void;
};
function CreateRoleModal({ isOpen, onFinish }: Props) {
const api = useApi();
const [isLoading, setIsLoading] = useState(false);
const {
reset,
register,
control,
handleSubmit,
formState: { errors },
} = useForm<{ name: string; description?: string; scopes: Array<Option<string>> }>({
defaultValues: { name: '', scopes: [] },
});
const [keyword, setKeyword] = useState('');
const {
data: response,
error, // TODO: handle error
mutate,
} = useSWR<[OrganizationScope[], number], RequestError>(
buildUrl('api/organization-scopes', {
page: String(1),
page_size: String(defaultPageSize),
q: keyword,
}),
{ revalidateOnFocus: false }
);
const [scopes] = response ?? [[], 0];
const addRole = handleSubmit(async ({ scopes, ...json }) => {
setIsLoading(true);
try {
await api.post('api/organization-roles', {
json: {
...json,
organizationScopeIds: scopes.map(({ value }) => value),
},
});
onFinish();
} finally {
setIsLoading(false);
}
});
// Reset form on close
useEffect(() => {
if (!isOpen) {
reset();
}
}, [isOpen, reset]);
// Initial fetch on open
useEffect(() => {
if (isOpen) {
void mutate();
}
}, [isOpen, mutate]);
return (
<ReactModal
isOpen={isOpen}
className={modalStyles.content}
overlayClassName={modalStyles.overlay}
onRequestClose={onFinish}
>
<ModalLayout
title="organizations.create_organization_role"
footer={
<Button
type="primary"
title="organizations.create_role"
isLoading={isLoading}
onClick={addRole}
/>
}
onClose={onFinish}
>
<FormField isRequired title="general.name">
<TextInput
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
placeholder="viewer"
error={Boolean(errors.name)}
{...register('name', { required: true })}
/>
</FormField>
<FormField title="general.description">
<TextInput
placeholder="organizations.create_role_placeholder"
error={Boolean(errors.description)}
{...register('description')}
/>
</FormField>
<FormField title="organizations.permission_other">
<Controller
name="scopes"
control={control}
render={({ field: { onChange, value } }) => (
<MultiSelect
value={value}
options={scopes.map(({ id, name }) => ({ value: id, title: name }))}
placeholder="organizations.search_permission_placeholder"
onChange={onChange}
onSearch={setKeyword}
/>
)}
/>
</FormField>
</ModalLayout>
</ReactModal>
);
}
export default CreateRoleModal;

View file

@ -0,0 +1,93 @@
import { type OrganizationRole } from '@logto/schemas';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import useSWR from 'swr';
import DeleteButton from '@/components/DeleteButton';
import FormField from '@/ds-components/FormField';
import useApi, { type RequestError } from '@/hooks/use-api';
import { buildUrl } from '@/utils/url';
import CreateRoleModal from '../CreateRoleModal';
import TemplateTable, { pageSize } from '../TemplateTable';
import * as styles from '../index.module.scss';
/**
* Renders the roles field that allows users to add, edit, and delete organization
* roles.
*/
function RolesField() {
const [page, setPage] = useState(1);
const {
data: response,
error,
mutate,
} = useSWR<[OrganizationRole[], number], RequestError>(
buildUrl('api/organization-roles', {
page: String(page),
page_size: String(pageSize),
})
);
const [data, totalCount] = response ?? [[], 0];
const api = useApi();
const [isModalOpen, setIsModalOpen] = useState(false);
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const isLoading = !response && !error;
if (isLoading) {
return <>loading</>; // TODO: loading state
}
return (
<FormField title="organizations.organization_roles">
<CreateRoleModal
isOpen={isModalOpen}
onFinish={() => {
setIsModalOpen(false);
void mutate();
}}
/>
<TemplateTable
rowIndexKey="id"
page={page}
totalCount={totalCount}
data={data}
columns={[
{
title: t('general.name'),
dataIndex: 'name',
colSpan: 4,
render: ({ name }) => <div className={styles.permission}>{name}</div>,
},
{
title: t('organizations.permission_other'),
dataIndex: 'permissions',
colSpan: 6,
render: ({ description }) => description ?? '-',
},
{
title: null,
dataIndex: 'delete',
render: ({ id }) => (
<DeleteButton
content="Delete at your own risk, mate."
onDelete={async () => {
await api.delete(`api/organization-roles/${id}`);
void mutate();
}}
/>
),
},
]}
onPageChange={setPage}
onAdd={() => {
setIsModalOpen(true);
}}
/>
</FormField>
);
}
export default RolesField;

View file

@ -1,6 +1,7 @@
import FormCard from '@/components/FormCard'; import FormCard from '@/components/FormCard';
import PermissionsField from '../PermissionsField'; import PermissionsField from '../PermissionsField';
import RolesField from '../RolesField';
export default function Settings() { export default function Settings() {
return ( return (
@ -9,6 +10,7 @@ export default function Settings() {
description="organizations.access_control_description" description="organizations.access_control_description"
> >
<PermissionsField /> <PermissionsField />
<RolesField />
</FormCard> </FormCard>
); );
} }