diff --git a/README.md b/README.md index 18bd6ff..8bacc01 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,13 @@ The Next.js front end for the [OrcaHouse](https://github.com/umccr/orcahouse) data warehouse. Its first module is a browser for the OrcaVault data mart: every table the -mart GraphQL API exposes, grouped the way the dbt project lays them out, with filtering, -sorting, paging and CSV export. Further modules are planned for project overview, +mart GraphQL API exposes, grouped the way the dbt project lays them out, with search, +filtering, sorting, paging and CSV export. Further modules are planned for project overview, pipeline monitoring, automations, and user-built tables. This is currently a demo MVP. It is built as static files served under -https://portal.umccr.org/orcahouse/, next to the OrcaBus portal, and people sign in with their UMCCR -Google account through the portal's Cognito user pool. +, next to the OrcaBus portal, and people sign in with their +UMCCR Google account through the portal's Cognito user pool. ## Related repositories @@ -19,61 +19,11 @@ Google account through the portal's Cognito user pool. | [umccr/orcahouse-doc](https://github.com/umccr/orcahouse-doc) | Warehouse documentation, glossary and ERDs | | [umccr/frontend-infrastructure-pipelines](https://github.com/umccr/frontend-infrastructure-pipelines) | The portal's S3 buckets, CloudFront distribution, runtime config (`env.js`) and this app's build/deploy pipeline | -## How it works +## Quick start -```text -Deployed: https://portal.umccr.org/orcahouse/ (static files in S3, behind the portal's CloudFront) - Browser ──/orcahouse/env.js────> the portal's runtime config: Cognito user pool and app client - Browser ──GraphQL + ID token───> https://mart..umccr.org/graphql - API Gateway JWT authorizer - -> Lambda (PostGraphile) - -> Aurora PostgreSQL `mart` schema - -Local development: http://localhost:3000/orcahouse/ (next dev) - Browser ──/orcahouse/api/graphql/──> Next.js dev proxy ──MART_API_TOKEN──> https://mart.prod.umccr.org/graphql -``` - -- **Static export, one page.** `next build` writes plain HTML, CSS and JS to `out/` with - `basePath: '/orcahouse'`, the counterpart of orca-ui-v2's `base: '/v2/'`. The app is a single - page: the catalogue is `/orcahouse/` and a table is `/orcahouse/?table=`. One - `index.html` therefore serves every URL, which is what the portal's CloudFront rewrite - expects, and tables the build has never seen still open. -- **No backend host is built in.** The portal builds this app once and promotes the same - artifact from dev to prod, so `src/lib/environment.ts` derives the mart API host from the - hostname the app is served from: `mart.dev.umccr.org` on the dev portal, - `mart.prod.umccr.org` on prod. `MART_API_URL` is local development only. -- **Cognito sign-in.** `src/lib/auth.ts` configures Amplify for the hosted UI with Google - federation, as orca-ui-v2 does. Deployed, it reads the portal's `env.js` from this app's own base - path, so it uses the portal's app client and shares the portal session: anyone signed in to - the portal is signed in here. That client is also the only one the mart API's authorizer accepts. Locally the - settings come from `start.sh`. `AuthGate` in the root layout shows the sign-in page to anyone - signed out, and the avatar menu in the header shows the profile and current token, has a - theme setting, and signs out. -- **API calls.** Deployed, the browser calls the mart API directly with the ID token; the - API's CORS policy allows the portal origins. It does not allow `localhost`, so in development - requests go through `src/app/api/graphql/route.dev.ts`, a same-origin proxy that sends - `MART_API_TOKEN`. The `.dev.ts` extension is only a page extension under `next dev`, which - keeps the proxy out of the static export. -- **Runtime introspection.** PostGraphile generates the API from the `mart` schema, so the - UI introspects it on load and builds each table view from the live columns, filter - operators and sort keys. Browsing a new mart table needs no code. -- **Catalogue registry.** `src/lib/catalog.ts` maps dbt table names to groups, descriptions - and stability. The groups mirror `orcavault/models/mart//` in the orcahouse repo, - and the descriptions and STABLE/DEMO remarks come from its - `seeds/dictionary/dictionary__data_mart_catalog.csv`. The live schema stays the source - of truth: catalogued tables missing from the API are shown as unavailable, and API - collections missing from the registry appear under "Other". -- **Side navigation.** Groups fold, and the folds are remembered in the browser. The search box - filters tables as you type: each word matches a table name fuzzily (`fqh` finds - `fastq_history`), or appears in the group name or description. -- **Shareable URLs.** The table, page, page size, sort and filter live in the query string, for - example - `/orcahouse/?table=lims&sort=SEQUENCING_RUN_DATE_DESC&filter={"and":[{"libraryId":{"equalTo":"L2400001"}}]}`. - -## Run locally - -Requires Node 24 (see `.nvmrc`), pnpm 10 (`corepack enable` picks the version from -`package.json`) and the AWS CLI. +Needs Node 24, pnpm 10, the AWS CLI and pre-commit. The +[local development guide](docs/local-development.md) covers each step and what to do when one +fails. ```sh make install @@ -81,72 +31,9 @@ aws sso login --profile dev && export AWS_PROFILE=dev make start MART_API_TOKEN= ``` -`make start` sources `start.sh`, the same wrapper orca-ui-v2 uses: it reads the Cognito sign-in -settings for the localhost app client from SSM Parameter Store in the dev account, exports them -as `NEXT_PUBLIC_*` variables and starts the dev server. Open http://localhost:3000/orcahouse/ -(the root `/` redirects there) and sign in with your UMCCR Google account. - -The mart API is only deployed to prod, so the Makefile points `MART_API_URL` at -https://mart.prod.umccr.org. It does not accept localhost sign-in tokens, so in local -development the proxy sends your own token instead: copy the ID token from portal.umccr.org -(profile menu > Token) and pass it as `MART_API_TOKEN`. Portal ID tokens last up to a day; when -API calls fail with "The mart API rejected MART_API_TOKEN", restart with a fresh one. The static -build has no proxy, so it never uses `MART_API_TOKEN`. - -The dev server must stay on port 3000, the only callback URL registered on the Cognito -localhost app client. `make dev` starts it without the Cognito settings, so the sign-in page -only reports that sign-in is not configured. - -| Variable | Set by | Purpose | -| ------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------- | -| `NEXT_PUBLIC_COGNITO_USER_POOL_ID` | `start.sh`, from `/data_portal/client/cog_user_pool_id` | Cognito user pool | -| `NEXT_PUBLIC_COGNITO_OAUTH_DOMAIN` | `start.sh`, from `/data_portal/client/oauth_domain` | Hosted UI domain prefix (a full host name also works) | -| `NEXT_PUBLIC_COGNITO_APP_CLIENT_ID` | `start.sh`, from `/data_portal/client/cog_app_client_id_local` | Localhost app client | -| `NEXT_PUBLIC_OAUTH_REDIRECT_SIGN_IN` | `start.sh`, from `/data_portal/client/oauth_redirect_in_local` | Unused: the app derives its own redirect (see below) | -| `NEXT_PUBLIC_OAUTH_REDIRECT_SIGN_OUT` | `start.sh`, from `/data_portal/client/oauth_redirect_out_local` | Unused: the app derives its own redirect (see below) | -| `NEXT_PUBLIC_COGNITO_REGION` | `start.sh` (`ap-southeast-2`) | Region of the user pool | -| `MART_API_URL` | Makefile, default `https://mart.prod.umccr.org` | Local development only: upstream for the dev proxy | -| `MART_API_TOKEN` | you, e.g. `make start MART_API_TOKEN=...` | Local development only: bearer token for mart API calls | - -The deployed app does not use the `NEXT_PUBLIC_*` values: it reads the same settings from the -portal's `env.js`. It does not use `MART_API_URL` either, deriving the mart host from its own -hostname instead, so nothing environment-specific is baked into the artifact. - -The OAuth redirect URLs are derived rather than read from either source, in development and when -deployed: both `env.js` and the SSM parameters name the portal root, which is a different app. -`src/lib/auth.ts` uses the current origin plus the base path, so local sign-in returns to -http://localhost:3000/orcahouse/ and the deployed app returns to its own page. Both URLs are -registered on the relevant Cognito app client by the `cognito_aai` Terraform stack. - -## Deployment +Open and sign in with your UMCCR Google account. -Deployment is owned by -[umccr/frontend-infrastructure-pipelines](https://github.com/umccr/frontend-infrastructure-pipelines), -not by this repository. There is nothing to run here: a push to `main` triggers -`OrcaHouseAppCICDPipeline`, which runs `pnpm build`, syncs `out/` to -`s3://orcahouse-cloudfront-/orcahouse/`, and invokes the portal's config Lambda to write -`env.js`. Dev deploys automatically; prod is behind a manual approval. - -That repository owns the bucket, the `/orcahouse/*` CloudFront behaviour and the viewer-request -rewrite. This app's entry in its registry (`lib/portal/apps.ts`) declares -`pathPrefix: 'orcahouse'` and `clientRouting: 'static-export'` — the latter is why -`trailingSlash: true` matters here: it makes each route export as `/index.html`, which is -what the rewrite resolves to. - -Two consequences of the shared portal worth knowing: - -- **Nothing environment-specific is in the artifact.** One build is promoted from dev to prod. - Cognito settings arrive at runtime via `env.js`; the mart API host is derived from the - hostname. Do not add a backend URL to `next.config.ts`'s `env` block, which Next inlines at - build time. -- **Sign-in returns to this app, not the portal home page.** The redirect URL is derived from the - current origin plus the base path, so it is right in every environment without configuration. - That exact URL must be registered as a callback and logout URL on the portal's Cognito app - client, which the `cognito_aai` Terraform stack generates from its `portal_app_paths` list. - **Apply that stack before shipping a change to the base path**, or the hosted UI rejects sign-in - with `redirect_mismatch`. - -## Development workflow +## Commands | Command | What it does | | ----------------- | ---------------------------------------------------------------- | @@ -163,55 +50,33 @@ Two consequences of the shared portal worth knowing: Pull requests run the same checks plus a production build through `.github/workflows/pr-tests.yml`. Dependabot proposes weekly dependency and action updates. -## Project layout - -```text -src/ - app/ - layout.tsx app shell: theme script, auth gate, header, side navigation, Apollo provider - page.tsx the one page - api/graphql/route.dev.ts local-development proxy to the mart API (not in the build) - components/ - MartView.tsx the catalogue, or a table when the URL has ?table= - TableBrowser.tsx orchestrates schema, URL state, query and layout - DataTable.tsx FilterBuilder.tsx Pagination.tsx ColumnPicker.tsx - CatalogHome.tsx SideNav.tsx AppHeader.tsx - AuthGate.tsx resolves the Cognito session; shows SignInPage when signed out - UserMenu.tsx header avatar menu: ProfileDialog, TokenDialog, SettingsDialog - hooks/ - useMartSchema.ts introspection query, cached by Apollo - useTheme.ts stored light/dark/system preference - useCollapsedGroups.ts side navigation folds, remembered in localStorage - lib/ - auth.ts Amplify configuration (portal /env.js or start.sh), ID token, return path - apollo.ts Apollo Client factory: the mart API directly, or the dev proxy - catalog.ts table registry mirroring the dbt mart folders - schema.ts introspection helpers: collections, columns, filters, sorts - query-builder.ts builds the per-table rows query - table-search.ts fuzzy table search for the side navigation - filters.ts csv.ts theme.ts -``` - -## Adding or changing a table - -Nothing is required for a new mart table to appear: it shows up under "Other" as soon as -the API exposes it. To place it in a group with a description, status and default sort, -add an entry to `src/lib/catalog.ts`. The `collection` value is the PostGraphile root -field, `all` + the plural PascalCase table name (`fastq_history` becomes `allFastqHistories`). - -## Known limits and next steps - -- **Authentication.** Sign-in happens in the browser and the tokens live in localStorage, shared - with the portal on portal.umccr.org. The dev proxy forwards whatever bearer token it has and - relies on the API's authorizer to check it. -- **Authorization.** Any valid token can read every mart table today: the API runs with - `ignoreRBAC` and one shared read-only database user. Showing different tables to - different users must be enforced in the API or with Postgres roles, not in this UI. -- **Catalogue drift.** Descriptions and STABLE/DEMO remarks are a copy of the dbt seed. - Reading them live from the `catalog` mart table would remove the duplication. -- **Query limits.** The API caps request bodies at 10,000 bytes and uses offset pagination, - so very wide tables or very deep pages will be slow or rejected. -- **No dev mart API.** `mart.dev.umccr.org` does not exist yet, so on the dev portal queries - surface an API error while sign-in and the catalogue still work. Deliberate: better than the - dev deployment reading production data. Local development points at prod with your own - `MART_API_TOKEN`. +## Key things to know + +- **One static page.** `next build` exports plain files served under `/orcahouse/`. A table is + `/orcahouse/?table=`, so one `index.html` serves every URL, and the table, page, sort, + search and filter all live in the query string for sharing. +- **The API defines the tables.** The UI introspects the mart GraphQL API at runtime, so a new + mart table appears without code changes. `src/lib/catalog.ts` only adds groups, descriptions + and STABLE/DEMO status. +- **Nothing environment-specific is in the build.** One artifact is promoted from dev to prod: + the Cognito settings come from the portal's `env.js` and the mart API host from the page's + hostname. Never add a backend URL to the `env` block in `next.config.ts`. +- **The mart API is prod-only.** Local development reaches `mart.prod.umccr.org` through a dev + proxy with your own `MART_API_TOKEN`. The dev portal shows API errors until + `mart.dev.umccr.org` exists. +- **Some features follow the API's configuration.** Row search needs the API (orcahouse + `infra/api`) to allow the `includesInsensitive` filter operator; where it does not, the search + box is hidden. +- **Deploys happen elsewhere.** A push to `main` triggers the pipeline in + frontend-infrastructure-pipelines, and prod needs a manual approval. Changing the base path + needs the `cognito_aai` Terraform stack applied first. + +## Documentation + +| Document | What it covers | +| ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| [How it works](docs/how-it-works.md) | Request flow, sign-in, runtime schema introspection, and how search, filters, sorting and export work | +| [Local development](docs/local-development.md) | Prerequisites, running the dev server, the mart API token, environment variables, troubleshooting | +| [Deployment](docs/deployment.md) | The portal pipeline, runtime config, Cognito redirects, and the mart API this app depends on | +| [Development guide](docs/development-guide.md) | Project layout, naming and code conventions, common changes, gotchas, known limits | +| [OrcaVault project description](docs/OrcaVault-PD.md) | Background on the OrcaVault warehouse the mart tables come from | diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..c232715 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,96 @@ +# Deployment + +How OrcaHouse UI reaches , and what it depends on outside +this repository. + +## The pipeline + +Deployment is owned by +[umccr/frontend-infrastructure-pipelines](https://github.com/umccr/frontend-infrastructure-pipelines), +not by this repository, so there is nothing to run here. A push to `main` triggers +`OrcaHouseAppCICDPipeline`, which: + +1. runs `pnpm build`, +2. syncs `out/` to `s3://orcahouse-cloudfront-/orcahouse/`, +3. invokes the portal's config Lambda to write this app's `env.js`. + +Dev deploys automatically; prod is behind a manual approval. + +That repository also owns the bucket, the `/orcahouse/*` CloudFront behaviour and the +viewer-request rewrite. This app's entry in its registry (`lib/portal/apps.ts`) declares +`pathPrefix: 'orcahouse'` and `clientRouting: 'static-export'`. The latter is why +`trailingSlash: true` matters here: it makes each route export as `/index.html`, which is +what the rewrite resolves to. + +## One artifact for every environment + +One build is promoted from dev to prod, so nothing environment-specific may be in it: + +- **Cognito settings** arrive at runtime through `env.js`, which the app loads from its own base + path (`/orcahouse/env.js`). +- **The mart API host** is derived from the hostname the page is served from + (`src/lib/environment.ts`; see [How it works](how-it-works.md#which-api-the-app-calls)). + +Do not add a backend URL or any other per-environment value to the `env` block in +`next.config.ts`: Next inlines those values into the bundle at build time, so the dev deployment +would carry them into prod. `MART_API_URL` is deliberately left out of it and is only read by the +local dev proxy. + +## Sign-in redirects + +Sign-in returns to this app, not the portal home page. The redirect URL is the current origin +plus the base path (`https://portal.umccr.org/orcahouse/` in prod), so it is right in every +environment without configuration. + +That exact URL must be registered as a callback and logout URL on the portal's Cognito app +client. The `cognito_aai` Terraform stack generates those URLs from its `portal_app_paths` list. +The same stack registers on the localhost app client. + +### Changing the base path + +The base path (`/orcahouse`) is set in several places that must agree: + +1. `BASE_PATH` in `next.config.ts`. +2. `pathPrefix` in the app registry, `lib/portal/apps.ts` in frontend-infrastructure-pipelines. +3. `portal_app_paths` in the `cognito_aai` Terraform stack. + +**Apply the `cognito_aai` stack before shipping the change**, or the hosted UI rejects sign-in +with `redirect_mismatch`. + +## The mart API + +The mart GraphQL API is deployed separately, from the orcahouse repository's `infra/api` +Terraform module, and only to prod (). See that module's README for +the full steps. In short, with prod AWS credentials: + +```sh +cd infra/api/lambda-server +pnpm install --frozen-lockfile +pnpm build # bundles and zips dist/index.zip + +cd .. +terraform init +terraform workspace select prod +terraform plan -var-file="orcavault.tfvars" # expect only the Lambda function to change +terraform apply -var-file="orcavault.tfvars" +``` + +This UI depends on how that API is configured: + +| API setting | What depends on it | +| ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| JWT authorizer audience: the portal's Cognito app client | Deployed sign-in tokens are accepted; localhost sign-in tokens are not, hence `MART_API_TOKEN` | +| CORS allowed origins (`portal.umccr.org`, `orcaui.umccr.org`, their `prod` aliases) | The browser can call the API directly. Serving this app from a new hostname needs that origin added | +| `connectionFilterAllowedOperators` | The filter builder's operators. Row search needs `includesInsensitive`; without it the search box is hidden | +| `maxRequestLength` (10,000 bytes) | How many columns one rows query can request | + +The UI reads the operators from the live schema, so an API change and a UI change can ship in +either order: the UI hides what the API does not offer yet, and picks up a newly allowed operator +on the next page reload. + +## No dev mart API + +`mart.dev.umccr.org` does not exist yet, so on the dev portal queries show an API error while +sign-in and the catalogue still work. This is deliberate: it is better than the dev deployment +reading production data. Local development points at prod with your own `MART_API_TOKEN` (see +[Local development](local-development.md#the-mart-api-token)). diff --git a/docs/development-guide.md b/docs/development-guide.md new file mode 100644 index 0000000..9e43d9a --- /dev/null +++ b/docs/development-guide.md @@ -0,0 +1,183 @@ +# Development guide + +Where the code lives, the conventions it follows, how to make common changes, and what to watch +out for. For the commands, see the [README](../README.md#commands); for the runtime behaviour, see +[How it works](how-it-works.md). + +## Project layout + +```text +Makefile install, check, start/dev, build; MART_API_URL and MART_API_TOKEN for the dev proxy +start.sh local development: Cognito settings from SSM, then next dev on port 3000 +next.config.ts base path, static export, dev-only proxy route +docs/ this documentation +src/ + app/ + layout.tsx app shell: theme script, auth gate, header, side navigation, Apollo provider + page.tsx the one page + globals.css Tailwind setup, colour tokens, dark mode variant + api/graphql/route.dev.ts local-development proxy to the mart API (not in the build) + components/ + MartView.tsx the catalogue, or a table when the URL has ?table= + CatalogHome.tsx home page: tables by group, with how many are live + TableBrowser.tsx one table: schema metadata, URL state, rows query and layout + RowSearch.tsx search box: a contains search on one text column + FilterBuilder.tsx advanced filter: conditions combined with AND or OR + DataTable.tsx the rows, sortable headers and cell formatting + Pagination.tsx page and page-size controls + ColumnPicker.tsx show and hide columns + SideNav.tsx table tree with fuzzy search and foldable groups + AuthGate.tsx resolves the Cognito session; shows SignInPage when signed out + AppHeader.tsx UserMenu.tsx header and avatar menu: ProfileDialog, TokenDialog, SettingsDialog + ApolloWrapper.tsx Apollo provider + ThemeSync.tsx keeps in step with the stored preference + Dialog.tsx ErrorNotice.tsx StatusBadge.tsx + ui.ts shared Tailwind class strings: BUTTON, BUTTON_PRIMARY, INPUT, MENU + hooks/ + useMartSchema.ts introspection query, cached by Apollo + useTheme.ts stored light/dark/system preference + useCollapsedGroups.ts side navigation folds, remembered in localStorage + useIsClient.ts useNow.ts client-only rendering; a ticking clock for token expiry + lib/ + auth.ts Amplify configuration (portal /env.js or start.sh), ID token, return path + apollo.ts Apollo Client factory: the mart API directly, or the dev proxy + environment.ts deployed environment and mart API host from the hostname + catalog.ts table registry mirroring the dbt mart folders + schema.ts introspection helpers: collections, columns, filters, sorts + query-builder.ts builds the per-table rows query + filters.ts advanced filter state, its URL JSON, combining filters + row-search.ts the row search: searchable columns and its filter clause + table-search.ts fuzzy table search for the side navigation + csv.ts theme.ts +``` + +## Conventions + +### Files + +| Kind | Convention | Example | +| ---------------- | --------------------------------------------------------------- | ----------------------------------- | +| React components | PascalCase `.tsx`, exporting one component named after the file | `RowSearch.tsx` exports `RowSearch` | +| Hooks | `use` + PascalCase, `.ts`, in `src/hooks/` | `useMartSchema.ts` | +| Library modules | Lowercase kebab-case `.ts` in `src/lib/`, no React | `row-search.ts`, `query-builder.ts` | +| Dev-only routes | `.dev.ts`, which only `next dev` treats as a page extension | `src/app/api/graphql/route.dev.ts` | +| Documentation | Lowercase kebab-case `.md` in `docs/` | `local-development.md` | + +### Code + +- **Exports.** Named exports throughout. Default exports only where Next.js requires them + (`page.tsx`, `layout.tsx`). +- **Names.** Types and interfaces in PascalCase, module-level constants in UPPER_SNAKE_CASE + (`PAGE_SIZES`, `OPERATOR_ORDER`, `BUTTON`), everything else in camelCase. +- **Imports.** The `@/` alias for anything under `src/` (`@/lib/schema`), and `./` for siblings + in the same folder (`./ui`). +- **Where logic goes.** Data logic that does not need React, such as parsing the schema, + building queries and filters, and matching searches, lives in `src/lib/` as plain functions. + Components hold state and layout. +- **Comments** explain why, not what. Exported functions and components get a short doc comment. +- **Spelling.** British English in comments, docs and UI text ("catalogue", "colour"). +- **Formatting.** Prettier: single quotes (JSX too), semicolons, 100-column lines, ES5 trailing + commas, and Tailwind classes sorted by `prettier-plugin-tailwindcss`. EditorConfig: UTF-8, LF, + two-space indents, tabs in the Makefile. Run `pnpm format` rather than formatting by hand. + +### Styling + +- **Tailwind CSS v4**, configured in `src/app/globals.css`, and no component library. +- **Colour tokens** come in light and dark pairs: `signal`, `canvas`, `surface`, `line`, `muted` + and `ink`, each with a `-dark` twin, plus `raised-dark`. Use them in pairs, for example + `text-muted dark:text-muted-dark`. +- **Dark mode** is the `dark:` variant, which follows `` rather than the + system media query, so the user's setting wins. +- **Controls** reuse the class strings in `src/components/ui.ts` (`BUTTON`, `BUTTON_PRIMARY`, + `INPUT`, `MENU`), so buttons and inputs line up at the same height. +- **Icons** come from `lucide-react`, with `aria-hidden='true'` when they are decorative. + +### Names shared with the API, the URL and the browser + +| Thing | Convention | Example | +| ------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------- | +| Catalogue `table` | The dbt model name, in snake_case; also the `?table=` value | `fastq_history` | +| Root field (`collection`) | `all` + the plural PascalCase table name | `allFastqHistories` | +| GraphQL types | Singular node, `Filter`, `OrderBy` | `FastqHistory`, `FastqHistoryFilter`, `FastqHistoriesOrderBy` | +| Columns | camelCase of the snake_case database column | `sequencing_run_id` becomes `sequencingRunId` | +| Sort values | The column in UPPER_SNAKE_CASE plus `_ASC` or `_DESC` | `SEQUENCING_RUN_DATE_DESC` | +| Column headers | `humanize()`, keeping the acronyms listed in `ACRONYMS` | "Sequencing Run ID" | +| URL parameters | Short lowercase words | `table`, `page`, `size`, `sort`, `filter`, `q`, `qcol` | +| Browser storage keys | Prefixed `orcahouse-ui.` | `orcahouse-ui.theme`, `orcahouse-ui.sidenav-collapsed` | + +## Common changes + +### Adding or changing a table + +Nothing is required for a new mart table to appear: it shows up under "Other" as soon as the API +exposes it. To place it in a group with a description, status and default sort, add an entry to +`src/lib/catalog.ts`: + +- `table`: the dbt model name. +- `collection`: the PostGraphile root field, `all` + the plural PascalCase table name + (`fastq_history` becomes `allFastqHistories`). +- `description` and `status`: from `orcavault/seeds/dictionary/dictionary__data_mart_catalog.csv` + in the orcahouse repo. +- `defaultSort` (optional): an orderBy value such as `SEQUENCING_RUN_DATE_DESC`. Without it the + table sorts by its first date or datetime column, newest first, if it has one. + +### Adding a filter operator + +1. Allow it in the API: `connectionFilterAllowedOperators` in the orcahouse repo's + `infra/api/lambda-server/src/postgraphile.ts`, then deploy the API (see + [Deployment](deployment.md#the-mart-api)). +2. Add it to `OPERATOR_ORDER` and `OPERATOR_LABELS` in `src/lib/schema.ts`. The filter builder + only offers operators it knows, in that order, and the first one is the default for a new + condition. +3. If its value is not a plain string, number, date or boolean, handle it in `coerceValue` + (`src/lib/filters.ts`) and `ValueInput` (`FilterBuilder.tsx`). + +### Adding a URL parameter + +Read it with `params.get()` in `TableBrowser` and write it through `update(patch, replace)`. +Leave the default value out of the URL, reset `page` when the change alters which rows match, and +pass `replace = true` for changes that should not add a history entry, such as typing. + +## Gotchas + +- **Nothing environment-specific in the build.** Never add a backend URL to the `env` block in + `next.config.ts`; Next inlines it at build time and one artifact serves dev and prod. +- **Base path changes need Cognito first.** Apply the `cognito_aai` stack before shipping, or + sign-in fails with `redirect_mismatch` (see [Deployment](deployment.md#changing-the-base-path)). +- **Port 3000 locally.** It is the only callback registered on the Cognito localhost app client. +- **`.dev.ts` files are not deployed.** Anything the static site needs cannot live in one. +- **`useSearchParams` needs a Suspense boundary** for the static export to build. `page.tsx` + wraps `MartView`, and the layout wraps `SideNav`; keep new users of it inside one. +- **Never send an empty filter.** The API rejects `{}`. Build filters with `combineFilters`, + which drops empty parts. +- **Requests are capped at 10,000 bytes.** The rows query requests every column, which is fine + for today's tables but not for a table with hundreds of columns. +- **The schema is read once per page load.** After an API change, reload to see it. +- **The theme exists twice.** `THEME_SCRIPT` in `src/lib/theme.ts` runs before React; keep it in + step with `applyTheme` in the same file. +- **Never commit tokens.** Pass `MART_API_TOKEN` on the command line rather than editing the + Makefile. After an intentional change that trips detect-secrets, run `make baseline`. +- **Markdown-only pull requests skip CI** (`paths-ignore` in `.github/workflows/pr-tests.yml`), + so run `make check` locally before pushing docs. + +## Known limits and next steps + +- **Authentication.** Sign-in happens in the browser and the tokens live in `localStorage`, + shared with the portal on portal.umccr.org. The dev proxy forwards whatever bearer token it has + and relies on the API's authorizer to check it. +- **Authorization.** Any valid token can read every mart table today: the API runs with + `ignoreRBAC` and one shared read-only database user. Showing different tables to different + users must be enforced in the API or with Postgres roles, not in this UI. +- **Catalogue drift.** Descriptions and STABLE/DEMO remarks are a copy of the dbt seed. Reading + them live from the `catalog` mart table would remove the duplication. +- **Query limits.** The API caps request bodies at 10,000 bytes and uses offset pagination, so + very wide tables or very deep pages will be slow or rejected. +- **Search cost.** A contains search (`ILIKE '%text%'`) cannot use the tables' btree indexes, so + it reads the whole table. That is fine at today's sizes, up to about 400,000 rows in + `fastq_history`. If it gets slow, add `pg_trgm` GIN indexes on the most searched columns in the + dbt models. +- **No dev mart API.** `mart.dev.umccr.org` does not exist yet, so on the dev portal queries show + an API error while sign-in and the catalogue still work (see + [Deployment](deployment.md#no-dev-mart-api)). +- **CSV export is one page.** Export CSV downloads the rows on screen, at most 100. Exporting a + whole filtered table would need paging through the API or a server-side export. diff --git a/docs/how-it-works.md b/docs/how-it-works.md new file mode 100644 index 0000000..6518621 --- /dev/null +++ b/docs/how-it-works.md @@ -0,0 +1,285 @@ +# How it works + +How OrcaHouse UI is served, how it signs people in and reaches the mart API, and how the table +view turns URL state into GraphQL queries. To run it, see [Local development](local-development.md); +for where the code lives, see the [Development guide](development-guide.md). + +## Overview + +```text +Deployed: https://portal.umccr.org/orcahouse/ (static files in S3, behind the portal's CloudFront) + Browser ──/orcahouse/env.js────> the portal's runtime config: Cognito user pool and app client + Browser ──GraphQL + ID token───> https://mart..umccr.org/graphql + API Gateway JWT authorizer + -> Lambda (PostGraphile) + -> Aurora PostgreSQL `mart` schema + +Local development: http://localhost:3000/orcahouse/ (next dev) + Browser ──/orcahouse/api/graphql/──> Next.js dev proxy ──MART_API_TOKEN──> https://mart.prod.umccr.org/graphql +``` + +The app has no server of its own. It is a static Next.js export that runs in the browser: it +signs the user in with Cognito, then calls the mart GraphQL API with their ID token. + +## What happens on a page load + +1. **Theme.** An inline script in `` (`src/lib/theme.ts`) sets `data-theme` on `` + from the stored preference before the first paint, so the page never flashes the wrong theme. +2. **Sign-in check.** `AuthGate`, in the root layout, shows "Checking sign-in…" while it + configures Amplify (see [Sign-in](#sign-in)). If the URL carries an OAuth `code` and `state`, + Amplify completes that redirect first. +3. **Signed out.** The sign-in page renders at whatever URL was opened. Signing in stores that + path and query in `sessionStorage`, goes to the Cognito hosted UI (Google), and comes back to + `/orcahouse/`, where `AuthGate` restores the stored path. +4. **Signed in.** The header, side navigation and Apollo provider render, and `useMartSchema` + sends one introspection query. Apollo caches the result for the life of the page, so every + component reads the same schema. +5. **The view.** `MartView` reads `?table=`. Without it, the catalogue home page shows; with it, + `TableBrowser` renders that table. `TableBrowser` is keyed by the table name, so switching + tables starts with fresh column, search and filter state. + +## One static page + +- `next build` writes plain HTML, CSS and JS to `out/` (`output: 'export'`) with + `basePath: '/orcahouse'`, the counterpart of orca-ui-v2's `base: '/v2/'`. +- There is one route. The catalogue is `/orcahouse/` and a table is `/orcahouse/?table=`, + so one `index.html` serves every URL. That is what the portal's CloudFront rewrite expects, + and it means tables the build has never seen still open. +- `trailingSlash: true` makes each route export as `/index.html`, which is what the + rewrite resolves to (see [Deployment](deployment.md)). +- `next dev` keeps its server features for the local API proxy. `.dev.ts` is a page extension + in development only, so `src/app/api/graphql/route.dev.ts` never reaches the export. + +## Which API the app calls + +The portal builds this app once and promotes the same artifact from dev to prod, so no API host +is built in. `src/lib/environment.ts` derives it from the hostname the page is served from: + +| Hostname | Environment | Mart API | +| ---------------------------------------------------------------------------------------- | ----------- | ----------------------------- | +| `portal.umccr.org`, `portal.prod.umccr.org`, `orcaui.umccr.org`, `orcaui.prod.umccr.org` | prod | `https://mart.prod.umccr.org` | +| `portal.stg.umccr.org`, `orcaui.stg.umccr.org` | stg | `https://mart.stg.umccr.org` | +| Anything else | dev | `https://mart.dev.umccr.org` | + +An unrecognised hostname resolves to dev, so a mistake fails towards the non-production API. +Only the prod API exists today, so the dev portal shows an API error. That is deliberate: it is +better than the dev deployment reading production data. Under `next dev` the app calls the local +proxy instead, and `MART_API_URL` decides where the proxy forwards. + +## Sign-in + +`src/lib/auth.ts` configures Amplify for the Cognito hosted UI with Google federation, as +orca-ui-v2 does, using the authorization code flow and the `openid email profile` scopes. + +- **Settings.** Deployed, they come from the portal's runtime config. The app loads + `/orcahouse/env.js`, which the portal's config Lambda writes into this app's own path and which + sets `window.config` (`VITE_COG_USER_POOL_ID`, `VITE_COG_APP_CLIENT_ID`, `VITE_OAUTH_DOMAIN`, + `VITE_REGION`). In development they come from the `NEXT_PUBLIC_COGNITO_*` variables that + `start.sh` exports. With neither, the sign-in page reports that sign-in is not configured. +- **Shared session.** Deployed, the app uses the portal's app client, so anyone signed in to the + portal on portal.umccr.org is already signed in here. That client is also the only one the + mart API's authorizer accepts. +- **Redirects.** Cognito returns to `/orcahouse/`. The app derives this at + runtime instead of reading it from `env.js` or SSM, which both name the portal root, a + different app. That exact URL must be registered on the Cognito app client (see + [Deployment](deployment.md#sign-in-redirects)). +- **Tokens.** Amplify keeps the tokens in `localStorage` and refreshes the ID token when it has + expired. A failed refresh or a sign-out returns the app to the sign-in page. +- **User menu.** The avatar menu shows the ID token's claims (Profile), a freshly refreshed ID + token and its expiry for GraphiQL or scripts (Token) and the theme setting (Settings), and it + signs out through the Cognito logout endpoint. + +## Calling the mart API + +- **Client.** `src/lib/apollo.ts` builds the Apollo Client. Its `HttpLink` resolves the endpoint + on each request and adds `Authorization: Bearer ` from Amplify. +- **Deployed**, the browser calls `https://mart..umccr.org/graphql` directly. The API's CORS + policy allows the portal origins. +- **In development**, that CORS policy does not allow `localhost`, so requests go to + `/orcahouse/api/graphql/`, a same-origin proxy (`route.dev.ts`). It forwards the request body + to `MART_API_URL` with `MART_API_TOKEN`, or with the session's token when that is not set, which + the API rejects because localhost sign-in uses a different app client. A 401 or 403 comes back + as a GraphQL error with a readable message, so the UI shows it instead of a network failure. +- **The API** lives in the orcahouse repo under `infra/api`. It is an API Gateway JWT authorizer + in front of a Lambda that runs PostGraphile v5 over the `mart` schema as a read-only database + user. It accepts GraphQL over POST only and caps request bodies at 10,000 bytes. Filtering + comes from the connection-filter plugin, limited to an allowlist: `equalTo`, `notEqualTo`, + `lessThan`, `lessThanOrEqualTo`, `greaterThan`, `greaterThanOrEqualTo`, `isNull` and + `includesInsensitive`, combined with `and`, `or` and `not`. It rejects empty filter objects. + +## Reading the schema at runtime + +PostGraphile generates the API from the `mart` Postgres schema, so tables, columns, filter +operators and sort keys change whenever dbt publishes a model. Instead of generated types, the UI +introspects the live schema on load (`src/lib/schema.ts`) and derives: + +| Metadata | Derived from | Example for `lims` | +| ------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| Collections | Root `Query` fields whose type has a `nodes` field, with the types of their `filter` and `orderBy` arguments | `allLims`: nodes `Lim`, filter `LimFilter`, sort `LimsOrderBy` | +| Columns | Fields of the node type that take no arguments and return a scalar or enum | `libraryId: String`, `sequencingRunDate: Date` | +| Filter fields | Fields of the filter input type other than `and`, `or` and `not`, keeping the operators the UI knows (`OPERATOR_ORDER`) | `libraryId`: equals, not equal, contains, greater than, …, is null | +| Sort keys | Values of the orderBy enum | `SEQUENCING_RUN_DATE_DESC` | + +Browsing a new mart table therefore needs no code. The schema is read once per page load, so a +change to the API, such as a newly allowed operator, shows after a reload. + +## The catalogue + +`src/lib/catalog.ts` lists the dbt mart tables with their group, description, STABLE/DEMO status +and an optional default sort. `resolveCatalog` merges that list with the live collections: + +- An entry matches a collection by its `collection` value, or by its table name with the + pluralisation PostGraphile may apply (`all` + the table name, plus `s` or `es`). +- Catalogued tables the API does not expose stay listed but greyed out, and open to a + "Not exposed by the API" notice. +- Collections the catalogue does not list appear under "Other", so nothing the API exposes is + hidden. + +The home page lists every group and how many catalogued tables are live. + +## The table view + +`src/components/TableBrowser.tsx` connects the schema metadata, the URL and the rows query. + +### URL state + +Everything that decides which rows show lives in the query string, so any view can be shared or +bookmarked. Default values are left out of the URL. + +| Parameter | Meaning | Default | +| --------- | ---------------------------------------------------------- | ------------------------------------------ | +| `table` | dbt table name | None: the catalogue home page | +| `page` | Page number, from 1 | `1` | +| `size` | Rows per page: 10, 25, 50 or 100 | `25` | +| `sort` | One orderBy enum value, such as `SEQUENCING_RUN_DATE_DESC` | Set on the first visit (see below) | +| `filter` | The advanced filter, as GraphQL filter JSON | Set on the first visit (see below) | +| `q` | Row search text | None | +| `qcol` | Row search column | `libraryId`, or else the first text column | + +For example, +`/orcahouse/?table=lims&sort=SEQUENCING_RUN_DATE_DESC&filter={"and":[{"libraryId":{"equalTo":"L2400001"}}]}` +or `/orcahouse/?table=workflow&q=umccrise&qcol=workflowName`. + +Changing the page, page size, sort or filter adds a browser history entry. Search changes +replace the current entry, so typing does not leave one entry per pause. + +### First visit + +When the URL has neither `sort` nor `filter`, the view sorts newest first and hides rows whose +sort column is null, as the legacy portal did. The sort is the catalogue's `defaultSort` when the +API offers it, or else the first `Date` or `Datetime` column, descending. The filter is +`{"and":[{"":{"isNull":false}}]}`. Both are written to the URL, replacing the current +entry, so they show in the advanced filter and can be removed there. + +### The rows query + +`src/lib/query-builder.ts` builds one query per table. For `lims` it looks like this: + +```graphql +query allLimsRows($first: Int!, $offset: Int!, $orderBy: [LimsOrderBy!], $filter: LimFilter) { + rows: allLims(first: $first, offset: $offset, orderBy: $orderBy, filter: $filter) { + totalCount + nodes { + sequencingRunId + sequencingRunDate + libraryId + # …every scalar column + } + } +} +``` + +- The connection is aliased to `rows`, so every table shares one result shape. +- Every scalar column is requested whichever ones are shown, so hiding or showing a column never + refetches. A few dozen column names stay well under the API's 10,000-byte request limit. +- Paging is offset-based: `first` is the page size and `offset` is `(page - 1) × size`. + `totalCount` gives the number of pages. +- While a new page loads, the previous rows stay on screen, dimmed. + +### Row search + +The search box above the table finds rows whose value in one text column contains the typed +text, ignoring case. The logic is in `src/lib/row-search.ts` and the box is +`src/components/RowSearch.tsx`. + +- **Operator.** The search sends the connection-filter operator `includesInsensitive`, which + PostGraphile runs as `"column" ILIKE '%text%'`. Any `%` or `_` in the text is escaped, so the + text always matches literally. +- **Columns.** Only text columns support that operator, so only text columns are offered. The + search starts on `libraryId` when the table has one, since most mart tables are keyed by + library, and otherwise on the first text column. +- **When it runs.** 400 ms after typing pauses, or straight away on Enter. Escape or the clear + button removes the search. Changing the text, or the column while there is text, goes back to + page 1. +- **URL.** The text is stored as `q` and the column as `qcol`. The box keeps what the user is + typing while its own URL updates arrive, and it takes the URL's text when the URL changes for + any other reason, such as the back button. +- **Availability.** If the API does not allow `includesInsensitive`, no column offers it and the + box is hidden. The advanced filter button still shows. + +### Advanced filter + +The **Advanced filter** button after the search box opens the filter builder +(`src/components/FilterBuilder.tsx`), for conditions the search cannot express. + +- **Closed by default.** The button shows how many conditions are applied, including the + first-visit filter, so rows hidden by a filter are never a surprise. Closing the panel keeps + edits that have not been applied yet. +- **Conditions.** Each condition is `column operator value`, and all of them combine with AND or + all with OR. +- **Operators.** Every filterable column offers equals, not equal, greater than, greater or equal, + less than, less or equal, and is null / is not null. Text columns also offer contains + (`includesInsensitive`). +- **Values.** The input follows the column type: a date picker for `Date` and `Datetime` + (compared in UTC), a number input for numbers, true or false for booleans, and text otherwise. + Values are converted to the type the API expects before they are sent. +- **Apply and Reset.** Nothing is sent until Apply. Reset removes every condition. +- **URL.** The applied filter is stored in `filter` as the ready-to-send GraphQL filter JSON, the + same convention the legacy portal used. + +### How search and filter combine + +The search is kept apart from `filter` and ANDed onto it when the query is sent +(`combineFilters` in `src/lib/filters.ts`). With the first-visit filter and a search for `l24` on +`lims`, the query's `filter` variable is: + +```json +{ + "and": [ + { "and": [{ "sequencingRunDate": { "isNull": false } }] }, + { "libraryId": { "includesInsensitive": "l24" } } + ] +} +``` + +Missing and empty parts are left out, because the API rejects an empty filter object. + +### Sorting, columns, export and cells + +- **Sorting.** Column headers with an orderBy enum value are clickable. The first click sorts + descending and the next ascending. One column sorts at a time, and a new sort goes back to + page 1. +- **Columns.** The Columns menu hides columns in the browser only. The choice is not in the URL + and resets when you switch tables. +- **Export CSV.** Downloads the rows on the current page, visible columns only, as RFC 4180 CSV + with a byte order mark so that Excel reads it as UTF-8. The file is named + `-page-.csv`. +- **Cells.** Datetimes show without the `T` and fractional seconds, integers use thousands + separators, JSON values show as text, and nulls show as a faint italic `null`. Headers are + humanised from the field name, keeping acronyms: `sequencingRunId` becomes "Sequencing Run ID". + +## Side navigation + +The side navigation lists the catalogue by group. It is hidden on narrow screens. + +- **Table search.** Filters the tables as you type (`src/lib/table-search.ts`). Every word must + match, either fuzzily against the table name (`fqh` finds `fastq_history`, with the matched + letters highlighted) or, from three characters, within the group name or description. Name + matches rank first. +- **Folds.** Groups fold. The folds are remembered in `localStorage` and follow other tabs. + +## Theme + +Light, dark or system, stored in `localStorage`. The head script applies it before the first +paint, and `ThemeSync` keeps it in step with system changes and other tabs. Dark mode is the +`data-theme` attribute on ``, which Tailwind's `dark:` variant follows. diff --git a/docs/local-development.md b/docs/local-development.md new file mode 100644 index 0000000..956abb0 --- /dev/null +++ b/docs/local-development.md @@ -0,0 +1,120 @@ +# Local development + +How to run OrcaHouse UI on your machine against the mart API. For how the pieces fit together, +see [How it works](how-it-works.md). + +## Prerequisites + +| Tool | Version | Used for | +| -------------- | ----------------------------------------------------------- | --------------------------------------------- | +| Node.js | 24 (see `.nvmrc`) | Next.js | +| pnpm | 10; `corepack enable` picks the version from `package.json` | Dependencies and scripts | +| AWS CLI | v2, with SSO access to the dev account | Reading the Cognito sign-in settings from SSM | +| pre-commit | Any recent version (`brew install pre-commit`) | `make install` and `make check` | +| detect-secrets | Optional (`brew install detect-secrets`) | `make baseline` only | + +You also need a UMCCR Google account to sign in, and access to to +copy a token for the mart API. + +## Start the dev server + +```sh +make install # pre-commit hooks and pinned dependencies +aws sso login --profile dev && export AWS_PROFILE=dev +make start MART_API_TOKEN= +``` + +Then open (the root `/` redirects there) and sign in with your +UMCCR Google account. + +`make start` sources `start.sh`, the same wrapper orca-ui-v2 uses. It reads the Cognito sign-in +settings for the localhost app client from SSM Parameter Store in the dev account, exports them +as `NEXT_PUBLIC_*` variables and starts `next dev` on port 3000. + +## The mart API token + +The mart API is only deployed to prod, so the Makefile points `MART_API_URL` at +. Its authorizer only accepts tokens from the portal's app client, +not the localhost app client you sign in with here. In development the dev proxy therefore +sends your own token instead: + +1. Sign in to . +2. Open the profile menu, choose **Token**, and copy the ID token. +3. Pass it as `MART_API_TOKEN`, either on the command line as above or by exporting it once per + shell: `export MART_API_TOKEN=eyJ...` and then `make start`. + +Portal ID tokens last up to a day. When API calls fail with "The mart API rejected +MART_API_TOKEN", restart with a fresh one. + +Do not paste the token into the Makefile's `MART_API_TOKEN ?=` line: the next `git add` would +commit it. The detect-secrets hook is there to catch that, but the command line or a shell +export keeps it out of the repository entirely. + +The static build has no proxy, so it never uses `MART_API_TOKEN`. + +## Port and sign-in + +The dev server must stay on port 3000, the only callback URL registered on the Cognito localhost +app client. `PORT` still overrides it (`PORT=3001 make start`), but sign-in then fails. + +`make dev` starts the server without the Cognito settings, so the sign-in page only reports that +sign-in is not configured. It is useful for work on the sign-in page itself. + +The OAuth redirect URLs are derived rather than read from SSM, because the SSM parameters name +the portal root, a different app. `src/lib/auth.ts` uses the current origin plus the base path, +so local sign-in returns to . That URL is registered on the +localhost app client by the `cognito_aai` Terraform stack. + +## Environment variables + +| Variable | Set by | Purpose | +| ------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------- | +| `NEXT_PUBLIC_COGNITO_USER_POOL_ID` | `start.sh`, from `/data_portal/client/cog_user_pool_id` | Cognito user pool | +| `NEXT_PUBLIC_COGNITO_OAUTH_DOMAIN` | `start.sh`, from `/data_portal/client/oauth_domain` | Hosted UI domain prefix (a full host name also works) | +| `NEXT_PUBLIC_COGNITO_APP_CLIENT_ID` | `start.sh`, from `/data_portal/client/cog_app_client_id_local` | Localhost app client | +| `NEXT_PUBLIC_OAUTH_REDIRECT_SIGN_IN` | `start.sh`, from `/data_portal/client/oauth_redirect_in_local` | Unused: the app derives its own redirect | +| `NEXT_PUBLIC_OAUTH_REDIRECT_SIGN_OUT` | `start.sh`, from `/data_portal/client/oauth_redirect_out_local` | Unused: the app derives its own redirect | +| `NEXT_PUBLIC_COGNITO_REGION` | `start.sh` (`ap-southeast-2`) | Region of the user pool | +| `MART_API_URL` | Makefile, default `https://mart.prod.umccr.org` | Local development only: upstream for the dev proxy | +| `MART_API_TOKEN` | You, for example `make start MART_API_TOKEN=...` | Local development only: bearer token for mart API calls | + +`source start.sh unset` clears the `NEXT_PUBLIC_*` variables from your shell. + +The deployed app uses none of these. It reads the Cognito settings from the portal's `env.js` +and derives the mart API host from its own hostname, so nothing environment-specific is baked +into the build. + +## Using another API + +`MART_API_URL` is only read by the dev proxy, so the UI can point at any PostGraphile server with +the same schema, for example to try an API change before it is deployed: + +```sh +make start MART_API_URL=http://localhost:5055 +``` + +The proxy still sends `MART_API_TOKEN`; a local server without an authorizer ignores it. The API +server in the orcahouse repo (`infra/api/lambda-server`) runs locally with `pnpm start`, reading +`DATABASE_URL` and `SCHEMA_NAME=mart`. It listens on port 5000, which macOS often reserves for +AirPlay Receiver. + +## Before you push + +```sh +make check # every pre-commit hook: ESLint, Prettier, secret and file checks +pnpm build # the static export, as the pipeline builds it +``` + +Pull requests run the same hooks plus a type check and a production build. + +## Troubleshooting + +| Symptom | Cause | Fix | +| ---------------------------------------------------------- | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------- | +| `make start` stops with "No valid AWS login session found" | No AWS session for the dev account | `aws sso login --profile dev && export AWS_PROFILE=dev` | +| The sign-in page says sign-in is not configured | Started with `make dev`, or `start.sh` could not read SSM | Use `make start` and check its output for SSM errors | +| The hosted UI shows `redirect_mismatch` | The dev server is not on port 3000 | Stop whatever holds port 3000 and restart on it | +| Tables show "The mart API rejected MART_API_TOKEN (401)" | The token has expired or was copied incompletely | Copy a fresh ID token from portal.umccr.org and restart | +| Tables show "The mart API rejected the sign-in token" | `MART_API_TOKEN` is not set | Pass `MART_API_TOKEN` to `make start` | +| No search box above a table | The API does not allow `includesInsensitive`, or the table has no text columns | Check the API's allowed operators (see [Deployment](deployment.md#the-mart-api)), then reload | +| A table opens to "Not exposed by the API" | The catalogue lists a table the live schema does not have | Nothing to fix in the UI: the table is not deployed in the `mart` schema yet | diff --git a/src/components/RowSearch.tsx b/src/components/RowSearch.tsx new file mode 100644 index 0000000..c61012a --- /dev/null +++ b/src/components/RowSearch.tsx @@ -0,0 +1,127 @@ +'use client'; + +import { useEffect, useEffectEvent, useRef, useState } from 'react'; +import { Search, X } from 'lucide-react'; +import { humanize, type FilterFieldMeta } from '@/lib/schema'; +import { INPUT } from './ui'; + +/** How long typing must pause before the search runs, so not every keystroke is a query. */ +const DEBOUNCE_MS = 400; + +interface Props { + /** Columns the API can search. */ + fields: FilterFieldMeta[]; + column: string; + /** The search text in the URL. */ + text: string; + onColumnChange: (column: string) => void; + onSearch: (text: string) => void; +} + +/** + * Search box for rows whose value in the chosen column contains the text, ignoring case. + * Typing searches once it pauses and Enter searches at once. The search lives in the URL, + * so it can be shared and follows back and forward. + */ +export function RowSearch({ fields, column, text, onColumnChange, onSearch }: Props) { + const inputRef = useRef(null); + const [draft, setDraft] = useState(text); + // Searches sent to the URL that have not shown up in it yet, oldest first. + const [pending, setPending] = useState([]); + const [seenText, setSeenText] = useState(text); + + // The URL text changed. One of our own searches arriving leaves the draft alone, as the + // user may have typed on since; any other change, such as back or forward, replaces it. + if (text !== seenText) { + setSeenText(text); + const own = pending.indexOf(text); + if (own >= 0) { + setPending(pending.slice(own + 1)); + } else { + setPending([]); + setDraft(text); + } + } + + // The text the URL will have once the pending searches arrive. + const target = pending.at(-1) ?? text; + + const submit = (value: string) => { + const next = value.trim(); + if (next === target) return; + setPending([...pending, next]); + onSearch(next); + }; + + const submitDraft = useEffectEvent(() => submit(draft)); + useEffect(() => { + if (draft.trim() === target) return; + const timer = setTimeout(() => submitDraft(), DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [draft, target]); + + const clear = () => { + setDraft(''); + submit(''); + }; + + return ( +
{ + event.preventDefault(); + submit(draft); + }} + className='flex w-full flex-wrap items-center gap-2 sm:w-auto' + > + +
+
+ + ); +} diff --git a/src/components/TableBrowser.tsx b/src/components/TableBrowser.tsx index 1056bc3..ca0ad14 100644 --- a/src/components/TableBrowser.tsx +++ b/src/components/TableBrowser.tsx @@ -1,19 +1,21 @@ 'use client'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useId, useMemo, useState } from 'react'; import Link from 'next/link'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { useQuery } from '@apollo/client/react'; +import { ChevronDown } from 'lucide-react'; import { useMartSchema } from '@/hooks/useMartSchema'; import { groupById, resolveCatalog } from '@/lib/catalog'; import { downloadCsv } from '@/lib/csv'; -import { parseFilterJson } from '@/lib/filters'; +import { combineFilters, parseFilterJson } from '@/lib/filters'; import { buildRowsQuery, EMPTY_QUERY, type RowsResult, type RowsVariables, } from '@/lib/query-builder'; +import { pickSearchColumn, searchableFields, searchClause } from '@/lib/row-search'; import { filterFields, humanize, @@ -28,6 +30,7 @@ import { DataTable } from './DataTable'; import { ErrorNotice } from './ErrorNotice'; import { FilterBuilder } from './FilterBuilder'; import { DEFAULT_PAGE_SIZE, PAGE_SIZES, Pagination } from './Pagination'; +import { RowSearch } from './RowSearch'; import { StatusBadge } from './StatusBadge'; import { BUTTON } from './ui'; @@ -82,6 +85,7 @@ export function TableBrowser({ table }: Props) { () => (schema && collection ? orderByValues(schema, collection.orderByType) : []), [schema, collection] ); + const searchFields = useMemo(() => searchableFields(filterMeta), [filterMeta]); const page = Math.max(1, Number(params.get('page')) || 1); const sizeParam = Number(params.get('size')); @@ -89,6 +93,10 @@ export function TableBrowser({ table }: Props) { const sort = params.get('sort'); const filterRaw = params.get('filter'); const filter = useMemo(() => parseFilterJson(filterRaw), [filterRaw]); + // The row search is kept apart from `filter`, which the filter builder owns, and ANDed on. + const searchText = params.get('q') ?? ''; + const searchColumn = pickSearchColumn(searchFields, params.get('qcol')); + const defaultSearchColumn = pickSearchColumn(searchFields); const update = useCallback( (patch: Patch, replace = false) => { @@ -116,7 +124,9 @@ export function TableBrowser({ table }: Props) { }, [collection, columns, defaultSort, filterRaw, sort, update]); const [hidden, setHidden] = useState>(() => new Set()); - const [filtersOpen, setFiltersOpen] = useState(true); + // The advanced filter starts closed: the row search covers most lookups. + const [filtersOpen, setFiltersOpen] = useState(false); + const filterPanelId = useId(); const visible = useMemo(() => columns.filter((c) => !hidden.has(c.name)), [columns, hidden]); const document = useMemo( @@ -133,7 +143,7 @@ export function TableBrowser({ table }: Props) { first: size, offset: (page - 1) * size, orderBy: sort ? [sort] : undefined, - filter: filter ?? undefined, + filter: combineFilters(filter, searchClause(searchColumn, searchText)) ?? undefined, }; const { data, previousData, loading, error, refetch } = useQuery( document ?? EMPTY_QUERY, @@ -151,6 +161,14 @@ export function TableBrowser({ table }: Props) { const prefix = sortPrefix(column); update({ sort: sort === `${prefix}_DESC` ? `${prefix}_ASC` : `${prefix}_DESC`, page: null }); }; + // Search updates replace the history entry, so typing does not leave one per pause. + const onSearch = (text: string) => update({ q: text || null, page: null }, true); + const onSearchColumn = (column: string) => { + const patch: Patch = { qcol: column === defaultSearchColumn ? null : column }; + // Another column only changes the rows, and so the page count, when there is text. + if (searchText.trim()) patch.page = null; + update(patch, true); + }; const exportCsv = () => downloadCsv( `${table}-page${page}-${new Date().toISOString().slice(0, 10)}.csv`, @@ -202,9 +220,6 @@ export function TableBrowser({ table }: Props) {

-
+ )} + + {/* Hidden rather than unmounted, so closing the panel keeps edits not yet applied. */} + {collection && ( + )} {error && } diff --git a/src/lib/filters.ts b/src/lib/filters.ts index 9f3e57c..7dcf927 100644 --- a/src/lib/filters.ts +++ b/src/lib/filters.ts @@ -81,3 +81,13 @@ export function serializeFilter( }); return clauses.length ? { [state.op]: clauses } : null; } + +type FilterInput = Record; + +/** ANDs filter inputs together, skipping missing and empty ones, which the API rejects. */ +export function combineFilters(...filters: (FilterInput | null)[]): FilterInput | null { + const present = filters.filter( + (filter): filter is FilterInput => filter !== null && Object.keys(filter).length > 0 + ); + return present.length > 1 ? { and: present } : (present[0] ?? null); +} diff --git a/src/lib/row-search.ts b/src/lib/row-search.ts new file mode 100644 index 0000000..2d60208 --- /dev/null +++ b/src/lib/row-search.ts @@ -0,0 +1,38 @@ +import type { FilterFieldMeta } from './schema'; + +/** + * Row search: rows whose value in one text column contains the search text, ignoring case. + * + * It is a connection-filter operator, which PostGraphile runs as `ILIKE '%text%'` with any + * `%` and `_` in the text escaped, so the text always matches literally. The API has to + * allow the operator (connectionFilterAllowedOperators in the orcahouse API server); where + * it does not, no column offers it and the UI hides the search box. + */ +export const SEARCH_OPERATOR = 'includesInsensitive'; + +/** Most mart tables are keyed by library, so the search starts on this column when present. */ +const PREFERRED_COLUMN = 'libraryId'; + +/** The filter fields the API can search, which are the text columns. */ +export function searchableFields(fields: FilterFieldMeta[]): FilterFieldMeta[] { + return fields.filter((field) => field.operators.includes(SEARCH_OPERATOR)); +} + +/** + * The requested column if it is searchable, else libraryId, else the first searchable + * column. Null when the table has none. + */ +export function pickSearchColumn( + fields: FilterFieldMeta[], + requested?: string | null +): string | null { + const names = fields.map((field) => field.name); + if (requested && names.includes(requested)) return requested; + return names.includes(PREFERRED_COLUMN) ? PREFERRED_COLUMN : (names[0] ?? null); +} + +/** The filter clause for a search, or null when there is no column or no text. */ +export function searchClause(column: string | null, text: string): Record | null { + const value = text.trim(); + return column && value ? { [column]: { [SEARCH_OPERATOR]: value } } : null; +} diff --git a/src/lib/schema.ts b/src/lib/schema.ts index fb60be1..f285865 100644 --- a/src/lib/schema.ts +++ b/src/lib/schema.ts @@ -114,6 +114,8 @@ export interface FilterFieldMeta { export const OPERATOR_ORDER = [ 'equalTo', 'notEqualTo', + // Text columns only: a case-insensitive substring match. + 'includesInsensitive', 'greaterThan', 'greaterThanOrEqualTo', 'lessThan', @@ -124,6 +126,7 @@ export const OPERATOR_ORDER = [ export const OPERATOR_LABELS: Record = { equalTo: 'equals', notEqualTo: 'not equal', + includesInsensitive: 'contains', greaterThan: 'greater than', greaterThanOrEqualTo: 'greater or equal', lessThan: 'less than',