From e2e3dd08a5444cf7a4aac96018274a02f2b86199 Mon Sep 17 00:00:00 2001 From: Dustin Brewer Date: Thu, 9 Mar 2023 22:47:17 +0000 Subject: [PATCH 01/12] Add FireHydrant annotation option Signed-off-by: Dustin Brewer --- .changeset/tidy-planes-unite.md | 5 ++++ plugins/firehydrant/README.md | 8 ++++++ plugins/firehydrant/package.json | 1 + plugins/firehydrant/src/api/index.ts | 5 +++- .../ServiceDetailsCard/ServiceDetailsCard.tsx | 7 ++--- plugins/firehydrant/src/components/hooks.ts | 27 +++++++++++++++++++ .../src/components/serviceDetails.ts | 13 +++++++-- plugins/firehydrant/src/index.ts | 6 ++++- plugins/firehydrant/src/plugin.ts | 3 +++ yarn.lock | 1 + 10 files changed, 69 insertions(+), 7 deletions(-) create mode 100644 .changeset/tidy-planes-unite.md create mode 100644 plugins/firehydrant/src/components/hooks.ts diff --git a/.changeset/tidy-planes-unite.md b/.changeset/tidy-planes-unite.md new file mode 100644 index 0000000000..158498d381 --- /dev/null +++ b/.changeset/tidy-planes-unite.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-firehydrant': minor +--- + +Allow firehydrant to use component annotation diff --git a/plugins/firehydrant/README.md b/plugins/firehydrant/README.md index d7169e08f0..4472536245 100644 --- a/plugins/firehydrant/README.md +++ b/plugins/firehydrant/README.md @@ -56,3 +56,11 @@ proxy: # Supply the token you generated from https://app.firehydrant.io/organizations/bots Authorization: Bearer fhb-e4911b22bcd788c4a4afeb0c111ffbfa ``` + +4. Optionally add an annotation to the yaml config file of a component + +```yaml +metadata: + annotations: + firehydrant.com/service-name: +``` diff --git a/plugins/firehydrant/package.json b/plugins/firehydrant/package.json index 293679c2ea..8084876f69 100644 --- a/plugins/firehydrant/package.json +++ b/plugins/firehydrant/package.json @@ -23,6 +23,7 @@ "clean": "backstage-cli package clean" }, "dependencies": { + "@backstage/catalog-model": "workspace:^", "@backstage/core-components": "workspace:^", "@backstage/core-plugin-api": "workspace:^", "@backstage/plugin-catalog-react": "workspace:^", diff --git a/plugins/firehydrant/src/api/index.ts b/plugins/firehydrant/src/api/index.ts index 7101b5dc62..45834d90e9 100644 --- a/plugins/firehydrant/src/api/index.ts +++ b/plugins/firehydrant/src/api/index.ts @@ -30,6 +30,7 @@ export interface FireHydrantAPI { getServiceDetails(options: { serviceName: string; + lookupByName: boolean; }): Promise; getServiceIncidents(options: { @@ -77,10 +78,12 @@ export class FireHydrantAPIClient implements FireHydrantAPI { async getServiceDetails(options: { serviceName: string; + lookupByName: boolean; }): Promise { + const queryOpt = options.lookupByName ? 'name' : 'query'; const proxyUrl = await this.getApiUrl(); const response = await fetch( - `${proxyUrl}/services?query=${options.serviceName}`, + `${proxyUrl}/services?${queryOpt}=${options.serviceName}`, ); if (!response.ok) { diff --git a/plugins/firehydrant/src/components/ServiceDetailsCard/ServiceDetailsCard.tsx b/plugins/firehydrant/src/components/ServiceDetailsCard/ServiceDetailsCard.tsx index bc2dd46fef..b6bfbcc13b 100644 --- a/plugins/firehydrant/src/components/ServiceDetailsCard/ServiceDetailsCard.tsx +++ b/plugins/firehydrant/src/components/ServiceDetailsCard/ServiceDetailsCard.tsx @@ -39,6 +39,7 @@ import { ResponseErrorPanel, } from '@backstage/core-components'; import { configApiRef, useApi } from '@backstage/core-plugin-api'; +import { isFireHydrantAvailable, getFireHydrantServiceName } from '../hooks'; const useStyles = makeStyles(theme => ({ button: { @@ -149,14 +150,14 @@ export const ServiceDetailsCard = () => { const startDate = DateTime.now().minus({ days: 30 }).toUTC(); const endDate = DateTime.now().toUTC(); + // The service name is provided by an annotation or a Backstage generated service name. // The Backstage service name in FireHydrant is a unique formatted string // that requires the entity's kind, name, and namespace. - const fireHydrantServiceName = `${entity?.kind}:${ - entity?.metadata?.namespace ?? 'default' - }/${entity?.metadata?.name}`; + const fireHydrantServiceName = getFireHydrantServiceName(entity); const { loading, value, error } = useServiceDetails({ serviceName: fireHydrantServiceName, + lookupByName: isFireHydrantAvailable(entity), }); const activeIncidents: string[] = value?.service?.active_incidents ?? []; diff --git a/plugins/firehydrant/src/components/hooks.ts b/plugins/firehydrant/src/components/hooks.ts new file mode 100644 index 0000000000..e8129e1b28 --- /dev/null +++ b/plugins/firehydrant/src/components/hooks.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2023 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 { Entity } from '@backstage/catalog-model'; + +export const FIREHYDRANT_SERVICE_NAME_ANNOTATION = + 'firehydrant.com/service-name'; +export const isFireHydrantAvailable = (entity: Entity) => + Boolean(entity.metadata.annotations?.[FIREHYDRANT_SERVICE_NAME_ANNOTATION]); +export const getFireHydrantServiceName = (entity: Entity) => + isFireHydrantAvailable(entity) + ? entity?.metadata.annotations?.[FIREHYDRANT_SERVICE_NAME_ANNOTATION] ?? '' + : `${entity?.kind}:${entity?.metadata?.namespace ?? 'default'}/${ + entity?.metadata?.name + }`; diff --git a/plugins/firehydrant/src/components/serviceDetails.ts b/plugins/firehydrant/src/components/serviceDetails.ts index 9c12677577..f0ea325419 100644 --- a/plugins/firehydrant/src/components/serviceDetails.ts +++ b/plugins/firehydrant/src/components/serviceDetails.ts @@ -17,13 +17,22 @@ import useAsyncRetry from 'react-use/lib/useAsyncRetry'; import { fireHydrantApiRef } from '../api'; import { errorApiRef, useApi } from '@backstage/core-plugin-api'; -export const useServiceDetails = ({ serviceName }: { serviceName: string }) => { +export const useServiceDetails = ({ + serviceName, + lookupByName, +}: { + serviceName: string; + lookupByName: boolean; +}) => { const api = useApi(fireHydrantApiRef); const errorApi = useApi(errorApiRef); const { loading, value, error, retry } = useAsyncRetry(async () => { try { - return await api.getServiceDetails({ serviceName: serviceName }); + return await api.getServiceDetails({ + serviceName: serviceName, + lookupByName: lookupByName, + }); } catch (e) { errorApi.post(e); return Promise.reject(e); diff --git a/plugins/firehydrant/src/index.ts b/plugins/firehydrant/src/index.ts index f1ef66fd3e..218b944244 100644 --- a/plugins/firehydrant/src/index.ts +++ b/plugins/firehydrant/src/index.ts @@ -20,4 +20,8 @@ * @packageDocumentation */ -export { firehydrantPlugin, FirehydrantCard } from './plugin'; +export { + firehydrantPlugin, + FirehydrantCard, + isFireHydrantAvailable, +} from './plugin'; diff --git a/plugins/firehydrant/src/plugin.ts b/plugins/firehydrant/src/plugin.ts index 6deefe4fce..14a8c53c66 100644 --- a/plugins/firehydrant/src/plugin.ts +++ b/plugins/firehydrant/src/plugin.ts @@ -50,3 +50,6 @@ export const FirehydrantCard = firehydrantPlugin.provide( }, }), ); + +/** @public */ +export { isFireHydrantAvailable } from './components/hooks'; diff --git a/yarn.lock b/yarn.lock index 4ad5e727d0..5d3cd95dbb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6249,6 +6249,7 @@ __metadata: version: 0.0.0-use.local resolution: "@backstage/plugin-firehydrant@workspace:plugins/firehydrant" dependencies: + "@backstage/catalog-model": "workspace:^" "@backstage/cli": "workspace:^" "@backstage/core-app-api": "workspace:^" "@backstage/core-components": "workspace:^" From cce21f4cc4a4468f9d63385ea944ac2a4f3c04a0 Mon Sep 17 00:00:00 2001 From: Dustin Brewer Date: Thu, 9 Mar 2023 23:06:14 +0000 Subject: [PATCH 02/12] Add a few tests Signed-off-by: Dustin Brewer --- .../firehydrant/src/components/hooks.test.ts | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 plugins/firehydrant/src/components/hooks.test.ts diff --git a/plugins/firehydrant/src/components/hooks.test.ts b/plugins/firehydrant/src/components/hooks.test.ts new file mode 100644 index 0000000000..4278911ebb --- /dev/null +++ b/plugins/firehydrant/src/components/hooks.test.ts @@ -0,0 +1,67 @@ +/* + * 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 { Entity } from '@backstage/catalog-model'; +import { isFireHydrantAvailable, getFireHydrantServiceName } from './hooks'; + +describe('firehydrant-hooks-isFireHydrantAvailable', () => { + it('should find an annotation', () => { + const e: Entity = { + metadata: { + annotations: { + 'firehydrant.com/service-name': 'test-fh-name', + }, + }, + }; + expect(isFireHydrantAvailable(e)).toEqual(true); + }); + + it('should not find an annotation', () => { + const e: Entity = { + metadata: {}, + }; + expect(isFireHydrantAvailable(e)).toEqual(false); + }); +}); + +describe('firehydrant-hooks-getFireHydrantServiceName', () => { + it('should return annotation service name', () => { + const expected = 'test-fh-name'; + const e: Entity = { + kind: 'Component', + metadata: { + namespace: 'default', + name: 'test-fh-name', + annotations: { + 'firehydrant.com/service-name': expected, + }, + }, + }; + expect(getFireHydrantServiceName(e)).toEqual(expected); + }); + + it('should return generated service name', () => { + const expected = 'Component:default/test-fh-name'; + const e: Entity = { + kind: 'Component', + metadata: { + namespace: 'default', + name: 'test-fh-name', + annotations: {}, + }, + }; + expect(getFireHydrantServiceName(e)).toEqual(expected); + }); +}); From 392e71fa7fbfea1bc652ca28fe72e135dc04c253 Mon Sep 17 00:00:00 2001 From: Dustin Brewer Date: Thu, 9 Mar 2023 23:22:54 +0000 Subject: [PATCH 03/12] Correcting Entity in tests Signed-off-by: Dustin Brewer --- plugins/firehydrant/src/components/hooks.test.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/plugins/firehydrant/src/components/hooks.test.ts b/plugins/firehydrant/src/components/hooks.test.ts index 4278911ebb..2fbf1e2d57 100644 --- a/plugins/firehydrant/src/components/hooks.test.ts +++ b/plugins/firehydrant/src/components/hooks.test.ts @@ -19,7 +19,11 @@ import { isFireHydrantAvailable, getFireHydrantServiceName } from './hooks'; describe('firehydrant-hooks-isFireHydrantAvailable', () => { it('should find an annotation', () => { const e: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', metadata: { + namespace: 'default', + name: 'test-fh-name', annotations: { 'firehydrant.com/service-name': 'test-fh-name', }, @@ -30,7 +34,12 @@ describe('firehydrant-hooks-isFireHydrantAvailable', () => { it('should not find an annotation', () => { const e: Entity = { - metadata: {}, + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + namespace: 'default', + name: 'test-fh-name', + }, }; expect(isFireHydrantAvailable(e)).toEqual(false); }); @@ -40,6 +49,7 @@ describe('firehydrant-hooks-getFireHydrantServiceName', () => { it('should return annotation service name', () => { const expected = 'test-fh-name'; const e: Entity = { + apiVersion: 'backstage.io/v1alpha1', kind: 'Component', metadata: { namespace: 'default', @@ -55,6 +65,7 @@ describe('firehydrant-hooks-getFireHydrantServiceName', () => { it('should return generated service name', () => { const expected = 'Component:default/test-fh-name'; const e: Entity = { + apiVersion: 'backstage.io/v1alpha1', kind: 'Component', metadata: { namespace: 'default', From ad4840b0f579fee2c724b0cbebaf94cc426c3f3b Mon Sep 17 00:00:00 2001 From: Dustin Brewer Date: Fri, 10 Mar 2023 00:08:59 +0000 Subject: [PATCH 04/12] Correcting the api-report Signed-off-by: Dustin Brewer --- plugins/firehydrant/api-report.md | 4 ++++ plugins/firehydrant/src/components/hooks.ts | 1 + 2 files changed, 5 insertions(+) diff --git a/plugins/firehydrant/api-report.md b/plugins/firehydrant/api-report.md index 2e3bcf6385..0ff5bbc62a 100644 --- a/plugins/firehydrant/api-report.md +++ b/plugins/firehydrant/api-report.md @@ -6,6 +6,7 @@ /// import { BackstagePlugin } from '@backstage/core-plugin-api'; +import { Entity } from '@backstage/catalog-model'; import { RouteRef } from '@backstage/core-plugin-api'; // @public (undocumented) @@ -19,4 +20,7 @@ export const firehydrantPlugin: BackstagePlugin< {}, {} >; + +// @public (undocumented) +export const isFireHydrantAvailable: (entity: Entity) => boolean; ``` diff --git a/plugins/firehydrant/src/components/hooks.ts b/plugins/firehydrant/src/components/hooks.ts index e8129e1b28..d9145f13f0 100644 --- a/plugins/firehydrant/src/components/hooks.ts +++ b/plugins/firehydrant/src/components/hooks.ts @@ -17,6 +17,7 @@ import { Entity } from '@backstage/catalog-model'; export const FIREHYDRANT_SERVICE_NAME_ANNOTATION = 'firehydrant.com/service-name'; +/** @public */ export const isFireHydrantAvailable = (entity: Entity) => Boolean(entity.metadata.annotations?.[FIREHYDRANT_SERVICE_NAME_ANNOTATION]); export const getFireHydrantServiceName = (entity: Entity) => From 078f46ea3269a61faaaab1d21290741c511d199e Mon Sep 17 00:00:00 2001 From: Dustin Brewer Date: Fri, 10 Mar 2023 15:45:48 +0000 Subject: [PATCH 05/12] Cleanup building FireHydrant service name Remove the reduntant check for the annotation, and utilize stringifyEntityRef as recommended Signed-off-by: Dustin Brewer --- .../ServiceDetailsCard/ServiceDetailsCard.test.tsx | 4 +++- plugins/firehydrant/src/components/hooks.test.ts | 2 +- plugins/firehydrant/src/components/hooks.ts | 9 +++------ 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/plugins/firehydrant/src/components/ServiceDetailsCard/ServiceDetailsCard.test.tsx b/plugins/firehydrant/src/components/ServiceDetailsCard/ServiceDetailsCard.test.tsx index 20dbb77e86..ce2b1f5391 100644 --- a/plugins/firehydrant/src/components/ServiceDetailsCard/ServiceDetailsCard.test.tsx +++ b/plugins/firehydrant/src/components/ServiceDetailsCard/ServiceDetailsCard.test.tsx @@ -30,7 +30,9 @@ const apis = TestApiRegistry.from([fireHydrantApiRef, mockFireHydrantApi]); jest.mock('@backstage/plugin-catalog-react', () => ({ useEntity: () => { - return { entity: { metadata: { name: 'service-example' } } }; + return { + entity: { kind: 'Component', metadata: { name: 'service-example' } }, + }; }, })); diff --git a/plugins/firehydrant/src/components/hooks.test.ts b/plugins/firehydrant/src/components/hooks.test.ts index 2fbf1e2d57..f35d6774ce 100644 --- a/plugins/firehydrant/src/components/hooks.test.ts +++ b/plugins/firehydrant/src/components/hooks.test.ts @@ -63,7 +63,7 @@ describe('firehydrant-hooks-getFireHydrantServiceName', () => { }); it('should return generated service name', () => { - const expected = 'Component:default/test-fh-name'; + const expected = 'component:default/test-fh-name'; const e: Entity = { apiVersion: 'backstage.io/v1alpha1', kind: 'Component', diff --git a/plugins/firehydrant/src/components/hooks.ts b/plugins/firehydrant/src/components/hooks.ts index d9145f13f0..c4055b1724 100644 --- a/plugins/firehydrant/src/components/hooks.ts +++ b/plugins/firehydrant/src/components/hooks.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Entity } from '@backstage/catalog-model'; +import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; export const FIREHYDRANT_SERVICE_NAME_ANNOTATION = 'firehydrant.com/service-name'; @@ -21,8 +21,5 @@ export const FIREHYDRANT_SERVICE_NAME_ANNOTATION = export const isFireHydrantAvailable = (entity: Entity) => Boolean(entity.metadata.annotations?.[FIREHYDRANT_SERVICE_NAME_ANNOTATION]); export const getFireHydrantServiceName = (entity: Entity) => - isFireHydrantAvailable(entity) - ? entity?.metadata.annotations?.[FIREHYDRANT_SERVICE_NAME_ANNOTATION] ?? '' - : `${entity?.kind}:${entity?.metadata?.namespace ?? 'default'}/${ - entity?.metadata?.name - }`; + entity?.metadata.annotations?.[FIREHYDRANT_SERVICE_NAME_ANNOTATION] ?? + stringifyEntityRef(entity); From c5afe933bbbaa6037e42b6516156fc135d11905b Mon Sep 17 00:00:00 2001 From: Dustin Brewer Date: Fri, 10 Mar 2023 16:00:32 +0000 Subject: [PATCH 06/12] Cleanup query string encoding on services API call Signed-off-by: Dustin Brewer --- plugins/firehydrant/src/api/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/firehydrant/src/api/index.ts b/plugins/firehydrant/src/api/index.ts index 45834d90e9..1cbf10ec9b 100644 --- a/plugins/firehydrant/src/api/index.ts +++ b/plugins/firehydrant/src/api/index.ts @@ -81,10 +81,10 @@ export class FireHydrantAPIClient implements FireHydrantAPI { lookupByName: boolean; }): Promise { const queryOpt = options.lookupByName ? 'name' : 'query'; + const query = new URLSearchParams(); + query.set(queryOpt, options.serviceName); const proxyUrl = await this.getApiUrl(); - const response = await fetch( - `${proxyUrl}/services?${queryOpt}=${options.serviceName}`, - ); + const response = await fetch(`${proxyUrl}/services?${query}`); if (!response.ok) { throw new Error( From e262738b8a0cd43ce4cfea116adc05aef8c9061c Mon Sep 17 00:00:00 2001 From: Adi Krhan Date: Mon, 13 Mar 2023 09:23:59 +0100 Subject: [PATCH 07/12] Handle token difference in Microsoft provider Signed-off-by: Adi Krhan --- .changeset/many-carpets-act.md | 5 +++++ plugins/auth-backend/src/providers/microsoft/provider.ts | 9 ++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 .changeset/many-carpets-act.md diff --git a/.changeset/many-carpets-act.md b/.changeset/many-carpets-act.md new file mode 100644 index 0000000000..5285eeebbd --- /dev/null +++ b/.changeset/many-carpets-act.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Handle difference in expiration time between Microsoft session and Backstage session which caused the Backstage token to be invalid during a time frame. diff --git a/plugins/auth-backend/src/providers/microsoft/provider.ts b/plugins/auth-backend/src/providers/microsoft/provider.ts index fd2a1529a7..9b14a3c91b 100644 --- a/plugins/auth-backend/src/providers/microsoft/provider.ts +++ b/plugins/auth-backend/src/providers/microsoft/provider.ts @@ -50,6 +50,8 @@ import { import { Logger } from 'winston'; import fetch from 'node-fetch'; +const BACKSTAGE_SESSION_EXPIRATION = 3600; + type PrivateInfo = { refreshToken: string; }; @@ -145,12 +147,17 @@ export class MicrosoftAuthProvider implements OAuthHandlers { const { profile } = await this.authHandler(result, this.resolverContext); + const expiresInSeconds = + result.params.expires_in === undefined + ? BACKSTAGE_SESSION_EXPIRATION + : Math.min(result.params.expires_in, BACKSTAGE_SESSION_EXPIRATION); + const response: OAuthResponse = { providerInfo: { idToken: result.params.id_token, accessToken: result.accessToken, scope: result.params.scope, - expiresInSeconds: result.params.expires_in, + expiresInSeconds, }, profile, }; From e6ac8e69b5b779444817d32f6782aa93740193b1 Mon Sep 17 00:00:00 2001 From: Alec Jacobs Date: Mon, 13 Mar 2023 09:27:50 -0700 Subject: [PATCH 08/12] fix(shortcuts): client handles state updates from storageApi Signed-off-by: Alec Jacobs --- .../src/api/LocalStoredShortcuts.test.ts | 3 +- .../shortcuts/src/api/LocalStoredShortcuts.ts | 54 +++++++++++++------ 2 files changed, 41 insertions(+), 16 deletions(-) diff --git a/plugins/shortcuts/src/api/LocalStoredShortcuts.test.ts b/plugins/shortcuts/src/api/LocalStoredShortcuts.test.ts index dbea91863c..86890fc75b 100644 --- a/plugins/shortcuts/src/api/LocalStoredShortcuts.test.ts +++ b/plugins/shortcuts/src/api/LocalStoredShortcuts.test.ts @@ -36,8 +36,9 @@ describe('LocalStoredShortcuts', () => { resolve(); }, }); - shortcutApi.add(shortcut); }); + observerNextHandler.mockClear(); // handler is called with current state to start + await shortcutApi.add(shortcut); expect(observerNextHandler).toHaveBeenCalledTimes(1); expect(observerNextHandler).toHaveBeenCalledWith( diff --git a/plugins/shortcuts/src/api/LocalStoredShortcuts.ts b/plugins/shortcuts/src/api/LocalStoredShortcuts.ts index 65aeb86e3f..44f7364f69 100644 --- a/plugins/shortcuts/src/api/LocalStoredShortcuts.ts +++ b/plugins/shortcuts/src/api/LocalStoredShortcuts.ts @@ -16,10 +16,10 @@ import { pageTheme } from '@backstage/theme'; import { v4 as uuid } from 'uuid'; -import { ShortcutApi } from './ShortcutApi'; -import { Shortcut } from '../types'; -import { StorageApi } from '@backstage/core-plugin-api'; -import Observable from 'zen-observable'; +import { ShortcutApi, type Shortcut } from '@backstage/plugin-shortcuts'; +import type { StorageApi } from '@backstage/core-plugin-api'; +import type { Observable } from '@backstage/types'; +import ObservableImpl from 'zen-observable'; /** * Implementation of the ShortcutApi that uses the StorageApi to store shortcuts. @@ -27,27 +27,41 @@ import Observable from 'zen-observable'; * @public */ export class LocalStoredShortcuts implements ShortcutApi { - private readonly shortcuts: Observable; + private shortcuts: Shortcut[]; + private readonly subscribers = new Set< + ZenObservable.SubscriptionObserver + >(); + + private readonly observable = new ObservableImpl(subscriber => { + // forward the the latest value + subscriber.next(this.shortcuts); + + this.subscribers.add(subscriber); + return () => { + this.subscribers.delete(subscriber); + }; + }); constructor(private readonly storageApi: StorageApi) { - this.shortcuts = Observable.from( - this.storageApi.observe$('items'), - ).map(snapshot => snapshot.value ?? []); + this.shortcuts = this.storageApi.snapshot('items').value ?? []; + this.storageApi.observe$('items').subscribe({ + next: next => { + this.shortcuts = next.value ?? []; + this.notifyChanges(); + }, + }); } - shortcut$() { - return this.shortcuts; + shortcut$(): Observable { + return this.observable; } get() { - return Array.from( - this.storageApi.snapshot('items').value ?? [], - ).sort((a, b) => (a.title >= b.title ? 1 : -1)); + return this.shortcuts; } async add(shortcut: Omit) { - const shortcuts = this.get(); - shortcuts.push({ ...shortcut, id: uuid() }); + const shortcuts = this.sort([...this.get(), { ...shortcut, id: uuid() }]); await this.storageApi.set('items', shortcuts); } @@ -78,4 +92,14 @@ export class LocalStoredShortcuts implements ShortcutApi { catalog: 'home', docs: 'documentation', }; + + private sort(shortcuts: Shortcut[]): Shortcut[] { + return shortcuts.slice().sort((a, b) => (a.title >= b.title ? 1 : -1)); + } + + private notifyChanges() { + for (const subscription of this.subscribers) { + subscription.next(this.shortcuts); + } + } } From 7c38e565d1fba9bb68d93a856d69bafa0c3b1cd7 Mon Sep 17 00:00:00 2001 From: Alec Jacobs Date: Mon, 13 Mar 2023 09:34:20 -0700 Subject: [PATCH 09/12] docs(changeset): add patch changeset Signed-off-by: Alec Jacobs --- .changeset/pink-apples-design.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/pink-apples-design.md diff --git a/.changeset/pink-apples-design.md b/.changeset/pink-apples-design.md new file mode 100644 index 0000000000..411786370a --- /dev/null +++ b/.changeset/pink-apples-design.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-shortcuts': patch +--- + +Fixed bug in LocalStoredShortcuts client where adding new Shortcut results in replacing entire shortcut list. + +Refactored LocalStoredShortcuts client to listen to `storageApi` updates to ensure that local state is always up to date. From c5a57d15e29a53a796fbfc27ca018d673b62427e Mon Sep 17 00:00:00 2001 From: Alec Jacobs Date: Mon, 13 Mar 2023 10:28:38 -0700 Subject: [PATCH 10/12] docs(shortcuts): regen api-report.md Signed-off-by: Alec Jacobs --- plugins/shortcuts/api-report.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/plugins/shortcuts/api-report.md b/plugins/shortcuts/api-report.md index 1aa1dea003..19c0035a55 100644 --- a/plugins/shortcuts/api-report.md +++ b/plugins/shortcuts/api-report.md @@ -9,24 +9,25 @@ import { ApiRef } from '@backstage/core-plugin-api'; import { BackstagePlugin } from '@backstage/core-plugin-api'; import { IconComponent } from '@backstage/core-plugin-api'; import { Observable } from '@backstage/types'; -import { default as Observable_2 } from 'zen-observable'; -import { StorageApi } from '@backstage/core-plugin-api'; +import { Shortcut as Shortcut_2 } from '@backstage/plugin-shortcuts'; +import { ShortcutApi as ShortcutApi_2 } from '@backstage/plugin-shortcuts'; +import type { StorageApi } from '@backstage/core-plugin-api'; // @public -export class LocalStoredShortcuts implements ShortcutApi { +export class LocalStoredShortcuts implements ShortcutApi_2 { constructor(storageApi: StorageApi); // (undocumented) - add(shortcut: Omit): Promise; + add(shortcut: Omit): Promise; // (undocumented) - get(): Shortcut[]; + get(): Shortcut_2[]; // (undocumented) getColor(url: string): string; // (undocumented) remove(id: string): Promise; // (undocumented) - shortcut$(): Observable_2; + shortcut$(): Observable; // (undocumented) - update(shortcut: Shortcut): Promise; + update(shortcut: Shortcut_2): Promise; } // @public (undocumented) From 2d20ea246518fca3c5de9a517faa82ec422f5c05 Mon Sep 17 00:00:00 2001 From: Alec Jacobs Date: Mon, 13 Mar 2023 10:47:06 -0700 Subject: [PATCH 11/12] build(shortcuts): move @types/zen-observable to devDependencies Signed-off-by: Alec Jacobs --- plugins/shortcuts/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/shortcuts/package.json b/plugins/shortcuts/package.json index eb48088934..22c0456342 100644 --- a/plugins/shortcuts/package.json +++ b/plugins/shortcuts/package.json @@ -30,7 +30,6 @@ "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", - "@types/zen-observable": "^0.8.2", "react-hook-form": "^7.12.2", "react-use": "^17.2.4", "uuid": "^8.3.2", @@ -49,6 +48,7 @@ "@testing-library/react": "^12.1.3", "@testing-library/user-event": "^14.0.0", "@types/node": "^16.11.26", + "@types/zen-observable": "^0.8.2", "cross-fetch": "^3.1.5", "msw": "^1.0.0" }, From 3fa416968208bbc7a462174a3a926cffc5a611e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 14 Mar 2023 13:10:07 +0100 Subject: [PATCH 12/12] Make the "Copied!" message disappear automatically after some time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/brave-planets-grab.md | 5 +++++ .../components/EntityContextMenu/EntityContextMenu.tsx | 10 +++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) create mode 100644 .changeset/brave-planets-grab.md diff --git a/.changeset/brave-planets-grab.md b/.changeset/brave-planets-grab.md new file mode 100644 index 0000000000..8b99978695 --- /dev/null +++ b/.changeset/brave-planets-grab.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Make the "Copied!" message disappear automatically after some time diff --git a/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx index 675f74fa8d..1961fe6cac 100644 --- a/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx +++ b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx @@ -90,9 +90,13 @@ export function EntityContextMenu(props: EntityContextMenuProps) { const alertApi = useApi(alertApiRef); const copyToClipboard = useCallback(() => { - window.navigator.clipboard - .writeText(window.location.toString()) - .then(() => alertApi.post({ message: 'Copied!', severity: 'info' })); + window.navigator.clipboard.writeText(window.location.toString()).then(() => + alertApi.post({ + message: 'Copied!', + severity: 'info', + display: 'transient', + }), + ); }, [alertApi]); const extraItems = UNSTABLE_extraContextMenuItems && [