Skip to content

Commit

Permalink
frontend: Add create resource UI
Browse files Browse the repository at this point in the history
These changes introduce a new UI feature that allows users to create
resources from the associated list view. Clicking the 'Create' button
opens up the EditorDialog used in the generic 'Create / Apply' button,
now accepting generic YAML/JSON text rather than explicitly expecting an
item that looks like a Kubernetes resource. The dialog box also includes
a generic template for each resource. The apply logic for this new
feature (as well as the original 'Create / Apply' button) has been
consolidated in EditorDialog, with a flag allowing external components
to utilize their own dispatch functionality.

Fixes: #1820

Signed-off-by: Evangelos Skopelitis <[email protected]>
  • Loading branch information
skoeva committed Dec 6, 2024
1 parent 8f32ff7 commit 45bf405
Show file tree
Hide file tree
Showing 65 changed files with 6,059 additions and 32 deletions.
98 changes: 98 additions & 0 deletions frontend/src/components/common/CreateResourceButton.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { Meta, StoryObj } from '@storybook/react';
import { expect, userEvent, waitFor } from '@storybook/test';
import { screen } from '@testing-library/react';
import React from 'react';
import { Provider } from 'react-redux';
import { KubeObjectClass } from '../../lib/k8s/cluster';
import ConfigMap from '../../lib/k8s/configMap';
import store from '../../redux/stores/store';
import { TestContext } from '../../test';
import { CreateResourceButton, CreateResourceButtonProps } from './CreateResourceButton';

export default {
title: 'CreateResourceButton',
component: CreateResourceButton,
parameters: {
storyshots: {
disable: true,
},
},
decorators: [
Story => {
return (
<Provider store={store}>
<TestContext>
<Story />
</TestContext>
</Provider>
);
},
],
} as Meta;

type Story = StoryObj<CreateResourceButtonProps>;

export const ValidResource: Story = {
args: { resourceClass: ConfigMap as unknown as KubeObjectClass },

play: async ({ args }) => {
await userEvent.click(
screen.getByRole('button', {
name: `Create ${args.resourceClass.getBaseObject().kind}`,
})
);

await waitFor(() => expect(screen.getByRole('textbox')).toBeVisible());

await userEvent.click(screen.getByRole('textbox'));

await userEvent.keyboard('{Control>}a{/Control} {Backspace}');
await userEvent.keyboard(`apiVersion: v1{Enter}`);
await userEvent.keyboard(`kind: ConfigMap{Enter}`);
await userEvent.keyboard(`metadata:{Enter}`);
await userEvent.keyboard(` name: base-configmap`);

const button = await screen.findByRole('button', { name: 'Apply' });
expect(button).toBeVisible();
},
};

export const InvalidResource: Story = {
args: { resourceClass: ConfigMap as unknown as KubeObjectClass },

play: async ({ args }) => {
await userEvent.click(
screen.getByRole('button', {
name: `Create ${args.resourceClass.getBaseObject().kind}`,
})
);

await waitFor(() => expect(screen.getByRole('textbox')).toBeVisible());

await userEvent.click(screen.getByRole('textbox'));

await userEvent.keyboard('{Control>}a{/Control}');
await userEvent.keyboard(`apiVersion: v1{Enter}`);
await userEvent.keyboard(`kind: ConfigMap{Enter}`);
await userEvent.keyboard(`metadata:{Enter}`);
await userEvent.keyboard(` name: base-configmap{Enter}`);
await userEvent.keyboard(`creationTimestamp: ''`);

const button = await screen.findByRole('button', { name: 'Apply' });
expect(button).toBeVisible();

await userEvent.click(button);

await waitFor(() =>
userEvent.click(
screen.getByRole('button', {
name: `Create ${args.resourceClass.getBaseObject().kind}`,
})
)
);

await waitFor(() => expect(screen.getByText(/Failed/)).toBeVisible(), {
timeout: 15000,
});
},
};
41 changes: 41 additions & 0 deletions frontend/src/components/common/CreateResourceButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { KubeObjectClass } from '../../lib/k8s/cluster';
import { ActionButton, AuthVisible, EditorDialog } from '../common';

export interface CreateResourceButtonProps {
resourceClass: KubeObjectClass;
resourceName?: string;
}

