diff --git a/frontend/src/components/namespace/CreateNamespaceButton.stories.tsx b/frontend/src/components/namespace/CreateNamespaceButton.stories.tsx new file mode 100644 index 00000000000..50daa9e4662 --- /dev/null +++ b/frontend/src/components/namespace/CreateNamespaceButton.stories.tsx @@ -0,0 +1,28 @@ +import { Meta, StoryFn } from '@storybook/react'; +import React from 'react'; +import { TestContext } from '../../test'; +import CreateNamespaceButton from './CreateNamespaceButton'; + +export default { + title: 'Namespace/CreateNamespaceButton', + component: CreateNamespaceButton, + decorators: [ + Story => { + return ( +
+ +
+ ); + }, + ], +} as Meta; + +const Template: StoryFn = () => { + return ( + + + + ); +}; + +export const Regular = Template.bind({}); diff --git a/frontend/src/components/namespace/CreateNamespaceButton.tsx b/frontend/src/components/namespace/CreateNamespaceButton.tsx new file mode 100644 index 00000000000..755e95fb41c --- /dev/null +++ b/frontend/src/components/namespace/CreateNamespaceButton.tsx @@ -0,0 +1,166 @@ +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + TextField, +} from '@mui/material'; +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch } from 'react-redux'; +import { getCluster } from '../../lib/cluster'; +import { post } from '../../lib/k8s/apiProxy'; +import Namespace from '../../lib/k8s/namespace'; +import { clusterAction } from '../../redux/clusterActionSlice'; +import { EventStatus, HeadlampEventType, useEventCallback } from '../../redux/headlampEventSlice'; +import { ActionButton } from '../common'; + +export default function CreateNamespaceButton() { + const { t } = useTranslation(['glossary', 'translation']); + const [namespaceName, setNamespaceName] = useState(''); + const [namespaceNameValid, setNamespaceNameValid] = useState(false); + const [nameHelperMessage, setNameHelperMessage] = useState(''); + const [namespaceDialogOpen, setNamespaceDialogOpen] = useState(false); + const dispatchCreateEvent = useEventCallback(HeadlampEventType.CREATE_RESOURCE); + const dispatch = useDispatch(); + + function createNewNamespace() { + const clusterData = getCluster(); + const newNamespaceData = { + apiVersion: 'v1', + kind: 'Namespace', + metadata: { + name: namespaceName, + }, + }; + + const newNamespaceName = newNamespaceData.metadata.name; + + async function namespaceRequest() { + try { + return await post('/api/v1/namespaces', newNamespaceData, true, { + cluster: clusterData || '', + }); + } catch (error) { + console.error('Error creating namespace', error); + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes('already exists')) { + setNamespaceDialogOpen(true); + setNamespaceNameValid(false); + setNameHelperMessage(t('translation|Namespace name already exists.')); + } + throw error; + } + } + + setNamespaceDialogOpen(false); + + dispatch( + clusterAction(() => namespaceRequest(), { + startMessage: t('translation|Applying {{ newItemName }}…', { + newItemName: newNamespaceName, + }), + cancelledMessage: t('translation|Cancelled applying {{ newItemName }}.', { + newItemName: newNamespaceName, + }), + successMessage: t('translation|Applied {{ newItemName }}.', { + newItemName: newNamespaceName, + }), + errorMessage: t('translation|Failed to create {{ kind }} {{ name }}.', { + kind: 'namespace', + name: newNamespaceName, + }), + cancelCallback: () => { + setNamespaceDialogOpen(true); + }, + }) + ); + } + + useEffect(() => { + setNamespaceNameValid(Namespace.isValidNamespaceFormat(namespaceName)); + + if (!Namespace.isValidNamespaceFormat(namespaceName)) { + if (namespaceName.length > 63) { + setNameHelperMessage(t('translation|Namespaces must be under 64 characters.')); + } else { + setNameHelperMessage( + t( + "translation|Namespaces must contain only lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character." + ) + ); + } + } + }, [namespaceName]); + + return ( + <> + { + setNamespaceDialogOpen(true); + }} + /> + + {namespaceDialogOpen && ( + setNamespaceDialogOpen(false)}> + {t('translation|Create Namespace')} + + + 0} + helperText={ + !namespaceNameValid && namespaceName.length > 0 ? nameHelperMessage : '' + } + fullWidth + value={namespaceName} + onChange={event => setNamespaceName(event.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') { + e.preventDefault(); + if (namespaceNameValid) { + createNewNamespace(); + dispatchCreateEvent({ + status: EventStatus.CONFIRMED, + }); + } + } + }} + /> + + + + + + + + )} + + ); +} diff --git a/frontend/src/components/namespace/List.tsx b/frontend/src/components/namespace/List.tsx index bdb29c2ff7f..cea0403bb49 100644 --- a/frontend/src/components/namespace/List.tsx +++ b/frontend/src/components/namespace/List.tsx @@ -10,6 +10,7 @@ import { ResourceTableFromResourceClassProps, ResourceTableProps, } from '../common/Resource/ResourceTable'; +import CreateNamespaceButton from './CreateNamespaceButton'; export default function NamespacesList() { const { t } = useTranslation(['glossary', 'translation']); @@ -91,6 +92,7 @@ export default function NamespacesList() { ], noNamespaceFilter: true, }} {...resourceTableProps} diff --git a/frontend/src/components/namespace/__snapshots__/CreateNamespaceButton.badNamespaceData.stories.storyshot b/frontend/src/components/namespace/__snapshots__/CreateNamespaceButton.badNamespaceData.stories.storyshot new file mode 100644 index 00000000000..df46f87231a --- /dev/null +++ b/frontend/src/components/namespace/__snapshots__/CreateNamespaceButton.badNamespaceData.stories.storyshot @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/namespace/__snapshots__/CreateNamespaceButton.longNamespaceData.stories.storyshot b/frontend/src/components/namespace/__snapshots__/CreateNamespaceButton.longNamespaceData.stories.storyshot new file mode 100644 index 00000000000..df46f87231a --- /dev/null +++ b/frontend/src/components/namespace/__snapshots__/CreateNamespaceButton.longNamespaceData.stories.storyshot @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/namespace/__snapshots__/CreateNamespaceButton.newNamespaceData.stories.storyshot b/frontend/src/components/namespace/__snapshots__/CreateNamespaceButton.newNamespaceData.stories.storyshot new file mode 100644 index 00000000000..df46f87231a --- /dev/null +++ b/frontend/src/components/namespace/__snapshots__/CreateNamespaceButton.newNamespaceData.stories.storyshot @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/i18n/locales/de/translation.json b/frontend/src/i18n/locales/de/translation.json index 1f6392adcbf..a8698bef044 100644 --- a/frontend/src/i18n/locales/de/translation.json +++ b/frontend/src/i18n/locales/de/translation.json @@ -317,6 +317,11 @@ "Default Request": "Standardanforderung", "Max": "Max", "Min": "Min", + "Namespace name already exists.": "", + "Applying {{ newItemName }}…": "", + "Cancelled applying {{ newItemName }}.": "", + "Namespaces must be under 64 characters.": "", + "Create Namespace": "", "To": "An", "used": "benutzt", "Scheduling Disabled": "Planung deaktiviert", diff --git a/frontend/src/i18n/locales/en/translation.json b/frontend/src/i18n/locales/en/translation.json index 6f941488d53..273496a73b6 100644 --- a/frontend/src/i18n/locales/en/translation.json +++ b/frontend/src/i18n/locales/en/translation.json @@ -317,6 +317,11 @@ "Default Request": "Default Request", "Max": "Max", "Min": "Min", + "Namespace name already exists.": "Namespace name already exists.", + "Applying {{ newItemName }}…": "Applying {{ newItemName }}…", + "Cancelled applying {{ newItemName }}.": "Cancelled applying {{ newItemName }}.", + "Namespaces must be under 64 characters.": "Namespaces must be under 64 characters.", + "Create Namespace": "Create Namespace", "To": "To", "used": "used", "Scheduling Disabled": "Scheduling Disabled", diff --git a/frontend/src/i18n/locales/es/translation.json b/frontend/src/i18n/locales/es/translation.json index e4dd5ddd3bf..541162576ad 100644 --- a/frontend/src/i18n/locales/es/translation.json +++ b/frontend/src/i18n/locales/es/translation.json @@ -318,6 +318,11 @@ "Default Request": "Solicitud por defecto", "Max": "Máx.", "Min": "Mín.", + "Namespace name already exists.": "", + "Applying {{ newItemName }}…": "", + "Cancelled applying {{ newItemName }}.": "", + "Namespaces must be under 64 characters.": "", + "Create Namespace": "", "To": "A", "used": "usado", "Scheduling Disabled": "Agendamiento deshabilitado", diff --git a/frontend/src/i18n/locales/fr/translation.json b/frontend/src/i18n/locales/fr/translation.json index 008e183741a..361863e6d4b 100644 --- a/frontend/src/i18n/locales/fr/translation.json +++ b/frontend/src/i18n/locales/fr/translation.json @@ -318,6 +318,11 @@ "Default Request": "Demande par défaut", "Max": "Max", "Min": "Min", + "Namespace name already exists.": "", + "Applying {{ newItemName }}…": "", + "Cancelled applying {{ newItemName }}.": "", + "Namespaces must be under 64 characters.": "", + "Create Namespace": "", "To": "À", "used": "utilisé", "Scheduling Disabled": "Planification désactivée", diff --git a/frontend/src/i18n/locales/pt/translation.json b/frontend/src/i18n/locales/pt/translation.json index c715f4af8c4..d4598f7e8b3 100644 --- a/frontend/src/i18n/locales/pt/translation.json +++ b/frontend/src/i18n/locales/pt/translation.json @@ -318,6 +318,11 @@ "Default Request": "Pedido por defeito", "Max": "Máx.", "Min": "Mín.", + "Namespace name already exists.": "", + "Applying {{ newItemName }}…": "", + "Cancelled applying {{ newItemName }}.": "", + "Namespaces must be under 64 characters.": "", + "Create Namespace": "", "To": "Para", "used": "usado", "Scheduling Disabled": "Agendamento Desactivado", diff --git a/frontend/src/lib/k8s/index.test.ts b/frontend/src/lib/k8s/index.test.ts index 5a8fab79b90..9022e5ec88a 100644 --- a/frontend/src/lib/k8s/index.test.ts +++ b/frontend/src/lib/k8s/index.test.ts @@ -1,6 +1,7 @@ import { createRouteURL } from '../router'; import { labelSelectorToQuery, ResourceClasses } from '.'; import { KubeObjectClass, LabelSelector } from './cluster'; +import Namespace from './namespace'; // Remove NetworkPolicy since we don't use it. const k8sClassesToTest = Object.values(ResourceClasses).filter( @@ -264,3 +265,32 @@ describe('Test class namespaces', () => { expect(classCopy).toEqual({}); }); }); + +/** + * This is a function test for the Namespace class. + * note: this test will not work correctly if used in its own environment (i.e. namespace.test.ts) + */ +describe('Namespace testing', () => { + describe('test working isValidNamespaceFormat', () => { + it('should return true for valid namespace', () => { + expect(Namespace.isValidNamespaceFormat('valid-namespace')).toBe(true); + }); + + it('should return false for namespace longer than 63 characters', () => { + const longNamespace = 'a'.repeat(64); + expect(Namespace.isValidNamespaceFormat(longNamespace)).toBe(false); + }); + + it('should return false for namespace with invalid characters', () => { + expect(Namespace.isValidNamespaceFormat('invalid_namespace')).toBe(false); + }); + + it('should return false for namespace with capital letters', () => { + expect(Namespace.isValidNamespaceFormat('InvalidNamespace')).toBe(false); + }); + + it('should return false for empty namespace', () => { + expect(Namespace.isValidNamespaceFormat('')).toBe(false); + }); + }); +}); diff --git a/frontend/src/lib/k8s/namespace.ts b/frontend/src/lib/k8s/namespace.ts index ad454c37303..c838ee3e691 100644 --- a/frontend/src/lib/k8s/namespace.ts +++ b/frontend/src/lib/k8s/namespace.ts @@ -13,6 +13,24 @@ class Namespace extends makeKubeObject('namespace') { get status() { return this.jsonData!.status; } + + /** + * This function validates the custom namespace input matches the crieria for DNS-1123 label names. + * @returns true if the namespace is valid, false otherwise. + * @params namespace: string + * @see https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-label-names + */ + static isValidNamespaceFormat(namespace: string) { + // Validates that the namespace is under 64 characters. + if (namespace.length > 63) { + return false; + } + + // Validates that the namespace contains only lowercase alphanumeric characters or '-', + const regex = new RegExp('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$'); + + return regex.test(namespace); + } } export default Namespace;