mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
feat(console): add guide support in api resource creation process (#4457)
* feat(console): add guide support in api resource creation process * fix(test): ui integration test
This commit is contained in:
parent
6cdd33bf1c
commit
2a9a9263ea
21 changed files with 592 additions and 166 deletions
|
@ -24,9 +24,3 @@
|
||||||
@include _.multi-line-ellipsis(2);
|
@include _.multi-line-ellipsis(2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: dim.$guide-content-max-width) {
|
|
||||||
.actionBar .wrapper {
|
|
||||||
margin: 0 0 0 _.unit(62.5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { type AdminConsoleKey } from '@logto/phrases';
|
import { type AdminConsoleKey } from '@logto/phrases';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import Button from '@/ds-components/Button';
|
import Button from '@/ds-components/Button';
|
||||||
import DynamicT from '@/ds-components/DynamicT';
|
import DynamicT from '@/ds-components/DynamicT';
|
||||||
|
@ -6,15 +7,16 @@ import DynamicT from '@/ds-components/DynamicT';
|
||||||
import * as styles from './index.module.scss';
|
import * as styles from './index.module.scss';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
wrapperClassName?: string;
|
||||||
content: AdminConsoleKey;
|
content: AdminConsoleKey;
|
||||||
buttonText: AdminConsoleKey;
|
buttonText: AdminConsoleKey;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ModalFooter({ content, buttonText, onClick }: Props) {
|
export default function ModalFooter({ wrapperClassName, content, buttonText, onClick }: Props) {
|
||||||
return (
|
return (
|
||||||
<nav className={styles.actionBar}>
|
<nav className={styles.actionBar}>
|
||||||
<div className={styles.wrapper}>
|
<div className={classNames(styles.wrapper, wrapperClassName)}>
|
||||||
<span className={styles.text}>
|
<span className={styles.text}>
|
||||||
<DynamicT forKey={content} />
|
<DynamicT forKey={content} />
|
||||||
</span>
|
</span>
|
||||||
|
|
|
@ -17,8 +17,11 @@ type FilterOptions = {
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useApiGuideMetadata = () =>
|
||||||
|
guides.filter(({ metadata: { target } }) => target === 'API');
|
||||||
|
|
||||||
export const useAppGuideMetadata = (): {
|
export const useAppGuideMetadata = (): {
|
||||||
getFilteredAppGuideMetadata: (filters?: FilterOptions) => readonly Guide[] | undefined;
|
getFilteredAppGuideMetadata: (filters?: FilterOptions) => readonly Guide[];
|
||||||
getStructuredAppGuideMetadata: (
|
getStructuredAppGuideMetadata: (
|
||||||
filters?: FilterOptions
|
filters?: FilterOptions
|
||||||
) => Record<AppGuideCategory, readonly Guide[]>;
|
) => Record<AppGuideCategory, readonly Guide[]>;
|
||||||
|
@ -64,13 +67,14 @@ export const useAppGuideMetadata = (): {
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return [];
|
||||||
},
|
},
|
||||||
[appGuides]
|
[appGuides]
|
||||||
);
|
);
|
||||||
|
|
||||||
const getStructuredAppGuideMetadata = useCallback(
|
const getStructuredAppGuideMetadata = useCallback(
|
||||||
(filters?: FilterOptions) => {
|
(filters?: FilterOptions) => {
|
||||||
const filteredMetadata = getFilteredAppGuideMetadata(filters) ?? [];
|
const filteredMetadata = getFilteredAppGuideMetadata(filters);
|
||||||
return filteredMetadata.reduce((accumulated, guide) => {
|
return filteredMetadata.reduce((accumulated, guide) => {
|
||||||
const { target, isFeatured } = guide.metadata;
|
const { target, isFeatured } = guide.metadata;
|
||||||
|
|
||||||
|
|
|
@ -83,6 +83,7 @@ function ConsoleContent() {
|
||||||
<Route path="api-resources">
|
<Route path="api-resources">
|
||||||
<Route index element={<ApiResources />} />
|
<Route index element={<ApiResources />} />
|
||||||
<Route path="create" element={<ApiResources />} />
|
<Route path="create" element={<ApiResources />} />
|
||||||
|
<Route path=":id/guide/:guideId" element={<ApiResourceDetails />} />
|
||||||
<Route path=":id" element={<ApiResourceDetails />}>
|
<Route path=":id" element={<ApiResourceDetails />}>
|
||||||
<Route index element={<Navigate replace to={ApiResourceDetailsTabs.Settings} />} />
|
<Route index element={<Navigate replace to={ApiResourceDetailsTabs.Settings} />} />
|
||||||
<Route path={ApiResourceDetailsTabs.Settings} element={<ApiResourceSettings />} />
|
<Route path={ApiResourceDetailsTabs.Settings} element={<ApiResourceSettings />} />
|
||||||
|
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { conditional } from '@silverhand/essentials';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import guides from '@/assets/docs/guides';
|
||||||
|
import Guide, { GuideContext, type GuideContextType } from '@/components/Guide';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
className?: string;
|
||||||
|
guideId: string;
|
||||||
|
isCompact?: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ApiGuide({ className, guideId, isCompact, onClose }: Props) {
|
||||||
|
const guide = guides.find(({ id }) => id === guideId);
|
||||||
|
|
||||||
|
const memorizedContext = useMemo(
|
||||||
|
() =>
|
||||||
|
conditional(
|
||||||
|
!!guide && {
|
||||||
|
metadata: guide.metadata,
|
||||||
|
Logo: guide.Logo,
|
||||||
|
isCompact: Boolean(isCompact),
|
||||||
|
}
|
||||||
|
) satisfies GuideContextType | undefined,
|
||||||
|
[guide, isCompact]
|
||||||
|
);
|
||||||
|
|
||||||
|
return memorizedContext ? (
|
||||||
|
<GuideContext.Provider value={memorizedContext}>
|
||||||
|
<Guide className={className} guideId={guideId} isLoading={!guide} onClose={onClose} />
|
||||||
|
</GuideContext.Provider>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ApiGuide;
|
|
@ -0,0 +1,37 @@
|
||||||
|
@use '@/scss/underscore' as _;
|
||||||
|
|
||||||
|
.drawerContainer {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
flex: 0 0 64px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 _.unit(6);
|
||||||
|
background-color: var(--color-layer-1);
|
||||||
|
font: var(--font-title-2);
|
||||||
|
color: var(--color-text);
|
||||||
|
box-shadow: var(--shadow-1);
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
border-left: 1px solid var(--color-border);
|
||||||
|
height: 20px;
|
||||||
|
width: 0;
|
||||||
|
margin: 0 _.unit(5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardGroup {
|
||||||
|
flex: 1;
|
||||||
|
padding: _.unit(6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: _.unit(6);
|
||||||
|
}
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import ArrowLeft from '@/assets/icons/arrow-left.svg';
|
||||||
|
import Close from '@/assets/icons/close.svg';
|
||||||
|
import { type SelectedGuide } from '@/components/Guide/GuideCard';
|
||||||
|
import GuideCardGroup from '@/components/Guide/GuideCardGroup';
|
||||||
|
import { useApiGuideMetadata } from '@/components/Guide/hooks';
|
||||||
|
import IconButton from '@/ds-components/IconButton';
|
||||||
|
import Spacer from '@/ds-components/Spacer';
|
||||||
|
|
||||||
|
import ApiGuide from '../ApiGuide';
|
||||||
|
|
||||||
|
import * as styles from './index.module.scss';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function GuideDrawer({ onClose }: Props) {
|
||||||
|
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.guide' });
|
||||||
|
const guides = useApiGuideMetadata();
|
||||||
|
const [selectedGuide, setSelectedGuide] = useState<SelectedGuide>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.drawerContainer}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
{selectedGuide && (
|
||||||
|
<>
|
||||||
|
<IconButton
|
||||||
|
size="large"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedGuide(undefined);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArrowLeft />
|
||||||
|
</IconButton>
|
||||||
|
<div className={styles.separator} />
|
||||||
|
<span>{t('checkout_tutorial', { name: selectedGuide.name })}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!selectedGuide && t('api.select_a_tutorial')}
|
||||||
|
<Spacer />
|
||||||
|
<IconButton size="large" onClick={onClose}>
|
||||||
|
<Close />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
{!selectedGuide && (
|
||||||
|
<GuideCardGroup
|
||||||
|
className={styles.cardGroup}
|
||||||
|
guides={guides}
|
||||||
|
onClickGuide={(guide) => {
|
||||||
|
setSelectedGuide(guide);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{selectedGuide && (
|
||||||
|
<ApiGuide
|
||||||
|
isCompact
|
||||||
|
className={styles.guide}
|
||||||
|
guideId={selectedGuide.id}
|
||||||
|
onClose={() => {
|
||||||
|
setSelectedGuide(undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GuideDrawer;
|
|
@ -0,0 +1,19 @@
|
||||||
|
@use '@/scss/underscore' as _;
|
||||||
|
@use '@/scss/dimensions' as dim;
|
||||||
|
|
||||||
|
.modalContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background-color: var(--color-base);
|
||||||
|
overflow-x: auto;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
min-width: dim.$guide-content-min-width;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide {
|
||||||
|
flex: 1;
|
||||||
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
import Modal from 'react-modal';
|
||||||
|
|
||||||
|
import ModalHeader from '@/components/Guide/ModalHeader';
|
||||||
|
import * as modalStyles from '@/scss/modal.module.scss';
|
||||||
|
|
||||||
|
import ApiGuide from '../ApiGuide';
|
||||||
|
|
||||||
|
import * as styles from './index.module.scss';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
guideId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function GuideModal({ guideId, onClose }: Props) {
|
||||||
|
return (
|
||||||
|
<Modal shouldCloseOnEsc isOpen className={modalStyles.fullScreen} onRequestClose={onClose}>
|
||||||
|
<div className={styles.modalContainer}>
|
||||||
|
<ModalHeader
|
||||||
|
title="guide.api.modal_title"
|
||||||
|
subtitle="guide.api.modal_subtitle"
|
||||||
|
buttonText="guide.cannot_find_guide"
|
||||||
|
requestFormFieldLabel="guide.describe_guide_looking_for"
|
||||||
|
requestFormFieldPlaceholder="guide.api.describe_guide_looking_for_placeholder"
|
||||||
|
requestSuccessMessage="guide.request_guide_successfully"
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
<ApiGuide className={styles.guide} guideId={guideId} onClose={onClose} />
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GuideModal;
|
|
@ -13,9 +13,11 @@ import ApiResource from '@/assets/icons/api-resource.svg';
|
||||||
import Delete from '@/assets/icons/delete.svg';
|
import Delete from '@/assets/icons/delete.svg';
|
||||||
import More from '@/assets/icons/more.svg';
|
import More from '@/assets/icons/more.svg';
|
||||||
import DetailsPage from '@/components/DetailsPage';
|
import DetailsPage from '@/components/DetailsPage';
|
||||||
|
import Drawer from '@/components/Drawer';
|
||||||
import PageMeta from '@/components/PageMeta';
|
import PageMeta from '@/components/PageMeta';
|
||||||
import { ApiResourceDetailsTabs } from '@/consts/page-tabs';
|
import { ApiResourceDetailsTabs } from '@/consts/page-tabs';
|
||||||
import ActionMenu, { ActionMenuItem } from '@/ds-components/ActionMenu';
|
import ActionMenu, { ActionMenuItem } from '@/ds-components/ActionMenu';
|
||||||
|
import Button from '@/ds-components/Button';
|
||||||
import Card from '@/ds-components/Card';
|
import Card from '@/ds-components/Card';
|
||||||
import CopyToClipboard from '@/ds-components/CopyToClipboard';
|
import CopyToClipboard from '@/ds-components/CopyToClipboard';
|
||||||
import DeleteConfirmModal from '@/ds-components/DeleteConfirmModal';
|
import DeleteConfirmModal from '@/ds-components/DeleteConfirmModal';
|
||||||
|
@ -26,14 +28,18 @@ import useApi from '@/hooks/use-api';
|
||||||
import useTenantPathname from '@/hooks/use-tenant-pathname';
|
import useTenantPathname from '@/hooks/use-tenant-pathname';
|
||||||
import useTheme from '@/hooks/use-theme';
|
import useTheme from '@/hooks/use-theme';
|
||||||
|
|
||||||
|
import GuideDrawer from './components/GuideDrawer';
|
||||||
|
import GuideModal from './components/GuideModal';
|
||||||
import * as styles from './index.module.scss';
|
import * as styles from './index.module.scss';
|
||||||
import { type ApiResourceDetailsOutletContext } from './types';
|
import { type ApiResourceDetailsOutletContext } from './types';
|
||||||
|
|
||||||
function ApiResourceDetails() {
|
function ApiResourceDetails() {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const { id } = useParams();
|
const { id, guideId } = useParams();
|
||||||
|
const { navigate, match } = useTenantPathname();
|
||||||
|
const isGuideView = !!id && !!guideId && match(`/api-resources/${id}/guide/${guideId}`);
|
||||||
|
|
||||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||||
const { navigate } = useTenantPathname();
|
|
||||||
const { data, error, mutate } = useSWR<Resource, RequestError>(id && `api/resources/${id}`);
|
const { data, error, mutate } = useSWR<Resource, RequestError>(id && `api/resources/${id}`);
|
||||||
const isLoading = !data && !error;
|
const isLoading = !data && !error;
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
@ -42,6 +48,7 @@ function ApiResourceDetails() {
|
||||||
const isOnPermissionPage = pathname.endsWith(ApiResourceDetailsTabs.Permissions);
|
const isOnPermissionPage = pathname.endsWith(ApiResourceDetailsTabs.Permissions);
|
||||||
const isLogtoManagementApiResource = isManagementApi(data?.indicator ?? '');
|
const isLogtoManagementApiResource = isManagementApi(data?.indicator ?? '');
|
||||||
|
|
||||||
|
const [isGuideDrawerOpen, setIsGuideDrawerOpen] = useState(false);
|
||||||
const [isDeleteFormOpen, setIsDeleteFormOpen] = useState(false);
|
const [isDeleteFormOpen, setIsDeleteFormOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -68,6 +75,21 @@ function ApiResourceDetails() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onCloseDrawer = () => {
|
||||||
|
setIsGuideDrawerOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isGuideView) {
|
||||||
|
return (
|
||||||
|
<GuideModal
|
||||||
|
guideId={guideId}
|
||||||
|
onClose={() => {
|
||||||
|
navigate(`/api-resources/${id}`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DetailsPage
|
<DetailsPage
|
||||||
backLink="/api-resources"
|
backLink="/api-resources"
|
||||||
|
@ -99,6 +121,16 @@ function ApiResourceDetails() {
|
||||||
</div>
|
</div>
|
||||||
{!isLogtoManagementApiResource && (
|
{!isLogtoManagementApiResource && (
|
||||||
<div className={styles.operations}>
|
<div className={styles.operations}>
|
||||||
|
<Button
|
||||||
|
title="application_details.check_guide"
|
||||||
|
size="large"
|
||||||
|
onClick={() => {
|
||||||
|
setIsGuideDrawerOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Drawer isOpen={isGuideDrawerOpen} onClose={onCloseDrawer}>
|
||||||
|
<GuideDrawer onClose={onCloseDrawer} />
|
||||||
|
</Drawer>
|
||||||
<ActionMenu
|
<ActionMenu
|
||||||
buttonProps={{ icon: <More className={styles.moreIcon} />, size: 'large' }}
|
buttonProps={{ icon: <More className={styles.moreIcon} />, size: 'large' }}
|
||||||
title={t('general.more_options')}
|
title={t('general.more_options')}
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
import { isManagementApi, type Resource } from '@logto/schemas';
|
import { isManagementApi, type Resource } from '@logto/schemas';
|
||||||
import { useContext } from 'react';
|
import { useContext } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
import { Trans, useTranslation } from 'react-i18next';
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
|
import Modal from 'react-modal';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
|
||||||
import ContactUsPhraseLink from '@/components/ContactUsPhraseLink';
|
import ContactUsPhraseLink from '@/components/ContactUsPhraseLink';
|
||||||
|
@ -16,6 +18,7 @@ import TextInput from '@/ds-components/TextInput';
|
||||||
import TextLink from '@/ds-components/TextLink';
|
import TextLink from '@/ds-components/TextLink';
|
||||||
import useApi from '@/hooks/use-api';
|
import useApi from '@/hooks/use-api';
|
||||||
import useSubscriptionPlan from '@/hooks/use-subscription-plan';
|
import useSubscriptionPlan from '@/hooks/use-subscription-plan';
|
||||||
|
import * as modalStyles from '@/scss/modal.module.scss';
|
||||||
import { trySubmitSafe } from '@/utils/form';
|
import { trySubmitSafe } from '@/utils/form';
|
||||||
import { hasReachedQuotaLimit } from '@/utils/quota';
|
import { hasReachedQuotaLimit } from '@/utils/quota';
|
||||||
|
|
||||||
|
@ -58,76 +61,87 @@ function CreateForm({ onClose }: Props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const createdApiResource = await api.post('api/resources', { json: data }).json<Resource>();
|
const createdApiResource = await api.post('api/resources', { json: data }).json<Resource>();
|
||||||
|
toast.success(t('api_resources.api_resource_created', { name: createdApiResource.name }));
|
||||||
onClose?.(createdApiResource);
|
onClose?.(createdApiResource);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalLayout
|
<Modal
|
||||||
title="api_resources.create"
|
shouldCloseOnEsc
|
||||||
subtitle="api_resources.subtitle"
|
isOpen
|
||||||
footer={
|
className={modalStyles.content}
|
||||||
isResourcesReachLimit && currentPlan ? (
|
overlayClassName={modalStyles.overlay}
|
||||||
<QuotaGuardFooter>
|
onRequestClose={() => {
|
||||||
<Trans
|
onClose?.();
|
||||||
components={{
|
}}
|
||||||
a: <ContactUsPhraseLink />,
|
|
||||||
planName: <PlanName name={currentPlan.name} />,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('upsell.paywall.resources', {
|
|
||||||
count: currentPlan.quota.resourcesLimit ?? 0,
|
|
||||||
})}
|
|
||||||
</Trans>
|
|
||||||
</QuotaGuardFooter>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
isLoading={isSubmitting}
|
|
||||||
htmlType="submit"
|
|
||||||
title="api_resources.create"
|
|
||||||
size="large"
|
|
||||||
type="primary"
|
|
||||||
onClick={onSubmit}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onClose={onClose}
|
|
||||||
>
|
>
|
||||||
<form>
|
<ModalLayout
|
||||||
<FormField isRequired title="api_resources.api_name">
|
title="api_resources.create"
|
||||||
<TextInput
|
subtitle="api_resources.subtitle"
|
||||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
footer={
|
||||||
autoFocus
|
isResourcesReachLimit && currentPlan ? (
|
||||||
{...register('name', { required: true })}
|
<QuotaGuardFooter>
|
||||||
placeholder={t('api_resources.api_name_placeholder')}
|
<Trans
|
||||||
/>
|
components={{
|
||||||
</FormField>
|
a: <ContactUsPhraseLink />,
|
||||||
<FormField
|
planName: <PlanName name={currentPlan.name} />,
|
||||||
isRequired
|
}}
|
||||||
title="api_resources.api_identifier"
|
>
|
||||||
tip={(closeTipHandler) => (
|
{t('upsell.paywall.resources', {
|
||||||
<Trans
|
count: currentPlan.quota.resourcesLimit ?? 0,
|
||||||
components={{
|
})}
|
||||||
a: (
|
</Trans>
|
||||||
<TextLink
|
</QuotaGuardFooter>
|
||||||
href="https://datatracker.ietf.org/doc/html/rfc8707#section-2"
|
) : (
|
||||||
target="_blank"
|
<Button
|
||||||
onClick={closeTipHandler}
|
isLoading={isSubmitting}
|
||||||
/>
|
htmlType="submit"
|
||||||
),
|
title="api_resources.create"
|
||||||
}}
|
size="large"
|
||||||
>
|
type="primary"
|
||||||
{t('api_resources.api_identifier_tip')}
|
onClick={onSubmit}
|
||||||
</Trans>
|
/>
|
||||||
)}
|
)
|
||||||
>
|
}
|
||||||
<TextInput
|
onClose={onClose}
|
||||||
{...register('indicator', { required: true })}
|
>
|
||||||
placeholder={t('api_resources.api_identifier_placeholder')}
|
<form>
|
||||||
/>
|
<FormField isRequired title="api_resources.api_name">
|
||||||
</FormField>
|
<TextInput
|
||||||
</form>
|
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||||
</ModalLayout>
|
autoFocus
|
||||||
|
{...register('name', { required: true })}
|
||||||
|
placeholder={t('api_resources.api_name_placeholder')}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField
|
||||||
|
isRequired
|
||||||
|
title="api_resources.api_identifier"
|
||||||
|
tip={(closeTipHandler) => (
|
||||||
|
<Trans
|
||||||
|
components={{
|
||||||
|
a: (
|
||||||
|
<TextLink
|
||||||
|
href="https://datatracker.ietf.org/doc/html/rfc8707#section-2"
|
||||||
|
target="_blank"
|
||||||
|
onClick={closeTipHandler}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('api_resources.api_identifier_tip')}
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
{...register('indicator', { required: true })}
|
||||||
|
placeholder={t('api_resources.api_identifier_placeholder')}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</form>
|
||||||
|
</ModalLayout>
|
||||||
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,39 @@
|
||||||
|
@use '@/scss/underscore' as _;
|
||||||
|
@use '@/scss/dimensions' as dim;
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
width: 100%;
|
||||||
|
min-width: dim.$guide-content-min-width;
|
||||||
|
max-width: dim.$guide-main-content-max-width + 2 * dim.$guide-content-padding;
|
||||||
|
margin: 0 auto;
|
||||||
|
position: relative;
|
||||||
|
padding: dim.$guide-content-padding;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groups {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding-bottom: _.unit(8);
|
||||||
|
position: relative;
|
||||||
|
max-width: dim.$guide-main-content-max-width;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
flex: unset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.guideGroup {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyPlaceholder {
|
||||||
|
justify-content: center;
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 70%;
|
||||||
|
}
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { type Resource } from '@logto/schemas';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
import EmptyDataPlaceholder from '@/components/EmptyDataPlaceholder';
|
||||||
|
import { type SelectedGuide } from '@/components/Guide/GuideCard';
|
||||||
|
import GuideCardGroup from '@/components/Guide/GuideCardGroup';
|
||||||
|
import { useApiGuideMetadata } from '@/components/Guide/hooks';
|
||||||
|
import OverlayScrollbar from '@/ds-components/OverlayScrollbar';
|
||||||
|
import useTenantPathname from '@/hooks/use-tenant-pathname';
|
||||||
|
|
||||||
|
import CreateForm from '../CreateForm';
|
||||||
|
|
||||||
|
import * as styles from './index.module.scss';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
className?: string;
|
||||||
|
hasCardBorder?: boolean;
|
||||||
|
hasCardButton?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function GuideLibrary({ className, hasCardBorder, hasCardButton }: Props) {
|
||||||
|
const { navigate } = useTenantPathname();
|
||||||
|
const [selectedGuide, setSelectedGuide] = useState<SelectedGuide>();
|
||||||
|
const guides = useApiGuideMetadata();
|
||||||
|
const [showCreateForm, setShowCreateForm] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const onClickGuide = useCallback((data: SelectedGuide) => {
|
||||||
|
setShowCreateForm(true);
|
||||||
|
setSelectedGuide(data);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onCloseCreateForm = useCallback(
|
||||||
|
(newResource?: Resource) => {
|
||||||
|
if (newResource && selectedGuide) {
|
||||||
|
navigate(`/api-resources/${newResource.id}/guide/${selectedGuide.id}`, { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setShowCreateForm(false);
|
||||||
|
setSelectedGuide(undefined);
|
||||||
|
},
|
||||||
|
[navigate, selectedGuide]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OverlayScrollbar className={classNames(styles.container, className)}>
|
||||||
|
<div className={styles.wrapper}>
|
||||||
|
<div className={styles.groups}>
|
||||||
|
{guides.length > 0 ? (
|
||||||
|
<GuideCardGroup
|
||||||
|
className={styles.guideGroup}
|
||||||
|
hasCardBorder={hasCardBorder}
|
||||||
|
hasCardButton={hasCardButton}
|
||||||
|
guides={guides}
|
||||||
|
onClickGuide={onClickGuide}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EmptyDataPlaceholder className={styles.emptyPlaceholder} size="large" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{selectedGuide?.target === 'API' && showCreateForm && (
|
||||||
|
<CreateForm onClose={onCloseCreateForm} />
|
||||||
|
)}
|
||||||
|
</OverlayScrollbar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GuideLibrary;
|
|
@ -0,0 +1,20 @@
|
||||||
|
@use '@/scss/underscore' as _;
|
||||||
|
@use '@/scss/dimensions' as dim;
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: var(--color-base);
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
overflow-x: auto;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
min-width: dim.$guide-content-min-width;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Modal from 'react-modal';
|
||||||
|
|
||||||
|
import ModalFooter from '@/components/Guide/ModalFooter';
|
||||||
|
import ModalHeader from '@/components/Guide/ModalHeader';
|
||||||
|
import useTenantPathname from '@/hooks/use-tenant-pathname';
|
||||||
|
import * as modalStyles from '@/scss/modal.module.scss';
|
||||||
|
|
||||||
|
import CreateForm from '../CreateForm';
|
||||||
|
import GuideLibrary from '../GuideLibrary';
|
||||||
|
|
||||||
|
import * as styles from './index.module.scss';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function GuideLibraryModal({ isOpen, onClose }: Props) {
|
||||||
|
const { navigate } = useTenantPathname();
|
||||||
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
shouldCloseOnEsc
|
||||||
|
isOpen={isOpen}
|
||||||
|
className={modalStyles.fullScreen}
|
||||||
|
onRequestClose={onClose}
|
||||||
|
>
|
||||||
|
<div className={styles.container}>
|
||||||
|
<ModalHeader
|
||||||
|
title="guide.api.modal_title"
|
||||||
|
subtitle="guide.api.modal_subtitle"
|
||||||
|
buttonText="guide.cannot_find_guide"
|
||||||
|
requestFormFieldLabel="guide.describe_guide_looking_for"
|
||||||
|
requestFormFieldPlaceholder="guide.api.describe_guide_looking_for_placeholder"
|
||||||
|
requestSuccessMessage="guide.request_guide_successfully"
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
<GuideLibrary hasCardButton className={styles.content} />
|
||||||
|
<ModalFooter
|
||||||
|
content="guide.do_not_need_tutorial"
|
||||||
|
buttonText="guide.api.continue_without_tutorial"
|
||||||
|
onClick={() => {
|
||||||
|
setShowCreateForm(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{showCreateForm && (
|
||||||
|
<CreateForm
|
||||||
|
onClose={(newApiResource) => {
|
||||||
|
if (newApiResource) {
|
||||||
|
navigate(`/api-resources/${newApiResource.id}`);
|
||||||
|
}
|
||||||
|
setShowCreateForm(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GuideLibraryModal;
|
|
@ -1,9 +1,7 @@
|
||||||
import { withAppInsights } from '@logto/app-insights/react';
|
import { withAppInsights } from '@logto/app-insights/react';
|
||||||
import type { Resource } from '@logto/schemas';
|
import type { Resource } from '@logto/schemas';
|
||||||
import { Theme } from '@logto/schemas';
|
import { Theme } from '@logto/schemas';
|
||||||
import { toast } from 'react-hot-toast';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import Modal from 'react-modal';
|
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
@ -20,10 +18,9 @@ import type { RequestError } from '@/hooks/use-api';
|
||||||
import useSearchParametersWatcher from '@/hooks/use-search-parameters-watcher';
|
import useSearchParametersWatcher from '@/hooks/use-search-parameters-watcher';
|
||||||
import useTenantPathname from '@/hooks/use-tenant-pathname';
|
import useTenantPathname from '@/hooks/use-tenant-pathname';
|
||||||
import useTheme from '@/hooks/use-theme';
|
import useTheme from '@/hooks/use-theme';
|
||||||
import * as modalStyles from '@/scss/modal.module.scss';
|
|
||||||
import { buildUrl } from '@/utils/url';
|
import { buildUrl } from '@/utils/url';
|
||||||
|
|
||||||
import CreateForm from './components/CreateForm';
|
import GuideLibraryModal from './components/GuideLibraryModal';
|
||||||
import * as styles from './index.module.scss';
|
import * as styles from './index.module.scss';
|
||||||
|
|
||||||
const pageSize = defaultPageSize;
|
const pageSize = defaultPageSize;
|
||||||
|
@ -56,93 +53,73 @@ function ApiResources() {
|
||||||
const isCreating = match(createApiResourcePathname);
|
const isCreating = match(createApiResourcePathname);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListPage
|
<>
|
||||||
title={{
|
<ListPage
|
||||||
title: 'api_resources.title',
|
title={{
|
||||||
subtitle: 'api_resources.subtitle',
|
title: 'api_resources.title',
|
||||||
}}
|
subtitle: 'api_resources.subtitle',
|
||||||
pageMeta={{ titleKey: 'api_resources.page_title' }}
|
}}
|
||||||
createButton={{
|
pageMeta={{ titleKey: 'api_resources.page_title' }}
|
||||||
title: 'api_resources.create',
|
createButton={{
|
||||||
onClick: () => {
|
title: 'api_resources.create',
|
||||||
navigate({
|
onClick: () => {
|
||||||
pathname: createApiResourcePathname,
|
|
||||||
search,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
table={{
|
|
||||||
rowGroups: [{ key: 'apiResources', data: apiResources }],
|
|
||||||
rowIndexKey: 'id',
|
|
||||||
isLoading,
|
|
||||||
errorMessage: error?.body?.message ?? error?.message,
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
title: t('api_resources.api_name'),
|
|
||||||
dataIndex: 'name',
|
|
||||||
colSpan: 6,
|
|
||||||
render: ({ id, name, isDefault }) => (
|
|
||||||
<ItemPreview
|
|
||||||
title={name}
|
|
||||||
icon={<ResourceIcon className={styles.icon} />}
|
|
||||||
to={buildDetailsPathname(id)}
|
|
||||||
suffix={isDefault && <Tag>{t('api_resources.default_api')}</Tag>}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('api_resources.api_identifier'),
|
|
||||||
dataIndex: 'indicator',
|
|
||||||
colSpan: 10,
|
|
||||||
render: ({ indicator }) => <CopyToClipboard value={indicator} variant="text" />,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
placeholder: <EmptyDataPlaceholder />,
|
|
||||||
rowClickHandler: ({ id }) => {
|
|
||||||
navigate(buildDetailsPathname(id));
|
|
||||||
},
|
|
||||||
onRetry: async () => mutate(undefined, true),
|
|
||||||
pagination: {
|
|
||||||
page,
|
|
||||||
totalCount,
|
|
||||||
pageSize,
|
|
||||||
onChange: (page) => {
|
|
||||||
updateSearchParameters({ page });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
widgets={
|
|
||||||
<Modal
|
|
||||||
shouldCloseOnEsc
|
|
||||||
isOpen={isCreating}
|
|
||||||
className={modalStyles.content}
|
|
||||||
overlayClassName={modalStyles.overlay}
|
|
||||||
onRequestClose={() => {
|
|
||||||
navigate({
|
navigate({
|
||||||
pathname: apiResourcesPathname,
|
pathname: createApiResourcePathname,
|
||||||
search,
|
search,
|
||||||
});
|
});
|
||||||
}}
|
},
|
||||||
>
|
}}
|
||||||
<CreateForm
|
table={{
|
||||||
onClose={(createdApiResource) => {
|
rowGroups: [{ key: 'apiResources', data: apiResources }],
|
||||||
if (createdApiResource) {
|
rowIndexKey: 'id',
|
||||||
toast.success(
|
isLoading,
|
||||||
t('api_resources.api_resource_created', { name: createdApiResource.name })
|
errorMessage: error?.body?.message ?? error?.message,
|
||||||
);
|
columns: [
|
||||||
navigate(buildDetailsPathname(createdApiResource.id), { replace: true });
|
{
|
||||||
|
title: t('api_resources.api_name'),
|
||||||
return;
|
dataIndex: 'name',
|
||||||
}
|
colSpan: 6,
|
||||||
navigate({
|
render: ({ id, name, isDefault }) => (
|
||||||
pathname: apiResourcesPathname,
|
<ItemPreview
|
||||||
search,
|
title={name}
|
||||||
});
|
icon={<ResourceIcon className={styles.icon} />}
|
||||||
}}
|
to={buildDetailsPathname(id)}
|
||||||
/>
|
suffix={isDefault && <Tag>{t('api_resources.default_api')}</Tag>}
|
||||||
</Modal>
|
/>
|
||||||
}
|
),
|
||||||
/>
|
},
|
||||||
|
{
|
||||||
|
title: t('api_resources.api_identifier'),
|
||||||
|
dataIndex: 'indicator',
|
||||||
|
colSpan: 10,
|
||||||
|
render: ({ indicator }) => <CopyToClipboard value={indicator} variant="text" />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
placeholder: <EmptyDataPlaceholder />,
|
||||||
|
rowClickHandler: ({ id }) => {
|
||||||
|
navigate(buildDetailsPathname(id));
|
||||||
|
},
|
||||||
|
onRetry: async () => mutate(undefined, true),
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
totalCount,
|
||||||
|
pageSize,
|
||||||
|
onChange: (page) => {
|
||||||
|
updateSearchParameters({ page });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<GuideLibraryModal
|
||||||
|
isOpen={isCreating}
|
||||||
|
onClose={() => {
|
||||||
|
navigate({
|
||||||
|
pathname: apiResourcesPathname,
|
||||||
|
search,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -107,7 +107,7 @@ function GuideLibrary({ className, hasCardBorder, hasCardButton, hasFilters }: P
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{keyword &&
|
{keyword &&
|
||||||
(filteredMetadata?.length ? (
|
(filteredMetadata.length > 0 ? (
|
||||||
<GuideCardGroup
|
<GuideCardGroup
|
||||||
className={styles.guideGroup}
|
className={styles.guideGroup}
|
||||||
hasCardBorder={hasCardBorder}
|
hasCardBorder={hasCardBorder}
|
||||||
|
|
|
@ -18,3 +18,9 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: dim.$guide-content-max-width) {
|
||||||
|
.container .footerInnerWrapper {
|
||||||
|
margin: 0 0 0 _.unit(62.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -38,6 +38,7 @@ function GuideLibraryModal({ isOpen, onClose }: Props) {
|
||||||
/>
|
/>
|
||||||
<GuideLibrary hasFilters hasCardButton className={styles.content} />
|
<GuideLibrary hasFilters hasCardButton className={styles.content} />
|
||||||
<ModalFooter
|
<ModalFooter
|
||||||
|
wrapperClassName={styles.footerInnerWrapper}
|
||||||
content="guide.do_not_need_tutorial"
|
content="guide.do_not_need_tutorial"
|
||||||
buttonText="guide.app.continue_without_framework"
|
buttonText="guide.app.continue_without_framework"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
|
@ -51,6 +51,13 @@ describe('RBAC', () => {
|
||||||
text: 'Create API Resource',
|
text: 'Create API Resource',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await expectModalWithTitle(page, 'Start with tutorials');
|
||||||
|
|
||||||
|
// Click bottom button to skip tutorials
|
||||||
|
await expect(page).toClick('.ReactModalPortal nav[class$=actionBar] button span', {
|
||||||
|
text: 'Continue without tutorial',
|
||||||
|
});
|
||||||
|
|
||||||
await expectModalWithTitle(page, 'Create API Resource');
|
await expectModalWithTitle(page, 'Create API Resource');
|
||||||
|
|
||||||
await expect(page).toFillForm('.ReactModalPortal form', {
|
await expect(page).toFillForm('.ReactModalPortal form', {
|
||||||
|
|
|
@ -2,7 +2,7 @@ const guide = {
|
||||||
start_building: '开始构建',
|
start_building: '开始构建',
|
||||||
get_started: '立即开始',
|
get_started: '立即开始',
|
||||||
categories: {
|
categories: {
|
||||||
featured: '推荐热门框架',
|
featured: '推荐热门开发框架',
|
||||||
Traditional: '传统网页应用',
|
Traditional: '传统网页应用',
|
||||||
SPA: '单页应用',
|
SPA: '单页应用',
|
||||||
Native: '原生应用',
|
Native: '原生应用',
|
||||||
|
|
Loading…
Reference in a new issue