@luquimbo/bi-superpowers 3.2.0 → 4.1.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/.claude-plugin/marketplace.json +5 -3
- package/.claude-plugin/plugin.json +28 -2
- package/.claude-plugin/skill-manifest.json +22 -6
- package/.plugin/plugin.json +1 -1
- package/AGENTS.md +52 -36
- package/CHANGELOG.md +295 -0
- package/README.md +75 -26
- package/bin/build-plugin.js +11 -4
- package/bin/cli.js +113 -16
- package/bin/commands/build-desktop.js +35 -16
- package/bin/commands/diff.js +31 -13
- package/bin/commands/install.js +7 -3
- package/bin/commands/lint.js +40 -26
- package/bin/commands/mcp-setup.js +3 -10
- package/bin/commands/update-check.js +389 -0
- package/bin/lib/generators/claude-plugin.js +144 -6
- package/bin/lib/generators/shared.js +29 -33
- package/bin/lib/mcp-config.js +168 -12
- package/bin/lib/skills.js +115 -27
- package/bin/postinstall.js +4 -2
- package/bin/utils/mcp-detect.js +2 -2
- package/commands/bi-start.md +218 -0
- package/commands/pbi-connect.md +43 -65
- package/commands/project-kickoff.md +393 -673
- package/commands/report-design.md +403 -0
- package/desktop-extension/manifest.json +3 -3
- package/package.json +7 -5
- package/skills/bi-start/SKILL.md +220 -0
- package/skills/bi-start/scripts/update-check.js +389 -0
- package/skills/pbi-connect/SKILL.md +45 -67
- package/skills/pbi-connect/scripts/update-check.js +389 -0
- package/skills/project-kickoff/SKILL.md +395 -675
- package/skills/project-kickoff/scripts/update-check.js +389 -0
- package/skills/report-design/SKILL.md +405 -0
- package/skills/report-design/references/cli-commands.md +184 -0
- package/skills/report-design/references/cli-setup.md +101 -0
- package/skills/report-design/references/close-write-open-pattern.md +80 -0
- package/skills/report-design/references/layouts/finance.md +65 -0
- package/skills/report-design/references/layouts/generic.md +46 -0
- package/skills/report-design/references/layouts/hr.md +48 -0
- package/skills/report-design/references/layouts/marketing.md +45 -0
- package/skills/report-design/references/layouts/operations.md +44 -0
- package/skills/report-design/references/layouts/sales.md +50 -0
- package/skills/report-design/references/native-visuals.md +341 -0
- package/skills/report-design/references/pbi-desktop-installation.md +87 -0
- package/skills/report-design/references/pbir-preview-activation.md +40 -0
- package/skills/report-design/references/slicer.md +89 -0
- package/skills/report-design/references/textbox.md +101 -0
- package/skills/report-design/references/themes/BISuperpowers.json +915 -0
- package/skills/report-design/references/troubleshooting.md +135 -0
- package/skills/report-design/references/visual-types.md +78 -0
- package/skills/report-design/scripts/apply-theme.js +243 -0
- package/skills/report-design/scripts/create-visual.js +878 -0
- package/skills/report-design/scripts/ensure-pbi-cli.sh +41 -0
- package/skills/report-design/scripts/update-check.js +389 -0
- package/skills/report-design/scripts/validate-pbir.js +322 -0
- package/src/content/base.md +12 -68
- package/src/content/mcp-requirements.json +0 -25
- package/src/content/routing.md +19 -74
- package/src/content/skills/bi-start.md +191 -0
- package/src/content/skills/pbi-connect.md +22 -65
- package/src/content/skills/project-kickoff.md +372 -673
- package/src/content/skills/report-design/SKILL.md +376 -0
- package/src/content/skills/report-design/references/cli-commands.md +184 -0
- package/src/content/skills/report-design/references/cli-setup.md +101 -0
- package/src/content/skills/report-design/references/close-write-open-pattern.md +80 -0
- package/src/content/skills/report-design/references/layouts/finance.md +65 -0
- package/src/content/skills/report-design/references/layouts/generic.md +46 -0
- package/src/content/skills/report-design/references/layouts/hr.md +48 -0
- package/src/content/skills/report-design/references/layouts/marketing.md +45 -0
- package/src/content/skills/report-design/references/layouts/operations.md +44 -0
- package/src/content/skills/report-design/references/layouts/sales.md +50 -0
- package/src/content/skills/report-design/references/native-visuals.md +341 -0
- package/src/content/skills/report-design/references/pbi-desktop-installation.md +87 -0
- package/src/content/skills/report-design/references/pbir-preview-activation.md +40 -0
- package/src/content/skills/report-design/references/slicer.md +89 -0
- package/src/content/skills/report-design/references/textbox.md +101 -0
- package/src/content/skills/report-design/references/themes/BISuperpowers.json +915 -0
- package/src/content/skills/report-design/references/troubleshooting.md +135 -0
- package/src/content/skills/report-design/references/visual-types.md +78 -0
- package/src/content/skills/report-design/scripts/apply-theme.js +243 -0
- package/src/content/skills/report-design/scripts/create-visual.js +878 -0
- package/src/content/skills/report-design/scripts/ensure-pbi-cli.sh +41 -0
- package/src/content/skills/report-design/scripts/validate-pbir.js +322 -0
- package/bin/commands/install.test.js +0 -289
- package/bin/commands/lint.test.js +0 -103
- package/bin/lib/generators/claude-plugin.test.js +0 -111
- package/bin/lib/mcp-config.test.js +0 -310
- package/bin/lib/microsoft-mcp.test.js +0 -115
- package/bin/utils/mcp-detect.test.js +0 -81
- package/bin/utils/tui.test.js +0 -127
|
@@ -0,0 +1,878 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* create-visual.js — Node-only PBIR visual creator.
|
|
4
|
+
*
|
|
5
|
+
* Replaces `pbi visual add` + `pbi visual bind` for the 28 native visualTypes
|
|
6
|
+
* that PBI Desktop (March 2026, schema 2.7.0) accepts. No dependency on the
|
|
7
|
+
* pbi-cli-tool upstream. The exact count is locked in by create-visual.test.js
|
|
8
|
+
* (the EXPECTED_TYPES array + a length assertion) — update both if the
|
|
9
|
+
* allowlist changes.
|
|
10
|
+
*
|
|
11
|
+
* Why: the CLI has 3 known regressions (see SKILL.md audit):
|
|
12
|
+
* 1. advertises only 5 types in `--type`; silently accepts ~20 more.
|
|
13
|
+
* 2. accepts type aliases that rewrite to invalid PBIR (e.g. `pie` → `donutChart`).
|
|
14
|
+
* 3. accepts `stackedBarChart` / `stackedColumnChart` which are NOT native
|
|
15
|
+
* visualTypes — Desktop renders them as "objeto visual personalizado".
|
|
16
|
+
*
|
|
17
|
+
* This script is the single source of truth for what a native PBIR visual
|
|
18
|
+
* looks like. Shape canonical derived from:
|
|
19
|
+
* - CLI-generated shapes (for the 23 types the CLI can create)
|
|
20
|
+
* - examples/smoke-test/.../theme/visuals/*.json (hundredPercentStackedBarChart,
|
|
21
|
+
* hundredPercentStackedColumnChart, pieChart, scatterChart-with-Legend-and-Size,
|
|
22
|
+
* waterfallChart-with-Breakdown)
|
|
23
|
+
*
|
|
24
|
+
* Usage:
|
|
25
|
+
* node create-visual.js \
|
|
26
|
+
* --report-path ./pbip-files/Foo.Report \
|
|
27
|
+
* --page experiment \
|
|
28
|
+
* --type clusteredBarChart \
|
|
29
|
+
* --name my_bar \
|
|
30
|
+
* --x 24 --y 24 --width 600 --height 280 \
|
|
31
|
+
* --bind category="Categorias Finanzas[Categoria]" \
|
|
32
|
+
* --bind y="_Measures[Gastos]" \
|
|
33
|
+
* --bind legend="Cuentas[Banco]" \
|
|
34
|
+
* --title "Gasto por categoría"
|
|
35
|
+
*
|
|
36
|
+
* Special visual types with shortcuts:
|
|
37
|
+
*
|
|
38
|
+
* --type textbox use --paragraph "text" (repeatable), no --bind
|
|
39
|
+
* or --title/--description for title+body shortcut.
|
|
40
|
+
*
|
|
41
|
+
* --type slicer use --slicer-mode {basic|horizontal|dropdown|between|relative}
|
|
42
|
+
* + --bind values=Table[Column]
|
|
43
|
+
* (horizontal implies Basic mode + orientation 2)
|
|
44
|
+
*
|
|
45
|
+
* Options:
|
|
46
|
+
* -p, --report-path Path to the .Report folder. [required]
|
|
47
|
+
* --page Page name (folder under definition/pages). [required]
|
|
48
|
+
* -t, --type Native PBIR visualType. See `--list-types`. [required]
|
|
49
|
+
* -n, --name Visual name (also the folder name). Auto-generated if omitted.
|
|
50
|
+
* --x, --y Canvas position (pixels). Default 0.
|
|
51
|
+
* --width, --height Size. Defaults 400x300 for charts, 280x120 for cards.
|
|
52
|
+
* --tab-order Tab order. Auto-incremented from existing visuals if omitted.
|
|
53
|
+
* -z z-index. Auto-incremented from existing visuals if omitted.
|
|
54
|
+
* --bind role=FIELD Bind a field to a data role. Repeatable.
|
|
55
|
+
* FIELD = "Table[Column]" or "Table[Measure]".
|
|
56
|
+
* Use 'Table Name'[Field] if the table has spaces.
|
|
57
|
+
* Role is case-insensitive (category = Category).
|
|
58
|
+
* --title TEXT Set container title text (overrides the default "" title).
|
|
59
|
+
* --no-title Hide the container title.
|
|
60
|
+
* --paragraph TEXT Textbox paragraph. Repeatable. First paragraph is styled
|
|
61
|
+
* bigger by default (title-ish).
|
|
62
|
+
* --description TEXT Textbox body shortcut (used with --title).
|
|
63
|
+
* --slicer-mode MODE basic | horizontal | dropdown | between | relative
|
|
64
|
+
* --list-types Print all supported visualTypes with their roles and exit.
|
|
65
|
+
* -h, --help Show this help.
|
|
66
|
+
*
|
|
67
|
+
* Idempotent: if a visual with the same --name already exists on --page,
|
|
68
|
+
* this script FAILS (no silent overwrite). Delete the folder manually or
|
|
69
|
+
* use a different --name. This matches the CLI's behavior and avoids
|
|
70
|
+
* accidental data loss on bindings.
|
|
71
|
+
*
|
|
72
|
+
* Exit codes: 0 success, 1 user error (bad args, unknown type), 2 I/O error.
|
|
73
|
+
*/
|
|
74
|
+
|
|
75
|
+
'use strict';
|
|
76
|
+
|
|
77
|
+
const fs = require('fs');
|
|
78
|
+
const path = require('path');
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// NATIVE_VISUAL_TYPES — the single source of truth for what PBIR accepts.
|
|
82
|
+
//
|
|
83
|
+
// Each entry describes one visualType:
|
|
84
|
+
// - description: one-line human explanation.
|
|
85
|
+
// - roles: { <RoleName>: { kind: 'measure'|'column', required, multi } }
|
|
86
|
+
// Role name is the PBIR Pascal-case name (Category, Y, Legend,
|
|
87
|
+
// ColumnY, LineY, Breakdown, Details, X, Size, Indicator, etc.)
|
|
88
|
+
// - defaults: (optional) extra fields to set on visual (e.g.
|
|
89
|
+
// sortDefinition for cardVisual).
|
|
90
|
+
// - notes: (optional) extra guidance printed with --list-types.
|
|
91
|
+
//
|
|
92
|
+
// NOT in this table = NOT a supported native PBIR visualType. Do NOT add
|
|
93
|
+
// `stackedBarChart`, `stackedColumnChart`, or `stackedAreaChart` — they are
|
|
94
|
+
// patterns (use the non-stacked type with a Legend field), not types.
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
const NATIVE_VISUAL_TYPES = {
|
|
98
|
+
// ---- Single value ----
|
|
99
|
+
card: {
|
|
100
|
+
description: 'Tarjeta KPI clásica (un valor grande + label).',
|
|
101
|
+
roles: {
|
|
102
|
+
Values: { kind: 'measure', required: true, multi: false },
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
cardVisual: {
|
|
106
|
+
description: 'New card (March 2026). Multiple measures + targets inline.',
|
|
107
|
+
roles: {
|
|
108
|
+
Data: { kind: 'measure', required: true, multi: true },
|
|
109
|
+
},
|
|
110
|
+
defaults: {
|
|
111
|
+
query: { sortDefinition: { sort: [], isDefaultSort: true } },
|
|
112
|
+
visualContainerObjects: {},
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
kpi: {
|
|
116
|
+
description: 'KPI con Indicator + Goal + TrendLine.',
|
|
117
|
+
roles: {
|
|
118
|
+
Indicator: { kind: 'measure', required: true, multi: false },
|
|
119
|
+
Goal: { kind: 'measure', required: false, multi: false },
|
|
120
|
+
TrendLine: { kind: 'column', required: false, multi: false },
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
gauge: {
|
|
124
|
+
description: 'Gauge arco: Y actual vs MaxValue (+ target opcional).',
|
|
125
|
+
roles: {
|
|
126
|
+
Y: { kind: 'measure', required: true, multi: false },
|
|
127
|
+
MaxValue: { kind: 'measure', required: false, multi: false },
|
|
128
|
+
TargetValue: { kind: 'measure', required: false, multi: false },
|
|
129
|
+
MinValue: { kind: 'measure', required: false, multi: false },
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
multiRowCard: {
|
|
133
|
+
description: 'Card multi-valor (múltiples measures apiladas).',
|
|
134
|
+
roles: {
|
|
135
|
+
Values: { kind: 'measure', required: true, multi: true },
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
// ---- Bar / Column ----
|
|
140
|
+
barChart: {
|
|
141
|
+
description: 'Bar chart. Agrega Legend para stacked-bar (no uses stackedBarChart).',
|
|
142
|
+
roles: {
|
|
143
|
+
Category: { kind: 'column', required: true, multi: false },
|
|
144
|
+
Y: { kind: 'measure', required: true, multi: true },
|
|
145
|
+
Legend: { kind: 'column', required: false, multi: false },
|
|
146
|
+
},
|
|
147
|
+
notes: 'Para stacked: agregá --bind legend=Tabla[Col]. NO existe stackedBarChart nativo.',
|
|
148
|
+
},
|
|
149
|
+
clusteredBarChart: {
|
|
150
|
+
description: 'Bar chart agrupado (side-by-side por Series).',
|
|
151
|
+
roles: {
|
|
152
|
+
Category: { kind: 'column', required: true, multi: false },
|
|
153
|
+
Y: { kind: 'measure', required: true, multi: true },
|
|
154
|
+
Legend: { kind: 'column', required: false, multi: false },
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
columnChart: {
|
|
158
|
+
description: 'Column chart. Agrega Legend para stacked-column (no uses stackedColumnChart).',
|
|
159
|
+
roles: {
|
|
160
|
+
Category: { kind: 'column', required: true, multi: false },
|
|
161
|
+
Y: { kind: 'measure', required: true, multi: true },
|
|
162
|
+
Legend: { kind: 'column', required: false, multi: false },
|
|
163
|
+
},
|
|
164
|
+
notes: 'Para stacked: agregá --bind legend=Tabla[Col]. NO existe stackedColumnChart nativo.',
|
|
165
|
+
},
|
|
166
|
+
clusteredColumnChart: {
|
|
167
|
+
description: 'Column chart agrupado (side-by-side por Series).',
|
|
168
|
+
roles: {
|
|
169
|
+
Category: { kind: 'column', required: true, multi: false },
|
|
170
|
+
Y: { kind: 'measure', required: true, multi: true },
|
|
171
|
+
Legend: { kind: 'column', required: false, multi: false },
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
hundredPercentStackedBarChart: {
|
|
175
|
+
description: 'Bar 100% apilado (Legend obligatorio).',
|
|
176
|
+
roles: {
|
|
177
|
+
Category: { kind: 'column', required: true, multi: false },
|
|
178
|
+
Y: { kind: 'measure', required: true, multi: false },
|
|
179
|
+
Legend: { kind: 'column', required: true, multi: false },
|
|
180
|
+
},
|
|
181
|
+
notes: 'Legend es mandatorio — sin él no tiene sentido el 100%.',
|
|
182
|
+
},
|
|
183
|
+
hundredPercentStackedColumnChart: {
|
|
184
|
+
description: 'Column 100% apilado (Legend obligatorio).',
|
|
185
|
+
roles: {
|
|
186
|
+
Category: { kind: 'column', required: true, multi: false },
|
|
187
|
+
Y: { kind: 'measure', required: true, multi: false },
|
|
188
|
+
Legend: { kind: 'column', required: true, multi: false },
|
|
189
|
+
},
|
|
190
|
+
notes: 'Legend es mandatorio — sin él no tiene sentido el 100%.',
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
// ---- Line / Area ----
|
|
194
|
+
lineChart: {
|
|
195
|
+
description: 'Line chart para time series.',
|
|
196
|
+
roles: {
|
|
197
|
+
Category: { kind: 'column', required: true, multi: false },
|
|
198
|
+
Y: { kind: 'measure', required: true, multi: true },
|
|
199
|
+
Legend: { kind: 'column', required: false, multi: false },
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
areaChart: {
|
|
203
|
+
description: 'Area chart. Agrega Legend para stacked-area (no uses stackedAreaChart).',
|
|
204
|
+
roles: {
|
|
205
|
+
Category: { kind: 'column', required: true, multi: false },
|
|
206
|
+
Y: { kind: 'measure', required: true, multi: true },
|
|
207
|
+
Legend: { kind: 'column', required: false, multi: false },
|
|
208
|
+
},
|
|
209
|
+
notes: 'Para stacked: agregá --bind legend=Tabla[Col]. NO existe stackedAreaChart nativo.',
|
|
210
|
+
},
|
|
211
|
+
lineClusteredColumnComboChart: {
|
|
212
|
+
description: 'Combo: columnas agrupadas + líneas sobre el mismo eje X.',
|
|
213
|
+
roles: {
|
|
214
|
+
Category: { kind: 'column', required: true, multi: false },
|
|
215
|
+
ColumnY: { kind: 'measure', required: true, multi: true },
|
|
216
|
+
LineY: { kind: 'measure', required: true, multi: true },
|
|
217
|
+
Legend: { kind: 'column', required: false, multi: false },
|
|
218
|
+
},
|
|
219
|
+
notes:
|
|
220
|
+
'ÚNICA variante combo que Desktop March 2026 renderiza (la variante stacked está rota — ver KNOWN_NON_NATIVE_TYPES). Usa --bind columny= y --bind liney=.',
|
|
221
|
+
},
|
|
222
|
+
ribbonChart: {
|
|
223
|
+
description: 'Ribbon chart (muestra ranking changing over time).',
|
|
224
|
+
roles: {
|
|
225
|
+
Category: { kind: 'column', required: true, multi: false },
|
|
226
|
+
Y: { kind: 'measure', required: true, multi: false },
|
|
227
|
+
Legend: { kind: 'column', required: false, multi: false },
|
|
228
|
+
},
|
|
229
|
+
notes:
|
|
230
|
+
'Puede requerir el preview flag "Ribbon visual" en Desktop Options. Verificá en `references/visual-types.md` antes de publicar un reporte que depende de este tipo.',
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
// ---- Part-to-whole ----
|
|
234
|
+
pieChart: {
|
|
235
|
+
description: 'Torta (pie).',
|
|
236
|
+
roles: {
|
|
237
|
+
Category: { kind: 'column', required: true, multi: false },
|
|
238
|
+
Y: { kind: 'measure', required: true, multi: false },
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
donutChart: {
|
|
242
|
+
description: 'Dona (donut).',
|
|
243
|
+
roles: {
|
|
244
|
+
Category: { kind: 'column', required: true, multi: false },
|
|
245
|
+
Y: { kind: 'measure', required: true, multi: false },
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
treemap: {
|
|
249
|
+
description: 'Treemap (rectángulos proporcionales).',
|
|
250
|
+
roles: {
|
|
251
|
+
Category: { kind: 'column', required: true, multi: false },
|
|
252
|
+
Values: { kind: 'measure', required: true, multi: false },
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
// ---- Análisis ----
|
|
257
|
+
funnelChart: {
|
|
258
|
+
description: 'Embudo (stages con drop-off).',
|
|
259
|
+
roles: {
|
|
260
|
+
Category: { kind: 'column', required: true, multi: false },
|
|
261
|
+
Y: { kind: 'measure', required: true, multi: false },
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
scatterChart: {
|
|
265
|
+
description: 'Scatter / bubble (X vs Y, opcional Size → bubble).',
|
|
266
|
+
roles: {
|
|
267
|
+
Details: { kind: 'column', required: true, multi: false },
|
|
268
|
+
X: { kind: 'measure', required: true, multi: false },
|
|
269
|
+
Y: { kind: 'measure', required: true, multi: false },
|
|
270
|
+
Legend: { kind: 'column', required: false, multi: false },
|
|
271
|
+
Size: { kind: 'measure', required: false, multi: false },
|
|
272
|
+
},
|
|
273
|
+
notes: 'Si agregás --bind size=..., se convierte en bubble chart.',
|
|
274
|
+
},
|
|
275
|
+
waterfallChart: {
|
|
276
|
+
description: 'Cascada (accumulator con deltas positivos/negativos).',
|
|
277
|
+
roles: {
|
|
278
|
+
Category: { kind: 'column', required: true, multi: false },
|
|
279
|
+
Y: { kind: 'measure', required: true, multi: false },
|
|
280
|
+
Breakdown: { kind: 'column', required: false, multi: false },
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
|
|
284
|
+
// ---- Tabular ----
|
|
285
|
+
tableEx: {
|
|
286
|
+
description: 'Tabla (row-detail, muchas columnas).',
|
|
287
|
+
roles: {
|
|
288
|
+
Values: { kind: 'any', required: true, multi: true },
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
pivotTable: {
|
|
292
|
+
description: 'Matriz (rows × columns × values).',
|
|
293
|
+
roles: {
|
|
294
|
+
Rows: { kind: 'column', required: true, multi: true },
|
|
295
|
+
Columns: { kind: 'column', required: false, multi: true },
|
|
296
|
+
Values: { kind: 'measure', required: true, multi: true },
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
|
|
300
|
+
// ---- Filtros ----
|
|
301
|
+
slicer: {
|
|
302
|
+
description: 'Slicer clásico (5 modos: basic, horizontal, dropdown, between, relative).',
|
|
303
|
+
roles: {
|
|
304
|
+
Values: { kind: 'column', required: true, multi: false },
|
|
305
|
+
},
|
|
306
|
+
notes: 'Usá --slicer-mode para elegir modo. Default: basic (vertical list).',
|
|
307
|
+
},
|
|
308
|
+
advancedSlicerVisual: {
|
|
309
|
+
description: 'Advanced slicer (March 2026 new slicer).',
|
|
310
|
+
roles: {
|
|
311
|
+
Values: { kind: 'column', required: true, multi: false },
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
listSlicer: {
|
|
315
|
+
description: 'List slicer (legacy typeId, list-only).',
|
|
316
|
+
roles: {
|
|
317
|
+
Values: { kind: 'column', required: true, multi: false },
|
|
318
|
+
},
|
|
319
|
+
notes: 'Legacy typeId. Preferí `slicer` con `--slicer-mode basic` para reportes nuevos.',
|
|
320
|
+
},
|
|
321
|
+
textSlicer: {
|
|
322
|
+
description: 'Text input slicer (filtra por substring).',
|
|
323
|
+
roles: {
|
|
324
|
+
Values: { kind: 'column', required: true, multi: false },
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
|
|
328
|
+
// ---- Texto ----
|
|
329
|
+
textbox: {
|
|
330
|
+
description: 'Textbox libre (no tiene query). Usá --paragraph o --title+--description.',
|
|
331
|
+
roles: {}, // no data roles
|
|
332
|
+
notes: 'No admite --bind. Usá --paragraph (repeatable) o --title/--description.',
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
// Known non-native types we want to *block* with a clear error message.
|
|
337
|
+
const KNOWN_NON_NATIVE_TYPES = {
|
|
338
|
+
stackedBarChart:
|
|
339
|
+
'stackedBarChart NO es un tipo nativo. Usá --type barChart + --bind legend=Tabla[Col] para el mismo efecto.',
|
|
340
|
+
stackedColumnChart:
|
|
341
|
+
'stackedColumnChart NO es un tipo nativo. Usá --type columnChart + --bind legend=Tabla[Col].',
|
|
342
|
+
stackedAreaChart:
|
|
343
|
+
'stackedAreaChart NO es un tipo nativo. Usá --type areaChart + --bind legend=Tabla[Col].',
|
|
344
|
+
lineStackedColumnComboChart:
|
|
345
|
+
'lineStackedColumnComboChart pasa validate pero Desktop no lo renderiza (container vacío). Usá --type lineClusteredColumnComboChart (la variante clustered, que SÍ funciona).',
|
|
346
|
+
funnel: 'funnel es un typeId legacy. Usá --type funnelChart.',
|
|
347
|
+
pie: 'pie no es un visualType. Usá --type pieChart (o --type donutChart).',
|
|
348
|
+
donut: 'donut no es un visualType. Usá --type donutChart.',
|
|
349
|
+
bar_chart: 'bar_chart es alias del CLI upstream. Este script usa nombres PBIR nativos — usá --type barChart.',
|
|
350
|
+
line_chart: 'line_chart es alias del CLI upstream. Usá --type lineChart.',
|
|
351
|
+
column_chart: 'column_chart no existe. Usá --type columnChart.',
|
|
352
|
+
area_chart: 'area_chart no existe. Usá --type areaChart.',
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const SLICER_MODES = {
|
|
356
|
+
basic: { mode: 'Basic', orientation: 1 },
|
|
357
|
+
horizontal: { mode: 'Basic', orientation: 2 },
|
|
358
|
+
dropdown: { mode: 'Dropdown' },
|
|
359
|
+
between: { mode: 'Between' },
|
|
360
|
+
relative: { mode: 'Relative' },
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const VISUAL_SCHEMA = 'https://developer.microsoft.com/json-schemas/fabric/item/report/definition/visualContainer/2.7.0/schema.json';
|
|
364
|
+
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
// CLI argument parsing
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
|
|
369
|
+
function fail(msg, code = 1) {
|
|
370
|
+
process.stderr.write(`create-visual: ${msg}\n`);
|
|
371
|
+
process.exit(code);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function parseArgs(argv) {
|
|
375
|
+
const out = {
|
|
376
|
+
bindings: {}, // { RoleName: [fieldRef, ...] } always array to handle multi
|
|
377
|
+
paragraphs: [],
|
|
378
|
+
};
|
|
379
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
380
|
+
const a = argv[i];
|
|
381
|
+
const next = () => {
|
|
382
|
+
const v = argv[++i];
|
|
383
|
+
if (v === undefined) fail(`missing value for ${a}`);
|
|
384
|
+
return v;
|
|
385
|
+
};
|
|
386
|
+
if (a === '-p' || a === '--report-path') out.reportPath = next();
|
|
387
|
+
else if (a === '--page') out.page = next();
|
|
388
|
+
else if (a === '-t' || a === '--type') out.type = next();
|
|
389
|
+
else if (a === '-n' || a === '--name') out.name = next();
|
|
390
|
+
else if (a === '--x') out.x = Number(next());
|
|
391
|
+
else if (a === '--y') out.y = Number(next());
|
|
392
|
+
else if (a === '--width') out.width = Number(next());
|
|
393
|
+
else if (a === '--height') out.height = Number(next());
|
|
394
|
+
else if (a === '--tab-order') out.tabOrder = Number(next());
|
|
395
|
+
else if (a === '-z' || a === '--z') out.z = Number(next());
|
|
396
|
+
else if (a === '--bind' || a === '--binding') {
|
|
397
|
+
const v = next();
|
|
398
|
+
const eq = v.indexOf('=');
|
|
399
|
+
if (eq < 1) fail(`--bind expects role=Table[Field], got: ${v}`);
|
|
400
|
+
const role = v.slice(0, eq).trim();
|
|
401
|
+
const ref = v.slice(eq + 1).trim();
|
|
402
|
+
if (!role || !ref) fail(`--bind expects role=Table[Field], got: ${v}`);
|
|
403
|
+
out.bindings[role] = out.bindings[role] || [];
|
|
404
|
+
out.bindings[role].push(ref);
|
|
405
|
+
} else if (a === '--title') out.title = next();
|
|
406
|
+
else if (a === '--no-title') out.noTitle = true;
|
|
407
|
+
else if (a === '--paragraph') out.paragraphs.push(next());
|
|
408
|
+
else if (a === '--description') out.description = next();
|
|
409
|
+
else if (a === '--slicer-mode') out.slicerMode = next();
|
|
410
|
+
else if (a === '--list-types') out.listTypes = true;
|
|
411
|
+
else if (a === '-h' || a === '--help') out.help = true;
|
|
412
|
+
else fail(`unknown argument: ${a}`);
|
|
413
|
+
}
|
|
414
|
+
return out;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function printHelp() {
|
|
418
|
+
process.stdout.write(
|
|
419
|
+
[
|
|
420
|
+
'Usage: create-visual.js --report-path <path> --page <name> --type <visualType> [options]',
|
|
421
|
+
'',
|
|
422
|
+
'Required:',
|
|
423
|
+
' -p, --report-path PATH Path to .Report folder',
|
|
424
|
+
' --page NAME Page folder name under definition/pages/',
|
|
425
|
+
' -t, --type TYPE Native PBIR visualType (use --list-types to see all)',
|
|
426
|
+
'',
|
|
427
|
+
'Visual placement:',
|
|
428
|
+
' -n, --name NAME Visual folder name (auto-generated if omitted)',
|
|
429
|
+
' --x N --y N Canvas position in px (default 0,0)',
|
|
430
|
+
' --width N Width in px (default 400)',
|
|
431
|
+
' --height N Height in px (default 300)',
|
|
432
|
+
' --tab-order N Tab order (auto-incremented if omitted)',
|
|
433
|
+
' -z, --z N z-index (auto-incremented if omitted)',
|
|
434
|
+
'',
|
|
435
|
+
'Data bindings (repeatable):',
|
|
436
|
+
' --bind ROLE=FIELD e.g. --bind category="Cuentas[Banco]"',
|
|
437
|
+
' --bind y="_Measures[Monto]"',
|
|
438
|
+
' Role names are case-insensitive.',
|
|
439
|
+
'',
|
|
440
|
+
'Container:',
|
|
441
|
+
' --title TEXT Container title text',
|
|
442
|
+
' --no-title Hide container title',
|
|
443
|
+
'',
|
|
444
|
+
'Textbox shortcuts (--type textbox):',
|
|
445
|
+
' --paragraph TEXT Repeatable paragraph',
|
|
446
|
+
' --title TEXT Title paragraph (28pt bold by default)',
|
|
447
|
+
' --description TEXT Body paragraph (14pt by default)',
|
|
448
|
+
'',
|
|
449
|
+
'Slicer shortcut (--type slicer):',
|
|
450
|
+
' --slicer-mode MODE basic | horizontal | dropdown | between | relative',
|
|
451
|
+
'',
|
|
452
|
+
'Other:',
|
|
453
|
+
' --list-types Show all supported visualTypes with roles',
|
|
454
|
+
' -h, --help Show this help',
|
|
455
|
+
'',
|
|
456
|
+
].join('\n')
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function printTypes() {
|
|
461
|
+
const names = Object.keys(NATIVE_VISUAL_TYPES).sort();
|
|
462
|
+
process.stdout.write(`Native PBIR visualTypes (${names.length}):\n\n`);
|
|
463
|
+
for (const name of names) {
|
|
464
|
+
const def = NATIVE_VISUAL_TYPES[name];
|
|
465
|
+
process.stdout.write(` ${name}\n ${def.description}\n`);
|
|
466
|
+
const roleLines = Object.entries(def.roles).map(([r, spec]) => {
|
|
467
|
+
const flags = [];
|
|
468
|
+
if (spec.required) flags.push('required');
|
|
469
|
+
if (spec.multi) flags.push('multi');
|
|
470
|
+
flags.push(spec.kind);
|
|
471
|
+
return ` ${r} (${flags.join(', ')})`;
|
|
472
|
+
});
|
|
473
|
+
if (roleLines.length) process.stdout.write(` roles:\n${roleLines.join('\n')}\n`);
|
|
474
|
+
if (def.notes) process.stdout.write(` note: ${def.notes}\n`);
|
|
475
|
+
process.stdout.write('\n');
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// ---------------------------------------------------------------------------
|
|
480
|
+
// Field ref parsing
|
|
481
|
+
// ---------------------------------------------------------------------------
|
|
482
|
+
|
|
483
|
+
// Parses "Table[Field]" or "'Table With Spaces'[Field]" into { table, field }.
|
|
484
|
+
function parseFieldRef(ref) {
|
|
485
|
+
// Quoted: 'Some Table'[Field]
|
|
486
|
+
let m = ref.match(/^'([^']+)'\[([^\]]+)\]$/);
|
|
487
|
+
if (m) return { table: m[1], field: m[2] };
|
|
488
|
+
// Unquoted: Table[Field] (table can have spaces too in some models)
|
|
489
|
+
m = ref.match(/^([^[]+)\[([^\]]+)\]$/);
|
|
490
|
+
if (m) return { table: m[1].trim(), field: m[2] };
|
|
491
|
+
fail(`invalid field ref: ${ref} — use Table[Field] or 'Table Name'[Field]`);
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function buildProjection(fieldRef, kind) {
|
|
496
|
+
const { table, field } = parseFieldRef(fieldRef);
|
|
497
|
+
const wrapper = kind === 'measure' ? 'Measure' : 'Column';
|
|
498
|
+
return {
|
|
499
|
+
field: {
|
|
500
|
+
[wrapper]: {
|
|
501
|
+
Expression: { SourceRef: { Entity: table } },
|
|
502
|
+
Property: field,
|
|
503
|
+
},
|
|
504
|
+
},
|
|
505
|
+
queryRef: `${table}.${field}`,
|
|
506
|
+
nativeQueryRef: field,
|
|
507
|
+
// Only columns get "active: true" in the CLI-generated shapes; measures don't.
|
|
508
|
+
...(kind === 'column' ? { active: true } : {}),
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Role name canonicalization: users can type 'category' and we match it to 'Category'.
|
|
513
|
+
function canonicalRoleName(roles, input) {
|
|
514
|
+
const lower = input.toLowerCase();
|
|
515
|
+
for (const key of Object.keys(roles)) {
|
|
516
|
+
if (key.toLowerCase() === lower) return key;
|
|
517
|
+
}
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ---------------------------------------------------------------------------
|
|
522
|
+
// Shape builder per visualType category
|
|
523
|
+
// ---------------------------------------------------------------------------
|
|
524
|
+
|
|
525
|
+
function buildQueryState(def, bindingsCanonical) {
|
|
526
|
+
const queryState = {};
|
|
527
|
+
for (const role of Object.keys(def.roles)) {
|
|
528
|
+
const spec = def.roles[role];
|
|
529
|
+
const refs = bindingsCanonical[role] || [];
|
|
530
|
+
const projections = refs.map((ref) => {
|
|
531
|
+
// 'any' kind → decide by table prefix convention: _Measures → measure.
|
|
532
|
+
let kind = spec.kind;
|
|
533
|
+
if (kind === 'any') {
|
|
534
|
+
const { table } = parseFieldRef(ref);
|
|
535
|
+
kind = table === '_Measures' || table.endsWith('_Measures') ? 'measure' : 'column';
|
|
536
|
+
}
|
|
537
|
+
return buildProjection(ref, kind);
|
|
538
|
+
});
|
|
539
|
+
queryState[role] = { projections };
|
|
540
|
+
}
|
|
541
|
+
return queryState;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function buildTextbox(args) {
|
|
545
|
+
const paragraphs = [];
|
|
546
|
+
// --title + --description combo shortcut
|
|
547
|
+
if (args.title) {
|
|
548
|
+
paragraphs.push({
|
|
549
|
+
textRuns: [
|
|
550
|
+
{
|
|
551
|
+
value: args.title,
|
|
552
|
+
textStyle: {
|
|
553
|
+
fontFamily: "'Segoe UI Semibold', wf_segoe-ui_semibold, helvetica, arial, sans-serif",
|
|
554
|
+
fontSize: '19pt',
|
|
555
|
+
color: '#111827',
|
|
556
|
+
},
|
|
557
|
+
},
|
|
558
|
+
],
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
if (args.description) {
|
|
562
|
+
paragraphs.push({
|
|
563
|
+
textRuns: [
|
|
564
|
+
{
|
|
565
|
+
value: args.description,
|
|
566
|
+
textStyle: {
|
|
567
|
+
fontFamily: "'Segoe UI', wf_segoe-ui_normal, helvetica, arial, sans-serif",
|
|
568
|
+
fontSize: '10.5pt',
|
|
569
|
+
color: '#6B7280',
|
|
570
|
+
},
|
|
571
|
+
},
|
|
572
|
+
],
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
// --paragraph is raw (no styling).
|
|
576
|
+
for (const p of args.paragraphs) {
|
|
577
|
+
paragraphs.push({ textRuns: [{ value: p }] });
|
|
578
|
+
}
|
|
579
|
+
if (paragraphs.length === 0) {
|
|
580
|
+
fail('textbox needs at least one of --title, --description, or --paragraph');
|
|
581
|
+
}
|
|
582
|
+
return {
|
|
583
|
+
objects: {
|
|
584
|
+
general: [{ properties: { paragraphs } }],
|
|
585
|
+
},
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function buildSlicerObjects(args) {
|
|
590
|
+
const mode = args.slicerMode || 'basic';
|
|
591
|
+
const spec = SLICER_MODES[mode];
|
|
592
|
+
if (!spec) fail(`unknown --slicer-mode: ${mode}. Valid: ${Object.keys(SLICER_MODES).join(', ')}`);
|
|
593
|
+
const objects = {
|
|
594
|
+
data: [{ properties: { mode: { expr: { Literal: { Value: `'${spec.mode}'` } } } } }],
|
|
595
|
+
};
|
|
596
|
+
if (spec.orientation != null) {
|
|
597
|
+
objects.general = [
|
|
598
|
+
{ properties: { orientation: { expr: { Literal: { Value: String(spec.orientation) } } } } },
|
|
599
|
+
];
|
|
600
|
+
}
|
|
601
|
+
return { objects };
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function buildContainerTitle(args) {
|
|
605
|
+
if (args.noTitle) {
|
|
606
|
+
return {
|
|
607
|
+
title: [{ properties: { show: { expr: { Literal: { Value: 'false' } } } } }],
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
if (args.title) {
|
|
611
|
+
return {
|
|
612
|
+
title: [
|
|
613
|
+
{
|
|
614
|
+
properties: {
|
|
615
|
+
show: { expr: { Literal: { Value: 'true' } } },
|
|
616
|
+
text: { expr: { Literal: { Value: `'${escapePbiLiteral(args.title)}'` } } },
|
|
617
|
+
},
|
|
618
|
+
},
|
|
619
|
+
],
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
return null; // default: don't emit visualContainerObjects.title
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function escapePbiLiteral(s) {
|
|
626
|
+
// PBI literal strings use single-quote delimiters — escape internal quotes,
|
|
627
|
+
// and collapse newlines/tabs to single spaces so the resulting literal
|
|
628
|
+
// stays on one line (PBI doesn't parse multi-line literals for these fields).
|
|
629
|
+
return String(s)
|
|
630
|
+
.replace(/[\r\n\t]+/g, ' ')
|
|
631
|
+
.replace(/'/g, "''");
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// ---------------------------------------------------------------------------
|
|
635
|
+
// File I/O — atomic write (same pattern as apply-theme.js)
|
|
636
|
+
// ---------------------------------------------------------------------------
|
|
637
|
+
|
|
638
|
+
function writeJsonAtomic(filePath, data) {
|
|
639
|
+
const dir = path.dirname(filePath);
|
|
640
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
641
|
+
if (fs.existsSync(filePath)) fs.copyFileSync(filePath, `${filePath}.bak`);
|
|
642
|
+
const tmp = `${filePath}.tmp`;
|
|
643
|
+
try {
|
|
644
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
|
|
645
|
+
fs.renameSync(tmp, filePath);
|
|
646
|
+
} catch (err) {
|
|
647
|
+
try {
|
|
648
|
+
fs.unlinkSync(tmp);
|
|
649
|
+
} catch (_) {
|
|
650
|
+
/* best-effort */
|
|
651
|
+
}
|
|
652
|
+
throw err;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function readExistingVisuals(pageDir) {
|
|
657
|
+
const visualsDir = path.join(pageDir, 'visuals');
|
|
658
|
+
if (!fs.existsSync(visualsDir)) return [];
|
|
659
|
+
const out = [];
|
|
660
|
+
for (const name of fs.readdirSync(visualsDir)) {
|
|
661
|
+
const vp = path.join(visualsDir, name, 'visual.json');
|
|
662
|
+
if (!fs.existsSync(vp)) continue;
|
|
663
|
+
try {
|
|
664
|
+
const data = JSON.parse(fs.readFileSync(vp, 'utf8'));
|
|
665
|
+
out.push({ name, data, path: vp });
|
|
666
|
+
} catch (_) {
|
|
667
|
+
// Skip malformed visuals; don't block creation.
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
return out;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function nextInt(existing, key) {
|
|
674
|
+
let max = -1;
|
|
675
|
+
for (const v of existing) {
|
|
676
|
+
const pos = v.data && v.data.position;
|
|
677
|
+
if (pos && typeof pos[key] === 'number' && pos[key] > max) max = pos[key];
|
|
678
|
+
}
|
|
679
|
+
return max + 1;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// ---------------------------------------------------------------------------
|
|
683
|
+
// main()
|
|
684
|
+
// ---------------------------------------------------------------------------
|
|
685
|
+
|
|
686
|
+
function main() {
|
|
687
|
+
const args = parseArgs(process.argv.slice(2));
|
|
688
|
+
if (args.help || process.argv.length === 2) {
|
|
689
|
+
printHelp();
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
if (args.listTypes) {
|
|
693
|
+
printTypes();
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// ---- required args
|
|
698
|
+
if (!args.reportPath) fail('--report-path is required');
|
|
699
|
+
if (!args.page) fail('--page is required');
|
|
700
|
+
if (!args.type) fail('--type is required (use --list-types to see options)');
|
|
701
|
+
|
|
702
|
+
// ---- type validation with friendly errors for known non-native aliases
|
|
703
|
+
if (KNOWN_NON_NATIVE_TYPES[args.type]) {
|
|
704
|
+
fail(KNOWN_NON_NATIVE_TYPES[args.type]);
|
|
705
|
+
}
|
|
706
|
+
const def = NATIVE_VISUAL_TYPES[args.type];
|
|
707
|
+
if (!def) {
|
|
708
|
+
fail(`unknown visualType: ${args.type}. Run --list-types for the allowlist.`);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// ---- resolve paths
|
|
712
|
+
const reportPath = path.resolve(args.reportPath);
|
|
713
|
+
const pageDir = path.join(reportPath, 'definition', 'pages', args.page);
|
|
714
|
+
if (!fs.existsSync(pageDir)) {
|
|
715
|
+
fail(`page not found: ${pageDir}. Create the page first (e.g. via pbi report add-page or create-page.js).`);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// ---- auto-generate name if omitted
|
|
719
|
+
let name = args.name;
|
|
720
|
+
if (!name) {
|
|
721
|
+
const existing = readExistingVisuals(pageDir);
|
|
722
|
+
let n = existing.length;
|
|
723
|
+
let candidate;
|
|
724
|
+
do {
|
|
725
|
+
candidate = `${args.type}_${n}`;
|
|
726
|
+
n += 1;
|
|
727
|
+
} while (existing.some((v) => v.name === candidate));
|
|
728
|
+
name = candidate;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const visualDir = path.join(pageDir, 'visuals', name);
|
|
732
|
+
const visualJsonPath = path.join(visualDir, 'visual.json');
|
|
733
|
+
if (fs.existsSync(visualJsonPath)) {
|
|
734
|
+
fail(
|
|
735
|
+
`visual already exists: ${visualJsonPath}. Delete the folder or use a different --name (got: ${name}).`
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// ---- canonicalize bindings against role names
|
|
740
|
+
const bindingsCanonical = {};
|
|
741
|
+
for (const [inputRole, refs] of Object.entries(args.bindings)) {
|
|
742
|
+
// Textbox has no roles — reject bindings early.
|
|
743
|
+
if (args.type === 'textbox') {
|
|
744
|
+
fail('textbox does not accept --bind. Use --title, --description, or --paragraph.');
|
|
745
|
+
}
|
|
746
|
+
const canonical = canonicalRoleName(def.roles, inputRole);
|
|
747
|
+
if (!canonical) {
|
|
748
|
+
const valid = Object.keys(def.roles).join(', ') || '(none)';
|
|
749
|
+
fail(`--bind role "${inputRole}" is not valid for --type ${args.type}. Valid roles: ${valid}.`);
|
|
750
|
+
}
|
|
751
|
+
const spec = def.roles[canonical];
|
|
752
|
+
if (refs.length > 1 && !spec.multi) {
|
|
753
|
+
fail(`role ${canonical} does not accept multiple bindings (got ${refs.length}).`);
|
|
754
|
+
}
|
|
755
|
+
bindingsCanonical[canonical] = refs;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// ---- check required roles (textbox/cardVisual exceptions)
|
|
759
|
+
if (args.type !== 'textbox') {
|
|
760
|
+
for (const [role, spec] of Object.entries(def.roles)) {
|
|
761
|
+
if (spec.required && !(role in bindingsCanonical)) {
|
|
762
|
+
fail(`required role missing: --bind ${role.toLowerCase()}=Table[Field]`);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// ---- build position
|
|
768
|
+
const existing = readExistingVisuals(pageDir);
|
|
769
|
+
const autoZ = nextInt(existing, 'z');
|
|
770
|
+
const autoTabOrder = nextInt(existing, 'tabOrder');
|
|
771
|
+
|
|
772
|
+
// Size defaults: cards are smaller, charts bigger.
|
|
773
|
+
const defaultSize = ['card', 'cardVisual', 'kpi', 'gauge', 'multiRowCard'].includes(args.type)
|
|
774
|
+
? { w: 280, h: 120 }
|
|
775
|
+
: { w: 400, h: 300 };
|
|
776
|
+
|
|
777
|
+
const position = {
|
|
778
|
+
x: args.x != null ? args.x : 0,
|
|
779
|
+
y: args.y != null ? args.y : 0,
|
|
780
|
+
z: args.z != null ? args.z : autoZ,
|
|
781
|
+
height: args.height != null ? args.height : defaultSize.h,
|
|
782
|
+
width: args.width != null ? args.width : defaultSize.w,
|
|
783
|
+
tabOrder: args.tabOrder != null ? args.tabOrder : autoTabOrder,
|
|
784
|
+
};
|
|
785
|
+
|
|
786
|
+
// ---- assemble visual.json
|
|
787
|
+
const visual = { visualType: args.type };
|
|
788
|
+
|
|
789
|
+
if (args.type === 'textbox') {
|
|
790
|
+
Object.assign(visual, buildTextbox(args));
|
|
791
|
+
} else {
|
|
792
|
+
visual.query = { queryState: buildQueryState(def, bindingsCanonical) };
|
|
793
|
+
if (def.defaults && def.defaults.query) {
|
|
794
|
+
Object.assign(visual.query, def.defaults.query);
|
|
795
|
+
}
|
|
796
|
+
if (args.type === 'slicer') {
|
|
797
|
+
Object.assign(visual, buildSlicerObjects(args));
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// drillFilterOtherVisuals only makes sense for data visuals; textboxes
|
|
802
|
+
// have no query so there's nothing to filter — omit the flag entirely
|
|
803
|
+
// to keep the written shape minimal.
|
|
804
|
+
if (args.type !== 'textbox') {
|
|
805
|
+
visual.drillFilterOtherVisuals = true;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// Container defaults (cardVisual spawns an empty visualContainerObjects).
|
|
809
|
+
if (def.defaults && def.defaults.visualContainerObjects !== undefined) {
|
|
810
|
+
visual.visualContainerObjects = def.defaults.visualContainerObjects;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
if (args.type === 'textbox') {
|
|
814
|
+
// Textbox chrome is always hidden — the paragraphs ARE the content,
|
|
815
|
+
// and --title/--description are paragraph shortcuts (see buildTextbox),
|
|
816
|
+
// not container title overrides. Emitting container title here on top
|
|
817
|
+
// of the inner title paragraph causes a double-render in PBI Desktop.
|
|
818
|
+
visual.visualContainerObjects = {
|
|
819
|
+
title: [{ properties: { show: { expr: { Literal: { Value: 'false' } } } } }],
|
|
820
|
+
background: [{ properties: { show: { expr: { Literal: { Value: 'false' } } } } }],
|
|
821
|
+
border: [{ properties: { show: { expr: { Literal: { Value: 'false' } } } } }],
|
|
822
|
+
dropShadow: [{ properties: { show: { expr: { Literal: { Value: 'false' } } } } }],
|
|
823
|
+
};
|
|
824
|
+
} else {
|
|
825
|
+
// For data visuals, --title / --no-title control the container title.
|
|
826
|
+
const titleBlock = buildContainerTitle(args);
|
|
827
|
+
if (titleBlock) {
|
|
828
|
+
visual.visualContainerObjects = visual.visualContainerObjects || {};
|
|
829
|
+
Object.assign(visual.visualContainerObjects, titleBlock);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const visualJson = {
|
|
834
|
+
$schema: VISUAL_SCHEMA,
|
|
835
|
+
name,
|
|
836
|
+
position,
|
|
837
|
+
visual,
|
|
838
|
+
};
|
|
839
|
+
|
|
840
|
+
// ---- write
|
|
841
|
+
try {
|
|
842
|
+
writeJsonAtomic(visualJsonPath, visualJson);
|
|
843
|
+
} catch (err) {
|
|
844
|
+
fail(`failed to write ${visualJsonPath}: ${err.message}`, 2);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
process.stdout.write(
|
|
848
|
+
[
|
|
849
|
+
`create-visual: ✓ created ${args.type}`,
|
|
850
|
+
` name: ${name}`,
|
|
851
|
+
` page: ${args.page}`,
|
|
852
|
+
` file: ${visualJsonPath}`,
|
|
853
|
+
` pos: x=${position.x} y=${position.y} w=${position.width} h=${position.height}`,
|
|
854
|
+
'',
|
|
855
|
+
].join('\n')
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// Export internals for unit tests.
|
|
860
|
+
module.exports = {
|
|
861
|
+
NATIVE_VISUAL_TYPES,
|
|
862
|
+
KNOWN_NON_NATIVE_TYPES,
|
|
863
|
+
SLICER_MODES,
|
|
864
|
+
parseArgs,
|
|
865
|
+
parseFieldRef,
|
|
866
|
+
buildProjection,
|
|
867
|
+
canonicalRoleName,
|
|
868
|
+
buildQueryState,
|
|
869
|
+
buildTextbox,
|
|
870
|
+
buildSlicerObjects,
|
|
871
|
+
buildContainerTitle,
|
|
872
|
+
escapePbiLiteral,
|
|
873
|
+
};
|
|
874
|
+
|
|
875
|
+
// Only run main() when invoked as script (not when required from tests).
|
|
876
|
+
if (require.main === module) {
|
|
877
|
+
main();
|
|
878
|
+
}
|