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);
|
||||
}
|
||||
}
|
||||
|
||||
@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 classNames from 'classnames';
|
||||
|
||||
import Button from '@/ds-components/Button';
|
||||
import DynamicT from '@/ds-components/DynamicT';
|
||||
|
@ -6,15 +7,16 @@ import DynamicT from '@/ds-components/DynamicT';
|
|||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
wrapperClassName?: string;
|
||||
content: AdminConsoleKey;
|
||||
buttonText: AdminConsoleKey;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export default function ModalFooter({ content, buttonText, onClick }: Props) {
|
||||
export default function ModalFooter({ wrapperClassName, content, buttonText, onClick }: Props) {
|
||||
return (
|
||||
<nav className={styles.actionBar}>
|
||||
<div className={styles.wrapper}>
|
||||
<div className={classNames(styles.wrapper, wrapperClassName)}>
|
||||
<span className={styles.text}>
|
||||
<DynamicT forKey={content} />
|
||||
</span>
|
||||
|
|
|
@ -17,8 +17,11 @@ type FilterOptions = {
|
|||
keyword?: string;
|
||||
};
|
||||
|
||||
export const useApiGuideMetadata = () =>
|
||||
guides.filter(({ metadata: { target } }) => target === 'API');
|
||||
|
||||
export const useAppGuideMetadata = (): {
|
||||
getFilteredAppGuideMetadata: (filters?: FilterOptions) => readonly Guide[] | undefined;
|
||||
getFilteredAppGuideMetadata: (filters?: FilterOptions) => readonly Guide[];
|
||||
getStructuredAppGuideMetadata: (
|
||||
filters?: FilterOptions
|
||||
) => Record<AppGuideCategory, readonly Guide[]>;
|
||||
|
@ -64,13 +67,14 @@ export const useAppGuideMetadata = (): {
|
|||
)
|
||||
);
|
||||
}
|
||||
return [];
|
||||
},
|
||||
[appGuides]
|
||||
);
|
||||
|
||||
const getStructuredAppGuideMetadata = useCallback(
|
||||
(filters?: FilterOptions) => {
|
||||
const filteredMetadata = getFilteredAppGuideMetadata(filters) ?? [];
|
||||
const filteredMetadata = getFilteredAppGuideMetadata(filters);
|
||||
return filteredMetadata.reduce((accumulated, guide) => {
|
||||
const { target, isFeatured } = guide.metadata;
|
||||
|
||||
|
|
|
@ -83,6 +83,7 @@ function ConsoleContent() {
|
|||
<Route path="api-resources">
|
||||
<Route index element={<ApiResources />} />
|
||||
<Route path="create" element={<ApiResources />} />
|
||||
<Route path=":id/guide/:guideId" element={<ApiResourceDetails />} />
|
||||
<Route path=":id" element={<ApiResourceDetails />}>
|
||||
<Route index element={<Navigate replace to={ApiResourceDetailsTabs.Settings} />} />
|
||||
<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 More from '@/assets/icons/more.svg';
|
||||
import DetailsPage from '@/components/DetailsPage';
|
||||
import Drawer from '@/components/Drawer';
|
||||
import PageMeta from '@/components/PageMeta';
|
||||
import { ApiResourceDetailsTabs } from '@/consts/page-tabs';
|
||||
import ActionMenu, { ActionMenuItem } from '@/ds-components/ActionMenu';
|
||||
import Button from '@/ds-components/Button';
|
||||
import Card from '@/ds-components/Card';
|
||||
import CopyToClipboard from '@/ds-components/CopyToClipboard';
|
||||
import DeleteConfirmModal from '@/ds-components/DeleteConfirmModal';
|
||||
|
@ -26,14 +28,18 @@ import useApi from '@/hooks/use-api';
|
|||
import useTenantPathname from '@/hooks/use-tenant-pathname';
|
||||
import useTheme from '@/hooks/use-theme';
|
||||
|
||||
import GuideDrawer from './components/GuideDrawer';
|
||||
import GuideModal from './components/GuideModal';
|
||||
import * as styles from './index.module.scss';
|
||||
import { type ApiResourceDetailsOutletContext } from './types';
|
||||
|
||||
function ApiResourceDetails() {
|
||||
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 { navigate } = useTenantPathname();
|
||||
const { data, error, mutate } = useSWR<Resource, RequestError>(id && `api/resources/${id}`);
|
||||
const isLoading = !data && !error;
|
||||
const theme = useTheme();
|
||||
|
@ -42,6 +48,7 @@ function ApiResourceDetails() {
|
|||
const isOnPermissionPage = pathname.endsWith(ApiResourceDetailsTabs.Permissions);
|
||||
const isLogtoManagementApiResource = isManagementApi(data?.indicator ?? '');
|
||||
|
||||
const [isGuideDrawerOpen, setIsGuideDrawerOpen] = useState(false);
|
||||
const [isDeleteFormOpen, setIsDeleteFormOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -68,6 +75,21 @@ function ApiResourceDetails() {
|
|||
}
|
||||
};
|
||||
|
||||
const onCloseDrawer = () => {
|
||||
setIsGuideDrawerOpen(false);
|
||||
};
|
||||
|
||||
if (isGuideView) {
|
||||
return (
|
||||
<GuideModal
|
||||
guideId={guideId}
|
||||
onClose={() => {
|
||||
navigate(`/api-resources/${id}`);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DetailsPage
|
||||
backLink="/api-resources"
|
||||
|
@ -99,6 +121,16 @@ function ApiResourceDetails() {
|
|||
</div>
|
||||
{!isLogtoManagementApiResource && (
|
||||
<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
|
||||
buttonProps={{ icon: <More className={styles.moreIcon} />, size: 'large' }}
|
||||
title={t('general.more_options')}
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import { isManagementApi, type Resource } from '@logto/schemas';
|
||||
import { useContext } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import Modal from 'react-modal';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import ContactUsPhraseLink from '@/components/ContactUsPhraseLink';
|
||||
|
@ -16,6 +18,7 @@ import TextInput from '@/ds-components/TextInput';
|
|||
import TextLink from '@/ds-components/TextLink';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import useSubscriptionPlan from '@/hooks/use-subscription-plan';
|
||||
import * as modalStyles from '@/scss/modal.module.scss';
|
||||
import { trySubmitSafe } from '@/utils/form';
|
||||
import { hasReachedQuotaLimit } from '@/utils/quota';
|
||||
|
||||
|
@ -58,76 +61,87 @@ function CreateForm({ onClose }: Props) {
|
|||
}
|
||||
|
||||
const createdApiResource = await api.post('api/resources', { json: data }).json<Resource>();
|
||||
toast.success(t('api_resources.api_resource_created', { name: createdApiResource.name }));
|
||||
onClose?.(createdApiResource);
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<ModalLayout
|
||||
title="api_resources.create"
|
||||
subtitle="api_resources.subtitle"
|
||||
footer={
|
||||
isResourcesReachLimit && currentPlan ? (
|
||||
<QuotaGuardFooter>
|
||||
<Trans
|
||||
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}
|
||||
<Modal
|
||||
shouldCloseOnEsc
|
||||
isOpen
|
||||
className={modalStyles.content}
|
||||
overlayClassName={modalStyles.overlay}
|
||||
onRequestClose={() => {
|
||||
onClose?.();
|
||||
}}
|
||||
>
|
||||
<form>
|
||||
<FormField isRequired title="api_resources.api_name">
|
||||
<TextInput
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
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>
|
||||
<ModalLayout
|
||||
title="api_resources.create"
|
||||
subtitle="api_resources.subtitle"
|
||||
footer={
|
||||
isResourcesReachLimit && currentPlan ? (
|
||||
<QuotaGuardFooter>
|
||||
<Trans
|
||||
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>
|
||||
<FormField isRequired title="api_resources.api_name">
|
||||
<TextInput
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
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 type { Resource } from '@logto/schemas';
|
||||
import { Theme } from '@logto/schemas';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Modal from 'react-modal';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
|
||||
|
@ -20,10 +18,9 @@ import type { RequestError } from '@/hooks/use-api';
|
|||
import useSearchParametersWatcher from '@/hooks/use-search-parameters-watcher';
|
||||
import useTenantPathname from '@/hooks/use-tenant-pathname';
|
||||
import useTheme from '@/hooks/use-theme';
|
||||
import * as modalStyles from '@/scss/modal.module.scss';
|
||||
import { buildUrl } from '@/utils/url';
|
||||
|
||||
import CreateForm from './components/CreateForm';
|
||||
import GuideLibraryModal from './components/GuideLibraryModal';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
const pageSize = defaultPageSize;
|
||||
|
@ -56,93 +53,73 @@ function ApiResources() {
|
|||
const isCreating = match(createApiResourcePathname);
|
||||
|
||||
return (
|
||||
<ListPage
|
||||
title={{
|
||||
title: 'api_resources.title',
|
||||
subtitle: 'api_resources.subtitle',
|
||||
}}
|
||||
pageMeta={{ titleKey: 'api_resources.page_title' }}
|
||||
createButton={{
|
||||
title: 'api_resources.create',
|
||||
onClick: () => {
|
||||
navigate({
|
||||
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={() => {
|
||||
<>
|
||||
<ListPage
|
||||
title={{
|
||||
title: 'api_resources.title',
|
||||
subtitle: 'api_resources.subtitle',
|
||||
}}
|
||||
pageMeta={{ titleKey: 'api_resources.page_title' }}
|
||||
createButton={{
|
||||
title: 'api_resources.create',
|
||||
onClick: () => {
|
||||
navigate({
|
||||
pathname: apiResourcesPathname,
|
||||
pathname: createApiResourcePathname,
|
||||
search,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<CreateForm
|
||||
onClose={(createdApiResource) => {
|
||||
if (createdApiResource) {
|
||||
toast.success(
|
||||
t('api_resources.api_resource_created', { name: createdApiResource.name })
|
||||
);
|
||||
navigate(buildDetailsPathname(createdApiResource.id), { replace: true });
|
||||
|
||||
return;
|
||||
}
|
||||
navigate({
|
||||
pathname: apiResourcesPathname,
|
||||
search,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
}
|
||||
/>
|
||||
},
|
||||
}}
|
||||
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 });
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<GuideLibraryModal
|
||||
isOpen={isCreating}
|
||||
onClose={() => {
|
||||
navigate({
|
||||
pathname: apiResourcesPathname,
|
||||
search,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -107,7 +107,7 @@ function GuideLibrary({ className, hasCardBorder, hasCardButton, hasFilters }: P
|
|||
</div>
|
||||
)}
|
||||
{keyword &&
|
||||
(filteredMetadata?.length ? (
|
||||
(filteredMetadata.length > 0 ? (
|
||||
<GuideCardGroup
|
||||
className={styles.guideGroup}
|
||||
hasCardBorder={hasCardBorder}
|
||||
|
|
|
@ -18,3 +18,9 @@
|
|||
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} />
|
||||
<ModalFooter
|
||||
wrapperClassName={styles.footerInnerWrapper}
|
||||
content="guide.do_not_need_tutorial"
|
||||
buttonText="guide.app.continue_without_framework"
|
||||
onClick={() => {
|
||||
|
|
|
@ -51,6 +51,13 @@ describe('RBAC', () => {
|
|||
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 expect(page).toFillForm('.ReactModalPortal form', {
|
||||
|
|
|
@ -2,7 +2,7 @@ const guide = {
|
|||
start_building: '开始构建',
|
||||
get_started: '立即开始',
|
||||
categories: {
|
||||
featured: '推荐热门框架',
|
||||
featured: '推荐热门开发框架',
|
||||
Traditional: '传统网页应用',
|
||||
SPA: '单页应用',
|
||||
Native: '原生应用',
|
||||
|
|
Loading…
Reference in a new issue