From 645619478ea946ed86846e27c7c692a03c5d0fb9 Mon Sep 17 00:00:00 2001 From: Evangelos Skopelitis Date: Tue, 28 May 2024 07:51:13 -0400 Subject: [PATCH] frontend: Add create resource UI 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 --- .../common/CreateResourceButton.stories.tsx | 98 +++++++ .../common/CreateResourceButton.tsx | 42 +++ .../components/common/Resource/EditButton.tsx | 1 + .../common/Resource/EditorDialog.stories.tsx | 11 + .../common/Resource/EditorDialog.tsx | 86 +++++- .../common/Resource/ResourceListView.tsx | 9 +- .../common/Resource/ViewButton.stories.tsx | 11 + ...rceButton.ConfigMapStory.stories.storyshot | 1 + ...ceButton.InvalidResource.stories.storyshot | 13 + ...urceButton.ValidResource.stories.storyshot | 13 + frontend/src/components/common/index.test.ts | 1 + frontend/src/components/common/index.ts | 1 + .../List.Items.stories.storyshot | 258 +++++++++++++++++- .../src/components/crd/CustomResourceList.tsx | 8 +- .../CustomResourceList.List.stories.storyshot | 258 +++++++++++++++++- .../List.DaemonSets.stories.storyshot | 258 +++++++++++++++++- .../EndpointList.Items.stories.storyshot | 258 +++++++++++++++++- .../HPAList.Items.stories.storyshot | 258 +++++++++++++++++- .../ClassList.Items.stories.storyshot | 258 +++++++++++++++++- .../List.Items.stories.storyshot | 258 +++++++++++++++++- .../List.Items.stories.storyshot | 258 +++++++++++++++++- .../List.Nodes.stories.storyshot | 258 +++++++++++++++++- .../pdbList.Items.stories.storyshot | 258 +++++++++++++++++- .../priorityClassList.Items.stories.storyshot | 258 +++++++++++++++++- .../List.ReplicaSets.stories.storyshot | 258 +++++++++++++++++- .../List.Items.stories.storyshot | 258 +++++++++++++++++- .../List.Items.stories.storyshot | 258 +++++++++++++++++- .../ClaimList.Items.stories.storyshot | 258 +++++++++++++++++- .../ClassList.Items.stories.storyshot | 258 +++++++++++++++++- .../VolumeList.Items.stories.storyshot | 258 +++++++++++++++++- .../VPAList.List.stories.storyshot | 258 +++++++++++++++++- ...gWebhookConfigList.Items.stories.storyshot | 258 +++++++++++++++++- ...gWebhookConfigList.Items.stories.storyshot | 258 +++++++++++++++++- frontend/src/i18n/locales/de/translation.json | 1 + frontend/src/i18n/locales/en/translation.json | 1 + frontend/src/i18n/locales/es/translation.json | 1 + frontend/src/i18n/locales/fr/translation.json | 1 + frontend/src/i18n/locales/pt/translation.json | 1 + frontend/src/lib/k8s/cluster.ts | 18 +- frontend/src/lib/k8s/configMap.ts | 8 +- frontend/src/lib/k8s/hpa.ts | 13 +- frontend/src/lib/k8s/lease.ts | 11 + frontend/src/lib/k8s/limitRange.tsx | 28 ++ .../lib/k8s/mutatingWebhookConfiguration.ts | 26 ++ frontend/src/lib/k8s/podDisruptionBudget.ts | 8 +- frontend/src/lib/k8s/priorityClass.ts | 11 +- frontend/src/lib/k8s/resourceQuota.ts | 8 +- frontend/src/lib/k8s/runtime.ts | 6 + frontend/src/lib/k8s/secret.ts | 8 +- .../lib/k8s/validatingWebhookConfiguration.ts | 26 ++ frontend/src/lib/k8s/vpa.ts | 14 +- 51 files changed, 5610 insertions(+), 35 deletions(-) create mode 100644 frontend/src/components/common/CreateResourceButton.stories.tsx create mode 100644 frontend/src/components/common/CreateResourceButton.tsx create mode 100644 frontend/src/components/common/__snapshots__/CreateResourceButton.ConfigMapStory.stories.storyshot create mode 100644 frontend/src/components/common/__snapshots__/CreateResourceButton.InvalidResource.stories.storyshot create mode 100644 frontend/src/components/common/__snapshots__/CreateResourceButton.ValidResource.stories.storyshot diff --git a/frontend/src/components/common/CreateResourceButton.stories.tsx b/frontend/src/components/common/CreateResourceButton.stories.tsx new file mode 100644 index 0000000000..77f3e5b808 --- /dev/null +++ b/frontend/src/components/common/CreateResourceButton.stories.tsx @@ -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 ( + + + + + + ); + }, + ], +} as Meta; + +type Story = StoryObj; + +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, + }); + }, +}; diff --git a/frontend/src/components/common/CreateResourceButton.tsx b/frontend/src/components/common/CreateResourceButton.tsx new file mode 100644 index 0000000000..c452ff0b56 --- /dev/null +++ b/frontend/src/components/common/CreateResourceButton.tsx @@ -0,0 +1,42 @@ +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 ( + + { + setOpenDialog(true); + }} + /> + setOpenDialog(false)} + onSave={() => setOpenDialog(false)} + saveLabel={t('translation|Apply')} + errorMessage={errorMessage} + onEditorChanged={() => setErrorMessage('')} + title={t('translation|Create {{ name }}', { name })} + /> + + ); +} diff --git a/frontend/src/components/common/Resource/EditButton.tsx b/frontend/src/components/common/Resource/EditButton.tsx index e838eadef0..b965b0326d 100644 --- a/frontend/src/components/common/Resource/EditButton.tsx +++ b/frontend/src/components/common/Resource/EditButton.tsx @@ -115,6 +115,7 @@ export default function EditButton(props: EditButtonProps) { onSave={handleSave} errorMessage={errorMessage} onEditorChanged={() => setErrorMessage('')} + applyOnSave /> )} diff --git a/frontend/src/components/common/Resource/EditorDialog.stories.tsx b/frontend/src/components/common/Resource/EditorDialog.stories.tsx index 24399fa6e5..204d8cbb1d 100644 --- a/frontend/src/components/common/Resource/EditorDialog.stories.tsx +++ b/frontend/src/components/common/Resource/EditorDialog.stories.tsx @@ -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 ( + + + + ); + }, + ], } as Meta; const Template: StoryFn = args => { diff --git a/frontend/src/components/common/Resource/EditorDialog.tsx b/frontend/src/components/common/Resource/EditorDialog.tsx index 53e74b459e..fab55f74a0 100644 --- a/frontend/src/components/common/Resource/EditorDialog.tsx +++ b/frontend/src/components/common/Resource/EditorDialog.tsx @@ -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'; @@ -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(), @@ -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)); @@ -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); @@ -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); } @@ -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 }); diff --git a/frontend/src/components/common/Resource/ResourceListView.tsx b/frontend/src/components/common/Resource/ResourceListView.tsx index 08cc7f5c2e..fe23cfd885 100644 --- a/frontend/src/components/common/Resource/ResourceListView.tsx +++ b/frontend/src/components/common/Resource/ResourceListView.tsx @@ -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'; @@ -23,6 +24,8 @@ export default function ResourceListView( const { title, children, headerProps, ...tableProps } = props; const withNamespaceFilter = 'resourceClass' in props && (props.resourceClass as KubeObject)?.isNamespaced; + const resourceClass = (props as ResourceListViewWithResourceClassProps) + .resourceClass as KubeObjectClass; return ( ( ] : undefined) + } {...headerProps} /> ) : ( diff --git a/frontend/src/components/common/Resource/ViewButton.stories.tsx b/frontend/src/components/common/Resource/ViewButton.stories.tsx index e002a6face..feb9480877 100644 --- a/frontend/src/components/common/Resource/ViewButton.stories.tsx +++ b/frontend/src/components/common/Resource/ViewButton.stories.tsx @@ -1,6 +1,8 @@ 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'; @@ -8,6 +10,15 @@ export default { title: 'Resource/ViewButton', component: ViewButton, argTypes: {}, + decorators: [ + Story => { + return ( + + + + ); + }, + ], } as Meta; const Template: StoryFn = args => ; diff --git a/frontend/src/components/common/__snapshots__/CreateResourceButton.ConfigMapStory.stories.storyshot b/frontend/src/components/common/__snapshots__/CreateResourceButton.ConfigMapStory.stories.storyshot new file mode 100644 index 0000000000..df46f87231 --- /dev/null +++ b/frontend/src/components/common/__snapshots__/CreateResourceButton.ConfigMapStory.stories.storyshot @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/common/__snapshots__/CreateResourceButton.InvalidResource.stories.storyshot b/frontend/src/components/common/__snapshots__/CreateResourceButton.InvalidResource.stories.storyshot new file mode 100644 index 0000000000..895858ca2d --- /dev/null +++ b/frontend/src/components/common/__snapshots__/CreateResourceButton.InvalidResource.stories.storyshot @@ -0,0 +1,13 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/common/__snapshots__/CreateResourceButton.ValidResource.stories.storyshot b/frontend/src/components/common/__snapshots__/CreateResourceButton.ValidResource.stories.storyshot new file mode 100644 index 0000000000..895858ca2d --- /dev/null +++ b/frontend/src/components/common/__snapshots__/CreateResourceButton.ValidResource.stories.storyshot @@ -0,0 +1,13 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/common/index.test.ts b/frontend/src/components/common/index.test.ts index 0af5c688a8..a1d343491c 100644 --- a/frontend/src/components/common/index.test.ts +++ b/frontend/src/components/common/index.test.ts @@ -19,6 +19,7 @@ const checkExports = [ 'Chart', 'ConfirmDialog', 'ConfirmButton', + 'CreateResourceButton', 'Dialog', 'EmptyContent', 'ErrorPage', diff --git a/frontend/src/components/common/index.ts b/frontend/src/components/common/index.ts index 4e35bcc8c7..54e664535d 100644 --- a/frontend/src/components/common/index.ts +++ b/frontend/src/components/common/index.ts @@ -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'; diff --git a/frontend/src/components/configmap/__snapshots__/List.Items.stories.storyshot b/frontend/src/components/configmap/__snapshots__/List.Items.stories.storyshot index 9d5663eff0..958128d2e1 100644 --- a/frontend/src/components/configmap/__snapshots__/List.Items.stories.storyshot +++ b/frontend/src/components/configmap/__snapshots__/List.Items.stories.storyshot @@ -19,7 +19,19 @@
+ > + +
+