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 Oct 2, 2024
1 parent 54f1d44 commit 71ca671
Show file tree
Hide file tree
Showing 28 changed files with 1,362 additions and 59 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -498,39 +498,7 @@
</h1>
<div
class="MuiBox-root css-ldp2l3"
>
<label
class="MuiFormControlLabel-root MuiFormControlLabel-labelPlacementEnd css-j204z7-MuiFormControlLabel-root"
>
<span
class="MuiSwitch-root MuiSwitch-sizeMedium css-julti5-MuiSwitch-root"
>
<span
class="MuiButtonBase-root MuiSwitch-switchBase MuiSwitch-colorPrimary Mui-checked PrivateSwitchBase-root MuiSwitch-switchBase MuiSwitch-colorPrimary Mui-checked Mui-checked css-1nsozxe-MuiButtonBase-root-MuiSwitch-switchBase"
>
<input
checked=""
class="PrivateSwitchBase-input MuiSwitch-input css-1m9pwf3"
type="checkbox"
/>
<span
class="MuiSwitch-thumb css-jsexje-MuiSwitch-thumb"
/>
<span
class="MuiTouchRipple-root css-8je8zh-MuiTouchRipple-root"
/>
</span>
<span
class="MuiSwitch-track css-1yjjitx-MuiSwitch-track"
/>
</span>
<span
class="MuiTypography-root MuiTypography-body1 MuiFormControlLabel-label css-1ezega9-MuiTypography-root"
>
Only warnings (0)
</span>
</label>
</div>
/>
</div>
</div>
<div
Expand Down
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, EditorDialog } from '../common';

export interface CreateResourceButtonProps {
resourceClass: KubeObjectClass;
}

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

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

return (
<React.Fragment>
<ActionButton
color="primary"
description={t('translation|Create {{ resourceName }}', { resourceName })}
icon={'mdi:plus-circle'}
onClick={() => {
setOpenDialog(true);
}}
/>
<EditorDialog
item={baseObject}
open={openDialog}
onClose={() => setOpenDialog(false)}
onSave={() => setOpenDialog(false)}
saveLabel={t('translation|Apply')}
errorMessage={errorMessage}
onEditorChanged={() => setErrorMessage('')}
title={t('translation|Create {{ resourceName }}', { resourceName })}
/>
</React.Fragment>
);
}
1 change: 1 addition & 0 deletions frontend/src/components/common/Resource/EditButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ export default function EditButton(props: EditButtonProps) {
onSave={handleSave}
errorMessage={errorMessage}
onEditorChanged={() => setErrorMessage('')}
applyOnSave
/>
)}
</AuthVisible>
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/components/common/Resource/EditorDialog.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
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
86 changes: 81 additions & 5 deletions frontend/src/components/common/Resource/EditorDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,18 @@ 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/cluster';
import { getThemeName } from '../../../lib/themes';
import { useId } from '../../../lib/util';
import { clusterAction } from '../../../redux/clusterActionSlice';
import {
EventStatus,
HeadlampEventType,
useEventCallback,
} from '../../../redux/headlampEventSlice';
import ConfirmButton from '../ConfirmButton';
import { Dialog, DialogProps } from '../Dialog';
import Loader from '../Loader';
Expand Down Expand Up @@ -62,11 +71,22 @@ export interface EditorDialogProps extends DialogProps {
errorMessage?: string;
/** The dialog title. */
title?: string;
/** The flag for applying the onSave function. */
applyOnSave?: boolean;
}

export default function EditorDialog(props: EditorDialogProps) {
const { item, onClose, onSave, onEditorChanged, saveLabel, errorMessage, title, ...other } =
props;
const {
item,
onClose,
onSave,
onEditorChanged,
saveLabel,
errorMessage,
title,
applyOnSave,
...other
} = props;
const editorOptions = {
selectOnLineNumbers: true,
readOnly: isReadOnly(),
Expand Down Expand Up @@ -94,6 +114,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 = useDispatch();

function setUseSimpleEditor(data: boolean) {
localStorage.setItem('useSimpleEditor', JSON.stringify(data));
Expand Down Expand Up @@ -257,6 +279,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 @@ -273,6 +321,36 @@ export default function EditorDialog(props: EditorDialogProps) {
setError(t("Error parsing the code. Please verify it's valid YAML or JSON!"));
return;
}

const newItemDefs = obj!;

if (!applyOnSave) {
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,
});
}

onSave!(obj);
}

Expand Down Expand Up @@ -309,9 +387,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
8 changes: 7 additions & 1 deletion frontend/src/components/common/Resource/ResourceListView.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { PropsWithChildren } from 'react';
import { KubeObject } from '../../../lib/k8s/cluster';
import { KubeObject, KubeObjectClass } from '../../../lib/k8s/cluster';
import { CreateResourceButton } from '../CreateResourceButton';
import SectionBox from '../SectionBox';
import SectionFilterHeader, { SectionFilterHeaderProps } from '../SectionFilterHeader';
import ResourceTable, { ResourceTableProps } from './ResourceTable';
Expand All @@ -23,6 +24,8 @@ export default function ResourceListView<ItemType>(
const { title, children, headerProps, ...tableProps } = props;
const withNamespaceFilter =
'resourceClass' in props && (props.resourceClass as KubeObject)?.isNamespaced;
const resourceClass = (props as ResourceListViewWithResourceClassProps<ItemType>)
.resourceClass as KubeObjectClass;

return (
<SectionBox
Expand All @@ -31,6 +34,9 @@ export default function ResourceListView<ItemType>(
<SectionFilterHeader
title={title}
noNamespaceFilter={!withNamespaceFilter}
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,13 +1,24 @@
import '../../../i18n/config';
import { Meta, StoryFn } from '@storybook/react';
import React from 'react';
import { Provider } from 'react-redux';
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
Loading

0 comments on commit 71ca671

Please sign in to comment.