Skip to content

Commit

Permalink
feat: getting started carousel on dashboard page (podman-desktop#5142)
Browse files Browse the repository at this point in the history
* Carousel component to show cards and rotate them with left and right buttons
* Learning center carousel for dashboard with links to the podman desktop guides

Signed-off-by: Denis Golovin <[email protected]>
  • Loading branch information
dgolovin authored Feb 13, 2024
1 parent f8ba14b commit 4bddf04
Show file tree
Hide file tree
Showing 11 changed files with 367 additions and 1 deletion.
5 changes: 5 additions & 0 deletions packages/main/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ import { WebviewRegistry } from './webview/webview-registry.js';
import type { IDisposable } from './types/disposable.js';

import { KubernetesUtils } from './kubernetes-util.js';
import { downloadGuideList } from './learning-center/learning-center.js';

type LogType = 'log' | 'warn' | 'trace' | 'debug' | 'error';

Expand Down Expand Up @@ -2217,6 +2218,10 @@ export class PluginSystem {
return webviewRegistry.getRegistryHttpPort();
});

this.ipcHandle('learning-center:listGuides', async () => {
return downloadGuideList();
});

const dockerDesktopInstallation = new DockerDesktopInstallation(
apiSender,
containerProviderRegistry,
Expand Down
44 changes: 44 additions & 0 deletions packages/main/src/plugin/learning-center/guides.json

Large diffs are not rendered by default.

26 changes: 26 additions & 0 deletions packages/main/src/plugin/learning-center/learning-center-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**********************************************************************
* Copyright (C) 2023 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
***********************************************************************/

export interface Guide {
id: string;
url: string;
title: string;
description: string;
categories: string[];
icon: string;
}
24 changes: 24 additions & 0 deletions packages/main/src/plugin/learning-center/learning-center.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**********************************************************************
* Copyright (C) 2023 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 type { Guide } from './learning-center-api.js';
import { default as guidesJson } from './guides.json';

export function downloadGuideList(): Guide[] {
return guidesJson.guides;
}
5 changes: 5 additions & 0 deletions packages/preload/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import type { FeaturedExtension } from '../../main/src/plugin/featured/featured-
import type { CatalogExtension } from '../../main/src/plugin/extensions-catalog/extensions-catalog-api';
import type { CommandInfo } from '../../main/src/plugin/api/command-info';
import type { KubernetesInformerResourcesType } from '../../main/src/plugin/api/kubernetes-informer-info';
import type { Guide } from '../../main/src/plugin/learning-center/learning-center-api';

import type { V1Route } from '../../main/src/plugin/api/openshift-types';
import type { AuthenticationProviderInfo } from '../../main/src/plugin/authentication';
Expand Down Expand Up @@ -1960,6 +1961,10 @@ function initExposure(): void {
return ipcInvoke('image-checker:check', id, image, cancellationToken);
},
);

contextBridge.exposeInMainWorld('listGuides', async (): Promise<Guide[]> => {
return ipcInvoke('learning-center:listGuides');
});
}

// expose methods
Expand Down
131 changes: 131 additions & 0 deletions packages/renderer/src/lib/carousel/Carousel.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**********************************************************************
* Copyright (C) 2023 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
***********************************************************************/

/* eslint-disable @typescript-eslint/no-explicit-any */

import '@testing-library/jest-dom/vitest';
import { test, expect, vi, beforeEach, afterEach } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
import CarouselTest from './CarouselTest.svelte';

let callback: any;

class ResizeObserver {
constructor(callback1: (entries: ResizeObserverEntry[], observer: ResizeObserver) => void) {
callback = callback1;
}

observe = vi.fn();
disconnect = vi.fn();
unobserve = vi.fn();
}

beforeEach(() => {
(window as any).ResizeObserver = ResizeObserver;
});

afterEach(() => {
vi.resetAllMocks();
});

