0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-10 22:22:45 -05:00

Merge pull request #4822 from logto-io/gao-fix-org-issues

refactor(console): fix organization issues
This commit is contained in:
Gao Sun 2023-11-07 12:39:16 +08:00 committed by GitHub
commit b5fce550fc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 139 additions and 77 deletions

View file

@ -1,3 +1,3 @@
.moreIcon {
.icon {
color: var(--color-text-secondary);
}

View file

@ -6,7 +6,6 @@ import Delete from '@/assets/icons/delete.svg';
import Edit from '@/assets/icons/edit.svg';
import More from '@/assets/icons/more.svg';
import ActionMenu, { ActionMenuItem } from '@/ds-components/ActionMenu';
import type { Props as ButtonProps } from '@/ds-components/Button';
import ConfirmModal from '@/ds-components/ConfirmModal';
import DynamicT from '@/ds-components/DynamicT';
import useActionTranslation from '@/hooks/use-action-translation';
@ -14,11 +13,6 @@ import useActionTranslation from '@/hooks/use-action-translation';
import * as styles from './index.module.scss';
type Props = {
/**
* Props that will be passed to the button that opens the menu. It will override the
* default props.
*/
buttonProps?: Partial<ButtonProps>;
/** A function that will be called when the user confirms the deletion. */
onDelete: () => void | Promise<void>;
/**
@ -47,14 +41,7 @@ type Props = {
* - Edit (optional)
* - Delete
*/
function ActionsButton({
buttonProps,
onDelete,
onEdit,
deleteConfirmation,
fieldName,
textOverrides,
}: Props) {
function ActionsButton({ onDelete, onEdit, deleteConfirmation, fieldName, textOverrides }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const tAction = useActionTranslation();
const [isModalOpen, setIsModalOpen] = useState(false);
@ -72,17 +59,9 @@ function ActionsButton({
return (
<>
<ActionMenu
buttonProps={{
icon: <More className={styles.moreIcon} />,
size: 'small',
type: 'text',
...buttonProps,
}}
title={t('general.more_options')}
>
<ActionMenu icon={<More className={styles.icon} />} title={t('general.more_options')}>
{onEdit && (
<ActionMenuItem iconClassName={styles.moreIcon} icon={<Edit />} onClick={onEdit}>
<ActionMenuItem iconClassName={styles.icon} icon={<Edit />} onClick={onEdit}>
{textOverrides?.edit ? (
<DynamicT forKey={textOverrides.edit} />
) : (

View file

@ -0,0 +1,5 @@
.breakable {
word-break: break-word;
white-space: pre-wrap;
overflow-wrap: break-word;
}

View file

@ -0,0 +1,19 @@
import * as styles from './index.module.scss';
type Props = {
children: React.ReactNode;
};
/**
* A component that can be used to render text that can be broken into multiple lines. It
* wraps the children with a div and applies `word-break: break-word` and
* `overflow-wrap: break-word` to it.
*
* @see {@link https://developer.mozilla.org/en-US/docs/Web/CSS/word-break | word-break in MDN}
* @see {@link https://developer.mozilla.org/en-US/docs/Web/CSS/overflow-wrap | overflow-wrap in MDN}
*/
function Breakable({ children }: Props) {
return <div className={styles.breakable}>{children}</div>;
}
export default Breakable;

View file

@ -23,7 +23,6 @@ $column-width: calc((100% - 23 * $gutter-width) / 24);
.form {
width: calc($column-width * 16 + $gutter-width * 15);
overflow: hidden;
}
@container (max-width: 600px) {

View file

@ -9,7 +9,11 @@
gap: _.unit(3);
}
> span {
> svg {
flex-shrink: 0;
}
> div {
font: var(--font-label-2);
}
}

View file

@ -5,6 +5,7 @@ import RoleIcon from '@/assets/icons/role-feature.svg';
import MultiSelect, { type Option } from '@/ds-components/Select/MultiSelect';
import useSearchValues from '@/hooks/use-search-values';
import Breakable from '../Breakable';
import ThemedIcon from '../ThemedIcon';
import * as styles from './index.module.scss';
@ -19,7 +20,7 @@ export function RoleOption({ title, value, size = 'small' }: RoleOptionProps) {
return (
<div className={classNames(styles.roleOption, size === 'large' && styles.large)}>
<ThemedIcon for={RoleIcon} size={size === 'small' ? 16 : 40} />
<span>{title ?? value}</span>
<Breakable>{title ?? value}</Breakable>
</div>
);
}

View file

@ -3,6 +3,8 @@ import { type OrganizationScope } from '@logto/schemas';
import MultiSelect, { type Option } from '@/ds-components/Select/MultiSelect';
import useSearchValues from '@/hooks/use-search-values';
import Breakable from '../Breakable';
type Props = {
value: Array<Option<string>>;
onChange: (value: Array<Option<string>>) => void;
@ -22,6 +24,7 @@ function OrganizationScopesSelect({ value, onChange, keyword, setKeyword }: Prop
options={scopes.map(({ id, name }) => ({ value: id, title: name }))}
placeholder="organizations.search_permission_placeholder"
isOptionsLoading={isLoading}
renderOption={({ title, value }) => <Breakable>{title ?? value}</Breakable>}
onChange={onChange}
onSearch={setKeyword}
/>

View file

@ -6,41 +6,73 @@ import type { HorizontalAlignment } from '@/types/positioning';
import type { Props as ButtonProps } from '../Button';
import Dropdown from '../Dropdown';
import IconButton from '../IconButton';
import ActionMenuButton from './ActionMenuButton';
import * as styles from './index.module.scss';
export { default as ActionMenuItem } from '../Dropdown/DropdownItem';
type Props = {
type BaseProps = {
children: ReactNode;
buttonProps: ButtonProps;
title?: ReactNode;
dropdownHorizontalAlign?: HorizontalAlignment;
dropdownClassName?: string;
isDropdownFullWidth?: boolean;
};
function ActionMenu({
children,
buttonProps,
title,
dropdownHorizontalAlign,
dropdownClassName,
isDropdownFullWidth = false,
}: Props) {
type Props =
| (BaseProps & {
buttonProps: ButtonProps;
})
| (BaseProps & {
icon: ReactNode;
});
/**
* A button that can be used to open and close a dropdown menu.
*
* @param props If `buttonProps` is provided, the button will be rendered using the `ActionMenuButton`
* component. Otherwise, `icon` will be required to render the button using the `IconButton`
* component.
* @see {@link ActionMenuButton} for the list of props that can be passed to the button.
* @see {@link IconButton} for how the button will be rendered if `icon` is provided.
*/
function ActionMenu(props: Props) {
const {
children,
title,
dropdownHorizontalAlign,
dropdownClassName,
isDropdownFullWidth = false,
} = props;
const [isOpen, setIsOpen] = useState(false);
const anchorReference = useRef<HTMLDivElement>(null);
const anchorReference = useRef(null);
const hasButtonProps = 'buttonProps' in props;
return (
<div>
<ActionMenuButton
{...buttonProps}
ref={anchorReference}
onClick={() => {
setIsOpen(true);
}}
/>
{hasButtonProps && (
<ActionMenuButton
// eslint-disable-next-line unicorn/consistent-destructuring -- cannot deconstruct before checking
{...props.buttonProps}
ref={anchorReference}
onClick={() => {
setIsOpen(true);
}}
/>
)}
{!hasButtonProps && (
<IconButton
ref={anchorReference}
onClick={() => {
setIsOpen(true);
}}
>
{/* eslint-disable-next-line unicorn/consistent-destructuring -- cannot deconstruct before checking */}
{props.icon}
</IconButton>
)}
<Dropdown
title={title}
titleClassName={styles.dropdownTitle}

View file

@ -33,7 +33,7 @@ function ModalLayout({
<div className={styles.header}>
<div className={styles.iconAndTitle}>
{headerIcon}
<CardTitle {...cardTitleProps} />
<CardTitle isWordWrapEnabled {...cardTitleProps} />
</div>
{onClose && (
<IconButton

View file

@ -27,7 +27,7 @@ function Settings({ isDeleting, data, onUpdated }: Props) {
register,
reset,
handleSubmit,
formState: { isDirty, isSubmitting },
formState: { isDirty, isSubmitting, errors },
} = useForm<Partial<Organization>>({
defaultValues: data,
});
@ -59,10 +59,11 @@ function Settings({ isDeleting, data, onUpdated }: Props) {
title="general.settings_nav"
description="organization_details.settings_description"
>
<FormField title="general.name">
<FormField isRequired title="general.name">
<TextInput
placeholder={t('organization_details.name_placeholder')}
{...register('name')}
error={Boolean(errors.name)}
{...register('name', { required: true })}
/>
</FormField>
<FormField title="general.description">

View file

@ -6,17 +6,17 @@ import OrganizationEmptyDark from '@/assets/images/organization-empty-dark.svg';
import OrganizationEmpty from '@/assets/images/organization-empty.svg';
import Button from '@/ds-components/Button';
import useConfigs from '@/hooks/use-configs';
import useTenantPathname from '@/hooks/use-tenant-pathname';
import useTheme from '@/hooks/use-theme';
import { createPathname, guidePathname } from '../../consts';
import * as styles from './index.module.scss';
function EmptyDataPlaceholder() {
type Props = {
onCreate: () => void;
};
function EmptyDataPlaceholder({ onCreate }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.organizations' });
const { configs } = useConfigs();
const { navigate } = useTenantPathname();
const theme = useTheme();
const PlaceholderImage = theme === Theme.Light ? OrganizationEmpty : OrganizationEmptyDark;
const isInitialSetup = !configs?.organizationCreated;
@ -34,9 +34,7 @@ function EmptyDataPlaceholder() {
title={
isInitialSetup ? 'organizations.setup_organization' : 'organizations.create_organization'
}
onClick={() => {
navigate(isInitialSetup ? guidePathname : createPathname);
}}
onClick={onCreate}
/>
</div>
);

View file

@ -26,7 +26,11 @@ const pathname = '/organizations';
/** The organizations API pathname in the management API. */
const apiPathname = 'api/organizations';
function OrganizationsTable() {
type Props = {
onCreate: () => void;
};
function OrganizationsTable({ onCreate }: Props) {
const [keyword, setKeyword] = useState('');
const [page, setPage] = useState(1);
const { data: response, error } = useSWR<[OrganizationWithFeatured[], number], RequestError>(
@ -46,7 +50,7 @@ function OrganizationsTable() {
<Table
className={pageLayout.table}
isLoading={isLoading}
placeholder={<EmptyDataPlaceholder />}
placeholder={<EmptyDataPlaceholder onCreate={onCreate} />}
rowGroups={[{ key: 'data', data }]}
rowClickHandler={({ id }) => {
navigate(joinPath(pathname, id));

View file

@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import useSWR, { useSWRConfig } from 'swr';
import ActionsButton from '@/components/ActionsButton';
import Breakable from '@/components/Breakable';
import FormCard from '@/components/FormCard';
import Tag from '@/ds-components/Tag';
import useApi, { type RequestError } from '@/hooks/use-api';
@ -68,13 +69,17 @@ function PermissionsCard() {
title: t('general.name'),
dataIndex: 'name',
colSpan: 4,
render: ({ name }) => <Tag variant="cell">{name}</Tag>,
render: ({ name }) => (
<Tag variant="cell">
<Breakable>{name}</Breakable>
</Tag>
),
},
{
title: t('general.description'),
dataIndex: 'description',
colSpan: 6,
render: ({ description }) => description ?? '-',
render: ({ description }) => <Breakable>{description ?? '-'}</Breakable>,
},
{
title: null,

View file

@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import useSWR from 'swr';
import ActionsButton from '@/components/ActionsButton';
import Breakable from '@/components/Breakable';
import FormCard from '@/components/FormCard';
import { RoleOption } from '@/components/OrganizationRolesSelect';
import Tag from '@/ds-components/Tag';
@ -80,7 +81,7 @@ function RolesCard() {
<div className={styles.permissions}>
{scopes.map(({ id, name }) => (
<Tag key={id} variant="cell">
{name}
<Breakable>{name}</Breakable>
</Tag>
))}
</div>

View file

@ -1,3 +1,2 @@
export const organizationsPathname = '/organizations';
export const createPathname = `${organizationsPathname}/create`;
export const guidePathname = '/organization-guide';

View file

@ -1,4 +1,5 @@
import { condString, joinPath } from '@silverhand/essentials';
import { joinPath } from '@silverhand/essentials';
import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import Plus from '@/assets/icons/plus.svg';
@ -15,7 +16,7 @@ import CreateOrganizationModal from './CreateOrganizationModal';
import OrganizationsTable from './OrganizationsTable';
import EmptyDataPlaceholder from './OrganizationsTable/EmptyDataPlaceholder';
import Settings from './Settings';
import { createPathname, organizationsPathname } from './consts';
import { guidePathname, organizationsPathname } from './consts';
import * as styles from './index.module.scss';
const tabs = Object.freeze({
@ -28,17 +29,29 @@ type Props = {
function Organizations({ tab }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { navigate, match } = useTenantPathname();
const isCreating = match(createPathname);
const { navigate } = useTenantPathname();
const [isCreating, setIsCreating] = useState(false);
const { configs } = useConfigs();
const isInitialSetup = !configs?.organizationCreated;
const handleCreate = useCallback(() => {
if (isInitialSetup) {
navigate(organizationsPathname + guidePathname);
return;
}
setIsCreating(true);
}, [isInitialSetup, navigate]);
return (
<div className={pageLayout.container}>
<CreateOrganizationModal
isOpen={isCreating}
onClose={(createdId?: string) => {
navigate(organizationsPathname + condString(createdId && `/${createdId}`));
if (createdId) {
navigate(organizationsPathname + `/${createdId}`);
return;
}
setIsCreating(false);
}}
/>
<PageMeta titleKey="organizations.page_title" />
@ -50,15 +63,13 @@ function Organizations({ tab }: Props) {
type="primary"
size="large"
title="organizations.create_organization"
onClick={() => {
navigate(createPathname);
}}
onClick={handleCreate}
/>
)}
</div>
{isInitialSetup && (
<Card className={styles.emptyCardContainer}>
<EmptyDataPlaceholder />
<EmptyDataPlaceholder onCreate={handleCreate} />
</Card>
)}
{!isInitialSetup && (
@ -74,7 +85,7 @@ function Organizations({ tab }: Props) {
{t('general.settings_nav')}
</TabNavItem>
</TabNav>
{!tab && <OrganizationsTable />}
{!tab && <OrganizationsTable onCreate={handleCreate} />}
{tab === 'settings' && <Settings />}
</>
)}

View file

@ -13,6 +13,7 @@ import {
generateResourceIndicator,
generateScopeName,
generateRoleName,
dcls,
} from '#src/utils.js';
import {
@ -209,7 +210,7 @@ describe('M2M RBAC', () => {
it('assign a permission to a role on the role details page', async () => {
// Wait for the deletion confirmation modal to disappear.
await page.waitForSelector('.ReactModalPortal div[class$=header] div[class$=titleEllipsis]', {
await page.waitForSelector(['.ReactModalPortal', dcls('header'), dcls('title')].join(' '), {
hidden: true,
});

View file

@ -9,7 +9,7 @@ import {
logtoConsoleUrl as logtoConsoleUrlString,
} from '#src/constants.js';
import { clearConnectorsByTypes, setEmailConnector } from '#src/helpers/connector.js';
import { expectNavigation, waitFor } from '#src/utils.js';
import { dcls, expectNavigation, waitFor } from '#src/utils.js';
export const goToAdminConsole = async () => {
const logtoConsoleUrl = new URL(logtoConsoleUrlString);
@ -90,7 +90,7 @@ export const expectToClickDetailsPageOption = async (page: Page, optionText: str
export const expectModalWithTitle = async (page: Page, title: string | RegExp) => {
await expect(page).toMatchElement(
'.ReactModalPortal div[class$=header] div[class$=titleEllipsis]',
['.ReactModalPortal', dcls('header'), dcls('title')].join(' '),
{
text: title,
}