ServiceAccount : use InteractiveTable (#85203)

This commit is contained in:
Laura Fernández
2024-04-03 13:12:23 +02:00
committed by GitHub
parent beb15d938b
commit 4845b1e3c6
3 changed files with 275 additions and 119 deletions

View File

@@ -0,0 +1,249 @@
import React, { useMemo } from 'react';
import Skeleton from 'react-loading-skeleton';
import {
Avatar,
CellProps,
Column,
InteractiveTable,
Pagination,
Stack,
TextLink,
Button,
IconButton,
Icon,
} from '@grafana/ui';
import { UserRolePicker } from 'app/core/components/RolePicker/UserRolePicker';
import { contextSrv } from 'app/core/core';
import { AccessControlAction, OrgRole, Role, ServiceAccountDTO } from 'app/types';
import { OrgRolePicker } from '../admin/OrgRolePicker';
type Cell<T extends keyof ServiceAccountDTO = keyof ServiceAccountDTO> = CellProps<
ServiceAccountDTO,
ServiceAccountDTO[T]
>;
interface ServiceAccountTableProps {
services: ServiceAccountDTO[];
onRoleChange: (role: OrgRole, serviceAccount: ServiceAccountDTO) => void;
roleOptions: Role[];
onRemoveButtonClick: (serviceAccount: ServiceAccountDTO) => void;
onDisable: (serviceAccount: ServiceAccountDTO) => void;
onEnable: (serviceAccount: ServiceAccountDTO) => void;
onAddTokenClick: (serviceAccount: ServiceAccountDTO) => void;
showPaging?: boolean;
totalPages: number;
onChangePage: (page: number) => void;
currentPage: number;
isLoading: boolean;
}
export const ServiceAccountTable = ({
services,
onRoleChange,
roleOptions,
onRemoveButtonClick,
onDisable,
onEnable,
onAddTokenClick,
showPaging,
totalPages,
onChangePage,
currentPage,
isLoading,
}: ServiceAccountTableProps) => {
const columns: Array<Column<ServiceAccountDTO>> = useMemo(
() => [
{
id: 'avatarUrl',
header: '',
cell: ({ cell: { value }, row: { original } }: Cell<'role'>) => {
return getCellContent(value, original, isLoading, 'avatarUrl');
},
},
{
id: 'name',
header: 'Account',
cell: ({ cell: { value }, row: { original } }: Cell<'role'>) => {
return getCellContent(value, original, isLoading);
},
sortType: 'string',
},
{
id: 'id',
header: 'ID',
cell: ({ cell: { value }, row: { original } }: Cell<'role'>) => {
return getCellContent(value, original, isLoading, 'id');
},
},
{
id: 'role',
header: 'Roles',
cell: ({ cell: { value }, row: { original } }: Cell<'role'>) => {
return getRoleCell(value, original, isLoading, roleOptions, onRoleChange);
},
},
{
id: 'tokens',
header: 'Tokens',
cell: ({ cell: { value }, row: { original } }: Cell<'role'>) => {
return getCellContent(value, original, isLoading, 'tokens');
},
},
{
id: 'actions',
header: '',
cell: ({ row: { original } }: Cell) => {
return getActionsCell(original, isLoading, onAddTokenClick, onEnable, onDisable, onRemoveButtonClick);
},
},
],
[isLoading, onAddTokenClick, onDisable, onEnable, onRemoveButtonClick, onRoleChange, roleOptions]
);
return (
<Stack direction={'column'} gap={2}>
<InteractiveTable columns={columns} data={services} getRowId={(service) => String(service.id)} />
{showPaging && totalPages > 1 && (
<Stack justifyContent={'flex-end'}>
<Pagination numberOfPages={totalPages} currentPage={currentPage} onNavigate={onChangePage} />
</Stack>
)}
</Stack>
);
};
const getCellContent = (
value: string,
original: ServiceAccountDTO,
isLoading: boolean,
columnName?: Column<ServiceAccountDTO>['id']
) => {
if (isLoading) {
return columnName === 'avatarUrl' ? <Skeleton circle width={24} height={24} /> : <Skeleton width={100} />;
}
const href = `/org/serviceaccounts/${original.id}`;
const ariaLabel = `Edit service account's ${name} details`;
switch (columnName) {
case 'avatarUrl':
return (
<a aria-label={ariaLabel} href={href}>
<Avatar src={value} alt={'User avatar'} />
</a>
);
case 'id':
return (
<TextLink href={href} aria-label={ariaLabel} color="secondary">
{original.login}
</TextLink>
);
case 'tokens':
return (
<Stack alignItems="center">
<Icon name="key-skeleton-alt" />
<TextLink href={href} aria-label={ariaLabel} color="primary">
{value || 'No tokens'}
</TextLink>
</Stack>
);
default:
return (
<TextLink href={href} aria-label={ariaLabel} color="primary">
{value}
</TextLink>
);
}
};
const getRoleCell = (
value: OrgRole,
original: ServiceAccountDTO,
isLoading: boolean,
roleOptions: Role[],
onRoleChange: (role: OrgRole, serviceAccount: ServiceAccountDTO) => void
) => {
const displayRolePicker =
contextSrv.hasPermission(AccessControlAction.ActionRolesList) &&
contextSrv.hasPermission(AccessControlAction.ActionUserRolesList);
const canUpdateRole = contextSrv.hasPermissionInMetadata(AccessControlAction.ServiceAccountsWrite, original);
if (isLoading) {
return <Skeleton width={100} />;
} else {
return contextSrv.licensedAccessControlEnabled() ? (
displayRolePicker && (
<UserRolePicker
userId={original.id}
orgId={original.orgId}
basicRole={value}
roles={original.roles || []}
onBasicRoleChange={(newRole) => onRoleChange(newRole, original)}
roleOptions={roleOptions}
basicRoleDisabled={!canUpdateRole}
disabled={original.isExternal || original.isDisabled}
width={40}
/>
)
) : (
<OrgRolePicker
aria-label="Role"
value={value}
disabled={original.isExternal || !canUpdateRole || original.isDisabled}
onChange={(newRole) => onRoleChange(newRole, original)}
/>
);
}
};
const getActionsCell = (
original: ServiceAccountDTO,
isLoading: boolean,
onAddTokenClick: (serviceAccount: ServiceAccountDTO) => void,
onEnable: (serviceAccount: ServiceAccountDTO) => void,
onDisable: (serviceAccount: ServiceAccountDTO) => void,
onRemoveButtonClick: (serviceAccount: ServiceAccountDTO) => void
) => {
if (isLoading) {
return <Skeleton width={100} />;
} else {
return !original.isExternal ? (
<Stack alignItems="center" justifyContent="flex-end">
{contextSrv.hasPermission(AccessControlAction.ServiceAccountsWrite) && !original.tokens && (
<Button onClick={() => onAddTokenClick(original)} disabled={original.isDisabled}>
Add token
</Button>
)}
{contextSrv.hasPermissionInMetadata(AccessControlAction.ServiceAccountsWrite, original) &&
(original.isDisabled ? (
<Button variant="secondary" size="md" onClick={() => onEnable(original)}>
Enable
</Button>
) : (
<Button variant="secondary" size="md" onClick={() => onDisable(original)}>
Disable
</Button>
))}
{contextSrv.hasPermissionInMetadata(AccessControlAction.ServiceAccountsDelete, original) && (
<IconButton
name="trash-alt"
aria-label={`Delete service account ${original.name}`}
variant="secondary"
onClick={() => onRemoveButtonClick(original)}
/>
)}
</Stack>
) : (
<Stack alignItems="center" justifyContent="flex-end">
<IconButton
disabled={true}
name="lock"
size="md"
tooltip={`This is a managed service account and cannot be modified.`}
/>
</Stack>
);
}
};
ServiceAccountTable.displayName = 'ServiceAccountTable';

View File

@@ -77,7 +77,7 @@ const getDefaultServiceAccount: () => ServiceAccountDTO = () => ({
}); });
describe('ServiceAccountsListPage tests', () => { describe('ServiceAccountsListPage tests', () => {
it('Should display list of service accounts', () => { it('Should display list of service accounts', async () => {
setup({ setup({
serviceAccounts: [getDefaultServiceAccount()], serviceAccounts: [getDefaultServiceAccount()],
}); });
@@ -153,7 +153,7 @@ describe('ServiceAccountsListPage tests', () => {
}); });
const user = userEvent.setup(); const user = userEvent.setup();
await user.click(screen.getByLabelText(/Delete service account/)); await user.click(screen.getByLabelText(`Delete service account ${getDefaultServiceAccount().name}`));
await user.click(screen.getByRole('button', { name: 'Delete' })); await user.click(screen.getByRole('button', { name: 'Delete' }));
expect(deleteServiceAccountMock).toHaveBeenCalledWith(42); expect(deleteServiceAccountMock).toHaveBeenCalledWith(42);

View File

@@ -1,28 +1,17 @@
import { css, cx } from '@emotion/css';
import pluralize from 'pluralize'; import pluralize from 'pluralize';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { connect, ConnectedProps } from 'react-redux'; import { connect, ConnectedProps } from 'react-redux';
import { GrafanaTheme2, OrgRole } from '@grafana/data'; import { OrgRole } from '@grafana/data';
import { import { ConfirmModal, FilterInput, LinkButton, RadioButtonGroup, InlineField, EmptyState, Box } from '@grafana/ui';
ConfirmModal,
FilterInput,
LinkButton,
RadioButtonGroup,
useStyles2,
InlineField,
Pagination,
Stack,
EmptyState,
} from '@grafana/ui';
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import { Page } from 'app/core/components/Page/Page'; import { Page } from 'app/core/components/Page/Page';
import config from 'app/core/config'; import config from 'app/core/config';
import { contextSrv } from 'app/core/core'; import { contextSrv } from 'app/core/core';
import { StoreState, ServiceAccountDTO, AccessControlAction, ServiceAccountStateFilter } from 'app/types'; import { StoreState, ServiceAccountDTO, AccessControlAction, ServiceAccountStateFilter } from 'app/types';
import { ServiceAccountTable } from './ServiceAccountTable';
import { CreateTokenModal, ServiceAccountToken } from './components/CreateTokenModal'; import { CreateTokenModal, ServiceAccountToken } from './components/CreateTokenModal';
import ServiceAccountListItem from './components/ServiceAccountsListItem';
import { import {
changeQuery, changeQuery,
changePage, changePage,
@@ -84,7 +73,6 @@ export const ServiceAccountsListPageUnconnected = ({
changeStateFilter, changeStateFilter,
createServiceAccountToken, createServiceAccountToken,
}: Props): JSX.Element => { }: Props): JSX.Element => {
const styles = useStyles2(getStyles);
const [isAddModalOpen, setIsAddModalOpen] = useState(false); const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [isRemoveModalOpen, setIsRemoveModalOpen] = useState(false); const [isRemoveModalOpen, setIsRemoveModalOpen] = useState(false);
const [isDisableModalOpen, setIsDisableModalOpen] = useState(false); const [isDisableModalOpen, setIsDisableModalOpen] = useState(false);
@@ -213,12 +201,13 @@ export const ServiceAccountsListPageUnconnected = ({
width={50} width={50}
/> />
</InlineField> </InlineField>
<RadioButtonGroup <Box marginBottom={1}>
options={availableFilters} <RadioButtonGroup
onChange={onStateFilterChange} options={availableFilters}
value={serviceAccountStateFilter} onChange={onStateFilterChange}
className={styles.filter} value={serviceAccountStateFilter}
/> />
</Box>
</div> </div>
{!isLoading && !noServiceAccountsCreated && serviceAccounts.length === 0 && <EmptyState variant="not-found" />} {!isLoading && !noServiceAccountsCreated && serviceAccounts.length === 0 && <EmptyState variant="not-found" />}
{!isLoading && noServiceAccountsCreated && ( {!isLoading && noServiceAccountsCreated && (
@@ -238,48 +227,20 @@ export const ServiceAccountsListPageUnconnected = ({
)} )}
{(isLoading || serviceAccounts.length !== 0) && ( {(isLoading || serviceAccounts.length !== 0) && (
<> <ServiceAccountTable
<div className={cx(styles.table, 'admin-list-table')}> services={serviceAccounts}
<table className="filter-table filter-table--hover"> showPaging={true}
<thead> totalPages={totalPages}
<tr> onChangePage={changePage}
<th></th> currentPage={page}
<th>Account</th> onRoleChange={onRoleChange}
<th>ID</th> roleOptions={roleOptions}
<th>Roles</th> onRemoveButtonClick={onRemoveButtonClick}
<th>Tokens</th> onDisable={onDisableButtonClick}
<th style={{ width: '120px' }} /> onEnable={onEnable}
</tr> onAddTokenClick={onTokenAdd}
</thead> isLoading={isLoading}
<tbody> />
{isLoading ? (
<>
<ServiceAccountListItem.Skeleton />
<ServiceAccountListItem.Skeleton />
<ServiceAccountListItem.Skeleton />
</>
) : (
serviceAccounts.map((serviceAccount) => (
<ServiceAccountListItem
serviceAccount={serviceAccount}
key={serviceAccount.id}
roleOptions={roleOptions}
onRoleChange={onRoleChange}
onRemoveButtonClick={onRemoveButtonClick}
onDisable={onDisableButtonClick}
onEnable={onEnable}
onAddTokenClick={onTokenAdd}
/>
))
)}
</tbody>
</table>
<Stack justifyContent="flex-end">
<Pagination hideWhenSinglePage currentPage={page} numberOfPages={totalPages} onNavigate={changePage} />
</Stack>
</div>
</>
)} )}
{currentServiceAccount && ( {currentServiceAccount && (
<> <>
@@ -320,59 +281,5 @@ export const ServiceAccountsListPageUnconnected = ({
); );
}; };
export const getStyles = (theme: GrafanaTheme2) => {
return {
table: css({
marginTop: theme.spacing(3),
}),
filter: css({
margin: `0 ${theme.spacing(1)}`,
}),
row: css({
display: 'flex',
alignItems: 'center',
height: '100% !important',
a: {
padding: `${theme.spacing(0.5)} 0 !important`,
},
}),
unitTooltip: css({
display: 'flex',
flexDirection: 'column',
}),
unitItem: css({
cursor: 'pointer',
padding: theme.spacing(0.5, 0),
marginRight: theme.spacing(1),
}),
disabled: css({
color: theme.colors.text.disabled,
}),
link: css({
color: 'inherit',
cursor: 'pointer',
textDecoration: 'underline',
}),
pageHeader: css({
display: 'flex',
marginBottom: theme.spacing(2),
}),
apiKeyInfoLabel: css({
marginLeft: theme.spacing(1),
lineHeight: 2.2,
flexGrow: 1,
color: theme.colors.text.secondary,
span: {
padding: theme.spacing(0.5),
},
}),
filterDelimiter: css({
flexGrow: 1,
}),
};
};
const ServiceAccountsListPage = connector(ServiceAccountsListPageUnconnected); const ServiceAccountsListPage = connector(ServiceAccountsListPageUnconnected);
export default ServiceAccountsListPage; export default ServiceAccountsListPage;