Skip to content

Commit b45413a

Browse files
authored
List kubernetes providers in "no context" page (#368)
* feat: display kubernetes renderers Signed-off-by: Philippe Martin <[email protected]> * feat: simple markdown component Signed-off-by: Philippe Martin <[email protected]> * test: add unit tests Signed-off-by: Philippe Martin <[email protected]> --------- Signed-off-by: Philippe Martin <[email protected]>
1 parent 649be1b commit b45413a

File tree

4 files changed

+258
-2
lines changed

4 files changed

+258
-2
lines changed
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/**********************************************************************
2+
* Copyright (C) 2025 Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
* SPDX-License-Identifier: Apache-2.0
17+
***********************************************************************/
18+
19+
import '@testing-library/jest-dom/vitest';
20+
21+
import { render, screen } from '@testing-library/svelte';
22+
import { beforeEach, expect, test, vi } from 'vitest';
23+
import KubernetesProviderCard from '/@/component/dashboard/KubernetesProviderCard.svelte';
24+
import { RemoteMocks } from '/@/tests/remote-mocks';
25+
import { API_NAVIGATION } from '@kubernetes-dashboard/channels';
26+
import type { NavigationApi } from '@kubernetes-dashboard/channels';
27+
import userEvent from '@testing-library/user-event';
28+
29+
const remoteMocks = new RemoteMocks();
30+
31+
beforeEach(() => {
32+
vi.resetAllMocks();
33+
remoteMocks.reset();
34+
35+
remoteMocks.mock(API_NAVIGATION, {
36+
navigateToProviderNewConnection: vi.fn(),
37+
} as unknown as NavigationApi);
38+
});
39+
40+
test('should render with all values passed', async () => {
41+
render(KubernetesProviderCard, {
42+
provider: {
43+
id: 'k8s-provider',
44+
creationDisplayName: 'Kubernetes Provider',
45+
creationButtonTitle: 'Create Kubernetes Provider',
46+
emptyConnectionMarkdownDescription: 'Create a new Kubernetes Provider',
47+
},
48+
});
49+
screen.getByText('Kubernetes Provider');
50+
screen.getByText('Create Kubernetes Provider');
51+
const btn = screen.getByRole('button', { name: 'Create Kubernetes Provider' });
52+
expect(btn).toBeEnabled();
53+
await userEvent.click(btn);
54+
expect(remoteMocks.get(API_NAVIGATION).navigateToProviderNewConnection).toHaveBeenCalledWith('k8s-provider');
55+
});
56+
57+
test('should render with minimal values passed', async () => {
58+
render(KubernetesProviderCard, {
59+
provider: {
60+
id: 'k8s-provider',
61+
},
62+
});
63+
screen.getByText('Create');
64+
screen.getByText('Create new');
65+
const btn = screen.getByRole('button', { name: 'Create new' });
66+
expect(btn).toBeEnabled();
67+
await userEvent.click(btn);
68+
expect(remoteMocks.get(API_NAVIGATION).navigateToProviderNewConnection).toHaveBeenCalledWith('k8s-provider');
69+
});
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<script lang="ts">
2+
import { faPlusCircle } from '@fortawesome/free-solid-svg-icons';
3+
import { API_NAVIGATION, type KubernetesProvider } from '@kubernetes-dashboard/channels';
4+
import { Button } from '@podman-desktop/ui-svelte';
5+
import Fa from 'svelte-fa';
6+
import IconImage from '/@/component/icons/IconImage.svelte';
7+
import { getContext } from 'svelte';
8+
import { Remote } from '/@/remote/remote';
9+
import Markdown from '/@/markdown/Markdown.svelte';
10+
11+
interface Props {
12+
provider: KubernetesProvider;
13+
}
14+
15+
let { provider }: Props = $props();
16+
17+
const remote = getContext<Remote>(Remote);
18+
const navigationApi = remote.getProxy(API_NAVIGATION);
19+
20+
async function createNew(provider: KubernetesProvider): Promise<void> {
21+
return navigationApi.navigateToProviderNewConnection(provider.id);
22+
}
23+
</script>
24+
25+
<div class="rounded-xl p-5 text-left bg-[var(--pd-content-card-bg)]">
26+
<div class="flex justify-left text-[var(--pd-details-empty-icon)] py-2 mb-2">
27+
<IconImage image={provider?.images?.icon} class="mx-0 max-h-10" alt={provider.creationDisplayName}></IconImage>
28+
</div>
29+
<h1 class="text-lg font-semibold mb-4">
30+
{provider.creationDisplayName ?? 'Create'}
31+
</h1>
32+
33+
<p class="text-sm text-[var(--pd-content-text)] mb-6">
34+
<Markdown markdown={provider.emptyConnectionMarkdownDescription} />
35+
</p>
36+
37+
<div class="flex justify-center">
38+
<Button
39+
type="primary"
40+
on:click={(): Promise<void> => createNew(provider)}
41+
class="flex items-center"
42+
aria-label={provider.creationButtonTitle ?? 'Create new'}>
43+
<Fa icon={faPlusCircle} size="1.2x" class="mr-1" />
44+
{provider.creationButtonTitle ?? 'Create new'}
45+
</Button>
46+
</div>
47+
</div>
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/**********************************************************************
2+
* Copyright (C) 2025 Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
* SPDX-License-Identifier: Apache-2.0
17+
***********************************************************************/
18+
19+
import '@testing-library/jest-dom/vitest';
20+
21+
import { render, screen } from '@testing-library/svelte';
22+
import { beforeEach, expect, test, vi } from 'vitest';
23+
import NoContextPage from './NoContextPage.svelte';
24+
import { StatesMocks } from '/@/tests/state-mocks';
25+
import { FakeStateObject } from '/@/state/util/fake-state-object.svelte';
26+
import type { KubernetesProvidersInfo } from '@kubernetes-dashboard/channels';
27+
import KubeIcon from '/@/component/icons/KubeIcon.svelte';
28+
import KubernetesProviderCard from '/@/component/dashboard/KubernetesProviderCard.svelte';
29+
import type { Unsubscriber } from 'svelte/store';
30+
31+
vi.mock(import('./KubernetesProviderCard.svelte'));
32+
vi.mock(import('/@/component/icons/KubeIcon.svelte'));
33+
34+
const statesMocks = new StatesMocks();
35+
let kubernetesProvidersMock: FakeStateObject<KubernetesProvidersInfo, void>;
36+
37+
beforeEach(() => {
38+
vi.resetAllMocks();
39+
statesMocks.reset();
40+
41+
kubernetesProvidersMock = new FakeStateObject<KubernetesProvidersInfo, void>();
42+
statesMocks.mock<KubernetesProvidersInfo, void>('stateKubernetesProvidersInfoUI', kubernetesProvidersMock);
43+
});
44+
45+
test('should render the Kubernetes icon', () => {
46+
render(NoContextPage);
47+
expect(KubeIcon).toHaveBeenCalledWith(expect.anything(), { size: '80' });
48+
});
49+
50+
test('should render the main heading', () => {
51+
render(NoContextPage);
52+
53+
const heading = screen.getByRole('heading', { level: 1 });
54+
expect(heading).toHaveTextContent('No Kubernetes cluster');
55+
});
56+
57+
test('should render the description text', () => {
58+
render(NoContextPage);
59+
60+
const description = screen.getByText(/A Kubernetes cluster is a group of nodes/);
61+
expect(description).toBeInTheDocument();
62+
expect(description).toHaveClass('text-[var(--pd-details-empty-sub-header)]', 'text-balance');
63+
});
64+
65+
test('should render providers when data is available', () => {
66+
const mockProviders: KubernetesProvidersInfo = {
67+
providers: [
68+
{
69+
id: 'provider-1',
70+
creationDisplayName: 'Provider 1',
71+
creationButtonTitle: 'Create Provider 1',
72+
emptyConnectionMarkdownDescription: 'Description 1',
73+
},
74+
{
75+
id: 'provider-2',
76+
creationDisplayName: 'Provider 2',
77+
creationButtonTitle: 'Create Provider 2',
78+
emptyConnectionMarkdownDescription: 'Description 2',
79+
},
80+
],
81+
};
82+
83+
kubernetesProvidersMock.setData(mockProviders);
84+
render(NoContextPage);
85+
86+
expect(KubernetesProviderCard).toHaveBeenCalledWith(expect.anything(), { provider: mockProviders.providers[0] });
87+
expect(KubernetesProviderCard).toHaveBeenCalledWith(expect.anything(), { provider: mockProviders.providers[1] });
88+
});
89+
90+
test('should handle providers with minimal data', () => {
91+
const mockProviders = {
92+
providers: [
93+
{
94+
id: 'minimal-provider',
95+
},
96+
],
97+
};
98+
99+
kubernetesProvidersMock.setData(mockProviders);
100+
render(NoContextPage);
101+
102+
expect(KubernetesProviderCard).toHaveBeenCalledWith(expect.anything(), { provider: mockProviders.providers[0] });
103+
});
104+
105+
test('should call subscribe on mount', () => {
106+
render(NoContextPage);
107+
108+
expect(kubernetesProvidersMock.subscribe).toHaveBeenCalledTimes(1);
109+
});
110+
111+
test('should call unsubscribe on unmount', () => {
112+
const unsubscribeMock: Unsubscriber = vi.fn();
113+
vi.mocked(kubernetesProvidersMock.subscribe).mockReturnValue(unsubscribeMock);
114+
const component = render(NoContextPage);
115+
116+
component.unmount();
117+
expect(unsubscribeMock).toHaveBeenCalledTimes(1);
118+
});
Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,38 @@
11
<script lang="ts">
22
import KubeIcon from '/@/component/icons/KubeIcon.svelte';
3+
import { getContext, onDestroy, onMount } from 'svelte';
4+
import { States } from '/@/state/states';
5+
import type { Unsubscriber } from 'svelte/store';
6+
import KubernetesProviderCard from '/@/component/dashboard/KubernetesProviderCard.svelte';
7+
8+
const states = getContext<States>(States);
9+
const kubernetesProviders = states.stateKubernetesProvidersInfoUI;
10+
11+
let unsubscribers: Unsubscriber[] = [];
12+
onMount(() => {
13+
unsubscribers.push(kubernetesProviders.subscribe());
14+
});
15+
16+
onDestroy(() => {
17+
unsubscribers.forEach(unsubscriber => unsubscriber());
18+
unsubscribers = [];
19+
});
320
</script>
421

522
<div class="mt-8 flex justify-center overflow-auto">
6-
<div class="max-w-[600px] flex flex-col text-center space-y-3">
23+
<div class="max-w-[800px] flex flex-col text-center content-center space-y-3">
724
<div class="flex justify-center text-[var(--pd-details-empty-icon)] py-2">
825
<KubeIcon size="80" />
926
</div>
1027
<h1 class="text-xl text-[var(--pd-details-empty-header)]">No Kubernetes cluster</h1>
11-
<div class="text-[var(--pd-details-empty-sub-header)] text-pretty">
28+
<div class="text-[var(--pd-details-empty-sub-header)] text-balance">
1229
A Kubernetes cluster is a group of nodes (virtual or physical) that run Kubernetes, a system for automating the
1330
deployment and management of containerized applications.
1431
</div>
32+
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 p-2 justify-center">
33+
{#each kubernetesProviders.data?.providers as provider (provider.id)}
34+
<KubernetesProviderCard provider={provider} />
35+
{/each}
36+
</div>
1537
</div>
1638
</div>

0 commit comments

Comments
 (0)