Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add examples page #942

Merged
merged 5 commits into from
Oct 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions packages/backend/assets/examples.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"tag": "latest",
"categories": ["fedora"],
"architectures": ["amd64"],
"basedir": "httpd"
"basedir": "httpd",
"size": 2000000000
},
{
"id": "fedora-tailscale",
Expand All @@ -21,7 +22,8 @@
"tag": "latest",
"categories": ["fedora"],
"architectures": ["amd64"],
"basedir": "tailscale"
"basedir": "tailscale",
"size": 1720000000
},
{
"id": "fedora-podman-systemd",
Expand All @@ -32,7 +34,8 @@
"tag": "latest",
"categories": ["fedora"],
"architectures": ["amd64"],
"basedir": "app-podman-systemd"
"basedir": "app-podman-systemd",
"size": 1530000000
}
],
"categories": [
Expand Down
4 changes: 4 additions & 0 deletions packages/frontend/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { getRouterState } from './api/client';
import { rpcBrowser } from '/@/api/client';
import { Messages } from '/@shared/src/messages/Messages';
import DiskImageDetails from './lib/disk-image/DiskImageDetails.svelte';
import Examples from './Examples.svelte';
import Navigation from './Navigation.svelte';
import DiskImagesList from './lib/disk-image/DiskImagesList.svelte';
import Dashboard from './lib/dashboard/Dashboard.svelte';
Expand Down Expand Up @@ -37,6 +38,9 @@ onMount(() => {
<Route path="/" breadcrumb="Dashboard">
<Dashboard />
</Route>
<Route path="/examples" breadcrumb="Examples">
<Examples />
</Route>
<Route path="/disk-images/" breadcrumb="Disk Images">
<DiskImagesList />
</Route>
Expand Down
140 changes: 140 additions & 0 deletions packages/frontend/src/Examples.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**********************************************************************
* Copyright (C) 2024 Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/

import '@testing-library/jest-dom/vitest';

import { render, screen, waitFor } from '@testing-library/svelte';
import { expect, test, vi } from 'vitest';
import type { ExamplesList } from '/@shared/src/models/examples';
import type { ImageInfo } from '@podman-desktop/api';

import Examples from './Examples.svelte';
import { bootcClient } from './api/client';
import { tick } from 'svelte';

vi.mock('./api/client', async () => {
return {
bootcClient: {
getExamples: vi.fn(),
listBootcImages: vi.fn(),
},
rpcBrowser: {
subscribe: () => {
return {
unsubscribe: () => {},
};
},
},
};
});

// Create a mock ExamplesList
const examplesList: ExamplesList = {
examples: [
{
id: 'example1',
name: 'Example 1',
categories: ['Category 1'],
description: 'Description 1',
repository: 'https://example.com/example1',
image: 'quay.io/example/example1',
tag: 'latest',
},
{
id: 'example2',
name: 'Example 2',
categories: ['Category 2'],
description: 'Description 2',
repository: 'https://example.com/example2',
image: 'quay.io/example/example2',
tag: 'latest',
},
],
categories: [
{
id: 'Category 1',
name: 'Category 1',
},
{
id: 'Category 2',
name: 'Category 2',
},
],
};

// Mock the list of images returned, make sure example1 is in it, only mock the RepoTags since that is what is used / checked and nothing else
const imagesList = [
{
RepoTags: ['quay.io/example/example1:latest'],
},
] as ImageInfo[];

test('Test examples render correctly', async () => {
// Mock the getExamples method
vi.mocked(bootcClient.getExamples).mockResolvedValue(examplesList);

// Render the examples component
render(Examples);

// Wait for 2 examples to appear, aria-label="Example 1" and aria-label="Example 2"
await screen.findByLabelText('Example 1');
await screen.findByLabelText('Example 2');

// Confirm example 1 and 2 exist
const example1 = screen.getByLabelText('Example 1');
expect(example1).toBeInTheDocument();
const example2 = screen.getByLabelText('Example 2');
expect(example2).toBeInTheDocument();
});

test('Test examples correctly marks examples either Pull image or Build image depending on availability', async () => {
// Mock the getExamples method
vi.mocked(bootcClient.getExamples).mockResolvedValue(examplesList);

// Mock image list has example1 in it (button should be Build Image instead of Pull Image)
vi.mocked(bootcClient.listBootcImages).mockResolvedValue(imagesList);

// Render the examples component
render(Examples);

// Wait for aria-label aria-label="Example 1" to appear
await screen.findByLabelText('Example 1');

// Confirm that the example 1 is rendered
const example1 = screen.getByLabelText('Example 1');
expect(example1).toBeInTheDocument();

// Confirm example 2 is rendered
const example2 = screen.getByLabelText('Example 2');
expect(example2).toBeInTheDocument();

// Use the tick function to wait for the next render (updating pulled state)
await tick();

// Wait until example1 says Build image as it updates reactively
// same for example 2 but Pull image
await waitFor(() => {
const buildImage1 = example1.querySelector('[aria-label="Build image"]');
expect(buildImage1).toBeInTheDocument();
});

await waitFor(() => {
const pullImage2 = example2.querySelector('[aria-label="Pull image"]');
expect(pullImage2).toBeInTheDocument();
});
});
56 changes: 56 additions & 0 deletions packages/frontend/src/Examples.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<script lang="ts">
import type { Example, Category } from '/@shared/src/models/examples';
import { onMount } from 'svelte';
import { NavPage } from '@podman-desktop/ui-svelte';
import { bootcClient } from './api/client';
import ExamplesCard from './lib/ExamplesCard.svelte';

let groups: Map<Category, Example[]> = new Map();

const UNCLASSIFIED: Category = {
id: 'unclassified',
name: 'Unclassified',
};

onMount(async () => {
// onmount get the examples
let examples = await bootcClient.getExamples();
benoitf marked this conversation as resolved.
Show resolved Hide resolved

const categoryDict = Object.fromEntries(examples.categories.map((category: { id: any }) => [category.id, category]));

const output: Map<Category, Example[]> = new Map();

for (const example of examples.examples) {
if (example.categories.length === 0) {
output.set(UNCLASSIFIED, [...(output.get(UNCLASSIFIED) ?? []), example]);
continue;
}

// iterate over all categories
for (const categoryId of example.categories) {
let key: Category;
if (categoryId in categoryDict) {
key = categoryDict[categoryId];
} else {
key = UNCLASSIFIED;
}

output.set(key, [...(output.get(key) ?? []), example]);
}
}

groups = output;
});
</script>

<NavPage title="Examples" searchEnabled={false}>
<div slot="content" class="flex flex-col min-w-full min-h-full">
<div class="min-w-full min-h-full flex-1">
<div class="px-5 space-y-5">
{#each groups.entries() as [category, examples]}
<ExamplesCard category={category} examples={examples} />
{/each}
</div>
</div>
</div>
</NavPage>
1 change: 1 addition & 0 deletions packages/frontend/src/Navigation.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,6 @@ export let meta: TinroRouteMeta;
<div class="h-full overflow-hidden hover:overflow-y-auto" style="margin-bottom:auto">
<SettingsNavItem title="Dashboard" selected={meta.url === '/'} href="/" />
<SettingsNavItem title="Disk Images" selected={meta.url.startsWith('/disk-image')} href="/disk-images" />
<SettingsNavItem title="Examples" selected={meta.url.startsWith('/examples')} href="/examples" />
</div>
</nav>
34 changes: 34 additions & 0 deletions packages/frontend/src/lib/Card.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<script lang="ts">
cdrage marked this conversation as resolved.
Show resolved Hide resolved
interface Props {
title: string;
description?: string;
href?: string;
}
let { title, description, href }: Props = $props();
</script>

<a class="no-underline" href={href}>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
<a class="no-underline" href={href}>
<a class="no-underline" {href}>

<div class="font-medium mt-4 rounded-md flex-nowrap overflow-hidden" role="region" aria-label={title ?? 'Card'}>
<div class="flex flex-row">
<div class="flex flex-row items-start">
<div
class="flex flex-col text-[var(--pd-content-card-text)] whitespace-normal space-y-2"
aria-label="context-name">
{#if title}
<div>
{title}
</div>
{/if}
{#if description}
<div>
{description}
</div>
{/if}
</div>
</div>
</div>
<div class="flex overflow-hidden" role="region" aria-label="content">
<slot name="content" />
</div>
</div>
</a>
Loading