test('carousel cards get visible when size permits', async () => {
render(CarouselTest);
const card1 = screen.getByText('card 1');
console.log(window.innerWidth);
expect(card1).toBeVisible();

callback([{ contentRect: { width: 680 } }]);

await waitFor(() => {
const card2 = screen.getByText('card 2');
expect(card2).toBeVisible();
});

const cards = screen.queryAllByText('card 3');
expect(cards.length).toBe(0);

callback([{ contentRect: { width: 1020 } }]);

await waitFor(() => {
const card3 = screen.getByText('card 3');
expect(card3).toBeVisible();
});
});

test('rotate left button displays previous card', async () => {
render(CarouselTest);
const card1 = screen.getByText('card 1');
expect(card1).toBeVisible();

const cards = screen.queryAllByText('card 3');
expect(cards.length).toBe(0);

const left = screen.getByRole('button', { name: 'Rotate left' });
await fireEvent.click(left);

const card3 = screen.getByText('card 3');
expect(card3).toBeVisible();
});

test('rotate right button displays next card', async () => {
render(CarouselTest);
const card1 = screen.getByText('card 1');
expect(card1).toBeVisible();

const cards = screen.queryAllByText('card 2');
expect(cards.length).toBe(0);

const right = screen.getByRole('button', { name: 'Rotate right' });
await fireEvent.click(right);

const card3 = screen.getByText('card 2');
expect(card3).toBeVisible();
});

test('carousel left and right buttons enabled when all items does not fit into screen and disabled otherwise', async () => {
render(CarouselTest);
const card1 = screen.getByText('card 1');
console.log(window.innerWidth);
expect(card1).toBeVisible();

let cards = screen.queryAllByText('card 2');
expect(cards.length).toBe(0);

cards = screen.queryAllByText('card 3');
expect(cards.length).toBe(0);

const left = screen.getByRole('button', { name: 'Rotate left' });
const right = screen.getByRole('button', { name: 'Rotate right' });

expect(left).toBeEnabled();
expect(right).toBeEnabled();

callback([{ contentRect: { width: 1020 } }]);

await waitFor(() => {
const card1 = screen.getByText('card 1');
expect(card1).toBeVisible();
const card2 = screen.getByText('card 2');
expect(card2).toBeVisible();
const card3 = screen.getByText('card 3');
expect(card3).toBeVisible();
});

expect(left).toBeDisabled();
expect(right).toBeDisabled();
});
71 changes: 71 additions & 0 deletions packages/renderer/src/lib/carousel/Carousel.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<script lang="ts">
import { faGreaterThan, faLessThan } from '@fortawesome/free-solid-svg-icons';
import Fa from 'svelte-fa';
import { onDestroy, onMount } from 'svelte';
let resizeObserver: ResizeObserver;
export let cards: any[];
export let cardWidth = 340;
let cardsFit = 1;
let containerId = Math.random().toString(36).slice(-6);
$: visibleCards = cards.slice(0, cardsFit);
function calcCardsToFit(width: number) {
const cf = Math.floor(width / cardWidth);
return cf === 0 ? 1 : cf;
}
function update(entries: any) {
const width = entries[0].contentRect.width;
cardsFit = calcCardsToFit(width);
}
onMount(() => {
const cardsContainer = document.getElementById(`carousel-cards-${containerId}`);
const initialWidth = cardsContainer?.offsetWidth as number;
cardsFit = calcCardsToFit(initialWidth);
resizeObserver = new ResizeObserver(update);
resizeObserver.observe(cardsContainer as Element);
});
onDestroy(() => {
resizeObserver.disconnect();
});
function rotateLeft() {
cards = [cards[cards.length - 1], ...cards.slice(0, cards.length - 1)];
}
function rotateRight() {
cards = [...cards.slice(1, cards.length), cards[0]];
}
</script>

<div class="flex flex-row items-center">
<button
id="left"
on:click="{rotateLeft}"
aria-label="Rotate left"
class="h-8 w-8 mr-3 bg-gray-800 rounded-full disabled:bg-zinc-700"
disabled="{visibleCards.length === cards.length}">
<Fa class="w-8 h-8" icon="{faLessThan}" color="black" />
</button>

