Skip to content

Commit

Permalink
feat: table component
Browse files Browse the repository at this point in the history
DRAFT.

We have impending technical debt where our expanding number of table components
(images, volumes, pods, containers, soon adding Kubernetes objects) will cross
with our future design ideas (tables should be sortable, columns configurable,
environments groupable, etc). We can't copy/paste these features into every page,
and deal with differences between them.

This is the best I've come up with so far. Each page passes an array of objects
to a Table component, which is responsible for creating the basic layout and
calling back a Row component to render each cell in the table. This seems like
just enough abstraction b/c all of the basic/common table capability can be
handled in that component, and each Row doesn't know or care if columns are
missing or sorted differently. A TableHelper class providers a few functions
like whether objects can be selected & sorting.

Switched to grid layout, which also helped to reduce code in *Row components.
I am still getting bizarre changes when I try to tweak the column widths,
but it appears to be in a happy spot here.

Added sorting with a sort indicator in the header, and tests. Sorting by image
and volume size was left out for now b/c it shouldn't be alphabetical.

Still to do before removing draft status:
- Filtering isn't working great... but maybe that's how it was working before.
- Volume* and Image* will be extracted to their own PRs, but I felt like it
  helps to see how they change here.

Support for 'object containers' in order to support Pod and Container lists
will be done separately since this is already a big PR.

Fixes #4365.

Signed-off-by: Tim deBoer <git@tdeboer.ca>
  • Loading branch information
deboer-tim committed Nov 9, 2023
1 parent a26d368 commit e3a635c
Show file tree
Hide file tree
Showing 9 changed files with 561 additions and 237 deletions.
91 changes: 91 additions & 0 deletions packages/renderer/src/lib/image/ImageRow.svelte
@@ -0,0 +1,91 @@
<script lang="ts">
import { router } from 'tinro';
import ImageActions from './ImageActions.svelte';
import type { ImageInfoUI } from './ImageInfoUI';
import PushImageModal from './PushImageModal.svelte';
import RenameImageModal from './RenameImageModal.svelte';
import ImageIcon from '../images/ImageIcon.svelte';
import StatusIcon from '../images/StatusIcon.svelte';
export let object: any;
export let column: string;
$: image = object;
export let multipleEngines = false; // TODO
function openDetailsImage(image: ImageInfoUI) {
router.goto(`/images/${image.id}/${image.engineId}/${image.base64RepoTag}/summary`);
}
let pushImageModal = false;
let pushImageModalImageInfo: ImageInfoUI | undefined = undefined;
function handlePushImageModal(imageInfo: ImageInfoUI) {
pushImageModalImageInfo = imageInfo;
pushImageModal = true;
}
let renameImageModal = false;
let renameImageModalImageInfo: ImageInfoUI | undefined = undefined;
function handleRenameImageModal(imageInfo: ImageInfoUI) {
renameImageModalImageInfo = imageInfo;
renameImageModal = true;
}
function closeModals() {
pushImageModal = false;
renameImageModal = false;
}
</script>

