[Dashboard] Add saved object version for collapsible sections (#222450)

Closes https://github.com/elastic/kibana/issues/222437

## Summary

Rather than [dynamically adding `sections` to V2 of the dashboard saved
object](https://github.com/elastic/kibana/pull/220877/files#diff-98ad8ac0d2638b4dbc8ca6476730a22566c9e3d35fc92d32030a2d7eaf4159a7R82-R85),
this PR adds a V3 saved object instead in order to add this new key (and
removes it from V2) - this allows V2 saved objects to be properly
updated to the new version.

**Steps to reproduce:**
1. Start an ES server + Kibana on a version **before** collapsible
sections merged
2. Download sample data on that same server
3. Launch a second Kibana from `main` on top of the same ES server
started in step 1
4. Try to add a collapsible section on this new Kibana
5. You will hit an error when trying to save the dashboard 🔥

**Steps to test this fix:**
1. Follow steps 1 and 2 from the reproduction steps above
2. Launch a second Kibana from **this PR** on top of the same ES server
started in step 1
3. Try to add a collapsible section on this new Kibana
4. You should be able to save your dashboard this time! 🎉 

### Before


https://github.com/user-attachments/assets/e2af0f8b-cabb-4a56-86b3-36a6120cd91f

### After


https://github.com/user-attachments/assets/480d7054-27aa-4661-8279-43bd761eaf44




### Checklist

- [x] The PR description includes the appropriate Release Notes section,
and the correct `release_note:*` label is applied per the
[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Hannah Mudge 2025-06-05 11:37:50 -06:00 committed by GitHub
parent 2564d6de38
commit 2ec8ca00b4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 114 additions and 40 deletions

View file

@ -923,6 +923,7 @@
}
},
"dashboard": {
"dynamic": false,
"properties": {
"controlGroupInput": {
"properties": {

View file

@ -91,7 +91,7 @@ describe('checking migration metadata changes on all registered SO types', () =>
"connector_token": "79977ea2cb1530ba7e315b95c1b5a524b622a6b3",
"core-usage-stats": "b3c04da317c957741ebcdedfea4524049fdc79ff",
"csp-rule-template": "c151324d5f85178169395eecb12bac6b96064654",
"dashboard": "7fea2b6f8f860ac4f665fd0d5c91645ac248fd56",
"dashboard": "904b7a17c97a8df4292fa411195c33c693aab510",
"dynamic-config-overrides": "eb3ec7d96a42991068eda5421eecba9349c82d2b",
"endpoint:unified-user-artifact-manifest": "71c7fcb52c658b21ea2800a6b6a76972ae1c776e",
"endpoint:user-artifact-manifest": "1c3533161811a58772e30cdc77bac4631da3ef2b",

View file

@ -12,6 +12,8 @@ import { SavedObjectsType } from '@kbn/core/server';
import { dashboardAttributesSchema as dashboardAttributesSchemaV1 } from './schema/v1';
import { dashboardAttributesSchema as dashboardAttributesSchemaV2 } from './schema/v2';
import { dashboardAttributesSchema as dashboardAttributesSchemaV3 } from './schema/v3';
import {
createDashboardSavedObjectTypeMigrations,
DashboardSavedObjectTypeMigrationsDeps,
@ -69,8 +71,23 @@ export const createDashboardSavedObjectType = ({
create: dashboardAttributesSchemaV2,
},
},
3: {
changes: [
{
type: 'mappings_addition',
addedMappings: {
sections: { properties: {}, dynamic: false },
},
},
],
schemas: {
forwardCompatibility: dashboardAttributesSchemaV3.extends({}, { unknowns: 'ignore' }),
create: dashboardAttributesSchemaV3,
},
},
},
mappings: {
dynamic: false,
properties: {
description: { type: 'text' },
hits: { type: 'integer', index: false, doc_values: false },

View file

@ -7,11 +7,11 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
// Latest model version for dashboard saved objects is v2
// Latest model version for dashboard saved objects is v3
export {
dashboardAttributesSchema as dashboardSavedObjectSchema,
type DashboardAttributes as DashboardSavedObjectAttributes,
type GridData,
type SavedDashboardPanel,
type SavedDashboardSection,
} from './v2';
} from './v3';

View file

@ -7,10 +7,5 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export type {
DashboardAttributes,
GridData,
SavedDashboardPanel,
SavedDashboardSection,
} from './types';
export type { DashboardAttributes, GridData, SavedDashboardPanel } from './types';
export { controlGroupInputSchema, dashboardAttributesSchema } from './v2';

View file

@ -9,7 +9,7 @@
import { Serializable } from '@kbn/utility-types';
import { TypeOf } from '@kbn/config-schema';
import { dashboardAttributesSchema, gridDataSchema, sectionSchema } from './v2';
import { dashboardAttributesSchema, gridDataSchema } from './v2';
export type DashboardAttributes = TypeOf<typeof dashboardAttributesSchema>;
export type GridData = TypeOf<typeof gridDataSchema>;
@ -33,8 +33,3 @@ export interface SavedDashboardPanel {
*/
version?: string;
}
/**
* A saved dashboard section parsed directly from the Dashboard Attributes
*/
export type SavedDashboardSection = TypeOf<typeof sectionSchema>;

View file

@ -13,26 +13,6 @@ import {
dashboardAttributesSchema as dashboardAttributesSchemaV1,
} from '../v1';
// sections only include y + i for grid data
export const sectionGridDataSchema = schema.object({
y: schema.number(),
i: schema.string(),
});
// panels include all grid data keys, including those that sections use
export const gridDataSchema = sectionGridDataSchema.extends({
x: schema.number(),
w: schema.number(),
h: schema.number(),
sectionId: schema.maybe(schema.string()),
});
export const sectionSchema = schema.object({
title: schema.string(),
collapsed: schema.maybe(schema.boolean()),
gridData: sectionGridDataSchema,
});
export const controlGroupInputSchema = controlGroupInputSchemaV1.extends(
{
showApplySelections: schema.maybe(schema.boolean()),
@ -43,7 +23,14 @@ export const controlGroupInputSchema = controlGroupInputSchemaV1.extends(
export const dashboardAttributesSchema = dashboardAttributesSchemaV1.extends(
{
controlGroupInput: schema.maybe(controlGroupInputSchema),
sections: schema.maybe(schema.arrayOf(sectionSchema)),
},
{ unknowns: 'ignore' }
);
export const gridDataSchema = schema.object({
x: schema.number(),
y: schema.number(),
w: schema.number(),
h: schema.number(),
i: schema.string(),
});

View file

@ -0,0 +1,16 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export type {
DashboardAttributes,
GridData,
SavedDashboardPanel,
SavedDashboardSection,
} from './types';
export { dashboardAttributesSchema } from './v3';

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { TypeOf } from '@kbn/config-schema';
import { SavedDashboardPanel as SavedDashboardPanelV2 } from '../v2';
import { dashboardAttributesSchema, gridDataSchema, sectionSchema } from './v3';
export type DashboardAttributes = TypeOf<typeof dashboardAttributesSchema>;
export type GridData = TypeOf<typeof gridDataSchema>;
/**
* A saved dashboard panel parsed directly from the Dashboard Attributes panels JSON
*/
export type SavedDashboardPanel = Omit<SavedDashboardPanelV2, 'gridData'> & { gridData: GridData };
/**
* A saved dashboard section parsed directly from the Dashboard Attributes
*/
export type SavedDashboardSection = TypeOf<typeof sectionSchema>;

View file

@ -0,0 +1,38 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { schema } from '@kbn/config-schema';
import { dashboardAttributesSchema as dashboardAttributesSchemaV2 } from '../v2';
// sections only include y + i for grid data
export const sectionGridDataSchema = schema.object({
y: schema.number(),
i: schema.string(),
});
// panels include all grid data keys, including those that sections use
export const gridDataSchema = sectionGridDataSchema.extends({
x: schema.number(),
w: schema.number(),
h: schema.number(),
sectionId: schema.maybe(schema.string()),
});
export const sectionSchema = schema.object({
title: schema.string(),
collapsed: schema.maybe(schema.boolean()),
gridData: sectionGridDataSchema,
});
export const dashboardAttributesSchema = dashboardAttributesSchemaV2.extends(
{
sections: schema.maybe(schema.arrayOf(sectionSchema)),
},
{ unknowns: 'ignore' }
);

View file

@ -234,10 +234,10 @@ export default function ({ getService }: FtrProviderContext) {
type: 'dashboard',
namespaces: ['default'],
migrationVersion: {
dashboard: '10.2.0',
dashboard: '10.3.0',
},
coreMigrationVersion: '8.8.0',
typeMigrationVersion: '10.2.0',
typeMigrationVersion: '10.3.0',
updated_at: '2015-01-01T00:00:00.000Z',
created_at: '2015-01-01T00:00:00.000Z',
version: resp.body.saved_objects[3].version,

View file

@ -84,10 +84,10 @@ export default function ({ getService }: FtrProviderContext) {
type: 'dashboard',
namespaces: ['default'],
migrationVersion: {
dashboard: '10.2.0',
dashboard: '10.3.0',
},
coreMigrationVersion: '8.8.0',
typeMigrationVersion: '10.2.0',
typeMigrationVersion: '10.3.0',
updated_at: resp.body.updated_at,
created_at: resp.body.created_at,
version: resp.body.version,