@lightdash/cli 3.163.1 → 3.165.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/dist/.tsbuildinfo.build +1 -1
- package/dist/handlers/slugUpdate.d.ts +8 -3
- package/dist/handlers/slugUpdate.d.ts.map +1 -1
- package/dist/handlers/slugUpdate.js +100 -21
- package/dist/index.js +2 -2
- package/dist/vendor/template/skill.md +7 -6
- package/dist/vendor/template/src/lib/format.ts +106 -5
- package/package.json +4 -3
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { ContentType, type ContentSlugRenameRequest } from '@lightdash/common';
|
|
2
|
+
type SlugUpdateResourceType = ContentType.CHART | ContentType.DASHBOARD | ContentType.DATA_APP;
|
|
2
3
|
type SlugUpdateOptions = {
|
|
3
4
|
verbose: boolean;
|
|
4
5
|
project?: string;
|
|
5
6
|
path?: string;
|
|
6
7
|
dryRun: boolean;
|
|
7
|
-
type: ContentType.CHART | ContentType.DASHBOARD;
|
|
8
|
+
type: ContentType.CHART | ContentType.DASHBOARD | 'app';
|
|
8
9
|
from: string;
|
|
9
10
|
to: string;
|
|
10
11
|
};
|
|
@@ -17,6 +18,10 @@ type LocalSlugUpdatePlan = {
|
|
|
17
18
|
source: string;
|
|
18
19
|
target: string;
|
|
19
20
|
}>;
|
|
21
|
+
directoryMoves: Array<{
|
|
22
|
+
source: string;
|
|
23
|
+
target: string;
|
|
24
|
+
}>;
|
|
20
25
|
metadata: {
|
|
21
26
|
root: string;
|
|
22
27
|
resourceType: ContentType.CHART | ContentType.DASHBOARD;
|
|
@@ -28,11 +33,11 @@ export type LocalSlugUpdateFileChange = {
|
|
|
28
33
|
source: string;
|
|
29
34
|
target?: string;
|
|
30
35
|
};
|
|
31
|
-
export declare const planLocalSlugUpdate: (customPath: string | undefined, from: string, to: string, resourceType?:
|
|
36
|
+
export declare const planLocalSlugUpdate: (customPath: string | undefined, from: string, to: string, resourceType?: SlugUpdateResourceType) => Promise<LocalSlugUpdatePlan>;
|
|
32
37
|
export declare const getLocalSlugUpdateFileChanges: (plan: LocalSlugUpdatePlan, customPath: string | undefined) => LocalSlugUpdateFileChange[];
|
|
33
38
|
export declare const applyLocalSlugUpdate: (plan: LocalSlugUpdatePlan) => Promise<() => Promise<void>>;
|
|
34
39
|
export declare const requestSlugUpdate: (projectUuid: string, request: ContentSlugRenameRequest) => Promise<void>;
|
|
35
|
-
export declare const executeSlugUpdate: (projectUuid: string, customPath: string | undefined, from: string, to: string, resourceType?:
|
|
40
|
+
export declare const executeSlugUpdate: (projectUuid: string, customPath: string | undefined, from: string, to: string, resourceType?: SlugUpdateResourceType) => Promise<LocalSlugUpdatePlan>;
|
|
36
41
|
export declare const slugUpdateHandler: (options: SlugUpdateOptions) => Promise<void>;
|
|
37
42
|
export {};
|
|
38
43
|
//# sourceMappingURL=slugUpdate.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"slugUpdate.d.ts","sourceRoot":"","sources":["../../src/handlers/slugUpdate.ts"],"names":[],"mappings":"AAAA,OAAO,EACH,WAAW,
|
|
1
|
+
{"version":3,"file":"slugUpdate.d.ts","sourceRoot":"","sources":["../../src/handlers/slugUpdate.ts"],"names":[],"mappings":"AAAA,OAAO,EACH,WAAW,EAMX,KAAK,wBAAwB,EAChC,MAAM,mBAAmB,CAAC;AAa3B,KAAK,sBAAsB,GACrB,WAAW,CAAC,KAAK,GACjB,WAAW,CAAC,SAAS,GACrB,WAAW,CAAC,QAAQ,CAAC;AAE3B,KAAK,iBAAiB,GAAG;IACrB,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,OAAO,CAAC;IAEhB,IAAI,EAAE,WAAW,CAAC,KAAK,GAAG,WAAW,CAAC,SAAS,GAAG,KAAK,CAAC;IACxD,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,KAAK,mBAAmB,GAAG;IACvB,WAAW,EAAE,KAAK,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC1D,SAAS,EAAE,KAAK,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAGrD,cAAc,EAAE,KAAK,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC1D,QAAQ,EACF;QACI,IAAI,EAAE,MAAM,CAAC;QACb,YAAY,EAAE,WAAW,CAAC,KAAK,GAAG,WAAW,CAAC,SAAS,CAAC;QACxD,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;KACjC,GACD,SAAS,CAAC;IAChB,iBAAiB,EAAE,MAAM,CAAC;CAC7B,CAAC;AAIF,MAAM,MAAM,yBAAyB,GAAG;IACpC,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC;AA+FF,eAAO,MAAM,mBAAmB,eAChB,MAAM,GAAG,SAAS,QACxB,MAAM,MACR,MAAM,iBACI,sBAAsB,KACrC,OAAO,CAAC,mBAAmB,CAwP7B,CAAC;AAEF,eAAO,MAAM,6BAA6B,SAChC,mBAAmB,cACb,MAAM,GAAG,SAAS,KAC/B,yBAAyB,EA2B3B,CAAC;AAuDF,eAAO,MAAM,oBAAoB,SACvB,mBAAmB,KAC1B,OAAO,CAAC,MAAM,OAAO,CAAC,IAAI,CAAC,CA8E7B,CAAC;AAEF,eAAO,MAAM,iBAAiB,gBACb,MAAM,WACV,wBAAwB,KAClC,OAAO,CAAC,IAAI,CAmBd,CAAC;AAEF,eAAO,MAAM,iBAAiB,gBACb,MAAM,cACP,MAAM,GAAG,SAAS,QACxB,MAAM,MACR,MAAM,iBACI,sBAAsB,KACrC,OAAO,CAAC,mBAAmB,CAuB7B,CAAC;AAEF,eAAO,MAAM,iBAAiB,YACjB,iBAAiB,KAC3B,OAAO,CAAC,IAAI,CA2Dd,CAAC"}
|
|
@@ -25,7 +25,11 @@ const getYamlFiles = async (root) => {
|
|
|
25
25
|
(entry.name.endsWith('.yml') ||
|
|
26
26
|
entry.name.endsWith('.yaml')) &&
|
|
27
27
|
!entry.name.endsWith('.language.map.yml') &&
|
|
28
|
-
!entry.name.endsWith('.language.map.yaml')
|
|
28
|
+
!entry.name.endsWith('.language.map.yaml') &&
|
|
29
|
+
// App bundles can hold installed deps and lockfiles;
|
|
30
|
+
// neither carries renameable content references.
|
|
31
|
+
entry.name !== 'pnpm-lock.yaml' &&
|
|
32
|
+
!entry.parentPath.split(path.sep).includes('node_modules'))
|
|
29
33
|
.map((entry) => path.join(entry.parentPath, entry.name));
|
|
30
34
|
}
|
|
31
35
|
catch (error) {
|
|
@@ -57,6 +61,7 @@ const assertMovesAreSafe = async (moves) => {
|
|
|
57
61
|
}
|
|
58
62
|
}
|
|
59
63
|
};
|
|
64
|
+
const APP_MANIFEST_FILENAME = 'lightdash-app.yml';
|
|
60
65
|
// Comment-only or scalar YAML (e.g. chart-type context files) parses to null or a primitive
|
|
61
66
|
const isYamlMapping = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
62
67
|
const readYamlDocuments = async (root) => {
|
|
@@ -78,18 +83,29 @@ const planLocalSlugUpdate = async (customPath, from, to, resourceType = common_1
|
|
|
78
83
|
const root = (0, contentAsCodePaths_1.getDownloadFolder)(customPath);
|
|
79
84
|
const fileUpdates = [];
|
|
80
85
|
const fileMoves = [];
|
|
86
|
+
const directoryMoves = [];
|
|
81
87
|
let referencesUpdated = 0;
|
|
82
88
|
const languageMapKey = resourceType === common_1.ContentType.CHART ? 'chart' : 'dashboard';
|
|
83
89
|
if (!from) {
|
|
84
90
|
throw new common_1.ParameterError('The source slug must not be empty');
|
|
85
91
|
}
|
|
86
|
-
if (
|
|
92
|
+
if (resourceType === common_1.ContentType.DATA_APP) {
|
|
93
|
+
// App slugs follow the server's own shape check, which allows more
|
|
94
|
+
// than generateSlug emits (e.g. consecutive hyphens).
|
|
95
|
+
if (!(0, common_1.isValidDataAppSlug)(to)) {
|
|
96
|
+
throw new common_1.ParameterError(`Invalid data app slug "${to}". Slugs must start with a lowercase letter or digit and contain only lowercase letters, digits, and hyphens, up to 255 characters.`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
else if (!to ||
|
|
100
|
+
to.length > common_1.MAX_CONTENT_SLUG_LENGTH ||
|
|
101
|
+
(0, common_1.generateSlug)(to) !== to) {
|
|
87
102
|
throw new common_1.ParameterError(`The target slug must contain between 1 and ${common_1.MAX_CONTENT_SLUG_LENGTH} lowercase letters, numbers, or hyphen-separated words`);
|
|
88
103
|
}
|
|
89
104
|
if (from === to) {
|
|
90
105
|
return {
|
|
91
106
|
fileUpdates,
|
|
92
107
|
fileMoves,
|
|
108
|
+
directoryMoves,
|
|
93
109
|
metadata: undefined,
|
|
94
110
|
referencesUpdated,
|
|
95
111
|
};
|
|
@@ -102,6 +118,38 @@ const planLocalSlugUpdate = async (customPath, from, to, resourceType = common_1
|
|
|
102
118
|
});
|
|
103
119
|
for (const { filePath, document, parsed } of contentDocuments) {
|
|
104
120
|
let changed = false;
|
|
121
|
+
if (resourceType === common_1.ContentType.DATA_APP) {
|
|
122
|
+
if (path.basename(filePath) === APP_MANIFEST_FILENAME &&
|
|
123
|
+
parsed.slug === from) {
|
|
124
|
+
document.set('slug', to);
|
|
125
|
+
referencesUpdated += 1;
|
|
126
|
+
changed = true;
|
|
127
|
+
// Bundles download to a folder named after the slug; keep the
|
|
128
|
+
// folder in step so the next download matches it.
|
|
129
|
+
const bundleDir = path.dirname(filePath);
|
|
130
|
+
if (path.basename(bundleDir) === from) {
|
|
131
|
+
directoryMoves.push({
|
|
132
|
+
source: bundleDir,
|
|
133
|
+
target: path.join(path.dirname(bundleDir), to),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (Array.isArray(parsed.tiles)) {
|
|
138
|
+
for (const [index, tile] of parsed.tiles.entries()) {
|
|
139
|
+
if (tile.type === 'data_app' &&
|
|
140
|
+
tile.properties?.appSlug === from) {
|
|
141
|
+
document.setIn(['tiles', index, 'properties', 'appSlug'], to);
|
|
142
|
+
referencesUpdated += 1;
|
|
143
|
+
changed = true;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (changed) {
|
|
148
|
+
fileUpdates.push({ filePath, content: document.toString() });
|
|
149
|
+
}
|
|
150
|
+
// eslint-disable-next-line no-continue
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
105
153
|
const isChart = parsed.contentType === common_1.ContentType.CHART ||
|
|
106
154
|
parsed.metricQuery !== undefined;
|
|
107
155
|
const isDashboard = parsed.contentType === common_1.ContentType.DASHBOARD ||
|
|
@@ -185,24 +233,28 @@ const planLocalSlugUpdate = async (customPath, from, to, resourceType = common_1
|
|
|
185
233
|
fileUpdates.push({ filePath, content: document.toString() });
|
|
186
234
|
}
|
|
187
235
|
}
|
|
188
|
-
const metadata = await (0, metadataFile_1.readMetadataFile)(root);
|
|
189
236
|
let updatedMetadata;
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
237
|
+
// The local metadata file only tracks charts and dashboards.
|
|
238
|
+
if (resourceType !== common_1.ContentType.DATA_APP) {
|
|
239
|
+
const metadata = await (0, metadataFile_1.readMetadataFile)(root);
|
|
240
|
+
const metadataSlugs = resourceType === common_1.ContentType.CHART
|
|
241
|
+
? metadata.charts
|
|
242
|
+
: metadata.dashboards;
|
|
243
|
+
if (from in metadataSlugs) {
|
|
244
|
+
if (to !== from && to in metadataSlugs) {
|
|
245
|
+
throw new common_1.ParameterError(`Cannot update local metadata because ${resourceType} slug "${to}" already exists`);
|
|
246
|
+
}
|
|
247
|
+
const slugs = { ...metadataSlugs };
|
|
248
|
+
slugs[to] = slugs[from];
|
|
249
|
+
delete slugs[from];
|
|
250
|
+
updatedMetadata = { root, resourceType, slugs };
|
|
196
251
|
}
|
|
197
|
-
const slugs = { ...metadataSlugs };
|
|
198
|
-
slugs[to] = slugs[from];
|
|
199
|
-
delete slugs[from];
|
|
200
|
-
updatedMetadata = { root, resourceType, slugs };
|
|
201
252
|
}
|
|
202
|
-
await assertMovesAreSafe(fileMoves);
|
|
253
|
+
await assertMovesAreSafe([...fileMoves, ...directoryMoves]);
|
|
203
254
|
return {
|
|
204
255
|
fileUpdates,
|
|
205
256
|
fileMoves,
|
|
257
|
+
directoryMoves,
|
|
206
258
|
metadata: updatedMetadata,
|
|
207
259
|
referencesUpdated,
|
|
208
260
|
};
|
|
@@ -217,6 +269,10 @@ const getLocalSlugUpdateFileChanges = (plan, customPath) => {
|
|
|
217
269
|
source: relative(source),
|
|
218
270
|
target: relative(target),
|
|
219
271
|
})),
|
|
272
|
+
...plan.directoryMoves.map(({ source, target }) => ({
|
|
273
|
+
source: `${relative(source)}${path.sep}`,
|
|
274
|
+
target: `${relative(target)}${path.sep}`,
|
|
275
|
+
})),
|
|
220
276
|
...plan.fileUpdates
|
|
221
277
|
.filter(({ filePath }) => !movedSources.has(filePath))
|
|
222
278
|
.map(({ filePath }) => ({ source: relative(filePath) })),
|
|
@@ -273,6 +329,14 @@ const applyLocalSlugUpdate = async (plan) => {
|
|
|
273
329
|
]);
|
|
274
330
|
const snapshots = await captureFiles(affectedPaths);
|
|
275
331
|
const stagedMoves = [];
|
|
332
|
+
const appliedDirectoryMoves = [];
|
|
333
|
+
const undoDirectoryMoves = async () => {
|
|
334
|
+
for (const move of [...appliedDirectoryMoves].reverse()) {
|
|
335
|
+
// eslint-disable-next-line no-await-in-loop
|
|
336
|
+
await fs_1.promises.rename(move.target, move.source);
|
|
337
|
+
}
|
|
338
|
+
appliedDirectoryMoves.length = 0;
|
|
339
|
+
};
|
|
276
340
|
try {
|
|
277
341
|
await Promise.all(plan.fileUpdates.map(({ filePath, content }) => fs_1.promises.writeFile(filePath, content)));
|
|
278
342
|
for (const [index, move] of plan.fileMoves.entries()) {
|
|
@@ -285,6 +349,13 @@ const applyLocalSlugUpdate = async (plan) => {
|
|
|
285
349
|
// eslint-disable-next-line no-await-in-loop
|
|
286
350
|
await fs_1.promises.rename(stagedMoves[index], move.target);
|
|
287
351
|
}
|
|
352
|
+
// Directory renames (data-app bundle folders) go last so every file
|
|
353
|
+
// write above targets the original paths.
|
|
354
|
+
for (const move of plan.directoryMoves) {
|
|
355
|
+
// eslint-disable-next-line no-await-in-loop
|
|
356
|
+
await fs_1.promises.rename(move.source, move.target);
|
|
357
|
+
appliedDirectoryMoves.push(move);
|
|
358
|
+
}
|
|
288
359
|
if (plan.metadata && metadataPath) {
|
|
289
360
|
const existing = await (0, metadataFile_1.readMetadataFile)(plan.metadata.root);
|
|
290
361
|
await fs_1.promises.writeFile(metadataPath, JSON.stringify({
|
|
@@ -296,11 +367,17 @@ const applyLocalSlugUpdate = async (plan) => {
|
|
|
296
367
|
}
|
|
297
368
|
}
|
|
298
369
|
catch (error) {
|
|
370
|
+
// Directory moves roll back first so restored files land inside
|
|
371
|
+
// folders at their original paths.
|
|
372
|
+
await undoDirectoryMoves();
|
|
299
373
|
await Promise.all(stagedMoves.map((temporary) => fs_1.promises.rm(temporary, { force: true })));
|
|
300
374
|
await restoreFiles(snapshots);
|
|
301
375
|
throw error;
|
|
302
376
|
}
|
|
303
|
-
return () =>
|
|
377
|
+
return async () => {
|
|
378
|
+
await undoDirectoryMoves();
|
|
379
|
+
await restoreFiles(snapshots);
|
|
380
|
+
};
|
|
304
381
|
};
|
|
305
382
|
exports.applyLocalSlugUpdate = applyLocalSlugUpdate;
|
|
306
383
|
const requestSlugUpdate = async (projectUuid, request) => {
|
|
@@ -341,6 +418,8 @@ const slugUpdateHandler = async (options) => {
|
|
|
341
418
|
(0, credentials_1.refuseAgentConnection)();
|
|
342
419
|
globalState_1.default.setVerbose(options.verbose);
|
|
343
420
|
await (0, apiClient_1.checkLightdashVersion)();
|
|
421
|
+
const resourceType = options.type === 'app' ? common_1.ContentType.DATA_APP : options.type;
|
|
422
|
+
const resourceLabel = options.type === 'app' ? 'data app' : options.type;
|
|
344
423
|
const config = await (0, config_1.getConfig)();
|
|
345
424
|
await (0, credentials_1.resolveCredential)(config);
|
|
346
425
|
if (!config.context?.serverUrl)
|
|
@@ -350,17 +429,17 @@ const slugUpdateHandler = async (options) => {
|
|
|
350
429
|
throw new common_1.ParameterError('No project selected. Run lightdash config set-project');
|
|
351
430
|
}
|
|
352
431
|
(0, selectProject_1.logSelectedProject)(selection, config, options.dryRun
|
|
353
|
-
? `Previewing ${
|
|
354
|
-
: `Updating ${
|
|
432
|
+
? `Previewing ${resourceLabel} slug update in`
|
|
433
|
+
: `Updating ${resourceLabel} slug in`);
|
|
355
434
|
if (options.dryRun) {
|
|
356
|
-
const localPlan = await (0, exports.planLocalSlugUpdate)(options.path, options.from, options.to,
|
|
357
|
-
console.info(styles.success(`Would update ${
|
|
435
|
+
const localPlan = await (0, exports.planLocalSlugUpdate)(options.path, options.from, options.to, resourceType);
|
|
436
|
+
console.info(styles.success(`Would update ${resourceLabel} slug "${options.from}" -> "${options.to}".`));
|
|
358
437
|
logLocalSlugUpdateFileChanges(localPlan, options.path, true);
|
|
359
438
|
console.info(styles.secondary('No changes were made.'));
|
|
360
439
|
return;
|
|
361
440
|
}
|
|
362
|
-
const localPlan = await (0, exports.executeSlugUpdate)(selection.projectUuid, options.path, options.from, options.to,
|
|
363
|
-
console.info(styles.success(`Updated ${
|
|
441
|
+
const localPlan = await (0, exports.executeSlugUpdate)(selection.projectUuid, options.path, options.from, options.to, resourceType);
|
|
442
|
+
console.info(styles.success(`Updated ${resourceLabel} slug "${options.from}" -> "${options.to}".`));
|
|
364
443
|
logLocalSlugUpdateFileChanges(localPlan, options.path, false);
|
|
365
444
|
};
|
|
366
445
|
exports.slugUpdateHandler = slugUpdateHandler;
|
package/dist/index.js
CHANGED
|
@@ -711,9 +711,9 @@ commander_1.program
|
|
|
711
711
|
.action(renameHandler_1.renameHandler);
|
|
712
712
|
commander_1.program
|
|
713
713
|
.command('slug-update')
|
|
714
|
-
.description('Rename a chart or
|
|
714
|
+
.description('Rename a chart, dashboard, or data app slug and update its local references')
|
|
715
715
|
.addOption(new commander_1.Option('--type <resource-type>', 'content type to rename')
|
|
716
|
-
.choices(['chart', 'dashboard'])
|
|
716
|
+
.choices(['chart', 'dashboard', 'app'])
|
|
717
717
|
.default('chart'))
|
|
718
718
|
.option('--verbose', 'show verbose output', false)
|
|
719
719
|
.option('--project <project uuid or slug>', 'specify a project by UUID or slug', parseProjectArgument, undefined)
|
|
@@ -608,6 +608,8 @@ function RevenueTable() {
|
|
|
608
608
|
|
|
609
609
|
### Formatting
|
|
610
610
|
|
|
611
|
+
**Trust Lightdash's values for date/time display — never re-convert them.** Date and timestamp values arrive as the correct wall-clock reading with a `Z` suffix (e.g. a March 1st date is `'2025-03-01T00:00:00Z'`). Rendering them through anything timezone-aware — `new Date(value).toLocaleDateString()`, `Intl.DateTimeFormat`, `date-fns-tz`, or manual offset math — shifts them into the viewer's local timezone, so a viewer behind UTC sees Feb 28 where Lightdash shows Mar 1. Always display dates via `format(row, fieldName)` or the `@/lib/format` helpers below (they render the server's wall-clock components as-is); when formatting manually, format the date's own Y/M/D components, never a locale/timezone conversion of them.
|
|
612
|
+
|
|
611
613
|
Every Lightdash row carries two views of each value:
|
|
612
614
|
|
|
613
615
|
- `row[fieldName]` — the **raw** value. Numbers are real numbers; **dates and timestamps are full ISO strings** (e.g. `'2025-03-01T00:00:00Z'`) regardless of the truncation grain. Pass these to charts as data, not as axis labels.
|
|
@@ -636,17 +638,15 @@ import { formatField, formatDate, formatNumber, getColumn } from '@/lib/format';
|
|
|
636
638
|
- number — server-formatted (currency / % / suffix preserved via the SDK `format()` you pass in)
|
|
637
639
|
- timestamp without grain — `Jun 16, 2025 14:00`
|
|
638
640
|
|
|
639
|
-
**Override** by passing `opts.pattern` to `formatDate
|
|
641
|
+
**Override** by passing `opts.pattern` to `formatDate` — this keeps the server's wall-clock reading while changing the pattern:
|
|
640
642
|
|
|
641
643
|
```tsx
|
|
642
|
-
import { format as formatDateFns, parseISO } from 'date-fns';
|
|
643
|
-
|
|
644
644
|
formatDate(row.order_date_month, getColumn(columns, 'order_date_month'), 'axis', { pattern: 'MMM yyyy' });
|
|
645
|
-
|
|
646
|
-
// Or fully manual:
|
|
647
|
-
formatDateFns(parseISO(row.order_date as string), 'EEEE, MMM d');
|
|
645
|
+
formatDate(row.order_date, undefined, 'cell', { pattern: 'EEEE, MMM d' });
|
|
648
646
|
```
|
|
649
647
|
|
|
648
|
+
Avoid `parseISO`/`new Date` + `format` from `date-fns` for display: the `Z` suffix makes them shift the value into the viewer's timezone, moving midnight dates to the previous day for viewers behind UTC.
|
|
649
|
+
|
|
650
650
|
#### Chart axes
|
|
651
651
|
|
|
652
652
|
**Every `<XAxis>` and `<YAxis>` in the app must have a `tickFormatter`.** No exceptions — including year axes that "look like they'd be fine" (`order_date_year` is still a full ISO timestamp at the data layer; Recharts will render `2025-01-01T00:00:00Z`, not `2025`).
|
|
@@ -1379,6 +1379,7 @@ The action-menu example above shows typical `drillDown()` usage. For the full AP
|
|
|
1379
1379
|
| Skipping `tickFormatter` on a year axis because "year is just a number" | Year dimensions (`*_year`) are still full ISO timestamps at the data layer, so the axis renders `2025-01-01T00:00:00Z` | Apply the same `formatDate(...)` `tickFormatter` to year axes — `formatDate` will collapse to `2025` for year-grain columns |
|
|
1380
1380
|
| Using `format(row, 'order_date')` for a date column in a table cell | Renders `2025-03-17` (zero-padded tabular) — readable but ugly in dashboards | `formatField(row, col, format, 'cell')` from `@/lib/format` — renders `Mar 17, 2025` while still routing currency/% metrics through the server format |
|
|
1381
1381
|
| Treating `row.order_date_month` as a `Date` or short string | It's a full ISO timestamp string (`2025-03-01T00:00:00Z`) for every grain | Pass through `formatDate` / `formatField`, or `parseISO` it before doing date math |
|
|
1382
|
+
| Displaying dates via `toLocaleDateString()`, `Intl.DateTimeFormat`, or `parseISO` + `format` | The `Z` suffix makes these convert to the viewer's timezone — viewers behind UTC see the previous day (e.g. Feb 28 instead of Mar 1) | Render through `format(row, field)` or `@/lib/format` helpers; they display the server's wall-clock value as-is |
|
|
1382
1383
|
| Wrong `.parameters()` key scope | Value silently ignored — query returns the default | Project-level → bare name (`{ region }`); model-level → `model.param` (`{ 'orders.region' }`), matching the `${lightdash.parameters.…}` reference |
|
|
1383
1384
|
| Inventing a parameter not declared in the YAML | Backend ignores unknown params; nothing changes | Only pass parameters that exist in a model's `meta.parameters` or `lightdash.config.yml`. Use `.filters()` for row restriction instead |
|
|
1384
1385
|
| Applying `.parameters()` at module scope for a UI-driven value | Value never updates when the control changes | Apply `.parameters()` in a `useMemo` keyed on the state value; keep the base query at module scope |
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { format as formatDateFns, parseISO } from 'date-fns';
|
|
2
1
|
import type { Column, FormatFunction, Row } from '@lightdash/query-sdk';
|
|
2
|
+
import { format as formatDateFns, parseISO } from 'date-fns';
|
|
3
3
|
|
|
4
4
|
export type FormatVariant = 'cell' | 'axis';
|
|
5
5
|
|
|
@@ -21,6 +21,93 @@ function inferGranularity(name: string): DateGranularity | undefined {
|
|
|
21
21
|
return undefined;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
// Lightdash returns dates/timestamps as the project's wall-clock reading with
|
|
25
|
+
// a `Z` suffix (e.g. a March 1st date arrives as '2025-03-01T00:00:00Z').
|
|
26
|
+
// Parsing that with `parseISO`/`new Date` and formatting locally shifts it
|
|
27
|
+
// into the viewer's timezone — a UTC-7 viewer would see "Feb 28". Render the
|
|
28
|
+
// wall-clock components exactly as the server sent them instead.
|
|
29
|
+
const ISO_WALL_CLOCK_RE =
|
|
30
|
+
/^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?)?/;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A Date whose local-time getters return UTC components, so date-fns `format`
|
|
34
|
+
* renders the stored wall-clock reading in every viewer timezone. A plain
|
|
35
|
+
* `new Date(y, m, d, h, …)` is not enough: the constructor interprets the
|
|
36
|
+
* components in the viewer's timezone and normalizes local times that don't
|
|
37
|
+
* exist there (a DST spring-forward gap turns 02:30 into 03:30). date-fns
|
|
38
|
+
* clones inputs via their own constructor, so the subclass survives `format`.
|
|
39
|
+
* Same mechanism as @date-fns/utc's UTCDate, without the dependency.
|
|
40
|
+
*/
|
|
41
|
+
class WallClockDate extends Date {
|
|
42
|
+
getFullYear() {
|
|
43
|
+
return this.getUTCFullYear();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
getMonth() {
|
|
47
|
+
return this.getUTCMonth();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
getDate() {
|
|
51
|
+
return this.getUTCDate();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
getDay() {
|
|
55
|
+
return this.getUTCDay();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
getHours() {
|
|
59
|
+
return this.getUTCHours();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
getMinutes() {
|
|
63
|
+
return this.getUTCMinutes();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
getSeconds() {
|
|
67
|
+
return this.getUTCSeconds();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
getMilliseconds() {
|
|
71
|
+
return this.getUTCMilliseconds();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
getTimezoneOffset() {
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Setters must match the getters: date-fns week tokens (II, ww, RRRR)
|
|
79
|
+
// compute via startOfWeek, which mutates clones with setDate/setHours.
|
|
80
|
+
// Inheriting the local setters would mix UTC reads with local writes and
|
|
81
|
+
// land on the wrong ISO week in non-UTC viewer timezones.
|
|
82
|
+
setFullYear(...args: Parameters<Date['setFullYear']>) {
|
|
83
|
+
return this.setUTCFullYear(...args);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
setMonth(...args: Parameters<Date['setMonth']>) {
|
|
87
|
+
return this.setUTCMonth(...args);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
setDate(day: number) {
|
|
91
|
+
return this.setUTCDate(day);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
setHours(...args: Parameters<Date['setHours']>) {
|
|
95
|
+
return this.setUTCHours(...args);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
setMinutes(...args: Parameters<Date['setMinutes']>) {
|
|
99
|
+
return this.setUTCMinutes(...args);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
setSeconds(...args: Parameters<Date['setSeconds']>) {
|
|
103
|
+
return this.setUTCSeconds(...args);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
setMilliseconds(ms: number) {
|
|
107
|
+
return this.setUTCMilliseconds(ms);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
24
111
|
function toDate(value: unknown): Date | null {
|
|
25
112
|
if (value === null || value === undefined || value === '') return null;
|
|
26
113
|
if (value instanceof Date)
|
|
@@ -30,6 +117,23 @@ function toDate(value: unknown): Date | null {
|
|
|
30
117
|
return Number.isNaN(d.getTime()) ? null : d;
|
|
31
118
|
}
|
|
32
119
|
if (typeof value === 'string') {
|
|
120
|
+
const wallClock = ISO_WALL_CLOCK_RE.exec(value);
|
|
121
|
+
if (wallClock) {
|
|
122
|
+
const [, year, month, day, hours, minutes, seconds, millis] =
|
|
123
|
+
wallClock;
|
|
124
|
+
const d = new WallClockDate(
|
|
125
|
+
Date.UTC(
|
|
126
|
+
Number(year),
|
|
127
|
+
Number(month) - 1,
|
|
128
|
+
Number(day),
|
|
129
|
+
Number(hours ?? 0),
|
|
130
|
+
Number(minutes ?? 0),
|
|
131
|
+
Number(seconds ?? 0),
|
|
132
|
+
Number(millis?.padEnd(3, '0') ?? 0),
|
|
133
|
+
),
|
|
134
|
+
);
|
|
135
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
136
|
+
}
|
|
33
137
|
const d = parseISO(value);
|
|
34
138
|
return Number.isNaN(d.getTime()) ? null : d;
|
|
35
139
|
}
|
|
@@ -135,9 +239,6 @@ export function formatField(
|
|
|
135
239
|
}
|
|
136
240
|
}
|
|
137
241
|
|
|
138
|
-
export function getColumn(
|
|
139
|
-
columns: Column[],
|
|
140
|
-
name: string,
|
|
141
|
-
): Column | undefined {
|
|
242
|
+
export function getColumn(columns: Column[], name: string): Column | undefined {
|
|
142
243
|
return columns.find((c) => c.name === name);
|
|
143
244
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lightdash/cli",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.165.0",
|
|
4
4
|
"description": "Lightdash CLI tool",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
"@actions/core": "1.11.1",
|
|
23
23
|
"@aws-sdk/credential-providers": "3.995.0",
|
|
24
24
|
"@casl/ability": "6.8.0",
|
|
25
|
-
"@lightdash/common": "3.
|
|
26
|
-
"@lightdash/warehouses": "3.
|
|
25
|
+
"@lightdash/common": "3.165.0",
|
|
26
|
+
"@lightdash/warehouses": "3.165.0",
|
|
27
27
|
"@sidvind/better-ajv-errors": "7.0.0",
|
|
28
28
|
"@types/columnify": "1.5.1",
|
|
29
29
|
"ajv": "8.18.0",
|
|
@@ -61,6 +61,7 @@
|
|
|
61
61
|
"@vercel/ncc": "0.38.4",
|
|
62
62
|
"@yao-pkg/pkg": "6.24.0",
|
|
63
63
|
"copyfiles": "2.4.1",
|
|
64
|
+
"date-fns": "3.6.0",
|
|
64
65
|
"oxlint": "1.75.0",
|
|
65
66
|
"oxlint-tsgolint": "7.0.2001",
|
|
66
67
|
"tsx": "4.19.4",
|