Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
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;
}