mirror of
https://github.com/unraid/api.git
synced 2026-01-08 01:29:49 -06:00
refactor: optimistic column toggle
This commit is contained in:
@@ -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<string>(() => props.rootFolderId || 'root');
|
||||
|
||||
interface BaseTableInstance {
|
||||
columnVisibility?: { value: Record<string, boolean> };
|
||||
tableApi?: {
|
||||
getAllColumns: () => Array<{
|
||||
id: string;
|
||||
getCanHide: () => boolean;
|
||||
getIsVisible: () => boolean;
|
||||
toggleVisibility: (visible: boolean) => void;
|
||||
}>;
|
||||
getColumn: (id: string) =>
|
||||
| {
|
||||
toggleVisibility: (visible: boolean) => void;
|
||||
}
|
||||
| undefined;
|
||||
setColumnVisibility?: (
|
||||
updater: Record<string, boolean> | ((prev: Record<string, boolean>) => Record<string, boolean>)
|
||||
) => void;
|
||||
};
|
||||
}
|
||||
|
||||
const baseTableRef = ref<BaseTableInstance | null>(null);
|
||||
const baseTableRef = ref<ColumnVisibilityTableInstance | null>(null);
|
||||
|
||||
const searchableKeys = [
|
||||
'name',
|
||||
@@ -505,125 +487,14 @@ const resolvedColumnVisibility = computed<Record<string, boolean>>(() => ({
|
||||
...(columnVisibilityRef.value ?? {}),
|
||||
}));
|
||||
|
||||
function getEffectiveVisibility(
|
||||
visibility: Record<string, boolean> | 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<string, boolean> | null | undefined,
|
||||
target: Record<string, boolean> | 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<string, boolean>) {
|
||||
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<Record<string, boolean> | 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<string, boolean> | null | undefined
|
||||
): Record<string, boolean> {
|
||||
const ids = getHideableColumnIds();
|
||||
const normalized: Record<string, boolean> = {};
|
||||
for (const id of ids) {
|
||||
normalized[id] = getEffectiveVisibility(raw, id);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function readCurrentColumnVisibility(): Record<string, boolean> | null {
|
||||
const tableApi = baseTableRef.value?.tableApi;
|
||||
if (!tableApi) return null;
|
||||
const record: Record<string, boolean> = {};
|
||||
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<DropdownMenuItems>(() => {
|
||||
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) {
|
||||
|
||||
240
web/src/composables/usePersistentColumnVisibility.ts
Normal file
240
web/src/composables/usePersistentColumnVisibility.ts
Normal file
@@ -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<ColumnVisibilityTableInstance | null>(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<string, boolean> | ((prev: Record<string, boolean>) => Record<string, boolean>)
|
||||
) => void;
|
||||
getColumn?: (id: string) => { toggleVisibility: (visible: boolean) => void } | undefined;
|
||||
}
|
||||
|
||||
export interface ColumnVisibilityTableInstance {
|
||||
columnVisibility?: { value: Record<string, boolean> };
|
||||
tableApi?: ColumnVisibilityTableApi;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for `usePersistentColumnVisibility`.
|
||||
*/
|
||||
interface UsePersistentColumnVisibilityOptions {
|
||||
/**
|
||||
* Reactive ref to the table component instance (must expose `columnVisibility` + `tableApi`).
|
||||
*/
|
||||
tableRef: Ref<ColumnVisibilityTableInstance | null>;
|
||||
/**
|
||||
* Computed visibility map that reflects the desired visibility (defaults merged with saved prefs).
|
||||
*/
|
||||
resolvedVisibility: ComputedRef<Record<string, boolean>>;
|
||||
/**
|
||||
* Fallback default visibility used when the table API cannot provide all hideable column ids.
|
||||
*/
|
||||
fallbackVisibility: ComputedRef<Record<string, boolean>>;
|
||||
/**
|
||||
* Callback invoked with a normalised visibility record whenever the user changes column state.
|
||||
*/
|
||||
onPersist: (visibility: Record<string, boolean>) => void;
|
||||
/**
|
||||
* Optional guard; return `false` to skip persisting (e.g. compact mode).
|
||||
*/
|
||||
isPersistenceEnabled?: () => boolean;
|
||||
}
|
||||
|
||||
function getEffectiveVisibility(
|
||||
visibility: Record<string, boolean> | 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<string, boolean> | null | undefined,
|
||||
target: Record<string, boolean> | 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<Record<string, boolean> | 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<string, boolean> | null | undefined
|
||||
): Record<string, boolean> {
|
||||
const ids = getHideableColumnIds();
|
||||
const normalized: Record<string, boolean> = {};
|
||||
for (const id of ids) {
|
||||
normalized[id] = getEffectiveVisibility(raw, id);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function readCurrentVisibility(): Record<string, boolean> | null {
|
||||
const tableApi = options.tableRef.value?.tableApi;
|
||||
if (!tableApi) return null;
|
||||
|
||||
const record: Record<string, boolean> = {};
|
||||
for (const column of tableApi.getAllColumns()) {
|
||||
if (!column.getCanHide()) continue;
|
||||
record[column.id] = column.getIsVisible();
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
function applyColumnVisibility(target: Record<string, boolean>) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user