0
Fork 0
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:
simeng-li 2024-01-05 10:46:27 +08:00 committed by GitHub
parent 9669fc92fb
commit a85266284b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 182 additions and 18 deletions

View file

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

View file

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

View file

@ -0,0 +1 @@
# Place holder for third party OIDC guide

View file

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

View file

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

View file

@ -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. */

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -8,6 +8,7 @@ const guide = {
Native: 'Native',
MachineToMachine: 'Machine-to-machine',
Protected: 'Protected app',
ThirdParty: 'Third-party app',
},
filter: {
title: 'Filter framework',

View file

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

View file

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

View file

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

View file

@ -8,6 +8,8 @@ const guide = {
Native: 'ネイティブ',
MachineToMachine: 'Machine-to-machine',
Protected: '保護されたアプリ',
/** UNTRANSLATED */
ThirdParty: 'Third-party app',
},
filter: {
title: 'フレームワークを絞り込む',

View file

@ -8,6 +8,8 @@ const guide = {
Native: '네이티브',
MachineToMachine: '기계 간 통신',
Protected: '보호된 앱',
/** UNTRANSLATED */
ThirdParty: 'Third-party app',
},
filter: {
title: '프레임워크 필터',

View file

@ -8,6 +8,8 @@ const guide = {
Native: 'Natywna',
MachineToMachine: 'Maszyna-do-maszyny',
Protected: 'Aplikacja chroniona',
/** UNTRANSLATED */
ThirdParty: 'Third-party app',
},
filter: {
title: 'Filtr Framework',

View file

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

View file

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

View file

@ -8,6 +8,8 @@ const guide = {
Native: 'Нативное',
MachineToMachine: 'Машина к машине',
Protected: 'Защищенное приложение',
/** UNTRANSLATED */
ThirdParty: 'Third-party app',
},
filter: {
title: 'Фильтры фреймворков',

View file

@ -8,6 +8,8 @@ const guide = {
Native: 'Doğal',
MachineToMachine: 'Makineden makineye',
Protected: 'Korunan uygulama',
/** UNTRANSLATED */
ThirdParty: 'Third-party app',
},
filter: {
title: "Framework'ü filtrele",

View file

@ -8,6 +8,8 @@ const guide = {
Native: '原生应用',
MachineToMachine: 'Machine-to-machine',
Protected: '受保护的应用',
/** UNTRANSLATED */
ThirdParty: 'Third-party app',
},
filter: {
title: '筛选框架',

View file

@ -8,6 +8,8 @@ const guide = {
Native: '原生應用',
MachineToMachine: '機器對機器',
Protected: '受保護的應用程式',
/** UNTRANSLATED */
ThirdParty: 'Third-party app',
},
filter: {
title: '篩選框架',

View file

@ -6,8 +6,11 @@ const guide = {
Traditional: '傳統網頁應用',
SPA: '單頁應用',
Native: '原生應用',
/** UNTRANSLATED */
MachineToMachine: 'Machine-to-machine',
Protected: '受保護的應用',
/** UNTRANSLATED */
ThirdParty: 'Third-party app',
},
filter: {
title: '篩選框架',