0
Fork 0
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:
Charles Zhao 2023-09-12 20:48:56 +08:00 committed by GitHub
parent 6cdd33bf1c
commit 2a9a9263ea
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 592 additions and 166 deletions

View file

@ -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);
}
}

View file

@ -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>

View file

@ -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;

View file

@ -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 />} />

View file

@ -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;

View file

@ -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);
}

View file

@ -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;

View file

@ -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;
}

View file

@ -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;

View file

@ -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')}

View file

@ -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>
); );
} }

View file

@ -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%;
}

View file

@ -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;

View file

@ -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;
}
}

View file

@ -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;

View file

@ -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,
});
}}
/>
</>
); );
} }

View file

@ -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}

View file

@ -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);
}
}

View file

@ -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={() => {

View file

@ -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', {

View file

@ -2,7 +2,7 @@ const guide = {
start_building: '开始构建', start_building: '开始构建',
get_started: '立即开始', get_started: '立即开始',
categories: { categories: {
featured: '推荐热门框架', featured: '推荐热门开发框架',
Traditional: '传统网页应用', Traditional: '传统网页应用',
SPA: '单页应用', SPA: '单页应用',
Native: '原生应用', Native: '原生应用',