From e6ec17948dce032a9d556eb877024d46d4a3c805 Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Thu, 30 May 2024 13:17:59 +0300 Subject: [PATCH] feat: use signals to update user settings over sessions closes #24981 Signed-off-by: Heikki Hellgren --- .changeset/rude-snakes-clap.md | 7 ++++ plugins/user-settings-backend/README.md | 21 +++++++++-- plugins/user-settings-backend/api-report.md | 3 ++ plugins/user-settings-backend/package.json | 2 + plugins/user-settings-backend/src/alpha.ts | 8 ++-- .../src/service/router.test.ts | 15 ++++++++ .../src/service/router.ts | 20 ++++++++++ plugins/user-settings-common/.eslintrc.js | 1 + plugins/user-settings-common/README.md | 5 +++ plugins/user-settings-common/api-report.md | 13 +++++++ .../user-settings-common/catalog-info.yaml | 10 +++++ plugins/user-settings-common/package.json | 37 +++++++++++++++++++ plugins/user-settings-common/src/index.ts | 17 +++++++++ .../user-settings-common/src/setupTests.ts | 16 ++++++++ plugins/user-settings-common/src/types.ts | 21 +++++++++++ plugins/user-settings/api-report.md | 2 + plugins/user-settings/package.json | 2 + .../apis/StorageApi/UserSettingsStorage.ts | 33 ++++++++++++++--- yarn.lock | 12 ++++++ 19 files changed, 234 insertions(+), 11 deletions(-) create mode 100644 .changeset/rude-snakes-clap.md create mode 100644 plugins/user-settings-common/.eslintrc.js create mode 100644 plugins/user-settings-common/README.md create mode 100644 plugins/user-settings-common/api-report.md create mode 100644 plugins/user-settings-common/catalog-info.yaml create mode 100644 plugins/user-settings-common/package.json create mode 100644 plugins/user-settings-common/src/index.ts create mode 100644 plugins/user-settings-common/src/setupTests.ts create mode 100644 plugins/user-settings-common/src/types.ts diff --git a/.changeset/rude-snakes-clap.md b/.changeset/rude-snakes-clap.md new file mode 100644 index 0000000000..6292730c4c --- /dev/null +++ b/.changeset/rude-snakes-clap.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-user-settings-backend': patch +'@backstage/plugin-user-settings-common': patch +'@backstage/plugin-user-settings': patch +--- + +Use signals to update user settings across sessions diff --git a/plugins/user-settings-backend/README.md b/plugins/user-settings-backend/README.md index bbd69cb18e..a1fb504415 100644 --- a/plugins/user-settings-backend/README.md +++ b/plugins/user-settings-backend/README.md @@ -6,13 +6,26 @@ authorization token. ## Setup backend -1. Install the backend plugin: +Install the backend plugin ```bash # From your Backstage root directory yarn --cwd packages/backend add @backstage/plugin-user-settings-backend ``` +### New backend + +Add the plugin to your backend in `packages/backend/src/index.ts`: + +```ts +backend.add(import('@backstage/plugin-user-settings-backend/alpha')); +``` + +To get real-time updates of the user settings across different user sessions, you must also install +the `@backstage/plugin-signals-backend` plugin. + +### Old backend + 1. Configure the routes by adding a new `userSettings.ts` file in `packages/backend/src/plugins/`: @@ -29,7 +42,7 @@ export default async function createPlugin(env: PluginEnvironment) { } ``` -3. Add the new routes to your backend by modifying `packages/backend/src/index.ts`: +2. Add the new routes to your backend by modifying `packages/backend/src/index.ts`: ```diff // packages/backend/src/index.ts @@ -58,6 +71,7 @@ To make use of the user settings backend, replace the `WebStorage` with the + storageApiRef, } from '@backstage/core-plugin-api'; +import { UserSettingsStorage } from '@backstage/plugin-user-settings'; ++import { signalApiRef } from '@backstage/plugin-signals-react'; export const apis: AnyApiFactory[] = [ + createApiFactory({ @@ -66,7 +80,8 @@ To make use of the user settings backend, replace the `WebStorage` with the + discoveryApi: discoveryApiRef, + errorApi: errorApiRef, + fetchApi: fetchApiRef, -+ identityApi: identityApiRef ++ identityApi: identityApiRef, ++ signalApi: signalApiRef, // Optional + }, + factory: deps => UserSettingsStorage.create(deps), + }), diff --git a/plugins/user-settings-backend/api-report.md b/plugins/user-settings-backend/api-report.md index 1bc37bbec7..f8bf571fa7 100644 --- a/plugins/user-settings-backend/api-report.md +++ b/plugins/user-settings-backend/api-report.md @@ -6,6 +6,7 @@ import express from 'express'; import { IdentityApi } from '@backstage/plugin-auth-node'; import { PluginDatabaseManager } from '@backstage/backend-common'; +import { SignalsService } from '@backstage/plugin-signals-node'; // @public export function createRouter(options: RouterOptions): Promise; @@ -16,6 +17,8 @@ export interface RouterOptions { database: PluginDatabaseManager; // (undocumented) identity: IdentityApi; + // (undocumented) + signals?: SignalsService; } // (No @packageDocumentation comment for this package) diff --git a/plugins/user-settings-backend/package.json b/plugins/user-settings-backend/package.json index 38d6d0e04d..1d20d5fa23 100644 --- a/plugins/user-settings-backend/package.json +++ b/plugins/user-settings-backend/package.json @@ -51,6 +51,8 @@ "@backstage/config": "workspace:^", "@backstage/errors": "workspace:^", "@backstage/plugin-auth-node": "workspace:^", + "@backstage/plugin-signals-node": "workspace:^", + "@backstage/plugin-user-settings-common": "workspace:^", "@backstage/types": "workspace:^", "@types/express": "^4.17.6", "express": "^4.17.1", diff --git a/plugins/user-settings-backend/src/alpha.ts b/plugins/user-settings-backend/src/alpha.ts index a070396ca0..86180c8699 100644 --- a/plugins/user-settings-backend/src/alpha.ts +++ b/plugins/user-settings-backend/src/alpha.ts @@ -15,10 +15,11 @@ */ import { - createBackendPlugin, coreServices, + createBackendPlugin, } from '@backstage/backend-plugin-api'; import { createRouter } from './service/router'; +import { signalsServiceRef } from '@backstage/plugin-signals-node'; /** * The user settings backend plugin. @@ -33,9 +34,10 @@ export default createBackendPlugin({ database: coreServices.database, identity: coreServices.identity, httpRouter: coreServices.httpRouter, + signals: signalsServiceRef, }, - async init({ database, identity, httpRouter }) { - httpRouter.use(await createRouter({ database, identity })); + async init({ database, identity, httpRouter, signals }) { + httpRouter.use(await createRouter({ database, identity, signals })); }, }); }, diff --git a/plugins/user-settings-backend/src/service/router.test.ts b/plugins/user-settings-backend/src/service/router.test.ts index 64cdfcc465..27375c6333 100644 --- a/plugins/user-settings-backend/src/service/router.test.ts +++ b/plugins/user-settings-backend/src/service/router.test.ts @@ -22,6 +22,7 @@ import express from 'express'; import request from 'supertest'; import { UserSettingsStore } from '../database/UserSettingsStore'; import { createRouterInternal } from './router'; +import { SignalsService } from '@backstage/plugin-signals-node'; describe('createRouter', () => { const userSettingsStore: jest.Mocked = { @@ -36,6 +37,9 @@ describe('createRouter', () => { const identityApi: jest.Mocked> = { getIdentity: getIdentityMock, }; + const signalService: jest.Mocked = { + publish: jest.fn(), + }; let app: express.Express; @@ -43,6 +47,7 @@ describe('createRouter', () => { const router = await createRouterInternal({ userSettingsStore, identity: identityApi as IdentityApi, + signals: signalService as SignalsService, }); app = express().use(router); @@ -118,6 +123,11 @@ describe('createRouter', () => { bucket: 'my-bucket', key: 'my-key', }); + expect(signalService.publish).toHaveBeenCalledWith({ + recipients: { type: 'user', entityRef: 'user-1' }, + channel: `user-settings`, + message: { type: 'key-deleted', key: 'my-key' }, + }); }); it('returns an error if the Authorization header is missing', async () => { @@ -167,6 +177,11 @@ describe('createRouter', () => { bucket: 'my-bucket', key: 'my-key', }); + expect(signalService.publish).toHaveBeenCalledWith({ + recipients: { type: 'user', entityRef: 'user-1' }, + channel: `user-settings`, + message: { type: 'key-changed', key: 'my-key' }, + }); }); it('returns an error if the value is not given', async () => { diff --git a/plugins/user-settings-backend/src/service/router.ts b/plugins/user-settings-backend/src/service/router.ts index ebbb6716a3..b38e536354 100644 --- a/plugins/user-settings-backend/src/service/router.ts +++ b/plugins/user-settings-backend/src/service/router.ts @@ -21,6 +21,8 @@ import express, { Request } from 'express'; import Router from 'express-promise-router'; import { DatabaseUserSettingsStore } from '../database/DatabaseUserSettingsStore'; import { UserSettingsStore } from '../database/UserSettingsStore'; +import { SignalsService } from '@backstage/plugin-signals-node'; +import { UserSettingsSignal } from '@backstage/plugin-user-settings-common'; /** * @public @@ -28,6 +30,7 @@ import { UserSettingsStore } from '../database/UserSettingsStore'; export interface RouterOptions { database: PluginDatabaseManager; identity: IdentityApi; + signals?: SignalsService; } /** @@ -45,12 +48,14 @@ export async function createRouter( return await createRouterInternal({ userSettingsStore, identity: options.identity, + signals: options.signals, }); } export async function createRouterInternal(options: { identity: IdentityApi; userSettingsStore: UserSettingsStore; + signals?: SignalsService; }): Promise { const router = Router(); router.use(express.json()); @@ -104,6 +109,14 @@ export async function createRouterInternal(options: { key, }); + if (options.signals) { + await options.signals.publish({ + recipients: { type: 'user', entityRef: userEntityRef }, + channel: `user-settings`, + message: { type: 'key-changed', key }, + }); + } + res.json(setting); }); @@ -113,6 +126,13 @@ export async function createRouterInternal(options: { const { bucket, key } = req.params; await options.userSettingsStore.delete({ userEntityRef, bucket, key }); + if (options.signals) { + await options.signals.publish({ + recipients: { type: 'user', entityRef: userEntityRef }, + channel: 'user-settings', + message: { type: 'key-deleted', key }, + }); + } res.status(204).end(); }); diff --git a/plugins/user-settings-common/.eslintrc.js b/plugins/user-settings-common/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/user-settings-common/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/user-settings-common/README.md b/plugins/user-settings-common/README.md new file mode 100644 index 0000000000..0b8039a5cd --- /dev/null +++ b/plugins/user-settings-common/README.md @@ -0,0 +1,5 @@ +# @backstage/plugin-user-settings-common + +Welcome to the common package for the user-settings plugin! + +_This plugin was created through the Backstage CLI_ diff --git a/plugins/user-settings-common/api-report.md b/plugins/user-settings-common/api-report.md new file mode 100644 index 0000000000..b69f4a4190 --- /dev/null +++ b/plugins/user-settings-common/api-report.md @@ -0,0 +1,13 @@ +## API Report File for "@backstage/plugin-user-settings-common" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +// @public (undocumented) +export type UserSettingsSignal = { + type: 'key-changed' | 'key-deleted'; + key: string; +}; + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/user-settings-common/catalog-info.yaml b/plugins/user-settings-common/catalog-info.yaml new file mode 100644 index 0000000000..ebbd0eec1e --- /dev/null +++ b/plugins/user-settings-common/catalog-info.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-plugin-user-settings-common + title: '@backstage/plugin-user-settings-common' + description: The Backstage common plugin to manage user settings +spec: + lifecycle: experimental + type: backstage-common-library + owner: maintainers diff --git a/plugins/user-settings-common/package.json b/plugins/user-settings-common/package.json new file mode 100644 index 0000000000..1575712a05 --- /dev/null +++ b/plugins/user-settings-common/package.json @@ -0,0 +1,37 @@ +{ + "name": "@backstage/plugin-user-settings-common", + "version": "0.0.0", + "description": "Common functionalities for the user-settings plugin", + "backstage": { + "role": "common-library" + }, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "module": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/user-settings-common" + }, + "license": "Apache-2.0", + "sideEffects": false, + "main": "src/index.ts", + "types": "src/index.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "backstage-cli package build", + "clean": "backstage-cli package clean", + "lint": "backstage-cli package lint", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "test": "backstage-cli package test" + }, + "devDependencies": { + "@backstage/cli": "workspace:^" + } +} diff --git a/plugins/user-settings-common/src/index.ts b/plugins/user-settings-common/src/index.ts new file mode 100644 index 0000000000..5d542de408 --- /dev/null +++ b/plugins/user-settings-common/src/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2024 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. + */ + +export * from './types'; diff --git a/plugins/user-settings-common/src/setupTests.ts b/plugins/user-settings-common/src/setupTests.ts new file mode 100644 index 0000000000..c7ce5c0988 --- /dev/null +++ b/plugins/user-settings-common/src/setupTests.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2024 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. + */ +export {}; diff --git a/plugins/user-settings-common/src/types.ts b/plugins/user-settings-common/src/types.ts new file mode 100644 index 0000000000..6a49a72c93 --- /dev/null +++ b/plugins/user-settings-common/src/types.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2024 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. + */ + +/** @public */ +export type UserSettingsSignal = { + type: 'key-changed' | 'key-deleted'; + key: string; +}; diff --git a/plugins/user-settings/api-report.md b/plugins/user-settings/api-report.md index d485320fe8..cee35724bb 100644 --- a/plugins/user-settings/api-report.md +++ b/plugins/user-settings/api-report.md @@ -22,6 +22,7 @@ import { PropsWithChildren } from 'react'; import { default as React_2 } from 'react'; import { RouteRef } from '@backstage/core-plugin-api'; import { SessionApi } from '@backstage/core-plugin-api'; +import { SignalApi } from '@backstage/plugin-signals-react'; import { StorageApi } from '@backstage/core-plugin-api'; import { StorageValueSnapshot } from '@backstage/core-plugin-api'; import { TabProps } from '@material-ui/core/Tab'; @@ -133,6 +134,7 @@ export class UserSettingsStorage implements StorageApi { discoveryApi: DiscoveryApi; errorApi: ErrorApi; identityApi: IdentityApi; + signalApi?: SignalApi; namespace?: string; }): UserSettingsStorage; // (undocumented) diff --git a/plugins/user-settings/package.json b/plugins/user-settings/package.json index 03617c88b1..3b44bf15b6 100644 --- a/plugins/user-settings/package.json +++ b/plugins/user-settings/package.json @@ -56,6 +56,8 @@ "@backstage/errors": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", "@backstage/plugin-catalog-react": "workspace:^", + "@backstage/plugin-signals-react": "workspace:^", + "@backstage/plugin-user-settings-common": "workspace:^", "@backstage/theme": "workspace:^", "@backstage/types": "workspace:^", "@material-ui/core": "^4.12.2", diff --git a/plugins/user-settings/src/apis/StorageApi/UserSettingsStorage.ts b/plugins/user-settings/src/apis/StorageApi/UserSettingsStorage.ts index ef4d3ead5d..6b109f5ecd 100644 --- a/plugins/user-settings/src/apis/StorageApi/UserSettingsStorage.ts +++ b/plugins/user-settings/src/apis/StorageApi/UserSettingsStorage.ts @@ -25,7 +25,9 @@ import { } from '@backstage/core-plugin-api'; import { ResponseError } from '@backstage/errors'; import { JsonValue, Observable } from '@backstage/types'; +import { SignalApi, SignalSubscriber } from '@backstage/plugin-signals-react'; import ObservableImpl from 'zen-observable'; +import { UserSettingsSignal } from '@backstage/plugin-user-settings-common'; const JSON_HEADERS = { 'Content-Type': 'application/json; charset=utf-8', @@ -57,6 +59,7 @@ export class UserSettingsStorage implements StorageApi { private readonly errorApi: ErrorApi, private readonly identityApi: IdentityApi, private readonly fallback: WebStorage, + private readonly signalApi?: SignalApi, ) {} static create(options: { @@ -64,6 +67,7 @@ export class UserSettingsStorage implements StorageApi { discoveryApi: DiscoveryApi; errorApi: ErrorApi; identityApi: IdentityApi; + signalApi?: SignalApi; namespace?: string; }): UserSettingsStorage { return new UserSettingsStorage( @@ -76,6 +80,7 @@ export class UserSettingsStorage implements StorageApi { namespace: options.namespace, errorApi: options.errorApi, }), + options.signalApi, ); } @@ -145,15 +150,33 @@ export class UserSettingsStorage implements StorageApi { this.observables.set( key, new ObservableImpl>(subscriber => { + let signalSubscription: SignalSubscriber | undefined; this.subscribers.add(subscriber); - // TODO(freben): Introduce server polling or similar, to ensure that different devices update when values change - Promise.resolve() - .then(() => this.get(key)) - .then(snapshot => subscriber.next(snapshot)) - .catch(error => this.errorApi.post(error)); + const updateSnapshot = () => { + Promise.resolve() + .then(() => this.get(key)) + .then(snapshot => subscriber.next(snapshot)) + .catch(error => this.errorApi.post(error)); + }; + + if (this.signalApi) { + signalSubscription = this.signalApi.subscribe( + `user-settings`, + (msg: UserSettingsSignal) => { + if (msg.key === key) { + updateSnapshot(); + } + }, + ); + } + + updateSnapshot(); return () => { + if (signalSubscription) { + signalSubscription.unsubscribe(); + } this.subscribers.delete(subscriber); }; }).filter(({ key: messageKey }) => messageKey === key), diff --git a/yarn.lock b/yarn.lock index 2ad2995d52..8c94dc8907 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7590,6 +7590,8 @@ __metadata: "@backstage/config": "workspace:^" "@backstage/errors": "workspace:^" "@backstage/plugin-auth-node": "workspace:^" + "@backstage/plugin-signals-node": "workspace:^" + "@backstage/plugin-user-settings-common": "workspace:^" "@backstage/types": "workspace:^" "@types/express": ^4.17.6 "@types/supertest": ^2.0.8 @@ -7602,6 +7604,14 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-user-settings-common@workspace:^, @backstage/plugin-user-settings-common@workspace:plugins/user-settings-common": + version: 0.0.0-use.local + resolution: "@backstage/plugin-user-settings-common@workspace:plugins/user-settings-common" + dependencies: + "@backstage/cli": "workspace:^" + languageName: unknown + linkType: soft + "@backstage/plugin-user-settings@workspace:^, @backstage/plugin-user-settings@workspace:plugins/user-settings": version: 0.0.0-use.local resolution: "@backstage/plugin-user-settings@workspace:plugins/user-settings" @@ -7616,6 +7626,8 @@ __metadata: "@backstage/frontend-plugin-api": "workspace:^" "@backstage/plugin-catalog": "workspace:^" "@backstage/plugin-catalog-react": "workspace:^" + "@backstage/plugin-signals-react": "workspace:^" + "@backstage/plugin-user-settings-common": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/theme": "workspace:^" "@backstage/types": "workspace:^"