[Dashboard] Public CRUD API MVP (#193067)

Closes #[192618](https://github.com/elastic/kibana/issues/192618)

Adds public CRUD+List endpoints for the Dashboards API. 

The schema for the endpoints are generated from Content Management
schemas so that the RPC and Public APIs use the same schemas for CRUD
operations. A new version (v3) has been added to the Dashboards content
management specification that decouples Content from Saved Objects using
a translation layer in Content Management. When retrieving a saved
object the Content Management layer parses and validates the panelJSON,
optionsListJSON, and savedSearchJSON properties against the defines
schema and passes the translated content to the consumer (user interface
or API).

When writing a saved object, the Content Management layer serializes
(`JSON.stringify`) the Content object into the saved object schema. So
the saved object schema continues to store as stringified JSON, but the
user interface and public API see and use the JSON objects.

These planned features are out of scope for this PR and may be added in
subsequent PRs.
1) https://github.com/elastic/kibana/issues/192758
2) https://github.com/elastic/kibana/issues/192622

Reviewers, please test both UI and endpoints. 

# cURL examples: 

First, `yarn start --no-base-path`. Assumes `elastic:changeme` is the
username:password.

## Create

<details>
<summary>Create an empty dashboard with the minimum required
properties</summary>

```
curl  -X POST \
  'http://localhost:5601/api/dashboards/dashboard' \
  --user elastic:changeme \
  --header 'Accept: */*' \
  --header 'elastic-api-version: 2023-10-31' \
  --header 'kbn-xsrf: true' \
  --header 'Content-Type: application/json' \
  --data-raw '{
    "attributes": { "title": "my empty dashboard" }
  }'
```

</details>

<details>
<summary>Create a dashboard of a specific ID with some ES|QL
panels</summary>


```
curl  -X POST \
  'http://localhost:5601/api/dashboards/dashboard/foo-123' \
  --user elastic:changeme \
  --header 'Accept: */*' \
  --header 'elastic-api-version: 2023-10-31' \
  --header 'kbn-xsrf: true' \
  --header 'Content-Type: application/json' \
  --data-raw '{
  "attributes": {
    "description": "",
    "panels": [
      {
        "panelConfig": {
          "attributes": {
            "references": [],
            "state": {
              "adHocDataViews": {
                "32eec79c9673ab1b9265f3e422e8f952778f02c82eaf13147a9c0ba86290337a": {
                  "allowHidden": false,
                  "allowNoIndex": false,
                  "fieldFormats": {},
                  "id": "32eec79c9673ab1b9265f3e422e8f952778f02c82eaf13147a9c0ba86290337a",
                  "name": "kibana_sample_data_ecommerce",
                  "runtimeFieldMap": {},
                  "sourceFilters": [],
                  "title": "kibana_sample_data_ecommerce",
                  "type": "esql"
                }
              },
              "datasourceStates": {
                "textBased": {
                  "indexPatternRefs": [
                    {
                      "id": "32eec79c9673ab1b9265f3e422e8f952778f02c82eaf13147a9c0ba86290337a",
                      "title": "kibana_sample_data_ecommerce"
                    }
                  ],
                  "layers": {
                    "44866844-8fca-482a-a769-006e7d029b9b": {
                      "columns": [
                        {
                          "columnId": "6376af5c-fdd1-4d72-a3ec-5686b5049664",
                          "fieldName": "customer_gender",
                          "meta": {
                            "esType": "keyword",
                            "type": "string"
                          }
                        },
                        {
                          "columnId": "a2e3e039-dff6-4893-9c9d-9f0a816207dd",
                          "fieldName": "taxless_total_price",
                          "meta": {
                            "esType": "double",
                            "type": "number"
                          }
                        }
                      ],
                      "index": "32eec79c9673ab1b9265f3e422e8f952778f02c82eaf13147a9c0ba86290337a",
                      "query": {
                        "esql": "FROM kibana_sample_data_ecommerce | LIMIT 100"
                      }
                    },
                    "781db49e-f4f1-42e0-975f-7118d2ef7a18": {
                      "columns": [],
                      "index": "32eec79c9673ab1b9265f3e422e8f952778f02c82eaf13147a9c0ba86290337a",
                      "query": {
                        "esql": "FROM kibana_sample_data_ecommerce | LIMIT 100"
                      }
                    }
                  }
                }
              },
              "filters": [],
              "query": {
                "esql": "FROM kibana_sample_data_ecommerce | LIMIT 100"
              },
              "visualization": {
                "layers": [
                  {
                    "categoryDisplay": "default",
                    "colorMapping": {
                      "assignments": [],
                      "colorMode": {
                        "type": "categorical"
                      },
                      "paletteId": "eui_amsterdam_color_blind",
                      "specialAssignments": [
                        {
                          "color": {
                            "type": "loop"
                          },
                          "rule": {
                            "type": "other"
                          },
                          "touched": false
                        }
                      ]
                    },
                    "layerId": "44866844-8fca-482a-a769-006e7d029b9b",
                    "layerType": "data",
                    "legendDisplay": "default",
                    "metrics": [
                      "a2e3e039-dff6-4893-9c9d-9f0a816207dd"
                    ],
                    "nestedLegend": false,
                    "numberDisplay": "percent",
                    "primaryGroups": [
                      "6376af5c-fdd1-4d72-a3ec-5686b5049664"
                    ]
                  }
                ],
                "shape": "pie"
              }
            },
            "title": "Table category & category.keyword & currency & customer_first_name & customer_first_name.keyword",
            "type": "lens",
            "visualizationType": "lnsPie"
          }
        },
        "gridData": {
          "h": 15,
          "w": 24,
          "x": 0,
          "y": 0
        },
        "type": "lens"
      },
      {
        "panelConfig": {
          "attributes": {
            "references": [],
            "state": {
              "adHocDataViews": {
                "e3465e67bdeced2befff9f9dca7ecf9c48504cad68a10efd881f4c7dd5ade28a": {
                  "allowHidden": false,
                  "allowNoIndex": false,
                  "fieldFormats": {},
                  "id": "e3465e67bdeced2befff9f9dca7ecf9c48504cad68a10efd881f4c7dd5ade28a",
                  "name": "kibana_sample_data_logs",
                  "runtimeFieldMap": {},
                  "sourceFilters": [],
                  "timeFieldName": "@timestamp",
                  "title": "kibana_sample_data_logs",
                  "type": "esql"
                }
              },
              "datasourceStates": {
                "textBased": {
                  "indexPatternRefs": [
                    {
                      "id": "e3465e67bdeced2befff9f9dca7ecf9c48504cad68a10efd881f4c7dd5ade28a",
                      "timeField": "@timestamp",
                      "title": "kibana_sample_data_logs"
                    }
                  ],
                  "layers": {
                    "2e3f211d-289f-4a24-87bb-1ccacd678adb": {
                      "columns": [
                        {
                          "columnId": "AVG(machine.ram)",
                          "fieldName": "AVG(machine.ram)",
                          "inMetricDimension": true,
                          "meta": {
                            "esType": "double",
                            "type": "number"
                          }
                        },
                        {
                          "columnId": "machine.os.keyword",
                          "fieldName": "machine.os.keyword",
                          "meta": {
                            "esType": "keyword",
                            "type": "string"
                          }
                        }
                      ],
                      "index": "e3465e67bdeced2befff9f9dca7ecf9c48504cad68a10efd881f4c7dd5ade28a",
                      "query": {
                        "esql": "FROM kibana_sample_data_logs| STATS AVG(machine.ram) BY machine.os.keyword "
                      },
                      "timeField": "@timestamp"
                    }
                  }
                }
              },
              "filters": [],
              "query": {
                "esql": "FROM kibana_sample_data_logs| STATS AVG(machine.ram) BY machine.os.keyword "
              },
              "visualization": {
                "axisTitlesVisibilitySettings": {
                  "x": true,
                  "yLeft": true,
                  "yRight": true
                },
                "fittingFunction": "None",
                "gridlinesVisibilitySettings": {
                  "x": true,
                  "yLeft": true,
                  "yRight": true
                },
                "labelsOrientation": {
                  "x": 0,
                  "yLeft": 0,
                  "yRight": 0
                },
                "layers": [
                  {
                    "accessors": [
                      "AVG(machine.ram)"
                    ],
                    "colorMapping": {
                      "assignments": [],
                      "colorMode": {
                        "type": "categorical"
                      },
                      "paletteId": "eui_amsterdam_color_blind",
                      "specialAssignments": [
                        {
                          "color": {
                            "type": "loop"
                          },
                          "rule": {
                            "type": "other"
                          },
                          "touched": false
                        }
                      ]
                    },
                    "layerId": "2e3f211d-289f-4a24-87bb-1ccacd678adb",
                    "layerType": "data",
                    "seriesType": "bar_stacked",
                    "xAccessor": "machine.os.keyword"
                  }
                ],
                "legend": {
                  "isVisible": true,
                  "position": "right"
                },
                "preferredSeriesType": "bar_stacked",
                "tickLabelsVisibilitySettings": {
                  "x": true,
                  "yLeft": true,
                  "yRight": true
                },
                "valueLabels": "hide"
              }
            },
            "title": "Bar vertical stacked",
            "type": "lens",
            "visualizationType": "lnsXY"
          }
        },
        "gridData": {
          "h": 15,
          "w": 24,
          "x": 24,
          "y": 0
        },
        "type": "lens"
      },
      {
        "panelConfig": {
          "attributes": {
            "references": [],
            "state": {
              "adHocDataViews": {
                "5d671714fc025d173ee40f0825b86d59b6e432344593b725be28f1f8f17a8a03": {
                  "allowHidden": false,
                  "allowNoIndex": false,
                  "fieldFormats": {},
                  "id": "5d671714fc025d173ee40f0825b86d59b6e432344593b725be28f1f8f17a8a03",
                  "name": "kibana_sample_data_flights",
                  "runtimeFieldMap": {},
                  "sourceFilters": [],
                  "title": "kibana_sample_data_flights",
                  "type": "esql"
                }
              },
              "datasourceStates": {
                "textBased": {
                  "indexPatternRefs": [
                    {
                      "id": "5d671714fc025d173ee40f0825b86d59b6e432344593b725be28f1f8f17a8a03",
                      "title": "kibana_sample_data_flights"
                    }
                  ],
                  "layers": {
                    "4451c40f-b3ef-464e-b3d4-b10469f65c2a": {
                      "columns": [
                        {
                          "columnId": "AvgDelayMins",
                          "fieldName": "AvgDelayMins",
                          "inMetricDimension": true,
                          "meta": {
                            "esType": "double",
                            "type": "number"
                          }
                        },
                        {
                          "columnId": "Carrier",
                          "fieldName": "Carrier",
                          "meta": {
                            "esType": "keyword",
                            "type": "string"
                          }
                        }
                      ],
                      "index": "5d671714fc025d173ee40f0825b86d59b6e432344593b725be28f1f8f17a8a03",
                      "query": {
                        "esql": "FROM kibana_sample_data_flights| STATS AvgDelayMins = AVG(FlightDelayMin) BY Carrier "
                      }
                    }
                  }
                }
              },
              "filters": [],
              "query": {
                "esql": "FROM kibana_sample_data_flights| STATS AvgDelayMins = AVG(FlightDelayMin) BY Carrier "
              },
              "visualization": {
                "breakdownByAccessor": "Carrier",
                "layerId": "4451c40f-b3ef-464e-b3d4-b10469f65c2a",
                "layerType": "data",
                "metricAccessor": "AvgDelayMins",
                "palette": {
                  "name": "status",
                  "params": {
                    "colorStops": [],
                    "continuity": "all",
                    "maxSteps": 5,
                    "name": "status",
                    "progression": "fixed",
                    "rangeMax": 100,
                    "rangeMin": 0,
                    "rangeType": "percent",
                    "reverse": false,
                    "steps": 3,
                    "stops": [
                      {
                        "color": "#209280",
                        "stop": 33.33
                      },
                      {
                        "color": "#d6bf57",
                        "stop": 66.66
                      },
                      {
                        "color": "#cc5642",
                        "stop": 100
                      }
                    ]
                  },
                  "type": "palette"
                }
              }
            },
            "title": "Bar vertical stacked",
            "type": "lens",
            "visualizationType": "lnsMetric"
          }
        },
        "gridData": {
          "h": 15,
          "w": 24,
          "x": 0,
          "y": 15
        },
        "type": "lens"
      }
    ],
    "timeRestore": false,
    "title": "several es|ql panels",
    "version": 3
  }
}'
```
</details>

<details>
<summary>Create a dashboard with a Links panel</summary>


```
curl  -X POST \
  'http://localhost:5601/api/dashboards/dashboard' \
  --user elastic:changeme \
  --header 'Accept: */*' \
  --header 'elastic-api-version: 2023-10-31' \
  --header 'kbn-xsrf: true' \
  --header 'Content-Type: application/json' \
  --data-raw '{
  "attributes": {
    "panels": [
      {
        "panelConfig": {
          "attributes": {
            "layout": "vertical",
            "links": [
              {
                "destinationRefName": "link_1981a00f-8120-4c80-b37f-ed38969afe09_dashboard",
                "id": "1981a00f-8120-4c80-b37f-ed38969afe09",
                "order": 0,
                "type": "dashboardLink"
              },
              {
                "destinationRefName": "link_f2e1a75c-fbca-4f41-a290-d5d89a60a797_dashboard",
                "id": "f2e1a75c-fbca-4f41-a290-d5d89a60a797",
                "order": 1,
                "type": "dashboardLink"
              },
              {
                "destination": "https://example.com",
                "id": "63342ea6-f686-42b2-a526-ec0bcf4476b0",
                "order": 2,
                "type": "externalLink"
              }
            ]
          },
          "enhancements": {},
          "id": "abbbaedc-62f5-46ee-9d17-8367dcf4f52b"
        },
        "gridData": {
          "h": 7,
          "i": "abbbaedc-62f5-46ee-9d17-8367dcf4f52b",
          "w": 8,
          "x": 0,
          "y": 0
        },
        "panelIndex": "abbbaedc-62f5-46ee-9d17-8367dcf4f52b",
        "type": "links"
      }
    ],
    "timeRestore": false,
    "title": "a links panel",
    "version": 3
  },
  "references": [
    {
      "id": "722b74f0-b882-11e8-a6d9-e546fe2bba5f",
      "name": "abbbaedc-62f5-46ee-9d17-8367dcf4f52b:link_1981a00f-8120-4c80-b37f-ed38969afe09_dashboard",
      "type": "dashboard"
    },
    {
      "id": "edf84fe0-e1a0-11e7-b6d5-4dc382ef7f5b",
      "name": "abbbaedc-62f5-46ee-9d17-8367dcf4f52b:link_f2e1a75c-fbca-4f41-a290-d5d89a60a797_dashboard",
      "type": "dashboard"
    }
  ]
}'
```
</details>

<details>
<summary>Create a dashboard with a Maps panel</summary>


```
curl  -X POST \
  'http://localhost:5601/api/dashboards/dashboard' \
  --user elastic:changeme \
  --header 'Accept: */*' \
  --header 'elastic-api-version: 2023-10-31' \
  --header 'kbn-xsrf: true' \
  --header 'Content-Type: application/json' \
  --data-raw '{
  "attributes": {
    "panels": [
      {
        "panelConfig": {
          "attributes": {
            "description": "",
            "layerListJSON": "[{\"locale\":\"autoselect\",\"sourceDescriptor\":{\"type\":\"EMS_TMS\",\"isAutoSelect\":true,\"lightModeDefault\":\"road_map_desaturated\"},\"id\":\"db63eee8-3dfc-48c6-8c8b-7f2c4e32329d\",\"label\":null,\"minZoom\":0,\"maxZoom\":24,\"alpha\":1,\"visible\":true,\"style\":{\"type\":\"EMS_VECTOR_TILE\",\"color\":\"\"},\"includeInFitToBounds\":true,\"type\":\"EMS_VECTOR_TILE\"},{\"sourceDescriptor\":{\"geoField\":\"geoip.location\",\"scalingType\":\"MVT\",\"id\":\"9ee192e4-18f0-41b2-b8b7-89eb91d0e529\",\"type\":\"ES_SEARCH\",\"applyGlobalQuery\":true,\"applyGlobalTime\":true,\"applyForceRefresh\":true,\"filterByMapBounds\":true,\"tooltipProperties\":[],\"sortField\":\"\",\"sortOrder\":\"desc\",\"topHitsGroupByTimeseries\":false,\"topHitsSplitField\":\"\",\"topHitsSize\":1,\"indexPatternRefName\":\"layer_1_source_index_pattern\"},\"id\":\"65710bbc-f41c-4fe7-b0c3-a6dbc0613220\",\"label\":null,\"minZoom\":0,\"maxZoom\":24,\"alpha\":0.75,\"visible\":true,\"style\":{\"type\":\"VECTOR\",\"properties\":{\"icon\":{\"type\":\"STATIC\",\"options\":{\"value\":\"marker\"}},\"fillColor\":{\"type\":\"DYNAMIC\",\"options\":{\"color\":\"Blues\",\"colorCategory\":\"palette_0\",\"field\":{\"name\":\"category.keyword\",\"origin\":\"source\"},\"fieldMetaOptions\":{\"isEnabled\":true,\"sigma\":3},\"type\":\"CATEGORICAL\"}},\"lineColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#41937c\"}},\"lineWidth\":{\"type\":\"STATIC\",\"options\":{\"size\":0}},\"iconSize\":{\"type\":\"STATIC\",\"options\":{\"size\":6}},\"iconOrientation\":{\"type\":\"STATIC\",\"options\":{\"orientation\":0}},\"labelText\":{\"type\":\"STATIC\",\"options\":{\"value\":\"\"}},\"labelColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#000000\"}},\"labelSize\":{\"type\":\"STATIC\",\"options\":{\"size\":14}},\"labelZoomRange\":{\"options\":{\"useLayerZoomRange\":true,\"minZoom\":0,\"maxZoom\":24}},\"labelBorderColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#FFFFFF\"}},\"symbolizeAs\":{\"options\":{\"value\":\"circle\"}},\"labelBorderSize\":{\"options\":{\"size\":\"SMALL\"}},\"labelPosition\":{\"options\":{\"position\":\"CENTER\"}}},\"isTimeAware\":true},\"includeInFitToBounds\":true,\"type\":\"MVT_VECTOR\",\"joins\":[],\"disableTooltips\":false}]",
            "mapStateJSON": "{\"adHocDataViews\":[],\"zoom\":1.57,\"center\":{\"lon\":0,\"lat\":19.94277},\"timeFilters\":{\"from\":\"now-7d\",\"to\":\"now\"},\"refreshConfig\":{\"isPaused\":true,\"interval\":60000},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[{\"meta\":{\"disabled\":false,\"negate\":false,\"alias\":\"males only\",\"index\":\"ff959d40-b880-11e8-a6d9-e546fe2bba5f\",\"key\":\"customer_gender\",\"field\":\"customer_gender\",\"params\":{\"query\":\"MALE\"},\"type\":\"phrase\"},\"query\":{\"match_phrase\":{\"customer_gender\":\"MALE\"}},\"$state\":{\"store\":\"appState\"}}],\"settings\":{\"autoFitToDataBounds\":false,\"backgroundColor\":\"#ffffff\",\"customIcons\":[],\"disableInteractive\":false,\"disableTooltipControl\":false,\"hideToolbarOverlay\":false,\"hideLayerControl\":false,\"hideViewControl\":false,\"initialLocation\":\"LAST_SAVED_LOCATION\",\"fixedLocation\":{\"lat\":0,\"lon\":0,\"zoom\":2},\"browserLocation\":{\"zoom\":2},\"keydownScrollZoom\":false,\"maxZoom\":24,\"minZoom\":0,\"showScaleControl\":false,\"showSpatialFilters\":true,\"showTimesliderToggleButton\":true,\"spatialFiltersAlpa\":0.3,\"spatialFiltersFillColor\":\"#DA8B45\",\"spatialFiltersLineColor\":\"#DA8B45\"}}",
            "title": "",
            "uiStateJSON": "{\"isLayerTOCOpen\":true,\"openTOCDetails\":[\"65710bbc-f41c-4fe7-b0c3-a6dbc0613220\"]}"
          },
          "enhancements": {
            "dynamicActions": {
              "events": []
            }
          },
          "hiddenLayers": [],
          "id": "108b2f72-0101-4e09-b8a9-22f7aa9573b0",
          "isLayerTOCOpen": false,
          "mapBuffer": {
            "maxLat": 85.05113,
            "maxLon": 180,
            "minLat": -66.51326,
            "minLon": -180
          },
          "mapCenter": {
            "lat": 19.94277,
            "lon": 0,
            "zoom": 1.57
          },
          "openTOCDetails": [
            "65710bbc-f41c-4fe7-b0c3-a6dbc0613220"
          ]
        },
        "gridData": {
          "h": 25,
          "i": "108b2f72-0101-4e09-b8a9-22f7aa9573b0",
          "w": 38,
          "x": 0,
          "y": 0
        },
        "panelIndex": "108b2f72-0101-4e09-b8a9-22f7aa9573b0",
        "type": "map"
      }
    ],
    "timeRestore": false,
    "title": "a maps panel",
    "version": 3
  },
  "references": [
    {
      "type": "tag",
      "id": "662b28f2-71e4-4c04-b4e5-0c6249b1c08a",
      "name": "tag-ref-662b28f2-71e4-4c04-b4e5-0c6249b1c08a"
    },
    {
      "name": "108b2f72-0101-4e09-b8a9-22f7aa9573b0:layer_1_source_index_pattern",
      "type": "index-pattern",
      "id": "ff959d40-b880-11e8-a6d9-e546fe2bba5f"
    }
  ]
}'
```

</details>

<details>
<summary>Create a dashboard with a Filter pill and a Field statistics
panel</summary>


```
curl  -X POST \
  'http://localhost:5601/api/dashboards/dashboard' \
  --user elastic:changeme \
  --header 'Accept: */*' \
  --header 'elastic-api-version: 2023-10-31' \
  --header 'kbn-xsrf: true' \
  --header 'Content-Type: application/json' \
  --data-raw '{
  "attributes": {
    "description": "",
    "kibanaSavedObjectMeta": {
      "searchSource": {
        "filter": [
          {
            "$state": {
              "store": "appState"
            },
            "meta": {
              "alias": "gnomehouse",
              "disabled": false,
              "field": "products.manufacturer.keyword",
              "indexRefName": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index",
              "key": "products.manufacturer.keyword",
              "negate": false,
              "params": [
                "Gnomehouse",
                "Gnomehouse mom"
              ],
              "type": "phrases"
            },
            "query": {
              "bool": {
                "minimum_should_match": 1,
                "should": [
                  {
                    "match_phrase": {
                      "products.manufacturer.keyword": "Gnomehouse"
                    }
                  },
                  {
                    "match_phrase": {
                      "products.manufacturer.keyword": "Gnomehouse mom"
                    }
                  }
                ]
              }
            }
          }
        ],
        "query": {
          "language": "kuery",
          "query": ""
        }
      }
    },
    "panels": [
      {
        "panelConfig": {
          "dataViewId": "32eec79c9673ab1b9265f3e422e8f952778f02c82eaf13147a9c0ba86290337a",
          "enhancements": {},
          "id": "3c9dee70-4a01-4c2f-9ccd-0c2812e2a5d4",
          "query": {
            "esql": "from kibana_sample_data_ecommerce | limit 10"
          },
          "viewType": "esql"
        },
        "gridData": {
          "h": 18,
          "i": "3c9dee70-4a01-4c2f-9ccd-0c2812e2a5d4",
          "w": 48,
          "x": 0,
          "y": 0
        },
        "panelIndex": "3c9dee70-4a01-4c2f-9ccd-0c2812e2a5d4",
        "type": "field_stats_table"
      }
    ],
    "timeRestore": false,
    "title": "field stats panel",
    "version": 2
  },
  "references": [
    {
      "id": "32eec79c9673ab1b9265f3e422e8f952778f02c82eaf13147a9c0ba86290337a",
      "name": "kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index",
      "type": "index-pattern"
    },
    {
      "id": "662b28f2-71e4-4c04-b4e5-0c6249b1c08a",
      "name": "tag-ref-662b28f2-71e4-4c04-b4e5-0c6249b1c08a",
      "type": "tag"
    },
    {
      "id": "32eec79c9673ab1b9265f3e422e8f952778f02c82eaf13147a9c0ba86290337a",
      "name": "3c9dee70-4a01-4c2f-9ccd-0c2812e2a5d4:fieldStatsTableDataViewId",
      "type": "index-pattern"
    }
  ]
}'
```
</details>

<details>
<summary>Create a dashboard with a Lens panel</summary>

```
curl  -X POST \
  'http://localhost:5601/api/dashboards/dashboard/' \
  --user elastic:changeme \
  --header 'Accept: */*' \
  --header 'elastic-api-version: 2023-10-31' \
  --header 'kbn-xsrf: true' \
  --header 'Content-Type: application/json' \
  --data-raw '{
  "attributes": {
    "title": "a lens panel",
    "kibanaSavedObjectMeta": {
      "searchSource": {}
    },
    "timeRestore": false,
    "panels": [
      {
        "panelConfig": {
          "attributes": {
            "title": "",
            "visualizationType": "lnsDatatable",
            "type": "lens",
            "references": [
              {
                "type": "index-pattern",
                "id": "d3d7af60-4c81-11e8-b3d7-01146121b73d",
                "name": "indexpattern-datasource-layer-b9789655-f916-4732-9bf2-641a88075210"
              }
            ],
            "state": {
              "visualization": {
                "layerId": "b9789655-f916-4732-9bf2-641a88075210",
                "layerType": "data",
                "columns": [
                  {
                    "isTransposed": false,
                    "columnId": "4175e737-76b9-46db-894b-57106a06b9cb"
                  },
                  {
                    "isTransposed": false,
                    "columnId": "1494f183-3bfa-4602-a780-6a41624f6c69"
                  },
                  {
                    "isTransposed": false,
                    "columnId": "8eb92ea9-5b76-45a2-865e-d78511c1e506"
                  }
                ]
              },
              "query": {
                "query": "",
                "language": "kuery"
              },
              "filters": [],
              "datasourceStates": {
                "formBased": {
                  "layers": {
                    "b9789655-f916-4732-9bf2-641a88075210": {
                      "columns": {
                        "4175e737-76b9-46db-894b-57106a06b9cb": {
                          "label": "Top 5 values of Carrier",
                          "dataType": "string",
                          "operationType": "terms",
                          "scale": "ordinal",
                          "sourceField": "Carrier",
                          "isBucketed": true,
                          "params": {
                            "size": 5,
                            "orderBy": {
                              "type": "column",
                              "columnId": "1494f183-3bfa-4602-a780-6a41624f6c69"
                            },
                            "orderDirection": "desc",
                            "otherBucket": true,
                            "missingBucket": false,
                            "parentFormat": {
                              "id": "terms"
                            },
                            "include": [],
                            "exclude": [],
                            "includeIsRegex": false,
                            "excludeIsRegex": false
                          }
                        },
                        "1494f183-3bfa-4602-a780-6a41624f6c69": {
                          "label": "Count of records",
                          "dataType": "number",
                          "operationType": "count",
                          "isBucketed": false,
                          "scale": "ratio",
                          "sourceField": "___records___",
                          "params": {
                            "emptyAsNull": true
                          }
                        },
                        "8eb92ea9-5b76-45a2-865e-d78511c1e506": {
                          "label": "Median of AvgTicketPrice",
                          "dataType": "number",
                          "operationType": "median",
                          "sourceField": "AvgTicketPrice",
                          "isBucketed": false,
                          "scale": "ratio",
                          "params": {
                            "emptyAsNull": true
                          }
                        }
                      },
                      "columnOrder": [
                        "4175e737-76b9-46db-894b-57106a06b9cb",
                        "1494f183-3bfa-4602-a780-6a41624f6c69",
                        "8eb92ea9-5b76-45a2-865e-d78511c1e506"
                      ],
                      "incompleteColumns": {},
                      "sampling": 1
                    }
                  }
                },
                "indexpattern": {
                  "layers": {}
                },
                "textBased": {
                  "layers": {}
                }
              },
              "internalReferences": [],
              "adHocDataViews": {}
            }
          },
          "enhancements": {}
        },
        "gridData": {
          "x": 0,
          "y": 0,
          "w": 24,
          "h": 15
        },
        "type": "lens"
      }
    ],
    "options": {
      "hidePanelTitles": false,
      "useMargins": true,
      "syncColors": false,
      "syncTooltips": true,
      "syncCursor": true
    },
    "version": 3
  },
  "references": [
    {
      "type": "index-pattern",
      "id": "d3d7af60-4c81-11e8-b3d7-01146121b73d",
      "name": "indexpattern-datasource-layer-b9789655-f916-4732-9bf2-641a88075210"
    }
  ]
}'
```

