diff --git a/frontend/src/components/cluster/__snapshots__/Overview.Events.stories.storyshot b/frontend/src/components/cluster/__snapshots__/Overview.Events.stories.storyshot index f09b9b4dfb..c57fecdac2 100644 --- a/frontend/src/components/cluster/__snapshots__/Overview.Events.stories.storyshot +++ b/frontend/src/components/cluster/__snapshots__/Overview.Events.stories.storyshot @@ -498,39 +498,7 @@
- -
+ />
{ + 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..7cf9f0d98c --- /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, 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 ( + + { + setOpenDialog(true); + }} + /> + setOpenDialog(false)} + onSave={() => setOpenDialog(false)} + saveLabel={t('translation|Apply')} + errorMessage={errorMessage} + onEditorChanged={() => setErrorMessage('')} + title={t('translation|Create {{ resourceName }}', { resourceName })} + /> + + ); +} diff --git a/frontend/src/components/common/Resource/CreateButton.tsx b/frontend/src/components/common/Resource/CreateButton.tsx index 7dce4352bd..46c5599b8c 100644 --- a/frontend/src/components/common/Resource/CreateButton.tsx +++ b/frontend/src/components/common/Resource/CreateButton.tsx @@ -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'; @@ -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 ( @@ -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('')} diff --git a/frontend/src/components/common/Resource/EditButton.tsx b/frontend/src/components/common/Resource/EditButton.tsx index bd68c54b66..3b9d687353 100644 --- a/frontend/src/components/common/Resource/EditButton.tsx +++ b/frontend/src/components/common/Resource/EditButton.tsx @@ -114,6 +114,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 6e29ae0e74..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'; @@ -48,8 +57,8 @@ import SimpleEditor from './SimpleEditor'; type KubeObjectIsh = Partial; export interface EditorDialogProps extends DialogProps { - /** The object to edit, or null to make the dialog be in "loading mode". Pass it an empty object if no contents are to be shown when the dialog is first open. */ - item: KubeObjectIsh | null; + /** The object(s) to edit, or null to make the dialog be in "loading mode". Pass it an empty object if no contents are to be shown when the dialog is first open. */ + item: KubeObjectIsh | object | object[] | string | null; /** Called when the dialog is closed. */ onClose: () => void; /** Called when the user clicks the save button. */ @@ -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(), @@ -76,11 +96,14 @@ export default function EditorDialog(props: EditorDialogProps) { const [lang, setLang] = React.useState(i18n.language); const themeName = getThemeName(); - const originalCodeRef = React.useRef({ code: '', format: item ? 'yaml' : '' }); + const initialCode = typeof item === 'string' ? item : yaml.dump(item || {}); + const originalCodeRef = React.useRef({ code: initialCode, format: item ? 'yaml' : '' }); const [code, setCode] = React.useState(originalCodeRef.current); const codeRef = React.useRef(code); const lastCodeCheckHandler = React.useRef(0); - const previousVersionRef = React.useRef(item?.metadata?.resourceVersion || ''); + const previousVersionRef = React.useRef( + isKubeObjectIsh(item) ? item?.metadata?.resourceVersion || '' : '' + ); const [error, setError] = React.useState(''); const [docSpecs, setDocSpecs] = React.useState< KubeObjectInterface | KubeObjectInterface[] | null @@ -91,42 +114,53 @@ 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)); setUseSimpleEditorState(data); } + function isKubeObjectIsh(item: any): item is KubeObjectIsh { + return item && typeof item === 'object' && !Array.isArray(item) && 'metadata' in item; + } + // Update the code when the item changes, but only if the code hasn't been touched. React.useEffect(() => { if (!item || Object.keys(item || {}).length === 0) { + const defaultCode = '# Enter your YAML or JSON here'; + originalCodeRef.current = { code: defaultCode, format: 'yaml' }; + setCode({ code: defaultCode, format: 'yaml' }); return; } - const originalCode = originalCodeRef.current.code; - const itemCode = - originalCodeRef.current.format === 'json' ? JSON.stringify(item) : yaml.dump(item); - if (itemCode !== originalCodeRef.current.code) { - originalCodeRef.current = { code: itemCode, format: originalCodeRef.current.format }; - } + // Determine the format (YAML or JSON) and serialize to string + const format = looksLikeJson(originalCodeRef.current.code) ? 'json' : 'yaml'; + const itemCode = format === 'json' ? JSON.stringify(item) : yaml.dump(item); - if (!item.metadata) { - return; + // Update the code if the item representation has changed + if (itemCode !== originalCodeRef.current.code) { + originalCodeRef.current = { code: itemCode, format }; + setCode({ code: itemCode, format }); } - const resourceVersionsDiffer = - (previousVersionRef.current || '') !== (item.metadata!.resourceVersion || ''); - // Only change if the code hasn't been touched. - // We use the codeRef in this effect instead of the code, because we need to access the current - // state of the code but we don't want to trigger a re-render when we set the code here. - if (resourceVersionsDiffer || codeRef.current.code === originalCode) { - // Prevent updating to the same code, which would lead to an infinite loop. - if (codeRef.current.code !== itemCode) { - setCode({ code: itemCode, format: originalCodeRef.current.format }); - } + // Additional handling for Kubernetes objects + if (isKubeObjectIsh(item) && item.metadata) { + const resourceVersionsDiffer = + (previousVersionRef.current || '') !== (item.metadata!.resourceVersion || ''); + // Only change if the code hasn't been touched. + // We use the codeRef in this effect instead of the code, because we need to access the current + // state of the code but we don't want to trigger a re-render when we set the code here. + if (resourceVersionsDiffer || codeRef.current.code === originalCodeRef.current.code) { + // Prevent updating to the same code, which would lead to an infinite loop. + if (codeRef.current.code !== itemCode) { + setCode({ code: itemCode, format: originalCodeRef.current.format }); + } - if (resourceVersionsDiffer && !!item.metadata!.resourceVersion) { - previousVersionRef.current = item.metadata!.resourceVersion; + if (resourceVersionsDiffer && !!item.metadata!.resourceVersion) { + previousVersionRef.current = item.metadata!.resourceVersion; + } } } }, [item]); @@ -245,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); @@ -261,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); } @@ -297,7 +387,7 @@ export default function EditorDialog(props: EditorDialogProps) { const errorLabel = error || errorMessage; let dialogTitle = title; if (!dialogTitle && item) { - const itemName = item.metadata?.name || 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..7f95b6115b 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 ( ( title={title} noNamespaceFilter={!withNamespaceFilter} {...headerProps} + titleSideActions={ + resourceClass?.hasOwnProperty('getBaseObject') + ? [] + : undefined + } /> ) : ( title 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 712922d258..741d10bda3 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 @@
+ > + +
+