mirror of
https://github.com/logto-io/logto.git
synced 2025-03-24 22:41:28 -05:00
feat(console,phrases): add new third-party application to the guide Library (#5144)
* feat(console,phrases): add new third-party applicaiton to the guide library add new third-party applicaiton to the guide library * fix(test): adjust the test order adjust the test order * fix(phrases): add untranslated tag add untranslated tag
This commit is contained in:
parent
9669fc92fb
commit
a85266284b
29 changed files with 182 additions and 18 deletions
|
@ -24,7 +24,9 @@ const data = await Promise.all(
|
|||
const logo = ['logo.svg'].find((logo) => existsSync(`${directory}/${logo}`));
|
||||
|
||||
const config = existsSync(`${directory}/config.json`)
|
||||
? await import(`./${directory}/config.json`, { assert: { type: 'json' } }).then((module) => module.default)
|
||||
? await import(`./${directory}/config.json`, { assert: { type: 'json' } }).then(
|
||||
(module) => module.default
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
|
@ -34,7 +36,10 @@ const data = await Promise.all(
|
|||
};
|
||||
})
|
||||
);
|
||||
const metadata = data.filter(Boolean).slice().sort((a, b) => a.order - b.order);
|
||||
const metadata = data
|
||||
.filter(Boolean)
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order);
|
||||
|
||||
const camelCase = (value) => value.replaceAll(/-./g, (x) => x[1].toUpperCase());
|
||||
const filename = 'index.ts';
|
||||
|
|
|
@ -14,6 +14,7 @@ import nativeIosSwift from './native-ios-swift/index';
|
|||
import spaReact from './spa-react/index';
|
||||
import spaVanilla from './spa-vanilla/index';
|
||||
import spaVue from './spa-vue/index';
|
||||
import thirdPartyOidc from './third-party-oidc/index';
|
||||
import { type Guide } from './types';
|
||||
import webAspNetCore from './web-asp-net-core/index';
|
||||
import webAspNetCoreMvc from './web-asp-net-core-mvc/index';
|
||||
|
@ -197,6 +198,13 @@ const guides: Readonly<Guide[]> = Object.freeze([
|
|||
Component: lazy(async () => import('./api-spring-boot/README.mdx')),
|
||||
metadata: apiSpringBoot,
|
||||
},
|
||||
{
|
||||
order: Number.POSITIVE_INFINITY,
|
||||
id: 'third-party-oidc',
|
||||
Logo: lazy(async () => import('./third-party-oidc/logo.svg')),
|
||||
Component: lazy(async () => import('./third-party-oidc/README.mdx')),
|
||||
metadata: thirdPartyOidc,
|
||||
},
|
||||
]);
|
||||
|
||||
export default guides;
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
# Place holder for third party OIDC guide
|
|
@ -0,0 +1,12 @@
|
|||
import { ApplicationType } from '@logto/schemas';
|
||||
|
||||
import { type GuideMetadata } from '../types';
|
||||
|
||||
const metadata: Readonly<GuideMetadata> = Object.freeze({
|
||||
name: 'OIDC',
|
||||
description: 'Use Logto as a third-party OIDC identity provider (IdP) for your application. ',
|
||||
target: ApplicationType.Traditional,
|
||||
isThirdParty: true,
|
||||
});
|
||||
|
||||
export default metadata;
|
|
@ -0,0 +1,12 @@
|
|||
<svg width="36" height="36" viewBox="0 0 36 36" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_983_7215)">
|
||||
<path d="M36 20.7003L35.1 12.6003L32.49 14.3103C30.06 12.7803 27 11.7003 23.58 11.1603C23.58 11.1603 21.87 10.8003 19.62 10.8003C17.37 10.8003 15.3 11.0703 15.3 11.0703C6.57 12.1503 0 17.1003 0 23.0403C0 29.1603 6.75 34.2003 17.1 35.1003V31.5903C9.99 30.6003 5.49 27.2703 5.49 23.0403C5.49 19.0803 9.63 15.7503 15.3 14.6703C15.3 14.6703 19.71 13.6803 23.58 14.8503C25.47 15.3003 27.18 15.9303 28.62 16.8303L25.2 18.9003L36 20.7003Z" fill="#9E9E9E"/>
|
||||
<path d="M17.0999 3.60015V35.1001L22.4999 32.4001V0.900146L17.0999 3.60015Z" fill="#FF9800"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_983_7215">
|
||||
<rect width="36" height="36" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 823 B |
|
@ -27,6 +27,9 @@ export type GuideMetadata = {
|
|||
};
|
||||
/** Whether the guide is displayed in featured group. */
|
||||
isFeatured?: boolean;
|
||||
|
||||
/** Indicate whether the application is for third-party use */
|
||||
isThirdParty?: boolean;
|
||||
};
|
||||
|
||||
/** The guide instance to build in the console. */
|
||||
|
|
|
@ -11,6 +11,7 @@ export type SelectedGuide = {
|
|||
id: Guide['id'];
|
||||
target: GuideMetadata['target'];
|
||||
name: GuideMetadata['name'];
|
||||
isThirdParty: GuideMetadata['isThirdParty'];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
|
@ -24,13 +25,13 @@ function GuideCard({ data, onClick, hasBorder, hasButton }: Props) {
|
|||
const {
|
||||
id,
|
||||
Logo,
|
||||
metadata: { target, name, description },
|
||||
metadata: { target, name, description, isThirdParty },
|
||||
} = data;
|
||||
|
||||
const buttonText = target === 'API' ? 'guide.get_started' : 'guide.start_building';
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
onClick({ id, target, name });
|
||||
onClick({ id, target, name, isThirdParty });
|
||||
}, [id, name, target, onClick]);
|
||||
|
||||
return (
|
||||
|
|
|
@ -2,7 +2,12 @@ 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';
|
||||
import { isDevFeaturesEnabled } from '@/consts/env';
|
||||
import {
|
||||
thirdPartyAppCategory,
|
||||
type AppGuideCategory,
|
||||
type StructuredAppGuideMetadata,
|
||||
} from '@/types/applications';
|
||||
|
||||
const defaultStructuredMetadata: StructuredAppGuideMetadata = {
|
||||
featured: [],
|
||||
|
@ -11,6 +16,7 @@ const defaultStructuredMetadata: StructuredAppGuideMetadata = {
|
|||
Native: [],
|
||||
MachineToMachine: [],
|
||||
Protected: [],
|
||||
ThirdParty: [],
|
||||
};
|
||||
|
||||
type FilterOptions = {
|
||||
|
@ -28,7 +34,12 @@ export const useAppGuideMetadata = (): {
|
|||
) => Record<AppGuideCategory, readonly Guide[]>;
|
||||
} => {
|
||||
const appGuides = useMemo(
|
||||
() => guides.filter(({ metadata: { target } }) => target !== 'API'),
|
||||
() =>
|
||||
guides.filter(
|
||||
({ metadata: { target, isThirdParty } }) =>
|
||||
// @simeng-li #FIXME: remove isDevFeaturesEnabled check once we have all guides ready
|
||||
target !== 'API' && (isDevFeaturesEnabled || !isThirdParty)
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
|
@ -49,11 +60,15 @@ export const useAppGuideMetadata = (): {
|
|||
|
||||
// Categories only, return selected categories
|
||||
if (!keyword && filterCategories?.length) {
|
||||
return appGuides.filter(({ metadata: { target, isFeatured } }) =>
|
||||
filterCategories.some(
|
||||
(filterCategory) =>
|
||||
filterCategory === target || (isFeatured && filterCategory === 'featured')
|
||||
)
|
||||
return appGuides.filter(({ metadata: { target, isFeatured, isThirdParty } }) =>
|
||||
filterCategories.some((filterCategory) => {
|
||||
return (
|
||||
filterCategory === target ||
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
(isFeatured && filterCategory === 'featured') ||
|
||||
(isThirdParty && filterCategory === 'ThirdParty')
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -76,13 +91,22 @@ export const useAppGuideMetadata = (): {
|
|||
const getStructuredAppGuideMetadata = useCallback(
|
||||
(filters?: FilterOptions) => {
|
||||
const filteredMetadata = getFilteredAppGuideMetadata(filters);
|
||||
|
||||
return filteredMetadata.reduce((accumulated, guide) => {
|
||||
const { target, isFeatured } = guide.metadata;
|
||||
const { target, isFeatured, isThirdParty } = guide.metadata;
|
||||
|
||||
// Rule out API target guides to make TypeScript happy
|
||||
if (target === 'API') {
|
||||
return accumulated;
|
||||
}
|
||||
|
||||
if (isThirdParty) {
|
||||
return {
|
||||
...accumulated,
|
||||
[thirdPartyAppCategory]: [...accumulated[thirdPartyAppCategory], guide],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...accumulated,
|
||||
[target]: [...accumulated[target], guide],
|
||||
|
|
|
@ -39,9 +39,9 @@ function GuideDrawer({ app, onClose }: Props) {
|
|||
if (guide) {
|
||||
const {
|
||||
id,
|
||||
metadata: { target, name },
|
||||
metadata: { target, name, isThirdParty },
|
||||
} = guide;
|
||||
setSelectedGuide({ id, target, name });
|
||||
setSelectedGuide({ id, target, name, isThirdParty });
|
||||
}
|
||||
}
|
||||
}, [hasSingleGuide, app.type, structuredMetadata]);
|
||||
|
|
|
@ -25,21 +25,30 @@ type FormData = {
|
|||
type: ApplicationType;
|
||||
name: string;
|
||||
description?: string;
|
||||
isThirdParty?: boolean;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
isDefaultCreateThirdParty?: boolean;
|
||||
defaultCreateType?: ApplicationType;
|
||||
defaultCreateFrameworkName?: string;
|
||||
onClose?: (createdApp?: Application) => void;
|
||||
};
|
||||
|
||||
function CreateForm({ defaultCreateType, defaultCreateFrameworkName, onClose }: Props) {
|
||||
function CreateForm({
|
||||
defaultCreateType,
|
||||
defaultCreateFrameworkName,
|
||||
isDefaultCreateThirdParty,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
register,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<FormData>({ defaultValues: { type: defaultCreateType } });
|
||||
} = useForm<FormData>({
|
||||
defaultValues: { type: defaultCreateType, isThirdParty: isDefaultCreateThirdParty },
|
||||
});
|
||||
|
||||
const {
|
||||
field: { onChange, value, name, ref },
|
||||
|
|
|
@ -16,6 +16,7 @@ import OverlayScrollbar from '@/ds-components/OverlayScrollbar';
|
|||
import TextInput from '@/ds-components/TextInput';
|
||||
import useTenantPathname from '@/hooks/use-tenant-pathname';
|
||||
import { allAppGuideCategories, type AppGuideCategory } from '@/types/applications';
|
||||
import { thirdPartyAppCategory } from '@/types/applications';
|
||||
|
||||
import CreateForm from '../CreateForm';
|
||||
|
||||
|
@ -56,7 +57,13 @@ function GuideLibrary({ className, hasCardBorder, hasCardButton, hasFilters }: P
|
|||
const onCloseCreateForm = useCallback(
|
||||
(newApp?: Application) => {
|
||||
if (newApp && selectedGuide) {
|
||||
navigate(`/applications/${newApp.id}/guide/${selectedGuide.id}`, { replace: true });
|
||||
navigate(
|
||||
// Third party app directly goes to the app detail page
|
||||
selectedGuide.isThirdParty
|
||||
? `/applications/${newApp.id}`
|
||||
: `/applications/${newApp.id}/guide/${selectedGuide.id}`,
|
||||
{ replace: true }
|
||||
);
|
||||
return;
|
||||
}
|
||||
setShowCreateForm(false);
|
||||
|
@ -86,7 +93,11 @@ function GuideLibrary({ className, hasCardBorder, hasCardButton, hasFilters }: P
|
|||
<CheckboxGroup
|
||||
className={styles.checkboxGroup}
|
||||
options={allAppGuideCategories
|
||||
.filter((category) => isDevFeaturesEnabled || category !== 'Protected')
|
||||
.filter(
|
||||
(category) =>
|
||||
isDevFeaturesEnabled ||
|
||||
(category !== 'Protected' && category !== thirdPartyAppCategory)
|
||||
)
|
||||
.map((category) => ({
|
||||
title: `guide.categories.${category}`,
|
||||
value: category,
|
||||
|
@ -145,6 +156,7 @@ function GuideLibrary({ className, hasCardBorder, hasCardButton, hasFilters }: P
|
|||
<CreateForm
|
||||
defaultCreateType={selectedGuide?.target}
|
||||
defaultCreateFrameworkName={selectedGuide?.name}
|
||||
isDefaultCreateThirdParty={selectedGuide?.isThirdParty}
|
||||
onClose={onCloseCreateForm}
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -2,6 +2,8 @@ import { ApplicationType } from '@logto/schemas';
|
|||
|
||||
import { type Guide } from '@/assets/docs/guides/types';
|
||||
|
||||
export const thirdPartyAppCategory = 'ThirdParty' as const;
|
||||
|
||||
export const applicationTypeI18nKey = Object.freeze({
|
||||
[ApplicationType.Native]: 'applications.type.native',
|
||||
[ApplicationType.SPA]: 'applications.type.spa',
|
||||
|
@ -22,6 +24,7 @@ export const allAppGuideCategories = Object.freeze([
|
|||
'Native',
|
||||
'MachineToMachine',
|
||||
'Protected',
|
||||
thirdPartyAppCategory,
|
||||
] as const);
|
||||
|
||||
export type AppGuideCategory = (typeof allAppGuideCategories)[number];
|
||||
|
|
|
@ -39,12 +39,23 @@ export const testApp: ApplicationCase = {
|
|||
postSignOutRedirectUri: 'https://my.test.app/sign-out',
|
||||
};
|
||||
|
||||
export const thirdPartyApp: Omit<
|
||||
ApplicationCase,
|
||||
'sample' | 'redirectUri' | 'postSignOutRedirectUri'
|
||||
> = {
|
||||
framework: 'OIDC',
|
||||
name: 'OIDC third party app',
|
||||
description: 'This is a OIDC third party app',
|
||||
guideFilename: 'third-party-oidc',
|
||||
};
|
||||
|
||||
export const frameworkGroupLabels = [
|
||||
'Popular and for you',
|
||||
'Traditional web app',
|
||||
'Single page app',
|
||||
'Native',
|
||||
'Machine-to-machine',
|
||||
'Third-party',
|
||||
] as const;
|
||||
|
||||
export type ApplicationMetadata = {
|
||||
|
|
|
@ -19,6 +19,7 @@ import {
|
|||
applicationTypesMetadata,
|
||||
initialApp,
|
||||
testApp,
|
||||
thirdPartyApp,
|
||||
} from './constants.js';
|
||||
import {
|
||||
expectFrameworkExists,
|
||||
|
@ -260,6 +261,38 @@ describe('applications', () => {
|
|||
}
|
||||
);
|
||||
|
||||
it('can create an third party application', async () => {
|
||||
await expect(page).toClick('div[class$=main] div[class$=headline] button span', {
|
||||
text: 'Create application',
|
||||
});
|
||||
|
||||
await expectModalWithTitle(page, 'Start with SDK and guides');
|
||||
|
||||
await expectFrameworksInGroup(page, '.ReactModalPortal div[class$=guideGroup]:has(>label)');
|
||||
|
||||
// Expect the framework contains on the page
|
||||
await expectFrameworkExists(page, thirdPartyApp.framework);
|
||||
|
||||
// Filter
|
||||
await expect(page).toFill('div[class$=searchInput] input', thirdPartyApp.framework);
|
||||
|
||||
// Expect the framework exists after filtering
|
||||
await expectFrameworkExists(page, thirdPartyApp.framework);
|
||||
|
||||
await expectToChooseAndClickApplicationFramework(page, thirdPartyApp.framework);
|
||||
|
||||
// Expect the app can be created successfully
|
||||
await expectToProceedApplicationCreationFrom(page, thirdPartyApp);
|
||||
|
||||
await expect(page).toMatchElement('div[class$=main] div[class$=header] div[class$=name]', {
|
||||
text: thirdPartyApp.name,
|
||||
});
|
||||
|
||||
await expectToProceedAppDeletion(page, thirdPartyApp.name);
|
||||
|
||||
expect(page.url()).toBe(new URL('/console/applications', logtoConsoleUrl).href);
|
||||
});
|
||||
|
||||
it('delete the initial application', async () => {
|
||||
await expect(page).toClick('table tbody tr td div[class$=item] a[class$=title]', {
|
||||
text: initialApp.name,
|
||||
|
|
|
@ -8,6 +8,8 @@ const guide = {
|
|||
Native: 'Native',
|
||||
MachineToMachine: 'Maschinen-zu-Maschinen',
|
||||
Protected: 'Geschützte App',
|
||||
/** UNTRANSLATED */
|
||||
ThirdParty: 'Third-party app',
|
||||
},
|
||||
filter: {
|
||||
title: 'Filter-Framework',
|
||||
|
|
|
@ -8,6 +8,7 @@ const guide = {
|
|||
Native: 'Native',
|
||||
MachineToMachine: 'Machine-to-machine',
|
||||
Protected: 'Protected app',
|
||||
ThirdParty: 'Third-party app',
|
||||
},
|
||||
filter: {
|
||||
title: 'Filter framework',
|
||||
|
|
|
@ -8,6 +8,8 @@ const guide = {
|
|||
Native: 'Nativa',
|
||||
MachineToMachine: 'Máquina a máquina',
|
||||
Protected: 'Aplicación protegida',
|
||||
/** UNTRANSLATED */
|
||||
ThirdParty: 'Third-party app',
|
||||
},
|
||||
filter: {
|
||||
title: 'Filtrar framework',
|
||||
|
|
|
@ -8,6 +8,8 @@ const guide = {
|
|||
Native: 'Natif',
|
||||
MachineToMachine: 'Machine-à-machine',
|
||||
Protected: 'Application protégée',
|
||||
/** UNTRANSLATED */
|
||||
ThirdParty: 'Third-party app',
|
||||
},
|
||||
filter: {
|
||||
title: 'Filtrer le framework',
|
||||
|
|
|
@ -8,6 +8,8 @@ const guide = {
|
|||
Native: 'Nativo',
|
||||
MachineToMachine: 'Dalla macchina alla macchina',
|
||||
Protected: 'App protetta',
|
||||
/** UNTRANSLATED */
|
||||
ThirdParty: 'Third-party app',
|
||||
},
|
||||
filter: {
|
||||
title: 'Filtra framework',
|
||||
|
|
|
@ -8,6 +8,8 @@ const guide = {
|
|||
Native: 'ネイティブ',
|
||||
MachineToMachine: 'Machine-to-machine',
|
||||
Protected: '保護されたアプリ',
|
||||
/** UNTRANSLATED */
|
||||
ThirdParty: 'Third-party app',
|
||||
},
|
||||
filter: {
|
||||
title: 'フレームワークを絞り込む',
|
||||
|
|
|
@ -8,6 +8,8 @@ const guide = {
|
|||
Native: '네이티브',
|
||||
MachineToMachine: '기계 간 통신',
|
||||
Protected: '보호된 앱',
|
||||
/** UNTRANSLATED */
|
||||
ThirdParty: 'Third-party app',
|
||||
},
|
||||
filter: {
|
||||
title: '프레임워크 필터',
|
||||
|
|
|
@ -8,6 +8,8 @@ const guide = {
|
|||
Native: 'Natywna',
|
||||
MachineToMachine: 'Maszyna-do-maszyny',
|
||||
Protected: 'Aplikacja chroniona',
|
||||
/** UNTRANSLATED */
|
||||
ThirdParty: 'Third-party app',
|
||||
},
|
||||
filter: {
|
||||
title: 'Filtr Framework',
|
||||
|
|
|
@ -8,6 +8,8 @@ const guide = {
|
|||
Native: 'Nativo',
|
||||
MachineToMachine: 'Máquina a máquina',
|
||||
Protected: 'Aplicativo protegido',
|
||||
/** UNTRANSLATED */
|
||||
ThirdParty: 'Third-party app',
|
||||
},
|
||||
filter: {
|
||||
title: 'Filtrar framework',
|
||||
|
|
|
@ -8,6 +8,8 @@ const guide = {
|
|||
Native: 'Nativo',
|
||||
MachineToMachine: 'Máquina-a-máquina',
|
||||
Protected: 'Aplicação protegida',
|
||||
/** UNTRANSLATED */
|
||||
ThirdParty: 'Third-party app',
|
||||
},
|
||||
filter: {
|
||||
title: 'Filtrar framework',
|
||||
|
|
|
@ -8,6 +8,8 @@ const guide = {
|
|||
Native: 'Нативное',
|
||||
MachineToMachine: 'Машина к машине',
|
||||
Protected: 'Защищенное приложение',
|
||||
/** UNTRANSLATED */
|
||||
ThirdParty: 'Third-party app',
|
||||
},
|
||||
filter: {
|
||||
title: 'Фильтры фреймворков',
|
||||
|
|
|
@ -8,6 +8,8 @@ const guide = {
|
|||
Native: 'Doğal',
|
||||
MachineToMachine: 'Makineden makineye',
|
||||
Protected: 'Korunan uygulama',
|
||||
/** UNTRANSLATED */
|
||||
ThirdParty: 'Third-party app',
|
||||
},
|
||||
filter: {
|
||||
title: "Framework'ü filtrele",
|
||||
|
|
|
@ -8,6 +8,8 @@ const guide = {
|
|||
Native: '原生应用',
|
||||
MachineToMachine: 'Machine-to-machine',
|
||||
Protected: '受保护的应用',
|
||||
/** UNTRANSLATED */
|
||||
ThirdParty: 'Third-party app',
|
||||
},
|
||||
filter: {
|
||||
title: '筛选框架',
|
||||
|
|
|
@ -8,6 +8,8 @@ const guide = {
|
|||
Native: '原生應用',
|
||||
MachineToMachine: '機器對機器',
|
||||
Protected: '受保護的應用程式',
|
||||
/** UNTRANSLATED */
|
||||
ThirdParty: 'Third-party app',
|
||||
},
|
||||
filter: {
|
||||
title: '篩選框架',
|
||||
|
|
|
@ -6,8 +6,11 @@ const guide = {
|
|||
Traditional: '傳統網頁應用',
|
||||
SPA: '單頁應用',
|
||||
Native: '原生應用',
|
||||
/** UNTRANSLATED */
|
||||
MachineToMachine: 'Machine-to-machine',
|
||||
Protected: '受保護的應用',
|
||||
/** UNTRANSLATED */
|
||||
ThirdParty: 'Third-party app',
|
||||
},
|
||||
filter: {
|
||||
title: '篩選框架',
|
||||
|
|
Loading…
Add table
Reference in a new issue