export function CreateResourceButton(props: CreateResourceButtonProps) {
const { resourceClass, resourceName } = props;
const { t } = useTranslation(['glossary', 'translation']);
const [openDialog, setOpenDialog] = React.useState(false);
const [errorMessage, setErrorMessage] = React.useState('');

const baseObject = resourceClass.getBaseObject();
const name = resourceName ?? baseObject.kind;

return (
<AuthVisible item={resourceClass} authVerb="create">
<ActionButton
color="primary"
description={t('translation|Create {{ name }}', { name })}
icon={'mdi:plus-circle'}
onClick={() => {
setOpenDialog(true);
}}
/>
<EditorDialog
item={baseObject}
open={openDialog}
onClose={() => setOpenDialog(false)}
saveLabel={t('translation|Apply')}
errorMessage={errorMessage}
onEditorChanged={() => setErrorMessage('')}
title={t('translation|Create {{ name }}', { name })}
/>
</AuthVisible>
);
}
11 changes: 11 additions & 0 deletions frontend/src/components/common/Resource/EditorDialog.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,23 @@ import FormControlLabel from '@mui/material/FormControlLabel';
import FormGroup from '@mui/material/FormGroup';
import Switch from '@mui/material/Switch';
import { Meta, StoryFn } from '@storybook/react';
import { Provider } from 'react-redux';
import store from '../../../redux/stores/store';
import { EditorDialog, EditorDialogProps } from '..';

export default {
title: 'Resource/EditorDialog',
component: EditorDialog,
argTypes: {},
decorators: [
Story => {
return (
<Provider store={store}>
<Story />
</Provider>
);
},
],
} as Meta;

