Skip to content

Commit

Permalink
feat: Allow to install in one click featured extensions (if not insta…
Browse files Browse the repository at this point in the history
…lled)

related to #1901

widget available from dashboard page and extensions list

Change-Id: I495ad06dd7f27a3779195c3fc503dae3dd6e0cc3

Signed-off-by: Florent Benoit <fbenoit@redhat.com>
Change-Id: I1559329c48dd4d9390e133d8ddcf647340dded92
  • Loading branch information
benoitf committed May 9, 2023
1 parent f733864 commit 52a0047
Show file tree
Hide file tree
Showing 6 changed files with 466 additions and 103 deletions.
3 changes: 3 additions & 0 deletions packages/renderer/src/lib/dashboard/DashboardPage.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import ProviderStopped from './ProviderStopped.svelte';
import ProviderStarting from './ProviderStarting.svelte';
import NavPage from '../ui/NavPage.svelte';
import type { InitializationMode } from './ProviderInitUtils';
import FeaturedExtensions from '/@/lib/featured/FeaturedExtensions.svelte';
const providerInitMode = new Map<string, InitializationMode>();
Expand Down Expand Up @@ -74,6 +75,8 @@ function updateInitializationMode(id: string, mode: InitializationMode) {
<ProviderStopped provider="{providerStopped}" />
{/each}
{/if}

<FeaturedExtensions />
</div>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**********************************************************************
* 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';
import { beforeAll, test, expect, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/svelte';
import FeaturedExtensionDownload from './FeaturedExtensionDownload.svelte';
import type { FeaturedExtension } from '../../../../main/src/plugin/featured/featured-api';

const extensionInstallFromImageMock = vi.fn();

// fake the window.events object
beforeAll(() => {
(window as any).extensionInstallFromImage = extensionInstallFromImageMock;
(window.events as unknown) = {
receive: (_channel: string, func: any) => {
func();
},
};
});

test('Expect that the install button is hidden if extension is not installable', async () => {
const featuredExtension: FeaturedExtension = {
builtin: true,
id: 'foo.bar',
displayName: 'FooBar',
description: 'This is FooBar description',
icon: 'data:image/png;base64,foobar',
categories: [],
fetchable: false,
installed: false,
};

await render(FeaturedExtensionDownload, { featuredExtension });

// expect to have the button if installable
const installButton = screen.queryByRole('button', { name: 'Install foo.bar Extension' });
// expect the button is not there
expect(installButton).not.toBeInTheDocument();
});

test('Expect that we can see the button and click on the install', async () => {
const featuredExtension: FeaturedExtension = {
builtin: true,
id: 'foo.bar',
displayName: 'FooBar',
description: 'This is FooBar description',
icon: 'data:image/png;base64,foobar',
categories: [],
fetchable: true,
fetchLink: 'oci-hello/world',
fetchVersion: '1.2.3',
installed: false,
};

const { component } = await render(FeaturedExtensionDownload, { featuredExtension });

// expect to have the button if installable
const installButton = screen.getByRole('button', { name: 'Install foo.bar Extension' });
// expect the button to be there
expect(installButton).toBeInTheDocument();

// mock the install function
extensionInstallFromImageMock.mockImplementation(async () => {
featuredExtension.installed = true;
featuredExtension.fetchable = false;
await component.$set({ featuredExtension });
});

// click on the button
await fireEvent.click(installButton);

// install should have been called
expect(extensionInstallFromImageMock).toHaveBeenCalled();

// now, expect the button to be gone
// expect the button to be there
const installButtonAfterClick = screen.queryByRole('button', { name: 'Install foo.bar Extension' });
// expect the button is not there
expect(installButtonAfterClick).not.toBeInTheDocument();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<script lang="ts">
import type { FeaturedExtension } from '../../../../main/src/plugin/featured/featured-api';
import { faCheckCircle, faDownload } from '@fortawesome/free-solid-svg-icons';
import Fa from 'svelte-fa/src/fa.svelte';
import LoadingIcon from '../ui/LoadingIcon.svelte';
import ErrorMessage from '../ui/ErrorMessage.svelte';
export let featuredExtension: FeaturedExtension;
let installInProgress = false;
let logs = [];
let errorInstall = '';
let percentage = '0%';
async function installExtension() {
console.log('User asked to install the extension with the following properties', featuredExtension);
logs = [];
installInProgress = true;
// do a trim on the image name
const ociImage = featuredExtension.fetchLink.trim();
try {
// download image
await window.extensionInstallFromImage(
ociImage,
(data: string) => {
logs = [...logs, data];
console.log('data', data);
// try to extract percentage from string like
// data Downloading sha256:e8d2c9e5c69499c41ba39b7828c00e55087572884cac466b4d1b47243b085c7d.tar - 11% - (55132/521578)
const percentageMatch = data.match(/(\d+)%/);
if (percentageMatch) {
percentage = percentageMatch[1] + '%';
}
},
(error: string) => {
console.log(`got an error when installing ${featuredExtension.id}`, error);
installInProgress = false;
errorInstall = error;
},
);
logs = [...logs, '☑️ installation finished !'];
percentage = '100%';
} catch (error) {
console.log('error', error);
}
installInProgress = false;
}
</script>

<button
aria-label="Install {featuredExtension.id} Extension"
on:click="{() => installExtension()}"
hidden="{!featuredExtension.fetchable}"
title="Install {featuredExtension.displayName} v{featuredExtension.fetchVersion} Extension"
class="border-2 relative rounded border-dustypurple-700 text-dustypurple-700 hover:bg-charcoal-800 hover:text-dustypurple-600 w-10 p-2 text-center cursor-pointer flex flex-row">
<!--<Fa class="ml-1.5" size="16" icon={faDownload} />-->
<span class="ml-0.5"></span>
<LoadingIcon
icon="{faDownload}"
iconSize="16"
loadingWidthClass="w-7"
loadingHeightClass="h-7"
positionTopClass="top-[2px]"
positionLeftClass="left-[4px]"
loading="{installInProgress}" />
<span
class:hidden="{!installInProgress}"
class="absolute -top-[15px] right-0 text-dustypurple-500"
style="font-size: 8px">{percentage}</span>
<div class:hidden="{!errorInstall}" class="absolute w-56 -top-[25px] right-0" style="font-size: 8px">
<ErrorMessage error="{errorInstall}" />
</div>
</button>
127 changes: 127 additions & 0 deletions packages/renderer/src/lib/featured/FeaturedExtensions.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**********************************************************************
* 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';
import { beforeAll, test, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { get } from 'svelte/store';
import FeaturedExtensions from './FeaturedExtensions.svelte';
import { featuredExtensionInfos } from '/@/stores/featuredExtensions';
import type { FeaturedExtension } from '../../../../main/src/plugin/featured/featured-api';

const getFeaturedExtensionsMock = vi.fn();

// fake the window.events object
beforeAll(() => {
(window as any).getFeaturedExtensions = getFeaturedExtensionsMock;
(window.events as unknown) = {
receive: (_channel: string, func: any) => {
func();
},
};
});

test('Expect that featured extensions are displayed', async () => {
const featuredExtension1: FeaturedExtension = {
builtin: true,
id: 'foo.bar',
displayName: 'FooBar',
description: 'This is FooBar description',
icon: 'data:image/png;base64,foobar',
categories: [],
fetchable: true,
fetchLink: 'oci-hello/world',
fetchVersion: '1.2.3',
installed: false,
};

const featuredExtension2: FeaturedExtension = {
builtin: true,
id: 'foo.baz',
displayName: 'FooBaz',
description: 'Foobaz description',
icon: 'data:image/png;base64,foobaz',
categories: [],
fetchable: false,
installed: true,
};

const featuredExtension3: FeaturedExtension = {
builtin: true,
id: 'foo.bar',
displayName: 'Bar',
description: 'FooBar not fetchable description',
icon: 'data:image/png;base64,bar',
categories: [],
fetchable: false,
installed: false,
};

getFeaturedExtensionsMock.mockResolvedValue([featuredExtension1, featuredExtension2, featuredExtension3]);

// ask to update the featured Extensions store
window.dispatchEvent(new CustomEvent('system-ready'));

// wait store are populated
while (get(featuredExtensionInfos).length === 0) {
await new Promise(resolve => setTimeout(resolve, 500));
}

await render(FeaturedExtensions);

// get by title
const firstExtension = screen.getByTitle('This is FooBar description');
expect(firstExtension).toBeInTheDocument();

const imageExt1 = screen.getByRole('img', { name: 'FooBar logo' });
// expect the image to be there
expect(imageExt1).toBeInTheDocument();
// expect image source is correct
expect(imageExt1).toHaveAttribute('src', 'data:image/png;base64,foobar');

// Not installed so it should have a button to install
const installButton = screen.getByRole('button', { name: 'Install foo.bar Extension' });
// expect the button to be there
expect(installButton).toBeInTheDocument();

// get by title
const secondExtension = screen.getByTitle('Foobaz description');
expect(secondExtension).toBeInTheDocument();
// contains the text installed
expect(secondExtension).toHaveTextContent(/.*installed/);

const imageExt2 = screen.getByRole('img', { name: 'FooBaz logo' });
// expect the image to be there
expect(imageExt2).toBeInTheDocument();
// expect image source is correct
expect(imageExt2).toHaveAttribute('src', 'data:image/png;base64,foobaz');

// get by title
const thirdExtension = screen.getByTitle('FooBar not fetchable description');
expect(thirdExtension).toBeInTheDocument();
// contains the text installed
expect(thirdExtension).toHaveTextContent(/.*N\/A/);

const imageExt3 = screen.getByRole('img', { name: 'Bar logo' });
// expect the image to be there
expect(imageExt3).toBeInTheDocument();
// expect image source is correct
expect(imageExt3).toHaveAttribute('src', 'data:image/png;base64,bar');
});
46 changes: 46 additions & 0 deletions packages/renderer/src/lib/featured/FeaturedExtensions.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<script lang="ts">
import { featuredExtensionInfos } from '/@/stores/featuredExtensions';
import { faCheckCircle, faCircleXmark, faDownload } from '@fortawesome/free-solid-svg-icons';
import Fa from 'svelte-fa/src/fa.svelte';
import FeaturedExtensionDownload from './FeaturedExtensionDownload.svelte';
</script>

<!--Title-->
<p class="text-lg first-letter:uppercase font-bold">featured extensions:</p>
<div class="grid md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-3 pb-4">
{#each $featuredExtensionInfos as featuredExtension}
<div
title="{featuredExtension.description}"
class="rounded-md
bg-charcoal-800 flex flex-row justify-center p-4 h-20 border-2 border-charcoal-800 hover:border-dustypurple-500">
<div class=" flex flex-col flex-1">
<div class="flex flex-row place-items-center flex-1">
<div>
<img
class="w-12 h-12 object-contain"
alt="{featuredExtension.displayName} logo"
src="{featuredExtension.icon}" />
</div>
<div class="flex flex-1 mx-2 cursor-default font-bold justify-start">
{featuredExtension.displayName}
</div>
<div class="h-full w-18 flex flex-col items-end place-content-center">
{#if featuredExtension.installed}
<div class="text-dustypurple-700 p-1 text-center flex flex-row place-items-center">
<Fa class="ml-1.5 mr-2" size="18" icon="{faCheckCircle}" />
<div class="uppercase font-bold text-xs cursor-default">installed</div>
</div>
{:else if featuredExtension.fetchable}
<FeaturedExtensionDownload featuredExtension="{featuredExtension}" />
{:else}
<div class="text-charcoal-300 p-1 text-center flex flex-row place-items-center">
<Fa class="ml-1.5 mr-1" size="18" icon="{faCircleXmark}" />
<div class="uppercase text-xs cursor-default font-extralight">N/A</div>
</div>
{/if}
</div>
</div>
</div>
</div>
{/each}
</div>
Loading

0 comments on commit 52a0047

Please sign in to comment.