From bd62490a00339ba94a66b874bac911b1e77f0793 Mon Sep 17 00:00:00 2001 From: cmoulliard Date: Mon, 30 Oct 2023 10:10:19 +0100 Subject: [PATCH 01/16] WIP. Adding the Gitea RepoUrlPicker Signed-off-by: cmoulliard --- .../RepoUrlPicker/GiteaRepoPicker.test.tsx | 40 ++++++++++ .../fields/RepoUrlPicker/GiteaRepoPicker.tsx | 75 +++++++++++++++++++ .../fields/RepoUrlPicker/RepoUrlPicker.tsx | 10 +++ .../components/fields/RepoUrlPicker/schema.ts | 4 + .../components/fields/RepoUrlPicker/utils.ts | 1 - 5 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 plugins/scaffolder/src/components/fields/RepoUrlPicker/GiteaRepoPicker.test.tsx create mode 100644 plugins/scaffolder/src/components/fields/RepoUrlPicker/GiteaRepoPicker.tsx diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/GiteaRepoPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GiteaRepoPicker.test.tsx new file mode 100644 index 0000000000..e66ca49af1 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GiteaRepoPicker.test.tsx @@ -0,0 +1,40 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { GiteaRepoPicker } from './GiteaRepoPicker'; +import { render, fireEvent } from '@testing-library/react'; + +describe('GiteaRepoPicker', () => { + describe('owner input field', () => { + it('calls onChange when the owner input changes', () => { + const onChange = jest.fn(); + const { getAllByRole } = render( + , + ); + + const ownerInput = getAllByRole('textbox')[0]; + + fireEvent.change(ownerInput, { target: { value: 'test-owner' } }); + + expect(onChange).toHaveBeenCalledWith({ owner: 'test-owner' }); + }); + }); +}); diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/GiteaRepoPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GiteaRepoPicker.tsx new file mode 100644 index 0000000000..566a2732e3 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GiteaRepoPicker.tsx @@ -0,0 +1,75 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import FormControl from '@material-ui/core/FormControl'; +import FormHelperText from '@material-ui/core/FormHelperText'; +import Input from '@material-ui/core/Input'; +import InputLabel from '@material-ui/core/InputLabel'; +import { Select, SelectItem } from '@backstage/core-components'; +import { RepoUrlPickerState } from './types'; + +export const GiteaRepoPicker = (props: { + allowedOwners?: string[]; + allowedRepos?: string[]; + state: RepoUrlPickerState; + onChange: (state: RepoUrlPickerState) => void; + rawErrors: string[]; +}) => { + const { allowedOwners = [], state, onChange, rawErrors } = props; + const ownerItems: SelectItem[] = allowedOwners + ? allowedOwners.map(i => ({ label: i, value: i })) + : [{ label: 'Loading...', value: 'loading' }]; + + const { owner } = state; + + return ( + <> + 0 && !owner} + > + {allowedOwners?.length ? ( + onChange({ owner: e.target.value })} + value={owner} + /> + + )} + + Gitea namespace where this repository will belong to. It can be the + name of organization, group, subgroup, user, or the project. + + + + ); +}; diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx index 57cebb3d96..bfb6252876 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx @@ -20,6 +20,7 @@ import { } from '@backstage/integration-react'; import React, { useEffect, useState, useMemo, useCallback } from 'react'; import { GithubRepoPicker } from './GithubRepoPicker'; +import { GiteaRepoPicker } from './GiteaRepoPicker'; import { GitlabRepoPicker } from './GitlabRepoPicker'; import { AzureRepoPicker } from './AzureRepoPicker'; import { BitbucketRepoPicker } from './BitbucketRepoPicker'; @@ -174,6 +175,15 @@ export const RepoUrlPicker = (props: RepoUrlPickerProps) => { state={state} /> )} + {hostType === 'gitea' && ( + + )} {hostType === 'gitlab' && ( Date: Fri, 1 Dec 2023 10:59:18 +0800 Subject: [PATCH 02/16] feat: add permission to catalog create and refresh buttion Signed-off-by: rui ma --- .changeset/large-oranges-press.md | 6 ++ plugins/catalog/package.json | 3 +- .../components/AboutCard/AboutCard.test.tsx | 72 +++++++++++++++++++ .../src/components/AboutCard/AboutCard.tsx | 7 +- .../CatalogPage/DefaultCatalogPage.test.tsx | 3 + .../CatalogPage/DefaultCatalogPage.tsx | 15 ++-- plugins/org/package.json | 1 + .../Group/GroupProfile/GroupProfileCard.tsx | 7 +- yarn.lock | 2 + 9 files changed, 109 insertions(+), 7 deletions(-) create mode 100644 .changeset/large-oranges-press.md diff --git a/.changeset/large-oranges-press.md b/.changeset/large-oranges-press.md new file mode 100644 index 0000000000..b59520bf19 --- /dev/null +++ b/.changeset/large-oranges-press.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-catalog': patch +'@backstage/plugin-org': patch +--- + +Add permission check to catalog create and refresh button diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index 12d66efedb..13beb5b7a4 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -56,6 +56,7 @@ "@backstage/integration-react": "workspace:^", "@backstage/plugin-catalog-common": "workspace:^", "@backstage/plugin-catalog-react": "workspace:^", + "@backstage/plugin-permission-react": "workspace:^", "@backstage/plugin-scaffolder-common": "workspace:^", "@backstage/plugin-search-common": "workspace:^", "@backstage/plugin-search-react": "workspace:^", @@ -83,7 +84,7 @@ "@backstage/cli": "workspace:^", "@backstage/core-app-api": "workspace:^", "@backstage/dev-utils": "workspace:^", - "@backstage/plugin-permission-react": "workspace:^", + "@backstage/plugin-permission-common": "workspace:^", "@backstage/test-utils": "workspace:^", "@testing-library/dom": "^9.0.0", "@testing-library/jest-dom": "^6.0.0", diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx index 1679e2ddf5..e27de38913 100644 --- a/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx @@ -33,6 +33,12 @@ import { RELATION_OWNED_BY } from '@backstage/catalog-model'; import React from 'react'; import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { permissionApiRef } from '@backstage/plugin-permission-react'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; + +const mockAuthorize = jest.fn(); + +const mockPermissionApi = { authorize: mockAuthorize }; describe('', () => { const catalogApi: jest.Mocked = { @@ -87,6 +93,7 @@ describe('', () => { ), ], [catalogApiRef, catalogApi], + [permissionApiRef, {}], ]} > @@ -143,6 +150,7 @@ describe('', () => { ), ], [catalogApiRef, catalogApi], + [permissionApiRef, {}], ]} > @@ -198,6 +206,7 @@ describe('', () => { ), ], [catalogApiRef, catalogApi], + [permissionApiRef, {}], ]} > @@ -240,6 +249,7 @@ describe('', () => { ScmIntegrationsApi.fromConfig(new ConfigReader({})), ], [catalogApiRef, catalogApi], + [permissionApiRef, {}], ]} > @@ -276,6 +286,10 @@ describe('', () => { }, }; + mockAuthorize.mockImplementation(async () => ({ + result: AuthorizeResult.ALLOW, + })); + await renderInTestApp( ', () => { ScmIntegrationsApi.fromConfig(new ConfigReader({})), ], [catalogApiRef, catalogApi], + [permissionApiRef, mockPermissionApi], ]} > @@ -308,6 +323,55 @@ describe('', () => { ); }); + it('should not render refresh button if the permission is DENY', async () => { + const entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + annotations: { + 'backstage.io/managed-by-location': + 'url:https://backstage.io/catalog-info.yaml', + }, + name: 'software-deny', + }, + spec: { + owner: 'guest', + type: 'service', + lifecycle: 'production', + }, + }; + + mockAuthorize.mockImplementation(async () => ({ + result: AuthorizeResult.DENY, + })); + + await renderInTestApp( + + + + + , + { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, + }, + }, + ); + + expect( + screen.queryByTitle('Schedule entity refresh'), + ).not.toBeInTheDocument(); + }); + it('should not render refresh button if the location is not an url or file', async () => { const entity = { apiVersion: 'v1', @@ -330,6 +394,7 @@ describe('', () => { ScmIntegrationsApi.fromConfig(new ConfigReader({})), ], [catalogApiRef, catalogApi], + [permissionApiRef, {}], ]} > @@ -384,6 +449,7 @@ describe('', () => { ), ], [catalogApiRef, catalogApi], + [permissionApiRef, {}], ]} > @@ -440,6 +506,7 @@ describe('', () => { ), ], [catalogApiRef, catalogApi], + [permissionApiRef, {}], ]} > @@ -493,6 +560,7 @@ describe('', () => { ), ], [catalogApiRef, catalogApi], + [permissionApiRef, {}], ]} > @@ -546,6 +614,7 @@ describe('', () => { ), ], [catalogApiRef, catalogApi], + [permissionApiRef, {}], ]} > @@ -592,6 +661,7 @@ describe('', () => { ), ], [catalogApiRef, catalogApi], + [permissionApiRef, {}], ]} > @@ -659,6 +729,7 @@ describe('', () => { ), ], [catalogApiRef, catalogApi], + [permissionApiRef, {}], ]} > @@ -707,6 +778,7 @@ describe('', () => { ), ], [catalogApiRef, catalogApi], + [permissionApiRef, {}], ]} > diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.tsx index 34aa463c39..763f6fc094 100644 --- a/plugins/catalog/src/components/AboutCard/AboutCard.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutCard.tsx @@ -60,6 +60,8 @@ import DocsIcon from '@material-ui/icons/Description'; import EditIcon from '@material-ui/icons/Edit'; import { isTemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common'; import { parseEntityRef } from '@backstage/catalog-model'; +import { useEntityPermission } from '@backstage/plugin-catalog-react/alpha'; +import { catalogEntityRefreshPermission } from '@backstage/plugin-catalog-common/alpha'; const TECHDOCS_ANNOTATION = 'backstage.io/techdocs-ref'; @@ -108,6 +110,9 @@ export function AboutCard(props: AboutCardProps) { const errorApi = useApi(errorApiRef); const viewTechdocLink = useRouteRef(viewTechDocRouteRef); const templateRoute = useRouteRef(createFromTemplateRouteRef); + const { allowed: canRefresh } = useEntityPermission( + catalogEntityRefreshPermission, + ); const entitySourceLocation = getEntitySourceLocation( entity, @@ -215,7 +220,7 @@ export function AboutCard(props: AboutCardProps) { title="About" action={ <> - {allowRefresh && ( + {allowRefresh && canRefresh && ( { const origReplaceState = window.history.replaceState; @@ -168,6 +170,7 @@ describe('DefaultCatalogPage', () => { [identityApiRef, identityApi], [storageApiRef, storageApi], [starredEntitiesApiRef, new MockStarredEntitiesApi()], + [permissionApiRef, new MockPermissionApi()], ]} > {children} diff --git a/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx index 8dfc29f87f..779b347b48 100644 --- a/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx @@ -45,6 +45,8 @@ import { catalogTranslationRef } from '../../translation'; import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; import { CatalogTableColumnsFunc } from '../CatalogTable/types'; +import { catalogEntityCreatePermission } from '@backstage/plugin-catalog-common/alpha'; +import { usePermission } from '@backstage/plugin-permission-react'; /** @internal */ export type BaseCatalogPageProps = { @@ -60,15 +62,20 @@ export function BaseCatalogPage(props: BaseCatalogPageProps) { useApi(configApiRef).getOptionalString('organization.name') ?? 'Backstage'; const createComponentLink = useRouteRef(createComponentRouteRef); const { t } = useTranslationRef(catalogTranslationRef); + const { allowed } = usePermission({ + permission: catalogEntityCreatePermission, + }); return ( - + {allowed && ( + + )} All your software catalog entities diff --git a/plugins/org/package.json b/plugins/org/package.json index 3e006fb958..6f46f44740 100644 --- a/plugins/org/package.json +++ b/plugins/org/package.json @@ -33,6 +33,7 @@ "@backstage/catalog-model": "workspace:^", "@backstage/core-components": "workspace:^", "@backstage/core-plugin-api": "workspace:^", + "@backstage/plugin-catalog-common": "workspace:^", "@backstage/plugin-catalog-react": "workspace:^", "@backstage/theme": "workspace:^", "@material-ui/core": "^4.12.2", diff --git a/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx b/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx index 76527362ff..33c6ee137a 100644 --- a/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx +++ b/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx @@ -54,6 +54,8 @@ import EditIcon from '@material-ui/icons/Edit'; import EmailIcon from '@material-ui/icons/Email'; import GroupIcon from '@material-ui/icons/Group'; import { LinksGroup } from '../../Meta'; +import { useEntityPermission } from '@backstage/plugin-catalog-react/alpha'; +import { catalogEntityRefreshPermission } from '@backstage/plugin-catalog-common/alpha'; const CardTitle = (props: { title: string }) => ( @@ -70,6 +72,9 @@ export const GroupProfileCard = (props: { const catalogApi = useApi(catalogApiRef); const alertApi = useApi(alertApiRef); const { entity: group } = useEntity(); + const { allowed: canRefresh } = useEntityPermission( + catalogEntityRefreshPermission, + ); const refreshEntity = useCallback(async () => { await catalogApi.refreshEntity(stringifyEntityRef(group)); @@ -127,7 +132,7 @@ export const GroupProfileCard = (props: { variant={props.variant} action={ <> - {allowRefresh && ( + {allowRefresh && canRefresh && ( Date: Sat, 9 Dec 2023 11:07:59 +0100 Subject: [PATCH 03/16] docs: start some i18n docs Signed-off-by: blam --- docs/plugins/internationalization.md | 89 ++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/docs/plugins/internationalization.md b/docs/plugins/internationalization.md index 13bb098262..de53e45abe 100644 --- a/docs/plugins/internationalization.md +++ b/docs/plugins/internationalization.md @@ -46,6 +46,95 @@ return ( ); ``` +### Guidelines for `i18n` messages and keys + +The API for `i18n` messages and keys can be pretty tricky to get right, as it's a pretty flexible API. We've put together some guidelines to help you get started that encourage good practices when thinking about translating plugins: + +#### Key names + +Dot notation is used when consuming these keys, and should represent a semantic hierarchy in your translations. This allows for better organization and understanding of the structure. For example: + +```ts +export const myPluginTranslationRef = createTranslationRef({ + id: 'plugin.my-plugin', + messages: { + dashboardPage: { + title: 'All your components', + subtitle: 'Create new component', + widgets: { + weather: { + title: 'Weather', + description: 'Shows the weather', + }, + calendar: { + title: 'Calendar', + description: 'Shows the calendar', + }, + }, + }, + entityPage: { + notFound: 'Entity not found', + }, + }, +}); +``` + +Think about the semantic placement of content rather than the text content itself. Group related translations under a common prefix, and use nesting to represent relationships between different parts of your application. It's good to start grouping under extensions, page sections, or visual scopes and experiences. + +The translations should avoid where possible having their text content in the keys, as this can lead to ambiguity and confusion when the translation changes. + +#### Key reuse + +Discourage key reuse to prevent ambiguity and maintain a clear separation of concerns. Consider creating duplicate keys that are grouped under a semantic section instead. + +#### Flat keys + +Avoid a flat key structure at the root level, as it can lead to naming conflicts and make the translation file harder to manage and change evolve over time. Instead, group translations under a common prefix. + +```ts +export const myPluginTranslationRef = createTranslationRef({ + id: 'plugin.my-plugin', + messages: { + // this is BAD + title: 'My page', + subtitle: 'My subtitle', + // this is GOOD + dashboardPage: { + header: { + title: 'All your components', + subtitle: 'Create new component', + }, + }, + }, +}); +``` + +#### Plurals + +There's build in support for pluralization in our `i18n` library which closely follows the `react-i18next` API. You can read more about it [here](https://www.i18next.com/translation-function/plurals). + +We enourage you to use this feature and avoid creating duplicate keys for pluralized content. For example: + +```ts +export const myPluginTranslationRef = createTranslationRef({ + id: 'plugin.my-plugin', + messages: { + dashboardPage: { + title: 'All your components', + subtitle: 'Create new component', + cards: { + title_one: 'You have one card', + title_two: 'You have two cards', + title_other: 'You have many cards ({{count}})', + }, + }, + entityPage: { + notFound: 'Entity not found', + }, + }, +}); +``` + ## For an application developer overwrite plugin messages In an app you can both override the default messages, as well as register translations for additional languages: From e40ee6597d5ebe07d9d475ec72a3afafb09b6474 Mon Sep 17 00:00:00 2001 From: blam Date: Sat, 9 Dec 2023 11:15:34 +0100 Subject: [PATCH 04/16] chore: fix spelling Signed-off-by: blam --- docs/plugins/internationalization.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plugins/internationalization.md b/docs/plugins/internationalization.md index de53e45abe..040e75874f 100644 --- a/docs/plugins/internationalization.md +++ b/docs/plugins/internationalization.md @@ -113,7 +113,7 @@ export const myPluginTranslationRef = createTranslationRef({ There's build in support for pluralization in our `i18n` library which closely follows the `react-i18next` API. You can read more about it [here](https://www.i18next.com/translation-function/plurals). -We enourage you to use this feature and avoid creating duplicate keys for pluralized content. For example: +We encourage you to use this feature and avoid creating different key prefixes for pluralized content. For example: ```ts export const myPluginTranslationRef = createTranslationRef({ From 6dbe807dadfc33e4422389e4d0ab2d9e3a96884c Mon Sep 17 00:00:00 2001 From: Armel Soro Date: Tue, 12 Dec 2023 00:21:59 +0100 Subject: [PATCH 05/16] Microsite: Add Scaffolder odo Actions Plugin Signed-off-by: Armel Soro --- microsite/data/plugins/scaffolder-backend-odo.yaml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 microsite/data/plugins/scaffolder-backend-odo.yaml diff --git a/microsite/data/plugins/scaffolder-backend-odo.yaml b/microsite/data/plugins/scaffolder-backend-odo.yaml new file mode 100644 index 0000000000..954d51ecb2 --- /dev/null +++ b/microsite/data/plugins/scaffolder-backend-odo.yaml @@ -0,0 +1,10 @@ +--- +title: Scaffolder odo CLI actions +author: Red Hat +authorUrl: https://developers.redhat.com +category: Scaffolder +description: Collection of actions to run odo CLI commands. odo is a developer-focused CLI for container-based application development on Podman and Kubernetes. +documentation: https://github.com/redhat-developer/backstage-odo-devfile-plugin/blob/main/packages/scaffolder-odo-actions-backend/README.md +iconUrl: https://odo.dev/img/logo.png +npmPackageName: '@redhat-developer/plugin-scaffolder-odo-actions' +addedDate: '2023-12-08' From 92242965a10a0cbd0b29bdced58b69311f6ff099 Mon Sep 17 00:00:00 2001 From: Joseph Campos Date: Tue, 12 Dec 2023 12:33:10 -0800 Subject: [PATCH 06/16] Updating bad path in docs Signed-off-by: Joseph Campos --- docs/deployment/docker.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/deployment/docker.md b/docs/deployment/docker.md index c9ace49d4d..5c3301258f 100644 --- a/docs/deployment/docker.md +++ b/docs/deployment/docker.md @@ -309,7 +309,7 @@ package, which is done as follows: .addRouter('', await app(appEnv)); ``` 3. Remove the `@backstage/plugin-app-backend` and the app package dependency - (e.g. `app`) from `packages/backend/packages.json`. If you don't remove the + (e.g. `app`) from `packages/backend/package.json`. If you don't remove the app package dependency the app will still be built and bundled with the backend. From 4c71c54d35e344be9149f5ab39dee5e127683b50 Mon Sep 17 00:00:00 2001 From: cmoulliard Date: Wed, 13 Dec 2023 12:21:12 +0100 Subject: [PATCH 07/16] Adding the new integrations gitea to ScaffolderClient Signed-off-by: cmoulliard --- plugins/scaffolder/src/api.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index c67a5c944e..a66ac522c2 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -107,6 +107,7 @@ export class ScaffolderClient implements ScaffolderApi { ...this.scmIntegrationsApi.bitbucketCloud.list(), ...this.scmIntegrationsApi.bitbucketServer.list(), ...this.scmIntegrationsApi.gerrit.list(), + ...this.scmIntegrationsApi.gitea.list(), ...this.scmIntegrationsApi.github.list(), ...this.scmIntegrationsApi.gitlab.list(), ] From a3792432bb9e2a66616819d738daaad9f6d444b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Wed, 13 Dec 2023 16:50:09 +0100 Subject: [PATCH 08/16] introduce FrontendFeature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/big-ads-travel.md | 5 ++++ .changeset/brown-emus-explode.md | 6 +++++ packages/core-compat-api/api-report.md | 5 ++-- .../core-compat-api/src/convertLegacyApp.ts | 5 ++-- packages/frontend-app-api/api-report.md | 11 +++----- .../src/routing/collectRouteIds.ts | 7 ++--- .../src/tree/createAppTree.ts | 8 ++---- .../src/tree/resolveAppNodeSpecs.ts | 3 ++- .../frontend-app-api/src/wiring/createApp.tsx | 15 +++++------ .../frontend-app-api/src/wiring/discovery.ts | 13 +++------ packages/frontend-plugin-api/api-report.md | 3 +++ .../src/wiring/createExtensionOverrides.ts | 7 +---- .../src/wiring/createPlugin.test.ts | 3 ++- .../src/wiring/createPlugin.ts | 25 +++++------------ .../frontend-plugin-api/src/wiring/index.ts | 18 ++++++------- .../frontend-plugin-api/src/wiring/types.ts | 27 +++++++++++++++++++ 16 files changed, 82 insertions(+), 79 deletions(-) create mode 100644 .changeset/big-ads-travel.md create mode 100644 .changeset/brown-emus-explode.md diff --git a/.changeset/big-ads-travel.md b/.changeset/big-ads-travel.md new file mode 100644 index 0000000000..e58feda5a0 --- /dev/null +++ b/.changeset/big-ads-travel.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-plugin-api': patch +--- + +Add the `FrontendFeature` type, which is the union of `BackstagePlugin` and `ExtensionOverrides` diff --git a/.changeset/brown-emus-explode.md b/.changeset/brown-emus-explode.md new file mode 100644 index 0000000000..255849da46 --- /dev/null +++ b/.changeset/brown-emus-explode.md @@ -0,0 +1,6 @@ +--- +'@backstage/frontend-app-api': patch +'@backstage/core-compat-api': patch +--- + +Leverage the new `FrontendFeature` type to simplify interfaces diff --git a/packages/core-compat-api/api-report.md b/packages/core-compat-api/api-report.md index a764d8c091..494747be60 100644 --- a/packages/core-compat-api/api-report.md +++ b/packages/core-compat-api/api-report.md @@ -4,10 +4,9 @@ ```ts import { AnyRouteRefParams } from '@backstage/core-plugin-api'; -import { BackstagePlugin } from '@backstage/frontend-plugin-api'; -import { ExtensionOverrides } from '@backstage/frontend-plugin-api'; import { ExternalRouteRef } from '@backstage/core-plugin-api'; import { ExternalRouteRef as ExternalRouteRef_2 } from '@backstage/frontend-plugin-api'; +import { FrontendFeature } from '@backstage/frontend-plugin-api'; import { default as React_2 } from 'react'; import { ReactNode } from 'react'; import { RouteRef } from '@backstage/core-plugin-api'; @@ -21,7 +20,7 @@ export function compatWrapper(element: ReactNode): React_2.JSX.Element; // @public (undocumented) export function convertLegacyApp( rootElement: React_2.JSX.Element, -): (ExtensionOverrides | BackstagePlugin)[]; +): FrontendFeature[]; // @public export function convertLegacyRouteRef( diff --git a/packages/core-compat-api/src/convertLegacyApp.ts b/packages/core-compat-api/src/convertLegacyApp.ts index 8055a7b72e..593a159875 100644 --- a/packages/core-compat-api/src/convertLegacyApp.ts +++ b/packages/core-compat-api/src/convertLegacyApp.ts @@ -22,8 +22,7 @@ import React, { isValidElement, } from 'react'; import { - BackstagePlugin, - ExtensionOverrides, + FrontendFeature, coreExtensionData, createExtension, createExtensionInput, @@ -61,7 +60,7 @@ function selectChildren( /** @public */ export function convertLegacyApp( rootElement: React.JSX.Element, -): (ExtensionOverrides | BackstagePlugin)[] { +): FrontendFeature[] { if (getComponentData(rootElement, 'core.type') === 'FlatRoutes') { return collectLegacyRoutes(rootElement); } diff --git a/packages/frontend-app-api/api-report.md b/packages/frontend-app-api/api-report.md index a453463645..a4654e9f8d 100644 --- a/packages/frontend-app-api/api-report.md +++ b/packages/frontend-app-api/api-report.md @@ -3,26 +3,23 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import { BackstagePlugin } from '@backstage/frontend-plugin-api'; import { Config } from '@backstage/config'; import { ConfigApi } from '@backstage/core-plugin-api'; import { ExtensionDataRef } from '@backstage/frontend-plugin-api'; -import { ExtensionOverrides } from '@backstage/frontend-plugin-api'; import { ExternalRouteRef } from '@backstage/frontend-plugin-api'; +import { FrontendFeature } from '@backstage/frontend-plugin-api'; import { JSX as JSX_2 } from 'react'; import { RouteRef } from '@backstage/frontend-plugin-api'; import { SubRouteRef } from '@backstage/frontend-plugin-api'; // @public (undocumented) export function createApp(options?: { - features?: (BackstagePlugin | ExtensionOverrides)[]; + features?: FrontendFeature[]; configLoader?: () => Promise<{ config: ConfigApi; }>; bindRoutes?(context: { bind: CreateAppRouteBinder }): void; - featureLoader?: (ctx: { - config: ConfigApi; - }) => Promise<(BackstagePlugin | ExtensionOverrides)[]>; + featureLoader?: (ctx: { config: ConfigApi }) => Promise; }): { createRoot(): JSX_2.Element; }; @@ -45,7 +42,7 @@ export function createExtensionTree(options: { config: Config }): ExtensionTree; // @public export function createSpecializedApp(options?: { - features?: (BackstagePlugin | ExtensionOverrides)[]; + features?: FrontendFeature[]; config?: ConfigApi; bindRoutes?(context: { bind: CreateAppRouteBinder }): void; }): { diff --git a/packages/frontend-app-api/src/routing/collectRouteIds.ts b/packages/frontend-app-api/src/routing/collectRouteIds.ts index 7e2a4cad2e..1d6aa14e5d 100644 --- a/packages/frontend-app-api/src/routing/collectRouteIds.ts +++ b/packages/frontend-app-api/src/routing/collectRouteIds.ts @@ -15,11 +15,10 @@ */ import { - BackstagePlugin, - ExtensionOverrides, RouteRef, SubRouteRef, ExternalRouteRef, + FrontendFeature, } from '@backstage/frontend-plugin-api'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { toInternalRouteRef } from '../../../frontend-plugin-api/src/routing/RouteRef'; @@ -33,9 +32,7 @@ export interface RouteRefsById { } /** @internal */ -export function collectRouteIds( - features: (BackstagePlugin | ExtensionOverrides)[], -): RouteRefsById { +export function collectRouteIds(features: FrontendFeature[]): RouteRefsById { const routesById = new Map(); const externalRoutesById = new Map(); diff --git a/packages/frontend-app-api/src/tree/createAppTree.ts b/packages/frontend-app-api/src/tree/createAppTree.ts index 950669febd..0f8ce09138 100644 --- a/packages/frontend-app-api/src/tree/createAppTree.ts +++ b/packages/frontend-app-api/src/tree/createAppTree.ts @@ -14,11 +14,7 @@ * limitations under the License. */ -import { - BackstagePlugin, - Extension, - ExtensionOverrides, -} from '@backstage/frontend-plugin-api'; +import { Extension, FrontendFeature } from '@backstage/frontend-plugin-api'; import { readAppExtensionsConfig } from './readAppExtensionsConfig'; import { resolveAppTree } from './resolveAppTree'; import { resolveAppNodeSpecs } from './resolveAppNodeSpecs'; @@ -28,7 +24,7 @@ import { instantiateAppNodeTree } from './instantiateAppNodeTree'; /** @internal */ export interface CreateAppTreeOptions { - features: (BackstagePlugin | ExtensionOverrides)[]; + features: FrontendFeature[]; builtinExtensions: Extension[]; config: Config; } diff --git a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts index 870e1f69f8..8fd9d44638 100644 --- a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts +++ b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts @@ -18,6 +18,7 @@ import { BackstagePlugin, Extension, ExtensionOverrides, + FrontendFeature, } from '@backstage/frontend-plugin-api'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { toInternalExtensionOverrides } from '../../../frontend-plugin-api/src/wiring/createExtensionOverrides'; @@ -30,7 +31,7 @@ import { toInternalExtension } from '../../../frontend-plugin-api/src/wiring/res /** @internal */ export function resolveAppNodeSpecs(options: { - features: (BackstagePlugin | ExtensionOverrides)[]; + features: FrontendFeature[]; builtinExtensions: Extension[]; parameters: Array; forbidden?: Set; diff --git a/packages/frontend-app-api/src/wiring/createApp.tsx b/packages/frontend-app-api/src/wiring/createApp.tsx index 67b8494953..0f8e3de953 100644 --- a/packages/frontend-app-api/src/wiring/createApp.tsx +++ b/packages/frontend-app-api/src/wiring/createApp.tsx @@ -19,7 +19,6 @@ import { ConfigReader, Config } from '@backstage/config'; import { AppTree, appTreeApiRef, - BackstagePlugin, ComponentRef, componentsApiRef, coreExtensionData, @@ -29,7 +28,7 @@ import { createThemeExtension, createTranslationExtension, ExtensionDataRef, - ExtensionOverrides, + FrontendFeature, RouteRef, useRouteRef, } from '@backstage/frontend-plugin-api'; @@ -217,8 +216,8 @@ export function createExtensionTree(options: { } function deduplicateFeatures( - allFeatures: (BackstagePlugin | ExtensionOverrides)[], -): (BackstagePlugin | ExtensionOverrides)[] { + allFeatures: FrontendFeature[], +): FrontendFeature[] { // Start by removing duplicates by reference const features = Array.from(new Set(allFeatures)); @@ -241,12 +240,10 @@ function deduplicateFeatures( /** @public */ export function createApp(options?: { - features?: (BackstagePlugin | ExtensionOverrides)[]; + features?: FrontendFeature[]; configLoader?: () => Promise<{ config: ConfigApi }>; bindRoutes?(context: { bind: CreateAppRouteBinder }): void; - featureLoader?: (ctx: { - config: ConfigApi; - }) => Promise<(BackstagePlugin | ExtensionOverrides)[]>; + featureLoader?: (ctx: { config: ConfigApi }) => Promise; }): { createRoot(): JSX.Element; } { @@ -291,7 +288,7 @@ export function createApp(options?: { * @public */ export function createSpecializedApp(options?: { - features?: (BackstagePlugin | ExtensionOverrides)[]; + features?: FrontendFeature[]; config?: ConfigApi; bindRoutes?(context: { bind: CreateAppRouteBinder }): void; }): { createRoot(): JSX.Element } { diff --git a/packages/frontend-app-api/src/wiring/discovery.ts b/packages/frontend-app-api/src/wiring/discovery.ts index 1181992d53..4e50290350 100644 --- a/packages/frontend-app-api/src/wiring/discovery.ts +++ b/packages/frontend-app-api/src/wiring/discovery.ts @@ -15,10 +15,7 @@ */ import { Config, ConfigReader } from '@backstage/config'; -import { - BackstagePlugin, - ExtensionOverrides, -} from '@backstage/frontend-plugin-api'; +import { FrontendFeature } from '@backstage/frontend-plugin-api'; interface DiscoveryGlobal { modules: Array<{ name: string; export?: string; default: unknown }>; @@ -58,9 +55,7 @@ function readPackageDetectionConfig(config: Config) { /** * @public */ -export function getAvailableFeatures( - config: Config, -): (BackstagePlugin | ExtensionOverrides)[] { +export function getAvailableFeatures(config: Config): FrontendFeature[] { const discovered = ( window as { '__@backstage/discovered__'?: DiscoveryGlobal } )['__@backstage/discovered__']; @@ -86,9 +81,7 @@ export function getAvailableFeatures( ); } -function isBackstageFeature( - obj: unknown, -): obj is BackstagePlugin | ExtensionOverrides { +function isBackstageFeature(obj: unknown): obj is FrontendFeature { if (obj !== null && typeof obj === 'object' && '$$type' in obj) { return ( obj.$$type === '@backstage/BackstagePlugin' || diff --git a/packages/frontend-plugin-api/api-report.md b/packages/frontend-plugin-api/api-report.md index 7c3ff5af34..b67c4e75eb 100644 --- a/packages/frontend-plugin-api/api-report.md +++ b/packages/frontend-plugin-api/api-report.md @@ -863,6 +863,9 @@ export { FetchApi }; export { fetchApiRef }; +// @public (undocumented) +export type FrontendFeature = BackstagePlugin | ExtensionOverrides; + export { githubAuthApiRef }; export { gitlabAuthApiRef }; diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionOverrides.ts b/packages/frontend-plugin-api/src/wiring/createExtensionOverrides.ts index a60e655ff9..3c14d285b3 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionOverrides.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionOverrides.ts @@ -19,7 +19,7 @@ import { Extension, resolveExtensionDefinition, } from './resolveExtensionDefinition'; -import { FeatureFlagConfig } from './types'; +import { ExtensionOverrides, FeatureFlagConfig } from './types'; /** @public */ export interface ExtensionOverridesOptions { @@ -27,11 +27,6 @@ export interface ExtensionOverridesOptions { featureFlags?: FeatureFlagConfig[]; } -/** @public */ -export interface ExtensionOverrides { - readonly $$type: '@backstage/ExtensionOverrides'; -} - /** @internal */ export interface InternalExtensionOverrides extends ExtensionOverrides { readonly version: 'v1'; diff --git a/packages/frontend-plugin-api/src/wiring/createPlugin.test.ts b/packages/frontend-plugin-api/src/wiring/createPlugin.test.ts index d533048d2b..c7f31eb60d 100644 --- a/packages/frontend-plugin-api/src/wiring/createPlugin.test.ts +++ b/packages/frontend-plugin-api/src/wiring/createPlugin.test.ts @@ -18,13 +18,14 @@ import React from 'react'; import { createApp } from '@backstage/frontend-app-api'; import { screen } from '@testing-library/react'; import { createSchemaFromZod } from '../schema/createSchemaFromZod'; -import { createPlugin, BackstagePlugin } from './createPlugin'; +import { createPlugin } from './createPlugin'; import { JsonObject } from '@backstage/types'; import { createExtension } from './createExtension'; import { createExtensionDataRef } from './createExtensionDataRef'; import { coreExtensionData } from './coreExtensionData'; import { MockConfigApi, renderWithEffects } from '@backstage/test-utils'; import { createExtensionInput } from './createExtensionInput'; +import { BackstagePlugin } from './types'; const nameExtensionDataRef = createExtensionDataRef('name'); diff --git a/packages/frontend-plugin-api/src/wiring/createPlugin.ts b/packages/frontend-plugin-api/src/wiring/createPlugin.ts index 06c216a37f..67907e188e 100644 --- a/packages/frontend-plugin-api/src/wiring/createPlugin.ts +++ b/packages/frontend-plugin-api/src/wiring/createPlugin.ts @@ -15,18 +15,16 @@ */ import { ExtensionDefinition } from './createExtension'; -import { ExternalRouteRef, RouteRef } from '../routing'; -import { FeatureFlagConfig } from './types'; import { Extension, resolveExtensionDefinition, } from './resolveExtensionDefinition'; - -/** @public */ -export type AnyRoutes = { [name in string]: RouteRef }; - -/** @public */ -export type AnyExternalRoutes = { [name in string]: ExternalRouteRef }; +import { + AnyExternalRoutes, + AnyRoutes, + BackstagePlugin, + FeatureFlagConfig, +} from './types'; /** @public */ export interface PluginOptions< @@ -40,17 +38,6 @@ export interface PluginOptions< featureFlags?: FeatureFlagConfig[]; } -/** @public */ -export interface BackstagePlugin< - Routes extends AnyRoutes = AnyRoutes, - ExternalRoutes extends AnyExternalRoutes = AnyExternalRoutes, -> { - readonly $$type: '@backstage/BackstagePlugin'; - readonly id: string; - readonly routes: Routes; - readonly externalRoutes: ExternalRoutes; -} - /** @public */ export interface InternalBackstagePlugin< Routes extends AnyRoutes = AnyRoutes, diff --git a/packages/frontend-plugin-api/src/wiring/index.ts b/packages/frontend-plugin-api/src/wiring/index.ts index 2a426c145a..61f821c7ce 100644 --- a/packages/frontend-plugin-api/src/wiring/index.ts +++ b/packages/frontend-plugin-api/src/wiring/index.ts @@ -34,17 +34,17 @@ export { type ExtensionDataRef, type ConfigurableExtensionDataRef, } from './createExtensionDataRef'; -export { - createPlugin, - type BackstagePlugin, - type PluginOptions, - type AnyRoutes, - type AnyExternalRoutes, -} from './createPlugin'; +export { createPlugin, type PluginOptions } from './createPlugin'; export { createExtensionOverrides, - type ExtensionOverrides, type ExtensionOverridesOptions, } from './createExtensionOverrides'; export { type Extension } from './resolveExtensionDefinition'; -export type { FeatureFlagConfig } from './types'; +export { + type AnyRoutes, + type AnyExternalRoutes, + type BackstagePlugin, + type ExtensionOverrides, + type FeatureFlagConfig, + type FrontendFeature, +} from './types'; diff --git a/packages/frontend-plugin-api/src/wiring/types.ts b/packages/frontend-plugin-api/src/wiring/types.ts index ab9f07b35e..c2a6b09ba7 100644 --- a/packages/frontend-plugin-api/src/wiring/types.ts +++ b/packages/frontend-plugin-api/src/wiring/types.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import { ExternalRouteRef, RouteRef } from '../routing'; + /** * Feature flag configuration. * @@ -23,3 +25,28 @@ export type FeatureFlagConfig = { /** Feature flag name */ name: string; }; + +/** @public */ +export type AnyRoutes = { [name in string]: RouteRef }; + +/** @public */ +export type AnyExternalRoutes = { [name in string]: ExternalRouteRef }; + +/** @public */ +export interface BackstagePlugin< + Routes extends AnyRoutes = AnyRoutes, + ExternalRoutes extends AnyExternalRoutes = AnyExternalRoutes, +> { + readonly $$type: '@backstage/BackstagePlugin'; + readonly id: string; + readonly routes: Routes; + readonly externalRoutes: ExternalRoutes; +} + +/** @public */ +export interface ExtensionOverrides { + readonly $$type: '@backstage/ExtensionOverrides'; +} + +/** @public */ +export type FrontendFeature = BackstagePlugin | ExtensionOverrides; From 44735df63341380ee27da62ccf154b7025254c75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 14 Dec 2023 10:33:44 +0100 Subject: [PATCH 09/16] no more featureLoader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/fluffy-bikes-laugh.md | 5 +++ packages/frontend-app-api/api-report.md | 8 +++- packages/frontend-app-api/package.json | 1 + .../src/wiring/createApp.test.tsx | 27 +++++++++++++ .../frontend-app-api/src/wiring/createApp.tsx | 39 +++++++++++++++---- packages/frontend-app-api/src/wiring/index.ts | 1 + yarn.lock | 1 + 7 files changed, 72 insertions(+), 10 deletions(-) create mode 100644 .changeset/fluffy-bikes-laugh.md diff --git a/.changeset/fluffy-bikes-laugh.md b/.changeset/fluffy-bikes-laugh.md new file mode 100644 index 0000000000..40be65c126 --- /dev/null +++ b/.changeset/fluffy-bikes-laugh.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-app-api': minor +--- + +Removed `featureLoader` from `createApp`, `features` instead accepts both `FrontendFeature` and `CreateAppFeatureLoader` diff --git a/packages/frontend-app-api/api-report.md b/packages/frontend-app-api/api-report.md index a4654e9f8d..08db759511 100644 --- a/packages/frontend-app-api/api-report.md +++ b/packages/frontend-app-api/api-report.md @@ -14,16 +14,20 @@ import { SubRouteRef } from '@backstage/frontend-plugin-api'; // @public (undocumented) export function createApp(options?: { - features?: FrontendFeature[]; + features?: (FrontendFeature | CreateAppFeatureLoader)[]; configLoader?: () => Promise<{ config: ConfigApi; }>; bindRoutes?(context: { bind: CreateAppRouteBinder }): void; - featureLoader?: (ctx: { config: ConfigApi }) => Promise; }): { createRoot(): JSX_2.Element; }; +// @public +export type CreateAppFeatureLoader = (options: { + config: ConfigApi; +}) => Promise; + // @public export type CreateAppRouteBinder = < TExternalRoutes extends { diff --git a/packages/frontend-app-api/package.json b/packages/frontend-app-api/package.json index 11f7260a20..db4dc931f2 100644 --- a/packages/frontend-app-api/package.json +++ b/packages/frontend-app-api/package.json @@ -38,6 +38,7 @@ "@backstage/core-app-api": "workspace:^", "@backstage/core-components": "workspace:^", "@backstage/core-plugin-api": "workspace:^", + "@backstage/errors": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", "@backstage/theme": "workspace:^", "@backstage/types": "workspace:^", diff --git a/packages/frontend-app-api/src/wiring/createApp.test.tsx b/packages/frontend-app-api/src/wiring/createApp.test.tsx index dafad5365c..a7956137c4 100644 --- a/packages/frontend-app-api/src/wiring/createApp.test.tsx +++ b/packages/frontend-app-api/src/wiring/createApp.test.tsx @@ -99,6 +99,33 @@ describe('createApp', () => { ); }); + it('should support feature loaders', async () => { + const app = createApp({ + configLoader: async () => ({ + config: new MockConfigApi({ key: 'config-value' }), + }), + features: [ + async ({ config }) => [ + createPlugin({ + id: 'test', + extensions: [ + createPageExtension({ + defaultPath: '/', + loader: async () =>
{config.getString('key')}
, + }), + ], + }), + ], + ], + }); + + await renderWithEffects(app.createRoot()); + + await expect( + screen.findByText('config-value'), + ).resolves.toBeInTheDocument(); + }); + it('should register feature flags', async () => { const app = createApp({ configLoader: async () => ({ config: new MockConfigApi({}) }), diff --git a/packages/frontend-app-api/src/wiring/createApp.tsx b/packages/frontend-app-api/src/wiring/createApp.tsx index 0f8e3de953..f3b32db399 100644 --- a/packages/frontend-app-api/src/wiring/createApp.tsx +++ b/packages/frontend-app-api/src/wiring/createApp.tsx @@ -101,6 +101,7 @@ import { toInternalBackstagePlugin } from '../../../frontend-plugin-api/src/wiri // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { toInternalExtensionOverrides } from '../../../frontend-plugin-api/src/wiring/createExtensionOverrides'; import { DefaultComponentsApi } from '../apis/implementations/ComponentsApi'; +import { stringifyError } from '@backstage/errors'; export const builtinExtensions = [ Core, @@ -238,12 +239,20 @@ function deduplicateFeatures( .reverse(); } +/** + * A source of dynamically loaded frontend features. + * + * @public + */ +export type CreateAppFeatureLoader = (options: { + config: ConfigApi; +}) => Promise; + /** @public */ export function createApp(options?: { - features?: FrontendFeature[]; + features?: (FrontendFeature | CreateAppFeatureLoader)[]; configLoader?: () => Promise<{ config: ConfigApi }>; bindRoutes?(context: { bind: CreateAppRouteBinder }): void; - featureLoader?: (ctx: { config: ConfigApi }) => Promise; }): { createRoot(): JSX.Element; } { @@ -255,15 +264,28 @@ export function createApp(options?: { ); const discoveredFeatures = getAvailableFeatures(config); - const loadedFeatures = (await options?.featureLoader?.({ config })) ?? []; + + const providedFeatures: FrontendFeature[] = []; + for (const feature of options?.features ?? []) { + if (typeof feature === 'function') { + try { + const loadedFeatures = await feature({ config }); + providedFeatures.push(...loadedFeatures); + } catch (e) { + throw new Error( + `Failed to read frontend features from loader, ${stringifyError( + e, + )}`, + ); + } + } else { + providedFeatures.push(feature); + } + } const app = createSpecializedApp({ config, - features: [ - ...discoveredFeatures, - ...loadedFeatures, - ...(options?.features ?? []), - ], + features: [...discoveredFeatures, ...providedFeatures], bindRoutes: options?.bindRoutes, }).createRoot(); @@ -285,6 +307,7 @@ export function createApp(options?: { /** * Synchronous version of {@link createApp}, expecting all features and * config to have been loaded already. + * * @public */ export function createSpecializedApp(options?: { diff --git a/packages/frontend-app-api/src/wiring/index.ts b/packages/frontend-app-api/src/wiring/index.ts index 412e643523..00c6458bd5 100644 --- a/packages/frontend-app-api/src/wiring/index.ts +++ b/packages/frontend-app-api/src/wiring/index.ts @@ -18,6 +18,7 @@ export { createApp, createSpecializedApp, createExtensionTree, + type CreateAppFeatureLoader, type ExtensionTreeNode, type ExtensionTree, } from './createApp'; diff --git a/yarn.lock b/yarn.lock index 8a7e8a1dac..22ca9ef371 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4158,6 +4158,7 @@ __metadata: "@backstage/core-app-api": "workspace:^" "@backstage/core-components": "workspace:^" "@backstage/core-plugin-api": "workspace:^" + "@backstage/errors": "workspace:^" "@backstage/frontend-plugin-api": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/theme": "workspace:^" From 8f5b7a0a6232923d17d65efcef8e4bd5251f4ffd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 14 Dec 2023 10:44:43 +0100 Subject: [PATCH 10/16] one more test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../src/wiring/createApp.test.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/frontend-app-api/src/wiring/createApp.test.tsx b/packages/frontend-app-api/src/wiring/createApp.test.tsx index a7956137c4..244ce625fb 100644 --- a/packages/frontend-app-api/src/wiring/createApp.test.tsx +++ b/packages/frontend-app-api/src/wiring/createApp.test.tsx @@ -126,6 +126,25 @@ describe('createApp', () => { ).resolves.toBeInTheDocument(); }); + it('should propagate errors thrown by feature loaders', async () => { + const app = createApp({ + configLoader: async () => ({ + config: new MockConfigApi({}), + }), + features: [ + async () => { + throw new TypeError('boom'); + }, + ], + }); + + await expect( + renderWithEffects(app.createRoot()), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to read frontend features from loader, TypeError: boom"`, + ); + }); + it('should register feature flags', async () => { const app = createApp({ configLoader: async () => ({ config: new MockConfigApi({}) }), From df88d094c627de78296cf13da3cd66daf106bcf0 Mon Sep 17 00:00:00 2001 From: cmoulliard Date: Thu, 14 Dec 2023 12:10:18 +0100 Subject: [PATCH 11/16] Created a changeSet documenting the new gitea repo picker url Signed-off-by: cmoulliard --- .changeset/giant-pets-cover.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/giant-pets-cover.md diff --git a/.changeset/giant-pets-cover.md b/.changeset/giant-pets-cover.md new file mode 100644 index 0000000000..e26407e432 --- /dev/null +++ b/.changeset/giant-pets-cover.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': major +--- + +Add a new git repository url picker for `gitea`. This `GiteaRepoPicker` can be used in a template to scaffold a project to be cloned using gitea. From bae1e40f683e0b8d42562c1db0ef5904622e1619 Mon Sep 17 00:00:00 2001 From: cmoulliard Date: Thu, 14 Dec 2023 12:16:03 +0100 Subject: [PATCH 12/16] Revert the change from major to minor as code will not break current release Signed-off-by: cmoulliard --- .changeset/giant-pets-cover.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/giant-pets-cover.md b/.changeset/giant-pets-cover.md index e26407e432..604f39cf8d 100644 --- a/.changeset/giant-pets-cover.md +++ b/.changeset/giant-pets-cover.md @@ -1,5 +1,5 @@ --- -'@backstage/plugin-scaffolder': major +'@backstage/plugin-scaffolder': minor --- Add a new git repository url picker for `gitea`. This `GiteaRepoPicker` can be used in a template to scaffold a project to be cloned using gitea. From 5da4599d018b77c498f56f260bb6a19dfb8a7ff5 Mon Sep 17 00:00:00 2001 From: cmoulliard Date: Thu, 14 Dec 2023 13:33:06 +0100 Subject: [PATCH 13/16] Updating the api report for new gitea parameters Signed-off-by: cmoulliard --- plugins/scaffolder/api-report.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/scaffolder/api-report.md b/plugins/scaffolder/api-report.md index 6f8479990b..4df1eff430 100644 --- a/plugins/scaffolder/api-report.md +++ b/plugins/scaffolder/api-report.md @@ -379,6 +379,7 @@ export const RepoUrlPickerFieldExtension: FieldExtensionComponent_2< secretsKey: string; additionalScopes?: | { + gitea?: string[] | undefined; gerrit?: string[] | undefined; github?: string[] | undefined; gitlab?: string[] | undefined; @@ -405,6 +406,7 @@ export const RepoUrlPickerFieldSchema: FieldSchema< secretsKey: string; additionalScopes?: | { + gitea?: string[] | undefined; gerrit?: string[] | undefined; github?: string[] | undefined; gitlab?: string[] | undefined; From bdd5d99e4c2cb1e042fa1b28d164a3f01d1bd1a0 Mon Sep 17 00:00:00 2001 From: Ben Lambert Date: Wed, 13 Dec 2023 13:29:50 +0100 Subject: [PATCH 14/16] Apply suggestions from code review Co-authored-by: Patrik Oldsberg Signed-off-by: Ben Lambert Signed-off-by: blam --- docs/plugins/internationalization.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/plugins/internationalization.md b/docs/plugins/internationalization.md index 040e75874f..98f6a05a58 100644 --- a/docs/plugins/internationalization.md +++ b/docs/plugins/internationalization.md @@ -52,7 +52,7 @@ The API for `i18n` messages and keys can be pretty tricky to get right, as it's #### Key names -Dot notation is used when consuming these keys, and should represent a semantic hierarchy in your translations. This allows for better organization and understanding of the structure. For example: +When defining messages it is recommended to use a nested structure that represents the semantic hierarchy in your translations. This allows for better organization and understanding of the structure. For example: ```ts export const myPluginTranslationRef = createTranslationRef({ @@ -81,11 +81,11 @@ export const myPluginTranslationRef = createTranslationRef({ Think about the semantic placement of content rather than the text content itself. Group related translations under a common prefix, and use nesting to represent relationships between different parts of your application. It's good to start grouping under extensions, page sections, or visual scopes and experiences. -The translations should avoid where possible having their text content in the keys, as this can lead to ambiguity and confusion when the translation changes. +Translations should avoid using their own text content as key where possible, as this can lead to confusion if the translation changes. Instead prefer to use keys that describe the location or usage of the text. #### Key reuse -Discourage key reuse to prevent ambiguity and maintain a clear separation of concerns. Consider creating duplicate keys that are grouped under a semantic section instead. +Reusing the same key in multiple places is discouraged. This helps prevent ambiguity, and instead keeps the usage of each key as clear as possible. Consider creating duplicate keys that are grouped under a semantic section instead. #### Flat keys @@ -111,7 +111,7 @@ export const myPluginTranslationRef = createTranslationRef({ #### Plurals -There's build in support for pluralization in our `i18n` library which closely follows the `react-i18next` API. You can read more about it [here](https://www.i18next.com/translation-function/plurals). +The `i18next` library, which is used as the underlying implementation, has built-in support for pluralization. You can use this feature as is described in [the documentation](https://www.i18next.com/translation-function/plurals). We encourage you to use this feature and avoid creating different key prefixes for pluralized content. For example: From 627dabd6d36501e13c85e806eabc70d9c03e9c82 Mon Sep 17 00:00:00 2001 From: blam Date: Thu, 14 Dec 2023 15:46:49 +0100 Subject: [PATCH 15/16] chore: some more smaller tweaks Signed-off-by: blam --- docs/plugins/internationalization.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/plugins/internationalization.md b/docs/plugins/internationalization.md index 98f6a05a58..fd12452cff 100644 --- a/docs/plugins/internationalization.md +++ b/docs/plugins/internationalization.md @@ -6,7 +6,7 @@ description: Documentation on adding internationalization to the plugin ## Overview -The Backstage core function provides internationalization for plugins +The Backstage core function provides internationalization for plugins. The underlying library is [`i18next`](https://www.i18next.com/) with some additional Backstage typescript magic for type safety with keys. ## For a plugin developer @@ -46,6 +46,8 @@ return ( ); ``` +You will see how the initial dictionary structure and nesting gets converted into dot notation, so we encourage `camelCase` in key names and lean on the nesting structure to separate keys. + ### Guidelines for `i18n` messages and keys The API for `i18n` messages and keys can be pretty tricky to get right, as it's a pretty flexible API. We've put together some guidelines to help you get started that encourage good practices when thinking about translating plugins: @@ -83,6 +85,16 @@ Think about the semantic placement of content rather than the text content itsel Translations should avoid using their own text content as key where possible, as this can lead to confusion if the translation changes. Instead prefer to use keys that describe the location or usage of the text. +#### Common Key names + +This list is intended to grow over time, but below are some examples of common key names and patterns that we encourage you to use where possible: + +- `${page}.title` +- `${page}.subtitle` +- `${page}.description` + +- `${page}.header.title` + #### Key reuse Reusing the same key in multiple places is discouraged. This helps prevent ambiguity, and instead keeps the usage of each key as clear as possible. Consider creating duplicate keys that are grouped under a semantic section instead. From 51754bca1971cf8d5a3d68baaa1b2d5fda618389 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 14 Dec 2023 15:59:25 +0100 Subject: [PATCH 16/16] make the loader into a proper interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- packages/frontend-app-api/api-report.md | 9 ++-- .../src/wiring/createApp.test.tsx | 54 ++++++++++++------- .../frontend-app-api/src/wiring/createApp.tsx | 28 ++++++---- 3 files changed, 59 insertions(+), 32 deletions(-) diff --git a/packages/frontend-app-api/api-report.md b/packages/frontend-app-api/api-report.md index 08db759511..e7dd584734 100644 --- a/packages/frontend-app-api/api-report.md +++ b/packages/frontend-app-api/api-report.md @@ -24,9 +24,12 @@ export function createApp(options?: { }; // @public -export type CreateAppFeatureLoader = (options: { - config: ConfigApi; -}) => Promise; +export interface CreateAppFeatureLoader { + getLoaderName(): string; + load(options: { config: ConfigApi }): Promise<{ + features: FrontendFeature[]; + }>; +} // @public export type CreateAppRouteBinder = < diff --git a/packages/frontend-app-api/src/wiring/createApp.test.tsx b/packages/frontend-app-api/src/wiring/createApp.test.tsx index 244ce625fb..dc39ab3ae5 100644 --- a/packages/frontend-app-api/src/wiring/createApp.test.tsx +++ b/packages/frontend-app-api/src/wiring/createApp.test.tsx @@ -25,7 +25,7 @@ import { createThemeExtension, } from '@backstage/frontend-plugin-api'; import { screen, waitFor } from '@testing-library/react'; -import { createApp } from './createApp'; +import { CreateAppFeatureLoader, createApp } from './createApp'; import { MockConfigApi, renderWithEffects } from '@backstage/test-utils'; import React from 'react'; import { featureFlagsApiRef, useApi } from '@backstage/core-plugin-api'; @@ -100,23 +100,32 @@ describe('createApp', () => { }); it('should support feature loaders', async () => { + const loader: CreateAppFeatureLoader = { + getLoaderName() { + return 'test-loader'; + }, + async load({ config }) { + return { + features: [ + createPlugin({ + id: 'test', + extensions: [ + createPageExtension({ + defaultPath: '/', + loader: async () =>
{config.getString('key')}
, + }), + ], + }), + ], + }; + }, + }; + const app = createApp({ configLoader: async () => ({ config: new MockConfigApi({ key: 'config-value' }), }), - features: [ - async ({ config }) => [ - createPlugin({ - id: 'test', - extensions: [ - createPageExtension({ - defaultPath: '/', - loader: async () =>
{config.getString('key')}
, - }), - ], - }), - ], - ], + features: [loader], }); await renderWithEffects(app.createRoot()); @@ -127,21 +136,26 @@ describe('createApp', () => { }); it('should propagate errors thrown by feature loaders', async () => { + const loader: CreateAppFeatureLoader = { + getLoaderName() { + return 'test-loader'; + }, + async load() { + throw new TypeError('boom'); + }, + }; + const app = createApp({ configLoader: async () => ({ config: new MockConfigApi({}), }), - features: [ - async () => { - throw new TypeError('boom'); - }, - ], + features: [loader], }); await expect( renderWithEffects(app.createRoot()), ).rejects.toThrowErrorMatchingInlineSnapshot( - `"Failed to read frontend features from loader, TypeError: boom"`, + `"Failed to read frontend features from loader 'test-loader', TypeError: boom"`, ); }); diff --git a/packages/frontend-app-api/src/wiring/createApp.tsx b/packages/frontend-app-api/src/wiring/createApp.tsx index f3b32db399..572e88e647 100644 --- a/packages/frontend-app-api/src/wiring/createApp.tsx +++ b/packages/frontend-app-api/src/wiring/createApp.tsx @@ -244,9 +244,19 @@ function deduplicateFeatures( * * @public */ -export type CreateAppFeatureLoader = (options: { - config: ConfigApi; -}) => Promise; +export interface CreateAppFeatureLoader { + /** + * Returns name of this loader. suitable for showing to users. + */ + getLoaderName(): string; + + /** + * Loads a number of features dynamically. + */ + load(options: { config: ConfigApi }): Promise<{ + features: FrontendFeature[]; + }>; +} /** @public */ export function createApp(options?: { @@ -266,20 +276,20 @@ export function createApp(options?: { const discoveredFeatures = getAvailableFeatures(config); const providedFeatures: FrontendFeature[] = []; - for (const feature of options?.features ?? []) { - if (typeof feature === 'function') { + for (const entry of options?.features ?? []) { + if ('load' in entry) { try { - const loadedFeatures = await feature({ config }); - providedFeatures.push(...loadedFeatures); + const result = await entry.load({ config }); + providedFeatures.push(...result.features); } catch (e) { throw new Error( - `Failed to read frontend features from loader, ${stringifyError( + `Failed to read frontend features from loader '${entry.getLoaderName()}', ${stringifyError( e, )}`, ); } } else { - providedFeatures.push(feature); + providedFeatures.push(entry); } }