Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Role editor: hierarchical validation and tabs #49546

Open
wants to merge 8 commits into
base: bl-nero/slidetabs
Choose a base branch
from
36 changes: 32 additions & 4 deletions web/packages/teleport/src/Roles/RoleEditor/EditorHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,29 +17,57 @@
*/

import React from 'react';
import { Flex, ButtonText, H2 } from 'design';
import { Flex, ButtonText, H2, Indicator, Box } from 'design';
import { HoverTooltip } from 'design/Tooltip';
import { Trash } from 'design/Icon';

import useTeleport from 'teleport/useTeleport';
import { Role } from 'teleport/services/resources';

import { EditorTab, EditorTabs } from './EditorTabs';

/** Renders a header button with role name and delete button. */
export const EditorHeader = ({
role = null,
onDelete,
selectedEditorTab,
onEditorTabChange,
isProcessing,
standardEditorId,
yamlEditorId,
}: {
onDelete?(): void;
role?: Role;
onDelete(): void;
selectedEditorTab: EditorTab;
onEditorTabChange(t: EditorTab): void;
isProcessing: boolean;
standardEditorId: string;
yamlEditorId: string;
}) => {
const ctx = useTeleport();
const isCreating = !role;

const hasDeleteAccess = ctx.storeUser.getRoleAccess().remove;

return (
<Flex alignItems="center" mb={3} justifyContent="space-between">
<H2>{isCreating ? 'Create a New Role' : role?.metadata.name}</H2>
<Flex alignItems="center" mb={3} gap={2}>
<Box flex="1">
<H2>
{isCreating
? 'Create a New Role'
: `Edit Role ${role?.metadata.name}`}
</H2>
</Box>
<Box flex="0 0 24px" lineHeight={0}>
{isProcessing && <Indicator size={24} color="text.muted" />}
</Box>
<EditorTabs
onTabChange={onEditorTabChange}
selectedEditorTab={selectedEditorTab}
disabled={isProcessing}
standardEditorId={standardEditorId}
yamlEditorId={yamlEditorId}
/>
{!isCreating && (
<HoverTooltip
position="bottom"
Expand Down
34 changes: 28 additions & 6 deletions web/packages/teleport/src/Roles/RoleEditor/EditorTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@

import React from 'react';
import { SlideTabs } from 'design/SlideTabs';
import * as Icon from 'design/Icon';

const tabs = ['Standard', 'YAML'];
export enum EditorTab {
Standard,
Yaml,
Expand All @@ -28,20 +28,42 @@ export enum EditorTab {
export const EditorTabs = ({
onTabChange,
selectedEditorTab,
isProcessing,
disabled,
standardEditorId,
yamlEditorId,
}: {
onTabChange(t: EditorTab): void;
selectedEditorTab: EditorTab;
isProcessing: boolean;
disabled: boolean;
standardEditorId: string;
yamlEditorId: string;
}) => {
const standardLabel = 'Switch to standard editor';
const yamlLabel = 'Switch to YAML editor';
return (
<SlideTabs
appearance="round"
tabs={tabs}
tabs={[
{
key: 'standard',
icon: Icon.ListAddCheck,
tooltip: { content: standardLabel, position: 'bottom' },
ariaLabel: standardLabel,
controls: standardEditorId,
},
{
key: 'yaml',
icon: Icon.Code,
tooltip: { content: yamlLabel, position: 'bottom' },
ariaLabel: yamlLabel,
controls: yamlEditorId,
},
]}
onChange={onTabChange}
size="medium"
size="small"
fitContent
activeIndex={selectedEditorTab}
isProcessing={isProcessing}
disabled={disabled}
/>
);
};
60 changes: 41 additions & 19 deletions web/packages/teleport/src/Roles/RoleEditor/RoleEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,18 +75,15 @@ afterEach(() => {

test('rendering and switching tabs for new role', async () => {
render(<TestRoleEditor />);
expect(screen.getByRole('tab', { name: 'Standard' })).toHaveAttribute(
'aria-selected',
'true'
);
expect(getStandardEditorTab()).toHaveAttribute('aria-selected', 'true');
expect(
screen.queryByRole('button', { name: /Reset to Standard Settings/i })
).not.toBeInTheDocument();
expect(screen.getByLabelText('Role Name')).toHaveValue('new_role_name');
expect(screen.getByLabelText('Description')).toHaveValue('');
expect(screen.getByRole('button', { name: 'Create Role' })).toBeEnabled();

await user.click(screen.getByRole('tab', { name: 'YAML' }));
await user.click(getYamlEditorTab());
expect(fromFauxYaml(await getTextEditorContents())).toEqual(
withDefaults({
kind: 'role',
Expand All @@ -103,7 +100,7 @@ test('rendering and switching tabs for new role', async () => {
);
expect(screen.getByRole('button', { name: 'Create Role' })).toBeEnabled();

await user.click(screen.getByRole('tab', { name: 'Standard' }));
await user.click(getStandardEditorTab());
await screen.findByLabelText('Role Name');
expect(
screen.queryByRole('button', { name: /Reset to Standard Settings/i })
Expand All @@ -127,41 +124,60 @@ test('rendering and switching tabs for a non-standard role', async () => {
originalRole={{ object: originalRole, yaml: originalYaml }}
/>
);
expect(screen.getByRole('tab', { name: 'YAML' })).toHaveAttribute(
'aria-selected',
'true'
);
expect(getYamlEditorTab()).toHaveAttribute('aria-selected', 'true');
expect(fromFauxYaml(await getTextEditorContents())).toEqual(originalRole);
expect(screen.getByRole('button', { name: 'Update Role' })).toBeDisabled();

await user.click(screen.getByRole('tab', { name: 'Standard' }));
await user.click(getStandardEditorTab());
expect(
screen.getByRole('button', { name: 'Reset to Standard Settings' })
).toBeVisible();
expect(screen.getByLabelText('Role Name')).toHaveValue('some-role');
expect(screen.getByLabelText('Description')).toHaveValue('');
expect(screen.getByRole('button', { name: 'Update Role' })).toBeDisabled();

await user.click(screen.getByRole('tab', { name: 'YAML' }));
await user.click(getYamlEditorTab());
expect(fromFauxYaml(await getTextEditorContents())).toEqual(originalRole);
expect(screen.getByRole('button', { name: 'Update Role' })).toBeDisabled();

// Switch once again, reset to standard
await user.click(screen.getByRole('tab', { name: 'Standard' }));
await user.click(getStandardEditorTab());
expect(screen.getByRole('button', { name: 'Update Role' })).toBeDisabled();
await user.click(
screen.getByRole('button', { name: 'Reset to Standard Settings' })
);
expect(screen.getByRole('button', { name: 'Update Role' })).toBeEnabled();
await user.type(screen.getByLabelText('Description'), 'some description');

await user.click(screen.getByRole('tab', { name: 'YAML' }));
await user.click(getYamlEditorTab());
const editorContents = fromFauxYaml(await getTextEditorContents());
expect(editorContents.metadata.description).toBe('some description');
expect(editorContents.spec.deny).toEqual({});
expect(screen.getByRole('button', { name: 'Update Role' })).toBeEnabled();
});

test('no double conversions when clicking already active tabs', async () => {
render(<TestRoleEditor />);
await user.click(getYamlEditorTab());
await user.click(getStandardEditorTab());
await user.type(screen.getByLabelText('Role Name'), '_2');
await user.click(getStandardEditorTab());
expect(screen.getByLabelText('Role Name')).toHaveValue('new_role_name_2');

await user.click(getYamlEditorTab());
await user.clear(await findTextEditor());
await user.type(
await findTextEditor(),
// Note: this is actually correct JSON syntax; the testing library uses
// braces for special keys, so we need to use double opening braces.
'{{"kind":"role", metadata:{{"name":"new_role_name_3"}}'
);
await user.click(getYamlEditorTab());
expect(await getTextEditorContents()).toBe(
'{"kind":"role", metadata:{"name":"new_role_name_3"}}'
);
});

test('canceling standard editor', async () => {
const onCancel = jest.fn();
render(<TestRoleEditor onCancel={onCancel} />);
Expand All @@ -175,7 +191,7 @@ test('canceling standard editor', async () => {
test('canceling yaml editor', async () => {
const onCancel = jest.fn();
render(<TestRoleEditor onCancel={onCancel} />);
await user.click(screen.getByRole('tab', { name: 'YAML' }));
await user.click(getYamlEditorTab());
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(onCancel).toHaveBeenCalled();
expect(userEventService.captureUserEvent).toHaveBeenCalledWith({
Expand Down Expand Up @@ -222,7 +238,7 @@ test('saving a new role after editing as YAML', async () => {
render(<TestRoleEditor onSave={onSave} />);
expect(screen.getByRole('button', { name: 'Create Role' })).toBeEnabled();

await user.click(screen.getByRole('tab', { name: 'YAML' }));
await user.click(getYamlEditorTab());
await user.clear(await findTextEditor());
await user.type(await findTextEditor(), '{{"foo":"bar"}');
await user.click(screen.getByRole('button', { name: 'Create Role' }));
Expand All @@ -247,7 +263,7 @@ test('error while yamlifying', async () => {
.spyOn(yamlService, 'stringify')
.mockRejectedValue(new Error('me no speak yaml'));
render(<TestRoleEditor />);
await user.click(screen.getByRole('tab', { name: 'YAML' }));
await user.click(getYamlEditorTab());
expect(screen.getByText('me no speak yaml')).toBeVisible();
});

Expand All @@ -256,8 +272,8 @@ test('error while parsing', async () => {
.spyOn(yamlService, 'parse')
.mockRejectedValue(new Error('me no speak yaml'));
render(<TestRoleEditor />);
await user.click(screen.getByRole('tab', { name: 'YAML' }));
await user.click(screen.getByRole('tab', { name: 'Standard' }));
await user.click(getYamlEditorTab());
await user.click(getStandardEditorTab());
expect(screen.getByText('me no speak yaml')).toBeVisible();
});

Expand All @@ -280,6 +296,12 @@ const TestRoleEditor = (props: RoleEditorProps) => {
);
};

const getStandardEditorTab = () =>
screen.getByRole('tab', { name: 'Switch to standard editor' });

const getYamlEditorTab = () =>
screen.getByRole('tab', { name: 'Switch to YAML editor' });

const findTextEditor = async () =>
within(await screen.findByTestId('text-editor-container')).getByRole(
'textbox'
Expand Down
69 changes: 42 additions & 27 deletions web/packages/teleport/src/Roles/RoleEditor/RoleEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import { Alert, Box, Flex } from 'design';
import React, { useState } from 'react';
import { Alert, Flex } from 'design';
import React, { useId, useState } from 'react';
import { useAsync } from 'shared/hooks/useAsync';

import { Role, RoleWithYaml } from 'teleport/services/resources';
Expand All @@ -32,7 +32,7 @@ import {
roleToRoleEditorModel as roleToRoleEditorModel,
} from './standardmodel';
import { YamlEditorModel } from './yamlmodel';
import { EditorTab, EditorTabs } from './EditorTabs';
import { EditorTab } from './EditorTabs';
import { EditorHeader } from './EditorHeader';
import { StandardEditor } from './StandardEditor';
import { YamlEditor } from './YamlEditor';
Expand All @@ -59,6 +59,12 @@ export const RoleEditor = ({
onSave,
onDelete,
}: RoleEditorProps) => {
const idPrefix = useId();
// These IDs are needed to connect accessibility attributes between the
// standard/YAML tab switcher and the switched panels.
const standardEditorId = `${idPrefix}-standard`;
const yamlEditorId = `${idPrefix}-yaml`;

const [standardModel, setStandardModel] = useState<StandardEditorModel>(
() => {
const role = originalRole?.object ?? newRole();
Expand Down Expand Up @@ -114,6 +120,10 @@ export const RoleEditor = ({
saveAttempt.status === 'processing';

async function onTabChange(activeIndex: EditorTab) {
// The code below is not idempotent, so we need to protect ourselves from
// an accidental model replacement.
if (activeIndex === selectedEditorTab) return;

switch (activeIndex) {
case EditorTab.Standard: {
if (!yamlModel.content) {
Expand Down Expand Up @@ -160,7 +170,15 @@ export const RoleEditor = ({

return (
<Flex flexDirection="column" flex="1">
<EditorHeader role={originalRole?.object} onDelete={onDelete} />
<EditorHeader
role={originalRole?.object}
onDelete={onDelete}
selectedEditorTab={selectedEditorTab}
onEditorTabChange={onTabChange}
isProcessing={isProcessing}
standardEditorId={standardEditorId}
yamlEditorId={yamlEditorId}
/>
{saveAttempt.status === 'error' && (
<Alert mt={3} dismissible>
{saveAttempt.statusText}
Expand All @@ -176,32 +194,29 @@ export const RoleEditor = ({
{yamlifyAttempt.statusText}
</Alert>
)}
<Box mb={3}>
<EditorTabs
onTabChange={onTabChange}
selectedEditorTab={selectedEditorTab}
isProcessing={isProcessing}
/>
</Box>
{selectedEditorTab === EditorTab.Standard && (
<StandardEditor
originalRole={originalRole}
onSave={object => handleSave({ object })}
onCancel={handleCancel}
standardEditorModel={standardModel}
isProcessing={isProcessing}
onChange={setStandardModel}
/>
<div id={standardEditorId}>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is <div id={standardEditorId} needed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are used to associate the tab switcher tabs with the switched panels. I'm adding a comment.

<StandardEditor
originalRole={originalRole}
onSave={object => handleSave({ object })}
onCancel={handleCancel}
standardEditorModel={standardModel}
isProcessing={isProcessing}
onChange={setStandardModel}
/>
</div>
)}
{selectedEditorTab === EditorTab.Yaml && (
<YamlEditor
yamlEditorModel={yamlModel}
onChange={setYamlModel}
onSave={async yaml => void (await handleSave({ yaml }))}
isProcessing={isProcessing}
onCancel={handleCancel}
originalRole={originalRole}
/>
<Flex flexDirection="column" flex="1" id={yamlEditorId}>
<YamlEditor
yamlEditorModel={yamlModel}
onChange={setYamlModel}
onSave={async yaml => void (await handleSave({ yaml }))}
isProcessing={isProcessing}
onCancel={handleCancel}
originalRole={originalRole}
/>
</Flex>
)}
</Flex>
);
Expand Down
Loading
Loading