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/docs/tooling/local-dev/linking-local-packages.md b/docs/tooling/local-dev/linking-local-packages.md index 40b722e302..eb2a2cba37 100644 --- a/docs/tooling/local-dev/linking-local-packages.md +++ b/docs/tooling/local-dev/linking-local-packages.md @@ -4,34 +4,68 @@ title: Linking in Local Packages description: How to link in other local packages into your Backstage monorepo --- -It can often be useful to try out changes to the packages in the main Backstage -repo within your own app. For example if you want to make modifications to -`@backstage/core-plugin-api` and try them out in your app. +## Why? -To link in external packages, add them to your `package.json` and `lerna.json` -workspace paths. These can be either relative or absolute paths with or without -globs. For example: +If you are looking to make changes within the core Backstage repository and test +those changes within your Backstage application, you will need to link the two +together: -```json +```text +~/backstage // cloned from Github +~/my-backstage-application // generated using npx +``` + +For example, you might want to make modifications to `@backstage/core-plugin-api` and try them out in your company's +instance of Backstage. + +## Linking in Backstage NPM Packages + +To link in external packages, add them to your root `package.json` and `lerna.json` +`"workspace"` paths. These can be either relative or absolute paths with or without +globs. + +For example: + +```json title="/lerna.json" +... "packages": [ "packages/*", "plugins/*", "../backstage/packages/core-plugin-api", // New path added to work on @backstage/core-plugin-api ], +... ``` -Then reinstall packages to make yarn set up symlinks: +```json title="/package.json" +... +"workspaces": { + "packages": [ + "packages/*", + "plugins/*", + "../backstage/packages/core-plugin-api", // New path added to work on @backstage/core-plugin-api + ], +} +... +``` + +Now reinstall all packages from the root to make yarn set up symlinks from your application to the core Backstage clone: ```bash yarn install ``` +## Making Backstage Changes + With this in place you can now modify the `@backstage/core-plugin-api` package within the main repo, and have those changes be reflected and tested in your app. Simply run your app using `yarn dev` (or `yarn start` for just frontend) as normal. -Note that for backend packages you need to make sure that linked packages are +## Common Problems + +### Backend Issues + +For backend packages you need to make sure that linked packages are not dependencies of any non-linked package. If you for example want to work on `@backstage/backend-common`, you need to also link in other backend plugins and packages that depend on `@backstage/backend-common`, or temporarily disable @@ -39,6 +73,8 @@ those plugins in your backend. This is because the transformation of backend module tree stops whenever a non-local package is encountered, and from that point node will `require` packages directly for that entire module subtree. +### Typescript Issues + Type checking can also have issues when linking in external packages, since the linked in packages will use the types in the external project and dependency version mismatches between the two projects may cause errors. To fix any of @@ -54,3 +90,17 @@ invalidate correctly for the linked in packages, causing type checking to not reflect changes made to types. You can work around this by either setting `compilerOptions.incremental = false` in `tsconfig.json`, or by deleting the types cache folder `dist-types` before running `yarn tsc`. + +### Version Issues + +While `yarn install` might not error, it does not mean that the linking worked properly. +You will know that linking worked properly when: + +1. Your Backstage application root `/node_modules/@backstage/[some package]` is a symlink +2. Your Backstage application `/packages/app/node_modules` and `/packages/backend/node_modules` does + not contain the package you are attempting to link! + +If you see Yarn continuing to download the package you are trying to link from NPM, you might need to be +explicit in your `package.json` version so that it exactly matches what you have in the cloned Backstage +repository on your machine. For example, if you have cloned `/plugins/catalog` with version +`"version": "1.19.1-next.1"` you will need to be explicit in your application to point to `"1.19.1-next.1"`. 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 f3d764a7eb..332c7eada1 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 bf636fd19b..ed9affbfe7 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 a67630c6a5..0220fe901a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7820,6 +7820,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 @@ -7832,6 +7834,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" @@ -7846,6 +7856,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:^"