@memberjunction/react-runtime 5.40.2 → 5.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +14 -14
- package/CHANGELOG.md +46 -0
- package/dist/324.runtime.umd.js +144 -133
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/runtime.umd.js +4868 -3113
- package/dist/utilities/user-state.d.ts +7 -0
- package/dist/utilities/user-state.d.ts.map +1 -0
- package/dist/utilities/user-state.js +48 -0
- package/dist/utilities/user-state.js.map +1 -0
- package/package.json +6 -6
- package/src/__tests__/user-state.test.ts +155 -0
- package/src/index.ts +9 -0
- package/src/utilities/user-state.ts +129 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const USER_STATE_KEY_PREFIX = "InteractiveComponents_UserState_Root/";
|
|
2
|
+
export declare function resolveUserStateScope(explicitScope: string | undefined | null, namespace: string | undefined | null, name: string | undefined | null): string | null;
|
|
3
|
+
export declare function userStateStorageKey(scope: string | null): string | null;
|
|
4
|
+
export declare function parseStoredUserSettings(raw: string | undefined | null): Record<string, unknown>;
|
|
5
|
+
export declare function mergeUserSettings(hostDefaults: Record<string, unknown> | undefined | null, stored: Record<string, unknown> | undefined | null): Record<string, unknown>;
|
|
6
|
+
export declare function applyUserSettingsUpdate(current: Record<string, unknown> | undefined | null, incoming: Record<string, unknown> | undefined | null): Record<string, unknown>;
|
|
7
|
+
//# sourceMappingURL=user-state.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"user-state.d.ts","sourceRoot":"","sources":["../../src/utilities/user-state.ts"],"names":[],"mappings":"AAoBA,eAAO,MAAM,qBAAqB,0CAA0C,CAAC;AAiB7E,wBAAgB,qBAAqB,CACnC,aAAa,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,EACxC,SAAS,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,EACpC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,GAC9B,MAAM,GAAG,IAAI,CAYf;AAMD,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,GAAG,IAAI,CAEvE;AAOD,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAa/F;AAOD,wBAAgB,iBAAiB,CAC/B,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,GAAG,IAAI,EACxD,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,GAAG,IAAI,GACjD,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAEzB;AAsBD,wBAAgB,uBAAuB,CACrC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,GAAG,IAAI,EACnD,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,GAAG,IAAI,GACnD,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAUzB"}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export const USER_STATE_KEY_PREFIX = 'InteractiveComponents_UserState_Root/';
|
|
2
|
+
export function resolveUserStateScope(explicitScope, namespace, name) {
|
|
3
|
+
const explicit = explicitScope?.trim();
|
|
4
|
+
if (explicit) {
|
|
5
|
+
return explicit.toLowerCase();
|
|
6
|
+
}
|
|
7
|
+
const cleanName = name?.trim();
|
|
8
|
+
if (!cleanName) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
const cleanNamespace = namespace?.trim();
|
|
12
|
+
const scope = cleanNamespace ? `${cleanNamespace}/${cleanName}` : cleanName;
|
|
13
|
+
return scope.toLowerCase();
|
|
14
|
+
}
|
|
15
|
+
export function userStateStorageKey(scope) {
|
|
16
|
+
return scope ? `${USER_STATE_KEY_PREFIX}${scope}` : null;
|
|
17
|
+
}
|
|
18
|
+
export function parseStoredUserSettings(raw) {
|
|
19
|
+
if (!raw) {
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const parsed = JSON.parse(raw);
|
|
24
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
25
|
+
return parsed;
|
|
26
|
+
}
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function mergeUserSettings(hostDefaults, stored) {
|
|
34
|
+
return { ...(hostDefaults ?? {}), ...(stored ?? {}) };
|
|
35
|
+
}
|
|
36
|
+
export function applyUserSettingsUpdate(current, incoming) {
|
|
37
|
+
const next = { ...(current ?? {}) };
|
|
38
|
+
for (const [key, value] of Object.entries(incoming ?? {})) {
|
|
39
|
+
if (value === null || value === undefined) {
|
|
40
|
+
delete next[key];
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
next[key] = value;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return next;
|
|
47
|
+
}
|
|
48
|
+
//# sourceMappingURL=user-state.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"user-state.js","sourceRoot":"","sources":["../../src/utilities/user-state.ts"],"names":[],"mappings":"AAoBA,MAAM,CAAC,MAAM,qBAAqB,GAAG,uCAAuC,CAAC;AAiB7E,MAAM,UAAU,qBAAqB,CACnC,aAAwC,EACxC,SAAoC,EACpC,IAA+B;IAE/B,MAAM,QAAQ,GAAG,aAAa,EAAE,IAAI,EAAE,CAAC;IACvC,IAAI,QAAQ,EAAE,CAAC;QACb,OAAO,QAAQ,CAAC,WAAW,EAAE,CAAC;IAChC,CAAC;IACD,MAAM,SAAS,GAAG,IAAI,EAAE,IAAI,EAAE,CAAC;IAC/B,IAAI,CAAC,SAAS,EAAE,CAAC;QACf,OAAO,IAAI,CAAC;IACd,CAAC;IACD,MAAM,cAAc,GAAG,SAAS,EAAE,IAAI,EAAE,CAAC;IACzC,MAAM,KAAK,GAAG,cAAc,CAAC,CAAC,CAAC,GAAG,cAAc,IAAI,SAAS,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC;IAC5E,OAAO,KAAK,CAAC,WAAW,EAAE,CAAC;AAC7B,CAAC;AAMD,MAAM,UAAU,mBAAmB,CAAC,KAAoB;IACtD,OAAO,KAAK,CAAC,CAAC,CAAC,GAAG,qBAAqB,GAAG,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;AAC3D,CAAC;AAOD,MAAM,UAAU,uBAAuB,CAAC,GAA8B;IACpE,IAAI,CAAC,GAAG,EAAE,CAAC;QACT,OAAO,EAAE,CAAC;IACZ,CAAC;IACD,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QAC/B,IAAI,MAAM,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;YACnE,OAAO,MAAiC,CAAC;QAC3C,CAAC;QACD,OAAO,EAAE,CAAC;IACZ,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,CAAC;IACZ,CAAC;AACH,CAAC;AAOD,MAAM,UAAU,iBAAiB,CAC/B,YAAwD,EACxD,MAAkD;IAElD,OAAO,EAAE,GAAG,CAAC,YAAY,IAAI,EAAE,CAAC,EAAE,GAAG,CAAC,MAAM,IAAI,EAAE,CAAC,EAAE,CAAC;AACxD,CAAC;AAsBD,MAAM,UAAU,uBAAuB,CACrC,OAAmD,EACnD,QAAoD;IAEpD,MAAM,IAAI,GAA4B,EAAE,GAAG,CAAC,OAAO,IAAI,EAAE,CAAC,EAAE,CAAC;IAC7D,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,QAAQ,IAAI,EAAE,CAAC,EAAE,CAAC;QAC1D,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;YAC1C,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC;QACnB,CAAC;aAAM,CAAC;YACN,IAAI,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC;QACpB,CAAC;IACH,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC","sourcesContent":["/**\n * @fileoverview Helpers for the per-user interactive-component settings contract\n * (`savedUserSettings` in / `onSaveUserSettings` out).\n *\n * These helpers are intentionally **pure and framework-agnostic** so the Angular\n * host bridge and the Node test harness share one implementation and the scoping /\n * serialization logic can be unit tested in isolation. The host wires these to a\n * durable per-user store (e.g. `UserInfoEngine`); the runtime itself never persists.\n *\n * @module @memberjunction/react-runtime\n */\n\n/**\n * Prefix applied to every interactive-component user-settings storage key. The\n * full key is `InteractiveComponents_UserState_Root/<scope>`, where `<scope>` is\n * resolved by {@link resolveUserStateScope}. It is deliberately long and unique\n * to avoid collisions with other namespaced keys in the shared\n * `MJ: User Settings` keyspace. Keeping the prefix in one place also avoids\n * stringly-typed drift between the seed (read) and persist (write) paths.\n */\nexport const USER_STATE_KEY_PREFIX = 'InteractiveComponents_UserState_Root/';\n\n/**\n * Resolve the stable per-component scope used to namespace a component's\n * persisted user settings.\n *\n * An explicit, host-supplied scope always wins (used when a host needs to\n * differentiate two instances of the same component spec — e.g. scope a form's\n * settings by the entity it edits). Otherwise the scope is derived from the\n * component spec's `namespace` + `name`.\n *\n * The result is lowercased to avoid case-variant duplicate rows in the settings\n * store (per MJ's user-settings key convention).\n *\n * @returns the resolved scope, or `null` when no stable scope can be derived\n * (e.g. an unnamed component) — signaling the caller to skip persistence.\n */\nexport function resolveUserStateScope(\n explicitScope: string | undefined | null,\n namespace: string | undefined | null,\n name: string | undefined | null\n): string | null {\n const explicit = explicitScope?.trim();\n if (explicit) {\n return explicit.toLowerCase();\n }\n const cleanName = name?.trim();\n if (!cleanName) {\n return null;\n }\n const cleanNamespace = namespace?.trim();\n const scope = cleanNamespace ? `${cleanNamespace}/${cleanName}` : cleanName;\n return scope.toLowerCase();\n}\n\n/**\n * Build the full storage key for a resolved scope, or `null` when the scope is\n * `null` (persistence should be skipped).\n */\nexport function userStateStorageKey(scope: string | null): string | null {\n return scope ? `${USER_STATE_KEY_PREFIX}${scope}` : null;\n}\n\n/**\n * Safely parse a stored settings blob into a plain object. Returns `{}` for\n * null/empty input, invalid JSON, or any non-object JSON (arrays, primitives) —\n * persisted user settings are always a flat key/value object.\n */\nexport function parseStoredUserSettings(raw: string | undefined | null): Record<string, unknown> {\n if (!raw) {\n return {};\n }\n try {\n const parsed = JSON.parse(raw);\n if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {\n return parsed as Record<string, unknown>;\n }\n return {};\n } catch {\n return {};\n }\n}\n\n/**\n * Merge host-provided defaults with the stored per-user settings. Stored values\n * win, so a returning user sees their saved preferences while a host can still\n * seed sensible first-run defaults that fill any gaps.\n */\nexport function mergeUserSettings(\n hostDefaults: Record<string, unknown> | undefined | null,\n stored: Record<string, unknown> | undefined | null\n): Record<string, unknown> {\n return { ...(hostDefaults ?? {}), ...(stored ?? {}) };\n}\n\n/**\n * Apply an `onSaveUserSettings` payload to the host's current settings snapshot.\n *\n * **Merge, not replace.** The contract asks components to pass the complete\n * settings object, but the host must be resilient to a component (especially an\n * AI-generated one) passing only the changed keys — and to the stale-prop case\n * where a component spreads the mount-time `savedUserSettings` prop, which the\n * host deliberately never refreshes mid-session (no re-render on save). Under\n * full-replace semantics either slip silently wipes every other saved\n * preference; under merge the worst case is a no-op.\n *\n * **Removing a key requires explicit intent**: set its value to `null` and the\n * key is deleted from the snapshot (reads fall back to defaults via the\n * documented `savedUserSettings?.key ?? fallback` pattern). `undefined` values\n * are treated the same way, since `JSON.stringify` would drop them from the\n * persisted blob anyway and the in-memory snapshot must stay consistent with\n * what is stored.\n *\n * @returns a new object — neither input is mutated.\n */\nexport function applyUserSettingsUpdate(\n current: Record<string, unknown> | undefined | null,\n incoming: Record<string, unknown> | undefined | null\n): Record<string, unknown> {\n const next: Record<string, unknown> = { ...(current ?? {}) };\n for (const [key, value] of Object.entries(incoming ?? {})) {\n if (value === null || value === undefined) {\n delete next[key];\n } else {\n next[key] = value;\n }\n }\n return next;\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@memberjunction/react-runtime",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.41.0",
|
|
4
4
|
"description": "Platform-agnostic React component runtime for MemberJunction. Provides core compilation, registry, and execution capabilities for React components in any JavaScript environment.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -29,11 +29,11 @@
|
|
|
29
29
|
},
|
|
30
30
|
"homepage": "https://github.com/MemberJunction/MJ#readme",
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@memberjunction/core": "5.
|
|
33
|
-
"@memberjunction/global": "5.
|
|
34
|
-
"@memberjunction/interactive-component-types": "5.
|
|
35
|
-
"@memberjunction/core-entities": "5.
|
|
36
|
-
"@memberjunction/graphql-dataprovider": "5.
|
|
32
|
+
"@memberjunction/core": "5.41.0",
|
|
33
|
+
"@memberjunction/global": "5.41.0",
|
|
34
|
+
"@memberjunction/interactive-component-types": "5.41.0",
|
|
35
|
+
"@memberjunction/core-entities": "5.41.0",
|
|
36
|
+
"@memberjunction/graphql-dataprovider": "5.41.0",
|
|
37
37
|
"@babel/standalone": "^7.29.1",
|
|
38
38
|
"rxjs": "^7.8.2"
|
|
39
39
|
},
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
USER_STATE_KEY_PREFIX,
|
|
4
|
+
resolveUserStateScope,
|
|
5
|
+
userStateStorageKey,
|
|
6
|
+
parseStoredUserSettings,
|
|
7
|
+
mergeUserSettings,
|
|
8
|
+
applyUserSettingsUpdate
|
|
9
|
+
} from '../utilities/user-state';
|
|
10
|
+
|
|
11
|
+
describe('user-state helpers', () => {
|
|
12
|
+
describe('resolveUserStateScope', () => {
|
|
13
|
+
it('prefers an explicit scope over namespace/name', () => {
|
|
14
|
+
expect(resolveUserStateScope('Form/Contacts', 'crm', 'ContactForm')).toBe('form/contacts');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('lowercases the explicit scope', () => {
|
|
18
|
+
expect(resolveUserStateScope('MyDashboard', null, null)).toBe('mydashboard');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('trims surrounding whitespace from the explicit scope', () => {
|
|
22
|
+
expect(resolveUserStateScope(' Spaced ', null, null)).toBe('spaced');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('combines namespace and name when no explicit scope is given', () => {
|
|
26
|
+
expect(resolveUserStateScope(undefined, 'CRM/Analytics', 'AccountDashboard'))
|
|
27
|
+
.toBe('crm/analytics/accountdashboard');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('uses just the name when there is no namespace', () => {
|
|
31
|
+
expect(resolveUserStateScope(undefined, undefined, 'SalesReport')).toBe('salesreport');
|
|
32
|
+
expect(resolveUserStateScope(null, ' ', 'SalesReport')).toBe('salesreport');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('returns null when no stable scope can be derived', () => {
|
|
36
|
+
expect(resolveUserStateScope(undefined, undefined, undefined)).toBeNull();
|
|
37
|
+
expect(resolveUserStateScope('', '', '')).toBeNull();
|
|
38
|
+
expect(resolveUserStateScope(' ', 'crm', ' ')).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('userStateStorageKey', () => {
|
|
43
|
+
it('prefixes a resolved scope with the unique root namespace', () => {
|
|
44
|
+
expect(userStateStorageKey('form/contacts')).toBe(`${USER_STATE_KEY_PREFIX}form/contacts`);
|
|
45
|
+
expect(userStateStorageKey('form/contacts')).toBe('InteractiveComponents_UserState_Root/form/contacts');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('returns null for a null scope', () => {
|
|
49
|
+
expect(userStateStorageKey(null)).toBeNull();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('parseStoredUserSettings', () => {
|
|
54
|
+
it('parses a valid JSON object', () => {
|
|
55
|
+
expect(parseStoredUserSettings('{"sortBy":"Name","page":2}')).toEqual({ sortBy: 'Name', page: 2 });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('returns an empty object for null/undefined/empty input', () => {
|
|
59
|
+
expect(parseStoredUserSettings(null)).toEqual({});
|
|
60
|
+
expect(parseStoredUserSettings(undefined)).toEqual({});
|
|
61
|
+
expect(parseStoredUserSettings('')).toEqual({});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('returns an empty object for invalid JSON instead of throwing', () => {
|
|
65
|
+
expect(parseStoredUserSettings('{not valid json')).toEqual({});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('returns an empty object for non-object JSON (arrays, primitives)', () => {
|
|
69
|
+
expect(parseStoredUserSettings('[1,2,3]')).toEqual({});
|
|
70
|
+
expect(parseStoredUserSettings('42')).toEqual({});
|
|
71
|
+
expect(parseStoredUserSettings('"hello"')).toEqual({});
|
|
72
|
+
expect(parseStoredUserSettings('null')).toEqual({});
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('mergeUserSettings', () => {
|
|
77
|
+
it('lets stored values win over host defaults', () => {
|
|
78
|
+
const merged = mergeUserSettings({ sortBy: 'CloseDate', viewMode: 'grid' }, { sortBy: 'Name' });
|
|
79
|
+
expect(merged).toEqual({ sortBy: 'Name', viewMode: 'grid' });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('keeps host defaults that the stored settings do not override', () => {
|
|
83
|
+
expect(mergeUserSettings({ a: 1, b: 2 }, { b: 3 })).toEqual({ a: 1, b: 3 });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('handles null/undefined on either side', () => {
|
|
87
|
+
expect(mergeUserSettings(null, { x: 1 })).toEqual({ x: 1 });
|
|
88
|
+
expect(mergeUserSettings({ x: 1 }, null)).toEqual({ x: 1 });
|
|
89
|
+
expect(mergeUserSettings(null, null)).toEqual({});
|
|
90
|
+
expect(mergeUserSettings(undefined, undefined)).toEqual({});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('returns a new object (does not mutate inputs)', () => {
|
|
94
|
+
const defaults = { a: 1 };
|
|
95
|
+
const stored = { b: 2 };
|
|
96
|
+
const merged = mergeUserSettings(defaults, stored);
|
|
97
|
+
expect(merged).not.toBe(defaults);
|
|
98
|
+
expect(merged).not.toBe(stored);
|
|
99
|
+
expect(defaults).toEqual({ a: 1 });
|
|
100
|
+
expect(stored).toEqual({ b: 2 });
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('applyUserSettingsUpdate', () => {
|
|
105
|
+
it('overlays incoming keys onto the current snapshot', () => {
|
|
106
|
+
expect(applyUserSettingsUpdate({ viewMode: 'grid', sortBy: 'CloseDate' }, { sortBy: 'Name' }))
|
|
107
|
+
.toEqual({ viewMode: 'grid', sortBy: 'Name' });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('preserves untouched keys when a component passes only a delta', () => {
|
|
111
|
+
// The AI-forgot-the-spread case: a delta must not wipe other preferences.
|
|
112
|
+
expect(applyUserSettingsUpdate({ viewMode: 'grid', page: 3 }, { sortBy: 'Name' }))
|
|
113
|
+
.toEqual({ viewMode: 'grid', page: 3, sortBy: 'Name' });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('survives the stale-prop spread (two sequential saves keep both changes)', () => {
|
|
117
|
+
// Component spreads the mount-time prop both times; the host snapshot
|
|
118
|
+
// must still accumulate both changes.
|
|
119
|
+
const mountProp = { viewMode: 'grid' };
|
|
120
|
+
let snapshot = applyUserSettingsUpdate(mountProp, { ...mountProp, sortBy: 'Name' });
|
|
121
|
+
snapshot = applyUserSettingsUpdate(snapshot, { ...mountProp, viewMode: 'list' });
|
|
122
|
+
expect(snapshot).toEqual({ viewMode: 'list', sortBy: 'Name' });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('removes a key when its incoming value is explicitly null', () => {
|
|
126
|
+
expect(applyUserSettingsUpdate({ sortBy: 'Name', viewMode: 'grid' }, { sortBy: null }))
|
|
127
|
+
.toEqual({ viewMode: 'grid' });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('removes a key when its incoming value is undefined', () => {
|
|
131
|
+
expect(applyUserSettingsUpdate({ sortBy: 'Name', viewMode: 'grid' }, { sortBy: undefined }))
|
|
132
|
+
.toEqual({ viewMode: 'grid' });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('treats a full-object payload as a superset (replace-equivalent)', () => {
|
|
136
|
+
expect(applyUserSettingsUpdate({ a: 1, b: 2 }, { a: 10, b: 20 })).toEqual({ a: 10, b: 20 });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('handles null/undefined on either side', () => {
|
|
140
|
+
expect(applyUserSettingsUpdate(null, { x: 1 })).toEqual({ x: 1 });
|
|
141
|
+
expect(applyUserSettingsUpdate({ x: 1 }, null)).toEqual({ x: 1 });
|
|
142
|
+
expect(applyUserSettingsUpdate(null, null)).toEqual({});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('returns a new object (does not mutate inputs)', () => {
|
|
146
|
+
const current = { a: 1 };
|
|
147
|
+
const incoming = { b: 2 };
|
|
148
|
+
const next = applyUserSettingsUpdate(current, incoming);
|
|
149
|
+
expect(next).not.toBe(current);
|
|
150
|
+
expect(next).not.toBe(incoming);
|
|
151
|
+
expect(current).toEqual({ a: 1 });
|
|
152
|
+
expect(incoming).toEqual({ b: 2 });
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -140,6 +140,15 @@ export {
|
|
|
140
140
|
unwrapAllComponents
|
|
141
141
|
} from './utilities/component-unwrapper';
|
|
142
142
|
|
|
143
|
+
export {
|
|
144
|
+
USER_STATE_KEY_PREFIX,
|
|
145
|
+
resolveUserStateScope,
|
|
146
|
+
userStateStorageKey,
|
|
147
|
+
parseStoredUserSettings,
|
|
148
|
+
mergeUserSettings,
|
|
149
|
+
applyUserSettingsUpdate
|
|
150
|
+
} from './utilities/user-state';
|
|
151
|
+
|
|
143
152
|
// Version information
|
|
144
153
|
export const VERSION = '2.69.1';
|
|
145
154
|
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Helpers for the per-user interactive-component settings contract
|
|
3
|
+
* (`savedUserSettings` in / `onSaveUserSettings` out).
|
|
4
|
+
*
|
|
5
|
+
* These helpers are intentionally **pure and framework-agnostic** so the Angular
|
|
6
|
+
* host bridge and the Node test harness share one implementation and the scoping /
|
|
7
|
+
* serialization logic can be unit tested in isolation. The host wires these to a
|
|
8
|
+
* durable per-user store (e.g. `UserInfoEngine`); the runtime itself never persists.
|
|
9
|
+
*
|
|
10
|
+
* @module @memberjunction/react-runtime
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Prefix applied to every interactive-component user-settings storage key. The
|
|
15
|
+
* full key is `InteractiveComponents_UserState_Root/<scope>`, where `<scope>` is
|
|
16
|
+
* resolved by {@link resolveUserStateScope}. It is deliberately long and unique
|
|
17
|
+
* to avoid collisions with other namespaced keys in the shared
|
|
18
|
+
* `MJ: User Settings` keyspace. Keeping the prefix in one place also avoids
|
|
19
|
+
* stringly-typed drift between the seed (read) and persist (write) paths.
|
|
20
|
+
*/
|
|
21
|
+
export const USER_STATE_KEY_PREFIX = 'InteractiveComponents_UserState_Root/';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Resolve the stable per-component scope used to namespace a component's
|
|
25
|
+
* persisted user settings.
|
|
26
|
+
*
|
|
27
|
+
* An explicit, host-supplied scope always wins (used when a host needs to
|
|
28
|
+
* differentiate two instances of the same component spec — e.g. scope a form's
|
|
29
|
+
* settings by the entity it edits). Otherwise the scope is derived from the
|
|
30
|
+
* component spec's `namespace` + `name`.
|
|
31
|
+
*
|
|
32
|
+
* The result is lowercased to avoid case-variant duplicate rows in the settings
|
|
33
|
+
* store (per MJ's user-settings key convention).
|
|
34
|
+
*
|
|
35
|
+
* @returns the resolved scope, or `null` when no stable scope can be derived
|
|
36
|
+
* (e.g. an unnamed component) — signaling the caller to skip persistence.
|
|
37
|
+
*/
|
|
38
|
+
export function resolveUserStateScope(
|
|
39
|
+
explicitScope: string | undefined | null,
|
|
40
|
+
namespace: string | undefined | null,
|
|
41
|
+
name: string | undefined | null
|
|
42
|
+
): string | null {
|
|
43
|
+
const explicit = explicitScope?.trim();
|
|
44
|
+
if (explicit) {
|
|
45
|
+
return explicit.toLowerCase();
|
|
46
|
+
}
|
|
47
|
+
const cleanName = name?.trim();
|
|
48
|
+
if (!cleanName) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const cleanNamespace = namespace?.trim();
|
|
52
|
+
const scope = cleanNamespace ? `${cleanNamespace}/${cleanName}` : cleanName;
|
|
53
|
+
return scope.toLowerCase();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Build the full storage key for a resolved scope, or `null` when the scope is
|
|
58
|
+
* `null` (persistence should be skipped).
|
|
59
|
+
*/
|
|
60
|
+
export function userStateStorageKey(scope: string | null): string | null {
|
|
61
|
+
return scope ? `${USER_STATE_KEY_PREFIX}${scope}` : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Safely parse a stored settings blob into a plain object. Returns `{}` for
|
|
66
|
+
* null/empty input, invalid JSON, or any non-object JSON (arrays, primitives) —
|
|
67
|
+
* persisted user settings are always a flat key/value object.
|
|
68
|
+
*/
|
|
69
|
+
export function parseStoredUserSettings(raw: string | undefined | null): Record<string, unknown> {
|
|
70
|
+
if (!raw) {
|
|
71
|
+
return {};
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const parsed = JSON.parse(raw);
|
|
75
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
76
|
+
return parsed as Record<string, unknown>;
|
|
77
|
+
}
|
|
78
|
+
return {};
|
|
79
|
+
} catch {
|
|
80
|
+
return {};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Merge host-provided defaults with the stored per-user settings. Stored values
|
|
86
|
+
* win, so a returning user sees their saved preferences while a host can still
|
|
87
|
+
* seed sensible first-run defaults that fill any gaps.
|
|
88
|
+
*/
|
|
89
|
+
export function mergeUserSettings(
|
|
90
|
+
hostDefaults: Record<string, unknown> | undefined | null,
|
|
91
|
+
stored: Record<string, unknown> | undefined | null
|
|
92
|
+
): Record<string, unknown> {
|
|
93
|
+
return { ...(hostDefaults ?? {}), ...(stored ?? {}) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Apply an `onSaveUserSettings` payload to the host's current settings snapshot.
|
|
98
|
+
*
|
|
99
|
+
* **Merge, not replace.** The contract asks components to pass the complete
|
|
100
|
+
* settings object, but the host must be resilient to a component (especially an
|
|
101
|
+
* AI-generated one) passing only the changed keys — and to the stale-prop case
|
|
102
|
+
* where a component spreads the mount-time `savedUserSettings` prop, which the
|
|
103
|
+
* host deliberately never refreshes mid-session (no re-render on save). Under
|
|
104
|
+
* full-replace semantics either slip silently wipes every other saved
|
|
105
|
+
* preference; under merge the worst case is a no-op.
|
|
106
|
+
*
|
|
107
|
+
* **Removing a key requires explicit intent**: set its value to `null` and the
|
|
108
|
+
* key is deleted from the snapshot (reads fall back to defaults via the
|
|
109
|
+
* documented `savedUserSettings?.key ?? fallback` pattern). `undefined` values
|
|
110
|
+
* are treated the same way, since `JSON.stringify` would drop them from the
|
|
111
|
+
* persisted blob anyway and the in-memory snapshot must stay consistent with
|
|
112
|
+
* what is stored.
|
|
113
|
+
*
|
|
114
|
+
* @returns a new object — neither input is mutated.
|
|
115
|
+
*/
|
|
116
|
+
export function applyUserSettingsUpdate(
|
|
117
|
+
current: Record<string, unknown> | undefined | null,
|
|
118
|
+
incoming: Record<string, unknown> | undefined | null
|
|
119
|
+
): Record<string, unknown> {
|
|
120
|
+
const next: Record<string, unknown> = { ...(current ?? {}) };
|
|
121
|
+
for (const [key, value] of Object.entries(incoming ?? {})) {
|
|
122
|
+
if (value === null || value === undefined) {
|
|
123
|
+
delete next[key];
|
|
124
|
+
} else {
|
|
125
|
+
next[key] = value;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return next;
|
|
129
|
+
}
|