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 <eskopelitis@microsoft.com>
  • Loading branch information
skoeva committed Sep 27, 2024
1 parent fa7c26c commit 88d8c26
Show file tree
Hide file tree
Showing 29 changed files with 1,399 additions and 189 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>
);
}
94 changes: 1 addition & 93 deletions frontend/src/components/common/Resource/CreateButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,6 @@ import { InlineIcon } from '@iconify/react';
import Button from '@mui/material/Button';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { getCluster } from '../../../lib/cluster';
import { apply } from '../../../lib/k8s/apiProxy';
import { KubeObjectInterface } from '../../../lib/k8s/cluster';
import { clusterAction } from '../../../redux/clusterActionSlice';
import {
EventStatus,
HeadlampEventType,
useEventCallback,
} from '../../../redux/headlampEventSlice';
import ActionButton from '../ActionButton';
import EditorDialog from './EditorDialog';

Expand All @@ -22,90 +11,9 @@ interface CreateButtonProps {

export default function CreateButton(props: CreateButtonProps) {
const { isNarrow } = props;
const dispatch = useDispatch();
const [openDialog, setOpenDialog] = React.useState(false);
const [errorMessage, setErrorMessage] = React.useState('');
const location = useLocation();
const { t } = useTranslation(['translation']);
const dispatchCreateEvent = useEventCallback(HeadlampEventType.CREATE_RESOURCE);

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,
});
}
setErrorMessage(msg);
setOpenDialog(true);
throw msg;
}
});
}
);
};

function handleSave(newItemDefs: KubeObjectInterface[]) {
let massagedNewItemDefs = newItemDefs;
const cancelUrl = location.pathname;

// check if all yaml objects are valid
for (let i = 0; i < massagedNewItemDefs.length; i++) {
if (massagedNewItemDefs[i].kind === 'List') {
// flatten this List kind with the items that it has which is a list of valid k8s resources
const deletedItem = massagedNewItemDefs.splice(i, 1);
massagedNewItemDefs = massagedNewItemDefs.concat(deletedItem[0].items);
}
if (!massagedNewItemDefs[i].metadata?.name) {
setErrorMessage(
t(`translation|Invalid: One or more of resources doesn't have a name property`)
);
return;
}
if (!massagedNewItemDefs[i].kind) {
setErrorMessage(t('translation|Invalid: Please set a kind to the resource'));
return;
}
}
// all resources name
const resourceNames = massagedNewItemDefs.map(newItemDef => newItemDef.metadata.name);
setOpenDialog(false);

const clusterName = getCluster() || '';

dispatch(
clusterAction(() => applyFunc(massagedNewItemDefs, 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,
})
);

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

return (
<React.Fragment>
Expand Down Expand Up @@ -135,7 +43,7 @@ export default function CreateButton(props: CreateButtonProps) {
item={{}}
open={openDialog}
onClose={() => setOpenDialog(false)}
onSave={handleSave}
onSave={() => setOpenDialog(false)}
saveLabel={t('translation|Apply')}
errorMessage={errorMessage}
onEditorChanged={() => setErrorMessage('')}
Expand Down
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 @@ -114,6 +114,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
Loading

0 comments on commit 88d8c26

Please sign in to comment.