Skip to content

Commit

Permalink
frontend: Add create resource UI
Browse files Browse the repository at this point in the history
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 <eskopelitis@microsoft.com>

Makefile: Add backend-coverage and backend-coverage-html

To see the full coverage report in text or html in a browser.

Signed-off-by: René Dudfield <renedudfield@microsoft.com>

.github/workflows/backend-test: Add coverage report to PR comment

This adds a line to the PR comment with a collapsable toggle
so the user can click to see the full coverage report.

Signed-off-by: René Dudfield <renedudfield@microsoft.com>

.github/workflows/backend-test: Add coverage html report

As an artifact, and link to the artifact to view in the browser.

Signed-off-by: René Dudfield <renedudfield@microsoft.com>

frontend CreateNamespaceButton: Remove aria label for create/cancel

It was unnecessary.

Signed-off-by: Vincent T <vtaylor@microsoft.com>

frontend CreateNamespaceButton: Add Create Namespace label

For the action button.

Signed-off-by: Vincent T <vtaylor@microsoft.com>

frontend CreateNamespaceButton: Add data-testid for all buttons

Signed-off-by: Vincent T <vtaylor@microsoft.com>
  • Loading branch information
skoeva committed Sep 27, 2024
1 parent bacabbe commit 6f3ad30
Show file tree
Hide file tree
Showing 33 changed files with 1,449 additions and 180 deletions.
35 changes: 35 additions & 0 deletions .github/workflows/backend-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,25 @@ jobs:
set -x
cd backend
go test ./... -coverprofile=coverage.out -covermode=atomic -coverpkg=./...
go tool cover -html=coverage.out -o backend_coverage.html
testcoverage_full=$(go tool cover -func=coverage.out)
testcoverage=$(go tool cover -func=coverage.out | grep total | grep -Eo '[0-9]+\.[0-9]+')
testcoverage_full_base64=$(echo "$testcoverage_full" | base64 -w 0)
echo "Code coverage: $testcoverage"
echo "$testcoverage_full"
echo "coverage=$testcoverage" >> $GITHUB_ENV
echo "testcoverage_full_base64=$testcoverage_full_base64" >> $GITHUB_ENV
echo "cleaning up..."
rm ~/.config/Headlamp/kubeconfigs/config
shell: bash

- name: Upload coverage report as artifact
id: upload-artifact
uses: actions/upload-artifact@v4
with:
name: backend-coverage-report
path: ./backend/backend_coverage.html

- name: Get base branch code coverage
if: ${{ github.event_name }} == 'pull_request'
run: |
Expand Down Expand Up @@ -125,8 +137,13 @@ jobs:
exit 0
fi
testcoverage="${{ env.coverage }}"
testcoverage_full_base64="${{ env.testcoverage_full_base64 }}"
testcoverage_full=$(echo "$testcoverage_full_base64" | base64 --decode)
base_coverage="${{ env.base_coverage }}"
coverage_diff="${{ env.coverage_diff }}"
artifact_url=${{ steps.upload-artifact.outputs.artifact-url }}
if (( $(echo "$coverage_diff < 0" | bc -l) )); then
emoji="😞" # Decreased coverage
else
Expand All @@ -135,6 +152,24 @@ jobs:
comment="Backend Code coverage changed from $base_coverage% to $testcoverage%. Change: $coverage_diff% $emoji."
echo "$comment"
# Add the full coverage report as a collapsible section
comment="${comment}
<details>
<summary>Coverage report</summary>
\`\`\`
$testcoverage_full
\`\`\`
</details>
[Html coverage report download]($artifact_url)
"
echo "$comment"
if [[ "${{github.event.pull_request.head.repo.full_name}}" == "${{github.repository}}" ]]; then
# Forks (like dependabot ones) do not have permission to comment on the PR,
# so do not fail the action if this fails.
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
backend/headlamp-server
backend/headlamp-server.exe
backend/tools
backend/coverage.out
app/electron/src/*
docs/development/storybook/
.plugins
Expand Down
10 changes: 10 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ backend:
backend-test:
cd backend && go test -v -p 1 ./...

.PHONY: backend-coverage
backend-coverage:
cd backend && go test -v -p 1 -coverprofile=coverage.out ./...
cd backend && go tool cover -func=coverage.out

.PHONY: backend-coverage-html
backend-coverage-html:
cd backend && go test -v -p 1 -coverprofile=coverage.out ./...
cd backend && go tool cover -html=coverage.out

.PHONY: backend-format
backend-format:
cd backend && go fmt ./cmd/ ./pkg/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -498,39 +498,7 @@
</h1>
<div
class="MuiBox-root css-ldp2l3"
>
<label
class="MuiFormControlLabel-root MuiFormControlLabel-labelPlacementEnd css-j204z7-MuiFormControlLabel-root"
>
<span
class="MuiSwitch-root MuiSwitch-sizeMedium css-julti5-MuiSwitch-root"
>
<span
class="MuiButtonBase-root MuiSwitch-switchBase MuiSwitch-colorPrimary Mui-checked PrivateSwitchBase-root MuiSwitch-switchBase MuiSwitch-colorPrimary Mui-checked Mui-checked css-1nsozxe-MuiButtonBase-root-MuiSwitch-switchBase"
>
<input
checked=""
class="PrivateSwitchBase-input MuiSwitch-input css-1m9pwf3"
type="checkbox"
/>
<span
class="MuiSwitch-thumb css-jsexje-MuiSwitch-thumb"
/>
<span
class="MuiTouchRipple-root css-8je8zh-MuiTouchRipple-root"
/>
</span>
<span
class="MuiSwitch-track css-1yjjitx-MuiSwitch-track"
/>
</span>
<span
class="MuiTypography-root MuiTypography-body1 MuiFormControlLabel-label css-1ezega9-MuiTypography-root"
>
Only warnings (0)
</span>
</label>
</div>
/>
</div>
</div>
<div
Expand Down
98 changes: 98 additions & 0 deletions frontend/src/components/common/CreateResourceButton.stories.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Provider store={store}>
<TestContext>
<Story />
</TestContext>
</Provider>
);
},
],
} as Meta;

type Story = StoryObj<CreateResourceButtonProps>;

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,
});
},
};
41 changes: 41 additions & 0 deletions frontend/src/components/common/CreateResourceButton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<React.Fragment>
<ActionButton
color="primary"
description={t('translation|Create {{ resourceName }}', { resourceName })}
icon={'mdi:plus-circle'}
onClick={() => {
setOpenDialog(true);
}}
/>
<EditorDialog
item={baseObject}
open={openDialog}
onClose={() => setOpenDialog(false)}
onSave={() => setOpenDialog(false)}
saveLabel={t('translation|Apply')}
errorMessage={errorMessage}
onEditorChanged={() => setErrorMessage('')}
title={t('translation|Create {{ resourceName }}', { resourceName })}
/>
</React.Fragment>
);
}
94 changes: 1 addition & 93 deletions frontend/src/components/common/Resource/CreateButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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 (
<React.Fragment>
Expand Down Expand Up @@ -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('')}
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/common/Resource/EditButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ export default function EditButton(props: EditButtonProps) {
onSave={handleSave}
errorMessage={errorMessage}
onEditorChanged={() => setErrorMessage('')}
applyOnSave
/>
)}
</AuthVisible>
Expand Down
Loading

0 comments on commit 6f3ad30

Please sign in to comment.