From b112193dc01a418829181a80774de7b844cc1f1e 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 | 41 +++ .../common/Resource/EditorDialog.stories.tsx | 11 + .../common/Resource/EditorDialog.tsx | 82 +++++- .../common/Resource/ResourceListView.tsx | 10 +- .../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 | 265 +++++++++++++++++- .../src/components/crd/CustomResourceList.tsx | 8 +- .../CustomResourceList.List.stories.storyshot | 265 +++++++++++++++++- .../List.DaemonSets.stories.storyshot | 265 +++++++++++++++++- .../EndpointList.Items.stories.storyshot | 265 +++++++++++++++++- .../HPAList.Items.stories.storyshot | 265 +++++++++++++++++- .../ClassList.Items.stories.storyshot | 265 +++++++++++++++++- .../List.Items.stories.storyshot | 265 +++++++++++++++++- .../List.Items.stories.storyshot | 265 +++++++++++++++++- .../List.Nodes.stories.storyshot | 265 +++++++++++++++++- .../pdbList.Items.stories.storyshot | 265 +++++++++++++++++- .../priorityClassList.Items.stories.storyshot | 265 +++++++++++++++++- .../List.ReplicaSets.stories.storyshot | 265 +++++++++++++++++- .../List.Items.stories.storyshot | 265 +++++++++++++++++- .../List.Items.stories.storyshot | 265 +++++++++++++++++- .../ClaimList.Items.stories.storyshot | 265 +++++++++++++++++- .../ClassList.Items.stories.storyshot | 265 +++++++++++++++++- .../VolumeList.Items.stories.storyshot | 265 +++++++++++++++++- .../VPAList.List.stories.storyshot | 265 +++++++++++++++++- ...gWebhookConfigList.Items.stories.storyshot | 265 +++++++++++++++++- ...gWebhookConfigList.Items.stories.storyshot | 265 +++++++++++++++++- 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/KubeObject.ts | 12 + frontend/src/lib/k8s/configMap.ts | 6 + frontend/src/lib/k8s/cronJob.ts | 31 ++ frontend/src/lib/k8s/daemonSet.ts | 34 ++- frontend/src/lib/k8s/deployment.ts | 29 ++ frontend/src/lib/k8s/endpoints.ts | 23 ++ frontend/src/lib/k8s/hpa.ts | 11 + frontend/src/lib/k8s/ingress.ts | 33 +++ frontend/src/lib/k8s/ingressClass.ts | 6 + frontend/src/lib/k8s/lease.ts | 11 + frontend/src/lib/k8s/limitRange.tsx | 28 ++ .../lib/k8s/mutatingWebhookConfiguration.ts | 26 ++ frontend/src/lib/k8s/networkpolicy.tsx | 43 +++ frontend/src/lib/k8s/persistentVolume.ts | 19 ++ frontend/src/lib/k8s/persistentVolumeClaim.ts | 13 + frontend/src/lib/k8s/podDisruptionBudget.ts | 6 + frontend/src/lib/k8s/priorityClass.ts | 9 + frontend/src/lib/k8s/replicaSet.ts | 28 ++ frontend/src/lib/k8s/resourceQuota.ts | 6 + frontend/src/lib/k8s/runtime.ts | 6 + frontend/src/lib/k8s/secret.ts | 6 + frontend/src/lib/k8s/service.ts | 20 ++ frontend/src/lib/k8s/serviceAccount.ts | 10 + frontend/src/lib/k8s/statefulSet.ts | 32 ++- frontend/src/lib/k8s/storageClass.ts | 9 + .../lib/k8s/validatingWebhookConfiguration.ts | 26 ++ frontend/src/lib/k8s/vpa.ts | 12 + .../plugin/__snapshots__/pluginLib.snapshot | 1 + 65 files changed, 6059 insertions(+), 32 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..8e3340e9c2 --- /dev/null +++ b/frontend/src/components/common/CreateResourceButton.tsx @@ -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 ( + + { + setOpenDialog(true); + }} + /> + setOpenDialog(false)} + saveLabel={t('translation|Apply')} + errorMessage={errorMessage} + onEditorChanged={() => setErrorMessage('')} + title={t('translation|Create {{ name }}', { name })} + /> + + ); +} diff --git a/frontend/src/components/common/Resource/EditorDialog.stories.tsx b/frontend/src/components/common/Resource/EditorDialog.stories.tsx index 0e974b6481..3ad4b5c66c 100644 --- a/frontend/src/components/common/Resource/EditorDialog.stories.tsx +++ b/frontend/src/components/common/Resource/EditorDialog.stories.tsx @@ -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 ( + + + + ); + }, + ], } 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 983c30cd50..a39e319c4c 100644 --- a/frontend/src/components/common/Resource/EditorDialog.tsx +++ b/frontend/src/components/common/Resource/EditorDialog.tsx @@ -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'; @@ -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. */ @@ -71,7 +81,7 @@ export default function EditorDialog(props: EditorDialogProps) { const { item, onClose, - onSave, + onSave = 'default', onEditorChanged, saveLabel, errorMessage, @@ -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)); @@ -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); @@ -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() { @@ -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 }); diff --git a/frontend/src/components/common/Resource/ResourceListView.tsx b/frontend/src/components/common/Resource/ResourceListView.tsx index 19e6334b23..1157e0c674 100644 --- a/frontend/src/components/common/Resource/ResourceListView.tsx +++ b/frontend/src/components/common/Resource/ResourceListView.tsx @@ -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'; @@ -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) + .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 9f4867efcc..b61ab181be 100644 --- a/frontend/src/components/common/Resource/ViewButton.stories.tsx +++ b/frontend/src/components/common/Resource/ViewButton.stories.tsx @@ -1,7 +1,9 @@ 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'; @@ -9,6 +11,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 ebe816364f..71e7a60d70 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 @@
+ > + +
+