Skip to content

Commit

Permalink
frontend: Add create namespace ui
Browse files Browse the repository at this point in the history
Signed-off-by: Vincent T <vtaylor@microsoft.com>
  • Loading branch information
vyncent-t committed Jun 26, 2024
1 parent 31234c3 commit 49b4eb5
Show file tree
Hide file tree
Showing 13 changed files with 272 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -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 (
<div style={{ margin: '4em' }}>
<Story />
</div>
);
},
],
} as Meta;

const Template: StoryFn = () => {
return (
<TestContext>
<CreateNamespaceButton />
</TestContext>
);
};

export const Regular = Template.bind({});
166 changes: 166 additions & 0 deletions frontend/src/components/namespace/CreateNamespaceButton.tsx
Original file line number Diff line number Diff line change
@@ -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, AuthVisible } 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 (
<AuthVisible item={Namespace} authVerb="create">
<ActionButton
color="primary"
description={t('translation|Create')}
icon={'mdi:plus-circle'}
onClick={() => {
setNamespaceDialogOpen(true);
}}
/>

{namespaceDialogOpen && (
<Dialog open={namespaceDialogOpen} onClose={() => setNamespaceDialogOpen(false)}>
<DialogTitle>{t('translation|Create Namespace')}</DialogTitle>
<DialogContent>
<Box component="form" style={{ width: '20vw', maxWidth: '20vw' }}>
<TextField
margin="dense"
id="name"
label={t('translation|Name')}
type="text"
error={!namespaceNameValid && namespaceName.length > 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,
});
}
}
}}
/>
</Box>
</DialogContent>
<DialogActions>
<Button
aria-label="Cancel"
onClick={() => {
setNamespaceDialogOpen(false);
}}
>
{t('translation|Cancel')}
</Button>
<Button
aria-label="Create"
disabled={!namespaceNameValid}
onClick={() => {
createNewNamespace();
dispatchCreateEvent({
status: EventStatus.CONFIRMED,
});
}}
>
{t('translation|Create')}
</Button>
</DialogActions>
</Dialog>
)}
</AuthVisible>
);
}
2 changes: 2 additions & 0 deletions frontend/src/components/namespace/List.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
ResourceTableFromResourceClassProps,
ResourceTableProps,
} from '../common/Resource/ResourceTable';
import CreateNamespaceButton from './CreateNamespaceButton';

export default function NamespacesList() {
const { t } = useTranslation(['glossary', 'translation']);
Expand Down Expand Up @@ -91,6 +92,7 @@ export default function NamespacesList() {
<ResourceListView
title={t('Namespaces')}
headerProps={{
titleSideActions: [<CreateNamespaceButton />],
noNamespaceFilter: true,
}}
{...resourceTableProps}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<DocumentFragment />
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<DocumentFragment />
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<DocumentFragment />
5 changes: 5 additions & 0 deletions frontend/src/i18n/locales/de/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/i18n/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/i18n/locales/es/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/i18n/locales/fr/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/i18n/locales/pt/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
30 changes: 30 additions & 0 deletions frontend/src/lib/k8s/index.test.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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);
});
});
});
18 changes: 18 additions & 0 deletions frontend/src/lib/k8s/namespace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,24 @@ class Namespace extends makeKubeObject<KubeNamespace>('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;

0 comments on commit 49b4eb5

Please sign in to comment.