diff --git a/.changeset/beige-ducks-retire.md b/.changeset/beige-ducks-retire.md new file mode 100644 index 0000000000..77204cee65 --- /dev/null +++ b/.changeset/beige-ducks-retire.md @@ -0,0 +1,5 @@ +--- +'@equinor/fusion-framework-cookbook-app-react-settings': major +--- + +Created a cookbook for using settings diff --git a/.changeset/breezy-suits-watch.md b/.changeset/breezy-suits-watch.md new file mode 100644 index 0000000000..46b46b508d --- /dev/null +++ b/.changeset/breezy-suits-watch.md @@ -0,0 +1,9 @@ +--- +'@equinor/fusion-framework-cli': minor +--- + +Created a plugin for handling application settings. This plugin allows retrieving and setting application settings when developing locally by intercepting the request to the settings API and returning the local settings instead. Settings are stored in memory and are not persisted, which means the CLI will always provide settings as if the user has never set them before. By restarting the CLI, the settings will be lost. This plugin is useful for testing and development purposes. + +Also added a utility function `parseJsonFromRequest` to parse JSON from a request body. This function is used in the plugin to parse the `PUT` request body and update the settings accordingly. + +The default development server has enabled this plugin by default and confiuigred it to intercept the settings API on `/apps-proxy/persons/me/apps/${CURRENT_APP_KEY}/settings` diff --git a/.changeset/fast-months-allow.md b/.changeset/fast-months-allow.md new file mode 100644 index 0000000000..e4df2bd63a --- /dev/null +++ b/.changeset/fast-months-allow.md @@ -0,0 +1,29 @@ +--- +'@equinor/fusion-framework-module-app': minor +--- + +Added `updateSetting` and `updateSettingAsync` to the `App` class. This allows updating a setting in settings without the need to handle the settings object directly. This wil ensure that the settings are mutated correctly. + +```ts +const app = new App(); +// the app class will fetch the latest settings before updating the setting +app.updateSetting('property', 'value'); +``` + +example of flux state of settings: + +```ts +const app = new App(); +const settings = app.getSettings(); + +setTimeout(() => { + settings.foo = 'foo'; + app.updateSettingsAsync(settings); +}, 1000); + +setTimeout(() => { + settings.bar = 'bar'; + app.updateSettingsAsync(settings); + // foo is now reset to its original value, which is not what we want +}, 2000); +``` diff --git a/.changeset/late-numbers-trade.md b/.changeset/late-numbers-trade.md new file mode 100644 index 0000000000..08e6c83b72 --- /dev/null +++ b/.changeset/late-numbers-trade.md @@ -0,0 +1,5 @@ +--- +'@equinor/fusion-framework-docs': minor +--- + +Added doc for app settings diff --git a/cookbooks/app-react-settings/README.md b/cookbooks/app-react-settings/README.md new file mode 100644 index 0000000000..81de620425 --- /dev/null +++ b/cookbooks/app-react-settings/README.md @@ -0,0 +1,3 @@ +# React cookbook + +App for cooking settings with Fusion-Framework and React diff --git a/cookbooks/app-react-settings/package.json b/cookbooks/app-react-settings/package.json new file mode 100644 index 0000000000..f5e1cce619 --- /dev/null +++ b/cookbooks/app-react-settings/package.json @@ -0,0 +1,24 @@ +{ + "name": "@equinor/fusion-framework-cookbook-app-react-settings", + "version": "0.0.0", + "description": "", + "private": true, + "type": "module", + "main": "src/index.ts", + "scripts": { + "build": "fusion-framework-cli app build", + "dev": "fusion-framework-cli app dev", + "docker": "cd .. && sh docker-script.sh app-react" + }, + "author": "", + "license": "ISC", + "devDependencies": { + "@equinor/fusion-framework-cli": "workspace:^", + "@equinor/fusion-framework-react-app": "workspace:^", + "@types/react": "^18.2.50", + "@types/react-dom": "^18.2.7", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "typescript": "^5.5.4" + } +} diff --git a/cookbooks/app-react-settings/src/App.tsx b/cookbooks/app-react-settings/src/App.tsx new file mode 100644 index 0000000000..7b5b8531cf --- /dev/null +++ b/cookbooks/app-react-settings/src/App.tsx @@ -0,0 +1,90 @@ +import { useCallback, useState } from 'react'; +import { useAppSettings, useAppSetting } from '@equinor/fusion-framework-react-app/settings'; + +type MyAppSettings = { + theme: 'none' | 'light' | 'dark'; + size: 'small' | 'medium' | 'large'; + fancy: boolean; +}; + +declare module '@equinor/fusion-framework-react-app/settings' { + interface AppSettings extends MyAppSettings {} +} + +export const App = () => { + const [isLoading, setIsLoading] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + const [settingsHooks] = useState(() => ({ + onLoading: setIsLoading, + onUpdating: setIsUpdating, + })); + + const [theme, setTheme] = useAppSetting('theme', 'none', settingsHooks); + const [size, setSize] = useAppSetting('size', 'medium', settingsHooks); + const [fancy, setFancy] = useAppSetting('fancy', false); + + const onFancyChange = useCallback(() => setFancy((isFancy) => !isFancy), [setFancy]); + + const [settings] = useAppSettings(); + + return ( +
+
+

