[Spaces] UX improvements to spaces grid (#188261)

## Summary

This PR offers UX improvements to the Spaces Management listing page
which are part of epic:
https://github.com/elastic/kibana-team/issues/785

* Use a badge to denote the current space
* Update wording of the "features visible" column header
* Truncate Space description text
* Add an action to switch to the space identified by the table row.

In the Roles & Spaces UX Improvements project, our roll out plan is work
in https://github.com/elastic/kibana/pull/184697 and to pull small
mergeable changes a little at a time, to release the changes as separate
PRs.

### Screenshot

**Before:**
<img width="1513" alt="image"
src="https://github.com/user-attachments/assets/2b6017f6-2395-464b-a176-3e8fbf51a2a4">

**After:**
<img width="1511" alt="image"
src="https://github.com/user-attachments/assets/b550a186-7b32-4c52-a3fb-bf285452a597">

### Release Note

Added minor user experience improvements to Spaces Management in Stack
Management.

### Checklist

Delete any items that are not applicable to this PR.

- [X] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [X] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [X] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [X] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [X] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

---------

Co-authored-by: elena-shostak <165678770+elena-shostak@users.noreply.github.com>
This commit is contained in:
Tim Sullivan 2024-08-01 11:34:40 -07:00 committed by GitHub
parent 399d7db571
commit 4e0910a166
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 300 additions and 54 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Before After
Before After

View file

@ -58,6 +58,11 @@ featuresStart.getFeatures.mockResolvedValue([
}),
]);
const spacesGridCommonProps = {
serverBasePath: '',
maxSpaces: 1000,
};
describe('SpacesGridPage', () => {
const getUrlForApp = (appId: string) => appId;
const history = scopedHistoryMock.create();
@ -79,7 +84,7 @@ describe('SpacesGridPage', () => {
catalogue: {},
spaces: { manage: true },
}}
maxSpaces={1000}
{...spacesGridCommonProps}
/>
);
@ -138,8 +143,8 @@ describe('SpacesGridPage', () => {
catalogue: {},
spaces: { manage: true },
}}
maxSpaces={1000}
solutionNavExperiment={Promise.resolve(true)}
{...spacesGridCommonProps}
/>
);
@ -156,6 +161,103 @@ describe('SpacesGridPage', () => {
});
});
it('renders a "current" badge for the current space', async () => {
spacesManager.getActiveSpace.mockResolvedValue(spaces[2]);
const current = await spacesManager.getActiveSpace();
expect(current.id).toBe('custom-2');
const wrapper = mountWithIntl(
<SpacesGridPage
spacesManager={spacesManager as unknown as SpacesManager}
getFeatures={featuresStart.getFeatures}
notifications={notificationServiceMock.createStartContract()}
getUrlForApp={getUrlForApp}
history={history}
capabilities={{
navLinks: {},
management: {},
catalogue: {},
spaces: { manage: true },
}}
solutionNavExperiment={Promise.resolve(true)}
{...spacesGridCommonProps}
/>
);
// allow spacesManager to load spaces and lazy-load SpaceAvatar
await act(async () => {});
wrapper.update();
const activeRow = wrapper.find('[data-test-subj="spacesListTableRow-custom-2"]');
const nameCell = activeRow.find('[data-test-subj="spacesListTableRowNameCell"]');
const activeBadge = nameCell.find('EuiBadge');
expect(activeBadge.text()).toBe('current');
});
it('renders a non-clickable "switch" action for the current space', async () => {
spacesManager.getActiveSpace.mockResolvedValue(spaces[2]);
const current = await spacesManager.getActiveSpace();
expect(current.id).toBe('custom-2');
const wrapper = mountWithIntl(
<SpacesGridPage
spacesManager={spacesManager as unknown as SpacesManager}
getFeatures={featuresStart.getFeatures}
notifications={notificationServiceMock.createStartContract()}
getUrlForApp={getUrlForApp}
history={history}
capabilities={{
navLinks: {},
management: {},
catalogue: {},
spaces: { manage: true },
}}
solutionNavExperiment={Promise.resolve(true)}
{...spacesGridCommonProps}
/>
);
// allow spacesManager to load spaces and lazy-load SpaceAvatar
await act(async () => {});
wrapper.update();
const activeRow = wrapper.find('[data-test-subj="spacesListTableRow-custom-2"]');
const switchAction = activeRow.find('EuiButtonIcon[data-test-subj="Custom 2-switchSpace"]');
expect(switchAction.prop('isDisabled')).toBe(true);
});
it('renders a clickable "switch" action for the non-current space', async () => {
spacesManager.getActiveSpace.mockResolvedValue(spaces[2]);
const current = await spacesManager.getActiveSpace();
expect(current.id).toBe('custom-2');
const wrapper = mountWithIntl(
<SpacesGridPage
spacesManager={spacesManager as unknown as SpacesManager}
getFeatures={featuresStart.getFeatures}
notifications={notificationServiceMock.createStartContract()}
getUrlForApp={getUrlForApp}
history={history}
capabilities={{
navLinks: {},
management: {},
catalogue: {},
spaces: { manage: true },
}}
solutionNavExperiment={Promise.resolve(true)}
{...spacesGridCommonProps}
/>
);
// allow spacesManager to load spaces and lazy-load SpaceAvatar
await act(async () => {});
wrapper.update();
const nonActiveRow = wrapper.find('[data-test-subj="spacesListTableRow-default"]');
const switchAction = nonActiveRow.find('EuiButtonIcon[data-test-subj="Default-switchSpace"]');
expect(switchAction.prop('isDisabled')).toBe(false);
});
it('renders a create spaces button', async () => {
const httpStart = httpServiceMock.createStartContract();
httpStart.get.mockResolvedValue([]);
@ -173,7 +275,7 @@ describe('SpacesGridPage', () => {
catalogue: {},
spaces: { manage: true },
}}
maxSpaces={1000}
{...spacesGridCommonProps}
/>
);
@ -203,6 +305,7 @@ describe('SpacesGridPage', () => {
spaces: { manage: true },
}}
maxSpaces={1}
serverBasePath={spacesGridCommonProps.serverBasePath}
/>
);
@ -236,7 +339,7 @@ describe('SpacesGridPage', () => {
catalogue: {},
spaces: { manage: true },
}}
maxSpaces={1000}
{...spacesGridCommonProps}
/>
);
@ -271,7 +374,7 @@ describe('SpacesGridPage', () => {
catalogue: {},
spaces: { manage: true },
}}
maxSpaces={1000}
{...spacesGridCommonProps}
/>
);

