@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.
Files changed (53) hide show
  1. package/CHANGELOG.md +2311 -0
  2. package/README.md +557 -11
  3. package/dist/index.cjs +1663 -463
  4. package/dist/index.d.cts +1780 -518
  5. package/dist/index.d.ts +1780 -518
  6. package/dist/index.js +1648 -457
  7. package/package.json +9 -9
  8. package/dist/tsconfig.tsbuildinfo +0 -1
  9. package/src/aggregate-capability.test.ts +0 -177
  10. package/src/apiAccessDenied.test.ts +0 -233
  11. package/src/appAccessProbe.test.ts +0 -147
  12. package/src/bulkDelete.test.ts +0 -75
  13. package/src/bulkUpdate.test.ts +0 -93
  14. package/src/cache/MetadataCache.test.ts +0 -477
  15. package/src/cache/MetadataCache.ts +0 -289
  16. package/src/cloud-surface-retired-4152.pin.test.ts +0 -171
  17. package/src/connection.test.ts +0 -188
  18. package/src/contracts.ts +0 -115
  19. package/src/deleteView.homes.test.ts +0 -389
  20. package/src/deleteViewContract.types.test.ts +0 -246
  21. package/src/errors.test.ts +0 -426
  22. package/src/errors.ts +0 -293
  23. package/src/exportDownload.test.ts +0 -141
  24. package/src/filter-entry-translation.test.ts +0 -360
  25. package/src/filter-operator-ast-parity.test.ts +0 -135
  26. package/src/getObjectSchema.test.ts +0 -145
  27. package/src/httpFailureLogging.test.ts +0 -109
  28. package/src/importJob.test.ts +0 -71
  29. package/src/index.ts +0 -4442
  30. package/src/integration.ts +0 -200
  31. package/src/listViewOverrides.test.ts +0 -241
  32. package/src/listViews.test.ts +0 -175
  33. package/src/metadata-client.get-envelope.test.ts +0 -171
  34. package/src/metadata-client.saveAdvisories.test.ts +0 -226
  35. package/src/metadata-client.test.ts +0 -303
  36. package/src/metadata-client.ts +0 -1046
  37. package/src/occ.test.ts +0 -101
  38. package/src/onMutation.test.ts +0 -589
  39. package/src/onSaveAdvisory.test.ts +0 -311
  40. package/src/onWriteWarning.test.ts +0 -273
  41. package/src/orderby-serialization.test.ts +0 -115
  42. package/src/queryDataset.test.ts +0 -543
  43. package/src/searchAll.test.ts +0 -120
  44. package/src/security.ts +0 -243
  45. package/src/spec-symbol-batch6.test.ts +0 -326
  46. package/src/studio.ts +0 -152
  47. package/src/updateView.draft.test.ts +0 -242
  48. package/src/userState.test.ts +0 -315
  49. package/src/userState.ts +0 -238
  50. package/src/v3-compat.test.ts +0 -229
  51. package/src/validation-error.test.ts +0 -94
  52. package/src/viewCacheInvalidation.pin.test.ts +0 -260
  53. package/src/viewInvalidationSeam.pin.test.ts +0 -162
@@ -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
- });
@@ -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
- });