DashboardViewer.tsx2.6 KBView on GitHub
'use client';

import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTRPC } from '@/providers/query-provider';
import { cn } from '@/lib/utils';
import { applyComputations } from '../lib/data';
import { isMockSource, type DashboardSpec } from '../types/dashboard';
import { SourcesProvider, type ResolvedSource } from './context';
import { LayoutNode } from './blocks';

interface DashboardViewerProps {
  spec: DashboardSpec;
  /** Execution context for query params (userId, dateFrom, …). Ignored by mock sources. */
  queryContext?: Record<string, string>;
  className?: string;
}

/**
 * Resolves a dashboard spec's data sources and renders its layout tree.
 *
 * Mock sources resolve inline (no network). Query sources call
 * `dashboard.execute` with a 5-minute cache. The set of sources is fixed for a
 * given spec, so the per-source hooks below have a stable order — callers
 * should key this component by `spec.id` when the spec can change.
 */
export function DashboardViewer({ spec, queryContext = {}, className }: DashboardViewerProps) {
  const trpc = useTRPC();
  const entries = Object.entries(spec.dataSources);

  const sources: Record<string, ResolvedSource> = {};
  for (const [name, source] of entries) {
    if (isMockSource(source)) {
      // eslint-disable-next-line react-hooks/rules-of-hooks
      const rows = useMemo(() => applyComputations(source.mock, source.computations), [source]);
      sources[name] = { rows, isLoading: false };
    } else {
      // Positional params, in spec order — bound as $2…$n on the server ($1 is
      // always the authenticated user id).
      const queryParams = (source.queryParams ?? []).map((p) => queryContext[p.sourceField] ?? '');
      // eslint-disable-next-line react-hooks/rules-of-hooks
      const result = useQuery({
        ...trpc.dashboard.execute.queryOptions({
          specId: spec.id,
          source: name,
          query: source.query,
          queryParams,
        }),
        staleTime: 5 * 60 * 1000,
        refetchOnWindowFocus: false,
      });
      sources[name] = {
        rows: applyComputations(
          (result.data?.rows as Record<string, unknown>[]) ?? [],
          source.computations,
        ),
        isLoading: result.isLoading,
        error: result.error,
      };
    }
  }

  return (
    <SourcesProvider value={sources}>
      <div className={cn('flex flex-col gap-4', className)}>
        {spec.title && <h2 className="text-lg font-semibold">{spec.title}</h2>}
        <LayoutNode node={spec.layout} />
      </div>
    </SourcesProvider>
  );
}