View file

@ -5,11 +5,13 @@
* 2.0.
*/
import type { EuiBasicTableColumn } from '@elastic/eui';
import {
EuiBadge,
type EuiBasicTableColumn,
EuiButton,
EuiButtonIcon,
EuiCallOut,
EuiFlexGroup,
EuiFlexItem,
EuiInMemoryTable,
EuiLink,
EuiLoadingSpinner,
@ -31,9 +33,9 @@ import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { reactRouterNavigate } from '@kbn/kibana-react-plugin/public';
import type { Space } from '../../../common';
import { addSpaceIdToPath, type Space } from '../../../common';
import { isReservedSpace } from '../../../common';
import { DEFAULT_SPACE_ID } from '../../../common/constants';
import { DEFAULT_SPACE_ID, ENTER_SPACE_PATH } from '../../../common/constants';
import { getSpacesFeatureDescription } from '../../constants';
import { getSpaceAvatarComponent } from '../../space_avatar';
import { SpaceSolutionBadge } from '../../space_solution_badge';
@ -49,6 +51,7 @@ const LazySpaceAvatar = lazy(() =>
interface Props {
spacesManager: SpacesManager;
notifications: NotificationsStart;
serverBasePath: string;
getFeatures: FeaturesPluginStart['getFeatures'];
capabilities: Capabilities;
history: ScopedHistory;
@ -59,6 +62,7 @@ interface Props {
interface State {
spaces: Space[];
activeSpace: Space | null;
features: KibanaFeature[];
loading: boolean;
showConfirmDeleteModal: boolean;
@ -71,6 +75,7 @@ export class SpacesGridPage extends Component<Props, State> {
super(props);
this.state = {
spaces: [],
activeSpace: null,
features: [],
loading: true,
showConfirmDeleteModal: false,
@ -133,11 +138,15 @@ export class SpacesGridPage extends Component<Props, State> {
) : undefined}
<EuiInMemoryTable
itemId={'id'}
data-test-subj="spacesListTable"
items={this.state.spaces}
tableCaption={i18n.translate('xpack.spaces.management.spacesGridPage.tableCaption', {
defaultMessage: 'Kibana spaces',
})}
rowHeader="name"
rowProps={(item) => ({
'data-test-subj': `spacesListTableRow-${item.id}`,
})}
columns={this.getColumnConfig()}
pagination={true}
sorting={true}
@ -221,12 +230,18 @@ export class SpacesGridPage extends Component<Props, State> {
});
const getSpaces = spacesManager.getSpaces();
const getActiveSpace = spacesManager.getActiveSpace();
try {
const [spaces, features] = await Promise.all([getSpaces, getFeatures()]);
const [spaces, activeSpace, features] = await Promise.all([
getSpaces,
getActiveSpace,
getFeatures(),
]);
this.setState({
loading: false,
spaces,
activeSpace,
features,
});
} catch (error) {
@ -247,11 +262,13 @@ export class SpacesGridPage extends Component<Props, State> {
field: 'initials',
name: '',
width: '50px',
render: (value: string, record: Space) => {
render: (_value: string, rowRecord) => {
return (
<Suspense fallback={<EuiLoadingSpinner />}>
<EuiLink {...reactRouterNavigate(this.props.history, this.getEditSpacePath(record))}>
<LazySpaceAvatar space={record} size="s" />
<EuiLink
{...reactRouterNavigate(this.props.history, this.getEditSpacePath(rowRecord))}
>
<LazySpaceAvatar space={rowRecord} size="s" />
</EuiLink>
</Suspense>
);
@ -263,11 +280,28 @@ export class SpacesGridPage extends Component<Props, State> {
defaultMessage: 'Space',
}),
sortable: true,
render: (value: string, record: Space) => (
<EuiLink {...reactRouterNavigate(this.props.history, this.getEditSpacePath(record))}>
{value}
</EuiLink>
render: (value: string, rowRecord: Space) => (
<EuiFlexGroup responsive={false} alignItems="center" gutterSize="m">
<EuiFlexItem grow={false}>
<EuiLink
{...reactRouterNavigate(this.props.history, this.getEditSpacePath(rowRecord))}
data-test-subj={`${rowRecord.id}-hyperlink`}
>
{value}
</EuiLink>
</EuiFlexItem>
{this.state.activeSpace?.name === rowRecord.name && (
<EuiFlexItem grow={false}>
<EuiBadge color="primary" data-test-subj={`spacesListCurrentBadge-${rowRecord.id}`}>
{i18n.translate('xpack.spaces.management.spacesGridPage.currentSpaceMarkerText', {
defaultMessage: 'current',
})}
</EuiBadge>
</EuiFlexItem>
)}
</EuiFlexGroup>
),
'data-test-subj': 'spacesListTableRowNameCell',
},
{
field: 'description',
@ -275,17 +309,19 @@ export class SpacesGridPage extends Component<Props, State> {
defaultMessage: 'Description',
}),
sortable: true,
truncateText: true,
width: '30%',
},
{
field: 'disabledFeatures',
name: i18n.translate('xpack.spaces.management.spacesGridPage.featuresColumnName', {
defaultMessage: 'Features',
defaultMessage: 'Features visible',
}),
sortable: (space: Space) => {
return getEnabledFeatures(this.state.features, space).length;
},
render: (disabledFeatures: string[], record: Space) => {
const enabledFeatureCount = getEnabledFeatures(this.state.features, record).length;
render: (_disabledFeatures: string[], rowRecord: Space) => {
const enabledFeatureCount = getEnabledFeatures(this.state.features, rowRecord).length;
if (enabledFeatureCount === this.state.features.length) {
return (
<FormattedMessage
@ -307,7 +343,7 @@ export class SpacesGridPage extends Component<Props, State> {
return (
<FormattedMessage
id="xpack.spaces.management.spacesGridPage.someFeaturesEnabled"
defaultMessage="{enabledFeatureCount} / {totalFeatureCount} features visible"
defaultMessage="{enabledFeatureCount} / {totalFeatureCount}"
values={{
enabledFeatureCount,
totalFeatureCount: this.state.features.length,
@ -350,39 +386,80 @@ export class SpacesGridPage extends Component<Props, State> {
}),
actions: [
{
render: (record: Space) => (
<EuiButtonIcon
data-test-subj={`${record.name}-editSpace`}
aria-label={i18n.translate(
'xpack.spaces.management.spacesGridPage.editSpaceActionName',
{
defaultMessage: `Edit {spaceName}.`,
values: { spaceName: record.name },
}
)}
color={'primary'}
iconType={'pencil'}
{...reactRouterNavigate(this.props.history, this.getEditSpacePath(record))}
/>
),
isPrimary: true,
name: i18n.translate('xpack.spaces.management.spacesGridPage.editSpaceActionName', {
defaultMessage: `Edit`,
}),
description: (rowRecord) =>
i18n.translate('xpack.spaces.management.spacesGridPage.editSpaceActionDescription', {
defaultMessage: `Edit {spaceName}.`,
values: { spaceName: rowRecord.name },
}),
type: 'icon',
icon: 'pencil',
color: 'primary',
href: (rowRecord) =>
reactRouterNavigate(this.props.history, this.getEditSpacePath(rowRecord)).href,
onClick: (rowRecord) =>
reactRouterNavigate(this.props.history, this.getEditSpacePath(rowRecord)).onClick,
'data-test-subj': (rowRecord) => `${rowRecord.name}-editSpace`,
},
{
available: (record: Space) => !isReservedSpace(record),
render: (record: Space) => (
<EuiButtonIcon
data-test-subj={`${record.name}-deleteSpace`}
aria-label={i18n.translate(
'xpack.spaces.management.spacesGridPage.deleteActionName',
{
defaultMessage: `Delete {spaceName}.`,
values: { spaceName: record.name },
}
)}
color={'danger'}
iconType={'trash'}
onClick={() => this.onDeleteSpaceClick(record)}
/>
),
isPrimary: true,
name: i18n.translate('xpack.spaces.management.spacesGridPage.switchSpaceActionName', {
defaultMessage: 'Switch',
}),
description: (rowRecord) =>
this.state.activeSpace?.name !== rowRecord.name
? i18n.translate(
'xpack.spaces.management.spacesGridPage.switchSpaceActionDescription',
{
defaultMessage: 'Switch to {spaceName}',
values: { spaceName: rowRecord.name },
}
)
: i18n.translate(
'xpack.spaces.management.spacesGridPage.switchSpaceActionDisabledDescription',
{
defaultMessage: '{spaceName} is the current space',
values: { spaceName: rowRecord.name },
}
),
type: 'icon',
icon: 'merge',
color: 'primary',
href: (rowRecord: Space) =>
addSpaceIdToPath(
this.props.serverBasePath,
rowRecord.id,
`${ENTER_SPACE_PATH}?next=/app/management/kibana/spaces/`
),
enabled: (rowRecord) => this.state.activeSpace?.name !== rowRecord.name,
'data-test-subj': (rowRecord) => `${rowRecord.name}-switchSpace`,
},
{
name: i18n.translate('xpack.spaces.management.spacesGridPage.deleteActionName', {
defaultMessage: `Delete`,
}),
description: (rowRecord) =>
isReservedSpace(rowRecord)
? i18n.translate(
'xpack.spaces.management.spacesGridPage.deleteActionDisabledDescription',
{
defaultMessage: `{spaceName} is reserved`,
values: { spaceName: rowRecord.name },
}
)
: i18n.translate('xpack.spaces.management.spacesGridPage.deleteActionDescription', {
defaultMessage: `Delete {spaceName}`,
values: { spaceName: rowRecord.name },
}),
type: 'icon',
icon: 'trash',
color: 'danger',
onClick: (rowRecord: Space) => this.onDeleteSpaceClick(rowRecord),
enabled: (rowRecord: Space) => !isReservedSpace(rowRecord),
'data-test-subj': (rowRecord) => `${rowRecord.name}-deleteSpace`,
},
],
});

View file

@ -105,7 +105,7 @@ describe('spacesManagementApp', () => {
css="You have tried to stringify object returned from \`css\` function. It isn't supposed to be used directly (e.g. as value of the \`className\` prop), but rather handed to emotion so it can handle it (e.g. as value of \`css\` prop)."
data-test-subj="kbnRedirectAppLink"
>
Spaces Page: {"capabilities":{"catalogue":{},"management":{},"navLinks":{}},"notifications":{"toasts":{}},"spacesManager":{"onActiveSpaceChange$":{}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/","search":"","hash":""}},"maxSpaces":1000,"solutionNavExperiment":{}}
Spaces Page: {"capabilities":{"catalogue":{},"management":{},"navLinks":{}},"notifications":{"toasts":{}},"spacesManager":{"onActiveSpaceChange$":{}},"serverBasePath":"","history":{"action":"PUSH","length":1,"location":{"pathname":"/","search":"","hash":""}},"maxSpaces":1000,"solutionNavExperiment":{}}
</div>
</div>
`);

View file

@ -59,7 +59,7 @@ export const spacesManagementApp = Object.freeze({
text: title,
href: `/`,
};
const { notifications, application, chrome } = coreStart;
const { notifications, application, chrome, http } = coreStart;
chrome.docTitle.change(title);
@ -71,6 +71,7 @@ export const spacesManagementApp = Object.freeze({
getFeatures={features.getFeatures}
notifications={notifications}
spacesManager={spacesManager}
serverBasePath={http.basePath.serverBasePath}
history={history}
getUrlForApp={application.getUrlForApp}
maxSpaces={config.maxSpaces}

View file

@ -14,5 +14,6 @@ export default function spacesApp({ loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./spaces_selection'));
loadTestFile(require.resolve('./enter_space'));
loadTestFile(require.resolve('./create_edit_space'));
loadTestFile(require.resolve('./spaces_grid'));
});
}

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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { FtrProviderContext } from '../../ftr_provider_context';
export default function enterSpaceFunctionalTests({
getService,
getPageObjects,
}: FtrProviderContext) {
const kibanaServer = getService('kibanaServer');
const PageObjects = getPageObjects(['security', 'spaceSelector', 'common']);
const spacesService = getService('spaces');
const testSubjects = getService('testSubjects');
const anotherSpace = {
id: 'space2',
name: 'space2',
disabledFeatures: [],
};
describe('Spaces grid', function () {
before(async () => {
await spacesService.create(anotherSpace);
await PageObjects.common.navigateToApp('spacesManagement');
await testSubjects.existOrFail('spaces-grid-page');
});
after(async () => {
await spacesService.delete('another-space');
await kibanaServer.savedObjects.cleanStandardList();
});
it('can switch to a space from the row in the grid', async () => {
// use the "current" badge confirm that Default is the current space
await testSubjects.existOrFail('spacesListCurrentBadge-default');
// click the switch button of "another space"
await PageObjects.spaceSelector.clickSwitchSpaceButton('space2');
// use the "current" badge confirm that "Another Space" is now the current space
await testSubjects.existOrFail('spacesListCurrentBadge-space2');
});
});
}

View file

@ -146,6 +146,9 @@ export default async function ({ readConfigFile }) {
snapshotRestore: {
pathname: '/app/management/data/snapshot_restore',
},
spacesManagement: {
pathname: '/app/management/kibana/spaces',
},
remoteClusters: {
pathname: '/app/management/data/remote_clusters',
},

View file

@ -212,7 +212,21 @@ export class SpaceSelectorPageObject extends FtrService {
await this.testSubjects.setValue('descriptionSpaceText', descriptionSpace);
}
async clickSwitchSpaceButton(spaceName: string) {
const collapsedButtonSelector = '[data-test-subj=euiCollapsedItemActionsButton]';
// open context menu
await this.find.clickByCssSelector(`#${spaceName}-actions ${collapsedButtonSelector}`);
// click context menu item
await this.find.clickByCssSelector(
`.euiContextMenuItem[data-test-subj="${spaceName}-switchSpace"]` // can not use testSubj: multiple elements exist with the same data-test-subj
);
}
async clickOnDeleteSpaceButton(spaceName: string) {
const collapsedButtonSelector = '[data-test-subj=euiCollapsedItemActionsButton]';
// open context menu
await this.find.clickByCssSelector(`#${spaceName}-actions ${collapsedButtonSelector}`);
// click context menu item
await this.testSubjects.click(`${spaceName}-deleteSpace`);
}