Skip to content

Commit

Permalink
Fix(app-settings): use-app-setting (#2615)
Browse files Browse the repository at this point in the history
* fix(cli): vite-proxy for persons/me/settings with own use proxy path

* fix(app-settings): improvements to flows

* fix(app-module): faiilover when meta.id is not set. vite proxy handles manifest only

* feat(cli): add appSettingsPlugin to handle app settings requests

* feat(app): add updateSetting and updateSettingAsync methods for app settings management

* fix(react-app): fix build of app settings

- Add README.md to settings directory
- changed functionality of `useAppSetting` from allowing dot path to only allowing root property name
- removed dot-path.ts

* feat(react-app): add initial setup for app-react-settings cookbook with configuration and main app component

* chore(dependencies): update devDependencies for app-react-settings cookbook

* Empty-Commit

* refactor(app): simplify state selection for observables in App class

* fix(app): improve action filtering for updateSettings in App class

* fix(app): enhance AppClient to utilize IHttpClient for updating app settings

`Query` does not support update actions, since the query method only execute a method, so the update must done in a separate action which mutates the query on update.

* fix(app): update appSettingsPlugin to directly assign parsed request body for PUT method

align the plugin with PUT, not PATCH

* fix(app): update settings handling and improve action structure in AppClient

* fix(app): enhance useAppSetting and useAppSettings hooks to support status handling and improve error management

* fix(app): enhance useAppSetting and useAppSettings hooks to support callback functions for setting updates and improve error handling

* fix(app): enhance App component to support new 'fancy' setting and loading states for theme and size updates

* fix(deps): add fast-deep-equal dependency at version 3.1.3 in pnpm-lock.yaml

* docs(settings): add section for Portal Settings in README.md

* feat(docs): add documentation for app settings

* docs(settings): update README.md with notes on global state handling and UI best practices for settings updates

---------

Co-authored-by: Øyvind Eikeland <oyvind@eikeland.me>
  • Loading branch information
odinr and eikeland committed Dec 2, 2024
1 parent 0c91ae2 commit 3fdaa1f
Show file tree
Hide file tree
Showing 30 changed files with 883 additions and 235 deletions.
5 changes: 5 additions & 0 deletions .changeset/beige-ducks-retire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@equinor/fusion-framework-cookbook-app-react-settings': major
---

Created a cookbook for using settings
9 changes: 9 additions & 0 deletions .changeset/breezy-suits-watch.md
Original file line number Diff line number Diff line change
@@ -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`
29 changes: 29 additions & 0 deletions .changeset/fast-months-allow.md
Original file line number Diff line number Diff line change
@@ -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);
```
5 changes: 5 additions & 0 deletions .changeset/late-numbers-trade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@equinor/fusion-framework-docs': minor
---

Added doc for app settings
3 changes: 3 additions & 0 deletions cookbooks/app-react-settings/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# React cookbook

App for cooking settings with Fusion-Framework and React
24 changes: 24 additions & 0 deletions cookbooks/app-react-settings/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
90 changes: 90 additions & 0 deletions cookbooks/app-react-settings/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
fontFamily: fancy ? 'cursive' : 'sans-serif',
fontSize: size === 'small' ? '0.6rem' : size === 'large' ? '2rem' : '1rem',
background:
theme === 'light' ? '#f0f0f0' : theme === 'dark' ? '#343434' : '#f97fcc',
color: theme === 'dark' ? '#f0f0f0' : '#343434',
}}
>
<div
style={{
background: theme === 'dark' ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)',
padding: '1em',
}}
>
<h1>🚀 Hello Fusion Settings 🔧</h1>
<section style={{ display: 'grid', gridTemplateColumns: '3em auto', gap: '1rem' }}>
<span>Theme:</span>
<select
disabled={isLoading || isUpdating}
value={theme}
onChange={(e) => setTheme(e.currentTarget.value as MyAppSettings['theme'])}
>
<option value="none">None</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</section>
<section style={{ display: 'grid', gridTemplateColumns: '3em auto', gap: '1rem' }}>
<span>Size:</span>
<select
disabled={isLoading || isUpdating}
value={size}
onChange={(e) => setSize(e.currentTarget.value as MyAppSettings['size'])}
>
<option value="small">Small</option>
<option value="medium">Medium</option>
<option value="large">Large</option>
</select>
</section>
<section style={{ display: 'grid', gridTemplateColumns: '3em auto', gap: '1rem' }}>
<span>Size:</span>
<input type="checkbox" checked={fancy} onChange={onFancyChange} />
</section>
<div>
<span>App settings:</span>
<br />
<pre>{JSON.stringify(settings, null, 2)}</pre>
</div>
</div>
</div>
);
};

export default App;
5 changes: 5 additions & 0 deletions cookbooks/app-react-settings/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { AppModuleInitiator } from '@equinor/fusion-framework-react-app';

export const configure: AppModuleInitiator = () => {};

export default configure;
30 changes: 30 additions & 0 deletions cookbooks/app-react-settings/src/index.ts
Original file line number Diff line number Diff line change
@@ -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;
22 changes: 22 additions & 0 deletions cookbooks/app-react-settings/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
4 changes: 4 additions & 0 deletions packages/cli/src/bin/create-dev-serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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: {
Expand Down
58 changes: 58 additions & 0 deletions packages/cli/src/lib/plugins/app-settings/index.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
}

/**
* 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;
19 changes: 19 additions & 0 deletions packages/cli/src/lib/utils/parse-json-request.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>> {
return await new Promise<Record<string, unknown>>((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;
1 change: 1 addition & 0 deletions packages/modules/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading

0 comments on commit 3fdaa1f

Please sign in to comment.