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 && (
+
+ )}
+ >
+ );
+}
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;