</details>


<details>
<summary>Create a dashboard in a specific Space</summary>

```
curl  -X POST \
  'http://localhost:5601/s/space-1/api/dashboards/dashboard/' \
  --user elastic:changeme \
  --header 'Accept: */*' \
  --header 'elastic-api-version: 2023-10-31' \
  --header 'kbn-xsrf: true' \
  --header 'Content-Type: application/json' \
  --data-raw '{
  "attributes": {
  "title": "my other demo dashboard",
  "kibanaSavedObjectMeta": {
    "searchSource": {}
  },
  "timeRestore": false,
  "panels": [
    {
      "panelConfig": {
        "savedVis": {
          "description": "",
          "type": "markdown",
          "params": {
            "fontSize": 12,
            "openLinksInNewTab": false,
            "markdown": "## Sample eCommerce Data\nThis dashboard contains sample data for you to play with. You can view it, search it, and interact with the visualizations. For more information about Kibana, check our [docs](https://www.elastic.co/guide/en/kibana/current/index.html)."
          },
          "uiState": {},
          "data": {
            "aggs": [],
            "searchSource": {
              "query": {
                "query": "",
                "language": "kuery"
              },
              "filter": []
            }
          }
        },
        "enhancements": {}
      },
      "gridData": {
        "x": 0,
        "y": 0,
        "w": 24,
        "h": 15,
        "i": "1"
      },
      "type": "visualization",
      "version": "7.9.2"
    }
  ],
  "options": {
    "hidePanelTitles": false,
    "useMargins": true,
    "syncColors": false,
    "syncTooltips": true,
    "syncCursor": true
  },
  "version": 3
  },
  "references": [],
  "spaces": ["space-1"]
}'
```

</details>

## Update

<details>
<summary>Update an existing dashboard</summary>

```
curl  -X PUT \
  'http://localhost:5601/api/dashboards/dashboard/foo-123' \
  --user elastic:changeme \
  --header 'Accept: */*' \
  --header 'elastic-api-version: 2023-10-31' \
  --header 'kbn-xsrf: true' \
  --header 'Content-Type: application/json' \
  --data-raw '{
  "attributes": {
    "title": "my demo dashboard",
    "kibanaSavedObjectMeta": {
      "searchSource": {}
    },
    "timeRestore": false,
    "panels": [
      {
        "panelConfig": {
          "savedVis": {
            "description": "",
            "type": "markdown",
            "params": {
              "fontSize": 12,
              "openLinksInNewTab": false,
              "markdown": "## Sample eCommerce Data\nThis dashboard contains sample data for you to play with. You can view it, search it, and interact with the visualizations. For more information about Kibana, check our [docs](https://www.elastic.co/guide/en/kibana/current/index.html).\nWubba lubba dub-dub!"
            },
            "uiState": {},
            "data": {
              "aggs": [],
              "searchSource": {
                "query": {
                  "query": "",
                  "language": "kuery"
                },
                "filter": []
              }
            }
          },
          "enhancements": {}
        },
        "gridData": {
          "x": 0,
          "y": 0,
          "w": 24,
          "h": 15
        },
        "type": "visualization"
      }
    ],
    "version": 3
  },
  "references": []
}'
```

</details>

## Get / List

<details>
<summary>Get a dashboard</summary>

```
curl  -X GET \
  'http://localhost:5601/api/dashboards/dashboard/foo-123' \
    --user elastic:changeme \
  --header 'Accept: */*' \
  --header 'elastic-api-version: 2023-10-31' 
```
</details>

<details>
<summary>Get a paginated list of dashboards</summary>


```
curl  -X GET \
  'http://localhost:5601/api/dashboards/dashboard' \
  --user elastic:changeme \
  --header 'Accept: */*' \
  --header 'elastic-api-version: 2023-10-31' 
```
</details>

## Delete
<details>
<summary>Delete a dashboard</summary>

```
curl  -X DELETE \
  'http://localhost:5601/api/dashboards/dashboard/foo-123' \
  --user elastic:changeme \
  --header 'Accept: */*' \
  --header 'elastic-api-version: 2023-10-31' \
  --header 'kbn-xsrf: true' 
```

</details>

## Open API specification

<details>
<summary>Retrieve the Open API specification</summary>

```
curl  -X GET \
  'http://localhost:5601/api/oas?pathStartsWith=%2Fapi%2Fdashboard' \
  --user elastic:changeme \
  --header 'Accept: */*' 
```

</details>

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Nick Peihl 2024-11-08 11:35:13 -05:00 committed by GitHub
parent 4b6b1c3eff
commit a227021302
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
117 changed files with 4056 additions and 682 deletions

View file

@ -7,11 +7,25 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { ControlGroupChainingSystem } from './control_group';
import { ControlLabelPosition, ControlWidth } from './types';
export const DEFAULT_CONTROL_WIDTH: ControlWidth = 'medium';
export const CONTROL_WIDTH_OPTIONS = { SMALL: 'small', MEDIUM: 'medium', LARGE: 'large' } as const;
export const CONTROL_LABEL_POSITION_OPTIONS = { ONE_LINE: 'oneLine', TWO_LINE: 'twoLine' } as const;
export const CONTROL_CHAINING_OPTIONS = { NONE: 'NONE', HIERARCHICAL: 'HIERARCHICAL' } as const;
export const DEFAULT_CONTROL_WIDTH: ControlWidth = CONTROL_WIDTH_OPTIONS.MEDIUM;
export const DEFAULT_CONTROL_LABEL_POSITION: ControlLabelPosition =
CONTROL_LABEL_POSITION_OPTIONS.ONE_LINE;
export const DEFAULT_CONTROL_GROW: boolean = true;
export const DEFAULT_CONTROL_LABEL_POSITION: ControlLabelPosition = 'oneLine';
export const DEFAULT_CONTROL_CHAINING: ControlGroupChainingSystem =
CONTROL_CHAINING_OPTIONS.HIERARCHICAL;
export const DEFAULT_IGNORE_PARENT_SETTINGS = {
ignoreFilters: false,
ignoreQuery: false,
ignoreTimerange: false,
ignoreValidations: false,
} as const;
export const DEFAULT_AUTO_APPLY_SELECTIONS = true;
export const TIME_SLIDER_CONTROL = 'timeSlider';
export const RANGE_SLIDER_CONTROL = 'rangeSliderControl';

View file

@ -9,10 +9,12 @@
import { DataViewField } from '@kbn/data-views-plugin/common';
import { ControlLabelPosition, DefaultControlState, ParentIgnoreSettings } from '../types';
import { CONTROL_CHAINING_OPTIONS } from '../constants';
export const CONTROL_GROUP_TYPE = 'control_group';
export type ControlGroupChainingSystem = 'HIERARCHICAL' | 'NONE';
export type ControlGroupChainingSystem =
(typeof CONTROL_CHAINING_OPTIONS)[keyof typeof CONTROL_CHAINING_OPTIONS];
export type FieldFilterPredicate = (f: DataViewField) => boolean;
@ -45,15 +47,11 @@ export interface ControlGroupRuntimeState<State extends DefaultControlState = De
}
export interface ControlGroupSerializedState
extends Pick<ControlGroupRuntimeState, 'chainingSystem' | 'editorConfig'> {
panelsJSON: string; // stringified version of ControlSerializedState
ignoreParentSettingsJSON: string;
// In runtime state, we refer to this property as `labelPosition`;
// to avoid migrations, we will continue to refer to this property as `controlStyle` in the serialized state
controlStyle: ControlLabelPosition;
// In runtime state, we refer to the inverse of this property as `autoApplySelections`
// to avoid migrations, we will continue to refer to this property as `showApplySelections` in the serialized state
showApplySelections?: boolean;
extends Omit<ControlGroupRuntimeState, 'initialChildControlState'> {
// In runtime state, we refer to this property as `initialChildControlState`, but in
// the serialized state we transform the state object into an array of state objects
// to make it easier for API consumers to add new controls without specifying a uuid key.
controls: Array<ControlPanelState & { id?: string }>;
}
/**

View file

@ -17,9 +17,15 @@ export type {
} from './types';
export {
DEFAULT_CONTROL_CHAINING,
DEFAULT_CONTROL_GROW,
DEFAULT_CONTROL_LABEL_POSITION,
DEFAULT_CONTROL_WIDTH,
DEFAULT_IGNORE_PARENT_SETTINGS,
DEFAULT_AUTO_APPLY_SELECTIONS,
CONTROL_WIDTH_OPTIONS,
CONTROL_CHAINING_OPTIONS,
CONTROL_LABEL_POSITION_OPTIONS,
OPTIONS_LIST_CONTROL,
RANGE_SLIDER_CONTROL,
TIME_SLIDER_CONTROL,

View file

@ -7,12 +7,16 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export type ControlWidth = 'small' | 'medium' | 'large';
export type ControlLabelPosition = 'twoLine' | 'oneLine';
import { SerializableRecord } from '@kbn/utility-types';
import { CONTROL_LABEL_POSITION_OPTIONS, CONTROL_WIDTH_OPTIONS } from './constants';
export type ControlWidth = (typeof CONTROL_WIDTH_OPTIONS)[keyof typeof CONTROL_WIDTH_OPTIONS];
export type ControlLabelPosition =
(typeof CONTROL_LABEL_POSITION_OPTIONS)[keyof typeof CONTROL_LABEL_POSITION_OPTIONS];
export type TimeSlice = [number, number];
export interface ParentIgnoreSettings {
export interface ParentIgnoreSettings extends SerializableRecord {
ignoreFilters?: boolean;
ignoreQuery?: boolean;
ignoreTimerange?: boolean;

View file

@ -19,8 +19,11 @@ import { useSearchApi, type ViewMode as ViewModeType } from '@kbn/presentation-p
import type { ControlGroupApi } from '../..';
import {
CONTROL_GROUP_TYPE,
DEFAULT_CONTROL_LABEL_POSITION,
type ControlGroupRuntimeState,
type ControlGroupSerializedState,
DEFAULT_CONTROL_CHAINING,
DEFAULT_AUTO_APPLY_SELECTIONS,
} from '../../../common';
import {
type ControlGroupStateBuilder,
@ -136,16 +139,19 @@ export const ControlGroupRenderer = ({
...initialState,
editorConfig,
});
const state = {
...omit(initialState, ['initialChildControlState', 'ignoreParentSettings']),
const state: ControlGroupSerializedState = {
...omit(initialState, ['initialChildControlState']),
editorConfig,
controlStyle: initialState?.labelPosition,
panelsJSON: JSON.stringify(initialState?.initialChildControlState ?? {}),
ignoreParentSettingsJSON: JSON.stringify(initialState?.ignoreParentSettings ?? {}),
autoApplySelections: initialState?.autoApplySelections ?? DEFAULT_AUTO_APPLY_SELECTIONS,
labelPosition: initialState?.labelPosition ?? DEFAULT_CONTROL_LABEL_POSITION,
chainingSystem: initialState?.chainingSystem ?? DEFAULT_CONTROL_CHAINING,
controls: Object.entries(initialState?.initialChildControlState ?? {}).map(
([controlId, value]) => ({ ...value, id: controlId })
),
};
if (!cancelled) {
setSerializedState(state as ControlGroupSerializedState);
setSerializedState(state);
}
})();
return () => {

View file

@ -33,7 +33,11 @@ import type {
ControlPanelsState,
ParentIgnoreSettings,
} from '../../common';
import { CONTROL_GROUP_TYPE, DEFAULT_CONTROL_LABEL_POSITION } from '../../common';
import {
CONTROL_GROUP_TYPE,
DEFAULT_CONTROL_CHAINING,
DEFAULT_CONTROL_LABEL_POSITION,
} from '../../common';
import { openDataControlEditor } from '../controls/data_controls/open_data_control_editor';
import { coreServices, dataViewsService } from '../services/kibana_services';
import { ControlGroup } from './components/control_group';
@ -45,8 +49,6 @@ import { initSelectionsManager } from './selections_manager';
import type { ControlGroupApi } from './types';
import { deserializeControlGroup } from './utils/serialization_utils';
const DEFAULT_CHAINING_SYSTEM = 'HIERARCHICAL';
export const getControlGroupEmbeddableFactory = () => {
const controlGroupEmbeddableFactory: ReactEmbeddableFactory<
ControlGroupSerializedState,
@ -85,7 +87,7 @@ export const getControlGroupEmbeddableFactory = () => {
});
const dataViews = new BehaviorSubject<DataView[] | undefined>(undefined);
const chainingSystem$ = new BehaviorSubject<ControlGroupChainingSystem>(
chainingSystem ?? DEFAULT_CHAINING_SYSTEM
chainingSystem ?? DEFAULT_CONTROL_CHAINING
);
const ignoreParentSettings$ = new BehaviorSubject<ParentIgnoreSettings | undefined>(
ignoreParentSettings
@ -108,7 +110,7 @@ export const getControlGroupEmbeddableFactory = () => {
chainingSystem: [
chainingSystem$,
(next: ControlGroupChainingSystem) => chainingSystem$.next(next),
(a, b) => (a ?? DEFAULT_CHAINING_SYSTEM) === (b ?? DEFAULT_CHAINING_SYSTEM),
(a, b) => (a ?? DEFAULT_CONTROL_CHAINING) === (b ?? DEFAULT_CONTROL_CHAINING),
],
ignoreParentSettings: [
ignoreParentSettings$,
@ -187,14 +189,14 @@ export const getControlGroupEmbeddableFactory = () => {
});
},
serializeState: () => {
const { panelsJSON, references } = controlsManager.serializeControls();
const { controls, references } = controlsManager.serializeControls();
return {
rawState: {
chainingSystem: chainingSystem$.getValue(),
controlStyle: labelPosition$.getValue(),
showApplySelections: !autoApplySelections$.getValue(),
ignoreParentSettingsJSON: JSON.stringify(ignoreParentSettings$.getValue()),
panelsJSON,
labelPosition: labelPosition$.getValue(),
autoApplySelections: autoApplySelections$.getValue(),
ignoreParentSettings: ignoreParentSettings$.getValue(),
controls,
},
references,
};

View file

@ -147,9 +147,8 @@ export function initControlsManager(
},
serializeControls: () => {
const references: Reference[] = [];
const explicitInputPanels: {
[panelId: string]: ControlPanelState & { explicitInput: object };
} = {};
const controls: Array<ControlPanelState & { controlConfig: object }> = [];
controlsInOrder$.getValue().forEach(({ id }, index) => {
const controlApi = getControlApi(id);
@ -166,18 +165,18 @@ export function initControlsManager(
references.push(...controlReferences);
}
explicitInputPanels[id] = {
controls.push({
grow,
order: index,
type: controlApi.type,
width,
/** Re-add the `explicitInput` layer on serialize so control group saved object retains shape */
explicitInput: { id, ...rest },
};
/** Re-add the `controlConfig` layer on serialize so control group saved object retains shape */
controlConfig: { id, ...rest },
});
});
return {
panelsJSON: JSON.stringify(explicitInputPanels),
controls,
references,
};
},

View file

@ -7,17 +7,18 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { DEFAULT_CONTROL_LABEL_POSITION, type ControlGroupRuntimeState } from '../../../common';
import {
type ControlGroupRuntimeState,
DEFAULT_CONTROL_CHAINING,
DEFAULT_CONTROL_LABEL_POSITION,
DEFAULT_AUTO_APPLY_SELECTIONS,
DEFAULT_IGNORE_PARENT_SETTINGS,
} from '../../../common';
export const getDefaultControlGroupRuntimeState = (): ControlGroupRuntimeState => ({
initialChildControlState: {},
labelPosition: DEFAULT_CONTROL_LABEL_POSITION,
chainingSystem: 'HIERARCHICAL',
autoApplySelections: true,
ignoreParentSettings: {
ignoreFilters: false,
ignoreQuery: false,
ignoreTimerange: false,
ignoreValidations: false,
},
chainingSystem: DEFAULT_CONTROL_CHAINING,
autoApplySelections: DEFAULT_AUTO_APPLY_SELECTIONS,
ignoreParentSettings: DEFAULT_IGNORE_PARENT_SETTINGS,
});

View file

