From 9fe8a219fc238b39ede811d9a4aad830c21c96cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Eikeland?= Date: Thu, 31 Oct 2024 16:08:11 +0100 Subject: [PATCH 01/12] feat(module-app): adding settings in appclient and flowsubject for getting settings --- packages/modules/app/src/AppClient.ts | 43 +++++- packages/modules/app/src/AppModuleProvider.ts | 10 +- packages/modules/app/src/app/App.ts | 142 +++++++++++++++++- packages/modules/app/src/app/actions.ts | 16 +- .../modules/app/src/app/create-reducer.ts | 3 + packages/modules/app/src/app/create-state.ts | 10 +- packages/modules/app/src/app/events.ts | 15 +- packages/modules/app/src/app/flows.ts | 41 +++++ packages/modules/app/src/app/types.ts | 3 + packages/modules/app/src/errors.ts | 43 ++++++ packages/modules/app/src/types.ts | 2 + 11 files changed, 321 insertions(+), 7 deletions(-) diff --git a/packages/modules/app/src/AppClient.ts b/packages/modules/app/src/AppClient.ts index 2201754b74..5cf2a4e315 100644 --- a/packages/modules/app/src/AppClient.ts +++ b/packages/modules/app/src/AppClient.ts @@ -8,8 +8,8 @@ import { jsonSelector } from '@equinor/fusion-framework-module-http/selectors'; import { ApiApplicationSchema } from './schemas'; -import type { AppConfig, AppManifest, ConfigEnvironment } from './types'; -import { AppConfigError, AppManifestError } from './errors'; +import type { AppConfig, AppManifest, AppSettings, ConfigEnvironment } from './types'; +import { AppConfigError, AppManifestError, AppSettingsError } from './errors'; import { AppConfigSelector } from './AppClient.Selectors'; export interface IAppClient extends Disposable { @@ -30,6 +30,11 @@ export interface IAppClient extends Disposable { appKey: string; tag?: string; }) => ObservableInput>; + + /** + * Fetch app settings by appKey + */ + getAppSettings: (args: { appKey: string }) => ObservableInput; } /** @@ -62,6 +67,7 @@ export class AppClient implements IAppClient { #manifest: Query; #manifests: Query; #config: Query; + #settings: Query; constructor(client: IHttpClient) { const expire = 1 * 60 * 1000; @@ -114,7 +120,22 @@ export class AppClient implements IAppClient { key: (args) => JSON.stringify(args), expire, }); + + this.#settings = new Query({ + client: { + fn: ({ appKey }) => { + return client.json(`/persons/me/apps/${appKey}/settings`, { + headers: { + 'Api-Version': '1.0', + }, + }); + }, + }, + key: (args) => JSON.stringify(args), + expire, + }); } + getAppManifest(args: { appKey: string }): Observable { return this.#manifest.query(args).pipe( queryValue, @@ -158,11 +179,29 @@ export class AppClient implements IAppClient { ); } + getAppSettings(args: { appKey: string }): Observable { + return this.#settings.query(args).pipe( + queryValue, + catchError((err) => { + /** extract cause, since error will be a `QueryError` */ + const { cause } = err; + if (cause instanceof AppSettingsError) { + throw cause; + } + if (cause instanceof HttpResponseError) { + throw AppSettingsError.fromHttpResponse(cause.response, { cause }); + } + throw new AppSettingsError('unknown', 'failed to load settings', { cause }); + }), + ); + } + [Symbol.dispose]() { console.warn('AppClient disposed'); this.#manifest.complete(); this.#manifests.complete(); this.#config.complete(); + this.#settings.complete(); } } diff --git a/packages/modules/app/src/AppModuleProvider.ts b/packages/modules/app/src/AppModuleProvider.ts index 0b8f7da162..e5f17d5c48 100644 --- a/packages/modules/app/src/AppModuleProvider.ts +++ b/packages/modules/app/src/AppModuleProvider.ts @@ -12,7 +12,7 @@ import { import { ModuleType } from '@equinor/fusion-framework-module'; import { EventModule } from '@equinor/fusion-framework-module-event'; -import type { AppConfig, AppManifest, ConfigEnvironment, CurrentApp } from './types'; +import type { AppConfig, AppManifest, AppSettings, ConfigEnvironment, CurrentApp } from './types'; import { App, filterEmpty, IApp } from './app/App'; import { AppModuleConfig } from './AppConfigurator'; @@ -122,6 +122,14 @@ export class AppModuleProvider { return from(this.#appClient.getAppConfig({ appKey, tag })); } + /** + * fetch user settings for an application + * @param appKey - application key + */ + public getAppSettings(appKey: string): Observable { + return from(this.#appClient.getAppSettings({ appKey })); + } + /** * set the current application, will internally resolve manifest * @param appKey - application key diff --git a/packages/modules/app/src/app/App.ts b/packages/modules/app/src/app/App.ts index ae5b6d2031..4d3010e47c 100644 --- a/packages/modules/app/src/app/App.ts +++ b/packages/modules/app/src/app/App.ts @@ -3,6 +3,7 @@ import type { AppScriptModule, AppManifest, AppConfig, + AppSettings, ConfigEnvironment, } from '../types'; import { FlowSubject, Observable } from '@equinor/fusion-observable'; @@ -53,6 +54,12 @@ export interface IApp< */ get config$(): Observable>; + /** + * Observable that emits the settings of the app. + * @returns An Observable that emits the app settings. + */ + get settings$(): Observable; + /** * Returns an observable stream of the loaded app script instance. * @returns {Observable} The observable stream of app script modules. @@ -95,6 +102,18 @@ export interface IApp< */ get config(): AppConfig | undefined; + /** + * Gets the settings of the app. + * @returns The settings object or undefined if no settings is set. + */ + get settings(): AppSettings | undefined; + + /** + * Gets the settings of the app asyncronously. + * @returns A promise that resolves to the AppSettings. + */ + get settingsAsync(): Promise | undefined; + /** * Retrieves the configuration asynchronously. * @returns A promise that resolves to the AppConfig. @@ -120,6 +139,7 @@ export interface IApp< manifest: AppManifest; script: AppScriptModule; config: AppConfig; + settings: AppSettings; }>; /** @@ -127,6 +147,11 @@ export interface IApp< */ loadConfig(): void; + /** + * Loads the app settings. + */ + loadSettings(): void; + /** * Loads the app manifest. */ @@ -152,6 +177,20 @@ export interface IApp< */ getConfigAsync(allow_cache?: boolean): Promise; + /** + * Gets the app settings. + * @param force_refresh Whether to force refreshing the settings. + * @returns An observable that emits the app settings. + */ + getSettings(force_refresh?: boolean): Observable; + + /** + * Retrieves the app settings asynchronously. + * @param allow_cache Whether to allow loading from cache. + * @returns A promise that resolves to the AppSettings. + */ + getSettingsAsync(allow_cache?: boolean): Promise; + /** * Gets the app manifest. * @param force_refresh Whether to force refreshing the manifest. @@ -206,6 +245,13 @@ export class App< ); } + get settings$(): Observable { + return this.#state.pipe( + map(({ settings }) => settings as AppSettings), + filterEmpty(), + ); + } + get modules$(): Observable { return this.#state.pipe( map(({ modules }) => modules), @@ -247,6 +293,14 @@ export class App< return firstValueFrom(this.config$); } + get settings(): AppSettings | undefined { + return this.state.settings as AppSettings; + } + + get settingsAsync(): Promise { + return firstValueFrom(this.settings$); + } + get instance(): AppModulesInstance | undefined { return this.#state.value.instance as AppModulesInstance; } @@ -358,6 +412,33 @@ export class App< }); }); + // monitor when application settings is loading + this.#state.addEffect(actions.fetchSettings.type, () => { + // dispatch event to notify listeners that the application settings is being loaded + event.dispatchEvent('onAppSettingsLoad', { + detail: { appKey }, + source: this, + }); + }); + + // monitor when application settings is loaded + this.#state.addEffect(actions.fetchSettings.success.type, (action) => { + // dispatch event to notify listeners that the application settings has been loaded + event.dispatchEvent('onAppSettingsLoaded', { + detail: { appKey, settings: action.payload }, + source: this, + }); + }); + + // monitor when application settings fails to load + this.#state.addEffect(actions.fetchSettings.failure.type, (action) => { + // dispatch event to notify listeners that the application settings failed to load + event.dispatchEvent('onAppSettingsFailure', { + detail: { appKey, error: action.payload }, + source: this, + }); + }); + // monitor when application script is loading this.#state.addEffect(actions.importApp.type, () => { // dispatch event to notify listeners that the application script is being loaded @@ -417,6 +498,7 @@ export class App< manifest: AppManifest; script: AppScriptModule; config: AppConfig; + settings: AppSettings; }> { return new Observable((subscriber) => { // dispatch initialize action to indicate that the application is initializing @@ -427,13 +509,15 @@ export class App< this.getManifest(), this.getAppModule(), this.getConfig(), + this.getSettings(), ]).subscribe({ - next: ([manifest, script, config]) => + next: ([manifest, script, config, settings]) => // emit the manifest, script, and config to the subscriber subscriber.next({ manifest, script, config, + settings, }), error: (err) => { // emit error and complete the stream @@ -458,6 +542,10 @@ export class App< }); } + public loadSettings() { + this.#state.next(actions.fetchSettings(this.appKey)); + } + public loadManifest(update?: boolean) { this.#state.next(actions.fetchManifest(this.appKey, update)); } @@ -529,6 +617,58 @@ export class App< return operator(this.getConfig(!allow_cache)); } + public getSettings(force_refresh = false): Observable { + return new Observable((subscriber) => { + if (this.#state.value.config) { + // emit current settings to the subscriber + subscriber.next(this.#state.value.settings); + if (!force_refresh) { + // since we have the settings and no force refresh, complete the stream + return subscriber.complete(); + } + } + + // when stream closes, dispose of subscription to change of state settings + subscriber.add( + // monitor changes to state changes of settings and emit to subscriber + this.#state.addEffect('set_settings', ({ payload }) => { + subscriber.next(payload); + }), + ); + + // when stream closes, dispose of subscription to fetch settings + subscriber.add( + // monitor success of fetching settings and emit to subscriber + this.#state.addEffect('fetch_settings::success', ({ payload }) => { + // application settings loaded, emit to subscriber and complete the stream + subscriber.next(payload); + subscriber.complete(); + }), + ); + + // when stream closes, dispose of subscription to fetch settings + subscriber.add( + // monitor failure of fetching settings and emit error to subscriber + this.#state.addEffect('fetch_settings::failure', ({ payload }) => { + // application settings failed to load, emit error and complete the stream + subscriber.error( + Error('failed to load application settings', { + cause: payload, + }), + ); + }), + ); + + this.loadSettings(); + }); + } + + public getSettingsAsync(allow_cache = true): Promise { + // when allow_cache is true, use first emitted value, otherwise use last emitted value + const operator = allow_cache ? firstValueFrom : lastValueFrom; + return operator(this.getSettings(!allow_cache)); + } + public getManifest(force_refresh = false): Observable { return new Observable((subscriber) => { if (this.#state.value.manifest) { diff --git a/packages/modules/app/src/app/actions.ts b/packages/modules/app/src/app/actions.ts index 62d4a66e7e..76a0431a6a 100644 --- a/packages/modules/app/src/app/actions.ts +++ b/packages/modules/app/src/app/actions.ts @@ -4,7 +4,13 @@ import { createAction, createAsyncAction, } from '@equinor/fusion-observable'; -import type { AppConfig, AppManifest, AppModulesInstance, AppScriptModule } from '../types'; +import type { + AppConfig, + AppManifest, + AppModulesInstance, + AppScriptModule, + AppSettings, +} from '../types'; const createActions = () => ({ /** Manifest loading */ @@ -31,6 +37,14 @@ const createActions = () => ({ (config: AppConfig) => ({ payload: config }), (error: unknown) => ({ payload: error }), ), + /** Config loading */ + setSettings: createAction('set_settings', (settings: AppSettings) => ({ payload: settings })), + fetchSettings: createAsyncAction( + 'fetch_settings', + (key: string) => ({ payload: key }), + (settings: AppSettings) => ({ payload: settings }), + (error: unknown) => ({ payload: error }), + ), /** App loading */ // eslint-disable-next-line @typescript-eslint/no-explicit-any setModule: createAction('set_module', (module: any) => ({ payload: module })), diff --git a/packages/modules/app/src/app/create-reducer.ts b/packages/modules/app/src/app/create-reducer.ts index 27bbcd4109..393eb3e54f 100644 --- a/packages/modules/app/src/app/create-reducer.ts +++ b/packages/modules/app/src/app/create-reducer.ts @@ -30,6 +30,9 @@ export const createReducer = (value: AppBundleStateInitial) => .addCase(actions.setConfig, (state, action) => { state.config = action.payload; }) + .addCase(actions.setSettings, (state, action) => { + state.settings = action.payload; + }) .addCase(actions.setModule, (state, action) => { state.modules = action.payload; }) diff --git a/packages/modules/app/src/app/create-state.ts b/packages/modules/app/src/app/create-state.ts index e09d8dfd52..73279c9ec7 100644 --- a/packages/modules/app/src/app/create-state.ts +++ b/packages/modules/app/src/app/create-state.ts @@ -2,7 +2,12 @@ import { FlowSubject } from '@equinor/fusion-observable'; import { createReducer } from './create-reducer'; -import { handleFetchManifest, handleFetchConfig, handleImportApplication } from './flows'; +import { + handleFetchManifest, + handleFetchConfig, + handleFetchSettings, + handleImportApplication, +} from './flows'; import type { Actions } from './actions'; import type { AppBundleState, AppBundleStateInitial } from './types'; @@ -23,6 +28,9 @@ export const createState = ( // add handler for fetching config state.addFlow(handleFetchConfig(provider)); + // add handler for fetching settings + state.addFlow(handleFetchSettings(provider)); + // add handler for loading application script state.addFlow(handleImportApplication(provider)); diff --git a/packages/modules/app/src/app/events.ts b/packages/modules/app/src/app/events.ts index 7fd2371cdd..3d6dd5e4e1 100644 --- a/packages/modules/app/src/app/events.ts +++ b/packages/modules/app/src/app/events.ts @@ -2,7 +2,13 @@ import type { FrameworkEvent, FrameworkEventInit } from '@equinor/fusion-framewo import type { App } from './App'; -import type { AppConfig, AppManifest, AppModulesInstance, AppScriptModule } from '../types'; +import type { + AppConfig, + AppManifest, + AppModulesInstance, + AppScriptModule, + AppSettings, +} from '../types'; /** base event type for applications */ export type AppEventEventInit | unknown = unknown> = @@ -45,6 +51,13 @@ declare module '@equinor/fusion-framework-module-event' { }>; onAppConfigFailure: AppEventFailure; + onAppSettingsLoad: AppEvent; + /** fired when the application has loaded corresponding settings */ + onAppSettingsLoaded: AppEvent<{ + settings: AppSettings; + }>; + onAppSettingsFailure: AppEventFailure; + /** fired when the application has loaded corresponding javascript module */ onAppScriptLoad: AppEvent; onAppScriptLoaded: AppEvent<{ diff --git a/packages/modules/app/src/app/flows.ts b/packages/modules/app/src/app/flows.ts index ab9fa1592e..538aeb6a83 100644 --- a/packages/modules/app/src/app/flows.ts +++ b/packages/modules/app/src/app/flows.ts @@ -93,6 +93,47 @@ export const handleFetchConfig = }), ); +/** + * Handles the fetch settings action by fetching the app settings from the provider, + * filtering out null values, and dispatching success or failure actions accordingly. + * + * @param provider The AppModuleProvider used to fetch the app settings. + * @returns A Flow function that takes an Observable of actions and returns an Observable of actions. + */ +export const handleFetchSettings = + (provider: AppModuleProvider): Flow => + (action$) => + action$.pipe( + // only handle fetch settings request actions + filter(actions.fetchSettings.match), + // when request is received, abort any ongoing request and start new + switchMap((action) => { + const { payload: appKey } = action; + + // fetch settings from provider + const subject = from(provider.getAppSettings(appKey)).pipe( + // filter out null values + filter((x) => !!x), + // allow multiple subscriptions + share(), + ); + + // first load settings and then dispatch success action + return concat( + subject.pipe(map((settings) => actions.setSettings(settings))), + subject.pipe( + last(), + map((settings) => actions.fetchSettings.success(settings)), + ), + ).pipe( + // catch any error and dispatch failure action + catchError((err) => { + return of(actions.fetchSettings.failure(err)); + }), + ); + }), + ); + /** * Handles the import application flow. * @returns A flow that takes in actions and returns an observable of AppBundleState. diff --git a/packages/modules/app/src/app/types.ts b/packages/modules/app/src/app/types.ts index d7cde8618c..2e246f5c3f 100644 --- a/packages/modules/app/src/app/types.ts +++ b/packages/modules/app/src/app/types.ts @@ -4,6 +4,7 @@ import type { AppModulesInstance, AppScriptModule, ConfigEnvironment, + AppSettings, } from '../types'; /** @@ -16,6 +17,7 @@ import type { * @property {Set} status - A set of strings representing the status of the application. * @property {AppManifest} [manifest] - An optional manifest describing the application. * @property {AppConfig} [config] - An optional configuration object for the application. + * @property {AppSettings} [settings] - An optional application settings object. * @property {AppScriptModule} [modules] - An optional script module for the application. * @property {AppModulesInstance} [instance] - An optional instance of the application modules. */ @@ -28,6 +30,7 @@ export type AppBundleState< status: Set; manifest?: AppManifest; config?: AppConfig; + settings?: AppSettings; modules?: AppScriptModule; instance?: AppModulesInstance; }; diff --git a/packages/modules/app/src/errors.ts b/packages/modules/app/src/errors.ts index 0a12212999..18b9cafb59 100644 --- a/packages/modules/app/src/errors.ts +++ b/packages/modules/app/src/errors.ts @@ -86,6 +86,49 @@ export class AppConfigError extends Error { } } +/** + * Represents an error that occurs while fetching application settings. + */ +export class AppSettingsError extends Error { + /** + * Creates an instance of `AppSettingsError` based on the HTTP response status. + * @param response The HTTP response. + * @param options Additional error options. + * @returns An instance of `AppSettingsError` based on the HTTP response status. + */ + static fromHttpResponse(response: Response, options?: ErrorOptions): AppSettingsError { + switch (response.status) { + case 401: + return new AppSettingsError( + 'unauthorized', + 'failed to load application settings, request not authorized', + options, + ); + case 404: + return new AppSettingsError('not_found', 'application not found', options); + } + return new AppSettingsError( + 'unknown', + `failed to load application settings, status code ${response.status}`, + options, + ); + } + + /** + * Creates an instance of `AppSettingsError`. + * @param type The type of the application error. + * @param message The error message. + * @param options Additional error options. + */ + constructor( + public readonly type: AppErrorType, + message?: string, + options?: ErrorOptions, + ) { + super(message, options); + } +} + /** * Represents an error that occurs when loading the application script. */ diff --git a/packages/modules/app/src/types.ts b/packages/modules/app/src/types.ts index ed36bdee57..6536076fb0 100644 --- a/packages/modules/app/src/types.ts +++ b/packages/modules/app/src/types.ts @@ -23,6 +23,8 @@ export type AppEnv; + // TODO: remove `report` and `launcher` when legacy apps are removed export type AppType = 'standalone' | 'report' | 'launcher' | 'template'; From 79ed08dd73e4a6a44c0caabfd8d31796d474d52d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Eikeland?= Date: Thu, 14 Nov 2024 15:12:49 +0100 Subject: [PATCH 02/12] feat(useAppSettings): adding hook for useAppSettings, with methods in client provider and flows --- .changeset/large-beds-jog.md | 41 +++++++++ packages/modules/app/src/AppClient.ts | 45 ++++++++++ packages/modules/app/src/AppModuleProvider.ts | 9 ++ packages/modules/app/src/app/App.ts | 86 ++++++++++++++++++- packages/modules/app/src/app/actions.ts | 10 ++- .../modules/app/src/app/create-reducer.ts | 3 + packages/modules/app/src/app/create-state.ts | 3 + packages/modules/app/src/app/events.ts | 6 ++ packages/modules/app/src/app/flows.ts | 30 ++++++- packages/react/app/package.json | 7 ++ packages/react/app/src/settings/index.ts | 1 + .../react/app/src/settings/useAppSettings.ts | 34 ++++++++ 12 files changed, 272 insertions(+), 3 deletions(-) create mode 100644 .changeset/large-beds-jog.md create mode 100644 packages/react/app/src/settings/index.ts create mode 100644 packages/react/app/src/settings/useAppSettings.ts diff --git a/.changeset/large-beds-jog.md b/.changeset/large-beds-jog.md new file mode 100644 index 0000000000..2fadf404c0 --- /dev/null +++ b/.changeset/large-beds-jog.md @@ -0,0 +1,41 @@ +--- +'@equinor/fusion-framework-module-app': minor +'@equinor/fusion-framework-react-app': minor +--- + +#### Changes: + +1. **AppClient.ts** + - Added `updateAppSettings` method to set app settings by appKey. + - Initialized `#setSettings` query in the constructor. + +2. **AppModuleProvider.ts** + - Added `updateAppSettings` method to update app settings. + +3. **App.ts** + - Added `updateSettings` and `updateSettingsAsync` methods to set app settings. + - Added effects to monitor and dispatch events for settings updates. + +4. **actions.ts** + - Added `updateSettings` async action for updating settings. + +5. **create-reducer.ts** + - Added reducer case for `updateSettings.success` to update state settings. + +6. **create-state.ts** + - Added `handleUpdateSettings` flow to handle updating settings. + +7. **events.ts** + - Added new events: `onAppSettingsUpdate`, `onAppSettingsUpdated`, and `onAppSettingsUpdateFailure`. + +8. **flows.ts** + - Added `handleUpdateSettings` flow to handle the set settings action. + +9. **package.json** + - Added `settings` entry to exports and types. + +10. **index.ts** + - Created new file to export `useAppSettings`. + +11. **useAppSettings.ts** + - Created new hook for handling app settings. diff --git a/packages/modules/app/src/AppClient.ts b/packages/modules/app/src/AppClient.ts index 5cf2a4e315..80df44e9c0 100644 --- a/packages/modules/app/src/AppClient.ts +++ b/packages/modules/app/src/AppClient.ts @@ -35,6 +35,16 @@ export interface IAppClient extends Disposable { * Fetch app settings by appKey */ getAppSettings: (args: { appKey: string }) => ObservableInput; + + /** + * Set app settings by appKey + * @param args - Object with appKey and settings + * @returns ObservableInput + */ + updateAppSettings: (args: { + appKey: string; + settings: AppSettings; + }) => ObservableInput; } /** @@ -68,6 +78,7 @@ export class AppClient implements IAppClient { #manifests: Query; #config: Query; #settings: Query; + #setSettings: Query; constructor(client: IHttpClient) { const expire = 1 * 60 * 1000; @@ -134,6 +145,23 @@ export class AppClient implements IAppClient { key: (args) => JSON.stringify(args), expire, }); + this.#setSettings = new Query({ + client: { + fn: ({ appKey, settings }) => { + console.log('query', appKey, settings); + return client.fetch(`/persons/me/apps/${appKey}/settings`, { + method: 'PUT', + headers: { + 'Api-Version': '1.0', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(settings), + }); + }, + }, + key: (args) => JSON.stringify(args), + expire, + }); } getAppManifest(args: { appKey: string }): Observable { @@ -196,6 +224,23 @@ export class AppClient implements IAppClient { ); } + updateAppSettings(args: { appKey: string; settings: AppSettings }): Observable { + return this.#setSettings.query(args).pipe( + queryValue, + catchError((err) => { + /** extract cause, since error will be a `QueryError` */ + const { cause } = err; + if (cause instanceof AppSettingsError) { + throw cause; + } + if (cause instanceof HttpResponseError) { + throw AppSettingsError.fromHttpResponse(cause.response, { cause }); + } + throw new AppSettingsError('unknown', 'failed to update app settings', { cause }); + }), + ); + } + [Symbol.dispose]() { console.warn('AppClient disposed'); this.#manifest.complete(); diff --git a/packages/modules/app/src/AppModuleProvider.ts b/packages/modules/app/src/AppModuleProvider.ts index e5f17d5c48..aec6483b93 100644 --- a/packages/modules/app/src/AppModuleProvider.ts +++ b/packages/modules/app/src/AppModuleProvider.ts @@ -130,6 +130,15 @@ export class AppModuleProvider { return from(this.#appClient.getAppSettings({ appKey })); } + /** + * Put user settings for an application + * @param appKey - application key + * @param settings - The settings to add save + */ + public updateAppSettings(appKey: string, settings: AppSettings): Observable { + return from(this.#appClient.updateAppSettings({ appKey, settings })); + } + /** * set the current application, will internally resolve manifest * @param appKey - application key diff --git a/packages/modules/app/src/app/App.ts b/packages/modules/app/src/app/App.ts index 4d3010e47c..73428366cf 100644 --- a/packages/modules/app/src/app/App.ts +++ b/packages/modules/app/src/app/App.ts @@ -191,6 +191,20 @@ export interface IApp< */ getSettingsAsync(allow_cache?: boolean): Promise; + /** + * Sets the app settings. + * @param settings The settings object to save. + * @returns An observable that emits the app settings. + */ + updateSettings(settings: AppSettings): Observable; + + /** + * Sets the app settings asyncronously. + * @param settings The settings object to save. + * @returns An Promise that resolves the app settings. + */ + updateSettingsAsync(settings: AppSettings): Promise; + /** * Gets the app manifest. * @param force_refresh Whether to force refreshing the manifest. @@ -439,6 +453,33 @@ export class App< }); }); + // monitor when application settings is updated + this.#state.addEffect(actions.updateSettings.type, (action) => { + // dispatch event to notify listeners that the application settings has been loaded + event.dispatchEvent('onAppSettingsUpdate', { + detail: { appKey, settings: action.payload.settings }, + source: this, + }); + }); + + // monitor when application settings is updated + this.#state.addEffect(actions.updateSettings.success.type, (action) => { + // dispatch event to notify listeners that the application settings has been loaded + event.dispatchEvent('onAppSettingsUpdated', { + detail: { appKey, settings: action.payload.settings }, + source: this, + }); + }); + + // monitor when application settings fails to updated + this.#state.addEffect(actions.updateSettings.failure.type, (action) => { + // dispatch event to notify listeners that the application settings has been loaded + event.dispatchEvent('onAppSettingsUpdateFailure', { + detail: { appKey, settings: action.payload }, + source: this, + }); + }); + // monitor when application script is loading this.#state.addEffect(actions.importApp.type, () => { // dispatch event to notify listeners that the application script is being loaded @@ -619,7 +660,7 @@ export class App< public getSettings(force_refresh = false): Observable { return new Observable((subscriber) => { - if (this.#state.value.config) { + if (this.#state.value.settings) { // emit current settings to the subscriber subscriber.next(this.#state.value.settings); if (!force_refresh) { @@ -669,6 +710,49 @@ export class App< return operator(this.getSettings(!allow_cache)); } + public updateSettings(settings: AppSettings): Observable { + return new Observable((subscriber) => { + // when stream closes, dispose of subscription to change of state settings + subscriber.add( + // monitor changes to state changes of settings and emit to subscriber + this.#state.addEffect(actions.updateSettings.type, ({ payload }) => { + subscriber.next(payload); + }), + ); + + // when stream closes, dispose of subscription to fetch settings + subscriber.add( + // monitor success of fetching settings and emit to subscriber + this.#state.addEffect(actions.updateSettings.success.type, ({ payload }) => { + // application settings loaded, emit to subscriber and complete the stream + subscriber.next(payload); + subscriber.complete(); + }), + ); + + // when stream closes, dispose of subscription to fetch settings + subscriber.add( + // monitor failure of fetching settings and emit error to subscriber + this.#state.addEffect(actions.updateSettings.failure.type, ({ payload }) => { + // application settings failed to load, emit error and complete the stream + subscriber.error( + Error('failed to load application settings', { + cause: payload, + }), + ); + }), + ); + + this.#state.next(actions.updateSettings(settings)); + }); + } + + public updateSettingsAsync(settings: AppSettings): Promise { + // when allow_cache is true, use first emitted value, otherwise use last emitted value + const operator = lastValueFrom; + return operator(this.updateSettings(settings)); + } + public getManifest(force_refresh = false): Observable { return new Observable((subscriber) => { if (this.#state.value.manifest) { diff --git a/packages/modules/app/src/app/actions.ts b/packages/modules/app/src/app/actions.ts index 76a0431a6a..8f1701959e 100644 --- a/packages/modules/app/src/app/actions.ts +++ b/packages/modules/app/src/app/actions.ts @@ -37,14 +37,22 @@ const createActions = () => ({ (config: AppConfig) => ({ payload: config }), (error: unknown) => ({ payload: error }), ), - /** Config loading */ + /** Settings loading */ setSettings: createAction('set_settings', (settings: AppSettings) => ({ payload: settings })), + /** Fetching settings */ fetchSettings: createAsyncAction( 'fetch_settings', (key: string) => ({ payload: key }), (settings: AppSettings) => ({ payload: settings }), (error: unknown) => ({ payload: error }), ), + /** Updatings settings */ + updateSettings: createAsyncAction( + 'update_settings', + (settings: AppSettings) => ({ payload: { settings } }), + (settings: AppSettings) => ({ payload: settings }), + (error: unknown) => ({ payload: error }), + ), /** App loading */ // eslint-disable-next-line @typescript-eslint/no-explicit-any setModule: createAction('set_module', (module: any) => ({ payload: module })), diff --git a/packages/modules/app/src/app/create-reducer.ts b/packages/modules/app/src/app/create-reducer.ts index 393eb3e54f..2f1deeabe1 100644 --- a/packages/modules/app/src/app/create-reducer.ts +++ b/packages/modules/app/src/app/create-reducer.ts @@ -33,6 +33,9 @@ export const createReducer = (value: AppBundleStateInitial) => .addCase(actions.setSettings, (state, action) => { state.settings = action.payload; }) + .addCase(actions.updateSettings.success, (state, action) => { + state.settings = action.payload; + }) .addCase(actions.setModule, (state, action) => { state.modules = action.payload; }) diff --git a/packages/modules/app/src/app/create-state.ts b/packages/modules/app/src/app/create-state.ts index 73279c9ec7..8b0a0654b0 100644 --- a/packages/modules/app/src/app/create-state.ts +++ b/packages/modules/app/src/app/create-state.ts @@ -6,6 +6,7 @@ import { handleFetchManifest, handleFetchConfig, handleFetchSettings, + handleUpdateSettings, handleImportApplication, } from './flows'; @@ -31,6 +32,8 @@ export const createState = ( // add handler for fetching settings state.addFlow(handleFetchSettings(provider)); + state.addFlow(handleUpdateSettings(provider)); + // add handler for loading application script state.addFlow(handleImportApplication(provider)); diff --git a/packages/modules/app/src/app/events.ts b/packages/modules/app/src/app/events.ts index 3d6dd5e4e1..6b8a38544a 100644 --- a/packages/modules/app/src/app/events.ts +++ b/packages/modules/app/src/app/events.ts @@ -58,6 +58,12 @@ declare module '@equinor/fusion-framework-module-event' { }>; onAppSettingsFailure: AppEventFailure; + onAppSettingsUpdate: AppEvent; + onAppSettingsUpdated: AppEvent<{ + settings: AppSettings; + }>; + onAppSettingsUpdateFailure: AppEventFailure; + /** fired when the application has loaded corresponding javascript module */ onAppScriptLoad: AppEvent; onAppScriptLoaded: AppEvent<{ diff --git a/packages/modules/app/src/app/flows.ts b/packages/modules/app/src/app/flows.ts index 538aeb6a83..6f98f74182 100644 --- a/packages/modules/app/src/app/flows.ts +++ b/packages/modules/app/src/app/flows.ts @@ -1,5 +1,5 @@ import { from, of, concat } from 'rxjs'; -import { catchError, filter, last, map, share, switchMap } from 'rxjs/operators'; +import { catchError, filter, last, map, share, switchMap, withLatestFrom } from 'rxjs/operators'; import { actions } from './actions'; @@ -134,6 +134,34 @@ export const handleFetchSettings = }), ); +/** + * Handles the set settings action by setting the app settings from the provider, + * filtering out null values, and dispatching success or failure actions accordingly. + * + * @param provider The AppModuleProvider used to fetch the app settings. + * @returns A Flow function that takes an Observable of actions and returns an Observable of actions. + */ +export const handleUpdateSettings = + (provider: AppModuleProvider): Flow => + (action$, state$) => + action$.pipe( + // only handle update settings request actions + filter(actions.updateSettings.match), + withLatestFrom(state$), + // when request is received, abort any ongoing request and start new + switchMap(([action, state]) => { + const { payload } = action; + const { appKey } = state; + + // set settings in provider + return from(provider.updateAppSettings(appKey, payload.settings)).pipe( + // allow multiple subscriptions + map((settings) => actions.updateSettings.success(settings)), + catchError((err) => of(actions.updateSettings.failure(err))), + ); + }), + ); + /** * Handles the import application flow. * @returns A flow that takes in actions and returns an observable of AppBundleState. diff --git a/packages/react/app/package.json b/packages/react/app/package.json index b591127824..6fd205a21e 100644 --- a/packages/react/app/package.json +++ b/packages/react/app/package.json @@ -37,6 +37,10 @@ "types": "./dist/types/navigation/index.d.ts", "import": "./dist/esm/navigation/index.js" }, + "./settings": { + "types": "./dist/types/settings/index.d.ts", + "import": "./dist/esm/settings/index.js" + }, "./widget": { "types": "./dist/types/widget/index.d.ts", "import": "./dist/esm/widget/index.js" @@ -65,6 +69,9 @@ "navigation": [ "dist/types/navigation/index.d.ts" ], + "settings": [ + "dist/types/settings/index.d.ts" + ], "widget": [ "dist/types/widget/index.d.ts" ] diff --git a/packages/react/app/src/settings/index.ts b/packages/react/app/src/settings/index.ts new file mode 100644 index 0000000000..76e8d6811c --- /dev/null +++ b/packages/react/app/src/settings/index.ts @@ -0,0 +1 @@ +export { useAppSettings } from './useAppSettings'; diff --git a/packages/react/app/src/settings/useAppSettings.ts b/packages/react/app/src/settings/useAppSettings.ts new file mode 100644 index 0000000000..37cfbce497 --- /dev/null +++ b/packages/react/app/src/settings/useAppSettings.ts @@ -0,0 +1,34 @@ +import { useCallback, useMemo } from 'react'; +import { type AppSettings } from '@equinor/fusion-framework-module-app'; +import { useObservableState } from '@equinor/fusion-observable/react'; +import { EMPTY } from 'rxjs'; +import { useCurrentApp } from '@equinor/fusion-framework-react/app'; + +/** + * Hook for handling a users app settings + * @returns {settings, updateSettings} Methods for getting and setting settings. + */ +export const useAppSettings = (): { + settings: AppSettings | undefined; + updateSettings: (settings: AppSettings) => Promise | undefined; +} => { + const { currentApp } = useCurrentApp(); + + const { value: settings } = useObservableState( + useMemo(() => currentApp?.getSettings() || EMPTY, [currentApp]), + ); + + const updateSettings = useCallback( + (settings: AppSettings) => { + return currentApp?.updateSettingsAsync(settings); + }, + [currentApp], + ); + + return { + settings, + updateSettings, + }; +}; + +export default useAppSettings; From 6c5beb31d84422a8bf3b28c719a02cd318ee75dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Eikeland?= Date: Thu, 14 Nov 2024 15:21:07 +0100 Subject: [PATCH 03/12] fix(AppClient): dispose of this.#setSettings --- packages/modules/app/src/AppClient.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/modules/app/src/AppClient.ts b/packages/modules/app/src/AppClient.ts index 80df44e9c0..e6b882e249 100644 --- a/packages/modules/app/src/AppClient.ts +++ b/packages/modules/app/src/AppClient.ts @@ -247,6 +247,7 @@ export class AppClient implements IAppClient { this.#manifests.complete(); this.#config.complete(); this.#settings.complete(); + this.#setSettings.complete(); } } From b42254833bf53d9505f80a660a52b1530f7de644 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Eikeland?= Date: Fri, 15 Nov 2024 12:46:27 +0100 Subject: [PATCH 04/12] fix(appClient): same client method getting and updating settings based on in params --- packages/modules/app/src/AppClient.ts | 34 ++++++++------------------- 1 file changed, 10 insertions(+), 24 deletions(-) diff --git a/packages/modules/app/src/AppClient.ts b/packages/modules/app/src/AppClient.ts index e6b882e249..ddb08d4c1a 100644 --- a/packages/modules/app/src/AppClient.ts +++ b/packages/modules/app/src/AppClient.ts @@ -77,8 +77,7 @@ export class AppClient implements IAppClient { #manifest: Query; #manifests: Query; #config: Query; - #settings: Query; - #setSettings: Query; + #settings: Query; constructor(client: IHttpClient) { const expire = 1 * 60 * 1000; @@ -132,31 +131,19 @@ export class AppClient implements IAppClient { expire, }); - this.#settings = new Query({ - client: { - fn: ({ appKey }) => { - return client.json(`/persons/me/apps/${appKey}/settings`, { - headers: { - 'Api-Version': '1.0', - }, - }); - }, - }, - key: (args) => JSON.stringify(args), - expire, - }); - this.#setSettings = new Query({ + this.#settings = new Query({ client: { fn: ({ appKey, settings }) => { - console.log('query', appKey, settings); - return client.fetch(`/persons/me/apps/${appKey}/settings`, { - method: 'PUT', + const init: RequestInit = { headers: { 'Api-Version': '1.0', - 'Content-Type': 'application/json', }, - body: JSON.stringify(settings), - }); + }; + if (settings) { + init.method = 'PUT'; + init.body = JSON.stringify(settings); + } + return client.json(`/persons/me/apps/${appKey}/settings`, init); }, }, key: (args) => JSON.stringify(args), @@ -225,7 +212,7 @@ export class AppClient implements IAppClient { } updateAppSettings(args: { appKey: string; settings: AppSettings }): Observable { - return this.#setSettings.query(args).pipe( + return this.#settings.query(args).pipe( queryValue, catchError((err) => { /** extract cause, since error will be a `QueryError` */ @@ -247,7 +234,6 @@ export class AppClient implements IAppClient { this.#manifests.complete(); this.#config.complete(); this.#settings.complete(); - this.#setSettings.complete(); } } From f8ea6cde1e9a782459006ca47e68b5fe9ea5d7b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Eikeland?= Date: Fri, 15 Nov 2024 12:57:28 +0100 Subject: [PATCH 05/12] chore: changeset --- .changeset/large-beds-jog.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.changeset/large-beds-jog.md b/.changeset/large-beds-jog.md index 2fadf404c0..7ddbeb5b60 100644 --- a/.changeset/large-beds-jog.md +++ b/.changeset/large-beds-jog.md @@ -39,3 +39,6 @@ 11. **useAppSettings.ts** - Created new hook for handling app settings. + +12. **app-proxy-plugin.ts** + - Add conditional handler for persons/me/appKey/settings to prevent matching against appmanifest path From 7aa05bf436839ed8318ebe440118f5895518cee1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Eikeland?= Date: Fri, 15 Nov 2024 15:37:18 +0100 Subject: [PATCH 06/12] fix(appCLient): return empty settings object when app is not registrered --- packages/modules/app/src/AppClient.ts | 28 +++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/modules/app/src/AppClient.ts b/packages/modules/app/src/AppClient.ts index ddb08d4c1a..6ad5a4a9c6 100644 --- a/packages/modules/app/src/AppClient.ts +++ b/packages/modules/app/src/AppClient.ts @@ -106,8 +106,12 @@ export class AppClient implements IAppClient { 'Api-Version': '1.0', }, selector: async (res: Response) => { - const response = (await jsonSelector(res)) as { value: unknown[] }; - return ApplicationSchema.array().parse(response.value); + /** Return empyt settings if app is not registerred */ + if (res.status === 404) { + return {} + } + const body = await res.json(); + return body; }, }); }, @@ -133,17 +137,21 @@ export class AppClient implements IAppClient { this.#settings = new Query({ client: { - fn: ({ appKey, settings }) => { - const init: RequestInit = { + fn: ({ appKey, settings }) => {; + const update = settings ? {method: 'PUT', body: JSON.stringify(settings)} : {}; + return client.json(`/persons/me/apps/${appKey}/settings`, { headers: { 'Api-Version': '1.0', }, - }; - if (settings) { - init.method = 'PUT'; - init.body = JSON.stringify(settings); - } - return client.json(`/persons/me/apps/${appKey}/settings`, init); + ...update, + selector: async (res: Response) => { + /** return empty settings if app not registered */ + if (res.status === 404) { + return Promise.resolve({}); + } + return res.json(); + }, + }); }, }, key: (args) => JSON.stringify(args), From aed1e5c24038fbf40b254c6acc10308ae296418b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Eikeland?= Date: Mon, 18 Nov 2024 09:12:22 +0100 Subject: [PATCH 07/12] chore: update changeset and code comments --- .changeset/large-beds-jog.md | 1 - packages/modules/app/src/AppClient.ts | 10 +++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.changeset/large-beds-jog.md b/.changeset/large-beds-jog.md index 7ddbeb5b60..373c56aa37 100644 --- a/.changeset/large-beds-jog.md +++ b/.changeset/large-beds-jog.md @@ -7,7 +7,6 @@ 1. **AppClient.ts** - Added `updateAppSettings` method to set app settings by appKey. - - Initialized `#setSettings` query in the constructor. 2. **AppModuleProvider.ts** - Added `updateAppSettings` method to update app settings. diff --git a/packages/modules/app/src/AppClient.ts b/packages/modules/app/src/AppClient.ts index 6ad5a4a9c6..95d662a87e 100644 --- a/packages/modules/app/src/AppClient.ts +++ b/packages/modules/app/src/AppClient.ts @@ -108,7 +108,7 @@ export class AppClient implements IAppClient { selector: async (res: Response) => { /** Return empyt settings if app is not registerred */ if (res.status === 404) { - return {} + return {}; } const body = await res.json(); return body; @@ -137,8 +137,12 @@ export class AppClient implements IAppClient { this.#settings = new Query({ client: { - fn: ({ appKey, settings }) => {; - const update = settings ? {method: 'PUT', body: JSON.stringify(settings)} : {}; + fn: ({ appKey, settings }) => { + // is settings construct a push request + const update = settings + ? { method: 'PUT', body: JSON.stringify(settings) } + : {}; + return client.json(`/persons/me/apps/${appKey}/settings`, { headers: { 'Api-Version': '1.0', From fa40d7e7dcb91305443cd0f856ed624e6867cb7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Eikeland?= Date: Mon, 18 Nov 2024 13:12:13 +0100 Subject: [PATCH 08/12] fix(cli): vite-proxy for persons/me/settings with own use proxy path --- packages/cli/src/bin/create-dev-serve.ts | 1 + .../src/lib/plugins/app-proxy/app-proxy-plugin.ts | 12 +++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/bin/create-dev-serve.ts b/packages/cli/src/bin/create-dev-serve.ts index 3a3e4db6dc..e8f2656414 100644 --- a/packages/cli/src/bin/create-dev-serve.ts +++ b/packages/cli/src/bin/create-dev-serve.ts @@ -112,6 +112,7 @@ export const createDevServer = async (options: { generateConfig, generateManifest, manifestPath: `persons/me/apps/${appKey}`, + settingsPath: `persons/me/apps/${appKey}/settings`, }, }), // Restart the server when config changes or the dev portal source is updated diff --git a/packages/cli/src/lib/plugins/app-proxy/app-proxy-plugin.ts b/packages/cli/src/lib/plugins/app-proxy/app-proxy-plugin.ts index 163dad0842..d9dee913c5 100644 --- a/packages/cli/src/lib/plugins/app-proxy/app-proxy-plugin.ts +++ b/packages/cli/src/lib/plugins/app-proxy/app-proxy-plugin.ts @@ -65,6 +65,13 @@ export type AppProxyPluginOptions = { */ manifestPath?: string; + /** + * string path to the app settingse + * @example `persons/me/apps/${app.key}/settings` + * @default `apps/${app.key}/settings` + */ + settingsPath?: string; + /** * string path to the app bundle * @default `bundles/apps/${app.key}/${app.version}` @@ -159,9 +166,12 @@ export const appProxyPlugin = (options: AppProxyPluginOptions): Plugin => { res.end(JSON.stringify(await app.generateConfig())); }); + const settingsPath = join(proxyPath, app.settingsPath ?? `apps/${app.key}/settings`); + server.middlewares.use(settingsPath, async (_req, _res, next) => next()); + // serve app manifest if request matches the current app const manifestPath = [proxyPath, app.manifestPath ?? `apps/${app.key}`].join('/'); - server.middlewares.use(manifestPath, async (_req, res) => { + server.middlewares.use(async (_req, res) => { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(await app.generateManifest())); }); From 5951a4af98926234b231d7a620e8fd4af9d1647b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Eikeland?= Date: Mon, 18 Nov 2024 15:32:25 +0100 Subject: [PATCH 09/12] fix(app-settings): improvements to flows --- cookbooks/app-react/src/App.tsx | 42 ++++-- packages/cli/src/bin/create-dev-serve.ts | 1 - .../lib/plugins/app-proxy/app-proxy-plugin.ts | 23 ++-- packages/modules/app/src/AppClient.ts | 8 -- packages/modules/app/src/app/App.ts | 121 +++++++----------- packages/modules/app/src/app/actions.ts | 7 +- packages/modules/app/src/app/flows.ts | 19 ++- .../react/app/src/settings/useAppSettings.ts | 6 +- 8 files changed, 103 insertions(+), 124 deletions(-) diff --git a/cookbooks/app-react/src/App.tsx b/cookbooks/app-react/src/App.tsx index 3bb995a073..d45bd86ba3 100644 --- a/cookbooks/app-react/src/App.tsx +++ b/cookbooks/app-react/src/App.tsx @@ -1,16 +1,30 @@ -export const App = () => ( -
-

🚀 Hello Fusion 😎

-
-); +import { useAppSettings } from '@equinor/fusion-framework-react-app/settings'; +import { useCallback } from 'react'; + +export const App = () => { + const { settings, updateSettings } = useAppSettings(); + + console.log('settings', settings); + + const updateSettingsCallback = useCallback(() => { + updateSettings({ theme: 'dark', date: new Date().toISOString() }); + }, []); + + return ( +
+

🚀 Hello Fusion 😎

+ +
+ ); +}; export default App; diff --git a/packages/cli/src/bin/create-dev-serve.ts b/packages/cli/src/bin/create-dev-serve.ts index e8f2656414..3a3e4db6dc 100644 --- a/packages/cli/src/bin/create-dev-serve.ts +++ b/packages/cli/src/bin/create-dev-serve.ts @@ -112,7 +112,6 @@ export const createDevServer = async (options: { generateConfig, generateManifest, manifestPath: `persons/me/apps/${appKey}`, - settingsPath: `persons/me/apps/${appKey}/settings`, }, }), // Restart the server when config changes or the dev portal source is updated diff --git a/packages/cli/src/lib/plugins/app-proxy/app-proxy-plugin.ts b/packages/cli/src/lib/plugins/app-proxy/app-proxy-plugin.ts index d9dee913c5..6819012c78 100644 --- a/packages/cli/src/lib/plugins/app-proxy/app-proxy-plugin.ts +++ b/packages/cli/src/lib/plugins/app-proxy/app-proxy-plugin.ts @@ -65,13 +65,6 @@ export type AppProxyPluginOptions = { */ manifestPath?: string; - /** - * string path to the app settingse - * @example `persons/me/apps/${app.key}/settings` - * @default `apps/${app.key}/settings` - */ - settingsPath?: string; - /** * string path to the app bundle * @default `bundles/apps/${app.key}/${app.version}` @@ -166,14 +159,18 @@ export const appProxyPlugin = (options: AppProxyPluginOptions): Plugin => { res.end(JSON.stringify(await app.generateConfig())); }); - const settingsPath = join(proxyPath, app.settingsPath ?? `apps/${app.key}/settings`); - server.middlewares.use(settingsPath, async (_req, _res, next) => next()); - + // TODO: AppSettings should be saved in memory localy // serve app manifest if request matches the current app const manifestPath = [proxyPath, app.manifestPath ?? `apps/${app.key}`].join('/'); - server.middlewares.use(async (_req, res) => { - res.setHeader('content-type', 'application/json'); - res.end(JSON.stringify(await app.generateManifest())); + server.middlewares.use(async (req, res, next) => { + // We only want to match the exact path + const [requestPath] = (req.url ?? '').split('?'); + if (requestPath === manifestPath) { + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify(await app.generateManifest())); + } else { + next(); + } }); // serve local bundles if request matches the current app and version diff --git a/packages/modules/app/src/AppClient.ts b/packages/modules/app/src/AppClient.ts index 95d662a87e..fad1b9e1b4 100644 --- a/packages/modules/app/src/AppClient.ts +++ b/packages/modules/app/src/AppClient.ts @@ -106,10 +106,6 @@ export class AppClient implements IAppClient { 'Api-Version': '1.0', }, selector: async (res: Response) => { - /** Return empyt settings if app is not registerred */ - if (res.status === 404) { - return {}; - } const body = await res.json(); return body; }, @@ -149,10 +145,6 @@ export class AppClient implements IAppClient { }, ...update, selector: async (res: Response) => { - /** return empty settings if app not registered */ - if (res.status === 404) { - return Promise.resolve({}); - } return res.json(); }, }); diff --git a/packages/modules/app/src/app/App.ts b/packages/modules/app/src/app/App.ts index 73428366cf..5e3749d809 100644 --- a/packages/modules/app/src/app/App.ts +++ b/packages/modules/app/src/app/App.ts @@ -11,14 +11,14 @@ import { FlowSubject, Observable } from '@equinor/fusion-observable'; import type { AppModuleProvider } from '../AppModuleProvider'; import { combineLatest, - filter, - firstValueFrom, - lastValueFrom, - map, of, - OperatorFunction, + type OperatorFunction, Subscription, + firstValueFrom, + lastValueFrom, } from 'rxjs'; +import { defaultIfEmpty, filter, map } from 'rxjs/operators'; + import { EventModule } from '@equinor/fusion-framework-module-event'; import { AnyModule, ModuleType } from '@equinor/fusion-framework-module'; import { createState } from './create-state'; @@ -54,12 +54,6 @@ export interface IApp< */ get config$(): Observable>; - /** - * Observable that emits the settings of the app. - * @returns An Observable that emits the app settings. - */ - get settings$(): Observable; - /** * Returns an observable stream of the loaded app script instance. * @returns {Observable} The observable stream of app script modules. @@ -72,6 +66,12 @@ export interface IApp< */ get instance$(): Observable>; + /** + * Observable that emits the settings of the app. + * @returns An Observable that emits the app setttings. + */ + get settings$(): Observable; + /** * Gets the current state of the Application. * @returns The current state of the Application. @@ -102,18 +102,6 @@ export interface IApp< */ get config(): AppConfig | undefined; - /** - * Gets the settings of the app. - * @returns The settings object or undefined if no settings is set. - */ - get settings(): AppSettings | undefined; - - /** - * Gets the settings of the app asyncronously. - * @returns A promise that resolves to the AppSettings. - */ - get settingsAsync(): Promise | undefined; - /** * Retrieves the configuration asynchronously. * @returns A promise that resolves to the AppConfig. @@ -139,7 +127,6 @@ export interface IApp< manifest: AppManifest; script: AppScriptModule; config: AppConfig; - settings: AppSettings; }>; /** @@ -147,11 +134,6 @@ export interface IApp< */ loadConfig(): void; - /** - * Loads the app settings. - */ - loadSettings(): void; - /** * Loads the app manifest. */ @@ -234,6 +216,8 @@ export interface IApp< getAppModuleAsync(allow_cache?: boolean): Promise; } +const fallbackSettings: AppSettings = {}; + // TODO make streams distinct until changed from state // eslint-disable-next-line @typescript-eslint/no-explicit-any export class App< @@ -259,23 +243,25 @@ export class App< ); } - get settings$(): Observable { + get modules$(): Observable { return this.#state.pipe( - map(({ settings }) => settings as AppSettings), + map(({ modules }) => modules), filterEmpty(), ); } - get modules$(): Observable { + get instance$(): Observable> { return this.#state.pipe( - map(({ modules }) => modules), + map(({ instance }) => instance as AppModulesInstance), filterEmpty(), ); } - get instance$(): Observable> { + get settings$(): Observable { + this.#state.next(actions.fetchSettings(this.appKey)); return this.#state.pipe( - map(({ instance }) => instance as AppModulesInstance), + map(({ settings }) => settings), + defaultIfEmpty(fallbackSettings), filterEmpty(), ); } @@ -307,14 +293,6 @@ export class App< return firstValueFrom(this.config$); } - get settings(): AppSettings | undefined { - return this.state.settings as AppSettings; - } - - get settingsAsync(): Promise { - return firstValueFrom(this.settings$); - } - get instance(): AppModulesInstance | undefined { return this.#state.value.instance as AppModulesInstance; } @@ -539,7 +517,6 @@ export class App< manifest: AppManifest; script: AppScriptModule; config: AppConfig; - settings: AppSettings; }> { return new Observable((subscriber) => { // dispatch initialize action to indicate that the application is initializing @@ -550,15 +527,13 @@ export class App< this.getManifest(), this.getAppModule(), this.getConfig(), - this.getSettings(), ]).subscribe({ - next: ([manifest, script, config, settings]) => + next: ([manifest, script, config]) => // emit the manifest, script, and config to the subscriber subscriber.next({ manifest, script, config, - settings, }), error: (err) => { // emit error and complete the stream @@ -583,10 +558,6 @@ export class App< }); } - public loadSettings() { - this.#state.next(actions.fetchSettings(this.appKey)); - } - public loadManifest(update?: boolean) { this.#state.next(actions.fetchManifest(this.appKey, update)); } @@ -700,7 +671,7 @@ export class App< }), ); - this.loadSettings(); + this.#state.next(actions.fetchSettings(this.appKey)); }); } @@ -711,36 +682,32 @@ export class App< } public updateSettings(settings: AppSettings): Observable { - return new Observable((subscriber) => { - // when stream closes, dispose of subscription to change of state settings - subscriber.add( - // monitor changes to state changes of settings and emit to subscriber - this.#state.addEffect(actions.updateSettings.type, ({ payload }) => { - subscriber.next(payload); - }), - ); + const action = actions.updateSettings(settings); - // when stream closes, dispose of subscription to fetch settings + const updateActions$ = this.#state.action$.pipe( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + filter((a) => action.meta.id === a.meta.id), + ); + + return new Observable((subscriber) => { subscriber.add( - // monitor success of fetching settings and emit to subscriber - this.#state.addEffect(actions.updateSettings.success.type, ({ payload }) => { - // application settings loaded, emit to subscriber and complete the stream - subscriber.next(payload); - subscriber.complete(); - }), + updateActions$ + .pipe(filter((a) => a.type === actions.updateSettings.success.type)) + .subscribe(subscriber), ); - // when stream closes, dispose of subscription to fetch settings subscriber.add( - // monitor failure of fetching settings and emit error to subscriber - this.#state.addEffect(actions.updateSettings.failure.type, ({ payload }) => { - // application settings failed to load, emit error and complete the stream - subscriber.error( - Error('failed to load application settings', { - cause: payload, - }), - ); - }), + updateActions$ + .pipe(filter((a) => a.type === actions.updateSettings.failure.type)) + .subscribe(({ payload }) => { + // application settings failed to save, emit error and complete the stream + subscriber.error( + Error('failed to load application settings', { + cause: payload, + }), + ); + }), ); this.#state.next(actions.updateSettings(settings)); diff --git a/packages/modules/app/src/app/actions.ts b/packages/modules/app/src/app/actions.ts index 8f1701959e..48c1463e35 100644 --- a/packages/modules/app/src/app/actions.ts +++ b/packages/modules/app/src/app/actions.ts @@ -11,6 +11,7 @@ import type { AppScriptModule, AppSettings, } from '../types'; +import { v4 as uuid } from 'uuid'; const createActions = () => ({ /** Manifest loading */ @@ -49,9 +50,9 @@ const createActions = () => ({ /** Updatings settings */ updateSettings: createAsyncAction( 'update_settings', - (settings: AppSettings) => ({ payload: { settings } }), - (settings: AppSettings) => ({ payload: settings }), - (error: unknown) => ({ payload: error }), + (settings: AppSettings) => ({ payload: { settings }, meta: { id: uuid() } }), + (settings: AppSettings, meta: { id: string }) => ({ payload: settings, meta }), + (error: unknown, meta: { id: string }) => ({ payload: error, meta }), ), /** App loading */ // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/modules/app/src/app/flows.ts b/packages/modules/app/src/app/flows.ts index 6f98f74182..0a30ec3294 100644 --- a/packages/modules/app/src/app/flows.ts +++ b/packages/modules/app/src/app/flows.ts @@ -1,5 +1,14 @@ import { from, of, concat } from 'rxjs'; -import { catchError, filter, last, map, share, switchMap, withLatestFrom } from 'rxjs/operators'; +import { + catchError, + concatMap, + filter, + last, + map, + share, + switchMap, + withLatestFrom, +} from 'rxjs/operators'; import { actions } from './actions'; @@ -149,15 +158,15 @@ export const handleUpdateSettings = filter(actions.updateSettings.match), withLatestFrom(state$), // when request is received, abort any ongoing request and start new - switchMap(([action, state]) => { - const { payload } = action; + concatMap(([action, state]) => { + const { payload, meta } = action; const { appKey } = state; // set settings in provider return from(provider.updateAppSettings(appKey, payload.settings)).pipe( // allow multiple subscriptions - map((settings) => actions.updateSettings.success(settings)), - catchError((err) => of(actions.updateSettings.failure(err))), + map((settings) => actions.updateSettings.success(settings, meta)), + catchError((err) => of(actions.updateSettings.failure(err, meta))), ); }), ); diff --git a/packages/react/app/src/settings/useAppSettings.ts b/packages/react/app/src/settings/useAppSettings.ts index 37cfbce497..9d2ec4bb87 100644 --- a/packages/react/app/src/settings/useAppSettings.ts +++ b/packages/react/app/src/settings/useAppSettings.ts @@ -10,17 +10,17 @@ import { useCurrentApp } from '@equinor/fusion-framework-react/app'; */ export const useAppSettings = (): { settings: AppSettings | undefined; - updateSettings: (settings: AppSettings) => Promise | undefined; + updateSettings: (settings: AppSettings) => void; } => { const { currentApp } = useCurrentApp(); const { value: settings } = useObservableState( - useMemo(() => currentApp?.getSettings() || EMPTY, [currentApp]), + useMemo(() => currentApp?.settings$ || EMPTY, [currentApp]), ); const updateSettings = useCallback( (settings: AppSettings) => { - return currentApp?.updateSettingsAsync(settings); + currentApp?.updateSettingsAsync(settings); }, [currentApp], ); From 14ebc5fc95c97dae29b1efe771c52b15a3c0ae2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Eikeland?= Date: Tue, 19 Nov 2024 10:33:52 +0100 Subject: [PATCH 10/12] fix(app-module): faiilover when meta.id is not set. vite proxy handles manifest only --- packages/cli/src/lib/plugins/app-proxy/app-proxy-plugin.ts | 6 +++--- packages/modules/app/src/app/App.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/lib/plugins/app-proxy/app-proxy-plugin.ts b/packages/cli/src/lib/plugins/app-proxy/app-proxy-plugin.ts index 6819012c78..6903a33ea7 100644 --- a/packages/cli/src/lib/plugins/app-proxy/app-proxy-plugin.ts +++ b/packages/cli/src/lib/plugins/app-proxy/app-proxy-plugin.ts @@ -159,7 +159,6 @@ export const appProxyPlugin = (options: AppProxyPluginOptions): Plugin => { res.end(JSON.stringify(await app.generateConfig())); }); - // TODO: AppSettings should be saved in memory localy // serve app manifest if request matches the current app const manifestPath = [proxyPath, app.manifestPath ?? `apps/${app.key}`].join('/'); server.middlewares.use(async (req, res, next) => { @@ -168,9 +167,10 @@ export const appProxyPlugin = (options: AppProxyPluginOptions): Plugin => { if (requestPath === manifestPath) { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(await app.generateManifest())); - } else { - next(); + return; } + + next(); }); // serve local bundles if request matches the current app and version diff --git a/packages/modules/app/src/app/App.ts b/packages/modules/app/src/app/App.ts index 5e3749d809..83d947364f 100644 --- a/packages/modules/app/src/app/App.ts +++ b/packages/modules/app/src/app/App.ts @@ -687,7 +687,7 @@ export class App< const updateActions$ = this.#state.action$.pipe( // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - filter((a) => action.meta.id === a.meta.id), + filter((a) => action.meta.id === a.meta?.id), ); return new Observable((subscriber) => { From 158daf077f30465c87126366ffb6311d9ff7a607 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Eikeland?= Date: Tue, 19 Nov 2024 10:54:08 +0100 Subject: [PATCH 11/12] chore: adding uuid to app module --- packages/modules/app/package.json | 1 + pnpm-lock.yaml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/packages/modules/app/package.json b/packages/modules/app/package.json index 34a2e06aaa..cb98db0890 100644 --- a/packages/modules/app/package.json +++ b/packages/modules/app/package.json @@ -61,6 +61,7 @@ "@equinor/fusion-query": "workspace:^", "immer": "^9.0.16", "rxjs": "^7.8.1", + "uuid": "^11.0.3", "zod": "^3.23.8" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f464fed015..e685da9d34 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -886,6 +886,9 @@ importers: rxjs: specifier: ^7.8.1 version: 7.8.1 + uuid: + specifier: ^11.0.3 + version: 11.0.3 zod: specifier: ^3.23.8 version: 3.23.8 From a08dd60e6758a8aa4f47112fd55632b5919e03e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Eikeland?= Date: Fri, 22 Nov 2024 10:28:45 +0100 Subject: [PATCH 12/12] feat(react-app): hook for useAppSettings --- packages/react/app/src/settings/dot-path.ts | 13 +++ packages/react/app/src/settings/index.ts | 1 + .../react/app/src/settings/useAppSetting.ts | 92 +++++++++++++++++++ 3 files changed, 106 insertions(+) create mode 100644 packages/react/app/src/settings/dot-path.ts create mode 100644 packages/react/app/src/settings/useAppSetting.ts diff --git a/packages/react/app/src/settings/dot-path.ts b/packages/react/app/src/settings/dot-path.ts new file mode 100644 index 0000000000..bf4fa809ad --- /dev/null +++ b/packages/react/app/src/settings/dot-path.ts @@ -0,0 +1,13 @@ +export type DotPath = { + [Key in keyof TObject & string]: TObject[Key] extends object + ? `${Key}` | `${Key}.${DotPath}` + : `${Key}`; +}[keyof TObject & string]; + +export type DotPathType = TPath extends keyof TType + ? TType[TPath] + : TPath extends `${infer K}.${infer R}` + ? K extends keyof TType + ? DotPathType + : never + : never; diff --git a/packages/react/app/src/settings/index.ts b/packages/react/app/src/settings/index.ts index 76e8d6811c..94736d1f73 100644 --- a/packages/react/app/src/settings/index.ts +++ b/packages/react/app/src/settings/index.ts @@ -1 +1,2 @@ +export { useAppSetting } from './useAppSetting'; export { useAppSettings } from './useAppSettings'; diff --git a/packages/react/app/src/settings/useAppSetting.ts b/packages/react/app/src/settings/useAppSetting.ts new file mode 100644 index 0000000000..2c17df448a --- /dev/null +++ b/packages/react/app/src/settings/useAppSetting.ts @@ -0,0 +1,92 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { useCallback, useMemo } from 'react'; +import { useObservableState } from '@equinor/fusion-observable/react'; +import { EMPTY, from, lastValueFrom, type Observable } from 'rxjs'; +import { map, withLatestFrom } from 'rxjs/operators'; +import { useCurrentApp } from '@equinor/fusion-framework-react/app'; +import type { DotPath, DotPathType } from './dot-path'; +import { type AppSettings } from '@equinor/fusion-framework-module-app'; + +function getByDotPath>( + obj: T, + path: DotPath, +): DotPathType { + return path.split('.').reduce((acc, part) => acc && acc[part], obj) as DotPathType; +} + +function setByDotPath, TProp extends DotPath>( + obj: T, + path: TProp, + value: DotPathType, +): T { + // Split the property path into individual parts + const props = typeof path === 'string' ? path.split('.') : path; + + // Get the first property in the path + const prop = props.shift(); + + // If there is a property to process + if (prop) { + // Create the nested object if it doesn't exist + if (!obj[prop]) { + (obj as any)[prop] = {}; + } + + // If there are more properties in the path, recurse + props.length + ? setByDotPath(obj[prop] as Record, props.join('.'), value) + : Object.assign(obj, { [prop]: value }); + } + + // Return the modified object + return obj as T; +} + +/** + * Hook for handling a users app settings + * @returns {settings, updateSettings} Methods for getting and setting settings. + */ +export const useAppSetting = < + TSettings extends Record = AppSettings, + TProp extends DotPath = TSettings[keyof TSettings], +>( + prop: TProp, +): { + setting: DotPathType | undefined; + updateSettings: (value: DotPathType) => void; +} => { + const { currentApp } = useCurrentApp(); + + const selector = useMemo(() => { + return map((settings: TSettings) => getByDotPath(settings, prop)); + }, [prop]); + + const { value: setting } = useObservableState>( + useMemo( + () => (currentApp?.settings$ as Observable).pipe(selector) || EMPTY, + [currentApp, selector], + ), + ); + + const updateSettings = useCallback( + async (value: DotPathType) => { + const newSettings = await lastValueFrom( + from(value).pipe( + withLatestFrom(currentApp?.settings$ || EMPTY), + map(([value, settings]) => { + return setByDotPath(settings, prop, value as DotPathType); + }), + ), + ); + currentApp?.updateSettings(newSettings); + }, + [currentApp, prop], + ); + + return { + setting, + updateSettings, + }; +}; + +export default useAppSetting;