{#if column === 'Status'}
<StatusIcon icon="{ImageIcon}" status="{image.inUse ? 'USED' : 'UNUSED'}" />
{:else if column === 'Name'}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="hover:cursor-pointer flex flex-col" on:click="{() => openDetailsImage(image)}">
<div class="flex flex-row items-center">
<div class="text-sm text-gray-300">{image.name}</div>
</div>
<div class="flex flex-row items-center">
<div class="text-xs text-violet-400">{image.shortId}</div>
<div class="ml-1 text-xs font-extra-light text-gray-400">{image.tag}</div>
</div>
<div class="flex flex-row text-xs font-extra-light text-gray-900">
<!-- Hide in case of single engine-->
{#if multipleEngines}
<div class="px-2 inline-flex text-xs font-extralight rounded-full bg-slate-800 text-slate-400">
{image.engineName}
</div>
{/if}
</div>
</div>
{:else if column === 'Age'}
<div class="text-sm text-gray-700">
{image.age}
</div>
{:else if column === 'Size'}
<div class="text-sm text-gray-700">
{image.humanSize}
</div>
{:else}
<ImageActions
image="{image}"
onPushImage="{handlePushImageModal}"
onRenameImage="{handleRenameImageModal}"
dropdownMenu="{true}" />
{/if}
{#if pushImageModal && pushImageModalImageInfo}
<PushImageModal
imageInfoToPush="{pushImageModalImageInfo}"
closeCallback="{() => {
closeModals();
}}" />
{/if}
{#if renameImageModal && renameImageModalImageInfo}
<RenameImageModal
imageInfoToRename="{renameImageModalImageInfo}"
closeCallback="{() => {
closeModals();
}}" />
{/if}
196 changes: 55 additions & 141 deletions packages/renderer/src/lib/image/ImagesList.svelte
Expand Up @@ -6,58 +6,35 @@ import FilteredEmptyScreen from '../ui/FilteredEmptyScreen.svelte';
import { router } from 'tinro';
import type { ImageInfoUI } from './ImageInfoUI';
import ImageActions from './ImageActions.svelte';
import type { ImageInfo } from '../../../../main/src/plugin/api/image-info';
import NoContainerEngineEmptyScreen from './NoContainerEngineEmptyScreen.svelte';
import { providerInfos } from '../../stores/providers';
import PushImageModal from './PushImageModal.svelte';
import RenameImageModal from './RenameImageModal.svelte';
import { ImageUtils } from './image-utils';
import NavPage from '../ui/NavPage.svelte';
import ImageIcon from '../images/ImageIcon.svelte';
import StatusIcon from '../images/StatusIcon.svelte';
import type { Unsubscriber } from 'svelte/store';
import { containersInfos } from '../../stores/containers';
import type { ContainerInfo } from '../../../../main/src/plugin/api/container-info';
import moment from 'moment';
import Prune from '../engine/Prune.svelte';
import type { EngineInfoUI } from '../engine/EngineInfoUI';
import Checkbox from '../ui/Checkbox.svelte';
import Button from '../ui/Button.svelte';
import { faArrowCircleDown, faCube, faTrash } from '@fortawesome/free-solid-svg-icons';
import ImageRow from './ImageRow.svelte';
import Table from '../table/Table.svelte';
import { Column, TableHelper } from '../table/table';
export let searchTerm = '';
$: searchPattern.set(searchTerm);
let images: ImageInfoUI[] = [];
let multipleEngines = false;
let enginesList: EngineInfoUI[];
let pushImageModal = false;
let pushImageModalImageInfo: ImageInfoUI | undefined = undefined;
function handlePushImageModal(imageInfo: ImageInfoUI) {
pushImageModalImageInfo = imageInfo;
pushImageModal = true;
}
let renameImageModal = false;
let renameImageModalImageInfo: ImageInfoUI | undefined = undefined;
function handleRenameImageModal(imageInfo: ImageInfoUI) {
renameImageModalImageInfo = imageInfo;
renameImageModal = true;
}
$: providerConnections = $providerInfos
.map(provider => provider.containerConnections)
.flat()
.filter(providerContainerConnection => providerContainerConnection.status === 'started');
// number of selected items in the list
$: selectedItemsNumber = images.filter(image => !image.inUse).filter(image => image.selected).length;
// do we need to unselect all checkboxes if we don't have all items being selected ?
$: selectedAllCheckboxes = images.filter(image => !image.inUse).every(image => image.selected);
const imageUtils = new ImageUtils();
function updateImages() {
Expand Down Expand Up @@ -88,12 +65,6 @@ function updateImages() {
// Remove duplicates from engines by name
const uniqueEngines = engines.filter((engine, index, self) => index === self.findIndex(t => t.name === engine.name));
if (uniqueEngines.length > 1) {
multipleEngines = true;
} else {
multipleEngines = false;
}
// Set the engines to the global variable for the Prune functionality button
enginesList = uniqueEngines;
Expand Down Expand Up @@ -133,11 +104,6 @@ onDestroy(() => {
}
});
function closeModals() {
pushImageModal = false;
renameImageModal = false;
}
function gotoBuildImage(): void {
router.goto('/images/build');
}
Expand All @@ -146,17 +112,6 @@ function gotoPullImage(): void {
router.goto('/images/pull');
}
function openDetailsImage(image: ImageInfoUI) {
router.goto(`/images/${image.id}/${image.engineId}/${image.base64RepoTag}/summary`);
}
function toggleAllImages(checked: boolean) {
const toggleImages = images;
// filter out all images used by a container
toggleImages.filter(image => !image.inUse).forEach(image => (image.selected = checked));
images = toggleImages;
}
// delete the items selected in the list
let bulkDeleteInProgress = false;
async function deleteSelectedImages() {
Expand Down Expand Up @@ -214,6 +169,50 @@ function computeInterval(): number {
// every day
return 60 * 60 * 24 * SECOND;
}
let selectedItemsNumber: number;
let table: Table;
const columns: Column[] = [
new Column('Status', 'center', '70px'),
new Column('Name', 'left', '3fr'),
new Column('Age', 'left', '1fr'),
new Column('Size', 'right', '1fr'),
];
const tableHelper = new (class extends TableHelper {
getKey(object: any): any {
return object.name + object.shortId + object.tag;
}
selectable(image: any): boolean {
return image.inUse;
}
disabledText(): string {
return 'Image is used by a container';
}
compare(column: string): ((object1: any, object2: any) => number) | undefined {
if (column === 'Status') {
return (a, b) => {
let au: boolean = a.inUse;
let bu: boolean = b.inUse;
return au === bu ? 0 : au ? -1 : 1;
};
} else if (column === 'Name') {
return (a, b) => a.name.localeCompare(b.name);
} else if (column === 'Age') {
return (a, b) => {
return b.createdAt - a.createdAt;
};
} else if (column === 'Size') {
// TODO: should sort by value, not alphabetically (e.g. 1 Gb > 2 Mb)
//return (a, b) => a.humanSize.localeCompare(b.humanSize);
}
return undefined;
}
})('image');
</script>

<NavPage bind:searchTerm="{searchTerm}" title="images">
Expand All @@ -239,84 +238,14 @@ function computeInterval(): number {
</div>

<div class="flex min-w-full h-full" slot="content">
<table class="mx-5 w-full h-fit" class:hidden="{images.length === 0}">
<!-- title -->
<thead class="sticky top-0 bg-charcoal-700 z-[2]">
<tr class="h-7 uppercase text-xs text-gray-600">
<th class="whitespace-nowrap w-5"></th>
<th class="px-2 w-5">
<Checkbox
title="Toggle all"
bind:checked="{selectedAllCheckboxes}"
indeterminate="{selectedItemsNumber > 0 && !selectedAllCheckboxes}"
on:click="{event => toggleAllImages(event.detail)}" />
</th>
<th class="text-center font-extrabold w-10 px-2">status</th>
<th class="w-10">Name</th>
<th class="px-6 whitespace-nowrap w-10">age</th>
<th class="px-6 whitespace-nowrap text-end">size</th>
<th class="text-right pr-2">Actions</th>
</tr>
</thead>
<tbody>
{#each images as image}
<tr class="group h-12 bg-charcoal-800 hover:bg-zinc-700">
<td class="rounded-tl-lg rounded-bl-lg w-5"> </td>
<td class="px-2">
<Checkbox
title="Toggle image"
bind:checked="{image.selected}"
disabled="{image.inUse}"
disabledTooltip="Image is used by a container" />
</td>
<td class="bg-charcoal-800 group-hover:bg-zinc-700 flex flex-row justify-center content-center h-12">
<div class="grid place-content-center ml-3 mr-4">
<StatusIcon icon="{ImageIcon}" status="{image.inUse ? 'USED' : 'UNUSED'}" />
</div>
</td>
<td class="whitespace-nowrap w-10 hover:cursor-pointer" on:click="{() => openDetailsImage(image)}">
<div class="flex items-center">
<div class="">
<div class="flex flex-row items-center">
<div class="text-sm text-gray-300">{image.name}</div>
</div>
<div class="flex flex-row items-center">
<div class="text-xs text-violet-400">{image.shortId}</div>
<div class="ml-1 text-xs font-extra-light text-gray-400">{image.tag}</div>
</div>
<div class="flex flex-row text-xs font-extra-light text-gray-900">
<!-- Hide in case of single engine-->
{#if multipleEngines}
<div class="px-2 inline-flex text-xs font-extralight rounded-full bg-slate-800 text-slate-400">
{image.engineName}
</div>
{/if}
</div>
</div>
</div>
</td>
<td class="px-6 py-2 whitespace-nowrap w-10">
<div class="flex items-center">
<div class="text-sm text-gray-700">{image.age}</div>
</div>
</td>
<td class="px-6 py-2 whitespace-nowrap w-10">
<div class="flex">
<div class="w-full text-right text-sm text-gray-700">{image.humanSize}</div>
</div>
</td>
<td class="pl-6 text-right whitespace-nowrap rounded-tr-lg rounded-br-lg">
<ImageActions
image="{image}"
onPushImage="{handlePushImageModal}"
onRenameImage="{handleRenameImageModal}"
dropdownMenu="{true}" />
</td>
</tr>
<tr><td class="leading-[8px]">&nbsp;</td></tr>
{/each}
</tbody>
</table>
<Table
bind:this="{table}"
bind:selectedItemsNumber="{selectedItemsNumber}"
objects="{images}"
columns="{columns}"
row="{ImageRow}"
helper="{tableHelper}">
</Table>

{#if providerConnections.length === 0}
<NoContainerEngineEmptyScreen />
Expand All @@ -327,20 +256,5 @@ function computeInterval(): number {
<ImageEmptyScreen />
{/if}
{/if}

{#if pushImageModal && pushImageModalImageInfo}
<PushImageModal
imageInfoToPush="{pushImageModalImageInfo}"
closeCallback="{() => {
closeModals();
}}" />
{/if}
{#if renameImageModal && renameImageModalImageInfo}
<RenameImageModal
imageInfoToRename="{renameImageModalImageInfo}"
closeCallback="{() => {
closeModals();
}}" />
{/if}
</div>
</NavPage>

0 comments on commit e3a635c

Please sign in to comment.