From 1258e7e3533ee314a65953253200213a7256d953 Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Wed, 5 Nov 2025 12:43:11 -0500 Subject: [PATCH] refactor: optimistic column toggle --- .../Docker/DockerContainersTable.vue | 181 +------------ .../usePersistentColumnVisibility.ts | 240 ++++++++++++++++++ 2 files changed, 252 insertions(+), 169 deletions(-) create mode 100644 web/src/composables/usePersistentColumnVisibility.ts diff --git a/web/src/components/Docker/DockerContainersTable.vue b/web/src/components/Docker/DockerContainersTable.vue index c99f2aa22..8540c3c92 100644 --- a/web/src/components/Docker/DockerContainersTable.vue +++ b/web/src/components/Docker/DockerContainersTable.vue @@ -21,10 +21,12 @@ import { useDockerViewPreferences } from '@/composables/useDockerColumnVisibilit import { useDockerEditNavigation } from '@/composables/useDockerEditNavigation'; import { useFolderOperations } from '@/composables/useFolderOperations'; import { useFolderTree } from '@/composables/useFolderTree'; +import { usePersistentColumnVisibility } from '@/composables/usePersistentColumnVisibility'; import { useTreeData } from '@/composables/useTreeData'; import type { DockerContainer, FlatOrganizerEntry } from '@/composables/gql/graphql'; import type { DropEvent } from '@/composables/useDragDrop'; +import type { ColumnVisibilityTableInstance } from '@/composables/usePersistentColumnVisibility'; import type { TreeRow } from '@/composables/useTreeData'; import type { TableColumn } from '@nuxt/ui'; import type { Component } from 'vue'; @@ -196,27 +198,7 @@ const containersRef = computed(() => props.containers); const rootFolderId = computed(() => props.rootFolderId || 'root'); -interface BaseTableInstance { - columnVisibility?: { value: Record }; - tableApi?: { - getAllColumns: () => Array<{ - id: string; - getCanHide: () => boolean; - getIsVisible: () => boolean; - toggleVisibility: (visible: boolean) => void; - }>; - getColumn: (id: string) => - | { - toggleVisibility: (visible: boolean) => void; - } - | undefined; - setColumnVisibility?: ( - updater: Record | ((prev: Record) => Record) - ) => void; - }; -} - -const baseTableRef = ref(null); +const baseTableRef = ref(null); const searchableKeys = [ 'name', @@ -505,125 +487,14 @@ const resolvedColumnVisibility = computed>(() => ({ ...(columnVisibilityRef.value ?? {}), })); -function getEffectiveVisibility( - visibility: Record | undefined | null, - columnId: string -): boolean { - if (!visibility) return true; - if (Object.prototype.hasOwnProperty.call(visibility, columnId)) { - return visibility[columnId]; - } - return true; -} - -function visibilityStatesMatch( - current: Record | null | undefined, - target: Record | null | undefined, - columnIds: string[] | undefined -): boolean { - if (!current || !target) return false; - const keys = - columnIds && columnIds.length > 0 - ? new Set(columnIds) - : new Set([...Object.keys(current), ...Object.keys(target)]); - for (const key of keys) { - if (getEffectiveVisibility(current, key) !== getEffectiveVisibility(target, key)) { - return false; - } - } - return true; -} - -function applyColumnVisibility(target: Record) { - const tableInstance = baseTableRef.value; - if (!tableInstance?.columnVisibility) return; - - const visibilityRef = tableInstance.columnVisibility; - const tableApi = tableInstance.tableApi; - const current = visibilityRef.value || {}; - const columnIds = tableApi - ? tableApi - .getAllColumns() - .filter((column) => column.getCanHide()) - .map((column) => column.id) - : []; - - if (visibilityStatesMatch(current, target, columnIds)) { - return; - } - - if (tableApi?.setColumnVisibility) { - tableApi.setColumnVisibility(() => ({ ...target })); - } else { - visibilityRef.value = { ...target }; - } -} - -watch( - () => resolvedColumnVisibility.value, - (target) => { - applyColumnVisibility(target); - }, - { immediate: true, deep: true } -); - -watch( - baseTableRef, - () => { - applyColumnVisibility(resolvedColumnVisibility.value); - }, - { immediate: true, flush: 'post' } -); - -const lastSavedColumnVisibility = ref | null>(null); - -function getHideableColumnIds(): string[] { - const tableApi = baseTableRef.value?.tableApi; - if (tableApi) { - return tableApi - .getAllColumns() - .filter((column) => column.getCanHide()) - .map((column) => column.id); - } - return Object.keys(defaultColumnVisibility.value); -} - -function normalizeColumnVisibilityState( - raw: Record | null | undefined -): Record { - const ids = getHideableColumnIds(); - const normalized: Record = {}; - for (const id of ids) { - normalized[id] = getEffectiveVisibility(raw, id); - } - return normalized; -} - -function readCurrentColumnVisibility(): Record | null { - const tableApi = baseTableRef.value?.tableApi; - if (!tableApi) return null; - const record: Record = {}; - for (const column of tableApi.getAllColumns()) { - if (!column.getCanHide()) continue; - record[column.id] = column.getIsVisible(); - } - return record; -} - -async function persistCurrentColumnVisibility() { - await nextTick(); - const current = readCurrentColumnVisibility(); - if (!current) return; - const normalized = normalizeColumnVisibilityState(current); - if ( - lastSavedColumnVisibility.value && - visibilityStatesMatch(normalized, lastSavedColumnVisibility.value, Object.keys(normalized)) - ) { - return; - } - lastSavedColumnVisibility.value = { ...normalized }; - saveColumnVisibility({ ...normalized }); -} +// Keep table visibility in sync with saved preferences and persist optimistic user toggles. +const { persistCurrentColumnVisibility } = usePersistentColumnVisibility({ + tableRef: baseTableRef, + resolvedVisibility: resolvedColumnVisibility, + fallbackVisibility: defaultColumnVisibility, + onPersist: (visibility) => saveColumnVisibility({ ...visibility }), + isPersistenceEnabled: () => !props.compact, +}); watch( () => props.viewPrefs, @@ -635,34 +506,6 @@ watch( { immediate: true } ); -watch( - () => baseTableRef.value?.columnVisibility?.value, - (columnVisibility) => { - if (!columnVisibility || props.compact) { - return; - } - - const columnIds = getHideableColumnIds(); - const normalizedCurrent = normalizeColumnVisibilityState(columnVisibility); - - if (visibilityStatesMatch(normalizedCurrent, resolvedColumnVisibility.value, columnIds)) { - lastSavedColumnVisibility.value = { ...normalizedCurrent }; - return; - } - - if ( - lastSavedColumnVisibility.value && - visibilityStatesMatch(normalizedCurrent, lastSavedColumnVisibility.value, columnIds) - ) { - return; - } - - lastSavedColumnVisibility.value = { ...normalizedCurrent }; - saveColumnVisibility({ ...normalizedCurrent }); - }, - { deep: true } -); - type ActionDropdownItem = { label: string; icon?: string; onSelect?: (e?: Event) => void; as?: string }; type DropdownMenuItems = ActionDropdownItem[][]; @@ -699,7 +542,7 @@ const columnsMenuItems = computed(() => { type: 'checkbox' as const, checked: column.getIsVisible(), onUpdateChecked(checked: boolean) { - baseTableRef.value?.tableApi?.getColumn(column.id)?.toggleVisibility(!!checked); + baseTableRef.value?.tableApi?.getColumn?.(column.id)?.toggleVisibility(!!checked); void persistCurrentColumnVisibility(); }, onSelect(e: Event) { diff --git a/web/src/composables/usePersistentColumnVisibility.ts b/web/src/composables/usePersistentColumnVisibility.ts new file mode 100644 index 000000000..efe0b9fd0 --- /dev/null +++ b/web/src/composables/usePersistentColumnVisibility.ts @@ -0,0 +1,240 @@ +import { nextTick, ref, watch } from 'vue'; + +import type { ComputedRef, Ref } from 'vue'; + +/** + * Keeps a table's column visibility in sync with default + saved preferences and emits + * optimistic updates whenever the user toggles a column. + * + * The composable assumes the table exposes a TanStack-style API (like Nuxt UI's `UTable`). + * Consumers provide the resolved visibility map (defaults merged with stored prefs) and + * a persistence callback that will be invoked with a normalised visibility record. + * + * Example: + * ```ts + * const baseTableRef = ref(null); + * const defaults = computed(() => ({ status: true, version: false })); + * const resolved = computed(() => ({ ...defaults.value, ...prefs.value })); + * + * const { persistCurrentColumnVisibility } = usePersistentColumnVisibility({ + * tableRef: baseTableRef, + * resolvedVisibility: resolved, + * fallbackVisibility: defaults, + * onPersist: (visibility) => savePrefs(visibility), + * }); + * ``` + */ + +export interface ColumnVisibilityTableColumn { + id: string; + getCanHide: () => boolean; + getIsVisible: () => boolean; + toggleVisibility: (visible: boolean) => void; +} + +export interface ColumnVisibilityTableApi { + getAllColumns: () => ColumnVisibilityTableColumn[]; + setColumnVisibility?: ( + updater: Record | ((prev: Record) => Record) + ) => void; + getColumn?: (id: string) => { toggleVisibility: (visible: boolean) => void } | undefined; +} + +export interface ColumnVisibilityTableInstance { + columnVisibility?: { value: Record }; + tableApi?: ColumnVisibilityTableApi; +} + +/** + * Options for `usePersistentColumnVisibility`. + */ +interface UsePersistentColumnVisibilityOptions { + /** + * Reactive ref to the table component instance (must expose `columnVisibility` + `tableApi`). + */ + tableRef: Ref; + /** + * Computed visibility map that reflects the desired visibility (defaults merged with saved prefs). + */ + resolvedVisibility: ComputedRef>; + /** + * Fallback default visibility used when the table API cannot provide all hideable column ids. + */ + fallbackVisibility: ComputedRef>; + /** + * Callback invoked with a normalised visibility record whenever the user changes column state. + */ + onPersist: (visibility: Record) => void; + /** + * Optional guard; return `false` to skip persisting (e.g. compact mode). + */ + isPersistenceEnabled?: () => boolean; +} + +function getEffectiveVisibility( + visibility: Record | null | undefined, + columnId: string +): boolean { + if (!visibility) return true; + if (Object.prototype.hasOwnProperty.call(visibility, columnId)) { + return visibility[columnId]; + } + return true; +} + +function visibilityStatesMatch( + current: Record | null | undefined, + target: Record | null | undefined, + columnIds: string[] | undefined +): boolean { + if (!current || !target) return false; + const keys = + columnIds && columnIds.length > 0 + ? new Set(columnIds) + : new Set([...Object.keys(current), ...Object.keys(target)]); + + for (const key of keys) { + if (getEffectiveVisibility(current, key) !== getEffectiveVisibility(target, key)) { + return false; + } + } + + return true; +} + +/** + * Returns helpers for syncing a table's column visibility with saved preferences. + */ +export function usePersistentColumnVisibility(options: UsePersistentColumnVisibilityOptions) { + const lastSavedVisibility = ref | null>(null); + + function isPersistenceEnabled(): boolean { + return options.isPersistenceEnabled ? options.isPersistenceEnabled() : true; + } + + function getHideableColumnIds(): string[] { + const tableApi = options.tableRef.value?.tableApi; + if (tableApi) { + return tableApi + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => column.id); + } + return Object.keys(options.fallbackVisibility.value); + } + + function normalizeVisibility( + raw: Record | null | undefined + ): Record { + const ids = getHideableColumnIds(); + const normalized: Record = {}; + for (const id of ids) { + normalized[id] = getEffectiveVisibility(raw, id); + } + return normalized; + } + + function readCurrentVisibility(): Record | null { + const tableApi = options.tableRef.value?.tableApi; + if (!tableApi) return null; + + const record: Record = {}; + for (const column of tableApi.getAllColumns()) { + if (!column.getCanHide()) continue; + record[column.id] = column.getIsVisible(); + } + return record; + } + + function applyColumnVisibility(target: Record) { + const tableInstance = options.tableRef.value; + if (!tableInstance?.columnVisibility) return; + + const visibilityRef = tableInstance.columnVisibility; + const tableApi = tableInstance.tableApi; + const current = visibilityRef.value || {}; + const columnIds = tableApi + ? tableApi + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => column.id) + : []; + + if (visibilityStatesMatch(current, target, columnIds)) { + return; + } + + if (tableApi?.setColumnVisibility) { + tableApi.setColumnVisibility(() => ({ ...target })); + } else { + visibilityRef.value = { ...target }; + } + } + + async function persistCurrentColumnVisibility() { + if (!isPersistenceEnabled()) return; + await nextTick(); + const current = readCurrentVisibility(); + if (!current) return; + + const normalized = normalizeVisibility(current); + if ( + lastSavedVisibility.value && + visibilityStatesMatch(normalized, lastSavedVisibility.value, Object.keys(normalized)) + ) { + return; + } + + lastSavedVisibility.value = { ...normalized }; + options.onPersist({ ...normalized }); + } + + watch( + () => options.resolvedVisibility.value, + (target) => { + applyColumnVisibility(target); + }, + { immediate: true, deep: true } + ); + + watch( + options.tableRef, + () => { + applyColumnVisibility(options.resolvedVisibility.value); + }, + { immediate: true, flush: 'post' } + ); + + watch( + () => options.tableRef.value?.columnVisibility?.value, + (columnVisibility) => { + if (!columnVisibility || !isPersistenceEnabled()) { + return; + } + + const columnIds = getHideableColumnIds(); + const normalizedCurrent = normalizeVisibility(columnVisibility); + + if (visibilityStatesMatch(normalizedCurrent, options.resolvedVisibility.value, columnIds)) { + lastSavedVisibility.value = { ...normalizedCurrent }; + return; + } + + if ( + lastSavedVisibility.value && + visibilityStatesMatch(normalizedCurrent, lastSavedVisibility.value, columnIds) + ) { + return; + } + + lastSavedVisibility.value = { ...normalizedCurrent }; + options.onPersist({ ...normalizedCurrent }); + }, + { deep: true } + ); + + return { + persistCurrentColumnVisibility, + readCurrentVisibility, + }; +}