@ -16,37 +16,31 @@ import { parseReferenceName } from '../../controls/data_controls/reference_name_
export const deserializeControlGroup = (
state: SerializedPanelState<ControlGroupSerializedState>
): ControlGroupRuntimeState => {
const panels = JSON.parse(state.rawState.panelsJSON);
const ignoreParentSettings = JSON.parse(state.rawState.ignoreParentSettingsJSON);
const { controls } = state.rawState;
const controlsMap = Object.fromEntries(controls.map(({ id, ...rest }) => [id, rest]));
/** Inject data view references into each individual control */
const references = state.references ?? [];
references.forEach((reference) => {
const referenceName = reference.name;
const { controlId } = parseReferenceName(referenceName);
if (panels[controlId]) {
panels[controlId].dataViewId = reference.id;
if (controlsMap[controlId]) {
controlsMap[controlId].dataViewId = reference.id;
}
});
/** Flatten the state of each panel by removing `explicitInput` */
const flattenedPanels = Object.keys(panels).reduce((prev, panelId) => {
const currentPanel = panels[panelId];
const currentPanelExplicitInput = panels[panelId].explicitInput;
/** Flatten the state of each control by removing `controlConfig` */
const flattenedControls = Object.keys(controlsMap).reduce((prev, controlId) => {
const currentControl = controlsMap[controlId];
const currentControlExplicitInput = controlsMap[controlId].controlConfig;
return {
...prev,
[panelId]: { ...omit(currentPanel, 'explicitInput'), ...currentPanelExplicitInput },
[controlId]: { ...omit(currentControl, 'controlConfig'), ...currentControlExplicitInput },
};
}, {});
return {
...omit(state.rawState, ['panelsJSON', 'ignoreParentSettingsJSON']),
initialChildControlState: flattenedPanels,
ignoreParentSettings,
autoApplySelections:
typeof state.rawState.showApplySelections === 'boolean'
? !state.rawState.showApplySelections
: true, // Rename "showApplySelections" to "autoApplySelections"
labelPosition: state.rawState.controlStyle, // Rename "controlStyle" to "labelPosition"
...state.rawState,
initialChildControlState: flattenedControls,
};
};

View file

@ -18,10 +18,8 @@ import {
import { OptionsListControlState } from '../../common/options_list';
import { mockDataControlState, mockOptionsListControlState } from '../mocks';
import { removeHideExcludeAndHideExists } from './control_group_migrations';
import {
SerializableControlGroupState,
getDefaultControlGroupState,
} from './control_group_persistence';
import { getDefaultControlGroupState } from './control_group_persistence';
import type { SerializableControlGroupState } from './types';
describe('migrate control group', () => {
const getOptionsListControl = (

View file

@ -14,7 +14,7 @@ import {
type SerializedControlState,
} from '../../common';
import { OptionsListControlState } from '../../common/options_list';
import { SerializableControlGroupState } from './control_group_persistence';
import { SerializableControlGroupState } from './types';
export const makeControlOrdersZeroBased = (state: SerializableControlGroupState) => {
if (

View file

@ -20,7 +20,7 @@ import {
makeControlOrdersZeroBased,
removeHideExcludeAndHideExists,
} from './control_group_migrations';
import type { SerializableControlGroupState } from './control_group_persistence';
import { SerializableControlGroupState } from './types';
const getPanelStatePrefix = (state: SerializedControlState) => `${state.explicitInput.id}:`;

View file

@ -9,37 +9,22 @@
import { SerializableRecord } from '@kbn/utility-types';
import { ControlGroupSavedObjectState, SerializableControlGroupState } from './types';
import {
DEFAULT_CONTROL_CHAINING,
DEFAULT_CONTROL_LABEL_POSITION,
type ControlGroupRuntimeState,
type ControlGroupSerializedState,
type ControlPanelState,
type SerializedControlState,
DEFAULT_IGNORE_PARENT_SETTINGS,
DEFAULT_AUTO_APPLY_SELECTIONS,
} from '../../common';
export const getDefaultControlGroupState = (): SerializableControlGroupState => ({
panels: {},
labelPosition: DEFAULT_CONTROL_LABEL_POSITION,
chainingSystem: 'HIERARCHICAL',
autoApplySelections: true,
ignoreParentSettings: {
ignoreFilters: false,
ignoreQuery: false,
ignoreTimerange: false,
ignoreValidations: false,
},
chainingSystem: DEFAULT_CONTROL_CHAINING,
autoApplySelections: DEFAULT_AUTO_APPLY_SELECTIONS,
ignoreParentSettings: DEFAULT_IGNORE_PARENT_SETTINGS,
});
// using SerializableRecord to force type to be read as serializable
export type SerializableControlGroupState = SerializableRecord &
Omit<
ControlGroupRuntimeState,
'initialChildControlState' | 'ignoreParentSettings' | 'editorConfig' // editor config is not persisted
> & {
ignoreParentSettings: Record<string, boolean>;
panels: Record<string, ControlPanelState<SerializedControlState>> | {};
};
const safeJSONParse = <OutType>(jsonString?: string): OutType | undefined => {
if (!jsonString && typeof jsonString !== 'string') return;
try {
@ -49,22 +34,26 @@ const safeJSONParse = <OutType>(jsonString?: string): OutType | undefined => {
}
};
export const controlGroupSerializedStateToSerializableRuntimeState = (
serializedState: ControlGroupSerializedState
export const controlGroupSavedObjectStateToSerializableRuntimeState = (
savedObjectState: ControlGroupSavedObjectState
): SerializableControlGroupState => {
const defaultControlGroupInput = getDefaultControlGroupState();
return {
chainingSystem: serializedState?.chainingSystem,
labelPosition: serializedState?.controlStyle ?? defaultControlGroupInput.labelPosition,
autoApplySelections: !serializedState?.showApplySelections,
ignoreParentSettings: safeJSONParse(serializedState?.ignoreParentSettingsJSON) ?? {},
panels: safeJSONParse(serializedState?.panelsJSON) ?? {},
chainingSystem:
(savedObjectState?.chainingSystem as SerializableControlGroupState['chainingSystem']) ??
defaultControlGroupInput.chainingSystem,
labelPosition:
(savedObjectState?.controlStyle as SerializableControlGroupState['labelPosition']) ??
defaultControlGroupInput.labelPosition,
autoApplySelections: !savedObjectState?.showApplySelections,
ignoreParentSettings: safeJSONParse(savedObjectState?.ignoreParentSettingsJSON) ?? {},
panels: safeJSONParse(savedObjectState?.panelsJSON) ?? {},
};
};
export const serializableRuntimeStateToControlGroupSerializedState = (
export const serializableRuntimeStateToControlGroupSavedObjectState = (
serializable: SerializableRecord // It is safe to treat this as SerializableControlGroupState
): ControlGroupSerializedState => {
): ControlGroupSavedObjectState => {
return {
controlStyle: serializable.labelPosition as SerializableControlGroupState['labelPosition'],
chainingSystem: serializable.chainingSystem as SerializableControlGroupState['chainingSystem'],

View file

@ -7,16 +7,11 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { SerializableRecord } from '@kbn/utility-types';
import { type ControlGroupSerializedState } from '../../common';
import {
type ControlGroupTelemetry,
controlGroupTelemetry,
initializeControlGroupTelemetry,
} from './control_group_telemetry';
import { controlGroupTelemetry, initializeControlGroupTelemetry } from './control_group_telemetry';
import { ControlGroupSavedObjectState, ControlGroupTelemetry } from './types';
// controls attributes with all settings ignored + 3 options lists + hierarchical chaining + label above
const rawControlAttributes1: SerializableRecord & ControlGroupSerializedState = {
const rawControlAttributes1: ControlGroupSavedObjectState = {
controlStyle: 'twoLine',
chainingSystem: 'NONE',
showApplySelections: true,
@ -27,7 +22,7 @@ const rawControlAttributes1: SerializableRecord & ControlGroupSerializedState =
};
// controls attributes with some settings ignored + 2 range sliders, 1 time slider + No chaining + label inline
const rawControlAttributes2: SerializableRecord & ControlGroupSerializedState = {
const rawControlAttributes2: ControlGroupSavedObjectState = {
controlStyle: 'oneLine',
chainingSystem: 'NONE',
showApplySelections: false,
@ -38,7 +33,7 @@ const rawControlAttributes2: SerializableRecord & ControlGroupSerializedState =
};
// controls attributes with no settings ignored + 2 options lists, 1 range slider, 1 time slider + hierarchical chaining + label inline
const rawControlAttributes3: SerializableRecord & ControlGroupSerializedState = {
const rawControlAttributes3: ControlGroupSavedObjectState = {
controlStyle: 'oneLine',
chainingSystem: 'HIERARCHICAL',
showApplySelections: false,

View file

@ -9,31 +9,15 @@
import { PersistableStateService } from '@kbn/kibana-utils-plugin/common';
import { set } from '@kbn/safer-lodash-set';
import type { ControlGroupSerializedState } from '../../common';
import {
type SerializableControlGroupState,
controlGroupSerializedStateToSerializableRuntimeState,
controlGroupSavedObjectStateToSerializableRuntimeState,
getDefaultControlGroupState,
} from './control_group_persistence';
export interface ControlGroupTelemetry {
total: number;
chaining_system: {
[key: string]: number;
};
label_position: {
[key: string]: number;
};
ignore_settings: {
[key: string]: number;
};
by_type: {
[key: string]: {
total: number;
details: { [key: string]: number };
};
};
}
import {
ControlGroupSavedObjectState,
ControlGroupTelemetry,
SerializableControlGroupState,
} from './types';
export const initializeControlGroupTelemetry = (
statsSoFar: Record<string, unknown>
@ -113,8 +97,8 @@ export const controlGroupTelemetry: PersistableStateService['telemetry'] = (
const controlGroupStats = initializeControlGroupTelemetry(stats);
const controlGroupState = {
...getDefaultControlGroupState(),
...controlGroupSerializedStateToSerializableRuntimeState(
state as unknown as ControlGroupSerializedState
...controlGroupSavedObjectStateToSerializableRuntimeState(
state as unknown as ControlGroupSavedObjectState
),
};
if (!controlGroupState) return controlGroupStats;

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 { SerializableRecord } from '@kbn/utility-types';
import { ControlGroupRuntimeState, ControlPanelState, SerializedControlState } from '../../common';
// using SerializableRecord to force type to be read as serializable
export type SerializableControlGroupState = SerializableRecord &
Omit<
ControlGroupRuntimeState,
'initialChildControlState' | 'editorConfig' // editor config is not persisted
> & {
panels: Record<string, ControlPanelState<SerializedControlState>> | {};
};
export type ControlGroupSavedObjectState = SerializableRecord & {
chainingSystem: SerializableControlGroupState['chainingSystem'];
controlStyle: SerializableControlGroupState['labelPosition'];
showApplySelections: boolean;
ignoreParentSettingsJSON: string;
panelsJSON: string;
};
export interface ControlGroupTelemetry {
total: number;
chaining_system: {
[key: string]: number;
};
label_position: {
[key: string]: number;
};
ignore_settings: {
[key: string]: number;
};
by_type: {
[key: string]: {
total: number;
details: { [key: string]: number };
};
};
}

View file

@ -13,10 +13,9 @@ export const plugin = async () => {
};
export {
controlGroupSerializedStateToSerializableRuntimeState,
serializableRuntimeStateToControlGroupSerializedState,
controlGroupSavedObjectStateToSerializableRuntimeState,
serializableRuntimeStateToControlGroupSavedObjectState,
} from './control_group/control_group_persistence';
export {
type ControlGroupTelemetry,
initializeControlGroupTelemetry,
} from './control_group/control_group_telemetry';
export { initializeControlGroupTelemetry } from './control_group/control_group_telemetry';
export type { ControlGroupTelemetry } from './control_group/types';

View file

@ -38,7 +38,7 @@
"@kbn/field-formats-plugin",
"@kbn/presentation-panel-plugin",
"@kbn/shared-ux-utility",
"@kbn/std"
"@kbn/std",
],
"exclude": ["target/**/*"]
}

View file

@ -9,7 +9,7 @@
import type { SavedObjectReference } from '@kbn/core/public';
import type { Serializable } from '@kbn/utility-types';
import { GridData } from '../content_management';
import type { GridData } from '../../server/dashboard_saved_object';
interface KibanaAttributes {
kibanaSavedObjectMeta: {

View file

@ -7,6 +7,18 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export const LATEST_VERSION = 2;
export const LATEST_VERSION = 3;
export const CONTENT_ID = 'dashboard';
export const DASHBOARD_GRID_COLUMN_COUNT = 48;
export const DEFAULT_PANEL_WIDTH = DASHBOARD_GRID_COLUMN_COUNT / 2;
export const DEFAULT_PANEL_HEIGHT = 15;
export const DEFAULT_DASHBOARD_OPTIONS = {
hidePanelTitles: false,
useMargins: true,
syncColors: true,
syncCursor: true,
syncTooltips: true,
} as const;

View file

@ -7,14 +7,13 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export { LATEST_VERSION, CONTENT_ID } from './constants';
export {
LATEST_VERSION,
CONTENT_ID,
DASHBOARD_GRID_COLUMN_COUNT,
DEFAULT_PANEL_HEIGHT,
DEFAULT_PANEL_WIDTH,
DEFAULT_DASHBOARD_OPTIONS,
} from './constants';
export type { DashboardContentType } from './types';
export type {
GridData,
DashboardItem,
DashboardCrudTypes,
DashboardAttributes,
SavedDashboardPanel,
} from './latest';

View file

@ -14,7 +14,7 @@ import type {
} from '@kbn/content-management-utils';
import { Serializable } from '@kbn/utility-types';
import { RefreshInterval } from '@kbn/data-plugin/common';
import { ControlGroupSerializedState } from '@kbn/controls-plugin/common';
import { ControlGroupChainingSystem, ControlLabelPosition } from '@kbn/controls-plugin/common';
import { DashboardContentType } from '../types';
@ -62,10 +62,13 @@ export interface SavedDashboardPanel {
version?: string;
}
type ControlGroupAttributesV1 = Pick<
ControlGroupSerializedState,
'panelsJSON' | 'chainingSystem' | 'controlStyle' | 'ignoreParentSettingsJSON'
>;
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
export type ControlGroupAttributesV1 = {
chainingSystem?: ControlGroupChainingSystem;
panelsJSON: string; // stringified version of ControlSerializedState
ignoreParentSettingsJSON: string;
controlStyle?: ControlLabelPosition;
};
/* eslint-disable-next-line @typescript-eslint/consistent-type-definitions */
export type DashboardAttributes = {
@ -77,7 +80,7 @@ export type DashboardAttributes = {
description: string;
panelsJSON: string;
timeFrom?: string;
version: number;
version?: number;
timeTo?: string;
title: string;
kibanaSavedObjectMeta: {

View file

@ -8,4 +8,4 @@
*/
export type { GridData, DashboardItem, SavedDashboardPanel } from '../v1/types'; // no changes made to types from v1 to v2
export type { DashboardCrudTypes, DashboardAttributes } from './types';
export type { ControlGroupAttributes, DashboardCrudTypes, DashboardAttributes } from './types';

View file

@ -12,21 +12,18 @@ import type {
SavedObjectCreateOptions,
SavedObjectUpdateOptions,
} from '@kbn/content-management-utils';
import { ControlGroupSerializedState } from '@kbn/controls-plugin/common';
import { DashboardContentType } from '../types';
import { DashboardAttributes as DashboardAttributesV1 } from '../v1/types';
import {
ControlGroupAttributesV1,
DashboardAttributes as DashboardAttributesV1,
} from '../v1/types';
type ControlGroupAttributesV2 = Pick<
ControlGroupSerializedState,
| 'panelsJSON'
| 'chainingSystem'
| 'controlStyle'
| 'ignoreParentSettingsJSON'
| 'showApplySelections'
>;
export type ControlGroupAttributes = ControlGroupAttributesV1 & {
showApplySelections?: boolean;
};
export type DashboardAttributes = Omit<DashboardAttributesV1, 'controlGroupInput'> & {
controlGroupInput?: ControlGroupAttributesV2;
controlGroupInput?: ControlGroupAttributes;
};
export type DashboardCrudTypes = ContentManagementCrudTypes<

View file

@ -18,8 +18,7 @@ import type { Reference } from '@kbn/content-management-utils';
import { RefreshInterval } from '@kbn/data-plugin/common';
import { KibanaExecutionContext } from '@kbn/core-execution-context-common';
import { DashboardOptions } from '../types';
import { GridData } from '../content_management';
import type { DashboardOptions, GridData } from '../../server/content_management';
export interface DashboardPanelMap {
[key: string]: DashboardPanelState;

View file

@ -18,7 +18,8 @@ import {
createInject,
} from '../../dashboard_container/persistable_state/dashboard_container_references';
import { createEmbeddablePersistableStateServiceMock } from '@kbn/embeddable-plugin/common/mocks';
import { DashboardAttributes } from '../../content_management';
import type { DashboardAttributes, DashboardItem } from '../../../server/content_management';
import { DashboardAttributesAndReferences } from '../../types';
const embeddablePersistableStateServiceMock = createEmbeddablePersistableStateServiceMock();
const dashboardInject = createInject(embeddablePersistableStateServiceMock);
@ -44,28 +45,37 @@ const deps: InjectExtractDeps = {
};
const commonAttributes: DashboardAttributes = {
kibanaSavedObjectMeta: { searchSourceJSON: '' },
kibanaSavedObjectMeta: { searchSource: {} },
timeRestore: false,
panelsJSON: '',
version: 1,
options: {
hidePanelTitles: false,
useMargins: true,
syncColors: true,
syncCursor: true,
syncTooltips: true,
},
panels: [],
description: '',
title: '',
};
describe('extractReferences', () => {
test('extracts references from panelsJSON', () => {
test('extracts references from panels', () => {
const doc = {
id: '1',
attributes: {
...commonAttributes,
foo: true,
panelsJSON: JSON.stringify([
panels: [
{
panelIndex: 'panel-1',
type: 'visualization',
id: '1',
title: 'Title 1',
version: '7.9.1',
gridData: { x: 0, y: 0, w: 1, h: 1, i: 'panel-1' },
panelConfig: {},
},
{
panelIndex: 'panel-2',
@ -73,8 +83,10 @@ describe('extractReferences', () => {
id: '2',
title: 'Title 2',
version: '7.9.1',
gridData: { x: 1, y: 1, w: 2, h: 2, i: 'panel-2' },
panelConfig: {},
},
]),
],
},
references: [],
};
@ -86,9 +98,47 @@ describe('extractReferences', () => {
"description": "",
"foo": true,
"kibanaSavedObjectMeta": Object {
"searchSourceJSON": "",
"searchSource": Object {},
},
"panelsJSON": "[{\\"version\\":\\"7.9.1\\",\\"type\\":\\"visualization\\",\\"panelIndex\\":\\"panel-1\\",\\"embeddableConfig\\":{},\\"title\\":\\"Title 1\\",\\"panelRefName\\":\\"panel_panel-1\\"},{\\"version\\":\\"7.9.1\\",\\"type\\":\\"visualization\\",\\"panelIndex\\":\\"panel-2\\",\\"embeddableConfig\\":{},\\"title\\":\\"Title 2\\",\\"panelRefName\\":\\"panel_panel-2\\"}]",
"options": Object {
"hidePanelTitles": false,
"syncColors": true,
"syncCursor": true,
"syncTooltips": true,
"useMargins": true,
},
"panels": Array [
Object {
"gridData": Object {
"h": 1,
"i": "panel-1",
"w": 1,
"x": 0,
"y": 0,
},
"panelConfig": Object {},
"panelIndex": "panel-1",
"panelRefName": "panel_panel-1",
"title": "Title 1",
"type": "visualization",
"version": "7.9.1",
},
Object {
"gridData": Object {
"h": 2,
"i": "panel-2",
"w": 2,
"x": 1,
"y": 1,
},
"panelConfig": Object {},
"panelIndex": "panel-2",
"panelRefName": "panel_panel-2",
"title": "Title 2",
"type": "visualization",
"version": "7.9.1",
},
],
"timeRestore": false,
"title": "",
"version": 1,
@ -115,18 +165,18 @@ describe('extractReferences', () => {
attributes: {
...commonAttributes,
foo: true,
panelsJSON: JSON.stringify([
panels: [
{
id: '1',
title: 'Title 1',
version: '7.9.1',
},
]),
],
},
references: [],
};
} as unknown as DashboardAttributesAndReferences;
expect(() => extractReferences(doc, deps)).toThrowErrorMatchingInlineSnapshot(
`"\\"type\\" attribute is missing from panel \\"undefined\\""`
`"\\"type\\" attribute is missing from panel \\"0\\""`
);
});
@ -136,25 +186,49 @@ describe('extractReferences', () => {
attributes: {
...commonAttributes,
foo: true,
panelsJSON: JSON.stringify([
panels: [
{
type: 'visualization',
title: 'Title 1',
version: '7.9.1',
gridData: { x: 0, y: 0, w: 1, h: 1, i: 'panel-1' },
panelConfig: {},
},
]),
],
},
references: [],
};
expect(extractReferences(doc, deps)).toMatchInlineSnapshot(`
expect(extractReferences(doc as unknown as DashboardItem, deps)).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"description": "",
"foo": true,
"kibanaSavedObjectMeta": Object {
"searchSourceJSON": "",
"searchSource": Object {},
},
"panelsJSON": "[{\\"version\\":\\"7.9.1\\",\\"type\\":\\"visualization\\",\\"embeddableConfig\\":{},\\"title\\":\\"Title 1\\"}]",
"options": Object {
"hidePanelTitles": false,
"syncColors": true,
"syncCursor": true,
"syncTooltips": true,
"useMargins": true,
},
"panels": Array [
Object {
"gridData": Object {
"h": 1,
"i": "panel-1",
"w": 1,
"x": 0,
"y": 0,
},
"panelConfig": Object {},
"panelIndex": "0",
"title": "Title 1",
"type": "visualization",
"version": "7.9.1",
},
],
"timeRestore": false,
"title": "",
"version": 1,
@ -171,18 +245,26 @@ describe('injectReferences', () => {
...commonAttributes,
id: '1',
title: 'test',
panelsJSON: JSON.stringify([
panels: [
{
type: 'visualization',
panelRefName: 'panel_0',
panelIndex: '0',
title: 'Title 1',
version: '7.9.0',
gridData: { x: 0, y: 0, w: 1, h: 1, i: '0' },
panelConfig: {},
},
{
type: 'visualization',
panelRefName: 'panel_1',
panelIndex: '1',
title: 'Title 2',
version: '7.9.0',
gridData: { x: 1, y: 1, w: 2, h: 2, i: '1' },
panelConfig: {},
},
]),
],
};
const references = [
{
@ -203,9 +285,47 @@ describe('injectReferences', () => {
"description": "",
"id": "1",
"kibanaSavedObjectMeta": Object {
"searchSourceJSON": "",
"searchSource": Object {},
},
"panelsJSON": "[{\\"version\\":\\"7.9.0\\",\\"type\\":\\"visualization\\",\\"embeddableConfig\\":{},\\"title\\":\\"Title 1\\",\\"id\\":\\"1\\"},{\\"version\\":\\"7.9.0\\",\\"type\\":\\"visualization\\",\\"embeddableConfig\\":{},\\"title\\":\\"Title 2\\",\\"id\\":\\"2\\"}]",
"options": Object {
"hidePanelTitles": false,
"syncColors": true,
"syncCursor": true,
"syncTooltips": true,
"useMargins": true,
},
"panels": Array [
Object {
"gridData": Object {
"h": 1,
"i": "0",
"w": 1,
"x": 0,
"y": 0,
},
"id": "1",
"panelConfig": Object {},
"panelIndex": "0",
"title": "Title 1",
"type": "visualization",
"version": "7.9.0",
},
Object {
"gridData": Object {
"h": 2,
"i": "1",
"w": 2,
"x": 1,
"y": 1,
},
"id": "2",
"panelConfig": Object {},
"panelIndex": "1",
"title": "Title 2",
"type": "visualization",
"version": "7.9.0",
},
],
"timeRestore": false,
"title": "test",
"version": 1,
@ -213,7 +333,7 @@ describe('injectReferences', () => {
`);
});
test('skips when panelsJSON is missing', () => {
test('skips when panels is missing', () => {
const attributes = {
id: '1',
title: 'test',
@ -222,49 +342,34 @@ describe('injectReferences', () => {
expect(newAttributes).toMatchInlineSnapshot(`
Object {
"id": "1",
"panelsJSON": "[]",
"panels": Array [],
"title": "test",
}
`);
});
test('skips when panelsJSON is not an array', () => {
const attributes = {
...commonAttributes,
id: '1',
panelsJSON: '{}',
title: 'test',
};
const newAttributes = injectReferences({ attributes, references: [] }, deps);
expect(newAttributes).toMatchInlineSnapshot(`
Object {
"description": "",
"id": "1",
"kibanaSavedObjectMeta": Object {
"searchSourceJSON": "",
},
"panelsJSON": "[]",
"timeRestore": false,
"title": "test",
"version": 1,
}
`);
});
test('skips a panel when panelRefName is missing', () => {
const attributes = {
...commonAttributes,
id: '1',
title: 'test',
panelsJSON: JSON.stringify([
panels: [
{
type: 'visualization',
panelRefName: 'panel_0',
panelIndex: '0',
title: 'Title 1',
gridData: { x: 0, y: 0, w: 1, h: 1, i: '0' },
panelConfig: {},
},
{
type: 'visualization',
panelIndex: '1',
title: 'Title 2',
gridData: { x: 1, y: 1, w: 2, h: 2, i: '1' },
panelConfig: {},
},
]),
],
};
const references = [
{
@ -279,9 +384,46 @@ describe('injectReferences', () => {
"description": "",
"id": "1",
"kibanaSavedObjectMeta": Object {
"searchSourceJSON": "",
"searchSource": Object {},
},
"panelsJSON": "[{\\"type\\":\\"visualization\\",\\"embeddableConfig\\":{},\\"title\\":\\"Title 1\\",\\"id\\":\\"1\\"},{\\"embeddableConfig\\":{},\\"title\\":\\"Title 2\\"}]",
"options": Object {
"hidePanelTitles": false,
"syncColors": true,
"syncCursor": true,
"syncTooltips": true,
"useMargins": true,
},
"panels": Array [
Object {
"gridData": Object {
"h": 1,
"i": "0",
"w": 1,
"x": 0,
"y": 0,
},
"id": "1",
"panelConfig": Object {},
"panelIndex": "0",
"title": "Title 1",
"type": "visualization",
"version": undefined,
},
Object {
"gridData": Object {
"h": 2,
"i": "1",
"w": 2,
"x": 1,
"y": 1,
},
"panelConfig": Object {},
"panelIndex": "1",
"title": "Title 2",
"type": "visualization",
"version": undefined,
},
],
"timeRestore": false,
"title": "test",
"version": 1,
@ -294,12 +436,16 @@ describe('injectReferences', () => {
...commonAttributes,
id: '1',
title: 'test',
panelsJSON: JSON.stringify([
panels: [
{
panelIndex: '0',
panelRefName: 'panel_0',
title: 'Title 1',
type: 'visualization',
gridData: { x: 0, y: 0, w: 1, h: 1, i: '0' },
panelConfig: {},
},
]),
],
};
expect(() =>
injectReferences({ attributes, references: [] }, deps)

View file

@ -11,11 +11,11 @@ import type { Reference } from '@kbn/content-management-utils';
import { EmbeddablePersistableStateService } from '@kbn/embeddable-plugin/common/types';
import {
convertPanelMapToSavedPanels,
convertSavedPanelsToPanelMap,
convertPanelMapToPanelsArray,
convertPanelsArrayToPanelMap,
} from '../../lib/dashboard_panel_converters';
import { DashboardAttributesAndReferences, ParsedDashboardAttributesWithType } from '../../types';
import { DashboardAttributes, SavedDashboardPanel } from '../../content_management';
import type { DashboardAttributes } from '../../../server/content_management';
import {
createExtract,
createInject,
@ -25,20 +25,12 @@ export interface InjectExtractDeps {
embeddablePersistableStateService: EmbeddablePersistableStateService;
}
function parseDashboardAttributesWithType(
attributes: DashboardAttributes
): ParsedDashboardAttributesWithType {
let parsedPanels = [] as SavedDashboardPanel[];
if (typeof attributes.panelsJSON === 'string') {
const parsedJSON = JSON.parse(attributes.panelsJSON);
if (Array.isArray(parsedJSON)) {
parsedPanels = parsedJSON as SavedDashboardPanel[];
}
}
function parseDashboardAttributesWithType({
panels,
}: DashboardAttributes): ParsedDashboardAttributesWithType {
return {
type: 'dashboard',
panels: convertSavedPanelsToPanelMap(parsedPanels),
panels: convertPanelsArrayToPanelMap(panels),
} as ParsedDashboardAttributesWithType;
}
@ -51,12 +43,12 @@ export function injectReferences(
// inject references back into panels via the Embeddable persistable state service.
const inject = createInject(deps.embeddablePersistableStateService);
const injectedState = inject(parsedAttributes, references) as ParsedDashboardAttributesWithType;
const injectedPanels = convertPanelMapToSavedPanels(injectedState.panels);
const injectedPanels = convertPanelMapToPanelsArray(injectedState.panels);
const newAttributes = {
...attributes,
panelsJSON: JSON.stringify(injectedPanels),
} as DashboardAttributes;
panels: injectedPanels,
};
return newAttributes;
}
@ -81,12 +73,12 @@ export function extractReferences(
references: Reference[];
state: ParsedDashboardAttributesWithType;
};
const extractedPanels = convertPanelMapToSavedPanels(extractedState.panels);
const extractedPanels = convertPanelMapToPanelsArray(extractedState.panels);
const newAttributes = {
...attributes,
panelsJSON: JSON.stringify(extractedPanels),
} as DashboardAttributes;
panels: extractedPanels,
};
return {
references: [...references, ...extractedReferences],

View file

@ -7,7 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export type { DashboardOptions, DashboardCapabilities, SharedDashboardState } from './types';
export type { DashboardCapabilities, SharedDashboardState } from './types';
export type {
DashboardPanelMap,
@ -16,9 +16,8 @@ export type {
DashboardContainerByReferenceInput,
} from './dashboard_container/types';
export type { DashboardAttributes, SavedDashboardPanel } from './content_management';
export {
type InjectExtractDeps,
injectReferences,
extractReferences,
} from './dashboard_saved_object/persistable_state/dashboard_saved_object_references';
@ -31,10 +30,8 @@ export {
export { prefixReferencesFromPanel } from './dashboard_container/persistable_state/dashboard_container_references';
export {
convertPanelStateToSavedDashboardPanel,
convertSavedDashboardPanelToPanelState,
convertSavedPanelsToPanelMap,
convertPanelMapToSavedPanels,
convertPanelsArrayToPanelMap,
convertPanelMapToPanelsArray,
} from './lib/dashboard_panel_converters';
export const UI_SETTINGS = {

View file

@ -9,77 +9,63 @@
import { v4 } from 'uuid';
import { omit } from 'lodash';
import { EmbeddableInput, SavedObjectEmbeddableInput } from '@kbn/embeddable-plugin/common';
import type { SavedObjectEmbeddableInput } from '@kbn/embeddable-plugin/common';
import type { Reference } from '@kbn/content-management-utils';
import { DashboardPanelMap, DashboardPanelState } from '..';
import { SavedDashboardPanel } from '../content_management';
import type { DashboardPanelMap } from '..';
import type { DashboardPanel } from '../../server/content_management';
import {
getReferencesForPanelId,
prefixReferencesFromPanel,
} from '../dashboard_container/persistable_state/dashboard_container_references';
export function convertSavedDashboardPanelToPanelState<
TEmbeddableInput extends EmbeddableInput | SavedObjectEmbeddableInput = SavedObjectEmbeddableInput
>(savedDashboardPanel: SavedDashboardPanel): DashboardPanelState<TEmbeddableInput> {
return {
type: savedDashboardPanel.type,
gridData: savedDashboardPanel.gridData,
panelRefName: savedDashboardPanel.panelRefName,
explicitInput: {
id: savedDashboardPanel.panelIndex,
...(savedDashboardPanel.id !== undefined && { savedObjectId: savedDashboardPanel.id }),
...(savedDashboardPanel.title !== undefined && { title: savedDashboardPanel.title }),
...savedDashboardPanel.embeddableConfig,
} as TEmbeddableInput,
/**
* Version information used to be stored in the panel until 8.11 when it was moved
* to live inside the explicit Embeddable Input. If version information is given here, we'd like to keep it.
* It will be removed on Dashboard save
*/
version: savedDashboardPanel.version,
};
}
export function convertPanelStateToSavedDashboardPanel(
panelState: DashboardPanelState,
removeLegacyVersion?: boolean
): SavedDashboardPanel {
const savedObjectId = (panelState.explicitInput as SavedObjectEmbeddableInput).savedObjectId;
return {
/**
* 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
* the time being.
*/
...(!removeLegacyVersion ? { version: panelState.version } : {}),
type: panelState.type,
gridData: panelState.gridData,
panelIndex: panelState.explicitInput.id,
embeddableConfig: omit(panelState.explicitInput, ['id', 'savedObjectId', 'title']),
...(panelState.explicitInput.title !== undefined && { title: panelState.explicitInput.title }),
...(savedObjectId !== undefined && { id: savedObjectId }),
...(panelState.panelRefName !== undefined && { panelRefName: panelState.panelRefName }),
};
}
export const convertSavedPanelsToPanelMap = (panels?: SavedDashboardPanel[]): DashboardPanelMap => {
export const convertPanelsArrayToPanelMap = (panels?: DashboardPanel[]): DashboardPanelMap => {
const panelsMap: DashboardPanelMap = {};
panels?.forEach((panel, idx) => {
panelsMap![panel.panelIndex ?? String(idx)] = convertSavedDashboardPanelToPanelState(panel);
const panelIndex = panel.panelIndex ?? String(idx);
panelsMap![panel.panelIndex ?? String(idx)] = {
type: panel.type,
gridData: panel.gridData,
panelRefName: panel.panelRefName,
explicitInput: {
id: panelIndex,
...(panel.id !== undefined && { savedObjectId: panel.id }),
...(panel.title !== undefined && { title: panel.title }),
...panel.panelConfig,
},
version: panel.version,
};
});
return panelsMap;
};
export const convertPanelMapToSavedPanels = (
export const convertPanelMapToPanelsArray = (
panels: DashboardPanelMap,
removeLegacyVersion?: boolean
) => {
return Object.values(panels).map((panel) =>
convertPanelStateToSavedDashboardPanel(panel, removeLegacyVersion)
);
return Object.values(panels).map((panelState) => {
const savedObjectId = (panelState.explicitInput as SavedObjectEmbeddableInput).savedObjectId;
const panelIndex = panelState.explicitInput.id;
return {
/**
* 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
* the time being.
*/
...(!removeLegacyVersion ? { version: panelState.version } : {}),
type: panelState.type,
gridData: panelState.gridData,
panelIndex,
panelConfig: omit(panelState.explicitInput, ['id', 'savedObjectId', 'title']),
...(panelState.explicitInput.title !== undefined && {
title: panelState.explicitInput.title,
}),
...(savedObjectId !== undefined && { id: savedObjectId }),
...(panelState.panelRefName !== undefined && { panelRefName: panelState.panelRefName }),
};
});
};
/**

View file

@ -8,17 +8,9 @@
*/
import type { Reference } from '@kbn/content-management-utils';
import { EmbeddableStateWithType } from '@kbn/embeddable-plugin/common';
import { DashboardAttributes, SavedDashboardPanel } from './content_management';
import { DashboardContainerInput, DashboardPanelMap } from './dashboard_container/types';
export interface DashboardOptions {
hidePanelTitles: boolean;
useMargins: boolean;
syncColors: boolean;
syncTooltips: boolean;
syncCursor: boolean;
}
import type { EmbeddableStateWithType } from '@kbn/embeddable-plugin/common';
import type { DashboardContainerInput, DashboardPanelMap } from './dashboard_container/types';
import type { DashboardAttributes, DashboardPanel } from '../server/content_management';
export interface DashboardCapabilities {
showWriteControls: boolean;
@ -32,7 +24,7 @@ export interface DashboardCapabilities {
* For BWC reasons, dashboard state is stored with panels as an array instead of a map
*/
export type SharedDashboardState = Partial<
Omit<DashboardContainerInput, 'panels'> & { panels: SavedDashboardPanel[] }
Omit<DashboardContainerInput, 'panels'> & { panels: DashboardPanel[] }
>;
/**

View file

@ -10,7 +10,7 @@
import { ScopedHistory } from '@kbn/core-application-browser';
import { ForwardedDashboardState } from './locator';
import { convertSavedPanelsToPanelMap, DashboardContainerInput } from '../../../common';
import { convertPanelsArrayToPanelMap, DashboardContainerInput } from '../../../common';
export const loadDashboardHistoryLocationState = (
getScopedHistory: () => ScopedHistory
@ -28,6 +28,6 @@ export const loadDashboardHistoryLocationState = (
return {
...restOfState,
...{ panels: convertSavedPanelsToPanelMap(panels) },
...{ panels: convertPanelsArrayToPanelMap(panels) },
};
};

View file

@ -8,7 +8,7 @@
*/
import { Capabilities } from '@kbn/core/public';
import { convertPanelMapToSavedPanels, DashboardContainerInput } from '../../../../common';
import { convertPanelMapToPanelsArray, DashboardContainerInput } from '../../../../common';
import { DashboardLocatorParams } from '../../../dashboard_container';
import { shareService } from '../../../services/kibana_services';
@ -143,7 +143,7 @@ describe('ShowShareModal', () => {
).locatorParams.params;
const rawDashboardState = {
...unsavedDashboardState,
panels: convertPanelMapToSavedPanels(unsavedDashboardState.panels),
panels: convertPanelMapToPanelsArray(unsavedDashboardState.panels),
};
unsavedStateKeys.forEach((key) => {
expect(shareLocatorParams[key]).toStrictEqual(
@ -208,8 +208,8 @@ describe('ShowShareModal', () => {
).locatorParams.params;
expect(shareLocatorParams.panels).toBeDefined();
expect(shareLocatorParams.panels![0].embeddableConfig.changedKey1).toBe('changed');
expect(shareLocatorParams.panels![1].embeddableConfig.changedKey2).toBe('definitely changed');
expect(shareLocatorParams.panels![2].embeddableConfig.changedKey3).toBe('should still exist');
expect(shareLocatorParams.panels![0].panelConfig.changedKey1).toBe('changed');
expect(shareLocatorParams.panels![1].panelConfig.changedKey2).toBe('definitely changed');
expect(shareLocatorParams.panels![2].panelConfig.changedKey3).toBe('should still exist');
});
});

View file

@ -19,7 +19,7 @@ import { ViewMode } from '@kbn/embeddable-plugin/public';
import { i18n } from '@kbn/i18n';
import { getStateFromKbnUrl, setStateToKbnUrl, unhashUrl } from '@kbn/kibana-utils-plugin/public';
import { convertPanelMapToSavedPanels, DashboardPanelMap } from '../../../../common';
import { convertPanelMapToPanelsArray, DashboardPanelMap } from '../../../../common';
import { DashboardLocatorParams } from '../../../dashboard_container';
import {
getDashboardBackupService,
@ -151,7 +151,7 @@ export function ShowShareModal({
...latestPanels,
...modifiedPanels,
};
return convertPanelMapToSavedPanels(allUnsavedPanelsMap);
return convertPanelMapToPanelsArray(allUnsavedPanelsMap);
})();
if (unsavedDashboardState) {

View file

@ -22,7 +22,7 @@ import type { ViewMode } from '@kbn/embeddable-plugin/common';
import { DASHBOARD_APP_LOCATOR } from '@kbn/deeplinks-analytics';
import { SEARCH_SESSION_ID } from '../../dashboard_constants';
import { DashboardLocatorParams } from '../../dashboard_container';
import { convertPanelMapToSavedPanels } from '../../../common';
import { convertPanelMapToPanelsArray } from '../../../common';
import { dataService } from '../../services/kibana_services';
import { DashboardApi } from '../../dashboard_api/types';
@ -93,7 +93,7 @@ function getLocatorParams({
: undefined,
panels: savedObjectId
? undefined
: (convertPanelMapToSavedPanels(
: (convertPanelMapToPanelsArray(
dashboardApi.panels$.value
) as DashboardLocatorParams['panels']),
};

View file

@ -19,24 +19,29 @@ import {
DashboardContainerInput,
DashboardPanelMap,
SharedDashboardState,
convertSavedPanelsToPanelMap,
convertPanelsArrayToPanelMap,
} from '../../../common';
import { SavedDashboardPanel } from '../../../common/content_management';
import type { DashboardPanel } from '../../../server/content_management';
import type { SavedDashboardPanel } from '../../../server/dashboard_saved_object';
import { DashboardApi } from '../../dashboard_api/types';
import { DASHBOARD_STATE_STORAGE_KEY, createDashboardEditUrl } from '../../dashboard_constants';
import { migrateLegacyQuery } from '../../services/dashboard_content_management_service/lib/load_dashboard_state';
import { coreServices } from '../../services/kibana_services';
import { getPanelTooOldErrorString } from '../_dashboard_app_strings';
const panelIsLegacy = (panel: unknown): panel is SavedDashboardPanel => {
return (panel as SavedDashboardPanel).embeddableConfig !== undefined;
};
/**
* We no longer support loading panels from a version older than 7.3 in the URL.
* @returns whether or not there is a panel in the URL state saved with a version before 7.3
*/
export const isPanelVersionTooOld = (panels: SavedDashboardPanel[]) => {
export const isPanelVersionTooOld = (panels: DashboardPanel[] | SavedDashboardPanel[]) => {
for (const panel of panels) {
if (
!panel.gridData ||
!panel.embeddableConfig ||
!((panel as DashboardPanel).panelConfig || (panel as SavedDashboardPanel).embeddableConfig) ||
(panel.version && semverSatisfies(panel.version, '<7.3'))
)
return true;
@ -58,7 +63,19 @@ function getPanelsMap(appStateInUrl: SharedDashboardState): DashboardPanelMap |
return undefined;
}
return convertSavedPanelsToPanelMap(appStateInUrl.panels);
// convert legacy embeddableConfig keys to panelConfig
const panels = appStateInUrl.panels.map((panel) => {
if (panelIsLegacy(panel)) {
const { embeddableConfig, ...rest } = panel;
return {
...rest,
panelConfig: embeddableConfig,
};
}
return panel;
});
return convertPanelsArrayToPanelMap(panels);
}
/**

View file

@ -27,6 +27,7 @@ import {
DashboardPanelMap,
prefixReferencesFromPanel,
} from '../../../../common';
import type { DashboardAttributes } from '../../../../server/content_management';
import { DASHBOARD_CONTENT_ID, SAVED_OBJECT_POST_TIME } from '../../../dashboard_constants';
import {
SaveDashboardReturn,
@ -95,7 +96,11 @@ export async function runQuickSave(this: DashboardContainer) {
const { rawState: controlGroupSerializedState, references: extractedReferences } =
await controlGroupApi.serializeState();
controlGroupReferences = extractedReferences;
stateToSave = { ...stateToSave, controlGroupInput: controlGroupSerializedState };
stateToSave = {
...stateToSave,
controlGroupInput:
controlGroupSerializedState as unknown as DashboardAttributes['controlGroupInput'],
};
}
const saveResult = await getDashboardContentManagementService().saveDashboardState({
@ -186,7 +191,8 @@ export async function runInteractiveSave(this: DashboardContainer, interactionMo
controlGroupReferences = references;
dashboardStateToSave = {
...dashboardStateToSave,
controlGroupInput: controlGroupSerializedState,
controlGroupInput:
controlGroupSerializedState as unknown as DashboardAttributes['controlGroupInput'],
};
}

View file

@ -25,7 +25,7 @@ import { v4 } from 'uuid';
import { METRIC_TYPE } from '@kbn/analytics';
import type { Reference } from '@kbn/content-management-utils';
import { ControlGroupApi, ControlGroupSerializedState } from '@kbn/controls-plugin/public';
import { ControlGroupApi } from '@kbn/controls-plugin/public';
import type { KibanaExecutionContext, OverlayRef } from '@kbn/core/public';
import { RefreshInterval } from '@kbn/data-plugin/public';
import type { DataView } from '@kbn/data-views-plugin/public';
@ -69,12 +69,8 @@ import { LocatorPublic } from '@kbn/share-plugin/common';
import { ExitFullScreenButtonKibanaProvider } from '@kbn/shared-ux-button-exit-full-screen';
import { DASHBOARD_CONTAINER_TYPE, DashboardApi, DashboardLocatorParams } from '../..';
import {
DashboardAttributes,
DashboardContainerInput,
DashboardPanelMap,
DashboardPanelState,
} from '../../../common';
import type { DashboardAttributes } from '../../../server/content_management';
import { DashboardContainerInput, DashboardPanelMap, DashboardPanelState } from '../../../common';
import {
getReferencesForControls,
getReferencesForPanelId,
@ -887,15 +883,19 @@ export class DashboardContainer
public getSerializedStateForControlGroup = () => {
return {
rawState: this.controlGroupInput
? (this.controlGroupInput as ControlGroupSerializedState)
: ({
controlStyle: 'oneLine',
? this.controlGroupInput
: {
labelPosition: 'oneLine',
chainingSystem: 'HIERARCHICAL',
showApplySelections: false,
panelsJSON: '{}',
ignoreParentSettingsJSON:
'{"ignoreFilters":false,"ignoreQuery":false,"ignoreTimerange":false,"ignoreValidations":false}',
} as ControlGroupSerializedState),
autoApplySelections: true,
controls: [],
ignoreParentSettings: {
ignoreFilters: false,
ignoreQuery: false,
ignoreTimerange: false,
ignoreValidations: false,
},
},
references: getReferencesForControls(this.savedObjectReferences),
};
};

View file

@ -11,7 +11,7 @@ import { cloneDeep, forOwn } from 'lodash';
import { PanelNotFoundError } from '@kbn/embeddable-plugin/public';
import { DashboardPanelState } from '../../../common';
import { GridData } from '../../../common/content_management';
import type { GridData } from '../../../server/content_management';
import { PanelPlacementProps, PanelPlacementReturn } from './types';
import { DASHBOARD_GRID_COLUMN_COUNT } from '../../dashboard_constants';
@ -109,9 +109,9 @@ export function placeClonePanel({
for (let j = position + 1; j < grid.length; j++) {
originalPositionInTheGrid = grid[j].i;
const movedPanel = cloneDeep(otherPanels[originalPositionInTheGrid]);
movedPanel.gridData.y = movedPanel.gridData.y + diff;
otherPanels[originalPositionInTheGrid] = movedPanel;
const { gridData, ...movedPanel } = cloneDeep(otherPanels[originalPositionInTheGrid]);
const newGridData = { ...gridData, y: gridData.y + diff };
otherPanels[originalPositionInTheGrid] = { ...movedPanel, gridData: newGridData };
}
return { newPanelPlacement: bottomPlacement.grid, otherPanels };
}

View file

@ -20,9 +20,9 @@ export const runPanelPlacementStrategy = (
case PanelPlacementStrategy.placeAtTop:
const otherPanels = { ...currentPanels };
for (const [id, panel] of Object.entries(currentPanels)) {
const currentPanel = cloneDeep(panel);
currentPanel.gridData.y = currentPanel.gridData.y + height;
otherPanels[id] = currentPanel;
const { gridData, ...currentPanel } = cloneDeep(panel);
const newGridData = { ...gridData, y: gridData.y + height };
otherPanels[id] = { ...currentPanel, gridData: newGridData };
}
return {
newPanelPlacement: { x: 0, y: 0, w: width, h: height },

View file

@ -10,7 +10,7 @@
import { EmbeddableInput } from '@kbn/embeddable-plugin/public';
import { MaybePromise } from '@kbn/utility-types';
import { DashboardPanelState } from '../../../common';
import { GridData } from '../../../common/content_management';
import type { GridData } from '../../../server/content_management';
import { PanelPlacementStrategy } from '../../dashboard_constants';
export interface PanelPlacementSettings {

View file

@ -12,8 +12,8 @@ import type { ReduxEmbeddableState } from '@kbn/presentation-util-plugin/public'
import { SerializableRecord } from '@kbn/utility-types';
import { ControlGroupRuntimeState } from '@kbn/controls-plugin/public';
import type { DashboardContainerInput, DashboardOptions } from '../../common';
import { SavedDashboardPanel } from '../../common/content_management';
import type { DashboardContainerInput } from '../../common';
import type { DashboardOptions, DashboardPanel } from '../../server/content_management';
export interface UnsavedPanelState {
[key: string]: object | undefined;
@ -101,7 +101,7 @@ export type DashboardLocatorParams = Partial<
/**
* List of dashboard panels
*/
panels?: Array<SavedDashboardPanel & SerializableRecord>; // used SerializableRecord here to force the GridData type to be read as serializable
panels?: Array<DashboardPanel & SerializableRecord>; // used SerializableRecord here to force the GridData type to be read as serializable
/**
* Control group changes

View file

@ -20,7 +20,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { DashboardAttributes } from '../../common/content_management';
import type { DashboardAttributes } from '../../server/content_management';
import {
DASHBOARD_PANELS_UNSAVED_ID,
getDashboardBackupService,

View file

@ -17,7 +17,7 @@ import { reportPerformanceMetricEvent } from '@kbn/ebt-tools';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { DashboardContainerInput } from '../../../common';
import { DashboardItem } from '../../../common/content_management';
import type { DashboardSearchOut } from '../../../server/content_management';
import {
DASHBOARD_CONTENT_ID,
SAVED_OBJECT_DELETE_TIME,
@ -42,7 +42,9 @@ type GetDetailViewLink =
const SAVED_OBJECTS_LIMIT_SETTING = 'savedObjects:listingLimit';
const SAVED_OBJECTS_PER_PAGE_SETTING = 'savedObjects:perPage';
const toTableListViewSavedObject = (hit: DashboardItem): DashboardSavedObjectUserContent => {
const toTableListViewSavedObject = (
hit: DashboardSearchOut['hits'][number]
): DashboardSavedObjectUserContent => {
const { title, description, timeRestore } = hit.attributes;
return {
type: 'dashboard',
@ -51,7 +53,7 @@ const toTableListViewSavedObject = (hit: DashboardItem): DashboardSavedObjectUse
createdAt: hit.createdAt,
createdBy: hit.createdBy,
updatedBy: hit.updatedBy,
references: hit.references,
references: hit.references ?? [],
managed: hit.managed,
attributes: {
title,

View file

@ -8,14 +8,14 @@
*/
import LRUCache from 'lru-cache';
import { DashboardCrudTypes } from '../../../common/content_management';
import type { DashboardGetOut } from '../../../server/content_management';
import { DASHBOARD_CACHE_SIZE, DASHBOARD_CACHE_TTL } from '../../dashboard_constants';
export class DashboardContentManagementCache {
private cache: LRUCache<string, DashboardCrudTypes['GetOut']>;
private cache: LRUCache<string, DashboardGetOut>;
constructor() {
this.cache = new LRUCache<string, DashboardCrudTypes['GetOut']>({
this.cache = new LRUCache<string, DashboardGetOut>({
max: DASHBOARD_CACHE_SIZE,
maxAge: DASHBOARD_CACHE_TTL,
});
@ -27,7 +27,7 @@ export class DashboardContentManagementCache {
}
/** Add the fetched dashboard to the cache */
public addDashboard({ item: dashboard, meta }: DashboardCrudTypes['GetOut']) {
public addDashboard({ item: dashboard, meta }: DashboardGetOut) {
this.cache.set(dashboard.id, {
meta,
item: dashboard,

View file

@ -7,8 +7,8 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { DashboardSearchIn, DashboardSearchOut } from '../../../../server/content_management';
import { DASHBOARD_CONTENT_ID } from '../../../dashboard_constants';
import { DashboardCrudTypes } from '../../../../common/content_management';
import { extractTitleAndCount } from '../../../dashboard_container/embeddable/api/lib/extract_title_and_count';
import { contentManagementService } from '../../kibana_services';
@ -54,8 +54,8 @@ export async function checkForDuplicateDashboardTitle({
const [baseDashboardName] = extractTitleAndCount(title);
const { hits } = await contentManagementService.client.search<
DashboardCrudTypes['SearchIn'],
DashboardCrudTypes['SearchOut']
DashboardSearchIn,
DashboardSearchOut
>({
contentTypeId: DASHBOARD_CONTENT_ID,
query: {

View file

@ -7,18 +7,15 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { getDashboardContentManagementCache } from '..';
import { DashboardCrudTypes } from '../../../../common/content_management';
import type { DeleteIn, DeleteResult } from '@kbn/content-management-plugin/common';
import { DASHBOARD_CONTENT_ID } from '../../../dashboard_constants';
import { getDashboardContentManagementCache } from '..';
import { contentManagementService } from '../../kibana_services';
export const deleteDashboards = async (ids: string[]) => {
const deletePromises = ids.map((id) => {
getDashboardContentManagementCache().deleteDashboard(id);
return contentManagementService.client.delete<
DashboardCrudTypes['DeleteIn'],
DashboardCrudTypes['DeleteOut']
>({
return contentManagementService.client.delete<DeleteIn, DeleteResult>({
contentTypeId: DASHBOARD_CONTENT_ID,
id,
});

View file

@ -10,17 +10,20 @@
import type { Reference } from '@kbn/content-management-utils';
import { SavedObjectError, SavedObjectsFindOptionsReference } from '@kbn/core/public';
import { getDashboardContentManagementCache } from '..';
import {
import type {
DashboardAttributes,
DashboardCrudTypes,
DashboardItem,
} from '../../../../common/content_management';
DashboardGetIn,
DashboardGetOut,
DashboardSearchIn,
DashboardSearchOut,
DashboardSearchOptions,
} from '../../../../server/content_management';
import { getDashboardContentManagementCache } from '..';
import { DASHBOARD_CONTENT_ID } from '../../../dashboard_constants';
import { contentManagementService } from '../../kibana_services';
export interface SearchDashboardsArgs {
options?: DashboardCrudTypes['SearchIn']['options'];
options?: DashboardSearchOptions;
hasNoReference?: SavedObjectsFindOptionsReference[];
hasReference?: SavedObjectsFindOptionsReference[];
search: string;
@ -29,7 +32,7 @@ export interface SearchDashboardsArgs {
export interface SearchDashboardsResponse {
total: number;
hits: DashboardItem[];
hits: DashboardSearchOut['hits'];
}
export async function searchDashboards({
@ -42,10 +45,7 @@ export async function searchDashboards({
const {
hits,
pagination: { total },
} = await contentManagementService.client.search<
DashboardCrudTypes['SearchIn'],
DashboardCrudTypes['SearchOut']
>({
} = await contentManagementService.client.search<DashboardSearchIn, DashboardSearchOut>({
contentTypeId: DASHBOARD_CONTENT_ID,
query: {
text: search ? `${search}*` : undefined,
@ -84,10 +84,7 @@ export async function findDashboardById(id: string): Promise<FindDashboardsByIdR
/** Otherwise, fetch the dashboard from the content management client, add it to the cache, and return the result */
try {
const response = await contentManagementService.client.get<
DashboardCrudTypes['GetIn'],
DashboardCrudTypes['GetOut']
>({
const response = await contentManagementService.client.get<DashboardGetIn, DashboardGetOut>({
contentTypeId: DASHBOARD_CONTENT_ID,
id,
});
@ -119,8 +116,8 @@ export async function findDashboardsByIds(ids: string[]): Promise<FindDashboards
export async function findDashboardIdByTitle(title: string): Promise<{ id: string } | undefined> {
const { hits } = await contentManagementService.client.search<
DashboardCrudTypes['SearchIn'],
DashboardCrudTypes['SearchOut']
DashboardSearchIn,
DashboardSearchOut
>({
contentTypeId: DASHBOARD_CONTENT_ID,
query: {

View file

@ -10,19 +10,15 @@
import { has } from 'lodash';
import { v4 as uuidv4 } from 'uuid';
import { injectSearchSourceReferences, parseSearchSourceJSON } from '@kbn/data-plugin/public';
import { injectSearchSourceReferences } from '@kbn/data-plugin/public';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { Filter, Query } from '@kbn/es-query';
import { SavedObjectNotFound } from '@kbn/kibana-utils-plugin/public';
import { cleanFiltersForSerialize } from '@kbn/presentation-util-plugin/public';
import { getDashboardContentManagementCache } from '..';
import {
convertSavedPanelsToPanelMap,
injectReferences,
type DashboardOptions,
} from '../../../../common';
import { DashboardCrudTypes } from '../../../../common/content_management';
import { convertPanelsArrayToPanelMap, injectReferences } from '../../../../common';
import type { DashboardGetIn, DashboardGetOut } from '../../../../server/content_management';
import { DASHBOARD_CONTENT_ID, DEFAULT_DASHBOARD_INPUT } from '../../../dashboard_constants';
import {
contentManagementService,
@ -30,7 +26,11 @@ import {
embeddableService,
savedObjectsTaggingService,
} from '../../kibana_services';
import type { LoadDashboardFromSavedObjectProps, LoadDashboardReturn } from '../types';
import type {
DashboardSearchSource,
LoadDashboardFromSavedObjectProps,
LoadDashboardReturn,
} from '../types';
import { convertNumberToDashboardVersion } from './dashboard_versioning';
import { migrateDashboardInput } from './migrate_dashboard_input';
@ -72,8 +72,8 @@ export const loadDashboardState = async ({
/**
* Load the saved object from Content Management
*/
let rawDashboardContent: DashboardCrudTypes['GetOut']['item'];
let resolveMeta: DashboardCrudTypes['GetOut']['meta'];
let rawDashboardContent: DashboardGetOut['item'];
let resolveMeta: DashboardGetOut['meta'];
const cachedDashboard = dashboardContentManagementCache.fetchDashboard(id);
if (cachedDashboard) {
@ -82,7 +82,7 @@ export const loadDashboardState = async ({
} else {
/** Otherwise, fetch and load the dashboard from the content management client, and add it to the cache */
const result = await contentManagementService.client
.get<DashboardCrudTypes['GetIn'], DashboardCrudTypes['GetOut']>({
.get<DashboardGetIn, DashboardGetOut>({
contentTypeId: DASHBOARD_CONTENT_ID,
id,
})
@ -127,14 +127,16 @@ export const loadDashboardState = async ({
/**
* Create search source and pull filters and query from it.
*/
const searchSourceJSON = attributes.kibanaSavedObjectMeta.searchSourceJSON;
let searchSourceValues = attributes.kibanaSavedObjectMeta.searchSource;
const searchSource = await (async () => {
if (!searchSourceJSON) {
if (!searchSourceValues) {
return await dataSearchService.searchSource.create();
}
try {
let searchSourceValues = parseSearchSourceJSON(searchSourceJSON);
searchSourceValues = injectSearchSourceReferences(searchSourceValues as any, references);
searchSourceValues = injectSearchSourceReferences(
searchSourceValues as any,
references
) as DashboardSearchSource;
return await dataSearchService.searchSource.create(searchSourceValues);
} catch (error: any) {
return await dataSearchService.searchSource.create();
@ -151,8 +153,8 @@ export const loadDashboardState = async ({
refreshInterval,
description,
timeRestore,
optionsJSON,
panelsJSON,
options,
panels,
timeFrom,
version,
timeTo,
@ -167,11 +169,7 @@ export const loadDashboardState = async ({
}
: undefined;
/**
* Parse panels and options from JSON
*/
const options: DashboardOptions = optionsJSON ? JSON.parse(optionsJSON) : undefined;
const panels = convertSavedPanelsToPanelMap(panelsJSON ? JSON.parse(panelsJSON) : []);
const panelMap = convertPanelsArrayToPanelMap(panels ?? []);
const { dashboardInput, anyMigrationRun } = migrateDashboardInput({
...DEFAULT_DASHBOARD_INPUT,
@ -183,7 +181,7 @@ export const loadDashboardState = async ({
description,
timeRange,
filters,
panels,
panels: panelMap,
query,
title,
@ -192,7 +190,7 @@ export const loadDashboardState = async ({
controlGroupInput: attributes.controlGroupInput,
version: convertNumberToDashboardVersion(version),
...(version && { version: convertNumberToDashboardVersion(version) }),
});
return {

View file

@ -95,7 +95,7 @@ describe('Save dashboard state', () => {
currentState: {
...getSampleDashboardInput(),
title: 'BooThree',
panels: { idOne: { type: 'boop' } },
panels: { aVerySpecialVeryUniqueId: { type: 'boop' } },
} as unknown as DashboardContainerInput,
lastSavedId: 'Boogatoonie',
saveOptions: { saveAsCopy: true },
@ -106,7 +106,11 @@ describe('Save dashboard state', () => {
expect(contentManagementService.client.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
panelsJSON: expect.not.stringContaining('neverGonnaGetThisId'),
panels: expect.arrayContaining([
expect.objectContaining({
panelIndex: expect.not.stringContaining('aVerySpecialVeryUniqueId'),
}),
]),
}),
})
);

View file

@ -13,9 +13,16 @@ import moment, { Moment } from 'moment';
import { extractSearchSourceReferences, RefreshInterval } from '@kbn/data-plugin/public';
import { isFilterPinned } from '@kbn/es-query';
import type { SavedObjectReference } from '@kbn/core/server';
import { getDashboardContentManagementCache } from '..';
import { convertPanelMapToSavedPanels, extractReferences } from '../../../../common';
import { DashboardAttributes, DashboardCrudTypes } from '../../../../common/content_management';
import { convertPanelMapToPanelsArray, extractReferences } from '../../../../common';
import type {
DashboardAttributes,
DashboardCreateIn,
DashboardCreateOut,
DashboardUpdateIn,
DashboardUpdateOut,
} from '../../../../server/content_management';
import { generateNewPanelIds } from '../../../../common/lib/dashboard_panel_converters';
import { DASHBOARD_CONTENT_ID } from '../../../dashboard_constants';
import { LATEST_DASHBOARD_CONTAINER_VERSION } from '../../../dashboard_container';
@ -28,7 +35,7 @@ import {
embeddableService,
savedObjectsTaggingService,
} from '../../kibana_services';
import { SaveDashboardProps, SaveDashboardReturn } from '../types';
import { DashboardSearchSource, SaveDashboardProps, SaveDashboardReturn } from '../types';
import { convertDashboardVersionToNumber } from './dashboard_versioning';
export const convertTimeToUTCString = (time?: string | Moment): undefined | string => {
@ -88,33 +95,30 @@ export const saveDashboardState = async ({
//
}
/**
* Stringify filters and query into search source JSON
*/
const { searchSourceJSON, searchSourceReferences } = await (async () => {
const searchSource = await dataSearchService.searchSource.create();
searchSource.setField(
const { searchSource, searchSourceReferences } = await (async () => {
const searchSourceFields = await dataSearchService.searchSource.create();
searchSourceFields.setField(
'filter', // save only unpinned filters
filters.filter((filter) => !isFilterPinned(filter))
);
searchSource.setField('query', query);
searchSourceFields.setField('query', query);
const rawSearchSourceFields = searchSource.getSerializedFields();
const [fields, references] = extractSearchSourceReferences(rawSearchSourceFields);
return { searchSourceReferences: references, searchSourceJSON: JSON.stringify(fields) };
const rawSearchSourceFields = searchSourceFields.getSerializedFields();
const [fields, references] = extractSearchSourceReferences(rawSearchSourceFields) as [
DashboardSearchSource,
SavedObjectReference[]
];
return { searchSourceReferences: references, searchSource: fields };
})();
/**
* Stringify options and panels
*/
const optionsJSON = JSON.stringify({
const options = {
useMargins,
syncColors,
syncCursor,
syncTooltips,
hidePanelTitles,
});
const panelsJSON = JSON.stringify(convertPanelMapToSavedPanels(panels, true));
};
const savedPanels = convertPanelMapToPanelsArray(panels, true);
/**
* Parse global time filter settings
@ -134,12 +138,12 @@ export const saveDashboardState = async ({
const rawDashboardAttributes: DashboardAttributes = {
version: convertDashboardVersionToNumber(LATEST_DASHBOARD_CONTAINER_VERSION),
controlGroupInput,
kibanaSavedObjectMeta: { searchSourceJSON },
kibanaSavedObjectMeta: { searchSource },
description: description ?? '',
refreshInterval,
timeRestore,
optionsJSON,
panelsJSON,
options,
panels: savedPanels,
timeFrom,
title,
timeTo,
@ -174,10 +178,7 @@ export const saveDashboardState = async ({
try {
const result = idToSaveTo
? await contentManagementService.client.update<
DashboardCrudTypes['UpdateIn'],
DashboardCrudTypes['UpdateOut']
>({
? await contentManagementService.client.update<DashboardUpdateIn, DashboardUpdateOut>({
id: idToSaveTo,
contentTypeId: DASHBOARD_CONTENT_ID,
data: attributes,
@ -187,10 +188,7 @@ export const saveDashboardState = async ({
mergeAttributes: false,
},
})
: await contentManagementService.client.create<
DashboardCrudTypes['CreateIn'],
DashboardCrudTypes['CreateOut']
>({
: await contentManagementService.client.create<DashboardCreateIn, DashboardCreateOut>({
contentTypeId: DASHBOARD_CONTENT_ID,
data: attributes,
options: {

View file

@ -9,7 +9,7 @@
import { DashboardContainerInput } from '../../../../common';
import { DASHBOARD_CONTENT_ID } from '../../../dashboard_constants';
import { DashboardCrudTypes } from '../../../../common/content_management';
import type { DashboardUpdateIn, DashboardUpdateOut } from '../../../../server/content_management';
import { findDashboardsByIds } from './find_dashboards';
import { contentManagementService, savedObjectsTaggingService } from '../../kibana_services';
@ -35,10 +35,7 @@ export const updateDashboardMeta = async ({
? savedObjectsTaggingApi.ui.updateTagsReferences(dashboard.references, tags)
: dashboard.references;
await contentManagementService.client.update<
DashboardCrudTypes['UpdateIn'],
DashboardCrudTypes['UpdateOut']
>({
await contentManagementService.client.update<DashboardUpdateIn, DashboardUpdateOut>({
contentTypeId: DASHBOARD_CONTENT_ID,
id,
data: { title, description },

View file

@ -8,11 +8,12 @@
*/
import type { Reference } from '@kbn/content-management-utils';
import type { Query, SerializedSearchSourceFields } from '@kbn/data-plugin/common';
import { ControlGroupRuntimeState } from '@kbn/controls-plugin/public';
import { SavedObjectSaveOpts } from '@kbn/saved-objects-plugin/public';
import { DashboardContainerInput } from '../../../common';
import { DashboardAttributes, DashboardCrudTypes } from '../../../common/content_management';
import type { DashboardAttributes, DashboardGetOut } from '../../../server/content_management';
import { DashboardDuplicateTitleCheckProps } from './lib/check_for_duplicate_dashboard_title';
import {
FindDashboardsByIdResponse,
@ -38,7 +39,7 @@ export interface LoadDashboardFromSavedObjectProps {
id?: string;
}
type DashboardResolveMeta = DashboardCrudTypes['GetOut']['meta'];
type DashboardResolveMeta = DashboardGetOut['meta'];
export type SavedDashboardInput = DashboardContainerInput & {
/**
@ -54,6 +55,10 @@ export type SavedDashboardInput = DashboardContainerInput & {
controlGroupState?: Partial<ControlGroupRuntimeState>;
};
export type DashboardSearchSource = Omit<SerializedSearchSourceFields, 'query'> & {
query?: Query;
};
export interface LoadDashboardReturn {
dashboardFound: boolean;
newDashboardCreated?: boolean;

View file

@ -32,7 +32,8 @@ import { urlForwardingPluginMock } from '@kbn/url-forwarding-plugin/public/mocks
import { visualizationsPluginMock } from '@kbn/visualizations-plugin/public/mocks';
import { setKibanaServices } from './kibana_services';
import { DashboardAttributes, DashboardCapabilities } from '../../common';
import { DashboardAttributes } from '../../server/content_management';
import { DashboardCapabilities } from '../../common';
import { LoadDashboardReturn } from './dashboard_content_management_service/types';
import { SearchDashboardsResponse } from './dashboard_content_management_service/lib/find_dashboards';

View file

@ -0,0 +1,12 @@
/*
* 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 const PUBLIC_API_VERSION = '2023-10-31';
export const PUBLIC_API_CONTENT_MANAGEMENT_VERSION = 3;
export const PUBLIC_API_PATH = '/api/dashboards/dashboard';

View file

@ -7,8 +7,4 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export {
serviceDefinition,
dashboardSavedObjectSchema,
dashboardAttributesSchema,
} from './cm_services';
export { registerAPIRoutes } from './register_routes';

View file

@ -0,0 +1,327 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { schema } from '@kbn/config-schema';
import type { ContentManagementServerSetup } from '@kbn/content-management-plugin/server';
import type { HttpServiceSetup } from '@kbn/core/server';
import type { UsageCounter } from '@kbn/usage-collection-plugin/server';
import type { Logger } from '@kbn/logging';
import { CONTENT_ID } from '../../common/content_management';
import {
PUBLIC_API_PATH,
PUBLIC_API_VERSION,
PUBLIC_API_CONTENT_MANAGEMENT_VERSION,
} from './constants';
import {
dashboardAttributesSchema,
dashboardGetResultSchema,
dashboardCreateResultSchema,
dashboardSearchResultsSchema,
referenceSchema,
} from '../content_management/v3';
interface RegisterAPIRoutesArgs {
http: HttpServiceSetup;
contentManagement: ContentManagementServerSetup;
restCounter?: UsageCounter;
logger: Logger;
}
const TECHNICAL_PREVIEW_WARNING =
'This functionality is in technical preview and may be changed or removed in a future release. Elastic will work to fix any issues, but features in technical preview are not subject to the support SLA of official GA features.';
export function registerAPIRoutes({
http,
contentManagement,
restCounter,
logger,
}: RegisterAPIRoutesArgs) {
const { versioned: versionedRouter } = http.createRouter();
// Create API route
const createRoute = versionedRouter.post({
path: `${PUBLIC_API_PATH}/{id?}`,
access: 'public',
summary: 'Create a dashboard',
description: TECHNICAL_PREVIEW_WARNING,
options: {
tags: ['oas-tag:Dashboards'],
},
});
createRoute.addVersion(
{
version: PUBLIC_API_VERSION,
validate: {
request: {
params: schema.object({
id: schema.maybe(schema.string()),
}),
body: schema.object({
attributes: dashboardAttributesSchema,
references: schema.maybe(schema.arrayOf(referenceSchema)),
spaces: schema.maybe(schema.arrayOf(schema.string())),
}),
},
response: {
200: {
body: () => dashboardCreateResultSchema,
},
},
},
},
async (ctx, req, res) => {
const { id } = req.params;
const { attributes, references, spaces: initialNamespaces } = req.body;
const client = contentManagement.contentClient
.getForRequest({ request: req, requestHandlerContext: ctx })
.for(CONTENT_ID, PUBLIC_API_CONTENT_MANAGEMENT_VERSION);
let result;
try {
({ result } = await client.create(attributes, {
id,
references,
initialNamespaces,
}));
} catch (e) {
if (e.isBoom && e.output.statusCode === 409) {
return res.conflict({
body: {
message: `A dashboard with saved object ID ${id} already exists.`,
},
});
}
if (e.isBoom && e.output.statusCode === 403) {
return res.forbidden();
}
return res.badRequest();
}
return res.ok({ body: result });
}
);
// Update API route
const updateRoute = versionedRouter.put({
path: `${PUBLIC_API_PATH}/{id}`,
access: 'public',
summary: `Update an existing dashboard.`,
description: TECHNICAL_PREVIEW_WARNING,
options: {
tags: ['oas-tag:Dashboards'],
},
});
updateRoute.addVersion(
{
version: PUBLIC_API_VERSION,
validate: {
request: {
params: schema.object({
id: schema.string(),
}),
body: schema.object({
attributes: dashboardAttributesSchema,
references: schema.maybe(schema.arrayOf(referenceSchema)),
}),
},
response: {
200: {
body: () => dashboardCreateResultSchema,
},
},
},
},
async (ctx, req, res) => {
const { attributes, references } = req.body;
const client = contentManagement.contentClient
.getForRequest({ request: req, requestHandlerContext: ctx })
.for(CONTENT_ID, PUBLIC_API_CONTENT_MANAGEMENT_VERSION);
let result;
try {
({ result } = await client.update(req.params.id, attributes, { references }));
} catch (e) {
if (e.isBoom && e.output.statusCode === 404) {
return res.notFound({
body: {
message: `A dashboard with saved object ID ${req.params.id} was not found.`,
},
});
}
if (e.isBoom && e.output.statusCode === 403) {
return res.forbidden();
}
return res.badRequest(e.message);
}
return res.created({ body: result });
}
);
// List API route
const listRoute = versionedRouter.get({
path: `${PUBLIC_API_PATH}`,
access: 'public',
summary: `Get a list of dashboards.`,
description: TECHNICAL_PREVIEW_WARNING,
options: {
tags: ['oas-tag:Dashboards'],
},
});
listRoute.addVersion(
{
version: PUBLIC_API_VERSION,
validate: {
request: {
query: schema.object({
page: schema.number({ defaultValue: 1 }),
perPage: schema.maybe(schema.number()),
}),
},
response: {
200: {
body: () =>
schema.object({
items: schema.arrayOf(dashboardSearchResultsSchema),
total: schema.number(),
}),
},
},
},
},
async (ctx, req, res) => {
const { page, perPage: limit } = req.query;
const client = contentManagement.contentClient
.getForRequest({ request: req, requestHandlerContext: ctx })
.for(CONTENT_ID, PUBLIC_API_CONTENT_MANAGEMENT_VERSION);
let result;
try {
// TODO add filtering
({ result } = await client.search({ cursor: page.toString(), limit }));
} catch (e) {
if (e.isBoom && e.output.statusCode === 403) {
return res.forbidden();
}
return res.badRequest();
}
const body = {
items: result.hits,
total: result.pagination.total,
};
return res.ok({ body });
}
);
// Get API route
const getRoute = versionedRouter.get({
path: `${PUBLIC_API_PATH}/{id}`,
access: 'public',
summary: `Get a dashboard.`,
description: TECHNICAL_PREVIEW_WARNING,
options: {
tags: ['oas-tag:Dashboards'],
},
});
getRoute.addVersion(
{
version: PUBLIC_API_VERSION,
validate: {
request: {
params: schema.object({
id: schema.string(),
}),
},
response: {
200: {
body: () => dashboardGetResultSchema,
},
},
},
},
async (ctx, req, res) => {
const client = contentManagement.contentClient
.getForRequest({ request: req, requestHandlerContext: ctx })
.for(CONTENT_ID, PUBLIC_API_CONTENT_MANAGEMENT_VERSION);
let result;
try {
({ result } = await client.get(req.params.id));
} catch (e) {
if (e.isBoom && e.output.statusCode === 404) {
return res.notFound({
body: {
message: `A dashboard with saved object ID ${req.params.id}] was not found.`,
},
});
}
if (e.isBoom && e.output.statusCode === 403) {
return res.forbidden();
}
return res.badRequest(e.message);
}
return res.ok({ body: result });
}
);
// Delete API route
const deleteRoute = versionedRouter.delete({
path: `${PUBLIC_API_PATH}/{id}`,
access: 'public',
summary: `Delete a dashboard.`,
description: TECHNICAL_PREVIEW_WARNING,
options: {
tags: ['oas-tag:Dashboards'],
},
});
deleteRoute.addVersion(
{
version: PUBLIC_API_VERSION,
validate: {
request: {
params: schema.object({
id: schema.string(),
}),
},
},
},
async (ctx, req, res) => {
const client = contentManagement.contentClient
.getForRequest({ request: req, requestHandlerContext: ctx })
.for(CONTENT_ID, PUBLIC_API_CONTENT_MANAGEMENT_VERSION);
try {
await client.delete(req.params.id);
} catch (e) {
if (e.isBoom && e.output.statusCode === 404) {
return res.notFound({
body: {
message: `A dashboard with saved object ID ${req.params.id} was not found.`,
},
});
}
if (e.isBoom && e.output.statusCode === 403) {
return res.forbidden();
}
return res.badRequest();
}
return res.ok();
}
);
}

View file

@ -17,8 +17,10 @@ import type {
import { serviceDefinition as v1 } from './v1';
import { serviceDefinition as v2 } from './v2';
import { serviceDefinition as v3 } from './v3';
export const cmServicesDefinition: { [version: Version]: ServicesDefinition } = {
1: v1,
2: v2,
3: v3,
};

View file

@ -7,23 +7,40 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { SOContentStorage, tagsToFindOptions } from '@kbn/content-management-utils';
import { SavedObjectsFindOptions } from '@kbn/core-saved-objects-api-server';
import Boom from '@hapi/boom';
import { tagsToFindOptions } from '@kbn/content-management-utils';
import {
SavedObjectsFindOptions,
SavedObjectsFindResult,
} from '@kbn/core-saved-objects-api-server';
import type { Logger } from '@kbn/logging';
import { CONTENT_ID } from '../../common/content_management';
import { cmServicesDefinition } from './schema/cm_services';
import type { DashboardCrudTypes } from '../../common/content_management';
import { CreateResult, DeleteResult, SearchQuery } from '@kbn/content-management-plugin/common';
import { StorageContext } from '@kbn/content-management-plugin/server';
import { DASHBOARD_SAVED_OBJECT_TYPE } from '../dashboard_saved_object';
import { cmServicesDefinition } from './cm_services';
import { DashboardSavedObjectAttributes } from '../dashboard_saved_object';
import { itemAttrsToSavedObjectAttrs, savedObjectToItem } from './latest';
import type {
DashboardAttributes,
DashboardItem,
DashboardCreateOut,
DashboardCreateOptions,
DashboardGetOut,
DashboardSearchOut,
DashboardUpdateOptions,
DashboardUpdateOut,
DashboardSearchOptions,
} from './latest';
const searchArgsToSOFindOptions = (
args: DashboardCrudTypes['SearchIn']
query: SearchQuery,
options: DashboardSearchOptions
): SavedObjectsFindOptions => {
const { query, contentTypeId, options } = args;
return {
type: contentTypeId,
type: DASHBOARD_SAVED_OBJECT_TYPE,
searchFields: options?.onlyTitle ? ['title'] : ['title^3', 'description'],
fields: ['description', 'title', 'timeRestore'],
fields: options?.fields ?? ['title', 'description', 'timeRestore'],
search: query.text,
perPage: query.limit,
page: query.cursor ? +query.cursor : undefined,
@ -32,7 +49,16 @@ const searchArgsToSOFindOptions = (
};
};
export class DashboardStorage extends SOContentStorage<DashboardCrudTypes> {
const savedObjectClientFromRequest = async (ctx: StorageContext) => {
if (!ctx.requestHandlerContext) {
throw new Error('Storage context.requestHandlerContext missing.');
}
const { savedObjects } = await ctx.requestHandlerContext.core;
return savedObjects.client;
};
export class DashboardStorage {
constructor({
logger,
throwOnResultValidationError,
@ -40,26 +66,316 @@ export class DashboardStorage extends SOContentStorage<DashboardCrudTypes> {
logger: Logger;
throwOnResultValidationError: boolean;
}) {
super({
savedObjectType: CONTENT_ID,
cmServicesDefinition,
searchArgsToSOFindOptions,
enableMSearch: true,
allowedSavedObjectAttributes: [
'kibanaSavedObjectMeta',
'controlGroupInput',
'refreshInterval',
'description',
'timeRestore',
'optionsJSON',
'panelsJSON',
'timeFrom',
'version',
'timeTo',
'title',
],
logger,
throwOnResultValidationError,
});
this.logger = logger;
this.throwOnResultValidationError = throwOnResultValidationError ?? false;
this.mSearch = {
savedObjectType: DASHBOARD_SAVED_OBJECT_TYPE,
additionalSearchFields: [],
toItemResult: (ctx: StorageContext, savedObject: SavedObjectsFindResult): DashboardItem => {
const transforms = ctx.utils.getTransforms(cmServicesDefinition);
const { item, error: itemError } = savedObjectToItem(
savedObject as SavedObjectsFindResult<DashboardSavedObjectAttributes>,
false
);
if (itemError) {
throw Boom.badRequest(`Invalid response. ${itemError.message}`);
}
const validationError = transforms.mSearch.out.result.validate(item);
if (validationError) {
if (this.throwOnResultValidationError) {
throw Boom.badRequest(`Invalid response. ${validationError.message}`);
} else {
this.logger.warn(`Invalid response. ${validationError.message}`);
}
}
// Validate DB response and DOWN transform to the request version
const { value, error: resultError } = transforms.mSearch.out.result.down<
DashboardItem,
DashboardItem
>(
item,
undefined, // do not override version
{ validate: false } // validation is done above
);
if (resultError) {
throw Boom.badRequest(`Invalid response. ${resultError.message}`);
}
return value;
},
};
}
private logger: Logger;
private throwOnResultValidationError: boolean;
mSearch: {
savedObjectType: string;
toItemResult: (ctx: StorageContext, savedObject: SavedObjectsFindResult) => DashboardItem;
additionalSearchFields?: string[];
};
async get(ctx: StorageContext, id: string): Promise<DashboardGetOut> {
const transforms = ctx.utils.getTransforms(cmServicesDefinition);
const soClient = await savedObjectClientFromRequest(ctx);
// Save data in DB
const {
saved_object: savedObject,
alias_purpose: aliasPurpose,
alias_target_id: aliasTargetId,
outcome,
} = await soClient.resolve<DashboardSavedObjectAttributes>(DASHBOARD_SAVED_OBJECT_TYPE, id);
const { item, error: itemError } = savedObjectToItem(savedObject, false);
if (itemError) {
throw Boom.badRequest(`Invalid response. ${itemError.message}`);
}
const response = { item, meta: { aliasPurpose, aliasTargetId, outcome } };
const validationError = transforms.get.out.result.validate(response);
if (validationError) {
if (this.throwOnResultValidationError) {
throw Boom.badRequest(`Invalid response. ${validationError.message}`);
} else {
this.logger.warn(`Invalid response. ${validationError.message}`);
}
}
// Validate response and DOWN transform to the request version
const { value, error: resultError } = transforms.get.out.result.down<
DashboardGetOut,
DashboardGetOut
>(
response,
undefined, // do not override version
{ validate: false } // validation is done above
);
if (resultError) {
throw Boom.badRequest(`Invalid response. ${resultError.message}`);
}
return value;
}
async bulkGet(): Promise<never> {
// Not implemented
throw new Error(`[bulkGet] has not been implemented. See DashboardStorage class.`);
}
async create(
ctx: StorageContext,
data: DashboardAttributes,
options: DashboardCreateOptions
): Promise<DashboardCreateOut> {
const transforms = ctx.utils.getTransforms(cmServicesDefinition);
const soClient = await savedObjectClientFromRequest(ctx);
// Validate input (data & options) & UP transform them to the latest version
const { value: dataToLatest, error: dataError } = transforms.create.in.data.up<
DashboardAttributes,
DashboardAttributes
>(data);
if (dataError) {
throw Boom.badRequest(`Invalid data. ${dataError.message}`);
}
const { value: optionsToLatest, error: optionsError } = transforms.create.in.options.up<
DashboardCreateOptions,
DashboardCreateOptions
>(options);
if (optionsError) {
throw Boom.badRequest(`Invalid options. ${optionsError.message}`);
}
const { attributes: soAttributes, error: attributesError } =
itemAttrsToSavedObjectAttrs(dataToLatest);
if (attributesError) {
throw Boom.badRequest(`Invalid data. ${attributesError.message}`);
}
// Save data in DB
const savedObject = await soClient.create<DashboardSavedObjectAttributes>(
DASHBOARD_SAVED_OBJECT_TYPE,
soAttributes,
optionsToLatest
);
const { item, error: itemError } = savedObjectToItem(savedObject, false);
if (itemError) {
throw Boom.badRequest(`Invalid response. ${itemError.message}`);
}
const validationError = transforms.create.out.result.validate({ item });
if (validationError) {
if (this.throwOnResultValidationError) {
throw Boom.badRequest(`Invalid response. ${validationError.message}`);
} else {
this.logger.warn(`Invalid response. ${validationError.message}`);
}
}
// Validate DB response and DOWN transform to the request version
const { value, error: resultError } = transforms.create.out.result.down<
CreateResult<DashboardItem>
>(
{ item },
undefined, // do not override version
{ validate: false } // validation is done above
);
if (resultError) {
throw Boom.badRequest(`Invalid response. ${resultError.message}`);
}
return value;
}
async update(
ctx: StorageContext,
id: string,
data: DashboardAttributes,
options: DashboardUpdateOptions
): Promise<DashboardUpdateOut> {
const transforms = ctx.utils.getTransforms(cmServicesDefinition);
const soClient = await savedObjectClientFromRequest(ctx);
// Validate input (data & options) & UP transform them to the latest version
const { value: dataToLatest, error: dataError } = transforms.update.in.data.up<
DashboardAttributes,
DashboardAttributes
>(data);
if (dataError) {
throw Boom.badRequest(`Invalid data. ${dataError.message}`);
}
const { value: optionsToLatest, error: optionsError } = transforms.update.in.options.up<
DashboardUpdateOptions,
DashboardUpdateOptions
>(options);
if (optionsError) {
throw Boom.badRequest(`Invalid options. ${optionsError.message}`);
}
const { attributes: soAttributes, error: attributesError } =
itemAttrsToSavedObjectAttrs(dataToLatest);
if (attributesError) {
throw Boom.badRequest(`Invalid data. ${attributesError.message}`);
}
// Save data in DB
const partialSavedObject = await soClient.update<DashboardSavedObjectAttributes>(
DASHBOARD_SAVED_OBJECT_TYPE,
id,
soAttributes,
optionsToLatest
);
const { item, error: itemError } = savedObjectToItem(partialSavedObject, true);
if (itemError) {
throw Boom.badRequest(`Invalid response. ${itemError.message}`);
}
const validationError = transforms.update.out.result.validate({ item });
if (validationError) {
if (this.throwOnResultValidationError) {
throw Boom.badRequest(`Invalid response. ${validationError.message}`);
} else {
this.logger.warn(`Invalid response. ${validationError.message}`);
}
}
// Validate DB response and DOWN transform to the request version
const { value, error: resultError } = transforms.update.out.result.down<
DashboardUpdateOut,
DashboardUpdateOut
>(
{ item },
undefined, // do not override version
{ validate: false } // validation is done above
);
if (resultError) {
throw Boom.badRequest(`Invalid response. ${resultError.message}`);
}
return value;
}
async delete(
ctx: StorageContext,
id: string,
// force is necessary to delete saved objects that exist in multiple namespaces
options?: { force: boolean }
): Promise<DeleteResult> {
const soClient = await savedObjectClientFromRequest(ctx);
await soClient.delete(DASHBOARD_SAVED_OBJECT_TYPE, id, { force: options?.force ?? false });
return { success: true };
}
async search(
ctx: StorageContext,
query: SearchQuery,
options: DashboardSearchOptions
): Promise<DashboardSearchOut> {
const transforms = ctx.utils.getTransforms(cmServicesDefinition);
const soClient = await savedObjectClientFromRequest(ctx);
// Validate and UP transform the options
const { value: optionsToLatest, error: optionsError } = transforms.search.in.options.up<
DashboardSearchOptions,
DashboardSearchOptions
>(options);
if (optionsError) {
throw Boom.badRequest(`Invalid payload. ${optionsError.message}`);
}
const soQuery = searchArgsToSOFindOptions(query, optionsToLatest);
// Execute the query in the DB
const soResponse = await soClient.find<DashboardSavedObjectAttributes>(soQuery);
const hits = soResponse.saved_objects
.map((so) => {
const { item } = savedObjectToItem(so, false, soQuery.fields);
return item;
})
// Ignore any saved objects that failed to convert to items.
.filter((item) => item !== null);
const response = {
hits,
pagination: {
total: soResponse.total,
},
};
const validationError = transforms.search.out.result.validate(response);
if (validationError) {
if (this.throwOnResultValidationError) {
throw Boom.badRequest(`Invalid response. ${validationError.message}`);
} else {
this.logger.warn(`Invalid response. ${validationError.message}`);
}
}
// Validate the response and DOWN transform to the request version
const { value, error: resultError } = transforms.search.out.result.down<
DashboardSearchOut,
DashboardSearchOut
>(
response,
undefined, // do not override version
{ validate: false } // validation is done above
);
if (resultError) {
throw Boom.badRequest(`Invalid response. ${resultError.message}`);
}
return value;
}
}

View file

@ -7,4 +7,24 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export type {
ControlGroupAttributes,
GridData,
DashboardPanel,
DashboardAttributes,
DashboardItem,
DashboardGetIn,
DashboardGetOut,
DashboardCreateIn,
DashboardCreateOut,
DashboardCreateOptions,
DashboardSearchIn,
DashboardSearchOut,
DashboardSearchOptions,
DashboardUpdateIn,
DashboardUpdateOut,
DashboardUpdateOptions,
DashboardOptions,
} from './latest';
export { DashboardStorage } from './dashboard_storage';

View file

@ -7,5 +7,5 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
// Latest version is 2
export * from './v2';
// Latest version is 3
export * from './v3';

View file

@ -16,51 +16,7 @@ import {
updateOptionsSchema,
createResultSchema,
} from '@kbn/content-management-utils';
export const controlGroupInputSchema = schema
.object({
panelsJSON: schema.maybe(schema.string()),
controlStyle: schema.maybe(schema.string()),
chainingSystem: schema.maybe(schema.string()),
ignoreParentSettingsJSON: schema.maybe(schema.string()),
})
.extends({}, { unknowns: 'ignore' });
export const dashboardAttributesSchema = schema.object(
{
// General
title: schema.string(),
description: schema.string({ defaultValue: '' }),
// Search
kibanaSavedObjectMeta: schema.object({
searchSourceJSON: schema.maybe(schema.string()),
}),
// Time
timeRestore: schema.maybe(schema.boolean()),
timeFrom: schema.maybe(schema.string()),
timeTo: schema.maybe(schema.string()),
refreshInterval: schema.maybe(
schema.object({
pause: schema.boolean(),
value: schema.number(),
display: schema.maybe(schema.string()),
section: schema.maybe(schema.number()),
})
),
// Dashboard Content
controlGroupInput: schema.maybe(controlGroupInputSchema),
panelsJSON: schema.string({ defaultValue: '[]' }),
optionsJSON: schema.string({ defaultValue: '{}' }),
// Legacy
hits: schema.maybe(schema.number()),
version: schema.maybe(schema.number()),
},
{ unknowns: 'forbid' }
);
import { dashboardAttributesSchema } from '../../dashboard_saved_object/schema/v1';
export const dashboardSavedObjectSchema = savedObjectSchema(dashboardAttributesSchema);
@ -84,8 +40,10 @@ const dashboardUpdateOptionsSchema = schema.object({
mergeAttributes: schema.maybe(updateOptionsSchema.mergeAttributes),
});
// Content management service definition.
// We need it for BWC support between different versions of the content
/**
* Content management service definition v1.
* Dashboard attributes in content management version v1 are tightly coupled with the v1 model version saved object schema.
*/
export const serviceDefinition: ServicesDefinition = {
get: {
out: {

View file

@ -7,9 +7,4 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export {
serviceDefinition,
dashboardSavedObjectSchema,
controlGroupInputSchema,
dashboardAttributesSchema,
} from './cm_services';
export { serviceDefinition } from './cm_services';

View file

@ -7,36 +7,23 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { schema } from '@kbn/config-schema';
import {
createResultSchema,
objectTypeToGetResultSchema,
savedObjectSchema,
} from '@kbn/content-management-utils';
import type { ContentManagementServicesDefinition as ServicesDefinition } from '@kbn/object-versioning';
import {
controlGroupInputSchema as controlGroupInputSchemaV1,
dashboardAttributesSchema as dashboardAttributesSchemaV1,
serviceDefinition as serviceDefinitionV1,
} from '../v1';
export const dashboardAttributesSchema = dashboardAttributesSchemaV1.extends(
{
controlGroupInput: schema.maybe(
controlGroupInputSchemaV1.extends(
{
showApplySelections: schema.maybe(schema.boolean()),
},
{ unknowns: 'ignore' }
)
),
},
{ unknowns: 'ignore' }
);
import type { DashboardCrudTypes } from '../../../common/content_management/v2';
import { serviceDefinition as serviceDefinitionV1 } from '../v1';
import { dashboardAttributesOut as attributesTov3 } from '../v3';
import { dashboardAttributesSchema } from '../../dashboard_saved_object/schema/v2';
export const dashboardSavedObjectSchema = savedObjectSchema(dashboardAttributesSchema);
// Content management service definition.
/**
* Content management service definition v2.
* Dashboard attributes in content management version v2 are tightly coupled with the v2 model version saved object schema.
*/
export const serviceDefinition: ServicesDefinition = {
get: {
out: {
@ -50,6 +37,7 @@ export const serviceDefinition: ServicesDefinition = {
...serviceDefinitionV1?.create?.in,
data: {
schema: dashboardAttributesSchema,
up: (data: DashboardCrudTypes['CreateIn']['data']) => attributesTov3(data),
},
},
out: {
@ -63,6 +51,7 @@ export const serviceDefinition: ServicesDefinition = {
...serviceDefinitionV1.update?.in,
data: {
schema: dashboardAttributesSchema,
up: (data: DashboardCrudTypes['UpdateIn']['data']) => attributesTov3(data),
},
},
},

View file

@ -0,0 +1,10 @@
/*
* 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 { serviceDefinition } from './cm_services';

View file

@ -0,0 +1,539 @@
/*
* 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 { v4 as uuidv4 } from 'uuid';
import { schema, Type } from '@kbn/config-schema';
import { createOptionsSchemas, updateOptionsSchema } from '@kbn/content-management-utils';
import type { ContentManagementServicesDefinition as ServicesDefinition } from '@kbn/object-versioning';
import {
type ControlGroupChainingSystem,
type ControlLabelPosition,
type ControlWidth,
CONTROL_CHAINING_OPTIONS,
CONTROL_LABEL_POSITION_OPTIONS,
CONTROL_WIDTH_OPTIONS,
DEFAULT_CONTROL_CHAINING,
DEFAULT_CONTROL_GROW,
DEFAULT_CONTROL_LABEL_POSITION,
DEFAULT_CONTROL_WIDTH,
DEFAULT_IGNORE_PARENT_SETTINGS,
DEFAULT_AUTO_APPLY_SELECTIONS,
} from '@kbn/controls-plugin/common';
import { FilterStateStore } from '@kbn/es-query';
import { SortDirection } from '@kbn/data-plugin/common/search';
import {
DASHBOARD_GRID_COLUMN_COUNT,
DEFAULT_PANEL_HEIGHT,
DEFAULT_PANEL_WIDTH,
DEFAULT_DASHBOARD_OPTIONS,
} from '../../../common/content_management';
import { getResultV3ToV2 } from './transform_utils';
const apiError = schema.object({
error: schema.string(),
message: schema.string(),
statusCode: schema.number(),
metadata: schema.maybe(schema.object({}, { unknowns: 'allow' })),
});
// This schema should be provided by the controls plugin. Perhaps we can resolve this with the embeddable registry.
// See https://github.com/elastic/kibana/issues/192622
export const controlGroupInputSchema = schema.object({
controls: schema.arrayOf(
schema.object(
{
type: schema.string({ meta: { description: 'The type of the control panel.' } }),
controlConfig: schema.maybe(schema.recordOf(schema.string(), schema.any())),
id: schema.string({
defaultValue: uuidv4(),
meta: { description: 'The unique ID of the control.' },
}),
order: schema.number({
meta: {
description: 'The order of the control panel in the control group.',
},
}),
width: schema.oneOf(
Object.values(CONTROL_WIDTH_OPTIONS).map((value) => schema.literal(value)) as [
Type<ControlWidth>
],
{
defaultValue: DEFAULT_CONTROL_WIDTH,
meta: { description: 'Minimum width of the control panel in the control group.' },
}
),
grow: schema.boolean({
defaultValue: DEFAULT_CONTROL_GROW,
meta: { description: 'Expand width of the control panel to fit available space.' },
}),
},
{ unknowns: 'allow' }
),
{
defaultValue: [],
meta: { description: 'An array of control panels and their state in the control group.' },
}
),
labelPosition: schema.oneOf(
Object.values(CONTROL_LABEL_POSITION_OPTIONS).map((value) => schema.literal(value)) as [
Type<ControlLabelPosition>
],
{
defaultValue: DEFAULT_CONTROL_LABEL_POSITION,
meta: {
description: 'Position of the labels for controls. For example, "oneLine", "twoLine".',
},
}
),
chainingSystem: schema.oneOf(
Object.values(CONTROL_CHAINING_OPTIONS).map((value) => schema.literal(value)) as [
Type<ControlGroupChainingSystem>
],
{
defaultValue: DEFAULT_CONTROL_CHAINING,
meta: {
description:
'The chaining strategy for multiple controls. For example, "HIERARCHICAL" or "NONE".',
},
}
),
enhancements: schema.maybe(schema.recordOf(schema.string(), schema.any())),
ignoreParentSettings: schema.object({
ignoreFilters: schema.boolean({
meta: { description: 'Ignore global filters in controls.' },
defaultValue: DEFAULT_IGNORE_PARENT_SETTINGS.ignoreFilters,
}),
ignoreQuery: schema.boolean({
meta: { description: 'Ignore the global query bar in controls.' },
defaultValue: DEFAULT_IGNORE_PARENT_SETTINGS.ignoreQuery,
}),
ignoreTimerange: schema.boolean({
meta: { description: 'Ignore the global time range in controls.' },
defaultValue: DEFAULT_IGNORE_PARENT_SETTINGS.ignoreTimerange,
}),
ignoreValidations: schema.boolean({
meta: { description: 'Ignore validations in controls.' },
defaultValue: DEFAULT_IGNORE_PARENT_SETTINGS.ignoreValidations,
}),
}),
autoApplySelections: schema.boolean({
meta: { description: 'Show apply selections button in controls.' },
defaultValue: DEFAULT_AUTO_APPLY_SELECTIONS,
}),
});
const searchSourceSchema = schema.object(
{
type: schema.maybe(schema.string()),
query: schema.maybe(
schema.object({
query: schema.oneOf([
schema.string({
meta: {
description:
'A text-based query such as Kibana Query Language (KQL) or Lucene query language.',
},
}),
schema.recordOf(schema.string(), schema.any()),
]),
language: schema.string({
meta: { description: 'The query language such as KQL or Lucene.' },
}),
})
),
filter: schema.maybe(
schema.arrayOf(
schema.object(
{
meta: schema.object(
{
alias: schema.maybe(schema.nullable(schema.string())),
disabled: schema.maybe(schema.boolean()),
negate: schema.maybe(schema.boolean()),
controlledBy: schema.maybe(schema.string()),
group: schema.maybe(schema.string()),
index: schema.maybe(schema.string()),
isMultiIndex: schema.maybe(schema.boolean()),
type: schema.maybe(schema.string()),
key: schema.maybe(schema.string()),
params: schema.maybe(schema.any()),
value: schema.maybe(schema.string()),
field: schema.maybe(schema.string()),
},
{ unknowns: 'allow' }
),
query: schema.maybe(schema.recordOf(schema.string(), schema.any())),
$state: schema.maybe(
schema.object({
store: schema.oneOf(
[
schema.literal(FilterStateStore.APP_STATE),
schema.literal(FilterStateStore.GLOBAL_STATE),
],
{
meta: {
description:
"Denote whether a filter is specific to an application's context (e.g. 'appState') or whether it should be applied globally (e.g. 'globalState').",
},
}
),
})
),
},
{ meta: { description: 'A filter for the search source.' } }
)
)
),
sort: schema.maybe(
schema.arrayOf(
schema.recordOf(
schema.string(),
schema.oneOf([
schema.oneOf([schema.literal(SortDirection.asc), schema.literal(SortDirection.desc)]),
schema.object({
order: schema.oneOf([
schema.literal(SortDirection.asc),
schema.literal(SortDirection.desc),
]),
format: schema.maybe(schema.string()),
}),
schema.object({
order: schema.oneOf([
schema.literal(SortDirection.asc),
schema.literal(SortDirection.desc),
]),
numeric_type: schema.maybe(
schema.oneOf([
schema.literal('double'),
schema.literal('long'),
schema.literal('date'),
schema.literal('date_nanos'),
])
),
}),
])
)
)
),
},
/**
The Dashboard _should_ only ever uses the query and filters fields on the search
source. But we should be liberal in what we accept, so we allow unknowns.
*/
{ defaultValue: {}, unknowns: 'allow' }
);
export const gridDataSchema = schema.object({
x: schema.number({ meta: { description: 'The x coordinate of the panel in grid units' } }),
y: schema.number({ meta: { description: 'The y coordinate of the panel in grid units' } }),
w: schema.number({
defaultValue: DEFAULT_PANEL_WIDTH,
min: 1,
max: DASHBOARD_GRID_COLUMN_COUNT,
meta: { description: 'The width of the panel in grid units' },
}),
h: schema.number({
defaultValue: DEFAULT_PANEL_HEIGHT,
min: 1,
meta: { description: 'The height of the panel in grid units' },
}),
i: schema.string({
meta: { description: 'The unique identifier of the panel' },
defaultValue: uuidv4(),
}),
});
export const panelSchema = schema.object({
panelConfig: schema.object(
{
version: schema.maybe(
schema.string({
meta: { description: 'The version of the embeddable in the panel.' },
})
),
title: schema.maybe(schema.string({ meta: { description: 'The title of the panel' } })),
description: schema.maybe(
schema.string({ meta: { description: 'The description of the panel' } })
),
savedObjectId: schema.maybe(
schema.string({
meta: { description: 'The unique id of the library item to construct the embeddable.' },
})
),
hidePanelTitles: schema.maybe(
schema.boolean({
defaultValue: false,
meta: { description: 'Set to true to hide the panel title in its container.' },
})
),
enhancements: schema.maybe(schema.recordOf(schema.string(), schema.any())),
},
{
unknowns: 'allow',
}
),
id: schema.maybe(
schema.string({ meta: { description: 'The saved object id for by reference panels' } })
),
type: schema.string({ meta: { description: 'The embeddable type' } }),
panelRefName: schema.maybe(schema.string()),
gridData: gridDataSchema,
panelIndex: schema.string({
meta: { description: 'The unique ID of the panel.' },
defaultValue: schema.siblingRef('gridData.i'),
}),
title: schema.maybe(schema.string({ meta: { description: 'The title of the panel' } })),
version: schema.maybe(
schema.string({
meta: {
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).",
deprecated: true,
},
})
),
});
export const optionsSchema = schema.object({
hidePanelTitles: schema.boolean({
defaultValue: DEFAULT_DASHBOARD_OPTIONS.hidePanelTitles,
meta: { description: 'Hide the panel titles in the dashboard.' },
}),
useMargins: schema.boolean({
defaultValue: DEFAULT_DASHBOARD_OPTIONS.useMargins,
meta: { description: 'Show margins between panels in the dashboard layout.' },
}),
syncColors: schema.boolean({
defaultValue: DEFAULT_DASHBOARD_OPTIONS.syncColors,
meta: { description: 'Synchronize colors between related panels in the dashboard.' },
}),
syncTooltips: schema.boolean({
defaultValue: DEFAULT_DASHBOARD_OPTIONS.syncTooltips,
meta: { description: 'Synchronize tooltips between related panels in the dashboard.' },
}),
syncCursor: schema.boolean({
defaultValue: DEFAULT_DASHBOARD_OPTIONS.syncCursor,
meta: { description: 'Synchronize cursor position between related panels in the dashboard.' },
}),
});
// These are the attributes that are returned in search results
export const searchResultsAttributesSchema = schema.object({
title: schema.string({ meta: { description: 'A human-readable title for the dashboard' } }),
description: schema.string({ defaultValue: '', meta: { description: 'A short description.' } }),
timeRestore: schema.boolean({
defaultValue: false,
meta: { description: 'Whether to restore time upon viewing this dashboard' },
}),
});
export const dashboardAttributesSchema = searchResultsAttributesSchema.extends({
// Search
kibanaSavedObjectMeta: schema.object(
{
searchSource: schema.maybe(searchSourceSchema),
},
{
meta: {
description: 'A container for various metadata',
},
defaultValue: {},
}
),
// Time
timeFrom: schema.maybe(
schema.string({ meta: { description: 'An ISO string indicating when to restore time from' } })
),
timeTo: schema.maybe(
schema.string({ meta: { description: 'An ISO string indicating when to restore time from' } })
),
refreshInterval: schema.maybe(
schema.object(
{
pause: schema.boolean({
meta: {
description:
'Whether the refresh interval is set to be paused while viewing the dashboard.',
},
}),
value: schema.number({
meta: {
description: 'A numeric value indicating refresh frequency in milliseconds.',
},
}),
display: schema.maybe(
schema.string({
meta: {
description:
'A human-readable string indicating the refresh frequency. No longer used.',
deprecated: true,
},
})
),
section: schema.maybe(
schema.number({
meta: {
description: 'No longer used.', // TODO what is this legacy property?
deprecated: true,
},
})
),
},
{
meta: {
description: 'A container for various refresh interval settings',
},
}
)
),
// Dashboard Content
controlGroupInput: schema.maybe(controlGroupInputSchema),
panels: schema.arrayOf(panelSchema, { defaultValue: [] }),
options: optionsSchema,
version: schema.maybe(schema.number({ meta: { deprecated: true } })),
});
export const referenceSchema = schema.object(
{
name: schema.string(),
type: schema.string(),
id: schema.string(),
},
{ unknowns: 'forbid' }
);
export const dashboardItemSchema = schema.object(
{
id: schema.string(),
type: schema.string(),
version: schema.maybe(schema.string()),
createdAt: schema.maybe(schema.string()),
updatedAt: schema.maybe(schema.string()),
createdBy: schema.maybe(schema.string()),
updatedBy: schema.maybe(schema.string()),
managed: schema.maybe(schema.boolean()),
error: schema.maybe(apiError),
attributes: dashboardAttributesSchema,
references: schema.arrayOf(referenceSchema),
namespaces: schema.maybe(schema.arrayOf(schema.string())),
originId: schema.maybe(schema.string()),
},
{ unknowns: 'allow' }
);
export const dashboardSearchResultsSchema = dashboardItemSchema.extends({
attributes: searchResultsAttributesSchema,
});
export const dashboardSearchOptionsSchema = schema.maybe(
schema.object(
{
onlyTitle: schema.maybe(schema.boolean()),
fields: schema.maybe(schema.arrayOf(schema.string())),
kuery: schema.maybe(schema.string()),
cursor: schema.maybe(schema.number()),
limit: schema.maybe(schema.number()),
},
{ unknowns: 'forbid' }
)
);
export const dashboardCreateOptionsSchema = schema.object({
id: schema.maybe(createOptionsSchemas.id),
overwrite: schema.maybe(createOptionsSchemas.overwrite),
references: schema.maybe(schema.arrayOf(referenceSchema)),
initialNamespaces: schema.maybe(createOptionsSchemas.initialNamespaces),
});
export const dashboardUpdateOptionsSchema = schema.object({
references: schema.maybe(schema.arrayOf(referenceSchema)),
mergeAttributes: schema.maybe(updateOptionsSchema.mergeAttributes),
});
export const dashboardGetResultSchema = schema.object(
{
item: dashboardItemSchema,
meta: schema.object(
{
outcome: schema.oneOf([
schema.literal('exactMatch'),
schema.literal('aliasMatch'),
schema.literal('conflict'),
]),
aliasTargetId: schema.maybe(schema.string()),
aliasPurpose: schema.maybe(
schema.oneOf([
schema.literal('savedObjectConversion'),
schema.literal('savedObjectImport'),
])
),
},
{ unknowns: 'forbid' }
),
},
{ unknowns: 'forbid' }
);
export const dashboardCreateResultSchema = schema.object(
{
item: dashboardItemSchema,
},
{ unknowns: 'forbid' }
);
export const serviceDefinition: ServicesDefinition = {
get: {
out: {
result: {
schema: dashboardGetResultSchema,
down: getResultV3ToV2,
},
},
},
create: {
in: {
options: {
schema: dashboardCreateOptionsSchema,
},
data: {
schema: dashboardAttributesSchema,
},
},
out: {
result: {
schema: dashboardCreateResultSchema,
},
},
},
update: {
in: {
options: {
schema: dashboardUpdateOptionsSchema,
},
data: {
schema: dashboardAttributesSchema,
},
},
},
search: {
in: {
options: {
schema: dashboardSearchOptionsSchema,
},
},
},
mSearch: {
out: {
result: {
schema: dashboardItemSchema,
},
},
},
};

View file

@ -0,0 +1,42 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export type {
ControlGroupAttributes,
GridData,
DashboardPanel,
DashboardAttributes,
DashboardItem,
DashboardGetIn,
DashboardGetOut,
DashboardCreateIn,
DashboardCreateOut,
DashboardCreateOptions,
DashboardSearchIn,
DashboardSearchOut,
DashboardSearchOptions,
DashboardUpdateIn,
DashboardUpdateOut,
DashboardUpdateOptions,
DashboardOptions,
} from './types';
export {
serviceDefinition,
dashboardAttributesSchema,
dashboardGetResultSchema,
dashboardCreateResultSchema,
dashboardItemSchema,
dashboardSearchResultsSchema,
referenceSchema,
} from './cm_services';
export {
dashboardAttributesOut,
itemAttrsToSavedObjectAttrs,
savedObjectToItem,
} from './transform_utils';

View file

@ -0,0 +1,551 @@
/*
* 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 type { SavedObject } from '@kbn/core-saved-objects-api-server';
import type {
DashboardSavedObjectAttributes,
SavedDashboardPanel,
} from '../../dashboard_saved_object';
import type { DashboardAttributes, DashboardItem } from './types';
import {
dashboardAttributesOut,
getResultV3ToV2,
itemAttrsToSavedObjectAttrs,
savedObjectToItem,
} from './transform_utils';
import {
DEFAULT_AUTO_APPLY_SELECTIONS,
DEFAULT_CONTROL_CHAINING,
DEFAULT_CONTROL_GROW,
DEFAULT_CONTROL_LABEL_POSITION,
DEFAULT_CONTROL_WIDTH,
DEFAULT_IGNORE_PARENT_SETTINGS,
ControlLabelPosition,
ControlGroupChainingSystem,
ControlWidth,
} from '@kbn/controls-plugin/common';
import { DEFAULT_DASHBOARD_OPTIONS } from '../../../common/content_management';
describe('dashboardAttributesOut', () => {
const controlGroupInputControlsSo = {
explicitInput: { anyKey: 'some value' },
type: 'type1',
order: 0,
};
const panelsSo: SavedDashboardPanel[] = [
{
embeddableConfig: { enhancements: {} },
gridData: { x: 0, y: 0, w: 10, h: 10, i: '1' },
id: '1',
panelIndex: '1',
panelRefName: 'ref1',
title: 'title1',
type: 'type1',
version: '2',
},
];
it('should set default values if not provided', () => {
const input: DashboardSavedObjectAttributes = {
controlGroupInput: {
panelsJSON: JSON.stringify({ foo: controlGroupInputControlsSo }),
},
panelsJSON: JSON.stringify(panelsSo),
optionsJSON: JSON.stringify({
hidePanelTitles: false,
}),
kibanaSavedObjectMeta: {},
title: 'my title',
description: 'my description',
};
expect(dashboardAttributesOut(input)).toEqual<DashboardAttributes>({
controlGroupInput: {
chainingSystem: DEFAULT_CONTROL_CHAINING,
labelPosition: DEFAULT_CONTROL_LABEL_POSITION,
ignoreParentSettings: DEFAULT_IGNORE_PARENT_SETTINGS,
autoApplySelections: DEFAULT_AUTO_APPLY_SELECTIONS,
controls: [
{
controlConfig: { anyKey: 'some value' },
grow: DEFAULT_CONTROL_GROW,
id: 'foo',
order: 0,
type: 'type1',
width: DEFAULT_CONTROL_WIDTH,
},
],
},
description: 'my description',
kibanaSavedObjectMeta: {},
options: {
...DEFAULT_DASHBOARD_OPTIONS,
hidePanelTitles: false,
},
panels: [
{
panelConfig: { enhancements: {} },
gridData: { x: 0, y: 0, w: 10, h: 10, i: '1' },
id: '1',
panelIndex: '1',
panelRefName: 'ref1',
title: 'title1',
type: 'type1',
version: '2',
},
],
timeRestore: false,
title: 'my title',
});
});
it('should transform full attributes correctly', () => {
const input: DashboardSavedObjectAttributes = {
controlGroupInput: {
panelsJSON: JSON.stringify({
foo: {
...controlGroupInputControlsSo,
grow: false,
width: 'small',
},
}),
ignoreParentSettingsJSON: JSON.stringify({ ignoreFilters: true }),
controlStyle: 'twoLine',
chainingSystem: 'NONE',
showApplySelections: true,
},
description: 'description',
kibanaSavedObjectMeta: {
searchSourceJSON: JSON.stringify({ query: { query: 'test', language: 'KQL' } }),
},
optionsJSON: JSON.stringify({
hidePanelTitles: true,
useMargins: false,
syncColors: false,
syncTooltips: false,
syncCursor: false,
}),
panelsJSON: JSON.stringify(panelsSo),
refreshInterval: { pause: true, value: 1000 },
timeFrom: 'now-15m',
timeRestore: true,
timeTo: 'now',
title: 'title',
};
expect(dashboardAttributesOut(input)).toEqual<DashboardAttributes>({
controlGroupInput: {
chainingSystem: 'NONE',
labelPosition: 'twoLine',
ignoreParentSettings: {
ignoreFilters: true,
ignoreQuery: false,
ignoreTimerange: false,
ignoreValidations: false,
},
autoApplySelections: false,
controls: [
{
controlConfig: {
anyKey: 'some value',
},
id: 'foo',
grow: false,
width: 'small',
order: 0,
type: 'type1',
},
],
},
description: 'description',
kibanaSavedObjectMeta: {
searchSource: { query: { query: 'test', language: 'KQL' } },
},
options: {
hidePanelTitles: true,
useMargins: false,
syncColors: false,
syncTooltips: false,
syncCursor: false,
},
panels: [
{
panelConfig: {
enhancements: {},
},
gridData: {
x: 0,
y: 0,
w: 10,
h: 10,
i: '1',
},
id: '1',
panelIndex: '1',
panelRefName: 'ref1',
title: 'title1',
type: 'type1',
version: '2',
},
],
refreshInterval: {
pause: true,
value: 1000,
},
timeFrom: 'now-15m',
timeRestore: true,
timeTo: 'now',
title: 'title',
});
});
});
describe('itemAttrsToSavedObjectAttrs', () => {
it('should transform item attributes to saved object attributes correctly', () => {
const input: DashboardAttributes = {
controlGroupInput: {
chainingSystem: 'NONE',
labelPosition: 'twoLine',
controls: [
{
controlConfig: { anyKey: 'some value' },
grow: false,
id: 'foo',
order: 0,
type: 'type1',
width: 'small',
},
],
ignoreParentSettings: {
ignoreFilters: true,
ignoreQuery: true,
ignoreTimerange: true,
ignoreValidations: true,
},
autoApplySelections: false,
},
description: 'description',
kibanaSavedObjectMeta: { searchSource: { query: { query: 'test', language: 'KQL' } } },
options: {
hidePanelTitles: true,
useMargins: false,
syncColors: false,
syncTooltips: false,
syncCursor: false,
},
panels: [
{
gridData: { x: 0, y: 0, w: 10, h: 10, i: '1' },
id: '1',
panelConfig: { enhancements: {} },
panelIndex: '1',
panelRefName: 'ref1',
title: 'title1',
type: 'type1',
version: '2',
},
],
timeRestore: true,
title: 'title',
refreshInterval: { pause: true, value: 1000 },
timeFrom: 'now-15m',
timeTo: 'now',
};
const output = itemAttrsToSavedObjectAttrs(input);
expect(output).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"controlGroupInput": Object {
"chainingSystem": "NONE",
"controlStyle": "twoLine",
"ignoreParentSettingsJSON": "{\\"ignoreFilters\\":true,\\"ignoreQuery\\":true,\\"ignoreTimerange\\":true,\\"ignoreValidations\\":true}",
"panelsJSON": "{\\"foo\\":{\\"grow\\":false,\\"order\\":0,\\"type\\":\\"type1\\",\\"width\\":\\"small\\",\\"explicitInput\\":{\\"anyKey\\":\\"some value\\",\\"id\\":\\"foo\\"}}}",
"showApplySelections": true,
},
"description": "description",
"kibanaSavedObjectMeta": Object {
"searchSourceJSON": "{\\"query\\":{\\"query\\":\\"test\\",\\"language\\":\\"KQL\\"}}",
},
"optionsJSON": "{\\"hidePanelTitles\\":true,\\"useMargins\\":false,\\"syncColors\\":false,\\"syncTooltips\\":false,\\"syncCursor\\":false}",
"panelsJSON": "[{\\"id\\":\\"1\\",\\"panelRefName\\":\\"ref1\\",\\"title\\":\\"title1\\",\\"type\\":\\"type1\\",\\"version\\":\\"2\\",\\"embeddableConfig\\":{\\"enhancements\\":{}},\\"panelIndex\\":\\"1\\",\\"gridData\\":{\\"x\\":0,\\"y\\":0,\\"w\\":10,\\"h\\":10,\\"i\\":\\"1\\"}}]",
"refreshInterval": Object {
"pause": true,
"value": 1000,
},
"timeFrom": "now-15m",
"timeRestore": true,
"timeTo": "now",
"title": "title",
},
"error": null,
}
`);
});
it('should handle missing optional attributes', () => {
const input: DashboardAttributes = {
title: 'title',
description: 'my description',
timeRestore: false,
panels: [],
options: DEFAULT_DASHBOARD_OPTIONS,
kibanaSavedObjectMeta: {},
};
const output = itemAttrsToSavedObjectAttrs(input);
expect(output).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"description": "my description",
"kibanaSavedObjectMeta": Object {
"searchSourceJSON": "{}",
},
"optionsJSON": "{\\"hidePanelTitles\\":false,\\"useMargins\\":true,\\"syncColors\\":true,\\"syncCursor\\":true,\\"syncTooltips\\":true}",
"panelsJSON": "[]",
"timeRestore": false,
"title": "title",
},
"error": null,
}
`);
});
});
describe('savedObjectToItem', () => {
const commonSavedObject: SavedObject = {
references: [],
id: '3d8459d9-0f1a-403d-aa82-6d93713a54b5',
type: 'dashboard',
attributes: {},
};
const getSavedObjectForAttributes = (
attributes: DashboardSavedObjectAttributes
): SavedObject<DashboardSavedObjectAttributes> => {
return {
...commonSavedObject,
attributes,
};
};
it('should convert saved object to item with all attributes', () => {
const input = getSavedObjectForAttributes({
title: 'title',
description: 'description',
timeRestore: true,
panelsJSON: JSON.stringify([
{
embeddableConfig: { enhancements: {} },
gridData: { x: 0, y: 0, w: 10, h: 10, i: '1' },
id: '1',
panelIndex: '1',
panelRefName: 'ref1',
title: 'title1',
type: 'type1',
version: '2',
},
]),
optionsJSON: JSON.stringify({
hidePanelTitles: true,
useMargins: false,
syncColors: false,
syncTooltips: false,
syncCursor: false,
}),
kibanaSavedObjectMeta: {
searchSourceJSON: '{"query":{"query":"test","language":"KQL"}}',
},
});
const { item, error } = savedObjectToItem(input, false);
expect(error).toBeNull();
expect(item).toEqual<DashboardItem>({
...commonSavedObject,
attributes: {
title: 'title',
description: 'description',
timeRestore: true,
panels: [
{
panelConfig: { enhancements: {} },
gridData: { x: 0, y: 0, w: 10, h: 10, i: '1' },
id: '1',
panelIndex: '1',
panelRefName: 'ref1',
title: 'title1',
type: 'type1',
version: '2',
},
],
options: {
hidePanelTitles: true,
useMargins: false,
syncColors: false,
syncTooltips: false,
syncCursor: false,
},
kibanaSavedObjectMeta: {
searchSource: { query: { query: 'test', language: 'KQL' } },
},
},
});
});
it('should handle missing optional attributes', () => {
const input = getSavedObjectForAttributes({
title: 'title',
description: 'description',
timeRestore: false,
panelsJSON: '[]',
optionsJSON: '{}',
kibanaSavedObjectMeta: {},
});
const { item, error } = savedObjectToItem(input, false);
expect(error).toBeNull();
expect(item).toEqual<DashboardItem>({
...commonSavedObject,
attributes: {
title: 'title',
description: 'description',
timeRestore: false,
panels: [],
options: DEFAULT_DASHBOARD_OPTIONS,
kibanaSavedObjectMeta: {},
},
});
});
it('should handle partial saved object', () => {
const input = {
...commonSavedObject,
references: undefined,
attributes: {
title: 'title',
description: 'my description',
timeRestore: false,
},
};
const { item, error } = savedObjectToItem(input, true, ['title', 'description']);
expect(error).toBeNull();
expect(item).toEqual({
...commonSavedObject,
references: undefined,
attributes: {
title: 'title',
description: 'my description',
},
});
});
it('should return an error if attributes can not be parsed', () => {
const input = {
...commonSavedObject,
references: undefined,
attributes: {
title: 'title',
panelsJSON: 'not stringified json',
},
};
const { item, error } = savedObjectToItem(input, true);
expect(item).toBeNull();
expect(error).not.toBe(null);
});
});
describe('getResultV3ToV2', () => {
const commonAttributes = {
description: 'description',
refreshInterval: { pause: true, value: 1000 },
timeFrom: 'now-15m',
timeRestore: true,
timeTo: 'now',
title: 'title',
};
it('should transform a v3 result to a v2 result with all attributes', () => {
const v3Result = {
meta: { outcome: 'exactMatch' as const },
item: {
id: '1',
type: 'dashboard',
attributes: {
...commonAttributes,
controlGroupInput: {
chainingSystem: 'NONE' as ControlGroupChainingSystem,
labelPosition: 'twoLine' as ControlLabelPosition,
controls: [
{
controlConfig: { bizz: 'buzz' },
grow: false,
order: 0,
id: 'foo',
type: 'type1',
width: 'small' as ControlWidth,
},
],
ignoreParentSettings: {
ignoreFilters: true,
ignoreQuery: true,
ignoreTimerange: true,
ignoreValidations: true,
},
autoApplySelections: false,
},
kibanaSavedObjectMeta: { searchSource: { query: { query: 'test', language: 'KQL' } } },
options: {
hidePanelTitles: true,
useMargins: false,
syncColors: false,
syncCursor: false,
syncTooltips: false,
},
panels: [
{
id: '1',
type: 'visualization',
panelConfig: { title: 'my panel' },
gridData: { x: 0, y: 0, w: 15, h: 15, i: 'foo' },
panelIndex: 'foo',
},
],
},
references: [],
},
};
const output = getResultV3ToV2(v3Result);
// Common attributes should match between v2 and v3
expect(output.item.attributes).toMatchObject(commonAttributes);
expect(output.item.attributes.controlGroupInput).toMatchObject({
chainingSystem: 'NONE',
controlStyle: 'twoLine',
showApplySelections: true,
});
// Check transformed attributes
expect(output.item.attributes.controlGroupInput!.panelsJSON).toMatchInlineSnapshot(
`"{\\"foo\\":{\\"grow\\":false,\\"order\\":0,\\"type\\":\\"type1\\",\\"width\\":\\"small\\",\\"explicitInput\\":{\\"bizz\\":\\"buzz\\",\\"id\\":\\"foo\\"}}}"`
);
expect(
output.item.attributes.controlGroupInput!.ignoreParentSettingsJSON
).toMatchInlineSnapshot(
`"{\\"ignoreFilters\\":true,\\"ignoreQuery\\":true,\\"ignoreTimerange\\":true,\\"ignoreValidations\\":true}"`
);
expect(output.item.attributes.kibanaSavedObjectMeta.searchSourceJSON).toMatchInlineSnapshot(
`"{\\"query\\":{\\"query\\":\\"test\\",\\"language\\":\\"KQL\\"}}"`
);
expect(output.item.attributes.optionsJSON).toMatchInlineSnapshot(
`"{\\"hidePanelTitles\\":true,\\"useMargins\\":false,\\"syncColors\\":false,\\"syncCursor\\":false,\\"syncTooltips\\":false}"`
);
expect(output.item.attributes.panelsJSON).toMatchInlineSnapshot(
`"[{\\"id\\":\\"1\\",\\"type\\":\\"visualization\\",\\"embeddableConfig\\":{\\"title\\":\\"my panel\\"},\\"panelIndex\\":\\"foo\\",\\"gridData\\":{\\"x\\":0,\\"y\\":0,\\"w\\":15,\\"h\\":15,\\"i\\":\\"foo\\"}}]"`
);
});
});

View file

@ -0,0 +1,365 @@
/*
* 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 { v4 as uuidv4 } from 'uuid';
import { pick } from 'lodash';
import type { Query } from '@kbn/es-query';
import {
type ControlGroupChainingSystem,
type ControlLabelPosition,
type ControlPanelsState,
type SerializedControlState,
DEFAULT_AUTO_APPLY_SELECTIONS,
DEFAULT_CONTROL_CHAINING,
DEFAULT_CONTROL_GROW,
DEFAULT_CONTROL_LABEL_POSITION,
DEFAULT_CONTROL_WIDTH,
DEFAULT_IGNORE_PARENT_SETTINGS,
} from '@kbn/controls-plugin/common';
import { SerializedSearchSourceFields, parseSearchSourceJSON } from '@kbn/data-plugin/common';
import type { SavedObject, SavedObjectReference } from '@kbn/core-saved-objects-api-server';
import type {
ControlGroupAttributes,
DashboardAttributes,
DashboardGetOut,
DashboardItem,
DashboardOptions,
ItemAttrsToSavedObjectAttrsReturn,
PartialDashboardItem,
SavedObjectToItemReturn,
} from './types';
import type {
DashboardSavedObjectAttributes,
SavedDashboardPanel,
} from '../../dashboard_saved_object';
import type {
ControlGroupAttributes as ControlGroupAttributesV2,
DashboardCrudTypes as DashboardCrudTypesV2,
} from '../../../common/content_management/v2';
import { DEFAULT_DASHBOARD_OPTIONS } from '../../../common/content_management';
function controlGroupInputOut(
controlGroupInput?: DashboardSavedObjectAttributes['controlGroupInput']
): ControlGroupAttributes | undefined {
if (!controlGroupInput) {
return;
}
const {
panelsJSON,
ignoreParentSettingsJSON,
controlStyle = DEFAULT_CONTROL_LABEL_POSITION,
chainingSystem = DEFAULT_CONTROL_CHAINING,
showApplySelections = !DEFAULT_AUTO_APPLY_SELECTIONS,
} = controlGroupInput;
const controls = panelsJSON
? Object.entries(JSON.parse(panelsJSON) as ControlPanelsState<SerializedControlState>).map(
([
id,
{
explicitInput,
type,
grow = DEFAULT_CONTROL_GROW,
width = DEFAULT_CONTROL_WIDTH,
order,
},
]) => ({
controlConfig: explicitInput,
id,
grow,
order,
type,
width,
})
)
: [];
const {
ignoreFilters = DEFAULT_IGNORE_PARENT_SETTINGS.ignoreFilters,
ignoreQuery = DEFAULT_IGNORE_PARENT_SETTINGS.ignoreQuery,
ignoreTimerange = DEFAULT_IGNORE_PARENT_SETTINGS.ignoreTimerange,
ignoreValidations = DEFAULT_IGNORE_PARENT_SETTINGS.ignoreValidations,
} = ignoreParentSettingsJSON ? JSON.parse(ignoreParentSettingsJSON) : {};
// try to maintain a consistent (alphabetical) order of keys
return {
autoApplySelections: !showApplySelections,
chainingSystem: chainingSystem as ControlGroupChainingSystem,
controls,
labelPosition: controlStyle as ControlLabelPosition,
ignoreParentSettings: { ignoreFilters, ignoreQuery, ignoreTimerange, ignoreValidations },
};
}
function kibanaSavedObjectMetaOut(
kibanaSavedObjectMeta: DashboardSavedObjectAttributes['kibanaSavedObjectMeta']
): DashboardAttributes['kibanaSavedObjectMeta'] {
const { searchSourceJSON } = kibanaSavedObjectMeta;
if (!searchSourceJSON) {
return {};
}
// Dashboards do not yet support ES|QL (AggregateQuery) in the search source
return {
searchSource: parseSearchSourceJSON(searchSourceJSON) as Omit<
SerializedSearchSourceFields,
'query'
> & { query?: Query },
};
}
function optionsOut(optionsJSON: string): DashboardAttributes['options'] {
const {
hidePanelTitles = DEFAULT_DASHBOARD_OPTIONS.hidePanelTitles,
useMargins = DEFAULT_DASHBOARD_OPTIONS.useMargins,
syncColors = DEFAULT_DASHBOARD_OPTIONS.syncColors,
syncCursor = DEFAULT_DASHBOARD_OPTIONS.syncCursor,
syncTooltips = DEFAULT_DASHBOARD_OPTIONS.syncTooltips,
} = JSON.parse(optionsJSON) as DashboardOptions;
return {
hidePanelTitles,
useMargins,
syncColors,
syncCursor,
syncTooltips,
};
}
function panelsOut(panelsJSON: string): DashboardAttributes['panels'] {
const panels = JSON.parse(panelsJSON) as SavedDashboardPanel[];
return panels.map(
({ embeddableConfig, gridData, id, panelIndex, panelRefName, title, type, version }) => ({
gridData,
id,
panelConfig: embeddableConfig,
panelIndex,
panelRefName,
title,
type,
version,
})
);
}
export function dashboardAttributesOut(
attributes: DashboardSavedObjectAttributes | Partial<DashboardSavedObjectAttributes>
): DashboardAttributes | Partial<DashboardAttributes> {
const {
controlGroupInput,
description,
kibanaSavedObjectMeta,
optionsJSON,
panelsJSON,
refreshInterval,
timeFrom,
timeRestore,
timeTo,
title,
version,
} = attributes;
// try to maintain a consistent (alphabetical) order of keys
return {
...(controlGroupInput && { controlGroupInput: controlGroupInputOut(controlGroupInput) }),
...(description && { description }),
...(kibanaSavedObjectMeta && {
kibanaSavedObjectMeta: kibanaSavedObjectMetaOut(kibanaSavedObjectMeta),
}),
...(optionsJSON && { options: optionsOut(optionsJSON) }),
...(panelsJSON && { panels: panelsOut(panelsJSON) }),
...(refreshInterval && {
refreshInterval: { pause: refreshInterval.pause, value: refreshInterval.value },
}),
...(timeFrom && { timeFrom }),
timeRestore: timeRestore ?? false,
...(timeTo && { timeTo }),
title,
...(version && { version }),
};
}
function controlGroupInputIn(
controlGroupInput?: ControlGroupAttributes
): DashboardSavedObjectAttributes['controlGroupInput'] | undefined {
if (!controlGroupInput) {
return;
}
const { controls, ignoreParentSettings, labelPosition, chainingSystem, autoApplySelections } =
controlGroupInput;
const updatedControls = Object.fromEntries(
controls.map(({ controlConfig, id = uuidv4(), ...restOfControl }) => {
return [id, { ...restOfControl, explicitInput: { ...controlConfig, id } }];
})
);
return {
chainingSystem,
controlStyle: labelPosition,
ignoreParentSettingsJSON: JSON.stringify(ignoreParentSettings),
panelsJSON: JSON.stringify(updatedControls),
showApplySelections: !autoApplySelections,
};
}
function panelsIn(
panels: DashboardAttributes['panels']
): DashboardSavedObjectAttributes['panelsJSON'] {
const updatedPanels = panels.map(({ panelIndex, gridData, panelConfig, ...restPanel }) => {
const idx = panelIndex ?? uuidv4();
return {
...restPanel,
embeddableConfig: panelConfig,
panelIndex: idx,
gridData: {
...gridData,
i: idx,
},
};
});
return JSON.stringify(updatedPanels);
}
function kibanaSavedObjectMetaIn(
kibanaSavedObjectMeta: DashboardAttributes['kibanaSavedObjectMeta']
) {
const { searchSource } = kibanaSavedObjectMeta;
return { searchSourceJSON: JSON.stringify(searchSource ?? {}) };
}
export const getResultV3ToV2 = (result: DashboardGetOut): DashboardCrudTypesV2['GetOut'] => {
const { meta, item } = result;
const { attributes, ...rest } = item;
const {
controlGroupInput,
description,
kibanaSavedObjectMeta,
options,
panels,
refreshInterval,
timeFrom,
timeRestore,
timeTo,
title,
version,
} = attributes;
const v2Attributes = {
...(controlGroupInput && {
controlGroupInput: controlGroupInputIn(controlGroupInput) as ControlGroupAttributesV2,
}),
description,
...(kibanaSavedObjectMeta && {
kibanaSavedObjectMeta: kibanaSavedObjectMetaIn(kibanaSavedObjectMeta),
}),
...(options && { optionsJSON: JSON.stringify(options) }),
panelsJSON: panels ? panelsIn(panels) : '[]',
refreshInterval,
...(timeFrom && { timeFrom }),
timeRestore,
...(timeTo && { timeTo }),
title,
...(version && { version }),
};
return {
meta,
item: {
...rest,
attributes: v2Attributes,
},
};
};
export const itemAttrsToSavedObjectAttrs = (
attributes: DashboardAttributes
): ItemAttrsToSavedObjectAttrsReturn => {
try {
const { controlGroupInput, kibanaSavedObjectMeta, options, panels, ...rest } = attributes;
const soAttributes = {
...rest,
...(controlGroupInput && {
controlGroupInput: controlGroupInputIn(controlGroupInput),
}),
...(options && {
optionsJSON: JSON.stringify(options),
}),
...(panels && {
panelsJSON: panelsIn(panels),
}),
...(kibanaSavedObjectMeta && {
kibanaSavedObjectMeta: kibanaSavedObjectMetaIn(kibanaSavedObjectMeta),
}),
};
return { attributes: soAttributes, error: null };
} catch (e) {
return { attributes: null, error: e };
}
};
type PartialSavedObject<T> = Omit<SavedObject<Partial<T>>, 'references'> & {
references: SavedObjectReference[] | undefined;
};
export function savedObjectToItem(
savedObject: SavedObject<DashboardSavedObjectAttributes>,
partial: false,
allowedAttributes?: string[]
): SavedObjectToItemReturn<DashboardItem>;
export function savedObjectToItem(
savedObject: PartialSavedObject<DashboardSavedObjectAttributes>,
partial: true,
allowedAttributes?: string[]
): SavedObjectToItemReturn<PartialDashboardItem>;
export function savedObjectToItem(
savedObject:
| SavedObject<DashboardSavedObjectAttributes>
| PartialSavedObject<DashboardSavedObjectAttributes>,
partial: boolean,
allowedAttributes?: string[]
): SavedObjectToItemReturn<DashboardItem | PartialDashboardItem> {
const {
id,
type,
updated_at: updatedAt,
updated_by: updatedBy,
created_at: createdAt,
created_by: createdBy,
attributes,
error,
namespaces,
references,
version,
managed,
} = savedObject;
try {
const attributesOut = allowedAttributes
? pick(dashboardAttributesOut(attributes), allowedAttributes)
: dashboardAttributesOut(attributes);
return {
item: {
id,
type,
updatedAt,
updatedBy,
createdAt,
createdBy,
attributes: attributesOut,
error,
namespaces,
references,
version,
managed,
},
error: null,
};
} catch (e) {
return { item: null, error: e };
}
}

View file

@ -0,0 +1,90 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { TypeOf } from '@kbn/config-schema';
import {
CreateIn,
GetIn,
SearchIn,
SearchResult,
UpdateIn,
} from '@kbn/content-management-plugin/common';
import { SavedObjectReference } from '@kbn/core-saved-objects-api-server';
import {
dashboardItemSchema,
controlGroupInputSchema,
gridDataSchema,
panelSchema,
dashboardAttributesSchema,
dashboardCreateOptionsSchema,
dashboardCreateResultSchema,
dashboardGetResultSchema,
dashboardSearchOptionsSchema,
dashboardSearchResultsSchema,
dashboardUpdateOptionsSchema,
optionsSchema,
} from './cm_services';
import { CONTENT_ID } from '../../../common/content_management';
import { DashboardSavedObjectAttributes } from '../../dashboard_saved_object';
export type DashboardOptions = TypeOf<typeof optionsSchema>;
// Panel config has some defined types but also allows for custom keys added by embeddables
// The schema uses "unknowns: 'allow'" to permit any other keys, but the TypeOf helper does not
// recognize this, so we need to manually extend the type here.
export type DashboardPanel = Omit<TypeOf<typeof panelSchema>, 'panelConfig'> & {
panelConfig: TypeOf<typeof panelSchema>['panelConfig'] & { [key: string]: any };
};
export type DashboardAttributes = Omit<TypeOf<typeof dashboardAttributesSchema>, 'panels'> & {
panels: DashboardPanel[];
};
export type DashboardItem = TypeOf<typeof dashboardItemSchema>;
export type PartialDashboardItem = Omit<DashboardItem, 'attributes' | 'references'> & {
attributes: Partial<DashboardAttributes>;
references: SavedObjectReference[] | undefined;
};
export type ControlGroupAttributes = TypeOf<typeof controlGroupInputSchema>;
export type GridData = TypeOf<typeof gridDataSchema>;
export type DashboardGetIn = GetIn<typeof CONTENT_ID>;
export type DashboardGetOut = TypeOf<typeof dashboardGetResultSchema>;
export type DashboardCreateIn = CreateIn<typeof CONTENT_ID, DashboardAttributes>;
export type DashboardCreateOut = TypeOf<typeof dashboardCreateResultSchema>;
export type DashboardCreateOptions = TypeOf<typeof dashboardCreateOptionsSchema>;
export type DashboardUpdateIn = UpdateIn<typeof CONTENT_ID, Partial<DashboardAttributes>>;
export type DashboardUpdateOut = TypeOf<typeof dashboardCreateResultSchema>;
export type DashboardUpdateOptions = TypeOf<typeof dashboardUpdateOptionsSchema>;
export type DashboardSearchIn = SearchIn<typeof CONTENT_ID>;
export type DashboardSearchOptions = TypeOf<typeof dashboardSearchOptionsSchema>;
export type DashboardSearchOut = SearchResult<TypeOf<typeof dashboardSearchResultsSchema>>;
export type SavedObjectToItemReturn<T> =
| {
item: T;
error: null;
}
| {
item: null;
error: Error;
};
export type ItemAttrsToSavedObjectAttrsReturn =
| {
attributes: DashboardSavedObjectAttributes;
error: null;
}
| {
attributes: null;
error: Error;
};

View file

@ -10,19 +10,21 @@
import { ANALYTICS_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server';
import { SavedObjectsType } from '@kbn/core/server';
import { dashboardAttributesSchema as dashboardAttributesSchemaV1 } from '../content_management/schema/v1';
import { dashboardAttributesSchema as dashboardAttributesSchemaV2 } from '../content_management/schema/v2';
import { dashboardAttributesSchema as dashboardAttributesSchemaV1 } from './schema/v1';
import { dashboardAttributesSchema as dashboardAttributesSchemaV2 } from './schema/v2';
import {
createDashboardSavedObjectTypeMigrations,
DashboardSavedObjectTypeMigrationsDeps,
} from './migrations/dashboard_saved_object_migrations';
export const DASHBOARD_SAVED_OBJECT_TYPE = 'dashboard';
export const createDashboardSavedObjectType = ({
migrationDeps,
}: {
migrationDeps: DashboardSavedObjectTypeMigrationsDeps;
}): SavedObjectsType => ({
name: 'dashboard',
name: DASHBOARD_SAVED_OBJECT_TYPE,
indexPattern: ANALYTICS_SAVED_OBJECT_INDEX,
hidden: false,
namespaceType: 'multiple-isolated',

View file

@ -7,4 +7,8 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export { createDashboardSavedObjectType } from './dashboard_saved_object';
export {
createDashboardSavedObjectType,
DASHBOARD_SAVED_OBJECT_TYPE,
} from './dashboard_saved_object';
export type { DashboardSavedObjectAttributes, GridData, SavedDashboardPanel } from './schema';

View file

@ -613,12 +613,11 @@ describe('dashboard', () => {
expect(newDoc).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"description": "",
"kibanaSavedObjectMeta": Object {
"searchSourceJSON": "{\\"query\\":{\\"language\\":\\"kuery\\",\\"query\\":\\"\\"},\\"filter\\":[{\\"query\\":{\\"match_phrase\\":{\\"machine.os.keyword\\":\\"osx\\"}},\\"$state\\":{\\"store\\":\\"appState\\"},\\"meta\\":{\\"type\\":\\"phrase\\",\\"key\\":\\"machine.os.keyword\\",\\"params\\":{\\"query\\":\\"osx\\"},\\"disabled\\":false,\\"negate\\":false,\\"alias\\":null,\\"indexRefName\\":\\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\\"}}]}",
},
"optionsJSON": "{\\"useMargins\\":true,\\"hidePanelTitles\\":false}",
"panelsJSON": "[{\\"version\\":\\"7.9.3\\",\\"type\\":\\"visualization\\",\\"gridData\\":{\\"x\\":0,\\"y\\":0,\\"w\\":24,\\"h\\":15,\\"i\\":\\"82fa0882-9f9e-476a-bbb9-03555e5ced91\\"},\\"panelIndex\\":\\"82fa0882-9f9e-476a-bbb9-03555e5ced91\\",\\"embeddableConfig\\":{\\"enhancements\\":{\\"dynamicActions\\":{\\"events\\":[]}}},\\"panelRefName\\":\\"panel_82fa0882-9f9e-476a-bbb9-03555e5ced91\\"}]",
"optionsJSON": "{\\"hidePanelTitles\\":false,\\"useMargins\\":true,\\"syncColors\\":true,\\"syncCursor\\":true,\\"syncTooltips\\":true}",
"panelsJSON": "[{\\"version\\":\\"7.9.3\\",\\"type\\":\\"visualization\\",\\"panelRefName\\":\\"panel_82fa0882-9f9e-476a-bbb9-03555e5ced91\\",\\"embeddableConfig\\":{\\"enhancements\\":{\\"dynamicActions\\":{\\"events\\":[]}}},\\"panelIndex\\":\\"82fa0882-9f9e-476a-bbb9-03555e5ced91\\",\\"gridData\\":{\\"x\\":0,\\"y\\":0,\\"w\\":24,\\"h\\":15,\\"i\\":\\"82fa0882-9f9e-476a-bbb9-03555e5ced91\\"}}]",
"timeRestore": false,
"title": "Dashboard A",
"version": 1,
@ -710,7 +709,7 @@ describe('dashboard', () => {
contextMock
);
expect(migratedDoc.attributes.panelsJSON).toMatchInlineSnapshot(
`"[{\\"version\\":\\"7.9.3\\",\\"gridData\\":{\\"x\\":0,\\"y\\":0,\\"w\\":24,\\"h\\":15,\\"i\\":\\"0\\"},\\"panelIndex\\":\\"0\\",\\"embeddableConfig\\":{}},{\\"version\\":\\"7.13.0\\",\\"gridData\\":{\\"x\\":24,\\"y\\":0,\\"w\\":24,\\"h\\":15,\\"i\\":\\"1\\"},\\"panelIndex\\":\\"1\\",\\"embeddableConfig\\":{\\"attributes\\":{\\"byValueThing\\":\\"ThisIsByValue\\"},\\"superCoolKey\\":\\"ONLY 4 BY VALUE EMBEDDABLES THANK YOU VERY MUCH\\"}}]"`
`"[{\\"version\\":\\"7.9.3\\",\\"gridData\\":{\\"x\\":0,\\"y\\":0,\\"w\\":24,\\"h\\":15,\\"i\\":\\"0\\"},\\"panelIndex\\":\\"0\\",\\"embeddableConfig\\":{}},{\\"gridData\\":{\\"x\\":24,\\"y\\":0,\\"w\\":24,\\"h\\":15,\\"i\\":\\"1\\"},\\"panelIndex\\":\\"1\\",\\"embeddableConfig\\":{\\"attributes\\":{\\"byValueThing\\":\\"ThisIsByValue\\"},\\"superCoolKey\\":\\"ONLY 4 BY VALUE EMBEDDABLES THANK YOU VERY MUCH\\"},\\"version\\":\\"7.13.0\\"}]"`
);
});
});

View file

@ -9,8 +9,8 @@
import { CONTROL_GROUP_TYPE } from '@kbn/controls-plugin/common';
import {
controlGroupSerializedStateToSerializableRuntimeState,
serializableRuntimeStateToControlGroupSerializedState,
controlGroupSavedObjectStateToSerializableRuntimeState,
serializableRuntimeStateToControlGroupSavedObjectState,
} from '@kbn/controls-plugin/server';
import { Serializable, SerializableRecord } from '@kbn/utility-types';
import { SavedObjectMigrationFn } from '@kbn/core/server';
@ -20,8 +20,8 @@ import { SavedObjectEmbeddableInput } from '@kbn/embeddable-plugin/common';
import {
convertPanelStateToSavedDashboardPanel,
convertSavedDashboardPanelToPanelState,
} from '../../../common';
import { SavedDashboardPanel } from '../../../common/content_management';
} from './utils';
import type { SavedDashboardPanel } from '..';
type ValueOrReferenceInput = SavedObjectEmbeddableInput & {
attributes?: Serializable;
@ -35,7 +35,7 @@ export const migrateByValueDashboardPanels =
const { attributes } = doc;
if (attributes?.controlGroupInput) {
const controlGroupState = controlGroupSerializedStateToSerializableRuntimeState(
const controlGroupState = controlGroupSavedObjectStateToSerializableRuntimeState(
attributes.controlGroupInput
);
const migratedControlGroupInput = migrate({
@ -43,7 +43,7 @@ export const migrateByValueDashboardPanels =
type: CONTROL_GROUP_TYPE,
} as SerializableRecord);
attributes.controlGroupInput =
serializableRuntimeStateToControlGroupSerializedState(migratedControlGroupInput);
serializableRuntimeStateToControlGroupSavedObjectState(migratedControlGroupInput);
}
// Skip if panelsJSON is missing otherwise this will cause saved object import to fail when

View file

@ -7,11 +7,12 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { SavedObjectMigrationFn } from '@kbn/core/server';
import { SavedObject, SavedObjectMigrationFn } from '@kbn/core/server';
import { extractReferences, injectReferences } from '../../../common';
import { DashboardAttributes } from '../../../common/content_management';
import { DashboardSavedObjectTypeMigrationsDeps } from './dashboard_saved_object_migrations';
import type { DashboardSavedObjectTypeMigrationsDeps } from './dashboard_saved_object_migrations';
import type { DashboardSavedObjectAttributes } from '../schema';
import { itemAttrsToSavedObjectAttrs, savedObjectToItem } from '../../content_management/latest';
/**
* In 7.8.0 we introduced dashboard drilldowns which are stored inside dashboard saved object as part of embeddable state
@ -26,7 +27,7 @@ import { DashboardSavedObjectTypeMigrationsDeps } from './dashboard_saved_object
*/
export function createExtractPanelReferencesMigration(
deps: DashboardSavedObjectTypeMigrationsDeps
): SavedObjectMigrationFn<DashboardAttributes> {
): SavedObjectMigrationFn<DashboardSavedObjectAttributes> {
return (doc) => {
const references = doc.references ?? [];
@ -36,19 +37,32 @@ export function createExtractPanelReferencesMigration(
*/
const oldNonPanelReferences = references.filter((ref) => !ref.name.startsWith('panel_'));
// Use Content Management to convert the saved object to the DashboardAttributes
// expected by injectReferences
const { item, error: itemError } = savedObjectToItem(
doc as unknown as SavedObject<DashboardSavedObjectAttributes>,
false
);
if (itemError) throw itemError;
const parsedAttributes = item.attributes;
const injectedAttributes = injectReferences(
{
attributes: doc.attributes,
attributes: parsedAttributes,
references,
},
{ embeddablePersistableStateService: deps.embeddable }
);
const { attributes, references: newPanelReferences } = extractReferences(
const { attributes: extractedAttributes, references: newPanelReferences } = extractReferences(
{ attributes: injectedAttributes, references: [] },
{ embeddablePersistableStateService: deps.embeddable }
);
const { attributes, error: attributesError } = itemAttrsToSavedObjectAttrs(extractedAttributes);
if (attributesError) throw attributesError;
return {
...doc,
references: [...oldNonPanelReferences, ...newPanelReferences],

View file

@ -7,14 +7,14 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { SavedObjectMigrationFn } from '@kbn/core/server';
import { EmbeddableInput } from '@kbn/embeddable-plugin/common';
import type { SavedObjectMigrationFn } from '@kbn/core/server';
import type { EmbeddableInput } from '@kbn/embeddable-plugin/common';
import type { SavedDashboardPanel } from '../schema';
import {
convertSavedDashboardPanelToPanelState,
convertPanelStateToSavedDashboardPanel,
} from '../../../common';
import { SavedDashboardPanel } from '../../../common/content_management';
} from './utils';
/**
* Before 7.10, hidden panel titles were stored as a blank string on the title attribute. In 7.10, this was replaced

View file

@ -13,7 +13,7 @@ import semverSatisfies from 'semver/functions/satisfies';
import { i18n } from '@kbn/i18n';
import type { SerializableRecord } from '@kbn/utility-types';
import {
import type {
SavedDashboardPanel620,
SavedDashboardPanel630,
SavedDashboardPanel610,
@ -25,7 +25,7 @@ import {
RawSavedDashboardPanel640To720,
RawSavedDashboardPanel730ToLatest,
} from './types';
import { GridData } from '../../../../common/content_management';
import type { GridData } from '../../../content_management';
const PANEL_HEIGHT_SCALE_FACTOR = 5;
const PANEL_HEIGHT_SCALE_FACTOR_WITH_MARGINS = 4;

View file

@ -49,7 +49,7 @@ export const migrations730 = (doc: DashboardDoc700To720, { log }: SavedObjectMig
}
try {
const searchSource = JSON.parse(doc.attributes.kibanaSavedObjectMeta.searchSourceJSON);
const searchSource = JSON.parse(doc.attributes.kibanaSavedObjectMeta.searchSourceJSON!);
doc.attributes.kibanaSavedObjectMeta.searchSourceJSON = JSON.stringify(
moveFiltersToQuery(searchSource)
);

View file

@ -10,14 +10,12 @@
import type { Serializable } from '@kbn/utility-types';
import { SavedObjectReference } from '@kbn/core/server';
import type {
GridData,
DashboardAttributes as CurrentDashboardAttributes, // Dashboard attributes from common are the source of truth for the current version.
} from '../../../../common/content_management';
import type { GridData } from '../../../content_management';
import type { DashboardSavedObjectAttributes } from '../../schema';
interface KibanaAttributes {
kibanaSavedObjectMeta: {
searchSourceJSON: string;
searchSourceJSON?: string;
};
}
@ -45,7 +43,7 @@ interface DashboardAttributesTo720 extends KibanaAttributes {
optionsJSON?: string;
}
export type DashboardDoc730ToLatest = Doc<CurrentDashboardAttributes>;
export type DashboardDoc730ToLatest = Doc<DashboardSavedObjectAttributes>;
export type DashboardDoc700To720 = Doc<DashboardAttributesTo720>;

View file

@ -7,13 +7,14 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { EmbeddableInput } from '@kbn/embeddable-plugin/common/types';
import type { SavedDashboardPanel } from '../schema';
import type { DashboardPanelState } from '../../../common';
import {
convertSavedDashboardPanelToPanelState,
convertPanelStateToSavedDashboardPanel,
} from './dashboard_panel_converters';
import { SavedDashboardPanel } from '../content_management';
import { DashboardPanelState } from '../dashboard_container/types';
import { EmbeddableInput } from '@kbn/embeddable-plugin/common/types';
} from './utils';
test('convertSavedDashboardPanelToPanelState', () => {
const savedDashboardPanel: SavedDashboardPanel = {
@ -148,7 +149,7 @@ test('convertPanelStateToSavedDashboardPanel will not leave title as part of emb
expect(converted.title).toBe('title');
});
test('convertPanelStateToSavedDashboardPanel retains legacy version info when not passed removeLegacyVersion', () => {
test('convertPanelStateToSavedDashboardPanel retains legacy version info', () => {
const dashboardPanel: DashboardPanelState = {
gridData: {
x: 0,
@ -168,24 +169,3 @@ test('convertPanelStateToSavedDashboardPanel retains legacy version info when no
const converted = convertPanelStateToSavedDashboardPanel(dashboardPanel);
expect(converted.version).toBe('8.10.0');
});
test('convertPanelStateToSavedDashboardPanel removes legacy version info when passed removeLegacyVersion', () => {
const dashboardPanel: DashboardPanelState = {
gridData: {
x: 0,
y: 0,
h: 15,
w: 15,
i: '123',
},
explicitInput: {
id: '123',
title: 'title',
} as EmbeddableInput,
type: 'search',
version: '8.10.0',
};
const converted = convertPanelStateToSavedDashboardPanel(dashboardPanel, true);
expect(converted.version).not.toBeDefined();
});

View file

@ -0,0 +1,50 @@
/*
* 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 { omit } from 'lodash';
import type { EmbeddableInput, SavedObjectEmbeddableInput } from '@kbn/embeddable-plugin/common';
import type { SavedDashboardPanel } from '../schema';
import type { DashboardPanelState } from '../../../common';
export function convertSavedDashboardPanelToPanelState<
TEmbeddableInput extends EmbeddableInput | SavedObjectEmbeddableInput = SavedObjectEmbeddableInput
>(savedDashboardPanel: SavedDashboardPanel): DashboardPanelState<TEmbeddableInput> {
return {
type: savedDashboardPanel.type,
gridData: savedDashboardPanel.gridData,
panelRefName: savedDashboardPanel.panelRefName,
explicitInput: {
id: savedDashboardPanel.panelIndex,
...(savedDashboardPanel.id !== undefined && { savedObjectId: savedDashboardPanel.id }),
...(savedDashboardPanel.title !== undefined && { title: savedDashboardPanel.title }),
...savedDashboardPanel.embeddableConfig,
} as TEmbeddableInput,
version: savedDashboardPanel.version,
};
}
export function convertPanelStateToSavedDashboardPanel(
panelState: DashboardPanelState
): SavedDashboardPanel {
const savedObjectId = (panelState.explicitInput as SavedObjectEmbeddableInput).savedObjectId;
const panelIndex = panelState.explicitInput.id;
return {
type: panelState.type,
gridData: {
...panelState.gridData,
i: panelIndex,
},
panelIndex,
embeddableConfig: omit(panelState.explicitInput, ['id', 'savedObjectId', 'title']),
...(panelState.explicitInput.title !== undefined && { title: panelState.explicitInput.title }),
...(savedObjectId !== undefined && { id: savedObjectId }),
...(panelState.panelRefName !== undefined && { panelRefName: panelState.panelRefName }),
...(panelState.version !== undefined && { version: panelState.version }),
};
}

View file

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

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".
*/
// Latest model version for dashboard saved objects is v2
export {
dashboardAttributesSchema as dashboardSavedObjectSchema,
type DashboardAttributes as DashboardSavedObjectAttributes,
type GridData,
type SavedDashboardPanel,
} from './v2';

View file

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

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { TypeOf } from '@kbn/config-schema';
import { dashboardAttributesSchema } from './v1';
export type DashboardAttributes = TypeOf<typeof dashboardAttributesSchema>;

View file

@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { schema } from '@kbn/config-schema';
export const controlGroupInputSchema = schema
.object({
panelsJSON: schema.maybe(schema.string()),
controlStyle: schema.maybe(schema.string()),
chainingSystem: schema.maybe(schema.string()),
ignoreParentSettingsJSON: schema.maybe(schema.string()),
})
.extends({}, { unknowns: 'ignore' });
export const dashboardAttributesSchema = schema.object(
{
// General
title: schema.string(),
description: schema.string({ defaultValue: '' }),
// Search
kibanaSavedObjectMeta: schema.object({
searchSourceJSON: schema.maybe(schema.string()),
}),
// Time
timeRestore: schema.maybe(schema.boolean()),
timeFrom: schema.maybe(schema.string()),
timeTo: schema.maybe(schema.string()),
refreshInterval: schema.maybe(
schema.object({
pause: schema.boolean(),
value: schema.number(),
display: schema.maybe(schema.string()),
section: schema.maybe(schema.number()),
})
),
// Dashboard Content
controlGroupInput: schema.maybe(controlGroupInputSchema),
panelsJSON: schema.string({ defaultValue: '[]' }),
optionsJSON: schema.maybe(schema.string()),
// Legacy
hits: schema.maybe(schema.number()),
version: schema.maybe(schema.number()),
},
{ unknowns: 'forbid' }
);

View file

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

View file

@ -0,0 +1,35 @@
/*
* 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 { Serializable } from '@kbn/utility-types';
import { TypeOf } from '@kbn/config-schema';
import { dashboardAttributesSchema, gridDataSchema } from './v2';
export type DashboardAttributes = TypeOf<typeof dashboardAttributesSchema>;
export type GridData = TypeOf<typeof gridDataSchema>;
/**
* A saved dashboard panel parsed directly from the Dashboard Attributes panels JSON
*/
export interface SavedDashboardPanel {
embeddableConfig: { [key: string]: Serializable }; // parsed into the panel's explicitInput
id?: string; // the saved object id for by reference panels
type: string; // the embeddable type
panelRefName?: string;
gridData: GridData;
panelIndex: string;
title?: string;
/**
* This version key 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. (embeddableConfig in this type).
*/
version?: string;
}

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { schema } from '@kbn/config-schema';
import {
controlGroupInputSchema as controlGroupInputSchemaV1,
dashboardAttributesSchema as dashboardAttributesSchemaV1,
} from '../v1';
export const controlGroupInputSchema = controlGroupInputSchemaV1.extends(
{
showApplySelections: schema.maybe(schema.boolean()),
},
{ unknowns: 'ignore' }
);
export const dashboardAttributesSchema = dashboardAttributesSchemaV1.extends(
{
controlGroupInput: schema.maybe(controlGroupInputSchema),
},
{ unknowns: 'ignore' }
);
export const gridDataSchema = schema.object({
x: schema.number(),
y: schema.number(),
w: schema.number(),
h: schema.number(),
i: schema.string(),
});

View file

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

View file

@ -30,6 +30,7 @@ import { createDashboardSavedObjectType } from './dashboard_saved_object';
import { CONTENT_ID, LATEST_VERSION } from '../common/content_management';
import { registerDashboardUsageCollector } from './usage/register_collector';
import { dashboardPersistableStateServiceFactory } from './dashboard_container/dashboard_container_embeddable_factory';
import { registerAPIRoutes } from './api';
interface SetupDeps {
embeddable: EmbeddableSetup;
@ -111,6 +112,12 @@ export class DashboardPlugin
core.uiSettings.register(getUISettings());
registerAPIRoutes({
http: core.http,
contentManagement: plugins.contentManagement,
logger: this.logger,
});
return {};
}

View file

@ -7,7 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { SavedDashboardPanel } from '../../common/content_management';
import { SavedDashboardPanel } from '../dashboard_saved_object';
import { getEmptyDashboardData, collectPanelsByType } from './dashboard_telemetry';
import { EmbeddableStateWithType } from '@kbn/embeddable-plugin/common';
import { createEmbeddablePersistableStateServiceMock } from '@kbn/embeddable-plugin/common/mocks';

View file

@ -17,7 +17,7 @@ import {
import { EmbeddablePersistableStateService } from '@kbn/embeddable-plugin/common';
import { TaskManagerStartContract } from '@kbn/task-manager-plugin/server';
import { DashboardAttributes, SavedDashboardPanel } from '../../common/content_management';
import { DashboardSavedObjectAttributes, SavedDashboardPanel } from '../dashboard_saved_object';
import { TASK_ID } from './dashboard_telemetry_collection_task';
import { emptyState, type LatestTaskStateSchema } from './task_state';
@ -95,7 +95,7 @@ export const collectPanelsByType = (
export const controlsCollectorFactory =
(embeddableService: EmbeddablePersistableStateService) =>
(attributes: DashboardAttributes, collectorData: DashboardCollectorData) => {
(attributes: DashboardSavedObjectAttributes, collectorData: DashboardCollectorData) => {
if (!isEmpty(attributes.controlGroupInput)) {
collectorData.controls = embeddableService.telemetry(
{

View file

@ -23,9 +23,15 @@ import {
collectPanelsByType,
getEmptyDashboardData,
} from './dashboard_telemetry';
import { injectReferences } from '../../common';
import { DashboardAttributesAndReferences } from '../../common/types';
import { DashboardAttributes, SavedDashboardPanel } from '../../common/content_management';
import type {
DashboardSavedObjectAttributes,
SavedDashboardPanel,
} from '../dashboard_saved_object';
interface DashboardSavedObjectAttributesAndReferences {
attributes: DashboardSavedObjectAttributes;
references: SavedObjectReference[];
}
// This task is responsible for running daily and aggregating all the Dashboard telemerty data
// into a single document. This is an effort to make sure the load of fetching/parsing all of the
@ -88,17 +94,18 @@ export function dashboardTaskRunner(logger: Logger, core: CoreSetup, embeddable:
async run() {
let dashboardData = getEmptyDashboardData();
const controlsCollector = controlsCollectorFactory(embeddable);
const processDashboards = (dashboards: DashboardAttributesAndReferences[]) => {
const processDashboards = (dashboards: DashboardSavedObjectAttributesAndReferences[]) => {
for (const dashboard of dashboards) {
const attributes = injectReferences(dashboard, {
embeddablePersistableStateService: embeddable,
});
// TODO is this injecting references really necessary?
// const attributes = injectReferences(dashboard, {
// embeddablePersistableStateService: embeddable,
// });
dashboardData = controlsCollector(attributes, dashboardData);
dashboardData = controlsCollector(dashboard.attributes, dashboardData);
try {
const panels = JSON.parse(
attributes.panelsJSON as string
dashboard.attributes.panelsJSON as string
) as unknown as SavedDashboardPanel[];
collectPanelsByType(panels, dashboardData, embeddable);
@ -129,7 +136,7 @@ export function dashboardTaskRunner(logger: Logger, core: CoreSetup, embeddable:
const esClient = await getEsClient();
let result = await esClient.search<{
dashboard: DashboardAttributes;
dashboard: DashboardSavedObjectAttributes;
references: SavedObjectReference[];
}>(searchParams);
@ -144,8 +151,8 @@ export function dashboardTaskRunner(logger: Logger, core: CoreSetup, embeddable:
}
return undefined;
})
.filter<DashboardAttributesAndReferences>(
(s): s is DashboardAttributesAndReferences => s !== undefined
.filter<DashboardSavedObjectAttributesAndReferences>(
(s): s is DashboardSavedObjectAttributesAndReferences => s !== undefined
)
);
@ -163,8 +170,8 @@ export function dashboardTaskRunner(logger: Logger, core: CoreSetup, embeddable:
}
return undefined;
})
.filter<DashboardAttributesAndReferences>(
(s): s is DashboardAttributesAndReferences => s !== undefined
.filter<DashboardSavedObjectAttributesAndReferences>(
(s): s is DashboardSavedObjectAttributesAndReferences => s !== undefined
)
);
}

View file

@ -22,7 +22,7 @@ import { DynamicActionsSerializedState } from '@kbn/embeddable-enhanced-plugin/p
import { HasSerializedChildState, PresentationContainer } from '@kbn/presentation-containers';
import { LocatorPublic } from '@kbn/share-plugin/common';
import { DashboardLocatorParams, DASHBOARD_CONTAINER_TYPE } from '@kbn/dashboard-plugin/public';
import { DashboardAttributes } from '@kbn/dashboard-plugin/common';
import type { DashboardAttributes } from '@kbn/dashboard-plugin/server';
import { CONTENT_ID } from '../common';
import { Link, LinksAttributes, LinksLayoutType } from '../common/content_management';
@ -73,5 +73,5 @@ export type ResolvedLink = Link & {
export interface DashboardItem {
id: string;
attributes: DashboardAttributes;
attributes: Pick<DashboardAttributes, 'title' | 'description' | 'timeRestore'>;
}

View file

@ -0,0 +1,30 @@
/*
* 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 { FtrProviderContext } from '../../../ftr_provider_context';
export default function ({ getService, loadTestFile }: FtrProviderContext) {
const kibanaServer = getService('kibanaServer');
describe('dashboards - create', () => {
before(async () => {
await kibanaServer.importExport.load(
'test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json'
);
});
after(async () => {
await kibanaServer.savedObjects.cleanStandardList();
await kibanaServer.importExport.unload(
'test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json'
);
});
loadTestFile(require.resolve('./main'));
loadTestFile(require.resolve('./validation'));
});
}

View file

@ -0,0 +1,216 @@
/*
* 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 expect from '@kbn/expect';
import { PUBLIC_API_PATH } from '@kbn/dashboard-plugin/server';
import { DEFAULT_IGNORE_PARENT_SETTINGS } from '@kbn/controls-plugin/common';
import { FtrProviderContext } from '../../../ftr_provider_context';
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
describe('main', () => {
it('sets top level default values', async () => {
const title = `foo-${Date.now()}-${Math.random()}`;
const response = await supertest
.post(PUBLIC_API_PATH)
.set('kbn-xsrf', 'true')
.set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31')
.send({
attributes: {
title,
},
});
expect(response.status).to.be(200);
expect(response.body.item.attributes.kibanaSavedObjectMeta.searchSource).to.eql({});
expect(response.body.item.attributes.panels).to.eql([]);
expect(response.body.item.attributes.timeRestore).to.be(false);
expect(response.body.item.attributes.options).to.eql({
hidePanelTitles: false,
useMargins: true,
syncColors: true,
syncTooltips: true,
syncCursor: true,
});
});
it('sets panels default values', async () => {
const title = `foo-${Date.now()}-${Math.random()}`;
const response = await supertest
.post(PUBLIC_API_PATH)
.set('kbn-xsrf', 'true')
.set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31')
.send({
attributes: {
title,
panels: [
{
type: 'visualization',
gridData: {
x: 0,
y: 0,
w: 24,
h: 15,
},
panelConfig: {},
},
],
},
});
expect(response.status).to.be(200);
expect(response.body.item.attributes.panels).to.be.an('array');
// panel index is a random uuid when not provided
expect(response.body.item.attributes.panels[0].panelIndex).match(/^[0-9a-f-]{36}$/);
expect(response.body.item.attributes.panels[0].panelIndex).to.eql(
response.body.item.attributes.panels[0].gridData.i
);
});
it('sets controls default values', async () => {
const title = `foo-${Date.now()}-${Math.random()}`;
const response = await supertest
.post(PUBLIC_API_PATH)
.set('kbn-xsrf', 'true')
.set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31')
.send({
attributes: {
title,
controlGroupInput: {
controls: [
{
type: 'optionsListControl',
order: 0,
width: 'medium',
grow: true,
controlConfig: {
title: 'Origin City',
fieldName: 'OriginCityName',
dataViewId: 'd3d7af60-4c81-11e8-b3d7-01146121b73d',
selectedOptions: [],
enhancements: {},
},
},
],
},
},
});
expect(response.status).to.be(200);
// generates a random saved object id
expect(response.body.item.id).match(/^[0-9a-f-]{36}$/);
// saved object stores controls panels as an object, but the API should return as an array
expect(response.body.item.attributes.controlGroupInput.controls).to.be.an('array');
expect(response.body.item.attributes.controlGroupInput.ignoreParentSettings).to.eql(
DEFAULT_IGNORE_PARENT_SETTINGS
);
});
it('can create a dashboard with a specific id', async () => {
const title = `foo-${Date.now()}-${Math.random()}`;
const id = `bar-${Date.now()}-${Math.random()}`;
const response = await supertest
.post(`${PUBLIC_API_PATH}/${id}`)
.set('kbn-xsrf', 'true')
.set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31')
.send({
attributes: { title },
});
expect(response.status).to.be(200);
expect(response.body.item.id).to.be(id);
});
it('creates a dashboard with references', async () => {
const title = `foo-${Date.now()}-${Math.random()}`;
const response = await supertest
.post(PUBLIC_API_PATH)
.set('kbn-xsrf', 'true')
.set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31')
.send({
attributes: {
title,
panels: [
{
type: 'visualization',
gridData: {
x: 0,
y: 0,
w: 24,
h: 15,
i: 'bizz',
},
panelConfig: {},
panelIndex: 'bizz',
panelRefName: 'panel_bizz',
},
],
},
references: [
{
name: 'bizz:panel_bizz',
type: 'visualization',
id: 'my-saved-object',
},
],
});
expect(response.status).to.be(200);
expect(response.body.item.attributes.panels).to.be.an('array');
});
// TODO Maybe move this test to x-pack/test/api_integration/dashboards
it('can create a dashboard in a defined space', async () => {
const title = `foo-${Date.now()}-${Math.random()}`;
const spaceId = 'space-1';
const response = await supertest
.post(`/s/${spaceId}${PUBLIC_API_PATH}`)
.set('kbn-xsrf', 'true')
.set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31')
.send({
attributes: {
title,
},
spaces: [spaceId],
});
expect(response.status).to.be(200);
expect(response.body.item.namespaces).to.eql([spaceId]);
});
it('return error if provided id already exists', async () => {
const title = `foo-${Date.now()}-${Math.random()}`;
// id is a saved object loaded by the kbn_archiver
const id = 'be3733a0-9efe-11e7-acb3-3dab96693fab';
const response = await supertest
.post(`${PUBLIC_API_PATH}/${id}`)
.set('kbn-xsrf', 'true')
.set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31')
.send({
attributes: {
title,
},
});
expect(response.status).to.be(409);
expect(response.body.message).to.be(
'A dashboard with saved object ID be3733a0-9efe-11e7-acb3-3dab96693fab already exists.'
);
});
});
}

View file

@ -0,0 +1,63 @@
/*
* 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 expect from '@kbn/expect';
import { PUBLIC_API_PATH } from '@kbn/dashboard-plugin/server';
import { FtrProviderContext } from '../../../ftr_provider_context';
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
describe('validation', () => {
it('returns error when attributes object is not provided', async () => {
const response = await supertest
.post(PUBLIC_API_PATH)
.set('kbn-xsrf', 'true')
.set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31')
.send({});
expect(response.status).to.be(400);
expect(response.body.statusCode).to.be(400);
expect(response.body.message).to.be(
'[request body.attributes.title]: expected value of type [string] but got [undefined]'
);
});
it('returns error when title is not provided', async () => {
const response = await supertest
.post(PUBLIC_API_PATH)
.set('kbn-xsrf', 'true')
.set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31')
.send({
attributes: {},
});
expect(response.status).to.be(400);
expect(response.body.statusCode).to.be(400);
expect(response.body.message).to.be(
'[request body.attributes.title]: expected value of type [string] but got [undefined]'
);
});
it('returns error if panels is not an array', async () => {
const response = await supertest
.post(PUBLIC_API_PATH)
.set('kbn-xsrf', 'true')
.set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31')
.send({
attributes: {
title: 'foo',
panels: {},
},
});
expect(response.status).to.be(400);
expect(response.body.statusCode).to.be(400);
expect(response.body.message).to.be(
'[request body.attributes.panels]: expected value of type [array] but got [Object]'
);
});
});
}

View file

@ -0,0 +1,29 @@
/*
* 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 { FtrProviderContext } from '../../../ftr_provider_context';
export default function ({ getService, loadTestFile }: FtrProviderContext) {
const kibanaServer = getService('kibanaServer');
describe('dashboards - delete', () => {
before(async () => {
await kibanaServer.importExport.load(
'test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json'
);
});
after(async () => {
await kibanaServer.savedObjects.cleanStandardList();
await kibanaServer.importExport.unload(
'test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json'
);
});
loadTestFile(require.resolve('./main'));
});
}

View file

@ -0,0 +1,42 @@
/*
* 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 expect from '@kbn/expect';
import { PUBLIC_API_PATH } from '@kbn/dashboard-plugin/server';
import { FtrProviderContext } from '../../../ftr_provider_context';
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
describe('main', () => {
it('should return 404 for a non-existent dashboard', async () => {
const response = await supertest
.delete(`${PUBLIC_API_PATH}/non-existent-dashboard`)
.set('kbn-xsrf', 'true')
.set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31')
.send();
expect(response.status).to.be(404);
expect(response.body).to.eql({
statusCode: 404,
error: 'Not Found',
message: 'A dashboard with saved object ID non-existent-dashboard was not found.',
});
});
it('should return 200 if the dashboard is deleted', async () => {
const response = await supertest
.delete(`${PUBLIC_API_PATH}/be3733a0-9efe-11e7-acb3-3dab96693fab`)
.set('kbn-xsrf', 'true')
.set('ELASTIC_HTTP_VERSION_HEADER', '2023-10-31')
.send();
expect(response.status).to.be(200);
});
});
}

View file

@ -0,0 +1,29 @@
/*
* 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 { FtrProviderContext } from '../../../ftr_provider_context';
export default function ({ getService, loadTestFile }: FtrProviderContext) {
const kibanaServer = getService('kibanaServer');
describe('dashboards - get', () => {
before(async () => {
await kibanaServer.importExport.load(
'test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json'
);
});
after(async () => {
await kibanaServer.savedObjects.cleanStandardList();
await kibanaServer.importExport.unload(
'test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json'
);
});
loadTestFile(require.resolve('./main'));
});
}

Some files were not shown because too many files have changed in this diff Show more