use-global-canvas-presets.ts6.0 KBView on GitHub
/**
 * useGlobalCanvasPresets Hook
 *
 * Manages global canvas column presets stored in userSettings.canvasPresets.
 * All canvas column presets are stored here at the user level and shared
 * across all conversation canvases. Individual canvases only store their
 * current column state (presetId + columnConfiguration).
 *
 * - Save current canvas presets as global defaults
 * - Load global presets for new canvases
 * - CRUD operations on global presets
 */

import { useSettings } from '@/modules/userSettings/hooks/use-settings';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { CanvasPreset } from '@/modules/canvas/types/canvas-types';
import type { CanvasPresetSettings, UserSettings } from '@zero/server/schemas';
import { useTRPC } from '@/providers/query-provider';
import { useCallback, useMemo } from 'react';
import { toast } from 'sonner';

/**
 * Hook to manage global canvas column presets (stored in userSettings).
 */
export function useGlobalCanvasPresets() {
  const trpc = useTRPC();
  const queryClient = useQueryClient();
  const { data: settingsData } = useSettings();

  const { mutateAsync: saveUserSettings, isPending: isSaving } = useMutation({
    ...trpc.settings.save.mutationOptions(),
    onSuccess: () => {
      queryClient.invalidateQueries({
        queryKey=[redacted],
      });
    },
  });

  /** Current global presets from userSettings (empty array if none set). */
  const globalPresets = useMemo((): CanvasPreset[] => {
    const raw = settingsData?.settings?.canvasPresets ?? [];
    return raw.map((p: CanvasPresetSettings) => ({
      id: p.id,
      name: p.name,
      columnIds: p.columnIds,
      expanded: p.expanded,
    }));
  }, [settingsData?.settings?.canvasPresets]);

  /** Whether the user has any global presets configured. */
  const hasGlobalPresets = globalPresets.length > 0;

  /** Save an array of presets as the user's global canvas presets. */
  const saveGlobalPresets = useCallback(
    async (presets: CanvasPreset[], options?: { silent?: boolean }) => {
      const serialized: CanvasPresetSettings[] = presets.map((p) => ({
        id: p.id,
        name: p.name,
        columnIds: p.columnIds,
        expanded: p.expanded,
      }));

      // Optimistically update the cache so the UI reflects the change immediately.
      // Spread `previous` rather than rebuilding the envelope: the response carries
      // siblings of `settings` (`authenticated`, which gates the chat composer's
      // access notice) and dropping them here downgrades those readers to "unknown"
      // until the next refetch.
      const queryKey=[redacted];
      type SettingsQueryData = { settings: UserSettings; authenticated?: boolean };
      const previous = queryClient.getQueryData<SettingsQueryData>(queryKey);
      queryClient.setQueryData<SettingsQueryData>(queryKey, {
        ...previous,
        settings: { ...(previous?.settings ?? settingsData?.settings ?? {} as UserSettings), canvasPresets: serialized },
      });

      try {
        await saveUserSettings({
          canvasPresets: serialized,
        });
      } catch (error) {
        // Rollback optimistic update on failure
        queryClient.setQueryData<SettingsQueryData>(queryKey, previous);
        console.error('Failed to save global presets:', error);
        if (!options?.silent) {
          toast.error('Failed to save global presets');
        }
        throw error;
      }
    },
    [settingsData?.settings, saveUserSettings, queryClient, trpc.settings.get],
  );

  /** Upsert a single preset in the global list. */
  const upsertGlobalPreset = useCallback(
    async (preset: CanvasPreset, options?: { silent?: boolean }) => {
      const existing = [...globalPresets];
      const idx = existing.findIndex((p) => p.id === preset.id);
      if (idx >= 0) {
        existing[idx] = preset;
      } else {
        existing.push(preset);
      }
      await saveGlobalPresets(existing, options);
    },
    [globalPresets, saveGlobalPresets],
  );

  /** Remove a single preset from the global list by id. */
  const removeGlobalPreset = useCallback(
    async (presetId: string) => {
      const updated = globalPresets.filter((p) => p.id !== presetId);
      if (updated.length === globalPresets.length) return;
      await saveGlobalPresets(updated);
    },
    [globalPresets, saveGlobalPresets],
  );

  /** Save all presets from a specific canvas as the global defaults. */
  const saveCanvasPresetsAsGlobal = useCallback(
    async (canvasPresets: CanvasPreset[]) => {
      await saveGlobalPresets(canvasPresets);
    },
    [saveGlobalPresets],
  );

  /**
   * Silently sync column order/visibility to the active preset.
   * Called after column reorder or visibility toggle to keep the preset in sync.
   */
  const syncActivePresetColumns = useCallback(
    async (presetId: string | undefined, orderedColumnIds: string[]) => {
      if (!presetId) return;
      const preset = globalPresets.find((p) => p.id === presetId);
      if (!preset) return;

      // Skip if columnIds are already identical (same items in same order)
      if (
        preset.columnIds.length === orderedColumnIds.length &&
        preset.columnIds.every((id, i) => id === orderedColumnIds[i])
      ) {
        return;
      }

      await upsertGlobalPreset({ ...preset, columnIds: orderedColumnIds }, { silent: true });
    },
    [globalPresets, upsertGlobalPreset],
  );

  /** Clear all global presets (revert to built-in defaults). */
  const clearGlobalPresets = useCallback(async () => {
    try {
      await saveUserSettings({
        canvasPresets: [],
      });
    } catch (error) {
      console.error('Failed to clear global presets:', error);
      toast.error('Failed to clear global presets');
      throw error;
    }
  }, [settingsData?.settings, saveUserSettings]);

  return {
    globalPresets,
    hasGlobalPresets,
    isSaving,
    saveGlobalPresets,
    upsertGlobalPreset,
    removeGlobalPreset,
    saveCanvasPresetsAsGlobal,
    clearGlobalPresets,
    syncActivePresetColumns,
  };
}