@object-ui/data-objectstack 17.5.0 → 17.7.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/CHANGELOG.md +2311 -0
- package/README.md +557 -11
- package/dist/index.cjs +1663 -463
- package/dist/index.d.cts +1780 -518
- package/dist/index.d.ts +1780 -518
- package/dist/index.js +1648 -457
- package/package.json +9 -9
- package/dist/tsconfig.tsbuildinfo +0 -1
- package/src/aggregate-capability.test.ts +0 -177
- package/src/apiAccessDenied.test.ts +0 -233
- package/src/appAccessProbe.test.ts +0 -147
- package/src/bulkDelete.test.ts +0 -75
- package/src/bulkUpdate.test.ts +0 -93
- package/src/cache/MetadataCache.test.ts +0 -477
- package/src/cache/MetadataCache.ts +0 -289
- package/src/cloud-surface-retired-4152.pin.test.ts +0 -171
- package/src/connection.test.ts +0 -188
- package/src/contracts.ts +0 -115
- package/src/deleteView.homes.test.ts +0 -389
- package/src/deleteViewContract.types.test.ts +0 -246
- package/src/errors.test.ts +0 -426
- package/src/errors.ts +0 -293
- package/src/exportDownload.test.ts +0 -141
- package/src/filter-entry-translation.test.ts +0 -360
- package/src/filter-operator-ast-parity.test.ts +0 -135
- package/src/getObjectSchema.test.ts +0 -145
- package/src/httpFailureLogging.test.ts +0 -109
- package/src/importJob.test.ts +0 -71
- package/src/index.ts +0 -4442
- package/src/integration.ts +0 -200
- package/src/listViewOverrides.test.ts +0 -241
- package/src/listViews.test.ts +0 -175
- package/src/metadata-client.get-envelope.test.ts +0 -171
- package/src/metadata-client.saveAdvisories.test.ts +0 -226
- package/src/metadata-client.test.ts +0 -303
- package/src/metadata-client.ts +0 -1046
- package/src/occ.test.ts +0 -101
- package/src/onMutation.test.ts +0 -589
- package/src/onSaveAdvisory.test.ts +0 -311
- package/src/onWriteWarning.test.ts +0 -273
- package/src/orderby-serialization.test.ts +0 -115
- package/src/queryDataset.test.ts +0 -543
- package/src/searchAll.test.ts +0 -120
- package/src/security.ts +0 -243
- package/src/spec-symbol-batch6.test.ts +0 -326
- package/src/studio.ts +0 -152
- package/src/updateView.draft.test.ts +0 -242
- package/src/userState.test.ts +0 -315
- package/src/userState.ts +0 -238
- package/src/v3-compat.test.ts +0 -229
- package/src/validation-error.test.ts +0 -94
- package/src/viewCacheInvalidation.pin.test.ts +0 -260
- package/src/viewInvalidationSeam.pin.test.ts +0 -162
package/src/integration.ts
DELETED
|
@@ -1,200 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ObjectUI
|
|
3
|
-
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
-
*
|
|
5
|
-
* This source code is licensed under the MIT license found in the
|
|
6
|
-
* LICENSE file in the root directory of this source tree.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Integration module for @objectstack/spec v3.0.0
|
|
11
|
-
* Provides third-party service connectors for Slack, email, webhooks.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
export type IntegrationProvider = 'slack' | 'email' | 'webhook' | 'teams' | 'discord';
|
|
15
|
-
|
|
16
|
-
export interface IntegrationConfig {
|
|
17
|
-
/** Integration provider type */
|
|
18
|
-
provider: IntegrationProvider;
|
|
19
|
-
/** Whether this integration is enabled */
|
|
20
|
-
enabled: boolean;
|
|
21
|
-
/** Provider-specific configuration */
|
|
22
|
-
config: Record<string, unknown>;
|
|
23
|
-
/** Event triggers */
|
|
24
|
-
triggers?: IntegrationTrigger[];
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export interface IntegrationTrigger {
|
|
28
|
-
/** Event name (e.g. 'record.created', 'record.updated') */
|
|
29
|
-
event: string;
|
|
30
|
-
/** Filter condition */
|
|
31
|
-
filter?: string;
|
|
32
|
-
/** Template for the message/payload */
|
|
33
|
-
template?: string;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export interface SlackIntegrationConfig extends IntegrationConfig {
|
|
37
|
-
provider: 'slack';
|
|
38
|
-
config: {
|
|
39
|
-
webhookUrl: string;
|
|
40
|
-
channel?: string;
|
|
41
|
-
username?: string;
|
|
42
|
-
iconEmoji?: string;
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export interface EmailIntegrationConfig extends IntegrationConfig {
|
|
47
|
-
provider: 'email';
|
|
48
|
-
config: {
|
|
49
|
-
smtpHost: string;
|
|
50
|
-
smtpPort: number;
|
|
51
|
-
secure: boolean;
|
|
52
|
-
from: string;
|
|
53
|
-
to: string[];
|
|
54
|
-
subject?: string;
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export interface WebhookIntegrationConfig extends IntegrationConfig {
|
|
59
|
-
provider: 'webhook';
|
|
60
|
-
config: {
|
|
61
|
-
url: string;
|
|
62
|
-
method: 'GET' | 'POST' | 'PUT' | 'PATCH';
|
|
63
|
-
headers?: Record<string, string>;
|
|
64
|
-
retryCount?: number;
|
|
65
|
-
retryDelay?: number;
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Integration service manager.
|
|
71
|
-
* Manages third-party service connections and event dispatching.
|
|
72
|
-
*/
|
|
73
|
-
export class IntegrationManager {
|
|
74
|
-
private integrations: Map<string, IntegrationConfig> = new Map();
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Register a new integration.
|
|
78
|
-
*/
|
|
79
|
-
register(id: string, config: IntegrationConfig): void {
|
|
80
|
-
this.integrations.set(id, config);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Remove an integration.
|
|
85
|
-
*/
|
|
86
|
-
unregister(id: string): void {
|
|
87
|
-
this.integrations.delete(id);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Get all registered integrations.
|
|
92
|
-
*/
|
|
93
|
-
getAll(): Map<string, IntegrationConfig> {
|
|
94
|
-
return new Map(this.integrations);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Get integrations that match a specific event.
|
|
99
|
-
*/
|
|
100
|
-
getForEvent(event: string): IntegrationConfig[] {
|
|
101
|
-
return Array.from(this.integrations.values()).filter(
|
|
102
|
-
(integration) =>
|
|
103
|
-
integration.enabled &&
|
|
104
|
-
integration.triggers?.some((t) => t.event === event)
|
|
105
|
-
);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Dispatch an event to all matching integrations.
|
|
110
|
-
* Returns results for each integration.
|
|
111
|
-
*/
|
|
112
|
-
async dispatch(event: string, payload: Record<string, unknown>): Promise<Array<{ id: string; success: boolean; error?: string }>> {
|
|
113
|
-
const matching = this.getForEvent(event);
|
|
114
|
-
const results: Array<{ id: string; success: boolean; error?: string }> = [];
|
|
115
|
-
|
|
116
|
-
for (const [id, integration] of this.integrations) {
|
|
117
|
-
if (!matching.includes(integration)) continue;
|
|
118
|
-
|
|
119
|
-
try {
|
|
120
|
-
await this.send(integration, payload);
|
|
121
|
-
results.push({ id, success: true });
|
|
122
|
-
} catch (err) {
|
|
123
|
-
results.push({ id, success: false, error: (err as Error).message });
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
return results;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Send payload to a specific integration.
|
|
132
|
-
*/
|
|
133
|
-
private async send(integration: IntegrationConfig, payload: Record<string, unknown>): Promise<void> {
|
|
134
|
-
switch (integration.provider) {
|
|
135
|
-
case 'webhook': {
|
|
136
|
-
const cfg = integration.config as WebhookIntegrationConfig['config'];
|
|
137
|
-
const url = cfg.url;
|
|
138
|
-
// Validate URL - only allow http and https protocols
|
|
139
|
-
try {
|
|
140
|
-
const parsed = new URL(url);
|
|
141
|
-
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
142
|
-
throw new Error(`Invalid URL protocol: ${parsed.protocol}`);
|
|
143
|
-
}
|
|
144
|
-
} catch (e) {
|
|
145
|
-
if (e instanceof TypeError) {
|
|
146
|
-
// A malformed URL surfaces as its own message; we can't attach `e`
|
|
147
|
-
// as the `Error` `cause` because this package targets ES2020, whose
|
|
148
|
-
// lib types the 1-arg `Error` constructor only. Scoped disable.
|
|
149
|
-
// eslint-disable-next-line preserve-caught-error
|
|
150
|
-
throw new Error(`Invalid webhook URL: ${url}`);
|
|
151
|
-
}
|
|
152
|
-
throw e;
|
|
153
|
-
}
|
|
154
|
-
await fetch(url, {
|
|
155
|
-
method: cfg.method,
|
|
156
|
-
headers: {
|
|
157
|
-
'Content-Type': 'application/json',
|
|
158
|
-
...cfg.headers,
|
|
159
|
-
},
|
|
160
|
-
body: JSON.stringify(payload),
|
|
161
|
-
});
|
|
162
|
-
break;
|
|
163
|
-
}
|
|
164
|
-
case 'slack': {
|
|
165
|
-
const cfg = integration.config as SlackIntegrationConfig['config'];
|
|
166
|
-
const url = cfg.webhookUrl;
|
|
167
|
-
// Validate URL - only allow https protocol for Slack webhooks
|
|
168
|
-
try {
|
|
169
|
-
const parsed = new URL(url);
|
|
170
|
-
if (parsed.protocol !== 'https:') {
|
|
171
|
-
throw new Error(`Invalid Slack webhook URL protocol: ${parsed.protocol}`);
|
|
172
|
-
}
|
|
173
|
-
} catch (e) {
|
|
174
|
-
if (e instanceof TypeError) {
|
|
175
|
-
// A malformed URL surfaces as its own message; we can't attach `e`
|
|
176
|
-
// as the `Error` `cause` because this package targets ES2020, whose
|
|
177
|
-
// lib types the 1-arg `Error` constructor only. Scoped disable.
|
|
178
|
-
// eslint-disable-next-line preserve-caught-error
|
|
179
|
-
throw new Error(`Invalid Slack webhook URL: ${url}`);
|
|
180
|
-
}
|
|
181
|
-
throw e;
|
|
182
|
-
}
|
|
183
|
-
await fetch(url, {
|
|
184
|
-
method: 'POST',
|
|
185
|
-
headers: { 'Content-Type': 'application/json' },
|
|
186
|
-
body: JSON.stringify({
|
|
187
|
-
channel: cfg.channel,
|
|
188
|
-
username: cfg.username,
|
|
189
|
-
icon_emoji: cfg.iconEmoji,
|
|
190
|
-
text: JSON.stringify(payload),
|
|
191
|
-
}),
|
|
192
|
-
});
|
|
193
|
-
break;
|
|
194
|
-
}
|
|
195
|
-
// Email and other providers would require server-side implementation
|
|
196
|
-
default:
|
|
197
|
-
break;
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
}
|
|
@@ -1,241 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ObjectUI
|
|
3
|
-
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
-
*
|
|
5
|
-
* This source code is licensed under the MIT license found in the
|
|
6
|
-
* LICENSE file in the root directory of this source tree.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* objectui#3774 — the view-override read and write must share one key space.
|
|
11
|
-
*
|
|
12
|
-
* `updateViewConfig` persists a personalization (density, column widths, sort,
|
|
13
|
-
* hidden columns, inlineEdit …) as `PUT /api/v1/meta/view/{viewId}`, i.e. a
|
|
14
|
-
* `sys_metadata` row with `type='view'`. `listViewOverrides` used to enumerate
|
|
15
|
-
* `GET /api/v1/meta/{objectName}` — the OBJECT name in the metadata TYPE slot.
|
|
16
|
-
* The two key spaces are disjoint, so the batch map came back empty for every
|
|
17
|
-
* object forever and every saved preference read back as "setting didn't save".
|
|
18
|
-
*
|
|
19
|
-
* The measurement that picked the fix: `GET /meta/:type` accepts `?package=`
|
|
20
|
-
* and `?preview=draft` and NOTHING else (framework
|
|
21
|
-
* `packages/rest/src/rest-server.ts`, and `client.meta.getItems(type, {
|
|
22
|
-
* packageId })`). There is no `?object=` to push the filter into, so the
|
|
23
|
-
* correct route is the one `listViews()` already uses over the same rows:
|
|
24
|
-
* enumerate `type='view'` once, narrow to the object client-side.
|
|
25
|
-
*
|
|
26
|
-
* The suite is written round-trip on purpose — a stub `sys_metadata` that the
|
|
27
|
-
* adapter's own write path fills and its own read path drains. Asserting the
|
|
28
|
-
* read against a hand-written fixture would have stayed green through the
|
|
29
|
-
* entire bug, because the fixture is where the namespace mistake lived.
|
|
30
|
-
*/
|
|
31
|
-
|
|
32
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
33
|
-
import { ObjectStackAdapter } from './index';
|
|
34
|
-
|
|
35
|
-
/** A stub metadata store keyed the way `sys_metadata` is: `type` + `name`. */
|
|
36
|
-
function makeMetaStore() {
|
|
37
|
-
const rows = new Map<string, any>();
|
|
38
|
-
const getItemsTypes: string[] = [];
|
|
39
|
-
|
|
40
|
-
const meta = {
|
|
41
|
-
getItems: vi.fn(async (type: string) => {
|
|
42
|
-
getItemsTypes.push(type);
|
|
43
|
-
const items = [...rows.entries()]
|
|
44
|
-
.filter(([k]) => k.startsWith(`${type}::`))
|
|
45
|
-
.map(([, v]) => v);
|
|
46
|
-
return { type, items };
|
|
47
|
-
}),
|
|
48
|
-
getItem: vi.fn(async (type: string, name: string) => {
|
|
49
|
-
const item = rows.get(`${type}::${name}`);
|
|
50
|
-
if (!item) {
|
|
51
|
-
const err: any = new Error(`Not found: ${type}/${name}`);
|
|
52
|
-
err.status = 404;
|
|
53
|
-
throw err;
|
|
54
|
-
}
|
|
55
|
-
return { type, name, item };
|
|
56
|
-
}),
|
|
57
|
-
saveItem: vi.fn(async (type: string, name: string, item: any) => {
|
|
58
|
-
rows.set(`${type}::${name}`, { ...item });
|
|
59
|
-
return { success: true, item: rows.get(`${type}::${name}`) };
|
|
60
|
-
}),
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
return { meta, rows, getItemsTypes };
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function makeDS(meta: any) {
|
|
67
|
-
const ds: any = new ObjectStackAdapter({
|
|
68
|
-
baseUrl: 'http://test.local',
|
|
69
|
-
fetch: vi.fn(async () =>
|
|
70
|
-
new Response(JSON.stringify({ success: true, data: { capabilities: {}, routes: {} } }), {
|
|
71
|
-
status: 200,
|
|
72
|
-
headers: { 'Content-Type': 'application/json' },
|
|
73
|
-
})),
|
|
74
|
-
});
|
|
75
|
-
ds.connected = true;
|
|
76
|
-
ds.connectionState = 'connected';
|
|
77
|
-
ds.client = { meta };
|
|
78
|
-
return ds;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* What ObjectView's `persistViewPatch` actually sends on a density toggle +
|
|
83
|
-
* column sort: the view's raw config plus the personalization state. The
|
|
84
|
-
* adapter stamps `object` / `name` on top (and the framework's overlay heals
|
|
85
|
-
* the same identity fields onto rows that lack them — objectstack#2555).
|
|
86
|
-
*/
|
|
87
|
-
const PERSONALIZATION = {
|
|
88
|
-
type: 'grid',
|
|
89
|
-
data: { provider: 'object', object: 'crm_lead' },
|
|
90
|
-
columns: ['name', 'status'],
|
|
91
|
-
density: 'compact',
|
|
92
|
-
sort: [{ field: 'created_at', order: 'desc' }],
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
describe('ObjectStackAdapter.listViewOverrides — read/write key space (#3774)', () => {
|
|
96
|
-
it('round-trips: a config saved through updateViewConfig comes back from listViewOverrides', async () => {
|
|
97
|
-
const { meta, getItemsTypes } = makeMetaStore();
|
|
98
|
-
const ds = makeDS(meta);
|
|
99
|
-
|
|
100
|
-
await ds.updateViewConfig('crm_lead', 'crm_lead.all', { ...PERSONALIZATION });
|
|
101
|
-
const map = await ds.listViewOverrides('crm_lead');
|
|
102
|
-
|
|
103
|
-
// The bug in one assertion: pre-fix this was `{}`.
|
|
104
|
-
expect(Object.keys(map)).toEqual(['crm_lead.all']);
|
|
105
|
-
expect(map['crm_lead.all']).toMatchObject({
|
|
106
|
-
name: 'crm_lead.all',
|
|
107
|
-
object: 'crm_lead',
|
|
108
|
-
density: 'compact',
|
|
109
|
-
sort: [{ field: 'created_at', order: 'desc' }],
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
// …and the type slot itself, which is the actual defect. The write went to
|
|
113
|
-
// `saveItem('view', …)`; the read must enumerate the SAME type.
|
|
114
|
-
expect(meta.saveItem).toHaveBeenCalledWith('view', 'crm_lead.all', expect.any(Object));
|
|
115
|
-
expect(getItemsTypes).toEqual(['view']);
|
|
116
|
-
expect(getItemsTypes).not.toContain('crm_lead');
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it('keys the map by the identity getView reads back by (drop-in for the per-view fetch)', async () => {
|
|
120
|
-
const { meta } = makeMetaStore();
|
|
121
|
-
const ds = makeDS(meta);
|
|
122
|
-
|
|
123
|
-
await ds.updateViewConfig('crm_lead', 'crm_lead.all', { ...PERSONALIZATION });
|
|
124
|
-
|
|
125
|
-
const batched = (await ds.listViewOverrides('crm_lead'))['crm_lead.all'];
|
|
126
|
-
const perView = await ds.getView('crm_lead', 'crm_lead.all');
|
|
127
|
-
|
|
128
|
-
// Same key space, same document — that equality is what lets a caller
|
|
129
|
-
// substitute one call for N without changing what it renders.
|
|
130
|
-
expect(batched).toEqual(perView);
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it('finds an override whose only object association is the top-level `object` stamp', async () => {
|
|
134
|
-
const { meta } = makeMetaStore();
|
|
135
|
-
const ds = makeDS(meta);
|
|
136
|
-
|
|
137
|
-
// A saved view with no `data.object` (e.g. a kanban personalization whose
|
|
138
|
-
// provider block was never sent) — `updateViewConfig`'s stamp is the only
|
|
139
|
-
// association, and it must be enough.
|
|
140
|
-
await ds.updateViewConfig('crm_lead', 'crm_lead.pipeline', { type: 'kanban', density: 'comfortable' });
|
|
141
|
-
const map = await ds.listViewOverrides('crm_lead');
|
|
142
|
-
|
|
143
|
-
expect(map['crm_lead.pipeline']).toMatchObject({ object: 'crm_lead', density: 'comfortable' });
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
it('narrows the shared `type=view` enumeration to the requested object', async () => {
|
|
147
|
-
const { meta } = makeMetaStore();
|
|
148
|
-
const ds = makeDS(meta);
|
|
149
|
-
|
|
150
|
-
await ds.updateViewConfig('crm_lead', 'crm_lead.all', { ...PERSONALIZATION });
|
|
151
|
-
await ds.updateViewConfig('crm_account', 'crm_account.all', {
|
|
152
|
-
type: 'grid', data: { provider: 'object', object: 'crm_account' }, density: 'compact',
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
expect(Object.keys(await ds.listViewOverrides('crm_lead'))).toEqual(['crm_lead.all']);
|
|
156
|
-
expect(Object.keys(await ds.listViewOverrides('crm_account'))).toEqual(['crm_account.all']);
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
it('skips rows the enumeration returns without a name (nothing can address them)', async () => {
|
|
160
|
-
const { meta, rows } = makeMetaStore();
|
|
161
|
-
const ds = makeDS(meta);
|
|
162
|
-
rows.set('view::anon', { object: 'crm_lead', density: 'compact' });
|
|
163
|
-
|
|
164
|
-
expect(await ds.listViewOverrides('crm_lead')).toEqual({});
|
|
165
|
-
});
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
describe('ObjectStackAdapter.listViewOverrides — empty vs unknown (#3774)', () => {
|
|
169
|
-
it('an EMPTY successful enumeration resolves to {} — that answer is authoritative', async () => {
|
|
170
|
-
const { meta } = makeMetaStore();
|
|
171
|
-
const ds = makeDS(meta);
|
|
172
|
-
|
|
173
|
-
// No overrides exist for this object. The caller may trust this and skip
|
|
174
|
-
// the per-view reads; the batch optimization depends on it not throwing.
|
|
175
|
-
await expect(ds.listViewOverrides('crm_lead')).resolves.toEqual({});
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
it('a FAILING enumeration rejects — it must not masquerade as an empty map', async () => {
|
|
179
|
-
const { meta } = makeMetaStore();
|
|
180
|
-
meta.getItems.mockRejectedValueOnce(new Error('503 metadata store unavailable'));
|
|
181
|
-
const ds = makeDS(meta);
|
|
182
|
-
|
|
183
|
-
// Pre-fix this resolved to `{}`, which is indistinguishable from the
|
|
184
|
-
// authoritative "no overrides" above — that is what made ObjectView's
|
|
185
|
-
// per-view getView fallback unreachable code.
|
|
186
|
-
await expect(ds.listViewOverrides('crm_lead')).rejects.toThrow('503 metadata store unavailable');
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
it('a rejection is not cached — the next call re-reads instead of pinning the failure', async () => {
|
|
190
|
-
const { meta } = makeMetaStore();
|
|
191
|
-
const ds = makeDS(meta);
|
|
192
|
-
await ds.updateViewConfig('crm_lead', 'crm_lead.all', { ...PERSONALIZATION });
|
|
193
|
-
meta.getItems.mockRejectedValueOnce(new Error('transient'));
|
|
194
|
-
|
|
195
|
-
await expect(ds.listViewOverrides('crm_lead')).rejects.toThrow('transient');
|
|
196
|
-
// The store recovered; a cached failure would have starved the view of its
|
|
197
|
-
// overrides for the whole TTL.
|
|
198
|
-
expect(Object.keys(await ds.listViewOverrides('crm_lead'))).toEqual(['crm_lead.all']);
|
|
199
|
-
});
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
describe('ObjectStackAdapter.listViewOverrides — wire route (#3774)', () => {
|
|
203
|
-
/** Let the REAL `client.meta` run so the request URL is observed, not assumed. */
|
|
204
|
-
function makeHttpDS(items: any[]) {
|
|
205
|
-
const urls: string[] = [];
|
|
206
|
-
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
|
|
207
|
-
const url = String(input);
|
|
208
|
-
urls.push(url);
|
|
209
|
-
if (url.includes('/meta/')) {
|
|
210
|
-
return new Response(JSON.stringify({ type: 'view', items }), {
|
|
211
|
-
status: 200, headers: { 'Content-Type': 'application/json' },
|
|
212
|
-
});
|
|
213
|
-
}
|
|
214
|
-
return new Response(JSON.stringify({ success: true, data: { capabilities: {}, routes: {} } }), {
|
|
215
|
-
status: 200, headers: { 'Content-Type': 'application/json' },
|
|
216
|
-
});
|
|
217
|
-
});
|
|
218
|
-
const ds: any = new ObjectStackAdapter({ baseUrl: 'http://test.local', fetch: fetchImpl });
|
|
219
|
-
ds.connected = true;
|
|
220
|
-
ds.connectionState = 'connected';
|
|
221
|
-
return { ds, urls };
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
it('GETs the view TYPE, never a route built from the object name', async () => {
|
|
225
|
-
const { ds, urls } = makeHttpDS([
|
|
226
|
-
{ name: 'crm_lead.all', object: 'crm_lead', ...PERSONALIZATION },
|
|
227
|
-
]);
|
|
228
|
-
|
|
229
|
-
const map = await ds.listViewOverrides('crm_lead');
|
|
230
|
-
expect(map['crm_lead.all']).toMatchObject({ density: 'compact' });
|
|
231
|
-
|
|
232
|
-
const metaUrls = urls.filter((u) => u.includes('/meta/'));
|
|
233
|
-
expect(metaUrls.length).toBeGreaterThan(0);
|
|
234
|
-
for (const u of metaUrls) {
|
|
235
|
-
expect(u).toMatch(/\/meta\/view(\?|$)/);
|
|
236
|
-
// The pre-fix route. Pinned by shape so it cannot come back as
|
|
237
|
-
// `/meta/crm_lead` under a different spelling of the same mistake.
|
|
238
|
-
expect(u).not.toContain('/meta/crm_lead');
|
|
239
|
-
}
|
|
240
|
-
});
|
|
241
|
-
});
|
package/src/listViews.test.ts
DELETED
|
@@ -1,175 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ObjectUI
|
|
3
|
-
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
-
*
|
|
5
|
-
* This source code is licensed under the MIT license found in the
|
|
6
|
-
* LICENSE file in the root directory of this source tree.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
10
|
-
import { ObjectStackAdapter } from './index';
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* `listViews(object)` feeds the list-view switcher (ObjectView → ViewTabBar),
|
|
14
|
-
* so it must return LIST-family views only. The backend exposes each view as
|
|
15
|
-
* an independent ViewItem carrying a `viewKind` discriminant (ADR-0017); a
|
|
16
|
-
* form-family view (e.g. `crm_activity.default`) must NOT leak in as a tab.
|
|
17
|
-
*/
|
|
18
|
-
function makeDS(items: any[]) {
|
|
19
|
-
const ds: any = new ObjectStackAdapter({
|
|
20
|
-
baseUrl: 'http://test.local',
|
|
21
|
-
fetch: vi.fn(async () =>
|
|
22
|
-
new Response(JSON.stringify({ success: true, data: { capabilities: {}, routes: {} } }), {
|
|
23
|
-
status: 200,
|
|
24
|
-
headers: { 'Content-Type': 'application/json' },
|
|
25
|
-
})),
|
|
26
|
-
});
|
|
27
|
-
ds.connected = true;
|
|
28
|
-
ds.connectionState = 'connected';
|
|
29
|
-
ds.client = { meta: { getItems: vi.fn(async () => ({ items })) } };
|
|
30
|
-
return ds;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* A DS whose HTTP layer answers the `?preview=draft` metadata route with a
|
|
35
|
-
* pre-overlaid list (the shape the framework already returns: draft wins by
|
|
36
|
-
* name, each draft tagged `_draft`). The published `client.meta.getItems` stub
|
|
37
|
-
* is present but MUST NOT be used in preview mode.
|
|
38
|
-
*/
|
|
39
|
-
function makeDraftDS(overlaid: any[]) {
|
|
40
|
-
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
|
|
41
|
-
const url = String(input);
|
|
42
|
-
if (url.includes('/meta/view')) {
|
|
43
|
-
return new Response(JSON.stringify({ items: overlaid }), {
|
|
44
|
-
status: 200,
|
|
45
|
-
headers: { 'Content-Type': 'application/json' },
|
|
46
|
-
});
|
|
47
|
-
}
|
|
48
|
-
return new Response(JSON.stringify({ success: true, data: {} }), {
|
|
49
|
-
status: 200,
|
|
50
|
-
headers: { 'Content-Type': 'application/json' },
|
|
51
|
-
});
|
|
52
|
-
});
|
|
53
|
-
const ds: any = new ObjectStackAdapter({ baseUrl: 'http://test.local', fetch: fetchImpl });
|
|
54
|
-
ds.connected = true;
|
|
55
|
-
ds.connectionState = 'connected';
|
|
56
|
-
ds.client = { meta: { getItems: vi.fn(async () => ({ items: [] })) } };
|
|
57
|
-
return { ds, fetchImpl };
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
describe('ObjectStackDataSource.listViews', () => {
|
|
61
|
-
// The crm_activity view set as the framework expands it.
|
|
62
|
-
const listAll = {
|
|
63
|
-
name: 'crm_activity.all', object: 'crm_activity', viewKind: 'list', isDefault: true,
|
|
64
|
-
label: 'All Activities', config: { type: 'grid', data: { object: 'crm_activity' } },
|
|
65
|
-
};
|
|
66
|
-
const listCalendar = {
|
|
67
|
-
name: 'crm_activity.calendar', object: 'crm_activity', viewKind: 'list',
|
|
68
|
-
label: 'Activity Calendar', config: { type: 'calendar', data: { object: 'crm_activity' } },
|
|
69
|
-
};
|
|
70
|
-
const formDefault = {
|
|
71
|
-
name: 'crm_activity.default', object: 'crm_activity', viewKind: 'form',
|
|
72
|
-
label: 'Activity', config: { type: 'simple' },
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
it('excludes form-family ViewItems from the list-view switcher', async () => {
|
|
76
|
-
const ds = makeDS([listAll, listCalendar, formDefault]);
|
|
77
|
-
const views = await ds.listViews('crm_activity');
|
|
78
|
-
const names = views.map((v: any) => v.name).sort();
|
|
79
|
-
expect(names).toEqual(['crm_activity.all', 'crm_activity.calendar']);
|
|
80
|
-
// The form view is the original leak — it must be gone.
|
|
81
|
-
expect(views.find((v: any) => v.name === 'crm_activity.default')).toBeUndefined();
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it('excludes detail-family views too', async () => {
|
|
85
|
-
const detail = { name: 'crm_activity.detail', object: 'crm_activity', viewKind: 'detail', config: {} };
|
|
86
|
-
const ds = makeDS([listAll, detail]);
|
|
87
|
-
const views = await ds.listViews('crm_activity');
|
|
88
|
-
expect(views.map((v: any) => v.name)).toEqual(['crm_activity.all']);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it('filters by object binding', async () => {
|
|
92
|
-
const otherList = { name: 'crm_lead.all', object: 'crm_lead', viewKind: 'list', config: {} };
|
|
93
|
-
const ds = makeDS([listAll, otherList]);
|
|
94
|
-
const views = await ds.listViews('crm_activity');
|
|
95
|
-
expect(views.map((v: any) => v.name)).toEqual(['crm_activity.all']);
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it('keeps legacy bare specs without a viewKind (saved/list views)', async () => {
|
|
99
|
-
const legacy = { name: 'saved_grid', object: 'crm_activity', type: 'grid' };
|
|
100
|
-
const wrapped = { list: { name: 'wrapped_grid', object: 'crm_activity', type: 'grid' } };
|
|
101
|
-
const ds = makeDS([legacy, wrapped]);
|
|
102
|
-
const views = await ds.listViews('crm_activity');
|
|
103
|
-
expect(views.map((v: any) => v.name).sort()).toEqual(['saved_grid', 'wrapped_grid']);
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
// ── #2767 draft-preview branch ───────────────────────────────────────────
|
|
107
|
-
describe('previewDrafts (#2767 P2/P3)', () => {
|
|
108
|
-
it('reads the draft-overlaid list in a SINGLE preview=draft request', async () => {
|
|
109
|
-
// Server response overlays a draft edit of `all` (draft wins by name) and
|
|
110
|
-
// a brand-new draft-only `mine`; `calendar` is untouched/published.
|
|
111
|
-
const overlaid = [
|
|
112
|
-
{
|
|
113
|
-
name: 'crm_activity.all', object: 'crm_activity', viewKind: 'list',
|
|
114
|
-
label: 'All (edited)', config: { type: 'grid', data: { object: 'crm_activity' } },
|
|
115
|
-
_draft: true,
|
|
116
|
-
},
|
|
117
|
-
{
|
|
118
|
-
name: 'crm_activity.calendar', object: 'crm_activity', viewKind: 'list',
|
|
119
|
-
label: 'Activity Calendar', config: { type: 'calendar', data: { object: 'crm_activity' } },
|
|
120
|
-
},
|
|
121
|
-
{
|
|
122
|
-
name: 'crm_activity.mine', object: 'crm_activity', viewKind: 'list',
|
|
123
|
-
label: 'Mine', config: { type: 'grid', data: { object: 'crm_activity' } },
|
|
124
|
-
_draft: true,
|
|
125
|
-
},
|
|
126
|
-
];
|
|
127
|
-
const { ds, fetchImpl } = makeDraftDS(overlaid);
|
|
128
|
-
const views = await ds.listViews('crm_activity', { previewDrafts: true });
|
|
129
|
-
|
|
130
|
-
// Exactly one request, and it carries the preview flag.
|
|
131
|
-
const metaCalls = fetchImpl.mock.calls.filter(([u]: any[]) => String(u).includes('/meta/view'));
|
|
132
|
-
expect(metaCalls).toHaveLength(1);
|
|
133
|
-
expect(String(metaCalls[0][0])).toContain('preview=draft');
|
|
134
|
-
// Preview mode must NOT also hit the published path (that's the P2 dupe bug).
|
|
135
|
-
expect(ds.client.meta.getItems).not.toHaveBeenCalled();
|
|
136
|
-
|
|
137
|
-
// One tab per name — the draft edit of `all` does not duplicate it.
|
|
138
|
-
expect(views.map((v: any) => v.name).sort()).toEqual([
|
|
139
|
-
'crm_activity.all', 'crm_activity.calendar', 'crm_activity.mine',
|
|
140
|
-
]);
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
it('flows the _draft provenance flag through for the switcher badge', async () => {
|
|
144
|
-
const overlaid = [
|
|
145
|
-
{
|
|
146
|
-
name: 'crm_activity.mine', object: 'crm_activity', viewKind: 'list',
|
|
147
|
-
label: 'Mine', config: { type: 'grid', data: { object: 'crm_activity' } }, _draft: true,
|
|
148
|
-
},
|
|
149
|
-
{
|
|
150
|
-
name: 'crm_activity.calendar', object: 'crm_activity', viewKind: 'list',
|
|
151
|
-
label: 'Cal', config: { type: 'calendar', data: { object: 'crm_activity' } },
|
|
152
|
-
},
|
|
153
|
-
];
|
|
154
|
-
const { ds } = makeDraftDS(overlaid);
|
|
155
|
-
const views = await ds.listViews('crm_activity', { previewDrafts: true });
|
|
156
|
-
expect(views.find((v: any) => v.name === 'crm_activity.mine')?._draft).toBe(true);
|
|
157
|
-
expect(views.find((v: any) => v.name === 'crm_activity.calendar')?._draft).toBeUndefined();
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
it('non-preview mode uses the published getItems path (no draft fetch)', async () => {
|
|
161
|
-
const { ds, fetchImpl } = makeDraftDS([]);
|
|
162
|
-
ds.client.meta.getItems = vi.fn(async () => ({
|
|
163
|
-
items: [{
|
|
164
|
-
name: 'crm_activity.all', object: 'crm_activity', viewKind: 'list',
|
|
165
|
-
config: { type: 'grid', data: { object: 'crm_activity' } },
|
|
166
|
-
}],
|
|
167
|
-
}));
|
|
168
|
-
const views = await ds.listViews('crm_activity');
|
|
169
|
-
expect(ds.client.meta.getItems).toHaveBeenCalledTimes(1);
|
|
170
|
-
expect(fetchImpl.mock.calls.filter(([u]: any[]) => String(u).includes('/meta/view'))).toHaveLength(0);
|
|
171
|
-
expect(views.map((v: any) => v.name)).toEqual(['crm_activity.all']);
|
|
172
|
-
expect(views[0]._draft).toBeUndefined();
|
|
173
|
-
});
|
|
174
|
-
});
|
|
175
|
-
});
|