move the settings storage to the user settings frontend

Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2022-09-19 15:21:10 +02:00
parent 294805ed59
commit 8448b53dd6
16 changed files with 99 additions and 42 deletions
+1 -1
View File
@@ -57,7 +57,7 @@ To make use of the user settings backend, replace the `WebStorage` with the
+ identityApi,
+ storageApiRef,
} from '@backstage/core-plugin-api';
+import { UserSettingsStorage } from '@backstage/core-app-api';
+import { UserSettingsStorage } from '@backstage/plugin-user-settings';
export const apis: AnyApiFactory[] = [
+ createApiFactory({
+7 -5
View File
@@ -2,15 +2,17 @@
Welcome to the user-settings plugin!
_This plugin was created through the Backstage CLI_
## About the plugin
This plugin provides two components, `<UserSettings />` is intended to be used within the [`<Sidebar>`](https://backstage.io/storybook/?path=/story/sidebar--sample-sidebar) and displays the signed-in users profile picture and name.
This plugin provides two components, `<UserSettings />` is intended to be used within the [`<Sidebar>`](https://backstage.io/storybook/?path=/story/sidebar--sample-sidebar) and displays the signed-in users profile picture and name. The second component is a settings page where the user can control different settings across the App.
The second component is a settings page where the user can control different settings across the App.
It also provides a `UserSettingsStorage` implementation of the `StorageApi`, to
be used in the frontend as a persistent alternative to the builtin `WebStorage`.
Please see [the backend
README](https://github.com/backstage/backstage/tree/master/plugins/user-settings-backend)
for installation instructions.
## Usage
## Components Usage
Add the item to the Sidebar:
+32
View File
@@ -8,11 +8,19 @@
import { ApiRef } from '@backstage/core-plugin-api';
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { BackstageUserIdentity } from '@backstage/core-plugin-api';
import { DiscoveryApi } from '@backstage/core-plugin-api';
import { ErrorApi } from '@backstage/core-plugin-api';
import { FetchApi } from '@backstage/core-plugin-api';
import { IconComponent } from '@backstage/core-plugin-api';
import { IdentityApi } from '@backstage/core-plugin-api';
import { JsonValue } from '@backstage/types';
import { Observable } from '@backstage/types';
import { ProfileInfo } from '@backstage/core-plugin-api';
import { PropsWithChildren } from 'react';
import { RouteRef } from '@backstage/core-plugin-api';
import { SessionApi } from '@backstage/core-plugin-api';
import { StorageApi } from '@backstage/core-plugin-api';
import { StorageValueSnapshot } from '@backstage/core-plugin-api';
// @public (undocumented)
export const DefaultProviderSettings: (props: {
@@ -83,6 +91,30 @@ export const UserSettingsSignInAvatar: (props: {
size?: number;
}) => JSX.Element;
// @public
export class UserSettingsStorage implements StorageApi {
// (undocumented)
static create(options: {
fetchApi: FetchApi;
discoveryApi: DiscoveryApi;
errorApi: ErrorApi;
identityApi: IdentityApi;
namespace?: string;
}): UserSettingsStorage;
// (undocumented)
forBucket(name: string): StorageApi;
// (undocumented)
observe$<T extends JsonValue>(
key: string,
): Observable<StorageValueSnapshot<T>>;
// (undocumented)
remove(key: string): Promise<void>;
// (undocumented)
set<T extends JsonValue>(key: string, data: T): Promise<void>;
// (undocumented)
snapshot<T extends JsonValue>(key: string): StorageValueSnapshot<T>;
}
// @public
export const UserSettingsTab: (props: UserSettingsTabProps) => JSX.Element;
+5 -2
View File
@@ -32,14 +32,18 @@
"clean": "backstage-cli package clean"
},
"dependencies": {
"@backstage/core-app-api": "^1.1.0-next.3",
"@backstage/core-components": "^0.11.1-next.3",
"@backstage/core-plugin-api": "^1.0.6-next.3",
"@backstage/errors": "^1.1.1-next.0",
"@backstage/theme": "^0.2.16",
"@backstage/types": "^1.0.0",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.57",
"@types/react": "^16.13.1 || ^17.0.0",
"react-use": "^17.2.4"
"react-use": "^17.2.4",
"zen-observable": "^0.8.15"
},
"peerDependencies": {
"react": "^16.13.1 || ^17.0.0",
@@ -47,7 +51,6 @@
},
"devDependencies": {
"@backstage/cli": "^0.19.0-next.3",
"@backstage/core-app-api": "^1.1.0-next.3",
"@backstage/dev-utils": "^1.0.6-next.2",
"@backstage/test-utils": "^1.2.0-next.3",
"@testing-library/jest-dom": "^5.10.1",
@@ -0,0 +1,353 @@
/*
* 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 {
DiscoveryApi,
ErrorApi,
FetchApi,
IdentityApi,
StorageApi,
} from '@backstage/core-plugin-api';
import { MockFetchApi, setupRequestMockHandlers } from '@backstage/test-utils';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { UserSettingsStorage } from './UserSettingsStorage';
describe('Persistent Storage API', () => {
const server = setupServer();
setupRequestMockHandlers(server);
const mockBaseUrl = 'http://backstage:9191/api';
const mockErrorApi = { post: jest.fn(), error$: jest.fn() };
const mockDiscoveryApi = {
getBaseUrl: async () => mockBaseUrl,
};
const mockIdentityApi: Partial<IdentityApi> = {
getCredentials: async () => ({ token: 'a-token' }),
};
const createPersistentStorage = (
args?: Partial<{
fetchApi: FetchApi;
discoveryApi: DiscoveryApi;
errorApi: ErrorApi;
namespace?: string;
}>,
): StorageApi => {
return UserSettingsStorage.create({
errorApi: mockErrorApi,
fetchApi: new MockFetchApi(),
discoveryApi: mockDiscoveryApi,
identityApi: mockIdentityApi as IdentityApi,
...args,
});
};
afterEach(() => {
jest.resetAllMocks();
});
it('should return undefined for values which are unset', async () => {
const storage = createPersistentStorage();
server.use(
rest.get(
`${mockBaseUrl}/buckets/:bucket/keys/:key`,
async (_req, res, ctx) => {
return res(ctx.json({ value: 'a' }));
},
),
);
expect(storage.snapshot('myfakekey').value).toBeUndefined();
expect(storage.snapshot('myfakekey')).toEqual({
key: 'myfakekey',
presence: 'unknown',
value: undefined,
});
});
it('should allow setting of a simple data structure', async () => {
const storage = createPersistentStorage();
const dummyValue = 'a';
server.use(
rest.put(
`${mockBaseUrl}/buckets/:bucket/keys/:key`,
async (req, res, ctx) => {
const body = await req.json();
const data = { value: dummyValue };
expect(body).toEqual(data);
return res(ctx.json(data));
},
),
);
await storage.set('my-key', dummyValue);
});
it('should allow setting of a complex data structure', async () => {
const storage = createPersistentStorage();
const dummyValue = {
some: 'nice data',
with: { nested: 'values', nice: true },
};
server.use(
rest.put(
`${mockBaseUrl}/buckets/:bucket/keys/:key`,
async (req, res, ctx) => {
const body = await req.json();
const data = { value: dummyValue };
expect(body).toEqual(data);
return res(ctx.json(data));
},
),
);
await storage.set('my-key', dummyValue);
});
it('should subscribe to key changes when setting a new value', async () => {
const storage = createPersistentStorage();
const wrongKeyNextHandler = jest.fn();
const selectedKeyNextHandler = jest.fn();
const mockData = { hello: 'im a great new value' };
server.use(
rest.put(
`${mockBaseUrl}/buckets/:bucket/keys/:key`,
async (req, res, ctx) => {
const body = await req.json();
const data = { value: mockData };
expect(body).toEqual(data);
return res(ctx.json(data));
},
),
);
await new Promise<void>(resolve => {
storage.observe$<typeof mockData>('correctKey').subscribe({
next: snapshot => {
selectedKeyNextHandler(snapshot);
if (snapshot.presence === 'present') {
resolve();
}
},
});
storage.observe$('wrongKey').subscribe({ next: wrongKeyNextHandler });
storage.set('correctKey', mockData);
});
expect(wrongKeyNextHandler).toHaveBeenCalledTimes(0);
expect(selectedKeyNextHandler).toHaveBeenCalledTimes(1);
expect(selectedKeyNextHandler).toHaveBeenCalledWith({
key: 'correctKey',
presence: 'present',
value: mockData,
});
});
it('should subscribe to key changes when deleting a value', async () => {
const storage = createPersistentStorage();
const wrongKeyNextHandler = jest.fn();
const selectedKeyNextHandler = jest.fn();
server.use(
rest.delete(
`${mockBaseUrl}/buckets/:bucket/keys/:key`,
async (_req, res, ctx) => {
return res(ctx.status(204));
},
),
);
await new Promise<void>(resolve => {
storage.observe$('correctKey').subscribe({
next: snapshot => {
selectedKeyNextHandler(snapshot);
if (snapshot.presence === 'absent') {
resolve();
}
},
});
storage.observe$('wrongKey').subscribe({ next: wrongKeyNextHandler });
storage.remove('correctKey');
});
expect(wrongKeyNextHandler).toHaveBeenCalledTimes(0);
expect(selectedKeyNextHandler).toHaveBeenCalledTimes(1);
expect(selectedKeyNextHandler).toHaveBeenCalledWith({
key: 'correctKey',
presence: 'absent',
value: undefined,
});
});
it('should not clash with other namespaces when creating buckets', async () => {
const rootStorage = createPersistentStorage();
const selectedKeyNextHandler = jest.fn();
server.use(
rest.put(
`${mockBaseUrl}/buckets/:bucket/keys/:key`,
async (req, res, ctx) => {
const { bucket, key } = req.params;
const { value } = await req.json();
expect(bucket).toEqual('default.profile.something.deep');
expect(key).toEqual('test2');
return res(ctx.json({ value }));
},
),
rest.get(
`${mockBaseUrl}/buckets/:bucket/keys/:key`,
async (req, res, ctx) => {
const { bucket, key } = req.params;
expect(bucket).toEqual('default.profile/something');
expect(key).toEqual('deep/test2');
return res(ctx.status(404));
},
),
);
// when getting key test2 it will translate to default.profile.something.deep/test2
const firstStorage = rootStorage
.forBucket('profile')
.forBucket('something')
.forBucket('deep');
// when getting key deep/test2 it will translate to default.profile.something/deep/test2
const secondStorage = rootStorage.forBucket('profile/something');
await firstStorage.set('test2', { error: true });
await new Promise<void>(resolve => {
secondStorage.observe$('deep/test2').subscribe({
next: snapshot => {
selectedKeyNextHandler(snapshot);
if (snapshot.presence === 'absent') {
resolve();
}
},
});
secondStorage.snapshot('deep/test2');
});
expect(selectedKeyNextHandler).toHaveBeenCalledWith({
key: 'deep/test2',
presence: 'absent',
value: undefined,
});
expect(mockErrorApi.post).not.toHaveBeenCalled();
});
it('should silently treat the value as absent when the json can not be parsed', async () => {
const selectedKeyNextHandler = jest.fn();
const rootStorage = createPersistentStorage({
namespace: 'Test.Mock.Thing',
});
server.use(
rest.get(
`${mockBaseUrl}/buckets/:bucket/keys/:key`,
async (req, res, ctx) => {
const { bucket, key } = req.params;
expect(bucket).toEqual('Test.Mock.Thing');
expect(key).toEqual('key');
return res(ctx.text('{ invalid: json string }'));
},
),
);
await new Promise<void>(resolve => {
rootStorage.observe$('key').subscribe({
next: snapshot => {
selectedKeyNextHandler(snapshot);
if (snapshot.presence === 'absent') {
resolve();
}
},
});
rootStorage.snapshot('key');
});
expect(selectedKeyNextHandler).toHaveBeenCalledWith({
key: 'key',
presence: 'absent',
value: undefined,
});
});
it('should freeze the snapshot value', async () => {
const storage = createPersistentStorage();
const selectedKeyNextHandler = jest.fn();
const data = { foo: 'bar', baz: [{ foo: 'bar' }] };
server.use(
rest.get(
`${mockBaseUrl}/buckets/:bucket/keys/:key`,
async (_req, res, ctx) => {
return res(ctx.json({ value: data }));
},
),
);
await new Promise<void>(resolve => {
storage.observe$('key').subscribe({
next: snapshot => {
selectedKeyNextHandler(snapshot);
if (snapshot.presence === 'present') {
resolve();
}
},
});
storage.snapshot('key');
});
expect(selectedKeyNextHandler).toHaveBeenCalledWith({
key: 'key',
presence: 'present',
value: { baz: [{ foo: 'bar' }], foo: 'bar' },
});
const snapshot = selectedKeyNextHandler.mock.calls[0][0];
expect(() => {
snapshot.value.foo = 'buzz';
}).toThrow(/Cannot assign to read only property/);
expect(() => {
snapshot.value.baz[0].foo = 'buzz';
}).toThrow(/Cannot assign to read only property/);
expect(() => {
snapshot.value.baz.push({ foo: 'buzz' });
}).toThrow(/Cannot add property 1, object is not extensible/);
});
});
@@ -0,0 +1,231 @@
/*
* 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 { WebStorage } from '@backstage/core-app-api';
import {
DiscoveryApi,
ErrorApi,
FetchApi,
IdentityApi,
StorageApi,
StorageValueSnapshot,
} from '@backstage/core-plugin-api';
import { ResponseError } from '@backstage/errors';
import { JsonValue, Observable } from '@backstage/types';
import ObservableImpl from 'zen-observable';
const JSON_HEADERS = {
'Content-Type': 'application/json; charset=utf-8',
Accept: 'application/json',
};
const buckets = new Map<string, UserSettingsStorage>();
/**
* An implementation of the storage API, that uses the user-settings backend to
* persist the data in the DB.
*
* @public
*/
export class UserSettingsStorage implements StorageApi {
private subscribers = new Set<
ZenObservable.SubscriptionObserver<StorageValueSnapshot<JsonValue>>
>();
private readonly observables = new Map<
string,
Observable<StorageValueSnapshot<JsonValue>>
>();
private constructor(
private readonly namespace: string,
private readonly fetchApi: FetchApi,
private readonly discoveryApi: DiscoveryApi,
private readonly errorApi: ErrorApi,
private readonly identityApi: IdentityApi,
private readonly fallback: WebStorage,
) {}
static create(options: {
fetchApi: FetchApi;
discoveryApi: DiscoveryApi;
errorApi: ErrorApi;
identityApi: IdentityApi;
namespace?: string;
}): UserSettingsStorage {
return new UserSettingsStorage(
options.namespace ?? 'default',
options.fetchApi,
options.discoveryApi,
options.errorApi,
options.identityApi,
WebStorage.create({
namespace: options.namespace,
errorApi: options.errorApi,
}),
);
}
forBucket(name: string): StorageApi {
// use dot instead of slash separator to have nicer URLs
const bucketPath = `${this.namespace}.${name}`;
if (!buckets.has(bucketPath)) {
buckets.set(
bucketPath,
new UserSettingsStorage(
bucketPath,
this.fetchApi,
this.discoveryApi,
this.errorApi,
this.identityApi,
this.fallback,
),
);
}
return buckets.get(bucketPath)!;
}
async remove(key: string): Promise<void> {
const fetchUrl = await this.getFetchUrl(key);
const response = await this.fetchApi.fetch(fetchUrl, {
method: 'DELETE',
});
if (!response.ok && response.status !== 404) {
throw await ResponseError.fromResponse(response);
}
this.notifyChanges({ key, presence: 'absent' });
}
async set<T extends JsonValue>(key: string, data: T): Promise<void> {
if (!(await this.isSignedIn())) {
await this.fallback.set(key, data);
this.notifyChanges({ key, presence: 'present', value: data });
}
const fetchUrl = await this.getFetchUrl(key);
const response = await this.fetchApi.fetch(fetchUrl, {
method: 'PUT',
headers: JSON_HEADERS,
body: JSON.stringify({ value: data }),
});
if (!response.ok) {
throw await ResponseError.fromResponse(response);
}
const { value } = await response.json();
this.notifyChanges({ key, value, presence: 'present' });
}
observe$<T extends JsonValue>(
key: string,
): Observable<StorageValueSnapshot<T>> {
if (!this.observables.has(key)) {
this.observables.set(
key,
new ObservableImpl<StorageValueSnapshot<JsonValue>>(subscriber => {
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));
return () => {
this.subscribers.delete(subscriber);
};
}).filter(({ key: messageKey }) => messageKey === key),
);
}
return this.observables.get(key) as Observable<StorageValueSnapshot<T>>;
}
snapshot<T extends JsonValue>(key: string): StorageValueSnapshot<T> {
return { key, presence: 'unknown' };
}
private async get<T extends JsonValue>(
key: string,
): Promise<StorageValueSnapshot<T>> {
if (!(await this.isSignedIn())) {
// This explicitly uses WebStorage, which we know is synchronous and doesn't return presence: unknown
return this.fallback.snapshot(key);
}
const fetchUrl = await this.getFetchUrl(key);
const response = await this.fetchApi.fetch(fetchUrl);
if (response.status === 404) {
return { key, presence: 'absent' };
}
if (!response.ok) {
throw await ResponseError.fromResponse(response);
}
try {
const { value: rawValue } = await response.json();
const value = JSON.parse(JSON.stringify(rawValue), (_key, val) => {
if (typeof val === 'object' && val !== null) {
Object.freeze(val);
}
return val;
});
return { key, presence: 'present', value };
} catch {
// If the value is not valid JSON, we return an unknown presence. This should never happen
return { key, presence: 'absent' };
}
}
private async getFetchUrl(key: string) {
const baseUrl = await this.discoveryApi.getBaseUrl('user-settings');
const encodedNamespace = encodeURIComponent(this.namespace);
const encodedKey = encodeURIComponent(key);
return `${baseUrl}/buckets/${encodedNamespace}/keys/${encodedKey}`;
}
private async notifyChanges<T extends JsonValue>(
snapshot: StorageValueSnapshot<T>,
) {
for (const subscription of this.subscribers) {
try {
subscription.next(snapshot);
} catch {
// ignore
}
}
}
private async isSignedIn(): Promise<boolean> {
try {
const credentials = await this.identityApi.getCredentials();
return credentials?.token ? true : false;
} catch {
return false;
}
}
}
@@ -0,0 +1,17 @@
/*
* Copyright 2020 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 { UserSettingsStorage } from './UserSettingsStorage';
+17
View File
@@ -0,0 +1,17 @@
/*
* 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.
*/
export * from './StorageApi';
+2 -1
View File
@@ -15,11 +15,12 @@
*/
/**
* A Backstage plugin that provides a settings page
* A Backstage plugin that provides various per-user settings functionality.
*
* @packageDocumentation
*/
export * from './apis';
export {
userSettingsPlugin,
userSettingsPlugin as plugin,