const Template: StoryFn<EditorDialogProps> = args => {
Expand Down
82 changes: 75 additions & 7 deletions frontend/src/components/common/Resource/EditorDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,19 @@ import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { getCluster } from '../../../lib/cluster';
import { apply } from '../../../lib/k8s/apiProxy';
import { KubeObjectInterface } from '../../../lib/k8s/KubeObject';
import { getThemeName } from '../../../lib/themes';
import { useId } from '../../../lib/util';
import { clusterAction } from '../../../redux/clusterActionSlice';
import {
EventStatus,
HeadlampEventType,
useEventCallback,
} from '../../../redux/headlampEventSlice';
import { AppDispatch } from '../../../redux/stores/store';
import ConfirmButton from '../ConfirmButton';
import { Dialog, DialogProps } from '../Dialog';
import Loader from '../Loader';
Expand Down Expand Up @@ -53,8 +63,8 @@ export interface EditorDialogProps extends DialogProps {
item: KubeObjectIsh | object | object[] | string | null;
/** Called when the dialog is closed. */
onClose: () => void;
/** Called when the user clicks the save button. */
onSave: ((...args: any[]) => void) | null;
/** Called by a component for when the user clicks the save button. When set to "default", internal save logic is applied. */
onSave?: ((...args: any[]) => void) | 'default' | null;
/** Called when the editor's contents change. */
onEditorChanged?: ((newValue: string) => void) | null;
/** The label to use for the save button. */
Expand All @@ -71,7 +81,7 @@ export default function EditorDialog(props: EditorDialogProps) {
const {
item,
onClose,
onSave,
onSave = 'default',
onEditorChanged,
saveLabel,
errorMessage,
Expand Down Expand Up @@ -106,6 +116,8 @@ export default function EditorDialog(props: EditorDialogProps) {
const localData = localStorage.getItem('useSimpleEditor');
return localData ? JSON.parse(localData) : false;
});
const dispatchCreateEvent = useEventCallback(HeadlampEventType.CREATE_RESOURCE);
const dispatch: AppDispatch = useDispatch();

function setUseSimpleEditor(data: boolean) {
localStorage.setItem('useSimpleEditor', JSON.stringify(data));
Expand Down Expand Up @@ -269,6 +281,32 @@ export default function EditorDialog(props: EditorDialogProps) {
setCode(originalCodeRef.current);
}

const applyFunc = async (newItems: KubeObjectInterface[], clusterName: string) => {
await Promise.allSettled(newItems.map(newItem => apply(newItem, clusterName))).then(
(values: any) => {
values.forEach((value: any, index: number) => {
if (value.status === 'rejected') {
let msg;
const kind = newItems[index].kind;
const name = newItems[index].metadata.name;
const apiVersion = newItems[index].apiVersion;
if (newItems.length === 1) {
msg = t('translation|Failed to create {{ kind }} {{ name }}.', { kind, name });
} else {
msg = t('translation|Failed to create {{ kind }} {{ name }} in {{ apiVersion }}.', {
kind,
name,
apiVersion,
});
}
setError(msg);
throw msg;
}
});
}
);
};

function handleSave() {
// Verify the YAML even means anything before trying to use it.
const { obj, format, error } = getObjectsFromCode(code);
Expand All @@ -285,7 +323,39 @@ export default function EditorDialog(props: EditorDialogProps) {
setError(t("Error parsing the code. Please verify it's valid YAML or JSON!"));
return;
}
onSave!(obj);

const newItemDefs = obj!;

if (typeof onSave === 'string' && onSave === 'default') {
const resourceNames = newItemDefs.map(newItemDef => newItemDef.metadata.name);
const clusterName = getCluster() || '';

dispatch(
clusterAction(() => applyFunc(newItemDefs, clusterName), {
startMessage: t('translation|Applying {{ newItemName }}…', {
newItemName: resourceNames.join(','),
}),
cancelledMessage: t('translation|Cancelled applying {{ newItemName }}.', {
newItemName: resourceNames.join(','),
}),
successMessage: t('translation|Applied {{ newItemName }}.', {
newItemName: resourceNames.join(','),
}),
errorMessage: t('translation|Failed to apply {{ newItemName }}.', {
newItemName: resourceNames.join(','),
}),
cancelUrl: location.pathname,
})
);

dispatchCreateEvent({
status: EventStatus.CONFIRMED,
});

onClose();
} else if (typeof onSave === 'function') {
onSave!(obj);
}
}

function makeEditor() {
Expand Down Expand Up @@ -321,9 +391,7 @@ export default function EditorDialog(props: EditorDialogProps) {
const errorLabel = error || errorMessage;
let dialogTitle = title;
if (!dialogTitle && item) {
const itemName = isKubeObjectIsh(item)
? item.metadata?.name || t('New Object')
: t('New Object');
const itemName = (isKubeObjectIsh(item) && item.metadata?.name) || t('New Object');
dialogTitle = isReadOnly()
? t('translation|View: {{ itemName }}', { itemName })
: t('translation|Edit: {{ itemName }}', { itemName });
Expand Down
10 changes: 8 additions & 2 deletions frontend/src/components/common/Resource/ResourceListView.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { PropsWithChildren, ReactElement, ReactNode } from 'react';
import { KubeObject } from '../../../lib/k8s/KubeObject';
import { KubeObjectClass } from '../../../lib/k8s/KubeObject';
import { KubeObject, KubeObjectClass } from '../../../lib/k8s/KubeObject';
import { CreateResourceButton } from '../CreateResourceButton';
import SectionBox from '../SectionBox';
import SectionFilterHeader, { SectionFilterHeaderProps } from '../SectionFilterHeader';
import ResourceTable, { ResourceTableProps } from './ResourceTable';
Expand Down Expand Up @@ -30,6 +30,8 @@ export default function ResourceListView(
) {
const { title, children, headerProps, ...tableProps } = props;
const withNamespaceFilter = 'resourceClass' in props && props.resourceClass?.isNamespaced;
const resourceClass = (props as ResourceListViewWithResourceClassProps<any>)
.resourceClass as KubeObjectClass;

return (
<SectionBox
Expand All @@ -38,6 +40,10 @@ export default function ResourceListView(
<SectionFilterHeader
title={title}
noNamespaceFilter={!withNamespaceFilter}
titleSideActions={
headerProps?.titleSideActions ||
(resourceClass ? [<CreateResourceButton resourceClass={resourceClass} />] : undefined)
}
{...headerProps}
/>
) : (
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/components/common/Resource/ViewButton.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,25 @@
import '../../../i18n/config';
import { Meta, StoryFn } from '@storybook/react';
import React from 'react';
import { Provider } from 'react-redux';
import { KubeObject } from '../../../lib/k8s/KubeObject';
import store from '../../../redux/stores/store';
import ViewButton from './ViewButton';
import { ViewButtonProps } from './ViewButton';

export default {
title: 'Resource/ViewButton',
component: ViewButton,
argTypes: {},
decorators: [
Story => {
return (
<Provider store={store}>
<Story />
</Provider>
);
},
],
} as Meta;

const Template: StoryFn<ViewButtonProps> = args => <ViewButton {...args} />;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<DocumentFragment />
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<DocumentFragment>
<button
aria-label="Create ConfigMap"
class="MuiButtonBase-root MuiIconButton-root MuiIconButton-sizeMedium css-whz9ym-MuiButtonBase-root-MuiIconButton-root"
data-mui-internal-clone-element="true"
tabindex="0"
type="button"
>
<span
class="MuiTouchRipple-root css-8je8zh-MuiTouchRipple-root"
/>
</button>
</DocumentFragment>
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<DocumentFragment>
<button
aria-label="Create ConfigMap"
class="MuiButtonBase-root MuiIconButton-root MuiIconButton-sizeMedium css-whz9ym-MuiButtonBase-root-MuiIconButton-root"
data-mui-internal-clone-element="true"
tabindex="0"
type="button"
>
<span
class="MuiTouchRipple-root css-8je8zh-MuiTouchRipple-root"
/>
</button>
</DocumentFragment>
1 change: 1 addition & 0 deletions frontend/src/components/common/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const checkExports = [
'Chart',
'ConfirmDialog',
'ConfirmButton',
'CreateResourceButton',
'Dialog',
'EmptyContent',
'ErrorPage',
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,4 @@ export { default as ConfirmButton } from './ConfirmButton';
export * from './NamespacesAutocomplete';
export * from './Table/Table';
export { default as Table } from './Table';
export * from './CreateResourceButton';
Loading

0 comments on commit 45bf405

Please sign in to comment.