[Dashboard] Add collapsible sections (#220877)

Closes https://github.com/elastic/kibana/issues/1547
Closes https://github.com/elastic/kibana/issues/190342
Closes https://github.com/elastic/kibana/issues/197716

## Summary

This PR adds the ability for collapsible sections to be created and
managed on Dashboards.




https://github.com/user-attachments/assets/c5c046d0-58f1-45e1-88b3-33421f3ec002

> [!NOTE]
> Most of the work for developing collapsible sections occurred in PRs
contained to the `kbn-grid-layout` package (see [this meta
issue](https://github.com/elastic/kibana/issues/190342) to track this
work) - this PR simply makes them available on Dashboards by adding them
as a widget that can be added through the "Add panel" menu. As a result
of this, most work is contained in the Dashboard plugin - changes made
to the `kbn-grid-layout` package only include adding IDs for additional
tests that were added for the Dashboard integration.

### Technical Details

#### Content Management Schema

The content management schema allows for panels and sections to be mixed
within the single `panels` key for a dashboard **without** worrying
about section IDs; for example:

```
{
  "panels": [
    {
       // this is a simplified panel
       "gridData": {
         "x": 0,
         "y": 0,
         "w": 12,
         "h": 8,
       },
      "panelConfig": { ... },
    },
    {
       // this is a section
       "gridData": {
         "y": 9,
       },
      "collapsed": false,
      "title": "Section title",
      "panels": [
          {
          // this is a simplified panel
          "gridData": {
           "x": 0,
           "y": 0,
           "w": 24,
           "h": 16,
          },
          "panelConfig": { ... },
        },
      ],
   },
  ]
} 
```

#### Saved Object Schema

The dashboard saved object schema, on the other hand, separates out
sections and panels under different keys - this is because, while we are
stuck with panels being stored as `panelJSON`, I didn't want to add much
to this. So, under grid data for each panel, they have an optional
`sectionId` which then links to a section in the `sections` array in the
saved object:

```
{
  "panelsJSON": "<...> \"gridData\":{\"i\":\"panelId\",\"y\":0,\"x\":0,\"w\":12,\"h\":8,\"sectionId\":\"someSectionId\"} <...>"
  "sections": [
     {
       "collapsed": false,
       "title": "Section title",
       "gridData": {
          "i": "someSectionId",
          "y": 8.
        }
     }
  ],
}
```

This allows sections to be serialized **without** being stringified.
This storage also matches how we store this data in runtime using
`layout`.

### Checklist

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [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)


## Release note

Adds collapsible sections to Dashboard, which allow panels to grouped
into sections that will not load their contents when their assigned
section is collapsed.

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Hannah Mudge 2025-05-30 11:40:28 -06:00 committed by GitHub
parent b3f79c809f
commit 74ee116780
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
78 changed files with 5615 additions and 1982 deletions

View file

@ -7469,6 +7469,8 @@
"panels": {
"default": [],
"items": {
"anyOf": [
{
"additionalProperties": false,
"properties": {
"gridData": {
@ -7568,6 +7570,147 @@
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"collapsed": {
"description": "The collapsed state of the section.",
"type": "boolean"
},
"gridData": {
"additionalProperties": false,
"properties": {
"i": {
"type": "string"
},
"y": {
"description": "The y coordinate of the section in grid units",
"type": "number"
}
},
"required": [
"y",
"i"
],
"type": "object"
},
"panels": {
"items": {
"additionalProperties": false,
"properties": {
"gridData": {
"additionalProperties": false,
"properties": {
"h": {
"default": 15,
"description": "The height of the panel in grid units",
"minimum": 1,
"type": "number"
},
"i": {
"type": "string"
},
"w": {
"default": 24,
"description": "The width of the panel in grid units",
"maximum": 48,
"minimum": 1,
"type": "number"
},
"x": {
"description": "The x coordinate of the panel in grid units",
"type": "number"
},
"y": {
"description": "The y coordinate of the panel in grid units",
"type": "number"
}
},
"required": [
"x",
"y",
"i"
],
"type": "object"
},
"id": {
"description": "The saved object id for by reference panels",
"type": "string"
},
"panelConfig": {
"additionalProperties": true,
"properties": {
"description": {
"description": "The description of the panel",
"type": "string"
},
"enhancements": {
"additionalProperties": {},
"type": "object"
},
"hidePanelTitles": {
"description": "Set to true to hide the panel title in its container.",
"type": "boolean"
},
"savedObjectId": {
"description": "The unique id of the library item to construct the embeddable.",
"type": "string"
},
"title": {
"description": "The title of the panel",
"type": "string"
},
"version": {
"description": "The version of the embeddable in the panel.",
"type": "string"
}
},
"type": "object"
},
"panelIndex": {
"type": "string"
},
"panelRefName": {
"type": "string"
},
"title": {
"description": "The title of the panel",
"type": "string"
},
"type": {
"description": "The embeddable type",
"type": "string"
},
"version": {
"deprecated": true,
"description": "The version was used to store Kibana version information from versions 7.3.0 -> 8.11.0. As of version 8.11.0, the versioning information is now per-embeddable-type and is stored on the embeddable's input. (panelConfig in this type).",
"type": "string"
}
},
"required": [
"panelConfig",
"type",
"gridData",
"panelIndex"
],
"type": "object"
},
"type": "array"
},
"title": {
"description": "The title of the section.",
"type": "string"
}
},
"required": [
"title",
"gridData",
"panels"
],
"type": "object"
}
]
},
"type": "array"
},
"refreshInterval": {
@ -8130,6 +8273,8 @@
"panels": {
"default": [],
"items": {
"anyOf": [
{
"additionalProperties": false,
"properties": {
"gridData": {
@ -8229,6 +8374,148 @@
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"collapsed": {
"description": "The collapsed state of the section.",
"type": "boolean"
},
"gridData": {
"additionalProperties": false,
"properties": {
"i": {
"description": "The unique identifier of the section",
"type": "string"
},
"y": {
"description": "The y coordinate of the section in grid units",
"type": "number"
}
},
"required": [
"y"
],
"type": "object"
},
"panels": {
"default": [],
"description": "The panels that belong to the section.",
"items": {
"additionalProperties": false,
"properties": {
"gridData": {
"additionalProperties": false,
"properties": {
"h": {
"default": 15,
"description": "The height of the panel in grid units",
"minimum": 1,
"type": "number"
},
"i": {
"description": "The unique identifier of the panel",
"type": "string"
},
"w": {
"default": 24,
"description": "The width of the panel in grid units",
"maximum": 48,
"minimum": 1,
"type": "number"
},
"x": {
"description": "The x coordinate of the panel in grid units",
"type": "number"
},
"y": {
"description": "The y coordinate of the panel in grid units",
"type": "number"
}
},
"required": [
"x",
"y"
],
"type": "object"
},
"id": {
"description": "The saved object id for by reference panels",
"type": "string"
},
"panelConfig": {
"additionalProperties": true,
"properties": {
"description": {
"description": "The description of the panel",
"type": "string"
},
"enhancements": {
"additionalProperties": {},
"type": "object"
},
"hidePanelTitles": {
"description": "Set to true to hide the panel title in its container.",
"type": "boolean"
},
"savedObjectId": {
"description": "The unique id of the library item to construct the embeddable.",
"type": "string"
},
"title": {
"description": "The title of the panel",
"type": "string"
},
"version": {
"description": "The version of the embeddable in the panel.",
"type": "string"
}
},
"type": "object"
},
"panelIndex": {
"description": "The unique ID of the panel.",
"type": "string"
},
"panelRefName": {
"type": "string"
},
"title": {
"description": "The title of the panel",
"type": "string"
},
"type": {
"description": "The embeddable type",
"type": "string"
},
"version": {
"deprecated": true,
"description": "The version was used to store Kibana version information from versions 7.3.0 -> 8.11.0. As of version 8.11.0, the versioning information is now per-embeddable-type and is stored on the embeddable's input. (panelConfig in this type).",
"type": "string"
}
},
"required": [
"panelConfig",
"type",
"gridData"
],
"type": "object"
},
"type": "array"
},
"title": {
"description": "The title of the section.",
"type": "string"
}
},
"required": [
"title",
"gridData"
],
"type": "object"
}
]
},
"type": "array"
},
"refreshInterval": {
@ -8675,6 +8962,8 @@
"panels": {
"default": [],
"items": {
"anyOf": [
{
"additionalProperties": false,
"properties": {
"gridData": {
@ -8774,6 +9063,147 @@
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"collapsed": {
"description": "The collapsed state of the section.",
"type": "boolean"
},
"gridData": {
"additionalProperties": false,
"properties": {
"i": {
"type": "string"
},
"y": {
"description": "The y coordinate of the section in grid units",
"type": "number"
}
},
"required": [
"y",
"i"
],
"type": "object"
},
"panels": {
"items": {
"additionalProperties": false,
"properties": {
"gridData": {
"additionalProperties": false,
"properties": {
"h": {
"default": 15,
"description": "The height of the panel in grid units",
"minimum": 1,
"type": "number"
},
"i": {
"type": "string"
},
"w": {
"default": 24,
"description": "The width of the panel in grid units",
"maximum": 48,
"minimum": 1,
"type": "number"
},
"x": {
"description": "The x coordinate of the panel in grid units",
"type": "number"
},
"y": {
"description": "The y coordinate of the panel in grid units",
"type": "number"
}
},
"required": [
"x",
"y",
"i"
],
"type": "object"
},
"id": {
"description": "The saved object id for by reference panels",
"type": "string"
},
"panelConfig": {
"additionalProperties": true,
"properties": {
"description": {
"description": "The description of the panel",
"type": "string"
},
"enhancements": {
"additionalProperties": {},
"type": "object"
},
"hidePanelTitles": {
"description": "Set to true to hide the panel title in its container.",
"type": "boolean"
},
"savedObjectId": {
"description": "The unique id of the library item to construct the embeddable.",
"type": "string"
},
"title": {
"description": "The title of the panel",
"type": "string"
},
"version": {
"description": "The version of the embeddable in the panel.",
"type": "string"
}
},
"type": "object"
},
"panelIndex": {
"type": "string"
},
"panelRefName": {
"type": "string"
},
"title": {
"description": "The title of the panel",
"type": "string"
},
"type": {
"description": "The embeddable type",
"type": "string"
},
"version": {
"deprecated": true,
"description": "The version was used to store Kibana version information from versions 7.3.0 -> 8.11.0. As of version 8.11.0, the versioning information is now per-embeddable-type and is stored on the embeddable's input. (panelConfig in this type).",
"type": "string"
}
},
"required": [
"panelConfig",
"type",
"gridData",
"panelIndex"
],
"type": "object"
},
"type": "array"
},
"title": {
"description": "The title of the section.",
"type": "string"
}
},
"required": [
"title",
"gridData",
"panels"
],
"type": "object"
}
]
},
"type": "array"
},
"refreshInterval": {
@ -9308,6 +9738,8 @@
"panels": {
"default": [],
"items": {
"anyOf": [
{
"additionalProperties": false,
"properties": {
"gridData": {
@ -9407,6 +9839,148 @@
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"collapsed": {
"description": "The collapsed state of the section.",
"type": "boolean"
},
"gridData": {
"additionalProperties": false,
"properties": {
"i": {
"description": "The unique identifier of the section",
"type": "string"
},
"y": {
"description": "The y coordinate of the section in grid units",
"type": "number"
}
},
"required": [
"y"
],
"type": "object"
},
"panels": {
"default": [],
"description": "The panels that belong to the section.",
"items": {
"additionalProperties": false,
"properties": {
"gridData": {
"additionalProperties": false,
"properties": {
"h": {
"default": 15,
"description": "The height of the panel in grid units",
"minimum": 1,
"type": "number"
},
"i": {
"description": "The unique identifier of the panel",
"type": "string"
},
"w": {
"default": 24,
"description": "The width of the panel in grid units",
"maximum": 48,
"minimum": 1,
"type": "number"
},
"x": {
"description": "The x coordinate of the panel in grid units",
"type": "number"
},
"y": {
"description": "The y coordinate of the panel in grid units",
"type": "number"
}
},
"required": [
"x",
"y"
],
"type": "object"
},
"id": {
"description": "The saved object id for by reference panels",
"type": "string"
},
"panelConfig": {
"additionalProperties": true,
"properties": {
"description": {
"description": "The description of the panel",
"type": "string"
},
"enhancements": {
"additionalProperties": {},
"type": "object"
},
"hidePanelTitles": {
"description": "Set to true to hide the panel title in its container.",
"type": "boolean"
},
"savedObjectId": {
"description": "The unique id of the library item to construct the embeddable.",
"type": "string"
},
"title": {
"description": "The title of the panel",
"type": "string"
},
"version": {
"description": "The version of the embeddable in the panel.",
"type": "string"
}
},
"type": "object"
},
"panelIndex": {
"description": "The unique ID of the panel.",
"type": "string"
},
"panelRefName": {
"type": "string"
},
"title": {
"description": "The title of the panel",
"type": "string"
},
"type": {
"description": "The embeddable type",
"type": "string"
},
"version": {
"deprecated": true,
"description": "The version was used to store Kibana version information from versions 7.3.0 -> 8.11.0. As of version 8.11.0, the versioning information is now per-embeddable-type and is stored on the embeddable's input. (panelConfig in this type).",
"type": "string"
}
},
"required": [
"panelConfig",
"type",
"gridData"
],
"type": "object"
},
"type": "array"
},
"title": {
"description": "The title of the section.",
"type": "string"
}
},
"required": [
"title",
"gridData"
],
"type": "object"
}
]
},
"type": "array"
},
"refreshInterval": {
@ -9847,6 +10421,8 @@
"panels": {
"default": [],
"items": {
"anyOf": [
{
"additionalProperties": false,
"properties": {
"gridData": {
@ -9946,6 +10522,147 @@
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"collapsed": {
"description": "The collapsed state of the section.",
"type": "boolean"
},
"gridData": {
"additionalProperties": false,
"properties": {
"i": {
"type": "string"
},
"y": {
"description": "The y coordinate of the section in grid units",
"type": "number"
}
},
"required": [
"y",
"i"
],
"type": "object"
},
"panels": {
"items": {
"additionalProperties": false,
"properties": {
"gridData": {
"additionalProperties": false,
"properties": {
"h": {
"default": 15,
"description": "The height of the panel in grid units",
"minimum": 1,
"type": "number"
},
"i": {
"type": "string"
},
"w": {
"default": 24,
"description": "The width of the panel in grid units",
"maximum": 48,
"minimum": 1,
"type": "number"
},
"x": {
"description": "The x coordinate of the panel in grid units",
"type": "number"
},
"y": {
"description": "The y coordinate of the panel in grid units",
"type": "number"
}
},
"required": [
"x",
"y",
"i"
],
"type": "object"
},
"id": {
"description": "The saved object id for by reference panels",
"type": "string"
},
"panelConfig": {
"additionalProperties": true,
"properties": {
"description": {
"description": "The description of the panel",
"type": "string"
},
"enhancements": {
"additionalProperties": {},
"type": "object"
},
"hidePanelTitles": {
"description": "Set to true to hide the panel title in its container.",
"type": "boolean"
},
"savedObjectId": {
"description": "The unique id of the library item to construct the embeddable.",
"type": "string"
},
"title": {
"description": "The title of the panel",
"type": "string"
},
"version": {
"description": "The version of the embeddable in the panel.",
"type": "string"
}
},
"type": "object"
},
"panelIndex": {
"type": "string"
},
"panelRefName": {
"type": "string"
},
"title": {
"description": "The title of the panel",
"type": "string"
},
"type": {
"description": "The embeddable type",
"type": "string"
},
"version": {
"deprecated": true,
"description": "The version was used to store Kibana version information from versions 7.3.0 -> 8.11.0. As of version 8.11.0, the versioning information is now per-embeddable-type and is stored on the embeddable's input. (panelConfig in this type).",
"type": "string"
}
},
"required": [
"panelConfig",
"type",
"gridData",
"panelIndex"
],
"type": "object"
},
"type": "array"
},
"title": {
"description": "The title of the section.",
"type": "string"
}
},
"required": [
"title",
"gridData",
"panels"
],
"type": "object"
}
]
},
"type": "array"
},
"refreshInterval": {

View file

@ -7469,6 +7469,8 @@
"panels": {
"default": [],
"items": {
"anyOf": [
{
"additionalProperties": false,
"properties": {
"gridData": {
@ -7568,6 +7570,147 @@
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"collapsed": {
"description": "The collapsed state of the section.",
"type": "boolean"
},
"gridData": {
"additionalProperties": false,
"properties": {
"i": {
"type": "string"
},
"y": {
"description": "The y coordinate of the section in grid units",
"type": "number"
}
},
"required": [
"y",
"i"
],
"type": "object"
},
"panels": {
"items": {
"additionalProperties": false,
"properties": {
"gridData": {
"additionalProperties": false,
"properties": {
"h": {
"default": 15,
"description": "The height of the panel in grid units",
"minimum": 1,
"type": "number"
},
"i": {
"type": "string"
},
"w": {
"default": 24,
"description": "The width of the panel in grid units",
"maximum": 48,
"minimum": 1,
"type": "number"
},
"x": {
"description": "The x coordinate of the panel in grid units",
"type": "number"
},
"y": {
"description": "The y coordinate of the panel in grid units",
"type": "number"
}
},
"required": [
"x",
"y",
"i"
],
"type": "object"
},
"id": {
"description": "The saved object id for by reference panels",
"type": "string"
},
"panelConfig": {
"additionalProperties": true,
"properties": {
"description": {
"description": "The description of the panel",
"type": "string"
},
"enhancements": {
"additionalProperties": {},
"type": "object"
},
"hidePanelTitles": {
"description": "Set to true to hide the panel title in its container.",
"type": "boolean"
},
"savedObjectId": {
"description": "The unique id of the library item to construct the embeddable.",
"type": "string"
},
"title": {
"description": "The title of the panel",
"type": "string"
},
"version": {
"description": "The version of the embeddable in the panel.",
"type": "string"
}
},
"type": "object"
},
"panelIndex": {
"type": "string"
},
"panelRefName": {
"type": "string"
},
"title": {
"description": "The title of the panel",
"type": "string"
},
"type": {
"description": "The embeddable type",
"type": "string"
},
"version": {
"deprecated": true,
"description": "The version was used to store Kibana version information from versions 7.3.0 -> 8.11.0. As of version 8.11.0, the versioning information is now per-embeddable-type and is stored on the embeddable's input. (panelConfig in this type).",
"type": "string"
}
},
"required": [
"panelConfig",
"type",
"gridData",
"panelIndex"
],
"type": "object"
},
"type": "array"
},
"title": {
"description": "The title of the section.",
"type": "string"
}
},
"required": [
"title",
"gridData",
"panels"
],
"type": "object"
}
]
},
"type": "array"
},
"refreshInterval": {
@ -8130,6 +8273,8 @@
"panels": {
"default": [],
"items": {
"anyOf": [
{
"additionalProperties": false,
"properties": {
"gridData": {
@ -8229,6 +8374,148 @@
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"collapsed": {
"description": "The collapsed state of the section.",
"type": "boolean"
},
"gridData": {
"additionalProperties": false,
"properties": {
"i": {
"description": "The unique identifier of the section",
"type": "string"
},
"y": {
"description": "The y coordinate of the section in grid units",
"type": "number"
}
},
"required": [
"y"
],
"type": "object"
},
"panels": {
"default": [],
"description": "The panels that belong to the section.",
"items": {
"additionalProperties": false,
"properties": {
"gridData": {
"additionalProperties": false,
"properties": {
"h": {
"default": 15,
"description": "The height of the panel in grid units",
"minimum": 1,
"type": "number"
},
"i": {
"description": "The unique identifier of the panel",
"type": "string"
},
"w": {
"default": 24,
"description": "The width of the panel in grid units",
"maximum": 48,
"minimum": 1,
"type": "number"
},
"x": {
"description": "The x coordinate of the panel in grid units",
"type": "number"
},
"y": {
"description": "The y coordinate of the panel in grid units",
"type": "number"
}
},
"required": [
"x",
"y"
],
"type": "object"
},
"id": {
"description": "The saved object id for by reference panels",
"type": "string"
},
"panelConfig": {
"additionalProperties": true,
"properties": {
"description": {
"description": "The description of the panel",
"type": "string"
},
"enhancements": {
"additionalProperties": {},
"type": "object"
},
"hidePanelTitles": {
"description": "Set to true to hide the panel title in its container.",
"type": "boolean"
},
"savedObjectId": {
"description": "The unique id of the library item to construct the embeddable.",
"type": "string"
},
"title": {
"description": "The title of the panel",
"type": "string"
},
"version": {
"description": "The version of the embeddable in the panel.",
"type": "string"
}
},
"type": "object"
},
"panelIndex": {
"description": "The unique ID of the panel.",
"type": "string"
},
"panelRefName": {
"type": "string"
},
"title": {
"description": "The title of the panel",
"type": "string"
},
"type": {
"description": "The embeddable type",
"type": "string"
},
"version": {
"deprecated": true,
"description": "The version was used to store Kibana version information from versions 7.3.0 -> 8.11.0. As of version 8.11.0, the versioning information is now per-embeddable-type and is stored on the embeddable's input. (panelConfig in this type).",
"type": "string"
}
},
"required": [
"panelConfig",
"type",
"gridData"
],
"type": "object"
},
"type": "array"
},
"title": {
"description": "The title of the section.",
"type": "string"
}
},
"required": [
"title",
"gridData"
],
"type": "object"
}
]
},
"type": "array"
},
"refreshInterval": {
@ -8675,6 +8962,8 @@
"panels": {
"default": [],
"items": {
"anyOf": [
{
"additionalProperties": false,
"properties": {
"gridData": {
@ -8774,6 +9063,147 @@
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"collapsed": {
"description": "The collapsed state of the section.",
"type": "boolean"
},
"gridData": {
"additionalProperties": false,
"properties": {
"i": {
"type": "string"
},
"y": {
"description": "The y coordinate of the section in grid units",
"type": "number"
}
},
"required": [
"y",
"i"
],
"type": "object"
},
"panels": {
"items": {
"additionalProperties": false,
"properties": {
"gridData": {
"additionalProperties": false,
"properties": {
"h": {
"default": 15,
"description": "The height of the panel in grid units",
"minimum": 1,
"type": "number"
},
"i": {
"type": "string"
},
"w": {
"default": 24,
"description": "The width of the panel in grid units",
"maximum": 48,
"minimum": 1,
"type": "number"
},
"x": {
"description": "The x coordinate of the panel in grid units",
"type": "number"
},
"y": {
"description": "The y coordinate of the panel in grid units",
"type": "number"
}
},
"required": [
"x",
"y",
"i"
],
"type": "object"
},
"id": {
"description": "The saved object id for by reference panels",
"type": "string"
},
"panelConfig": {
"additionalProperties": true,
"properties": {
"description": {
"description": "The description of the panel",
"type": "string"
},
"enhancements": {
"additionalProperties": {},
"type": "object"
},
"hidePanelTitles": {
"description": "Set to true to hide the panel title in its container.",
"type": "boolean"
},
"savedObjectId": {
"description": "The unique id of the library item to construct the embeddable.",
"type": "string"
},
"title": {
"description": "The title of the panel",
"type": "string"
},
"version": {
"description": "The version of the embeddable in the panel.",
"type": "string"
}
},
"type": "object"
},
"panelIndex": {
"type": "string"
},
"panelRefName": {
"type": "string"
},
"title": {
"description": "The title of the panel",
"type": "string"
},
"type": {
"description": "The embeddable type",
"type": "string"
},
"version": {
"deprecated": true,
"description": "The version was used to store Kibana version information from versions 7.3.0 -> 8.11.0. As of version 8.11.0, the versioning information is now per-embeddable-type and is stored on the embeddable's input. (panelConfig in this type).",
"type": "string"
}
},
"required": [
"panelConfig",
"type",
"gridData",
"panelIndex"
],
"type": "object"
},
"type": "array"
},
"title": {
"description": "The title of the section.",
"type": "string"
}
},
"required": [
"title",
"gridData",
"panels"
],
"type": "object"
}
]
},
"type": "array"
},
"refreshInterval": {
@ -9308,6 +9738,8 @@
"panels": {
"default": [],
"items": {
"anyOf": [
{
"additionalProperties": false,
"properties": {
"gridData": {
@ -9407,6 +9839,148 @@
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"collapsed": {
"description": "The collapsed state of the section.",
"type": "boolean"
},
"gridData": {
"additionalProperties": false,
"properties": {
"i": {
"description": "The unique identifier of the section",
"type": "string"
},
"y": {
"description": "The y coordinate of the section in grid units",
"type": "number"
}
},
"required": [
"y"
],
"type": "object"
},
"panels": {
"default": [],
"description": "The panels that belong to the section.",
"items": {
"additionalProperties": false,
"properties": {
"gridData": {
"additionalProperties": false,
"properties": {
"h": {
"default": 15,
"description": "The height of the panel in grid units",
"minimum": 1,
"type": "number"
},
"i": {
"description": "The unique identifier of the panel",
"type": "string"
},
"w": {
"default": 24,
"description": "The width of the panel in grid units",
"maximum": 48,
"minimum": 1,
"type": "number"
},
"x": {
"description": "The x coordinate of the panel in grid units",
"type": "number"
},
"y": {
"description": "The y coordinate of the panel in grid units",
"type": "number"
}
},
"required": [
"x",
"y"
],
"type": "object"
},
"id": {
"description": "The saved object id for by reference panels",
"type": "string"
},
"panelConfig": {
"additionalProperties": true,
"properties": {
"description": {
"description": "The description of the panel",
"type": "string"
},
"enhancements": {
"additionalProperties": {},
"type": "object"
},
"hidePanelTitles": {
"description": "Set to true to hide the panel title in its container.",
"type": "boolean"
},
"savedObjectId": {
"description": "The unique id of the library item to construct the embeddable.",
"type": "string"
},
"title": {
"description": "The title of the panel",
"type": "string"
},
"version": {
"description": "The version of the embeddable in the panel.",
"type": "string"
}
},
"type": "object"
},
"panelIndex": {
"description": "The unique ID of the panel.",
"type": "string"
},
"panelRefName": {
"type": "string"
},
"title": {
"description": "The title of the panel",
"type": "string"
},
"type": {
"description": "The embeddable type",
"type": "string"
},
"version": {
"deprecated": true,
"description": "The version was used to store Kibana version information from versions 7.3.0 -> 8.11.0. As of version 8.11.0, the versioning information is now per-embeddable-type and is stored on the embeddable's input. (panelConfig in this type).",
"type": "string"
}
},
"required": [
"panelConfig",
"type",
"gridData"
],
"type": "object"
},
"type": "array"
},
"title": {
"description": "The title of the section.",
"type": "string"
}
},
"required": [
"title",
"gridData"
],
"type": "object"
}
]
},
"type": "array"
},
"refreshInterval": {
@ -9847,6 +10421,8 @@
"panels": {
"default": [],
"items": {
"anyOf": [
{
"additionalProperties": false,
"properties": {
"gridData": {
@ -9946,6 +10522,147 @@
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"collapsed": {
"description": "The collapsed state of the section.",
"type": "boolean"
},
"gridData": {
"additionalProperties": false,
"properties": {
"i": {
"type": "string"
},
"y": {
"description": "The y coordinate of the section in grid units",
"type": "number"
}
},
"required": [
"y",
"i"
],
"type": "object"
},
"panels": {
"items": {
"additionalProperties": false,
"properties": {
"gridData": {
"additionalProperties": false,
"properties": {
"h": {
"default": 15,
"description": "The height of the panel in grid units",
"minimum": 1,
"type": "number"
},
"i": {
"type": "string"
},
"w": {
"default": 24,
"description": "The width of the panel in grid units",
"maximum": 48,
"minimum": 1,
"type": "number"
},
"x": {
"description": "The x coordinate of the panel in grid units",
"type": "number"
},
"y": {
"description": "The y coordinate of the panel in grid units",
"type": "number"
}
},
"required": [
"x",
"y",
"i"
],
"type": "object"
},
"id": {
"description": "The saved object id for by reference panels",
"type": "string"
},
"panelConfig": {
"additionalProperties": true,
"properties": {
"description": {
"description": "The description of the panel",
"type": "string"
},
"enhancements": {
"additionalProperties": {},
"type": "object"
},
"hidePanelTitles": {
"description": "Set to true to hide the panel title in its container.",
"type": "boolean"
},
"savedObjectId": {
"description": "The unique id of the library item to construct the embeddable.",
"type": "string"
},
"title": {
"description": "The title of the panel",
"type": "string"
},
"version": {
"description": "The version of the embeddable in the panel.",
"type": "string"
}
},
"type": "object"
},
"panelIndex": {
"type": "string"
},
"panelRefName": {
"type": "string"
},
"title": {
"description": "The title of the panel",
"type": "string"
},
"type": {
"description": "The embeddable type",
"type": "string"
},
"version": {
"deprecated": true,
"description": "The version was used to store Kibana version information from versions 7.3.0 -> 8.11.0. As of version 8.11.0, the versioning information is now per-embeddable-type and is stored on the embeddable's input. (panelConfig in this type).",
"type": "string"
}
},
"required": [
"panelConfig",
"type",
"gridData",
"panelIndex"
],
"type": "object"
},
"type": "array"
},
"title": {
"description": "The title of the section.",
"type": "string"
}
},
"required": [
"title",
"gridData",
"panels"
],
"type": "object"
}
]
},
"type": "array"
},
"refreshInterval": {

View file

@ -6731,6 +6731,101 @@ paths:
type: boolean
panels:
default: []
items:
anyOf:
- additionalProperties: false
type: object
properties:
gridData:
additionalProperties: false
type: object
properties:
h:
default: 15
description: The height of the panel in grid units
minimum: 1
type: number
i:
type: string
w:
default: 24
description: The width of the panel in grid units
maximum: 48
minimum: 1
type: number
x:
description: The x coordinate of the panel in grid units
type: number
'y':
description: The y coordinate of the panel in grid units
type: number
required:
- x
- 'y'
- i
id:
description: The saved object id for by reference panels
type: string
panelConfig:
additionalProperties: true
type: object
properties:
description:
description: The description of the panel
type: string
enhancements:
additionalProperties: {}
type: object
hidePanelTitles:
description: Set to true to hide the panel title in its container.
type: boolean
savedObjectId:
description: The unique id of the library item to construct the embeddable.
type: string
title:
description: The title of the panel
type: string
version:
description: The version of the embeddable in the panel.
type: string
panelIndex:
type: string
panelRefName:
type: string
title:
description: The title of the panel
type: string
type:
description: The embeddable type
type: string
version:
deprecated: true
description: The version was used to store Kibana version information from versions 7.3.0 -> 8.11.0. As of version 8.11.0, the versioning information is now per-embeddable-type and is stored on the embeddable's input. (panelConfig in this type).
type: string
required:
- panelConfig
- type
- gridData
- panelIndex
- additionalProperties: false
type: object
properties:
collapsed:
description: The collapsed state of the section.
type: boolean
gridData:
additionalProperties: false
type: object
properties:
i:
type: string
'y':
description: The y coordinate of the section in grid units
type: number
required:
- 'y'
- i
panels:
items:
additionalProperties: false
type: object
@ -6807,6 +6902,14 @@ paths:
- gridData
- panelIndex
type: array
title:
description: The title of the section.
type: string
required:
- title
- gridData
- panels
type: array
refreshInterval:
additionalProperties: false
description: A container for various refresh interval settings
@ -7201,6 +7304,103 @@ paths:
type: boolean
panels:
default: []
items:
anyOf:
- additionalProperties: false
type: object
properties:
gridData:
additionalProperties: false
type: object
properties:
h:
default: 15
description: The height of the panel in grid units
minimum: 1
type: number
i:
description: The unique identifier of the panel
type: string
w:
default: 24
description: The width of the panel in grid units
maximum: 48
minimum: 1
type: number
x:
description: The x coordinate of the panel in grid units
type: number
'y':
description: The y coordinate of the panel in grid units
type: number
required:
- x
- 'y'
id:
description: The saved object id for by reference panels
type: string
panelConfig:
additionalProperties: true
type: object
properties:
description:
description: The description of the panel
type: string
enhancements:
additionalProperties: {}
type: object
hidePanelTitles:
description: Set to true to hide the panel title in its container.
type: boolean
savedObjectId:
description: The unique id of the library item to construct the embeddable.
type: string
title:
description: The title of the panel
type: string
version:
description: The version of the embeddable in the panel.
type: string
panelIndex:
description: The unique ID of the panel.
type: string
panelRefName:
type: string
title:
description: The title of the panel
type: string
type:
description: The embeddable type
type: string
version:
deprecated: true
description: The version was used to store Kibana version information from versions 7.3.0 -> 8.11.0. As of version 8.11.0, the versioning information is now per-embeddable-type and is stored on the embeddable's input. (panelConfig in this type).
type: string
required:
- panelConfig
- type
- gridData
- additionalProperties: false
type: object
properties:
collapsed:
description: The collapsed state of the section.
type: boolean
gridData:
additionalProperties: false
type: object
properties:
i:
description: The unique identifier of the section
type: string
'y':
description: The y coordinate of the section in grid units
type: number
required:
- 'y'
panels:
default: []
description: The panels that belong to the section.
items:
additionalProperties: false
type: object
@ -7277,6 +7477,13 @@ paths:
- type
- gridData
type: array
title:
description: The title of the section.
type: string
required:
- title
- gridData
type: array
refreshInterval:
additionalProperties: false
description: A container for various refresh interval settings
@ -7594,6 +7801,101 @@ paths:
type: boolean
panels:
default: []
items:
anyOf:
- additionalProperties: false
type: object
properties:
gridData:
additionalProperties: false
type: object
properties:
h:
default: 15
description: The height of the panel in grid units
minimum: 1
type: number
i:
type: string
w:
default: 24
description: The width of the panel in grid units
maximum: 48
minimum: 1
type: number
x:
description: The x coordinate of the panel in grid units
type: number
'y':
description: The y coordinate of the panel in grid units
type: number
required:
- x
- 'y'
- i
id:
description: The saved object id for by reference panels
type: string
panelConfig:
additionalProperties: true
type: object
properties:
description:
description: The description of the panel
type: string
enhancements:
additionalProperties: {}
type: object
hidePanelTitles:
description: Set to true to hide the panel title in its container.
type: boolean
savedObjectId:
description: The unique id of the library item to construct the embeddable.
type: string
title:
description: The title of the panel
type: string
version:
description: The version of the embeddable in the panel.
type: string
panelIndex:
type: string
panelRefName:
type: string
title:
description: The title of the panel
type: string
type:
description: The embeddable type
type: string
version:
deprecated: true
description: The version was used to store Kibana version information from versions 7.3.0 -> 8.11.0. As of version 8.11.0, the versioning information is now per-embeddable-type and is stored on the embeddable's input. (panelConfig in this type).
type: string
required:
- panelConfig
- type
- gridData
- panelIndex
- additionalProperties: false
type: object
properties:
collapsed:
description: The collapsed state of the section.
type: boolean
gridData:
additionalProperties: false
type: object
properties:
i:
type: string
'y':
description: The y coordinate of the section in grid units
type: number
required:
- 'y'
- i
panels:
items:
additionalProperties: false
type: object
@ -7670,6 +7972,14 @@ paths:
- gridData
- panelIndex
type: array
title:
description: The title of the section.
type: string
required:
- title
- gridData
- panels
type: array
refreshInterval:
additionalProperties: false
description: A container for various refresh interval settings
@ -8044,6 +8354,103 @@ paths:
type: boolean
panels:
default: []
items:
anyOf:
- additionalProperties: false
type: object
properties:
gridData:
additionalProperties: false
type: object
properties:
h:
default: 15
description: The height of the panel in grid units
minimum: 1
type: number
i:
description: The unique identifier of the panel
type: string
w:
default: 24
description: The width of the panel in grid units
maximum: 48
minimum: 1
type: number
x:
description: The x coordinate of the panel in grid units
type: number
'y':
description: The y coordinate of the panel in grid units
type: number
required:
- x
- 'y'
id:
description: The saved object id for by reference panels
type: string
panelConfig:
additionalProperties: true
type: object
properties:
description:
description: The description of the panel
type: string
enhancements:
additionalProperties: {}
type: object
hidePanelTitles:
description: Set to true to hide the panel title in its container.
type: boolean
savedObjectId:
description: The unique id of the library item to construct the embeddable.
type: string
title:
description: The title of the panel
type: string
version:
description: The version of the embeddable in the panel.
type: string
panelIndex:
description: The unique ID of the panel.
type: string
panelRefName:
type: string
title:
description: The title of the panel
type: string
type:
description: The embeddable type
type: string
version:
deprecated: true
description: The version was used to store Kibana version information from versions 7.3.0 -> 8.11.0. As of version 8.11.0, the versioning information is now per-embeddable-type and is stored on the embeddable's input. (panelConfig in this type).
type: string
required:
- panelConfig
- type
- gridData
- additionalProperties: false
type: object
properties:
collapsed:
description: The collapsed state of the section.
type: boolean
gridData:
additionalProperties: false
type: object
properties:
i:
description: The unique identifier of the section
type: string
'y':
description: The y coordinate of the section in grid units
type: number
required:
- 'y'
panels:
default: []
description: The panels that belong to the section.
items:
additionalProperties: false
type: object
@ -8120,6 +8527,13 @@ paths:
- type
- gridData
type: array
title:
description: The title of the section.
type: string
required:
- title
- gridData
type: array
refreshInterval:
additionalProperties: false
description: A container for various refresh interval settings
@ -8433,6 +8847,101 @@ paths:
type: boolean
panels:
default: []
items:
anyOf:
- additionalProperties: false
type: object
properties:
gridData:
additionalProperties: false
type: object
properties:
h:
default: 15
description: The height of the panel in grid units
minimum: 1
type: number
i:
type: string
w:
default: 24
description: The width of the panel in grid units
maximum: 48
minimum: 1
type: number
x:
description: The x coordinate of the panel in grid units
type: number
'y':
description: The y coordinate of the panel in grid units
type: number
required:
- x
- 'y'
- i
id:
description: The saved object id for by reference panels
type: string
panelConfig:
additionalProperties: true
type: object
properties:
description:
description: The description of the panel
type: string
enhancements:
additionalProperties: {}
type: object
hidePanelTitles:
description: Set to true to hide the panel title in its container.
type: boolean
savedObjectId:
description: The unique id of the library item to construct the embeddable.
type: string
title:
description: The title of the panel
type: string
version:
description: The version of the embeddable in the panel.
type: string
panelIndex:
type: string
panelRefName:
type: string
title:
description: The title of the panel
type: string
type:
description: The embeddable type
type: string
version:
deprecated: true
description: The version was used to store Kibana version information from versions 7.3.0 -> 8.11.0. As of version 8.11.0, the versioning information is now per-embeddable-type and is stored on the embeddable's input. (panelConfig in this type).
type: string
required:
- panelConfig
- type
- gridData
- panelIndex
- additionalProperties: false
type: object
properties:
collapsed:
description: The collapsed state of the section.
type: boolean
gridData:
additionalProperties: false
type: object
properties:
i:
type: string
'y':
description: The y coordinate of the section in grid units
type: number
required:
- 'y'
- i
panels:
items:
additionalProperties: false
type: object
@ -8509,6 +9018,14 @@ paths:
- gridData
- panelIndex
type: array
title:
description: The title of the section.
type: string
required:
- title
- gridData
- panels
type: array
refreshInterval:
additionalProperties: false
description: A container for various refresh interval settings

View file

@ -8273,6 +8273,101 @@ paths:
type: boolean
panels:
default: []
items:
anyOf:
- additionalProperties: false
type: object
properties:
gridData:
additionalProperties: false
type: object
properties:
h:
default: 15
description: The height of the panel in grid units
minimum: 1
type: number
i:
type: string
w:
default: 24
description: The width of the panel in grid units
maximum: 48
minimum: 1
type: number
x:
description: The x coordinate of the panel in grid units
type: number
'y':
description: The y coordinate of the panel in grid units
type: number
required:
- x
- 'y'
- i
id:
description: The saved object id for by reference panels
type: string
panelConfig:
additionalProperties: true
type: object
properties:
description:
description: The description of the panel
type: string
enhancements:
additionalProperties: {}
type: object
hidePanelTitles:
description: Set to true to hide the panel title in its container.
type: boolean
savedObjectId:
description: The unique id of the library item to construct the embeddable.
type: string
title:
description: The title of the panel
type: string
version:
description: The version of the embeddable in the panel.
type: string
panelIndex:
type: string
panelRefName:
type: string
title:
description: The title of the panel
type: string
type:
description: The embeddable type
type: string
version:
deprecated: true
description: The version was used to store Kibana version information from versions 7.3.0 -> 8.11.0. As of version 8.11.0, the versioning information is now per-embeddable-type and is stored on the embeddable's input. (panelConfig in this type).
type: string
required:
- panelConfig
- type
- gridData
- panelIndex
- additionalProperties: false
type: object
properties:
collapsed:
description: The collapsed state of the section.
type: boolean
gridData:
additionalProperties: false
type: object
properties:
i:
type: string
'y':
description: The y coordinate of the section in grid units
type: number
required:
- 'y'
- i
panels:
items:
additionalProperties: false
type: object
@ -8349,6 +8444,14 @@ paths:
- gridData
- panelIndex
type: array
title:
description: The title of the section.
type: string
required:
- title
- gridData
- panels
type: array
refreshInterval:
additionalProperties: false
description: A container for various refresh interval settings
@ -8743,6 +8846,103 @@ paths:
type: boolean
panels:
default: []
items:
anyOf:
- additionalProperties: false
type: object
properties:
gridData:
additionalProperties: false
type: object
properties:
h:
default: 15
description: The height of the panel in grid units
minimum: 1
type: number
i:
description: The unique identifier of the panel
type: string
w:
default: 24
description: The width of the panel in grid units
maximum: 48
minimum: 1
type: number
x:
description: The x coordinate of the panel in grid units
type: number
'y':
description: The y coordinate of the panel in grid units
type: number
required:
- x
- 'y'
id:
description: The saved object id for by reference panels
type: string
panelConfig:
additionalProperties: true
type: object
properties:
description:
description: The description of the panel
type: string
enhancements:
additionalProperties: {}
type: object
hidePanelTitles:
description: Set to true to hide the panel title in its container.
type: boolean
savedObjectId:
description: The unique id of the library item to construct the embeddable.
type: string
title:
description: The title of the panel
type: string
version:
description: The version of the embeddable in the panel.
type: string
panelIndex:
description: The unique ID of the panel.
type: string
panelRefName:
type: string
title:
description: The title of the panel
type: string
type:
description: The embeddable type
type: string
version:
deprecated: true
description: The version was used to store Kibana version information from versions 7.3.0 -> 8.11.0. As of version 8.11.0, the versioning information is now per-embeddable-type and is stored on the embeddable's input. (panelConfig in this type).
type: string
required:
- panelConfig
- type
- gridData
- additionalProperties: false
type: object
properties:
collapsed:
description: The collapsed state of the section.
type: boolean
gridData:
additionalProperties: false
type: object
properties:
i:
description: The unique identifier of the section
type: string
'y':
description: The y coordinate of the section in grid units
type: number
required:
- 'y'
panels:
default: []
description: The panels that belong to the section.
items:
additionalProperties: false
type: object
@ -8819,6 +9019,13 @@ paths:
- type
- gridData
type: array
title:
description: The title of the section.
type: string
required:
- title
- gridData
type: array
refreshInterval:
additionalProperties: false
description: A container for various refresh interval settings
@ -9136,6 +9343,101 @@ paths:
type: boolean
panels:
default: []
items:
anyOf:
- additionalProperties: false
type: object
properties:
gridData:
additionalProperties: false
type: object
properties:
h:
default: 15
description: The height of the panel in grid units
minimum: 1
type: number
i:
type: string
w:
default: 24
description: The width of the panel in grid units
maximum: 48
minimum: 1
type: number
x:
description: The x coordinate of the panel in grid units
type: number
'y':
description: The y coordinate of the panel in grid units
type: number
required:
- x
- 'y'
- i
id:
description: The saved object id for by reference panels
type: string
panelConfig:
additionalProperties: true
type: object
properties:
description:
description: The description of the panel
type: string
enhancements:
additionalProperties: {}
type: object
hidePanelTitles:
description: Set to true to hide the panel title in its container.
type: boolean
savedObjectId:
description: The unique id of the library item to construct the embeddable.
type: string
title:
description: The title of the panel
type: string
version:
description: The version of the embeddable in the panel.
type: string
panelIndex:
type: string
panelRefName:
type: string
title:
description: The title of the panel
type: string
type:
description: The embeddable type
type: string
version:
deprecated: true
description: The version was used to store Kibana version information from versions 7.3.0 -> 8.11.0. As of version 8.11.0, the versioning information is now per-embeddable-type and is stored on the embeddable's input. (panelConfig in this type).
type: string
required:
- panelConfig
- type
- gridData
- panelIndex
- additionalProperties: false
type: object
properties:
collapsed:
description: The collapsed state of the section.
type: boolean
gridData:
additionalProperties: false
type: object
properties:
i:
type: string
'y':
description: The y coordinate of the section in grid units
type: number
required:
- 'y'
- i
panels:
items:
additionalProperties: false
type: object
@ -9212,6 +9514,14 @@ paths:
- gridData
- panelIndex
type: array
title:
description: The title of the section.
type: string
required:
- title
- gridData
- panels
type: array
refreshInterval:
additionalProperties: false
description: A container for various refresh interval settings
@ -9586,6 +9896,103 @@ paths:
type: boolean
panels:
default: []
items:
anyOf:
- additionalProperties: false
type: object
properties:
gridData:
additionalProperties: false
type: object
properties:
h:
default: 15
description: The height of the panel in grid units
minimum: 1
type: number
i:
description: The unique identifier of the panel
type: string
w:
default: 24
description: The width of the panel in grid units
maximum: 48
minimum: 1
type: number
x:
description: The x coordinate of the panel in grid units
type: number
'y':
description: The y coordinate of the panel in grid units
type: number
required:
- x
- 'y'
id:
description: The saved object id for by reference panels
type: string
panelConfig:
additionalProperties: true
type: object
properties:
description:
description: The description of the panel
type: string
enhancements:
additionalProperties: {}
type: object
hidePanelTitles:
description: Set to true to hide the panel title in its container.
type: boolean
savedObjectId:
description: The unique id of the library item to construct the embeddable.
type: string
title:
description: The title of the panel
type: string
version:
description: The version of the embeddable in the panel.
type: string
panelIndex:
description: The unique ID of the panel.
type: string
panelRefName:
type: string
title:
description: The title of the panel
type: string
type:
description: The embeddable type
type: string
version:
deprecated: true
description: The version was used to store Kibana version information from versions 7.3.0 -> 8.11.0. As of version 8.11.0, the versioning information is now per-embeddable-type and is stored on the embeddable's input. (panelConfig in this type).
type: string
required:
- panelConfig
- type
- gridData
- additionalProperties: false
type: object
properties:
collapsed:
description: The collapsed state of the section.
type: boolean
gridData:
additionalProperties: false
type: object
properties:
i:
description: The unique identifier of the section
type: string
'y':
description: The y coordinate of the section in grid units
type: number
required:
- 'y'
panels:
default: []
description: The panels that belong to the section.
items:
additionalProperties: false
type: object
@ -9662,6 +10069,13 @@ paths:
- type
- gridData
type: array
title:
description: The title of the section.
type: string
required:
- title
- gridData
type: array
refreshInterval:
additionalProperties: false
description: A container for various refresh interval settings
@ -9975,6 +10389,101 @@ paths:
type: boolean
panels:
default: []
items:
anyOf:
- additionalProperties: false
type: object
properties:
gridData:
additionalProperties: false
type: object
properties:
h:
default: 15
description: The height of the panel in grid units
minimum: 1
type: number
i:
type: string
w:
default: 24
description: The width of the panel in grid units
maximum: 48
minimum: 1
type: number
x:
description: The x coordinate of the panel in grid units
type: number
'y':
description: The y coordinate of the panel in grid units
type: number
required:
- x
- 'y'
- i
id:
description: The saved object id for by reference panels
type: string
panelConfig:
additionalProperties: true
type: object
properties:
description:
description: The description of the panel
type: string
enhancements:
additionalProperties: {}
type: object
hidePanelTitles:
description: Set to true to hide the panel title in its container.
type: boolean
savedObjectId:
description: The unique id of the library item to construct the embeddable.
type: string
title:
description: The title of the panel
type: string
version:
description: The version of the embeddable in the panel.
type: string
panelIndex:
type: string
panelRefName:
type: string
title:
description: The title of the panel
type: string
type:
description: The embeddable type
type: string
version:
deprecated: true
description: The version was used to store Kibana version information from versions 7.3.0 -> 8.11.0. As of version 8.11.0, the versioning information is now per-embeddable-type and is stored on the embeddable's input. (panelConfig in this type).
type: string
required:
- panelConfig
- type
- gridData
- panelIndex
- additionalProperties: false
type: object
properties:
collapsed:
description: The collapsed state of the section.
type: boolean
gridData:
additionalProperties: false
type: object
properties:
i:
type: string
'y':
description: The y coordinate of the section in grid units
type: number
required:
- 'y'
- i
panels:
items:
additionalProperties: false
type: object
@ -10051,6 +10560,14 @@ paths:
- gridData
- panelIndex
type: array
title:
description: The title of the section.
type: string
required:
- title
- gridData
- panels
type: array
refreshInterval:
additionalProperties: false
description: A container for various refresh interval settings

View file

@ -285,6 +285,7 @@
"refreshInterval.pause",
"refreshInterval.section",
"refreshInterval.value",
"sections",
"timeFrom",
"timeRestore",
"timeTo",

View file

@ -982,6 +982,10 @@
}
}
},
"sections": {
"dynamic": false,
"properties": {}
},
"timeFrom": {
"doc_values": false,
"index": false,

View file

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

View file

@ -38,6 +38,11 @@ const renderGridLayout = (propsOverrides: Partial<GridLayoutProps> = {}) => {
const { rerender, ...rtlRest } = render(<GridLayout {...props} />, { wrapper: EuiThemeProvider });
const gridLayout = screen.getByTestId('kbnGridLayout');
jest.spyOn(gridLayout, 'getBoundingClientRect').mockImplementation(() => {
return { top: 0, bottom: 500 } as DOMRect;
});
return {
...rtlRest,
rerender: (overrides: Partial<GridLayoutProps>) => {

View file

@ -198,6 +198,7 @@ export const GridLayout = ({
<GridLayoutContext.Provider value={memoizedContext}>
<GridHeightSmoother>
<div
data-test-subj="kbnGridLayout"
ref={(divElement) => {
layoutRef.current = divElement;
setDimensionsRef(divElement);

View file

@ -35,6 +35,7 @@ export const DeleteGridSectionModal = ({
return (
<EuiModal
data-test-subj={`kbnGridLayoutDeleteSectionModal-${sectionId}`}
onClose={() => {
setDeleteModalVisible(false);
}}

View file

@ -174,6 +174,10 @@ export const GridSectionHeader = React.memo(({ sectionId }: GridSectionHeaderPro
section.isCollapsed = !section.isCollapsed;
gridLayoutStateManager.gridLayout$.next(newLayout);
const buttonRef = collapseButtonRef.current;
if (!buttonRef) return;
buttonRef.setAttribute('aria-expanded', `${!section.isCollapsed}`);
}, [gridLayoutStateManager, sectionId]);
return (

View file

@ -110,6 +110,7 @@ export const GridSectionTitle = React.memo(
size="m"
id={`kbnGridSectionTitle-${sectionId}`}
aria-controls={`kbnGridSection-${sectionId}`}
aria-expanded={!currentSection?.isCollapsed}
data-test-subj={`kbnGridSectionTitle-${sectionId}`}
textProps={false}
className={'kbnGridSectionTitle--button'}

View file

@ -86,6 +86,7 @@ export const GridSectionWrapper = React.memo(({ sectionId }: GridSectionProps) =
gridLayoutStateManager.sectionRefs.current[sectionId] = rowRef;
}}
className={'kbnGridSection'}
data-test-subj={`kbnGridSectionWrapper-${sectionId}`}
/>
);
});

View file

@ -88,9 +88,17 @@ export const moveAction = (
let previousSection;
let targetSectionId: string | undefined = (() => {
if (isResize) return lastSectionId;
// early return - target the first "main" section if the panel is dragged above the layout element
if (previewRect.top < (gridLayoutElement?.getBoundingClientRect().top ?? 0)) {
const layoutRect = gridLayoutElement?.getBoundingClientRect();
// early returns for edge cases
if (previewRect.top < (layoutRect?.top ?? 0)) {
// target the first "main" section if the panel is dragged above the layout element
return `main-0`;
} else if (previewRect.top > (layoutRect?.bottom ?? Infinity)) {
// target the last "main" section if the panel is dragged below the layout element
const sections = Object.values(currentLayout);
const maxOrder = sections.length - 1;
previousSection = sections.filter(({ order }) => order === maxOrder)[0].id;
return `main-${maxOrder}`;
}
const previewBottom = previewRect.top + rowHeight;

View file

@ -62,6 +62,7 @@ export const isLayoutEqual = (a: GridLayoutData, b: GridLayoutData) => {
for (const key of keys) {
const widgetA = a[key];
const widgetB = b[key];
if (!widgetA || !widgetB) return widgetA === widgetB;
if (widgetA.type === 'panel' && widgetB.type === 'panel') {
isEqual = isGridDataEqual(widgetA, widgetB);

View file

@ -17,6 +17,7 @@ export {
type CanDuplicatePanels,
type CanExpandPanels,
} from './interfaces/panel_management';
export { type CanAddNewSection, apiCanAddNewSection } from './interfaces/can_add_new_section';
export {
canTrackContentfulRender,
type TrackContentfulRender,

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 interface CanAddNewSection {
addNewSection: () => void;
}
export const apiCanAddNewSection = (api: unknown): api is CanAddNewSection => {
return typeof (api as CanAddNewSection)?.addNewSection === 'function';
};

View file

@ -7,15 +7,14 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { combineLatest, debounceTime, distinctUntilChanged, map, of, switchMap } from 'rxjs';
import deepEqual from 'fast-deep-equal';
import {
apiHasUniqueId,
apiPublishesUnsavedChanges,
HasUniqueId,
PublishesUnsavedChanges,
PublishingSubject,
apiHasUniqueId,
apiPublishesUnsavedChanges,
} from '@kbn/presentation-publishing';
import { combineLatest, debounceTime, map, of, switchMap } from 'rxjs';
export const DEBOUNCE_TIME = 100;
@ -27,8 +26,6 @@ export function childrenUnsavedChanges$<Api extends unknown = unknown>(
) {
return children$.pipe(
map((children) => Object.keys(children)),
distinctUntilChanged(deepEqual),
// children may change, so make sure we subscribe/unsubscribe with switchMap
switchMap((newChildIds: string[]) => {
if (newChildIds.length === 0) return of([]);

View file

@ -7,5 +7,11 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export type { GridData, DashboardItem, SavedDashboardPanel } from '../v1/types'; // no changes made to types from v1 to v2
export type { ControlGroupAttributes, DashboardCrudTypes, DashboardAttributes } from './types';
export type { DashboardItem } from '../v1/types'; // no changes made to types from v1 to v2
export type {
ControlGroupAttributes,
DashboardCrudTypes,
DashboardAttributes,
GridData,
SavedDashboardPanel,
} from './types';

View file

@ -16,7 +16,18 @@ import { DashboardContentType } from '../types';
import {
ControlGroupAttributesV1,
DashboardAttributes as DashboardAttributesV1,
GridData as GridDataV1,
SavedDashboardPanel as SavedDashboardPanelV1,
} from '../v1/types';
import { DashboardSectionState } from '../..';
export type GridData = GridDataV1 & {
sectionId?: string;
};
export type SavedDashboardPanel = Omit<SavedDashboardPanelV1, 'gridData'> & {
gridData: GridData;
};
export type ControlGroupAttributes = ControlGroupAttributesV1 & {
showApplySelections?: boolean;
@ -24,6 +35,7 @@ export type ControlGroupAttributes = ControlGroupAttributesV1 & {
export type DashboardAttributes = Omit<DashboardAttributesV1, 'controlGroupInput'> & {
controlGroupInput?: ControlGroupAttributes;
sections?: DashboardSectionState[];
};
export type DashboardCrudTypes = ContentManagementCrudTypes<

View file

@ -26,6 +26,7 @@ const dashboardWithExtractedPanel: ParsedDashboardAttributesWithType = {
},
},
},
sections: {},
};
const extractedSavedObjectPanelRef = {
@ -47,6 +48,7 @@ const unextractedDashboardState: ParsedDashboardAttributesWithType = {
},
},
},
sections: {},
};
describe('inject/extract by reference panel', () => {
@ -85,6 +87,7 @@ const dashboardWithExtractedByValuePanel: ParsedDashboardAttributesWithType = {
},
},
},
sections: {},
};
const extractedByValueRef = {
@ -106,6 +109,7 @@ const unextractedDashboardByValueState: ParsedDashboardAttributesWithType = {
},
},
},
sections: {},
};
describe('inject/extract by value panels', () => {

View file

@ -11,6 +11,17 @@ import type { Reference } from '@kbn/content-management-utils';
import type { GridData } from '../../server/content_management';
export interface DashboardSectionMap {
[id: string]: DashboardSectionState;
}
export interface DashboardSectionState {
title: string;
collapsed?: boolean; // if undefined, then collapsed is false
readonly gridData: Pick<GridData, 'i' | 'y'>;
id: string;
}
export interface DashboardPanelMap {
[key: string]: DashboardPanelState;
}
@ -18,7 +29,7 @@ export interface DashboardPanelMap {
export interface DashboardPanelState<PanelState = object> {
type: string;
explicitInput: PanelState;
readonly gridData: GridData;
readonly gridData: GridData & { sectionId?: string };
panelRefName?: string;
/**

View file

@ -11,8 +11,8 @@ import type { Reference } from '@kbn/content-management-utils';
import { EmbeddablePersistableStateService } from '@kbn/embeddable-plugin/common/types';
import {
convertPanelMapToPanelsArray,
convertPanelsArrayToPanelMap,
convertPanelSectionMapsToPanelsArray,
convertPanelsArrayToPanelSectionMaps,
} from '../../lib/dashboard_panel_converters';
import { DashboardAttributesAndReferences, ParsedDashboardAttributesWithType } from '../../types';
import type { DashboardAttributes } from '../../../server/content_management';
@ -28,9 +28,11 @@ export interface InjectExtractDeps {
function parseDashboardAttributesWithType({
panels,
}: DashboardAttributes): ParsedDashboardAttributesWithType {
const { panels: panelsMap, sections } = convertPanelsArrayToPanelSectionMaps(panels); // drop sections
return {
type: 'dashboard',
panels: convertPanelsArrayToPanelMap(panels),
panels: panelsMap,
sections,
} as ParsedDashboardAttributesWithType;
}
@ -43,7 +45,10 @@ export function injectReferences(
// inject references back into panels via the Embeddable persistable state service.
const inject = createInject(deps.embeddablePersistableStateService);
const injectedState = inject(parsedAttributes, references) as ParsedDashboardAttributesWithType;
const injectedPanels = convertPanelMapToPanelsArray(injectedState.panels);
const injectedPanels = convertPanelSectionMapsToPanelsArray(
injectedState.panels,
parsedAttributes.sections
); // sections don't have references
const newAttributes = {
...attributes,
@ -58,7 +63,6 @@ export function extractReferences(
deps: InjectExtractDeps
): DashboardAttributesAndReferences {
const parsedAttributes = parseDashboardAttributesWithType(attributes);
const panels = parsedAttributes.panels;
const panelMissingType = Object.entries(panels).find(
@ -73,8 +77,10 @@ export function extractReferences(
references: Reference[];
state: ParsedDashboardAttributesWithType;
};
const extractedPanels = convertPanelMapToPanelsArray(extractedState.panels);
const extractedPanels = convertPanelSectionMapsToPanelsArray(
extractedState.panels,
parsedAttributes.sections
); // sections don't have references
const newAttributes = {
...attributes,
panels: extractedPanels,

View file

@ -14,6 +14,12 @@ export type {
DashboardState,
} from './types';
export type { DashboardPanelMap, DashboardPanelState } from './dashboard_container/types';
export type {
DashboardPanelMap,
DashboardPanelState,
DashboardSectionMap,
DashboardSectionState,
} from './dashboard_container/types';
export { type InjectExtractDeps } from './dashboard_saved_object/persistable_state/dashboard_saved_object_references';
export { isDashboardSection } from './lib/dashboard_panel_converters';

View file

@ -7,22 +7,69 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { v4 } from 'uuid';
import { omit } from 'lodash';
import { v4 } from 'uuid';
import type { Reference } from '@kbn/content-management-utils';
import type { DashboardPanelMap } from '..';
import type { DashboardPanel } from '../../server/content_management';
import type { DashboardPanelMap, DashboardSectionMap } from '..';
import type {
DashboardAttributes,
DashboardPanel,
DashboardSection,
} from '../../server/content_management';
import {
getReferencesForPanelId,
prefixReferencesFromPanel,
} from '../dashboard_container/persistable_state/dashboard_container_references';
export const convertPanelsArrayToPanelMap = (panels?: DashboardPanel[]): DashboardPanelMap => {
export const isDashboardSection = (
widget: DashboardAttributes['panels'][number]
): widget is DashboardSection => {
return 'panels' in widget;
};
export const convertPanelsArrayToPanelSectionMaps = (
panels?: DashboardAttributes['panels']
): { panels: DashboardPanelMap; sections: DashboardSectionMap } => {
const panelsMap: DashboardPanelMap = {};
panels?.forEach((panel, idx) => {
panelsMap![panel.panelIndex ?? String(idx)] = {
const sectionsMap: DashboardSectionMap = {};
/**
* panels and sections are mixed in the DashboardAttributes 'panels' key, so we need
* to separate them out into separate maps for the dashboard client side code
*/
panels?.forEach((widget, i) => {
if (isDashboardSection(widget)) {
const sectionId = widget.gridData.i ?? String(i);
const { panels: sectionPanels, ...restOfSection } = widget;
sectionsMap[sectionId] = {
...restOfSection,
gridData: {
...widget.gridData,
i: sectionId,
},
id: sectionId,
};
(sectionPanels as DashboardPanel[]).forEach((panel, j) => {
const panelId = panel.panelIndex ?? String(j);
const transformed = transformPanel(panel);
panelsMap[panelId] = {
...transformed,
gridData: { ...transformed.gridData, sectionId, i: panelId },
};
});
} else {
// if not a section, then this widget is a panel
panelsMap[widget.panelIndex ?? String(i)] = transformPanel(widget);
}
});
return { panels: panelsMap, sections: sectionsMap };
};
const transformPanel = (panel: DashboardPanel): DashboardPanelMap[string] => {
return {
type: panel.type,
gridData: panel.gridData,
panelRefName: panel.panelRefName,
@ -33,18 +80,24 @@ export const convertPanelsArrayToPanelMap = (panels?: DashboardPanel[]): Dashboa
},
version: panel.version,
};
});
return panelsMap;
};
export const convertPanelMapToPanelsArray = (
export const convertPanelSectionMapsToPanelsArray = (
panels: DashboardPanelMap,
sections: DashboardSectionMap,
removeLegacyVersion?: boolean
) => {
return Object.entries(panels).map(([panelId, panelState]) => {
): DashboardAttributes['panels'] => {
const combined: DashboardAttributes['panels'] = [];
const panelsInSections: { [sectionId: string]: DashboardSection } = {};
Object.entries(sections).forEach(([sectionId, sectionState]) => {
panelsInSections[sectionId] = { ...omit(sectionState, 'id'), panels: [] };
});
Object.entries(panels).forEach(([panelId, panelState]) => {
const savedObjectId = (panelState.explicitInput as { savedObjectId?: string }).savedObjectId;
const title = (panelState.explicitInput as { title?: string }).title;
return {
const { sectionId, ...gridData } = panelState.gridData; // drop section ID
const convertedPanelState = {
/**
* Version information used to be stored in the panel until 8.11 when it was moved to live inside the
* explicit Embeddable Input. If removeLegacyVersion is not passed, we'd like to keep this information for
@ -53,14 +106,22 @@ export const convertPanelMapToPanelsArray = (
...(!removeLegacyVersion ? { version: panelState.version } : {}),
type: panelState.type,
gridData: panelState.gridData,
gridData,
panelIndex: panelId,
panelConfig: omit(panelState.explicitInput, ['id', 'savedObjectId', 'title']),
...(title !== undefined && { title }),
...(savedObjectId !== undefined && { id: savedObjectId }),
...(panelState.panelRefName !== undefined && { panelRefName: panelState.panelRefName }),
};
if (sectionId) {
panelsInSections[sectionId].panels.push(convertedPanelState);
} else {
combined.push(convertedPanelState);
}
});
return [...combined, ...Object.values(panelsInSections)];
};
/**

View file

@ -11,7 +11,7 @@ import type { ScopedHistory } from '@kbn/core-application-browser';
import { ForwardedDashboardState } from './locator';
import type { DashboardState } from '../types';
import { convertPanelsArrayToPanelMap } from '../lib/dashboard_panel_converters';
import { convertPanelsArrayToPanelSectionMaps } from '../lib/dashboard_panel_converters';
export const loadDashboardHistoryLocationState = (
getScopedHistory: () => ScopedHistory
@ -29,6 +29,6 @@ export const loadDashboardHistoryLocationState = (
return {
...restOfState,
...{ panels: convertPanelsArrayToPanelMap(panels) },
...convertPanelsArrayToPanelSectionMaps(panels),
};
};

View file

@ -17,11 +17,12 @@ import type {
ControlGroupSerializedState,
} from '@kbn/controls-plugin/common';
import type { DashboardPanelMap } from './dashboard_container/types';
import type { DashboardPanelMap, DashboardSectionMap } from './dashboard_container/types';
import type {
DashboardAttributes,
DashboardOptions,
DashboardPanel,
DashboardSection,
} from '../server/content_management';
export interface DashboardCapabilities {
@ -37,6 +38,7 @@ export interface DashboardCapabilities {
export interface ParsedDashboardAttributesWithType {
id: string;
panels: DashboardPanelMap;
sections: DashboardSectionMap;
type: 'dashboard';
}
@ -59,6 +61,7 @@ export interface DashboardState extends DashboardSettings {
refreshInterval?: RefreshInterval;
viewMode: ViewMode;
panels: DashboardPanelMap;
sections: DashboardSectionMap;
/**
* Temporary. Currently Dashboards are in charge of providing references to all of their children.
@ -78,7 +81,7 @@ export interface DashboardState extends DashboardSettings {
* Do not change type without considering BWC of stored URLs
*/
export type SharedDashboardState = Partial<
Omit<DashboardState, 'panels'> & {
Omit<DashboardState, 'panels' | 'sections'> & {
controlGroupInput?: DashboardState['controlGroupInput'] & SerializableRecord;
/**
@ -87,7 +90,7 @@ export type SharedDashboardState = Partial<
*/
controlGroupState?: Partial<ControlGroupRuntimeState> & SerializableRecord;
panels: DashboardPanel[];
panels: Array<DashboardPanel | DashboardSection>;
references?: DashboardState['references'] & SerializableRecord;
}

View file

@ -0,0 +1,47 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { ADD_PANEL_ANNOTATION_GROUP } from '@kbn/embeddable-plugin/public';
import { apiCanAddNewSection, CanAddNewSection } from '@kbn/presentation-containers';
import { EmbeddableApiContext } from '@kbn/presentation-publishing';
import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
import { ACTION_ADD_SECTION } from './constants';
type AddSectionActionApi = CanAddNewSection;
const isApiCompatible = (api: unknown | null): api is AddSectionActionApi =>
Boolean(apiCanAddNewSection(api));
export class AddSectionAction implements Action<EmbeddableApiContext> {
public readonly type = ACTION_ADD_SECTION;
public readonly id = ACTION_ADD_SECTION;
public order = 40;
public grouping = [ADD_PANEL_ANNOTATION_GROUP];
public getDisplayName() {
return i18n.translate('dashboard.collapsibleSection.displayName', {
defaultMessage: 'Collapsible section',
});
}
public getIconType() {
return 'section';
}
public async isCompatible({ embeddable }: EmbeddableApiContext) {
return isApiCompatible(embeddable);
}
public async execute({ embeddable }: EmbeddableApiContext) {
if (!isApiCompatible(embeddable)) throw new IncompatibleActionError();
embeddable.addNewSection();
}
}

View file

@ -15,4 +15,5 @@ export const ACTION_COPY_TO_DASHBOARD = 'copyToDashboard';
export const ACTION_EXPAND_PANEL = 'togglePanel';
export const ACTION_EXPORT_CSV = 'ACTION_EXPORT_CSV';
export const ACTION_UNLINK_FROM_LIBRARY = 'unlinkFromLibrary';
export const ACTION_ADD_SECTION = 'addCollapsibleSection';
export const BADGE_FILTERS_NOTIFICATION = 'ACTION_FILTERS_NOTIFICATION';

View file

@ -8,8 +8,10 @@
*/
import { CONTEXT_MENU_TRIGGER, PANEL_NOTIFICATION_TRIGGER } from '@kbn/embeddable-plugin/public';
import { ADD_PANEL_TRIGGER } from '@kbn/ui-actions-plugin/public';
import { DashboardStartDependencies } from '../plugin';
import {
ACTION_ADD_SECTION,
ACTION_ADD_TO_LIBRARY,
ACTION_CLONE_PANEL,
ACTION_COPY_TO_DASHBOARD,
@ -40,6 +42,12 @@ export const registerActions = async (plugins: DashboardStartDependencies) => {
});
uiActions.attachAction(PANEL_NOTIFICATION_TRIGGER, BADGE_FILTERS_NOTIFICATION);
uiActions.registerActionAsync(ACTION_ADD_SECTION, async () => {
const { AddSectionAction } = await import('../dashboard_renderer/dashboard_module');
return new AddSectionAction();
});
uiActions.attachAction(ADD_PANEL_TRIGGER, ACTION_ADD_SECTION);
if (share) {
uiActions.registerActionAsync(ACTION_EXPORT_CSV, async () => {
const { ExportCSVAction } = await import('../dashboard_renderer/dashboard_module');

View file

@ -0,0 +1,54 @@
/*
* 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 deepEqual from 'fast-deep-equal';
import { xor } from 'lodash';
import { DashboardLayout } from './types';
/**
* Checks whether the layouts have the same keys, and if they do, checks whether every layout item in the
* original layout is deep equal to the layout item at the same ID in the new layout
*/
export const areLayoutsEqual = (originalLayout?: DashboardLayout, newLayout?: DashboardLayout) => {
/**
* It is safe to assume that there are **usually** more panels than sections, so do cheaper section ID comparison first
*/
const newSectionUuids = Object.keys(newLayout?.sections ?? {});
const sectionIdDiff = xor(Object.keys(originalLayout?.sections ?? {}), newSectionUuids);
if (sectionIdDiff.length > 0) return false;
/**
* Since section IDs are equal, check for more expensive panel ID equality
*/
const newPanelUuids = Object.keys(newLayout?.panels ?? {});
const panelIdDiff = xor(Object.keys(originalLayout?.panels ?? {}), newPanelUuids);
if (panelIdDiff.length > 0) return false;
/**
* IDs of all widgets are equal, so now actually compare contents - this is the most expensive equality comparison step
*/
// again, start with section comparison since it is most likely cheaper
for (const sectionId of newSectionUuids) {
if (!deepEqual(originalLayout?.sections[sectionId], newLayout?.sections[sectionId])) {
return false;
}
}
// then compare panel grid data
for (const embeddableId of newPanelUuids) {
if (
!deepEqual(
originalLayout?.panels[embeddableId]?.gridData,
newLayout?.panels[embeddableId]?.gridData
)
) {
return false;
}
}
return true;
};

View file

@ -1,37 +0,0 @@
/*
* 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 { xor } from 'lodash';
import deepEqual from 'fast-deep-equal';
import { DashboardLayout } from './types';
/**
* Checks whether the panel maps have the same keys, and if they do, whether the grid data and types of each panel
* are equal.
*/
export const arePanelLayoutsEqual = (
originalPanels?: DashboardLayout,
newPanels?: DashboardLayout
) => {
const originalUuids = Object.keys(originalPanels ?? {});
const newUuids = Object.keys(newPanels ?? {});
const idDiff = xor(originalUuids, newUuids);
if (idDiff.length > 0) return false;
for (const embeddableId of newUuids) {
if (originalPanels?.[embeddableId]?.type !== newPanels?.[embeddableId]?.type) {
return false;
}
if (!deepEqual(originalPanels?.[embeddableId]?.gridData, newPanels?.[embeddableId]?.gridData)) {
return false;
}
}
return true;
};

View file

@ -16,6 +16,7 @@ export const DEFAULT_DASHBOARD_STATE: DashboardState = {
description: '',
filters: [],
panels: {},
sections: {},
title: '',
tags: [],

View file

@ -11,18 +11,23 @@ import type { Reference } from '@kbn/content-management-utils';
import { EmbeddablePackageState } from '@kbn/embeddable-plugin/public';
import { BehaviorSubject, debounceTime, merge } from 'rxjs';
import { v4 } from 'uuid';
import { DASHBOARD_APP_ID } from '../../common/constants';
import {
getReferencesForControls,
getReferencesForPanelId,
} from '../../common/dashboard_container/persistable_state/dashboard_container_references';
import { DASHBOARD_APP_ID } from '../../common/constants';
import type { DashboardState } from '../../common/types';
import { getDashboardContentManagementService } from '../services/dashboard_content_management_service';
import { LoadDashboardReturn } from '../services/dashboard_content_management_service/types';
import {
CONTROL_GROUP_EMBEDDABLE_ID,
initializeControlGroupManager,
} from './control_group_manager';
import { initializeDataLoadingManager } from './data_loading_manager';
import { initializeDataViewsManager } from './data_views_manager';
import { DEFAULT_DASHBOARD_STATE } from './default_dashboard_state';
import { getSerializedState } from './get_serialized_state';
import { initializePanelsManager } from './panels_manager';
import { initializeLayoutManager } from './layout_manager';
import { openSaveModal } from './save_modal/open_save_modal';
import { initializeSearchSessionManager } from './search_sessions/search_session_manager';
import { initializeSettingsManager } from './settings_manager';
@ -35,14 +40,9 @@ import {
DashboardCreationOptions,
DashboardInternalApi,
} from './types';
import type { DashboardState } from '../../common/types';
import { initializeUnifiedSearchManager } from './unified_search_manager';
import { initializeUnsavedChangesManager } from './unsaved_changes_manager';
import { initializeViewModeManager } from './view_mode_manager';
import {
CONTROL_GROUP_EMBEDDABLE_ID,
initializeControlGroupManager,
} from './control_group_manager';
export function getDashboardApi({
creationOptions,
@ -63,7 +63,7 @@ export function getDashboardApi({
const viewModeManager = initializeViewModeManager(incomingEmbeddable, savedObjectResult);
const trackPanel = initializeTrackPanel(async (id: string) => {
await panelsManager.api.getChildApi(id);
await layoutManager.api.getChildApi(id);
});
const references$ = new BehaviorSubject<Reference[] | undefined>(initialState.references);
@ -78,9 +78,10 @@ export function getDashboardApi({
return panelReferences.length > 0 ? panelReferences : references$.value ?? [];
};
const panelsManager = initializePanelsManager(
const layoutManager = initializeLayoutManager(
incomingEmbeddable,
initialState.panels,
initialState.sections,
trackPanel,
getReferences
);
@ -88,10 +89,10 @@ export function getDashboardApi({
initialState.controlGroupInput,
getReferences
);
const dataLoadingManager = initializeDataLoadingManager(panelsManager.api.children$);
const dataLoadingManager = initializeDataLoadingManager(layoutManager.api.children$);
const dataViewsManager = initializeDataViewsManager(
controlGroupManager.api.controlGroupApi$,
panelsManager.api.children$
layoutManager.api.children$
);
const settingsManager = initializeSettingsManager(initialState);
const unifiedSearchManager = initializeUnifiedSearchManager(
@ -107,7 +108,7 @@ export function getDashboardApi({
creationOptions,
controlGroupManager,
lastSavedState: savedObjectResult?.dashboardInput ?? DEFAULT_DASHBOARD_STATE,
panelsManager,
layoutManager,
savedObjectId$,
settingsManager,
unifiedSearchManager,
@ -115,13 +116,18 @@ export function getDashboardApi({
});
function getState() {
const { panels, references: panelReferences } = panelsManager.internalApi.serializePanels();
const {
panels,
sections,
references: panelReferences,
} = layoutManager.internalApi.serializeLayout();
const { state: unifiedSearchState, references: searchSourceReferences } =
unifiedSearchManager.internalApi.getState();
const dashboardState: DashboardState = {
...settingsManager.api.getSettings(),
...unifiedSearchState,
panels,
sections,
viewMode: viewModeManager.api.viewMode$.value,
};
@ -143,7 +149,7 @@ export function getDashboardApi({
...viewModeManager.api,
...dataLoadingManager.api,
...dataViewsManager.api,
...panelsManager.api,
...layoutManager.api,
...settingsManager.api,
...trackPanel,
...unifiedSearchManager.api,
@ -223,7 +229,7 @@ export function getDashboardApi({
getSerializedStateForChild: (childId: string) => {
return childId === CONTROL_GROUP_EMBEDDABLE_ID
? controlGroupManager.internalApi.getStateForControlGroup()
: panelsManager.internalApi.getSerializedStateForPanel(childId);
: layoutManager.internalApi.getSerializedStateForPanel(childId);
},
setSavedObjectId: (id: string | undefined) => savedObjectId$.next(id),
type: DASHBOARD_API_TYPE as 'dashboard',
@ -231,7 +237,7 @@ export function getDashboardApi({
} as Omit<DashboardApi, 'searchSessionId$'>;
const internalApi: DashboardInternalApi = {
...panelsManager.internalApi,
...layoutManager.internalApi,
...unifiedSearchManager.internalApi,
setControlGroupApi: controlGroupManager.internalApi.setControlGroupApi,
};

View file

@ -167,4 +167,70 @@ describe('getSerializedState', () => {
expect(result.references).toEqual(panelReferences);
});
it('should serialize sections', () => {
const dashboardState = {
...getSampleDashboardState(),
panels: {
oldPanelId: {
type: 'visualization',
gridData: { sectionId: 'section1' },
} as unknown as DashboardPanelState,
},
sections: {
section1: {
id: 'section1',
title: 'Section One',
collapsed: false,
gridData: { y: 1, i: 'section1' },
},
section2: {
id: 'section2',
title: 'Section Two',
collapsed: true,
gridData: { y: 2, i: 'section2' },
},
},
};
const result = getSerializedState({
controlGroupReferences: [],
generateNewIds: true,
dashboardState,
panelReferences: [],
searchSourceReferences: [],
});
expect(result.attributes.panels).toMatchInlineSnapshot(`
Array [
Object {
"collapsed": false,
"gridData": Object {
"i": "section1",
"y": 1,
},
"panels": Array [
Object {
"gridData": Object {
"i": "54321",
},
"panelConfig": Object {},
"panelIndex": "54321",
"type": "visualization",
"version": undefined,
},
],
"title": "Section One",
},
Object {
"collapsed": true,
"gridData": Object {
"i": "section2",
"y": 2,
},
"panels": Array [],
"title": "Section Two",
},
]
`);
});
});

View file

@ -7,27 +7,30 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { RefreshInterval } from '@kbn/data-plugin/public';
import { pick } from 'lodash';
import moment, { Moment } from 'moment';
import { RefreshInterval } from '@kbn/data-plugin/public';
import type { Reference } from '@kbn/content-management-utils';
import { extractReferences } from '../../common/dashboard_saved_object/persistable_state/dashboard_saved_object_references';
import {
convertPanelMapToPanelsArray,
convertPanelSectionMapsToPanelsArray,
generateNewPanelIds,
} from '../../common/lib/dashboard_panel_converters';
import { extractReferences } from '../../common/dashboard_saved_object/persistable_state/dashboard_saved_object_references';
import type { DashboardAttributes } from '../../server';
import { convertDashboardVersionToNumber } from '../services/dashboard_content_management_service/lib/dashboard_versioning';
import type { DashboardState } from '../../common';
import { LATEST_VERSION } from '../../common/content_management';
import {
convertDashboardVersionToNumber,
convertNumberToDashboardVersion,
} from '../services/dashboard_content_management_service/lib/dashboard_versioning';
import {
dataService,
embeddableService,
savedObjectsTaggingService,
} from '../services/kibana_services';
import type { DashboardState } from '../../common';
import { LATEST_VERSION } from '../../common/content_management';
import { convertNumberToDashboardVersion } from '../services/dashboard_content_management_service/lib/dashboard_versioning';
import { DashboardApi } from './types';
const LATEST_DASHBOARD_CONTAINER_VERSION = convertNumberToDashboardVersion(LATEST_VERSION);
@ -53,13 +56,12 @@ export const getSerializedState = ({
dashboardState: DashboardState;
panelReferences?: Reference[];
searchSourceReferences?: Reference[];
}) => {
}): ReturnType<DashboardApi['getSerializedState']> => {
const {
query: {
timefilter: { timefilter },
},
} = dataService;
const {
tags,
query,
@ -67,6 +69,7 @@ export const getSerializedState = ({
filters,
timeRestore,
description,
sections,
// Dashboard options
useMargins,
@ -100,7 +103,7 @@ export const getSerializedState = ({
syncTooltips,
hidePanelTitles,
};
const savedPanels = convertPanelMapToPanelsArray(panels, true);
const savedPanels = convertPanelSectionMapsToPanelsArray(panels, sections, true);
/**
* Parse global time filter settings

View file

@ -7,6 +7,10 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { filter, map as lodashMap, max } from 'lodash';
import { BehaviorSubject, Observable, combineLatestWith, debounceTime, map, merge } from 'rxjs';
import { v4 } from 'uuid';
import { METRIC_TYPE } from '@kbn/analytics';
import type { Reference } from '@kbn/content-management-utils';
import {
@ -14,12 +18,8 @@ import {
EmbeddablePackageState,
PanelNotFoundError,
} from '@kbn/embeddable-plugin/public';
import {
CanDuplicatePanels,
HasSerializedChildState,
PanelPackage,
PresentationContainer,
} from '@kbn/presentation-containers';
import { i18n } from '@kbn/i18n';
import { PanelPackage } from '@kbn/presentation-containers';
import {
SerializedPanelState,
SerializedTitles,
@ -32,10 +32,8 @@ import {
shouldLogStateDiff,
} from '@kbn/presentation-publishing';
import { asyncForEach } from '@kbn/std';
import { filter, map as lodashMap, max } from 'lodash';
import { BehaviorSubject, Observable, combineLatestWith, debounceTime, map, merge } from 'rxjs';
import { v4 } from 'uuid';
import type { DashboardState } from '../../common';
import type { DashboardSectionMap, DashboardState } from '../../common';
import { DashboardPanelMap } from '../../common';
import { DEFAULT_PANEL_HEIGHT, DEFAULT_PANEL_WIDTH } from '../../common/content_management';
import { prefixReferencesFromPanel } from '../../common/dashboard_container/persistable_state/dashboard_container_references';
@ -47,83 +45,84 @@ import { runPanelPlacementStrategy } from '../panel_placement/place_new_panel_st
import { PanelPlacementStrategy } from '../plugin_constants';
import { coreServices, usageCollectionService } from '../services/kibana_services';
import { DASHBOARD_UI_METRIC_ID } from '../utils/telemetry_constants';
import { arePanelLayoutsEqual } from './are_panel_layouts_equal';
import { areLayoutsEqual } from './are_layouts_equal';
import type { initializeTrackPanel } from './track_panel';
import {
DashboardApi,
DashboardChildState,
DashboardChildren,
DashboardLayout,
DashboardLayoutItem,
} from './types';
import { DashboardChildState, DashboardChildren, DashboardLayout, DashboardPanel } from './types';
export function initializePanelsManager(
export function initializeLayoutManager(
incomingEmbeddable: EmbeddablePackageState | undefined,
initialPanels: DashboardPanelMap, // SERIALIZED STATE ONLY TODO Remove the DashboardPanelMap layer. We could take the Saved Dashboard Panels array here directly.
initialSections: DashboardSectionMap,
trackPanel: ReturnType<typeof initializeTrackPanel>,
getReferences: (id: string) => Reference[]
): {
internalApi: {
startComparing$: (
lastSavedState$: BehaviorSubject<DashboardState>
) => Observable<{ panels?: DashboardPanelMap }>;
getSerializedStateForPanel: HasSerializedChildState['getSerializedStateForChild'];
layout$: BehaviorSubject<DashboardLayout>;
registerChildApi: (api: DefaultEmbeddableApi) => void;
resetPanels: (lastSavedPanels: DashboardPanelMap) => void;
setChildState: (uuid: string, state: SerializedPanelState<object>) => void;
serializePanels: () => { panels: DashboardPanelMap; references: Reference[] };
};
api: PresentationContainer<DefaultEmbeddableApi> &
CanDuplicatePanels & { getDashboardPanelFromId: DashboardApi['getDashboardPanelFromId'] };
} {
) {
// --------------------------------------------------------------------------------------
// Set up panel state manager
// --------------------------------------------------------------------------------------
const children$ = new BehaviorSubject<DashboardChildren>({});
const { layout: initialLayout, childState: initialChildState } = deserializePanels(initialPanels);
const { layout: initialLayout, childState: initialChildState } = deserializeLayout(
initialPanels,
initialSections
);
const layout$ = new BehaviorSubject<DashboardLayout>(initialLayout); // layout is the source of truth for which panels are in the dashboard.
let currentChildState = initialChildState; // childState is the source of truth for the state of each panel.
function deserializePanels(panelMap: DashboardPanelMap) {
const layout: DashboardLayout = {};
function deserializeLayout(panelMap: DashboardPanelMap, sectionMap: DashboardSectionMap) {
const layout: DashboardLayout = {
panels: {},
sections: {},
};
const childState: DashboardChildState = {};
Object.keys(panelMap).forEach((uuid) => {
const { gridData, explicitInput, type } = panelMap[uuid];
layout[uuid] = { type, gridData };
childState[uuid] = {
Object.keys(sectionMap).forEach((sectionId) => {
layout.sections[sectionId] = { collapsed: false, ...sectionMap[sectionId] };
});
Object.keys(panelMap).forEach((panelId) => {
const { gridData, explicitInput, type } = panelMap[panelId];
layout.panels[panelId] = { type, gridData } as DashboardPanel;
childState[panelId] = {
rawState: explicitInput,
references: getReferences(uuid),
references: getReferences(panelId),
};
});
return { layout, childState };
}
const serializePanels = (): { references: Reference[]; panels: DashboardPanelMap } => {
const serializeLayout = (): {
references: Reference[];
panels: DashboardPanelMap;
sections: DashboardSectionMap;
} => {
const references: Reference[] = [];
const layout = layout$.value;
const panels: DashboardPanelMap = {};
for (const uuid of Object.keys(layout$.value)) {
for (const panelId of Object.keys(layout.panels)) {
references.push(
...prefixReferencesFromPanel(uuid, currentChildState[uuid]?.references ?? [])
...prefixReferencesFromPanel(panelId, currentChildState[panelId]?.references ?? [])
);
panels[uuid] = {
...layout$.value[uuid],
explicitInput: currentChildState[uuid]?.rawState ?? {},
panels[panelId] = {
...layout.panels[panelId],
explicitInput: currentChildState[panelId]?.rawState ?? {},
};
}
return { panels, references };
return { panels, sections: { ...layout.sections }, references };
};
const resetPanels = (lastSavedPanels: DashboardPanelMap) => {
const { layout: lastSavedLayout, childState: lstSavedChildState } =
deserializePanels(lastSavedPanels);
const resetLayout = ({
panels: lastSavedPanels,
sections: lastSavedSections,
}: DashboardState) => {
const { layout: lastSavedLayout, childState: lastSavedChildState } = deserializeLayout(
lastSavedPanels,
lastSavedSections
);
layout$.next(lastSavedLayout);
currentChildState = lstSavedChildState;
currentChildState = lastSavedChildState;
let childrenModified = false;
const currentChildren = { ...children$.value };
for (const uuid of Object.keys(currentChildren)) {
if (lastSavedLayout[uuid]) {
if (lastSavedLayout.panels[uuid]) {
const child = currentChildren[uuid];
if (apiPublishesUnsavedChanges(child)) child.resetUnsavedChanges();
} else {
@ -145,7 +144,7 @@ export function initializePanelsManager(
{
width: size?.width ?? DEFAULT_PANEL_WIDTH,
height: size?.height ?? DEFAULT_PANEL_HEIGHT,
currentPanels: layout$.value,
currentPanels: layout$.value.panels,
}
);
return { ...newPanelPlacement, i: uuid };
@ -154,11 +153,17 @@ export function initializePanelsManager(
const placeNewPanel = async (
uuid: string,
panelPackage: PanelPackage,
gridData?: DashboardLayoutItem['gridData']
gridData?: DashboardPanel['gridData']
): Promise<DashboardLayout> => {
const { panelType: type, serializedState } = panelPackage;
if (gridData) {
return { ...layout$.value, [uuid]: { gridData: { ...gridData, i: uuid }, type } };
return {
...layout$.value,
panels: {
...layout$.value.panels,
[uuid]: { gridData: { ...gridData, i: uuid }, type } as DashboardPanel,
},
};
}
const getCustomPlacementSettingFunc = getDashboardPanelPlacementSetting(type);
const customPlacementSettings = getCustomPlacementSettingFunc
@ -167,12 +172,18 @@ export function initializePanelsManager(
const { newPanelPlacement, otherPanels } = runPanelPlacementStrategy(
customPlacementSettings?.strategy ?? PanelPlacementStrategy.findTopLeftMostOpenSpace,
{
currentPanels: layout$.value,
currentPanels: layout$.value.panels,
height: customPlacementSettings?.height ?? DEFAULT_PANEL_HEIGHT,
width: customPlacementSettings?.width ?? DEFAULT_PANEL_WIDTH,
}
);
return { ...otherPanels, [uuid]: { gridData: { ...newPanelPlacement, i: uuid }, type } };
return {
...layout$.value,
panels: {
...otherPanels,
[uuid]: { gridData: { ...newPanelPlacement, i: uuid }, type } as DashboardPanel,
},
};
};
// --------------------------------------------------------------------------------------
@ -181,7 +192,7 @@ export function initializePanelsManager(
if (incomingEmbeddable) {
const { serializedState, size, type } = incomingEmbeddable;
const uuid = incomingEmbeddable.embeddableId ?? v4();
const existingPanel: DashboardLayoutItem | undefined = layout$.value[uuid];
const existingPanel: DashboardPanel | undefined = layout$.value.panels[uuid];
const sameType = existingPanel?.type === type;
const gridData = existingPanel ? existingPanel.gridData : placeIncomingPanel(uuid, size);
@ -195,14 +206,17 @@ export function initializePanelsManager(
layout$.next({
...layout$.value,
[uuid]: { gridData, type },
panels: {
...layout$.value.panels,
[uuid]: { gridData, type } as DashboardPanel,
},
});
trackPanel.setScrollToPanelId(uuid);
trackPanel.setHighlightPanelId(uuid);
}
function getDashboardPanelFromId(panelId: string) {
const childLayout = layout$.value[panelId];
const childLayout = layout$.value.panels[panelId];
const childApi = children$.value[panelId];
if (!childApi || !childLayout) throw new PanelNotFoundError();
return {
@ -216,7 +230,7 @@ export function initializePanelsManager(
async function getPanelTitles(): Promise<string[]> {
const titles: string[] = [];
await asyncForEach(Object.keys(layout$.value), async (id) => {
await asyncForEach(Object.keys(layout$.value.panels), async (id) => {
const childApi = await getChildApi(id);
const title = apiPublishesTitle(childApi) ? getTitle(childApi) : '';
if (title) titles.push(title);
@ -230,7 +244,7 @@ export function initializePanelsManager(
const addNewPanel = async <ApiType>(
panelPackage: PanelPackage,
displaySuccessMessage?: boolean,
gridData?: DashboardLayoutItem['gridData']
gridData?: DashboardPanel['gridData']
) => {
const uuid = v4();
const { panelType: type, serializedState } = panelPackage;
@ -246,17 +260,17 @@ export function initializePanelsManager(
title: getPanelAddedSuccessString(title),
'data-test-subj': 'addEmbeddableToDashboardSuccess',
});
}
trackPanel.setScrollToPanelId(uuid);
trackPanel.setHighlightPanelId(uuid);
}
return (await getChildApi(uuid)) as ApiType;
};
const removePanel = (uuid: string) => {
const layout = { ...layout$.value };
if (layout[uuid]) {
delete layout[uuid];
layout$.next(layout);
const panels = { ...layout$.value.panels };
if (panels[uuid]) {
delete panels[uuid];
layout$.next({ ...layout$.value, panels });
}
const children = { ...children$.value };
if (children[uuid]) {
@ -269,7 +283,7 @@ export function initializePanelsManager(
};
const replacePanel = async (idToRemove: string, panelPackage: PanelPackage) => {
const existingGridData = layout$.value[idToRemove]?.gridData;
const existingGridData = layout$.value.panels[idToRemove]?.gridData;
if (!existingGridData) throw new PanelNotFoundError();
removePanel(idToRemove);
@ -278,7 +292,7 @@ export function initializePanelsManager(
};
const duplicatePanel = async (uuidToDuplicate: string) => {
const layoutItemToDuplicate = layout$.value[uuidToDuplicate];
const layoutItemToDuplicate = layout$.value.panels[uuidToDuplicate];
const apiToDuplicate = children$.value[uuidToDuplicate];
if (!apiToDuplicate || !layoutItemToDuplicate) throw new PanelNotFoundError();
@ -297,15 +311,23 @@ export function initializePanelsManager(
const { newPanelPlacement, otherPanels } = placeClonePanel({
width: layoutItemToDuplicate.gridData.w,
height: layoutItemToDuplicate.gridData.h,
currentPanels: layout$.value,
sectionId: layoutItemToDuplicate.gridData.sectionId,
currentPanels: layout$.value.panels,
placeBesideId: uuidToDuplicate,
});
layout$.next({
...layout$.value,
panels: {
...otherPanels,
[uuidOfDuplicate]: {
gridData: { ...newPanelPlacement, i: uuidOfDuplicate },
gridData: {
...newPanelPlacement,
i: uuidOfDuplicate,
sectionId: layoutItemToDuplicate.gridData.sectionId,
},
type: layoutItemToDuplicate.type,
},
},
});
coreServices.notifications.toasts.addSuccess({
@ -315,7 +337,7 @@ export function initializePanelsManager(
};
const getChildApi = async (uuid: string): Promise<DefaultEmbeddableApi | undefined> => {
if (!layout$.value[uuid]) throw new PanelNotFoundError();
if (!layout$.value.panels[uuid]) throw new PanelNotFoundError();
if (children$.value[uuid]) return children$.value[uuid];
return new Promise((resolve) => {
@ -326,7 +348,7 @@ export function initializePanelsManager(
}
// If we hit this, the panel was removed before the embeddable finished loading.
if (layout$.value[uuid] === undefined) {
if (layout$.value.panels[uuid] === undefined) {
subscription.unsubscribe();
resolve(undefined);
}
@ -338,25 +360,34 @@ export function initializePanelsManager(
internalApi: {
getSerializedStateForPanel: (uuid: string) => currentChildState[uuid],
layout$,
resetPanels,
serializePanels,
reset: resetLayout,
serializeLayout,
startComparing$: (
lastSavedState$: BehaviorSubject<DashboardState>
): Observable<{ panels?: DashboardPanelMap }> => {
): Observable<{ panels?: DashboardPanelMap; sections?: DashboardSectionMap }> => {
return layout$.pipe(
debounceTime(100),
combineLatestWith(lastSavedState$.pipe(map((lastSaved) => lastSaved.panels))),
map(([, lastSavedPanels]) => {
const panels = serializePanels().panels;
if (!arePanelLayoutsEqual(lastSavedPanels, panels)) {
combineLatestWith(
lastSavedState$.pipe(
map((lastSaved) => ({ panels: lastSaved.panels, sections: lastSaved.sections }))
)
),
map(([, { panels: lastSavedPanels, sections: lastSavedSections }]) => {
const { panels, sections } = serializeLayout();
if (
!areLayoutsEqual(
{ panels: lastSavedPanels, sections: lastSavedSections },
{ panels, sections }
)
) {
if (shouldLogStateDiff()) {
logStateDiff(
'dashboard layout',
deserializePanels(lastSavedPanels).layout,
deserializePanels(panels).layout
deserializeLayout(lastSavedPanels, lastSavedSections).layout,
deserializeLayout(panels, sections).layout
);
}
return { panels };
return { panels, sections };
}
return {};
})
@ -371,8 +402,13 @@ export function initializePanelsManager(
setChildState: (uuid: string, state: SerializedPanelState<object>) => {
currentChildState[uuid] = state;
},
isSectionCollapsed: (sectionId?: string): boolean => {
const { sections } = layout$.getValue();
return Boolean(sectionId && sections[sectionId].collapsed);
},
},
api: {
/** Panels */
children$,
getChildApi,
addNewPanel,
@ -380,8 +416,39 @@ export function initializePanelsManager(
replacePanel,
duplicatePanel,
getDashboardPanelFromId,
getPanelCount: () => Object.keys(layout$.value).length,
getPanelCount: () => Object.keys(layout$.value.panels).length,
canRemovePanels: () => trackPanel.expandedPanelId$.value === undefined,
/** Sections */
addNewSection: () => {
const currentLayout = layout$.getValue();
// find the max y so we know where to add the section
let maxY = 0;
[...Object.values(currentLayout.panels), ...Object.values(currentLayout.sections)].forEach(
(widget) => {
const { y, h } = { h: 1, ...widget.gridData };
maxY = Math.max(maxY, y + h);
}
);
// add the new section
const sections = { ...currentLayout.sections };
const newId = v4();
sections[newId] = {
id: newId,
gridData: { i: newId, y: maxY },
title: i18n.translate('dashboard.defaultSectionTitle', {
defaultMessage: 'New collapsible section',
}),
collapsed: false,
};
layout$.next({
...currentLayout,
sections,
});
trackPanel.scrollToBottom$.next();
},
},
};
}

View file

@ -7,13 +7,14 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { BehaviorSubject } from 'rxjs';
import { BehaviorSubject, Subject } from 'rxjs';
export function initializeTrackPanel(untilLoaded: (id: string) => Promise<undefined>) {
const expandedPanelId$ = new BehaviorSubject<string | undefined>(undefined);
const focusedPanelId$ = new BehaviorSubject<string | undefined>(undefined);
const highlightPanelId$ = new BehaviorSubject<string | undefined>(undefined);
const scrollToPanelId$ = new BehaviorSubject<string | undefined>(undefined);
const scrollToBottom$ = new Subject<void>();
let scrollPosition: number | undefined;
function setScrollToPanelId(id: string | undefined) {
@ -62,15 +63,19 @@ export function initializeTrackPanel(untilLoaded: (id: string) => Promise<undefi
untilLoaded(id).then(() => {
setScrollToPanelId(undefined);
if (scrollPosition !== undefined) {
window.scrollTo({ top: scrollPosition });
window.scrollTo({ top: scrollPosition, behavior: 'smooth' });
scrollPosition = undefined;
} else {
panelRef.scrollIntoView({ block: 'start' });
panelRef.scrollIntoView({ block: 'start', behavior: 'smooth' });
}
});
},
scrollToTop: () => {
window.scroll(0, 0);
window.scrollTo({ top: 0, behavior: 'smooth' });
},
scrollToBottom$,
scrollToBottom: () => {
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
},
setFocusedPanelId: (id: string | undefined) => {
if (focusedPanelId$.value !== id) focusedPanelId$.next(id);

View file

@ -15,6 +15,7 @@ import { Filter, Query, TimeRange } from '@kbn/es-query';
import { PublishesESQLVariables } from '@kbn/esql-types';
import { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
import {
CanAddNewSection,
CanExpandPanels,
HasLastSavedChildState,
HasSerializedChildState,
@ -47,6 +48,8 @@ import { BehaviorSubject, Observable, Subject } from 'rxjs';
import {
DashboardLocatorParams,
DashboardPanelMap,
DashboardPanelState,
DashboardSectionMap,
DashboardSettings,
DashboardState,
} from '../../common';
@ -58,9 +61,12 @@ import {
export const DASHBOARD_API_TYPE = 'dashboard';
export type DashboardLayoutItem = { gridData: GridData } & HasType;
export const ReservedLayoutItemTypes: readonly string[] = ['section'] as const;
export type DashboardPanel = Pick<DashboardPanelState, 'gridData'> & HasType;
export interface DashboardLayout {
[uuid: string]: DashboardLayoutItem;
panels: { [uuid: string]: DashboardPanel }; // partial of DashboardPanelState
sections: DashboardSectionMap;
}
export interface DashboardChildState {
@ -102,6 +108,7 @@ export interface DashboardCreationOptions {
}
export type DashboardApi = CanExpandPanels &
CanAddNewSection &
HasAppContext &
HasExecutionContext &
HasLastSavedChildState &
@ -151,6 +158,8 @@ export type DashboardApi = CanExpandPanels &
scrollToPanel: (panelRef: HTMLDivElement) => void;
scrollToPanelId$: PublishingSubject<string | undefined>;
scrollToTop: () => void;
scrollToBottom: () => void;
scrollToBottom$: Subject<void>;
setFilters: (filters?: Filter[] | undefined) => void;
setFullScreenMode: (fullScreenMode: boolean) => void;
setHighlightPanelId: (id: string | undefined) => void;
@ -168,5 +177,10 @@ export interface DashboardInternalApi {
layout$: BehaviorSubject<DashboardLayout>;
registerChildApi: (api: DefaultEmbeddableApi) => void;
setControlGroupApi: (controlGroupApi: ControlGroupApi) => void;
serializePanels: () => { references: Reference[]; panels: DashboardPanelMap };
serializeLayout: () => {
references: Reference[];
panels: DashboardPanelMap;
sections: DashboardSectionMap;
};
isSectionCollapsed: (sectionId?: string) => boolean;
}

View file

@ -26,7 +26,7 @@ import {
tap,
} from 'rxjs';
import { getDashboardBackupService } from '../services/dashboard_backup_service';
import { initializePanelsManager } from './panels_manager';
import { initializeLayoutManager } from './layout_manager';
import { initializeSettingsManager } from './settings_manager';
import { DashboardCreationOptions } from './types';
import { DashboardState } from '../../common';
@ -40,7 +40,7 @@ import {
const DEBOUNCE_TIME = 100;
export function initializeUnsavedChangesManager({
panelsManager,
layoutManager,
savedObjectId$,
lastSavedState,
settingsManager,
@ -55,7 +55,7 @@ export function initializeUnsavedChangesManager({
getReferences: (id: string) => Reference[];
savedObjectId$: PublishesSavedObjectId['savedObjectId$'];
controlGroupManager: ReturnType<typeof initializeControlGroupManager>;
panelsManager: ReturnType<typeof initializePanelsManager>;
layoutManager: ReturnType<typeof initializeLayoutManager>;
viewModeManager: ReturnType<typeof initializeViewModeManager>;
settingsManager: ReturnType<typeof initializeSettingsManager>;
unifiedSearchManager: ReturnType<typeof initializeUnifiedSearchManager>;
@ -75,14 +75,14 @@ export function initializeUnsavedChangesManager({
// references injected while loading dashboard saved object in loadDashboardState
const lastSavedState$ = new BehaviorSubject<DashboardState>(lastSavedState);
const hasPanelChanges$ = childrenUnsavedChanges$(panelsManager.api.children$).pipe(
const hasPanelChanges$ = childrenUnsavedChanges$(layoutManager.api.children$).pipe(
tap((childrenWithChanges) => {
// propagate the latest serialized state back to the panels manager.
// propagate the latest serialized state back to the layout manager.
for (const { uuid, hasUnsavedChanges } of childrenWithChanges) {
const childApi = panelsManager.api.children$.value[uuid];
const childApi = layoutManager.api.children$.value[uuid];
if (!hasUnsavedChanges || !childApi || !apiHasSerializableState(childApi)) continue;
panelsManager.internalApi.setChildState(uuid, childApi.serializeState());
layoutManager.internalApi.setChildState(uuid, childApi.serializeState());
}
}),
map((childrenWithChanges) => {
@ -93,7 +93,7 @@ export function initializeUnsavedChangesManager({
const dashboardStateChanges$: Observable<Partial<DashboardState>> = combineLatest([
settingsManager.internalApi.startComparing$(lastSavedState$),
unifiedSearchManager.internalApi.startComparing$(lastSavedState$),
panelsManager.internalApi.startComparing$(lastSavedState$),
layoutManager.internalApi.startComparing$(lastSavedState$),
]).pipe(
map(([settings, unifiedSearch, panels]) => {
return { ...settings, ...unifiedSearch, ...panels };
@ -129,10 +129,9 @@ export function initializeUnsavedChangesManager({
// always back up view mode. This allows us to know which Dashboards were last changed while in edit mode.
dashboardStateToBackup.viewMode = viewMode;
// Backup latest state from children that have unsaved changes
if (hasPanelChanges || hasControlGroupChanges) {
const { panels, references } = panelsManager.internalApi.serializePanels();
const { panels, references } = layoutManager.internalApi.serializeLayout();
const { controlGroupInput, controlGroupReferences } =
controlGroupManager.internalApi.serializeControlGroup();
// dashboardStateToBackup.references will be used instead of savedObjectResult.references
@ -169,9 +168,10 @@ export function initializeUnsavedChangesManager({
return {
api: {
asyncResetToLastSavedState: async () => {
panelsManager.internalApi.resetPanels(lastSavedState$.value.panels);
unifiedSearchManager.internalApi.reset(lastSavedState$.value);
settingsManager.internalApi.reset(lastSavedState$.value);
const savedState = lastSavedState$.value;
layoutManager.internalApi.reset(savedState);
unifiedSearchManager.internalApi.reset(savedState);
settingsManager.internalApi.reset(savedState);
await controlGroupManager.api.controlGroupApi$.value?.resetUnsavedChanges();
},

View file

@ -8,7 +8,7 @@
*/
import { Capabilities } from '@kbn/core/public';
import { convertPanelMapToPanelsArray } from '../../../../common/lib/dashboard_panel_converters';
import { convertPanelSectionMapsToPanelsArray } from '../../../../common/lib/dashboard_panel_converters';
import { DashboardLocatorParams } from '../../../../common/types';
import { getDashboardBackupService } from '../../../services/dashboard_backup_service';
import { shareService } from '../../../services/kibana_services';
@ -124,7 +124,7 @@ describe('ShowShareModal', () => {
).locatorParams.params;
const rawDashboardState = {
...unsavedDashboardState,
panels: convertPanelMapToPanelsArray(unsavedDashboardState.panels),
panels: convertPanelSectionMapsToPanelsArray(unsavedDashboardState.panels, {}),
};
unsavedStateKeys.forEach((key) => {
expect(shareLocatorParams[key]).toStrictEqual(

View file

@ -7,6 +7,10 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { omit } from 'lodash';
import moment from 'moment';
import React, { ReactElement, useState } from 'react';
import { EuiCallOut, EuiCheckboxGroup } from '@elastic/eui';
import type { Capabilities } from '@kbn/core/public';
import { QueryState } from '@kbn/data-plugin/common';
@ -14,12 +18,10 @@ import { DASHBOARD_APP_LOCATOR } from '@kbn/deeplinks-analytics';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { getStateFromKbnUrl, setStateToKbnUrl, unhashUrl } from '@kbn/kibana-utils-plugin/public';
import { omit } from 'lodash';
import moment from 'moment';
import React, { ReactElement, useState } from 'react';
import { LocatorPublic } from '@kbn/share-plugin/common';
import { DashboardLocatorParams } from '../../../../common';
import { convertPanelMapToPanelsArray } from '../../../../common/lib/dashboard_panel_converters';
import { convertPanelSectionMapsToPanelsArray } from '../../../../common/lib/dashboard_panel_converters';
import { SharedDashboardState } from '../../../../common/types';
import { getDashboardBackupService } from '../../../services/dashboard_backup_service';
import { coreServices, dataService, shareService } from '../../../services/kibana_services';
@ -110,8 +112,11 @@ export function ShowShareModal({
);
};
const { panels: allUnsavedPanelsMap, ...unsavedDashboardState } =
getDashboardBackupService().getState(savedObjectId) ?? {};
const {
panels: allUnsavedPanelsMap,
sections: allUnsavedSectionsMap,
...unsavedDashboardState
} = getDashboardBackupService().getState(savedObjectId) ?? {};
const hasPanelChanges = allUnsavedPanelsMap !== undefined;
@ -121,8 +126,11 @@ export function ShowShareModal({
unsavedDashboardState.controlGroupInput as SharedDashboardState['controlGroupInput'],
references: unsavedDashboardState.references as SharedDashboardState['references'],
};
if (allUnsavedPanelsMap) {
unsavedDashboardStateForLocator.panels = convertPanelMapToPanelsArray(allUnsavedPanelsMap);
if (allUnsavedPanelsMap || allUnsavedSectionsMap) {
unsavedDashboardStateForLocator.panels = convertPanelSectionMapsToPanelsArray(
allUnsavedPanelsMap ?? {},
allUnsavedSectionsMap ?? {}
);
}
const locatorParams: DashboardLocatorParams = {

View file

@ -19,7 +19,7 @@ import {
import { History } from 'history';
import { map } from 'rxjs';
import { SEARCH_SESSION_ID } from '../../../common/constants';
import { convertPanelMapToPanelsArray } from '../../../common/lib/dashboard_panel_converters';
import { convertPanelSectionMapsToPanelsArray } from '../../../common/lib/dashboard_panel_converters';
import { DashboardLocatorParams } from '../../../common/types';
import { DashboardApi, DashboardInternalApi } from '../../dashboard_api/types';
import { dataService } from '../../services/kibana_services';
@ -81,7 +81,7 @@ function getLocatorParams({
shouldRestoreSearchSession: boolean;
}): DashboardLocatorParams {
const savedObjectId = dashboardApi.savedObjectId$.value;
const { panels, references } = dashboardInternalApi.serializePanels();
const { panels, sections, references } = dashboardInternalApi.serializeLayout();
return {
viewMode: dashboardApi.viewMode$.value ?? 'view',
useHash: false,
@ -104,7 +104,10 @@ function getLocatorParams({
...(savedObjectId
? {}
: {
panels: convertPanelMapToPanelsArray(panels) as DashboardLocatorParams['panels'],
panels: convertPanelSectionMapsToPanelsArray(
panels,
sections
) as DashboardLocatorParams['panels'],
references: references as DashboardLocatorParams['references'],
}),
};

View file

@ -7,17 +7,22 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { serializeRuntimeState } from '@kbn/controls-plugin/public';
import { replaceUrlHashQuery } from '@kbn/kibana-utils-plugin/common';
import { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
import { History } from 'history';
import _ from 'lodash';
import { skip } from 'rxjs';
import semverSatisfies from 'semver/functions/satisfies';
import type { DashboardPanelMap } from '../../../common/dashboard_container/types';
import { convertPanelsArrayToPanelMap } from '../../../common/lib/dashboard_panel_converters';
import { serializeRuntimeState } from '@kbn/controls-plugin/public';
import { replaceUrlHashQuery } from '@kbn/kibana-utils-plugin/common';
import { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
import type {
DashboardPanelMap,
DashboardSectionMap,
} from '../../../common/dashboard_container/types';
import { convertPanelsArrayToPanelSectionMaps } from '../../../common/lib/dashboard_panel_converters';
import type { DashboardState, SharedDashboardState } from '../../../common/types';
import type { DashboardPanel } from '../../../server/content_management';
import type { DashboardPanel, DashboardSection } from '../../../server/content_management';
import type { SavedDashboardPanel } from '../../../server/dashboard_saved_object';
import { DashboardApi } from '../../dashboard_api/types';
import { migrateLegacyQuery } from '../../services/dashboard_content_management_service/lib/load_dashboard_state';
@ -33,8 +38,14 @@ const panelIsLegacy = (panel: unknown): panel is SavedDashboardPanel => {
* We no longer support loading panels from a version older than 7.3 in the URL.
* @returns whether or not there is a panel in the URL state saved with a version before 7.3
*/
export const isPanelVersionTooOld = (panels: DashboardPanel[] | SavedDashboardPanel[]) => {
export const isPanelVersionTooOld = (
panels: Array<DashboardPanel | DashboardSection> | SavedDashboardPanel[]
) => {
for (const panel of panels) {
if ('panels' in panel) {
// can't use isDashboardSection type guard because of SavedDashboardPanel type
continue; // ignore sections
}
if (
!panel.gridData ||
!((panel as DashboardPanel).panelConfig || (panel as SavedDashboardPanel).embeddableConfig) ||
@ -45,13 +56,15 @@ export const isPanelVersionTooOld = (panels: DashboardPanel[] | SavedDashboardPa
return false;
};
function getPanelsMap(panels?: DashboardPanel[]): DashboardPanelMap | undefined {
function getPanelSectionMaps(
panels?: Array<DashboardPanel | DashboardSection>
): { panels: DashboardPanelMap; sections: DashboardSectionMap } | undefined {
if (!panels) {
return undefined;
}
if (panels.length === 0) {
return {};
return { panels: {}, sections: {} };
}
if (isPanelVersionTooOld(panels)) {
@ -71,7 +84,7 @@ function getPanelsMap(panels?: DashboardPanel[]): DashboardPanelMap | undefined
return panel;
});
return convertPanelsArrayToPanelMap(standardizedPanels);
return convertPanelsArrayToPanelSectionMaps(standardizedPanels);
}
/**
@ -85,8 +98,7 @@ export const loadAndRemoveDashboardState = (
);
if (!rawAppStateInUrl) return {};
const panelsMap = getPanelsMap(rawAppStateInUrl.panels);
const converted = getPanelSectionMaps(rawAppStateInUrl.panels);
const nextUrl = replaceUrlHashQuery(window.location.href, (hashQuery) => {
delete hashQuery[DASHBOARD_STATE_STORAGE_KEY];
@ -100,7 +112,8 @@ export const loadAndRemoveDashboardState = (
controlGroupInput: serializeRuntimeState(rawAppStateInUrl.controlGroupState).rawState,
}
: {}),
...(panelsMap ? { panels: panelsMap } : {}),
...(converted?.panels ? { panels: converted.panels } : {}),
...(converted?.sections ? { sections: converted.sections } : {}),
...(rawAppStateInUrl.query ? { query: migrateLegacyQuery(rawAppStateInUrl.query) } : {}),
};

View file

@ -15,6 +15,7 @@
.dshEmptyPromptParent {
flex-grow: 1;
display: flex;
height: 100%;
}
.dshEmptyPromptPageTemplate {

View file

@ -17,3 +17,4 @@ export { ExportCSVAction } from '../dashboard_actions/export_csv_action';
export { AddToLibraryAction } from '../dashboard_actions/library_add_action';
export { UnlinkFromLibraryAction } from '../dashboard_actions/library_unlink_action';
export { CopyToDashboardAction } from '../dashboard_actions/copy_to_dashboard_action';
export { AddSectionAction } from '../dashboard_actions/add_section_action';

View file

@ -8,19 +8,21 @@
*/
import React from 'react';
import { EuiThemeProvider } from '@elastic/eui';
import { EuiThemeProvider } from '@elastic/eui';
import { useBatchedPublishingSubjects as mockUseBatchedPublishingSubjects } from '@kbn/presentation-publishing';
import { DashboardPanelMap } from '../../../common';
import { RenderResult, act, getByLabelText, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DashboardPanelMap, DashboardSectionMap } from '../../../common';
import {
DashboardContext,
useDashboardApi as mockUseDashboardApi,
} from '../../dashboard_api/use_dashboard_api';
import { DashboardInternalContext } from '../../dashboard_api/use_dashboard_internal_api';
import { buildMockDashboardApi } from '../../mocks';
import { buildMockDashboardApi, getMockDashboardPanels } from '../../mocks';
import { DashboardGrid } from './dashboard_grid';
import type { Props as DashboardGridItemProps } from './dashboard_grid_item';
import { RenderResult, act, render, waitFor } from '@testing-library/react';
jest.mock('./dashboard_grid_item', () => {
return {
@ -56,19 +58,6 @@ jest.mock('./dashboard_grid_item', () => {
};
});
const PANELS = {
'1': {
gridData: { x: 0, y: 0, w: 6, h: 6, i: '1' },
type: 'lens',
explicitInput: { id: '1' },
},
'2': {
gridData: { x: 6, y: 6, w: 6, h: 6, i: '2' },
type: 'lens',
explicitInput: { id: '2' },
},
};
const verifyElementHasClass = (
component: RenderResult,
elementSelector: string,
@ -79,10 +68,16 @@ const verifyElementHasClass = (
expect(itemToCheck!.classList.contains(className)).toBe(true);
};
const createAndMountDashboardGrid = async (panels: DashboardPanelMap = PANELS) => {
const createAndMountDashboardGrid = async (overrides?: {
panels?: DashboardPanelMap;
sections?: DashboardSectionMap;
}) => {
const panels = overrides?.panels ?? getMockDashboardPanels().panels;
const sections = overrides?.sections;
const { api, internalApi } = buildMockDashboardApi({
overrides: {
panels,
...(sections && { sections }),
},
});
const component = render(
@ -95,35 +90,45 @@ const createAndMountDashboardGrid = async (panels: DashboardPanelMap = PANELS) =
</EuiThemeProvider>
);
// panels in collapsed sections should not render
const panelRenderCount = sections
? Object.values(panels).filter((value) => {
const sectionId = value.gridData.sectionId;
return sectionId ? !sections[sectionId].collapsed : true;
}).length
: Object.keys(panels).length;
// wait for first render
await waitFor(() => {
expect(component.queryAllByTestId('dashboardGridItem').length).toBe(Object.keys(panels).length);
expect(component.queryAllByTestId('dashboardGridItem').length).toBe(panelRenderCount);
});
return { dashboardApi: api, component };
return { dashboardApi: api, internalApi, component };
};
test('renders DashboardGrid', async () => {
await createAndMountDashboardGrid(PANELS);
});
describe('DashboardGrid', () => {
test('renders', async () => {
await createAndMountDashboardGrid();
});
test('renders DashboardGrid with no visualizations', async () => {
await createAndMountDashboardGrid({});
});
describe('panels', () => {
test('renders with no visualizations', async () => {
await createAndMountDashboardGrid();
});
test('DashboardGrid removes panel when removed from container', async () => {
const { dashboardApi, component } = await createAndMountDashboardGrid(PANELS);
test('removes panel when removed from container', async () => {
const { dashboardApi, component } = await createAndMountDashboardGrid();
// remove panel
await act(async () => {
dashboardApi.removePanel('1');
dashboardApi.removePanel('2');
await new Promise((resolve) => setTimeout(resolve, 1));
});
expect(component.getAllByTestId('dashboardGridItem').length).toBe(1);
});
});
test('DashboardGrid renders expanded panel', async () => {
test('renders expanded panel', async () => {
const { dashboardApi, component } = await createAndMountDashboardGrid();
// maximize panel
@ -146,9 +151,9 @@ test('DashboardGrid renders expanded panel', async () => {
verifyElementHasClass(component, '#mockDashboardGridItem_1', 'regularPanel');
verifyElementHasClass(component, '#mockDashboardGridItem_2', 'regularPanel');
});
});
test('DashboardGrid renders focused panel', async () => {
test('renders focused panel', async () => {
const { dashboardApi, component } = await createAndMountDashboardGrid();
const overlayMock = {
onClose: new Promise<void>((resolve) => {
@ -175,4 +180,135 @@ test('DashboardGrid renders focused panel', async () => {
verifyElementHasClass(component, '#mockDashboardGridItem_1', 'regularPanel');
verifyElementHasClass(component, '#mockDashboardGridItem_2', 'regularPanel');
});
});
describe('sections', () => {
test('renders sections', async () => {
const { panels, sections } = getMockDashboardPanels(true);
await createAndMountDashboardGrid({
panels,
sections,
});
const header1 = screen.getByTestId('kbnGridSectionHeader-section1');
expect(header1).toBeInTheDocument();
expect(header1.classList).toContain('kbnGridSectionHeader--collapsed');
const header2 = screen.getByTestId('kbnGridSectionHeader-section2');
expect(header2).toBeInTheDocument();
expect(header2.classList).not.toContain('kbnGridSectionHeader--collapsed');
});
test('can add new section', async () => {
const { panels, sections } = getMockDashboardPanels(true);
const { dashboardApi, internalApi } = await createAndMountDashboardGrid({
panels,
sections,
});
dashboardApi.addNewSection();
await waitFor(() => {
const headers = screen.getAllByLabelText('Edit section title'); // aria-label
expect(headers.length).toEqual(3);
});
const newHeader = Object.values(internalApi.layout$.getValue().sections).filter(
({ gridData: { y } }) => y === 8
)[0];
expect(newHeader.title).toEqual('New collapsible section');
expect(screen.getByText(newHeader.title)).toBeInTheDocument();
expect(newHeader.collapsed).toBe(false);
expect(screen.getByTestId(`kbnGridSectionHeader-${newHeader.id}`).classList).not.toContain(
'kbnGridSectionHeader--collapsed'
);
});
test('dashboard state updates on collapse', async () => {
const { panels, sections } = getMockDashboardPanels(true);
const { internalApi } = await createAndMountDashboardGrid({
panels,
sections,
});
const headerButton = screen.getByTestId(`kbnGridSectionTitle-section2`);
expect(headerButton.nodeName.toLowerCase()).toBe('button');
userEvent.click(headerButton);
await waitFor(() => {
expect(internalApi.layout$.getValue().sections.section2.collapsed).toBe(true);
});
expect(headerButton.getAttribute('aria-expanded')).toBe('false');
});
test('dashboard state updates on section deletion', async () => {
const { panels, sections } = getMockDashboardPanels(true, {
sections: {
emptySection: {
id: 'emptySection',
title: 'Empty section',
collapsed: false,
gridData: { i: 'emptySection', y: 8 },
},
},
});
const { internalApi } = await createAndMountDashboardGrid({
panels,
sections,
});
// can delete empty section
const deleteEmptySectionButton = getByLabelText(
screen.getByTestId('kbnGridSectionHeader-emptySection'),
'Delete section'
);
await act(async () => {
await userEvent.click(deleteEmptySectionButton);
});
await waitFor(() => {
expect(Object.keys(internalApi.layout$.getValue().sections)).not.toContain('emptySection');
});
// can delete non-empty section
const deleteSection1Button = getByLabelText(
screen.getByTestId('kbnGridSectionHeader-section1'),
'Delete section'
);
await userEvent.click(deleteSection1Button);
await waitFor(() => {
expect(screen.getByTestId('kbnGridLayoutDeleteSectionModal-section1')).toBeInTheDocument();
});
const confirmDeleteButton = screen.getByText('Delete section and 1 panel');
await userEvent.click(confirmDeleteButton);
await waitFor(() => {
expect(Object.keys(internalApi.layout$.getValue().sections)).not.toContain('section1');
expect(Object.keys(internalApi.layout$.getValue().panels)).not.toContain('3'); // this is the panel in section1
});
});
test('layout responds to dashboard state update', async () => {
const withoutSections = getMockDashboardPanels();
const withSections = getMockDashboardPanels(true);
const { internalApi } = await createAndMountDashboardGrid({
panels: withoutSections.panels,
sections: {},
});
let sectionContainers = screen.getAllByTestId(`kbnGridSectionWrapper-`, {
exact: false,
});
expect(sectionContainers.length).toBe(1); // only the first top section is rendered
internalApi.layout$.next(withSections);
await waitFor(() => {
sectionContainers = screen.getAllByTestId(`kbnGridSectionWrapper-`, {
exact: false,
});
expect(sectionContainers.length).toBe(2); // section wrappers are not rendered for collapsed sections
expect(screen.getAllByTestId('dashboardGridItem').length).toBe(3); // one panel is in a collapsed section
});
});
});
});

View file

@ -10,19 +10,20 @@
import { useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
import { useAppFixedViewport } from '@kbn/core-rendering-browser';
import { GridLayout, type GridLayoutData } from '@kbn/grid-layout';
import { GridLayout, GridPanelData, GridSectionData, type GridLayoutData } from '@kbn/grid-layout';
import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing';
import classNames from 'classnames';
import React, { useCallback, useMemo, useRef } from 'react';
import { default as React, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { DASHBOARD_GRID_COLUMN_COUNT } from '../../../common/content_management/constants';
import { arePanelLayoutsEqual } from '../../dashboard_api/are_panel_layouts_equal';
import { GridData } from '../../../common/content_management/v2/types';
import { areLayoutsEqual } from '../../dashboard_api/are_layouts_equal';
import { DashboardLayout } from '../../dashboard_api/types';
import { useDashboardApi } from '../../dashboard_api/use_dashboard_api';
import { useDashboardInternalApi } from '../../dashboard_api/use_dashboard_internal_api';
import {
DEFAULT_DASHBOARD_DRAG_TOP_OFFSET,
DASHBOARD_GRID_HEIGHT,
DASHBOARD_MARGIN_SIZE,
DEFAULT_DASHBOARD_DRAG_TOP_OFFSET,
} from './constants';
import { DashboardGridItem } from './dashboard_grid_item';
import { useLayoutStyles } from './use_layout_styles';
@ -34,11 +35,13 @@ export const DashboardGrid = ({
}) => {
const dashboardApi = useDashboardApi();
const dashboardInternalApi = useDashboardInternalApi();
const layoutRef = useRef<HTMLDivElement | null>(null);
const layoutStyles = useLayoutStyles();
const panelRefs = useRef<{ [panelId: string]: React.Ref<HTMLDivElement> }>({});
const { euiTheme } = useEuiTheme();
const [topOffset, setTopOffset] = useState(DEFAULT_DASHBOARD_DRAG_TOP_OFFSET);
const [expandedPanelId, layout, useMargins, viewMode] = useBatchedPublishingSubjects(
dashboardApi.expandedPanelId$,
dashboardInternalApi.layout$,
@ -46,57 +49,93 @@ export const DashboardGrid = ({
dashboardApi.viewMode$
);
useEffect(() => {
setTopOffset(
dashboardContainerRef?.current?.getBoundingClientRect().top ??
DEFAULT_DASHBOARD_DRAG_TOP_OFFSET
);
}, [dashboardContainerRef]);
const appFixedViewport = useAppFixedViewport();
const currentLayout: GridLayoutData = useMemo(() => {
const singleRow: GridLayoutData = {};
Object.keys(layout).forEach((panelId) => {
const gridData = layout[panelId].gridData;
singleRow[panelId] = {
const newLayout: GridLayoutData = {};
Object.keys(layout.sections).forEach((sectionId) => {
const section = layout.sections[sectionId];
newLayout[sectionId] = {
id: sectionId,
type: 'section',
row: section.gridData.y,
isCollapsed: Boolean(section.collapsed),
title: section.title,
panels: {},
};
});
Object.keys(layout.panels).forEach((panelId) => {
const gridData = layout.panels[panelId].gridData;
const basePanel = {
id: panelId,
row: gridData.y,
column: gridData.x,
width: gridData.w,
height: gridData.h,
} as GridPanelData;
if (gridData.sectionId) {
(newLayout[gridData.sectionId] as GridSectionData).panels[panelId] = basePanel;
} else {
newLayout[panelId] = {
...basePanel,
type: 'panel',
};
}
// update `data-grid-row` attribute for all panels because it is used for some styling
const panelRef = panelRefs.current[panelId];
if (typeof panelRef !== 'function' && panelRef?.current) {
panelRef.current.setAttribute('data-grid-row', `${gridData.y}`);
}
});
return singleRow;
return newLayout;
}, [layout]);
const onLayoutChange = useCallback(
(newLayout: GridLayoutData) => {
if (viewMode !== 'edit') return;
const currentPanels = dashboardInternalApi.layout$.getValue();
const updatedPanels: DashboardLayout = Object.values(newLayout).reduce(
(updatedPanelsAcc, widget) => {
const currLayout = dashboardInternalApi.layout$.getValue();
const updatedLayout: DashboardLayout = {
sections: {},
panels: {},
};
Object.values(newLayout).forEach((widget) => {
if (widget.type === 'section') {
return updatedPanelsAcc; // sections currently aren't supported
}
updatedPanelsAcc[widget.id] = {
...currentPanels[widget.id],
updatedLayout.sections[widget.id] = {
collapsed: widget.isCollapsed,
title: widget.title,
id: widget.id,
gridData: {
i: widget.id,
y: widget.row,
x: widget.column,
w: widget.width,
h: widget.height,
},
};
return updatedPanelsAcc;
Object.values(widget.panels).forEach((panel) => {
updatedLayout.panels[panel.id] = {
...currLayout.panels[panel.id],
gridData: {
...convertGridPanelToDashboardGridData(panel),
sectionId: widget.id,
},
{} as DashboardLayout
);
if (!arePanelLayoutsEqual(currentPanels, updatedPanels)) {
dashboardInternalApi.layout$.next(updatedPanels);
};
});
} else {
// widget is a panel
updatedLayout.panels[widget.id] = {
...currLayout.panels[widget.id],
gridData: convertGridPanelToDashboardGridData(widget),
};
}
});
if (!areLayoutsEqual(currLayout, updatedLayout)) {
dashboardInternalApi.layout$.next(updatedLayout);
}
},
[dashboardInternalApi.layout$, viewMode]
@ -104,13 +143,14 @@ export const DashboardGrid = ({
const renderPanelContents = useCallback(
(id: string, setDragHandles: (refs: Array<HTMLElement | null>) => void) => {
const currentPanels = dashboardInternalApi.layout$.getValue();
if (!currentPanels[id]) return;
const panels = dashboardInternalApi.layout$.getValue().panels;
if (!panels[id]) return;
if (!panelRefs.current[id]) {
panelRefs.current[id] = React.createRef();
}
const type = currentPanels[id].type;
const type = panels[id].type;
return (
<DashboardGridItem
ref={panelRefs.current[id]}
@ -120,13 +160,45 @@ export const DashboardGrid = ({
setDragHandles={setDragHandles}
appFixedViewport={appFixedViewport}
dashboardContainerRef={dashboardContainerRef}
data-grid-row={currentPanels[id].gridData.y} // initialize data-grid-row
data-grid-row={panels[id].gridData.y} // initialize data-grid-row
/>
);
},
[appFixedViewport, dashboardContainerRef, dashboardInternalApi.layout$]
);
useEffect(() => {
/**
* ResizeObserver fires the callback on `.observe()` with the initial size of the observed
* element; we want to ignore this first call and scroll to the bottom on the **second**
* callback - i.e. after the row is actually added to the DOM
*/
let first = false;
const scrollToBottomOnResize = new ResizeObserver(() => {
if (first) {
first = false;
} else {
dashboardApi.scrollToBottom();
scrollToBottomOnResize.disconnect(); // once scrolled, stop observing resize events
}
});
/**
* When `scrollToBottom$` emits, wait for the `layoutRef` size to change then scroll
* to the bottom of the screen
*/
const scrollToBottomSubscription = dashboardApi.scrollToBottom$.subscribe(() => {
if (!layoutRef.current) return;
first = true; // ensure that only the second resize callback actually triggers scrolling
scrollToBottomOnResize.observe(layoutRef.current);
});
return () => {
scrollToBottomOnResize.disconnect();
scrollToBottomSubscription.unsubscribe();
};
}, [dashboardApi]);
const memoizedgridLayout = useMemo(() => {
// memoizing this component reduces the number of times it gets re-rendered to a minimum
return (
@ -137,9 +209,7 @@ export const DashboardGrid = ({
gutterSize: useMargins ? DASHBOARD_MARGIN_SIZE : 0,
rowHeight: DASHBOARD_GRID_HEIGHT,
columnCount: DASHBOARD_GRID_COLUMN_COUNT,
keyboardDragTopLimit:
dashboardContainerRef?.current?.getBoundingClientRect().top ||
DEFAULT_DASHBOARD_DRAG_TOP_OFFSET,
keyboardDragTopLimit: topOffset,
}}
useCustomDragHandle={true}
renderPanelContents={renderPanelContents}
@ -156,7 +226,7 @@ export const DashboardGrid = ({
onLayoutChange,
expandedPanelId,
viewMode,
dashboardContainerRef,
topOffset,
]);
const { dashboardClasses, dashboardStyles } = useMemo(() => {
@ -183,8 +253,18 @@ export const DashboardGrid = ({
}, [useMargins, viewMode, expandedPanelId, euiTheme.levels.toast]);
return (
<div className={dashboardClasses} css={dashboardStyles}>
<div ref={layoutRef} className={dashboardClasses} css={dashboardStyles}>
{memoizedgridLayout}
</div>
);
};
const convertGridPanelToDashboardGridData = (panel: GridPanelData): GridData => {
return {
i: panel.id,
y: panel.row,
x: panel.column,
w: panel.width,
h: panel.height,
};
};

View file

@ -50,7 +50,11 @@ export const useLayoutStyles = () => {
background-origin: content-box;
}
.kbnGridPanel--dragPreview {
// styles for the area where the panel and/or section header will be dropped
.kbnGridPanel--dragPreview,
.kbnGridSection--dragPreview {
border-radius: ${euiTheme.border.radius.medium} ${euiTheme.border.radius.medium};
background-color: ${transparentize(euiTheme.colors.vis.euiColorVis0, 0.2)};
}
@ -91,6 +95,50 @@ export const useLayoutStyles = () => {
transition: none;
}
}
// styling for what the grid section header looks like when being dragged
.kbnGridSectionHeader--active {
background-color: ${euiTheme.colors.backgroundBasePlain};
outline: var(--dashboardActivePanelBorderStyle);
border-radius: ${euiTheme.border.radius.medium} ${euiTheme.border.radius.medium};
padding-left: 8px;
// hide accordian arrow + panel count text when row is being dragged
& .kbnGridSectionTitle--button svg,
& .kbnGridLayout--panelCount {
display: none;
}
}
// styling for the section footer
.kbnGridSectionFooter {
height: ${euiTheme.size.s};
display: block;
border-top: ${euiTheme.border.thin};
// highlight the footer of a targeted section to make it clear where the section ends
&--targeted {
border-top: ${euiTheme.border.width.thick} solid
${transparentize(euiTheme.colors.vis.euiColorVis0, 0.5)};
}
}
// hide footer border when section is being dragged
&:has(.kbnGridSectionHeader--active) .kbnGridSectionHeader--active + .kbnGridSectionFooter {
border-top: none;
}
// apply a "fade out" effect when dragging a section header over another section, indicating that dropping is not allowed
.kbnGridSection--blocked {
z-index: 1;
background-color: ${transparentize(euiTheme.colors.backgroundBaseSubdued, 0.5)};
// the oulines of panels extend past 100% by 1px on each side, so adjust for that
margin-left: -1px;
margin-top: -1px;
width: calc(100% + 2px);
height: calc(100% + 2px);
}
&:has(.kbnGridSection--blocked) .kbnGridSection--dragHandle {
cursor: not-allowed !important;
}
`;
}, [euiTheme]);

View file

@ -12,6 +12,10 @@
.dshDashboardViewport {
width: 100%;
&.dshDashboardViewport--empty {
height: 100%;
}
&--panelExpanded {
flex: 1;
}

View file

@ -14,14 +14,14 @@ import { EuiPortal } from '@elastic/eui';
import { EmbeddableRenderer } from '@kbn/embeddable-plugin/public';
import { ExitFullScreenButton } from '@kbn/shared-ux-button-exit-full-screen';
import { ControlGroupApi } from '@kbn/controls-plugin/public';
import { CONTROL_GROUP_TYPE } from '@kbn/controls-plugin/common';
import { ControlGroupApi } from '@kbn/controls-plugin/public';
import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing';
import { DashboardGrid } from '../grid';
import { CONTROL_GROUP_EMBEDDABLE_ID } from '../../dashboard_api/control_group_manager';
import { useDashboardApi } from '../../dashboard_api/use_dashboard_api';
import { useDashboardInternalApi } from '../../dashboard_api/use_dashboard_internal_api';
import { DashboardGrid } from '../grid';
import { DashboardEmptyScreen } from './empty_screen/dashboard_empty_screen';
import { CONTROL_GROUP_EMBEDDABLE_ID } from '../../dashboard_api/control_group_manager';
export const DashboardViewport = ({
dashboardContainerRef,
@ -54,12 +54,21 @@ export const DashboardViewport = ({
dashboardApi.setFullScreenMode(false);
}, [dashboardApi]);
const panelCount = useMemo(() => {
return Object.keys(layout).length;
}, [layout]);
const { panelCount, visiblePanelCount, sectionCount } = useMemo(() => {
const panels = Object.values(layout.panels);
const visiblePanels = panels.filter(({ gridData }) => {
return !dashboardInternalApi.isSectionCollapsed(gridData.sectionId);
});
return {
panelCount: panels.length,
visiblePanelCount: visiblePanels.length,
sectionCount: Object.keys(layout.sections).length,
};
}, [layout, dashboardInternalApi]);
const classes = classNames({
dshDashboardViewport: true,
'dshDashboardViewport--empty': panelCount === 0 && sectionCount === 0,
'dshDashboardViewport--print': viewMode === 'print',
'dshDashboardViewport--panelExpanded': Boolean(expandedPanelId),
});
@ -124,15 +133,18 @@ export const DashboardViewport = ({
<ExitFullScreenButton onExit={onExit} toggleChrome={!dashboardApi.isEmbeddedExternally} />
</EuiPortal>
)}
{panelCount === 0 && <DashboardEmptyScreen />}
<div
className={classes}
data-shared-items-container
data-title={dashboardTitle}
data-description={description}
data-shared-items-count={panelCount}
data-shared-items-count={visiblePanelCount}
>
{panelCount === 0 && sectionCount === 0 ? (
<DashboardEmptyScreen />
) : (
<DashboardGrid dashboardContainerRef={dashboardContainerRef} />
)}
</div>
</div>
);

View file

@ -12,7 +12,7 @@ import { BehaviorSubject } from 'rxjs';
import { DashboardStart } from './plugin';
import { DashboardState } from '../common/types';
import { getDashboardApi } from './dashboard_api/get_dashboard_api';
import { DashboardPanelState } from '../common/dashboard_container/types';
import { DashboardPanelMap, DashboardSectionMap } from '../common';
export type Start = jest.Mocked<DashboardStart>;
@ -126,24 +126,67 @@ export function getSampleDashboardState(overrides?: Partial<DashboardState>): Da
timeRestore: false,
viewMode: 'view',
panels: {},
sections: {},
...overrides,
};
}
export function getSampleDashboardPanel(
overrides: Partial<DashboardPanelState> & {
explicitInput: { id: string };
type: string;
export function getMockDashboardPanels(
withSections: boolean = false,
overrides?: {
panels?: DashboardPanelMap;
sections?: DashboardSectionMap;
}
): DashboardPanelState {
return {
gridData: {
h: 15,
w: 15,
x: 0,
y: 0,
i: overrides.explicitInput.id,
): { panels: DashboardPanelMap; sections: DashboardSectionMap } {
const panels = {
'1': {
gridData: { x: 0, y: 0, w: 6, h: 6, i: '1' },
type: 'lens',
explicitInput: { id: '1' },
},
...overrides,
'2': {
gridData: { x: 6, y: 0, w: 6, h: 6, i: '2' },
type: 'lens',
explicitInput: { id: '2' },
},
...overrides?.panels,
};
if (!withSections) return { panels, sections: {} };
return {
panels: {
...panels,
'3': {
gridData: { x: 0, y: 0, w: 6, h: 6, i: '3', sectionId: 'section1' },
type: 'lens',
explicitInput: { id: '3' },
},
'4': {
gridData: { x: 0, y: 0, w: 6, h: 6, i: '4', sectionId: 'section2' },
type: 'lens',
explicitInput: { id: '4' },
},
},
sections: {
section1: {
id: 'section1',
title: 'Section One',
collapsed: true,
gridData: {
y: 6,
i: 'section1',
},
},
section2: {
id: 'section2',
title: 'Section Two',
collapsed: false,
gridData: {
y: 7,
i: 'section2',
},
},
...overrides?.sections,
},
} as any;
}

View file

@ -0,0 +1,72 @@
/*
* 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 { getMockDashboardPanels } from '../mocks';
import { placeClonePanel } from './place_clone_panel_strategy';
describe('Clone panel placement strategies', () => {
it('no other panels', () => {
const currentPanels = {
'1': {
gridData: { x: 0, y: 0, w: 6, h: 6, i: '1' },
type: 'lens',
explicitInput: { id: '1' },
},
};
const { newPanelPlacement, otherPanels } = placeClonePanel({
width: 6,
height: 6,
currentPanels,
placeBesideId: '1',
});
expect(newPanelPlacement).toEqual({
x: 6, // placed right beside the other panel
y: 0,
w: 6,
h: 6,
});
expect(otherPanels).toEqual(currentPanels);
});
it('panel collision at desired clone location', () => {
const { panels } = getMockDashboardPanels();
const { newPanelPlacement, otherPanels } = placeClonePanel({
width: 6,
height: 6,
currentPanels: panels,
placeBesideId: '1',
});
expect(newPanelPlacement).toEqual({
x: 0,
y: 6, // instead of being placed beside the cloned panel, it is placed right below
w: 6,
h: 6,
});
expect(otherPanels).toEqual(panels);
});
it('ignores panels in other sections', () => {
const { panels } = getMockDashboardPanels(true);
const { newPanelPlacement, otherPanels } = placeClonePanel({
width: 6,
height: 6,
currentPanels: panels,
placeBesideId: '3',
sectionId: 'section1',
});
expect(newPanelPlacement).toEqual({
x: 6, // placed beside panel 3, since is has space beside it in section1
y: 0,
w: 6,
h: 6,
});
expect(otherPanels).toEqual(panels);
});
});

View file

@ -9,10 +9,11 @@
import { PanelNotFoundError } from '@kbn/embeddable-plugin/public';
import { cloneDeep, forOwn } from 'lodash';
import { DASHBOARD_GRID_COLUMN_COUNT } from '../../common/content_management';
import type { GridData } from '../../server/content_management';
import { DashboardLayoutItem } from '../dashboard_api/types';
import { PanelPlacementProps, PanelPlacementReturn } from './types';
import { DashboardPanel } from '../dashboard_api/types';
interface IplacementDirection {
grid: Omit<GridData, 'i'>;
@ -42,6 +43,7 @@ function comparePanels(a: GridData, b: GridData): number {
export function placeClonePanel({
width,
height,
sectionId,
currentPanels,
placeBesideId,
}: PanelPlacementProps & { placeBesideId: string }): PanelPlacementReturn {
@ -51,8 +53,11 @@ export function placeClonePanel({
}
const beside = panelToPlaceBeside.gridData;
const otherPanelGridData: GridData[] = [];
forOwn(currentPanels, (panel: DashboardLayoutItem, key: string | undefined) => {
forOwn(currentPanels, (panel: DashboardPanel) => {
if (panel.gridData.sectionId === sectionId) {
// only check against panels that are in the same section as the cloned panel
otherPanelGridData.push(panel.gridData);
}
});
const possiblePlacementDirections: IplacementDirection[] = [
@ -109,8 +114,11 @@ export function placeClonePanel({
for (let j = position + 1; j < grid.length; j++) {
originalPositionInTheGrid = grid[j].i;
const { gridData, ...movedPanel } = cloneDeep(otherPanels[originalPositionInTheGrid]);
if (gridData.sectionId === sectionId) {
// only move panels in the cloned panel's section
const newGridData = { ...gridData, y: gridData.y + diff };
otherPanels[originalPositionInTheGrid] = { ...movedPanel, gridData: newGridData };
}
}
return { newPanelPlacement: bottomPlacement.grid, otherPanels };
}

View file

@ -0,0 +1,192 @@
/*
* 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 { getMockDashboardPanels } from '../mocks';
import { PanelPlacementStrategy } from '../plugin_constants';
import { runPanelPlacementStrategy } from './place_new_panel_strategies';
describe('new panel placement strategies', () => {
describe('place at top', () => {
it('no other panels', () => {
const { newPanelPlacement, otherPanels } = runPanelPlacementStrategy(
PanelPlacementStrategy.placeAtTop,
{ width: 6, height: 6, currentPanels: {} }
);
expect(newPanelPlacement).toEqual({
x: 0,
y: 0,
w: 6,
h: 6,
});
expect(otherPanels).toEqual({});
});
it('push other panels down', () => {
const { panels } = getMockDashboardPanels();
const { newPanelPlacement, otherPanels } = runPanelPlacementStrategy(
PanelPlacementStrategy.placeAtTop,
{ width: 6, height: 6, currentPanels: panels }
);
expect(newPanelPlacement).toEqual({
x: 0,
y: 0,
w: 6,
h: 6,
});
expect(otherPanels).toEqual(
Object.keys(panels).reduce((prev, panelId) => {
const originalGridData = panels[panelId].gridData;
return {
...prev,
[panelId]: {
...panels[panelId],
gridData: {
...originalGridData,
y: originalGridData.y + 6, // panel was pushed down by height of new panel
},
},
};
}, {})
);
});
it('ignores panels in other sections', () => {
const { panels } = getMockDashboardPanels(true);
const { newPanelPlacement, otherPanels } = runPanelPlacementStrategy(
PanelPlacementStrategy.placeAtTop,
{ width: 6, height: 6, currentPanels: panels, sectionId: 'section1' }
);
expect(newPanelPlacement).toEqual({
x: 0,
y: 0,
w: 6,
h: 6,
});
expect(otherPanels).toEqual(
Object.keys(panels).reduce((prev, panelId) => {
const originalGridData = panels[panelId].gridData;
return {
...prev,
[panelId]: {
...panels[panelId],
gridData: {
...originalGridData,
// only panels in the targetted section should get pushed down
...(originalGridData.sectionId === 'section1' && {
y: originalGridData.y + 6,
}),
},
},
};
}, {})
);
});
});
describe('Find top left most open space', () => {
it('no other panels', () => {
const { newPanelPlacement, otherPanels } = runPanelPlacementStrategy(
PanelPlacementStrategy.findTopLeftMostOpenSpace,
{ width: 6, height: 6, currentPanels: {} }
);
expect(newPanelPlacement).toEqual({
x: 0,
y: 0,
w: 6,
h: 6,
});
expect(otherPanels).toEqual({});
});
it('top left most space is available', () => {
const { panels } = getMockDashboardPanels(false, {
panels: {
'1': {
gridData: { x: 6, y: 0, w: 6, h: 6, i: '1' },
type: 'lens',
explicitInput: { id: '1' },
},
},
});
const { newPanelPlacement, otherPanels } = runPanelPlacementStrategy(
PanelPlacementStrategy.findTopLeftMostOpenSpace,
{ width: 6, height: 6, currentPanels: panels }
);
expect(newPanelPlacement).toEqual({
x: 0, // placed in the first available spot
y: 0,
w: 6,
h: 6,
});
expect(otherPanels).toEqual(panels); // other panels don't move with this strategy
});
it('panel must be pushed down', () => {
const { panels } = getMockDashboardPanels(true, {
panels: {
'5': {
gridData: { x: 6, y: 0, w: 42, h: 6, i: '5' },
type: 'lens',
explicitInput: { id: '1' },
},
},
});
const { newPanelPlacement, otherPanels } = runPanelPlacementStrategy(
PanelPlacementStrategy.findTopLeftMostOpenSpace,
{ width: 6, height: 6, currentPanels: panels }
);
expect(newPanelPlacement).toEqual({
x: 0,
y: 6,
w: 6,
h: 6,
});
expect(otherPanels).toEqual(panels); // other panels don't move with this strategy
});
it('ignores panels in other sections', () => {
const { panels } = getMockDashboardPanels(true, {
panels: {
'1': {
gridData: { x: 0, y: 0, w: 6, h: 100, i: '1' },
type: 'lens',
explicitInput: { id: '1' },
},
'2': {
gridData: { x: 6, y: 6, w: 42, h: 100, i: '2' },
type: 'lens',
explicitInput: { id: '2' },
},
'6': {
gridData: { x: 0, y: 6, w: 6, h: 6, i: '6', sectionId: 'section1' },
type: 'lens',
explicitInput: { id: '1' },
},
'7': {
gridData: { x: 6, y: 0, w: 42, h: 12, i: '7', sectionId: 'section1' },
type: 'lens',
explicitInput: { id: '1' },
},
},
});
const { newPanelPlacement, otherPanels } = runPanelPlacementStrategy(
PanelPlacementStrategy.findTopLeftMostOpenSpace,
{ width: 6, height: 6, currentPanels: panels, sectionId: 'section1' }
);
expect(newPanelPlacement).toEqual({
x: 0,
y: 12, // maxY is 12 for section1; maxY of 100 in section 0 is ignored
w: 6,
h: 6,
});
expect(otherPanels).toEqual(panels); // other panels don't move with this strategy
});
});
});

View file

@ -15,16 +15,19 @@ import { PanelPlacementProps, PanelPlacementReturn } from './types';
export const runPanelPlacementStrategy = (
strategy: PanelPlacementStrategy,
{ width, height, currentPanels }: PanelPlacementProps
{ width, height, currentPanels, sectionId }: PanelPlacementProps
): PanelPlacementReturn => {
switch (strategy) {
case PanelPlacementStrategy.placeAtTop:
const otherPanels = { ...currentPanels };
for (const [id, panel] of Object.entries(currentPanels)) {
// only consider collisions with panels in the same section
if (!sectionId || panel.gridData.sectionId === sectionId) {
const { gridData, ...currentPanel } = cloneDeep(panel);
const newGridData = { ...gridData, y: gridData.y + height };
otherPanels[id] = { ...currentPanel, gridData: newGridData };
}
}
return {
newPanelPlacement: { x: 0, y: 0, w: width, h: height },
otherPanels,
@ -35,7 +38,10 @@ export const runPanelPlacementStrategy = (
const currentPanelsArray = Object.values(currentPanels);
currentPanelsArray.forEach((panel) => {
// only consider panels in the same section when calculating maxY
if (panel.gridData.sectionId === sectionId) {
maxY = Math.max(panel.gridData.y + panel.gridData.h, maxY);
}
});
// Handle case of empty grid.
@ -52,6 +58,7 @@ export const runPanelPlacementStrategy = (
}
currentPanelsArray.forEach((panel) => {
if (panel.gridData.sectionId === sectionId) {
for (let x = panel.gridData.x; x < panel.gridData.x + panel.gridData.w; x++) {
for (let y = panel.gridData.y; y < panel.gridData.y + panel.gridData.h; y++) {
const row = grid[y];
@ -65,6 +72,7 @@ export const runPanelPlacementStrategy = (
grid[y][x] = 1;
}
}
}
});
for (let y = 0; y < maxY; y++) {

View file

@ -21,13 +21,14 @@ export interface PanelPlacementSettings {
export interface PanelPlacementReturn {
newPanelPlacement: Omit<GridData, 'i'>;
otherPanels: DashboardLayout;
otherPanels: DashboardLayout['panels'];
}
export interface PanelPlacementProps {
width: number;
height: number;
currentPanels: DashboardLayout;
currentPanels: DashboardLayout['panels'];
sectionId?: string; // section where panel is being placed
}
export type GetPanelPlacementSettings<SerializedState extends object = object> = (

View file

@ -7,18 +7,18 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { has } from 'lodash';
import { injectSearchSourceReferences } from '@kbn/data-plugin/public';
import { Filter, Query } from '@kbn/es-query';
import { SavedObjectNotFound } from '@kbn/kibana-utils-plugin/public';
import { has } from 'lodash';
import { cleanFiltersForSerialize } from '../../../utils/clean_filters_for_serialize';
import { getDashboardContentManagementCache } from '..';
import { convertPanelsArrayToPanelMap } from '../../../../common/lib/dashboard_panel_converters';
import { injectReferences } from '../../../../common/dashboard_saved_object/persistable_state/dashboard_saved_object_references';
import { convertPanelsArrayToPanelSectionMaps } from '../../../../common/lib/dashboard_panel_converters';
import type { DashboardGetIn, DashboardGetOut } from '../../../../server/content_management';
import { DASHBOARD_CONTENT_ID } from '../../../utils/telemetry_constants';
import { DEFAULT_DASHBOARD_STATE } from '../../../dashboard_api/default_dashboard_state';
import { cleanFiltersForSerialize } from '../../../utils/clean_filters_for_serialize';
import { DASHBOARD_CONTENT_ID } from '../../../utils/telemetry_constants';
import {
contentManagementService,
dataService,
@ -73,6 +73,7 @@ export const loadDashboardState = async ({
let resolveMeta: DashboardGetOut['meta'];
const cachedDashboard = dashboardContentManagementCache.fetchDashboard(id);
if (cachedDashboard) {
/** If the dashboard exists in the cache, use the cached version to load the dashboard */
({ item: rawDashboardContent, meta: resolveMeta } = cachedDashboard);
@ -149,7 +150,6 @@ export const loadDashboardState = async ({
const query = migrateLegacyQuery(
searchSource?.getOwnField('query') || queryString.getDefaultQuery() // TODO SAVED DASHBOARDS determine if migrateLegacyQuery is still needed
);
const {
refreshInterval,
description,
@ -170,7 +170,9 @@ export const loadDashboardState = async ({
}
: undefined;
const panelMap = convertPanelsArrayToPanelMap(panels ?? []);
const { panels: panelMap, sections: sectionsMap } = convertPanelsArrayToPanelSectionMaps(
panels ?? []
);
return {
managed,
@ -187,6 +189,7 @@ export const loadDashboardState = async ({
panels: panelMap,
query,
title,
sections: sectionsMap,
viewMode: 'view', // dashboards loaded from saved object default to view mode. If it was edited recently, the view mode from session storage will override this.
tags:

View file

@ -11,6 +11,7 @@ export type {
ControlGroupAttributes,
GridData,
DashboardPanel,
DashboardSection,
DashboardAttributes,
DashboardItem,
DashboardGetIn,

View file

@ -229,7 +229,16 @@ const searchSourceSchema = schema.object(
{ defaultValue: {}, unknowns: 'allow' }
);
export const gridDataSchema = schema.object({
const sectionGridDataSchema = schema.object({
y: schema.number({ meta: { description: 'The y coordinate of the section in grid units' } }),
i: schema.maybe(
schema.string({
meta: { description: 'The unique identifier of the section' },
})
),
});
export const panelGridDataSchema = schema.object({
x: schema.number({ meta: { description: 'The x coordinate of the panel in grid units' } }),
y: schema.number({ meta: { description: 'The y coordinate of the panel in grid units' } }),
w: schema.number({
@ -284,7 +293,7 @@ export const panelSchema = schema.object({
),
type: schema.string({ meta: { description: 'The embeddable type' } }),
panelRefName: schema.maybe(schema.string()),
gridData: gridDataSchema,
gridData: panelGridDataSchema,
panelIndex: schema.maybe(
schema.string({
meta: { description: 'The unique ID of the panel.' },
@ -302,6 +311,23 @@ export const panelSchema = schema.object({
),
});
export const sectionSchema = schema.object({
title: schema.string({
meta: { description: 'The title of the section.' },
}),
collapsed: schema.maybe(
schema.boolean({
meta: { description: 'The collapsed state of the section.' },
defaultValue: false,
})
),
gridData: sectionGridDataSchema,
panels: schema.arrayOf(panelSchema, {
meta: { description: 'The panels that belong to the section.' },
defaultValue: [],
}),
});
export const optionsSchema = schema.object({
hidePanelTitles: schema.boolean({
defaultValue: DEFAULT_DASHBOARD_OPTIONS.hidePanelTitles,
@ -402,7 +428,7 @@ export const dashboardAttributesSchema = searchResultsAttributesSchema.extends({
// Dashboard Content
controlGroupInput: schema.maybe(controlGroupInputSchema),
panels: schema.arrayOf(panelSchema, { defaultValue: [] }),
panels: schema.arrayOf(schema.oneOf([panelSchema, sectionSchema]), { defaultValue: [] }),
options: optionsSchema,
version: schema.maybe(schema.number({ meta: { deprecated: true } })),
});
@ -417,14 +443,29 @@ export const referenceSchema = schema.object(
);
const dashboardAttributesSchemaResponse = dashboardAttributesSchema.extends({
// Responses always include the panel index (for panels) and gridData.i (for panels + sections)
panels: schema.arrayOf(
schema.oneOf([
panelSchema.extends({
// Responses always include the panel index and gridData.i
panelIndex: schema.string(),
gridData: gridDataSchema.extends({
gridData: panelGridDataSchema.extends({
i: schema.string(),
}),
}),
sectionSchema.extends({
gridData: sectionGridDataSchema.extends({
i: schema.string(),
}),
panels: schema.arrayOf(
panelSchema.extends({
panelIndex: schema.string(),
gridData: panelGridDataSchema.extends({
i: schema.string(),
}),
})
),
}),
]),
{ defaultValue: [] }
),
});

View file

@ -11,6 +11,7 @@ export type {
ControlGroupAttributes,
GridData,
DashboardPanel,
DashboardSection,
DashboardAttributes,
DashboardItem,
DashboardGetIn,

View file

@ -10,21 +10,11 @@
import { pick } from 'lodash';
import type { SavedObject, SavedObjectReference } from '@kbn/core-saved-objects-api-server';
import type {
DashboardAttributes,
DashboardGetOut,
DashboardItem,
ItemAttrsToSavedObjectParams,
ItemAttrsToSavedObjectReturn,
ItemAttrsToSavedObjectWithTagsParams,
PartialDashboardItem,
SavedObjectToItemReturn,
} from './types';
import type { DashboardSavedObjectAttributes } from '../../dashboard_saved_object';
import type {
ControlGroupAttributes as ControlGroupAttributesV2,
DashboardCrudTypes as DashboardCrudTypesV2,
} from '../../../common/content_management/v2';
import type { DashboardSavedObjectAttributes } from '../../dashboard_saved_object';
import {
transformControlGroupIn,
transformControlGroupOut,
@ -34,6 +24,16 @@ import {
transformSearchSourceIn,
transformSearchSourceOut,
} from './transforms';
import type {
DashboardAttributes,
DashboardGetOut,
DashboardItem,
ItemAttrsToSavedObjectParams,
ItemAttrsToSavedObjectReturn,
ItemAttrsToSavedObjectWithTagsParams,
PartialDashboardItem,
SavedObjectToItemReturn,
} from './types';
export function dashboardAttributesOut(
attributes: DashboardSavedObjectAttributes | Partial<DashboardSavedObjectAttributes>,
@ -46,6 +46,7 @@ export function dashboardAttributesOut(
kibanaSavedObjectMeta,
optionsJSON,
panelsJSON,
sections,
refreshInterval,
timeFrom,
timeRestore,
@ -53,7 +54,6 @@ export function dashboardAttributesOut(
title,
version,
} = attributes;
// Inject any tag names from references into the attributes
let tags: string[] | undefined;
if (getTagNamesFromReferences && references && references.length) {
@ -68,7 +68,7 @@ export function dashboardAttributesOut(
kibanaSavedObjectMeta: transformSearchSourceOut(kibanaSavedObjectMeta),
}),
...(optionsJSON && { options: transformOptionsOut(optionsJSON) }),
...(panelsJSON && { panels: transformPanelsOut(panelsJSON) }),
...((panelsJSON || sections) && { panels: transformPanelsOut(panelsJSON, sections) }),
...(refreshInterval && {
refreshInterval: { pause: refreshInterval.pause, value: refreshInterval.value },
}),
@ -107,7 +107,7 @@ export const getResultV3ToV2 = (result: DashboardGetOut): DashboardCrudTypesV2['
kibanaSavedObjectMeta: transformSearchSourceIn(kibanaSavedObjectMeta),
}),
...(options && { optionsJSON: JSON.stringify(options) }),
panelsJSON: panels ? transformPanelsIn(panels) : '[]',
panelsJSON: panels ? transformPanelsIn(panels, true).panelsJSON : '[]',
refreshInterval,
...(timeFrom && { timeFrom }),
timeRestore,
@ -130,6 +130,8 @@ export const itemAttrsToSavedObject = ({
}: ItemAttrsToSavedObjectParams): ItemAttrsToSavedObjectReturn => {
try {
const { controlGroupInput, kibanaSavedObjectMeta, options, panels, tags, ...rest } = attributes;
const { panelsJSON, sections } = transformPanelsIn(panels);
const soAttributes = {
...rest,
...(controlGroupInput && {
@ -139,8 +141,9 @@ export const itemAttrsToSavedObject = ({
optionsJSON: JSON.stringify(options),
}),
...(panels && {
panelsJSON: transformPanelsIn(panels),
panelsJSON,
}),
...(sections?.length && { sections }),
...(kibanaSavedObjectMeta && {
kibanaSavedObjectMeta: transformSearchSourceIn(kibanaSavedObjectMeta),
}),
@ -217,7 +220,6 @@ export function savedObjectToItem(
version,
managed,
} = savedObject;
try {
const attributesOut = allowedAttributes
? pick(

View file

@ -30,7 +30,7 @@ describe('transformPanelsIn', () => {
},
];
const result = transformPanelsIn(panels as DashboardPanel[]);
expect(result).toEqual(
expect(result.panelsJSON).toEqual(
JSON.stringify([
{
type: 'foo',

View file

@ -9,13 +9,46 @@
import { v4 as uuidv4 } from 'uuid';
import { DashboardSavedObjectAttributes } from '../../../../dashboard_saved_object';
import { DashboardAttributes } from '../../types';
import { isDashboardSection } from '../../../../../common/lib/dashboard_panel_converters';
import {
DashboardSavedObjectAttributes,
SavedDashboardPanel,
SavedDashboardSection,
} from '../../../../dashboard_saved_object';
import { DashboardAttributes, DashboardPanel, DashboardSection } from '../../types';
export function transformPanelsIn(
panels: DashboardAttributes['panels']
): DashboardSavedObjectAttributes['panelsJSON'] {
const updatedPanels = panels.map(({ panelIndex, gridData, panelConfig, ...restPanel }) => {
widgets: DashboardAttributes['panels'] | undefined,
dropSections: boolean = false
): {
panelsJSON: DashboardSavedObjectAttributes['panelsJSON'];
sections: DashboardSavedObjectAttributes['sections'];
} {
const panels: SavedDashboardPanel[] = [];
const sections: SavedDashboardSection[] = [];
widgets?.forEach((widget) => {
if (isDashboardSection(widget)) {
const { panels: sectionPanels, gridData, ...restOfSection } = widget as DashboardSection;
const idx = gridData.i ?? uuidv4();
sections.push({ ...restOfSection, gridData: { ...gridData, i: idx } });
(sectionPanels as DashboardPanel[]).forEach((panel) => {
const transformed = transformPanel(panel);
panels.push({
...transformed,
gridData: { ...transformed.gridData, ...(!dropSections && { sectionId: idx }) },
});
});
} else {
// widget is a panel
panels.push(transformPanel(widget));
}
});
return { panelsJSON: JSON.stringify(panels), sections };
}
function transformPanel(panel: DashboardPanel): SavedDashboardPanel {
const { panelIndex, gridData, panelConfig, ...restPanel } = panel as DashboardPanel;
const idx = panelIndex ?? uuidv4();
return {
...restPanel,
@ -26,7 +59,4 @@ export function transformPanelsIn(
i: idx,
},
};
});
return JSON.stringify(updatedPanels);
}

View file

@ -7,25 +7,51 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { flow } from 'lodash';
import { SavedDashboardPanel } from '../../../../dashboard_saved_object';
import { DashboardAttributes } from '../../types';
import { SavedDashboardPanel, SavedDashboardSection } from '../../../../dashboard_saved_object';
import { DashboardAttributes, DashboardPanel, DashboardSection } from '../../types';
export function transformPanelsOut(panelsJSON: string): DashboardAttributes['panels'] {
return flow(JSON.parse, transformPanelsProperties)(panelsJSON);
export function transformPanelsOut(
panelsJSON: string = '{}',
sections: SavedDashboardSection[] = []
): DashboardAttributes['panels'] {
const panels = JSON.parse(panelsJSON);
const sectionsMap: { [uuid: string]: DashboardPanel | DashboardSection } = sections.reduce(
(prev, section) => {
const sectionId = section.gridData.i;
return { ...prev, [sectionId]: { ...section, panels: [] } };
},
{}
);
panels.forEach((panel: SavedDashboardPanel) => {
const { sectionId } = panel.gridData;
if (sectionId) {
(sectionsMap[sectionId] as DashboardSection).panels.push(transformPanelProperties(panel));
} else {
sectionsMap[panel.panelIndex] = transformPanelProperties(panel);
}
});
return Object.values(sectionsMap);
}
function transformPanelsProperties(panels: SavedDashboardPanel[]) {
return panels.map(
({ embeddableConfig, gridData, id, panelIndex, panelRefName, title, type, version }) => ({
function transformPanelProperties({
embeddableConfig,
gridData,
id,
panelIndex,
panelRefName,
title,
type,
version,
}: SavedDashboardPanel) {
const { sectionId, ...rest } = gridData; // drop section ID, if it exists
return {
gridData: rest,
id,
panelConfig: embeddableConfig,
panelIndex,
panelRefName,
title,
type,
version,
})
);
};
}

View file

@ -20,8 +20,9 @@ import { WithRequiredProperty } from '@kbn/utility-types';
import {
dashboardItemSchema,
controlGroupInputSchema,
gridDataSchema,
panelGridDataSchema,
panelSchema,
sectionSchema,
dashboardAttributesSchema,
dashboardCreateOptionsSchema,
dashboardCreateResultSchema,
@ -43,8 +44,9 @@ export type DashboardPanel = Omit<TypeOf<typeof panelSchema>, 'panelConfig'> & {
panelConfig: TypeOf<typeof panelSchema>['panelConfig'] & { [key: string]: any };
gridData: GridData;
};
export type DashboardSection = TypeOf<typeof sectionSchema>;
export type DashboardAttributes = Omit<TypeOf<typeof dashboardAttributesSchema>, 'panels'> & {
panels: DashboardPanel[];
panels: Array<DashboardPanel | DashboardSection>;
};
export type DashboardItem = TypeOf<typeof dashboardItemSchema>;
@ -54,7 +56,7 @@ export type PartialDashboardItem = Omit<DashboardItem, 'attributes' | 'reference
};
export type ControlGroupAttributes = TypeOf<typeof controlGroupInputSchema>;
export type GridData = WithRequiredProperty<TypeOf<typeof gridDataSchema>, 'i'>;
export type GridData = WithRequiredProperty<TypeOf<typeof panelGridDataSchema>, 'i'>;
export type DashboardGetIn = GetIn<typeof CONTENT_ID>;
export type DashboardGetOut = TypeOf<typeof dashboardGetResultSchema>;

View file

@ -79,6 +79,10 @@ export const createDashboardSavedObjectType = ({
},
optionsJSON: { type: 'text', index: false },
panelsJSON: { type: 'text', index: false },
sections: {
properties: {},
dynamic: false,
},
refreshInterval: {
properties: {
display: { type: 'keyword', index: false, doc_values: false },

View file

@ -11,4 +11,9 @@ export {
createDashboardSavedObjectType,
DASHBOARD_SAVED_OBJECT_TYPE,
} from './dashboard_saved_object';
export type { DashboardSavedObjectAttributes, GridData, SavedDashboardPanel } from './schema';
export type {
DashboardSavedObjectAttributes,
GridData,
SavedDashboardPanel,
SavedDashboardSection,
} from './schema';

View file

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

View file

@ -13,4 +13,5 @@ export {
type DashboardAttributes as DashboardSavedObjectAttributes,
type GridData,
type SavedDashboardPanel,
type SavedDashboardSection,
} from './v2';

View file

@ -7,5 +7,10 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export type { DashboardAttributes, GridData, SavedDashboardPanel } from './types';
export type {
DashboardAttributes,
GridData,
SavedDashboardPanel,
SavedDashboardSection,
} 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 } from './v2';
import { dashboardAttributesSchema, gridDataSchema, sectionSchema } from './v2';
export type DashboardAttributes = TypeOf<typeof dashboardAttributesSchema>;
export type GridData = TypeOf<typeof gridDataSchema>;
@ -33,3 +33,8 @@ export interface SavedDashboardPanel {
*/
version?: string;
}
/**
* A saved dashboard section parsed directly from the Dashboard Attributes
*/
export type SavedDashboardSection = TypeOf<typeof sectionSchema>;

View file

@ -13,6 +13,26 @@ 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()),
@ -23,14 +43,7 @@ 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

@ -39,7 +39,7 @@ export async function plugin(initializerContext: PluginInitializerContext) {
}
export type { DashboardPluginSetup, DashboardPluginStart } from './types';
export type { DashboardAttributes, DashboardPanel } from './content_management';
export type { DashboardAttributes, DashboardPanel, DashboardSection } from './content_management';
export type { DashboardSavedObjectAttributes } from './dashboard_saved_object';
export { PUBLIC_API_PATH } from './api/constants';

View file

@ -12,6 +12,7 @@ import chroma from 'chroma-js';
import rison from '@kbn/rison';
import { DEFAULT_PANEL_WIDTH } from '@kbn/dashboard-plugin/common/content_management/constants';
import { SharedDashboardState } from '@kbn/dashboard-plugin/common/types';
import { DashboardPanel } from '@kbn/dashboard-plugin/server';
import { PIE_CHART_VIS_NAME, AREA_CHART_VIS_NAME } from '../../../page_objects/dashboard_page';
import { FtrProviderContext } from '../../../ftr_provider_context';
@ -231,7 +232,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
(appState: Partial<SharedDashboardState>) => {
log.debug(JSON.stringify(appState, null, ' '));
return {
panels: (appState.panels ?? []).map((panel) => {
panels: (appState.panels ?? []).map((widget) => {
const panel = widget as DashboardPanel;
return {
...panel,
gridData: {
@ -306,7 +308,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
currentUrl,
(appState: Partial<SharedDashboardState>) => {
return {
panels: (appState.panels ?? []).map((panel) => {
panels: (appState.panels ?? []).map((widget) => {
const panel = widget as DashboardPanel;
return {
...panel,
panelConfig: {
@ -350,7 +353,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
currentUrl,
(appState: Partial<SharedDashboardState>) => {
return {
panels: (appState.panels ?? []).map((panel) => {
panels: (appState.panels ?? []).map((widget) => {
const panel = widget as DashboardPanel;
return {
...panel,
panelConfig: {

View file

@ -70,7 +70,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
]);
// Any changes to the number of panels needs to be audited by @elastic/kibana-presentation
expect(panelTypes.length).to.eql(10);
expect(panelTypes.length).to.eql(11);
});
});
}

View file

@ -5,19 +5,19 @@
* 2.0.
*/
import { v4 as uuidv4 } from 'uuid';
import type { SavedObjectsFindResult } from '@kbn/core/server';
import { IContentClient } from '@kbn/content-management-plugin/server/types';
import type { Logger, SavedObjectsFindResult } from '@kbn/core/server';
import { isDashboardSection } from '@kbn/dashboard-plugin/common';
import type { DashboardAttributes, DashboardPanel } from '@kbn/dashboard-plugin/server';
import type { LensAttributes } from '@kbn/lens-embeddable-utils';
import type {
FieldBasedIndexPatternColumn,
GenericIndexPatternColumn,
} from '@kbn/lens-plugin/public';
import type { Logger } from '@kbn/core/server';
import type { LensAttributes } from '@kbn/lens-embeddable-utils';
import type { RelevantPanel, RelatedDashboard } from '@kbn/observability-schema';
import type { DashboardAttributes, DashboardPanel } from '@kbn/dashboard-plugin/server';
import type { InvestigateAlertsClient } from './investigate_alerts_client';
import type { RelatedDashboard, RelevantPanel } from '@kbn/observability-schema';
import { v4 as uuidv4 } from 'uuid';
import type { AlertData } from './alert_data';
import type { InvestigateAlertsClient } from './investigate_alerts_client';
type Dashboard = SavedObjectsFindResult<DashboardAttributes>;
export class RelatedDashboardsClient {
@ -177,19 +177,21 @@ export class RelatedDashboardsClient {
return { dashboards: relevantDashboards };
}
getPanelsByIndex(index: string, panels: DashboardPanel[]): DashboardPanel[] {
getPanelsByIndex(index: string, panels: DashboardAttributes['panels']): DashboardPanel[] {
const panelsByIndex = panels.filter((p) => {
if (isDashboardSection(p)) return false; // filter out sections
const panelIndices = this.getPanelIndices(p);
return panelIndices.has(index);
});
}) as DashboardPanel[]; // filtering with type guard doesn't actually limit type, so need to cast
return panelsByIndex;
}
getPanelsByField(
fields: string[],
panels: DashboardPanel[]
panels: DashboardAttributes['panels']
): Array<{ matchingFields: Set<string>; panel: DashboardPanel }> {
const panelsByField = panels.reduce((acc, p) => {
if (isDashboardSection(p)) return acc; // filter out sections
const panelFields = this.getPanelFields(p);
const matchingFields = fields.filter((f) => panelFields.has(f));
if (matchingFields.length) {

View file

@ -69,7 +69,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
]);
// Any changes to the number of panels needs to be audited by @elastic/kibana-presentation
expect(panelTypes.length).to.eql(20);
expect(panelTypes.length).to.eql(21);
});
});
}