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:
commit
b5fce550fc
19 changed files with 139 additions and 77 deletions
|
@ -1,3 +1,3 @@
|
|||
.moreIcon {
|
||||
.icon {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
|
|
@ -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} />
|
||||
) : (
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
.breakable {
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
}
|
19
packages/console/src/components/Breakable/index.tsx
Normal file
19
packages/console/src/components/Breakable/index.tsx
Normal 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;
|
|
@ -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) {
|
||||
|
|
|
@ -9,7 +9,11 @@
|
|||
gap: _.unit(3);
|
||||
}
|
||||
|
||||
> span {
|
||||
> svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
> div {
|
||||
font: var(--font-label-2);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -33,7 +33,7 @@ function ModalLayout({
|
|||
<div className={styles.header}>
|
||||
<div className={styles.iconAndTitle}>
|
||||
{headerIcon}
|
||||
<CardTitle {...cardTitleProps} />
|
||||
<CardTitle isWordWrapEnabled {...cardTitleProps} />
|
||||
</div>
|
||||
{onClose && (
|
||||
<IconButton
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -1,3 +1,2 @@
|
|||
export const organizationsPathname = '/organizations';
|
||||
export const createPathname = `${organizationsPathname}/create`;
|
||||
export const guidePathname = '/organization-guide';
|
||||
|
|
|
@ -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 />}
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue