@growthub/cli 0.9.5 → 0.9.7
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/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +284 -3
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +819 -61
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-config.js +26 -146
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +770 -0
- package/dist/index.js +1824 -1061
- package/package.json +3 -2
|
@@ -0,0 +1,770 @@
|
|
|
1
|
+
const GRID_COLUMNS = 12;
|
|
2
|
+
const GRID_ROWS = 16;
|
|
3
|
+
const KNOWN_WIDGET_KINDS = ["chart", "view", "iframe", "rich-text"];
|
|
4
|
+
const KNOWN_FIELDS = ["dashboards", "widgetTypes", "canvas"];
|
|
5
|
+
const KNOWN_DATA_BINDING_MODES = ["manual", "json", "csv"];
|
|
6
|
+
const WORKSPACE_TEMPLATE_KIND = "growthub-workspace-template";
|
|
7
|
+
const WORKSPACE_TEMPLATE_VERSION = 1;
|
|
8
|
+
const WORKSPACE_TEMPLATE_SOURCE = "growthub-custom-workspace-starter-v1";
|
|
9
|
+
|
|
10
|
+
const WIDGET_SCHEMA_CONTRACTS = {
|
|
11
|
+
WidgetPosition: {
|
|
12
|
+
x: "integer >= 0",
|
|
13
|
+
y: "integer >= 0",
|
|
14
|
+
w: "integer >= 1",
|
|
15
|
+
h: "integer >= 1",
|
|
16
|
+
invariant: `x + w <= ${GRID_COLUMNS}; y + h <= ${GRID_ROWS}; no cell overlap`
|
|
17
|
+
},
|
|
18
|
+
WidgetBase: {
|
|
19
|
+
id: "non-empty string",
|
|
20
|
+
kind: KNOWN_WIDGET_KINDS.join(" | "),
|
|
21
|
+
title: "non-empty string",
|
|
22
|
+
position: "WidgetPosition",
|
|
23
|
+
config: "kind-specific config object"
|
|
24
|
+
},
|
|
25
|
+
ChartWidgetConfig: {
|
|
26
|
+
values: "number[]",
|
|
27
|
+
binding: "StaticDataBinding optional"
|
|
28
|
+
},
|
|
29
|
+
ViewWidgetConfig: {
|
|
30
|
+
source: "string",
|
|
31
|
+
layout: "Table",
|
|
32
|
+
columns: "string[]",
|
|
33
|
+
rows: "record[]",
|
|
34
|
+
binding: "StaticDataBinding optional"
|
|
35
|
+
},
|
|
36
|
+
IframeWidgetConfig: {
|
|
37
|
+
url: "string"
|
|
38
|
+
},
|
|
39
|
+
RichTextWidgetConfig: {
|
|
40
|
+
text: "string",
|
|
41
|
+
binding: "StaticDataBinding optional"
|
|
42
|
+
},
|
|
43
|
+
DashboardConfig: {
|
|
44
|
+
id: "non-empty string",
|
|
45
|
+
name: "non-empty string",
|
|
46
|
+
createdBy: "string",
|
|
47
|
+
updatedAt: "string",
|
|
48
|
+
status: "draft | active | archived"
|
|
49
|
+
},
|
|
50
|
+
CanvasConfig: {
|
|
51
|
+
layout: `{ columns: ${GRID_COLUMNS}, rowHeight: number, gap: number, responsive: boolean }`,
|
|
52
|
+
widgets: "WidgetBase[] for single-tab canvases only",
|
|
53
|
+
tabs: "optional tab array for multi-tab canvases; each tab owns WidgetBase[] and replaces canvas.widgets",
|
|
54
|
+
activeTabId: "optional active tab id",
|
|
55
|
+
bindings: "workspace-level boolean/config bindings"
|
|
56
|
+
},
|
|
57
|
+
StaticDataBinding: {
|
|
58
|
+
mode: KNOWN_DATA_BINDING_MODES.join(" | "),
|
|
59
|
+
source: "string",
|
|
60
|
+
rows: "manual record[] optional",
|
|
61
|
+
json: "JSON string optional",
|
|
62
|
+
csv: "CSV string optional"
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const SAMPLE_VIEW_ROWS = [
|
|
67
|
+
{ Name: "CMWL Direct", "Domain Name": "centerformedica" },
|
|
68
|
+
{ Name: "Medi-Weightloss", "Domain Name": "mediweightloss.com" },
|
|
69
|
+
{ Name: "Optima Tyler", "Domain Name": "optimatyler.com" },
|
|
70
|
+
{ Name: "Balanced Hormone He...", "Domain Name": "balancedhormor" },
|
|
71
|
+
{ Name: "Jolie Aesthetics RVA", "Domain Name": "jolie-aesthetics.c" },
|
|
72
|
+
{ Name: "Livea Centers", "Domain Name": "livea.com" }
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
const SAMPLE_DATA_BINDINGS = {
|
|
76
|
+
companiesManual: {
|
|
77
|
+
mode: "manual",
|
|
78
|
+
source: "Manual rows",
|
|
79
|
+
rows: SAMPLE_VIEW_ROWS
|
|
80
|
+
},
|
|
81
|
+
reportingJson: {
|
|
82
|
+
mode: "json",
|
|
83
|
+
source: "Sample JSON",
|
|
84
|
+
json: JSON.stringify([
|
|
85
|
+
{ metric: "Leads", value: 42 },
|
|
86
|
+
{ metric: "Qualified", value: 18 },
|
|
87
|
+
{ metric: "Booked", value: 7 }
|
|
88
|
+
], null, 2)
|
|
89
|
+
},
|
|
90
|
+
contentCsv: {
|
|
91
|
+
mode: "csv",
|
|
92
|
+
source: "Sample CSV",
|
|
93
|
+
csv: "channel,status,count\nBlog,Draft,4\nEmail,Review,3\nSocial,Scheduled,9"
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
function defaultConfigFor(kind) {
|
|
98
|
+
switch (kind) {
|
|
99
|
+
case "chart":
|
|
100
|
+
return { values: [58, 36, 72, 48, 64], binding: SAMPLE_DATA_BINDINGS.reportingJson };
|
|
101
|
+
case "view":
|
|
102
|
+
return {
|
|
103
|
+
source: "Companies",
|
|
104
|
+
layout: "Table",
|
|
105
|
+
columns: ["Name", "Domain Name"],
|
|
106
|
+
rows: SAMPLE_VIEW_ROWS,
|
|
107
|
+
binding: SAMPLE_DATA_BINDINGS.companiesManual
|
|
108
|
+
};
|
|
109
|
+
case "iframe":
|
|
110
|
+
return { url: "" };
|
|
111
|
+
case "rich-text":
|
|
112
|
+
return { text: "", binding: { mode: "manual", source: "Manual text", rows: [] } };
|
|
113
|
+
default:
|
|
114
|
+
return {};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function createWidget(kind, title, position, config = defaultConfigFor(kind)) {
|
|
119
|
+
return { kind, title, position, config };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const DASHBOARD_TEMPLATES = [
|
|
123
|
+
{
|
|
124
|
+
id: "blank",
|
|
125
|
+
name: "Blank",
|
|
126
|
+
description: "Empty governed canvas",
|
|
127
|
+
category: "blank",
|
|
128
|
+
bestFor: ["Custom layouts", "Fresh starts"],
|
|
129
|
+
tags: ["blank", "starter"],
|
|
130
|
+
preview: { layout: "empty", summary: "Start from an empty fixed-grid canvas" },
|
|
131
|
+
dashboard: { name: "Blank", status: "draft" },
|
|
132
|
+
widgets: []
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
id: "client-portal",
|
|
136
|
+
name: "Client Portal",
|
|
137
|
+
description: "Client status, documents, and embedded portal area",
|
|
138
|
+
category: "agency",
|
|
139
|
+
bestFor: ["Agencies", "Consultants", "Client delivery"],
|
|
140
|
+
tags: ["client", "portal", "delivery"],
|
|
141
|
+
preview: {
|
|
142
|
+
layout: "multi-panel",
|
|
143
|
+
summary: "Client summary, companies table, portal embed, and delivery health"
|
|
144
|
+
},
|
|
145
|
+
dashboard: { name: "Client Portal", status: "draft" },
|
|
146
|
+
widgets: [
|
|
147
|
+
createWidget("rich-text", "Client Summary", { x: 0, y: 0, w: 4, h: 4 }, { text: "Current client priorities, owner notes, and next milestone.", binding: { mode: "manual", source: "Manual text", rows: [] } }),
|
|
148
|
+
createWidget("view", "Companies", { x: 4, y: 0, w: 5, h: 5 }),
|
|
149
|
+
createWidget("iframe", "Client Portal Embed", { x: 9, y: 0, w: 3, h: 5 }, { url: "" }),
|
|
150
|
+
createWidget("chart", "Delivery Health", { x: 0, y: 4, w: 4, h: 4 }, { values: [72, 64, 81, 58, 76], binding: SAMPLE_DATA_BINDINGS.reportingJson })
|
|
151
|
+
]
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
id: "content-ops",
|
|
155
|
+
name: "Content Ops",
|
|
156
|
+
description: "Editorial pipeline and review snapshot",
|
|
157
|
+
category: "content",
|
|
158
|
+
bestFor: ["Editorial teams", "Content marketers", "Content reviewers"],
|
|
159
|
+
tags: ["content", "editorial", "review"],
|
|
160
|
+
preview: {
|
|
161
|
+
layout: "queue-and-mix",
|
|
162
|
+
summary: "Content queue, publishing mix chart, and review notes"
|
|
163
|
+
},
|
|
164
|
+
dashboard: { name: "Content Ops", status: "draft" },
|
|
165
|
+
widgets: [
|
|
166
|
+
createWidget("view", "Content Queue", { x: 0, y: 0, w: 5, h: 5 }, {
|
|
167
|
+
source: "Content",
|
|
168
|
+
layout: "Table",
|
|
169
|
+
columns: ["Channel", "Status"],
|
|
170
|
+
rows: [
|
|
171
|
+
{ Channel: "Blog", Status: "Draft" },
|
|
172
|
+
{ Channel: "Email", Status: "Review" },
|
|
173
|
+
{ Channel: "Social", Status: "Scheduled" }
|
|
174
|
+
],
|
|
175
|
+
binding: SAMPLE_DATA_BINDINGS.contentCsv
|
|
176
|
+
}),
|
|
177
|
+
createWidget("chart", "Publishing Mix", { x: 5, y: 0, w: 4, h: 4 }, { values: [34, 52, 45, 61, 38], binding: SAMPLE_DATA_BINDINGS.contentCsv }),
|
|
178
|
+
createWidget("rich-text", "Review Notes", { x: 9, y: 0, w: 3, h: 4 }, { text: "Open creative review notes and approval blockers.", binding: { mode: "manual", source: "Manual text", rows: [] } })
|
|
179
|
+
]
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
id: "reporting-dashboard",
|
|
183
|
+
name: "Reporting Dashboard",
|
|
184
|
+
description: "KPIs, table, and executive readout",
|
|
185
|
+
category: "reporting",
|
|
186
|
+
bestFor: ["Executives", "Analytics teams", "Operations"],
|
|
187
|
+
tags: ["kpi", "reporting", "analytics"],
|
|
188
|
+
preview: {
|
|
189
|
+
layout: "kpi-grid",
|
|
190
|
+
summary: "Pipeline trend, conversion chart, performance table, executive summary"
|
|
191
|
+
},
|
|
192
|
+
dashboard: { name: "Reporting Dashboard", status: "draft" },
|
|
193
|
+
widgets: [
|
|
194
|
+
createWidget("chart", "Pipeline Trend", { x: 0, y: 0, w: 4, h: 5 }, { values: [42, 58, 63, 71, 86], binding: SAMPLE_DATA_BINDINGS.reportingJson }),
|
|
195
|
+
createWidget("chart", "Conversion", { x: 4, y: 0, w: 4, h: 5 }, { values: [28, 36, 44, 39, 52], binding: SAMPLE_DATA_BINDINGS.reportingJson }),
|
|
196
|
+
createWidget("view", "Performance Table", { x: 8, y: 0, w: 4, h: 5 }),
|
|
197
|
+
createWidget("rich-text", "Executive Summary", { x: 0, y: 5, w: 6, h: 3 }, { text: "Weekly readout, risks, and decisions.", binding: { mode: "manual", source: "Manual text", rows: [] } })
|
|
198
|
+
]
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
id: "creative-review",
|
|
202
|
+
name: "Creative Review",
|
|
203
|
+
description: "Creative artifact embed and approval notes",
|
|
204
|
+
category: "creative",
|
|
205
|
+
bestFor: ["Creative leads", "Designers", "Account managers"],
|
|
206
|
+
tags: ["creative", "review", "approvals"],
|
|
207
|
+
preview: {
|
|
208
|
+
layout: "embed-and-queue",
|
|
209
|
+
summary: "Creative preview embed, approval notes, and review queue"
|
|
210
|
+
},
|
|
211
|
+
dashboard: { name: "Creative Review", status: "draft" },
|
|
212
|
+
widgets: [
|
|
213
|
+
createWidget("iframe", "Creative Preview", { x: 0, y: 0, w: 7, h: 6 }, { url: "" }),
|
|
214
|
+
createWidget("rich-text", "Approval Notes", { x: 7, y: 0, w: 5, h: 3 }, { text: "Feedback, approvals, and revision requests.", binding: { mode: "manual", source: "Manual text", rows: [] } }),
|
|
215
|
+
createWidget("view", "Review Queue", { x: 7, y: 3, w: 5, h: 4 }, {
|
|
216
|
+
source: "Creative",
|
|
217
|
+
layout: "Table",
|
|
218
|
+
columns: ["Asset", "Status"],
|
|
219
|
+
rows: [
|
|
220
|
+
{ Asset: "Landing Page", Status: "Review" },
|
|
221
|
+
{ Asset: "Email Hero", Status: "Approved" },
|
|
222
|
+
{ Asset: "Social Set", Status: "Revision" }
|
|
223
|
+
],
|
|
224
|
+
binding: { mode: "manual", source: "Manual rows", rows: [] }
|
|
225
|
+
})
|
|
226
|
+
]
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
id: "agency-delivery",
|
|
230
|
+
name: "Agency Delivery",
|
|
231
|
+
description: "Agency workstream, KPI, and delivery notes",
|
|
232
|
+
category: "agency",
|
|
233
|
+
bestFor: ["Agencies", "Delivery leads", "Producers"],
|
|
234
|
+
tags: ["agency", "delivery", "ops"],
|
|
235
|
+
preview: {
|
|
236
|
+
layout: "delivery-grid",
|
|
237
|
+
summary: "Delivery board, utilization chart, client commitments, delivery portal"
|
|
238
|
+
},
|
|
239
|
+
dashboard: { name: "Agency Delivery", status: "draft" },
|
|
240
|
+
widgets: [
|
|
241
|
+
createWidget("view", "Delivery Board", { x: 0, y: 0, w: 5, h: 5 }, {
|
|
242
|
+
source: "Tasks",
|
|
243
|
+
layout: "Table",
|
|
244
|
+
columns: ["Workstream", "Owner"],
|
|
245
|
+
rows: [
|
|
246
|
+
{ Workstream: "Strategy", Owner: "Agency" },
|
|
247
|
+
{ Workstream: "Creative", Owner: "Design" },
|
|
248
|
+
{ Workstream: "Launch", Owner: "Ops" }
|
|
249
|
+
],
|
|
250
|
+
binding: { mode: "manual", source: "Manual rows", rows: [] }
|
|
251
|
+
}),
|
|
252
|
+
createWidget("chart", "Utilization", { x: 5, y: 0, w: 3, h: 4 }, { values: [62, 74, 69, 82, 77], binding: SAMPLE_DATA_BINDINGS.reportingJson }),
|
|
253
|
+
createWidget("rich-text", "Client Commitments", { x: 8, y: 0, w: 4, h: 4 }, { text: "Committed scope, launch date, and open risks.", binding: { mode: "manual", source: "Manual text", rows: [] } }),
|
|
254
|
+
createWidget("iframe", "Delivery Portal", { x: 0, y: 5, w: 6, h: 4 }, { url: "" })
|
|
255
|
+
]
|
|
256
|
+
}
|
|
257
|
+
];
|
|
258
|
+
|
|
259
|
+
function isPlainObject(value) {
|
|
260
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function isFiniteInt(value) {
|
|
264
|
+
return typeof value === "number" && Number.isFinite(value) && Math.floor(value) === value;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function validateStringArray(value, path, errors) {
|
|
268
|
+
if (!Array.isArray(value)) {
|
|
269
|
+
errors.push(`${path} must be an array`);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
value.forEach((item, index) => {
|
|
273
|
+
if (typeof item !== "string") errors.push(`${path}[${index}] must be a string`);
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function validateStaticDataBinding(binding, path, errors) {
|
|
278
|
+
if (binding === undefined) return;
|
|
279
|
+
if (!isPlainObject(binding)) {
|
|
280
|
+
errors.push(`${path} must be a plain object`);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (!KNOWN_DATA_BINDING_MODES.includes(binding.mode)) {
|
|
284
|
+
errors.push(`${path}.mode must be one of ${KNOWN_DATA_BINDING_MODES.join(", ")}`);
|
|
285
|
+
}
|
|
286
|
+
if (binding.source !== undefined && typeof binding.source !== "string") {
|
|
287
|
+
errors.push(`${path}.source must be a string`);
|
|
288
|
+
}
|
|
289
|
+
if (binding.rows !== undefined && !Array.isArray(binding.rows)) {
|
|
290
|
+
errors.push(`${path}.rows must be an array`);
|
|
291
|
+
}
|
|
292
|
+
if (binding.json !== undefined && typeof binding.json !== "string") {
|
|
293
|
+
errors.push(`${path}.json must be a string`);
|
|
294
|
+
}
|
|
295
|
+
if (binding.csv !== undefined && typeof binding.csv !== "string") {
|
|
296
|
+
errors.push(`${path}.csv must be a string`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function validateWidgetConfig(kind, config, path, errors) {
|
|
301
|
+
if (config === undefined) return;
|
|
302
|
+
if (!isPlainObject(config)) {
|
|
303
|
+
errors.push(`${path} must be a plain object`);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (kind === "chart") {
|
|
307
|
+
if (config.values !== undefined) {
|
|
308
|
+
if (!Array.isArray(config.values)) {
|
|
309
|
+
errors.push(`${path}.values must be an array`);
|
|
310
|
+
} else {
|
|
311
|
+
config.values.forEach((value, index) => {
|
|
312
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
313
|
+
errors.push(`${path}.values[${index}] must be a finite number`);
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
validateStaticDataBinding(config.binding, `${path}.binding`, errors);
|
|
319
|
+
}
|
|
320
|
+
if (kind === "view") {
|
|
321
|
+
if (config.source !== undefined && typeof config.source !== "string") errors.push(`${path}.source must be a string`);
|
|
322
|
+
if (config.layout !== undefined && config.layout !== "Table") errors.push(`${path}.layout must be Table`);
|
|
323
|
+
if (config.columns !== undefined) validateStringArray(config.columns, `${path}.columns`, errors);
|
|
324
|
+
if (config.rows !== undefined && !Array.isArray(config.rows)) errors.push(`${path}.rows must be an array`);
|
|
325
|
+
validateStaticDataBinding(config.binding, `${path}.binding`, errors);
|
|
326
|
+
}
|
|
327
|
+
if (kind === "iframe" && config.url !== undefined && typeof config.url !== "string") {
|
|
328
|
+
errors.push(`${path}.url must be a string`);
|
|
329
|
+
}
|
|
330
|
+
if (kind === "rich-text") {
|
|
331
|
+
if (config.text !== undefined && typeof config.text !== "string") errors.push(`${path}.text must be a string`);
|
|
332
|
+
validateStaticDataBinding(config.binding, `${path}.binding`, errors);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function validateDashboardArray(dashboards, errors) {
|
|
337
|
+
if (!Array.isArray(dashboards)) {
|
|
338
|
+
errors.push("dashboards must be an array");
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
const ids = new Set();
|
|
342
|
+
dashboards.forEach((dashboard, index) => {
|
|
343
|
+
const prefix = `dashboards[${index}]`;
|
|
344
|
+
if (!isPlainObject(dashboard)) {
|
|
345
|
+
errors.push(`${prefix} must be an object`);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (typeof dashboard.id !== "string" || !dashboard.id) errors.push(`${prefix}.id must be a non-empty string`);
|
|
349
|
+
if (dashboard.id && ids.has(dashboard.id)) errors.push(`${prefix}.id duplicates an earlier dashboard id`);
|
|
350
|
+
ids.add(dashboard.id);
|
|
351
|
+
if (typeof dashboard.name !== "string" || !dashboard.name) errors.push(`${prefix}.name must be a non-empty string`);
|
|
352
|
+
if (dashboard.createdBy !== undefined && typeof dashboard.createdBy !== "string") errors.push(`${prefix}.createdBy must be a string`);
|
|
353
|
+
if (dashboard.updatedAt !== undefined && typeof dashboard.updatedAt !== "string") errors.push(`${prefix}.updatedAt must be a string`);
|
|
354
|
+
if (dashboard.status !== undefined && !["draft", "active", "archived"].includes(dashboard.status)) {
|
|
355
|
+
errors.push(`${prefix}.status must be draft, active, or archived`);
|
|
356
|
+
}
|
|
357
|
+
if (dashboard.tabs !== undefined) {
|
|
358
|
+
validateDashboardTabs(dashboard.tabs, dashboard.activeTabId, `${prefix}.tabs`, errors, new Set());
|
|
359
|
+
}
|
|
360
|
+
if (dashboard.activeTabId !== undefined && typeof dashboard.activeTabId !== "string") {
|
|
361
|
+
errors.push(`${prefix}.activeTabId must be a string`);
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function validateDashboardTabs(tabs, activeTabId, contextPath, errors, seenWidgetIds) {
|
|
367
|
+
if (!Array.isArray(tabs)) {
|
|
368
|
+
errors.push(`${contextPath} must be an array`);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (!tabs.length) {
|
|
372
|
+
errors.push(`${contextPath} must include at least one tab`);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
const seenTabIds = new Set();
|
|
376
|
+
tabs.forEach((tab, index) => {
|
|
377
|
+
const tabPrefix = `${contextPath}[${index}]`;
|
|
378
|
+
if (!isPlainObject(tab)) {
|
|
379
|
+
errors.push(`${tabPrefix} must be an object`);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (typeof tab.id !== "string" || !tab.id) {
|
|
383
|
+
errors.push(`${tabPrefix}.id must be a non-empty string`);
|
|
384
|
+
} else if (seenTabIds.has(tab.id)) {
|
|
385
|
+
errors.push(`${tabPrefix}.id duplicates an earlier tab id`);
|
|
386
|
+
} else {
|
|
387
|
+
seenTabIds.add(tab.id);
|
|
388
|
+
}
|
|
389
|
+
if (typeof tab.name !== "string" || !tab.name) errors.push(`${tabPrefix}.name must be a non-empty string`);
|
|
390
|
+
validateWidgetArray(tab.widgets || [], `${tabPrefix}.widgets`, errors, seenWidgetIds);
|
|
391
|
+
});
|
|
392
|
+
if (activeTabId !== undefined && !seenTabIds.has(activeTabId)) {
|
|
393
|
+
errors.push(`${contextPath.replace(/\.tabs$/, "")}.activeTabId must match an existing tab id`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function validateWidgetArray(widgets, contextPath, errors, seenIds) {
|
|
398
|
+
if (!Array.isArray(widgets)) {
|
|
399
|
+
errors.push(`${contextPath} must be an array`);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const occupied = new Map();
|
|
403
|
+
widgets.forEach((widget, index) => {
|
|
404
|
+
const prefix = `${contextPath}[${index}]`;
|
|
405
|
+
if (!isPlainObject(widget)) {
|
|
406
|
+
errors.push(`${prefix} must be an object`);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
if (typeof widget.id !== "string" || !widget.id) {
|
|
410
|
+
errors.push(`${prefix}.id must be a non-empty string`);
|
|
411
|
+
} else if (seenIds.has(widget.id)) {
|
|
412
|
+
errors.push(`${prefix}.id duplicates an earlier widget id`);
|
|
413
|
+
} else {
|
|
414
|
+
seenIds.add(widget.id);
|
|
415
|
+
}
|
|
416
|
+
if (!KNOWN_WIDGET_KINDS.includes(widget.kind)) {
|
|
417
|
+
errors.push(`${prefix}.kind must be one of ${KNOWN_WIDGET_KINDS.join(", ")}`);
|
|
418
|
+
}
|
|
419
|
+
if (typeof widget.title !== "string" || !widget.title) {
|
|
420
|
+
errors.push(`${prefix}.title must be a non-empty string`);
|
|
421
|
+
}
|
|
422
|
+
if (!isPlainObject(widget.position)) {
|
|
423
|
+
errors.push(`${prefix}.position must be an object`);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
for (const k of ["x", "y", "w", "h"]) {
|
|
427
|
+
if (!isFiniteInt(widget.position[k])) errors.push(`${prefix}.position.${k} must be a finite integer`);
|
|
428
|
+
}
|
|
429
|
+
if (
|
|
430
|
+
isFiniteInt(widget.position.x) &&
|
|
431
|
+
isFiniteInt(widget.position.w) &&
|
|
432
|
+
(widget.position.x < 0 || widget.position.w < 1 || widget.position.x + widget.position.w > GRID_COLUMNS)
|
|
433
|
+
) {
|
|
434
|
+
errors.push(`${prefix} x/w out of [0..${GRID_COLUMNS}] grid`);
|
|
435
|
+
}
|
|
436
|
+
if (
|
|
437
|
+
isFiniteInt(widget.position.y) &&
|
|
438
|
+
isFiniteInt(widget.position.h) &&
|
|
439
|
+
(widget.position.y < 0 || widget.position.h < 1 || widget.position.y + widget.position.h > GRID_ROWS)
|
|
440
|
+
) {
|
|
441
|
+
errors.push(`${prefix} y/h out of [0..${GRID_ROWS}] grid`);
|
|
442
|
+
}
|
|
443
|
+
if (
|
|
444
|
+
isFiniteInt(widget.position.x) &&
|
|
445
|
+
isFiniteInt(widget.position.y) &&
|
|
446
|
+
isFiniteInt(widget.position.w) &&
|
|
447
|
+
isFiniteInt(widget.position.h)
|
|
448
|
+
) {
|
|
449
|
+
for (let dx = 0; dx < widget.position.w; dx += 1) {
|
|
450
|
+
for (let dy = 0; dy < widget.position.h; dy += 1) {
|
|
451
|
+
const cell = `${widget.position.x + dx}:${widget.position.y + dy}`;
|
|
452
|
+
const previous = occupied.get(cell);
|
|
453
|
+
if (previous) {
|
|
454
|
+
errors.push(`${prefix} overlaps ${previous} at grid cell ${cell}`);
|
|
455
|
+
} else {
|
|
456
|
+
occupied.set(cell, `${prefix}.position`);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
validateWidgetConfig(widget.kind, widget.config, `${prefix}.config`, errors);
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function validateCanvasConfig(canvas, errors) {
|
|
466
|
+
if (!isPlainObject(canvas)) {
|
|
467
|
+
errors.push("canvas must be a plain object");
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
if (canvas.layout !== undefined) {
|
|
471
|
+
if (!isPlainObject(canvas.layout)) {
|
|
472
|
+
errors.push("canvas.layout must be a plain object");
|
|
473
|
+
} else if (canvas.layout.columns !== undefined && canvas.layout.columns !== GRID_COLUMNS) {
|
|
474
|
+
errors.push(`canvas.layout.columns must be ${GRID_COLUMNS}`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
const seenWidgetIds = new Set();
|
|
478
|
+
if (canvas.widgets !== undefined) {
|
|
479
|
+
validateWidgetArray(canvas.widgets, "canvas.widgets", errors, seenWidgetIds);
|
|
480
|
+
}
|
|
481
|
+
if (canvas.tabs !== undefined) {
|
|
482
|
+
if (!Array.isArray(canvas.tabs)) {
|
|
483
|
+
errors.push("canvas.tabs must be an array");
|
|
484
|
+
} else {
|
|
485
|
+
const seenTabIds = new Set();
|
|
486
|
+
canvas.tabs.forEach((tab, index) => {
|
|
487
|
+
const tabPrefix = `canvas.tabs[${index}]`;
|
|
488
|
+
if (!isPlainObject(tab)) {
|
|
489
|
+
errors.push(`${tabPrefix} must be an object`);
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
if (typeof tab.id !== "string" || !tab.id) {
|
|
493
|
+
errors.push(`${tabPrefix}.id must be a non-empty string`);
|
|
494
|
+
} else if (seenTabIds.has(tab.id)) {
|
|
495
|
+
errors.push(`${tabPrefix}.id duplicates an earlier tab id`);
|
|
496
|
+
} else {
|
|
497
|
+
seenTabIds.add(tab.id);
|
|
498
|
+
}
|
|
499
|
+
if (typeof tab.name !== "string" || !tab.name) errors.push(`${tabPrefix}.name must be a non-empty string`);
|
|
500
|
+
if (tab.widgets !== undefined) validateWidgetArray(tab.widgets, `${tabPrefix}.widgets`, errors, seenWidgetIds);
|
|
501
|
+
});
|
|
502
|
+
if (canvas.activeTabId !== undefined && !seenTabIds.has(canvas.activeTabId)) {
|
|
503
|
+
errors.push("canvas.activeTabId must match an existing tab id");
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
if (canvas.activeTabId !== undefined && typeof canvas.activeTabId !== "string") {
|
|
508
|
+
errors.push("canvas.activeTabId must be a string");
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function validateTemplateWidgetArray(widgets, contextPath, errors) {
|
|
513
|
+
if (!Array.isArray(widgets)) {
|
|
514
|
+
errors.push(`${contextPath} must be an array`);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
const occupied = new Map();
|
|
518
|
+
widgets.forEach((widget, index) => {
|
|
519
|
+
const prefix = `${contextPath}[${index}]`;
|
|
520
|
+
if (!isPlainObject(widget)) {
|
|
521
|
+
errors.push(`${prefix} must be an object`);
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
if (!KNOWN_WIDGET_KINDS.includes(widget.kind)) {
|
|
525
|
+
errors.push(`${prefix}.kind must be one of ${KNOWN_WIDGET_KINDS.join(", ")}`);
|
|
526
|
+
}
|
|
527
|
+
if (typeof widget.title !== "string" || !widget.title) {
|
|
528
|
+
errors.push(`${prefix}.title must be a non-empty string`);
|
|
529
|
+
}
|
|
530
|
+
if (!isPlainObject(widget.position)) {
|
|
531
|
+
errors.push(`${prefix}.position must be an object`);
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
for (const k of ["x", "y", "w", "h"]) {
|
|
535
|
+
if (!isFiniteInt(widget.position[k])) errors.push(`${prefix}.position.${k} must be a finite integer`);
|
|
536
|
+
}
|
|
537
|
+
if (
|
|
538
|
+
isFiniteInt(widget.position.x) &&
|
|
539
|
+
isFiniteInt(widget.position.w) &&
|
|
540
|
+
(widget.position.x < 0 || widget.position.w < 1 || widget.position.x + widget.position.w > GRID_COLUMNS)
|
|
541
|
+
) {
|
|
542
|
+
errors.push(`${prefix} x/w out of [0..${GRID_COLUMNS}] grid`);
|
|
543
|
+
}
|
|
544
|
+
if (
|
|
545
|
+
isFiniteInt(widget.position.y) &&
|
|
546
|
+
isFiniteInt(widget.position.h) &&
|
|
547
|
+
(widget.position.y < 0 || widget.position.h < 1 || widget.position.y + widget.position.h > GRID_ROWS)
|
|
548
|
+
) {
|
|
549
|
+
errors.push(`${prefix} y/h out of [0..${GRID_ROWS}] grid`);
|
|
550
|
+
}
|
|
551
|
+
if (
|
|
552
|
+
isFiniteInt(widget.position.x) &&
|
|
553
|
+
isFiniteInt(widget.position.y) &&
|
|
554
|
+
isFiniteInt(widget.position.w) &&
|
|
555
|
+
isFiniteInt(widget.position.h)
|
|
556
|
+
) {
|
|
557
|
+
for (let dx = 0; dx < widget.position.w; dx += 1) {
|
|
558
|
+
for (let dy = 0; dy < widget.position.h; dy += 1) {
|
|
559
|
+
const cell = `${widget.position.x + dx}:${widget.position.y + dy}`;
|
|
560
|
+
const previous = occupied.get(cell);
|
|
561
|
+
if (previous) {
|
|
562
|
+
errors.push(`${prefix} overlaps ${previous} at grid cell ${cell}`);
|
|
563
|
+
} else {
|
|
564
|
+
occupied.set(cell, `${prefix}.position`);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
validateWidgetConfig(widget.kind, widget.config, `${prefix}.config`, errors);
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function normalizeWorkspaceTemplate(template) {
|
|
574
|
+
if (!isPlainObject(template)) return template;
|
|
575
|
+
const widgets = Array.isArray(template.widgets) ? template.widgets : [];
|
|
576
|
+
const tags = Array.isArray(template.tags) ? template.tags.filter((tag) => typeof tag === "string") : [];
|
|
577
|
+
const bestFor = Array.isArray(template.bestFor) ? template.bestFor.filter((item) => typeof item === "string") : [];
|
|
578
|
+
const preview = isPlainObject(template.preview)
|
|
579
|
+
? { layout: template.preview.layout || "custom", summary: template.preview.summary || "" }
|
|
580
|
+
: { layout: "custom", summary: "" };
|
|
581
|
+
const dashboard = isPlainObject(template.dashboard)
|
|
582
|
+
? {
|
|
583
|
+
name: typeof template.dashboard.name === "string" && template.dashboard.name ? template.dashboard.name : template.name || "Untitled",
|
|
584
|
+
status: ["draft", "active", "archived"].includes(template.dashboard.status) ? template.dashboard.status : "draft"
|
|
585
|
+
}
|
|
586
|
+
: { name: template.name || "Untitled", status: "draft" };
|
|
587
|
+
return {
|
|
588
|
+
id: template.id,
|
|
589
|
+
name: template.name,
|
|
590
|
+
description: typeof template.description === "string" ? template.description : "",
|
|
591
|
+
category: typeof template.category === "string" && template.category ? template.category : "custom",
|
|
592
|
+
bestFor,
|
|
593
|
+
tags,
|
|
594
|
+
widgetCount: widgets.length,
|
|
595
|
+
preview,
|
|
596
|
+
dashboard,
|
|
597
|
+
widgets
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function validateWorkspaceTemplate(template) {
|
|
602
|
+
if (!isPlainObject(template)) {
|
|
603
|
+
const error = new Error("workspace template must be a plain object");
|
|
604
|
+
error.code = "INVALID_WORKSPACE_TEMPLATE";
|
|
605
|
+
error.details = ["root must be a plain object"];
|
|
606
|
+
throw error;
|
|
607
|
+
}
|
|
608
|
+
const errors = [];
|
|
609
|
+
if (typeof template.id !== "string" || !template.id) errors.push("template.id must be a non-empty string");
|
|
610
|
+
if (typeof template.name !== "string" || !template.name) errors.push("template.name must be a non-empty string");
|
|
611
|
+
if (template.description !== undefined && typeof template.description !== "string") {
|
|
612
|
+
errors.push("template.description must be a string");
|
|
613
|
+
}
|
|
614
|
+
if (template.category !== undefined && typeof template.category !== "string") {
|
|
615
|
+
errors.push("template.category must be a string");
|
|
616
|
+
}
|
|
617
|
+
if (template.bestFor !== undefined) validateStringArray(template.bestFor, "template.bestFor", errors);
|
|
618
|
+
if (template.tags !== undefined) validateStringArray(template.tags, "template.tags", errors);
|
|
619
|
+
if (template.preview !== undefined) {
|
|
620
|
+
if (!isPlainObject(template.preview)) errors.push("template.preview must be a plain object");
|
|
621
|
+
}
|
|
622
|
+
if (template.dashboard !== undefined) {
|
|
623
|
+
if (!isPlainObject(template.dashboard)) {
|
|
624
|
+
errors.push("template.dashboard must be a plain object");
|
|
625
|
+
} else {
|
|
626
|
+
if (typeof template.dashboard.name !== "string" || !template.dashboard.name) {
|
|
627
|
+
errors.push("template.dashboard.name must be a non-empty string");
|
|
628
|
+
}
|
|
629
|
+
if (template.dashboard.status !== undefined && !["draft", "active", "archived"].includes(template.dashboard.status)) {
|
|
630
|
+
errors.push("template.dashboard.status must be draft, active, or archived");
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
if (template.widgets !== undefined) {
|
|
635
|
+
validateTemplateWidgetArray(template.widgets, "template.widgets", errors);
|
|
636
|
+
}
|
|
637
|
+
if (errors.length) {
|
|
638
|
+
const error = new Error(`invalid workspace template: ${errors.join("; ")}`);
|
|
639
|
+
error.code = "INVALID_WORKSPACE_TEMPLATE";
|
|
640
|
+
error.details = errors;
|
|
641
|
+
throw error;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function requireIdFactory(idFactory) {
|
|
646
|
+
if (typeof idFactory !== "function") {
|
|
647
|
+
const error = new Error("idFactory function is required to clone a template");
|
|
648
|
+
error.code = "MISSING_TEMPLATE_ID_FACTORY";
|
|
649
|
+
throw error;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function cloneTemplateWidgets(template, idFactory) {
|
|
654
|
+
const widgets = Array.isArray(template.widgets) ? template.widgets : [];
|
|
655
|
+
return widgets.map((widget) => ({
|
|
656
|
+
id: idFactory("widget"),
|
|
657
|
+
kind: widget.kind,
|
|
658
|
+
title: widget.title,
|
|
659
|
+
position: { ...widget.position },
|
|
660
|
+
config: widget.config !== undefined ? JSON.parse(JSON.stringify(widget.config)) : defaultConfigFor(widget.kind)
|
|
661
|
+
}));
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function cloneTemplateToTab(template, options = {}) {
|
|
665
|
+
validateWorkspaceTemplate(template);
|
|
666
|
+
requireIdFactory(options.idFactory);
|
|
667
|
+
const widgets = cloneTemplateWidgets(template, options.idFactory);
|
|
668
|
+
return {
|
|
669
|
+
id: options.idFactory("tab"),
|
|
670
|
+
name: typeof options.tabName === "string" && options.tabName ? options.tabName : template.name,
|
|
671
|
+
widgets
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function cloneTemplateToDashboard(template, options = {}) {
|
|
676
|
+
validateWorkspaceTemplate(template);
|
|
677
|
+
requireIdFactory(options.idFactory);
|
|
678
|
+
const tab = cloneTemplateToTab(template, { tabName: template.name, idFactory: options.idFactory });
|
|
679
|
+
const baseDashboard = isPlainObject(template.dashboard) ? template.dashboard : {};
|
|
680
|
+
return {
|
|
681
|
+
dashboard: {
|
|
682
|
+
id: options.idFactory("dashboard"),
|
|
683
|
+
name: typeof options.dashboardName === "string" && options.dashboardName ? options.dashboardName : baseDashboard.name || template.name,
|
|
684
|
+
createdBy: "Workspace owner",
|
|
685
|
+
updatedAt: "new",
|
|
686
|
+
status: ["draft", "active", "archived"].includes(baseDashboard.status) ? baseDashboard.status : "draft"
|
|
687
|
+
},
|
|
688
|
+
tab
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function unwrapWorkspaceTemplateImport(parsed) {
|
|
693
|
+
if (!isPlainObject(parsed)) {
|
|
694
|
+
const error = new Error("template import must be a plain object");
|
|
695
|
+
error.code = "INVALID_WORKSPACE_TEMPLATE_IMPORT";
|
|
696
|
+
throw error;
|
|
697
|
+
}
|
|
698
|
+
if (parsed.kind !== undefined && parsed.kind !== WORKSPACE_TEMPLATE_KIND) {
|
|
699
|
+
const error = new Error(`unrecognized template kind: ${parsed.kind}`);
|
|
700
|
+
error.code = "INVALID_WORKSPACE_TEMPLATE_IMPORT";
|
|
701
|
+
throw error;
|
|
702
|
+
}
|
|
703
|
+
if (parsed.kind === WORKSPACE_TEMPLATE_KIND) {
|
|
704
|
+
if (!isPlainObject(parsed.payload)) {
|
|
705
|
+
const error = new Error("template import payload must be a plain object");
|
|
706
|
+
error.code = "INVALID_WORKSPACE_TEMPLATE_IMPORT";
|
|
707
|
+
throw error;
|
|
708
|
+
}
|
|
709
|
+
return parsed.payload;
|
|
710
|
+
}
|
|
711
|
+
return parsed;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function wrapWorkspaceTemplateExport(payload, metadata = {}) {
|
|
715
|
+
return {
|
|
716
|
+
version: WORKSPACE_TEMPLATE_VERSION,
|
|
717
|
+
kind: WORKSPACE_TEMPLATE_KIND,
|
|
718
|
+
exportedAt: new Date().toISOString(),
|
|
719
|
+
source: WORKSPACE_TEMPLATE_SOURCE,
|
|
720
|
+
name: typeof metadata.name === "string" && metadata.name ? metadata.name : "Workspace template",
|
|
721
|
+
description: typeof metadata.description === "string" ? metadata.description : "",
|
|
722
|
+
payload
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function validateWorkspaceConfig(nextConfig) {
|
|
727
|
+
if (!isPlainObject(nextConfig)) {
|
|
728
|
+
const error = new Error("workspace config must be a plain object");
|
|
729
|
+
error.code = "INVALID_WORKSPACE_CONFIG";
|
|
730
|
+
error.details = ["root must be a plain object"];
|
|
731
|
+
throw error;
|
|
732
|
+
}
|
|
733
|
+
const errors = [];
|
|
734
|
+
for (const key of Object.keys(nextConfig)) {
|
|
735
|
+
if (!KNOWN_FIELDS.includes(key)) errors.push(`unknown top-level field: ${key}`);
|
|
736
|
+
}
|
|
737
|
+
if (nextConfig.dashboards !== undefined) validateDashboardArray(nextConfig.dashboards, errors);
|
|
738
|
+
if (nextConfig.widgetTypes !== undefined && !Array.isArray(nextConfig.widgetTypes)) errors.push("widgetTypes must be an array");
|
|
739
|
+
if (nextConfig.canvas !== undefined) validateCanvasConfig(nextConfig.canvas, errors);
|
|
740
|
+
if (errors.length) {
|
|
741
|
+
const error = new Error(`invalid workspace config: ${errors.join("; ")}`);
|
|
742
|
+
error.code = "INVALID_WORKSPACE_CONFIG";
|
|
743
|
+
error.details = errors;
|
|
744
|
+
throw error;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
export {
|
|
749
|
+
DASHBOARD_TEMPLATES,
|
|
750
|
+
GRID_COLUMNS,
|
|
751
|
+
GRID_ROWS,
|
|
752
|
+
KNOWN_DATA_BINDING_MODES,
|
|
753
|
+
KNOWN_FIELDS,
|
|
754
|
+
KNOWN_WIDGET_KINDS,
|
|
755
|
+
SAMPLE_DATA_BINDINGS,
|
|
756
|
+
SAMPLE_VIEW_ROWS,
|
|
757
|
+
WIDGET_SCHEMA_CONTRACTS,
|
|
758
|
+
WORKSPACE_TEMPLATE_KIND,
|
|
759
|
+
WORKSPACE_TEMPLATE_SOURCE,
|
|
760
|
+
WORKSPACE_TEMPLATE_VERSION,
|
|
761
|
+
cloneTemplateToDashboard,
|
|
762
|
+
cloneTemplateToTab,
|
|
763
|
+
defaultConfigFor,
|
|
764
|
+
normalizeWorkspaceTemplate,
|
|
765
|
+
unwrapWorkspaceTemplateImport,
|
|
766
|
+
validateTemplateWidgetArray,
|
|
767
|
+
validateWorkspaceConfig,
|
|
768
|
+
validateWorkspaceTemplate,
|
|
769
|
+
wrapWorkspaceTemplateExport
|
|
770
|
+
};
|