fix: context menu

This commit is contained in:
Pujit Mehrotra
2025-10-14 15:46:06 -04:00
parent 946f51eb01
commit bacee666f1
5 changed files with 109 additions and 40 deletions

View File

@@ -44,6 +44,10 @@ const emit = defineEmits<{
e: 'row:select',
payload: { id: string; type: string; name: string; selected: boolean; meta?: T }
): void;
(
e: 'row:contextmenu',
payload: { id: string; type: string; name: string; meta?: T; event: MouseEvent }
): void;
(e: 'row:drop', payload: DropEvent<T>): void;
(e: 'update:selectedIds', value: string[]): void;
}>();
@@ -147,7 +151,7 @@ function toArray(value: unknown | unknown[]): unknown[] {
return value !== undefined && value !== null ? [value] : [];
}
function getRowSearchValues<T>(row: TreeRow<T>): string[] {
function getRowSearchValues(row: TreeRow<T>): string[] {
const values: unknown[] = [];
if (props.searchableKeys?.length) {
@@ -178,7 +182,7 @@ function getRowSearchValues<T>(row: TreeRow<T>): string[] {
.filter((str) => str.trim().length);
}
function rowMatchesTerm<T>(row: TreeRow<T>, term: string): boolean {
function rowMatchesTerm(row: TreeRow<T>, term: string): boolean {
if (!term) return true;
return getRowSearchValues(row)
@@ -186,7 +190,7 @@ function rowMatchesTerm<T>(row: TreeRow<T>, term: string): boolean {
.some((value) => value.includes(term));
}
function filterRowsByTerm<T>(rows: TreeRow<T>[], term: string): TreeRow<T>[] {
function filterRowsByTerm(rows: TreeRow<T>[], term: string): TreeRow<T>[] {
if (!term) {
return rows;
}
@@ -284,6 +288,18 @@ function wrapCellWithRow(row: { original: TreeRow<T>; depth?: number }, cellCont
}
: undefined,
onDragend: props.enableDragDrop ? handleDragEnd : undefined,
onContextmenu: (e: MouseEvent) => {
const target = e.target as HTMLElement | null;
if (
target &&
target.closest('input,button,textarea,a,[role=checkbox],[role=button],[data-stop-row-click]')
) {
return;
}
e.preventDefault();
const r = row.original;
emit('row:contextmenu', { id: r.id, type: r.type, name: r.name, meta: r.meta, event: e });
},
},
[cellContent]
);
@@ -428,7 +444,7 @@ defineExpose({
<slot
name="toolbar"
:selected-count="selectedCount"
:global-filter="globalFilter.value"
:global-filter="globalFilter"
:column-visibility="columnVisibility"
:row-selection="rowSelection"
:set-global-filter="setGlobalFilter"

View File

@@ -12,6 +12,7 @@ import DockerOverview from '@/components/Docker/Overview.vue';
import DockerPreview from '@/components/Docker/Preview.vue';
import type { DockerContainer, FlatOrganizerEntry } from '@/composables/gql/graphql';
import type { LocationQueryRaw } from 'vue-router';
interface Props {
disabled?: boolean;
@@ -97,7 +98,7 @@ if (hasRouter) {
const currentRouteId = normalizeContainerQuery(route!.query[ROUTE_QUERY_KEY]);
if (nextId === currentRouteId) return;
const nextQuery = { ...route!.query } as Record<string, unknown>;
const nextQuery: LocationQueryRaw = { ...route!.query };
if (nextId) nextQuery[ROUTE_QUERY_KEY] = nextId;
else delete nextQuery[ROUTE_QUERY_KEY];

View File

@@ -1,22 +1,5 @@
<script setup lang="ts">
import DockerContainerManagement from '@/components/Docker/DockerContainerManagement.vue';
import DockerContainerOverview from '@/components/Docker/DockerContainerOverview.vue';
import DockerEdit from '@/components/Docker/Edit.vue';
import DockerLogs from '@/components/Docker/Logs.vue';
import DockerOverview from '@/components/Docker/Overview.vue';
import DockerPreview from '@/components/Docker/Preview.vue';
const item = { id: '1', label: 'Test', icon: 'test', badge: '1' };
const details = {
network: 'bridge',
lanIpPort: '192.168.1.100:8080',
containerIp: '192.168.1.100',
uptime: '2 hours',
containerPort: '8080',
creationDate: new Date().toISOString(),
containerId: '1234567890',
maintainer: 'John Doe',
};
</script>
<template>
<div class="grid gap-8">

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, h, ref, resolveComponent, watch } from 'vue';
import { computed, h, nextTick, ref, resolveComponent, watch } from 'vue';
import { useMutation } from '@vue/apollo-composable';
import BaseTreeTable from '@/components/Common/BaseTreeTable.vue';
@@ -51,6 +51,10 @@ const UDropdownMenu = resolveComponent('UDropdownMenu');
const UModal = resolveComponent('UModal');
const USkeleton = resolveComponent('USkeleton') as Component;
const UIcon = resolveComponent('UIcon');
const rowActionDropdownUi = {
content: 'overflow-x-hidden z-50',
item: 'bg-transparent hover:bg-transparent focus:bg-transparent border-0 ring-0 outline-none shadow-none data-[state=checked]:bg-transparent',
};
const emit = defineEmits<{
(e: 'created-folder'): void;
@@ -113,7 +117,7 @@ const containersRef = computed(() => props.containers);
const rootFolderId = computed<string>(() => props.rootFolderId || 'root');
const baseTableRef = ref<InstanceType<typeof BaseTreeTable> | null>(null);
const baseTableRef = ref<{ columnVisibility?: { value: Record<string, boolean> } } | null>(null);
const searchableKeys = ['name', 'state', 'ports', 'autoStart', 'updates', 'containerId'];
@@ -137,7 +141,12 @@ const dockerSearchAccessor = (row: TreeRow<DockerContainer>): unknown[] => {
const columnVisibilityFallback = ref<Record<string, boolean>>({});
const columnVisibility = computed({
get: () => baseTableRef.value?.columnVisibility?.value ?? columnVisibilityFallback.value,
get: (): Record<string, boolean> => {
if (baseTableRef.value?.columnVisibility) {
return baseTableRef.value.columnVisibility.value;
}
return columnVisibilityFallback.value;
},
set: (value: Record<string, boolean>) => {
if (baseTableRef.value?.columnVisibility) {
baseTableRef.value.columnVisibility.value = value;
@@ -265,10 +274,7 @@ const columns = computed<TableColumn<TreeRow<DockerContainer>>[]>(() => {
{
items,
size: 'md',
ui: {
content: 'overflow-x-hidden z-50',
item: 'bg-transparent hover:bg-transparent focus:bg-transparent border-0 ring-0 outline-none shadow-none data-[state=checked]:bg-transparent',
},
ui: rowActionDropdownUi,
},
{
default: () =>
@@ -309,6 +315,26 @@ watch(
type ActionDropdownItem = { label: string; icon?: string; onSelect?: (e?: Event) => void; as?: string };
type DropdownMenuItems = ActionDropdownItem[][];
const contextMenuState = ref<{
open: boolean;
x: number;
y: number;
items: DropdownMenuItems;
rowId: string | null;
}>({
open: false,
x: 0,
y: 0,
items: [] as DropdownMenuItems,
rowId: null,
});
const contextMenuPopper = {
strategy: 'fixed' as const,
placement: 'bottom-start' as const,
offset: 4,
};
const columnsMenuItems = computed<DropdownMenuItems>(() => {
const keysFromColumns = (columns.value || [])
.map((col: TableColumn<TreeRow<DockerContainer>>) => {
@@ -358,8 +384,8 @@ const { mutate: unpauseContainerMutation } = useMutation(UNPAUSE_DOCKER_CONTAINE
declare global {
interface Window {
toast?: {
success: (title: string, options?: { description?: string }) => void;
error?: (title: string, options?: { description?: string }) => void;
success: (title: string, options: { description?: string }) => void;
error?: (title: string, options: { description?: string }) => void;
};
}
}
@@ -506,6 +532,36 @@ function handleRowAction(row: TreeRow<DockerContainer>, action: string) {
showToast(`${action}: ${row.name}`);
}
async function handleRowContextMenu(payload: {
id: string;
type: string;
name: string;
meta?: DockerContainer;
event: MouseEvent;
}) {
payload.event.preventDefault();
payload.event.stopPropagation();
const row = getRowById(payload.id, treeData.value);
if (!row) return;
if (busyRowIds.value.has(row.id)) return;
const items = getRowActionItems(row as TreeRow<DockerContainer>);
if (!items.length) return;
contextMenuState.value = {
open: false,
x: payload.event.clientX,
y: payload.event.clientY,
items,
rowId: row.id,
};
await nextTick();
if (contextMenuState.value.rowId === row.id) {
contextMenuState.value.open = true;
}
}
const bulkItems = computed<DropdownMenuItems>(() => [
[
{
@@ -604,6 +660,7 @@ function getRowActionItems(row: TreeRow<DockerContainer>): DropdownMenuItems {
containerId: payload.meta?.id,
})
"
@row:contextmenu="handleRowContextMenu"
@row:select="
(payload) =>
emit('row:select', {
@@ -652,6 +709,19 @@ function getRowActionItems(row: TreeRow<DockerContainer>): DropdownMenuItems {
</template>
</BaseTreeTable>
<UDropdownMenu
v-model:open="contextMenuState.open"
:items="contextMenuState.items"
size="md"
:popper="contextMenuPopper"
:ui="rowActionDropdownUi"
>
<div
class="fixed h-px w-px"
:style="{ top: `${contextMenuState.y}px`, left: `${contextMenuState.x}px` }"
/>
</UDropdownMenu>
<UModal
v-model:open="folderOps.moveOpen"
title="Move to folder"

View File

@@ -3,22 +3,21 @@ import { ref } from 'vue';
import { ContainerState } from '@/composables/gql/graphql';
import type { TreeRow } from '@/composables/useTreeData';
import type { MutateFunction } from '@vue/apollo-composable';
import type { DocumentNode } from 'graphql';
import type { Ref } from 'vue';
interface MutationOptions {
refetchQueries?: unknown[];
awaitRefetchQueries?: boolean;
}
type ContainerMutationFn = MutateFunction<unknown, { id: string }>;
export interface ContainerActionOptions<T = unknown> {
getRowById: (id: string, rows: TreeRow<T>[]) => TreeRow<T> | undefined;
treeData: Ref<TreeRow<T>[]>;
setRowsBusy: (ids: string[], busy: boolean) => void;
startMutation: (args: { id: string }, options?: MutationOptions) => Promise<unknown>;
stopMutation: (args: { id: string }, options?: MutationOptions) => Promise<unknown>;
pauseMutation: (args: { id: string }, options?: MutationOptions) => Promise<unknown>;
unpauseMutation: (args: { id: string }, options?: MutationOptions) => Promise<unknown>;
refetchQuery: { query: unknown; variables: { skipCache: boolean } };
startMutation: ContainerMutationFn;
stopMutation: ContainerMutationFn;
pauseMutation: ContainerMutationFn;
unpauseMutation: ContainerMutationFn;
refetchQuery: { query: DocumentNode; variables: { skipCache: boolean } };
onSuccess?: (message: string) => void;
}