[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": { "panels": {
"default": [], "default": [],
"items": { "items": {
"anyOf": [
{
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"gridData": { "gridData": {
@ -7568,6 +7570,147 @@
], ],
"type": "object" "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" "type": "array"
}, },
"refreshInterval": { "refreshInterval": {
@ -8130,6 +8273,8 @@
"panels": { "panels": {
"default": [], "default": [],
"items": { "items": {
"anyOf": [
{
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"gridData": { "gridData": {
@ -8229,6 +8374,148 @@
], ],
"type": "object" "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" "type": "array"
}, },
"refreshInterval": { "refreshInterval": {
@ -8675,6 +8962,8 @@
"panels": { "panels": {
"default": [], "default": [],
"items": { "items": {
"anyOf": [
{
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"gridData": { "gridData": {
@ -8774,6 +9063,147 @@
], ],
"type": "object" "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" "type": "array"
}, },
"refreshInterval": { "refreshInterval": {
@ -9308,6 +9738,8 @@
"panels": { "panels": {
"default": [], "default": [],
"items": { "items": {
"anyOf": [
{
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"gridData": { "gridData": {
@ -9407,6 +9839,148 @@
], ],
"type": "object" "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" "type": "array"
}, },
"refreshInterval": { "refreshInterval": {
@ -9847,6 +10421,8 @@
"panels": { "panels": {
"default": [], "default": [],
"items": { "items": {
"anyOf": [
{
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"gridData": { "gridData": {
@ -9946,6 +10522,147 @@
], ],
"type": "object" "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" "type": "array"
}, },
"refreshInterval": { "refreshInterval": {

View file

@ -7469,6 +7469,8 @@
"panels": { "panels": {
"default": [], "default": [],
"items": { "items": {
"anyOf": [
{
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"gridData": { "gridData": {
@ -7568,6 +7570,147 @@
], ],
"type": "object" "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" "type": "array"
}, },
"refreshInterval": { "refreshInterval": {
@ -8130,6 +8273,8 @@
"panels": { "panels": {
"default": [], "default": [],
"items": { "items": {
"anyOf": [
{
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"gridData": { "gridData": {
@ -8229,6 +8374,148 @@
], ],
"type": "object" "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" "type": "array"
}, },
"refreshInterval": { "refreshInterval": {
@ -8675,6 +8962,8 @@
"panels": { "panels": {
"default": [], "default": [],
"items": { "items": {
"anyOf": [
{
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"gridData": { "gridData": {
@ -8774,6 +9063,147 @@
], ],
"type": "object" "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" "type": "array"
}, },
"refreshInterval": { "refreshInterval": {
@ -9308,6 +9738,8 @@
"panels": { "panels": {
"default": [], "default": [],
"items": { "items": {
"anyOf": [
{
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"gridData": { "gridData": {
@ -9407,6 +9839,148 @@
], ],
"type": "object" "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" "type": "array"
}, },
"refreshInterval": { "refreshInterval": {
@ -9847,6 +10421,8 @@
"panels": { "panels": {
"default": [], "default": [],
"items": { "items": {
"anyOf": [
{
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"gridData": { "gridData": {
@ -9946,6 +10522,147 @@
], ],
"type": "object" "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" "type": "array"
}, },
"refreshInterval": { "refreshInterval": {

View file

@ -6731,6 +6731,101 @@ paths:
type: boolean type: boolean
panels: panels:
default: [] 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: items:
additionalProperties: false additionalProperties: false
type: object type: object
@ -6807,6 +6902,14 @@ paths:
- gridData - gridData
- panelIndex - panelIndex
type: array type: array
title:
description: The title of the section.
type: string
required:
- title
- gridData
- panels
type: array
refreshInterval: refreshInterval:
additionalProperties: false additionalProperties: false
description: A container for various refresh interval settings description: A container for various refresh interval settings
@ -7201,6 +7304,103 @@ paths:
type: boolean type: boolean
panels: panels:
default: [] 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: items:
additionalProperties: false additionalProperties: false
type: object type: object
@ -7277,6 +7477,13 @@ paths:
- type - type
- gridData - gridData
type: array type: array
title:
description: The title of the section.
type: string
required:
- title
- gridData
type: array
refreshInterval: refreshInterval:
additionalProperties: false additionalProperties: false
description: A container for various refresh interval settings description: A container for various refresh interval settings
@ -7594,6 +7801,101 @@ paths:
type: boolean type: boolean
panels: panels:
default: [] 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: items:
additionalProperties: false additionalProperties: false
type: object type: object
@ -7670,6 +7972,14 @@ paths:
- gridData - gridData
- panelIndex - panelIndex
type: array type: array
title:
description: The title of the section.
type: string
required:
- title
- gridData
- panels
type: array
refreshInterval: refreshInterval:
additionalProperties: false additionalProperties: false
description: A container for various refresh interval settings description: A container for various refresh interval settings
@ -8044,6 +8354,103 @@ paths:
type: boolean type: boolean
panels: panels:
default: [] 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: items:
additionalProperties: false additionalProperties: false
type: object type: object
@ -8120,6 +8527,13 @@ paths:
- type - type
- gridData - gridData
type: array type: array
title:
description: The title of the section.
type: string
required:
- title
- gridData
type: array
refreshInterval: refreshInterval:
additionalProperties: false additionalProperties: false
description: A container for various refresh interval settings description: A container for various refresh interval settings
@ -8433,6 +8847,101 @@ paths:
type: boolean type: boolean
panels: panels:
default: [] 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: items:
additionalProperties: false additionalProperties: false
type: object type: object
@ -8509,6 +9018,14 @@ paths:
- gridData - gridData
- panelIndex - panelIndex
type: array type: array
title:
description: The title of the section.
type: string
required:
- title
- gridData
- panels
type: array
refreshInterval: refreshInterval:
additionalProperties: false additionalProperties: false
description: A container for various refresh interval settings description: A container for various refresh interval settings

View file

@ -8273,6 +8273,101 @@ paths:
type: boolean type: boolean
panels: panels:
default: [] 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: items:
additionalProperties: false additionalProperties: false
type: object type: object
@ -8349,6 +8444,14 @@ paths:
- gridData - gridData
- panelIndex - panelIndex
type: array type: array
title:
description: The title of the section.
type: string
required:
- title
- gridData
- panels
type: array
refreshInterval: refreshInterval:
additionalProperties: false additionalProperties: false
description: A container for various refresh interval settings description: A container for various refresh interval settings
@ -8743,6 +8846,103 @@ paths:
type: boolean type: boolean
panels: panels:
default: [] 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: items:
additionalProperties: false additionalProperties: false
type: object type: object
@ -8819,6 +9019,13 @@ paths:
- type - type
- gridData - gridData
type: array type: array
title:
description: The title of the section.
type: string
required:
- title
- gridData
type: array
refreshInterval: refreshInterval:
additionalProperties: false additionalProperties: false
description: A container for various refresh interval settings description: A container for various refresh interval settings
@ -9136,6 +9343,101 @@ paths:
type: boolean type: boolean
panels: panels:
default: [] 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: items:
additionalProperties: false additionalProperties: false
type: object type: object
@ -9212,6 +9514,14 @@ paths:
- gridData - gridData
- panelIndex - panelIndex
type: array type: array
title:
description: The title of the section.
type: string
required:
- title
- gridData
- panels
type: array
refreshInterval: refreshInterval:
additionalProperties: false additionalProperties: false
description: A container for various refresh interval settings description: A container for various refresh interval settings
@ -9586,6 +9896,103 @@ paths:
type: boolean type: boolean
panels: panels:
default: [] 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: items:
additionalProperties: false additionalProperties: false
type: object type: object
@ -9662,6 +10069,13 @@ paths:
- type - type
- gridData - gridData
type: array type: array
title:
description: The title of the section.
type: string
required:
- title
- gridData
type: array
refreshInterval: refreshInterval:
additionalProperties: false additionalProperties: false
description: A container for various refresh interval settings description: A container for various refresh interval settings
@ -9975,6 +10389,101 @@ paths:
type: boolean type: boolean
panels: panels:
default: [] 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: items:
additionalProperties: false additionalProperties: false
type: object type: object
@ -10051,6 +10560,14 @@ paths:
- gridData - gridData
- panelIndex - panelIndex
type: array type: array
title:
description: The title of the section.
type: string
required:
- title
- gridData
- panels
type: array
refreshInterval: refreshInterval:
additionalProperties: false additionalProperties: false
description: A container for various refresh interval settings description: A container for various refresh interval settings

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -88,9 +88,17 @@ export const moveAction = (
let previousSection; let previousSection;
let targetSectionId: string | undefined = (() => { let targetSectionId: string | undefined = (() => {
if (isResize) return lastSectionId; if (isResize) return lastSectionId;
// early return - target the first "main" section if the panel is dragged above the layout element const layoutRect = gridLayoutElement?.getBoundingClientRect();
if (previewRect.top < (gridLayoutElement?.getBoundingClientRect().top ?? 0)) { // 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`; 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; const previewBottom = previewRect.top + rowHeight;

View file

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

View file

@ -17,6 +17,7 @@ export {
type CanDuplicatePanels, type CanDuplicatePanels,
type CanExpandPanels, type CanExpandPanels,
} from './interfaces/panel_management'; } from './interfaces/panel_management';
export { type CanAddNewSection, apiCanAddNewSection } from './interfaces/can_add_new_section';
export { export {
canTrackContentfulRender, canTrackContentfulRender,
type TrackContentfulRender, 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". * 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 { import {
apiHasUniqueId,
apiPublishesUnsavedChanges,
HasUniqueId, HasUniqueId,
PublishesUnsavedChanges, PublishesUnsavedChanges,
PublishingSubject, PublishingSubject,
apiHasUniqueId,
apiPublishesUnsavedChanges,
} from '@kbn/presentation-publishing'; } from '@kbn/presentation-publishing';
import { combineLatest, debounceTime, map, of, switchMap } from 'rxjs';
export const DEBOUNCE_TIME = 100; export const DEBOUNCE_TIME = 100;
@ -27,8 +26,6 @@ export function childrenUnsavedChanges$<Api extends unknown = unknown>(
) { ) {
return children$.pipe( return children$.pipe(
map((children) => Object.keys(children)), map((children) => Object.keys(children)),
distinctUntilChanged(deepEqual),
// children may change, so make sure we subscribe/unsubscribe with switchMap // children may change, so make sure we subscribe/unsubscribe with switchMap
switchMap((newChildIds: string[]) => { switchMap((newChildIds: string[]) => {
if (newChildIds.length === 0) return of([]); if (newChildIds.length === 0) return of([]);

View file

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

View file

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

View file

@ -26,6 +26,7 @@ const dashboardWithExtractedPanel: ParsedDashboardAttributesWithType = {
}, },
}, },
}, },
sections: {},
}; };
const extractedSavedObjectPanelRef = { const extractedSavedObjectPanelRef = {
@ -47,6 +48,7 @@ const unextractedDashboardState: ParsedDashboardAttributesWithType = {
}, },
}, },
}, },
sections: {},
}; };
describe('inject/extract by reference panel', () => { describe('inject/extract by reference panel', () => {
@ -85,6 +87,7 @@ const dashboardWithExtractedByValuePanel: ParsedDashboardAttributesWithType = {
}, },
}, },
}, },
sections: {},
}; };
const extractedByValueRef = { const extractedByValueRef = {
@ -106,6 +109,7 @@ const unextractedDashboardByValueState: ParsedDashboardAttributesWithType = {
}, },
}, },
}, },
sections: {},
}; };
describe('inject/extract by value panels', () => { 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'; 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 { export interface DashboardPanelMap {
[key: string]: DashboardPanelState; [key: string]: DashboardPanelState;
} }
@ -18,7 +29,7 @@ export interface DashboardPanelMap {
export interface DashboardPanelState<PanelState = object> { export interface DashboardPanelState<PanelState = object> {
type: string; type: string;
explicitInput: PanelState; explicitInput: PanelState;
readonly gridData: GridData; readonly gridData: GridData & { sectionId?: string };
panelRefName?: 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 { EmbeddablePersistableStateService } from '@kbn/embeddable-plugin/common/types';
import { import {
convertPanelMapToPanelsArray, convertPanelSectionMapsToPanelsArray,
convertPanelsArrayToPanelMap, convertPanelsArrayToPanelSectionMaps,
} from '../../lib/dashboard_panel_converters'; } from '../../lib/dashboard_panel_converters';
import { DashboardAttributesAndReferences, ParsedDashboardAttributesWithType } from '../../types'; import { DashboardAttributesAndReferences, ParsedDashboardAttributesWithType } from '../../types';
import type { DashboardAttributes } from '../../../server/content_management'; import type { DashboardAttributes } from '../../../server/content_management';
@ -28,9 +28,11 @@ export interface InjectExtractDeps {
function parseDashboardAttributesWithType({ function parseDashboardAttributesWithType({
panels, panels,
}: DashboardAttributes): ParsedDashboardAttributesWithType { }: DashboardAttributes): ParsedDashboardAttributesWithType {
const { panels: panelsMap, sections } = convertPanelsArrayToPanelSectionMaps(panels); // drop sections
return { return {
type: 'dashboard', type: 'dashboard',
panels: convertPanelsArrayToPanelMap(panels), panels: panelsMap,
sections,
} as ParsedDashboardAttributesWithType; } as ParsedDashboardAttributesWithType;
} }
@ -43,7 +45,10 @@ export function injectReferences(
// inject references back into panels via the Embeddable persistable state service. // inject references back into panels via the Embeddable persistable state service.
const inject = createInject(deps.embeddablePersistableStateService); const inject = createInject(deps.embeddablePersistableStateService);
const injectedState = inject(parsedAttributes, references) as ParsedDashboardAttributesWithType; 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 = { const newAttributes = {
...attributes, ...attributes,
@ -58,7 +63,6 @@ export function extractReferences(
deps: InjectExtractDeps deps: InjectExtractDeps
): DashboardAttributesAndReferences { ): DashboardAttributesAndReferences {
const parsedAttributes = parseDashboardAttributesWithType(attributes); const parsedAttributes = parseDashboardAttributesWithType(attributes);
const panels = parsedAttributes.panels; const panels = parsedAttributes.panels;
const panelMissingType = Object.entries(panels).find( const panelMissingType = Object.entries(panels).find(
@ -73,8 +77,10 @@ export function extractReferences(
references: Reference[]; references: Reference[];
state: ParsedDashboardAttributesWithType; state: ParsedDashboardAttributesWithType;
}; };
const extractedPanels = convertPanelMapToPanelsArray(extractedState.panels); const extractedPanels = convertPanelSectionMapsToPanelsArray(
extractedState.panels,
parsedAttributes.sections
); // sections don't have references
const newAttributes = { const newAttributes = {
...attributes, ...attributes,
panels: extractedPanels, panels: extractedPanels,

View file

@ -14,6 +14,12 @@ export type {
DashboardState, DashboardState,
} from './types'; } 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 { 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". * License v3.0 only", or the "Server Side Public License, v 1".
*/ */
import { v4 } from 'uuid';
import { omit } from 'lodash'; import { omit } from 'lodash';
import { v4 } from 'uuid';
import type { Reference } from '@kbn/content-management-utils'; import type { Reference } from '@kbn/content-management-utils';
import type { DashboardPanelMap } from '..'; import type { DashboardPanelMap, DashboardSectionMap } from '..';
import type { DashboardPanel } from '../../server/content_management'; import type {
DashboardAttributes,
DashboardPanel,
DashboardSection,
} from '../../server/content_management';
import { import {
getReferencesForPanelId, getReferencesForPanelId,
prefixReferencesFromPanel, prefixReferencesFromPanel,
} from '../dashboard_container/persistable_state/dashboard_container_references'; } 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 = {}; const panelsMap: DashboardPanelMap = {};
panels?.forEach((panel, idx) => { const sectionsMap: DashboardSectionMap = {};
panelsMap![panel.panelIndex ?? String(idx)] = {
/**
* 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, type: panel.type,
gridData: panel.gridData, gridData: panel.gridData,
panelRefName: panel.panelRefName, panelRefName: panel.panelRefName,
@ -33,18 +80,24 @@ export const convertPanelsArrayToPanelMap = (panels?: DashboardPanel[]): Dashboa
}, },
version: panel.version, version: panel.version,
}; };
});
return panelsMap;
}; };
export const convertPanelMapToPanelsArray = ( export const convertPanelSectionMapsToPanelsArray = (
panels: DashboardPanelMap, panels: DashboardPanelMap,
sections: DashboardSectionMap,
removeLegacyVersion?: boolean removeLegacyVersion?: boolean
) => { ): DashboardAttributes['panels'] => {
return Object.entries(panels).map(([panelId, panelState]) => { 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 savedObjectId = (panelState.explicitInput as { savedObjectId?: string }).savedObjectId;
const title = (panelState.explicitInput as { title?: string }).title; 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 * 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 * 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 } : {}), ...(!removeLegacyVersion ? { version: panelState.version } : {}),
type: panelState.type, type: panelState.type,
gridData: panelState.gridData, gridData,
panelIndex: panelId, panelIndex: panelId,
panelConfig: omit(panelState.explicitInput, ['id', 'savedObjectId', 'title']), panelConfig: omit(panelState.explicitInput, ['id', 'savedObjectId', 'title']),
...(title !== undefined && { title }), ...(title !== undefined && { title }),
...(savedObjectId !== undefined && { id: savedObjectId }), ...(savedObjectId !== undefined && { id: savedObjectId }),
...(panelState.panelRefName !== undefined && { panelRefName: panelState.panelRefName }), ...(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 { ForwardedDashboardState } from './locator';
import type { DashboardState } from '../types'; import type { DashboardState } from '../types';
import { convertPanelsArrayToPanelMap } from '../lib/dashboard_panel_converters'; import { convertPanelsArrayToPanelSectionMaps } from '../lib/dashboard_panel_converters';
export const loadDashboardHistoryLocationState = ( export const loadDashboardHistoryLocationState = (
getScopedHistory: () => ScopedHistory getScopedHistory: () => ScopedHistory
@ -29,6 +29,6 @@ export const loadDashboardHistoryLocationState = (
return { return {
...restOfState, ...restOfState,
...{ panels: convertPanelsArrayToPanelMap(panels) }, ...convertPanelsArrayToPanelSectionMaps(panels),
}; };
}; };

View file

@ -17,11 +17,12 @@ import type {
ControlGroupSerializedState, ControlGroupSerializedState,
} from '@kbn/controls-plugin/common'; } from '@kbn/controls-plugin/common';
import type { DashboardPanelMap } from './dashboard_container/types'; import type { DashboardPanelMap, DashboardSectionMap } from './dashboard_container/types';
import type { import type {
DashboardAttributes, DashboardAttributes,
DashboardOptions, DashboardOptions,
DashboardPanel, DashboardPanel,
DashboardSection,
} from '../server/content_management'; } from '../server/content_management';
export interface DashboardCapabilities { export interface DashboardCapabilities {
@ -37,6 +38,7 @@ export interface DashboardCapabilities {
export interface ParsedDashboardAttributesWithType { export interface ParsedDashboardAttributesWithType {
id: string; id: string;
panels: DashboardPanelMap; panels: DashboardPanelMap;
sections: DashboardSectionMap;
type: 'dashboard'; type: 'dashboard';
} }
@ -59,6 +61,7 @@ export interface DashboardState extends DashboardSettings {
refreshInterval?: RefreshInterval; refreshInterval?: RefreshInterval;
viewMode: ViewMode; viewMode: ViewMode;
panels: DashboardPanelMap; panels: DashboardPanelMap;
sections: DashboardSectionMap;
/** /**
* Temporary. Currently Dashboards are in charge of providing references to all of their children. * 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 * Do not change type without considering BWC of stored URLs
*/ */
export type SharedDashboardState = Partial< export type SharedDashboardState = Partial<
Omit<DashboardState, 'panels'> & { Omit<DashboardState, 'panels' | 'sections'> & {
controlGroupInput?: DashboardState['controlGroupInput'] & SerializableRecord; controlGroupInput?: DashboardState['controlGroupInput'] & SerializableRecord;
/** /**
@ -87,7 +90,7 @@ export type SharedDashboardState = Partial<
*/ */
controlGroupState?: Partial<ControlGroupRuntimeState> & SerializableRecord; controlGroupState?: Partial<ControlGroupRuntimeState> & SerializableRecord;
panels: DashboardPanel[]; panels: Array<DashboardPanel | DashboardSection>;
references?: DashboardState['references'] & SerializableRecord; 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_EXPAND_PANEL = 'togglePanel';
export const ACTION_EXPORT_CSV = 'ACTION_EXPORT_CSV'; export const ACTION_EXPORT_CSV = 'ACTION_EXPORT_CSV';
export const ACTION_UNLINK_FROM_LIBRARY = 'unlinkFromLibrary'; export const ACTION_UNLINK_FROM_LIBRARY = 'unlinkFromLibrary';
export const ACTION_ADD_SECTION = 'addCollapsibleSection';
export const BADGE_FILTERS_NOTIFICATION = 'ACTION_FILTERS_NOTIFICATION'; 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 { 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 { DashboardStartDependencies } from '../plugin';
import { import {
ACTION_ADD_SECTION,
ACTION_ADD_TO_LIBRARY, ACTION_ADD_TO_LIBRARY,
ACTION_CLONE_PANEL, ACTION_CLONE_PANEL,
ACTION_COPY_TO_DASHBOARD, ACTION_COPY_TO_DASHBOARD,
@ -40,6 +42,12 @@ export const registerActions = async (plugins: DashboardStartDependencies) => {
}); });
uiActions.attachAction(PANEL_NOTIFICATION_TRIGGER, BADGE_FILTERS_NOTIFICATION); 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) { if (share) {
uiActions.registerActionAsync(ACTION_EXPORT_CSV, async () => { uiActions.registerActionAsync(ACTION_EXPORT_CSV, async () => {
const { ExportCSVAction } = await import('../dashboard_renderer/dashboard_module'); 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: '', description: '',
filters: [], filters: [],
panels: {}, panels: {},
sections: {},
title: '', title: '',
tags: [], tags: [],

View file

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

View file

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

View file

@ -7,6 +7,10 @@
* License v3.0 only", or the "Server Side Public License, v 1". * 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 { METRIC_TYPE } from '@kbn/analytics';
import type { Reference } from '@kbn/content-management-utils'; import type { Reference } from '@kbn/content-management-utils';
import { import {
@ -14,12 +18,8 @@ import {
EmbeddablePackageState, EmbeddablePackageState,
PanelNotFoundError, PanelNotFoundError,
} from '@kbn/embeddable-plugin/public'; } from '@kbn/embeddable-plugin/public';
import { import { i18n } from '@kbn/i18n';
CanDuplicatePanels, import { PanelPackage } from '@kbn/presentation-containers';
HasSerializedChildState,
PanelPackage,
PresentationContainer,
} from '@kbn/presentation-containers';
import { import {
SerializedPanelState, SerializedPanelState,
SerializedTitles, SerializedTitles,
@ -32,10 +32,8 @@ import {
shouldLogStateDiff, shouldLogStateDiff,
} from '@kbn/presentation-publishing'; } from '@kbn/presentation-publishing';
import { asyncForEach } from '@kbn/std'; import { asyncForEach } from '@kbn/std';
import { filter, map as lodashMap, max } from 'lodash';
import { BehaviorSubject, Observable, combineLatestWith, debounceTime, map, merge } from 'rxjs'; import type { DashboardSectionMap, DashboardState } from '../../common';
import { v4 } from 'uuid';
import type { DashboardState } from '../../common';
import { DashboardPanelMap } from '../../common'; import { DashboardPanelMap } from '../../common';
import { DEFAULT_PANEL_HEIGHT, DEFAULT_PANEL_WIDTH } from '../../common/content_management'; import { DEFAULT_PANEL_HEIGHT, DEFAULT_PANEL_WIDTH } from '../../common/content_management';
import { prefixReferencesFromPanel } from '../../common/dashboard_container/persistable_state/dashboard_container_references'; 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 { PanelPlacementStrategy } from '../plugin_constants';
import { coreServices, usageCollectionService } from '../services/kibana_services'; import { coreServices, usageCollectionService } from '../services/kibana_services';
import { DASHBOARD_UI_METRIC_ID } from '../utils/telemetry_constants'; 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 type { initializeTrackPanel } from './track_panel';
import { import { DashboardChildState, DashboardChildren, DashboardLayout, DashboardPanel } from './types';
DashboardApi,
DashboardChildState,
DashboardChildren,
DashboardLayout,
DashboardLayoutItem,
} from './types';
export function initializePanelsManager( export function initializeLayoutManager(
incomingEmbeddable: EmbeddablePackageState | undefined, incomingEmbeddable: EmbeddablePackageState | undefined,
initialPanels: DashboardPanelMap, // SERIALIZED STATE ONLY TODO Remove the DashboardPanelMap layer. We could take the Saved Dashboard Panels array here directly. 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>, trackPanel: ReturnType<typeof initializeTrackPanel>,
getReferences: (id: string) => Reference[] 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 // Set up panel state manager
// -------------------------------------------------------------------------------------- // --------------------------------------------------------------------------------------
const children$ = new BehaviorSubject<DashboardChildren>({}); 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. 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. let currentChildState = initialChildState; // childState is the source of truth for the state of each panel.
function deserializePanels(panelMap: DashboardPanelMap) { function deserializeLayout(panelMap: DashboardPanelMap, sectionMap: DashboardSectionMap) {
const layout: DashboardLayout = {}; const layout: DashboardLayout = {
panels: {},
sections: {},
};
const childState: DashboardChildState = {}; const childState: DashboardChildState = {};
Object.keys(panelMap).forEach((uuid) => { Object.keys(sectionMap).forEach((sectionId) => {
const { gridData, explicitInput, type } = panelMap[uuid]; layout.sections[sectionId] = { collapsed: false, ...sectionMap[sectionId] };
layout[uuid] = { type, gridData }; });
childState[uuid] = { Object.keys(panelMap).forEach((panelId) => {
const { gridData, explicitInput, type } = panelMap[panelId];
layout.panels[panelId] = { type, gridData } as DashboardPanel;
childState[panelId] = {
rawState: explicitInput, rawState: explicitInput,
references: getReferences(uuid), references: getReferences(panelId),
}; };
}); });
return { layout, childState }; return { layout, childState };
} }
const serializePanels = (): { references: Reference[]; panels: DashboardPanelMap } => { const serializeLayout = (): {
references: Reference[];
panels: DashboardPanelMap;
sections: DashboardSectionMap;
} => {
const references: Reference[] = []; const references: Reference[] = [];
const layout = layout$.value;
const panels: DashboardPanelMap = {}; const panels: DashboardPanelMap = {};
for (const uuid of Object.keys(layout$.value)) {
for (const panelId of Object.keys(layout.panels)) {
references.push( references.push(
...prefixReferencesFromPanel(uuid, currentChildState[uuid]?.references ?? []) ...prefixReferencesFromPanel(panelId, currentChildState[panelId]?.references ?? [])
); );
panels[uuid] = { panels[panelId] = {
...layout$.value[uuid], ...layout.panels[panelId],
explicitInput: currentChildState[uuid]?.rawState ?? {}, explicitInput: currentChildState[panelId]?.rawState ?? {},
}; };
} }
return { panels, references }; return { panels, sections: { ...layout.sections }, references };
}; };
const resetPanels = (lastSavedPanels: DashboardPanelMap) => { const resetLayout = ({
const { layout: lastSavedLayout, childState: lstSavedChildState } = panels: lastSavedPanels,
deserializePanels(lastSavedPanels); sections: lastSavedSections,
}: DashboardState) => {
const { layout: lastSavedLayout, childState: lastSavedChildState } = deserializeLayout(
lastSavedPanels,
lastSavedSections
);
layout$.next(lastSavedLayout); layout$.next(lastSavedLayout);
currentChildState = lstSavedChildState; currentChildState = lastSavedChildState;
let childrenModified = false; let childrenModified = false;
const currentChildren = { ...children$.value }; const currentChildren = { ...children$.value };
for (const uuid of Object.keys(currentChildren)) { for (const uuid of Object.keys(currentChildren)) {
if (lastSavedLayout[uuid]) { if (lastSavedLayout.panels[uuid]) {
const child = currentChildren[uuid]; const child = currentChildren[uuid];
if (apiPublishesUnsavedChanges(child)) child.resetUnsavedChanges(); if (apiPublishesUnsavedChanges(child)) child.resetUnsavedChanges();
} else { } else {
@ -145,7 +144,7 @@ export function initializePanelsManager(
{ {
width: size?.width ?? DEFAULT_PANEL_WIDTH, width: size?.width ?? DEFAULT_PANEL_WIDTH,
height: size?.height ?? DEFAULT_PANEL_HEIGHT, height: size?.height ?? DEFAULT_PANEL_HEIGHT,
currentPanels: layout$.value, currentPanels: layout$.value.panels,
} }
); );
return { ...newPanelPlacement, i: uuid }; return { ...newPanelPlacement, i: uuid };
@ -154,11 +153,17 @@ export function initializePanelsManager(
const placeNewPanel = async ( const placeNewPanel = async (
uuid: string, uuid: string,
panelPackage: PanelPackage, panelPackage: PanelPackage,
gridData?: DashboardLayoutItem['gridData'] gridData?: DashboardPanel['gridData']
): Promise<DashboardLayout> => { ): Promise<DashboardLayout> => {
const { panelType: type, serializedState } = panelPackage; const { panelType: type, serializedState } = panelPackage;
if (gridData) { 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 getCustomPlacementSettingFunc = getDashboardPanelPlacementSetting(type);
const customPlacementSettings = getCustomPlacementSettingFunc const customPlacementSettings = getCustomPlacementSettingFunc
@ -167,12 +172,18 @@ export function initializePanelsManager(
const { newPanelPlacement, otherPanels } = runPanelPlacementStrategy( const { newPanelPlacement, otherPanels } = runPanelPlacementStrategy(
customPlacementSettings?.strategy ?? PanelPlacementStrategy.findTopLeftMostOpenSpace, customPlacementSettings?.strategy ?? PanelPlacementStrategy.findTopLeftMostOpenSpace,
{ {
currentPanels: layout$.value, currentPanels: layout$.value.panels,
height: customPlacementSettings?.height ?? DEFAULT_PANEL_HEIGHT, height: customPlacementSettings?.height ?? DEFAULT_PANEL_HEIGHT,
width: customPlacementSettings?.width ?? DEFAULT_PANEL_WIDTH, 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) { if (incomingEmbeddable) {
const { serializedState, size, type } = incomingEmbeddable; const { serializedState, size, type } = incomingEmbeddable;
const uuid = incomingEmbeddable.embeddableId ?? v4(); 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 sameType = existingPanel?.type === type;
const gridData = existingPanel ? existingPanel.gridData : placeIncomingPanel(uuid, size); const gridData = existingPanel ? existingPanel.gridData : placeIncomingPanel(uuid, size);
@ -195,14 +206,17 @@ export function initializePanelsManager(
layout$.next({ layout$.next({
...layout$.value, ...layout$.value,
[uuid]: { gridData, type }, panels: {
...layout$.value.panels,
[uuid]: { gridData, type } as DashboardPanel,
},
}); });
trackPanel.setScrollToPanelId(uuid); trackPanel.setScrollToPanelId(uuid);
trackPanel.setHighlightPanelId(uuid); trackPanel.setHighlightPanelId(uuid);
} }
function getDashboardPanelFromId(panelId: string) { function getDashboardPanelFromId(panelId: string) {
const childLayout = layout$.value[panelId]; const childLayout = layout$.value.panels[panelId];
const childApi = children$.value[panelId]; const childApi = children$.value[panelId];
if (!childApi || !childLayout) throw new PanelNotFoundError(); if (!childApi || !childLayout) throw new PanelNotFoundError();
return { return {
@ -216,7 +230,7 @@ export function initializePanelsManager(
async function getPanelTitles(): Promise<string[]> { async function getPanelTitles(): Promise<string[]> {
const titles: 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 childApi = await getChildApi(id);
const title = apiPublishesTitle(childApi) ? getTitle(childApi) : ''; const title = apiPublishesTitle(childApi) ? getTitle(childApi) : '';
if (title) titles.push(title); if (title) titles.push(title);
@ -230,7 +244,7 @@ export function initializePanelsManager(
const addNewPanel = async <ApiType>( const addNewPanel = async <ApiType>(
panelPackage: PanelPackage, panelPackage: PanelPackage,
displaySuccessMessage?: boolean, displaySuccessMessage?: boolean,
gridData?: DashboardLayoutItem['gridData'] gridData?: DashboardPanel['gridData']
) => { ) => {
const uuid = v4(); const uuid = v4();
const { panelType: type, serializedState } = panelPackage; const { panelType: type, serializedState } = panelPackage;
@ -246,17 +260,17 @@ export function initializePanelsManager(
title: getPanelAddedSuccessString(title), title: getPanelAddedSuccessString(title),
'data-test-subj': 'addEmbeddableToDashboardSuccess', 'data-test-subj': 'addEmbeddableToDashboardSuccess',
}); });
}
trackPanel.setScrollToPanelId(uuid); trackPanel.setScrollToPanelId(uuid);
trackPanel.setHighlightPanelId(uuid); trackPanel.setHighlightPanelId(uuid);
}
return (await getChildApi(uuid)) as ApiType; return (await getChildApi(uuid)) as ApiType;
}; };
const removePanel = (uuid: string) => { const removePanel = (uuid: string) => {
const layout = { ...layout$.value }; const panels = { ...layout$.value.panels };
if (layout[uuid]) { if (panels[uuid]) {
delete layout[uuid]; delete panels[uuid];
layout$.next(layout); layout$.next({ ...layout$.value, panels });
} }
const children = { ...children$.value }; const children = { ...children$.value };
if (children[uuid]) { if (children[uuid]) {
@ -269,7 +283,7 @@ export function initializePanelsManager(
}; };
const replacePanel = async (idToRemove: string, panelPackage: PanelPackage) => { 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(); if (!existingGridData) throw new PanelNotFoundError();
removePanel(idToRemove); removePanel(idToRemove);
@ -278,7 +292,7 @@ export function initializePanelsManager(
}; };
const duplicatePanel = async (uuidToDuplicate: string) => { const duplicatePanel = async (uuidToDuplicate: string) => {
const layoutItemToDuplicate = layout$.value[uuidToDuplicate]; const layoutItemToDuplicate = layout$.value.panels[uuidToDuplicate];
const apiToDuplicate = children$.value[uuidToDuplicate]; const apiToDuplicate = children$.value[uuidToDuplicate];
if (!apiToDuplicate || !layoutItemToDuplicate) throw new PanelNotFoundError(); if (!apiToDuplicate || !layoutItemToDuplicate) throw new PanelNotFoundError();
@ -297,15 +311,23 @@ export function initializePanelsManager(
const { newPanelPlacement, otherPanels } = placeClonePanel({ const { newPanelPlacement, otherPanels } = placeClonePanel({
width: layoutItemToDuplicate.gridData.w, width: layoutItemToDuplicate.gridData.w,
height: layoutItemToDuplicate.gridData.h, height: layoutItemToDuplicate.gridData.h,
currentPanels: layout$.value, sectionId: layoutItemToDuplicate.gridData.sectionId,
currentPanels: layout$.value.panels,
placeBesideId: uuidToDuplicate, placeBesideId: uuidToDuplicate,
}); });
layout$.next({ layout$.next({
...layout$.value,
panels: {
...otherPanels, ...otherPanels,
[uuidOfDuplicate]: { [uuidOfDuplicate]: {
gridData: { ...newPanelPlacement, i: uuidOfDuplicate }, gridData: {
...newPanelPlacement,
i: uuidOfDuplicate,
sectionId: layoutItemToDuplicate.gridData.sectionId,
},
type: layoutItemToDuplicate.type, type: layoutItemToDuplicate.type,
}, },
},
}); });
coreServices.notifications.toasts.addSuccess({ coreServices.notifications.toasts.addSuccess({
@ -315,7 +337,7 @@ export function initializePanelsManager(
}; };
const getChildApi = async (uuid: string): Promise<DefaultEmbeddableApi | undefined> => { 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]; if (children$.value[uuid]) return children$.value[uuid];
return new Promise((resolve) => { 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 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(); subscription.unsubscribe();
resolve(undefined); resolve(undefined);
} }
@ -338,25 +360,34 @@ export function initializePanelsManager(
internalApi: { internalApi: {
getSerializedStateForPanel: (uuid: string) => currentChildState[uuid], getSerializedStateForPanel: (uuid: string) => currentChildState[uuid],
layout$, layout$,
resetPanels, reset: resetLayout,
serializePanels, serializeLayout,
startComparing$: ( startComparing$: (
lastSavedState$: BehaviorSubject<DashboardState> lastSavedState$: BehaviorSubject<DashboardState>
): Observable<{ panels?: DashboardPanelMap }> => { ): Observable<{ panels?: DashboardPanelMap; sections?: DashboardSectionMap }> => {
return layout$.pipe( return layout$.pipe(
debounceTime(100), debounceTime(100),
combineLatestWith(lastSavedState$.pipe(map((lastSaved) => lastSaved.panels))), combineLatestWith(
map(([, lastSavedPanels]) => { lastSavedState$.pipe(
const panels = serializePanels().panels; map((lastSaved) => ({ panels: lastSaved.panels, sections: lastSaved.sections }))
if (!arePanelLayoutsEqual(lastSavedPanels, panels)) { )
),
map(([, { panels: lastSavedPanels, sections: lastSavedSections }]) => {
const { panels, sections } = serializeLayout();
if (
!areLayoutsEqual(
{ panels: lastSavedPanels, sections: lastSavedSections },
{ panels, sections }
)
) {
if (shouldLogStateDiff()) { if (shouldLogStateDiff()) {
logStateDiff( logStateDiff(
'dashboard layout', 'dashboard layout',
deserializePanels(lastSavedPanels).layout, deserializeLayout(lastSavedPanels, lastSavedSections).layout,
deserializePanels(panels).layout deserializeLayout(panels, sections).layout
); );
} }
return { panels }; return { panels, sections };
} }
return {}; return {};
}) })
@ -371,8 +402,13 @@ export function initializePanelsManager(
setChildState: (uuid: string, state: SerializedPanelState<object>) => { setChildState: (uuid: string, state: SerializedPanelState<object>) => {
currentChildState[uuid] = state; currentChildState[uuid] = state;
}, },
isSectionCollapsed: (sectionId?: string): boolean => {
const { sections } = layout$.getValue();
return Boolean(sectionId && sections[sectionId].collapsed);
},
}, },
api: { api: {
/** Panels */
children$, children$,
getChildApi, getChildApi,
addNewPanel, addNewPanel,
@ -380,8 +416,39 @@ export function initializePanelsManager(
replacePanel, replacePanel,
duplicatePanel, duplicatePanel,
getDashboardPanelFromId, getDashboardPanelFromId,
getPanelCount: () => Object.keys(layout$.value).length, getPanelCount: () => Object.keys(layout$.value.panels).length,
canRemovePanels: () => trackPanel.expandedPanelId$.value === undefined, 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". * 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>) { export function initializeTrackPanel(untilLoaded: (id: string) => Promise<undefined>) {
const expandedPanelId$ = new BehaviorSubject<string | undefined>(undefined); const expandedPanelId$ = new BehaviorSubject<string | undefined>(undefined);
const focusedPanelId$ = new BehaviorSubject<string | undefined>(undefined); const focusedPanelId$ = new BehaviorSubject<string | undefined>(undefined);
const highlightPanelId$ = new BehaviorSubject<string | undefined>(undefined); const highlightPanelId$ = new BehaviorSubject<string | undefined>(undefined);
const scrollToPanelId$ = new BehaviorSubject<string | undefined>(undefined); const scrollToPanelId$ = new BehaviorSubject<string | undefined>(undefined);
const scrollToBottom$ = new Subject<void>();
let scrollPosition: number | undefined; let scrollPosition: number | undefined;
function setScrollToPanelId(id: string | undefined) { function setScrollToPanelId(id: string | undefined) {
@ -62,15 +63,19 @@ export function initializeTrackPanel(untilLoaded: (id: string) => Promise<undefi
untilLoaded(id).then(() => { untilLoaded(id).then(() => {
setScrollToPanelId(undefined); setScrollToPanelId(undefined);
if (scrollPosition !== undefined) { if (scrollPosition !== undefined) {
window.scrollTo({ top: scrollPosition }); window.scrollTo({ top: scrollPosition, behavior: 'smooth' });
scrollPosition = undefined; scrollPosition = undefined;
} else { } else {
panelRef.scrollIntoView({ block: 'start' }); panelRef.scrollIntoView({ block: 'start', behavior: 'smooth' });
} }
}); });
}, },
scrollToTop: () => { 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) => { setFocusedPanelId: (id: string | undefined) => {
if (focusedPanelId$.value !== id) focusedPanelId$.next(id); 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 { PublishesESQLVariables } from '@kbn/esql-types';
import { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public'; import { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
import { import {
CanAddNewSection,
CanExpandPanels, CanExpandPanels,
HasLastSavedChildState, HasLastSavedChildState,
HasSerializedChildState, HasSerializedChildState,
@ -47,6 +48,8 @@ import { BehaviorSubject, Observable, Subject } from 'rxjs';
import { import {
DashboardLocatorParams, DashboardLocatorParams,
DashboardPanelMap, DashboardPanelMap,
DashboardPanelState,
DashboardSectionMap,
DashboardSettings, DashboardSettings,
DashboardState, DashboardState,
} from '../../common'; } from '../../common';
@ -58,9 +61,12 @@ import {
export const DASHBOARD_API_TYPE = 'dashboard'; 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 { export interface DashboardLayout {
[uuid: string]: DashboardLayoutItem; panels: { [uuid: string]: DashboardPanel }; // partial of DashboardPanelState
sections: DashboardSectionMap;
} }
export interface DashboardChildState { export interface DashboardChildState {
@ -102,6 +108,7 @@ export interface DashboardCreationOptions {
} }
export type DashboardApi = CanExpandPanels & export type DashboardApi = CanExpandPanels &
CanAddNewSection &
HasAppContext & HasAppContext &
HasExecutionContext & HasExecutionContext &
HasLastSavedChildState & HasLastSavedChildState &
@ -151,6 +158,8 @@ export type DashboardApi = CanExpandPanels &
scrollToPanel: (panelRef: HTMLDivElement) => void; scrollToPanel: (panelRef: HTMLDivElement) => void;
scrollToPanelId$: PublishingSubject<string | undefined>; scrollToPanelId$: PublishingSubject<string | undefined>;
scrollToTop: () => void; scrollToTop: () => void;
scrollToBottom: () => void;
scrollToBottom$: Subject<void>;
setFilters: (filters?: Filter[] | undefined) => void; setFilters: (filters?: Filter[] | undefined) => void;
setFullScreenMode: (fullScreenMode: boolean) => void; setFullScreenMode: (fullScreenMode: boolean) => void;
setHighlightPanelId: (id: string | undefined) => void; setHighlightPanelId: (id: string | undefined) => void;
@ -168,5 +177,10 @@ export interface DashboardInternalApi {
layout$: BehaviorSubject<DashboardLayout>; layout$: BehaviorSubject<DashboardLayout>;
registerChildApi: (api: DefaultEmbeddableApi) => void; registerChildApi: (api: DefaultEmbeddableApi) => void;
setControlGroupApi: (controlGroupApi: ControlGroupApi) => 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, tap,
} from 'rxjs'; } from 'rxjs';
import { getDashboardBackupService } from '../services/dashboard_backup_service'; import { getDashboardBackupService } from '../services/dashboard_backup_service';
import { initializePanelsManager } from './panels_manager'; import { initializeLayoutManager } from './layout_manager';
import { initializeSettingsManager } from './settings_manager'; import { initializeSettingsManager } from './settings_manager';
import { DashboardCreationOptions } from './types'; import { DashboardCreationOptions } from './types';
import { DashboardState } from '../../common'; import { DashboardState } from '../../common';
@ -40,7 +40,7 @@ import {
const DEBOUNCE_TIME = 100; const DEBOUNCE_TIME = 100;
export function initializeUnsavedChangesManager({ export function initializeUnsavedChangesManager({
panelsManager, layoutManager,
savedObjectId$, savedObjectId$,
lastSavedState, lastSavedState,
settingsManager, settingsManager,
@ -55,7 +55,7 @@ export function initializeUnsavedChangesManager({
getReferences: (id: string) => Reference[]; getReferences: (id: string) => Reference[];
savedObjectId$: PublishesSavedObjectId['savedObjectId$']; savedObjectId$: PublishesSavedObjectId['savedObjectId$'];
controlGroupManager: ReturnType<typeof initializeControlGroupManager>; controlGroupManager: ReturnType<typeof initializeControlGroupManager>;
panelsManager: ReturnType<typeof initializePanelsManager>; layoutManager: ReturnType<typeof initializeLayoutManager>;
viewModeManager: ReturnType<typeof initializeViewModeManager>; viewModeManager: ReturnType<typeof initializeViewModeManager>;
settingsManager: ReturnType<typeof initializeSettingsManager>; settingsManager: ReturnType<typeof initializeSettingsManager>;
unifiedSearchManager: ReturnType<typeof initializeUnifiedSearchManager>; unifiedSearchManager: ReturnType<typeof initializeUnifiedSearchManager>;
@ -75,14 +75,14 @@ export function initializeUnsavedChangesManager({
// references injected while loading dashboard saved object in loadDashboardState // references injected while loading dashboard saved object in loadDashboardState
const lastSavedState$ = new BehaviorSubject<DashboardState>(lastSavedState); const lastSavedState$ = new BehaviorSubject<DashboardState>(lastSavedState);
const hasPanelChanges$ = childrenUnsavedChanges$(panelsManager.api.children$).pipe( const hasPanelChanges$ = childrenUnsavedChanges$(layoutManager.api.children$).pipe(
tap((childrenWithChanges) => { 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) { 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; if (!hasUnsavedChanges || !childApi || !apiHasSerializableState(childApi)) continue;
panelsManager.internalApi.setChildState(uuid, childApi.serializeState()); layoutManager.internalApi.setChildState(uuid, childApi.serializeState());
} }
}), }),
map((childrenWithChanges) => { map((childrenWithChanges) => {
@ -93,7 +93,7 @@ export function initializeUnsavedChangesManager({
const dashboardStateChanges$: Observable<Partial<DashboardState>> = combineLatest([ const dashboardStateChanges$: Observable<Partial<DashboardState>> = combineLatest([
settingsManager.internalApi.startComparing$(lastSavedState$), settingsManager.internalApi.startComparing$(lastSavedState$),
unifiedSearchManager.internalApi.startComparing$(lastSavedState$), unifiedSearchManager.internalApi.startComparing$(lastSavedState$),
panelsManager.internalApi.startComparing$(lastSavedState$), layoutManager.internalApi.startComparing$(lastSavedState$),
]).pipe( ]).pipe(
map(([settings, unifiedSearch, panels]) => { map(([settings, unifiedSearch, panels]) => {
return { ...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. // always back up view mode. This allows us to know which Dashboards were last changed while in edit mode.
dashboardStateToBackup.viewMode = viewMode; dashboardStateToBackup.viewMode = viewMode;
// Backup latest state from children that have unsaved changes // Backup latest state from children that have unsaved changes
if (hasPanelChanges || hasControlGroupChanges) { if (hasPanelChanges || hasControlGroupChanges) {
const { panels, references } = panelsManager.internalApi.serializePanels(); const { panels, references } = layoutManager.internalApi.serializeLayout();
const { controlGroupInput, controlGroupReferences } = const { controlGroupInput, controlGroupReferences } =
controlGroupManager.internalApi.serializeControlGroup(); controlGroupManager.internalApi.serializeControlGroup();
// dashboardStateToBackup.references will be used instead of savedObjectResult.references // dashboardStateToBackup.references will be used instead of savedObjectResult.references
@ -169,9 +168,10 @@ export function initializeUnsavedChangesManager({
return { return {
api: { api: {
asyncResetToLastSavedState: async () => { asyncResetToLastSavedState: async () => {
panelsManager.internalApi.resetPanels(lastSavedState$.value.panels); const savedState = lastSavedState$.value;
unifiedSearchManager.internalApi.reset(lastSavedState$.value); layoutManager.internalApi.reset(savedState);
settingsManager.internalApi.reset(lastSavedState$.value); unifiedSearchManager.internalApi.reset(savedState);
settingsManager.internalApi.reset(savedState);
await controlGroupManager.api.controlGroupApi$.value?.resetUnsavedChanges(); await controlGroupManager.api.controlGroupApi$.value?.resetUnsavedChanges();
}, },

View file

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

View file

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

View file

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

View file

@ -7,17 +7,22 @@
* License v3.0 only", or the "Server Side Public License, v 1". * 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 { History } from 'history';
import _ from 'lodash'; import _ from 'lodash';
import { skip } from 'rxjs'; import { skip } from 'rxjs';
import semverSatisfies from 'semver/functions/satisfies'; 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 { 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 type { SavedDashboardPanel } from '../../../server/dashboard_saved_object';
import { DashboardApi } from '../../dashboard_api/types'; import { DashboardApi } from '../../dashboard_api/types';
import { migrateLegacyQuery } from '../../services/dashboard_content_management_service/lib/load_dashboard_state'; 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. * 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 * @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) { for (const panel of panels) {
if ('panels' in panel) {
// can't use isDashboardSection type guard because of SavedDashboardPanel type
continue; // ignore sections
}
if ( if (
!panel.gridData || !panel.gridData ||
!((panel as DashboardPanel).panelConfig || (panel as SavedDashboardPanel).embeddableConfig) || !((panel as DashboardPanel).panelConfig || (panel as SavedDashboardPanel).embeddableConfig) ||
@ -45,13 +56,15 @@ export const isPanelVersionTooOld = (panels: DashboardPanel[] | SavedDashboardPa
return false; return false;
}; };
function getPanelsMap(panels?: DashboardPanel[]): DashboardPanelMap | undefined { function getPanelSectionMaps(
panels?: Array<DashboardPanel | DashboardSection>
): { panels: DashboardPanelMap; sections: DashboardSectionMap } | undefined {
if (!panels) { if (!panels) {
return undefined; return undefined;
} }
if (panels.length === 0) { if (panels.length === 0) {
return {}; return { panels: {}, sections: {} };
} }
if (isPanelVersionTooOld(panels)) { if (isPanelVersionTooOld(panels)) {
@ -71,7 +84,7 @@ function getPanelsMap(panels?: DashboardPanel[]): DashboardPanelMap | undefined
return panel; return panel;
}); });
return convertPanelsArrayToPanelMap(standardizedPanels); return convertPanelsArrayToPanelSectionMaps(standardizedPanels);
} }
/** /**
@ -85,8 +98,7 @@ export const loadAndRemoveDashboardState = (
); );
if (!rawAppStateInUrl) return {}; if (!rawAppStateInUrl) return {};
const converted = getPanelSectionMaps(rawAppStateInUrl.panels);
const panelsMap = getPanelsMap(rawAppStateInUrl.panels);
const nextUrl = replaceUrlHashQuery(window.location.href, (hashQuery) => { const nextUrl = replaceUrlHashQuery(window.location.href, (hashQuery) => {
delete hashQuery[DASHBOARD_STATE_STORAGE_KEY]; delete hashQuery[DASHBOARD_STATE_STORAGE_KEY];
@ -100,7 +112,8 @@ export const loadAndRemoveDashboardState = (
controlGroupInput: serializeRuntimeState(rawAppStateInUrl.controlGroupState).rawState, controlGroupInput: serializeRuntimeState(rawAppStateInUrl.controlGroupState).rawState,
} }
: {}), : {}),
...(panelsMap ? { panels: panelsMap } : {}), ...(converted?.panels ? { panels: converted.panels } : {}),
...(converted?.sections ? { sections: converted.sections } : {}),
...(rawAppStateInUrl.query ? { query: migrateLegacyQuery(rawAppStateInUrl.query) } : {}), ...(rawAppStateInUrl.query ? { query: migrateLegacyQuery(rawAppStateInUrl.query) } : {}),
}; };

View file

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

View file

@ -17,3 +17,4 @@ export { ExportCSVAction } from '../dashboard_actions/export_csv_action';
export { AddToLibraryAction } from '../dashboard_actions/library_add_action'; export { AddToLibraryAction } from '../dashboard_actions/library_add_action';
export { UnlinkFromLibraryAction } from '../dashboard_actions/library_unlink_action'; export { UnlinkFromLibraryAction } from '../dashboard_actions/library_unlink_action';
export { CopyToDashboardAction } from '../dashboard_actions/copy_to_dashboard_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 React from 'react';
import { EuiThemeProvider } from '@elastic/eui';
import { EuiThemeProvider } from '@elastic/eui';
import { useBatchedPublishingSubjects as mockUseBatchedPublishingSubjects } from '@kbn/presentation-publishing'; 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 { import {
DashboardContext, DashboardContext,
useDashboardApi as mockUseDashboardApi, useDashboardApi as mockUseDashboardApi,
} from '../../dashboard_api/use_dashboard_api'; } from '../../dashboard_api/use_dashboard_api';
import { DashboardInternalContext } from '../../dashboard_api/use_dashboard_internal_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 { DashboardGrid } from './dashboard_grid';
import type { Props as DashboardGridItemProps } from './dashboard_grid_item'; import type { Props as DashboardGridItemProps } from './dashboard_grid_item';
import { RenderResult, act, render, waitFor } from '@testing-library/react';
jest.mock('./dashboard_grid_item', () => { jest.mock('./dashboard_grid_item', () => {
return { 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 = ( const verifyElementHasClass = (
component: RenderResult, component: RenderResult,
elementSelector: string, elementSelector: string,
@ -79,10 +68,16 @@ const verifyElementHasClass = (
expect(itemToCheck!.classList.contains(className)).toBe(true); 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({ const { api, internalApi } = buildMockDashboardApi({
overrides: { overrides: {
panels, panels,
...(sections && { sections }),
}, },
}); });
const component = render( const component = render(
@ -95,35 +90,45 @@ const createAndMountDashboardGrid = async (panels: DashboardPanelMap = PANELS) =
</EuiThemeProvider> </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 // wait for first render
await waitFor(() => { 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 () => { describe('DashboardGrid', () => {
await createAndMountDashboardGrid(PANELS); test('renders', async () => {
await createAndMountDashboardGrid();
}); });
test('renders DashboardGrid with no visualizations', async () => { describe('panels', () => {
await createAndMountDashboardGrid({}); test('renders with no visualizations', async () => {
await createAndMountDashboardGrid();
}); });
test('DashboardGrid removes panel when removed from container', async () => { test('removes panel when removed from container', async () => {
const { dashboardApi, component } = await createAndMountDashboardGrid(PANELS); const { dashboardApi, component } = await createAndMountDashboardGrid();
// remove panel // remove panel
await act(async () => { await act(async () => {
dashboardApi.removePanel('1'); dashboardApi.removePanel('2');
await new Promise((resolve) => setTimeout(resolve, 1)); await new Promise((resolve) => setTimeout(resolve, 1));
}); });
expect(component.getAllByTestId('dashboardGridItem').length).toBe(1); expect(component.getAllByTestId('dashboardGridItem').length).toBe(1);
}); });
test('DashboardGrid renders expanded panel', async () => { test('renders expanded panel', async () => {
const { dashboardApi, component } = await createAndMountDashboardGrid(); const { dashboardApi, component } = await createAndMountDashboardGrid();
// maximize panel // maximize panel
@ -148,7 +153,7 @@ test('DashboardGrid renders expanded panel', async () => {
verifyElementHasClass(component, '#mockDashboardGridItem_2', 'regularPanel'); verifyElementHasClass(component, '#mockDashboardGridItem_2', 'regularPanel');
}); });
test('DashboardGrid renders focused panel', async () => { test('renders focused panel', async () => {
const { dashboardApi, component } = await createAndMountDashboardGrid(); const { dashboardApi, component } = await createAndMountDashboardGrid();
const overlayMock = { const overlayMock = {
onClose: new Promise<void>((resolve) => { onClose: new Promise<void>((resolve) => {
@ -176,3 +181,134 @@ test('DashboardGrid renders focused panel', async () => {
verifyElementHasClass(component, '#mockDashboardGridItem_1', 'regularPanel'); verifyElementHasClass(component, '#mockDashboardGridItem_1', 'regularPanel');
verifyElementHasClass(component, '#mockDashboardGridItem_2', '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 { useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react'; import { css } from '@emotion/react';
import { useAppFixedViewport } from '@kbn/core-rendering-browser'; 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 { useBatchedPublishingSubjects } from '@kbn/presentation-publishing';
import classNames from 'classnames'; 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 { 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 { DashboardLayout } from '../../dashboard_api/types';
import { useDashboardApi } from '../../dashboard_api/use_dashboard_api'; import { useDashboardApi } from '../../dashboard_api/use_dashboard_api';
import { useDashboardInternalApi } from '../../dashboard_api/use_dashboard_internal_api'; import { useDashboardInternalApi } from '../../dashboard_api/use_dashboard_internal_api';
import { import {
DEFAULT_DASHBOARD_DRAG_TOP_OFFSET,
DASHBOARD_GRID_HEIGHT, DASHBOARD_GRID_HEIGHT,
DASHBOARD_MARGIN_SIZE, DASHBOARD_MARGIN_SIZE,
DEFAULT_DASHBOARD_DRAG_TOP_OFFSET,
} from './constants'; } from './constants';
import { DashboardGridItem } from './dashboard_grid_item'; import { DashboardGridItem } from './dashboard_grid_item';
import { useLayoutStyles } from './use_layout_styles'; import { useLayoutStyles } from './use_layout_styles';
@ -34,11 +35,13 @@ export const DashboardGrid = ({
}) => { }) => {
const dashboardApi = useDashboardApi(); const dashboardApi = useDashboardApi();
const dashboardInternalApi = useDashboardInternalApi(); const dashboardInternalApi = useDashboardInternalApi();
const layoutRef = useRef<HTMLDivElement | null>(null);
const layoutStyles = useLayoutStyles(); const layoutStyles = useLayoutStyles();
const panelRefs = useRef<{ [panelId: string]: React.Ref<HTMLDivElement> }>({}); const panelRefs = useRef<{ [panelId: string]: React.Ref<HTMLDivElement> }>({});
const { euiTheme } = useEuiTheme(); const { euiTheme } = useEuiTheme();
const [topOffset, setTopOffset] = useState(DEFAULT_DASHBOARD_DRAG_TOP_OFFSET);
const [expandedPanelId, layout, useMargins, viewMode] = useBatchedPublishingSubjects( const [expandedPanelId, layout, useMargins, viewMode] = useBatchedPublishingSubjects(
dashboardApi.expandedPanelId$, dashboardApi.expandedPanelId$,
dashboardInternalApi.layout$, dashboardInternalApi.layout$,
@ -46,57 +49,93 @@ export const DashboardGrid = ({
dashboardApi.viewMode$ dashboardApi.viewMode$
); );
useEffect(() => {
setTopOffset(
dashboardContainerRef?.current?.getBoundingClientRect().top ??
DEFAULT_DASHBOARD_DRAG_TOP_OFFSET
);
}, [dashboardContainerRef]);
const appFixedViewport = useAppFixedViewport(); const appFixedViewport = useAppFixedViewport();
const currentLayout: GridLayoutData = useMemo(() => { const currentLayout: GridLayoutData = useMemo(() => {
const singleRow: GridLayoutData = {}; const newLayout: GridLayoutData = {};
Object.keys(layout.sections).forEach((sectionId) => {
Object.keys(layout).forEach((panelId) => { const section = layout.sections[sectionId];
const gridData = layout[panelId].gridData; newLayout[sectionId] = {
singleRow[panelId] = { 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, id: panelId,
row: gridData.y, row: gridData.y,
column: gridData.x, column: gridData.x,
width: gridData.w, width: gridData.w,
height: gridData.h, height: gridData.h,
} as GridPanelData;
if (gridData.sectionId) {
(newLayout[gridData.sectionId] as GridSectionData).panels[panelId] = basePanel;
} else {
newLayout[panelId] = {
...basePanel,
type: 'panel', type: 'panel',
}; };
}
// update `data-grid-row` attribute for all panels because it is used for some styling // update `data-grid-row` attribute for all panels because it is used for some styling
const panelRef = panelRefs.current[panelId]; const panelRef = panelRefs.current[panelId];
if (typeof panelRef !== 'function' && panelRef?.current) { if (typeof panelRef !== 'function' && panelRef?.current) {
panelRef.current.setAttribute('data-grid-row', `${gridData.y}`); panelRef.current.setAttribute('data-grid-row', `${gridData.y}`);
} }
}); });
return newLayout;
return singleRow;
}, [layout]); }, [layout]);
const onLayoutChange = useCallback( const onLayoutChange = useCallback(
(newLayout: GridLayoutData) => { (newLayout: GridLayoutData) => {
if (viewMode !== 'edit') return; if (viewMode !== 'edit') return;
const currentPanels = dashboardInternalApi.layout$.getValue(); const currLayout = dashboardInternalApi.layout$.getValue();
const updatedPanels: DashboardLayout = Object.values(newLayout).reduce( const updatedLayout: DashboardLayout = {
(updatedPanelsAcc, widget) => { sections: {},
panels: {},
};
Object.values(newLayout).forEach((widget) => {
if (widget.type === 'section') { if (widget.type === 'section') {
return updatedPanelsAcc; // sections currently aren't supported updatedLayout.sections[widget.id] = {
} collapsed: widget.isCollapsed,
updatedPanelsAcc[widget.id] = { title: widget.title,
...currentPanels[widget.id], id: widget.id,
gridData: { gridData: {
i: widget.id, i: widget.id,
y: widget.row, 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)) { } else {
dashboardInternalApi.layout$.next(updatedPanels); // 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] [dashboardInternalApi.layout$, viewMode]
@ -104,13 +143,14 @@ export const DashboardGrid = ({
const renderPanelContents = useCallback( const renderPanelContents = useCallback(
(id: string, setDragHandles: (refs: Array<HTMLElement | null>) => void) => { (id: string, setDragHandles: (refs: Array<HTMLElement | null>) => void) => {
const currentPanels = dashboardInternalApi.layout$.getValue(); const panels = dashboardInternalApi.layout$.getValue().panels;
if (!currentPanels[id]) return; if (!panels[id]) return;
if (!panelRefs.current[id]) { if (!panelRefs.current[id]) {
panelRefs.current[id] = React.createRef(); panelRefs.current[id] = React.createRef();
} }
const type = currentPanels[id].type;
const type = panels[id].type;
return ( return (
<DashboardGridItem <DashboardGridItem
ref={panelRefs.current[id]} ref={panelRefs.current[id]}
@ -120,13 +160,45 @@ export const DashboardGrid = ({
setDragHandles={setDragHandles} setDragHandles={setDragHandles}
appFixedViewport={appFixedViewport} appFixedViewport={appFixedViewport}
dashboardContainerRef={dashboardContainerRef} 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$] [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(() => { const memoizedgridLayout = useMemo(() => {
// memoizing this component reduces the number of times it gets re-rendered to a minimum // memoizing this component reduces the number of times it gets re-rendered to a minimum
return ( return (
@ -137,9 +209,7 @@ export const DashboardGrid = ({
gutterSize: useMargins ? DASHBOARD_MARGIN_SIZE : 0, gutterSize: useMargins ? DASHBOARD_MARGIN_SIZE : 0,
rowHeight: DASHBOARD_GRID_HEIGHT, rowHeight: DASHBOARD_GRID_HEIGHT,
columnCount: DASHBOARD_GRID_COLUMN_COUNT, columnCount: DASHBOARD_GRID_COLUMN_COUNT,
keyboardDragTopLimit: keyboardDragTopLimit: topOffset,
dashboardContainerRef?.current?.getBoundingClientRect().top ||
DEFAULT_DASHBOARD_DRAG_TOP_OFFSET,
}} }}
useCustomDragHandle={true} useCustomDragHandle={true}
renderPanelContents={renderPanelContents} renderPanelContents={renderPanelContents}
@ -156,7 +226,7 @@ export const DashboardGrid = ({
onLayoutChange, onLayoutChange,
expandedPanelId, expandedPanelId,
viewMode, viewMode,
dashboardContainerRef, topOffset,
]); ]);
const { dashboardClasses, dashboardStyles } = useMemo(() => { const { dashboardClasses, dashboardStyles } = useMemo(() => {
@ -183,8 +253,18 @@ export const DashboardGrid = ({
}, [useMargins, viewMode, expandedPanelId, euiTheme.levels.toast]); }, [useMargins, viewMode, expandedPanelId, euiTheme.levels.toast]);
return ( return (
<div className={dashboardClasses} css={dashboardStyles}> <div ref={layoutRef} className={dashboardClasses} css={dashboardStyles}>
{memoizedgridLayout} {memoizedgridLayout}
</div> </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; 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)}; background-color: ${transparentize(euiTheme.colors.vis.euiColorVis0, 0.2)};
} }
@ -91,6 +95,50 @@ export const useLayoutStyles = () => {
transition: none; 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]); }, [euiTheme]);

View file

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

View file

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

View file

@ -12,7 +12,7 @@ import { BehaviorSubject } from 'rxjs';
import { DashboardStart } from './plugin'; import { DashboardStart } from './plugin';
import { DashboardState } from '../common/types'; import { DashboardState } from '../common/types';
import { getDashboardApi } from './dashboard_api/get_dashboard_api'; 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>; export type Start = jest.Mocked<DashboardStart>;
@ -126,24 +126,67 @@ export function getSampleDashboardState(overrides?: Partial<DashboardState>): Da
timeRestore: false, timeRestore: false,
viewMode: 'view', viewMode: 'view',
panels: {}, panels: {},
sections: {},
...overrides, ...overrides,
}; };
} }
export function getSampleDashboardPanel( export function getMockDashboardPanels(
overrides: Partial<DashboardPanelState> & { withSections: boolean = false,
explicitInput: { id: string }; overrides?: {
type: string; panels?: DashboardPanelMap;
sections?: DashboardSectionMap;
} }
): DashboardPanelState { ): { panels: DashboardPanelMap; sections: DashboardSectionMap } {
return { const panels = {
gridData: { '1': {
h: 15, gridData: { x: 0, y: 0, w: 6, h: 6, i: '1' },
w: 15, type: 'lens',
x: 0, explicitInput: { id: '1' },
y: 0,
i: overrides.explicitInput.id,
}, },
...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 { PanelNotFoundError } from '@kbn/embeddable-plugin/public';
import { cloneDeep, forOwn } from 'lodash'; import { cloneDeep, forOwn } from 'lodash';
import { DASHBOARD_GRID_COLUMN_COUNT } from '../../common/content_management'; import { DASHBOARD_GRID_COLUMN_COUNT } from '../../common/content_management';
import type { GridData } from '../../server/content_management'; import type { GridData } from '../../server/content_management';
import { DashboardLayoutItem } from '../dashboard_api/types';
import { PanelPlacementProps, PanelPlacementReturn } from './types'; import { PanelPlacementProps, PanelPlacementReturn } from './types';
import { DashboardPanel } from '../dashboard_api/types';
interface IplacementDirection { interface IplacementDirection {
grid: Omit<GridData, 'i'>; grid: Omit<GridData, 'i'>;
@ -42,6 +43,7 @@ function comparePanels(a: GridData, b: GridData): number {
export function placeClonePanel({ export function placeClonePanel({
width, width,
height, height,
sectionId,
currentPanels, currentPanels,
placeBesideId, placeBesideId,
}: PanelPlacementProps & { placeBesideId: string }): PanelPlacementReturn { }: PanelPlacementProps & { placeBesideId: string }): PanelPlacementReturn {
@ -51,8 +53,11 @@ export function placeClonePanel({
} }
const beside = panelToPlaceBeside.gridData; const beside = panelToPlaceBeside.gridData;
const otherPanelGridData: 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); otherPanelGridData.push(panel.gridData);
}
}); });
const possiblePlacementDirections: IplacementDirection[] = [ const possiblePlacementDirections: IplacementDirection[] = [
@ -109,8 +114,11 @@ export function placeClonePanel({
for (let j = position + 1; j < grid.length; j++) { for (let j = position + 1; j < grid.length; j++) {
originalPositionInTheGrid = grid[j].i; originalPositionInTheGrid = grid[j].i;
const { gridData, ...movedPanel } = cloneDeep(otherPanels[originalPositionInTheGrid]); 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 }; const newGridData = { ...gridData, y: gridData.y + diff };
otherPanels[originalPositionInTheGrid] = { ...movedPanel, gridData: newGridData }; otherPanels[originalPositionInTheGrid] = { ...movedPanel, gridData: newGridData };
} }
}
return { newPanelPlacement: bottomPlacement.grid, otherPanels }; 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 = ( export const runPanelPlacementStrategy = (
strategy: PanelPlacementStrategy, strategy: PanelPlacementStrategy,
{ width, height, currentPanels }: PanelPlacementProps { width, height, currentPanels, sectionId }: PanelPlacementProps
): PanelPlacementReturn => { ): PanelPlacementReturn => {
switch (strategy) { switch (strategy) {
case PanelPlacementStrategy.placeAtTop: case PanelPlacementStrategy.placeAtTop:
const otherPanels = { ...currentPanels }; const otherPanels = { ...currentPanels };
for (const [id, panel] of Object.entries(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 { gridData, ...currentPanel } = cloneDeep(panel);
const newGridData = { ...gridData, y: gridData.y + height }; const newGridData = { ...gridData, y: gridData.y + height };
otherPanels[id] = { ...currentPanel, gridData: newGridData }; otherPanels[id] = { ...currentPanel, gridData: newGridData };
} }
}
return { return {
newPanelPlacement: { x: 0, y: 0, w: width, h: height }, newPanelPlacement: { x: 0, y: 0, w: width, h: height },
otherPanels, otherPanels,
@ -35,7 +38,10 @@ export const runPanelPlacementStrategy = (
const currentPanelsArray = Object.values(currentPanels); const currentPanelsArray = Object.values(currentPanels);
currentPanelsArray.forEach((panel) => { 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); maxY = Math.max(panel.gridData.y + panel.gridData.h, maxY);
}
}); });
// Handle case of empty grid. // Handle case of empty grid.
@ -52,6 +58,7 @@ export const runPanelPlacementStrategy = (
} }
currentPanelsArray.forEach((panel) => { currentPanelsArray.forEach((panel) => {
if (panel.gridData.sectionId === sectionId) {
for (let x = panel.gridData.x; x < panel.gridData.x + panel.gridData.w; x++) { 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++) { for (let y = panel.gridData.y; y < panel.gridData.y + panel.gridData.h; y++) {
const row = grid[y]; const row = grid[y];
@ -65,6 +72,7 @@ export const runPanelPlacementStrategy = (
grid[y][x] = 1; grid[y][x] = 1;
} }
} }
}
}); });
for (let y = 0; y < maxY; y++) { for (let y = 0; y < maxY; y++) {

View file

@ -21,13 +21,14 @@ export interface PanelPlacementSettings {
export interface PanelPlacementReturn { export interface PanelPlacementReturn {
newPanelPlacement: Omit<GridData, 'i'>; newPanelPlacement: Omit<GridData, 'i'>;
otherPanels: DashboardLayout; otherPanels: DashboardLayout['panels'];
} }
export interface PanelPlacementProps { export interface PanelPlacementProps {
width: number; width: number;
height: number; height: number;
currentPanels: DashboardLayout; currentPanels: DashboardLayout['panels'];
sectionId?: string; // section where panel is being placed
} }
export type GetPanelPlacementSettings<SerializedState extends object = object> = ( 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". * License v3.0 only", or the "Server Side Public License, v 1".
*/ */
import { has } from 'lodash';
import { injectSearchSourceReferences } from '@kbn/data-plugin/public'; import { injectSearchSourceReferences } from '@kbn/data-plugin/public';
import { Filter, Query } from '@kbn/es-query'; import { Filter, Query } from '@kbn/es-query';
import { SavedObjectNotFound } from '@kbn/kibana-utils-plugin/public'; import { SavedObjectNotFound } from '@kbn/kibana-utils-plugin/public';
import { has } from 'lodash';
import { cleanFiltersForSerialize } from '../../../utils/clean_filters_for_serialize';
import { getDashboardContentManagementCache } from '..'; 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 { 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 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 { 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 { import {
contentManagementService, contentManagementService,
dataService, dataService,
@ -73,6 +73,7 @@ export const loadDashboardState = async ({
let resolveMeta: DashboardGetOut['meta']; let resolveMeta: DashboardGetOut['meta'];
const cachedDashboard = dashboardContentManagementCache.fetchDashboard(id); const cachedDashboard = dashboardContentManagementCache.fetchDashboard(id);
if (cachedDashboard) { if (cachedDashboard) {
/** If the dashboard exists in the cache, use the cached version to load the dashboard */ /** If the dashboard exists in the cache, use the cached version to load the dashboard */
({ item: rawDashboardContent, meta: resolveMeta } = cachedDashboard); ({ item: rawDashboardContent, meta: resolveMeta } = cachedDashboard);
@ -149,7 +150,6 @@ export const loadDashboardState = async ({
const query = migrateLegacyQuery( const query = migrateLegacyQuery(
searchSource?.getOwnField('query') || queryString.getDefaultQuery() // TODO SAVED DASHBOARDS determine if migrateLegacyQuery is still needed searchSource?.getOwnField('query') || queryString.getDefaultQuery() // TODO SAVED DASHBOARDS determine if migrateLegacyQuery is still needed
); );
const { const {
refreshInterval, refreshInterval,
description, description,
@ -170,7 +170,9 @@ export const loadDashboardState = async ({
} }
: undefined; : undefined;
const panelMap = convertPanelsArrayToPanelMap(panels ?? []); const { panels: panelMap, sections: sectionsMap } = convertPanelsArrayToPanelSectionMaps(
panels ?? []
);
return { return {
managed, managed,
@ -187,6 +189,7 @@ export const loadDashboardState = async ({
panels: panelMap, panels: panelMap,
query, query,
title, 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. 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: tags:

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -9,13 +9,46 @@
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { DashboardSavedObjectAttributes } from '../../../../dashboard_saved_object'; import { isDashboardSection } from '../../../../../common/lib/dashboard_panel_converters';
import { DashboardAttributes } from '../../types'; import {
DashboardSavedObjectAttributes,
SavedDashboardPanel,
SavedDashboardSection,
} from '../../../../dashboard_saved_object';
import { DashboardAttributes, DashboardPanel, DashboardSection } from '../../types';
export function transformPanelsIn( export function transformPanelsIn(
panels: DashboardAttributes['panels'] widgets: DashboardAttributes['panels'] | undefined,
): DashboardSavedObjectAttributes['panelsJSON'] { dropSections: boolean = false
const updatedPanels = panels.map(({ panelIndex, gridData, panelConfig, ...restPanel }) => { ): {
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(); const idx = panelIndex ?? uuidv4();
return { return {
...restPanel, ...restPanel,
@ -26,7 +59,4 @@ export function transformPanelsIn(
i: idx, i: idx,
}, },
}; };
});
return JSON.stringify(updatedPanels);
} }

View file

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

View file

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

View file

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

View file

@ -11,4 +11,9 @@ export {
createDashboardSavedObjectType, createDashboardSavedObjectType,
DASHBOARD_SAVED_OBJECT_TYPE, DASHBOARD_SAVED_OBJECT_TYPE,
} from './dashboard_saved_object'; } 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". * 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'; export { dashboardSavedObjectSchema } from './latest';

View file

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

View file

@ -7,5 +7,10 @@
* License v3.0 only", or the "Server Side Public License, v 1". * 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'; export { controlGroupInputSchema, dashboardAttributesSchema } from './v2';

View file

@ -9,7 +9,7 @@
import { Serializable } from '@kbn/utility-types'; import { Serializable } from '@kbn/utility-types';
import { TypeOf } from '@kbn/config-schema'; 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 DashboardAttributes = TypeOf<typeof dashboardAttributesSchema>;
export type GridData = TypeOf<typeof gridDataSchema>; export type GridData = TypeOf<typeof gridDataSchema>;
@ -33,3 +33,8 @@ export interface SavedDashboardPanel {
*/ */
version?: string; 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, dashboardAttributesSchema as dashboardAttributesSchemaV1,
} from '../v1'; } 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( export const controlGroupInputSchema = controlGroupInputSchemaV1.extends(
{ {
showApplySelections: schema.maybe(schema.boolean()), showApplySelections: schema.maybe(schema.boolean()),
@ -23,14 +43,7 @@ export const controlGroupInputSchema = controlGroupInputSchemaV1.extends(
export const dashboardAttributesSchema = dashboardAttributesSchemaV1.extends( export const dashboardAttributesSchema = dashboardAttributesSchemaV1.extends(
{ {
controlGroupInput: schema.maybe(controlGroupInputSchema), controlGroupInput: schema.maybe(controlGroupInputSchema),
sections: schema.maybe(schema.arrayOf(sectionSchema)),
}, },
{ unknowns: 'ignore' } { 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 { 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 type { DashboardSavedObjectAttributes } from './dashboard_saved_object';
export { PUBLIC_API_PATH } from './api/constants'; export { PUBLIC_API_PATH } from './api/constants';

View file

@ -12,6 +12,7 @@ import chroma from 'chroma-js';
import rison from '@kbn/rison'; import rison from '@kbn/rison';
import { DEFAULT_PANEL_WIDTH } from '@kbn/dashboard-plugin/common/content_management/constants'; import { DEFAULT_PANEL_WIDTH } from '@kbn/dashboard-plugin/common/content_management/constants';
import { SharedDashboardState } from '@kbn/dashboard-plugin/common/types'; 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 { PIE_CHART_VIS_NAME, AREA_CHART_VIS_NAME } from '../../../page_objects/dashboard_page';
import { FtrProviderContext } from '../../../ftr_provider_context'; import { FtrProviderContext } from '../../../ftr_provider_context';
@ -231,7 +232,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
(appState: Partial<SharedDashboardState>) => { (appState: Partial<SharedDashboardState>) => {
log.debug(JSON.stringify(appState, null, ' ')); log.debug(JSON.stringify(appState, null, ' '));
return { return {
panels: (appState.panels ?? []).map((panel) => { panels: (appState.panels ?? []).map((widget) => {
const panel = widget as DashboardPanel;
return { return {
...panel, ...panel,
gridData: { gridData: {
@ -306,7 +308,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
currentUrl, currentUrl,
(appState: Partial<SharedDashboardState>) => { (appState: Partial<SharedDashboardState>) => {
return { return {
panels: (appState.panels ?? []).map((panel) => { panels: (appState.panels ?? []).map((widget) => {
const panel = widget as DashboardPanel;
return { return {
...panel, ...panel,
panelConfig: { panelConfig: {
@ -350,7 +353,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
currentUrl, currentUrl,
(appState: Partial<SharedDashboardState>) => { (appState: Partial<SharedDashboardState>) => {
return { return {
panels: (appState.panels ?? []).map((panel) => { panels: (appState.panels ?? []).map((widget) => {
const panel = widget as DashboardPanel;
return { return {
...panel, ...panel,
panelConfig: { 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 // 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. * 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 { 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 { import type {
FieldBasedIndexPatternColumn, FieldBasedIndexPatternColumn,
GenericIndexPatternColumn, GenericIndexPatternColumn,
} from '@kbn/lens-plugin/public'; } from '@kbn/lens-plugin/public';
import type { Logger } from '@kbn/core/server'; import type { RelatedDashboard, RelevantPanel } from '@kbn/observability-schema';
import type { LensAttributes } from '@kbn/lens-embeddable-utils'; import { v4 as uuidv4 } from 'uuid';
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 { AlertData } from './alert_data'; import type { AlertData } from './alert_data';
import type { InvestigateAlertsClient } from './investigate_alerts_client';
type Dashboard = SavedObjectsFindResult<DashboardAttributes>; type Dashboard = SavedObjectsFindResult<DashboardAttributes>;
export class RelatedDashboardsClient { export class RelatedDashboardsClient {
@ -177,19 +177,21 @@ export class RelatedDashboardsClient {
return { dashboards: relevantDashboards }; return { dashboards: relevantDashboards };
} }
getPanelsByIndex(index: string, panels: DashboardPanel[]): DashboardPanel[] { getPanelsByIndex(index: string, panels: DashboardAttributes['panels']): DashboardPanel[] {
const panelsByIndex = panels.filter((p) => { const panelsByIndex = panels.filter((p) => {
if (isDashboardSection(p)) return false; // filter out sections
const panelIndices = this.getPanelIndices(p); const panelIndices = this.getPanelIndices(p);
return panelIndices.has(index); return panelIndices.has(index);
}); }) as DashboardPanel[]; // filtering with type guard doesn't actually limit type, so need to cast
return panelsByIndex; return panelsByIndex;
} }
getPanelsByField( getPanelsByField(
fields: string[], fields: string[],
panels: DashboardPanel[] panels: DashboardAttributes['panels']
): Array<{ matchingFields: Set<string>; panel: DashboardPanel }> { ): Array<{ matchingFields: Set<string>; panel: DashboardPanel }> {
const panelsByField = panels.reduce((acc, p) => { const panelsByField = panels.reduce((acc, p) => {
if (isDashboardSection(p)) return acc; // filter out sections
const panelFields = this.getPanelFields(p); const panelFields = this.getPanelFields(p);
const matchingFields = fields.filter((f) => panelFields.has(f)); const matchingFields = fields.filter((f) => panelFields.has(f));
if (matchingFields.length) { 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 // 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);
}); });
}); });
} }