<div id="carousel-cards-{containerId}" class="flex flex-grow gap-3 overflow-hidden">
{#each visibleCards as card}
<slot card="{card}" />
{/each}
</div>

<button
id="right"
on:click="{rotateRight}"
aria-label="Rotate right"
class="h-8 w-8 ml-3 bg-gray-800 rounded-full disabled:bg-zinc-700"
disabled="{visibleCards.length === cards.length}">
<Fa class="h-8 w-8" icon="{faGreaterThan}" color="black" />
</button>
</div>
12 changes: 12 additions & 0 deletions packages/renderer/src/lib/carousel/CarouselTest.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<script lang="ts">
import Carousel from './Carousel.svelte';
</script>

<div>
<p class="text-lg first-letter:uppercase font-bold pb-5">Learning center:</p>
<div class="bg-charcoal-800 p-4 rounded-lg">
<Carousel cards="{['card 1', 'card 2', 'card 3']}" cardWidth="{340}" let:card>
<p>{card}</p>
</Carousel>
</div>
</div>
3 changes: 2 additions & 1 deletion packages/renderer/src/lib/dashboard/DashboardPage.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { DoNothingMode } from './ProviderInitUtils';
import FeaturedExtensions from '/@/lib/featured/FeaturedExtensions.svelte';
import ProviderConfiguring from '/@/lib/dashboard/ProviderConfiguring.svelte';
import NotificationsBox from './NotificationsBox.svelte';
import LearningCenter from '../learning-center/LearningCenter.svelte';
const providerInitContexts = new Map<string, InitializationContext>();
Expand Down Expand Up @@ -95,7 +96,7 @@ function getInitializationContext(id: string): InitializationContext {
<ProviderStopped provider="{providerStopped}" />
{/each}
{/if}

<LearningCenter />
<FeaturedExtensions />
</div>
</div>
Expand Down
26 changes: 26 additions & 0 deletions packages/renderer/src/lib/learning-center/GuideCard.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<script lang="ts">
import type { Guide } from '../../../../main/src/plugin/learning-center/learning-center-api';
import Button from '../ui/Button.svelte';
export let guide: Guide;
export let width = 300;
export let height = 300;
</script>

<div
class="flex flex-col flex-1 bg-charcoal-600 pb-4 rounded-lg hover:bg-zinc-700 min-w-[{width}px] min-h-[{height}px]">
<dif class="pt-4 flex flex-col">
<div class="px-4">
<img src="{`data:image/png;base64,${guide.icon}`}" class="h-[48px]" alt="{guide.id}" />
</div>
<div class="px-4 pt-4 text-nowrap text-gray-400">
{guide.title}
</div>
<p class="line-clamp-4 px-4 pt-4 text-base text-gray-700">{guide.description}</p>
</dif>
<div class="flex justify-center items-end flex-1 pt-4">
<Button
class="justify-self-center self-end text-lg"
on:click="{() => window.openExternal(guide.url)}"
title="Get started">Get started</Button>
</div>
</div>
21 changes: 21 additions & 0 deletions packages/renderer/src/lib/learning-center/LearningCenter.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<script lang="ts">
import { onMount } from 'svelte';
import Carousel from '../carousel/Carousel.svelte';
import GuideCard from './GuideCard.svelte';
import type { Guide } from '../../../../main/src/plugin/learning-center/learning-center-api';
let guides: Guide[] = [];
onMount(async () => {
guides = await window.listGuides();
});
</script>

<div class="flex flex-1 flex-col">
<p class="text-lg first-letter:uppercase font-bold pb-5">Learning center:</p>
<div class="flex flex-1 flex-col bg-charcoal-800 p-5 rounded-lg">
<Carousel cards="{guides}" let:card>
<GuideCard guide="{card}" />
</Carousel>
</div>
</div>

0 comments on commit 4bddf04

Please sign in to comment.