From cbf25e994a722639fde2b518a72bd7012d007eab Mon Sep 17 00:00:00 2001 From: Ivan Ortega Alba Date: Wed, 2 Apr 2025 13:36:42 +0200 Subject: [PATCH] Transform: Minimum scene should not throw transformation errors (#103189) * Transform: Minimum scene should not throw transformation errors --- .../transformSceneToSaveModelSchemaV2.test.ts | 188 +++++++++++++++++ .../transformSceneToSaveModelSchemaV2.ts | 194 +++++++++--------- 2 files changed, 289 insertions(+), 93 deletions(-) diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts index 2194b6c69ff..71b495851b0 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts @@ -55,6 +55,7 @@ import { getPersistedDSFor, getElementDatasource, transformSceneToSaveModelSchemaV2, + validateDashboardSchemaV2, } from './transformSceneToSaveModelSchemaV2'; // Mock dependencies @@ -416,6 +417,14 @@ describe('transformSceneToSaveModelSchemaV2', () => { expect(result.annotations?.[2].spec.datasource?.type).toBe('loki'); }); + it('should transform the minimum scene to save model schema v2', () => { + const minimalScene = new DashboardScene({}); + + expect(() => { + transformSceneToSaveModelSchemaV2(minimalScene); + }).not.toThrow(); + }); + describe('getPersistedDSFor query', () => { it('should respect datasource reference mapping when determining query datasource', () => { // Setup test data @@ -889,3 +898,182 @@ function createAnnotationLayers() { }), ]; } + +describe('validateDashboardSchemaV2', () => { + const validDashboard = { + title: 'Test Dashboard', + timeSettings: { + from: 'now-1h', + to: 'now', + autoRefresh: '5s', + hideTimepicker: false, + timezone: 'UTC', + autoRefreshIntervals: ['5s', '10s', '30s'], + quickRanges: [], + weekStart: 'monday', + nowDelay: '1m', + fiscalYearStartMonth: 1, + }, + variables: [], + elements: {}, + annotations: [], + layout: { + kind: 'GridLayout', + spec: { + items: [], + }, + }, + }; + + it('should validate a valid dashboard', () => { + expect(validateDashboardSchemaV2(validDashboard)).toBe(true); + }); + + it('should throw error if dashboard is not an object', () => { + expect(() => validateDashboardSchemaV2(null)).toThrow('Dashboard is not an object or is null'); + expect(() => validateDashboardSchemaV2(undefined)).toThrow('Dashboard is not an object or is null'); + expect(() => validateDashboardSchemaV2('string')).toThrow('Dashboard is not an object or is null'); + expect(() => validateDashboardSchemaV2(123)).toThrow('Dashboard is not an object or is null'); + expect(() => validateDashboardSchemaV2(true)).toThrow('Dashboard is not an object or is null'); + expect(() => validateDashboardSchemaV2([])).toThrow('Dashboard is not an object or is null'); + }); + + it('should validate required properties', () => { + const requiredProps = { + title: 'Title is not a string', + timeSettings: 'TimeSettings is not an object or is null', + variables: 'Variables is not an array', + elements: 'Elements is not an object or is null', + annotations: 'Annotations is not an array', + layout: 'Layout is not an object or is null', + }; + + for (const [prop, message] of Object.entries(requiredProps)) { + const invalidDashboard = { ...validDashboard }; + delete invalidDashboard[prop as keyof typeof invalidDashboard]; + expect(() => validateDashboardSchemaV2(invalidDashboard)).toThrow(message); + } + }); + + it('should validate timeSettings required properties', () => { + const timeSettingsErrors = { + from: 'From is not a string', + to: 'To is not a string', + autoRefresh: 'AutoRefresh is not a string', + hideTimepicker: 'HideTimepicker is not a boolean', + } as const; + + for (const [prop, message] of Object.entries(timeSettingsErrors)) { + const invalidDashboard = { + ...validDashboard, + timeSettings: { ...validDashboard.timeSettings }, + }; + delete invalidDashboard.timeSettings[prop as keyof typeof invalidDashboard.timeSettings]; + expect(() => validateDashboardSchemaV2(invalidDashboard)).toThrow(message); + } + }); + + it('should validate optional properties when present', () => { + const invalidDashboard = { + ...validDashboard, + description: 123, // Should be string + cursorSync: 'Invalid', // Should be one of ['Off', 'Crosshair', 'Tooltip'] + liveNow: 'true', // Should be boolean + preload: 'true', // Should be boolean + editable: 'true', // Should be boolean + links: 'not-an-array', // Should be array + tags: 'not-an-array', // Should be array + id: 'not-a-number', // Should be number + }; + + expect(() => validateDashboardSchemaV2(invalidDashboard)).toThrow('Description is not a string'); + expect(() => validateDashboardSchemaV2({ ...validDashboard, cursorSync: 'Invalid' })).toThrow( + 'CursorSync is not a valid value' + ); + expect(() => validateDashboardSchemaV2({ ...validDashboard, liveNow: 'true' })).toThrow('LiveNow is not a boolean'); + expect(() => validateDashboardSchemaV2({ ...validDashboard, preload: 'true' })).toThrow('Preload is not a boolean'); + expect(() => validateDashboardSchemaV2({ ...validDashboard, editable: 'true' })).toThrow( + 'Editable is not a boolean' + ); + expect(() => validateDashboardSchemaV2({ ...validDashboard, links: 'not-an-array' })).toThrow( + 'Links is not an array' + ); + expect(() => validateDashboardSchemaV2({ ...validDashboard, tags: 'not-an-array' })).toThrow( + 'Tags is not an array' + ); + expect(() => validateDashboardSchemaV2({ ...validDashboard, id: 'not-a-number' })).toThrow('ID is not a number'); + }); + + it('should validate optional timeSettings properties when present', () => { + const invalidTimeSettings = { + ...validDashboard.timeSettings, + autoRefreshIntervals: 'not-an-array', + timezone: 123, + quickRanges: 'not-an-array', + weekStart: 'invalid-day', + nowDelay: 123, + fiscalYearStartMonth: 'not-a-number', + }; + + expect(() => validateDashboardSchemaV2({ ...validDashboard, timeSettings: invalidTimeSettings })).toThrow( + 'AutoRefreshIntervals is not an array' + ); + expect(() => + validateDashboardSchemaV2({ ...validDashboard, timeSettings: { ...validDashboard.timeSettings, timezone: 123 } }) + ).toThrow('Timezone is not a string'); + expect(() => + validateDashboardSchemaV2({ + ...validDashboard, + timeSettings: { ...validDashboard.timeSettings, quickRanges: 'not-an-array' }, + }) + ).toThrow('QuickRanges is not an array'); + expect(() => + validateDashboardSchemaV2({ + ...validDashboard, + timeSettings: { ...validDashboard.timeSettings, weekStart: 'invalid-day' }, + }) + ).toThrow('WeekStart should be one of "saturday", "sunday" or "monday"'); + expect(() => + validateDashboardSchemaV2({ ...validDashboard, timeSettings: { ...validDashboard.timeSettings, nowDelay: 123 } }) + ).toThrow('NowDelay is not a string'); + expect(() => + validateDashboardSchemaV2({ + ...validDashboard, + timeSettings: { ...validDashboard.timeSettings, fiscalYearStartMonth: 'not-a-number' }, + }) + ).toThrow('FiscalYearStartMonth is not a number'); + }); + + it('should validate layout kind and structure', () => { + // Missing kind + expect(() => validateDashboardSchemaV2({ ...validDashboard, layout: { spec: { items: [] } } })).toThrow( + 'Layout kind is required' + ); + + // Invalid GridLayout + expect(() => validateDashboardSchemaV2({ ...validDashboard, layout: { kind: 'GridLayout' } })).toThrow( + 'Layout spec is not an object or is null' + ); + expect(() => validateDashboardSchemaV2({ ...validDashboard, layout: { kind: 'GridLayout', spec: {} } })).toThrow( + 'Layout spec items is not an array' + ); + + // Invalid RowsLayout + expect(() => validateDashboardSchemaV2({ ...validDashboard, layout: { kind: 'RowsLayout' } })).toThrow( + 'Layout spec is not an object or is null' + ); + expect(() => validateDashboardSchemaV2({ ...validDashboard, layout: { kind: 'RowsLayout', spec: {} } })).toThrow( + 'Layout spec items is not an array' + ); + + // Valid GridLayout + expect(validateDashboardSchemaV2({ ...validDashboard, layout: { kind: 'GridLayout', spec: { items: [] } } })).toBe( + true + ); + + // Valid RowsLayout + expect(validateDashboardSchemaV2({ ...validDashboard, layout: { kind: 'RowsLayout', spec: { rows: [] } } })).toBe( + true + ); + }); +}); diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts index 572c10d88b6..a770b4ba0fc 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts @@ -77,10 +77,10 @@ export function transformSceneToSaveModelSchemaV2(scene: DashboardScene, isSnaps description: sceneDash.description, cursorSync: getCursorSync(sceneDash), liveNow: getLiveNow(sceneDash), - preload: sceneDash.preload, - editable: sceneDash.editable, - links: sceneDash.links, - tags: sceneDash.tags, + preload: sceneDash.preload ?? defaultDashboardV2Spec().preload, + editable: sceneDash.editable ?? defaultDashboardV2Spec().editable, + links: sceneDash.links ?? defaultDashboardV2Spec().links, + tags: sceneDash.tags ?? defaultDashboardV2Spec().tags, // EOF dashboard settings // time settings @@ -444,98 +444,18 @@ export function getDefaultDataSourceRef(): DataSourceRef { } // Function to know if the dashboard transformed is a valid DashboardV2Spec -function validateDashboardSchemaV2(dash: unknown): dash is DashboardV2Spec { - if (typeof dash !== 'object' || dash === null) { +export function validateDashboardSchemaV2(dash: unknown): dash is DashboardV2Spec { + if (typeof dash !== 'object' || dash === null || Array.isArray(dash)) { throw new Error('Dashboard is not an object or is null'); } - if ('title' in dash && typeof dash.title !== 'string') { + // Required properties + if (!('title' in dash) || typeof dash.title !== 'string') { throw new Error('Title is not a string'); } - if ('description' in dash && dash.description !== undefined && typeof dash.description !== 'string') { - throw new Error('Description is not a string'); - } - if ('cursorSync' in dash && typeof dash.cursorSync !== 'string') { - const validCursorSyncValues = ((): string[] => { - const typeValues: DashboardCursorSync[] = ['Off', 'Crosshair', 'Tooltip']; - return typeValues; - })(); - - if ( - 'cursorSync' in dash && - (typeof dash.cursorSync !== 'string' || !validCursorSyncValues.includes(dash.cursorSync)) - ) { - throw new Error('CursorSync is not a string'); - } - } - if ('liveNow' in dash && typeof dash.liveNow !== 'boolean') { - throw new Error('LiveNow is not a boolean'); - } - if ('preload' in dash && typeof dash.preload !== 'boolean') { - throw new Error('Preload is not a boolean'); - } - if ('editable' in dash && typeof dash.editable !== 'boolean') { - throw new Error('Editable is not a boolean'); - } - if ('links' in dash && !Array.isArray(dash.links)) { - throw new Error('Links is not an array'); - } - if ('tags' in dash && !Array.isArray(dash.tags)) { - throw new Error('Tags is not an array'); - } - - if ('id' in dash && dash.id !== undefined && typeof dash.id !== 'number') { - throw new Error('ID is not a number'); - } - - // Time settings if (!('timeSettings' in dash) || typeof dash.timeSettings !== 'object' || dash.timeSettings === null) { throw new Error('TimeSettings is not an object or is null'); } - if (!('timezone' in dash.timeSettings) || typeof dash.timeSettings.timezone !== 'string') { - throw new Error('Timezone is not a string'); - } - if (!('from' in dash.timeSettings) || typeof dash.timeSettings.from !== 'string') { - throw new Error('From is not a string'); - } - if (!('to' in dash.timeSettings) || typeof dash.timeSettings.to !== 'string') { - throw new Error('To is not a string'); - } - if (!('autoRefresh' in dash.timeSettings) || typeof dash.timeSettings.autoRefresh !== 'string') { - throw new Error('AutoRefresh is not a string'); - } - if (!('autoRefreshIntervals' in dash.timeSettings) || !Array.isArray(dash.timeSettings.autoRefreshIntervals)) { - throw new Error('AutoRefreshIntervals is not an array'); - } - if ( - 'quickRanges' in dash.timeSettings && - dash.timeSettings.quickRanges && - !Array.isArray(dash.timeSettings.quickRanges) - ) { - throw new Error('QuickRanges is not an array'); - } - if (!('hideTimepicker' in dash.timeSettings) || typeof dash.timeSettings.hideTimepicker !== 'boolean') { - throw new Error('HideTimepicker is not a boolean'); - } - if ( - 'weekStart' in dash.timeSettings && - typeof dash.timeSettings.weekStart === 'string' && - !['saturday', 'sunday', 'monday'].includes(dash.timeSettings.weekStart) - ) { - throw new Error('WeekStart should be one of "saturday", "sunday" or "monday"'); - } - if (!('fiscalYearStartMonth' in dash.timeSettings) || typeof dash.timeSettings.fiscalYearStartMonth !== 'number') { - throw new Error('FiscalYearStartMonth is not a number'); - } - if ( - 'nowDelay' in dash.timeSettings && - dash.timeSettings.nowDelay !== undefined && - typeof dash.timeSettings.nowDelay !== 'string' - ) { - throw new Error('NowDelay is not a string'); - } - - // Other sections if (!('variables' in dash) || !Array.isArray(dash.variables)) { throw new Error('Variables is not an array'); } @@ -545,17 +465,105 @@ function validateDashboardSchemaV2(dash: unknown): dash is DashboardV2Spec { if (!('annotations' in dash) || !Array.isArray(dash.annotations)) { throw new Error('Annotations is not an array'); } - - // Layout if (!('layout' in dash) || typeof dash.layout !== 'object' || dash.layout === null) { throw new Error('Layout is not an object or is null'); } - if (!('kind' in dash.layout) || dash.layout.kind === 'GridLayout') { - validateGridLayout(dash.layout); + // Optional properties - only validate if present + if ('description' in dash && dash.description !== undefined && typeof dash.description !== 'string') { + throw new Error('Description is not a string'); + } + if ('cursorSync' in dash && dash.cursorSync !== undefined) { + const validCursorSyncValues = ((): string[] => { + const typeValues: DashboardCursorSync[] = ['Off', 'Crosshair', 'Tooltip']; + return typeValues; + })(); + + if (typeof dash.cursorSync !== 'string' || !validCursorSyncValues.includes(dash.cursorSync)) { + throw new Error('CursorSync is not a valid value'); + } + } + if ('liveNow' in dash && dash.liveNow !== undefined && typeof dash.liveNow !== 'boolean') { + throw new Error('LiveNow is not a boolean'); + } + if ('preload' in dash && dash.preload !== undefined && typeof dash.preload !== 'boolean') { + throw new Error('Preload is not a boolean'); + } + if ('editable' in dash && dash.editable !== undefined && typeof dash.editable !== 'boolean') { + throw new Error('Editable is not a boolean'); + } + if ('links' in dash && dash.links !== undefined && !Array.isArray(dash.links)) { + throw new Error('Links is not an array'); + } + if ('tags' in dash && dash.tags !== undefined && !Array.isArray(dash.tags)) { + throw new Error('Tags is not an array'); + } + if ('id' in dash && dash.id !== undefined && typeof dash.id !== 'number') { + throw new Error('ID is not a number'); } - if (!('kind' in dash.layout) || dash.layout.kind === 'RowsLayout') { + // Time settings validation + const timeSettings = dash.timeSettings; + + // Required time settings + if (!('from' in timeSettings) || typeof timeSettings.from !== 'string') { + throw new Error('From is not a string'); + } + if (!('to' in timeSettings) || typeof timeSettings.to !== 'string') { + throw new Error('To is not a string'); + } + if (!('autoRefresh' in timeSettings) || typeof timeSettings.autoRefresh !== 'string') { + throw new Error('AutoRefresh is not a string'); + } + if (!('hideTimepicker' in timeSettings) || typeof timeSettings.hideTimepicker !== 'boolean') { + throw new Error('HideTimepicker is not a boolean'); + } + + // Optional time settings with defaults + if ( + 'autoRefreshIntervals' in timeSettings && + timeSettings.autoRefreshIntervals !== undefined && + !Array.isArray(timeSettings.autoRefreshIntervals) + ) { + throw new Error('AutoRefreshIntervals is not an array'); + } + if ('timezone' in timeSettings && timeSettings.timezone !== undefined && typeof timeSettings.timezone !== 'string') { + throw new Error('Timezone is not a string'); + } + if ( + 'quickRanges' in timeSettings && + timeSettings.quickRanges !== undefined && + !Array.isArray(timeSettings.quickRanges) + ) { + throw new Error('QuickRanges is not an array'); + } + if ('weekStart' in timeSettings && timeSettings.weekStart !== undefined) { + if ( + typeof timeSettings.weekStart !== 'string' || + !['saturday', 'sunday', 'monday'].includes(timeSettings.weekStart) + ) { + throw new Error('WeekStart should be one of "saturday", "sunday" or "monday"'); + } + } + if ('nowDelay' in timeSettings && timeSettings.nowDelay !== undefined && typeof timeSettings.nowDelay !== 'string') { + throw new Error('NowDelay is not a string'); + } + if ( + 'fiscalYearStartMonth' in timeSettings && + timeSettings.fiscalYearStartMonth !== undefined && + typeof timeSettings.fiscalYearStartMonth !== 'number' + ) { + throw new Error('FiscalYearStartMonth is not a number'); + } + + // Layout validation + if (!('kind' in dash.layout)) { + throw new Error('Layout kind is required'); + } + + if (dash.layout.kind === 'GridLayout') { + validateGridLayout(dash.layout); + } else if (dash.layout.kind === 'RowsLayout') { validateRowsLayout(dash.layout); }