Skip to content

Commit c07360c

Browse files
committed
feat: add catalog UI to browse the catalog
related to podman-desktop/podman-desktop#8972 Signed-off-by: Florent Benoit <[email protected]> Change-Id: I9e121490ccf65275ec1273f716d9d7a8e26d791a
1 parent e93d0c3 commit c07360c

23 files changed

+1111
-9
lines changed

src/app.d.ts

+18
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,21 @@
1+
/**********************************************************************
2+
* Copyright (C) 2024 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+
119
/// <reference types="svelte" />
220

321
// See https://kit.svelte.dev/docs/types#app

src/lib/Appearance.spec.ts

+114
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**********************************************************************
2+
* Copyright (C) 2024 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 } from '@testing-library/svelte';
22+
import { beforeEach, expect, test, vi } from 'vitest';
23+
24+
import Appearance from './Appearance.svelte';
25+
26+
const addEventListenerMock = vi.fn();
27+
28+
beforeEach(() => {
29+
vi.resetAllMocks();
30+
window.matchMedia = vi.fn().mockReturnValue({
31+
matches: false,
32+
addEventListener: addEventListenerMock,
33+
removeEventListener: vi.fn(),
34+
});
35+
});
36+
37+
function getRootElement(container: HTMLElement): HTMLElement {
38+
// get root html element
39+
let rootElement: HTMLElement | null = container;
40+
let loop = 0;
41+
while (rootElement?.parentElement && loop < 10) {
42+
rootElement = container.parentElement;
43+
loop++;
44+
}
45+
return rootElement as HTMLElement;
46+
}
47+
48+
function getRootElementClassesValue(container: HTMLElement): string | undefined {
49+
return getRootElement(container).classList.value;
50+
}
51+
52+
test('check initial light theme', async () => {
53+
const { baseElement } = render(Appearance, {});
54+
// expect to have no (dark) class as OS is using light
55+
await vi.waitFor(() => expect(getRootElementClassesValue(baseElement)).toBe(''));
56+
});
57+
58+
test('check initial dark theme', async () => {
59+
window.matchMedia = vi.fn().mockReturnValue({
60+
matches: true,
61+
addEventListener: addEventListenerMock,
62+
removeEventListener: vi.fn(),
63+
});
64+
const { baseElement } = render(Appearance, {});
65+
// expect to have no (dark) class as OS is using light
66+
await vi.waitFor(() => expect(getRootElementClassesValue(baseElement)).toBe('dark'));
67+
});
68+
69+
test('Expect event being changed when changing the default appearance on the operating system', async () => {
70+
// initial is dark
71+
window.matchMedia = vi.fn().mockReturnValue({
72+
matches: true,
73+
addEventListener: addEventListenerMock,
74+
removeEventListener: vi.fn(),
75+
});
76+
77+
let userCallback: () => void = () => {};
78+
addEventListenerMock.mockImplementation((event: string, callback: () => void) => {
79+
if (event === 'change') {
80+
userCallback = callback;
81+
}
82+
});
83+
84+
const { baseElement } = render(Appearance, {});
85+
86+
// check it's dark
87+
expect(getRootElementClassesValue(baseElement)).toBe('dark');
88+
89+
// now change to light
90+
window.matchMedia = vi.fn().mockReturnValue({
91+
matches: false,
92+
addEventListener: addEventListenerMock,
93+
removeEventListener: vi.fn(),
94+
});
95+
96+
// call the callback on matchMedia
97+
userCallback();
98+
99+
// check if it's now light
100+
await vi.waitFor(() => expect(getRootElementClassesValue(baseElement)).toBe(''));
101+
102+
// now change to dark
103+
window.matchMedia = vi.fn().mockReturnValue({
104+
matches: true,
105+
addEventListener: addEventListenerMock,
106+
removeEventListener: vi.fn(),
107+
});
108+
109+
// call again the callback on matchMedia
110+
userCallback();
111+
112+
// check if it's now dark
113+
await vi.waitFor(() => expect(getRootElementClassesValue(baseElement)).toBe('dark'));
114+
});

src/lib/Appearance.svelte

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<script lang="ts">
2+
import { onMount } from 'svelte';
3+
4+
let isDarkTheme = $state(window.matchMedia('(prefers-color-scheme: dark)').matches);
5+
6+
$effect(() => {
7+
const html = document.documentElement;
8+
9+
// toggle the dark class on the html element
10+
if (isDarkTheme) {
11+
html.classList.add('dark');
12+
html.setAttribute('style', 'color-scheme: dark;');
13+
} else {
14+
html.classList.remove('dark');
15+
html.setAttribute('style', 'color-scheme: light;');
16+
}
17+
});
18+
19+
onMount(async () => {
20+
// add a listener for the appearance change in case user change setting on the Operating System
21+
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
22+
isDarkTheme = window.matchMedia('(prefers-color-scheme: dark)').matches;
23+
});
24+
});
25+
</script>

src/lib/Markdown.svelte

+103
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
<script lang="ts">
2+
import { micromark } from 'micromark';
3+
import { directive } from 'micromark-extension-directive';
4+
5+
let html: string | undefined = $state(undefined);
6+
7+
// content to use in the markdown
8+
const { markdown }: { markdown: string } = $props();
9+
10+
$effect(() => {
11+
const tempHtml = micromark(markdown, {
12+
extensions: [directive()],
13+
htmlExtensions: [],
14+
});
15+
16+
if (!tempHtml) {
17+
return;
18+
}
19+
const parser = new DOMParser();
20+
const doc = parser.parseFromString(tempHtml, 'text/html');
21+
const links = doc.querySelectorAll('a');
22+
for (const link of links) {
23+
const currentHref = link.getAttribute('href');
24+
// remove and replace href attribute if matching
25+
if (currentHref?.startsWith('#')) {
26+
// get current value of href
27+
link.removeAttribute('href');
28+
29+
// remove from current href the #
30+
const withoutHashHRef = currentHref.substring(1);
31+
32+
// add an attribute to handle onclick
33+
link.setAttribute('data-pd-jump-in-page', withoutHashHRef);
34+
35+
// add a class for cursor
36+
link.classList.add('cursor-pointer');
37+
}
38+
}
39+
40+
// for all h1/h2/h3/h4/h5/h6, add an id attribute being the name of the attibute all in lowercase without spaces (replaced by -)
41+
const headers = doc.querySelectorAll('h1, h2, h3, h4, h5, h6');
42+
for (const header of headers) {
43+
const headerText = header.textContent;
44+
const headerId = headerText?.toLowerCase().replace(/\s/g, '-');
45+
if (headerId) {
46+
header.setAttribute('id', headerId);
47+
}
48+
}
49+
50+
html = doc.body.innerHTML;
51+
});
52+
</script>
53+
54+
<section class="markdown" aria-label="markdown-content">
55+
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
56+
{@html html}
57+
</section>
58+
59+
<!-- The markdown rendered has it's own style that you'll have to customize / check against podman desktop
60+
UI guidelines -->
61+
<style lang="postcss">
62+
.markdown > :global(p) {
63+
line-height: normal;
64+
padding-bottom: 8px;
65+
margin-bottom: 8px;
66+
}
67+
68+
.markdown > :global(h1),
69+
:global(h2),
70+
:global(h3),
71+
:global(h4),
72+
:global(h5) {
73+
font-size: revert;
74+
line-height: normal;
75+
font-weight: revert;
76+
border-bottom: 1px solid #444;
77+
margin-bottom: 20px;
78+
}
79+
80+
.markdown > :global(ul) {
81+
line-height: normal;
82+
list-style: revert;
83+
margin: revert;
84+
padding: revert;
85+
}
86+
87+
.markdown > :global(b),
88+
:global(strong) {
89+
font-weight: 600;
90+
}
91+
.markdown > :global(blockquote) {
92+
opacity: 0.8;
93+
line-height: normal;
94+
}
95+
.markdown :global(a) {
96+
color: theme(colors.purple.500);
97+
text-decoration: none;
98+
}
99+
.markdown :global(a):hover {
100+
color: theme(colors.purple.400);
101+
text-decoration: underline;
102+
}
103+
</style>

src/lib/api/extensions-info.ts

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
export interface CatalogExtensionVersionFileInfo {
2+
assetType: 'icon' | 'LICENSE' | 'README';
3+
data: string;
4+
}
5+
6+
export interface CatalogExtensionVersionInfo {
7+
version: string;
8+
podmanDesktopVersion?: string;
9+
ociUri: string;
10+
preview: boolean;
11+
lastUpdated: Date;
12+
files: CatalogExtensionVersionFileInfo[];
13+
}
14+
15+
export interface CatalogExtensionInfo {
16+
id: string;
17+
publisherName: string;
18+
publisherDisplayName: string;
19+
extensionName: string;
20+
categories: string[];
21+
shortDescription: string;
22+
displayName: string;
23+
keywords: string[];
24+
unlisted: boolean;
25+
versions: CatalogExtensionVersionInfo[];
26+
}
27+
28+
export interface ExtensionByCategoryInfo {
29+
category: string;
30+
extensions: CatalogExtensionInfo[];
31+
}

src/lib/extensions.svelte.spec.ts

+59
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**********************************************************************
2+
* Copyright (C) 2024 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 { beforeEach, describe, expect, test, vi } from 'vitest';
20+
21+
import catalogOfExtensions from '../../static/api/extensions.json';
22+
import type { CatalogExtensionInfo } from './api/extensions-info';
23+
import { catalogExtensions, getCurrentExtension, initCatalog, setCurrentExtension } from './extensions.svelte';
24+
25+
const fetchMock = vi.fn();
26+
27+
describe('check catalog', () => {
28+
beforeEach(() => {
29+
vi.resetAllMocks();
30+
catalogExtensions.length = 0;
31+
window.fetch = fetchMock;
32+
});
33+
34+
test('check fetch', async () => {
35+
fetchMock.mockResolvedValue({
36+
ok: true,
37+
json: async () => ({ extensions: catalogOfExtensions.extensions }),
38+
});
39+
await initCatalog();
40+
41+
expect(vi.mocked(fetch)).toHaveBeenCalledWith('https://registry.podman-desktop.io/api/extensions.json');
42+
43+
// check we have extensions in the catalog
44+
expect(catalogExtensions.length).toBeGreaterThan(0);
45+
});
46+
});
47+
48+
test('check current extension', () => {
49+
const currentExtension = getCurrentExtension();
50+
expect(currentExtension.value).toBeUndefined();
51+
setCurrentExtension({ id: 'dummy' } as unknown as CatalogExtensionInfo);
52+
53+
// check again the value
54+
expect(currentExtension.value).toEqual({ id: 'dummy' });
55+
56+
// unset
57+
setCurrentExtension(undefined);
58+
expect(currentExtension.value).toBeUndefined();
59+
});

0 commit comments

Comments
 (0)