Type src/ against API and database shapes, fixing implicit anys
Adds .d.ts declarations beside the untyped JS modules imported from TS (api.js, navConfig.js, adminSchema.js, adminNav.js, src/data/*), with shapes taken from the server routes and migrations. get/post/ patch/del now return unknown unless the caller names the response. Fixes found along the way: - website/email/instagram are bare strings from splitLinks, not Link objects; OrganizationDetail's website and email pills rendered with no href or label and now link correctly. - The chapter map falls back to FALLBACK_COLOR for a region with no colour instead of painting its tiles black. Adds defineSection() so each page manifest entry's props are checked against its own Component. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
5286cae9ca
commit
2428f4412a
39 changed files with 1318 additions and 343 deletions
118
src/lib/adminSchema.d.ts
vendored
Normal file
118
src/lib/adminSchema.d.ts
vendored
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
/* Types for adminSchema.js: the client half of the descriptor-driven
|
||||
admin CRUD engine. Rows are whatever columns the server-side
|
||||
descriptor in server/src/admin-schema.js declares, so they stay a
|
||||
string-keyed record; the descriptors are what's fixed. */
|
||||
|
||||
/** A row from /api/admin/:entity or /:entity/:id. Nested paths
|
||||
* ('region.scope') are side-table objects under their key, and
|
||||
* child collections are arrays of rows under theirs. */
|
||||
export type AdminRow = Record<string, unknown>;
|
||||
|
||||
/** One row of an OPTION_QUERIES result. `kind` and `org_id` ride
|
||||
* along on the lists that filterBy narrows. */
|
||||
export type AdminOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
kind?: string;
|
||||
org_id?: string | null;
|
||||
};
|
||||
|
||||
/** GET /api/admin/options, keyed by OPTION_QUERIES name. */
|
||||
export type AdminOptions = Record<string, AdminOption[]>;
|
||||
|
||||
export type FieldWidget =
|
||||
| "text"
|
||||
| "textarea"
|
||||
| "select"
|
||||
| "checkbox"
|
||||
| "color"
|
||||
| "number"
|
||||
| "date";
|
||||
|
||||
/** A bare value, or [value, label]. */
|
||||
export type SelectOption = string | readonly [string, string];
|
||||
|
||||
/** Show a group or collection only when another field has this value. */
|
||||
export type FieldCondition = { path: string; value: unknown };
|
||||
|
||||
export type AdminFieldSpec = {
|
||||
path: string;
|
||||
label: string;
|
||||
widget?: FieldWidget;
|
||||
required?: boolean;
|
||||
full?: boolean;
|
||||
help?: string;
|
||||
options?: readonly SelectOption[];
|
||||
optionsFrom?: string;
|
||||
blankLabel?: string;
|
||||
filterBy?: (option: AdminOption, row: AdminRow) => boolean;
|
||||
/* Set by EntityEdit on the id field rather than in a manifest. */
|
||||
readOnly?: boolean;
|
||||
prefix?: string;
|
||||
prefixPending?: boolean;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
export type FieldGroupSpec = {
|
||||
legend: string;
|
||||
note?: string;
|
||||
when?: FieldCondition;
|
||||
fields: AdminFieldSpec[];
|
||||
};
|
||||
|
||||
export type CollectionSpec = {
|
||||
key: string;
|
||||
label: string;
|
||||
addLabel?: string;
|
||||
note?: string;
|
||||
when?: FieldCondition;
|
||||
title?: (row: AdminRow, options: AdminOptions | null | undefined) => string;
|
||||
blank: AdminRow;
|
||||
fields: AdminFieldSpec[];
|
||||
children?: CollectionSpec[];
|
||||
};
|
||||
|
||||
export type ListColumn = {
|
||||
key: string;
|
||||
label: string;
|
||||
primary?: boolean;
|
||||
widget?: "bool";
|
||||
};
|
||||
|
||||
export type ListFilter = {
|
||||
key: string;
|
||||
label: string;
|
||||
options?: readonly SelectOption[];
|
||||
optionsFrom?: string;
|
||||
};
|
||||
|
||||
export type EntitySpec = {
|
||||
key: string;
|
||||
label: string;
|
||||
singular: string;
|
||||
idLabel: string;
|
||||
/** "auto": the table assigns the id, so the form shows it rather than asking. */
|
||||
idKind?: "auto";
|
||||
/** Field(s) the slug is composed from. Absent when idKind is "auto". */
|
||||
slugFrom?: string | string[];
|
||||
titleFrom?: string;
|
||||
list: { columns: ListColumn[]; filters: ListFilter[] };
|
||||
groups: FieldGroupSpec[];
|
||||
children?: CollectionSpec[];
|
||||
};
|
||||
|
||||
export type AdminEntityKey =
|
||||
| "organizations"
|
||||
| "events"
|
||||
| "people"
|
||||
| "teams"
|
||||
| "awards"
|
||||
| "timeline";
|
||||
|
||||
/* Indexed by route param as often as by name, so any other string
|
||||
reads as possibly missing. */
|
||||
export declare const ADMIN_ENTITIES: { readonly [K in AdminEntityKey]: EntitySpec } & {
|
||||
readonly [key: string]: EntitySpec | undefined;
|
||||
};
|
||||
|
||||
export declare function slugify(value: unknown): string;
|
||||
|
|
@ -14,12 +14,14 @@
|
|||
|
||||
import { createContext, useContext, useEffect } from "react";
|
||||
|
||||
export const AdminTitleContext = createContext(null);
|
||||
export type AdminTitleValue = { setDetail: (value: string | null) => void };
|
||||
|
||||
export const AdminTitleContext = createContext<AdminTitleValue | null>(null);
|
||||
|
||||
/* Publish the name of whatever this page is showing. Clears on
|
||||
unmount, so navigating away can't leave a stale record name in
|
||||
the tab. */
|
||||
export function useAdminDetail(name) {
|
||||
export function useAdminDetail(name: string | null | undefined) {
|
||||
const setDetail = useContext(AdminTitleContext)?.setDetail;
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
26
src/lib/api.d.ts
vendored
Normal file
26
src/lib/api.d.ts
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/* Types for api.js. The response type is the caller's to name:
|
||||
every endpoint sends a different body, so get<T> defaults to
|
||||
unknown rather than pretending to know. */
|
||||
|
||||
/** Per-field messages, as admin-crud.js and feedback.js send them. */
|
||||
export type FieldErrors = Record<string, string>;
|
||||
|
||||
export declare class ApiError extends Error {
|
||||
status?: number;
|
||||
fields?: FieldErrors;
|
||||
constructor(message: string, options?: { status?: number; fields?: FieldErrors });
|
||||
}
|
||||
|
||||
export declare function get<T = unknown>(
|
||||
path: string,
|
||||
options?: { ttl?: number; fallback?: T },
|
||||
): Promise<T>;
|
||||
|
||||
export declare function invalidate(path?: string): void;
|
||||
|
||||
export declare function post<T = unknown>(path: string, data: unknown): Promise<T>;
|
||||
|
||||
export declare function patch<T = unknown>(path: string, data: unknown): Promise<T>;
|
||||
|
||||
/* A 204 comes back as null. */
|
||||
export declare function del<T = null>(path: string): Promise<T>;
|
||||
|
|
@ -13,21 +13,40 @@
|
|||
staring at an empty table.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
import { Navigate, Outlet, useLocation } from "react-router-dom";
|
||||
|
||||
import { get, post, ApiError } from "./api.js";
|
||||
import type { Role } from "./roles.ts";
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
/* What currentUser() in server/src/auth.js returns, and what
|
||||
/auth/me and /auth/login send under `user`. */
|
||||
export type AdminUser = {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string | null;
|
||||
role: Role;
|
||||
};
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null);
|
||||
type AuthResponse = { user: AdminUser };
|
||||
|
||||
type AuthValue = {
|
||||
user: AdminUser | null;
|
||||
loading: boolean;
|
||||
login: (email: string, password: string) => Promise<AdminUser>;
|
||||
logout: () => Promise<void>;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthValue | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AdminUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
|
||||
get("/auth/me", { ttl: 0 })
|
||||
get<AuthResponse>("/auth/me", { ttl: 0 })
|
||||
.then((data) => {
|
||||
if (!ignore) setUser(data.user);
|
||||
})
|
||||
|
|
@ -43,8 +62,8 @@ export function AuthProvider({ children }) {
|
|||
};
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (email, password) => {
|
||||
const data = await post("/auth/login", { email, password });
|
||||
const login = useCallback(async (email: string, password: string) => {
|
||||
const data = await post<AuthResponse>("/auth/login", { email, password });
|
||||
setUser(data.user);
|
||||
return data.user;
|
||||
}, []);
|
||||
|
|
@ -73,7 +92,7 @@ export function useAuth() {
|
|||
|
||||
/* Signals a session that ended while the page was open — the
|
||||
admin pages call this when a request comes back 401. */
|
||||
export function isUnauthorized(error) {
|
||||
export function isUnauthorized(error: unknown): boolean {
|
||||
return error instanceof ApiError && error.status === 401;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from "react";
|
||||
import { useState, type ComponentType, type ReactNode } from "react";
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
SECTION MANIFEST
|
||||
|
|
@ -33,25 +33,69 @@ import { useState } from "react";
|
|||
want them.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
export function useSectionManifest(manifest) {
|
||||
/* What every section Component is handed on top of its own props. */
|
||||
export type SectionInjected = { accent: string; view?: string };
|
||||
|
||||
export type SectionToggleProps = {
|
||||
view: string;
|
||||
setView: (value: string) => void;
|
||||
accent: string;
|
||||
options: string[];
|
||||
};
|
||||
|
||||
export type SectionViews = {
|
||||
options: string[];
|
||||
default?: string;
|
||||
Toggle: ComponentType<SectionToggleProps>;
|
||||
};
|
||||
|
||||
export type SectionHeading = {
|
||||
id: string;
|
||||
title: string;
|
||||
blurb?: string;
|
||||
accent: string;
|
||||
background: string;
|
||||
};
|
||||
|
||||
export type SectionEntry<P extends object = Record<string, unknown>> = SectionHeading & {
|
||||
Component: ComponentType<P & SectionInjected>;
|
||||
/* The Component decides P; props are checked against it. */
|
||||
props?: NoInfer<P>;
|
||||
views?: SectionViews;
|
||||
};
|
||||
|
||||
/* A manifest mixes components with different props, which no single
|
||||
array element type can check. This checks each entry against its
|
||||
own Component where it's written, then forgets P so the entries
|
||||
fit one array. */
|
||||
export function defineSection<P extends object>(entry: SectionEntry<P>): SectionEntry {
|
||||
return entry as unknown as SectionEntry;
|
||||
}
|
||||
|
||||
export type ManifestSection = SectionHeading & {
|
||||
actions?: ReactNode;
|
||||
content: ReactNode;
|
||||
};
|
||||
|
||||
export function useSectionManifest(manifest: SectionEntry[]): ManifestSection[] {
|
||||
// One entry per section that has a toggle, seeded from its
|
||||
// declared default so the control is right before anything loads.
|
||||
const [views, setViews] = useState(() =>
|
||||
const [views, setViews] = useState<Record<string, string | null>>(() =>
|
||||
Object.fromEntries(
|
||||
manifest
|
||||
.filter(entry => entry.views)
|
||||
.map(entry => [
|
||||
entry.id,
|
||||
entry.views.default ?? entry.views.options?.[0] ?? null,
|
||||
entry.views?.default ?? entry.views?.options[0] ?? null,
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
const setView = (id, value) => setViews(prev => ({ ...prev, [id]: value }));
|
||||
const setView = (id: string, value: string) => setViews(prev => ({ ...prev, [id]: value }));
|
||||
|
||||
return manifest.map(entry => {
|
||||
const { Component, props, views: spec, ...heading } = entry;
|
||||
const view = views[entry.id];
|
||||
const view = views[entry.id] ?? undefined;
|
||||
const Toggle = spec?.Toggle;
|
||||
|
||||
return {
|
||||
|
|
@ -59,7 +103,7 @@ export function useSectionManifest(manifest) {
|
|||
|
||||
actions: Toggle ? (
|
||||
<Toggle
|
||||
view={view}
|
||||
view={view ?? ""}
|
||||
setView={value => setView(entry.id, value)}
|
||||
accent={entry.accent}
|
||||
options={spec.options}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@
|
|||
* and the public/ layout stay the frontend's business.
|
||||
*/
|
||||
|
||||
import type { OrgKind } from './hrefs.ts'
|
||||
|
||||
export type DatePrecision = 'year' | 'month' | 'day'
|
||||
|
||||
/** What an entry is about. Drives the marker and the body layout. */
|
||||
|
|
@ -41,6 +43,8 @@ export type TimelineRef = {
|
|||
kind: RefKind
|
||||
/** The row's TEXT primary key — an event id, org slug, team slug. */
|
||||
id: string
|
||||
/** Only on organizations: history.js copies v_timeline.org_kind. */
|
||||
orgKind?: OrgKind
|
||||
}
|
||||
|
||||
/** Filename plus the table it came from; the directory is derived
|
||||
|
|
|
|||
|
|
@ -103,12 +103,22 @@ export type EventRecord = {
|
|||
hosts: EventHost[]
|
||||
description: string[]
|
||||
links: Link[]
|
||||
instagram?: Link | null
|
||||
/** The instagram link's label, the handle. See splitLinks in shape.js. */
|
||||
instagram?: string | null
|
||||
blocks: ContentBlock[]
|
||||
people: EventPerson[]
|
||||
awards: EventAward[]
|
||||
}
|
||||
|
||||
/** One row of GET /events: shapeEvent without the detail-only
|
||||
* blocks, people and awards. */
|
||||
export type EventListItem = Omit<EventRecord, 'blocks' | 'people' | 'awards'>
|
||||
|
||||
/** An event_sections row, as GET /events sends it beside the list. */
|
||||
export type EventSection = { id: string; name: string; sort_order: number }
|
||||
|
||||
export type EventsResponse = { sections: EventSection[]; events: EventListItem[] }
|
||||
|
||||
export const useEvent = (id?: string): Resource<EventRecord> =>
|
||||
useRecord<EventRecord>(detailPath('/events', id), 'event')
|
||||
|
||||
|
|
@ -154,6 +164,17 @@ export type OrgEvent = {
|
|||
color?: string | null
|
||||
}
|
||||
|
||||
/** regions.scope's CHECK values. */
|
||||
export type RegionScope = 'domestic' | 'international' | 'virtual'
|
||||
|
||||
/** A region_areas row as attachRegionDetails sends it. */
|
||||
export type RegionArea = {
|
||||
area_code: string
|
||||
share: number
|
||||
edge: 'top' | 'bottom' | null
|
||||
note: string | null
|
||||
}
|
||||
|
||||
export type OrganizationRecord = {
|
||||
id: string
|
||||
kind: 'national' | 'region' | 'chapter' | 'partner'
|
||||
|
|
@ -173,14 +194,16 @@ export type OrganizationRecord = {
|
|||
blocks: ContentBlock[]
|
||||
links: Link[]
|
||||
socials: Link[]
|
||||
website?: Link | null
|
||||
email?: Link | null
|
||||
instagram?: Link | null
|
||||
/* splitLinks in shape.js lifts these out as bare strings: the
|
||||
website's url, the email's label, the instagram handle. */
|
||||
website?: string | null
|
||||
email?: string | null
|
||||
instagram?: string | null
|
||||
/** Shape depends on `kind`; empty object for national and partner. */
|
||||
details: {
|
||||
scope?: string | null
|
||||
scope?: RegionScope | null
|
||||
map_note?: string | null
|
||||
areas?: Array<{ area_code: string; share?: number | null; edge?: string | null; note?: string | null }>
|
||||
areas?: RegionArea[]
|
||||
chapters?: Array<{ id: string; name: string; location_label?: string | null; logo?: string | null }>
|
||||
region_id?: string | null
|
||||
region_name?: string | null
|
||||
|
|
@ -194,6 +217,14 @@ export type OrganizationRecord = {
|
|||
events: OrgEvent[]
|
||||
}
|
||||
|
||||
/** One row of GET /organizations: the card surface and details,
|
||||
* without the sections only the org's own page loads. */
|
||||
export type OrganizationListItem = Omit<OrganizationRecord, 'teams' | 'awards' | 'events'> & {
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
export type OrganizationsResponse = { organizations: OrganizationListItem[] }
|
||||
|
||||
export const useOrganization = (id?: string): Resource<OrganizationRecord> =>
|
||||
useRecord<OrganizationRecord>(detailPath('/organizations', id), 'organization')
|
||||
|
||||
|
|
@ -211,7 +242,8 @@ export type TeamRecord = {
|
|||
description: string[]
|
||||
links: Link[]
|
||||
socials: Link[]
|
||||
instagram?: Link | null
|
||||
/** The instagram handle. See splitLinks in shape.js. */
|
||||
instagram?: string | null
|
||||
blocks: ContentBlock[]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ export function useRecord<T>(path: string | null, key: string): Resource<T> {
|
|||
setError(null)
|
||||
setNotFound(false)
|
||||
try {
|
||||
const body = await get(path as string)
|
||||
const body = await get<Record<string, unknown> | null>(path as string)
|
||||
if (!live) return
|
||||
// A 200 with the key absent is a server-side shaping bug,
|
||||
// not an empty record. Say so rather than rendering a page
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue