Spreadsheet-UX op een echte relationele engine

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

5.7 kB Ruw bestand
import { createContext, useContext, useMemo, type ReactNode } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
  DEFAULT_CURRENCY,
  ROLE_RANK,
  type Account,
  type Role,
  type Workspace,
} from '@wellformed/shared';
import { api, ApiError, activeWorkspace, type SignupInput } from '../api/client';
import { usePublicConfig } from '../api/hooks';

interface SessionApi {
  /** Who is signed in, installation-wide. */
  account: Account | null;
  /** Every workspace this account can open. */
  workspaces: Workspace[];
  /** The one this page is showing, when the URL names one the account belongs to. */
  workspace: Workspace | null;
  /** True when the open workspace is the published demo and nobody is signed in. */
  visiting: boolean;
  loading: boolean;
  /** False only when the API itself cannot be reached. */
  reachable: boolean;
  /** Whether the account holds at least `role` **in the open workspace**. */
  can(role: Role): boolean;
  /** Resolves with the workspaces the account can open, so the caller can route. */
  signIn(email: string, password: string): Promise<Workspace[]>;
  signUp(input: SignupInput): Promise<Workspace | undefined>;
  signOut(): Promise<void>;
  refresh(): Promise<void>;
}

const SessionContext = createContext<SessionApi | null>(null);

export function SessionProvider({ children }: { children: ReactNode }): ReactNode {
  const queryClient = useQueryClient();
  const install = usePublicConfig();

  const me = useQuery({
    queryKey: ['session'],
    queryFn: () => api.me(),
    retry: (count, error) => !(error instanceof ApiError && error.status < 500) && count < 2,
    staleTime: 60_000,
  });

  const signIn = useMutation({
    mutationFn: ({ email, password }: { email: string; password: string }) =>
      api.login(email, password),
    onSuccess: (session) => {
      queryClient.setQueryData(['session'], session);
      void queryClient.invalidateQueries();
    },
  });

  const signUp = useMutation({
    mutationFn: (input: SignupInput) => api.signup(input),
    onSuccess: (session) => {
      queryClient.setQueryData(['session'], session);
    },
  });

  const signOut = useMutation({
    mutationFn: () => api.logout(),
    onSuccess: () => {
      // Nothing cached was readable without the session that just ended.
      queryClient.clear();
      queryClient.setQueryData(['session'], { account: null, workspaces: [] });
    },
  });

  const account = me.data?.account ?? null;
  const workspaces = useMemo(() => me.data?.workspaces ?? [], [me.data]);

  /**
   * The open workspace is whichever one the URL names, matched against the
   * memberships the server just returned. Nothing else decides it: a slug the
   * account has no membership in resolves to null, and the shell offers the
   * picker rather than a grid that would 404 on every request.
   */
  const workspace = useMemo(() => {
    const slug = activeWorkspace();
    const mine = workspaces.find((w) => w.slug === slug);
    if (mine) return mine;

    // A published demo is a workspace the server answers for without a
    // membership. It is described as `viewer` here for the same reason the
    // server treats a visitor as one: every affordance in the app is gated on
    // this role, so a visitor gets a read-only grid without a second code
    // path. Matched by slug across all of them: a Dutch browser must still be
    // able to open the English demo from a link, and the other way round.
    const demo = install.data?.demos.find((candidate) => candidate.slug === slug);
    if (slug && demo?.slug === slug) {
      return {
        id: `demo:${demo.slug}`,
        slug: demo.slug,
        name: demo.name,
        role: 'viewer' as const,
        status: 'active' as const,
        currency: demo.currency,
        locale: demo.locale,
        createdAt: '',
        // A visitor is not billed for anything and never sees a plan notice.
        plan: 'free' as const,
        billingStatus: 'active' as const,
        trialEndsAt: null,
      };
    }
    return null;
  }, [workspaces, install.data]);

  // Membership wins: a member browsing the demo is a member, not a visitor.
  const visiting = workspace !== null && !workspaces.some((w) => w.slug === workspace.slug);

  const value = useMemo<SessionApi>(
    () => ({
      account,
      workspaces,
      workspace,
      visiting,
      loading: me.isLoading,
      reachable: !(me.error instanceof ApiError && me.error.status === 0),
      can: (role) => (workspace ? ROLE_RANK[workspace.role] >= ROLE_RANK[role] : false),
      signIn: async (email, password) => {
        const session = await signIn.mutateAsync({ email, password });
        return session.workspaces;
      },
      signUp: async (input) => {
        const session = await signUp.mutateAsync(input);
        return session.workspaces[0];
      },
      signOut: async () => {
        await signOut.mutateAsync();
      },
      refresh: async () => {
        await me.refetch();
      },
    }),
    [account, workspaces, workspace, visiting, me, signIn, signUp, signOut],
  );

  return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
}

export function useSession(): SessionApi {
  const ctx = useContext(SessionContext);
  if (!ctx) throw new Error('useSession must be used inside a SessionProvider.');
  return ctx;
}

/**
 * The currency the open workspace keeps its books in.
 *
 * Read here rather than passed down because everything that prints an amount —
 * a cell, the total under a column, a linked record — sits at a different depth,
 * and threading one string through all of them would be a prop nobody enjoys.
 */
export function useCurrency(): string {
  return useSession().workspace?.currency ?? DEFAULT_CURRENCY;
}