🚀 Hello Fusion Settings 🔧

+
+ Theme: + +
+
+ Size: + +
+
+ Size: + +
+
+ App settings: +
+
{JSON.stringify(settings, null, 2)}
+
+
+
+ ); +}; + +export default App; diff --git a/cookbooks/app-react-settings/src/config.ts b/cookbooks/app-react-settings/src/config.ts new file mode 100644 index 0000000000..c723406669 --- /dev/null +++ b/cookbooks/app-react-settings/src/config.ts @@ -0,0 +1,5 @@ +import type { AppModuleInitiator } from '@equinor/fusion-framework-react-app'; + +export const configure: AppModuleInitiator = () => {}; + +export default configure; diff --git a/cookbooks/app-react-settings/src/index.ts b/cookbooks/app-react-settings/src/index.ts new file mode 100644 index 0000000000..7e07c40196 --- /dev/null +++ b/cookbooks/app-react-settings/src/index.ts @@ -0,0 +1,30 @@ +import { createElement } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { ComponentRenderArgs, makeComponent } from '@equinor/fusion-framework-react-app'; + +import configure from './config'; +import App from './App'; + +/** create a render component */ +const appComponent = createElement(App); + +/** create React render root component */ +const createApp = (args: ComponentRenderArgs) => makeComponent(appComponent, args, configure); + +/** Render function */ +export const renderApp = (el: HTMLElement, args: ComponentRenderArgs) => { + /** make render element */ + const app = createApp(args); + + /** create render root from provided element */ + const root = createRoot(el); + + /** render Application */ + root.render(createElement(app)); + + /** Teardown */ + return () => root.unmount(); +}; + +export default renderApp; diff --git a/cookbooks/app-react-settings/tsconfig.json b/cookbooks/app-react-settings/tsconfig.json new file mode 100644 index 0000000000..4c1b0a8b45 --- /dev/null +++ b/cookbooks/app-react-settings/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "jsx": "react-jsx", + }, + "references": [ + { + "path": "../../packages/react/app" + }, + { + "path": "../../packages/cli" + }, + ], + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "lib" + ] +} \ No newline at end of file diff --git a/packages/cli/src/bin/create-dev-serve.ts b/packages/cli/src/bin/create-dev-serve.ts index 3a3e4db6dc..48f06955f7 100644 --- a/packages/cli/src/bin/create-dev-serve.ts +++ b/packages/cli/src/bin/create-dev-serve.ts @@ -15,6 +15,7 @@ import deepmerge from 'deepmerge/index.js'; import ViteRestart from 'vite-plugin-restart'; import { appProxyPlugin } from '../lib/plugins/app-proxy/app-proxy-plugin.js'; +import { appSettingsPlugin } from '../lib/plugins/app-settings/index.js'; import { externalPublicPlugin } from '../lib/plugins/external-public/external-public-plugin.js'; import { supportedExt, type ConfigExecuterEnv } from '../lib/utils/config.js'; @@ -99,6 +100,9 @@ export const createDevServer = async (options: { plugins: [ // Serve the dev portal as static files externalPublicPlugin(devPortalPath), + appSettingsPlugin({ + match: `/apps-proxy/persons/me/apps/${appKey}/settings`, + }), // Proxy requests to the app server appProxyPlugin({ proxy: { diff --git a/packages/cli/src/lib/plugins/app-settings/index.ts b/packages/cli/src/lib/plugins/app-settings/index.ts new file mode 100644 index 0000000000..1ac90f763b --- /dev/null +++ b/packages/cli/src/lib/plugins/app-settings/index.ts @@ -0,0 +1,58 @@ +import { type Plugin } from 'vite'; + +import parseJsonFromRequest from '../../utils/parse-json-request.js'; + +/** + * Options for configuring the AppSettingsPlugin. + */ +export interface AppSettingsPluginOptions { + /** + * A string or regular expression to match specific settings. + * If provided, only settings that match this pattern will be considered. + */ + match?: string | RegExp; + + /** + * A record of default settings to be used if no other settings are provided. + * The keys are setting names and the values are the default values for those settings. + */ + defaultSettings?: Record; +} + +/** + * This plugin provides a simple way to manage application settings in a local development environment. + * + * This plugin will cache the settings in memory and respond to `PUT` requests to update the settings. + * Restarting the development server will reset the settings to the default values. + * + * @param options - The options for configuring the app settings plugin. + * @returns A Vite Plugin object that can be used to configure a server. + * + * The plugin provides the following functionality: + * - Matches requests based on a specified path pattern. + * - Handles `PUT` requests to update application settings. + * - Responds with the current application settings in JSON format. + */ +export function appSettingsPlugin(options: AppSettingsPluginOptions): Plugin { + let appSettings = options.defaultSettings ?? {}; + const pathMatch = new RegExp(options.match ?? '/persons/me/apps/.*/settings'); + return { + name: 'app-settings', + configureServer(server) { + server.middlewares.use(async (req, res, next) => { + if (!req.url?.match(pathMatch)) { + return next(); + } + + if (req.method === 'PUT') { + appSettings = await parseJsonFromRequest(req); + } + + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify(appSettings)); + }); + }, + }; +} + +export default appSettingsPlugin; diff --git a/packages/cli/src/lib/utils/parse-json-request.ts b/packages/cli/src/lib/utils/parse-json-request.ts new file mode 100644 index 0000000000..6557c9359d --- /dev/null +++ b/packages/cli/src/lib/utils/parse-json-request.ts @@ -0,0 +1,19 @@ +import { IncomingMessage } from 'node:http'; + +/** + * Extracts and parses JSON data from an incoming HTTP request. + * + * @param req - The incoming HTTP request object. + * @returns A promise that resolves to a record containing the parsed JSON data. + * @throws Will reject the promise if there is an error during data reception or JSON parsing. + */ +export async function parseJsonFromRequest(req: IncomingMessage): Promise> { + return await new Promise>((resolve, reject) => { + let data = ''; + req.on('data', (chunk) => (data += chunk.toString())); + req.on('end', () => resolve(JSON.parse(data))); + req.on('error', reject); + }); +} + +export default parseJsonFromRequest; diff --git a/packages/modules/app/package.json b/packages/modules/app/package.json index cb98db0890..7137845519 100644 --- a/packages/modules/app/package.json +++ b/packages/modules/app/package.json @@ -59,6 +59,7 @@ "dependencies": { "@equinor/fusion-observable": "workspace:^", "@equinor/fusion-query": "workspace:^", + "fast-deep-equal": "^3.1.3", "immer": "^9.0.16", "rxjs": "^7.8.1", "uuid": "^11.0.3", diff --git a/packages/modules/app/src/AppClient.ts b/packages/modules/app/src/AppClient.ts index fad1b9e1b4..5b9f4cef23 100644 --- a/packages/modules/app/src/AppClient.ts +++ b/packages/modules/app/src/AppClient.ts @@ -1,4 +1,4 @@ -import { catchError, map, Observable, ObservableInput } from 'rxjs'; +import { catchError, map, Observable, ObservableInput, tap } from 'rxjs'; import { Query } from '@equinor/fusion-query'; import { queryValue } from '@equinor/fusion-query/operators'; @@ -78,8 +78,11 @@ export class AppClient implements IAppClient { #manifests: Query; #config: Query; #settings: Query; + #client: IHttpClient; constructor(client: IHttpClient) { + this.#client = client; + const expire = 1 * 60 * 1000; this.#manifest = new Query({ client: { @@ -131,26 +134,17 @@ export class AppClient implements IAppClient { expire, }); - this.#settings = new Query({ + this.#settings = new Query({ client: { - 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`, { + fn: ({ appKey }) => { + return client.json(`/persons/me/apps/${appKey}/settings`, { headers: { 'Api-Version': '1.0', }, - ...update, - selector: async (res: Response) => { - return res.json(); - }, }); }, }, - key: (args) => JSON.stringify(args), + key: (args) => args.appKey, expire, }); } @@ -216,19 +210,29 @@ export class AppClient implements IAppClient { } updateAppSettings(args: { appKey: string; settings: AppSettings }): 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 update app settings', { cause }); - }), + const { appKey, settings } = args; + return ( + this.#client + // execute PUT request to update settings + .json$(`/persons/me/apps/${appKey}/settings`, { + method: 'PUT', + body: settings, + headers: { + 'Api-Version': '1.0', + }, + }) + .pipe( + tap((value) => { + // update cache with new settings + this.#settings.mutate( + { appKey }, + { + value, + updated: Date.now(), + }, + ); + }), + ) ); } diff --git a/packages/modules/app/src/app/App.ts b/packages/modules/app/src/app/App.ts index 83d947364f..733edcc317 100644 --- a/packages/modules/app/src/app/App.ts +++ b/packages/modules/app/src/app/App.ts @@ -17,7 +17,7 @@ import { firstValueFrom, lastValueFrom, } from 'rxjs'; -import { defaultIfEmpty, filter, map } from 'rxjs/operators'; +import { defaultIfEmpty, filter, last, map, switchMap } from 'rxjs/operators'; import { EventModule } from '@equinor/fusion-framework-module-event'; import { AnyModule, ModuleType } from '@equinor/fusion-framework-module'; @@ -25,6 +25,8 @@ import { createState } from './create-state'; import { actions, Actions } from './actions'; import { AppBundleState, AppBundleStateInitial } from './types'; +import isEqual from 'fast-deep-equal'; + import './events'; // TODO - move globally @@ -68,10 +70,16 @@ export interface IApp< /** * Observable that emits the settings of the app. - * @returns An Observable that emits the app setttings. + * @returns An Observable that emits the app settings. */ get settings$(): Observable; + /** + * Observable that emits the status of the app. + * @returns An Observable that emits the app status. + */ + get status$(): Observable; + /** * Gets the current state of the Application. * @returns The current state of the Application. @@ -178,14 +186,36 @@ export interface IApp< * @param settings The settings object to save. * @returns An observable that emits the app settings. */ - updateSettings(settings: AppSettings): Observable; + updateSettings(settings: T): 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; + updateSettingsAsync(settings: T): Promise; + + /** + * Updates a specific setting of the app. + * @param property The property to update. + * @param value The value to set. + * @returns An observable that emits the app settings. + */ + updateSetting( + property: P, + value: T[P], + ): Observable; + + /** + * Updates a specific setting of the app asynchronously. + * @param property The property to update. + * @param value The value to set. + * @returns A promise that resolves to the AppSettings. + */ + updateSettingAsync( + property: P, + value: T[P], + ): Promise; /** * Gets the app manifest. @@ -230,40 +260,39 @@ export class App< //#region === streams === get manifest$(): Observable { - return this.#state.pipe( - map(({ manifest }) => manifest), - filterEmpty(), - ); + return this.#state.select((state) => state.manifest).pipe(filterEmpty()); } get config$(): Observable> { - return this.#state.pipe( - map(({ config }) => config as AppConfig), - filterEmpty(), - ); + return this.#state + .select((state) => state.config as AppConfig, isEqual) + .pipe(filterEmpty()); } get modules$(): Observable { - return this.#state.pipe( - map(({ modules }) => modules), - filterEmpty(), - ); + return this.#state.select((state) => state.modules).pipe(filterEmpty()); } get instance$(): Observable> { - return this.#state.pipe( - map(({ instance }) => instance as AppModulesInstance), - filterEmpty(), - ); + return this.#state + .select((state) => state.instance as AppModulesInstance) + .pipe(filterEmpty()); } get settings$(): Observable { - this.#state.next(actions.fetchSettings(this.appKey)); - return this.#state.pipe( - map(({ settings }) => settings), - defaultIfEmpty(fallbackSettings), - filterEmpty(), - ); + return new Observable((subscriber) => { + this.#state.next(actions.fetchSettings(this.appKey)); + subscriber.add( + this.#state + .select((state) => state.settings, isEqual) + .pipe(filterEmpty(), defaultIfEmpty(fallbackSettings)) + .subscribe(subscriber), + ); + }); + } + + get status$(): Observable { + return this.#state.select((state) => state.status); } //#endregion @@ -629,11 +658,11 @@ export class App< return operator(this.getConfig(!allow_cache)); } - public getSettings(force_refresh = false): Observable { - return new Observable((subscriber) => { + public getSettings(force_refresh = false): Observable { + return new Observable((subscriber) => { if (this.#state.value.settings) { // emit current settings to the subscriber - subscriber.next(this.#state.value.settings); + subscriber.next(this.#state.value.settings as T); if (!force_refresh) { // since we have the settings and no force refresh, complete the stream return subscriber.complete(); @@ -644,7 +673,7 @@ export class App< subscriber.add( // monitor changes to state changes of settings and emit to subscriber this.#state.addEffect('set_settings', ({ payload }) => { - subscriber.next(payload); + subscriber.next(payload as T); }), ); @@ -653,7 +682,7 @@ export class App< // 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.next(payload as T); subscriber.complete(); }), ); @@ -675,49 +704,75 @@ export class App< }); } - public getSettingsAsync(allow_cache = true): Promise { + 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)); + return operator(this.getSettings(!allow_cache)); } - public updateSettings(settings: AppSettings): Observable { - const action = actions.updateSettings(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), - ); - + public updateSettings(settings: T): Observable { return new Observable((subscriber) => { subscriber.add( - updateActions$ - .pipe(filter((a) => a.type === actions.updateSettings.success.type)) - .subscribe(subscriber), + // monitor failure of updating settings and emit error to subscriber + this.#state.addEffect('update_settings::failure', ({ payload }) => { + // request to reset settings to source state + this.#state.next(actions.fetchSettings(this.appKey)); + + // application settings failed to save, emit error and complete the stream + subscriber.error( + Error('failed to update application settings', { + cause: payload, + }), + ); + }), ); subscriber.add( - 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, - }), - ); - }), + // monitor success of updating settings and emit to subscriber + this.#state.addEffect('update_settings::success', ({ payload }) => { + subscriber.next(payload as T); + subscriber.complete(); + }), ); - this.#state.next(actions.updateSettings(settings)); + // optimistic update of settings + this.#state.next(actions.setSettings(settings)); + + // request to update settings + this.#state.next(actions.updateSettings(this.appKey, 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 updateSettingsAsync(settings: T): Promise { + return lastValueFrom(this.updateSettings(settings)); + } + + public updateSetting( + property: P, + value: T[P], + ): Observable { + const currentSettings$ = + this.#state.value.settings === undefined + ? // if settings are not loaded, fetch settings + this.getSettings().pipe(last()) + : // if settings are loaded, use current settings + of(this.#state.value.settings); + + return currentSettings$.pipe( + // merge current settings with new value + map((settings) => ({ ...settings, [property]: value })), + // update settings + switchMap((settings) => this.updateSettings(settings as T)), + // return the updated property + map((settings) => settings[property] as T[P]), + ); + } + + public updateSettingAsync( + property: P, + value: T[P], + ): Promise { + return lastValueFrom(this.updateSetting(property, value)); } public getManifest(force_refresh = false): Observable { diff --git a/packages/modules/app/src/app/actions.ts b/packages/modules/app/src/app/actions.ts index 48c1463e35..c1bb6ca2fe 100644 --- a/packages/modules/app/src/app/actions.ts +++ b/packages/modules/app/src/app/actions.ts @@ -11,7 +11,6 @@ import type { AppScriptModule, AppSettings, } from '../types'; -import { v4 as uuid } from 'uuid'; const createActions = () => ({ /** Manifest loading */ @@ -39,21 +38,32 @@ const createActions = () => ({ (error: unknown) => ({ payload: error }), ), /** Settings loading */ - setSettings: createAction('set_settings', (settings: AppSettings) => ({ payload: settings })), + setSettings: createAction('set_settings', (settings?: AppSettings) => ({ + payload: settings, + })), /** Fetching settings */ fetchSettings: createAsyncAction( 'fetch_settings', - (key: string) => ({ payload: key }), + (appKey: string) => ({ payload: { appKey } }), (settings: AppSettings) => ({ payload: settings }), (error: unknown) => ({ payload: error }), ), - /** Updatings settings */ + /** Updating settings */ updateSettings: createAsyncAction( 'update_settings', - (settings: AppSettings) => ({ payload: { settings }, meta: { id: uuid() } }), - (settings: AppSettings, meta: { id: string }) => ({ payload: settings, meta }), - (error: unknown, meta: { id: string }) => ({ payload: error, meta }), + (appKey: string, settings: AppSettings) => ({ + payload: { appKey, settings }, + }), + (settings: AppSettings) => ({ + payload: settings, + }), + (error: unknown) => ({ + payload: error, + }), ), + updateSettingsAbort: createAction('update_settings::abort', (id: string) => ({ + payload: id, + })), /** 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 2f1deeabe1..393eb3e54f 100644 --- a/packages/modules/app/src/app/create-reducer.ts +++ b/packages/modules/app/src/app/create-reducer.ts @@ -33,9 +33,6 @@ 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/flows.ts b/packages/modules/app/src/app/flows.ts index 0a30ec3294..472cddb0f0 100644 --- a/packages/modules/app/src/app/flows.ts +++ b/packages/modules/app/src/app/flows.ts @@ -1,14 +1,5 @@ import { from, of, concat } from 'rxjs'; -import { - catchError, - concatMap, - filter, - last, - map, - share, - switchMap, - withLatestFrom, -} from 'rxjs/operators'; +import { catchError, concatMap, filter, last, map, share, switchMap } from 'rxjs/operators'; import { actions } from './actions'; @@ -116,8 +107,8 @@ export const handleFetchSettings = // 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; + switchMap(({ payload }) => { + const { appKey } = payload; // fetch settings from provider const subject = from(provider.getAppSettings(appKey)).pipe( @@ -152,24 +143,25 @@ export const handleFetchSettings = */ 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 - 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, meta)), - catchError((err) => of(actions.updateSettings.failure(err, meta))), + (action$) => { + return action$.pipe(filter(actions.updateSettings.match)).pipe( + switchMap(({ payload }) => { + const { appKey, settings } = payload; + return provider.updateAppSettings(appKey, settings).pipe( + // take the last value + last(), + // request updating of settings and dispatch success action + concatMap((updatedSettings) => + from([ + actions.setSettings(updatedSettings), + actions.updateSettings.success(updatedSettings), + ]), + ), + catchError((err) => of(actions.updateSettings.failure(err))), ); }), ); + }; /** * Handles the import application flow. diff --git a/packages/modules/app/src/app/types.ts b/packages/modules/app/src/app/types.ts index 2e246f5c3f..79ea8cbf5c 100644 --- a/packages/modules/app/src/app/types.ts +++ b/packages/modules/app/src/app/types.ts @@ -1,3 +1,4 @@ +import { ActionBaseType } from '@equinor/fusion-observable'; import type { AppManifest, AppConfig, @@ -6,6 +7,7 @@ import type { ConfigEnvironment, AppSettings, } from '../types'; +import { Actions } from './actions'; /** * Represents the state of an application bundle. @@ -27,7 +29,7 @@ export type AppBundleState< TModules = any, > = { appKey: string; - status: Set; + status: Set>; manifest?: AppManifest; config?: AppConfig; settings?: AppSettings; diff --git a/packages/modules/app/src/types.ts b/packages/modules/app/src/types.ts index 6536076fb0..2d95dfeb13 100644 --- a/packages/modules/app/src/types.ts +++ b/packages/modules/app/src/types.ts @@ -23,7 +23,9 @@ export type AppEnv; +export interface AppSettings { + [key: string]: unknown; +} // TODO: remove `report` and `launcher` when legacy apps are removed export type AppType = 'standalone' | 'report' | 'launcher' | 'template'; diff --git a/packages/react/app/src/settings/README.md b/packages/react/app/src/settings/README.md new file mode 100644 index 0000000000..2b3c32779c --- /dev/null +++ b/packages/react/app/src/settings/README.md @@ -0,0 +1,123 @@ +## Portal Settings + +> TBD + +## App Settings + +App settings are a way to store and retrieve settings that are shared across the app. The settings are stored in the configured service of the app module. + +```ts +declare module '@equinor/fusion-framework-react-app/settings' { + interface AppSettings { + theme: 'default' | 'light' | 'dark'; + mode: 'simple' | 'advanced'; + } +} +useAppSetting('theme', 'default'); + +// Explicit type the setting +useAppSetting<{notDefined: string}>('notDefined', 'not registered'); +``` + +### Example + +```tsx +const MyApp = () => { + const [ theme, setTheme ] = useAppSetting('theme', 'default'); + const [ mode, setMode ] = useAppSetting('mode', 'simple'); + + // using the setter as a callback + const toggleMode = useCallback(() => { + setMode(mode => mode === 'simple' ? 'advanced' : 'simple') + }, [setMode]); + + return ( + + + {mode === 'simple' ? : } + + ); +} +``` + +### Using all settings + +> [!WARNING] +> **Using the `setSettings` must include all settings, not just the ones you want to change.** +> prefer using `setSettings` with a callback function. + +> [!IMPORTANT] +> This is not recommended for large apps, as it will cause re-renders on every setting change. + +```tsx +const MyApp = () => { + const [ settings, setSettings ] = useAppSettings(); + + const updateTheme = useCallback( + (theme: AppSettings['theme']) => setSettings(settings => ({...settings, theme})), + [updateSettings] + ); + + return ( + + {settings.mode === 'simple' ? : } + + ); +} +``` + +### Using hook callbacks + +The `useAppSettings` and `useAppSetting` hooks can take callbacks for loading, updating, updated and error handling. + +> [!NOTE] +> These callbacks are optional and can be used to show loading spinners, error dialogs or other UI elements. +> +> We have chosen to use callbacks as parameters to the hooks, instead of returning them, to avoid unnecessary re-renders. + +> [!NOTE] +> `onUpdating` and `onLoading` refers to the global state of the settings, not the individual settings. This means that if you have multiple settings that are being updated, the `onUpdating` and `onLoading` will be true until all settings are updated. +> +> Good practice is to disable UI elements that can trigger settings updates when `onUpdating` or `onLoading` is true. + + +> [!IMPORTANT] +> Hooks must be memoized to avoid re-renders on every render. Provided callbacks are not internally memoized, to allow consumers to control implementation of these callbacks. + +```tsx + +// state and callback for loading settings +const [ loading, setLoading ] = useState(false); + +// state and callback for updating settings +const [ updating, setUpdating ] = useState(false); + +// state and callback for error handling +const [ error, setError ] = useState(null); + +// callback for when settings are updated +const onUpdated = useCallback(() => { + showSnackbar('Settings updated'); +}, [showSnackbar]); + +const [ settings, setSettings ] = useAppSettings(defaultSettings, { + onLoading: setLoading, + onUpdating: setUpdating, + onError: setError, +}); + +const updateSettings = useCallback(() => { + setSettings(/* new settings */); +}, [setSettings, onUpdated]); + +return ( + + {loading && } + {updating && } + {error && } + + +); +``` diff --git a/packages/react/app/src/settings/dot-path.ts b/packages/react/app/src/settings/dot-path.ts deleted file mode 100644 index bf4fa809ad..0000000000 --- a/packages/react/app/src/settings/dot-path.ts +++ /dev/null @@ -1,13 +0,0 @@ -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 94736d1f73..dc715bf0e7 100644 --- a/packages/react/app/src/settings/index.ts +++ b/packages/react/app/src/settings/index.ts @@ -1,2 +1,4 @@ export { useAppSetting } from './useAppSetting'; export { useAppSettings } from './useAppSettings'; + +export type { AppSettings } from '@equinor/fusion-framework-module-app'; diff --git a/packages/react/app/src/settings/useAppSetting.ts b/packages/react/app/src/settings/useAppSetting.ts index 2c17df448a..8e70928f53 100644 --- a/packages/react/app/src/settings/useAppSetting.ts +++ b/packages/react/app/src/settings/useAppSetting.ts @@ -1,92 +1,112 @@ -/* 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; -} +import { useCallback, useLayoutEffect, useMemo, useState } from 'react'; +import { BehaviorSubject, map } from 'rxjs'; -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(); +import { useCurrentApp } from '@equinor/fusion-framework-react/app'; - // 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] = {}; - } +import { useAppSettingsStatus, type AppSettingsStatusHooks } from './useAppSettingsStatus'; - // If there are more properties in the path, recurse - props.length - ? setByDotPath(obj[prop] as Record, props.join('.'), value) - : Object.assign(obj, { [prop]: value }); - } +import type { AppSettings } from '@equinor/fusion-framework-module-app'; +import { useObservableState } from '@equinor/fusion-observable/react'; - // Return the modified object - return obj as T; -} +type UpdateSettingFunction = (currentSetting: T | undefined) => O; /** - * Hook for handling a users app settings - * @returns {settings, updateSettings} Methods for getting and setting settings. + * Custom hook to manage application settings. + * + * @template TSettings - The type of the settings object. Defaults to `AppSettings`. + * @template TProp - The type of the property key in the settings object. Defaults to `keyof TSettings`. + * + * @param {TProp} prop - The property key in the settings object to manage. + * @param {TSettings[TProp]} [defaultValue] - The default value for the setting. + * @param hooks - Optional hooks to handle the status changes and errors. + * + * @returns {Array} An array containing: + * - `setting`: The current setting value or undefined. + * - `setSetting`: A function to update the setting. + * + * @example + * const { setting, setSetting } = useAppSetting('theme'); + * + * @example + * // with default value + * const { setting, setSetting } = useAppSetting('theme', 'dark'); + * + * @example + * // with hooks + * const [isLoading, setIsLoading] = useState(false); + * const [isUpdating, setIsUpdating] = useState(false); + * const [error, setError] = useState(null); + * + * const { setting, setSetting } = useAppSetting('theme', 'dark', { + * onLoading: setIsLoading, + * onUpdating: setIsUpdating, + * onError: setError, + * onUpdated: useCallback(() => console.log('Settings updated'), []) + * }); */ export const useAppSetting = < - TSettings extends Record = AppSettings, - TProp extends DotPath = TSettings[keyof TSettings], + TSettings extends Record = AppSettings, + TProp extends keyof TSettings = keyof TSettings, >( prop: TProp, -): { - setting: DotPathType | undefined; - updateSettings: (value: DotPathType) => void; -} => { - const { currentApp } = useCurrentApp(); + defaultValue?: TSettings[TProp], + hooks?: AppSettingsStatusHooks & { + onError?: (error: Error | null) => void; + onUpdated?: () => void; + }, +): [ + TSettings[TProp] | undefined, + (update: TSettings[TProp] | UpdateSettingFunction) => void, +] => { + const [{ onError, onUpdated, onLoading, onUpdating }] = useState(() => hooks ?? {}); - const selector = useMemo(() => { - return map((settings: TSettings) => getByDotPath(settings, prop)); - }, [prop]); + const { currentApp = null } = useCurrentApp(); - const { value: setting } = useObservableState>( - useMemo( - () => (currentApp?.settings$ as Observable).pipe(selector) || EMPTY, - [currentApp, selector], - ), - ); + // create a subject to manage the setting value + const subject = useMemo(() => { + return new BehaviorSubject(defaultValue); + // Only create a new subject when the current app changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentApp]); + + useLayoutEffect(() => { + const sub = currentApp?.settings$ + .pipe(map((settings) => (settings as TSettings)[prop])) + .subscribe(subject); + return () => sub?.unsubscribe(); + }, [currentApp, subject, prop]); + + // subscribe to the setting value + const { value: setting } = useObservableState(subject); - 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); + // update function + const setSetting = useCallback( + (update: TSettings[TProp] | UpdateSettingFunction) => { + if (!currentApp) { + return onError?.(new Error('App is not available')); + } + + // resolve setting value with the provided value or function + const value = + typeof update === 'function' + ? (update as UpdateSettingFunction)(subject.value) + : update; + + currentApp.updateSetting(prop, value).subscribe({ + error: onError, + complete: onUpdated, + }); }, - [currentApp, prop], + [currentApp, subject, prop, onError, onUpdated], ); - return { - setting, - updateSettings, - }; + // status hooks + useAppSettingsStatus(currentApp, { + onLoading, + onUpdating, + }); + + return [setting, setSetting]; }; export default useAppSetting; diff --git a/packages/react/app/src/settings/useAppSettings.ts b/packages/react/app/src/settings/useAppSettings.ts index 9d2ec4bb87..e7e6f28b48 100644 --- a/packages/react/app/src/settings/useAppSettings.ts +++ b/packages/react/app/src/settings/useAppSettings.ts @@ -1,34 +1,99 @@ -import { useCallback, useMemo } from 'react'; +import { useCallback, useLayoutEffect, useMemo } from 'react'; +import { BehaviorSubject, Observable } from 'rxjs'; + 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'; +import { useObservableState } from '@equinor/fusion-observable/react'; + +import { useAppSettingsStatus, type AppSettingsStatusHooks } from './useAppSettingsStatus'; + +type UpdateSettingsFunction = (currentSettings: T | undefined) => O; /** - * Hook for handling a users app settings - * @returns {settings, updateSettings} Methods for getting and setting settings. + * Custom hook to manage application settings. + * + * @template TSettings - The type of the settings object, extending Record. Defaults to AppSettings. + * + * @param {TSettings} [defaultValue] - The default value for the settings. + * @param hooks - Optional hooks to handle the status changes and errors. + * + * @note + * `defaultValue` will only be used on the first render. + * `hooks`must be memoized to avoid unnecessary re-renders. + * + * @returns {Array} An array containing: + * - `settings`: The current settings object. + * - `setSettings`: A function to update the settings. + * + * @example + * const [settings, setSettings] = useAppSettings(); + * + * @example + * const [settings, setSettings] = useAppSettings({ theme: 'dark' }); + * + * @example + * const [isLoading, setIsLoading] = useState(false); + * const [isUpdating, setIsUpdating] = useState(false); + * const [error, setError] = useState(null); + * + * const onUpdated = useCallback(() => console.log('Settings updated'), []); + * + * const [settings, setSettings] = useAppSettings({ theme: 'dark' }, { + * onLoading: setIsLoading, + * onUpdating: setIsUpdating, + * onError: setError, + * onUpdated, + * }); */ -export const useAppSettings = (): { - settings: AppSettings | undefined; - updateSettings: (settings: AppSettings) => void; -} => { - const { currentApp } = useCurrentApp(); - - const { value: settings } = useObservableState( - useMemo(() => currentApp?.settings$ || EMPTY, [currentApp]), - ); +export const useAppSettings = = AppSettings>( + defaultValue?: TSettings, + hooks?: AppSettingsStatusHooks & { + onError?: (error: Error | null) => void; + onUpdated?: () => void; + }, +): [TSettings, (settings: TSettings | UpdateSettingsFunction) => void] => { + const { onError, onUpdated, onLoading, onUpdating } = hooks ?? {}; + const { currentApp = null } = useCurrentApp(); - const updateSettings = useCallback( - (settings: AppSettings) => { - currentApp?.updateSettingsAsync(settings); + const subject = useMemo(() => { + return new BehaviorSubject(defaultValue ?? ({} as TSettings)); + // Only create a new subject when the current app changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentApp]); + + // connect the subject to the current app settings stream + useLayoutEffect(() => { + const sub = (currentApp?.settings$ as Observable).subscribe(subject); + return () => sub?.unsubscribe(); + }, [currentApp, subject]); + + // subscribe to the subject to get the latest settings + const { value: settings } = useObservableState(subject, { initial: defaultValue }); + + const setSettings = useCallback( + (update: TSettings | UpdateSettingsFunction) => { + if (!currentApp) { + return onError?.(new Error('App is not available')); + } + + // resolve settings with the provided value or function + const settings = typeof update === 'function' ? update(subject.value) : update; + + currentApp.updateSettings(settings).subscribe({ + next: () => { + onUpdated?.(); + onError?.(null); + }, + error: onError, + }); }, - [currentApp], + [currentApp, subject, onError, onUpdated], ); - return { - settings, - updateSettings, - }; + useAppSettingsStatus(currentApp, { onLoading, onUpdating }); + + return [settings, setSettings]; }; export default useAppSettings; diff --git a/packages/react/app/src/settings/useAppSettingsStatus.ts b/packages/react/app/src/settings/useAppSettingsStatus.ts new file mode 100644 index 0000000000..a5d97981a0 --- /dev/null +++ b/packages/react/app/src/settings/useAppSettingsStatus.ts @@ -0,0 +1,48 @@ +import { useLayoutEffect } from 'react'; +import { map } from 'rxjs'; + +import type { IApp } from '@equinor/fusion-framework-module-app'; + +export type AppSettingsStatusHooks = { + onLoading?: (isLoading: boolean) => void; + onUpdating?: (isUpdating: boolean) => void; +}; + +/** + * Custom hook to handle app settings status updates. + * + * @param {IApp | null} app - The app instance to monitor settings status. + * @param {AppSettingsStatusHooks} [hooks] - Optional hooks to handle loading and updating status. + * @param {function} [hooks.onLoading] - Callback function to handle loading status. + * @param {function} [hooks.onUpdating] - Callback function to handle updating status. + * + * @returns {void} + * + * @example + * const hooks = useMemo(() => ({ + * onLoading: (isLoading) => console.log('Loading:', isLoading), + * onUpdating: (isUpdating) => console.log('Updating:', isUpdating), + * }, []); + * useAppSettingsStatus(app, hooks); + */ +export const useAppSettingsStatus = (app: IApp | null, hooks?: AppSettingsStatusHooks) => { + const { onLoading, onUpdating } = hooks ?? {}; + + useLayoutEffect(() => { + if (app && onLoading) { + const subscription = app.status$ + .pipe(map((status) => status.has('fetch_settings'))) + .subscribe(onLoading); + return () => subscription.unsubscribe(); + } + }, [app, onLoading]); + + useLayoutEffect(() => { + if (app && onUpdating) { + const subscription = app.status$ + .pipe(map((status) => status.has('update_settings'))) + .subscribe(onUpdating); + return () => subscription.unsubscribe(); + } + }, [app, onUpdating]); +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e685da9d34..e96113ca2f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -546,6 +546,30 @@ importers: specifier: ^5.5.4 version: 5.5.4 + cookbooks/app-react-settings: + devDependencies: + '@equinor/fusion-framework-cli': + specifier: workspace:^ + version: link:../../packages/cli + '@equinor/fusion-framework-react-app': + specifier: workspace:^ + version: link:../../packages/react/app + '@types/react': + specifier: ^18.2.50 + version: 18.3.12 + '@types/react-dom': + specifier: ^18.2.7 + version: 18.3.1 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + typescript: + specifier: ^5.5.4 + version: 5.6.3 + cookbooks/app-vanilla: devDependencies: '@equinor/fusion-framework-app': @@ -880,6 +904,9 @@ importers: '@equinor/fusion-query': specifier: workspace:^ version: link:../../utils/query + fast-deep-equal: + specifier: ^3.1.3 + version: 3.1.3 immer: specifier: ^9.0.16 version: 9.0.21 @@ -12577,7 +12604,6 @@ snapshots: '@types/react-dom@18.3.1': dependencies: '@types/react': 18.3.12 - optional: true '@types/react-router-dom@5.3.3': dependencies: diff --git a/vue-press/src/.vuepress/sidebar.ts b/vue-press/src/.vuepress/sidebar.ts index 727a9c75c4..00689bade5 100644 --- a/vue-press/src/.vuepress/sidebar.ts +++ b/vue-press/src/.vuepress/sidebar.ts @@ -20,6 +20,7 @@ export default sidebar({ }, ], }, + 'app/settings', 'app/feature-flag', 'app/people', 'app/authentication', diff --git a/vue-press/src/guide/app/settings.md b/vue-press/src/guide/app/settings.md new file mode 100644 index 0000000000..6dabeb54e6 --- /dev/null +++ b/vue-press/src/guide/app/settings.md @@ -0,0 +1,10 @@ +--- +title: Settings +category: Guide +tag: + - how to + - basic + - app + - settings +--- +