mirror of
https://github.com/logto-io/logto.git
synced 2025-04-14 23:11:31 -05:00
refactor(console): re-use guide components in new api resource creation process (#4448)
This commit is contained in:
parent
f6caeacb5a
commit
a54efc84cc
36 changed files with 649 additions and 461 deletions
packages/console/src
assets/docs/guides
m2m-general/components
web-gpt-plugin/components
web-outline/components
components/Guide
mdx-components
pages
|
@ -2,20 +2,22 @@ import { useContext, useState } from 'react';
|
|||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { GuideContext } from '@/components/Guide';
|
||||
import FormField from '@/ds-components/FormField';
|
||||
import Switch from '@/ds-components/Switch';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import { GuideContext } from '@/pages/Applications/components/Guide';
|
||||
|
||||
export default function EnableAdminAccess() {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const {
|
||||
app: { id: appId, isAdmin },
|
||||
} = useContext(GuideContext);
|
||||
const { app } = useContext(GuideContext);
|
||||
const { id: appId, isAdmin = false } = app ?? {};
|
||||
const [value, setValue] = useState(isAdmin);
|
||||
const api = useApi();
|
||||
|
||||
const onSubmit = async (value: boolean) => {
|
||||
if (!appId) {
|
||||
return;
|
||||
}
|
||||
setValue(value);
|
||||
try {
|
||||
await api.patch(`api/applications/${appId}`, {
|
||||
|
|
|
@ -2,25 +2,25 @@ import { useContext, useState } from 'react';
|
|||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { GuideContext } from '@/components/Guide';
|
||||
import FormField from '@/ds-components/FormField';
|
||||
import Switch from '@/ds-components/Switch';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import { GuideContext } from '@/pages/Applications/components/Guide';
|
||||
|
||||
export default function AlwaysIssueRefreshToken() {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const {
|
||||
app: {
|
||||
id: appId,
|
||||
customClientMetadata: { alwaysIssueRefreshToken },
|
||||
},
|
||||
} = useContext(GuideContext);
|
||||
const [value, setValue] = useState(alwaysIssueRefreshToken ?? false);
|
||||
const { app } = useContext(GuideContext);
|
||||
const { alwaysIssueRefreshToken = false } = app?.customClientMetadata ?? {};
|
||||
const [value, setValue] = useState(alwaysIssueRefreshToken);
|
||||
const api = useApi();
|
||||
|
||||
const onSubmit = async (value: boolean) => {
|
||||
if (!app?.id) {
|
||||
return;
|
||||
}
|
||||
setValue(value);
|
||||
try {
|
||||
await api.patch(`api/applications/${appId}`, {
|
||||
await api.patch(`api/applications/${app.id}`, {
|
||||
json: {
|
||||
customClientMetadata: {
|
||||
alwaysIssueRefreshToken: value,
|
||||
|
|
|
@ -1,24 +1,27 @@
|
|||
import { useContext } from 'react';
|
||||
|
||||
import { GuideContext } from '@/components/Guide';
|
||||
import CopyToClipboard from '@/ds-components/CopyToClipboard';
|
||||
import FormField from '@/ds-components/FormField';
|
||||
import { GuideContext } from '@/pages/Applications/components/Guide';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
export default function ClientBasics() {
|
||||
const {
|
||||
app: { id, secret },
|
||||
} = useContext(GuideContext);
|
||||
const { app } = useContext(GuideContext);
|
||||
const { id, secret } = app ?? {};
|
||||
|
||||
return (
|
||||
<div className={styles.basic}>
|
||||
<FormField title="application_details.application_id" className={styles.item}>
|
||||
<CopyToClipboard value={id} variant="border" />
|
||||
</FormField>
|
||||
<FormField title="application_details.application_secret" className={styles.item}>
|
||||
<CopyToClipboard hasVisibilityToggle value={secret} variant="border" />
|
||||
</FormField>
|
||||
{id && (
|
||||
<FormField title="application_details.application_id" className={styles.item}>
|
||||
<CopyToClipboard value={id} variant="border" />
|
||||
</FormField>
|
||||
)}
|
||||
{secret && (
|
||||
<FormField title="application_details.application_secret" className={styles.item}>
|
||||
<CopyToClipboard hasVisibilityToggle value={secret} variant="border" />
|
||||
</FormField>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -2,15 +2,14 @@ import { type SnakeCaseOidcConfig } from '@logto/schemas';
|
|||
import { useContext } from 'react';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import { GuideContext } from '@/components/Guide';
|
||||
import { openIdProviderConfigPath } from '@/consts/oidc';
|
||||
import CopyToClipboard from '@/ds-components/CopyToClipboard';
|
||||
import { type RequestError } from '@/hooks/use-api';
|
||||
import { GuideContext } from '@/pages/Applications/components/Guide';
|
||||
|
||||
export default function EnvironmentVariables() {
|
||||
const {
|
||||
app: { id, secret },
|
||||
} = useContext(GuideContext);
|
||||
const { app } = useContext(GuideContext);
|
||||
const { id, secret } = app ?? {};
|
||||
const { data } = useSWR<SnakeCaseOidcConfig, RequestError>(openIdProviderConfigPath);
|
||||
const authorizationEndpoint = data?.authorization_endpoint ?? '[LOADING]';
|
||||
const tokenEndpoint = data?.token_endpoint ?? '[LOADING]';
|
||||
|
@ -26,20 +25,24 @@ export default function EnvironmentVariables() {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>OIDC_CLIENT_ID</td>
|
||||
<td>App ID</td>
|
||||
<td>
|
||||
<CopyToClipboard value={id} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>OIDC_CLIENT_SECRET</td>
|
||||
<td>App secret</td>
|
||||
<td>
|
||||
<CopyToClipboard value={secret} />
|
||||
</td>
|
||||
</tr>
|
||||
{id && (
|
||||
<tr>
|
||||
<td>OIDC_CLIENT_ID</td>
|
||||
<td>App ID</td>
|
||||
<td>
|
||||
<CopyToClipboard value={id} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{secret && (
|
||||
<tr>
|
||||
<td>OIDC_CLIENT_SECRET</td>
|
||||
<td>App secret</td>
|
||||
<td>
|
||||
<CopyToClipboard value={secret} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td>OIDC_AUTH_URI</td>
|
||||
<td>Authorization endpoint</td>
|
||||
|
|
|
@ -7,7 +7,7 @@ import GuideCard, { type SelectedGuide } from '../GuideCard';
|
|||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type GuideGroupProps = {
|
||||
type Props = {
|
||||
className?: string;
|
||||
categoryName?: string;
|
||||
guides?: readonly Guide[];
|
||||
|
@ -16,8 +16,8 @@ type GuideGroupProps = {
|
|||
onClickGuide: (data: SelectedGuide) => void;
|
||||
};
|
||||
|
||||
function GuideGroup(
|
||||
{ className, categoryName, guides, hasCardBorder, hasCardButton, onClickGuide }: GuideGroupProps,
|
||||
function GuideCardGroup(
|
||||
{ className, categoryName, guides, hasCardBorder, hasCardButton, onClickGuide }: Props,
|
||||
ref: Ref<HTMLDivElement>
|
||||
) {
|
||||
if (!guides?.length) {
|
||||
|
@ -42,4 +42,4 @@ function GuideGroup(
|
|||
);
|
||||
}
|
||||
|
||||
export default forwardRef<HTMLDivElement, GuideGroupProps>(GuideGroup);
|
||||
export default forwardRef<HTMLDivElement, Props>(GuideCardGroup);
|
|
@ -0,0 +1,32 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
@use '@/scss/dimensions' as dim;
|
||||
|
||||
.actionBar {
|
||||
inset: auto 0 0 0;
|
||||
width: 100%;
|
||||
padding: _.unit(4) _.unit(6);
|
||||
background-color: var(--color-layer-1);
|
||||
box-shadow: var(--shadow-3);
|
||||
z-index: 1;
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
max-width: dim.$guide-main-content-max-width;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.text {
|
||||
font: var(--font-body-2);
|
||||
color: var(--color-text);
|
||||
margin-right: _.unit(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);
|
||||
}
|
||||
}
|
25
packages/console/src/components/Guide/ModalFooter/index.tsx
Normal file
25
packages/console/src/components/Guide/ModalFooter/index.tsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { type AdminConsoleKey } from '@logto/phrases';
|
||||
|
||||
import Button from '@/ds-components/Button';
|
||||
import DynamicT from '@/ds-components/DynamicT';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
content: AdminConsoleKey;
|
||||
buttonText: AdminConsoleKey;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export default function ModalFooter({ content, buttonText, onClick }: Props) {
|
||||
return (
|
||||
<nav className={styles.actionBar}>
|
||||
<div className={styles.wrapper}>
|
||||
<span className={styles.text}>
|
||||
<DynamicT forKey={content} />
|
||||
</span>
|
||||
<Button size="large" title={buttonText} type="outline" onClick={onClick} />
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
|
@ -1,9 +1,11 @@
|
|||
import { type AdminConsoleKey } from '@logto/phrases';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ReactModal from 'react-modal';
|
||||
|
||||
import Button from '@/ds-components/Button';
|
||||
import DynamicT from '@/ds-components/DynamicT';
|
||||
import FormField from '@/ds-components/FormField';
|
||||
import ModalLayout from '@/ds-components/ModalLayout';
|
||||
import TextInput from '@/ds-components/TextInput';
|
||||
|
@ -11,13 +13,24 @@ import useMeCustomData from '@/hooks/use-me-custom-data';
|
|||
import * as modalStyles from '@/scss/modal.module.scss';
|
||||
|
||||
type Props = {
|
||||
title: AdminConsoleKey;
|
||||
fieldLabel: AdminConsoleKey;
|
||||
fieldPlaceholder: AdminConsoleKey;
|
||||
successMessage: AdminConsoleKey;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function RequestGuide({ isOpen, onClose }: Props) {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
export default function RequestForm({
|
||||
title,
|
||||
fieldLabel,
|
||||
fieldPlaceholder,
|
||||
successMessage,
|
||||
isOpen,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { data, update } = useMeCustomData();
|
||||
const guideRequests = data?.guideRequests;
|
||||
|
@ -37,7 +50,7 @@ export default function RequestGuide({ isOpen, onClose }: Props) {
|
|||
setIsLoading(false);
|
||||
setInputValue('');
|
||||
onClose();
|
||||
toast.success(t('applications.guide.request_guide_successfully'));
|
||||
toast.success(String(t(successMessage)));
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -49,7 +62,7 @@ export default function RequestGuide({ isOpen, onClose }: Props) {
|
|||
onRequestClose={onClose}
|
||||
>
|
||||
<ModalLayout
|
||||
title="applications.guide.cannot_find_guide"
|
||||
title={<DynamicT forKey={title} />}
|
||||
footer={
|
||||
<Button
|
||||
size="large"
|
||||
|
@ -62,9 +75,10 @@ export default function RequestGuide({ isOpen, onClose }: Props) {
|
|||
}
|
||||
onClose={onClose}
|
||||
>
|
||||
<FormField title="applications.guide.describe_guide_looking_for">
|
||||
<FormField title={<DynamicT forKey={fieldLabel} />}>
|
||||
<TextInput
|
||||
placeholder={t('applications.guide.describe_guide_looking_for_placeholder')}
|
||||
/** The i18n value is already string here. */
|
||||
placeholder={String(t(fieldPlaceholder))}
|
||||
value={inputValue}
|
||||
onChange={({ currentTarget: { value } }) => {
|
||||
setInputValue(value);
|
|
@ -1,3 +1,4 @@
|
|||
import { type AdminConsoleKey } from '@logto/phrases';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import Box from '@/assets/icons/box.svg';
|
||||
|
@ -9,14 +10,30 @@ import CardTitle from '@/ds-components/CardTitle';
|
|||
import IconButton from '@/ds-components/IconButton';
|
||||
import Spacer from '@/ds-components/Spacer';
|
||||
|
||||
import RequestGuide from './RequestGuide';
|
||||
import RequestForm from './RequestForm';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
title: AdminConsoleKey;
|
||||
subtitle: AdminConsoleKey;
|
||||
buttonText: AdminConsoleKey;
|
||||
requestFormTitle?: AdminConsoleKey;
|
||||
requestFormFieldLabel: AdminConsoleKey;
|
||||
requestFormFieldPlaceholder: AdminConsoleKey;
|
||||
requestSuccessMessage: AdminConsoleKey;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function GuideHeader({ onClose }: Props) {
|
||||
function ModalHeader({
|
||||
title,
|
||||
subtitle,
|
||||
buttonText,
|
||||
requestFormTitle = buttonText,
|
||||
requestFormFieldLabel,
|
||||
requestFormFieldPlaceholder,
|
||||
requestSuccessMessage,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const [isRequestGuideOpen, setIsRequestGuideOpen] = useState(false);
|
||||
const onRequestGuideClose = useCallback(() => {
|
||||
setIsRequestGuideOpen(false);
|
||||
|
@ -28,17 +45,13 @@ function GuideHeader({ onClose }: Props) {
|
|||
<Close className={styles.closeIcon} />
|
||||
</IconButton>
|
||||
<div className={styles.separator} />
|
||||
<CardTitle
|
||||
size="small"
|
||||
title="applications.guide.modal_header_title"
|
||||
subtitle="applications.guide.header_subtitle"
|
||||
/>
|
||||
<CardTitle size="small" title={title} subtitle={subtitle} />
|
||||
<Spacer />
|
||||
<Button
|
||||
className={styles.requestSdkButton}
|
||||
type="outline"
|
||||
icon={<Box />}
|
||||
title="applications.guide.cannot_find_guide"
|
||||
title={buttonText}
|
||||
onClick={() => {
|
||||
if (isCloud) {
|
||||
setIsRequestGuideOpen(true);
|
||||
|
@ -47,9 +60,18 @@ function GuideHeader({ onClose }: Props) {
|
|||
}
|
||||
}}
|
||||
/>
|
||||
{isCloud && <RequestGuide isOpen={isRequestGuideOpen} onClose={onRequestGuideClose} />}
|
||||
{isCloud && (
|
||||
<RequestForm
|
||||
title={requestFormTitle}
|
||||
successMessage={requestSuccessMessage}
|
||||
fieldLabel={requestFormFieldLabel}
|
||||
fieldPlaceholder={requestFormFieldPlaceholder}
|
||||
isOpen={isRequestGuideOpen}
|
||||
onClose={onRequestGuideClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GuideHeader;
|
||||
export default ModalHeader;
|
94
packages/console/src/components/Guide/hooks.ts
Normal file
94
packages/console/src/components/Guide/hooks.ts
Normal file
|
@ -0,0 +1,94 @@
|
|||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import guides from '@/assets/docs/guides';
|
||||
import { type Guide } from '@/assets/docs/guides/types';
|
||||
import { type AppGuideCategory, type StructuredAppGuideMetadata } from '@/types/applications';
|
||||
|
||||
const defaultStructuredMetadata: StructuredAppGuideMetadata = {
|
||||
featured: [],
|
||||
Traditional: [],
|
||||
SPA: [],
|
||||
Native: [],
|
||||
MachineToMachine: [],
|
||||
};
|
||||
|
||||
type FilterOptions = {
|
||||
categories?: AppGuideCategory[];
|
||||
keyword?: string;
|
||||
};
|
||||
|
||||
export const useAppGuideMetadata = (): {
|
||||
getFilteredAppGuideMetadata: (filters?: FilterOptions) => readonly Guide[] | undefined;
|
||||
getStructuredAppGuideMetadata: (
|
||||
filters?: FilterOptions
|
||||
) => Record<AppGuideCategory, readonly Guide[]>;
|
||||
} => {
|
||||
const appGuides = useMemo(
|
||||
() => guides.filter(({ metadata: { target } }) => target !== 'API'),
|
||||
[]
|
||||
);
|
||||
|
||||
const getFilteredAppGuideMetadata = useCallback(
|
||||
(filters?: FilterOptions) => {
|
||||
const { categories: filterCategories, keyword } = filters ?? {};
|
||||
// If no filter is applied, return all metadata
|
||||
if (!filterCategories?.length && !keyword) {
|
||||
return appGuides;
|
||||
}
|
||||
|
||||
// Keyword only, return partial name matched result
|
||||
if (keyword && !filterCategories?.length) {
|
||||
return appGuides.filter(({ metadata: { name } }) =>
|
||||
name.toLowerCase().includes(keyword.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// Categories only, return selected categories
|
||||
if (!keyword && filterCategories?.length) {
|
||||
return appGuides.filter(({ metadata: { target, isFeatured } }) =>
|
||||
filterCategories.some(
|
||||
(filterCategory) =>
|
||||
filterCategory === target || (isFeatured && filterCategory === 'featured')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Keyword and categories, return partial name matched result in selected categories
|
||||
if (keyword && filterCategories?.length) {
|
||||
return appGuides.filter(
|
||||
({ metadata: { name, target, isFeatured } }) =>
|
||||
name.toLowerCase().includes(keyword.toLowerCase()) &&
|
||||
filterCategories.some(
|
||||
(filterCategory) =>
|
||||
filterCategory === target || (isFeatured && filterCategory === 'featured')
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
[appGuides]
|
||||
);
|
||||
|
||||
const getStructuredAppGuideMetadata = useCallback(
|
||||
(filters?: FilterOptions) => {
|
||||
const filteredMetadata = getFilteredAppGuideMetadata(filters) ?? [];
|
||||
return filteredMetadata.reduce((accumulated, guide) => {
|
||||
const { target, isFeatured } = guide.metadata;
|
||||
|
||||
// Rule out API target guides to make TypeScript happy
|
||||
if (target === 'API') {
|
||||
return accumulated;
|
||||
}
|
||||
return {
|
||||
...accumulated,
|
||||
[target]: [...accumulated[target], guide],
|
||||
...(isFeatured && {
|
||||
featured: [...accumulated.featured, guide],
|
||||
}),
|
||||
};
|
||||
}, defaultStructuredMetadata);
|
||||
},
|
||||
[getFilteredAppGuideMetadata]
|
||||
);
|
||||
|
||||
return { getFilteredAppGuideMetadata, getStructuredAppGuideMetadata };
|
||||
};
|
113
packages/console/src/components/Guide/index.tsx
Normal file
113
packages/console/src/components/Guide/index.tsx
Normal file
|
@ -0,0 +1,113 @@
|
|||
import { type ApplicationResponse } from '@logto/schemas';
|
||||
import { MDXProvider } from '@mdx-js/react';
|
||||
import classNames from 'classnames';
|
||||
import { type LazyExoticComponent, Suspense, createContext, useContext } from 'react';
|
||||
|
||||
import guides from '@/assets/docs/guides';
|
||||
import { type GuideMetadata } from '@/assets/docs/guides/types';
|
||||
import Button from '@/ds-components/Button';
|
||||
import CodeEditor from '@/ds-components/CodeEditor';
|
||||
import OverlayScrollbar from '@/ds-components/OverlayScrollbar';
|
||||
import TextLink from '@/ds-components/TextLink';
|
||||
import DetailsSummary from '@/mdx-components/DetailsSummary';
|
||||
import NotFound from '@/pages/NotFound';
|
||||
|
||||
import StepsSkeleton from './StepsSkeleton';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
export type GuideContextType = {
|
||||
metadata: Readonly<GuideMetadata>;
|
||||
Logo?: LazyExoticComponent<SvgComponent>;
|
||||
isCompact: boolean;
|
||||
app?: ApplicationResponse;
|
||||
endpoint?: string;
|
||||
alternativeEndpoint?: string;
|
||||
redirectUris?: string[];
|
||||
postLogoutRedirectUris?: string[];
|
||||
sampleUrls?: {
|
||||
origin: string;
|
||||
callback: string;
|
||||
};
|
||||
};
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
guideId: string;
|
||||
isEmpty?: boolean;
|
||||
isLoading?: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const GuideContext = createContext<GuideContextType>({
|
||||
// The following `as` is for context initialization, they won't be used in production except for
|
||||
// HMR.
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, no-restricted-syntax
|
||||
metadata: {} as GuideMetadata,
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, no-restricted-syntax
|
||||
app: {} as ApplicationResponse,
|
||||
endpoint: '',
|
||||
redirectUris: [],
|
||||
postLogoutRedirectUris: [],
|
||||
isCompact: false,
|
||||
sampleUrls: { origin: '', callback: '' },
|
||||
});
|
||||
|
||||
function Guide({ className, guideId, isEmpty, isLoading, onClose }: Props) {
|
||||
const guide = guides.find(({ id }) => id === guideId);
|
||||
const GuideComponent = guide?.Component;
|
||||
const isApiResourceGuide = guide?.metadata.target === 'API';
|
||||
const context = useContext(GuideContext);
|
||||
|
||||
return (
|
||||
<>
|
||||
<OverlayScrollbar className={classNames(styles.content, className)}>
|
||||
{isLoading && <StepsSkeleton />}
|
||||
{isEmpty && !guide && <NotFound className={styles.notFound} />}
|
||||
<MDXProvider
|
||||
components={{
|
||||
code: ({ className, children }) => {
|
||||
const [, language] = /language-(\w+)/.exec(String(className ?? '')) ?? [];
|
||||
|
||||
return language ? (
|
||||
<CodeEditor
|
||||
isReadonly
|
||||
// We need to transform `ts` to `typescript` for prismjs, and
|
||||
// it's weird since it worked in the original Guide component.
|
||||
// To be investigated.
|
||||
language={language === 'ts' ? 'typescript' : language}
|
||||
value={String(children).trimEnd()}
|
||||
/>
|
||||
) : (
|
||||
<code>{String(children).trimEnd()}</code>
|
||||
);
|
||||
},
|
||||
a: ({ children, ...props }) => (
|
||||
<TextLink {...props} target="_blank" rel="noopener noreferrer">
|
||||
{children}
|
||||
</TextLink>
|
||||
),
|
||||
details: DetailsSummary,
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={<StepsSkeleton />}>
|
||||
{GuideComponent && <GuideComponent {...context} />}
|
||||
</Suspense>
|
||||
</MDXProvider>
|
||||
</OverlayScrollbar>
|
||||
{!isApiResourceGuide && (
|
||||
<nav className={styles.actionBar}>
|
||||
<div className={styles.layout}>
|
||||
<Button
|
||||
size="large"
|
||||
title="applications.guide.finish_and_done"
|
||||
type="primary"
|
||||
onClick={onClose}
|
||||
/>
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Guide;
|
|
@ -1,49 +1,52 @@
|
|||
import { useContext } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { GuideContext } from '@/components/Guide';
|
||||
import CopyToClipboard from '@/ds-components/CopyToClipboard';
|
||||
import FormField from '@/ds-components/FormField';
|
||||
import TextLink from '@/ds-components/TextLink';
|
||||
import { GuideContext } from '@/pages/Applications/components/Guide';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
function ApplicationCredentials() {
|
||||
const {
|
||||
app: { id, secret },
|
||||
} = useContext(GuideContext);
|
||||
const { app } = useContext(GuideContext);
|
||||
const { id, secret } = app ?? {};
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<FormField
|
||||
title="application_details.application_id"
|
||||
tip={(closeTipHandler) => (
|
||||
<Trans
|
||||
components={{
|
||||
a: (
|
||||
<TextLink
|
||||
href="https://openid.net/specs/openid-connect-core-1_0.html"
|
||||
target="_blank"
|
||||
onClick={closeTipHandler}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{t('application_details.application_id_tip')}
|
||||
</Trans>
|
||||
)}
|
||||
>
|
||||
<CopyToClipboard value={id} variant="border" className={styles.textField} />
|
||||
</FormField>
|
||||
<FormField title="application_details.application_secret">
|
||||
<CopyToClipboard
|
||||
hasVisibilityToggle
|
||||
value={secret}
|
||||
variant="border"
|
||||
className={styles.textField}
|
||||
/>
|
||||
</FormField>
|
||||
{id && (
|
||||
<FormField
|
||||
title="application_details.application_id"
|
||||
tip={(closeTipHandler) => (
|
||||
<Trans
|
||||
components={{
|
||||
a: (
|
||||
<TextLink
|
||||
href="https://openid.net/specs/openid-connect-core-1_0.html"
|
||||
target="_blank"
|
||||
onClick={closeTipHandler}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{t('application_details.application_id_tip')}
|
||||
</Trans>
|
||||
)}
|
||||
>
|
||||
<CopyToClipboard value={id} variant="border" className={styles.textField} />
|
||||
</FormField>
|
||||
)}
|
||||
{secret && (
|
||||
<FormField title="application_details.application_secret">
|
||||
<CopyToClipboard
|
||||
hasVisibilityToggle
|
||||
value={secret}
|
||||
variant="border"
|
||||
className={styles.textField}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import { appendPath } from '@silverhand/essentials';
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { GuideContext } from '@/components/Guide';
|
||||
import { githubOrgLink } from '@/consts';
|
||||
import { LinkButton } from '@/ds-components/Button';
|
||||
import DangerousRaw from '@/ds-components/DangerousRaw';
|
||||
import Spacer from '@/ds-components/Spacer';
|
||||
import { GuideContext } from '@/pages/Applications/components/Guide';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
|
|
|
@ -2,8 +2,8 @@ import { type Nullable } from '@silverhand/essentials';
|
|||
import classNames from 'classnames';
|
||||
import React, { useRef, type ReactElement, useEffect, useState, useMemo, useContext } from 'react';
|
||||
|
||||
import { GuideContext } from '@/components/Guide';
|
||||
import useScroll from '@/hooks/use-scroll';
|
||||
import { GuideContext } from '@/pages/Applications/components/Guide';
|
||||
import { onKeyDownHandler } from '@/utils/a11y';
|
||||
|
||||
import Sample from '../Sample';
|
||||
|
@ -37,19 +37,17 @@ export default function Steps({ children: reactChildren }: Props) {
|
|||
const stepReferences = useRef<Array<Nullable<HTMLElement>>>([]);
|
||||
const { scrollTop } = useScroll(findScrollableElement(contentRef.current));
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const { isCompact, metadata } = useContext(GuideContext);
|
||||
const isApiResourceGuide = metadata.target === 'API';
|
||||
|
||||
const furtherReadings = useMemo(
|
||||
() => <FurtherReadings title="Further readings" subtitle="4 articles" />,
|
||||
[]
|
||||
);
|
||||
const children: Array<ReactElement<StepProps, typeof Step>> = useMemo(
|
||||
() =>
|
||||
Array.isArray(reactChildren)
|
||||
? reactChildren.concat(furtherReadings)
|
||||
: [reactChildren, furtherReadings],
|
||||
[furtherReadings, reactChildren]
|
||||
);
|
||||
|
||||
const { isCompact } = useContext(GuideContext);
|
||||
const children: Array<ReactElement<StepProps, typeof Step>> = useMemo(() => {
|
||||
const steps = Array.isArray(reactChildren) ? reactChildren : [reactChildren];
|
||||
return isApiResourceGuide ? steps : steps.concat(furtherReadings);
|
||||
}, [isApiResourceGuide, furtherReadings, reactChildren]);
|
||||
|
||||
useEffect(() => {
|
||||
// Make sure the step references length matches the number of children.
|
||||
|
|
|
@ -8,6 +8,7 @@ import { toast } from 'react-hot-toast';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import { GuideContext } from '@/components/Guide';
|
||||
import MultiTextInputField from '@/components/MultiTextInputField';
|
||||
import Button from '@/ds-components/Button';
|
||||
import {
|
||||
|
@ -16,7 +17,6 @@ import {
|
|||
} from '@/ds-components/MultiTextInput/utils';
|
||||
import type { RequestError } from '@/hooks/use-api';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import { GuideContext } from '@/pages/Applications/components/Guide';
|
||||
import type { GuideForm } from '@/types/guide';
|
||||
import { trySubmitSafe } from '@/utils/form';
|
||||
import { uriValidator } from '@/utils/validator';
|
||||
|
@ -38,10 +38,9 @@ function UriInputField({ name, defaultValue }: Props) {
|
|||
reset,
|
||||
formState: { isSubmitting },
|
||||
} = methods;
|
||||
const {
|
||||
app: { id: appId },
|
||||
} = useContext(GuideContext);
|
||||
const { data, mutate } = useSWR<Application, RequestError>(`api/applications/${appId}`);
|
||||
const { app } = useContext(GuideContext);
|
||||
const appId = app?.id;
|
||||
const { data, mutate } = useSWR<Application, RequestError>(appId && `api/applications/${appId}`);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
@ -52,6 +51,9 @@ function UriInputField({ name, defaultValue }: Props) {
|
|||
: 'application_details.post_sign_out_redirect_uri';
|
||||
|
||||
const onSubmit = trySubmitSafe(async (value: string[]) => {
|
||||
if (!appId) {
|
||||
return;
|
||||
}
|
||||
const updatedApp = await api
|
||||
.patch(`api/applications/${appId}`, {
|
||||
json: {
|
||||
|
|
|
@ -0,0 +1,84 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
@use '@/scss/dimensions' as dim;
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
section {
|
||||
h3 {
|
||||
font: var(--font-title-2);
|
||||
color: var(--color-text-secondary);
|
||||
margin: _.unit(6) 0 _.unit(3);
|
||||
}
|
||||
|
||||
p {
|
||||
font: var(--font-body-2);
|
||||
margin: _.unit(4) 0;
|
||||
}
|
||||
|
||||
ul > li,
|
||||
ol > li {
|
||||
font: var(--font-body-2);
|
||||
margin-block: _.unit(2);
|
||||
padding-inline-start: _.unit(1);
|
||||
}
|
||||
|
||||
table {
|
||||
border-spacing: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
font: var(--font-body-2);
|
||||
|
||||
tr {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
td,
|
||||
th {
|
||||
padding: _.unit(2) _.unit(4);
|
||||
}
|
||||
|
||||
thead {
|
||||
font: var(--font-title-3);
|
||||
}
|
||||
|
||||
tbody td {
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
}
|
||||
|
||||
code:not(pre > code) {
|
||||
background: var(--color-layer-2);
|
||||
font: var(--font-body-2);
|
||||
padding: _.unit(1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.notFound {
|
||||
width: 100%;
|
||||
|
||||
svg {
|
||||
margin-top: 10%;
|
||||
}
|
||||
}
|
||||
|
||||
.actionBar {
|
||||
flex-shrink: 0;
|
||||
padding: _.unit(4) _.unit(6);
|
||||
background-color: var(--color-layer-1);
|
||||
box-shadow: var(--shadow-3);
|
||||
z-index: 1;
|
||||
|
||||
.layout {
|
||||
margin: 0 auto;
|
||||
max-width: dim.$guide-main-content-max-width;
|
||||
|
||||
> button {
|
||||
margin-right: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
import { DomainStatus, type ApplicationResponse } from '@logto/schemas';
|
||||
import { conditional } from '@silverhand/essentials';
|
||||
import { useContext, useMemo } from 'react';
|
||||
|
||||
import guides from '@/assets/docs/guides';
|
||||
import Guide, { GuideContext, type GuideContextType } from '@/components/Guide';
|
||||
import { AppDataContext } from '@/contexts/AppDataProvider';
|
||||
import useCustomDomain from '@/hooks/use-custom-domain';
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
guideId: string;
|
||||
app?: ApplicationResponse;
|
||||
isCompact?: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function AppGuide({ className, guideId, app, isCompact, onClose }: Props) {
|
||||
const { tenantEndpoint } = useContext(AppDataContext);
|
||||
const { data: customDomain } = useCustomDomain();
|
||||
const isCustomDomainActive = customDomain?.status === DomainStatus.Active;
|
||||
const guide = guides.find(({ id }) => id === guideId);
|
||||
|
||||
const GuideComponent = guide?.Component;
|
||||
|
||||
const memorizedContext = useMemo(
|
||||
() =>
|
||||
conditional(
|
||||
!!guide &&
|
||||
!!app && {
|
||||
metadata: guide.metadata,
|
||||
Logo: guide.Logo,
|
||||
app,
|
||||
endpoint: tenantEndpoint?.toString() ?? '',
|
||||
alternativeEndpoint: conditional(isCustomDomainActive && tenantEndpoint?.toString()),
|
||||
redirectUris: app.oidcClientMetadata.redirectUris,
|
||||
postLogoutRedirectUris: app.oidcClientMetadata.postLogoutRedirectUris,
|
||||
isCompact: Boolean(isCompact),
|
||||
sampleUrls: {
|
||||
origin: 'http://localhost:3001/',
|
||||
callback: 'http://localhost:3001/callback',
|
||||
},
|
||||
}
|
||||
) satisfies GuideContextType | undefined,
|
||||
[guide, app, tenantEndpoint, isCustomDomainActive, isCompact]
|
||||
);
|
||||
|
||||
return memorizedContext ? (
|
||||
<GuideContext.Provider value={memorizedContext}>
|
||||
<Guide
|
||||
className={className}
|
||||
guideId={guideId}
|
||||
isEmpty={!app && !guide}
|
||||
isLoading={!app}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</GuideContext.Provider>
|
||||
) : null;
|
||||
}
|
||||
|
||||
export default AppGuide;
|
|
@ -4,12 +4,13 @@ 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 { useAppGuideMetadata } from '@/components/Guide/hooks';
|
||||
import IconButton from '@/ds-components/IconButton';
|
||||
import Spacer from '@/ds-components/Spacer';
|
||||
import Guide from '@/pages/Applications/components/Guide';
|
||||
import { type SelectedGuide } from '@/pages/Applications/components/GuideCard';
|
||||
import GuideGroup from '@/pages/Applications/components/GuideGroup';
|
||||
import useAppGuideMetadata from '@/pages/Applications/components/GuideLibrary/hook';
|
||||
|
||||
import AppGuide from '../AppGuide';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
|
@ -20,12 +21,12 @@ type Props = {
|
|||
|
||||
function GuideDrawer({ app, onClose }: Props) {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.applications.guide' });
|
||||
const [_, getStructuredMetadata] = useAppGuideMetadata();
|
||||
const { getStructuredAppGuideMetadata } = useAppGuideMetadata();
|
||||
const [selectedGuide, setSelectedGuide] = useState<SelectedGuide>();
|
||||
|
||||
const structuredMetadata = useMemo(
|
||||
() => getStructuredMetadata({ categories: [app.type] }),
|
||||
[getStructuredMetadata, app.type]
|
||||
() => getStructuredAppGuideMetadata({ categories: [app.type] }),
|
||||
[getStructuredAppGuideMetadata, app.type]
|
||||
);
|
||||
|
||||
const hasSingleGuide = useMemo(() => {
|
||||
|
@ -73,7 +74,7 @@ function GuideDrawer({ app, onClose }: Props) {
|
|||
</IconButton>
|
||||
</div>
|
||||
{!selectedGuide && (
|
||||
<GuideGroup
|
||||
<GuideCardGroup
|
||||
className={styles.cardGroup}
|
||||
categoryName={t(`categories.${app.type}`)}
|
||||
guides={structuredMetadata[app.type]}
|
||||
|
@ -83,7 +84,7 @@ function GuideDrawer({ app, onClose }: Props) {
|
|||
/>
|
||||
)}
|
||||
{selectedGuide && (
|
||||
<Guide
|
||||
<AppGuide
|
||||
isCompact
|
||||
className={styles.guide}
|
||||
guideId={selectedGuide.id}
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
import type { ApplicationResponse } from '@logto/schemas';
|
||||
import Modal from 'react-modal';
|
||||
|
||||
import ModalHeader from '@/components/Guide/ModalHeader';
|
||||
import * as modalStyles from '@/scss/modal.module.scss';
|
||||
|
||||
import AppGuide from '../AppGuide';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
guideId: string;
|
||||
app?: ApplicationResponse;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function GuideModal({ guideId, app, onClose }: Props) {
|
||||
return (
|
||||
<Modal shouldCloseOnEsc isOpen className={modalStyles.fullScreen} onRequestClose={onClose}>
|
||||
<div className={styles.modalContainer}>
|
||||
<ModalHeader
|
||||
title="applications.guide.modal_header_title"
|
||||
subtitle="applications.guide.header_subtitle"
|
||||
buttonText="applications.guide.cannot_find_guide"
|
||||
requestFormFieldLabel="applications.guide.describe_guide_looking_for"
|
||||
requestFormFieldPlaceholder="applications.guide.describe_guide_looking_for_placeholder"
|
||||
requestSuccessMessage="applications.guide.request_guide_successfully"
|
||||
onClose={onClose}
|
||||
/>
|
||||
<AppGuide className={styles.guide} guideId={guideId} app={app} onClose={onClose} />
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default GuideModal;
|
|
@ -36,10 +36,9 @@ import useTenantPathname from '@/hooks/use-tenant-pathname';
|
|||
import { applicationTypeI18nKey } from '@/types/applications';
|
||||
import { trySubmitSafe } from '@/utils/form';
|
||||
|
||||
import GuideModal from '../Applications/components/GuideModal';
|
||||
|
||||
import AdvancedSettings from './components/AdvancedSettings';
|
||||
import GuideDrawer from './components/GuideDrawer';
|
||||
import GuideModal from './components/GuideModal';
|
||||
import Settings from './components/Settings';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
|
@ -150,7 +149,7 @@ function ApplicationDetails() {
|
|||
<GuideModal
|
||||
guideId={guideId}
|
||||
app={data}
|
||||
onClose={(id) => {
|
||||
onClose={() => {
|
||||
navigate(`/applications/${id}`);
|
||||
}}
|
||||
/>
|
||||
|
|
|
@ -1,145 +0,0 @@
|
|||
import { DomainStatus, type ApplicationResponse } from '@logto/schemas';
|
||||
import { MDXProvider } from '@mdx-js/react';
|
||||
import { conditional } from '@silverhand/essentials';
|
||||
import classNames from 'classnames';
|
||||
import { useContext, Suspense, createContext, useMemo, type LazyExoticComponent } from 'react';
|
||||
|
||||
import guides from '@/assets/docs/guides';
|
||||
import { type GuideMetadata } from '@/assets/docs/guides/types';
|
||||
import { AppDataContext } from '@/contexts/AppDataProvider';
|
||||
import Button from '@/ds-components/Button';
|
||||
import CodeEditor from '@/ds-components/CodeEditor';
|
||||
import OverlayScrollbar from '@/ds-components/OverlayScrollbar';
|
||||
import TextLink from '@/ds-components/TextLink';
|
||||
import useCustomDomain from '@/hooks/use-custom-domain';
|
||||
import DetailsSummary from '@/mdx-components/DetailsSummary';
|
||||
import NotFound from '@/pages/NotFound';
|
||||
|
||||
import StepsSkeleton from '../StepsSkeleton';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type GuideContextType = {
|
||||
metadata: Readonly<GuideMetadata>;
|
||||
Logo?: LazyExoticComponent<SvgComponent>;
|
||||
app: ApplicationResponse;
|
||||
endpoint: string;
|
||||
alternativeEndpoint?: string;
|
||||
redirectUris: string[];
|
||||
postLogoutRedirectUris: string[];
|
||||
isCompact: boolean;
|
||||
sampleUrls: {
|
||||
origin: string;
|
||||
callback: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const GuideContext = createContext<GuideContextType>({
|
||||
// The following `as` is for context initialization, they won't be used in production except for
|
||||
// HMR.
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, no-restricted-syntax
|
||||
metadata: {} as GuideMetadata,
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, no-restricted-syntax
|
||||
app: {} as ApplicationResponse,
|
||||
endpoint: '',
|
||||
redirectUris: [],
|
||||
postLogoutRedirectUris: [],
|
||||
isCompact: false,
|
||||
sampleUrls: { origin: '', callback: '' },
|
||||
});
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
guideId: string;
|
||||
app?: ApplicationResponse;
|
||||
isCompact?: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function Guide({ className, guideId, app, isCompact, onClose }: Props) {
|
||||
const { tenantEndpoint } = useContext(AppDataContext);
|
||||
const { data: customDomain } = useCustomDomain();
|
||||
const isCustomDomainActive = customDomain?.status === DomainStatus.Active;
|
||||
const guide = guides.find(({ id }) => id === guideId);
|
||||
|
||||
const GuideComponent = guide?.Component;
|
||||
|
||||
const memorizedContext = useMemo(
|
||||
() =>
|
||||
conditional(
|
||||
!!guide &&
|
||||
!!app && {
|
||||
metadata: guide.metadata,
|
||||
Logo: guide.Logo,
|
||||
app,
|
||||
endpoint: tenantEndpoint?.toString() ?? '',
|
||||
alternativeEndpoint: conditional(isCustomDomainActive && tenantEndpoint?.toString()),
|
||||
redirectUris: app.oidcClientMetadata.redirectUris,
|
||||
postLogoutRedirectUris: app.oidcClientMetadata.postLogoutRedirectUris,
|
||||
isCompact: Boolean(isCompact),
|
||||
sampleUrls: {
|
||||
origin: 'http://localhost:3001/',
|
||||
callback: 'http://localhost:3001/callback',
|
||||
},
|
||||
}
|
||||
) satisfies GuideContextType | undefined,
|
||||
[guide, app, tenantEndpoint, isCustomDomainActive, isCompact]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<OverlayScrollbar className={classNames(styles.content, className)}>
|
||||
{!app && <StepsSkeleton />}
|
||||
{!!app && !guide && <NotFound className={styles.notFound} />}
|
||||
{memorizedContext && (
|
||||
<GuideContext.Provider value={memorizedContext}>
|
||||
<MDXProvider
|
||||
components={{
|
||||
code: ({ className, children }) => {
|
||||
const [, language] = /language-(\w+)/.exec(String(className ?? '')) ?? [];
|
||||
|
||||
return language ? (
|
||||
<CodeEditor
|
||||
isReadonly
|
||||
// We need to transform `ts` to `typescript` for prismjs, and
|
||||
// it's weird since it worked in the original Guide component.
|
||||
// To be investigated.
|
||||
language={language === 'ts' ? 'typescript' : language}
|
||||
value={String(children).trimEnd()}
|
||||
/>
|
||||
) : (
|
||||
<code>{String(children).trimEnd()}</code>
|
||||
);
|
||||
},
|
||||
a: ({ children, ...props }) => (
|
||||
<TextLink {...props} target="_blank" rel="noopener noreferrer">
|
||||
{children}
|
||||
</TextLink>
|
||||
),
|
||||
details: DetailsSummary,
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={<StepsSkeleton />}>
|
||||
{tenantEndpoint && GuideComponent && <GuideComponent {...memorizedContext} />}
|
||||
</Suspense>
|
||||
</MDXProvider>
|
||||
</GuideContext.Provider>
|
||||
)}
|
||||
</OverlayScrollbar>
|
||||
{memorizedContext && (
|
||||
<nav className={styles.actionBar}>
|
||||
<div className={styles.layout}>
|
||||
<Button
|
||||
size="large"
|
||||
title="applications.guide.finish_and_done"
|
||||
type="primary"
|
||||
onClick={onClose}
|
||||
/>
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Guide;
|
|
@ -1,86 +0,0 @@
|
|||
import { useCallback } from 'react';
|
||||
|
||||
import guides from '@/assets/docs/guides';
|
||||
import { type Guide } from '@/assets/docs/guides/types';
|
||||
import { type AppGuideCategory, type StructuredAppGuideMetadata } from '@/types/applications';
|
||||
|
||||
const defaultStructuredMetadata: StructuredAppGuideMetadata = {
|
||||
featured: [],
|
||||
Traditional: [],
|
||||
SPA: [],
|
||||
Native: [],
|
||||
MachineToMachine: [],
|
||||
};
|
||||
|
||||
type FilterOptions = {
|
||||
categories?: AppGuideCategory[];
|
||||
keyword?: string;
|
||||
};
|
||||
|
||||
const useAppGuideMetadata = (): [
|
||||
(filters?: FilterOptions) => readonly Guide[] | undefined,
|
||||
(filters?: FilterOptions) => Record<AppGuideCategory, readonly Guide[]>,
|
||||
] => {
|
||||
const getFilteredMetadata = useCallback((filters?: FilterOptions) => {
|
||||
const { categories: filterCategories, keyword } = filters ?? {};
|
||||
// If no filter is applied, return all metadata
|
||||
if (!filterCategories?.length && !keyword) {
|
||||
return guides;
|
||||
}
|
||||
|
||||
// Keyword only, return partial name matched result
|
||||
if (keyword && !filterCategories?.length) {
|
||||
return guides.filter(({ metadata: { name } }) =>
|
||||
name.toLowerCase().includes(keyword.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// Categories only, return selected categories
|
||||
if (!keyword && filterCategories?.length) {
|
||||
return guides.filter(({ metadata: { target, isFeatured } }) =>
|
||||
filterCategories.some(
|
||||
(filterCategory) =>
|
||||
filterCategory === target || (isFeatured && filterCategory === 'featured')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Keyword and categories, return partial name matched result in selected categories
|
||||
if (keyword && filterCategories?.length) {
|
||||
return guides.filter(
|
||||
({ metadata: { name, target, isFeatured } }) =>
|
||||
name.toLowerCase().includes(keyword.toLowerCase()) &&
|
||||
filterCategories.some(
|
||||
(filterCategory) =>
|
||||
filterCategory === target || (isFeatured && filterCategory === 'featured')
|
||||
)
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getStructuredMetadata = useCallback(
|
||||
(filters?: FilterOptions) => {
|
||||
const filteredMetadata = getFilteredMetadata(filters) ?? [];
|
||||
return filteredMetadata.reduce((accumulated, guide) => {
|
||||
const { target, isFeatured } = guide.metadata;
|
||||
|
||||
// Rule out API target guides to make TypeScript happy
|
||||
if (target === 'API') {
|
||||
return accumulated;
|
||||
}
|
||||
return {
|
||||
...accumulated,
|
||||
[target]: [...accumulated[target], guide],
|
||||
...(isFeatured && {
|
||||
featured: [...accumulated.featured, guide],
|
||||
}),
|
||||
};
|
||||
}, defaultStructuredMetadata);
|
||||
},
|
||||
[getFilteredMetadata]
|
||||
);
|
||||
|
||||
return [getFilteredMetadata, getStructuredMetadata];
|
||||
};
|
||||
|
||||
export default useAppGuideMetadata;
|
|
@ -5,6 +5,9 @@ import { useTranslation } from 'react-i18next';
|
|||
|
||||
import SearchIcon from '@/assets/icons/search.svg';
|
||||
import EmptyDataPlaceholder from '@/components/EmptyDataPlaceholder';
|
||||
import { type SelectedGuide } from '@/components/Guide/GuideCard';
|
||||
import GuideCardGroup from '@/components/Guide/GuideCardGroup';
|
||||
import { useAppGuideMetadata } from '@/components/Guide/hooks';
|
||||
import ProTag from '@/components/ProTag';
|
||||
import { isCloud } from '@/consts/env';
|
||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||
|
@ -16,10 +19,7 @@ import useTenantPathname from '@/hooks/use-tenant-pathname';
|
|||
import { allAppGuideCategories, type AppGuideCategory } from '@/types/applications';
|
||||
|
||||
import CreateForm from '../CreateForm';
|
||||
import { type SelectedGuide } from '../GuideCard';
|
||||
import GuideGroup from '../GuideGroup';
|
||||
|
||||
import useAppGuideMetadata from './hook';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
|
@ -35,7 +35,7 @@ function GuideLibrary({ className, hasCardBorder, hasCardButton, hasFilters }: P
|
|||
const [keyword, setKeyword] = useState<string>('');
|
||||
const [filterCategories, setFilterCategories] = useState<AppGuideCategory[]>([]);
|
||||
const [selectedGuide, setSelectedGuide] = useState<SelectedGuide>();
|
||||
const [getFilteredMetadata, getStructuredMetadata] = useAppGuideMetadata();
|
||||
const { getFilteredAppGuideMetadata, getStructuredAppGuideMetadata } = useAppGuideMetadata();
|
||||
const [showCreateForm, setShowCreateForm] = useState<boolean>(false);
|
||||
|
||||
const { currentTenantId } = useContext(TenantsContext);
|
||||
|
@ -43,13 +43,13 @@ function GuideLibrary({ className, hasCardBorder, hasCardButton, hasFilters }: P
|
|||
const isM2mDisabledForCurrentPlan = isCloud && currentPlan?.quota.machineToMachineLimit === 0;
|
||||
|
||||
const structuredMetadata = useMemo(
|
||||
() => getStructuredMetadata({ categories: filterCategories }),
|
||||
[getStructuredMetadata, filterCategories]
|
||||
() => getStructuredAppGuideMetadata({ categories: filterCategories }),
|
||||
[getStructuredAppGuideMetadata, filterCategories]
|
||||
);
|
||||
|
||||
const filteredMetadata = useMemo(
|
||||
() => getFilteredMetadata({ keyword, categories: filterCategories }),
|
||||
[getFilteredMetadata, keyword, filterCategories]
|
||||
() => getFilteredAppGuideMetadata({ keyword, categories: filterCategories }),
|
||||
[getFilteredAppGuideMetadata, keyword, filterCategories]
|
||||
);
|
||||
|
||||
const onClickGuide = useCallback((data: SelectedGuide) => {
|
||||
|
@ -108,7 +108,7 @@ function GuideLibrary({ className, hasCardBorder, hasCardButton, hasFilters }: P
|
|||
)}
|
||||
{keyword &&
|
||||
(filteredMetadata?.length ? (
|
||||
<GuideGroup
|
||||
<GuideCardGroup
|
||||
className={styles.guideGroup}
|
||||
hasCardBorder={hasCardBorder}
|
||||
hasCardButton={hasCardButton}
|
||||
|
@ -122,7 +122,7 @@ function GuideLibrary({ className, hasCardBorder, hasCardButton, hasFilters }: P
|
|||
(filterCategories.length > 0 ? filterCategories : allAppGuideCategories).map(
|
||||
(category) =>
|
||||
structuredMetadata[category].length > 0 && (
|
||||
<GuideGroup
|
||||
<GuideCardGroup
|
||||
key={category}
|
||||
className={styles.guideGroup}
|
||||
hasCardBorder={hasCardBorder}
|
||||
|
|
|
@ -17,34 +17,4 @@
|
|||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.actionBar {
|
||||
inset: auto 0 0 0;
|
||||
width: 100%;
|
||||
padding: _.unit(4) _.unit(6);
|
||||
background-color: var(--color-layer-1);
|
||||
box-shadow: var(--shadow-3);
|
||||
z-index: 1;
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
max-width: dim.$guide-main-content-max-width;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.text {
|
||||
font: var(--font-body-2);
|
||||
color: var(--color-text);
|
||||
margin-right: _.unit(3);
|
||||
@include _.multi-line-ellipsis(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: dim.$guide-content-max-width) {
|
||||
.container .actionBar .wrapper {
|
||||
margin: 0 0 0 _.unit(62.5);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,13 +1,12 @@
|
|||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Modal from 'react-modal';
|
||||
|
||||
import Button from '@/ds-components/Button';
|
||||
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 GuideHeader from '../GuideHeader';
|
||||
import GuideLibrary from '../GuideLibrary';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
@ -18,7 +17,6 @@ type Props = {
|
|||
};
|
||||
|
||||
function GuideLibraryModal({ isOpen, onClose }: Props) {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.applications.guide' });
|
||||
const { navigate } = useTenantPathname();
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
return (
|
||||
|
@ -29,21 +27,23 @@ function GuideLibraryModal({ isOpen, onClose }: Props) {
|
|||
onRequestClose={onClose}
|
||||
>
|
||||
<div className={styles.container}>
|
||||
<GuideHeader onClose={onClose} />
|
||||
<ModalHeader
|
||||
title="applications.guide.modal_header_title"
|
||||
subtitle="applications.guide.header_subtitle"
|
||||
buttonText="applications.guide.cannot_find_guide"
|
||||
requestFormFieldLabel="applications.guide.describe_guide_looking_for"
|
||||
requestFormFieldPlaceholder="applications.guide.describe_guide_looking_for_placeholder"
|
||||
requestSuccessMessage="applications.guide.request_guide_successfully"
|
||||
onClose={onClose}
|
||||
/>
|
||||
<GuideLibrary hasFilters hasCardButton className={styles.content} />
|
||||
<nav className={styles.actionBar}>
|
||||
<div className={styles.wrapper}>
|
||||
<span className={styles.text}>{t('do_not_need_tutorial')}</span>
|
||||
<Button
|
||||
size="large"
|
||||
title="applications.guide.create_without_framework"
|
||||
type="outline"
|
||||
onClick={() => {
|
||||
setShowCreateForm(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</nav>
|
||||
<ModalFooter
|
||||
content="applications.guide.do_not_need_tutorial"
|
||||
buttonText="applications.guide.create_without_framework"
|
||||
onClick={() => {
|
||||
setShowCreateForm(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{showCreateForm && (
|
||||
<CreateForm
|
||||
|
|
|
@ -1,34 +0,0 @@
|
|||
import type { ApplicationResponse } from '@logto/schemas';
|
||||
import Modal from 'react-modal';
|
||||
|
||||
import * as modalStyles from '@/scss/modal.module.scss';
|
||||
|
||||
import Guide from '../Guide';
|
||||
import GuideHeader from '../GuideHeader';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
guideId: string;
|
||||
app?: ApplicationResponse;
|
||||
onClose: (id: string) => void;
|
||||
};
|
||||
|
||||
function GuideModal({ guideId, app, onClose }: Props) {
|
||||
const closeModal = () => {
|
||||
if (app) {
|
||||
onClose(app.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal shouldCloseOnEsc isOpen className={modalStyles.fullScreen} onRequestClose={closeModal}>
|
||||
<div className={styles.modalContainer}>
|
||||
<GuideHeader onClose={closeModal} />
|
||||
<Guide className={styles.guide} guideId={guideId} app={app} onClose={closeModal} />
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default GuideModal;
|
|
@ -1,6 +1,5 @@
|
|||
import { withAppInsights } from '@logto/app-insights/react';
|
||||
import type { Application } from '@logto/schemas';
|
||||
import { ApplicationType } from '@logto/schemas';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
|
@ -30,14 +29,6 @@ const pageSize = defaultPageSize;
|
|||
const applicationsPathname = '/applications';
|
||||
const createApplicationPathname = `${applicationsPathname}/create`;
|
||||
const buildDetailsPathname = (id: string) => `${applicationsPathname}/${id}`;
|
||||
const buildGuidePathname = (id: string) => `${buildDetailsPathname(id)}/guide`;
|
||||
|
||||
const buildNavigatePathPostAppCreation = ({ type, id }: Application) => {
|
||||
const build =
|
||||
type === ApplicationType.MachineToMachine ? buildDetailsPathname : buildGuidePathname;
|
||||
|
||||
return build(id);
|
||||
};
|
||||
|
||||
function Applications() {
|
||||
const { search } = useLocation();
|
||||
|
|
|
@ -10,6 +10,9 @@ import CreateRoleDark from '@/assets/icons/create-role-dark.svg';
|
|||
import CreateRole from '@/assets/icons/create-role.svg';
|
||||
import SocialDark from '@/assets/icons/social-dark.svg';
|
||||
import Social from '@/assets/icons/social.svg';
|
||||
import { type SelectedGuide } from '@/components/Guide/GuideCard';
|
||||
import GuideCardGroup from '@/components/Guide/GuideCardGroup';
|
||||
import { useAppGuideMetadata } from '@/components/Guide/hooks';
|
||||
import PageMeta from '@/components/PageMeta';
|
||||
import { ConnectorsTabs } from '@/consts';
|
||||
import { AppDataContext } from '@/contexts/AppDataProvider';
|
||||
|
@ -22,9 +25,6 @@ import useTheme from '@/hooks/use-theme';
|
|||
import useWindowResize from '@/hooks/use-window-resize';
|
||||
|
||||
import CreateForm from '../Applications/components/CreateForm';
|
||||
import { type SelectedGuide } from '../Applications/components/GuideCard';
|
||||
import GuideGroup from '../Applications/components/GuideGroup';
|
||||
import useAppGuideMetadata from '../Applications/components/GuideLibrary/hook';
|
||||
|
||||
import FreePlanNotification from './FreePlanNotification';
|
||||
import * as styles from './index.module.scss';
|
||||
|
@ -39,7 +39,7 @@ function GetStarted() {
|
|||
const { navigate } = useTenantPathname();
|
||||
const { tenantEndpoint } = useContext(AppDataContext);
|
||||
const [selectedGuide, setSelectedGuide] = useState<SelectedGuide>();
|
||||
const [_, getStructuredMetadata] = useAppGuideMetadata();
|
||||
const { getStructuredAppGuideMetadata } = useAppGuideMetadata();
|
||||
const [showCreateForm, setShowCreateForm] = useState<boolean>(false);
|
||||
// The number of visible guide cards to show in one row per the current screen width
|
||||
const [visibleCardCount, setVisibleCardCount] = useState(4);
|
||||
|
@ -50,7 +50,7 @@ function GetStarted() {
|
|||
useWindowResize(() => {
|
||||
const containerWidth = containerRef.current?.clientWidth ?? 0;
|
||||
|
||||
// Responsive breakpoints (1080, 680px) are defined in `GuideGroup` component SCSS,
|
||||
// Responsive breakpoints (1080, 680px) are defined in `GuideCardGroup` component SCSS,
|
||||
// and we need to keep them consistent.
|
||||
setVisibleCardCount(containerWidth > 1080 ? 4 : containerWidth > 680 ? 3 : 2);
|
||||
});
|
||||
|
@ -59,8 +59,8 @@ function GetStarted() {
|
|||
* Slice the guide metadata as we only need to show 1 row of guide cards in get-started page
|
||||
*/
|
||||
const featuredAppGuides = useMemo(
|
||||
() => getStructuredMetadata().featured.slice(0, visibleCardCount),
|
||||
[visibleCardCount, getStructuredMetadata]
|
||||
() => getStructuredAppGuideMetadata().featured.slice(0, visibleCardCount),
|
||||
[visibleCardCount, getStructuredAppGuideMetadata]
|
||||
);
|
||||
|
||||
const onClickGuide = useCallback((data: SelectedGuide) => {
|
||||
|
@ -90,7 +90,7 @@ function GetStarted() {
|
|||
<FreePlanNotification />
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.title}>{t('get_started.develop.title')}</div>
|
||||
<GuideGroup
|
||||
<GuideCardGroup
|
||||
ref={containerRef}
|
||||
hasCardBorder
|
||||
guides={featuredAppGuides}
|
||||
|
|
Loading…
Add table
Reference in a new issue