Eui dashboard listing (#16967)

* convert dashboard listing page to react and EUI

* add jest test for DashboardListing component

* add data-test-subj attributes

* clean up jest test

* hideWriteControls and call to action when no dashboards exist

* pass initial filter to dashboard listing, get functional tests to work

* fix dashboard queries functional tests

* upgraded to EUI 0.0.29 to get defaultFocusedButton fix

* move dashboardListing directive to index

* spacing in if statement

* switch to EuiBasicTable

* pagination

* add sorting

* fix jest test

* handle out of order fetchs

* remove info.gif

* re-instate search functional test

* replace EuiSearchBar with EuiFieldSearch

* fix functional tests

* update snapshot - when code rebased - new EUI version add another prop

* add Edit link to actions column
This commit is contained in:
Nathan Reese 2018-04-26 08:16:56 -06:00 committed by GitHub
parent b95b3f4fc1
commit bed97a27b0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 1356 additions and 440 deletions

View file

@ -6,14 +6,25 @@ import uiRoutes from 'ui/routes';
import { toastNotifications } from 'ui/notify';
import dashboardTemplate from './dashboard_app.html';
import dashboardListingTemplate from './listing/dashboard_listing.html';
import dashboardListingTemplate from './listing/dashboard_listing_ng_wrapper.html';
import { DashboardListingController } from './listing/dashboard_listing';
import { DashboardConstants, createDashboardEditUrl } from './dashboard_constants';
import { SavedObjectNotFound } from 'ui/errors';
import { FeatureCatalogueRegistryProvider, FeatureCatalogueCategory } from 'ui/registry/feature_catalogue';
import { SavedObjectsClientProvider } from 'ui/saved_objects';
import { recentlyAccessed } from 'ui/persisted_log';
import { SavedObjectRegistryProvider } from 'ui/saved_objects/saved_object_registry';
import { DashboardListing, EMPTY_FILTER } from './listing/dashboard_listing';
import { uiModules } from 'ui/modules';
const app = uiModules.get('app/dashboard', [
'ngRoute',
'react',
]);
app.directive('dashboardListing', function (reactDirective) {
return reactDirective(DashboardListing);
});
uiRoutes
.defaults(/dashboard/, {
@ -21,8 +32,20 @@ uiRoutes
})
.when(DashboardConstants.LANDING_PAGE_PATH, {
template: dashboardListingTemplate,
controller: DashboardListingController,
controllerAs: 'listingController',
controller($injector, $location, $scope, Private, config) {
const services = Private(SavedObjectRegistryProvider).byLoaderPropertiesName;
const dashboardConfig = $injector.get('dashboardConfig');
$scope.listingLimit = config.get('savedObjects:listingLimit');
$scope.find = (search) => {
return services.dashboards.find(search, $scope.listingLimit);
};
$scope.delete = (ids) => {
return services.dashboards.delete(ids);
};
$scope.hideWriteControls = dashboardConfig.getHideWriteControls();
$scope.initialFilter = ($location.search()).filter || EMPTY_FILTER;
},
resolve: {
dash: function ($route, Private, courier, kbnUrl) {
const savedObjectsClient = Private(SavedObjectsClientProvider);

View file

@ -0,0 +1,788 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`after fetch hideWriteControls 1`] = `
<EuiPage
data-test-subj="dashboardLandingPage"
>
<EuiFlexGroup
alignItems="flexEnd"
component="div"
data-test-subj="top-nav"
gutterSize="l"
justifyContent="spaceBetween"
responsive={true}
wrap={false}
>
<EuiFlexItem
component="div"
grow={false}
>
<EuiTitle
size="l"
>
<h1>
Dashboard
</h1>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer
size="m"
/>
<EuiFlexGroup
alignItems="stretch"
component="div"
gutterSize="l"
justifyContent="flexStart"
responsive={true}
wrap={false}
>
<EuiFlexItem
component="div"
grow={true}
>
<EuiFieldSearch
data-test-subj="searchFilter"
fullWidth={true}
incremental={false}
isLoading={false}
onChange={[Function]}
placeholder="Search..."
value=""
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiBasicTable
columns={
Array [
Object {
"field": "title",
"name": "Title",
"render": [Function],
"sortable": true,
},
Object {
"dataType": "string",
"field": "description",
"name": "Description",
"sortable": true,
},
]
}
itemIdToExpandedRowMap={Object {}}
items={Array []}
loading={false}
noItemsMessage={
<EuiText>
<h2>
<EuiTextColor
color="subdued"
>
Looks like you don't have any dashboards.
</EuiTextColor>
</h2>
</EuiText>
}
onChange={[Function]}
pagination={
Object {
"pageIndex": 0,
"pageSize": 20,
"pageSizeOptions": Array [
10,
20,
50,
],
"totalItemCount": 0,
}
}
selection={
Object {
"itemId": "id",
"onSelectionChange": [Function],
}
}
sorting={Object {}}
/>
</EuiPage>
`;
exports[`after fetch renders call to action when no dashboards exist 1`] = `
<EuiPage
data-test-subj="dashboardLandingPage"
>
<EuiFlexGroup
alignItems="flexEnd"
component="div"
data-test-subj="top-nav"
gutterSize="l"
justifyContent="spaceBetween"
responsive={true}
wrap={false}
>
<EuiFlexItem
component="div"
grow={false}
>
<EuiTitle
size="l"
>
<h1>
Dashboard
</h1>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem
component="div"
grow={false}
>
<EuiButton
color="primary"
data-test-subj="newDashboardLink"
fill={false}
href="#/dashboard"
iconSide="left"
type="button"
>
Create new dashboard
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer
size="m"
/>
<EuiFlexGroup
alignItems="stretch"
component="div"
gutterSize="l"
justifyContent="flexStart"
responsive={true}
wrap={false}
>
<EuiFlexItem
component="div"
grow={true}
>
<EuiFieldSearch
data-test-subj="searchFilter"
fullWidth={true}
incremental={false}
isLoading={false}
onChange={[Function]}
placeholder="Search..."
value=""
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiBasicTable
columns={
Array [
Object {
"field": "title",
"name": "Title",
"render": [Function],
"sortable": true,
},
Object {
"dataType": "string",
"field": "description",
"name": "Description",
"sortable": true,
},
Object {
"actions": Array [
Object {
"render": [Function],
},
],
"name": "Actions",
},
]
}
itemIdToExpandedRowMap={Object {}}
items={Array []}
loading={false}
noItemsMessage={
<UNDEFINED>
<EuiText>
<h2>
<EuiTextColor
color="subdued"
>
Looks like you don't have any dashboards. Let's create some!
</EuiTextColor>
</h2>
</EuiText>
<EuiButton
color="primary"
data-test-subj="createDashboardPromptButton"
fill={true}
href="#/dashboard"
iconSide="left"
iconType="plusInCircle"
type="button"
>
Create new dashboard
</EuiButton>
</UNDEFINED>
}
onChange={[Function]}
pagination={
Object {
"pageIndex": 0,
"pageSize": 20,
"pageSizeOptions": Array [
10,
20,
50,
],
"totalItemCount": 0,
}
}
selection={
Object {
"itemId": "id",
"onSelectionChange": [Function],
}
}
sorting={Object {}}
/>
</EuiPage>
`;
exports[`after fetch renders table rows 1`] = `
<EuiPage
data-test-subj="dashboardLandingPage"
>
<EuiFlexGroup
alignItems="flexEnd"
component="div"
data-test-subj="top-nav"
gutterSize="l"
justifyContent="spaceBetween"
responsive={true}
wrap={false}
>
<EuiFlexItem
component="div"
grow={false}
>
<EuiTitle
size="l"
>
<h1>
Dashboard
</h1>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem
component="div"
grow={false}
>
<EuiButton
color="primary"
data-test-subj="newDashboardLink"
fill={false}
href="#/dashboard"
iconSide="left"
type="button"
>
Create new dashboard
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer
size="m"
/>
<EuiFlexGroup
alignItems="stretch"
component="div"
gutterSize="l"
justifyContent="flexStart"
responsive={true}
wrap={false}
>
<EuiFlexItem
component="div"
grow={true}
>
<EuiFieldSearch
data-test-subj="searchFilter"
fullWidth={true}
incremental={false}
isLoading={false}
onChange={[Function]}
placeholder="Search..."
value=""
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiBasicTable
columns={
Array [
Object {
"field": "title",
"name": "Title",
"render": [Function],
"sortable": true,
},
Object {
"dataType": "string",
"field": "description",
"name": "Description",
"sortable": true,
},
Object {
"actions": Array [
Object {
"render": [Function],
},
],
"name": "Actions",
},
]
}
itemIdToExpandedRowMap={Object {}}
items={
Array [
Object {
"description": "dashboard0 desc",
"id": "dashboard0",
"title": "dashboard0 title",
},
Object {
"description": "dashboard1 desc",
"id": "dashboard1",
"title": "dashboard1 title",
},
]
}
loading={false}
noItemsMessage="No dashboards matched your search."
onChange={[Function]}
pagination={
Object {
"pageIndex": 0,
"pageSize": 20,
"pageSizeOptions": Array [
10,
20,
50,
],
"totalItemCount": 2,
}
}
selection={
Object {
"itemId": "id",
"onSelectionChange": [Function],
}
}
sorting={Object {}}
/>
</EuiPage>
`;
exports[`after fetch renders warning when listingLimit is exceeded 1`] = `
<EuiPage
data-test-subj="dashboardLandingPage"
>
<EuiFlexGroup
alignItems="flexEnd"
component="div"
data-test-subj="top-nav"
gutterSize="l"
justifyContent="spaceBetween"
responsive={true}
wrap={false}
>
<EuiFlexItem
component="div"
grow={false}
>
<EuiTitle
size="l"
>
<h1>
Dashboard
</h1>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem
component="div"
grow={false}
>
<EuiButton
color="primary"
data-test-subj="newDashboardLink"
fill={false}
href="#/dashboard"
iconSide="left"
type="button"
>
Create new dashboard
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer
size="m"
/>
<React.Fragment>
<EuiCallOut
color="warning"
iconType="help"
size="m"
title="Listing limit exceeded"
>
<p>
You have
2
dashboards, but your
<strong>
listingLimit
</strong>
setting prevents the table below from displaying more than
1
. You can change this setting under
<EuiLink
color="primary"
href="#/management/kibana/settings"
type="button"
>
Advanced Settings
</EuiLink>
.
</p>
</EuiCallOut>
<EuiSpacer
size="m"
/>
</React.Fragment>
<EuiFlexGroup
alignItems="stretch"
component="div"
gutterSize="l"
justifyContent="flexStart"
responsive={true}
wrap={false}
>
<EuiFlexItem
component="div"
grow={true}
>
<EuiFieldSearch
data-test-subj="searchFilter"
fullWidth={true}
incremental={false}
isLoading={false}
onChange={[Function]}
placeholder="Search..."
value=""
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiBasicTable
columns={
Array [
Object {
"field": "title",
"name": "Title",
"render": [Function],
"sortable": true,
},
Object {
"dataType": "string",
"field": "description",
"name": "Description",
"sortable": true,
},
Object {
"actions": Array [
Object {
"render": [Function],
},
],
"name": "Actions",
},
]
}
itemIdToExpandedRowMap={Object {}}
items={
Array [
Object {
"description": "dashboard0 desc",
"id": "dashboard0",
"title": "dashboard0 title",
},
Object {
"description": "dashboard1 desc",
"id": "dashboard1",
"title": "dashboard1 title",
},
]
}
loading={false}
noItemsMessage="No dashboards matched your search."
onChange={[Function]}
pagination={
Object {
"pageIndex": 0,
"pageSize": 20,
"pageSizeOptions": Array [
10,
20,
50,
],
"totalItemCount": 2,
}
}
selection={
Object {
"itemId": "id",
"onSelectionChange": [Function],
}
}
sorting={Object {}}
/>
</EuiPage>
`;
exports[`initialFilter 1`] = `
<EuiPage
data-test-subj="dashboardLandingPage"
>
<EuiFlexGroup
alignItems="flexEnd"
component="div"
data-test-subj="top-nav"
gutterSize="l"
justifyContent="spaceBetween"
responsive={true}
wrap={false}
>
<EuiFlexItem
component="div"
grow={false}
>
<EuiTitle
size="l"
>
<h1>
Dashboard
</h1>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem
component="div"
grow={false}
>
<EuiButton
color="primary"
data-test-subj="newDashboardLink"
fill={false}
href="#/dashboard"
iconSide="left"
type="button"
>
Create new dashboard
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer
size="m"
/>
<EuiFlexGroup
alignItems="stretch"
component="div"
gutterSize="l"
justifyContent="flexStart"
responsive={true}
wrap={false}
>
<EuiFlexItem
component="div"
grow={true}
>
<EuiFieldSearch
data-test-subj="searchFilter"
fullWidth={true}
incremental={false}
isLoading={false}
onChange={[Function]}
placeholder="Search..."
value="my dashboard"
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiBasicTable
columns={
Array [
Object {
"field": "title",
"name": "Title",
"render": [Function],
"sortable": true,
},
Object {
"dataType": "string",
"field": "description",
"name": "Description",
"sortable": true,
},
Object {
"actions": Array [
Object {
"render": [Function],
},
],
"name": "Actions",
},
]
}
itemIdToExpandedRowMap={Object {}}
items={Array []}
loading={true}
noItemsMessage=""
onChange={[Function]}
pagination={
Object {
"pageIndex": 0,
"pageSize": 20,
"pageSizeOptions": Array [
10,
20,
50,
],
"totalItemCount": 0,
}
}
selection={
Object {
"itemId": "id",
"onSelectionChange": [Function],
}
}
sorting={Object {}}
/>
</EuiPage>
`;
exports[`renders table in loading state 1`] = `
<EuiPage
data-test-subj="dashboardLandingPage"
>
<EuiFlexGroup
alignItems="flexEnd"
component="div"
data-test-subj="top-nav"
gutterSize="l"
justifyContent="spaceBetween"
responsive={true}
wrap={false}
>
<EuiFlexItem
component="div"
grow={false}
>
<EuiTitle
size="l"
>
<h1>
Dashboard
</h1>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem
component="div"
grow={false}
>
<EuiButton
color="primary"
data-test-subj="newDashboardLink"
fill={false}
href="#/dashboard"
iconSide="left"
type="button"
>
Create new dashboard
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer
size="m"
/>
<EuiFlexGroup
alignItems="stretch"
component="div"
gutterSize="l"
justifyContent="flexStart"
responsive={true}
wrap={false}
>
<EuiFlexItem
component="div"
grow={true}
>
<EuiFieldSearch
data-test-subj="searchFilter"
fullWidth={true}
incremental={false}
isLoading={false}
onChange={[Function]}
placeholder="Search..."
value=""
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiBasicTable
columns={
Array [
Object {
"field": "title",
"name": "Title",
"render": [Function],
"sortable": true,
},
Object {
"dataType": "string",
"field": "description",
"name": "Description",
"sortable": true,
},
Object {
"actions": Array [
Object {
"render": [Function],
},
],
"name": "Actions",
},
]
}
itemIdToExpandedRowMap={Object {}}
items={Array []}
loading={true}
noItemsMessage=""
onChange={[Function]}
pagination={
Object {
"pageIndex": 0,
"pageSize": 20,
"pageSizeOptions": Array [
10,
20,
50,
],
"totalItemCount": 0,
}
}
selection={
Object {
"itemId": "id",
"onSelectionChange": [Function],
}
}
sorting={Object {}}
/>
</EuiPage>
`;

View file

@ -1,268 +0,0 @@
<!-- Local nav. -->
<kbn-top-nav name="dashboard">
<!-- Transcluded elements. -->
<div data-transclude-slots>
<!-- Title. -->
<div
data-transclude-slot="topLeftCorner"
class="kuiLocalTitle"
role="heading"
aria-level="1"
>
Dashboard
</div>
</div>
</kbn-top-nav>
<div
class="kuiViewContent kuiViewContent--constrainedWidth"
data-test-subj="dashboardLandingPage"
>
<div class="kuiViewContentItem kuiVerticalRhythm" ng-if="listingController.showLimitError">
<div class="kuiInfoPanel kuiInfoPanel--warning">
<div class="kuiInfoPanelBody">
<div class="kuiInfoPanelBody__message">
You have {{ listingController.totalItems }} dashboards, but your "listingLimit" setting prevents the table below from displaying more than {{ listingController.listingLimit }}. You can change this setting under <a kbn-href="#/management/kibana/settings" class="kuiLink">Advanced Settings</a>.
</div>
</div>
</div>
</div>
<!-- ControlledTable -->
<div class="kuiViewContentItem kuiControlledTable kuiVerticalRhythm">
<!-- ToolBar -->
<div class="kuiToolBar">
<div class="kuiToolBarSearch">
<div class="kuiToolBarSearchBox">
<div class="kuiToolBarSearchBox__icon kuiIcon fa-search"></div>
<input
class="kuiToolBarSearchBox__input"
type="text"
placeholder="Search..."
aria-label="Filter dashboards"
data-test-subj="searchFilter"
ng-model="listingController.filter"
>
</div>
</div>
<div class="kuiToolBarSection">
<!-- Bulk delete button -->
<button
class="kuiButton kuiButton--danger"
ng-click="listingController.deleteSelectedItems()"
aria-label="Delete selected dashboards"
ng-if="listingController.getSelectedItemsCount() > 0 && !listingController.hideWriteControls"
tooltip="Delete selected dashboards"
tooltip-append-to-body="true"
data-test-subj="deleteSelectedDashboards"
>
<span aria-hidden="true" class="kuiButton__icon kuiIcon fa-trash"></span>
</button>
<!-- Create dashboard button -->
<a
class="kuiButton kuiButton--primary"
href="{{listingController.getCreateDashboardHref()}}"
aria-label="Create new dashboard"
data-test-subj="newDashboardLink"
ng-if="listingController.getSelectedItemsCount() === 0 && !listingController.hideWriteControls"
tooltip="Create new dashboard"
tooltip-append-to-body="true"
>
<span aria-hidden="true" class="kuiButton__icon kuiIcon fa-plus"></span>
</a>
</div>
<div class="kuiToolBarSection">
<!-- Pagination -->
<tool-bar-pager-text
start-item="listingController.pager.startItem"
end-item="listingController.pager.endItem"
total-items="listingController.pager.totalItems"
></tool-bar-pager-text>
<tool-bar-pager-buttons
has-previous-page="listingController.pager.hasPreviousPage"
has-next-page="listingController.pager.hasNextPage"
on-page-next="listingController.onPageNext"
on-page-previous="listingController.onPagePrevious"
></tool-bar-pager-buttons>
</div>
</div>
<!-- TableInfo -->
<div
class="kuiPanel kuiPanel--centered kuiPanel--withToolBar"
ng-if="!listingController.items.length && listingController.filter"
>
<div class="kuiTableInfo">
No dashboards matched your search.
</div>
</div>
<!-- EmptyTablePrompt -->
<div
class="kuiPanel kuiPanel--centered kuiPanel--withToolBar"
ng-if="!listingController.isFetchingItems && !listingController.items.length && !listingController.filter"
>
<div class="kuiEmptyTablePrompt">
<div class="kuiEmptyTablePrompt__message">
Looks like you don&rsquo;t have any dashboards. <span ng-if="!listingController.hideWriteControls">Let&rsquo;s create some!</span>
</div>
<div class="kuiEmptyTablePrompt__actions" ng-if="!listingController.hideWriteControls">
<a
class="kuiButton kuiButton--primary kuiButton--iconText"
data-test-subj="createDashboardPromptButton"
href="{{listingController.getCreateDashboardHref()}}"
>
<span class="kuiButton__inner">
<span class="kuiButton__icon kuiIcon fa-plus"></span>
<span>Create a dashboard</span>
</span>
</a>
</div>
</div>
</div>
<!-- Table -->
<table class="kuiTable dashboardListingTable" ng-if="listingController.items.length">
<thead>
<tr>
<th
class="kuiTableHeaderCell kuiTableHeaderCell--checkBox"
ng-if="!listingController.hideWriteControls"
scope="col"
>
<div class="kuiTableHeaderCell__liner">
<input
type="checkbox"
class="kuiCheckBox"
ng-checked="listingController.areAllItemsChecked()"
ng-click="listingController.toggleAll()"
aria-label="{{listingController.areAllItemsChecked() ? 'Deselect all rows' : 'Select all rows'}}"
>
</div>
</th>
<th scope="col" class="kuiTableHeaderCell">
<button
class="kuiTableHeaderCellButton"
ng-class="{'kuiTableHeaderCellButton-isSorted': listingController.getSortedProperty().name == 'title'}"
ng-click="listingController.sortOn('title')"
aria-label="{{listingController.isAscending('title') ? 'Sort name descending' : 'Sort name ascending'}}"
>
<span class="kuiTableHeaderCell__liner">
Name
<span
class="kuiTableSortIcon kuiIcon"
ng-class="listingController.isAscending('title') ? 'fa-long-arrow-up' : 'fa-long-arrow-down'"
></span>
</span>
</button>
</th>
<th scope="col" class="kuiTableHeaderCell">
<button
class="kuiTableHeaderCellButton"
ng-class="{'kuiTableHeaderCellButton-isSorted': listingController.getSortedProperty().name == 'description'}"
ng-click="listingController.sortOn('description')"
aria-label="{{listingController.isAscending('description') ? 'Sort description descending' : 'Sort description ascending'}}"
>
<span class="kuiTableHeaderCell__liner">
Description
<span
class="kuiTableSortIcon kuiIcon"
ng-class="listingController.isAscending('description') ? 'fa-long-arrow-up' : 'fa-long-arrow-down'"
></span>
</span>
</button>
</th>
<th
ng-if="!listingController.hideWriteControls"
scope="col"
class="kuiTableHeaderCell actionBtnHeaderCell"
>
<div class="kuiTableHeaderCell__liner">Actions</div>
</th>
</tr>
</thead>
<tbody>
<tr
ng-repeat="item in listingController.pageOfItems track by item.id"
class="kuiTableRow"
data-test-subj="dashboardListingRow"
>
<td class="kuiTableRowCell kuiTableRowCell--checkBox" ng-if="!listingController.hideWriteControls">
<div class="kuiTableRowCell__liner">
<input
type="checkbox"
class="kuiCheckBox"
data-test-subj="dashboardListItemCheckbox"
ng-click="listingController.toggleItem(item)"
ng-checked="listingController.isItemChecked(item)"
aria-label="{{listingController.isItemChecked(item) ? 'Deselect row' : 'Select row'}}"
>
</div>
</td>
<td class="kuiTableRowCell">
<div class="kuiTableRowCell__liner">
<a
class="kuiLink"
data-test-subj="dashboardListingTitleLink-{{item.title.split(' ').join('-')}}"
ng-href="{{ listingController.getUrlForItem(item) }}"
>
{{ item.title }}
</a>
</div>
</td>
<td class="kuiTableRowCell kuiTableRowCell--wrap">
<div class="kuiTableRowCell__liner">
{{ item.description }}
</div>
</td>
<td
ng-if="!listingController.hideWriteControls"
class="kuiTableRowCell kuiTableRowCell--wrap"
>
<div class="kuiTableRowCell__liner">
<a
class="kuiMenuButton kuiMenuButton--basic"
data-test-subj="dashboardListingTitleEditLink-{{item.title.split(' ').join('-')}}"
ng-href="{{ listingController.getEditUrlForItem(item) }}"
>
Edit
</a>
</div>
</td>
</tr>
</tbody>
</table>
<!-- ToolBarFooter -->
<div class="kuiToolBarFooter">
<div class="kuiToolBarFooterSection">
<div class="kuiToolBarText" ng-hide="listingController.getSelectedItemsCount() === 0">
{{ listingController.getSelectedItemsCount() }} selected
</div>
</div>
<div class="kuiToolBarSection">
<!-- Pagination -->
<tool-bar-pager-text
start-item="listingController.pager.startItem"
end-item="listingController.pager.endItem"
total-items="listingController.pager.totalItems"
></tool-bar-pager-text>
<tool-bar-pager-buttons
has-previous-page="listingController.pager.hasPreviousPage"
has-next-page="listingController.pager.hasNextPage"
on-page-next="listingController.onPageNext"
on-page-previous="listingController.onPagePrevious"
></tool-bar-pager-buttons>
</div>
</div>
</div>
</div>

View file

@ -1,168 +1,410 @@
import { SavedObjectRegistryProvider } from 'ui/saved_objects/saved_object_registry';
import 'ui/pager_control';
import 'ui/pager';
import './dashboard_listing.less';
import React from 'react';
import PropTypes from 'prop-types';
import _ from 'lodash';
import { toastNotifications } from 'ui/notify';
import {
EuiTitle,
EuiFieldSearch,
EuiBasicTable,
EuiPage,
EuiLink,
EuiFlexGroup,
EuiFlexItem,
EuiButton,
EuiSpacer,
EuiOverlayMask,
EuiConfirmModal,
EuiCallOut,
EuiText,
EuiTextColor,
} from '@elastic/eui';
import { DashboardConstants, createDashboardEditUrl } from '../dashboard_constants';
import { SortableProperties } from '@elastic/eui';
import { ConfirmationButtonTypes } from 'ui/modals';
export function DashboardListingController($injector, $scope, $location) {
const $filter = $injector.get('$filter');
const confirmModal = $injector.get('confirmModal');
const Notifier = $injector.get('Notifier');
const pagerFactory = $injector.get('pagerFactory');
const Private = $injector.get('Private');
const timefilter = $injector.get('timefilter');
const config = $injector.get('config');
const dashboardConfig = $injector.get('dashboardConfig');
export const EMPTY_FILTER = '';
timefilter.disableAutoRefreshSelector();
timefilter.disableTimeRangeSelector();
// saved object client does not support sorting by title because title is only mapped as analyzed
// the legacy implementation got around this by pulling `listingLimit` items and doing client side sorting
// and not supporting server-side paging.
// This component does not try to tackle these problems (yet) and is just feature matching the legacy component
// TODO support server side sorting/paging once title and description are sortable on the server.
export class DashboardListing extends React.Component {
const limitTo = $filter('limitTo');
// TODO: Extract this into an external service.
const services = Private(SavedObjectRegistryProvider).byLoaderPropertiesName;
const dashboardService = services.dashboards;
const notify = new Notifier({ location: 'Dashboard' });
constructor(props) {
super(props);
let selectedItems = [];
const sortableProperties = new SortableProperties([
{
name: 'title',
getValue: item => item.title.toLowerCase(),
isAscending: true,
},
{
name: 'description',
getValue: item => item.description.toLowerCase(),
isAscending: true
}
],
'title');
const calculateItemsOnPage = () => {
this.items = sortableProperties.sortItems(this.items);
this.pager.setTotalItems(this.items.length);
this.pageOfItems = limitTo(this.items, this.pager.pageSize, this.pager.startIndex);
};
const fetchItems = () => {
this.isFetchingItems = true;
dashboardService.find(this.filter, config.get('savedObjects:listingLimit'))
.then(result => {
this.isFetchingItems = false;
this.items = result.hits;
this.totalItems = result.total;
this.showLimitError = result.total > config.get('savedObjects:listingLimit');
this.listingLimit = config.get('savedObjects:listingLimit');
calculateItemsOnPage();
});
};
const deselectAll = () => {
selectedItems = [];
};
const selectAll = () => {
selectedItems = this.pageOfItems.slice(0);
};
this.isFetchingItems = false;
this.items = [];
this.pageOfItems = [];
this.filter = ($location.search()).filter || '';
this.pager = pagerFactory.create(this.items.length, 20, 1);
this.hideWriteControls = dashboardConfig.getHideWriteControls();
$scope.$watch(() => this.filter, () => {
deselectAll();
fetchItems();
$location.search('filter', this.filter);
});
this.isAscending = (name) => sortableProperties.isAscendingByName(name);
this.getSortedProperty = () => sortableProperties.getSortedProperty();
this.sortOn = function sortOn(propertyName) {
sortableProperties.sortOn(propertyName);
deselectAll();
calculateItemsOnPage();
};
this.toggleAll = function toggleAll() {
if (this.areAllItemsChecked()) {
deselectAll();
} else {
selectAll();
}
};
this.toggleItem = function toggleItem(item) {
if (this.isItemChecked(item)) {
const index = selectedItems.indexOf(item);
selectedItems.splice(index, 1);
} else {
selectedItems.push(item);
}
};
this.isItemChecked = function isItemChecked(item) {
return selectedItems.indexOf(item) !== -1;
};
this.areAllItemsChecked = function areAllItemsChecked() {
return this.getSelectedItemsCount() === this.pageOfItems.length;
};
this.getSelectedItemsCount = function getSelectedItemsCount() {
return selectedItems.length;
};
this.deleteSelectedItems = function deleteSelectedItems() {
const doDelete = () => {
const selectedIds = selectedItems.map(item => item.id);
dashboardService.delete(selectedIds)
.then(fetchItems)
.then(() => {
deselectAll();
})
.catch(error => notify.error(error));
this.state = {
isFetchingItems: false,
showDeleteModal: false,
showLimitError: false,
filter: this.props.initialFilter,
dashboards: [],
selectedIds: [],
page: 0,
perPage: 20,
};
}
confirmModal(
`You can't recover deleted dashboards.`,
{
confirmButtonText: 'Delete',
onConfirm: doDelete,
defaultFocusedButton: ConfirmationButtonTypes.CANCEL,
title: 'Delete selected dashboards?'
componentWillMount() {
this._isMounted = true;
}
componentWillUnmount() {
this._isMounted = false;
this.debouncedFetch.cancel();
}
componentDidMount() {
this.fetchItems();
}
debouncedFetch = _.debounce(async (filter) => {
const response = await this.props.find(filter);
if (!this._isMounted) {
return;
}
// We need this check to handle the case where search results come back in a different
// order than they were sent out. Only load results for the most recent search.
if (filter === this.state.filter) {
this.setState({
isFetchingItems: false,
dashboards: response.hits,
totalDashboards: response.total,
showLimitError: response.total > this.props.listingLimit,
});
}
}, 300);
fetchItems = () => {
this.setState({
isFetchingItems: true,
}, this.debouncedFetch.bind(null, this.state.filter));
}
deleteSelectedItems = async () => {
try {
await this.props.delete(this.state.selectedIds);
} catch (error) {
toastNotifications.addDanger({
title: `Unable to delete dashboard(s)`,
text: `${error}`,
});
}
this.fetchItems();
this.setState({
selectedIds: []
});
this.closeDeleteModal();
}
closeDeleteModal = () => {
this.setState({ showDeleteModal: false });
};
this.onPageNext = () => {
deselectAll();
this.pager.nextPage();
calculateItemsOnPage();
openDeleteModal = () => {
this.setState({ showDeleteModal: true });
};
this.onPagePrevious = () => {
deselectAll();
this.pager.previousPage();
calculateItemsOnPage();
};
onTableChange = ({ page, sort = {} }) => {
const {
index: pageIndex,
size: pageSize,
} = page;
this.getUrlForItem = function getUrlForItem(item) {
return `#${createDashboardEditUrl(item.id)}`;
};
let {
field: sortField,
direction: sortDirection,
} = sort;
this.getEditUrlForItem = function getEditUrlForItem(item) {
return `#${createDashboardEditUrl(item.id)}?_a=(viewMode:edit)`;
};
// 3rd sorting state that is not captured by sort - native order (no sort)
// when switching from desc to asc for the same field - use native order
if (this.state.sortField === sortField
&& this.state.sortDirection === 'desc'
&& sortDirection === 'asc') {
sortField = null;
sortDirection = null;
}
this.getCreateDashboardHref = function getCreateDashboardHref() {
return `#${DashboardConstants.CREATE_NEW_DASHBOARD_URL}`;
};
this.setState({
page: pageIndex,
perPage: pageSize,
sortField,
sortDirection,
});
}
// server-side paging not supported - see component comment for details
getPageOfItems = () => {
// do not sort original list to preserve elasticsearch ranking order
const dashboardsCopy = this.state.dashboards.slice();
if (this.state.sortField) {
dashboardsCopy.sort((a, b) => {
const fieldA = _.get(a, this.state.sortField, '');
const fieldB = _.get(b, this.state.sortField, '');
let order = 1;
if (this.state.sortDirection === 'desc') {
order = -1;
}
return order * fieldA.toLowerCase().localeCompare(fieldB.toLowerCase());
});
}
// If begin is greater than the length of the sequence, an empty array is returned.
const startIndex = this.state.page * this.state.perPage;
// If end is greater than the length of the sequence, slice extracts through to the end of the sequence (arr.length).
const lastIndex = startIndex + this.state.perPage;
return dashboardsCopy.slice(startIndex, lastIndex);
}
renderConfirmDeleteModal() {
return (
<EuiOverlayMask>
<EuiConfirmModal
title="Delete selected dashboards?"
onCancel={this.closeDeleteModal}
onConfirm={this.deleteSelectedItems}
cancelButtonText="Cancel"
confirmButtonText="Delete"
defaultFocusedButton="cancel"
>
<p>{`You can't recover deleted dashboards.`}</p>
</EuiConfirmModal>
</EuiOverlayMask>
);
}
renderListingLimitWarning() {
if (this.state.showLimitError) {
return (
<React.Fragment>
<EuiCallOut
title="Listing limit exceeded"
color="warning"
iconType="help"
>
<p>
You have {this.state.totalDashboards} dashboards,
but your <strong>listingLimit</strong> setting prevents the table below from displaying more than {this.props.listingLimit}.
You can change this setting under <EuiLink href="#/management/kibana/settings">Advanced Settings</EuiLink>.
</p>
</EuiCallOut>
<EuiSpacer size="m" />
</React.Fragment>
);
}
}
renderNoItemsMessage() {
if (this.state.isFetchingItems) {
return '';
}
if (!this.state.isFetchingItems && this.state.dashboards.length === 0 && !this.state.filter) {
if (this.props.hideWriteControls) {
return (
<EuiText>
<h2>
<EuiTextColor color="subdued">
{`Looks like you don't have any dashboards.`}
</EuiTextColor>
</h2>
</EuiText>
);
}
return (
<React.Fragment>
<EuiText>
<h2>
<EuiTextColor color="subdued">
{`Looks like you don't have any dashboards. Let's create some!`}
</EuiTextColor>
</h2>
</EuiText>
<EuiButton
href={`#${DashboardConstants.CREATE_NEW_DASHBOARD_URL}`}
fill
iconType="plusInCircle"
data-test-subj="createDashboardPromptButton"
>
Create new dashboard
</EuiButton>
</React.Fragment>
);
}
return 'No dashboards matched your search.';
}
renderSearchBar() {
let deleteBtn;
if (this.state.selectedIds.length > 0) {
deleteBtn = (
<EuiFlexItem grow={false}>
<EuiButton
color="danger"
onClick={this.openDeleteModal}
data-test-subj="deleteSelectedDashboards"
key="delete"
>
Delete selected
</EuiButton>
</EuiFlexItem>
);
}
return (
<EuiFlexGroup>
{deleteBtn}
<EuiFlexItem grow={true}>
<EuiFieldSearch
placeholder="Search..."
fullWidth
value={this.state.filter}
onChange={(e) => {
this.setState({
filter: e.target.value
}, this.fetchItems);
}}
data-test-subj="searchFilter"
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}
renderTable() {
const tableColumns = [
{
field: 'title',
name: 'Title',
sortable: true,
render: (field, record) => (
<EuiLink
className="dashboardLink"
href={`#${createDashboardEditUrl(record.id)}`}
data-test-subj={`dashboardListingTitleLink-${record.title.split(' ').join('-')}`}
>
{field}
</EuiLink>
)
},
{
field: 'description',
name: 'Description',
dataType: 'string',
sortable: true,
}
];
if (!this.props.hideWriteControls) {
tableColumns.push({
name: 'Actions',
actions: [
{
render: (record) => {
return (
<EuiLink
href={`#${createDashboardEditUrl(record.id)}?_a=(viewMode:edit)`}
>
Edit
</EuiLink>
);
}
}
]
});
}
const pagination = {
pageIndex: this.state.page,
pageSize: this.state.perPage,
totalItemCount: this.state.dashboards.length,
pageSizeOptions: [10, 20, 50],
};
const selection = {
itemId: 'id',
onSelectionChange: (selection) => {
this.setState({
selectedIds: selection.map(item => { return item.id; })
});
}
};
const sorting = {};
if (this.state.sortField) {
sorting.sort = {
field: this.state.sortField,
direction: this.state.sortDirection,
};
}
const items = this.state.dashboards.length === 0 ? [] : this.getPageOfItems();
return (
<EuiBasicTable
items={items}
loading={this.state.isFetchingItems}
columns={tableColumns}
selection={selection}
noItemsMessage={this.renderNoItemsMessage()}
pagination={pagination}
sorting={sorting}
onChange={this.onTableChange}
/>
);
}
render() {
let createButton;
if (!this.props.hideWriteControls) {
createButton = (
<EuiFlexItem grow={false}>
<EuiButton
href={`#${DashboardConstants.CREATE_NEW_DASHBOARD_URL}`}
data-test-subj="newDashboardLink"
>
Create new dashboard
</EuiButton>
</EuiFlexItem>
);
}
return (
<EuiPage data-test-subj="dashboardLandingPage">
{this.state.showDeleteModal && this.renderConfirmDeleteModal()}
<EuiFlexGroup justifyContent="spaceBetween" alignItems="flexEnd" data-test-subj="top-nav">
<EuiFlexItem grow={false}>
<EuiTitle size="l">
<h1>
Dashboard
</h1>
</EuiTitle>
</EuiFlexItem>
{createButton}
</EuiFlexGroup>
<EuiSpacer size="m" />
{this.renderListingLimitWarning()}
{this.renderSearchBar()}
{this.renderTable()}
</EuiPage>
);
}
}
DashboardListing.propTypes = {
find: PropTypes.func.isRequired,
delete: PropTypes.func.isRequired,
listingLimit: PropTypes.number.isRequired,
hideWriteControls: PropTypes.bool.isRequired,
initialFilter: PropTypes.string,
};
DashboardListing.defaultProps = {
initialFilter: EMPTY_FILTER,
};

View file

@ -1,9 +0,0 @@
.dashboardListingTable {
.kuiTableHeaderCell {
max-width: none;
}
.actionBtnHeaderCell {
width: 80px;
}
}

View file

@ -0,0 +1,126 @@
jest.mock('ui/notify',
() => ({
toastNotifications: {
addWarning: () => {},
}
}), { virtual: true });
jest.mock('lodash',
() => ({
// mock debounce to fire immediately with no internal timer
debounce: function (func) {
function debounced(...args) {
return func.apply(this, args);
}
return debounced;
}
}), { virtual: true });
import React from 'react';
import { shallow } from 'enzyme';
import {
DashboardListing,
} from './dashboard_listing';
const find = (num) => {
const hits = [];
for (let i = 0; i < num; i++) {
hits.push({
id: `dashboard${i}`,
title: `dashboard${i} title`,
description: `dashboard${i} desc`
});
}
return Promise.resolve({
total: num,
hits: hits
});
};
test('renders table in loading state', () => {
const component = shallow(<DashboardListing
find={find.bind(null, 2)}
delete={() => {}}
listingLimit={1000}
hideWriteControls={false}
/>);
expect(component).toMatchSnapshot();
});
test('initialFilter', () => {
const component = shallow(<DashboardListing
find={find.bind(null, 2)}
delete={() => {}}
listingLimit={1000}
hideWriteControls={false}
initialFilter="my dashboard"
/>);
expect(component).toMatchSnapshot();
});
describe('after fetch', () => {
test('renders table rows', async () => {
const component = shallow(<DashboardListing
find={find.bind(null, 2)}
delete={() => {}}
listingLimit={1000}
hideWriteControls={false}
/>);
// Ensure all promises resolve
await new Promise(resolve => process.nextTick(resolve));
// Ensure the state changes are reflected
component.update();
expect(component).toMatchSnapshot();
});
test('renders call to action when no dashboards exist', async () => {
const component = shallow(<DashboardListing
find={find.bind(null, 0)}
delete={() => {}}
listingLimit={1}
hideWriteControls={false}
/>);
// Ensure all promises resolve
await new Promise(resolve => process.nextTick(resolve));
// Ensure the state changes are reflected
component.update();
expect(component).toMatchSnapshot();
});
test('hideWriteControls', async () => {
const component = shallow(<DashboardListing
find={find.bind(null, 0)}
delete={() => {}}
listingLimit={1}
hideWriteControls={true}
/>);
// Ensure all promises resolve
await new Promise(resolve => process.nextTick(resolve));
// Ensure the state changes are reflected
component.update();
expect(component).toMatchSnapshot();
});
test('renders warning when listingLimit is exceeded', async () => {
const component = shallow(<DashboardListing
find={find.bind(null, 2)}
delete={() => {}}
listingLimit={1}
hideWriteControls={false}
/>);
// Ensure all promises resolve
await new Promise(resolve => process.nextTick(resolve));
// Ensure the state changes are reflected
component.update();
expect(component).toMatchSnapshot();
});
});

View file

@ -0,0 +1,7 @@
<dashboard-listing
find="find"
delete="delete"
listing-limit="listingLimit"
hide-write-controls="hideWriteControls"
initial-filter="initialFilter"
/>

View file

@ -42,8 +42,8 @@ export default function ({ getService, getPageObjects }) {
describe('delete', async function () {
it('default confirm action is cancel', async function () {
await PageObjects.dashboard.searchForDashboardWithName('');
await PageObjects.dashboard.clickListItemCheckbox();
await PageObjects.dashboard.searchForDashboardWithName(dashboardName);
await PageObjects.dashboard.checkDashboardListingSelectAllCheckbox();
await PageObjects.dashboard.clickDeleteSelectedDashboards();
await PageObjects.common.pressEnterKey();
@ -56,7 +56,7 @@ export default function ({ getService, getPageObjects }) {
});
it('succeeds on confirmation press', async function () {
await PageObjects.dashboard.clickListItemCheckbox();
await PageObjects.dashboard.checkDashboardListingSelectAllCheckbox();
await PageObjects.dashboard.clickDeleteSelectedDashboards();
await PageObjects.common.clickConfirmOnModal();
@ -69,7 +69,7 @@ export default function ({ getService, getPageObjects }) {
describe('search', function () {
before(async () => {
await PageObjects.dashboard.clearSearchValue();
await PageObjects.dashboard.clickCreateDashboardPrompt();
await PageObjects.dashboard.clickNewDashboard();
await PageObjects.dashboard.saveDashboard('Two Words');
});

View file

@ -198,8 +198,13 @@ export function DashboardPageProvider({ getService, getPageObjects }) {
return await testSubjects.exists('createDashboardPromptButton');
}
async clickListItemCheckbox() {
await testSubjects.click('dashboardListItemCheckbox');
async checkDashboardListingSelectAllCheckbox() {
const element = await testSubjects.find('checkboxSelectAll');
const isSelected = await element.isSelected();
if (!isSelected) {
log.debug(`checking checkbox "checkboxSelectAll"`);
await testSubjects.click('checkboxSelectAll');
}
}
async clickDeleteSelectedDashboards() {
@ -366,6 +371,7 @@ export function DashboardPageProvider({ getService, getPageObjects }) {
await retry.try(async () => {
const searchFilter = await testSubjects.find('searchFilter');
await searchFilter.clearValue();
await PageObjects.common.pressEnterKey();
});
}
@ -385,13 +391,14 @@ export function DashboardPageProvider({ getService, getPageObjects }) {
await searchFilter.click();
// Note: this replacement of - to space is to preserve original logic but I'm not sure why or if it's needed.
await searchFilter.type(dashName.replace('-', ' '));
await PageObjects.common.pressEnterKey();
});
await PageObjects.header.waitUntilLoadingHasFinished();
}
async getCountOfDashboardsInListingTable() {
const dashboardTitles = await testSubjects.findAll('dashboardListingRow');
const dashboardTitles = await find.allByCssSelector('.dashboardLink');
return dashboardTitles.length;
}