From 48b27440b065f4516b3f3172d10b4181bfc46de8 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Tue, 2 Jun 2026 10:04:57 +0200 Subject: [PATCH 001/205] chore: add Claude Code project config with hooks and MCP servers --- .claude/hooks/post-edit.sh | 29 +++++++++++++++++++++++++++++ .claude/settings.json | 33 +++++++++++++++++++++++++++++++++ .mcp.json | 13 +++++++++++++ 3 files changed, 75 insertions(+) create mode 100755 .claude/hooks/post-edit.sh create mode 100644 .claude/settings.json create mode 100644 .mcp.json diff --git a/.claude/hooks/post-edit.sh b/.claude/hooks/post-edit.sh new file mode 100755 index 0000000000..d9bb917f18 --- /dev/null +++ b/.claude/hooks/post-edit.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +FILE=$(node -e " + try { + const d = JSON.parse(process.env.TOOL_INPUT || '{}') + console.log(d.file_path || '') + } catch(e) {} +" 2>/dev/null) + +if [ -z "$FILE" ] || [ ! -f "$FILE" ]; then + exit 0 +fi + +case "$FILE" in + *.css) + npx prettier --write "$FILE" && echo "prettier: formatted $FILE" || echo "prettier: FAILED on $FILE" + if ls .stylelintrc* stylelint.config.* 2>/dev/null | grep -q .; then + npx stylelint "$FILE" --max-warnings=0 2>&1 | tail -5 + fi + ;; + *.ts|*.tsx|*.js|*.jsx) + npx prettier --write "$FILE" && echo "prettier: formatted $FILE" || echo "prettier: FAILED on $FILE" + npx eslint "$FILE" 2>&1 | tail -10 + ;; + *.json|*.md|*.yml|*.yaml) + npx prettier --write "$FILE" && echo "prettier: formatted $FILE" || echo "prettier: FAILED on $FILE" + ;; +esac + +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000000..52be2fb53d --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,33 @@ +{ + "permissions": { + "allow": [ + "mcp__grep__*", + "mcp__plugin_context7_*", + "mcp__plugin_chrome-devtools-mcp_*" + ] + }, + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "bash .claude/hooks/post-edit.sh" + } + ] + } + ] + }, + "enabledPlugins": { + "typescript-lsp@claude-plugins-official": true, + "chrome-devtools-mcp@claude-plugins-official": true, + "context7@claude-plugins-official": true + }, + "mcpServers": { + "grep": { + "type": "sse", + "url": "https://mcp.grep.app" + } + } +} diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000000..2c522a76aa --- /dev/null +++ b/.mcp.json @@ -0,0 +1,13 @@ +{ + "mcpServers": { + "chrome-devtools": { + "command": "npx", + "args": [ + "chrome-devtools-mcp@latest", + "--chromeArg=--use-angle=swiftshader-webgl", + "--chromeArg=--enable-unsafe-swiftshader", + "--chromeArg=--disable-dev-shm-usage" + ] + } + } +} From e1f47343527795a3154c25f6d158395eb3e3df42 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Fri, 17 Jul 2026 20:11:14 +0200 Subject: [PATCH 002/205] chore: add Claude Code tooling for maps-app Establishes CLAUDE.md, project conventions, and a set of skills/subagents covering both day-to-day dev workflow (specs, mockups, PR lifecycle, branch chains) and comms (manual test scenarios, docs, community posts). --- .claude/agents/community-post-writer.md | 21 ++ .claude/agents/docs-writer.md | 22 ++ .claude/agents/spec-writer.md | 51 ++++ .claude/agents/test-scenario-writer.md | 19 ++ .claude/commands/sonarqube-fix.md | 62 +++++ .claude/hooks/post-edit.sh | 40 ++-- .claude/settings.json | 8 +- .claude/skills/branch-update/SKILL.md | 59 +++++ .claude/skills/claude-stack-retro/SKILL.md | 60 +++++ .../references/analysis-checklist.md | 14 ++ .../references/evidence-gathering.md | 46 ++++ .../references/output-and-state.md | 64 ++++++ .../skills/commit-and-pr-messages/SKILL.md | 111 +++++++++ .claude/skills/community-post/SKILL.md | 28 +++ .../skills/dhis2-web-api-research/SKILL.md | 38 +++ .claude/skills/docs-update/SKILL.md | 43 ++++ .claude/skills/implement-plan/SKILL.md | 62 +++++ .../implement-plan/references/commit-cycle.md | 18 ++ .../references/permission-negotiation.md | 28 +++ .claude/skills/manual-test-scenarios/SKILL.md | 35 +++ .../skills/map-layer-architecture/SKILL.md | 23 ++ .../references/classification.md | 7 + .../references/earth-engine.md | 9 + .../references/layer-base-class.md | 16 ++ .claude/skills/mockup-pr/SKILL.md | 122 ++++++++++ .../references/algorithmic-fidelity.md | 23 ++ .../references/architecture-fidelity.md | 33 +++ .../mockup-pr/references/ui-ux-fidelity.md | 30 +++ .claude/skills/pr-chain/SKILL.md | 217 ++++++++++++++++++ .../pr-chain/references/squash-merge-sync.md | 86 +++++++ .claude/skills/pr-polish/SKILL.md | 66 ++++++ .claude/skills/pre-review/SKILL.md | 56 +++++ .claude/skills/spec-from-ticket/SKILL.md | 42 ++++ .../references/interview-and-scoping.md | 24 ++ .gitignore | 4 + .mcp.json | 4 + CLAUDE.md | 103 +++++++++ README.md | 13 ++ 38 files changed, 1677 insertions(+), 30 deletions(-) create mode 100644 .claude/agents/community-post-writer.md create mode 100644 .claude/agents/docs-writer.md create mode 100644 .claude/agents/spec-writer.md create mode 100644 .claude/agents/test-scenario-writer.md create mode 100644 .claude/commands/sonarqube-fix.md create mode 100644 .claude/skills/branch-update/SKILL.md create mode 100644 .claude/skills/claude-stack-retro/SKILL.md create mode 100644 .claude/skills/claude-stack-retro/references/analysis-checklist.md create mode 100644 .claude/skills/claude-stack-retro/references/evidence-gathering.md create mode 100644 .claude/skills/claude-stack-retro/references/output-and-state.md create mode 100644 .claude/skills/commit-and-pr-messages/SKILL.md create mode 100644 .claude/skills/community-post/SKILL.md create mode 100644 .claude/skills/dhis2-web-api-research/SKILL.md create mode 100644 .claude/skills/docs-update/SKILL.md create mode 100644 .claude/skills/implement-plan/SKILL.md create mode 100644 .claude/skills/implement-plan/references/commit-cycle.md create mode 100644 .claude/skills/implement-plan/references/permission-negotiation.md create mode 100644 .claude/skills/manual-test-scenarios/SKILL.md create mode 100644 .claude/skills/map-layer-architecture/SKILL.md create mode 100644 .claude/skills/map-layer-architecture/references/classification.md create mode 100644 .claude/skills/map-layer-architecture/references/earth-engine.md create mode 100644 .claude/skills/map-layer-architecture/references/layer-base-class.md create mode 100644 .claude/skills/mockup-pr/SKILL.md create mode 100644 .claude/skills/mockup-pr/references/algorithmic-fidelity.md create mode 100644 .claude/skills/mockup-pr/references/architecture-fidelity.md create mode 100644 .claude/skills/mockup-pr/references/ui-ux-fidelity.md create mode 100644 .claude/skills/pr-chain/SKILL.md create mode 100644 .claude/skills/pr-chain/references/squash-merge-sync.md create mode 100644 .claude/skills/pr-polish/SKILL.md create mode 100644 .claude/skills/pre-review/SKILL.md create mode 100644 .claude/skills/spec-from-ticket/SKILL.md create mode 100644 .claude/skills/spec-from-ticket/references/interview-and-scoping.md create mode 100644 CLAUDE.md diff --git a/.claude/agents/community-post-writer.md b/.claude/agents/community-post-writer.md new file mode 100644 index 0000000000..0bc638198f --- /dev/null +++ b/.claude/agents/community-post-writer.md @@ -0,0 +1,21 @@ +--- +name: community-post-writer +description: Drafts DHIS2 Community of Practice release-announcement posts. Invoked by the community-post skill once it has gathered version/tickets/tone/prerequisites — not a general-purpose writer, only this one format. +tools: Read, WebFetch, Write +--- + +You draft release-announcement posts for the DHIS2 Community of Practice forum (community.dhis2.org). Your audience is DHIS2 admins, health-program staff, and analysts — not developers. Never explain implementation details, code, or architecture; explain what changed and why it's useful to someone running or using a DHIS2 instance. + +Match the house style exactly, based on real published posts: + +- Opening line, verbatim pattern: "Dear DHIS2 Community, We are excited to announce the release of **[App] [version]**." (a seasonal greeting before this is fine if the user supplies one, e.g. "Happy new year!"). +- Per-app compatibility sentence, verbatim pattern: "The [App] app is on continuous release, compatible with [version] and above. You can download the new release from [DHIS2 App Hub](link) or test it out on [DHIS2 Play](link)." For maps: App Hub link `https://apps.dhis2.org/app/ad3a9d16-e56f-48a9-a9ed-b906d5646e74`, Play link `https://play.im.dhis2.org/dev/apps/maps`. +- Structure: intro (bolded version) → one heading per feature/section, each with a concrete bullet list of what changed and/or prerequisites, a screenshot placeholder, a Jira link anchor-texted literally "Jira" (never link to a GitHub PR — Jira is the house convention) → an optional data-source/attribution section if external datasets are involved → closing → signature. +- Closing: "Thank you for your continuous support!" or a feature-specific variant if one fits better. +- Signature: "Best regards, [Name], [Title]" — ask if not supplied; both a PM voice and a developer voice are real precedents. +- Title format: "[App] v[X.Y.Z] is now available - [feature summary]" or "[App] version [X.Y.Z] is now available - [feature summary]" — either is fine. Drop the version entirely only for a genuinely cross-app announcement. +- Screenshots: leave a clear placeholder marker per feature section. Never fabricate or describe a fake screenshot — the user supplies real images. +- Never invent a "leave a comment" or other call-to-action — none of the real examples use one; let engagement happen organically. +- Tone: warm but factual, accessible language, no unexplained jargon, technical enough to be precise about what a dataset/feature actually is when that matters (e.g. resolution, update frequency) but never implementation-level. + +You'll be given the gathered facts (version, features/tickets, tone lever, prerequisites, whether an urgent update note is needed, who's signing) by the skill that invoked you. If something essential is missing, say so rather than inventing it. diff --git a/.claude/agents/docs-writer.md b/.claude/agents/docs-writer.md new file mode 100644 index 0000000000..41d67a1ae2 --- /dev/null +++ b/.claude/agents/docs-writer.md @@ -0,0 +1,22 @@ +--- +name: docs-writer +description: Writes/updates maps-app's published end-user documentation (docs/src/*.md) and rebuilds docs/maps.md. Invoked by the docs-update skill once the affected doc file(s) are identified. +tools: Read, Write, Bash +--- + +You write maps-app's published end-user manual (`docs/src/*.md`, built into `docs/maps.md`). Your audience is DHIS2 admins and analysts using the Maps app — not developers reading source code. Plain instructional prose: what a feature does and how to use it, not why it was built or how it's implemented. + +Match the existing conventions exactly: + +- `> **Note**` blockquote for callouts. +- `{ #anchor_id }` attribute IDs only on new top-level (`##`) headings, for stable cross-linking — not needed on sub-bullets. +- Images referenced as `![](../resources/images/xxx.png)` (relative path from `docs/src/` — the build script rewrites this to `resources/images/xxx.png` for the generated `docs/maps.md`, don't do that rewrite yourself). Follow the existing `maps__.png` naming convention, where `` mirrors the dialog tab name (DATA/PERIOD/STYLE/FILTER/ORG_UNITS/RELATIONSHIPS) when relevant. +- No alt text on images anywhere in the existing docs — match that (empty `![]()`). + +Workflow: + +1. Edit the identified `docs/src/NN-topic.md` file(s) — never hand-edit `docs/maps.md` directly, it's fully generated and will be overwritten. +2. Run `yarn docs:build` (chains a `docs:format` prettier pass over `docs/src/*.md`, then regenerates `docs/maps.md`). +3. Report back which files changed, including the regenerated `docs/maps.md` — the skill that invoked you decides whether/when to commit. + +If the change affects something shown in an existing screenshot, say so explicitly (e.g. "the STYLE tab screenshot at `maps_thematic_layer_dialog_STYLE.png` may need recapturing") rather than fabricating a new image — real screenshots are actual app captures, never synthesize one. diff --git a/.claude/agents/spec-writer.md b/.claude/agents/spec-writer.md new file mode 100644 index 0000000000..a88fdb44d7 --- /dev/null +++ b/.claude/agents/spec-writer.md @@ -0,0 +1,51 @@ +--- +name: spec-writer +description: Turns a ticket, interview answers, and codebase-exploration findings into a polished, self-contained spec document. Invoked by the spec-from-ticket skill after the interactive interview is already done in the main session — does not conduct the interview itself. +tools: Read, Write, Grep, Glob +--- + +You write engineering specs meant to be read cold, later, by another developer, an architect, or a different AI-agent session — not by the person who just finished interviewing the user. Precise and self-contained: name concrete files/interfaces, state scope boundaries explicitly, don't leave anything implicit that the reader would otherwise have to reconstruct from a conversation they weren't part of. + +You will be given: the ticket content, the answers from an interview already conducted with the user, and findings from an Explore subagent that already scanned the codebase. Do not re-interview or re-explore — author from what you're given, using `Read`/`Grep`/`Glob` only to double-check specific file paths or confirm a detail before committing it to the spec, not to redo the exploration. + +Write to `.claude/specs/-.md` (or `.claude/specs/.md` if there's no ticket), following this structure: + +```markdown +--- +ticket: DHIS2-XXXXX # or "none" +status: draft +created: YYYY-MM-DD +--- + +# + +Implements [DHIS2-XXXXX](https://dhis2.atlassian.net/browse/DHIS2-XXXXX) + + + +## Problem statement + +## Acceptance criteria + +## Scope + +### In scope + +### Out of scope + +## Assumptions + +## Affected files / interfaces + +## Implementation plan + +Numbered, each step small and file-scoped enough to plausibly be one commit. + +## Verification + +Concrete and runnable — exact test commands, exact manual-check steps. + +## Open questions +``` + +If re-writing an existing spec for the same ticket, update it in place — never create a `-2` file. If the interview/exploration left a genuine unresolved question, put it under "Open questions" rather than guessing — don't silently resolve something you weren't actually told. diff --git a/.claude/agents/test-scenario-writer.md b/.claude/agents/test-scenario-writer.md new file mode 100644 index 0000000000..36b11c1c47 --- /dev/null +++ b/.claude/agents/test-scenario-writer.md @@ -0,0 +1,19 @@ +--- +name: test-scenario-writer +description: Drafts the "Manual testing" section of a maps-app PR body for internal QA/testers. Invoked by the manual-test-scenarios skill once ticket IDs and Netlify preview links are known. +tools: Read, Write +--- + +You write the "Manual testing" section of a maps-app PR description. Your audience is an internal, technically-comfortable tester who knows the app well but wasn't part of implementing this specific change — not a fully non-technical end user, and not a fellow developer reading code. Trust them with a working link and a screenshot; don't write click-by-click prose instructions. + +Match this real, established convention exactly: + +- Header: `### Manual testing`. +- Opening line, once, verbatim pattern: `Netlify: https://pr-.maps.netlify.dhis2.org/ + Instance: https://dev.im.dhis2.org/maps-app-42-3`. +- One bullet per Jira ticket, reusing the exact `[TICKET-ID](https://dhis2.atlassian.net/browse/TICKET-ID): ` link already used elsewhere in the PR body (under "Implements") — the ticket grouping _is_ the scenario grouping, not a separate numbered list. +- Under each ticket bullet, a nested "Test map(s)" list: `[ - main](#/)`, keyed by a real DHIS2 map/dashboard-item UID on the pinned Netlify preview. When the "Dashboard tested" checklist item applies, add a paired link with `?interpretationId=` labeled `- plugin` right next to the `- main` one. +- A screenshot per scenario block (`image`) as the visual "expected result" — never write textual "expected result:" prose instead. +- Use ` ` as a spacer line between ticket blocks (cosmetic, matches the real convention). +- No "Scenario 1 / Scenario 2" labeling, no numbered click-path steps. + +You'll be given the ticket list, the Netlify PR number(s)/instance URL, and real map/dashboard UIDs by the skill that invoked you — never invent a UID or fabricate a screenshot. If a UID or screenshot wasn't supplied for a ticket, leave that part as an explicit placeholder and say so, don't skip the bullet silently. diff --git a/.claude/commands/sonarqube-fix.md b/.claude/commands/sonarqube-fix.md new file mode 100644 index 0000000000..5b654b1e5d --- /dev/null +++ b/.claude/commands/sonarqube-fix.md @@ -0,0 +1,62 @@ +# SonarQube Issue Resolution Workflow + +Fix SonarQube quality gate issues for the current branch's PR, fetching issues directly from the SonarCloud API and addressing them in priority order. + +**Note**: this project (`dhis2_maps-app` on SonarCloud) is public, so every call below works anonymously — no `SONAR_TOKEN` needed. There is no local scanner/CI step to run — analysis happens automatically server-side on every push (SonarCloud "Automatic Analysis"), so there's no local "publish" step, just push and wait. + +## Instructions + +### 1. Identify the PR + +```bash +gh pr view --json number,title -q '"#\(.number) \(.title)"' +``` + +No `gh`/`GH_TOKEN` set up yet? Use the public GitHub REST API instead: + +```bash +git remote get-url origin # → parse org/repo +curl -s "https://api.github.com/repos///pulls?state=open&head=:$(git rev-parse --abbrev-ref HEAD)" \ + | jq -r '.[0].number' +``` + +### 2. Fetch and prioritize issues + +```bash +curl -s "https://sonarcloud.io/api/issues/search?componentKeys=dhis2_maps-app&pullRequest=&resolved=false&ps=100" \ + | jq -r '.issues[] | "\(.severity) - \(.type) - \(.message) - \(.component):\(.line)"' | sort +``` + +Fix order: **BLOCKER → CRITICAL → MAJOR → MINOR → INFO**, and within a severity, BUG before CODE_SMELL. Group by rule — fix every instance of the same rule together. + +Create a todo list (TodoWrite) with every issue before starting fixes, so progress stays visible. + +### 3. Fix in priority order + +For each issue: read the file for context, understand what's being flagged and why, apply the minimal fix that addresses it — don't refactor beyond what's reported. + +### 4. Test after each batch + +After every 3-5 related fixes: + +```bash +yarn lint && yarn test +``` + +Fix regressions immediately rather than accumulating unverified changes. + +### 5. Push and let Automatic Analysis catch up + +There's no local scanner to run. Push the commit, then re-poll the PR-scoped issues endpoint from step 2 — SonarCloud's GitHub App re-analyzes automatically on push, usually within a couple of minutes. Poll every 15-20s, capped at ~5 minutes so a stuck webhook doesn't hang the workflow. + +### 6. Done when + +- All todos completed +- `yarn lint && yarn test` pass +- The PR-scoped issues query returns none of the issues you fixed + +## Troubleshooting + +- **No matching PR** — confirm the branch has an open PR (`gh pr list` or the GitHub UI). +- **Issues still show after pushing** — re-analysis can lag a few minutes; re-poll rather than assuming the fix didn't take. If issues persist past ~5 minutes, check the PR's checks tab for a failed analysis run. +- **`gh` not authenticated** — not required; all the calls above work anonymously against this public repo. diff --git a/.claude/hooks/post-edit.sh b/.claude/hooks/post-edit.sh index d9bb917f18..554ba16d0d 100755 --- a/.claude/hooks/post-edit.sh +++ b/.claude/hooks/post-edit.sh @@ -1,29 +1,23 @@ #!/usr/bin/env bash -FILE=$(node -e " - try { - const d = JSON.parse(process.env.TOOL_INPUT || '{}') - console.log(d.file_path || '') - } catch(e) {} -" 2>/dev/null) +set -uo pipefail -if [ -z "$FILE" ] || [ ! -f "$FILE" ]; then - exit 0 -fi +input=$(cat) +file=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty') +[ -n "$file" ] && [ -f "$file" ] || exit 0 + +cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0 -case "$FILE" in - *.css) - npx prettier --write "$FILE" && echo "prettier: formatted $FILE" || echo "prettier: FAILED on $FILE" - if ls .stylelintrc* stylelint.config.* 2>/dev/null | grep -q .; then - npx stylelint "$FILE" --max-warnings=0 2>&1 | tail -5 - fi - ;; - *.ts|*.tsx|*.js|*.jsx) - npx prettier --write "$FILE" && echo "prettier: formatted $FILE" || echo "prettier: FAILED on $FILE" - npx eslint "$FILE" 2>&1 | tail -10 - ;; - *.json|*.md|*.yml|*.yaml) - npx prettier --write "$FILE" && echo "prettier: formatted $FILE" || echo "prettier: FAILED on $FILE" - ;; +case "$file" in + *.js | *.jsx | *.css | *.json | *.md | *.yml | *.yaml) ;; + *) exit 0 ;; esac +output=$(yarn d2-style apply "$file" 2>&1) +status=$? + +if [ "$status" -ne 0 ] || printf '%s' "$output" | grep -q '\[warn\]'; then + notes=$(printf '%s\n' "$output" | grep -vE '^\$ |^yarn run|^Done in|^info Visit|^\s*$') + jq -n --arg ctx "$notes" '{hookSpecificOutput: {hookEventName: "PostToolUse", additionalContext: $ctx}}' +fi + exit 0 diff --git a/.claude/settings.json b/.claude/settings.json index 52be2fb53d..b737843e29 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -20,14 +20,8 @@ ] }, "enabledPlugins": { - "typescript-lsp@claude-plugins-official": true, "chrome-devtools-mcp@claude-plugins-official": true, "context7@claude-plugins-official": true }, - "mcpServers": { - "grep": { - "type": "sse", - "url": "https://mcp.grep.app" - } - } + "enabledMcpjsonServers": ["grep"] } diff --git a/.claude/skills/branch-update/SKILL.md b/.claude/skills/branch-update/SKILL.md new file mode 100644 index 0000000000..dd7852ac8d --- /dev/null +++ b/.claude/skills/branch-update/SKILL.md @@ -0,0 +1,59 @@ +--- +name: branch-update +description: Bring the current feature branch up to date with master by merging (not rebasing) origin/master in — this team's real convention — then resolve any conflicts. Creates a merge commit, so only run when the user explicitly asks; notice and mention a stale branch, don't auto-run this. +disable-model-invocation: true +--- + +# Branch update + +This repo's real convention is **merge master into the feature branch**, repeatedly, during development — not rebase. The whole branch gets squash-merged into master via the PR at the end, so the intermediate merge commits never land on master's mainline; they only exist in the feature branch's own history. Don't rebase, and don't "clean up" these merge commits afterward — they're the norm, confirmed by real history (e.g. `Merge remote-tracking branch 'origin/master' into chore/setup-claude`). + +This skill creates a merge commit. `CLAUDE.md`'s "don't stage or commit unless explicitly asked" applies to merge commits too — that's why this skill requires explicit invocation. If you notice a branch is stale, say so; don't run this unasked. + +## Steps + +1. **Fetch.** + ``` + git fetch origin master + ``` +2. **Check how stale you actually are** before merging blindly: + ``` + git log --oneline HEAD..origin/master + git log --oneline origin/master..HEAD + ``` + Skip the merge entirely if there's nothing new to bring in. +3. **Merge.** + ``` + git merge origin/master + ``` +4. **No conflicts** — done. Mention the new merge commit exists; pushing it is a remote write and per this repo's universal rule needs its own explicit, in-the-moment ask, same as any other push. +5. **Conflicts** — see below. Never resolve by blindly taking "ours" or "theirs" wholesale; read both sides first. + +## Resolving conflicts + +For each conflicted file: + +1. Read the full conflict region plus enough surrounding context to understand _why_ each side changed it — not just what the diff lines say. `git log -p` on the conflicting commits from each side shows intent. +2. Prefer the minimal resolution that preserves _both_ changes' purpose — e.g. two additions to the same list/switch/reducer usually both belong, even when git can't auto-merge the surrounding lines. +3. After resolving, remove the conflict markers completely and re-read the result as if reviewing someone else's diff — a resolution that merges syntactically but silently drops one side's behavior is worse than an open conflict. +4. Run the touched files' tests (`npx jest `) plus `yarn d2-style check ` on every file you resolved, then the full `yarn lint && yarn test` before considering the merge done. + +### When to stop and ask instead of resolving + +Stop and hand back to the user — don't guess — when a conflict is a genuine business-logic collision, not line-adjacency noise: both sides changed the _behavior_ of the same function/condition in ways that don't obviously compose (one side changed a threshold, the other changed the formula it feeds into; one side removed a code path the other just extended). Signs it's this kind, not a mechanical one: + +- Resolving it requires deciding which behavior is "more correct," not just how to combine two edits. +- The two sides touch the same logic for unrelated reasons (different tickets), and combining them isn't obviously safe without domain knowledge you don't have. + +In that case, **leave the conflict markers in place** — don't `git merge --abort` unless the user asks you to. Run `git status` to show which files are still unresolved, describe what each side was trying to do, and ask the user how to reconcile them. Aborting loses the "you got this far" context for no benefit once you've already identified the ambiguity; leaving it in progress preserves both sides' intent for the user to inspect directly. + +## Done when + +- `git status` shows a clean merge (no unresolved paths). +- `yarn lint && yarn test` pass. +- The merge commit's message is left as git's default (`Merge branch 'master' into ` / `Merge remote-tracking branch 'origin/master' into `) — don't rewrite it to Conventional Commits format. These merge commits are exempt: they never land on master's mainline after the eventual squash-merge, and this team's real history confirms the default message is what's actually used. + +## Related + +- `commit-and-pr-messages` — for the _feature_ commits this branch carries, not the merge commit created here. +- `pr-chain` — has its own, more involved version of this problem once a stack member actually squash-merges (a plain merge stops working at that point; see that skill's `references/squash-merge-sync.md`). diff --git a/.claude/skills/claude-stack-retro/SKILL.md b/.claude/skills/claude-stack-retro/SKILL.md new file mode 100644 index 0000000000..efb63b19a8 --- /dev/null +++ b/.claude/skills/claude-stack-retro/SKILL.md @@ -0,0 +1,60 @@ +--- +name: claude-stack-retro +description: Use at a natural checkpoint after a chunk of work — end of a session, right after a PR merges, weekly — to look back over that stretch (which may span many separate Claude Code conversations over days or weeks) and propose concrete updates to this repo's own .claude/ tooling: new/changed skills, missing subagents, CLAUDE.md gaps. Never edits .claude/skills/*, .claude/agents/*, .claude/commands/*, or CLAUDE.md itself — proposes only, and logs the outcome so a rejected idea doesn't resurface. +disable-model-invocation: true +--- + +# Claude stack retro + +Explicit `/claude-stack-retro` invocation only. Its own side effects are light (it writes a local log + a local state file, nothing else) — it's gated on explicit invocation because _when_ to retrospect is a deliberate checkpoint the user picks, not something worth guessing at automatically. + +| Situation | Do this | +| ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| First run in this repo (`.claude/retros/.state.json` doesn't exist yet) | Bootstrap the window — see `references/evidence-gathering.md` | +| Evidence from steps 2-6 doesn't add up to anything concrete | Ask the user directly what was frustrating/manual/surprising — a "nothing" answer is a complete, valid retro | +| A candidate finding's fingerprint matches `rejectedFingerprints` in state | Skip it silently — don't re-propose (see `references/output-and-state.md`) | +| A candidate touches `.claude/skills/*`, `.claude/agents/*`, `.claude/commands/*`, `CLAUDE.md`, or `.claude/settings.json` | Never edit directly, ever — propose only | +| Checking whether a PR/merge actually happened as evidence | Read-only `gh`/`curl` only — never `gh pr merge/edit/ready`, per this repo's universal remote-write rule | + +## What this is (and isn't) + +This is about **this repo's own `.claude/` tooling** — skills, subagents, `CLAUDE.md`, hooks, commands. It is a different, narrower thing than the assistant's cross-project memory system (`~/.claude/projects/.../memory/`) — memory persists preferences/corrections _across_ repos and sessions; this skill's output is proposed edits to files committed _in this one repo_. It reads memory as one input signal (an unresolved `feedback-*` entry may point at a real gap in a skill) but never writes memory entries itself — that stays the memory system's own job. + +## 1. Determine the review window + +Read `.claude/retros/.state.json`. If present, the window is `lastReviewedSha..HEAD`. If missing, this is the first run — see "Bootstrapping the window" in `references/evidence-gathering.md` rather than guessing at a start point. + +## 2. Gather evidence + +Full detail and exact commands in `references/evidence-gathering.md`. In priority order: git log/diff since the marker (with `.claude/**` changes called out separately) → this thread's own context, free, if you're at the tail end of the session that did the work → memory index entries dated inside the window → `.claude/settings.local.json`'s allow-list as a careful signal (see the caution there — never propose committing that file itself) → other sessions' transcripts since the marker, best-effort/secondary → asking the user directly if the above is thin. + +## 3. Analyze — don't manufacture findings + +Work through `references/analysis-checklist.md`. The governing rule, adapted from Anthropic's own reviewer-gap caution ("a reviewer prompted to find gaps will usually report some, even when the work is sound"): **only surface a suggestion that would plausibly recur, or that already caused friction or a mistake this stretch** — never "this could theoretically be tidier." If nothing clears that bar, say so and stop; a short or empty retro is a correct outcome. + +## 4. Fresh-eyes pass (only if step 3 produced at least one candidate) + +Dispatch a plain, fresh-context subagent (Task tool, general-purpose — no bespoke persona file needed, same mechanism `implement-plan` uses for its own end-of-run diff review, and the same "use an Explore subagent" pattern already established in `dhis2-web-api-research`) with **only the raw evidence** you gathered in step 2 — git log/diff excerpts, transcript excerpts, memory entries — not your own draft candidate list. State explicitly in its prompt: read-only, no Edit/Write/git-write/gh-write, just analyze the evidence against the same checklist and report independently. + +Reconcile: keep anything either of you found with solid evidence behind it. Drop anything only you found that the fresh pass didn't independently surface and that isn't obviously supported by the evidence alone — that combination is a sign of rationalizing rather than observing. + +## 5. Present, never auto-apply + +For each surviving suggestion, in this shape (full template in `references/output-and-state.md`): **what** (concrete file/skill/section) / **why** / **evidence** / a **sketch** of the change (a short diff-style snippet for a small edit, or a frontmatter + section-header outline for a new skill/subagent — never a fully polished file inline) / **how to apply** (re-enter Plan Mode for anything nontrivial; direct in-the-moment approval for a one-line addition). + +Ask per suggestion, not once for the whole batch — accept / reject / defer. Nothing under `.claude/skills/*`, `.claude/agents/*`, `.claude/commands/*`, `CLAUDE.md`, or `.claude/settings.json` is touched until the user says so for that specific item. + +## 6. Log and update state + +Write `.claude/retros/YYYY-MM-DD.md` recording every suggestion and its outcome, and update `.claude/retros/.state.json` (`lastReviewedSha`/`lastReviewedDate`, plus any newly-rejected fingerprint). Exact schemas in `references/output-and-state.md`. Writing these files is a local edit like any other (no ask needed — same as `spec-from-ticket` writing its spec file) — but `git add`/`git commit` on them still needs the user's explicit ask, per this repo's existing convention. + +## Scope discipline + +- **Cap ~5-7 suggestions per run.** More than that means the window is too large — say so, and recommend running retros more often, rather than dumping a long backlog in one pass. +- **Skip one-offs.** A single typo or a one-time workaround for something already fixed doesn't earn a slot — only recurring/structural friction does. +- **Prefer the smallest fix.** A one-line addition to an existing skill's decision table or to `CLAUDE.md` beats a brand-new skill file whenever it covers the same ground — new skill/subagent files are the heaviest suggestion type, reserve them for genuinely repeated multi-step workflows (mirrors `pr-chain`'s own "when to stop and re-plan" restraint). +- **Never** edits `.claude/skills/*`, `.claude/agents/*`, `.claude/commands/*`, `CLAUDE.md`, or `.claude/settings.json` itself, and never runs `git push`/`gh pr *` — every suggestion is proposed, the user decides, every time, per this repo's universal remote-write/no-silent-tooling-edit rule. + +## Optional argument + +`/claude-stack-retro --since ` overrides the stored marker for this run's _analysis_ start point without corrupting state — `.state.json` still advances to the real `HEAD` at the end of the run, same as a normal invocation. diff --git a/.claude/skills/claude-stack-retro/references/analysis-checklist.md b/.claude/skills/claude-stack-retro/references/analysis-checklist.md new file mode 100644 index 0000000000..cd3f81cb0a --- /dev/null +++ b/.claude/skills/claude-stack-retro/references/analysis-checklist.md @@ -0,0 +1,14 @@ +# Analysis checklist + +Work through these; a "no" is a fine answer for most of them, most of the time. The bar for surfacing a suggestion: **it would plausibly recur, or it already caused friction or a mistake this stretch** — not "this could theoretically be tidier." (The same caution Anthropic's own best-practices doc gives about review prompts always finding _something_ — don't manufacture busywork here either.) + +1. **Repeated manual sequence → candidate skill.** The same ≥3-step Bash/tool sequence shows up across ≥2 separate sessions or commits in the window, with no skill covering it yet. +2. **Existing skill gave wrong/stale/incomplete guidance.** A skill or reference doc names a command, file, or convention that a commit in the window changed or removed; or the transcript shows the user correcting something a skill said. +3. **New file/architecture pattern `CLAUDE.md` doesn't mention.** A new top-level directory, state pattern, build step, or repo convention appears in the window's commits with no matching line in `CLAUDE.md`. +4. **A subagent would have helped but didn't exist.** The transcript/history shows the same skill improvising a different tone/persona more than once — not "this would be nice," only "this concretely happened and cost something." +5. **A mid-session correction reveals a missing standing rule.** The user said "don't do X" / "actually we always Y" — check it isn't already captured in `CLAUDE.md`, a skill, or memory; if genuinely new and phrased like a standing rule (not a one-off aside), it's a candidate `CLAUDE.md`/skill line. (This is exactly how this repo's own universal remote-write rule was born — one clear correction, documented once, applied everywhere. One clear correction can be enough; ten vague mentions of the same thing are one finding, not ten.) +6. **A referenced command/file no longer resolves.** Spot-check commands named in touched skills against `package.json` scripts / actual file paths — a skill telling the agent to run something that no longer exists is always worth flagging, regardless of how it was found. +7. **Old-style artifact newly inconsistent.** e.g. `.claude/commands/sonarqube-fix.md` predates the skills convention — true every retro, so don't flag it every retro just because it's still true; only surface it if something in _this_ window makes the inconsistency newly load-bearing (e.g. it needed the universal remote-write rule applied to it and doesn't have it). +8. **`.claude/settings.local.json` drift** — see the caution in `references/evidence-gathering.md`. + +Do not turn this into a linter. If nothing here clears the "would recur / already cost something" bar, the correct output is "nothing rose to that bar this stretch" — a complete, successful run, not a weak one. diff --git a/.claude/skills/claude-stack-retro/references/evidence-gathering.md b/.claude/skills/claude-stack-retro/references/evidence-gathering.md new file mode 100644 index 0000000000..b45198c5eb --- /dev/null +++ b/.claude/skills/claude-stack-retro/references/evidence-gathering.md @@ -0,0 +1,46 @@ +# Evidence gathering + +## 1. Review window + +Read `.claude/retros/.state.json`. If present, the window is `lastReviewedSha..HEAD`. + +**Bootstrapping (first run, no state file yet):** don't default to this repo's entire history — that's almost certainly too large a window to produce a useful, bounded retro. Ask the user: "No prior retro state — since when should I look? Since the `.claude/` tooling was first added (`git log --diff-filter=A --format=%H -- CLAUDE.md | tail -1`), or a shorter window (last 30 days / since the last release tag / a specific date)?" State whichever is chosen in the eventual retro log. + +## 2. Git history in the window + +```bash +git log --oneline # overview +git log --stat -- .claude/ # did the tooling change by hand, + # outside this skill? worth understanding + # why, and noting even without a "suggestion" +git log --name-only # spot new top-level dirs/patterns +git diff -- package.json # new scripts/deps implying new + # commands worth documenting +``` + +## 3. This thread's own context + +Free — no tool call needed. If this retro runs at the tail end of the session that did the work, everything discussed is already here: corrections the user gave, commands that failed and got worked around, places you had to ask the user something a skill should already have told you. + +## 4. Other sessions' transcripts since the window started (best-effort, secondary) + +Claude Code stores one JSONL transcript per session, under a per-project directory whose name is this repo's absolute path with `/`, `_`, and `.` replaced by `-`: + +```bash +slug=$(printf '%s' "$CLAUDE_PROJECT_DIR" | sed 's/[\/_.]/-/g') +ls -t ~/.claude/projects/"$slug"/*.jsonl +``` + +Skim (grep/Read, don't fully parse the JSONL structure) files modified since `lastReviewedDate` for: the user correcting a factual/procedural claim, a tool call repeated many times in a row (thrashing), an explicit "that's frustrating" / "why doesn't X exist" remark. Treat this as **color, not primary evidence** — this is undocumented internal storage and its format could change between Claude Code versions; if the directory or files aren't there, skip silently and lean on git history + the interactive question instead. + +## 5. Memory index + +Same project slug, under `memory/` instead of the transcript directory: `~/.claude/projects//memory/MEMORY.md` plus any `feedback-*.md`/`project-*.md` entries dated inside the window. A `feedback-*` entry is a strong signal — it exists because something already surprised or corrected the assistant once. If it also would have changed a skill's or `CLAUDE.md`'s guidance, that's this skill's business — cite it as evidence, but never edit or create memory files here. + +## 6. `.claude/settings.local.json` signal (careful) + +Read its `permissions.allow` list. A repeated, clearly-safe, repo-wide entry (e.g. a read-only `curl`/`git log` pattern used across multiple sessions) is worth a "promote to committed `settings.json`" suggestion. Do **not** propose promoting anything that looks personal or machine-specific (local absolute paths, one-off debug commands) or anything write-capable — err toward not suggesting if unsure. This file is gitignored on purpose (personal, per this repo's convention); most of its entries should stay personal. + +## 7. Ask, if evidence is thin + +If steps 2-6 don't add up to anything concrete, ask directly: "What was frustrating, manual, or surprising this stretch?" — one open question, not a checklist interrogation. "Nothing, it was smooth" is a valid, complete answer. diff --git a/.claude/skills/claude-stack-retro/references/output-and-state.md b/.claude/skills/claude-stack-retro/references/output-and-state.md new file mode 100644 index 0000000000..5891395e87 --- /dev/null +++ b/.claude/skills/claude-stack-retro/references/output-and-state.md @@ -0,0 +1,64 @@ +# Output format and state + +## Presenting suggestions + +One run produces 0-7 suggestions (see Scope discipline in `SKILL.md`). Present each as: + +### N. \ + +- **What**: one line, concrete — name the file/skill/`CLAUDE.md` section. +- **Why**: the friction/mistake it's tied to. +- **Evidence**: e.g. "seen in 3 sessions this window: \" or "user correction on 2026-07-15: '\'" or "`` still tells the agent to run ``, removed in ``". +- **Proposed change**: a short diff-style snippet (for a `CLAUDE.md` line or an existing skill's decision-table row), or a frontmatter + section-header outline (for a new skill/subagent) — never a fully polished file inline; that's what the follow-up Plan Mode pass is for. +- **How to apply**: "re-enter Plan Mode on this one" for anything nontrivial (new skill/subagent, `CLAUDE.md` restructuring), or "say the word and I'll make this small edit directly" for a one-line addition. + +Ask per suggestion, not once for the whole batch — accept / reject / defer. Nothing under `.claude/skills/*`, `.claude/agents/*`, `.claude/commands/*`, `CLAUDE.md`, or `.claude/settings.json` gets touched until the user says so for that specific item. + +## Retro log + +Write `.claude/retros/YYYY-MM-DD.md` (append `-2`, `-3` if a second/third retro lands the same day): + +```markdown +# Retro — YYYY-MM-DD + +Window: .. () + +## Suggestions + +### 1. — accepted + +<what/why/evidence, 2-4 lines> +Applied as: <what actually got written, or "deferred to a follow-up session"> + +### 2. <title> — rejected + +<what/why/evidence> +Reason given: "<user's stated reason, verbatim or close to it>" + +### 3. <title> — deferred + +<what/why/evidence> +``` + +Keep it short — a decision log, not a transcript dump. + +## State file + +`.claude/retros/.state.json`: + +```json +{ + "lastReviewedSha": "<full 40-char sha of HEAD at the time this retro ran>", + "lastReviewedDate": "YYYY-MM-DD", + "rejectedFingerprints": [ + "cypress-flake-triage-skill", + "promote-yarn-build-verbose-flag" + ] +} +``` + +`rejectedFingerprints`: a kebab-case slug of each rejected suggestion's title (lowercase, non-alphanumeric → `-`, collapse repeats). Before including a candidate in a future run, slug its title the same way and skip it silently on a match — a plain "already said no to this" check, not fuzzy matching. If the same underlying friction resurfaces with clearly new evidence (e.g. a third occurrence after being dismissed as a one-off), it's fine to re-raise it — say explicitly that it was rejected before and why this time is different, rather than silently re-proposing. + +Deferred suggestions are **not** fingerprinted — they weren't rejected, just not acted on yet, so a future retro can re-surface a still-relevant deferred item. + +Update this file every run, even a "nothing to report" one — it still moves `lastReviewedSha`/`lastReviewedDate` forward so the next run's window isn't re-scanned from scratch. diff --git a/.claude/skills/commit-and-pr-messages/SKILL.md b/.claude/skills/commit-and-pr-messages/SKILL.md new file mode 100644 index 0000000000..f656b1807f --- /dev/null +++ b/.claude/skills/commit-and-pr-messages/SKILL.md @@ -0,0 +1,111 @@ +--- +name: commit-and-pr-messages +description: Use before drafting a commit message, a PR title, or a PR description body — covers this repo's Conventional Commits format, the Jira ticket bracket convention, the real PR template, and the check-tasklist.yml constraint that blocks a PR on any unchecked checkbox. +--- + +# Commit and PR messages + +A lookup, not a workflow — consult the row that matches what you're drafting, then apply the rules below it. + +| Drafting... | What's different | +| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| A commit message | Header must be a valid Conventional Commit; never hand-append `(#NNNN)`; never hand-write a `chore(release):` message | +| A PR title | Same rules as a commit message — this repo squash-merges, so the PR title _becomes_ the final commit header. maps-app's CI doesn't lint-check this today (event-visualizer-app's does, via `wagoid/commitlint-github-action`), but write it as if it will be checked | +| A PR description body | Start from `.github/pull_request_template.md` verbatim; every `- [ ]` in the final body must be either genuinely checked or replaced with `_N/A_` — see "Checklist constraint" below | + +## Conventional Commits format + +`<type>(<scope>)?: <subject>` — enforced locally by `.hooks/commit-msg` (`yarn d2-style check commit "$1"`, i.e. commitlint via `@dhis2/cli-style`'s `@commitlint/config-conventional`). **Not** re-verified in maps-app's own CI (unlike event-visualizer-app), so the local hook is the only real gate — don't rely on CI to catch a bad message. + +- **Allowed types**: `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test` — but real usage on `master` is dominated by just three: `feat`, `fix`, and plain `chore` (+ scoped variants `chore(deps)`, `chore(deps-dev)`, `chore(release)`, `chore(app-platform)`, `fix(translations)`). Don't reach for an exotic type just because it's in the allowed list if `feat`/`fix`/`chore` already fits. +- Header ≤ 120 chars. +- Subject: not sentence-case, Start Case, PascalCase, or UPPERCASE; no trailing period. +- Scope is optional and rare in this repo's history — don't invent one just to fill the slot. + +## Bot-generated patterns — recognize these, never hand-write them + +- `chore(release): cut X.Y.Z [skip release]` — semantic-release only. +- `chore(deps): bump <pkg> from A to B (#NNNN)` / `chore(deps-dev): bump ...` / `chore(deps): bump the dependencies group across 1 directory with N updates` — Dependabot only. +- `fix(translations): sync translations from transifex (master)` — Transifex sync bot only. + +## Jira ticket references + +`[PROJECTKEY-NUMBER]` in square brackets — the project key isn't always `DHIS2` (`CLIM-501` also appears in history). Placed after the description, before any `(#PRNUM)`. **Multiple tickets in one bracket-stack is normal and common** for a commit/PR that batches related fixes: + +``` +fix: prevent timeline crash on load failure and fix period after drilling up/down [DHIS2-19063] [DHIS2-21113] (#3664) +fix: various bubble layer issues [DHIS2-15696] [DHIS2-19209] [DHIS2-19447] [DHIS2-19448] (#3550) +``` + +## What makes a real "good" message here + +The healthiest real examples describe the **observable behavior/outcome** — what a user or future maintainer would see changed — not the implementation mechanics: + +``` +fix: prevent duplicate overview map outline in splitmap download mode [DHIS2-21540] (#3676) +fix: preserve program/enrollment period type on TE layer reload [DHIS2-19205] (#3674) +feat: add custom scale toggle for heat stress layers [DHIS2-20564] (#3708) +fix: resize data table and map canvas continuously during drag [DHIS2-15884] (#3675) +``` + +Not "refactor the useEffect to guard against null" — "prevent X crash," "preserve Y on Z." Most real headers run ~40-70 chars, well under the 120 hard cap. Lowercase immediately after the type/colon, no trailing period, imperative/active verb. + +## Two things to never fabricate + +- **`(#PRNUM)`** — GitHub appends this automatically when the PR is squash-merged. Never write it into a drafted commit message or PR title yourself; you don't know what the number will be, and a wrong one is worse than none. +- **`chore(release): cut X.Y.Z [skip release]`** — generated by semantic-release on release. Never hand-write one. + +## PR description body + +Use `.github/pull_request_template.md` as the literal skeleton — don't paraphrase its section headers or reorder them: + +```markdown +Implements [DHIS2-XXXX](https://dhis2.atlassian.net/browse/DHIS2-XXXX) + +### Description + +_text_ + +--- + +### Quality checklist + +Add _N/A_ to items that are not applicable. + +- [ ] Dashboard tested +- [ ] Cypress and/or Jest tests added/updated +- [ ] Docs added +- [ ] d2-ci dependencies replaced (analytics or maps-gl link https://github.com/dhis2/[lib]/pull/XXX) +- [ ] Tester approved (name) + +--- + +### ToDos + +- [ ] _todo_ + +--- + +### Known issues + +- [ ] _issue_ + +--- + +### Screenshots + +_supporting images_ +``` + +### Checklist constraint (`check-tasklist.yml`) + +`Shopify/task-list-checker` scans the whole PR body on every edit and blocks the PR while _any_ `- [ ]` remains, anywhere in the body — not just the Quality checklist. When drafting or updating a description: + +- Only leave `- [ ]` on items you genuinely expect to complete before merge. +- For anything not applicable, write `_N/A_` in place of the checkbox — that's the template's own escape hatch, not a workaround. +- It's fine to leave items unchecked while the PR is still a draft; just don't hand it off as "ready for review" with stray open boxes. + +## Related + +- `pr-polish` — the workflow that keeps the Quality checklist honest across rounds of fixes; this skill just supplies the format rules it applies. +- `pre-review` — final check before flipping a PR to ready; doesn't touch message drafting, but its title/description check reuses this skill's template. diff --git a/.claude/skills/community-post/SKILL.md b/.claude/skills/community-post/SKILL.md new file mode 100644 index 0000000000..91036933d7 --- /dev/null +++ b/.claude/skills/community-post/SKILL.md @@ -0,0 +1,28 @@ +--- +name: community-post +description: Use when drafting a DHIS2 Community of Practice release-announcement post for a new maps-app feature/fix — gathers version/tickets/tone/prerequisites from the user, then dispatches the community-post-writer subagent to draft the post matching the real house format. +--- + +# Community post + +Drafts a release-announcement post for community.dhis2.org, matching a real, consistent format verified across multiple published posts. + +## Gather inputs + +Before dispatching, collect from the user (suggest, don't assume, where you can): + +- **App version(s)** — can suggest from `package.json`, but confirm since Play/App Hub publishing timing may lag the repo's own version. +- **The feature(s)/fix(es) to cover** — Jira ticket IDs. Jira is the house link convention; never link a GitHub PR in the post itself. +- **Audience/tone lever** — default: accessible to non-developer admins/analysts, matching all real examples. Flag if this one should be more technical (rare). +- **Feature-specific prerequisites** — e.g. "requires a Google Earth Engine API key configured," "requires organisation unit polygons." This is domain knowledge the skill can't guess; ask, or pull from the relevant ticket/PR description if available. +- **Urgent patch note?** — only needed if this is a bugfix release superseding a broken version (e.g. "update directly to 100.8.1"). +- **Screenshots** — the user supplies real images or says "I'll add them later." Never fabricate a fake UI image; no GIFs in any real example. +- **Who's signing** — name + title (PM and developer voices are both real precedents). + +## Dispatch + +Hand all of the above to the **`community-post-writer`** subagent to draft the actual post. Review the draft against the real structure before presenting it: opening line, per-app compatibility sentence, one heading per feature with a screenshot placeholder and a "Jira"-anchor-texted link, closing, signature — no fabricated call-to-action, no fabricated screenshot description. + +## Note + +Posting to the forum is manual, by the user — this skill only drafts the text. diff --git a/.claude/skills/dhis2-web-api-research/SKILL.md b/.claude/skills/dhis2-web-api-research/SKILL.md new file mode 100644 index 0000000000..4b6b634fad --- /dev/null +++ b/.claude/skills/dhis2-web-api-research/SKILL.md @@ -0,0 +1,38 @@ +--- +name: dhis2-web-api-research +description: Use when writing or modifying code that calls the DHIS2 Web API (analytics, geoFeatures, tracker, metadata endpoints) and the exact request/response shape is unclear. Prevents guessing/hallucinating API fields. +--- + +# DHIS2 Web API research + +Don't guess field names or response shapes from memory — DHIS2's API surface is large and versioned. Work through these tiers, cheapest first, stopping as soon as you have enough certainty. + +| Scenario | Do this | +| --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Need the shape of a specific endpoint/resource | Tier 1: fetch the scoped OpenAPI spec | +| Need to see real data (pagination, actual field values, edge cases) | Tier 2: probe a live instance | +| OpenAPI spec is ambiguous or you need to understand server-side logic | Tier 3: read the DHIS2 backend source | +| Just need to know which endpoints this app already calls | See `d2.config.js`'s `omitPatterns` list at repo root — it's a ready-made inventory: `geoFeatures`, `analytics`, `tracker/trackedEntities`, `organisationUnitGroupSets`, `dataElements`, `trackedEntityAttributes`, `optionSets`, `legendSets`, `programs`, `programStages`, `trackedEntityTypes`, `relationshipTypes`, `organisationUnitLevels`. | + +## Tier 1 — OpenAPI spec (cheapest) + +Fetch the spec scoped to the resource you care about via `curl`, not the whole multi-MB spec. Read only the relevant schema/paths. + +## Tier 2 — Probe a live instance + +GET-only, against a dev/test instance only (never write). Paginate — don't pull whole collections. Good for confirming actual field values and edge cases the spec doesn't show. + +## Tier 3 — Read the backend source + +When the spec is ambiguous or you need server-side validation/business logic: + +``` +npx opensrc dhis2/dhis2-core --modify +``` + +This clones the DHIS2 backend Java source into a gitignored `./opensrc/` directory. The codebase is large — use an Explore subagent to search it rather than reading broadly yourself. + +## Troubleshooting + +- **`opensrc` clone already exists and looks stale** — re-run with `--modify` to refresh, or check the pinned DHIS2 version this app targets (`minDHIS2Version` in `d2.config.js`) against what you cloned. +- **Live probe returns 401/403** — you're likely pointed at a non-dev instance or missing `cypress.env.json` credentials; don't escalate to write attempts to work around this. diff --git a/.claude/skills/docs-update/SKILL.md b/.claude/skills/docs-update/SKILL.md new file mode 100644 index 0000000000..e52e33a248 --- /dev/null +++ b/.claude/skills/docs-update/SKILL.md @@ -0,0 +1,43 @@ +--- +name: docs-update +description: Use when a code change is user-facing and maps-app's published documentation (docs/src/*.md -> docs/maps.md) may need updating — maps the feature area to the right docs/src file and dispatches the docs-writer subagent to draft the prose and rebuild. +--- + +# Docs update + +Keeps `docs/src/*.md` → `docs/maps.md` in sync with a user-facing code change. Advisory, not a gate — no CI enforces this, and not every change needs it (historically only ~24% of `feat`/`fix` commits touch `docs/` at all). Use judgment about whether a given change is even docs-relevant. + +## Feature-area → file mapping + +| Feature area | `docs/src/` file | +| ---------------------------------- | ------------------------------------- | +| Thematic layers | `04-thematic-layer.md` | +| Event layers | `05-event-layer.md` | +| Tracked entity layers | `06-tracked-entity-layer.md` | +| Facility layers | `07-facility-layer.md` | +| Org unit layers | `08-org-unit-layer.md` | +| Earth Engine layers | `09-earth-engine-layer.md` | +| External map layers | `10-external-map-layers.md` | +| Org unit profile | `11-org-unit-profile.md` | +| File menu | `12-file-menu.md` | +| Interpretations | `13-interpretations.md` | +| Image export | `14-image-export.md` | +| Search | `15-search.md` | +| Measure distance | `16-measure-distance.md` | +| Admin-only features | `18-administrator.md` | +| General create-map flow / basemaps | `02-create-map.md` / `03-basemaps.md` | + +## Flow + +1. Identify the affected file(s) from the table above. +2. Dispatch the **`docs-writer`** subagent with the feature description and affected file(s) — its job is drafting the prose (matching the existing `> **Note**` callout and heading-ID conventions) and running `yarn docs:build` to regenerate `docs/maps.md`. +3. Review the diff (both the `docs/src/*.md` edit and the regenerated `docs/maps.md`) before it's committed — still gated by `CLAUDE.md`'s "don't stage or commit unless explicitly asked." +4. Commit the source edit and the regenerated `docs/maps.md` together, in the same commit/PR as the feature — this is the dominant real historical pattern, not a separate docs follow-up. + +## Screenshots + +If the change alters something shown in an existing screenshot, flag it ("this may need recapturing") rather than fabricating a new image — real docs screenshots are actual app captures, never synthesized. + +## Related + +- `commit-and-pr-messages` — the "Docs added" Quality-checklist item this skill helps make honestly checkable. diff --git a/.claude/skills/implement-plan/SKILL.md b/.claude/skills/implement-plan/SKILL.md new file mode 100644 index 0000000000..0f121568a9 --- /dev/null +++ b/.claude/skills/implement-plan/SKILL.md @@ -0,0 +1,62 @@ +--- +name: implement-plan +description: Autonomously executes a written implementation plan (from spec-from-ticket, or pasted/referenced directly) through a commit-by-commit implement/test/self-review/commit cycle, requesting permissions up front rather than piecemeal. Explicit invocation only — run with /implement-plan <path-to-spec-or-plan>. +disable-model-invocation: true +--- + +# implement-plan + +Executes a plan (a `.claude/specs/*.md` file, or one pasted/described directly) through a disciplined commit-by-commit cycle, with **local** permissions negotiated once up front — because nobody is available to approve tool calls piecemeal for the duration of the run. Remote writes (push, PR create/edit/ready) are never part of that negotiation — see "Handoff." + +| Situation | Do this | +| ------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| Starting a fresh run | Do the permission negotiation (step 1) before touching anything else | +| A plan step is ambiguous, or the code doesn't match what the plan assumed | Stop, ask — see "Escalation conditions" | +| A step is done, tests pass, self-review is clean | Commit it, move to the next step | +| Every step is done and committed | Full suite, then a fresh-context full-diff review — see step 5 | +| Ready to push / open a PR | Stop and ask explicitly, every time — see "Handoff" | + +## 1. Negotiate permissions — before anything else + +Enumerate exactly what this run will need and get it granted once, up front, rather than mid-flight. Full script and the concrete Bash allowlist: `references/permission-negotiation.md`. In short, ask the user to either: + +- grant a session allowlist covering lint/test/build and **local** git (add/commit/status/diff/log/merge), or +- switch to Claude Code's auto mode (`/permission-mode auto`) for the run. + +**Never include `git push`, `gh pr create/edit/ready`, or any other remote-write command in this negotiation** — no matter how far ahead the user wants to go. This repo's universal rule (see `CLAUDE.md`) is that remote writes are never pre-negotiated or bundled into a blanket allowlist; they're handled fresh, per the "Handoff" section below, only when actually reached and only with a fresh explicit ask at that moment. + +## 2. Break the plan into commits + +If the plan document already has numbered, file-scoped "Implementation plan" steps (the `spec-from-ticket` shape), treat each as one commit unless it's too large (spans unrelated files — split it) or too small/coupled to compile independently (merge it with its neighbor). If working from a looser/pasted plan, derive equivalent units yourself. Full heuristic and commit-message drafting rules (Conventional Commits + the real `[TICKET-ID]` bracket convention — see the `commit-and-pr-messages` skill for the format itself, **never** fabricating the `(#PRNUM)` suffix GitHub appends at squash-merge): `references/commit-cycle.md`. + +Track progress with `TodoWrite` as you go — this is a multi-step, potentially long-running loop. + +## 3. The cycle, per step + +1. Implement the step. +2. Test/lint the touched files only — reuse `CLAUDE.md`'s "Lint/test workflow for agents" verbatim (`npx jest <file>`, then `yarn d2-style check <file>`), don't reinvent it. +3. Self-review just this step's diff — invoke `/code-review` at low/medium effort. Address findings; don't blindly `--fix` — surface what was found so there's an audit trail. +4. Commit (message per `commit-and-pr-messages`). +5. Next step. + +## 4. Escalation conditions — stop and ask, don't guess + +- The plan's assumption about the code no longer holds (file renamed/moved, prop already exists, etc.). +- A test fails and the fix requires a judgment call about behavior, not a mechanical fix. +- A step would touch something the plan's "Out of scope" section excludes, or wasn't anticipated at all. +- A step needs a cross-repo change (e.g. `@dhis2/maps-gl`) the plan didn't flag as needing coordination. +- A pre-existing, unrelated test/lint failure shows up (don't silently "fix" things outside the plan's scope — flag it and ask whether to proceed past it or address it first). +- Anything needs a tool/command outside what was negotiated in step 1 — pause and ask for that specific addition rather than expanding scope silently. + +## 5. After the last step + +1. Full suite: `yarn lint && yarn test` (and `yarn cy:run --spec ...` if the plan's verification section calls for it). +2. Fresh-context full-diff review: dispatch a subagent to run `/code-review` (or read the diff cold) across the _whole_ diff, not just the last step — the main session's context is full of the implementation by now, a fresh pair of eyes catches what a tired context misses. +3. Address findings as additional small commits (never amend prior commits — new commits only, per this repo's git norms), unless the user explicitly asks to clean up history before it's pushed. +4. Report a summary: commits made (SHAs + messages), test/lint/e2e status, current branch. If mid-flight master moved significantly, this is also where you'd have merged master into the feature branch (merge, not rebase — this team's convention, see `branch-update`) and re-run the suite; mention if that happened. + +## Handoff + +Stop here by default and ask before pushing/opening a PR — always, no exceptions, regardless of anything negotiated in step 1. Explicit `/implement-plan` invocation is permission to commit locally throughout (per `CLAUDE.md`'s "don't stage or commit unless explicitly asked," which this explicit invocation satisfies) — it is **not** permission for anything that writes to a remote. Push and PR-create are qualitatively different, harder-to-reverse actions, and this repo's universal rule requires a fresh, explicit, in-the-moment ask for those specifically, every single time, with no pre-provisioned credential or bundled approval to route around that ask. + +If the user explicitly says to proceed: push, then `gh pr create` filling the real template (`.github/pull_request_template.md`) — `Implements [TICKET-ID](url)`, Description, Quality checklist (check what's actually true, `_N/A_` the rest — `check-tasklist.yml` blocks the PR on **any** unchecked `- [ ]` anywhere in the body, not just that section), ToDos, Known issues, Screenshots. Then hand off to the `pre-review` skill for the final self-review-and-mark-ready pass — don't duplicate its logic. diff --git a/.claude/skills/implement-plan/references/commit-cycle.md b/.claude/skills/implement-plan/references/commit-cycle.md new file mode 100644 index 0000000000..2686593580 --- /dev/null +++ b/.claude/skills/implement-plan/references/commit-cycle.md @@ -0,0 +1,18 @@ +# Commit granularity and message drafting + +## Splitting/merging plan steps into commits + +- Default: one commit per numbered "Implementation plan" step, if the plan came from `spec-from-ticket` (that skill deliberately writes steps at commit granularity). +- Split a step if it spans clearly unrelated files/concerns. +- Merge adjacent steps if either one alone would leave the tree non-compiling or tests red (e.g. "add action creator" + "wire into reducer" — neither makes sense alone). +- Rule of thumb before committing: `yarn lint` and the touched-file tests should be green. Avoid deliberately-broken intermediate commits. + +## Commit message format + +See the `commit-and-pr-messages` skill for the full format rules and real examples. In short: Conventional Commits, `<type>: <description> [<TICKET-ID>]`, header ≤120 chars, **never** append `(#PRNUM)` (GitHub adds that automatically at squash-merge), **never** hand-write a `chore(release):` message. + +These are step-commits on a feature branch that will eventually be **squash-merged** — so the final PR title matters more for permanent history than any individual step message, but step messages still matter for reviewers reading commit-by-commit, so don't get sloppy just because they'll be squashed away. + +## Mid-flight master sync + +If the run is long enough that `master` moves meaningfully, **merge** master into the feature branch (this team's real convention — see the `branch-update` skill — never rebase), then re-run the full suite before continuing. This falls under the pre-negotiated `git merge*` allowlist entry — do it, but say so in the summary, don't do it silently. diff --git a/.claude/skills/implement-plan/references/permission-negotiation.md b/.claude/skills/implement-plan/references/permission-negotiation.md new file mode 100644 index 0000000000..f11b9bfeb7 --- /dev/null +++ b/.claude/skills/implement-plan/references/permission-negotiation.md @@ -0,0 +1,28 @@ +# Permission negotiation script + +Run this before touching any file. Use `AskUserQuestion`. + +## The one question — local operations only + +> This will run for a while without stopping to ask each time. Pick one: +> (a) Grant the allowlist below for this session (I'll ask once via the permission prompt). +> (b) Switch to auto mode (`/permission-mode auto`) for the duration — a classifier reviews each command and only escalates risky ones. +> (c) Ask me before every command (default — slow, not really compatible with an unattended run; confirm you actually want this). + +Concrete Bash patterns this workflow realistically needs — **all local, nothing that touches a remote**: + +- `Bash(yarn d2-style check*)` / `Bash(yarn lint*)` — touched-file and full-suite lint +- `Bash(npx jest*)` / `Bash(yarn test*)` — touched-file and full-suite tests +- `Bash(yarn build*)` — only if the plan's verification step needs it +- `Bash(yarn cy:run*)` — only if the plan touches map rendering / Cypress-covered UI; ask specifically if it comes up, don't pre-grant broadly +- `Bash(git status*)`, `Bash(git diff*)`, `Bash(git log*)`, `Bash(git add*)`, `Bash(git commit*)` — the commit cycle itself +- `Bash(git merge*)` — for syncing master into the feature branch mid-flight (merge, not rebase — team convention, see `branch-update`) +- `Skill(code-review)` / `Skill(code-review:*)` — per-step and final self-review +- Edit/Write on the repo tree (implicit, not a Bash pattern) + +**Deliberately never included here, no matter what — ask fresh, in the moment, only if actually reached (see the main SKILL.md's "Handoff" section):** + +- `Bash(git push*)` +- `Bash(gh pr create*)`, `Bash(gh pr edit*)`, `Bash(gh pr ready*)` + +This isn't a stricter default that can be relaxed by the user pre-authorizing further upfront — it's a hard boundary. Even a user who says "just go all the way through, don't stop" at the start of the run still gets a fresh, explicit ask at the actual push/PR-create moment. diff --git a/.claude/skills/manual-test-scenarios/SKILL.md b/.claude/skills/manual-test-scenarios/SKILL.md new file mode 100644 index 0000000000..c1a1cbd7ea --- /dev/null +++ b/.claude/skills/manual-test-scenarios/SKILL.md @@ -0,0 +1,35 @@ +--- +name: manual-test-scenarios +description: Use when drafting the "Manual testing" section of a PR body (or a combined write-up spanning a pr-chain) — gathers the ticket list, Netlify preview info, and real map/dashboard UIDs, then dispatches the test-scenario-writer subagent to draft the section matching this repo's real convention. +--- + +# Manual test scenarios + +Drafts the "Manual testing" section of a PR body, matching a real, consistent, already-established convention (verified across multiple real PRs) — one bullet per Jira ticket, a working link + a screenshot, not numbered click-steps. + +| Situation | Do this | +| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| Single PR, tickets already known | Gather inputs (below), dispatch `test-scenario-writer` | +| A `pr-chain` — multiple PRs, each with its own Netlify preview | Ask the user: one consolidated write-up (in the tail PR) or per-PR sections? Don't assume | +| No real map/dashboard UID available yet for a scenario | Ask the user for one — never invent a UID, it has to actually exist on the pinned instance | + +## Gather inputs + +Before dispatching, collect: + +- **Ticket list** — the same `[TICKET-ID](url)` links already used under "Implements" in the PR body; reuse them verbatim, don't re-derive the URL. +- **Netlify PR number and instance** — `Netlify: https://pr-<PR#>.maps.netlify.dhis2.org/ + Instance: https://dev.im.dhis2.org/maps-app-42-3` is the real fixed pattern; confirm the PR number if not obvious. +- **Real map/dashboard UIDs** to link per scenario, from the user — this skill can't invent working test data. +- **Whether the "Dashboard tested" checklist item applies** — if so, also gather the `interpretationId` for a paired "- plugin" link alongside each "- main" link. + +## Dispatch + +Hand all of the above to the **`test-scenario-writer`** subagent to draft the actual section in the tester-facing voice (trusts a technically-comfortable internal tester with a link + screenshot, never prose click-paths). Review its draft before inserting it into the PR body — confirm every ticket has either a real test-map link or an explicit "still needs a link" placeholder, never a silently-dropped bullet. + +## For a `pr-chain` + +Each PR in the stack gets its own Netlify preview URL. Ask the user which they want: one consolidated write-up organized by ticket across the whole set (living in the tail PR), or per-PR sections each referencing that PR's own preview. Don't default to one without asking — both are reasonable and it depends on how the reviewers actually work through the chain. + +## Ties to the PR template + +This section is the evidence that lets a named tester actually check the `Tester approved (name)` Quality-checklist item — `check-tasklist.yml` blocks the PR while that box (or any other) is left unchecked with no `_N/A_`. See `commit-and-pr-messages` for the full checklist-constraint rule. diff --git a/.claude/skills/map-layer-architecture/SKILL.md b/.claude/skills/map-layer-architecture/SKILL.md new file mode 100644 index 0000000000..28279ee34c --- /dev/null +++ b/.claude/skills/map-layer-architecture/SKILL.md @@ -0,0 +1,23 @@ +--- +name: map-layer-architecture +description: Use when adding or modifying a map layer type, thematic/classification config, Earth Engine layers, or anything at the maps-app/@dhis2/maps-gl rendering boundary. +--- + +# Map layer architecture + +| Working on... | Read | +| ---------------------------------------------------------------------------------------------- | -------------------------------- | +| A layer type (thematic, event, org unit, tracked entity, facility, external, GeoJSON, basemap) | `references/layer-base-class.md` | +| Legend/classification config (color scales, legend sets, decimal places) | `references/classification.md` | +| An Earth Engine dataset layer, or a build/dev-server issue involving `@dhis2/maps-gl` | `references/earth-engine.md` | +| Behavior that differs between the standalone app and the dashboard-embedded plugin | See "Dual deployment" below | + +## The maps-app / maps-gl boundary + +maps-app owns data fetching, Redux state, and config UI. `@dhis2/maps-gl` (imported via `src/components/map/MapApi.js`) owns the actual MapLibre GL rendering, layer/control types, and the Earth Engine worker. Changing what a "layer" can do usually touches both repos — check `@dhis2/maps-gl`'s `layerTypes.js`/`controlTypes.js` registries when a new layer _kind_ is needed, not just new _config_ for an existing kind. + +## Dual deployment + +`d2.config.js` declares two entry points: `app` (`AppWrapper.jsx`, standalone) and `plugin` (`PluginWrapper.jsx`, `pluginType: 'DASHBOARD'`, embedded in a dashboard). `PluginWrapper.jsx` debounces window-resize-driven re-renders specifically for the embedded case — if you're touching resize/layout logic, check both entry points render correctly. + +**The plugin entry point does not have a Redux store.** `AppWrapper.jsx` wraps its tree in `<ReduxProvider store={store}>`; `PluginWrapper.jsx` renders `<Plugin>` directly with no `Provider` at all. Any component that needs to render inside the dashboard plugin (not just the standalone app) can't rely on `connect`/`useSelector`/`useDispatch` reaching a real store — `connect`'d components mounted under the plugin tree would break or silently get `undefined` state. Check whether a component you're changing is reachable from `Plugin.jsx` before assuming Redux is available. diff --git a/.claude/skills/map-layer-architecture/references/classification.md b/.claude/skills/map-layer-architecture/references/classification.md new file mode 100644 index 0000000000..626c7b091d --- /dev/null +++ b/.claude/skills/map-layer-architecture/references/classification.md @@ -0,0 +1,7 @@ +# Thematic mapping / classification + +`src/components/classification/` implements the legend/color-scale config UI: `Classification.jsx`, `LegendTypeSelect.jsx`, `LegendSetSelect.jsx`, `NumericLegendStyle.jsx`, `DecimalPlacesSelect.jsx`, `IsolatedClass.jsx`, `SingleColor.jsx`. This produces the `legendSet`/color-scale config consumed by `ThematicLayer.jsx` and ultimately rendered by `@dhis2/maps-gl`. + +`src/constants/colorbrewer.js` and `colors.js` back the available color-scale choices. + +This is the layer of the app most likely to have subtly-wrong edge cases (decimal place rounding, legend-set vs. ad-hoc classification, single-color vs. scaled) — when touching it, check both the "predefined legend set" and "custom classification" code paths, not just one. diff --git a/.claude/skills/map-layer-architecture/references/earth-engine.md b/.claude/skills/map-layer-architecture/references/earth-engine.md new file mode 100644 index 0000000000..21a50c70f9 --- /dev/null +++ b/.claude/skills/map-layer-architecture/references/earth-engine.md @@ -0,0 +1,9 @@ +# Earth Engine layers + +`src/constants/earthEngineLayers/*.js` declares Google Earth Engine dataset configs (heat, precipitation, vegetation, population, elevation, land cover, etc. — one file per dataset/period combination), aggregated via `src/constants/earthEngineLayers/index.js`. + +## The Vite `optimizeDeps` workaround + +`d2.config.js`'s `viteConfigExtensions.optimizeDeps.exclude: ['@dhis2/maps-gl']` exists so Vite serves `@dhis2/maps-gl` unbundled via `/@fs/...` with its full transform pipeline — this lets the Earth Engine worker's URL resolve to the actual source file rather than a `.vite/` cache path where bare imports aren't rewritten. Because `@dhis2/maps-gl` is excluded from the dep scanner, its CJS dependencies must be listed explicitly under `optimizeDeps.include` (maplibre-gl, turf packages, comlink, etc.) — if you add a new maps-gl dependency that needs bundling, it likely needs adding here too. + +If you hit a dev-server error about the Earth Engine worker failing to load or resolve, check this config block before assuming the bug is in the worker code itself. diff --git a/.claude/skills/map-layer-architecture/references/layer-base-class.md b/.claude/skills/map-layer-architecture/references/layer-base-class.md new file mode 100644 index 0000000000..635a589315 --- /dev/null +++ b/.claude/skills/map-layer-architecture/references/layer-base-class.md @@ -0,0 +1,16 @@ +# The `Layer` base class + +`src/components/map/layers/Layer.js` is an abstract React **class** component every concrete layer type extends (`ThematicLayer`, `EventLayer`, `OrgUnitLayer`, `TrackedEntityLayer`, `FacilityLayer`, `ExternalLayer`, `GeoJsonLayer`, `BasemapLayer`). It bridges React lifecycle to maps-gl's **imperative** API — don't manage layer state with plain React state, funnel changes through this base class's methods. + +- `componentDidMount` → `createLayer()` → `map.createLayer(config)` + `map.addLayer(this.layer)`. +- `componentDidUpdate(prevProps)` diffs props and picks the cheapest applicable update: + - Data/config actually changed → `updateLayer()` (destroys and recreates the underlying maps-gl layer — expensive). + - Only order changed → `setLayerOrder()`. + - Only opacity changed → `setLayerOpacity()`. + - Only visibility changed → `setLayerVisibility()`. + - A feature needs highlighting → `highlightFeature(feature)`. +- `componentWillUnmount` removes the layer from the map. + +**When adding a new layer type**: extend `Layer`, override what you need (data fetching, config shape), but reuse the base class's update-diffing rather than re-implementing it. **When modifying update behavior**: change it in the base class, not per-subclass, unless the behavior is genuinely subclass-specific. + +**Performance rule**: never trigger a full `updateLayer()` when a cheaper method covers the actual change — destroy+recreate on every prop change is the most common map-perf regression in this codebase. diff --git a/.claude/skills/mockup-pr/SKILL.md b/.claude/skills/mockup-pr/SKILL.md new file mode 100644 index 0000000000..467f599ed2 --- /dev/null +++ b/.claude/skills/mockup-pr/SKILL.md @@ -0,0 +1,122 @@ +--- +name: mockup-pr +description: Build and open a draft "mockup" PR — real, clickable code on a throwaway branch that's never intended to merge, so stakeholders can react to something concrete before the team commits to full implementation. Explicit invocation only. +disable-model-invocation: true +--- + +# Mockup PR + +| Situation | Do | +| ----------------------------------------------------- | --------------------------------------------------------------------------- | +| Haven't yet identified what this mockup is de-risking | Step 1 — ask, don't guess | +| UI/UX or information-architecture question | `references/ui-ux-fidelity.md` | +| Algorithmic/statistical feasibility question | `references/algorithmic-fidelity.md` | +| Architecture/integration feasibility question | `references/architecture-fidelity.md` | +| Ready to push + open the PR | See "Pushing and opening the PR" below — always a fresh, explicit ask first | + +A "mockup" here is a **draft, never-merged PR** whose job is to give stakeholders something they can click through on a live preview, before real implementation work starts. It is not one recipe — three real precedents in this repo (#3684, #3673, #3655) each picked a different fidelity strategy depending on _what uncertainty they were de-risking_. Picking the wrong one either wastes effort (over-building a throwaway UI mock) or fails to actually answer the open question (under-building when the risk is algorithmic or architectural). + +## Step 0 — Scope + +Before writing anything, get from the user (don't assume): + +- A one-line pitch of the feature being mocked up. +- A Jira ticket ID or link, if one already exists (there's no live Jira API access — take it as pasted text; it's fine if there isn't one yet, see PR #3673, which validated a not-yet-ticketed direction). + +## Step 1 — What is this de-risking? + +Ask (`AskUserQuestion`), single-select, always — even if it looks obvious from the user's initial description: + +> **What's the biggest open question this mockup needs to answer before real implementation starts?** +> +> - **UI/UX & information architecture** — Will this layout/workflow make sense to users? Stub data locally, no Redux/API wiring. Cheapest, most disposable. → `references/ui-ux-fidelity.md` +> - **Algorithmic/statistical feasibility** — Does the calculation actually work and perform acceptably on real data? Implement it for real, with real tests, gated by a new config flag. → `references/algorithmic-fidelity.md` +> - **Architecture/integration feasibility** — Can a new subsystem integrate with the app's real state/actions? Wire the real "hands", stub only the expensive/uncertain "brain". → `references/architecture-fidelity.md` +> - **Not sure / a mix** — talk it through before picking one. + +Don't split effort across strategies for one PR — pick the single primary uncertainty and build to answer that one. + +## Step 2 — Branch + +- Ticket exists → `mockup/<TICKET-ID>` (e.g. `mockup/DHIS2-21453`). +- No ticket yet / exploratory → `feat/<short-slug>` (e.g. `feat/spatial-analysis`, `feat/ai-agent-poc`). Branch prefix tracks "does a ticket exist," not which fidelity strategy was picked — either naming style is fine either way. + +Always branch from latest `master`. + +## Step 3 — Build + +Open the reference file matched in Step 1 and follow its guidance for what to fake and what to build real. Regardless of which strategy: use real `@dhis2/ui` components, real `core/` field-wrapper components (`src/components/core/`), real colocated `ComponentName.module.css`, real `i18n.t()` for all strings, and put files in the correct `src/components/<feature>/` location. If the mockup touches a layer type or classification/thematic config, see the `map-layer-architecture` skill for the real conventions there — don't reinvent them for the mockup. + +Commit normally as you go (`feat(scope): ...`, `fix(scope): ...`, etc. — see `commit-and-pr-messages`) — there's no special "mockup:" commit-message prefix requirement; that convention belongs to the PR title, not every commit. + +## Step 4 — Confirm before pushing + +Before running any push or PR-create command, show the user: + +- Final branch name +- The commit list (`git log master..HEAD --oneline`) +- The PR title +- The filled-in PR body (see template below) + +Get an explicit go-ahead. This isn't extra bureaucracy on top of the skill's own invocation — it's the one checkpoint before something becomes visible to the rest of the team, and per this repo's universal rule, remote writes always need a fresh, in-the-moment ask regardless of how the skill was invoked. + +## PR title + +`mockup: <short description>` — optionally append `[DHIS2-XXXXX]` if there's a ticket and the user wants it visible in the PR list. Not required even when a ticket exists (precedent: #3684 has no ticket in its title despite one in the body). + +## PR body template + +``` +Mockup{ for [<TICKET-ID>](https://dhis2.atlassian.net/browse/<TICKET-ID>)} + +DO NOT MERGE + +### Description + +**What:** <one sentence — what this demonstrates and why now> + +- <what's built> +- <what's built> +- <what's explicitly faked/stubbed and why, per the chosen fidelity strategy> +- <out-of-scope/follow-up work, named explicitly — not vague> + +--- + +### Screenshots + +<!-- Drag screenshots into the PR description on github.com after it's open — + gh/the API can't embed inline images (github.com's drag-and-drop is a + private endpoint, not part of the public REST API or gh CLI). Omitting + this section entirely is also fine and has precedent (#3655) when the + live preview is expected to carry that weight instead. --> +``` + +Drop the `{ for [...] }` bracketed clause entirely (leaving plain `Mockup`) if there's no ticket. + +## Pushing and opening the PR + +**Per this repo's universal rule: never push or open a PR without a fresh, explicit, in-the-moment ask — every time, no exceptions, regardless of how this skill was invoked.** There's no pre-provisioned write credential to reach for and no way to bundle this into an earlier approval. Confirm with the user right before running either command, then attempt it through the normal tool-permission flow (which will prompt) — don't try to route around that prompt. + +```bash +git checkout master && git pull origin master +git checkout -b mockup/DHIS2-21453 # or feat/<short-slug> + +# ... commits happen during Step 3 ... + +git push -u origin mockup/DHIS2-21453 + +gh pr create \ + --draft \ + --base master \ + --title "mockup: <short description>" \ + --label mockup \ + --body-file <path-to-pr-body.md> +``` + +`--label mockup` assumes the label exists on the target repo (it does on `dhis2/maps-app`, confirmed). If it's missing on a fork, `gh pr create` fails with `could not add label: 'mockup' not found` — surface that error rather than retrying without the label. + +## After opening + +- **Never run `gh pr ready`** on this PR — it's meant to stay in draft forever. +- A SonarQube quality-gate failure comment is expected on these PRs — don't try to clean it up; that would defeat the point of a cheap, fast mockup. +- A Netlify bot comment with the live preview URL (`pr-<n>.maps.netlify.dhis2.org`) usually appears within a couple of minutes (`gh pr view <n> --comments` to check) — this is how stakeholders click through the mockup without pulling the branch. It's not guaranteed to post every time; don't block the handoff on it. diff --git a/.claude/skills/mockup-pr/references/algorithmic-fidelity.md b/.claude/skills/mockup-pr/references/algorithmic-fidelity.md new file mode 100644 index 0000000000..383932746d --- /dev/null +++ b/.claude/skills/mockup-pr/references/algorithmic-fidelity.md @@ -0,0 +1,23 @@ +# Algorithmic/statistical feasibility fidelity + +De-risking: _does the actual calculation work, and hold up on real data?_ +Reference precedent: PR #3684, "mockup: spatial hotspot analysis for thematic layers" (branch `feat/spatial-analysis`). + +## What's real + +- The algorithm itself, in full — not a stub. Precedent: 675-line `src/util/spatialStats.js` implementing real Getis-Ord Gi\*/Local Moran's I statistics, with a 456-line Jest spec actually exercising the math. +- Wiring into the **real production pipeline** — precedent: `getGiStar`/`getLisa` called directly from `src/loaders/thematicLoader.js`, not from a side path. +- New UI reuses real `core/` field-wrapper components, real Redux actions/reducers, and the existing dialog's CSS module — this strategy isn't about faking UI plumbing, only about proving the calculation. +- A genuinely new runtime dependency if the algorithm needs one — precedent: `@turf/distance` added to `package.json`/`yarn.lock` for real, not vendored or copy-pasted. + +## What makes it safe to merge-adjacent-but-not-merge + +- **Gate the entire feature behind a new config flag** so existing saved maps/configs are provably unaffected — precedent: `config.spatialAnalysis?.method` checked in `thematicLoader.js` before any of the new code path runs. Pick a flag name scoped to the feature, not a generic `experimental` toggle. + +## Commit shape + +Granular, iterative, real conventional commits as the algorithm gets built and debugged — precedent: `feat(spatialStats): implement ... with tests`, `fix(lint): resolve eslint errors across spatial analysis files`, `fix(spatialStats): fix undefined color palette crash`, `refactor(ui): align Analysis tab with the rest of the layer edit dialog`. Don't squash this history — the granularity is itself evidence the algorithm was iterated on and debugged for real, which is the whole point of this fidelity strategy. + +## PR body notes + +Explicitly frame the PR as a proof-of-concept for a first slice of a larger piece of work, and **name the follow-ups that are deliberately out of scope** (precedent: heatmap/DBSCAN clustering, multidimensional thematic maps named as future work, not built here). This is what lets reviewers calibrate how much rigor to expect from _this_ PR specifically. diff --git a/.claude/skills/mockup-pr/references/architecture-fidelity.md b/.claude/skills/mockup-pr/references/architecture-fidelity.md new file mode 100644 index 0000000000..831706c62a --- /dev/null +++ b/.claude/skills/mockup-pr/references/architecture-fidelity.md @@ -0,0 +1,33 @@ +# Architecture/integration feasibility fidelity + +De-risking: _can a new subsystem integrate with the app's real state/actions, and is the design provider-swappable/extensible the way we're claiming?_ +Reference precedent: PR #3673, "mockup: add AI map assistant" (branch `feat/ai-agent-poc`). + +## The real/fake split — the core technique + +This strategy is a deliberate split between: + +- **The "hands" — 100% real.** Whatever dispatches into the existing app must dispatch the _actual_ actions/reducers a normal user interaction would trigger. Precedent: `src/ai/tools/*.js` (e.g. `addThematicLayer.js`, `updateLayer.js`, `removeLayer.js`) call real Redux action creators — an AI-built layer is an ordinary layer the user can then edit or delete by hand, not a parallel shadow object. +- **The "brain" — explicitly faked, and say so in a comment.** Whatever part is expensive, slow, non-deterministic, or otherwise unsuitable for a live demo gets a deterministic stand-in with a code comment stating exactly what it's replacing and why. Precedent, verbatim from `src/ai/connectors/demo.js`: + + > Deterministic demo connector — implements LLMConnector without calling any model. Matches user input against DEMO_SCRIPTS and returns scripted tool calls one at a time. The real resolver tools and executor run against the live DHIS2 instance; only the "LLM reasoning" step is replaced by this fixed plan. + + Don't skip the comment — it's what makes the fake honest to the next person reading the code, and it's what lets a stakeholder demo run without depending on API keys, cost, or model non-determinism. + +- If the point being proven is "this design is swappable across providers/backends," build **more than one real implementation** of the swappable part to prove it, alongside the one demo-safe fake. Precedent: `src/ai/connectors/anthropic.js` and `openaiCompatible.js` are both real, working API-call implementations sitting next to `demo.js`. + +## Feature-flag the entry point + +New UI lives behind an `isEnabled()`-style check, not a config-file flag — precedent: `src/ai/ui/AssistantPanel.jsx` exports `isEnabled = () => window.__DHIS2_AI_ASSISTANT__ === true || localStorage.getItem(FEATURE_FLAG) === 'true' || process.env.NODE_ENV === 'development'`. This keeps the mockup invisible in production builds while still trivially reachable for a stakeholder demo (dev mode, or a `localStorage` toggle to flip on in a deployed preview). + +## Design principles before "what" + +If the mockup is validating an architectural stance (e.g. data minimization, vendor-swappability, security boundary), lead the PR body with that architecture prose _before_ the feature description — precedent: #3673's body states its two governing principles (customer controls what leaves their server; no vendor lock-in) before describing what was actually built. Stakeholders reviewing an architecture mockup are evaluating the stance, not just the demo. + +## Commit shape + +Can be coarse — precedent: #3673 has only two commits, both plain `feat: ...`, no special mockup prefix. The granularity signal that matters for the algorithmic strategy doesn't apply here; what matters is the real/fake split being clean and well-commented. + +## No ticket required + +This strategy is a legitimate way to validate a **not-yet-ticketed** direction — precedent: #3673 references no Jira ticket anywhere. Don't insist on one. diff --git a/.claude/skills/mockup-pr/references/ui-ux-fidelity.md b/.claude/skills/mockup-pr/references/ui-ux-fidelity.md new file mode 100644 index 0000000000..0a2f936e0b --- /dev/null +++ b/.claude/skills/mockup-pr/references/ui-ux-fidelity.md @@ -0,0 +1,30 @@ +# UI/UX & information-architecture fidelity + +De-risking: _will this layout/workflow/navigation make sense to users?_ +Reference precedent: PR #3655, "mockup: sources catalogue" (branch `mockup/DHIS2-21453`). + +## What to fake + +- **All data.** Write a hand-authored `mock<Thing>.js` module (precedent: `src/components/layerSources/mockCatalogueSources.js`, 753 lines of realistic-looking static data) that the component imports directly. +- **All state that would normally be Redux.** Use local `useState` inside the component (precedent: `ManageLayerSourcesModal.jsx` holds catalogue/search/filter state locally) — do not add reducers, action types, or thunks. There is no real data plumbing in this strategy, by design. +- Persistence, favorites, "saved" state, etc. — a second mock module playing the role of a store is fine (precedent: `mockFavoritesStore.js`, 27 lines). + +## What stays real + +- `@dhis2/ui` components and this repo's real `core/` field-wrapper components for every widget — a UI/UX mockup is specifically about how _these_ components compose, so faking them defeats the purpose. +- Colocated `ComponentName.module.css` per component, same as production code. +- `i18n.t()` for every string — stakeholders and translators alike should see the real strings. +- Correct file placement under `src/components/<feature>/`. + +## Expect, and don't chase + +- A high SonarQube issue count (precedent: 88 issues incl. 13 "bugs" on #3655) — consistent with fast, disposable code, and fine here. +- No Jest coverage of the mock data path is expected or needed. + +## Commit shape + +Can legitimately be a single squashed commit (precedent: #3655 shipped as one commit, `mockup: add layer, layer sources, add layer source`) — this strategy doesn't need the granular history the algorithmic strategy does, since there's no real logic under test to bisect later. + +## PR body notes + +Screenshots are the natural fit for this strategy (there's a UI to show), but are not mandatory — #3655 shipped with none, leaning entirely on the live Netlify preview link instead. Either is fine. diff --git a/.claude/skills/pr-chain/SKILL.md b/.claude/skills/pr-chain/SKILL.md new file mode 100644 index 0000000000..c81428e655 --- /dev/null +++ b/.claude/skills/pr-chain/SKILL.md @@ -0,0 +1,217 @@ +--- +name: pr-chain +description: Use when planning, creating, or maintaining a stacked chain of dependent PRs (PR1 -> PR2 -> ... -> PRn, each branched from the previous) for one large epic. Covers slicing a plan into a 5-10 PR chain, propagating review-feedback changes down the still-open stack, and re-anchoring downstream PRs after an earlier one squash-merges to master. Creates real branches/PRs — always requires explicit invocation, never trigger this automatically. +disable-model-invocation: true +--- + +# PR chain + +A "chain" is a stack of dependent branches for one epic: `pr1 -> pr2 -> ... -> prN`, each branched from the previous one's branch (not from `master`), each its own small, independently-reviewable PR. This repo squash-merges everything, which is the one fact that makes chains tricky — see "After a stack member merges" below before touching anything post-merge. + +| Situation | Do this | +| ----------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| You have (or need to produce) a plan with 3-10 discrete steps and want it as a reviewable chain | "Creating the chain" | +| Review feedback changed an earlier PR that **hasn't merged yet** | "Propagating a change down the chain" | +| An earlier PR in the chain **just squash-merged to master** | "After a stack member merges" + `references/squash-merge-sync.md` | +| The chain is pushing past ~10 PRs, or a propagation is turning into a conflict-fest | "When to stop and re-plan" | +| You just want to sanity-check the chain's current shape | "Chain health check" | + +## Before creating anything + +This skill only runs when explicitly invoked — it stages branches and opens PRs, which `CLAUDE.md`'s "don't stage or commit unless explicitly asked" policy puts squarely behind explicit consent. **Every push, `gh pr create`, and `gh pr edit` in this workflow still needs its own fresh, explicit, in-the-moment ask** — per this repo's universal rule, the initial `/pr-chain` invocation authorizes the workflow conceptually, not a standing blanket permission for every subsequent remote write across what could be a long-running, multi-day epic. Confirm before each one. + +Interview the user before creating branches: + +- **Slices**: if there's an existing plan (e.g. from `implement-plan`) with numbered steps, use those as the slice boundaries and confirm the count; otherwise ask them to describe the epic and propose a slicing, then confirm it — don't invent slice boundaries unilaterally. +- **Ticket scheme**: this team typically uses **one shared epic ticket across the whole chain**, with PRs numbered `[PR1]`, `[PR2]`, ... in their titles — confirm that's what's wanted here rather than assuming a sub-ticket per slice. +- **Count check**: if the plan has more than ~10 steps, flag it before creating anything (see "When to stop and re-plan") rather than mechanically making a 14-PR chain. + +## Rollout + +**PR1 opens ready-for-review; PR2..N open as drafts incrementally**, each only once its own branch/commits actually exist — not all upfront. This keeps upfront churn low while still building the dependency graph naturally as work progresses. + +## Naming convention + +No prior real chain exists in this repo's history to copy exactly (a seeded example, `feat/datatable-pr1-toolbar` → `pr2-bidirectional-sync` → ..., confirms the merge-based sync convention but predates a ticket-based naming scheme — and also contains a decoy `feature/...` branch using the wrong, non-existent type prefix; don't copy that). This is a new convention layered onto the existing `<type>/<TICKET-ID>[-short-description][-vN]` pattern: + +``` +<type>/<TICKET-ID>-PRk-<short-description> +``` + +e.g. for a 4-PR chain against `DHIS2-18821`: + +``` +feat/DHIS2-18821-PR1-toolbar +feat/DHIS2-18821-PR2-bidirectional-sync +feat/DHIS2-18821-PR3-filtering +feat/DHIS2-18821-PR4-columns +``` + +Keeping one shared `TICKET-ID` (the epic's) across every branch in the chain makes the whole stack `grep`-able and sortable together: `git branch -a | grep DHIS2-18821-PR`. + +PR titles get the bracketed position marker matching the user's real convention (confirmed by real `[PR7] [DHIS2-18242]`-style tags already seen in this team's PRs): `[PRk] <type>: <description> [<TICKET-ID>]`. + +## Creating the chain + +Build branches strictly in order, each one rooted on the previous, pushing and opening each PR before starting the next slice (so cross-links can reference real PR numbers) — confirming with the user before each push/`gh pr create`: + +```bash +git fetch origin master + +git checkout -b feat/DHIS2-18821-PR1-toolbar origin/master +# ... implement slice 1, commit ... +# ask before pushing: +git push -u origin feat/DHIS2-18821-PR1-toolbar +# ask before opening the PR: +gh pr create --base master --head feat/DHIS2-18821-PR1-toolbar \ + --title "[PR1] feat: add data table toolbar [DHIS2-18821]" \ + --body "$(cat <<'EOF' +Implements [DHIS2-18821](https://dhis2.atlassian.net/browse/DHIS2-18821) + +### Chain + +Part 1 of 4: **#<PR1> (this PR)** -> #<PR2 once opened> + +- First in the chain — no dependency. +- Merge order matters: this must land before the rest of the chain. + +### Description +... +EOF +)" +# capture the PR number gh just printed, e.g. PR1=1234 + +git checkout -b feat/DHIS2-18821-PR2-bidirectional-sync feat/DHIS2-18821-PR1-toolbar +# ... implement slice 2, commit ... +# ask before pushing: +git push -u origin feat/DHIS2-18821-PR2-bidirectional-sync +# ask before opening the draft PR: +gh pr create --draft --base feat/DHIS2-18821-PR1-toolbar \ + --head feat/DHIS2-18821-PR2-bidirectional-sync \ + --title "[PR2] feat: add bidirectional map/table selection sync [DHIS2-18821]" \ + --body "... Chain: Part 2 of 4: #<PR1> -> **#<PR2> (this PR)** -> #<PR3 once opened> ..." +# capture PR2's number, then ask before backfilling PR1's "Followed by": +gh pr edit <PR1> --body "<PR1's body with the Chain section's 'Followed by' line filled in as #<PR2>>" +``` + +Repeat for slices 3..N, each branch based on the previous, each new PR's body naming the ones before and after it, and each time backfilling the previous PR's "Followed by" line once the new PR's number exists. The tail PR (`PRN`) has no "Followed by" line. + +Base branches are chained on purpose (`gh pr create --base <previous-branch>`, not `--base master`) — that's what makes each PR's diff show only that slice's own changes instead of the whole epic. Only PR1 targets `master`. + +## PR body: chain cross-links + +Add a `### Chain` section right after the `Implements [...]` line, before `### Description`: + +```markdown +Implements [DHIS2-18821](https://dhis2.atlassian.net/browse/DHIS2-18821) + +### Chain + +Part 2 of 4 in the DHIS2-18821 chain: #1234 -> **#1235 (this PR)** -> #1236 -> #1237 + +- Depends on: #1234 — merge that first. +- Base branch: `feat/DHIS2-18821-PR1-toolbar` (retargets to `master` once #1234 merges). +- Followed by: #1236. + +### Description + +... +``` + +**Gotcha:** `.github/workflows/check-tasklist.yml` (`Shopify/task-list-checker`) fails the PR on _any_ unchecked `- [ ]` box anywhere in the body, not just the Quality checklist section. Write the Chain section with plain `-` bullets, never `- [ ]` — an "unmerged dependency" checkbox would permanently block CI until PR1 merges and someone remembers to tick it. + +Since there's no stacked-PR tooling installed (checked: no Graphite config, no `git-branchless`, no `gh` stacking extension, no relevant git aliases in this repo), merge-order is enforced purely by this text plus reviewer discipline — GitHub itself won't block someone from merging PR2 out of order. + +## Propagating a change down the chain (all still open) + +Team convention for keeping any branch in sync is **merge, never rebase** (this repo's history is full of `Merge branch 'master' into <feature-branch>` commits, no rebases — see `branch-update`) — apply the same rule between stack members while nothing has merged to master yet: + +```bash +# PR1's branch got new commits (e.g. addressing review feedback) +git checkout feat/DHIS2-18821-PR2-bidirectional-sync +git fetch origin feat/DHIS2-18821-PR1-toolbar +git merge origin/feat/DHIS2-18821-PR1-toolbar +# resolve conflicts if any, commit the merge +# ask before pushing: +git push origin feat/DHIS2-18821-PR2-bidirectional-sync +``` + +Then cascade the same merge down the rest of the tail, one generation at a time, in order (pr2 into pr3, the _updated_ pr3 into pr4, ...) — don't skip a link. If several PRs changed around the same time, do one top-to-bottom pass rather than N separate passes. + +If a merge conflicts, resolve it in place and commit — don't rebase to dodge the conflict. Rebasing here would both break convention and complicate the eventual squash-merge fix (below), since that fix already has to reason carefully about which commits are "real." + +## After a stack member merges + +The moment PR1 squash-merges, PR2's branch has a problem: it still contains PR1's original, unsquashed commits, but `master` now has a single new squash commit instead. PR2's diff against `master` will suddenly show PR1's _entire_ changeset again on top of its own — a huge, wrong, likely-conflicting "phantom diff." This is the classic stacked-PR-plus-squash-merge failure mode. + +**Fix it immediately after the merge, before accepting further review feedback on PR2 if you can** — the phantom diff is confusing to any reviewer who opens PR2 in the meantime, and delaying lets the problem compound if PR3+ get merged down from PR2 before PR2 is fixed. + +The fix is a `git rebase --onto`, re-anchoring PR2 directly onto the new `master` and dropping everything that came from PR1 (git's default rebase already drops merge commits and replays only PR2's own real commits — this is a genuine, narrow exception to "this team doesn't rebase": once a stack member has actually merged, there is no merge-based way to make the downstream branch's diff correct again). Full mechanics, the cascading effect on PR3..PRN, and a real limitation to watch for are in `references/squash-merge-sync.md` — read that before doing this the first time. Condensed version: + +```bash +# 1. Get PR1's exact final head commit (works even if its branch was deleted post-merge — +# GitHub keeps this ref forever). The read-only GH_TOKEN can do this step (a read). +git fetch origin refs/pull/<PR1_NUMBER>/head +OLD_PR1_TIP=$(git rev-parse FETCH_HEAD) + +# 2. Re-anchor PR2 directly onto the new master +git fetch origin master +git checkout feat/DHIS2-18821-PR2-bidirectional-sync +OLD_PR2_TIP=$(git rev-parse HEAD) # keep this — PR3 needs it next +git rebase --onto origin/master "$OLD_PR1_TIP" feat/DHIS2-18821-PR2-bidirectional-sync +# resolve any conflicts, then verify: lint/test/build before pushing — see reference doc for why +# ask before pushing (force-with-lease, since history was rewritten): +git push --force-with-lease origin feat/DHIS2-18821-PR2-bidirectional-sync +# ask before retargeting the base: +gh pr edit <PR2_NUMBER> --base master + +# 3. Cascade the same treatment down the rest of the tail (PR3 onto PR2's new tip, etc.) +git checkout feat/DHIS2-18821-PR3-filtering +git rebase --onto feat/DHIS2-18821-PR2-bidirectional-sync "$OLD_PR2_TIP" feat/DHIS2-18821-PR3-filtering +# ask before pushing: +git push --force-with-lease origin feat/DHIS2-18821-PR3-filtering +# PR3's base stays pr2's branch — only the just-merged PR's immediate child retargets to master +``` + +After the cascade, the chain is back to "merge, not rebase" for any further changes among the remaining open PRs — this rebase pass is a one-time re-anchoring triggered specifically by a squash-merge, not a permanent change of technique. + +**Known limitation** (full detail in the reference doc): plain rebase drops merge commits entirely, so if a past merge commit contained a real hand-resolution (not just mechanical merging), that fix silently vanishes and won't reappear as a conflict — mitigate by running the full suite after every rebase in the cascade, treating any failure as "re-add this as a new small commit," not as a sign the technique failed. + +## Merge/review order discipline + +- Merge strictly top-down: PR1, then PR2, then PR3, ... Never merge a downstream PR first — its diff still contains every upstream PR's unsquashed commits, so merging it "early" would dump the whole epic onto `master` out of order and out of squash-granularity. +- State this in every PR body's `### Chain` section (see above) — that's the only enforcement available; there's no installed tooling that blocks out-of-order merges. +- After each merge, immediately run the "After a stack member merges" fix on the new head of the chain before anything else touches it. + +## When to stop and re-plan + +Don't mechanically keep propagating if: + +- The chain is at or approaching ~10 PRs (the user's own stated ceiling) — ship what's ready, and treat the remainder as a fresh, shorter chain rooted on the new `master` rather than growing the current one further. +- A single propagation touches most of the remaining tail with real conflicts (not clean merges) — that usually means a slice boundary was wrong (shared/foundational work leaked into a later PR instead of an earlier one). Pause and discuss re-slicing rather than resolving conflict after conflict. +- Only 1-2 PRs remain and they're small — consider whether it's still worth maintaining stack machinery versus just merging them into one PR. +- Rule of thumb: if fixing one thing requires touching more than half the remaining stack with conflicts, stop and raise it with the user instead of continuing to merge-and-resolve. + +## Chain health check + +```bash +# Find every branch in a chain by its shared ticket ID +git branch -a | grep "DHIS2-18821-PR" + +# Confirm branch k is still directly descended from branch k-1 +git merge-base --is-ancestor origin/feat/DHIS2-18821-PR1-toolbar origin/feat/DHIS2-18821-PR2-bidirectional-sync && echo ok + +# See what branch k adds on top of branch k-1 (should look like "just that slice", not the whole epic) +git log --oneline origin/feat/DHIS2-18821-PR1-toolbar..origin/feat/DHIS2-18821-PR2-bidirectional-sync + +# Confirm GitHub's recorded base matches the chain (read-only GH_TOKEN is enough for this) +gh pr view <PR2_NUMBER> --json baseRefName,number,title +``` + +If a `..` diff between adjacent branches looks like the _whole epic_ rather than one slice, that branch is out of sync with a squash-merge upstream of it — go to "After a stack member merges". + +## Related + +- `implement-plan` — a natural source of the numbered plan steps that become this chain's slices. +- `manual-test-scenarios` — organizing "Manual testing" sections across a whole chain, since each PR gets its own Netlify preview. +- `branch-update` — the simpler, single-branch version of "merge master in" this skill's own step reuses. diff --git a/.claude/skills/pr-chain/references/squash-merge-sync.md b/.claude/skills/pr-chain/references/squash-merge-sync.md new file mode 100644 index 0000000000..d66839ccdb --- /dev/null +++ b/.claude/skills/pr-chain/references/squash-merge-sync.md @@ -0,0 +1,86 @@ +# Squash-merge sync: why PR2 breaks when PR1 merges, and how to fix it + +## The failure mode, concretely + +Say `feat/DHIS2-18821-PR1-toolbar` has commits A, B, C. `feat/DHIS2-18821-PR2-bidirectional-sync` was branched from it and has its own commits D, E, plus a couple of `Merge branch 'pr1...' into pr2...` merge commits picked up while PR1 was still being revised. Its ancestry is: + +``` +master(old) - A - B - C - [merge] - D - [merge] - E <- pr2 branch tip +``` + +PR1 merges to `master` via squash. `master` now looks like: + +``` +master(old) - S <- S = squash of A+B+C, one commit +``` + +PR2's diff is computed as `git diff master...pr2-branch` (merge-base to tip). The merge-base of `master`(new) and `pr2-branch` is still `master(old)` — S is a _sibling_ of A/B/C, not a descendant of them, so `master`(new) doesn't dominate A/B/C at all. The diff therefore includes A, B, C's changes a second time (once as the squash commit's shadow, once as pr2's own inherited history) stacked on top of PR2's real D/E changes. Result: PR2 in the GitHub UI suddenly shows the entire epic's diff, usually with conflicts, even though nothing about PR2's actual intent changed. + +## Why `git rebase --onto` is the right tool here (and why it's an exception) + +This team's rule for keeping branches in sync is merge, not rebase (confirmed: this repo's history is full of `Merge branch 'master' into <feature>` commits and has no rebases). That rule works precisely because merging never changes commit identity — A/B/C keep their SHAs everywhere they're merged in, so nothing downstream ever gets confused about what's "already there." + +Squash-merging breaks that invariant on GitHub's side, not this team's: `master` gets a _new_ commit (S) with a _new_ SHA that has no ancestry relationship to A/B/C at all. There is no merge you can perform that fixes this — merging `master`(new) into `pr2-branch` would just add S alongside A/B/C, worsening the duplication rather than resolving it. The only way to make PR2's diff correct again is to give PR2 a history where A/B/C literally aren't there anymore, replaced by "master already has S." That requires rewriting pr2-branch's history — a rebase. This is a deliberate, narrow exception: rebase only at the moment a stack member has _merged_, and only to re-anchor the immediate child, never as a general substitute for the merge convention above. + +## The fix, step by step + +### 1. Get PR1's exact final commit — before its branch ref disappears + +Repos with "delete head branch on merge" enabled remove `origin/feat/DHIS2-18821-PR1-toolbar` right after merge. GitHub keeps a permanent ref for every PR regardless: `refs/pull/<PR1_NUMBER>/head`, resolvable forever, readable through the project's normal read-only `GH_TOKEN` (this is a read, not a write): + +```bash +git fetch origin refs/pull/<PR1_NUMBER>/head +OLD_PR1_TIP=$(git rev-parse FETCH_HEAD) +``` + +Equivalent alternative (also read-only-token-safe): `gh pr view <PR1_NUMBER> --json headRefOid -q .headRefOid`. + +Do this as early as possible after seeing the merge notification — don't rely on the local `feat/DHIS2-18821-PR1-toolbar` branch still being the right pointer; if PR1 got any late-breaking commits right before merge that never made it to your local checkout, the ref-based fetch is the authoritative source. + +### 2. Re-anchor PR2 onto the new master + +```bash +git fetch origin master +git checkout feat/DHIS2-18821-PR2-bidirectional-sync +OLD_PR2_TIP=$(git rev-parse HEAD) # save this before rewriting — PR3 needs it +git rebase --onto origin/master "$OLD_PR1_TIP" feat/DHIS2-18821-PR2-bidirectional-sync +``` + +What this actually does: plain `git rebase` (no `--rebase-merges`/`-p`) walks the range `$OLD_PR1_TIP..pr2-branch`, **drops every merge commit in that range entirely**, and replays only the ordinary (non-merge) commits — i.e. exactly D and E, PR2's own real work — on top of `origin/master`. Since `$OLD_PR1_TIP` is A/B/C's own tip, A/B/C themselves are excluded from the range in the first place (they're ancestors of the boundary, not part of it). No manual commit-picking is needed; this is git already doing "PR2's own commits only," automatically and correctly. + +Verify before pushing — run the full lint/test/build, not just a conflict-free `rebase --continue` (see "Known limitation" below), then, after an explicit, in-the-moment ask (this repo's universal remote-write rule — the rebase rewrote history, so this push needs `--force-with-lease`, which makes the ask especially important): + +```bash +git push --force-with-lease origin feat/DHIS2-18821-PR2-bidirectional-sync +gh pr edit <PR2_NUMBER> --base master +``` + +If GitHub already auto-retargeted PR2's base to `master` because it deleted `pr1`'s branch, `gh pr edit --base master` is a no-op — safe to run either way. Retargeting the base alone does **not** fix the diff; the rebase is what fixes the diff, retargeting the base is just bookkeeping so GitHub compares against the right side going forward. + +### 3. Cascade down the rest of the tail + +PR3 was based on PR2, but PR2's commits just got new SHAs. A merge of the rebased PR2 into PR3 would not resolve the duplication — it needs the same `--onto` treatment, using PR2's _pre-rebase_ tip (saved as `$OLD_PR2_TIP` above) as the boundary. As before, verify with the full suite before pushing, and get an explicit, in-the-moment ask before the push itself — this repo's universal remote-write rule applies to every push in this cascade, not just the first one: + +```bash +git checkout feat/DHIS2-18821-PR3-filtering +git rebase --onto feat/DHIS2-18821-PR2-bidirectional-sync "$OLD_PR2_TIP" feat/DHIS2-18821-PR3-filtering +git push --force-with-lease origin feat/DHIS2-18821-PR3-filtering +``` + +PR3's _base_ branch setting doesn't change here — it's still `feat/DHIS2-18821-PR2-bidirectional-sync`, just now pointing at that branch's new tip. Repeat the same pattern for PR4..PRN, always using the previous branch's pre-rebase tip as the boundary and its post-rebase tip as the new base. Do the whole cascade top-to-bottom in one sitting — a half-migrated stack (PR2 fixed, PR3 not) is worse than not starting, since PR3's diff is now broken in a _new_ way relative to the just-rewritten PR2. + +Once the cascade reaches the tail, resume plain merge-based propagation for any further changes among the remaining open PRs (see the main `SKILL.md`) — this rebase pass is one-time, triggered by the merge event, not an ongoing change of technique. + +## Known limitation: merge-commit-only resolutions get silently dropped + +Because plain rebase drops merge commits from the replay list entirely, if a past `Merge branch 'pr1' into pr2` was more than a mechanical merge — e.g., it hand-resolved a real conflict by updating a call site for a function PR1 renamed — that resolution lives _only_ in the merge commit's own diff, not in any of D/E. Dropping the merge commit drops that fix too, and because the surrounding text often still applies cleanly against the new base, this usually does **not** show up as a rebase conflict — it just silently reverts, and you find out later from a broken build or a failing test. + +Mitigation: after every rebase in this cascade, before pushing, run the full build/lint/test suite — don't rely on "the rebase completed without conflicts" as a correctness signal. Treat any resulting failure as "this logic needs to be re-added as a new small commit on top," not as evidence the technique is wrong. + +## Why not the alternatives + +| Technique | Verdict | +| ------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `git rebase --onto <new-master> <old-PR1-tip> <branch>` (chosen) | Automatically replays exactly the downstream branch's own non-merge commits onto the new master. Same result as manual cherry-picking, with git doing the sequencing. | +| Manually recreate the branch from `master` and cherry-pick each of PR2's own commits | Produces an identical result to `rebase --onto` (same commit set, same order), but requires hand-enumerating `git log --no-merges <old-PR1-tip>..pr2-branch` and cherry-picking one at a time — more manual bookkeeping for no behavioral difference. Worth falling back to only if you also want to interactively squash/reorder PR2's own fixup commits (e.g. folding "chore: sonarqube issues" into the commit it's fixing) while you're already rewriting history anyway. | +| `git rebase --rebase-merges --onto ...` | Reconstructs the merge topology instead of flattening it — which means it _replays_ the old `Merge branch master into pr1`/`Merge branch pr1 into pr2` merges too, reintroducing exactly the duplicate-content conflict against the new master that this whole exercise exists to avoid. Does not fix the problem. | diff --git a/.claude/skills/pr-polish/SKILL.md b/.claude/skills/pr-polish/SKILL.md new file mode 100644 index 0000000000..ce2f9197fa --- /dev/null +++ b/.claude/skills/pr-polish/SKILL.md @@ -0,0 +1,66 @@ +--- +name: pr-polish +description: Use when iterating on an already-open PR across multiple rounds of manual testing and feedback — organizing fix lists, re-verifying each fix narrowly, and deciding when to hand off to the pre-review skill for the final self-check. Heavier and more interactive than pre-review, which is only the last quick pass. +--- + +# PR polish + +The umbrella workflow for an open PR (yours, or handed off from another Claude session) that isn't done after one pass — the user manually tests, reports back a list of things to fix, Claude fixes them, and this repeats until the user is satisfied. `pre-review` is the last, quick step of this loop, run once per "I think we're done" moment — not a replacement for it. + +| Situation | Do this | +| -------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| Picking this PR up cold (new session, or another agent's work) | Orient first — step 1 | +| User just handed you a list of desired fixes | Turn it into a todo list — step 2 | +| A fix from the todo list is implemented | Verify narrowly, not the whole suite — step 3 | +| Todo list is fully checked off | Report back and wait for the next round of manual testing — don't assume you're done | +| User says something like "I think this is it" / "ship it" | Run the `pre-review` skill — step 4 | +| `pre-review`'s self-review surfaces a new issue | Loop back to step 2 — see "Repeated final passes" | + +## 1. Orient + +Before touching anything, confirm what state the PR is actually in — don't assume your session's memory matches reality, especially if earlier rounds happened without you: + +``` +gh pr view --json number,title,body,state +gh pr diff +``` + +Skim the diff and the PR body's ToDos/Known issues sections; don't re-litigate decisions already reflected there. + +## 2. Turn feedback into a todo list + +When the user gives you a batch of fixes (from manual testing, a review comment, a screenshot, whatever), convert each distinct item into a `TodoWrite` entry before starting on any of them — this keeps the list visible and resumable across a long session, the same pattern the `sonarqube-fix` command uses for its issue list. Word each item as a concrete, checkable outcome ("org unit dialog closes on Escape"), not a vague restatement ("fix the dialog"). + +If an item is ambiguous, ask rather than guessing — this workflow exists because manual testing catches things automated tests don't, so precision matters more than speed here. + +## 3. Fix, then verify narrowly + +For each todo: + +1. Implement the fix. +2. Verify just that fix — the touched file(s), per `CLAUDE.md`'s "Lint/test workflow for agents" (`npx jest <file>`, `yarn d2-style check <file>`). Don't run the full `yarn lint && yarn test` after every single item; that's step 4's job. +3. Mark the todo complete and tell the user _specifically_ what changed and what to re-check — manual testing only catches regressions if the user knows where to look. + +Run the full suite after a batch (roughly 3-5 related fixes, or whenever the todo list empties) — same cadence as `sonarqube-fix`. + +Don't commit as you go unless the user asks — `CLAUDE.md`'s "don't stage or commit unless explicitly asked" applies throughout this loop, not just at the very end. + +### Keeping the PR body honest (optional, ask first) + +If a Quality checklist item becomes true (tests added, dashboard tested, etc.), propose the updated checklist text to the user. Never leave a box unchecked that's actually done, and never leave `- [ ]` on something that turned out not to apply — replace it with `_N/A_` per the template's own instruction. `check-tasklist.yml` blocks the PR while _any_ unchecked box exists anywhere in the body, so an accurate checklist matters more here than in most repos. + +Editing the PR body is a remote write (`gh pr edit`) — per this repo's universal rule, propose the text and get an explicit, in-the-moment ask before applying it, every time. Never apply it just because the checklist item is objectively true now. + +## 4. Know when it's actually done + +Finishing the current todo list means "ready for another round of manual testing," not "ready for review." Only move to the final pass when the user explicitly confirms the current round passed clean — then invoke `pre-review`. + +### Repeated final passes + +`pre-review`'s self-review step can itself surface a new issue. When it does: add it as a new todo (step 2), fix and verify it (step 3), then re-run `pre-review` from the top — a changed diff needs a fresh lint/test/self-review pass, not a partial re-check of just the new lines. If you're on a third or later `pre-review` pass for the same PR, say so explicitly — that's often a sign of a design question worth discussing rather than another quick fix. + +## See also + +- `pre-review` — the final quick self-check this workflow hands off to once the user confirms a round of testing passed. +- `sonarqube-fix` — the same todo-list-per-batch pattern, applied to SonarCloud findings instead of user-reported fixes. +- `commit-and-pr-messages` — format rules for the PR title/description this skill helps keep accurate. diff --git a/.claude/skills/pre-review/SKILL.md b/.claude/skills/pre-review/SKILL.md new file mode 100644 index 0000000000..59e4df2c8f --- /dev/null +++ b/.claude/skills/pre-review/SKILL.md @@ -0,0 +1,56 @@ +--- +name: pre-review +description: Use before converting a PR from draft to "ready for review" — the final quick self-check so issues get caught before a human reviewer spends time on them. Verifies tests (Jest and Cypress), SonarQube, code quality/conventions, and the PR title/description, in that order. +--- + +# Pre-review + +Still iterating on feedback from manual testing? Use `pr-polish` instead — come back here once a round of testing passes clean; this is the last, quick pass, not the place to work through a fix list. + +Before marking a PR ready for review and requesting a human review, verify all four in order: + +## 1. Zero failing tests + +``` +yarn lint && yarn test +``` + +Plus any relevant `yarn cy:run <spec>` if the change touches map rendering or other Cypress-covered UI. Don't proceed past this step with anything red. + +## 2. Zero remaining SonarQube issues + +Query the same anonymous SonarCloud endpoint `/sonarqube-fix` uses: + +``` +curl -s "https://sonarcloud.io/api/issues/search?projectKeys=dhis2_maps-app&pullRequest=<pr-number>&resolved=false&ps=100" +``` + +If anything's still open, fix it now in the same BLOCKER→INFO order `/sonarqube-fix` uses, or explicitly hand off to that command if the list is long. Don't mark ready with open issues on this PR. + +## 3. Code quality + +Self-review the diff — either invoke the `/code-review` skill, or fetch it directly (`gh pr diff`) and read it critically. Specifically check for, not just "does it look reasonable": + +- **DRY** — logic duplicated across files/components that should share a helper. +- **Convention adherence** — matches this repo's real patterns (`CLAUDE.md`, and the `map-layer-architecture` skill for anything layer-related), not a plausible-but-different approach. +- Correctness bugs and genuine simplification opportunities — not style nits. + +## 4. PR title/description + +Complete, succinct, "what"-focused (not overlong on "why"/"how"), and matches `.github/pull_request_template.md`'s structure exactly — see the `commit-and-pr-messages` skill for the format rules rather than re-deriving them here. Every `- [ ]` in the body must be genuinely expected to complete or replaced with `_N/A_` — `check-tasklist.yml` blocks the PR on any unchecked box left anywhere in it, not just the Quality checklist section. + +## 5. Check CI, then mark ready + +``` +gh pr checks +``` + +Don't mark ready while checks are still running or failing. Marking ready (`gh pr ready`) is a remote write — per this repo's universal rule, this always needs a fresh, explicit, in-the-moment ask before running it, regardless of how this skill was invoked; never treat "the user asked for a pre-review" as blanket permission to also mark the PR ready without asking separately. + +If `gh` isn't set up (no `GH_TOKEN` configured yet), use `curl` against the public GitHub REST API instead: + +- Diff: `curl -s -H "Accept: application/vnd.github.v3.diff" "https://api.github.com/repos/{owner}/{repo}/pulls/{n}"` +- Checks: `curl -s "https://api.github.com/repos/{owner}/{repo}/commits/{sha}/check-runs"` +- PR number for the current branch: `curl -s "https://api.github.com/repos/{owner}/{repo}/pulls?head={owner}:{branch}&state=open"` + +`{owner}/{repo}` comes from `git remote get-url origin`; `{branch}` from `git rev-parse --abbrev-ref HEAD`. diff --git a/.claude/skills/spec-from-ticket/SKILL.md b/.claude/skills/spec-from-ticket/SKILL.md new file mode 100644 index 0000000000..a6bf9dea02 --- /dev/null +++ b/.claude/skills/spec-from-ticket/SKILL.md @@ -0,0 +1,42 @@ +--- +name: spec-from-ticket +description: Use when the user pastes Jira ticket content (title/description/acceptance criteria) and asks for a durable spec or implementation-plan document — phrases like "spec this out", "write a spec for this ticket", "turn this into a plan doc", "I want to hand this to another session/agent". Interviews on genuinely ambiguous points, explores the codebase via a subagent first, then authors a self-contained document to .claude/specs/. Don't use for a same-session "just implement it" request, or a quick approve-and-go plan — that's Plan Mode. +--- + +# spec-from-ticket + +Turns a pasted Jira ticket into a **durable, standalone spec + implementation plan** — meant to be picked up cold, later, by a human or a different AI session, with no memory of this conversation. + +| Situation | Do this | +| -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| User pasted ticket content and wants a spec/plan doc/handoff artifact | Run the flow below | +| User pasted ticket content and just wants it implemented now, this session | Skip this skill — implement directly, or use Plan Mode if you want sign-off before executing | +| A `.claude/specs/<TICKET-ID>-*.md` already exists for this ticket | Read it first, update it via the `spec-writer` subagent — don't create a duplicate | +| A spec produced by this skill is ready to build | Hand it to the `implement-plan` skill — don't re-interview | + +No Jira/Atlassian connector is authorized here. Work only from what the user pastes into the conversation — never guess at fields the ticket didn't mention. + +## 1. Capture the ticket + +Note the ticket ID in the real bracket format used elsewhere in this repo's commits/branches: `PROJECTKEY-NUMBER` (e.g. `DHIS2-20564`, `CLIM-501`). If the user didn't paste one, ask for it once; if there genuinely isn't one, proceed ticket-less. + +## 2. Explore before you interview + +Dispatch a plain Explore subagent with the pasted ticket content and ask it to report back: concrete files/components/actions/reducers likely touched, existing patterns to follow (check the `map-layer-architecture` skill if a layer is involved), and anything it _couldn't_ resolve from code alone. Do this in a subagent so the broad reading doesn't burn the main session's context — you only need the findings, not the search process. + +Use those unresolved points to sharpen step 3 — don't ask the user something the exploration already answered. + +## 3. Interview — only what changes the plan + +See `references/interview-and-scoping.md` for the full guidance on what to ask vs. assume. Short version: ask about real technical forks (2+ reasonable implementations), unstated scope boundaries, and edge cases the AC is silent on. Batch questions via `AskUserQuestion`. Don't ask anything answerable by reading the code, or anything with one obviously-sane default — state the default as an assumption in the spec instead. + +## 4. Write the spec + +Dispatch the **`spec-writer`** subagent with the ticket content, the interview answers from step 3, and the Explore findings from step 2. Its job is authoring the actual document to `.claude/specs/<TICKET-ID>-<slug>.md` (`.claude/specs/<slug>.md` if ticket-less) — not `docs/`, which is the published end-user manual, a different audience entirely. Review its draft before presenting it to the user; check the "Implementation plan" section is broken into steps small and file-scoped enough that each could plausibly be one commit — that's what lets `implement-plan` consume the plan mechanically later. + +Re-running this skill against a ticket that already has a spec updates that file in place via the same subagent — never create a `-2`. + +## Handoff + +- **Plan Mode vs this skill**: Plan Mode's plan is ephemeral and session-scoped — "I approve this, execute it now." This skill's output is a durable artifact — designed to be read cold, possibly days later, possibly by a different agent entirely. Use Plan Mode when the user is at the keyboard right now and ready to go immediately; use this skill when the work might not start immediately, or the implementer isn't known yet. +- **Feeding forward**: once written, point the user at the `implement-plan` skill (`/implement-plan .claude/specs/<file>.md`) to execute it, or open a fresh Plan Mode session against the spec file directly. Either way, don't re-run the interview — the spec already captured the decisions. diff --git a/.claude/skills/spec-from-ticket/references/interview-and-scoping.md b/.claude/skills/spec-from-ticket/references/interview-and-scoping.md new file mode 100644 index 0000000000..42c5b035b4 --- /dev/null +++ b/.claude/skills/spec-from-ticket/references/interview-and-scoping.md @@ -0,0 +1,24 @@ +# Interview and scoping guidance + +## Order of operations + +1. Explore first (subagent), interview second. The interview should be _informed_ by what the code actually looks like — "I see two existing dialogs use pattern X vs. pattern Y for this, which do you want?" beats a generic open-ended question. +2. If the interview surfaces a decision that changes which files are affected, do a second, narrower Explore pass before handing off to `spec-writer` — again via subagent, so it doesn't cost the main session's context. +3. Hand off to `spec-writer` last, once decisions are made. + +## What to ask + +- **Real technical forks**: at least two reasonable implementations exist, and the ticket doesn't pick one (new layer type vs. config on an existing layer; Redux thunk vs. local component state). +- **Unstated scope boundaries**: does this need to work in the dashboard plugin (no Redux store — see `map-layer-architecture` skill) as well as the standalone app? One layer type or all of them? +- **Edge cases the AC is silent on**: empty states, permission/authority checks, what happens on API failure. +- **Tradeoffs against repo norms**: e.g. this app is in "live, released app" mode per `CLAUDE.md` — prefer minimal diffs over opportunistic refactors. If a ticket's ask nudges toward a bigger refactor than strictly needed, surface that tradeoff explicitly rather than silently picking the bigger scope. + +## What NOT to ask + +- Anything answerable by reading the code — that's what the Explore pass is for. +- Anything already stated in the ticket description or AC. +- Generic questions with one obviously-sane default — write the default into the spec's "Assumptions" section instead of interrupting the user. Reserve `AskUserQuestion` for things where a wrong guess would be expensive to unwind. + +## Batching + +Use `AskUserQuestion` with multiple questions in one call rather than serial round-trips. Stop interviewing once the remaining unknowns are cheap to fix later (i.e. would just mean editing the spec, not re-architecting). diff --git a/.gitignore b/.gitignore index dc3f7379f7..5accde5e2f 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,7 @@ src/locales .idea .vscode maps-app.code-workspace + +# Claude Code (personal/runtime, not shared config) +.claude/settings.local.json +.claude/worktrees/ diff --git a/.mcp.json b/.mcp.json index 2c522a76aa..da3aff47cf 100644 --- a/.mcp.json +++ b/.mcp.json @@ -8,6 +8,10 @@ "--chromeArg=--enable-unsafe-swiftshader", "--chromeArg=--disable-dev-shm-usage" ] + }, + "grep": { + "type": "http", + "url": "https://mcp.grep.app" } } } diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..6a88f5ca19 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,103 @@ +# maps-app + +DHIS2 Maps app — React 18 (JSX), DHIS2 App Platform (`@dhis2/cli-app-scripts` v12 — Vite-based under the hood despite the `d2-app-scripts` CLI name), Redux + `redux-thunk` for state, `@dhis2/ui`, Jest, Cypress e2e. Core map libs: `@dhis2/maps-gl` (rendering engine), `@dhis2/analytics`, `d3-*`, `@turf/*`. + +## Stage + +Live, released app (v101.16+). Prefer minimal, targeted diffs over opportunistic refactors — e.g. leave class-component map layers as classes, don't convert to hooks incidentally. + +## Generated — never hand-edit + +- `.d2/` — build output. +- `src/locales/` (incl. `src/locales/index.js`) — generated by `d2-i18n-generate`, gitignored. + +## Structure + +``` +src/ + actions/ action creators (plain thunks, e.g. layers.js, orgUnits.js) + components/ map/, classification/, orgunits/, trackedEntity/, app/, ... + constants/ actionTypes.js, colors.js, earthEngineLayers/ + hooks/ + loaders/ + reducers/ + store/ index.js — createStore + redux-thunk + styles/ + util/ + __tests__/ +AppWrapper.jsx standalone app entry +PluginWrapper.jsx dashboard-plugin entry (debounced resize handling) +``` + +## Commands + +`yarn start` / `build` / `test` / `test:coverage` / `lint` / `format` / `cy:open` / `cy:run` / `deploy` (Yarn 1 classic). + +## Conventions + +- Comments: prefer self-documenting code. Comment only non-obvious domain context (e.g. `d2.config.js`'s Vite `optimizeDeps` comment explaining the Earth Engine worker resolution). No historical/time-bound comments. +- Relative imports (no path-alias convention). Default exports and PropTypes are the norm on class components. +- Map layers are **class components** extending `Layer` (`src/components/map/layers/Layer.js`) — follow that pattern for changes there, don't convert to hooks. New non-layer UI: functional components + hooks. +- State: Redux with plain action creators (`src/actions/`), string constants in `src/constants/actionTypes.js`, `connect`/`useSelector`/`useDispatch` from `react-redux`. **Gotcha**: the dashboard-plugin entry point (`PluginWrapper.jsx`) has no `ReduxProvider` — components reachable from there can't rely on the store. See `map-layer-architecture` skill. + +## Testing + +Jest, co-located `__tests__/*.spec.js`. + +## DHIS2 specifics + +- i18n via the generated locale bundle (see Generated, above). +- Cypress e2e reads gitignored `cypress.env.json` for `dhis2BaseUrl`/credentials. +- Researching an unfamiliar DHIS2 Web API endpoint or response shape: see the `dhis2-web-api-research` skill. + +## Plugins / MCP + +`context7`, `chrome-devtools-mcp` plugins enabled. + +**Two chrome-devtools MCP registrations exist — know which to use.** The plugin's default launch has zero configurable Chrome flags (its manifest hardcodes `npx chrome-devtools-mcp@1.6.0`). This app needs specific flags for headless WebGL (`--use-angle=swiftshader-webgl --enable-unsafe-swiftshader --disable-dev-shm-usage`), which live in the project's own `.mcp.json` `chrome-devtools` server. **Use that one (`mcp__chrome-devtools__*` tools) for anything touching the map canvas.** The plugin stays enabled anyway for its bundled skills (a11y-debugging, LCP, memory-leak). + +## Git / PR workflow + +Don't stage or commit unless explicitly asked. Use `gh` (`gh pr view`, `gh pr diff`, `gh pr checks`, `gh api ...`) for reading PR state/diff/CI — a read-only-scoped token is configured (see README), so this is safe. If `gh` isn't set up yet, fall back to plain `curl` against the public GitHub REST API. Before marking a PR ready for review: see the `pre-review` skill. + +**Universal rule — no remote writes by default, for any skill.** Never push to a remote, or open/edit/ready a PR, without a fresh, explicit, in-the-moment ask from the user for that specific action — every time, no exceptions. This is never pre-negotiated or bundled into an earlier approval (not even "I approved this whole workflow up front"), and there's no special write-scoped credential to provision to get around the ask — a skill just stops and asks, then attempts the action through the normal tool-permission flow. Local operations (edit, lint, test, `git commit`, `git merge`) stay governed by "don't stage or commit unless explicitly asked" above, which an explicit workflow invocation can reasonably satisfy — remote writes cannot be satisfied that way, ever. + +## Performance + +Layer updates flow through `Layer.componentDidUpdate`'s prop diffing — prefer cheap in-place calls (`setLayerOpacity`/`setLayerVisibility`/`setLayerOrder`) over a full `updateLayer()` destroy+recreate. + +## Domain knowledge + +The maps-app ↔ `@dhis2/maps-gl` seam is `src/components/map/MapApi.js`: this repo owns data/Redux/config UI, maps-gl owns rendering. For the `Layer` base class, classification/thematic mapping, and Earth Engine details: see the `map-layer-architecture` skill. + +## Skills in this repo + +`.claude/skills/<name>/SKILL.md`, loaded on demand (not always in context). Each opens with a short decision table and pushes long reference material into `references/*.md` — keep new skills in that shape. + +- `dhis2-web-api-research` — researching unfamiliar DHIS2 API shapes. +- `map-layer-architecture` — Layer base class, classification, Earth Engine, dual app/plugin deployment. +- `pre-review` — self-review pass before marking a PR ready. +- `spec-from-ticket` — turns a pasted Jira ticket into a durable spec + implementation plan under `.claude/specs/`. +- `implement-plan` — autonomously executes a written plan through a commit-by-commit cycle. +- `mockup-pr` — builds a throwaway-but-real, never-merged draft PR for stakeholder feedback. +- `pr-polish` — iterating on manual-testing feedback across multiple rounds; hands off to `pre-review` for the final pass. +- `commit-and-pr-messages` — Conventional Commits / PR title / PR description conventions. +- `branch-update` — merge master into a feature branch and resolve conflicts (explicit invocation only). +- `pr-chain` — plans/creates/maintains a stacked chain of dependent PRs for a big epic. +- `manual-test-scenarios` — drafts the "Manual testing" section of a PR body. +- `community-post` — drafts a DHIS2 Community of Practice release-announcement post. +- `docs-update` — keeps `docs/src/*.md`/`docs/maps.md` in sync with a user-facing change. +- `claude-stack-retro` — at a work-session checkpoint, retrospects on that stretch and proposes updates to this repo's own `.claude/` tooling without editing it directly; logs decisions under `.claude/retros/`. + +## Subagents in this repo + +`.claude/agents/<name>.md` — a persona for output whose audience isn't "developer reading code," dispatched by the skill that gathers the relevant facts first. Restricted `tools:` per persona. + +- `community-post-writer` — general-public/non-technical Community of Practice voice; dispatched by `community-post`. +- `test-scenario-writer` — internal QA/tester voice; dispatched by `manual-test-scenarios`. +- `docs-writer` — end-user manual voice for `docs/src/*.md`; dispatched by `docs-update`. +- `spec-writer` — technical/architecture voice for a durable spec; dispatched by `spec-from-ticket` after its interactive interview. + +## Lint/test workflow for agents + +Touched-files-only during development: `npx jest <file>`, then `yarn d2-style check <file>` (wraps eslint+prettier, and stylelint once a local config exists — this repo doesn't have one yet, so CSS only gets prettier-checked for now). Full `yarn lint && yarn test` before finishing. The PostToolUse hook auto-formats/fixes on Edit/Write only — Bash-created edits aren't covered, run `yarn d2-style apply <file>` manually after those. diff --git a/README.md b/README.md index f7ca725864..e2afdc5de8 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,19 @@ To record tests in Cypress Cloud, you can use one of the following methods based This setup helps in managing Cypress Cloud credits more efficiently, ensuring recordings are only made when explicitly required. +## Claude Code Setup (optional) + +One-time setup, for a smoother experience: + +1. Install [`jq`](https://jqlang.org/) — the auto-format/lint hook run after every file edit depends on it. +2. Install the [GitHub CLI](https://cli.github.com/) (`gh`) — most of the PR-related skills (`pre-review`, `pr-polish`, `mockup-pr`, `pr-chain`, `branch-update`, ...) use it to read (and, only on your explicit go-ahead in the moment, write) PR/CI state. Without it, those skills fall back to plain `curl` against the public GitHub REST API, which only covers reads. +3. Export a read-only-scoped `GH_TOKEN` (a [fine-grained PAT](https://github.com/settings/personal-access-tokens/new) with just Pull requests/Contents/Actions: Read) so `gh` can read PRs/CI without ever being able to write, merge, or admin on its own. +4. Run `/plugin install context7@claude-plugins-official` then `/reload-plugins` in Claude Code (chrome-devtools-mcp is already enabled via the committed settings). + +`curl` (used as the read-only fallback whenever `gh` isn't set up, and for the tokenless SonarCloud queries in `pre-review`/`sonarqube-fix`) is virtually always preinstalled — nothing to do there. `git`, `yarn`, and `node`/`npx` are already prerequisites for this repo generally (see above), not something new for the Claude Code tooling specifically. + +`CLAUDE.md` and `.claude/skills/` are read automatically — see CLAUDE.md's "Skills in this repo" and "Subagents in this repo" sections for the current list (kept there, not duplicated here, so it doesn't go stale as the tooling grows). + ## Learn More You can learn more about the platform in the [DHIS2 Application Platform Documentation](https://platform.dhis2.nu/). From 2c72f01322992407a16758dc6f6ea26a40618d1b Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Sun, 19 Jul 2026 15:31:12 +0200 Subject: [PATCH 003/205] chore: create and use replica accounts in CI --- cypress.config.js | 30 ---------------------------- cypress/plugins/e2eReplicaAccount.js | 3 +-- cypress/support/util.js | 3 +-- 3 files changed, 2 insertions(+), 34 deletions(-) diff --git a/cypress.config.js b/cypress.config.js index a54646481d..3f4d51c1a5 100644 --- a/cypress.config.js +++ b/cypress.config.js @@ -29,36 +29,6 @@ async function setupNodeEvents(on, config) { ) } - config.env.useReplicaAccount = !!process.env.CI - - if (config.env.useReplicaAccount) { - try { - const { username, password, replicaUserId } = - await createReplicaAccountForRun({ - baseUrl: config.env.dhis2BaseUrl, - username: config.env.dhis2Username, - password: config.env.dhis2Password, - }) - - config.env.replicaUsername = username - config.env.replicaPassword = password - - on('after:run', () => - deleteReplicaAccount({ - baseUrl: config.env.dhis2BaseUrl, - username: config.env.dhis2Username, - password: config.env.dhis2Password, - replicaUserId, - }) - ) - } catch (error) { - console.warn( - `WARNING: could not create e2e replica account, falling back to the standard account: ${error.message}` - ) - config.env.useReplicaAccount = false - } - } - return config } diff --git a/cypress/plugins/e2eReplicaAccount.js b/cypress/plugins/e2eReplicaAccount.js index 7fa7d2fe7c..5658ee88bb 100644 --- a/cypress/plugins/e2eReplicaAccount.js +++ b/cypress/plugins/e2eReplicaAccount.js @@ -59,8 +59,7 @@ const dhis2Fetch = async ( } const buildReplicaUsername = () => - `e2e_mapsapp_run${ - process.env.GITHUB_RUN_ID ?? 'local' + `e2e_mapsapp_run${process.env.GITHUB_RUN_ID ?? 'local' }_${uniqueId().replaceAll('-', '_')}` const createReplicaUser = async ({ baseUrl, adminId, auth }) => { diff --git a/cypress/support/util.js b/cypress/support/util.js index 23e7255907..1093391d01 100644 --- a/cypress/support/util.js +++ b/cypress/support/util.js @@ -326,8 +326,7 @@ export const assertIntercepts = ({ normalizeErrors(errors).forEach((error) => { // Single intercept cy.log( - `[${n}] Intercepting single: ${alias}${ - error !== undefined ? ` - ${error}` : '' + `[${n}] Intercepting single: ${alias}${error !== undefined ? ` - ${error}` : '' }` ) From 929b31486a1a81a3df0263a2ad92979becafa1f0 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Sun, 19 Jul 2026 18:42:50 +0200 Subject: [PATCH 004/205] chore: remove arbitrary cy.wait --- cypress.config.js | 4 ---- cypress/plugins/e2eReplicaAccount.js | 3 ++- cypress/support/util.js | 3 ++- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/cypress.config.js b/cypress.config.js index 3f4d51c1a5..29c64d12ac 100644 --- a/cypress.config.js +++ b/cypress.config.js @@ -3,10 +3,6 @@ const { defineConfig } = require('cypress') const { downloadedFileTasks, } = require('./cypress/plugins/downloadedFileTasks.js') -const { - createReplicaAccountForRun, - deleteReplicaAccount, -} = require('./cypress/plugins/e2eReplicaAccount.js') const { excludeByVersionTags, } = require('./cypress/plugins/excludeByVersionTags.js') diff --git a/cypress/plugins/e2eReplicaAccount.js b/cypress/plugins/e2eReplicaAccount.js index 5658ee88bb..7fa7d2fe7c 100644 --- a/cypress/plugins/e2eReplicaAccount.js +++ b/cypress/plugins/e2eReplicaAccount.js @@ -59,7 +59,8 @@ const dhis2Fetch = async ( } const buildReplicaUsername = () => - `e2e_mapsapp_run${process.env.GITHUB_RUN_ID ?? 'local' + `e2e_mapsapp_run${ + process.env.GITHUB_RUN_ID ?? 'local' }_${uniqueId().replaceAll('-', '_')}` const createReplicaUser = async ({ baseUrl, adminId, auth }) => { diff --git a/cypress/support/util.js b/cypress/support/util.js index 1093391d01..23e7255907 100644 --- a/cypress/support/util.js +++ b/cypress/support/util.js @@ -326,7 +326,8 @@ export const assertIntercepts = ({ normalizeErrors(errors).forEach((error) => { // Single intercept cy.log( - `[${n}] Intercepting single: ${alias}${error !== undefined ? ` - ${error}` : '' + `[${n}] Intercepting single: ${alias}${ + error !== undefined ? ` - ${error}` : '' }` ) From e5f6846eea2591e3c1ef28fd2199177430857d5e Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Mon, 20 Jul 2026 14:01:12 +0200 Subject: [PATCH 005/205] chore: update e2eReplicaAccount.js --- cypress.config.js | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/cypress.config.js b/cypress.config.js index 29c64d12ac..a54646481d 100644 --- a/cypress.config.js +++ b/cypress.config.js @@ -3,6 +3,10 @@ const { defineConfig } = require('cypress') const { downloadedFileTasks, } = require('./cypress/plugins/downloadedFileTasks.js') +const { + createReplicaAccountForRun, + deleteReplicaAccount, +} = require('./cypress/plugins/e2eReplicaAccount.js') const { excludeByVersionTags, } = require('./cypress/plugins/excludeByVersionTags.js') @@ -25,6 +29,36 @@ async function setupNodeEvents(on, config) { ) } + config.env.useReplicaAccount = !!process.env.CI + + if (config.env.useReplicaAccount) { + try { + const { username, password, replicaUserId } = + await createReplicaAccountForRun({ + baseUrl: config.env.dhis2BaseUrl, + username: config.env.dhis2Username, + password: config.env.dhis2Password, + }) + + config.env.replicaUsername = username + config.env.replicaPassword = password + + on('after:run', () => + deleteReplicaAccount({ + baseUrl: config.env.dhis2BaseUrl, + username: config.env.dhis2Username, + password: config.env.dhis2Password, + replicaUserId, + }) + ) + } catch (error) { + console.warn( + `WARNING: could not create e2e replica account, falling back to the standard account: ${error.message}` + ) + config.env.useReplicaAccount = false + } + } + return config } From 690090c767a917a148ebdba9f517ab6021816905 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 23 Jun 2026 17:42:50 +0200 Subject: [PATCH 006/205] feat: add DATA_FILTERS_CLEAR_ALL action and reducer case Adds clearDataFilters(layerId) action creator and the corresponding DATA_FILTERS_CLEAR_ALL reducer case that resets dataFilters to {} for the target layer. Used by the new "Clear filters" button in the toolbar. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- src/actions/dataFilters.js | 5 +++++ src/constants/actionTypes.js | 1 + src/reducers/map.js | 12 ++++++++++++ 3 files changed, 18 insertions(+) diff --git a/src/actions/dataFilters.js b/src/actions/dataFilters.js index f564eb3e09..d73d4aeb9e 100644 --- a/src/actions/dataFilters.js +++ b/src/actions/dataFilters.js @@ -12,3 +12,8 @@ export const clearDataFilter = (layerId, fieldId) => ({ layerId, fieldId, }) + +export const clearDataFilters = (layerId) => ({ + type: types.DATA_FILTERS_CLEAR_ALL, + layerId, +}) diff --git a/src/constants/actionTypes.js b/src/constants/actionTypes.js index 04bc55e885..6bc917a61e 100644 --- a/src/constants/actionTypes.js +++ b/src/constants/actionTypes.js @@ -44,6 +44,7 @@ export const DATA_TABLE_RESIZE = 'DATA_TABLE_RESIZE' /* DATA FILTER */ export const DATA_FILTER_SET = 'DATA_FILTER_SET' export const DATA_FILTER_CLEAR = 'DATA_FILTER_CLEAR' +export const DATA_FILTERS_CLEAR_ALL = 'DATA_FILTERS_CLEAR_ALL' /* ORGANISATION UNITS */ export const ORGANISATION_UNIT_PROFILE_SET = 'ORGANISATION_UNIT_PROFILE_SET' diff --git a/src/reducers/map.js b/src/reducers/map.js index 7794693f07..9f7828d331 100644 --- a/src/reducers/map.js +++ b/src/reducers/map.js @@ -160,6 +160,17 @@ const layer = (state, action) => { dataFilters: filters, } + // Remove all filters for a layer + case types.DATA_FILTERS_CLEAR_ALL: + if (state.id !== action.layerId) { + return state + } + + return { + ...state, + dataFilters: {}, + } + case types.MAP_ALERTS_CLEAR: return { ...state, @@ -299,6 +310,7 @@ const map = (state = defaultState, action) => { case types.LAYER_TOGGLE_EXPAND: case types.DATA_FILTER_SET: case types.DATA_FILTER_CLEAR: + case types.DATA_FILTERS_CLEAR_ALL: case types.MAP_EARTH_ENGINE_VALUE_SHOW: return { ...state, From f364871ca623e477d99d76340f37a48b365d0938 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 23 Jun 2026 17:50:06 +0200 Subject: [PATCH 007/205] feat: expose totalCount and filteredCount from useTableData Returns the total number of rows before filtering (totalCount) and after filtering (filteredCount) so the toolbar can show "X of Y rows". Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- src/components/datatable/useTableData.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index ccbc96ab8f..12d8ebe2b7 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -385,10 +385,15 @@ export const useTableData = ({ layer, sortField, sortDirection }) => { (!aggregations || aggregations === EMPTY_AGGREGATIONS)) || (layerType === EVENT_LAYER && !layer.isExtended && !serverCluster) + const totalCount = dataWithAggregations?.length ?? 0 + const filteredCount = rows?.length ?? 0 + return { headers, rows, isLoading, error: getErrorCodeText(errorCode.current), + totalCount, + filteredCount, } } From 4cb9459714fd50c847a3adc787982cba7714fbc3 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 23 Jun 2026 17:52:01 +0200 Subject: [PATCH 008/205] feat: redesign BottomPanel dataTableControls as 36px toolbar Replaces the 20px grey strip with a 36px toolbar that shows: - Active layer name (truncated with ellipsis) - Row count ("X of Y rows" when filtered, "Y rows" otherwise) - "Clear filters" button (only visible when filters are active) - Close button Adds onCountChange callback prop to DataTable so BottomPanel can display the post-filter row count without reading useTableData directly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- src/components/datatable/BottomPanel.jsx | 51 +++++++++++++++++-- src/components/datatable/DataTable.jsx | 19 ++++--- .../datatable/styles/BottomPanel.module.css | 49 ++++++++++++------ 3 files changed, 95 insertions(+), 24 deletions(-) diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index bbdabed720..3c16ff9a4e 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -1,4 +1,5 @@ -import { IconCross16 } from '@dhis2/ui' +import i18n from '@dhis2/d2-i18n' +import { IconCross16, Button } from '@dhis2/ui' import React, { useRef, useCallback, @@ -7,6 +8,7 @@ import React, { useLayoutEffect, } from 'react' import { useSelector, useDispatch } from 'react-redux' +import { clearDataFilters } from '../../actions/dataFilters.js' import { closeDataTable, resizeDataTable } from '../../actions/dataTable.js' import useKeyDown from '../../hooks/useKeyDown.js' import { getCssVar } from '../../util/helpers.js' @@ -16,19 +18,27 @@ import ErrorBoundary from './ErrorBoundary.jsx' import ResizeHandle from './ResizeHandle.jsx' import styles from './styles/BottomPanel.module.css' -// Container for DataTable const BottomPanel = () => { const dataTableHeight = useSelector((state) => state.ui.dataTableHeight) + const activeLayerId = useSelector((state) => state.dataTable) + const activeLayer = useSelector((state) => + state.map.mapViews.find((l) => l.id === activeLayerId) + ) + const dataFilters = activeLayer?.dataFilters ?? {} + const hasActiveFilters = Object.keys(dataFilters).length > 0 const dispatch = useDispatch() const { height } = useWindowDimensions() const panelRef = useRef(null) const [panelWidth, setPanelWidth] = useState(0) + const [totalCount, setTotalCount] = useState(null) + const [filteredCount, setFilteredCount] = useState(null) const maxHeight = height - getCssVar('--header-height') - getCssVar('--toolbar-height') const tableHeight = dataTableHeight < maxHeight ? dataTableHeight : maxHeight + const onResize = useCallback((h) => { document.documentElement.style.setProperty( '--data-table-height', @@ -36,6 +46,11 @@ const BottomPanel = () => { ) }, []) + const onCountChange = useCallback((total, filtered) => { + setTotalCount(total) + setFilteredCount(filtered) + }, []) + useLayoutEffect(() => { document.documentElement.style.setProperty( '--data-table-height', @@ -65,6 +80,16 @@ const BottomPanel = () => { useKeyDown('Escape', () => dispatch(closeDataTable()), true) + const rowCountLabel = + totalCount !== null && filteredCount !== null + ? filteredCount < totalCount + ? i18n.t('{{filtered}} of {{total}} rows', { + filtered: filteredCount, + total: totalCount, + }) + : i18n.t('{{total}} rows', { total: totalCount }) + : null + return ( <div ref={panelRef} @@ -77,16 +102,36 @@ const BottomPanel = () => { onResize={onResize} onResizeEnd={(height) => dispatch(resizeDataTable(height))} /> + <span className={styles.layerName} title={activeLayer?.name}> + {activeLayer?.name} + </span> + {rowCountLabel && ( + <span className={styles.rowCount}>{rowCountLabel}</span> + )} + {hasActiveFilters && ( + <Button + small + onClick={() => + dispatch(clearDataFilters(activeLayerId)) + } + > + {i18n.t('Clear filters')} + </Button> + )} <button className={styles.closeIcon} onClick={() => dispatch(closeDataTable())} + title={i18n.t('Close')} > <IconCross16 /> </button> </div> <div className={styles.tableContainer}> <ErrorBoundary> - <DataTable availableWidth={panelWidth} /> + <DataTable + availableWidth={panelWidth} + onCountChange={onCountChange} + /> </ErrorBoundary> </div> </div> diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index 3d92479c39..92c3d37ec3 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -98,7 +98,7 @@ const TableComponents = { ), } -const Table = ({ availableWidth }) => { +const Table = ({ availableWidth, onCountChange }) => { const { systemSettings: { keyAnalysisDigitGroupSeparator }, } = useCachedData() @@ -205,11 +205,16 @@ const Table = ({ availableWidth }) => { ] ) - const { headers, rows, isLoading, error } = useTableData({ - layer, - sortField, - sortDirection, - }) + const { headers, rows, isLoading, error, totalCount, filteredCount } = + useTableData({ + layer, + sortField, + sortDirection, + }) + + useEffect(() => { + onCountChange?.(totalCount, filteredCount) + }, [onCountChange, totalCount, filteredCount]) useEffect(() => { // Measure column widths in auto layout, then switch to fixed to prevent content shift during virtual scrolling @@ -304,6 +309,7 @@ const Table = ({ availableWidth }) => { ? `${columnWidths[index]}px` : 'auto' } + title={name} > {name} </DataTableColumnHeader> @@ -344,6 +350,7 @@ const Table = ({ availableWidth }) => { Table.propTypes = { availableWidth: PropTypes.number, + onCountChange: PropTypes.func, } export default Table diff --git a/src/components/datatable/styles/BottomPanel.module.css b/src/components/datatable/styles/BottomPanel.module.css index 68ce43eab9..bc7d6316f5 100644 --- a/src/components/datatable/styles/BottomPanel.module.css +++ b/src/components/datatable/styles/BottomPanel.module.css @@ -18,32 +18,51 @@ .dataTableControls { width: 100%; - height: 20px; + height: 36px; background-color: var(--colors-grey100); position: relative; + display: flex; + align-items: center; + padding: 0 var(--spacers-dp4); + gap: var(--spacers-dp8); + flex-shrink: 0; + border-bottom: 1px solid var(--colors-grey300); +} + +.layerName { + font-weight: 500; + font-size: 12px; + color: var(--colors-grey800); + flex: 1; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + min-width: 0; +} + +.rowCount { + font-size: 11px; + color: var(--colors-grey600); + white-space: nowrap; + flex-shrink: 0; } .closeIcon { - position: absolute; - top: 0; - right: 2px; - z-index: 100; cursor: pointer; color: var(--colors-grey800); - background-color: var(--colors-grey100); - width: 20px; - height: 20px; + background-color: transparent; + width: 24px; + height: 24px; border: none; + border-radius: 3px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + padding: 0; } .closeIcon:hover { color: var(--colors-grey900); background-color: var(--colors-grey300); } - -.closeIcon svg { - position: absolute; - top: 50%; - left: 50%; - margin: -8px 0 0 -8px; -} From 2748537ef496c0983a83b324d971c0a4ed27bf6b Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 23 Jun 2026 17:59:19 +0200 Subject: [PATCH 009/205] feat: zoom map to feature on data table row click Extends highlightFeature with an optional zoom:true flag. When a table row is clicked (plain click, not Ctrl+click), dispatches highlightFeature with zoom:true. Layer.js watches for this in componentDidUpdate and calls panToFeature(), which uses getFeaturesById() to compute the feature's bounding box and calls map.fitBounds() to pan and zoom to the feature. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- src/components/datatable/DataTable.jsx | 12 ++++++- src/components/map/layers/Layer.js | 45 ++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index 92c3d37ec3..e607f17400 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -154,7 +154,17 @@ const Table = ({ availableWidth, onCountChange }) => { ) } else { const id = row.find((r) => r.dataKey === 'id')?.value - id && dispatch(setOrgUnitProfile(id)) + if (id) { + dispatch( + highlightFeature({ + id, + layerId: layer.id, + origin: 'table', + zoom: true, + }) + ) + dispatch(setOrgUnitProfile(id)) + } } }, [dispatch, layer] diff --git a/src/components/map/layers/Layer.js b/src/components/map/layers/Layer.js index c8bff13635..535efc3b6b 100644 --- a/src/components/map/layers/Layer.js +++ b/src/components/map/layers/Layer.js @@ -85,6 +85,9 @@ class Layer extends PureComponent { if (feature !== prevProps.feature) { this.highlightFeature(feature) + if (feature?.zoom && feature?.layerId === this.props.id) { + this.panToFeature(feature.id) + } } } @@ -188,6 +191,48 @@ class Layer extends PureComponent { } } + panToFeature(featureId) { + if (!this.layer?.getFeaturesById) return + const features = this.layer.getFeaturesById(featureId) + if (!features?.length) return + + let minLng = Infinity, + minLat = Infinity, + maxLng = -Infinity, + maxLat = -Infinity + + const processCoords = (coords) => { + if (!coords) return + if (typeof coords[0] === 'number') { + const [lng, lat] = coords + if (lng < minLng) minLng = lng + if (lat < minLat) minLat = lat + if (lng > maxLng) maxLng = lng + if (lat > maxLat) maxLat = lat + } else { + coords.forEach(processCoords) + } + } + + features.forEach((f) => processCoords(f.geometry?.coordinates)) + + if (!isFinite(minLng)) return + + const { map } = this.context + map.fitBounds( + [ + [minLng, minLat], + [maxLng, maxLat], + ], + { + padding: PADDING_DEFAULT, + duration: DURATION_DEFAULT, + essential: true, + maxZoom: 17, + } + ) + } + render() { return null } From cf83714dc17035cf3758d50c996e59a4bfafcc6f Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Mon, 6 Jul 2026 19:12:30 +0200 Subject: [PATCH 010/205] feat: add toolbar, row context menu, and fix highlight persistence --- i18n/en.pot | 36 ++-- src/components/core/icons.jsx | 49 +++++ src/components/datatable/BottomPanel.jsx | 67 ++++++- src/components/datatable/DataTable.jsx | 129 +++++++------ src/components/datatable/TableContextMenu.jsx | 174 ++++++++++++++++++ .../datatable/styles/BottomPanel.module.css | 65 +++++++ .../datatable/styles/DataTable.module.css | 30 +++ src/components/map/ContextMenu.jsx | 85 +++++++-- src/components/map/layers/GeoJsonLayer.js | 28 ++- src/components/map/layers/Layer.js | 53 +++--- src/components/map/layers/ThematicLayer.jsx | 19 ++ .../layers/earthEngine/EarthEngineLayer.jsx | 1 + src/util/geojson.js | 9 + 13 files changed, 602 insertions(+), 143 deletions(-) create mode 100644 src/components/datatable/TableContextMenu.jsx diff --git a/i18n/en.pot b/i18n/en.pot index 8e5f58ce96..0a65ebe653 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -155,6 +155,18 @@ msgstr "Operator" msgid "Date" msgstr "Date" +msgid "{{filtered}} of {{total}} rows" +msgstr "{{filtered}} of {{total}} rows" + +msgid "{{total}} rows" +msgstr "{{total}} rows" + +msgid "Clear filters" +msgstr "Clear filters" + +msgid "Close" +msgstr "Close" + msgid "No results found" msgstr "No results found" @@ -167,6 +179,18 @@ msgstr "Something went wrong" msgid "Search" msgstr "Search" +msgid "Drill up one level" +msgstr "Drill up one level" + +msgid "Drill down one level" +msgstr "Drill down one level" + +msgid "View profile" +msgstr "View profile" + +msgid "Zoom to feature" +msgstr "Zoom to feature" + msgid "Data table is not supported when events are grouped on the server." msgstr "Data table is not supported when events are grouped on the server." @@ -261,9 +285,6 @@ msgstr "Map download is not supported by your browser. Try Google Chrome or Fire msgid "Cancel" msgstr "Cancel" -msgid "Close" -msgstr "Close" - msgid "No organisation units are selected" msgstr "No organisation units are selected" @@ -774,15 +795,6 @@ msgstr "Selected org units: No coordinates found" msgid "Error" msgstr "Error" -msgid "Drill up one level" -msgstr "Drill up one level" - -msgid "Drill down one level" -msgstr "Drill down one level" - -msgid "View profile" -msgstr "View profile" - msgid "Show longitude/latitude" msgstr "Show longitude/latitude" diff --git a/src/components/core/icons.jsx b/src/components/core/icons.jsx index 420a19d24a..b46a6354f5 100644 --- a/src/components/core/icons.jsx +++ b/src/components/core/icons.jsx @@ -1,5 +1,54 @@ +import PropTypes from 'prop-types' import React from 'react' +export const SortIcon = ({ direction }) => ( + <svg + xmlns="http://www.w3.org/2000/svg" + width="16" + height="16" + viewBox="0 0 16 16" + style={{ display: 'block' }} + > + <g fill="none" fillRule="evenodd"> + <polygon + fill={ + direction === 'desc' + ? 'var(--colors-blue700)' + : 'var(--colors-grey500)' + } + points="4 9 12 9 8 14" + /> + <polygon + fill={ + direction === 'asc' + ? 'var(--colors-blue700)' + : 'var(--colors-grey500)' + } + points="4 7 12 7 8 2" + /> + </g> + </svg> +) + +SortIcon.propTypes = { + direction: PropTypes.string, +} + +// Magnifying glass with a + sign inside the lens — "zoom to feature" +export const IconZoomIn16 = () => ( + <svg + height="16" + viewBox="0 0 16 16" + width="16" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M6 1a5 5 0 013.871 8.164l4.483 4.482-.708.708L9.164 9.87A5 5 0 116 1zm0 1a4 4 0 100 8 4 4 0 000-8zM3.5 5.5L5.5 5.5 5.5 3.5 6.5 3.5 6.5 5.5 8.5 5.5 8.5 6.5 6.5 6.5 6.5 8.5 5.5 8.5 5.5 6.5 3.5 6.5Z" + fill="currentColor" + /> + </svg> +) + export const IconDrag = () => ( <svg height="8" diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index 3c16ff9a4e..593c551814 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -1,5 +1,5 @@ import i18n from '@dhis2/d2-i18n' -import { IconCross16, Button } from '@dhis2/ui' +import { IconCross16, IconFilter16, Tooltip } from '@dhis2/ui' import React, { useRef, useCallback, @@ -7,6 +7,7 @@ import React, { useEffect, useLayoutEffect, } from 'react' +import { createPortal } from 'react-dom' import { useSelector, useDispatch } from 'react-redux' import { clearDataFilters } from '../../actions/dataFilters.js' import { closeDataTable, resizeDataTable } from '../../actions/dataTable.js' @@ -30,9 +31,11 @@ const BottomPanel = () => { const dispatch = useDispatch() const { height } = useWindowDimensions() const panelRef = useRef(null) + const nameRef = useRef(null) const [panelWidth, setPanelWidth] = useState(0) const [totalCount, setTotalCount] = useState(null) const [filteredCount, setFilteredCount] = useState(null) + const [nameTooltipPos, setNameTooltipPos] = useState(null) const maxHeight = height - getCssVar('--header-height') - getCssVar('--toolbar-height') @@ -51,6 +54,26 @@ const BottomPanel = () => { setFilteredCount(filtered) }, []) + const onNameMouseEnter = useCallback(() => { + const el = nameRef.current + if (!el || el.scrollWidth <= el.offsetWidth) { + return + } + const rect = el.getBoundingClientRect() + const computed = getComputedStyle(el) + const lineHeight = parseFloat(computed.lineHeight) + setNameTooltipPos({ + top: rect.top + (rect.height - lineHeight) / 2, + left: rect.left, + color: computed.color, + fontSize: computed.fontSize, + lineHeight: `${lineHeight}px`, + paddingLeft: computed.paddingLeft, + }) + }, []) + + const onNameMouseLeave = useCallback(() => setNameTooltipPos(null), []) + useLayoutEffect(() => { document.documentElement.style.setProperty( '--data-table-height', @@ -102,28 +125,56 @@ const BottomPanel = () => { onResize={onResize} onResizeEnd={(height) => dispatch(resizeDataTable(height))} /> - <span className={styles.layerName} title={activeLayer?.name}> + <span + ref={nameRef} + className={styles.layerName} + onMouseEnter={onNameMouseEnter} + onMouseLeave={onNameMouseLeave} + > {activeLayer?.name} </span> + {nameTooltipPos && + createPortal( + <div + className={styles.nameTooltip} + style={{ + top: nameTooltipPos.top, + left: nameTooltipPos.left, + color: nameTooltipPos.color, + fontSize: nameTooltipPos.fontSize, + lineHeight: nameTooltipPos.lineHeight, + paddingLeft: nameTooltipPos.paddingLeft, + }} + > + {activeLayer?.name} + </div>, + document.body + )} {rowCountLabel && ( <span className={styles.rowCount}>{rowCountLabel}</span> )} {hasActiveFilters && ( - <Button - small + <button + className={styles.clearFiltersButton} onClick={() => dispatch(clearDataFilters(activeLayerId)) } > - {i18n.t('Clear filters')} - </Button> + <Tooltip content={i18n.t('Clear filters')}> + <span className={styles.filteredIcon}> + <IconFilter16 /> + <span className={styles.clearBadge} /> + </span> + </Tooltip> + </button> )} <button className={styles.closeIcon} onClick={() => dispatch(closeDataTable())} - title={i18n.t('Close')} > - <IconCross16 /> + <Tooltip content={i18n.t('Close')}> + <IconCross16 /> + </Tooltip> </button> </div> <div className={styles.tableContainer}> diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index e607f17400..a23047040a 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -9,6 +9,7 @@ import { ComponentCover, CenteredContent, CircularLoader, + Tooltip, } from '@dhis2/ui' import cx from 'classnames' import PropTypes from 'prop-types' @@ -22,14 +23,14 @@ import React, { } from 'react' import { useSelector, useDispatch } from 'react-redux' import { TableVirtuoso } from 'react-virtuoso' -import { highlightFeature, setFeatureProfile } from '../../actions/feature.js' -import { setOrgUnitProfile } from '../../actions/orgUnits.js' -import { EVENT_LAYER, GEOJSON_URL_LAYER } from '../../constants/layers.js' +import { highlightFeature } from '../../actions/feature.js' import { isDarkColor } from '../../util/colors.js' import { formatWithSeparator } from '../../util/numbers.js' import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' +import { SortIcon } from '../core/icons.jsx' import FilterInput from './FilterInput.jsx' import styles from './styles/DataTable.module.css' +import TableContextMenu from './TableContextMenu.jsx' import { useTableData } from './useTableData.js' const ASCENDING = 'asc' @@ -60,16 +61,16 @@ DataTableWithVirtuosoContext.propTypes = { const DataTableRowWithVirtuosoContext = ({ context, item, ...props }) => ( <DataTableRow - onClick={() => context.onClick(item)} onMouseEnter={() => context.onMouseEnter(item)} onMouseLeave={context.onMouseLeave} + onContextMenu={(e) => context.onContextMenu(e, item)} {...props} /> ) DataTableRowWithVirtuosoContext.propTypes = { context: PropTypes.shape({ - onClick: PropTypes.func, + onContextMenu: PropTypes.func, onMouseEnter: PropTypes.func, onMouseLeave: PropTypes.func, }), @@ -126,48 +127,12 @@ const Table = ({ availableWidth, onCountChange }) => { setSorting({ sortField: name, sortDirection: - sortDirection === ASCENDING ? DESCENDING : ASCENDING, + name === sortField && sortDirection === ASCENDING + ? DESCENDING + : ASCENDING, }) }, - [sortDirection] - ) - - const showDetailView = useCallback( - (row) => { - if (layer.layer === EVENT_LAYER) { - return - } - - if (layer.layer === GEOJSON_URL_LAYER) { - const { name } = layer - - const data = row.reduce((acc, { dataKey, value }) => { - acc[dataKey] = value - return acc - }, {}) - - dispatch( - setFeatureProfile({ - name, - data, - }) - ) - } else { - const id = row.find((r) => r.dataKey === 'id')?.value - if (id) { - dispatch( - highlightFeature({ - id, - layerId: layer.id, - origin: 'table', - zoom: true, - }) - ) - dispatch(setOrgUnitProfile(id)) - } - } - }, - [dispatch, layer] + [sortField, sortDirection] ) const setFeatureHighlight = useCallback( @@ -200,17 +165,45 @@ const Table = ({ availableWidth, onCountChange }) => { [dispatch] ) + const featureById = useMemo(() => { + const map = new Map() + layer.data?.forEach((f) => { + const id = f.properties?.id ?? f.id + if (id != null) { + map.set(id, f) + } + }) + return map + }, [layer.data]) + + const [tableContextMenu, setTableContextMenu] = useState(null) + + const onRowContextMenu = useCallback( + (e, row) => { + e.preventDefault() + const id = + row.find((r) => r.dataKey === 'id')?.value || row[0]?.itemId + const feature = featureById.get(id) + setTableContextMenu({ + x: e.clientX, + y: e.clientY, + featureProps: feature?.properties ?? { id }, + }) + }, + [featureById] + ) + const tableContext = useMemo( () => ({ - onClick: showDetailView, onMouseEnter: setFeatureHighlight, onMouseLeave: clearFeatureHighlight, + onContextMenu: onRowContextMenu, layout: columnWidths.length > 0 ? 'fixed' : 'auto', }), [ - showDetailView, setFeatureHighlight, clearFeatureHighlight, + onRowContextMenu, columnWidths, ] ) @@ -293,15 +286,6 @@ const Table = ({ availableWidth, onCountChange }) => { <DataTableColumnHeader className={styles.columnHeader} key={`${dataKey}-${index}`} - onSortIconClick={sortData} - sortDirection={ - dataKey === sortField - ? sortDirection - : 'default' - } - sortIconTitle={i18n.t('Sort by {{column}}', { - column: name, - })} onFilterIconClick={type && Function.prototype} showFilter={!!type && dataKey !== 'index'} name={dataKey} @@ -319,9 +303,31 @@ const Table = ({ availableWidth, onCountChange }) => { ? `${columnWidths[index]}px` : 'auto' } - title={name} > - {name} + <span className={styles.headerContent}> + {name} + <Tooltip + content={i18n.t('Sort by {{column}}', { + column: name, + })} + > + <button + type="button" + className={styles.sortButton} + onClick={() => + sortData({ name: dataKey }) + } + > + <SortIcon + direction={ + dataKey === sortField + ? sortDirection + : null + } + /> + </button> + </Tooltip> + </span> </DataTableColumnHeader> ))} </DataTableRow> @@ -347,13 +353,18 @@ const Table = ({ availableWidth, onCountChange }) => { )) } /> - {isLoading && ( + {(isLoading || layer?.isLoaded === false || layer?.isLoading) && ( <ComponentCover> <CenteredContent> <CircularLoader /> </CenteredContent> </ComponentCover> )} + <TableContextMenu + contextMenu={tableContextMenu} + layer={layer} + onClose={() => setTableContextMenu(null)} + /> </> ) } diff --git a/src/components/datatable/TableContextMenu.jsx b/src/components/datatable/TableContextMenu.jsx new file mode 100644 index 0000000000..4256d56c67 --- /dev/null +++ b/src/components/datatable/TableContextMenu.jsx @@ -0,0 +1,174 @@ +import i18n from '@dhis2/d2-i18n' +import { + Popover, + Menu, + MenuItem, + IconArrowDown16, + IconArrowUp16, + IconInfo16, +} from '@dhis2/ui' +import PropTypes from 'prop-types' +import React, { useRef } from 'react' +import { useDispatch } from 'react-redux' +import { highlightFeature, setFeatureProfile } from '../../actions/feature.js' +import { updateLayer } from '../../actions/layers.js' +import { setOrgUnitProfile } from '../../actions/orgUnits.js' +import { + BOUNDARY_LAYER, + EVENT_LAYER, + FACILITY_LAYER, + GEOJSON_URL_LAYER, +} from '../../constants/layers.js' +import { getGeojsonFeatureProfile } from '../../util/geojson.js' +import { drillUpDown } from '../../util/map.js' +import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' +import { IconZoomIn16 } from '../core/icons.jsx' + +const TableContextMenu = ({ contextMenu, layer, onClose }) => { + const anchorRef = useRef() + const dispatch = useDispatch() + const { + systemSettings: { keyAnalysisDigitGroupSeparator }, + } = useCachedData() + + if (!contextMenu) { + return null + } + + const { x, y, featureProps } = contextMenu + const layerType = layer.layer + + const { + id, + level, + hasCoordinatesUp, + hasCoordinatesDown, + grandParentId, + grandParentParentGraph, + parentGraph, + } = featureProps || {} + + const canDrill = + layerType !== BOUNDARY_LAYER && + layerType !== FACILITY_LAYER && + layerType !== EVENT_LAYER && + layerType !== GEOJSON_URL_LAYER + + const canViewProfile = id && layerType !== EVENT_LAYER + + return ( + <> + <div + ref={anchorRef} + style={{ + position: 'fixed', + left: x, + top: y, + width: 0, + height: 0, + pointerEvents: 'none', + }} + /> + <Popover + reference={anchorRef} + arrow={false} + placement="right" + onClickOutside={onClose} + > + <Menu dense> + {canDrill && ( + <MenuItem + label={i18n.t('Drill up one level')} + icon={<IconArrowUp16 />} + disabled={!hasCoordinatesUp} + onClick={() => { + dispatch( + updateLayer( + drillUpDown( + layer, + grandParentId, + grandParentParentGraph, + parseInt(level) - 1 + ) + ) + ) + onClose() + }} + /> + )} + {canDrill && ( + <MenuItem + label={i18n.t('Drill down one level')} + icon={<IconArrowDown16 />} + disabled={!hasCoordinatesDown} + onClick={() => { + dispatch( + updateLayer( + drillUpDown( + layer, + id, + parentGraph, + parseInt(level) + 1 + ) + ) + ) + onClose() + }} + /> + )} + {canViewProfile && ( + <MenuItem + label={i18n.t('View profile')} + icon={<IconInfo16 />} + onClick={() => { + if (layerType === GEOJSON_URL_LAYER) { + dispatch( + setFeatureProfile( + getGeojsonFeatureProfile( + { properties: featureProps }, + layer.name, + keyAnalysisDigitGroupSeparator + ) + ) + ) + } else { + dispatch(setOrgUnitProfile(id)) + } + onClose() + }} + /> + )} + {id && ( + <MenuItem + label={i18n.t('Zoom to feature')} + icon={<IconZoomIn16 />} + onClick={() => { + dispatch( + highlightFeature({ + id, + layerId: layer.id, + origin: 'table', + zoom: true, + }) + ) + onClose() + }} + /> + )} + </Menu> + </Popover> + </> + ) +} + +TableContextMenu.propTypes = { + layer: PropTypes.object.isRequired, + onClose: PropTypes.func.isRequired, + contextMenu: PropTypes.shape({ + featureProps: PropTypes.object, + x: PropTypes.number, + y: PropTypes.number, + }), +} + +export default TableContextMenu diff --git a/src/components/datatable/styles/BottomPanel.module.css b/src/components/datatable/styles/BottomPanel.module.css index bc7d6316f5..2d5dbaffb3 100644 --- a/src/components/datatable/styles/BottomPanel.module.css +++ b/src/components/datatable/styles/BottomPanel.module.css @@ -47,6 +47,70 @@ flex-shrink: 0; } +@keyframes tooltipExpandRight { + from { + clip-path: inset(0 100% 0 0); + } + to { + clip-path: inset(0 0% 0 0); + } +} + +.nameTooltip { + animation: tooltipExpandRight 160ms ease-out; + background: var(--colors-white); + border-radius: 3px; + -webkit-mask-image: linear-gradient(to left, transparent, black 2em); + mask-image: linear-gradient(to left, transparent, black 2em); + padding: 0 2em 0 0; + pointer-events: none; + position: fixed; + white-space: nowrap; + z-index: 1000; +} + +.filteredIcon { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; +} + +.clearBadge { + position: absolute; + bottom: 0px; + right: 0px; + width: 8px; + height: 8px; + background: var(--colors-grey100); +} + +.clearFiltersButton:hover .clearBadge { + background: var(--colors-grey300); +} + +.clearBadge::before, +.clearBadge::after { + content: ''; + position: absolute; + width: 5px; + height: 1px; + background: currentColor; + top: 50%; + left: 50%; +} + +.clearBadge::before { + transform: translate(-50%, -50%) rotate(45deg); +} + +.clearBadge::after { + transform: translate(-50%, -50%) rotate(-45deg); +} + +.clearFiltersButton, .closeIcon { cursor: pointer; color: var(--colors-grey800); @@ -62,6 +126,7 @@ padding: 0; } +.clearFiltersButton:hover, .closeIcon:hover { color: var(--colors-grey900); background-color: var(--colors-grey300); diff --git a/src/components/datatable/styles/DataTable.module.css b/src/components/datatable/styles/DataTable.module.css index c82cf02395..65a5b32ef8 100644 --- a/src/components/datatable/styles/DataTable.module.css +++ b/src/components/datatable/styles/DataTable.module.css @@ -24,6 +24,36 @@ td.lightText { justify-content: space-between; } +.headerContent { + display: flex; + align-items: center; + min-width: 0; + gap: 2px; +} + +.sortButton { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 24px; + height: 24px; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + cursor: pointer; +} + +.sortButton:hover, +.sortButton:focus-visible { + background: var(--colors-grey400); +} + +.sortButton:focus { + outline: none; +} + .columnHeader :global(input.dense) { padding: 4px 6px; } diff --git a/src/components/map/ContextMenu.jsx b/src/components/map/ContextMenu.jsx index f938129dd3..a7a4255293 100644 --- a/src/components/map/ContextMenu.jsx +++ b/src/components/map/ContextMenu.jsx @@ -11,6 +11,7 @@ import { import PropTypes from 'prop-types' import React, { Fragment, useRef } from 'react' import { connect } from 'react-redux' +import { highlightFeature, setFeatureProfile } from '../../actions/feature.js' import { updateLayer } from '../../actions/layers.js' import { closeContextMenu, @@ -20,14 +21,21 @@ import { import { setOrgUnitProfile } from '../../actions/orgUnits.js' import { FACILITY_LAYER, + GEOJSON_URL_LAYER, EARTH_ENGINE_LAYER, RENDERING_STRATEGY_SPLIT_BY_PERIOD, } from '../../constants/layers.js' +import { getGeojsonFeatureProfile } from '../../util/geojson.js' import { drillUpDown } from '../../util/map.js' +import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' +import { IconZoomIn16 } from '../core/icons.jsx' import styles from './styles/ContextMenu.module.css' const ContextMenu = (props) => { const anchorRef = useRef() + const { + systemSettings: { keyAnalysisDigitGroupSeparator }, + } = useCachedData() const { feature, @@ -38,9 +46,11 @@ const ContextMenu = (props) => { position, offset, closeContextMenu, + highlightFeature, openCoordinatePopup, showEarthEngineValue, setOrgUnitProfile, + setFeatureProfile, updateLayer, } = props @@ -81,7 +91,17 @@ const ContextMenu = (props) => { ) break case 'show_info': - setOrgUnitProfile(attr.id) + if (layerType === GEOJSON_URL_LAYER) { + setFeatureProfile( + getGeojsonFeatureProfile( + { properties: attr }, + layerConfig.name, + keyAnalysisDigitGroupSeparator + ) + ) + } else { + setOrgUnitProfile(attr.id) + } break case 'show_coordinate': openCoordinatePopup(coordinates) @@ -89,6 +109,14 @@ const ContextMenu = (props) => { case 'show_ee_value': showEarthEngineValue(id, coordinates) break + case 'zoom_to_feature': + highlightFeature({ + id: attr.id, + layerId: layerConfig.id, + origin: 'map', + zoom: true, + }) + break default: } @@ -109,25 +137,29 @@ const ContextMenu = (props) => { > <div className={styles.menu}> <Menu dense dataTest="context-menu"> - {layerType !== FACILITY_LAYER && feature && ( - <MenuItem - dataTest="context-menu-drill-up" - label={i18n.t('Drill up one level')} - icon={<IconArrowUp16 />} - disabled={!attr.hasCoordinatesUp} - onClick={() => onClick('drill_up')} - /> - )} - - {layerType !== FACILITY_LAYER && feature && ( - <MenuItem - dataTest="context-menu-drill-down" - label={i18n.t('Drill down one level')} - icon={<IconArrowDown16 />} - disabled={!attr.hasCoordinatesDown} - onClick={() => onClick('drill_down')} - /> - )} + {layerType !== FACILITY_LAYER && + layerType !== GEOJSON_URL_LAYER && + feature && ( + <MenuItem + dataTest="context-menu-drill-up" + label={i18n.t('Drill up one level')} + icon={<IconArrowUp16 />} + disabled={!attr.hasCoordinatesUp} + onClick={() => onClick('drill_up')} + /> + )} + + {layerType !== FACILITY_LAYER && + layerType !== GEOJSON_URL_LAYER && + feature && ( + <MenuItem + dataTest="context-menu-drill-down" + label={i18n.t('Drill down one level')} + icon={<IconArrowDown16 />} + disabled={!attr.hasCoordinatesDown} + onClick={() => onClick('drill_down')} + /> + )} {feature && ( <MenuItem @@ -138,6 +170,15 @@ const ContextMenu = (props) => { /> )} + {feature && ( + <MenuItem + dataTest="context-menu-zoom-to-feature" + label={i18n.t('Zoom to feature')} + icon={<IconZoomIn16 />} + onClick={() => onClick('zoom_to_feature')} + /> + )} + {coordinates && !isSplitView && ( <MenuItem dataTest="context-menu-show-long-lat" @@ -169,7 +210,9 @@ const ContextMenu = (props) => { ContextMenu.propTypes = { closeContextMenu: PropTypes.func.isRequired, + highlightFeature: PropTypes.func.isRequired, openCoordinatePopup: PropTypes.func.isRequired, + setFeatureProfile: PropTypes.func.isRequired, setOrgUnitProfile: PropTypes.func.isRequired, showEarthEngineValue: PropTypes.func.isRequired, updateLayer: PropTypes.func.isRequired, @@ -192,8 +235,10 @@ export default connect( }), { closeContextMenu, + highlightFeature, openCoordinatePopup, showEarthEngineValue, + setFeatureProfile, setOrgUnitProfile, updateLayer, } diff --git a/src/components/map/layers/GeoJsonLayer.js b/src/components/map/layers/GeoJsonLayer.js index b837f48a02..75895fac0b 100644 --- a/src/components/map/layers/GeoJsonLayer.js +++ b/src/components/map/layers/GeoJsonLayer.js @@ -1,7 +1,6 @@ import { GEOJSON_LAYER } from '../../../constants/layers.js' import { filterData } from '../../../util/filter.js' -import { getGeojsonDisplayData } from '../../../util/geojson.js' -import { formatWithSeparator } from '../../../util/numbers.js' +import { getGeojsonFeatureProfile } from '../../../util/geojson.js' import Layer from './Layer.js' class GeoJsonLayer extends Layer { @@ -45,6 +44,9 @@ class GeoJsonLayer extends Layer { onClick: isPlugin ? Function.prototype : this.onFeatureClick.bind(this), + onRightClick: isPlugin + ? undefined + : this.onFeatureRightClick.bind(this), }) map.addLayer(this.layer) @@ -55,27 +57,19 @@ class GeoJsonLayer extends Layer { } onFeatureClick(evt) { - const { keyAnalysisDigitGroupSeparator } = this.props + const { name, keyAnalysisDigitGroupSeparator } = this.props const feature = this.props.data.find( (d) => d.properties.id === evt.feature.properties.id ) - const data = getGeojsonDisplayData(feature).reduce( - (acc, { dataKey, value }) => { - acc[dataKey] = formatWithSeparator( - value, - keyAnalysisDigitGroupSeparator - ) - return acc - }, - {} + this.props.setFeatureProfile( + getGeojsonFeatureProfile( + feature, + name, + keyAnalysisDigitGroupSeparator + ) ) - - this.props.setFeatureProfile({ - name: this.props.name, - data, - }) } } diff --git a/src/components/map/layers/Layer.js b/src/components/map/layers/Layer.js index 535efc3b6b..6015e1cbcf 100644 --- a/src/components/map/layers/Layer.js +++ b/src/components/map/layers/Layer.js @@ -1,3 +1,4 @@ +import { bbox } from '@turf/bbox' import log from 'loglevel' import PropTypes from 'prop-types' import { PureComponent } from 'react' @@ -84,10 +85,7 @@ class Layer extends PureComponent { } if (feature !== prevProps.feature) { - this.highlightFeature(feature) - if (feature?.zoom && feature?.layerId === this.props.id) { - this.panToFeature(feature.id) - } + this.handleFeatureUpdate(feature) } } @@ -117,6 +115,7 @@ class Layer extends PureComponent { await this.createLayer(true) this.setLayerOrder() this.setLayerVisibility() + this.highlightFeature(this.props.feature) } // Override in subclass if needed @@ -185,38 +184,38 @@ class Layer extends PureComponent { } } + handleFeatureUpdate(feature) { + this.highlightFeature(feature) + if (feature?.zoom && feature?.layerId === this.props.id) { + this.panToFeature(feature.id) + } + } + highlightFeature(feature) { - if (this.layer.highlight) { + if (this.layer?.highlight) { this.layer.highlight(feature ? feature.id : null) } } panToFeature(featureId) { - if (!this.layer?.getFeaturesById) return - const features = this.layer.getFeaturesById(featureId) - if (!features?.length) return - - let minLng = Infinity, - minLat = Infinity, - maxLng = -Infinity, - maxLat = -Infinity - - const processCoords = (coords) => { - if (!coords) return - if (typeof coords[0] === 'number') { - const [lng, lat] = coords - if (lng < minLng) minLng = lng - if (lat < minLat) minLat = lat - if (lng > maxLng) maxLng = lng - if (lat > maxLat) maxLat = lat - } else { - coords.forEach(processCoords) - } + if (!this.layer?.getFeaturesById) { + return + } + const features = this.layer + .getFeaturesById(featureId) + ?.filter((f) => f.geometry) + if (!features?.length) { + return } - features.forEach((f) => processCoords(f.geometry?.coordinates)) + const [minLng, minLat, maxLng, maxLat] = bbox({ + type: 'FeatureCollection', + features, + }) - if (!isFinite(minLng)) return + if (!isFinite(minLng)) { + return + } const { map } = this.context map.fitBounds( diff --git a/src/components/map/layers/ThematicLayer.jsx b/src/components/map/layers/ThematicLayer.jsx index adf4bfbfe9..91b7abb29a 100644 --- a/src/components/map/layers/ThematicLayer.jsx +++ b/src/components/map/layers/ThematicLayer.jsx @@ -190,6 +190,20 @@ class ThematicLayer extends Layer { return <Fragment>{popup && this.getPopup()}</Fragment> } + highlightFeature(feature) { + const { thematicMapType = THEMATIC_CHOROPLETH } = this.props + if (thematicMapType === THEMATIC_BUBBLE) { + // LayerGroup has no highlight(); delegate to each sub-layer + this.layer?._layers?.forEach((l) => { + if (l.highlight) { + l.highlight(feature ? feature.id : null) + } + }) + } else { + super.highlightFeature(feature) + } + } + componentDidUpdate(prevProps) { const prevPeriodId = prevProps.externalPeriod?.id const newPeriodId = this.props.externalPeriod?.id @@ -211,6 +225,10 @@ class ThematicLayer extends Layer { this.setLayerOpacity() this.setLayerVisibility() this.setLayerOrder() + const { feature } = this.props + if (feature !== prevProps.feature) { + this.handleFeatureUpdate(feature) + } return } @@ -230,6 +248,7 @@ class ThematicLayer extends Layer { ) { try { this.layer.setData(filteredData) + this.highlightFeature(this.props.feature) } catch (e) { console.warn('Failed to set layer data incrementally:', e) // fallback to full update on error diff --git a/src/components/map/layers/earthEngine/EarthEngineLayer.jsx b/src/components/map/layers/earthEngine/EarthEngineLayer.jsx index 8a97a6f230..2978d561c2 100644 --- a/src/components/map/layers/earthEngine/EarthEngineLayer.jsx +++ b/src/components/map/layers/earthEngine/EarthEngineLayer.jsx @@ -45,6 +45,7 @@ export default class EarthEngineLayer extends Layer { await this.removeLayer() await this.createLayer(true) this.setLayerOrder() + this.highlightFeature(this.props.feature) } } diff --git a/src/util/geojson.js b/src/util/geojson.js index ce6b0d372a..81be9a3a98 100644 --- a/src/util/geojson.js +++ b/src/util/geojson.js @@ -1,6 +1,7 @@ import { booleanPointInPolygon } from '@turf/boolean-point-in-polygon' import turfCentroid from '@turf/centroid' import findIndex from 'lodash/findIndex' +import { formatWithSeparator } from './numbers.js' export const EVENT_ID_FIELD = 'psi' @@ -233,6 +234,14 @@ export const getGeojsonDisplayData = (feature) => { }) } +export const getGeojsonFeatureProfile = (feature, name, separator) => ({ + name, + data: getGeojsonDisplayData(feature).reduce((acc, { dataKey, value }) => { + acc[dataKey] = formatWithSeparator(value, separator) + return acc + }, {}), +}) + // Ensure that we are always working with a FeatureCollection export const buildGeoJsonFeatures = (geoJson) => { let finalGeoJson = geoJson From 16f87612e5244d289c4cb2953afaa1717789b444 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Mon, 6 Jul 2026 19:30:34 +0200 Subject: [PATCH 011/205] chore: sonarqube fixes --- src/components/datatable/BottomPanel.jsx | 11 ++++++----- src/components/datatable/TableContextMenu.jsx | 4 ++-- src/components/map/layers/Layer.js | 2 +- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index 593c551814..d4ce871750 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -61,7 +61,7 @@ const BottomPanel = () => { } const rect = el.getBoundingClientRect() const computed = getComputedStyle(el) - const lineHeight = parseFloat(computed.lineHeight) + const lineHeight = Number.parseFloat(computed.lineHeight) setNameTooltipPos({ top: rect.top + (rect.height - lineHeight) / 2, left: rect.left, @@ -103,15 +103,16 @@ const BottomPanel = () => { useKeyDown('Escape', () => dispatch(closeDataTable()), true) - const rowCountLabel = - totalCount !== null && filteredCount !== null - ? filteredCount < totalCount + let rowCountLabel = null + if (totalCount !== null && filteredCount !== null) { + rowCountLabel = + filteredCount < totalCount ? i18n.t('{{filtered}} of {{total}} rows', { filtered: filteredCount, total: totalCount, }) : i18n.t('{{total}} rows', { total: totalCount }) - : null + } return ( <div diff --git a/src/components/datatable/TableContextMenu.jsx b/src/components/datatable/TableContextMenu.jsx index 4256d56c67..428f7f2835 100644 --- a/src/components/datatable/TableContextMenu.jsx +++ b/src/components/datatable/TableContextMenu.jsx @@ -88,7 +88,7 @@ const TableContextMenu = ({ contextMenu, layer, onClose }) => { layer, grandParentId, grandParentParentGraph, - parseInt(level) - 1 + Number.parseInt(level) - 1 ) ) ) @@ -108,7 +108,7 @@ const TableContextMenu = ({ contextMenu, layer, onClose }) => { layer, id, parentGraph, - parseInt(level) + 1 + Number.parseInt(level) + 1 ) ) ) diff --git a/src/components/map/layers/Layer.js b/src/components/map/layers/Layer.js index 6015e1cbcf..8464556172 100644 --- a/src/components/map/layers/Layer.js +++ b/src/components/map/layers/Layer.js @@ -213,7 +213,7 @@ class Layer extends PureComponent { features, }) - if (!isFinite(minLng)) { + if (!Number.isFinite(minLng)) { return } From 4ecf34d5a12cc908284f039f2ccf37f6c5f6a056 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 9 Jul 2026 19:44:12 +0200 Subject: [PATCH 012/205] chore: update cypress tests --- cypress/elements/map_context_menu.js | 9 +++++- cypress/integration/dataTable.cy.js | 28 +++++++++++-------- cypress/integration/layers/geojsonlayer.cy.js | 7 +++-- .../integration/layers/thematiclayer.cy.js | 3 ++ src/components/datatable/DataTable.jsx | 1 + src/components/datatable/TableContextMenu.jsx | 6 +++- 6 files changed, 38 insertions(+), 16 deletions(-) diff --git a/cypress/elements/map_context_menu.js b/cypress/elements/map_context_menu.js index 0da64b2203..3f856a91c4 100644 --- a/cypress/elements/map_context_menu.js +++ b/cypress/elements/map_context_menu.js @@ -4,9 +4,16 @@ import { getMaps } from './map_canvas.js' export const DRILL_UP = 'context-menu-drill-up' export const DRILL_DOWN = 'context-menu-drill-down' export const VIEW_PROFILE = 'context-menu-view-profile' +export const ZOOM_TO_FEATURE = 'context-menu-zoom-to-feature' export const SHOW_LONG_LAT = 'context-menu-show-long-lat' -const ALL_OPTIONS = [DRILL_UP, DRILL_DOWN, VIEW_PROFILE, SHOW_LONG_LAT] +const ALL_OPTIONS = [ + DRILL_UP, + DRILL_DOWN, + VIEW_PROFILE, + ZOOM_TO_FEATURE, + SHOW_LONG_LAT, +] export const expectContextMenuOptions = (availableOptions) => { getMaps() diff --git a/cypress/integration/dataTable.cy.js b/cypress/integration/dataTable.cy.js index 8e2ae9afc3..637de6c56c 100644 --- a/cypress/integration/dataTable.cy.js +++ b/cypress/integration/dataTable.cy.js @@ -98,7 +98,7 @@ describe('data table', () => { checkTableCell({ row: 6, column: 1, expectedContent: 'Upper Bambara' }) // Sort by name - cy.get('button[title="Sort by Name"]').click() + cy.getByDataTest('data-table-column-sort-button-Name').click() // confirm that the rows are sorted by Name descending checkTableCell({ row: 0, column: 1, expectedContent: 'Upper Bambara' }) @@ -116,18 +116,20 @@ describe('data table', () => { .should('have.length', 5) // Sort by value - cy.get('button[title="Sort by Value"]').click() + cy.getByDataTest('data-table-column-sort-button-Value').click() // check that the rows are sorted by Value ascending checkTableCell({ row: 0, column: 3, expectedContent: '35' }) checkTableCell({ row: 4, column: 3, expectedContent: '76' }) - // click on a row + // right-click a row and select "View profile" cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-tablebody') .findByDataTest('dhis2-uicore-datatablerow') .first() - .click() + .rightclick() + + cy.getByDataTest('data-table-context-menu-view-profile').click() // check that the org unit profile drawer is opened cy.getByDataTest('org-unit-profile').should('be.visible') @@ -230,18 +232,22 @@ describe('data table', () => { .should('have.length', 2) // Sort by Age in years - cy.get('button[title="Sort by Age in years"]').click() + cy.getByDataTest('data-table-column-sort-button-Age in years').click() // confirm that the rows are sorted by Age in years descending checkTableCell({ row: 0, column: 7, expectedContent: '32' }) checkTableCell({ row: 1, column: 7, expectedContent: '6' }) - // click on a row + // right-click a row: Event layers have no profile to view cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-tablebody') .findByDataTest('dhis2-uicore-datatablerow') .first() - .click() + .rightclick() + + cy.getByDataTest('data-table-context-menu-view-profile').should( + 'not.exist' + ) // check that the org unit profile drawer is NOT opened cy.getByDataTest('org-unit-profile').should('not.exist') @@ -290,7 +296,7 @@ describe('data table', () => { // Confirm that the sort order is initially ascending by Name checkTableCell({ row: 0, column: 1, expectedContent: 'Bendu CHC' }) - cy.get('button[title="Sort by Value"]').click() + cy.getByDataTest('data-table-column-sort-button-Value').click() // Check that first row has Gbamgbama CHC with value 117.98 checkTableCell({ row: 0, column: 1, expectedContent: 'Gbamgbama CHC' }) @@ -304,7 +310,7 @@ describe('data table', () => { checkTableCell({ row: 6, column: 3, expectedContent: '' }) // Sort ascending by Value - cy.get('button[title="Sort by Value"]').click() + cy.getByDataTest('data-table-column-sort-button-Value').click() checkTableCell({ row: 0, column: 1, expectedContent: 'Tihun CHC' }) checkTableCell({ row: 0, column: 3, expectedContent: '28.63' }) @@ -315,7 +321,7 @@ describe('data table', () => { checkTableCell({ row: 6, column: 3, expectedContent: '' }) // Sort by index and scroll to the top - cy.get('button[title="Sort by Index"]').click() + cy.getByDataTest('data-table-column-sort-button-Index').click() cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') checkTableCell({ row: 0, column: 0, expectedContent: '28' }) @@ -324,7 +330,7 @@ describe('data table', () => { checkTableCell({ row: 0, column: 5, expectedContent: '' }) // Sort by range, which is a string - cy.get('button[title="Sort by Range"]').click() + cy.getByDataTest('data-table-column-sort-button-Range').click() // Check that row 0 range value has value '0-40' checkTableCell({ row: 0, column: 5, expectedContent: '0 – 40' }) diff --git a/cypress/integration/layers/geojsonlayer.cy.js b/cypress/integration/layers/geojsonlayer.cy.js index 3cacd25903..7aa50c44f6 100644 --- a/cypress/integration/layers/geojsonlayer.cy.js +++ b/cypress/integration/layers/geojsonlayer.cy.js @@ -76,13 +76,14 @@ describe('GeoJSON URL Layer', () => { .find('tr') .should('have.length', 1) - // open the feature panel by clicking on the row + // open the feature panel via the row context menu cy.getByDataTest('bottom-panel') .find('tbody') .find('tr') - .find('td') .first() - .click() + .rightclick() + + cy.getByDataTest('data-table-context-menu-view-profile').click() // check that Feature profile is displayed cy.getByDataTest('details-panel') diff --git a/cypress/integration/layers/thematiclayer.cy.js b/cypress/integration/layers/thematiclayer.cy.js index c7953b05bc..ca4e777b07 100644 --- a/cypress/integration/layers/thematiclayer.cy.js +++ b/cypress/integration/layers/thematiclayer.cy.js @@ -3,6 +3,7 @@ import { DRILL_UP, DRILL_DOWN, VIEW_PROFILE, + ZOOM_TO_FEATURE, SHOW_LONG_LAT, expectContextMenuOptions, } from '../../elements/map_context_menu.js' @@ -539,6 +540,7 @@ context('Thematic Layers', () => { { name: DRILL_UP, disabled: true }, { name: DRILL_DOWN }, { name: VIEW_PROFILE }, + { name: ZOOM_TO_FEATURE }, { name: SHOW_LONG_LAT }, ]) }) @@ -723,6 +725,7 @@ context('Thematic Layers', () => { { name: DRILL_UP, disabled: true }, { name: DRILL_DOWN }, { name: VIEW_PROFILE }, + { name: ZOOM_TO_FEATURE }, ]) }) diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index a23047040a..7113148b17 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -314,6 +314,7 @@ const Table = ({ availableWidth, onCountChange }) => { <button type="button" className={styles.sortButton} + data-test={`data-table-column-sort-button-${name}`} onClick={() => sortData({ name: dataKey }) } diff --git a/src/components/datatable/TableContextMenu.jsx b/src/components/datatable/TableContextMenu.jsx index 428f7f2835..79692e01c5 100644 --- a/src/components/datatable/TableContextMenu.jsx +++ b/src/components/datatable/TableContextMenu.jsx @@ -75,9 +75,10 @@ const TableContextMenu = ({ contextMenu, layer, onClose }) => { placement="right" onClickOutside={onClose} > - <Menu dense> + <Menu dense dataTest="data-table-context-menu"> {canDrill && ( <MenuItem + dataTest="data-table-context-menu-drill-up" label={i18n.t('Drill up one level')} icon={<IconArrowUp16 />} disabled={!hasCoordinatesUp} @@ -98,6 +99,7 @@ const TableContextMenu = ({ contextMenu, layer, onClose }) => { )} {canDrill && ( <MenuItem + dataTest="data-table-context-menu-drill-down" label={i18n.t('Drill down one level')} icon={<IconArrowDown16 />} disabled={!hasCoordinatesDown} @@ -118,6 +120,7 @@ const TableContextMenu = ({ contextMenu, layer, onClose }) => { )} {canViewProfile && ( <MenuItem + dataTest="data-table-context-menu-view-profile" label={i18n.t('View profile')} icon={<IconInfo16 />} onClick={() => { @@ -140,6 +143,7 @@ const TableContextMenu = ({ contextMenu, layer, onClose }) => { )} {id && ( <MenuItem + dataTest="data-table-context-menu-zoom-to-feature" label={i18n.t('Zoom to feature')} icon={<IconZoomIn16 />} onClick={() => { From 262dacdca66b4639ce1190f062fbec6d7956fdf6 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Fri, 10 Jul 2026 10:00:41 +0200 Subject: [PATCH 013/205] chore: update cypress tests --- cypress/integration/dataTable.cy.js | 34 ++++++++++--------- cypress/integration/layers/geojsonlayer.cy.js | 1 + 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/cypress/integration/dataTable.cy.js b/cypress/integration/dataTable.cy.js index 637de6c56c..c40d3da3ed 100644 --- a/cypress/integration/dataTable.cy.js +++ b/cypress/integration/dataTable.cy.js @@ -234,9 +234,10 @@ describe('data table', () => { // Sort by Age in years cy.getByDataTest('data-table-column-sort-button-Age in years').click() - // confirm that the rows are sorted by Age in years descending - checkTableCell({ row: 0, column: 7, expectedContent: '32' }) - checkTableCell({ row: 1, column: 7, expectedContent: '6' }) + // Confirm that the rows are sorted by Age in years ascending + // (the first click on a new column always sorts ascending) + checkTableCell({ row: 0, column: 7, expectedContent: '6' }) + checkTableCell({ row: 1, column: 7, expectedContent: '32' }) // right-click a row: Event layers have no profile to view cy.getByDataTest('bottom-panel') @@ -296,35 +297,36 @@ describe('data table', () => { // Confirm that the sort order is initially ascending by Name checkTableCell({ row: 0, column: 1, expectedContent: 'Bendu CHC' }) + // First click on a new column always sorts ascending cy.getByDataTest('data-table-column-sort-button-Value').click() - // Check that first row has Gbamgbama CHC with value 117.98 - checkTableCell({ row: 0, column: 1, expectedContent: 'Gbamgbama CHC' }) - checkTableCell({ row: 0, column: 3, expectedContent: '117.98' }) + // Check that first row has Tihun CHC with value 28.63 + checkTableCell({ row: 0, column: 1, expectedContent: 'Tihun CHC' }) + checkTableCell({ row: 0, column: 3, expectedContent: '28.63' }) - // Check that row 5 has Tihun CHC with value 28.63 - checkTableCell({ row: 5, column: 1, expectedContent: 'Tihun CHC' }) - checkTableCell({ row: 5, column: 3, expectedContent: '28.63' }) + // Check that row 5 has Gbamgbama CHC with value 117.98 + checkTableCell({ row: 5, column: 1, expectedContent: 'Gbamgbama CHC' }) + checkTableCell({ row: 5, column: 3, expectedContent: '117.98' }) // Check that row 6 has no value (undefined) checkTableCell({ row: 6, column: 3, expectedContent: '' }) - // Sort ascending by Value + // Sort descending by Value cy.getByDataTest('data-table-column-sort-button-Value').click() - checkTableCell({ row: 0, column: 1, expectedContent: 'Tihun CHC' }) - checkTableCell({ row: 0, column: 3, expectedContent: '28.63' }) + checkTableCell({ row: 0, column: 1, expectedContent: 'Gbamgbama CHC' }) + checkTableCell({ row: 0, column: 3, expectedContent: '117.98' }) - checkTableCell({ row: 5, column: 1, expectedContent: 'Gbamgbama CHC' }) - checkTableCell({ row: 5, column: 3, expectedContent: '117.98' }) + checkTableCell({ row: 5, column: 1, expectedContent: 'Tihun CHC' }) + checkTableCell({ row: 5, column: 3, expectedContent: '28.63' }) checkTableCell({ row: 6, column: 3, expectedContent: '' }) - // Sort by index and scroll to the top + // Sort by index (a new column, so ascending) and scroll to the top cy.getByDataTest('data-table-column-sort-button-Index').click() cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') - checkTableCell({ row: 0, column: 0, expectedContent: '28' }) + checkTableCell({ row: 0, column: 0, expectedContent: '0' }) // Check that row 0 range value is empty checkTableCell({ row: 0, column: 5, expectedContent: '' }) diff --git a/cypress/integration/layers/geojsonlayer.cy.js b/cypress/integration/layers/geojsonlayer.cy.js index 7aa50c44f6..507d0c5190 100644 --- a/cypress/integration/layers/geojsonlayer.cy.js +++ b/cypress/integration/layers/geojsonlayer.cy.js @@ -80,6 +80,7 @@ describe('GeoJSON URL Layer', () => { cy.getByDataTest('bottom-panel') .find('tbody') .find('tr') + .find('td') .first() .rightclick() From d0a21889597cb99c50d924ef1eeb0bdc16fe765b Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Sun, 19 Jul 2026 12:00:01 +0200 Subject: [PATCH 014/205] chore: sonarqube issues fix --- src/components/datatable/BottomPanel.jsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index d4ce871750..13def73809 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -156,6 +156,7 @@ const BottomPanel = () => { )} {hasActiveFilters && ( <button + type="button" className={styles.clearFiltersButton} onClick={() => dispatch(clearDataFilters(activeLayerId)) @@ -170,6 +171,7 @@ const BottomPanel = () => { </button> )} <button + type="button" className={styles.closeIcon} onClick={() => dispatch(closeDataTable())} > From 2d12943015c847d4b049dc7c555ae44a0dd11e7d Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Sun, 19 Jul 2026 15:31:12 +0200 Subject: [PATCH 015/205] chore: create and use replica accounts in CI --- cypress.config.js | 30 ------------------------------ 1 file changed, 30 deletions(-) diff --git a/cypress.config.js b/cypress.config.js index a54646481d..3f4d51c1a5 100644 --- a/cypress.config.js +++ b/cypress.config.js @@ -29,36 +29,6 @@ async function setupNodeEvents(on, config) { ) } - config.env.useReplicaAccount = !!process.env.CI - - if (config.env.useReplicaAccount) { - try { - const { username, password, replicaUserId } = - await createReplicaAccountForRun({ - baseUrl: config.env.dhis2BaseUrl, - username: config.env.dhis2Username, - password: config.env.dhis2Password, - }) - - config.env.replicaUsername = username - config.env.replicaPassword = password - - on('after:run', () => - deleteReplicaAccount({ - baseUrl: config.env.dhis2BaseUrl, - username: config.env.dhis2Username, - password: config.env.dhis2Password, - replicaUserId, - }) - ) - } catch (error) { - console.warn( - `WARNING: could not create e2e replica account, falling back to the standard account: ${error.message}` - ) - config.env.useReplicaAccount = false - } - } - return config } From 49ab3c04784203ffc168e6732b26d0355b1e0b13 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Sun, 19 Jul 2026 17:41:18 +0200 Subject: [PATCH 016/205] chore: sonarqube issues --- cypress.config.js | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/cypress.config.js b/cypress.config.js index 3f4d51c1a5..a54646481d 100644 --- a/cypress.config.js +++ b/cypress.config.js @@ -29,6 +29,36 @@ async function setupNodeEvents(on, config) { ) } + config.env.useReplicaAccount = !!process.env.CI + + if (config.env.useReplicaAccount) { + try { + const { username, password, replicaUserId } = + await createReplicaAccountForRun({ + baseUrl: config.env.dhis2BaseUrl, + username: config.env.dhis2Username, + password: config.env.dhis2Password, + }) + + config.env.replicaUsername = username + config.env.replicaPassword = password + + on('after:run', () => + deleteReplicaAccount({ + baseUrl: config.env.dhis2BaseUrl, + username: config.env.dhis2Username, + password: config.env.dhis2Password, + replicaUserId, + }) + ) + } catch (error) { + console.warn( + `WARNING: could not create e2e replica account, falling back to the standard account: ${error.message}` + ) + config.env.useReplicaAccount = false + } + } + return config } From f2b3f929e78e4e5f72ecfaf5d48b908514272e13 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Mon, 13 Jul 2026 17:36:21 +0200 Subject: [PATCH 017/205] feat: add bidirectional map/table selection sync and collapsible data table --- i18n/en.pot | 27 +- src/actions/dataTable.js | 23 ++ src/actions/feature.js | 5 + src/actions/selection.js | 23 ++ src/components/core/ColorPicker.jsx | 83 +++-- .../core/__tests__/ColorPicker.spec.jsx | 68 ++++ src/components/core/icons.jsx | 53 ++++ .../core/styles/ColorPicker.module.css | 13 + src/components/datatable/BottomPanel.jsx | 162 ++++++++-- src/components/datatable/DataTable.jsx | 291 +++++++++++++++--- src/components/datatable/ResizeHandle.jsx | 4 + src/components/datatable/TableContextMenu.jsx | 35 ++- .../datatable/__tests__/DataTable.spec.jsx | 59 +++- .../datatable/__tests__/useTableData.spec.jsx | 138 +++++++++ .../datatable/styles/BottomPanel.module.css | 34 +- .../datatable/styles/DataTable.module.css | 19 ++ .../datatable/styles/ResizeHandle.module.css | 3 +- src/components/datatable/useTableData.js | 52 +++- src/components/map/ContextMenu.jsx | 53 +++- src/components/map/Map.jsx | 20 ++ src/components/map/MapContainer.jsx | 31 +- src/components/map/MapPosition.jsx | 34 +- src/components/map/MapView.jsx | 24 ++ src/components/map/SplitView.jsx | 18 ++ src/components/map/layers/EventLayer.jsx | 13 +- src/components/map/layers/FacilityLayer.jsx | 10 +- src/components/map/layers/GeoJsonLayer.js | 12 + src/components/map/layers/Layer.js | 142 ++++++++- src/components/map/layers/OrgUnitLayer.jsx | 8 +- src/components/map/layers/ThematicLayer.jsx | 66 ++-- .../map/layers/TrackedEntityLayer.jsx | 20 +- .../layers/earthEngine/EarthEngineLayer.jsx | 14 +- src/components/plugin/Map.jsx | 5 + src/constants/actionTypes.js | 12 + .../useDebouncedHighlightFeature.spec.js | 86 ++++++ src/hooks/useDebouncedHighlightFeature.js | 45 +++ src/reducers/__tests__/selection.spec.js | 149 +++++++++ src/reducers/__tests__/ui.spec.js | 91 ++++++ src/reducers/index.js | 2 + src/reducers/selection.js | 49 +++ src/reducers/ui.js | 55 ++++ src/util/__tests__/geojson.spec.js | 138 +++++++++ src/util/geojson.js | 21 ++ 43 files changed, 2058 insertions(+), 152 deletions(-) create mode 100644 src/actions/selection.js create mode 100644 src/components/core/__tests__/ColorPicker.spec.jsx create mode 100644 src/hooks/__tests__/useDebouncedHighlightFeature.spec.js create mode 100644 src/hooks/useDebouncedHighlightFeature.js create mode 100644 src/reducers/__tests__/selection.spec.js create mode 100644 src/reducers/__tests__/ui.spec.js create mode 100644 src/reducers/selection.js diff --git a/i18n/en.pot b/i18n/en.pot index 0a65ebe653..e729fd9f52 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -161,15 +161,33 @@ msgstr "{{filtered}} of {{total}} rows" msgid "{{total}} rows" msgstr "{{total}} rows" +msgid "Restore" +msgstr "Restore" + +msgid "Collapse" +msgstr "Collapse" + msgid "Clear filters" msgstr "Clear filters" +msgid "Show only features in current map view" +msgstr "Show only features in current map view" + +msgid "Show only selected features" +msgstr "Show only selected features" + +msgid "Highlight color" +msgstr "Highlight color" + msgid "Close" msgstr "Close" msgid "No results found" msgstr "No results found" +msgid "Select all" +msgstr "Select all" + msgid "Sort by {{column}}" msgstr "Sort by {{column}}" @@ -191,6 +209,12 @@ msgstr "View profile" msgid "Zoom to feature" msgstr "Zoom to feature" +msgid "Zoom to layer" +msgstr "Zoom to layer" + +msgid "Zoom to selected features" +msgstr "Zoom to selected features" + msgid "Data table is not supported when events are grouped on the server." msgstr "Data table is not supported when events are grouped on the server." @@ -643,9 +667,6 @@ msgstr "" "Choose which layer sources are available to add to maps. This selection " "applies to all users." -msgid "Collapse" -msgstr "Collapse" - msgid "Expand" msgstr "Expand" diff --git a/src/actions/dataTable.js b/src/actions/dataTable.js index 392ebadf48..133e680b73 100644 --- a/src/actions/dataTable.js +++ b/src/actions/dataTable.js @@ -13,3 +13,26 @@ export const resizeDataTable = (height) => ({ type: types.DATA_TABLE_RESIZE, height, }) + +export const setMapBounds = (bounds) => ({ + type: types.MAP_BOUNDS_CHANGED, + bounds, +}) + +export const toggleShowOnlyFeaturesInView = () => ({ + type: types.TOGGLE_SHOW_ONLY_IN_VIEW, +}) + +export const toggleShowOnlySelected = () => ({ + type: types.TOGGLE_SHOW_ONLY_SELECTED, +}) + +export const setShowOnlySelected = (value) => ({ + type: types.SHOW_ONLY_SELECTED_SET, + value, +}) + +export const setHighlightColor = (color) => ({ + type: types.HIGHLIGHT_COLOR_SET, + color, +}) diff --git a/src/actions/feature.js b/src/actions/feature.js index 4c54bdc3ca..bd8dadefa8 100644 --- a/src/actions/feature.js +++ b/src/actions/feature.js @@ -13,3 +13,8 @@ export const setFeatureProfile = (payload) => ({ export const closeFeatureProfile = () => ({ type: types.FEATURE_PROFILE_CLOSE, }) + +export const clickFeature = (payload) => ({ + type: types.MAP_FEATURE_CLICKED, + payload, +}) diff --git a/src/actions/selection.js b/src/actions/selection.js new file mode 100644 index 0000000000..6b5795c46e --- /dev/null +++ b/src/actions/selection.js @@ -0,0 +1,23 @@ +import * as types from '../constants/actionTypes.js' + +export const toggleFeatureSelection = (id, layerId) => ({ + type: types.FEATURE_TOGGLE_SELECTION, + id, + layerId, +}) + +export const selectAllFeatures = (ids, layerId) => ({ + type: types.SELECTION_SET_ALL, + ids, + layerId, +}) + +export const selectFeatureRange = (ids, layerId) => ({ + type: types.SELECTION_ADD_RANGE, + ids, + layerId, +}) + +export const clearSelection = () => ({ + type: types.SELECTION_CLEAR, +}) diff --git a/src/components/core/ColorPicker.jsx b/src/components/core/ColorPicker.jsx index da139aebb1..cc93a59397 100644 --- a/src/components/core/ColorPicker.jsx +++ b/src/components/core/ColorPicker.jsx @@ -1,42 +1,69 @@ -import { IconChevronDown24 } from '@dhis2/ui' +import { IconChevronDown16, IconChevronDown24 } from '@dhis2/ui' import cx from 'classnames' import PropTypes from 'prop-types' import React, { Fragment } from 'react' import { isDarkColor } from '../../util/colors.js' import styles from './styles/ColorPicker.module.css' -const ColorPicker = ({ color, label, width, height, onChange, className }) => ( - <Fragment> - <div className={cx(styles.colorPicker, className)}> - {label && <div className={styles.label}>{label}</div>} - <label - style={{ - backgroundColor: color, - width: width || '100%', - height: height || 32, - }} - > - <span - className={cx(styles.icon, { - [styles.dark]: !isDarkColor(color), - })} +// The native <input type="color"> needs a real hex value to function as a +// controlled input — used only for that, never shown as the swatch's fill. +const FALLBACK_INPUT_COLOR = '#000000' + +const ColorPicker = ({ + color, + label, + width, + height, + onChange, + className, + centerIcon, +}) => { + const swatchHeight = height || 32 + const isCompact = swatchHeight <= 24 + const Chevron = isCompact ? IconChevronDown16 : IconChevronDown24 + const isUnset = !color + + return ( + <Fragment> + <div className={cx(styles.colorPicker, className)}> + {label && <div className={styles.label}>{label}</div>} + <label + className={cx({ [styles.unset]: isUnset })} + style={{ + backgroundColor: isUnset ? undefined : color, + width: width || '100%', + height: swatchHeight, + }} > - <IconChevronDown24 /> - </span> - <input - type="color" - value={color} - onChange={(e) => onChange(e.target.value.toUpperCase())} - /> - </label> - </div> - </Fragment> -) + <span + className={cx( + centerIcon ? styles.iconCentered : styles.icon, + { [styles.dark]: isUnset || !isDarkColor(color) } + )} + style={ + !centerIcon && isCompact + ? { height: 16 } + : undefined + } + > + <Chevron /> + </span> + <input + type="color" + value={color || FALLBACK_INPUT_COLOR} + onChange={(e) => onChange(e.target.value.toUpperCase())} + /> + </label> + </div> + </Fragment> + ) +} ColorPicker.propTypes = { - color: PropTypes.string.isRequired, onChange: PropTypes.func.isRequired, + centerIcon: PropTypes.bool, className: PropTypes.string, + color: PropTypes.string, height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), label: PropTypes.string, width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), diff --git a/src/components/core/__tests__/ColorPicker.spec.jsx b/src/components/core/__tests__/ColorPicker.spec.jsx new file mode 100644 index 0000000000..1ba966a74b --- /dev/null +++ b/src/components/core/__tests__/ColorPicker.spec.jsx @@ -0,0 +1,68 @@ +import { render } from '@testing-library/react' +import React from 'react' +import ColorPicker from '../ColorPicker.jsx' + +describe('ColorPicker', () => { + it('uses the default 24px chevron at the default size', () => { + const { container } = render( + <ColorPicker color="#FFC800" onChange={jest.fn()} /> + ) + expect(container.querySelector('svg').getAttribute('width')).toBe('24') + }) + + it('uses a compact 16px chevron when height is small', () => { + const { container } = render( + <ColorPicker + color="#FFC800" + height={20} + width={20} + onChange={jest.fn()} + /> + ) + expect(container.querySelector('svg').getAttribute('width')).toBe('16') + }) + + it('right-aligns the chevron by default (unchanged look for existing pickers)', () => { + const { container } = render( + <ColorPicker color="#FFC800" onChange={jest.fn()} /> + ) + expect(container.querySelector('span').className).toContain('icon') + expect(container.querySelector('span').className).not.toContain( + 'iconCentered' + ) + }) + + it('centers the chevron only when centerIcon is set (data-table swatch)', () => { + const { container } = render( + <ColorPicker color="#FFC800" onChange={jest.fn()} centerIcon /> + ) + expect(container.querySelector('span').className).toContain( + 'iconCentered' + ) + }) + + it('renders an empty/dashed swatch instead of a solid fill when no color is set', () => { + const { container } = render( + <ColorPicker color={null} onChange={jest.fn()} /> + ) + const label = container.querySelector('label') + expect(label.className).toContain('unset') + expect(label.style.backgroundColor).toBe('') + }) + + it('still gives the native color input a real value when unset', () => { + const { container } = render( + <ColorPicker color={null} onChange={jest.fn()} /> + ) + expect(container.querySelector('input').value).not.toBe('') + }) + + it('renders a solid fill (not the unset style) once a color is set', () => { + const { container } = render( + <ColorPicker color="#FF0000" onChange={jest.fn()} /> + ) + const label = container.querySelector('label') + expect(label.className).not.toContain('unset') + expect(label.style.backgroundColor).toBe('rgb(255, 0, 0)') + }) +}) diff --git a/src/components/core/icons.jsx b/src/components/core/icons.jsx index b46a6354f5..78de9d4a22 100644 --- a/src/components/core/icons.jsx +++ b/src/components/core/icons.jsx @@ -49,6 +49,59 @@ export const IconZoomIn16 = () => ( </svg> ) +// Two stacked chevrons — "collapse"/"restore to full height" toggle. +export const IconChevronDoubleDown16 = () => ( + <svg + height="16" + viewBox="0 0 16 16" + width="16" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M4 4L8 7L12 4" + fill="none" + stroke="currentColor" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + /> + <path + d="M4 9L8 12L12 9" + fill="none" + stroke="currentColor" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + /> + </svg> +) + +export const IconChevronDoubleUp16 = () => ( + <svg + height="16" + viewBox="0 0 16 16" + width="16" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M4 7L8 4L12 7" + fill="none" + stroke="currentColor" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + /> + <path + d="M4 12L8 9L12 12" + fill="none" + stroke="currentColor" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + /> + </svg> +) + export const IconDrag = () => ( <svg height="8" diff --git a/src/components/core/styles/ColorPicker.module.css b/src/components/core/styles/ColorPicker.module.css index 6efafb4dc2..63b7cbebed 100644 --- a/src/components/core/styles/ColorPicker.module.css +++ b/src/components/core/styles/ColorPicker.module.css @@ -32,6 +32,19 @@ color: var(--colors-white); } +.iconCentered { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--colors-white); +} + .dark { color: var(--colors-grey900); } + +.unset { + border-style: dashed; +} diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index 13def73809..d9cc0e2f18 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -1,5 +1,12 @@ import i18n from '@dhis2/d2-i18n' -import { IconCross16, IconFilter16, Tooltip } from '@dhis2/ui' +import { + IconCross16, + IconFilter16, + IconEmptyFrame16, + IconCheckmarkCircle16, + Tooltip, +} from '@dhis2/ui' +import cx from 'classnames' import React, { useRef, useCallback, @@ -10,15 +17,31 @@ import React, { import { createPortal } from 'react-dom' import { useSelector, useDispatch } from 'react-redux' import { clearDataFilters } from '../../actions/dataFilters.js' -import { closeDataTable, resizeDataTable } from '../../actions/dataTable.js' +import { + closeDataTable, + resizeDataTable, + toggleShowOnlyFeaturesInView, + toggleShowOnlySelected, + setShowOnlySelected, + setHighlightColor, +} from '../../actions/dataTable.js' import useKeyDown from '../../hooks/useKeyDown.js' import { getCssVar } from '../../util/helpers.js' +import ColorPicker from '../core/ColorPicker.jsx' +import { + IconChevronDoubleDown16, + IconChevronDoubleUp16, +} from '../core/icons.jsx' import { useWindowDimensions } from '../WindowDimensionsProvider.jsx' import DataTable from './DataTable.jsx' import ErrorBoundary from './ErrorBoundary.jsx' import ResizeHandle from './ResizeHandle.jsx' import styles from './styles/BottomPanel.module.css' +// Must match `.dataTableControls`'s height in BottomPanel.module.css +const COLLAPSED_HEIGHT = 36 +const MIN_HEIGHT = 50 + const BottomPanel = () => { const dataTableHeight = useSelector((state) => state.ui.dataTableHeight) const activeLayerId = useSelector((state) => state.dataTable) @@ -27,28 +50,62 @@ const BottomPanel = () => { ) const dataFilters = activeLayer?.dataFilters ?? {} const hasActiveFilters = Object.keys(dataFilters).length > 0 + const showOnlyFeaturesInView = useSelector( + (state) => state.ui.showOnlyFeaturesInView + ) + const showOnlySelected = useSelector((state) => state.ui.showOnlySelected) + const selection = useSelector((state) => state.selection) + const selectedCount = + selection.layerId === activeLayerId ? selection.ids.length : 0 + const highlightColor = useSelector((state) => state.ui.highlightColor) const dispatch = useDispatch() const { height } = useWindowDimensions() const panelRef = useRef(null) const nameRef = useRef(null) + const isDraggingRef = useRef(false) const [panelWidth, setPanelWidth] = useState(0) const [totalCount, setTotalCount] = useState(null) const [filteredCount, setFilteredCount] = useState(null) const [nameTooltipPos, setNameTooltipPos] = useState(null) + const [isCollapsed, setIsCollapsed] = useState(false) const maxHeight = height - getCssVar('--header-height') - getCssVar('--toolbar-height') const tableHeight = dataTableHeight < maxHeight ? dataTableHeight : maxHeight + const displayHeight = isCollapsed ? COLLAPSED_HEIGHT : tableHeight + + const toggleCollapsed = useCallback( + () => setIsCollapsed((collapsed) => !collapsed), + [] + ) + + const onResizeStart = useCallback(() => { + isDraggingRef.current = true + }, []) const onResize = useCallback((h) => { + setIsCollapsed(h <= MIN_HEIGHT) document.documentElement.style.setProperty( '--data-table-height', - `${h}px` + `${h <= MIN_HEIGHT ? COLLAPSED_HEIGHT : h}px` ) }, []) + const onResizeEnd = useCallback( + (h) => { + isDraggingRef.current = false + if (h <= MIN_HEIGHT) { + setIsCollapsed(true) + } else { + setIsCollapsed(false) + dispatch(resizeDataTable(h)) + } + }, + [dispatch] + ) + const onCountChange = useCallback((total, filtered) => { setTotalCount(total) setFilteredCount(filtered) @@ -75,11 +132,14 @@ const BottomPanel = () => { const onNameMouseLeave = useCallback(() => setNameTooltipPos(null), []) useLayoutEffect(() => { + if (isDraggingRef.current) { + return + } document.documentElement.style.setProperty( '--data-table-height', - `${tableHeight}px` + `${displayHeight}px` ) - }, [tableHeight]) + }, [displayHeight]) useLayoutEffect( () => () => @@ -103,6 +163,12 @@ const BottomPanel = () => { useKeyDown('Escape', () => dispatch(closeDataTable()), true) + useEffect(() => { + if (showOnlySelected && selectedCount === 0) { + dispatch(setShowOnlySelected(false)) + } + }, [dispatch, showOnlySelected, selectedCount]) + let rowCountLabel = null if (totalCount !== null && filteredCount !== null) { rowCountLabel = @@ -120,12 +186,26 @@ const BottomPanel = () => { className={styles.bottomPanel} data-test="bottom-panel" > - <div className={styles.dataTableControls}> - <ResizeHandle - maxHeight={maxHeight} - onResize={onResize} - onResizeEnd={(height) => dispatch(resizeDataTable(height))} - /> + <div + className={styles.dataTableControls} + onDoubleClick={toggleCollapsed} + > + <button + className={styles.toggleButton} + onClick={toggleCollapsed} + > + <Tooltip + content={ + isCollapsed ? i18n.t('Restore') : i18n.t('Collapse') + } + > + {isCollapsed ? ( + <IconChevronDoubleUp16 /> + ) : ( + <IconChevronDoubleDown16 /> + )} + </Tooltip> + </button> <span ref={nameRef} className={styles.layerName} @@ -151,6 +231,13 @@ const BottomPanel = () => { </div>, document.body )} + <ResizeHandle + maxHeight={maxHeight} + minHeight={MIN_HEIGHT} + onResizeStart={onResizeStart} + onResize={onResize} + onResizeEnd={onResizeEnd} + /> {rowCountLabel && ( <span className={styles.rowCount}>{rowCountLabel}</span> )} @@ -172,6 +259,40 @@ const BottomPanel = () => { )} <button type="button" + className={cx(styles.toggleButton, { + [styles.active]: showOnlyFeaturesInView, + })} + onClick={() => dispatch(toggleShowOnlyFeaturesInView())} + > + <Tooltip + content={i18n.t( + 'Show only features in current map view' + )} + > + <IconEmptyFrame16 /> + </Tooltip> + </button> + <button + className={cx(styles.toggleButton, { + [styles.active]: showOnlySelected, + })} + onClick={() => dispatch(toggleShowOnlySelected())} + > + <Tooltip content={i18n.t('Show only selected features')}> + <IconCheckmarkCircle16 /> + </Tooltip> + </button> + <Tooltip content={i18n.t('Highlight color')}> + <ColorPicker + className={styles.highlightColorPicker} + color={highlightColor} + width={18} + height={18} + centerIcon + onChange={(color) => dispatch(setHighlightColor(color))} + /> + </Tooltip> + <button className={styles.closeIcon} onClick={() => dispatch(closeDataTable())} > @@ -180,14 +301,17 @@ const BottomPanel = () => { </Tooltip> </button> </div> - <div className={styles.tableContainer}> - <ErrorBoundary> - <DataTable - availableWidth={panelWidth} - onCountChange={onCountChange} - /> - </ErrorBoundary> - </div> + {!isCollapsed && ( + <div className={styles.tableContainer}> + <ErrorBoundary> + <DataTable + availableWidth={panelWidth} + onCountChange={onCountChange} + showOnlySelected={showOnlySelected} + /> + </ErrorBoundary> + </div> + )} </div> ) } diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index 7113148b17..3da1faf1bd 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -24,6 +24,12 @@ import React, { import { useSelector, useDispatch } from 'react-redux' import { TableVirtuoso } from 'react-virtuoso' import { highlightFeature } from '../../actions/feature.js' +import { + toggleFeatureSelection, + selectAllFeatures, + selectFeatureRange, + clearSelection, +} from '../../actions/selection.js' import { isDarkColor } from '../../util/colors.js' import { formatWithSeparator } from '../../util/numbers.js' import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' @@ -36,15 +42,37 @@ import { useTableData } from './useTableData.js' const ASCENDING = 'asc' const DESCENDING = 'desc' -// Decides whether a row's highlight should be cleared on mouse leave. -// When hovering to the next row the next element is a `TD`, in which case -// `setFeatureHighlight` fires and the highlight does not need to be cleared. -// When leaving to no element (e.g. the cursor exits the browser window) -// `relatedTarget` is null, so the optional chaining guards against a crash. -// Exported for testing. export const shouldClearFeatureHighlight = (event) => event.relatedTarget?.tagName !== 'TD' +const getRowId = (row) => + row.find((r) => r.dataKey === 'id')?.value || row[0]?.itemId + +export const getRowClickAction = ( + event, + { id, rowIndex, rows, lastClickedRowIndex } +) => { + if (event.shiftKey) { + if (lastClickedRowIndex === null) { + return { type: 'toggle', id } + } + const [start, end] = [lastClickedRowIndex, rowIndex].sort( + (a, b) => a - b + ) + const ids = rows + .slice(start, end + 1) + .map(getRowId) + .filter(Boolean) + return { type: 'range', ids } + } + + if (event.ctrlKey || event.metaKey) { + return { type: 'toggle', id } + } + + return null +} + const DataTableWithVirtuosoContext = ({ context, ...props }) => ( <DataTable {...props} @@ -64,6 +92,8 @@ const DataTableRowWithVirtuosoContext = ({ context, item, ...props }) => ( onMouseEnter={() => context.onMouseEnter(item)} onMouseLeave={context.onMouseLeave} onContextMenu={(e) => context.onContextMenu(e, item)} + onClick={(e) => context.onRowClick(item, e)} + onDoubleClick={() => context.onRowDoubleClick(item)} {...props} /> ) @@ -73,6 +103,8 @@ DataTableRowWithVirtuosoContext.propTypes = { onContextMenu: PropTypes.func, onMouseEnter: PropTypes.func, onMouseLeave: PropTypes.func, + onRowClick: PropTypes.func, + onRowDoubleClick: PropTypes.func, }), item: PropTypes.arrayOf( PropTypes.shape({ @@ -99,12 +131,13 @@ const TableComponents = { ), } -const Table = ({ availableWidth, onCountChange }) => { +const Table = ({ availableWidth, onCountChange, showOnlySelected }) => { const { systemSettings: { keyAnalysisDigitGroupSeparator }, } = useCachedData() const headerRowRef = useRef(null) + const virtuosoRef = useRef(null) const [columnWidths, setColumnWidths] = useState([]) const minColumnWidthsRef = useRef([]) const { mapViews } = useSelector((state) => state.map) @@ -112,6 +145,11 @@ const Table = ({ availableWidth, onCountChange }) => { const dispatch = useDispatch() const feature = useSelector((state) => state.feature) + const selection = useSelector((state) => state.selection) + const showOnlyFeaturesInView = useSelector( + (state) => state.ui.showOnlyFeaturesInView + ) + const mapBounds = useSelector((state) => state.ui.mapBounds) const [{ sortField, sortDirection }, setSorting] = useReducer( (sorting, newSorting) => ({ ...sorting, ...newSorting }), { @@ -137,8 +175,7 @@ const Table = ({ availableWidth, onCountChange }) => { const setFeatureHighlight = useCallback( (row) => { - const id = - row.find((r) => r.dataKey === 'id')?.value || row[0].itemId + const id = getRowId(row) if (!id || !feature || id !== feature.id) { dispatch( @@ -181,8 +218,7 @@ const Table = ({ availableWidth, onCountChange }) => { const onRowContextMenu = useCallback( (e, row) => { e.preventDefault() - const id = - row.find((r) => r.dataKey === 'id')?.value || row[0]?.itemId + const id = getRowId(row) const feature = featureById.get(id) setTableContextMenu({ x: e.clientX, @@ -193,31 +229,147 @@ const Table = ({ availableWidth, onCountChange }) => { [featureById] ) + const selectedIds = useMemo( + () => (selection.layerId === layer.id ? selection.ids : []), + [selection, layer.id] + ) + const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]) + + const { headers, rows, isLoading, error, totalCount, filteredCount } = + useTableData({ + layer, + sortField, + sortDirection, + showOnlyFeaturesInView, + mapBounds, + showOnlySelected, + selectedIdSet, + }) + + useEffect(() => { + onCountChange?.(totalCount, filteredCount) + }, [onCountChange, totalCount, filteredCount]) + + const lastClickedRowIndexRef = useRef(null) + + const onRowClick = useCallback( + (row, event) => { + const id = getRowId(row) + + if (!id || !rows) { + return + } + + const rowIndex = rows.findIndex((r) => getRowId(r) === id) + const action = getRowClickAction(event, { + id, + rowIndex, + rows, + lastClickedRowIndex: lastClickedRowIndexRef.current, + }) + + if (!action) { + return + } + + if (action.type === 'range') { + dispatch(selectFeatureRange(action.ids, layer.id)) + } else { + dispatch(toggleFeatureSelection(action.id, layer.id)) + } + lastClickedRowIndexRef.current = rowIndex + }, + [dispatch, layer.id, rows] + ) + + const onRowDoubleClick = useCallback( + (row) => { + const id = getRowId(row) + + if (!id) { + return + } + + dispatch( + highlightFeature({ + id, + layerId: layer.id, + origin: 'table', + zoom: true, + }) + ) + }, + [dispatch, layer.id] + ) + const tableContext = useMemo( () => ({ onMouseEnter: setFeatureHighlight, onMouseLeave: clearFeatureHighlight, onContextMenu: onRowContextMenu, + onRowClick, + onRowDoubleClick, layout: columnWidths.length > 0 ? 'fixed' : 'auto', }), [ setFeatureHighlight, clearFeatureHighlight, onRowContextMenu, + onRowClick, + onRowDoubleClick, columnWidths, ] ) - const { headers, rows, isLoading, error, totalCount, filteredCount } = - useTableData({ - layer, - sortField, - sortDirection, - }) - + const lastClickedFeature = useSelector( + (state) => state.ui.lastClickedFeature + ) + const rowsRef = useRef(rows) + rowsRef.current = rows useEffect(() => { - onCountChange?.(totalCount, filteredCount) - }, [onCountChange, totalCount, filteredCount]) + if (!lastClickedFeature || lastClickedFeature.layerId !== layer.id) { + return + } + const currentRows = rowsRef.current + if (!currentRows) { + return + } + const rowIndex = currentRows.findIndex( + (row) => getRowId(row) === lastClickedFeature.id + ) + if (rowIndex !== -1) { + virtuosoRef.current?.scrollToIndex({ + index: rowIndex, + align: 'center', + behavior: 'smooth', + }) + } + }, [lastClickedFeature, layer.id]) + + const allRowIds = useMemo( + () => rows?.map(getRowId).filter(Boolean) ?? [], + [rows] + ) + const allRowIdSet = useMemo(() => new Set(allRowIds), [allRowIds]) + + const isAllSelected = useMemo( + () => + allRowIds.length > 0 && + allRowIds.every((id) => selectedIdSet.has(id)), + [allRowIds, selectedIdSet] + ) + + const onToggleSelectAll = useCallback(() => { + const nextIds = isAllSelected + ? selectedIds.filter((id) => !allRowIdSet.has(id)) + : [...new Set([...selectedIds, ...allRowIds])] + + if (nextIds.length) { + dispatch(selectAllFeatures(nextIds, layer.id)) + } else { + dispatch(clearSelection()) + } + }, [dispatch, isAllSelected, allRowIds, allRowIdSet, selectedIds, layer.id]) useEffect(() => { // Measure column widths in auto layout, then switch to fixed to prevent content shift during virtual scrolling @@ -225,7 +377,11 @@ const Table = ({ availableWidth, onCountChange }) => { requestAnimationFrame(() => { const measuredColumnWidths = [] - for (const cell of headerRowRef.current.cells) { + const dataCells = Array.from(headerRowRef.current.cells).slice( + 1 + ) + + for (const cell of dataCells) { const rect = cell.getBoundingClientRect() measuredColumnWidths.push(Math.floor(rect.width)) } @@ -273,6 +429,7 @@ const Table = ({ availableWidth, onCountChange }) => { return ( <> <TableVirtuoso + ref={virtuosoRef} context={tableContext} components={TableComponents} style={{ @@ -282,6 +439,17 @@ const Table = ({ availableWidth, onCountChange }) => { data={rows} fixedHeaderContent={() => ( <DataTableRow ref={headerRowRef}> + <DataTableColumnHeader + className={styles.checkboxCell} + width="32px" + > + <input + type="checkbox" + title={i18n.t('Select all')} + checked={isAllSelected} + onChange={onToggleSelectAll} + /> + </DataTableColumnHeader> {headers.map(({ name, dataKey, type }, index) => ( <DataTableColumnHeader className={styles.columnHeader} @@ -333,26 +501,65 @@ const Table = ({ availableWidth, onCountChange }) => { ))} </DataTableRow> )} - itemContent={(_, row) => - row.map(({ dataKey, value, align }) => ( - <DataTableCell - key={`dtcell-${dataKey}`} - className={cx(styles.dataCell, { - [styles.lightText]: - dataKey === 'color' && isDarkColor(value), - })} - backgroundColor={dataKey === 'color' ? value : null} - align={align} - > - {dataKey === 'color' - ? value?.toLowerCase() - : formatWithSeparator( - value, - keyAnalysisDigitGroupSeparator - )} - </DataTableCell> - )) - } + itemContent={(_, row) => { + const rowId = getRowId(row) + const isSelected = !!rowId && selectedIdSet.has(rowId) + const isHovered = + !!rowId && + feature?.id === rowId && + feature?.layerId === layer.id + + return ( + <> + <DataTableCell + staticStyle + className={cx(styles.checkboxCell, { + [styles.selected]: isSelected, + [styles.hovered]: isHovered, + })} + > + <input + type="checkbox" + checked={isSelected} + onChange={() => + rowId && + dispatch( + toggleFeatureSelection( + rowId, + layer.id + ) + ) + } + onClick={(e) => e.stopPropagation()} + /> + </DataTableCell> + {row.map(({ dataKey, value, align }) => ( + <DataTableCell + key={`dtcell-${dataKey}`} + staticStyle + className={cx(styles.dataCell, { + [styles.lightText]: + dataKey === 'color' && + isDarkColor(value), + [styles.selected]: isSelected, + [styles.hovered]: isHovered, + })} + backgroundColor={ + dataKey === 'color' ? value : null + } + align={align} + > + {dataKey === 'color' + ? value?.toLowerCase() + : formatWithSeparator( + value, + keyAnalysisDigitGroupSeparator + )} + </DataTableCell> + ))} + </> + ) + }} /> {(isLoading || layer?.isLoaded === false || layer?.isLoading) && ( <ComponentCover> @@ -364,6 +571,7 @@ const Table = ({ availableWidth, onCountChange }) => { <TableContextMenu contextMenu={tableContextMenu} layer={layer} + selectedIds={selectedIds} onClose={() => setTableContextMenu(null)} /> </> @@ -372,6 +580,7 @@ const Table = ({ availableWidth, onCountChange }) => { Table.propTypes = { availableWidth: PropTypes.number, + showOnlySelected: PropTypes.bool, onCountChange: PropTypes.func, } diff --git a/src/components/datatable/ResizeHandle.jsx b/src/components/datatable/ResizeHandle.jsx index f591aac527..13985be16c 100644 --- a/src/components/datatable/ResizeHandle.jsx +++ b/src/components/datatable/ResizeHandle.jsx @@ -12,6 +12,7 @@ EMPTY_DRAG_IMAGE.src = const ResizeHandle = ({ onResize, + onResizeStart, onResizeEnd, minHeight = 50, maxHeight = 500, @@ -26,6 +27,8 @@ const ResizeHandle = ({ evt.dataTransfer.setData('text/plain', 'node') // Required to initialize dragging in Firefox + onResizeStart?.() + // https://stackoverflow.com/questions/23992091/drag-and-drop-directive-no-e-clientx-or-e-clienty-on-drag-event-in-firefox document.ondragover = onDrag } @@ -80,6 +83,7 @@ ResizeHandle.propTypes = { minHeight: PropTypes.number, onResize: PropTypes.func, onResizeEnd: PropTypes.func, + onResizeStart: PropTypes.func, } export default ResizeHandle diff --git a/src/components/datatable/TableContextMenu.jsx b/src/components/datatable/TableContextMenu.jsx index 79692e01c5..21f582693e 100644 --- a/src/components/datatable/TableContextMenu.jsx +++ b/src/components/datatable/TableContextMenu.jsx @@ -24,7 +24,7 @@ import { drillUpDown } from '../../util/map.js' import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' import { IconZoomIn16 } from '../core/icons.jsx' -const TableContextMenu = ({ contextMenu, layer, onClose }) => { +const TableContextMenu = ({ contextMenu, layer, selectedIds, onClose }) => { const anchorRef = useRef() const dispatch = useDispatch() const { @@ -159,6 +159,38 @@ const TableContextMenu = ({ contextMenu, layer, onClose }) => { }} /> )} + <MenuItem + dataTest="data-table-context-menu-zoom-to-layer" + label={i18n.t('Zoom to layer')} + icon={<IconZoomIn16 />} + onClick={() => { + dispatch( + highlightFeature({ + layerId: layer.id, + origin: 'table', + zoom: true, + }) + ) + onClose() + }} + /> + <MenuItem + dataTest="data-table-context-menu-zoom-to-selected" + label={i18n.t('Zoom to selected features')} + icon={<IconZoomIn16 />} + disabled={!selectedIds?.length} + onClick={() => { + dispatch( + highlightFeature({ + ids: selectedIds, + layerId: layer.id, + origin: 'table', + zoom: true, + }) + ) + onClose() + }} + /> </Menu> </Popover> </> @@ -173,6 +205,7 @@ TableContextMenu.propTypes = { x: PropTypes.number, y: PropTypes.number, }), + selectedIds: PropTypes.array, } export default TableContextMenu diff --git a/src/components/datatable/__tests__/DataTable.spec.jsx b/src/components/datatable/__tests__/DataTable.spec.jsx index 5d18019348..e236e51833 100644 --- a/src/components/datatable/__tests__/DataTable.spec.jsx +++ b/src/components/datatable/__tests__/DataTable.spec.jsx @@ -1,4 +1,7 @@ -import { shouldClearFeatureHighlight } from '../DataTable.jsx' +import { + shouldClearFeatureHighlight, + getRowClickAction, +} from '../DataTable.jsx' // DataTable.jsx transitively imports MapApi.js (maplibre-gl), which is not // needed here and fails to load under jsdom. @@ -25,3 +28,57 @@ describe('shouldClearFeatureHighlight', () => { ).toBe(true) }) }) + +describe('getRowClickAction', () => { + const rows = [ + [{ dataKey: 'id', value: 'a', itemId: 'a' }], + [{ dataKey: 'id', value: 'b', itemId: 'b' }], + [{ dataKey: 'id', value: 'c', itemId: 'c' }], + [{ dataKey: 'id', value: 'd', itemId: 'd' }], + ] + + test('plain click is ignored', () => { + expect( + getRowClickAction( + {}, + { id: 'b', rowIndex: 1, rows, lastClickedRowIndex: null } + ) + ).toBeNull() + }) + + test('ctrl-click toggles just that row', () => { + expect( + getRowClickAction( + { ctrlKey: true }, + { id: 'b', rowIndex: 1, rows, lastClickedRowIndex: null } + ) + ).toEqual({ type: 'toggle', id: 'b' }) + }) + + test('shift-click with no prior anchor falls back to a single-row toggle', () => { + expect( + getRowClickAction( + { shiftKey: true }, + { id: 'c', rowIndex: 2, rows, lastClickedRowIndex: null } + ) + ).toEqual({ type: 'toggle', id: 'c' }) + }) + + test('shift-click with a prior anchor selects the range between them', () => { + expect( + getRowClickAction( + { shiftKey: true }, + { id: 'd', rowIndex: 3, rows, lastClickedRowIndex: 1 } + ) + ).toEqual({ type: 'range', ids: ['b', 'c', 'd'] }) + }) + + test('shift-click range works regardless of anchor/target order', () => { + expect( + getRowClickAction( + { shiftKey: true }, + { id: 'a', rowIndex: 0, rows, lastClickedRowIndex: 2 } + ) + ).toEqual({ type: 'range', ids: ['a', 'b', 'c'] }) + }) +}) diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index aa8d588656..c883914a57 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -838,3 +838,141 @@ describe('useTableData sorting', () => { expect(valueColumn).toEqual([null, null, null]) }) }) + +describe('useTableData showOnlyFeaturesInView', () => { + const store = { aggregations: {} } + const bounds = [-10, -10, 10, 10] + + const layer = { + id: 'test-layer', + layer: 'orgUnit', + dataFilters: null, + data: [ + { + id: 'inview', + properties: { id: 'inview', name: 'In view' }, + geometry: { type: 'Point', coordinates: [0, 0] }, + }, + { + id: 'outofview', + properties: { id: 'outofview', name: 'Out of view' }, + geometry: { type: 'Point', coordinates: [50, 50] }, + }, + ], + } + + const renderTableData = (props) => + renderHook(() => useTableData(props), { + wrapper: ({ children }) => ( + <Provider store={mockStore(store)}>{children}</Provider> + ), + }).result + + test('includes all rows when the toggle is off', () => { + const { current } = renderTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + showOnlyFeaturesInView: false, + mapBounds: bounds, + }) + expect(current.rows).toHaveLength(2) + }) + + test('excludes features outside the current map bounds when the toggle is on', () => { + const { current } = renderTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + showOnlyFeaturesInView: true, + mapBounds: bounds, + }) + expect(current.rows).toHaveLength(1) + expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( + 'In view' + ) + }) + + test('excludes features without geometry when the toggle is on', () => { + const layerWithoutCoords = { + ...layer, + data: [layer.data[0]], + dataWithoutCoords: [ + { + id: 'nogeom', + properties: { id: 'nogeom', name: 'No geometry' }, + geometry: null, + }, + ], + } + + const { current } = renderTableData({ + layer: layerWithoutCoords, + sortField: 'name', + sortDirection: 'asc', + showOnlyFeaturesInView: true, + mapBounds: bounds, + }) + expect(current.rows).toHaveLength(1) + expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( + 'In view' + ) + }) +}) + +describe('useTableData showOnlySelected', () => { + const store = { aggregations: {} } + + const layer = { + id: 'test-layer', + layer: 'orgUnit', + dataFilters: null, + data: [ + { id: 'a', properties: { id: 'a', name: 'Item A' } }, + { id: 'b', properties: { id: 'b', name: 'Item B' } }, + ], + } + + const renderTableData = (props) => + renderHook(() => useTableData(props), { + wrapper: ({ children }) => ( + <Provider store={mockStore(store)}>{children}</Provider> + ), + }).result + + test('includes all rows when the toggle is off', () => { + const { current } = renderTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + showOnlySelected: false, + selectedIdSet: new Set(['a']), + }) + expect(current.rows).toHaveLength(2) + }) + + test('includes only selected rows when the toggle is on', () => { + const { current } = renderTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + showOnlySelected: true, + selectedIdSet: new Set(['a']), + }) + expect(current.rows).toHaveLength(1) + expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( + 'Item A' + ) + }) + + test('shows no rows when the toggle is on and nothing is selected', () => { + const { current } = renderTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + showOnlySelected: true, + selectedIdSet: new Set(), + }) + expect(current.rows).toHaveLength(0) + }) +}) diff --git a/src/components/datatable/styles/BottomPanel.module.css b/src/components/datatable/styles/BottomPanel.module.css index 2d5dbaffb3..fc84b87494 100644 --- a/src/components/datatable/styles/BottomPanel.module.css +++ b/src/components/datatable/styles/BottomPanel.module.css @@ -33,7 +33,7 @@ font-weight: 500; font-size: 12px; color: var(--colors-grey800); - flex: 1; + flex: 0 1 auto; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; @@ -51,6 +51,7 @@ from { clip-path: inset(0 100% 0 0); } + to { clip-path: inset(0 0% 0 0); } @@ -111,7 +112,8 @@ } .clearFiltersButton, -.closeIcon { +.closeIcon, +.toggleButton { cursor: pointer; color: var(--colors-grey800); background-color: transparent; @@ -127,7 +129,33 @@ } .clearFiltersButton:hover, -.closeIcon:hover { +.closeIcon:hover, +.toggleButton:hover { color: var(--colors-grey900); background-color: var(--colors-grey300); } + +.toggleButton.active { + color: var(--colors-blue700); + background-color: var(--colors-blue100); +} + +.toggleButton.active:hover { + background-color: var(--colors-blue200); +} + +.highlightColorPicker { + margin-bottom: 0 !important; + flex-shrink: 0; + display: flex; + align-items: center; + position: relative; + top: -1px; +} + +.highlightColorPicker label { + box-sizing: border-box; + overflow: hidden; + min-width: 18px !important; + min-height: 18px !important; +} diff --git a/src/components/datatable/styles/DataTable.module.css b/src/components/datatable/styles/DataTable.module.css index 65a5b32ef8..055c0ea101 100644 --- a/src/components/datatable/styles/DataTable.module.css +++ b/src/components/datatable/styles/DataTable.module.css @@ -10,6 +10,7 @@ td.dataCell { padding-top: var(--spacers-dp8); padding-bottom: var(--spacers-dp8); font-size: 11px; + overflow-wrap: anywhere; } td.dataCell:hover { @@ -20,6 +21,24 @@ td.lightText { color: var(--colors-white); } +th.checkboxCell, +td.checkboxCell { + width: 32px; + min-width: 32px; + max-width: 32px; + text-align: center; + padding: 0; +} + +td.selected { + background-color: var(--colors-blue050); +} + +/* Declared after .selected so a hovered and selected row still shows the hover color */ +td.hovered { + background-color: var(--colors-blue100); +} + .columnHeader > :global(span.container) { justify-content: space-between; } diff --git a/src/components/datatable/styles/ResizeHandle.module.css b/src/components/datatable/styles/ResizeHandle.module.css index 27c0465abe..2bec5229a8 100644 --- a/src/components/datatable/styles/ResizeHandle.module.css +++ b/src/components/datatable/styles/ResizeHandle.module.css @@ -2,7 +2,8 @@ display: flex; justify-content: center; align-items: center; - width: 100%; + flex: 1 1 auto; + min-width: 24px; height: 100%; z-index: 1500; cursor: grab; diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index 12d8ebe2b7..55389a70ad 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -12,7 +12,7 @@ import { import { numberValueTypes } from '../../constants/valueTypes.js' import { hasClasses } from '../../util/earthEngine.js' import { filterData } from '../../util/filter.js' -import { getGeojsonDisplayData } from '../../util/geojson.js' +import { getGeojsonDisplayData, isFeatureInBounds } from '../../util/geojson.js' import { parseRange } from '../../util/legend.js' import { getRoundToPrecisionFn, getPrecision } from '../../util/numbers.js' import { isValidUid } from '../../util/uid.js' @@ -197,7 +197,15 @@ const getGeoJsonUrlHeaders = (firstDataItem) => const EMPTY_AGGREGATIONS = {} const EMPTY_LAYER = {} -export const useTableData = ({ layer, sortField, sortDirection }) => { +export const useTableData = ({ + layer, + sortField, + sortDirection, + showOnlyFeaturesInView, + mapBounds, + showOnlySelected, + selectedIdSet, +}) => { const allAggregations = useSelector((state) => state.aggregations) const aggregations = allAggregations[layer.id] || EMPTY_AGGREGATIONS @@ -216,6 +224,8 @@ export const useTableData = ({ layer, sortField, sortDirection }) => { serverCluster, } = layer || EMPTY_LAYER + const boundsDependency = showOnlyFeaturesInView ? mapBounds : null + const dataWithAggregations = useMemo(() => { errorCode.current = null if (serverCluster) { @@ -232,20 +242,34 @@ export const useTableData = ({ layer, sortField, sortDirection }) => { return null } + const inViewData = showOnlyFeaturesInView + ? allData.filter((d) => isFeatureInBounds(d, mapBounds)) + : allData + if (layerType === GEOJSON_URL_LAYER) { - return allData.map((d) => ({ + return inViewData.map((d) => ({ ...d.properties, })) } - return allData + return inViewData .filter((d) => !d.properties.hasAdditionalGeometry) .map((d, index) => ({ ...(d.properties || d), ...aggregations[d.id], index, })) - }, [data, dataWithoutCoords, aggregations, serverCluster, layerType]) + // boundsDependency intentionally proxies mapBounds only while the toggle is on + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + data, + dataWithoutCoords, + aggregations, + serverCluster, + layerType, + showOnlyFeaturesInView, + boundsDependency, + ]) const headers = useMemo(() => { if (errorCode.current) { @@ -321,7 +345,13 @@ export const useTableData = ({ layer, sortField, sortDirection }) => { return null } - const filteredData = filterData(dataWithAggregations, dataFilters) + let filteredData = filterData(dataWithAggregations, dataFilters) + + if (showOnlySelected) { + filteredData = filteredData.filter((item) => + selectedIdSet?.has(item.id) + ) + } //sort filteredData.sort((a, b) => { @@ -376,7 +406,15 @@ export const useTableData = ({ layer, sortField, sortDirection }) => { } }) ) - }, [headers, dataWithAggregations, dataFilters, sortField, sortDirection]) + }, [ + headers, + dataWithAggregations, + dataFilters, + sortField, + sortDirection, + showOnlySelected, + selectedIdSet, + ]) // EE layers and event layers may be loading additional data const isLoading = diff --git a/src/components/map/ContextMenu.jsx b/src/components/map/ContextMenu.jsx index a7a4255293..3c6f923244 100644 --- a/src/components/map/ContextMenu.jsx +++ b/src/components/map/ContextMenu.jsx @@ -23,6 +23,8 @@ import { FACILITY_LAYER, GEOJSON_URL_LAYER, EARTH_ENGINE_LAYER, + EVENT_LAYER, + TRACKED_ENTITY_LAYER, RENDERING_STRATEGY_SPLIT_BY_PERIOD, } from '../../constants/layers.js' import { getGeojsonFeatureProfile } from '../../util/geojson.js' @@ -43,6 +45,7 @@ const ContextMenu = (props) => { layerConfig, coordinates, earthEngineLayers, + selectedIds, position, offset, closeContextMenu, @@ -61,6 +64,9 @@ const ContextMenu = (props) => { const isSplitView = layerConfig?.renderingStrategy === RENDERING_STRATEGY_SPLIT_BY_PERIOD + const supportsProfileAndDrill = + layerType !== EVENT_LAYER && layerType !== TRACKED_ENTITY_LAYER + const left = offset[0] + position[0] const top = offset[1] + position[1] @@ -117,6 +123,21 @@ const ContextMenu = (props) => { zoom: true, }) break + case 'zoom_to_layer': + highlightFeature({ + layerId: layerConfig.id, + origin: 'map', + zoom: true, + }) + break + case 'zoom_to_selected': + highlightFeature({ + ids: selectedIds, + layerId: layerConfig.id, + origin: 'map', + zoom: true, + }) + break default: } @@ -137,7 +158,8 @@ const ContextMenu = (props) => { > <div className={styles.menu}> <Menu dense dataTest="context-menu"> - {layerType !== FACILITY_LAYER && + {supportsProfileAndDrill && + layerType !== FACILITY_LAYER && layerType !== GEOJSON_URL_LAYER && feature && ( <MenuItem @@ -149,7 +171,8 @@ const ContextMenu = (props) => { /> )} - {layerType !== FACILITY_LAYER && + {supportsProfileAndDrill && + layerType !== FACILITY_LAYER && layerType !== GEOJSON_URL_LAYER && feature && ( <MenuItem @@ -161,7 +184,7 @@ const ContextMenu = (props) => { /> )} - {feature && ( + {supportsProfileAndDrill && feature && ( <MenuItem dataTest="context-menu-view-profile" label={i18n.t('View profile')} @@ -179,6 +202,23 @@ const ContextMenu = (props) => { /> )} + {feature && ( + <MenuItem + dataTest="context-menu-zoom-to-layer" + label={i18n.t('Zoom to layer')} + icon={<IconZoomIn16 />} + onClick={() => onClick('zoom_to_layer')} + /> + )} + + <MenuItem + dataTest="context-menu-zoom-to-selected" + label={i18n.t('Zoom to selected features')} + icon={<IconZoomIn16 />} + disabled={!selectedIds.length} + onClick={() => onClick('zoom_to_selected')} + /> + {coordinates && !isSplitView && ( <MenuItem dataTest="context-menu-show-long-lat" @@ -224,14 +264,19 @@ ContextMenu.propTypes = { map: PropTypes.object, offset: PropTypes.array, position: PropTypes.array, + selectedIds: PropTypes.array, } export default connect( - ({ contextMenu, map }) => ({ + ({ contextMenu, map, selection }) => ({ ...contextMenu, earthEngineLayers: map.mapViews.filter( (view) => view.layer === EARTH_ENGINE_LAYER ), + selectedIds: + selection.layerId === contextMenu?.layerConfig?.id + ? selection.ids + : [], }), { closeContextMenu, diff --git a/src/components/map/Map.jsx b/src/components/map/Map.jsx index bc3571c6ed..3340801468 100644 --- a/src/components/map/Map.jsx +++ b/src/components/map/Map.jsx @@ -40,11 +40,14 @@ class Map extends Component { openContextMenu: PropTypes.func.isRequired, basemap: PropTypes.object, bounds: PropTypes.array, + clickFeature: PropTypes.func, closeCoordinatePopup: PropTypes.func, controls: PropTypes.array, coordinatePopup: PropTypes.array, engine: PropTypes.object, feature: PropTypes.object, + highlightColor: PropTypes.string, + highlightFeature: PropTypes.func, isFullscreen: PropTypes.bool, isPlugin: PropTypes.bool, latitude: PropTypes.number, @@ -53,9 +56,12 @@ class Map extends Component { longitude: PropTypes.number, nameProperty: PropTypes.string, resizeCount: PropTypes.number, + selection: PropTypes.object, setAggregations: PropTypes.func, setFeatureProfile: PropTypes.func, setMapObject: PropTypes.func, + showOnlySelected: PropTypes.bool, + toggleFeatureSelection: PropTypes.func, zoom: PropTypes.number, } @@ -176,6 +182,12 @@ class Map extends Component { nameProperty, layers, feature, + selection, + highlightFeature, + highlightColor, + showOnlySelected, + clickFeature, + toggleFeatureSelection, coordinatePopup: coordinates, closeCoordinatePopup, openContextMenu, @@ -222,6 +234,14 @@ class Map extends Component { key={config.id} index={layers.length - index} feature={highlight} + selection={selection} + highlightFeature={highlightFeature} + highlightColor={highlightColor} + showOnlySelected={showOnlySelected} + clickFeature={clickFeature} + toggleFeatureSelection={ + toggleFeatureSelection + } openContextMenu={openContextMenu} setAggregations={setAggregations} setFeatureProfile={setFeatureProfile} diff --git a/src/components/map/MapContainer.jsx b/src/components/map/MapContainer.jsx index 3ae0ee48ec..97a5acda70 100644 --- a/src/components/map/MapContainer.jsx +++ b/src/components/map/MapContainer.jsx @@ -1,10 +1,16 @@ import PropTypes from 'prop-types' -import React from 'react' +import React, { useCallback } from 'react' import { useSelector, useDispatch } from 'react-redux' import { setAggregations } from '../../actions/aggregations.js' -import { setFeatureProfile } from '../../actions/feature.js' +import { + highlightFeature, + setFeatureProfile, + clickFeature, +} from '../../actions/feature.js' import { openContextMenu, closeCoordinatePopup } from '../../actions/map.js' +import { toggleFeatureSelection } from '../../actions/selection.js' import useBasemapConfig from '../../hooks/useBasemapConfig.js' +import useDebouncedHighlightFeature from '../../hooks/useDebouncedHighlightFeature.js' import MapLoadingMask from './MapLoadingMask.jsx' import MapName from './MapName.jsx' import MapView from './MapView.jsx' @@ -17,10 +23,21 @@ const MapContainer = ({ resizeCount, setMap }) => { (state) => !!state.interpretation.id ) const feature = useSelector((state) => state.feature) - const { layersSorting } = useSelector((state) => state.ui) + const selection = useSelector((state) => state.selection) + const { layersSorting, highlightColor, showOnlySelected } = useSelector( + (state) => state.ui + ) const basemapConfig = useBasemapConfig(basemap) const dispatch = useDispatch() + const dispatchHighlightFeature = useCallback( + (payload) => dispatch(highlightFeature(payload)), + [dispatch] + ) + const debouncedHighlightFeature = useDebouncedHighlightFeature( + dispatchHighlightFeature + ) + const loadedMapViews = mapViews.filter((layer) => layer.isLoaded) const isLoading = loadedMapViews.length !== mapViews.length @@ -33,6 +50,14 @@ const MapContainer = ({ resizeCount, setMap }) => { layers={loadedMapViews} bounds={bounds} feature={feature} + selection={selection} + highlightColor={highlightColor} + showOnlySelected={showOnlySelected} + highlightFeature={debouncedHighlightFeature} + clickFeature={(payload) => dispatch(clickFeature(payload))} + toggleFeatureSelection={(id, layerId) => + dispatch(toggleFeatureSelection(id, layerId)) + } openContextMenu={(config) => dispatch(openContextMenu(config))} coordinatePopup={coordinatePopup} interpretationModalOpen={interpretationModalOpen} diff --git a/src/components/map/MapPosition.jsx b/src/components/map/MapPosition.jsx index b66119cafa..0fba1fd211 100644 --- a/src/components/map/MapPosition.jsx +++ b/src/components/map/MapPosition.jsx @@ -1,6 +1,7 @@ import cx from 'classnames' import React, { useState, useEffect, useRef } from 'react' -import { useSelector } from 'react-redux' +import { useSelector, useDispatch } from 'react-redux' +import { setMapBounds } from '../../actions/dataTable.js' import { getSplitViewLayer } from '../../util/helpers.js' import DownloadMapInfo from '../download/DownloadMapInfo.jsx' import NorthArrow from '../download/NorthArrow.jsx' @@ -13,6 +14,7 @@ const MapPosition = () => { const [map, setMap] = useState() const [resizeCount, setResizeCount] = useState(0) const outerRef = useRef(null) + const dispatch = useDispatch() const { showName, showDescription, @@ -83,6 +85,36 @@ const MapPosition = () => { } }, [map, mapId]) + // Track map bounds in Redux for the "show only features in view" data table toggle + useEffect(() => { + if (!map) { + return + } + + const mapgl = map.getMapGL() + + if (!mapgl) { + return + } + + const emitBounds = () => { + const b = mapgl.getBounds() + dispatch( + setMapBounds([ + b.getWest(), + b.getSouth(), + b.getEast(), + b.getNorth(), + ]) + ) + } + + emitBounds() + mapgl.on('moveend', emitBounds) + + return () => mapgl.off('moveend', emitBounds) + }, [map, dispatch]) + // Fit layer bounds when app mode is toggled useEffect(() => { if (map) { diff --git a/src/components/map/MapView.jsx b/src/components/map/MapView.jsx index 4cd1a60233..92a5624177 100644 --- a/src/components/map/MapView.jsx +++ b/src/components/map/MapView.jsx @@ -17,6 +17,12 @@ const MapView = (props) => { layers, controls, feature, + selection, + highlightFeature, + highlightColor, + showOnlySelected, + clickFeature, + toggleFeatureSelection, bounds, coordinatePopup, interpretationModalOpen, @@ -57,6 +63,12 @@ const MapView = (props) => { layers={splitViewLayers.reverse()} controls={mapControls} feature={feature} + selection={selection} + highlightFeature={highlightFeature} + highlightColor={highlightColor} + showOnlySelected={showOnlySelected} + clickFeature={clickFeature} + toggleFeatureSelection={toggleFeatureSelection} interpretationModalOpen={interpretationModalOpen} openContextMenu={openContextMenu} resizeCount={resizeCount} @@ -72,6 +84,12 @@ const MapView = (props) => { bounds={bounds} controls={mapControls} feature={feature} + selection={selection} + highlightFeature={highlightFeature} + highlightColor={highlightColor} + showOnlySelected={showOnlySelected} + clickFeature={clickFeature} + toggleFeatureSelection={toggleFeatureSelection} coordinatePopup={coordinatePopup} openContextMenu={openContextMenu} resizeCount={resizeCount} @@ -92,9 +110,12 @@ const MapView = (props) => { MapView.propTypes = { basemap: PropTypes.object, bounds: PropTypes.array, + clickFeature: PropTypes.func, controls: PropTypes.array, coordinatePopup: PropTypes.array, feature: PropTypes.object, + highlightColor: PropTypes.string, + highlightFeature: PropTypes.func, interpretationModalOpen: PropTypes.bool, isFullscreen: PropTypes.bool, isPlugin: PropTypes.bool, @@ -102,7 +123,10 @@ MapView.propTypes = { layersSorting: PropTypes.bool, openContextMenu: PropTypes.func, resizeCount: PropTypes.number, + selection: PropTypes.object, setMapObject: PropTypes.func, + showOnlySelected: PropTypes.bool, + toggleFeatureSelection: PropTypes.func, } export default MapView diff --git a/src/components/map/SplitView.jsx b/src/components/map/SplitView.jsx index c3268e96a5..c6cfab5e73 100644 --- a/src/components/map/SplitView.jsx +++ b/src/components/map/SplitView.jsx @@ -13,6 +13,12 @@ const SplitView = ({ basemap, layers, feature, + selection, + highlightFeature, + highlightColor, + showOnlySelected, + clickFeature, + toggleFeatureSelection, controls, openContextMenu = Function.prototype, isFullscreen, @@ -92,6 +98,12 @@ const SplitView = ({ index={layers.length - index} externalPeriod={period} feature={feature} + selection={selection} + highlightFeature={highlightFeature} + highlightColor={highlightColor} + showOnlySelected={showOnlySelected} + clickFeature={clickFeature} + toggleFeatureSelection={toggleFeatureSelection} openContextMenu={openContextMenu} /> ) @@ -113,14 +125,20 @@ SplitView.propTypes = { ).isRequired, openContextMenu: PropTypes.func.isRequired, basemap: PropTypes.object, + clickFeature: PropTypes.func, controls: PropTypes.array, feature: PropTypes.object, + highlightColor: PropTypes.string, + highlightFeature: PropTypes.func, interpretationModalOpen: PropTypes.bool, isFullscreen: PropTypes.bool, isPlugin: PropTypes.bool, layersSorting: PropTypes.bool, resizeCount: PropTypes.number, + selection: PropTypes.object, setMapObject: PropTypes.func, + showOnlySelected: PropTypes.bool, + toggleFeatureSelection: PropTypes.func, } export default SplitView diff --git a/src/components/map/layers/EventLayer.jsx b/src/components/map/layers/EventLayer.jsx index b132e09e74..8b392414c6 100644 --- a/src/components/map/layers/EventLayer.jsx +++ b/src/components/map/layers/EventLayer.jsx @@ -129,6 +129,9 @@ class EventLayer extends Layer { countColor, radius, onClick: this.onEventClick.bind(this), + onRightClick: this.onFeatureRightClick.bind(this), + onMouseEnter: this.onFeatureMouseEnter.bind(this), + onMouseLeave: this.onFeatureMouseLeave.bind(this), ...(styleDataItem && { hoverLabel: LABEL_TEMPLATE_TOOLTIP_ONLY }), ...(labelDataItem && labels && { @@ -267,8 +270,14 @@ class EventLayer extends Layer { ) : null } - onEventClick({ feature, coordinates }) { - this.setState({ popup: { feature, coordinates } }) + onEventClick(evt) { + const { feature, coordinates } = evt + + this.onFeatureLeftClick(evt) + + if (!this.isMultiSelectClick(evt)) { + this.setState({ popup: { feature, coordinates } }) + } } onPopupClose = () => { diff --git a/src/components/map/layers/FacilityLayer.jsx b/src/components/map/layers/FacilityLayer.jsx index 3ff48172b6..aadaf3aced 100644 --- a/src/components/map/layers/FacilityLayer.jsx +++ b/src/components/map/layers/FacilityLayer.jsx @@ -63,6 +63,8 @@ class FacilityLayer extends Layer { }, onClick: this.onFeatureClick.bind(this), onRightClick: this.onFeatureRightClick.bind(this), + onMouseEnter: this.onFeatureMouseEnter.bind(this), + onMouseLeave: this.onFeatureMouseLeave.bind(this), onError: this.onError.bind(this), } @@ -90,6 +92,8 @@ class FacilityLayer extends Layer { }, onClick: this.onAssociatedGeometryClick.bind(this), onRightClick: this.onFeatureRightClick.bind(this), + onMouseEnter: this.onFeatureMouseEnter.bind(this), + onMouseLeave: this.onFeatureMouseLeave.bind(this), }) } @@ -154,7 +158,11 @@ class FacilityLayer extends Layer { } onFeatureClick(evt) { - this.setState({ popup: evt }) + this.onFeatureLeftClick(evt) + + if (!this.isMultiSelectClick(evt)) { + this.setState({ popup: evt }) + } } onAssociatedGeometryClick(evt) { diff --git a/src/components/map/layers/GeoJsonLayer.js b/src/components/map/layers/GeoJsonLayer.js index 75895fac0b..864de180a2 100644 --- a/src/components/map/layers/GeoJsonLayer.js +++ b/src/components/map/layers/GeoJsonLayer.js @@ -47,6 +47,12 @@ class GeoJsonLayer extends Layer { onRightClick: isPlugin ? undefined : this.onFeatureRightClick.bind(this), + onMouseEnter: isPlugin + ? undefined + : this.onFeatureMouseEnter.bind(this), + onMouseLeave: isPlugin + ? undefined + : this.onFeatureMouseLeave.bind(this), }) map.addLayer(this.layer) @@ -59,6 +65,12 @@ class GeoJsonLayer extends Layer { onFeatureClick(evt) { const { name, keyAnalysisDigitGroupSeparator } = this.props + this.onFeatureLeftClick(evt) + + if (this.isMultiSelectClick(evt)) { + return + } + const feature = this.props.data.find( (d) => d.properties.id === evt.feature.properties.id ) diff --git a/src/components/map/layers/Layer.js b/src/components/map/layers/Layer.js index 8464556172..ff16e13de0 100644 --- a/src/components/map/layers/Layer.js +++ b/src/components/map/layers/Layer.js @@ -8,6 +8,9 @@ import { DURATION_DEFAULT, } from '../../../constants/layers.js' +export const idsEqual = (a, b) => + a.length === b.length && a.every((id, i) => id === b[i]) + class Layer extends PureComponent { static contextTypes = { map: PropTypes.object, @@ -16,16 +19,22 @@ class Layer extends PureComponent { static propTypes = { id: PropTypes.string.isRequired, + clickFeature: PropTypes.func, config: PropTypes.object, data: PropTypes.array, dataFilters: PropTypes.object, editCounter: PropTypes.number, externalPeriod: PropTypes.object, // eslint-disable-line react/no-unused-prop-types feature: PropTypes.object, + highlightColor: PropTypes.string, + highlightFeature: PropTypes.func, index: PropTypes.number, isVisible: PropTypes.bool, opacity: PropTypes.number, openContextMenu: PropTypes.func, + selection: PropTypes.object, + showOnlySelected: PropTypes.bool, + toggleFeatureSelection: PropTypes.func, } static defaultProps = { @@ -51,6 +60,9 @@ class Layer extends PureComponent { editCounter, dataFilters, feature, + selection, + highlightColor, + showOnlySelected, } = this.props const { period } = this.state const { period: prevPeriod } = prevState || {} @@ -86,6 +98,43 @@ class Layer extends PureComponent { if (feature !== prevProps.feature) { this.handleFeatureUpdate(feature) + + if ( + this.getHoverId(prevProps.feature) !== this.getHoverId(feature) + ) { + this.highlightFeature() + } + } + + if ( + selection !== prevProps.selection && + !idsEqual( + this.getSelectedIds(prevProps.selection), + this.getSelectedIds(selection) + ) + ) { + this.selectFeatures() + } + + if (highlightColor !== prevProps.highlightColor) { + if (this.getHoverId()) { + this.highlightFeature() + } + if (this.getSelectedIds().length) { + this.selectFeatures() + } + } + + if ( + !idsEqual( + this.getVisibleIds( + prevProps.selection, + prevProps.showOnlySelected + ) ?? [], + this.getVisibleIds(selection, showOnlySelected) ?? [] + ) + ) { + this.updateVisibleIds() } } @@ -115,7 +164,9 @@ class Layer extends PureComponent { await this.createLayer(true) this.setLayerOrder() this.setLayerVisibility() - this.highlightFeature(this.props.feature) + this.highlightFeature() + this.selectFeatures() + this.updateVisibleIds() } // Override in subclass if needed @@ -185,26 +236,90 @@ class Layer extends PureComponent { } handleFeatureUpdate(feature) { - this.highlightFeature(feature) if (feature?.zoom && feature?.layerId === this.props.id) { - this.panToFeature(feature.id) + if (feature.ids?.length) { + this.panToFeature(feature.ids) + } else if (feature.id != null) { + this.panToFeature(feature.id) + } else { + this.fitBounds() + } + } + } + + getHoverId(feature = this.props.feature) { + return feature?.layerId === this.props.id ? feature.id : null + } + + getSelectedIds(selection = this.props.selection) { + return selection?.layerId === this.props.id ? selection.ids : [] + } + + highlightFeature() { + this.layer?.highlight?.(this.getHoverId(), this.props.highlightColor) + } + + selectFeatures() { + this.layer?.select?.(this.getSelectedIds(), this.props.highlightColor) + } + + getVisibleIds( + selection = this.props.selection, + showOnlySelected = this.props.showOnlySelected + ) { + if (!showOnlySelected || selection?.layerId !== this.props.id) { + return null + } + return this.getSelectedIds(selection) + } + + updateVisibleIds() { + this.layer?.setVisibleIds?.(this.getVisibleIds()) + } + + onFeatureLeftClick(evt) { + const id = evt.feature?.properties?.id + + if (!id) { + return + } + + this.props.clickFeature?.({ id, layerId: this.props.id }) + + if (this.isMultiSelectClick(evt)) { + this.props.toggleFeatureSelection?.(id, this.props.id) } } - highlightFeature(feature) { - if (this.layer?.highlight) { - this.layer.highlight(feature ? feature.id : null) + isMultiSelectClick(evt) { + return Boolean(evt.ctrlKey || evt.metaKey) + } + + onFeatureMouseEnter(evt) { + const id = evt.feature?.properties?.id + + if (id) { + this.props.highlightFeature?.({ + id, + layerId: this.props.id, + origin: 'map', + }) } } - panToFeature(featureId) { + onFeatureMouseLeave() { + this.props.highlightFeature?.(null) + } + + panToFeature(featureIds) { if (!this.layer?.getFeaturesById) { return } - const features = this.layer - .getFeaturesById(featureId) - ?.filter((f) => f.geometry) - if (!features?.length) { + const ids = Array.isArray(featureIds) ? featureIds : [featureIds] + const features = ids + .flatMap((id) => this.layer.getFeaturesById(id) ?? []) + .filter((f) => f.geometry) + if (!features.length) { return } @@ -245,6 +360,11 @@ class Layer extends PureComponent { const { left, top } = container.getBoundingClientRect() const isSplitView = renderingStrategy === RENDERING_STRATEGY_SPLIT_BY_PERIOD + const id = evt.feature?.properties?.id + + if (id) { + this.props.clickFeature?.({ id, layerId: this.props.id }) + } this.props.openContextMenu({ ...evt, diff --git a/src/components/map/layers/OrgUnitLayer.jsx b/src/components/map/layers/OrgUnitLayer.jsx index 5e12478761..d509bec16f 100644 --- a/src/components/map/layers/OrgUnitLayer.jsx +++ b/src/components/map/layers/OrgUnitLayer.jsx @@ -47,6 +47,8 @@ export default class OrgUnitLayer extends Layer { }, onClick: this.onFeatureClick.bind(this), onRightClick: this.onFeatureRightClick.bind(this), + onMouseEnter: this.onFeatureMouseEnter.bind(this), + onMouseLeave: this.onFeatureMouseLeave.bind(this), } if (labels) { @@ -97,6 +99,10 @@ export default class OrgUnitLayer extends Layer { } onFeatureClick(evt) { - this.setState({ popup: evt }) + this.onFeatureLeftClick(evt) + + if (!this.isMultiSelectClick(evt)) { + this.setState({ popup: evt }) + } } } diff --git a/src/components/map/layers/ThematicLayer.jsx b/src/components/map/layers/ThematicLayer.jsx index 91b7abb29a..dd451d26a0 100644 --- a/src/components/map/layers/ThematicLayer.jsx +++ b/src/components/map/layers/ThematicLayer.jsx @@ -21,7 +21,7 @@ import { } from '../../../util/periods.js' import { poleOfInaccessibility } from '../MapApi.js' import Popup from '../Popup.jsx' -import Layer from './Layer.js' +import Layer, { idsEqual } from './Layer.js' import styles from './styles/Popup.module.css' export const ThematicLayerContext = React.createContext() @@ -67,6 +67,8 @@ class ThematicLayer extends Layer { color: noDataLegend?.color, onClick: this.onFeatureClick.bind(this), onRightClick: this.onFeatureRightClick.bind(this), + onMouseEnter: this.onFeatureMouseEnter.bind(this), + onMouseLeave: this.onFeatureMouseLeave.bind(this), } if (labels) { @@ -190,20 +192,6 @@ class ThematicLayer extends Layer { return <Fragment>{popup && this.getPopup()}</Fragment> } - highlightFeature(feature) { - const { thematicMapType = THEMATIC_CHOROPLETH } = this.props - if (thematicMapType === THEMATIC_BUBBLE) { - // LayerGroup has no highlight(); delegate to each sub-layer - this.layer?._layers?.forEach((l) => { - if (l.highlight) { - l.highlight(feature ? feature.id : null) - } - }) - } else { - super.highlightFeature(feature) - } - } - componentDidUpdate(prevProps) { const prevPeriodId = prevProps.externalPeriod?.id const newPeriodId = this.props.externalPeriod?.id @@ -225,9 +213,45 @@ class ThematicLayer extends Layer { this.setLayerOpacity() this.setLayerVisibility() this.setLayerOrder() - const { feature } = this.props + const { feature, selection, highlightColor, showOnlySelected } = + this.props if (feature !== prevProps.feature) { this.handleFeatureUpdate(feature) + + if ( + this.getHoverId(prevProps.feature) !== + this.getHoverId(feature) + ) { + this.highlightFeature() + } + } + if ( + selection !== prevProps.selection && + !idsEqual( + this.getSelectedIds(prevProps.selection), + this.getSelectedIds(selection) + ) + ) { + this.selectFeatures() + } + if (highlightColor !== prevProps.highlightColor) { + if (this.getHoverId()) { + this.highlightFeature() + } + if (this.getSelectedIds().length) { + this.selectFeatures() + } + } + if ( + !idsEqual( + this.getVisibleIds( + prevProps.selection, + prevProps.showOnlySelected + ) ?? [], + this.getVisibleIds(selection, showOnlySelected) ?? [] + ) + ) { + this.updateVisibleIds() } return } @@ -248,7 +272,9 @@ class ThematicLayer extends Layer { ) { try { this.layer.setData(filteredData) - this.highlightFeature(this.props.feature) + this.highlightFeature() + this.selectFeatures() + this.updateVisibleIds() } catch (e) { console.warn('Failed to set layer data incrementally:', e) // fallback to full update on error @@ -285,7 +311,11 @@ class ThematicLayer extends Layer { } onFeatureClick(evt) { - this.setState({ popup: evt }) + this.onFeatureLeftClick(evt) + + if (!this.isMultiSelectClick(evt)) { + this.setState({ popup: evt }) + } } buildPeriodData(props = this.props) { diff --git a/src/components/map/layers/TrackedEntityLayer.jsx b/src/components/map/layers/TrackedEntityLayer.jsx index 96f3f0841c..989c9f5573 100644 --- a/src/components/map/layers/TrackedEntityLayer.jsx +++ b/src/components/map/layers/TrackedEntityLayer.jsx @@ -86,6 +86,9 @@ class TrackedEntityLayer extends Layer { radius, }, onClick: this.onEventClick.bind(this), + onRightClick: this.onFeatureRightClick.bind(this), + onMouseEnter: this.onFeatureMouseEnter.bind(this), + onMouseLeave: this.onFeatureMouseLeave.bind(this), } if (areaRadius) { @@ -119,6 +122,9 @@ class TrackedEntityLayer extends Layer { radius: relatedPointRadius || TEI_RELATED_RADIUS, }, onClick: this.onEventClickSecondary.bind(this), + onRightClick: this.onFeatureRightClick.bind(this), + onMouseEnter: this.onFeatureMouseEnter.bind(this), + onMouseLeave: this.onFeatureMouseLeave.bind(this), } const relationshipConfig = makeRelationshipLayer( @@ -157,10 +163,16 @@ class TrackedEntityLayer extends Layer { ) : null } - onEventClick({ feature, coordinates }) { - this.setState({ - popup: { feature, coordinates, activeDataSource: 'primary' }, - }) + onEventClick(evt) { + const { feature, coordinates } = evt + + this.onFeatureLeftClick(evt) + + if (!this.isMultiSelectClick(evt)) { + this.setState({ + popup: { feature, coordinates, activeDataSource: 'primary' }, + }) + } } onEventClickSecondary({ feature, coordinates }) { this.setState({ diff --git a/src/components/map/layers/earthEngine/EarthEngineLayer.jsx b/src/components/map/layers/earthEngine/EarthEngineLayer.jsx index 2978d561c2..35ddf16b41 100644 --- a/src/components/map/layers/earthEngine/EarthEngineLayer.jsx +++ b/src/components/map/layers/earthEngine/EarthEngineLayer.jsx @@ -45,7 +45,9 @@ export default class EarthEngineLayer extends Layer { await this.removeLayer() await this.createLayer(true) this.setLayerOrder() - this.highlightFeature(this.props.feature) + this.highlightFeature() + this.selectFeatures() + this.updateVisibleIds() } } @@ -116,6 +118,8 @@ export default class EarthEngineLayer extends Layer { preload: !isPlugin && this.hasAggregations(), onClick: this.onFeatureClick.bind(this), onRightClick: this.onFeatureRightClick.bind(this), + onMouseEnter: this.onFeatureMouseEnter.bind(this), + onMouseLeave: this.onFeatureMouseLeave.bind(this), onLoad: this.onLoad.bind(this), } @@ -253,8 +257,12 @@ export default class EarthEngineLayer extends Layer { } onFeatureClick(evt) { - this.getAggregations() - this.setState({ popup: evt }) + this.onFeatureLeftClick(evt) + + if (!this.isMultiSelectClick(evt)) { + this.getAggregations() + this.setState({ popup: evt }) + } } onLoad() { diff --git a/src/components/plugin/Map.jsx b/src/components/plugin/Map.jsx index 32471e8a88..97b9c87fe0 100644 --- a/src/components/plugin/Map.jsx +++ b/src/components/plugin/Map.jsx @@ -12,6 +12,7 @@ import React, { useEffect, useRef, } from 'react' +import useDebouncedHighlightFeature from '../../hooks/useDebouncedHighlightFeature.js' import { drillUpDown } from '../../util/map.js' import { didViewsChange } from '../../util/pluginHelper.js' import MapView from '../map/MapView.jsx' @@ -55,6 +56,8 @@ const Map = forwardRef((props, ref) => { const [isFullscreen, setIsFullscreen] = useState( () => !!getFullscreenDoc().fullscreenElement ) + const [hoveredFeature, setHoveredFeature] = useState(null) + const highlightFeature = useDebouncedHighlightFeature(setHoveredFeature) const onResize = () => setResizeCount((state) => state + 1) @@ -180,6 +183,8 @@ const Map = forwardRef((props, ref) => { bounds={defaultBounds} openContextMenu={setContextMenu} resizeCount={resizeCount} + feature={hoveredFeature} + highlightFeature={highlightFeature} /> {mapViews.length > 0 && ( <Legend diff --git a/src/constants/actionTypes.js b/src/constants/actionTypes.js index 6bc917a61e..7b43834cd1 100644 --- a/src/constants/actionTypes.js +++ b/src/constants/actionTypes.js @@ -40,6 +40,12 @@ export const LAYER_DRILL = 'LAYER_DRILL' export const DATA_TABLE_CLOSE = 'DATA_TABLE_CLOSE' export const DATA_TABLE_TOGGLE = 'DATA_TABLE_TOGGLE' export const DATA_TABLE_RESIZE = 'DATA_TABLE_RESIZE' +export const MAP_BOUNDS_CHANGED = 'MAP_BOUNDS_CHANGED' +export const TOGGLE_SHOW_ONLY_IN_VIEW = 'TOGGLE_SHOW_ONLY_IN_VIEW' +export const TOGGLE_SHOW_ONLY_SELECTED = 'TOGGLE_SHOW_ONLY_SELECTED' +export const SHOW_ONLY_SELECTED_SET = 'SHOW_ONLY_SELECTED_SET' +export const HIGHLIGHT_COLOR_SET = 'HIGHLIGHT_COLOR_SET' +export const MAP_FEATURE_CLICKED = 'MAP_FEATURE_CLICKED' /* DATA FILTER */ export const DATA_FILTER_SET = 'DATA_FILTER_SET' @@ -182,6 +188,12 @@ export const FEATURE_HIGHLIGHT = 'FEATURE_HIGHLIGHT' export const FEATURE_PROFILE_SET = 'FEATURE_PROFILE_SET' export const FEATURE_PROFILE_CLOSE = 'FEATURE_PROFILE_CLOSE' +/* SELECTION */ +export const FEATURE_TOGGLE_SELECTION = 'FEATURE_TOGGLE_SELECTION' +export const SELECTION_SET_ALL = 'SELECTION_SET_ALL' +export const SELECTION_ADD_RANGE = 'SELECTION_ADD_RANGE' +export const SELECTION_CLEAR = 'SELECTION_CLEAR' + /* AGGREGATIONS */ export const AGGREGATIONS_SET = 'AGGREGATIONS_SET' diff --git a/src/hooks/__tests__/useDebouncedHighlightFeature.spec.js b/src/hooks/__tests__/useDebouncedHighlightFeature.spec.js new file mode 100644 index 0000000000..bae9696421 --- /dev/null +++ b/src/hooks/__tests__/useDebouncedHighlightFeature.spec.js @@ -0,0 +1,86 @@ +import { renderHook, act } from '@testing-library/react' +import useDebouncedHighlightFeature from '../useDebouncedHighlightFeature.js' + +describe('useDebouncedHighlightFeature', () => { + const setFeatureSpy = jest.fn() + + beforeEach(() => { + setFeatureSpy.mockClear() + }) + + it('calls setFeature immediately for a truthy payload', () => { + const { result } = renderHook(() => + useDebouncedHighlightFeature(setFeatureSpy) + ) + + act(() => { + result.current({ id: 'abc' }) + }) + + expect(setFeatureSpy).toHaveBeenCalledTimes(1) + expect(setFeatureSpy).toHaveBeenCalledWith({ id: 'abc' }) + }) + + it('debounces a null payload instead of calling setFeature immediately', () => { + jest.useFakeTimers() + const { result } = renderHook(() => + useDebouncedHighlightFeature(setFeatureSpy, 100) + ) + + act(() => { + result.current(null) + }) + expect(setFeatureSpy).not.toHaveBeenCalled() + + act(() => { + jest.advanceTimersByTime(100) + }) + expect(setFeatureSpy).toHaveBeenCalledTimes(1) + expect(setFeatureSpy).toHaveBeenCalledWith(null) + + jest.useRealTimers() + }) + + it('cancels a pending clear when a truthy payload arrives before the debounce elapses', () => { + jest.useFakeTimers() + const { result } = renderHook(() => + useDebouncedHighlightFeature(setFeatureSpy, 100) + ) + + act(() => { + result.current(null) + jest.advanceTimersByTime(50) + result.current({ id: 'xyz' }) + }) + + expect(setFeatureSpy).toHaveBeenCalledTimes(1) + expect(setFeatureSpy).toHaveBeenCalledWith({ id: 'xyz' }) + + act(() => { + jest.advanceTimersByTime(100) + }) + // The pending clear was cancelled, not just delayed further. + expect(setFeatureSpy).toHaveBeenCalledTimes(1) + + jest.useRealTimers() + }) + + it('clears the pending timeout on unmount without calling setFeature', () => { + jest.useFakeTimers() + const { result, unmount } = renderHook(() => + useDebouncedHighlightFeature(setFeatureSpy, 100) + ) + + act(() => { + result.current(null) + }) + unmount() + + act(() => { + jest.advanceTimersByTime(100) + }) + expect(setFeatureSpy).not.toHaveBeenCalled() + + jest.useRealTimers() + }) +}) diff --git a/src/hooks/useDebouncedHighlightFeature.js b/src/hooks/useDebouncedHighlightFeature.js new file mode 100644 index 0000000000..41d02711ef --- /dev/null +++ b/src/hooks/useDebouncedHighlightFeature.js @@ -0,0 +1,45 @@ +import { useCallback, useEffect, useRef } from 'react' + +const DEFAULT_HOVER_LEAVE_DEBOUNCE_MS = 100 + +// Debounces the "clear" side of a highlightFeature dispatcher +// socontinuous mouse movement doesn't flash the highlight +const useDebouncedHighlightFeature = ( + setFeature, + debounceMs = DEFAULT_HOVER_LEAVE_DEBOUNCE_MS +) => { + const hoverLeaveTimeoutRef = useRef(null) + + const debouncedHighlightFeature = useCallback( + (payload) => { + if (hoverLeaveTimeoutRef.current) { + clearTimeout(hoverLeaveTimeoutRef.current) + hoverLeaveTimeoutRef.current = null + } + + if (payload) { + setFeature(payload) + return + } + + hoverLeaveTimeoutRef.current = setTimeout(() => { + hoverLeaveTimeoutRef.current = null + setFeature(null) + }, debounceMs) + }, + [setFeature, debounceMs] + ) + + useEffect( + () => () => { + if (hoverLeaveTimeoutRef.current) { + clearTimeout(hoverLeaveTimeoutRef.current) + } + }, + [] + ) + + return debouncedHighlightFeature +} + +export default useDebouncedHighlightFeature diff --git a/src/reducers/__tests__/selection.spec.js b/src/reducers/__tests__/selection.spec.js new file mode 100644 index 0000000000..ddb81237ae --- /dev/null +++ b/src/reducers/__tests__/selection.spec.js @@ -0,0 +1,149 @@ +import * as types from '../../constants/actionTypes.js' +import selection from '../selection.js' + +describe('selection reducer', () => { + it('returns the default state', () => { + expect(selection(undefined, {})).toEqual({ layerId: null, ids: [] }) + }) + + it('selects a single feature on a fresh layer', () => { + const state = selection(undefined, { + type: types.FEATURE_TOGGLE_SELECTION, + id: 'a', + layerId: 'layer-1', + }) + + expect(state).toEqual({ layerId: 'layer-1', ids: ['a'] }) + }) + + it('adds a feature to the existing selection on the same layer', () => { + const state = selection( + { layerId: 'layer-1', ids: ['a'] }, + { + type: types.FEATURE_TOGGLE_SELECTION, + id: 'b', + layerId: 'layer-1', + } + ) + + expect(state).toEqual({ layerId: 'layer-1', ids: ['a', 'b'] }) + }) + + it('removes an already-selected feature (toggle off)', () => { + const state = selection( + { layerId: 'layer-1', ids: ['a', 'b'] }, + { + type: types.FEATURE_TOGGLE_SELECTION, + id: 'a', + layerId: 'layer-1', + } + ) + + expect(state).toEqual({ layerId: 'layer-1', ids: ['b'] }) + }) + + it('replaces the selection with a fresh single id when toggling on a different layer', () => { + const state = selection( + { layerId: 'layer-1', ids: ['a', 'b'] }, + { + type: types.FEATURE_TOGGLE_SELECTION, + id: 'c', + layerId: 'layer-2', + } + ) + + expect(state).toEqual({ layerId: 'layer-2', ids: ['c'] }) + }) + + it('sets the full selection in one action for "select all"', () => { + const state = selection( + { layerId: 'layer-1', ids: ['a'] }, + { + type: types.SELECTION_SET_ALL, + ids: ['a', 'b', 'c'], + layerId: 'layer-1', + } + ) + + expect(state).toEqual({ layerId: 'layer-1', ids: ['a', 'b', 'c'] }) + }) + + it('adds a range of ids to the existing selection on the same layer (Shift+Click)', () => { + const state = selection( + { layerId: 'layer-1', ids: ['a'] }, + { + type: types.SELECTION_ADD_RANGE, + ids: ['b', 'c', 'd'], + layerId: 'layer-1', + } + ) + + expect(state).toEqual({ layerId: 'layer-1', ids: ['a', 'b', 'c', 'd'] }) + }) + + it('dedupes ids already present when adding a range', () => { + const state = selection( + { layerId: 'layer-1', ids: ['a', 'b'] }, + { + type: types.SELECTION_ADD_RANGE, + ids: ['b', 'c'], + layerId: 'layer-1', + } + ) + + expect(state).toEqual({ layerId: 'layer-1', ids: ['a', 'b', 'c'] }) + }) + + it('starts a fresh selection when adding a range on a different layer', () => { + const state = selection( + { layerId: 'layer-1', ids: ['a', 'b'] }, + { + type: types.SELECTION_ADD_RANGE, + ids: ['x', 'y'], + layerId: 'layer-2', + } + ) + + expect(state).toEqual({ layerId: 'layer-2', ids: ['x', 'y'] }) + }) + + it.each([ + types.SELECTION_CLEAR, + types.MAP_NEW, + types.MAP_SET, + types.DATA_TABLE_CLOSE, + types.DATA_TABLE_TOGGLE, + ])('resets to default state on %s', (type) => { + const state = selection( + { layerId: 'layer-1', ids: ['a', 'b'] }, + { type } + ) + + expect(state).toEqual({ layerId: null, ids: [] }) + }) + + it('resets to default state when the selected layer is removed', () => { + const state = selection( + { layerId: 'layer-1', ids: ['a', 'b'] }, + { type: types.LAYER_REMOVE, id: 'layer-1' } + ) + + expect(state).toEqual({ layerId: null, ids: [] }) + }) + + it('keeps the selection when a different layer is removed', () => { + const prevState = { layerId: 'layer-1', ids: ['a', 'b'] } + const state = selection(prevState, { + type: types.LAYER_REMOVE, + id: 'layer-2', + }) + + expect(state).toBe(prevState) + }) + + it('ignores unrelated actions', () => { + const prevState = { layerId: 'layer-1', ids: ['a'] } + + expect(selection(prevState, { type: 'UNRELATED' })).toBe(prevState) + }) +}) diff --git a/src/reducers/__tests__/ui.spec.js b/src/reducers/__tests__/ui.spec.js new file mode 100644 index 0000000000..70adf262ae --- /dev/null +++ b/src/reducers/__tests__/ui.spec.js @@ -0,0 +1,91 @@ +import * as types from '../../constants/actionTypes.js' +import ui from '../ui.js' + +describe('ui reducer — highlightColor', () => { + it('defaults to null (no color override until the user picks one)', () => { + expect(ui(undefined, {}).highlightColor).toBe(null) + }) + + it('sets a new highlight color', () => { + const state = ui(undefined, { + type: types.HIGHLIGHT_COLOR_SET, + color: '#FF0000', + }) + + expect(state.highlightColor).toBe('#FF0000') + }) + + it('leaves other state untouched', () => { + const prevState = { ...ui(undefined, {}), dataTableHeight: 400 } + const state = ui(prevState, { + type: types.HIGHLIGHT_COLOR_SET, + color: '#FF0000', + }) + + expect(state.dataTableHeight).toBe(400) + }) +}) + +describe('ui reducer — showOnlySelected', () => { + it('defaults to false', () => { + expect(ui(undefined, {}).showOnlySelected).toBe(false) + }) + + it('toggles on TOGGLE_SHOW_ONLY_SELECTED', () => { + const state = ui(undefined, { type: types.TOGGLE_SHOW_ONLY_SELECTED }) + expect(state.showOnlySelected).toBe(true) + + const toggledBack = ui(state, { + type: types.TOGGLE_SHOW_ONLY_SELECTED, + }) + expect(toggledBack.showOnlySelected).toBe(false) + }) + + it('sets an explicit value on SHOW_ONLY_SELECTED_SET', () => { + const prevState = { ...ui(undefined, {}), showOnlySelected: true } + const state = ui(prevState, { + type: types.SHOW_ONLY_SELECTED_SET, + value: false, + }) + + expect(state.showOnlySelected).toBe(false) + }) + + it.each([ + types.MAP_NEW, + types.MAP_SET, + types.DATA_TABLE_CLOSE, + types.DATA_TABLE_TOGGLE, + ])('resets to false on %s', (type) => { + const prevState = { ...ui(undefined, {}), showOnlySelected: true } + const state = ui(prevState, { type }) + + expect(state.showOnlySelected).toBe(false) + }) +}) + +describe('ui reducer — lastClickedFeature', () => { + it('defaults to null', () => { + expect(ui(undefined, {}).lastClickedFeature).toBe(null) + }) + + it('sets the clicked feature on MAP_FEATURE_CLICKED', () => { + const payload = { id: 'abc', layerId: 'layer-1' } + const state = ui(undefined, { + type: types.MAP_FEATURE_CLICKED, + payload, + }) + + expect(state.lastClickedFeature).toEqual(payload) + }) + + it.each([types.MAP_NEW, types.MAP_SET])('resets to null on %s', (type) => { + const prevState = { + ...ui(undefined, {}), + lastClickedFeature: { id: 'abc', layerId: 'layer-1' }, + } + const state = ui(prevState, { type }) + + expect(state.lastClickedFeature).toBe(null) + }) +}) diff --git a/src/reducers/index.js b/src/reducers/index.js index dae6948282..bcc2c09114 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -11,6 +11,7 @@ import layerEdit from './layerEdit.js' import layerSources from './layerSources.js' import map from './map.js' import orgUnitProfile from './orgUnitProfile.js' +import selection from './selection.js' import ui from './ui.js' export default combineReducers({ @@ -27,4 +28,5 @@ export default combineReducers({ ui, feature, featureProfile, + selection, }) diff --git a/src/reducers/selection.js b/src/reducers/selection.js new file mode 100644 index 0000000000..338d8a952b --- /dev/null +++ b/src/reducers/selection.js @@ -0,0 +1,49 @@ +import * as types from '../constants/actionTypes.js' + +const defaultState = { layerId: null, ids: [] } + +const selection = (state = defaultState, action) => { + switch (action.type) { + case types.FEATURE_TOGGLE_SELECTION: { + if (state.layerId !== action.layerId) { + return { layerId: action.layerId, ids: [action.id] } + } + + const alreadySelected = state.ids.includes(action.id) + + return { + layerId: action.layerId, + ids: alreadySelected + ? state.ids.filter((id) => id !== action.id) + : [...state.ids, action.id], + } + } + + case types.SELECTION_SET_ALL: + return { layerId: action.layerId, ids: action.ids } + + case types.SELECTION_ADD_RANGE: { + const ids = state.layerId === action.layerId ? state.ids : [] + + return { + layerId: action.layerId, + ids: [...new Set([...ids, ...action.ids])], + } + } + + case types.SELECTION_CLEAR: + case types.MAP_NEW: + case types.MAP_SET: + case types.DATA_TABLE_CLOSE: + case types.DATA_TABLE_TOGGLE: + return defaultState + + case types.LAYER_REMOVE: + return state.layerId === action.id ? defaultState : state + + default: + return state + } +} + +export default selection diff --git a/src/reducers/ui.js b/src/reducers/ui.js index 42a29e3e91..b5d4ca63da 100644 --- a/src/reducers/ui.js +++ b/src/reducers/ui.js @@ -9,6 +9,11 @@ const defaultState = { mapContextMenu: true, downloadMode: false, layersSorting: false, + mapBounds: null, + showOnlyFeaturesInView: false, + showOnlySelected: false, + highlightColor: null, + lastClickedFeature: null, } const ui = (state = defaultState, action) => { @@ -36,11 +41,25 @@ const ui = (state = defaultState, action) => { case types.INTERPRETATIONS_PANEL_CLOSE: case types.ORGANISATION_UNIT_PROFILE_CLOSE: case types.FEATURE_PROFILE_CLOSE: + return { + ...state, + rightPanelOpen: false, + } + case types.MAP_NEW: case types.MAP_SET: return { ...state, rightPanelOpen: false, + showOnlySelected: false, + lastClickedFeature: null, + } + + case types.DATA_TABLE_CLOSE: + case types.DATA_TABLE_TOGGLE: + return { + ...state, + showOnlySelected: false, } case types.DOWNLOAD_MODE_OPEN: @@ -72,6 +91,42 @@ const ui = (state = defaultState, action) => { layersSorting: false, } + case types.MAP_BOUNDS_CHANGED: + return { + ...state, + mapBounds: action.bounds, + } + + case types.TOGGLE_SHOW_ONLY_IN_VIEW: + return { + ...state, + showOnlyFeaturesInView: !state.showOnlyFeaturesInView, + } + + case types.TOGGLE_SHOW_ONLY_SELECTED: + return { + ...state, + showOnlySelected: !state.showOnlySelected, + } + + case types.SHOW_ONLY_SELECTED_SET: + return { + ...state, + showOnlySelected: action.value, + } + + case types.HIGHLIGHT_COLOR_SET: + return { + ...state, + highlightColor: action.color, + } + + case types.MAP_FEATURE_CLICKED: + return { + ...state, + lastClickedFeature: action.payload, + } + default: return state } diff --git a/src/util/__tests__/geojson.spec.js b/src/util/__tests__/geojson.spec.js index 82247a8d98..a0c49b56ba 100644 --- a/src/util/__tests__/geojson.spec.js +++ b/src/util/__tests__/geojson.spec.js @@ -4,6 +4,8 @@ import { CENTROID_FORMAT_GEOJSON, getBounds, getCentroid, + isPointInBounds, + isFeatureInBounds, addStyleDataItem, createEventFeature, buildEventGeometryGetter, @@ -960,4 +962,140 @@ describe('geojson utils', () => { expect(getCentroid(unknown)).toBeNull() }) }) + + describe('isPointInBounds', () => { + const bounds = [-10, -10, 10, 10] + + it('returns true for a point inside the bounds', () => { + expect(isPointInBounds([0, 0], bounds)).toBe(true) + }) + + it('returns true for a point exactly on the bounds edge', () => { + expect(isPointInBounds([10, 10], bounds)).toBe(true) + expect(isPointInBounds([-10, -10], bounds)).toBe(true) + }) + + it('returns false for a point outside the bounds', () => { + expect(isPointInBounds([20, 0], bounds)).toBe(false) + expect(isPointInBounds([0, -20], bounds)).toBe(false) + }) + + describe('antimeridian-crossing bounds (west > east)', () => { + const antimeridianBounds = [170, -10, -170, 10] + + it('returns true for a point within the eastern segment', () => { + expect(isPointInBounds([175, 0], antimeridianBounds)).toBe(true) + }) + + it('returns true for a point within the western segment', () => { + expect(isPointInBounds([-175, 0], antimeridianBounds)).toBe( + true + ) + }) + + it('returns false for a point outside both segments', () => { + expect(isPointInBounds([0, 0], antimeridianBounds)).toBe(false) + }) + }) + }) + + describe('isFeatureInBounds', () => { + const bounds = [-10, -10, 10, 10] + + it('returns true for a Point feature whose coordinates fall within bounds', () => { + const feature = { + geometry: { type: GEO_TYPE_POINT, coordinates: [1, 2] }, + } + expect(isFeatureInBounds(feature, bounds)).toBe(true) + }) + + it('returns false for a Point feature outside bounds', () => { + const feature = { + geometry: { type: GEO_TYPE_POINT, coordinates: [50, 50] }, + } + expect(isFeatureInBounds(feature, bounds)).toBe(false) + }) + + it('returns true for a Polygon feature whose centroid falls within bounds', () => { + const feature = { + geometry: { + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], + [0, 0], + ], + ], + }, + } + expect(isFeatureInBounds(feature, bounds)).toBe(true) + }) + + it('returns true for a large Polygon whose centroid falls outside bounds but whose shape still overlaps them', () => { + const feature = { + geometry: { + type: 'Polygon', + coordinates: [ + [ + [-100, -100], + [-5, -100], + [-5, 100], + [-100, 100], + [-100, -100], + ], + ], + }, + } + expect(getCentroid(feature.geometry)[0]).toBeLessThan(bounds[0]) + expect(isFeatureInBounds(feature, bounds)).toBe(true) + }) + + it('returns false for a Polygon whose bounding box does not overlap bounds at all', () => { + const feature = { + geometry: { + type: 'Polygon', + coordinates: [ + [ + [50, 50], + [54, 50], + [54, 54], + [50, 54], + [50, 50], + ], + ], + }, + } + expect(isFeatureInBounds(feature, bounds)).toBe(false) + }) + + it('returns false when the feature has no geometry', () => { + expect(isFeatureInBounds({ geometry: null }, bounds)).toBe(false) + }) + + it('returns false when bounds are not provided', () => { + const feature = { + geometry: { type: GEO_TYPE_POINT, coordinates: [1, 2] }, + } + expect(isFeatureInBounds(feature, null)).toBe(false) + }) + + it('returns true for a Point feature within an antimeridian-crossing viewport', () => { + const antimeridianBounds = [170, -10, -170, 10] + const feature = { + geometry: { type: GEO_TYPE_POINT, coordinates: [175, 0] }, + } + expect(isFeatureInBounds(feature, antimeridianBounds)).toBe(true) + }) + + it('returns false for a Point feature outside an antimeridian-crossing viewport', () => { + const antimeridianBounds = [170, -10, -170, 10] + const feature = { + geometry: { type: GEO_TYPE_POINT, coordinates: [0, 0] }, + } + expect(isFeatureInBounds(feature, antimeridianBounds)).toBe(false) + }) + }) }) diff --git a/src/util/geojson.js b/src/util/geojson.js index 81be9a3a98..767c442758 100644 --- a/src/util/geojson.js +++ b/src/util/geojson.js @@ -1,3 +1,4 @@ +import turfBbox from '@turf/bbox' import { booleanPointInPolygon } from '@turf/boolean-point-in-polygon' import turfCentroid from '@turf/centroid' import findIndex from 'lodash/findIndex' @@ -203,6 +204,26 @@ export const getCentroid = (geometry, format = CENTROID_FORMAT_ARRAY) => { return coords } +export const isPointInBounds = ([lng, lat], [west, south, east, north]) => { + const lngInBounds = + west <= east ? lng >= west && lng <= east : lng >= west || lng <= east + return lngInBounds && lat >= south && lat <= north +} + +export const isFeatureInBounds = (feature, bounds) => { + if (!bounds || !feature.geometry) { + return false + } + const [west, south, east, north] = bounds + const [minLng, minLat, maxLng, maxLat] = turfBbox(feature.geometry) + const latOverlaps = minLat <= north && maxLat >= south + const lngOverlaps = + west <= east + ? minLng <= east && maxLng >= west + : minLng <= east || maxLng >= west + return lngOverlaps && latOverlaps +} + export const getGeojsonDisplayData = (feature) => { const { properties } = feature if (!properties) { From b2161eab49a05c0621b8c63eba238afcb33019a2 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Mon, 13 Jul 2026 18:01:55 +0200 Subject: [PATCH 018/205] fix: bump maps-gl --- package.json | 2 +- yarn.lock | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index b0339d1e88..7363a39fa1 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "@dhis2/analytics": "^29.5.5", "@dhis2/app-runtime": "^3.17.3", "@dhis2/app-service-datastore": "^1.0.0-beta.3", - "@dhis2/maps-gl": "^4.3.1", + "@dhis2/maps-gl": "git+https://github.com/d2-ci/maps-gl.git#90476d118e5d9b62b6d7d97ac1d2d41e7a3fb840", "@dhis2/ui": "^10.16.4", "@dnd-kit/core": "^6.0.8", "@dnd-kit/modifiers": "^9.0.0", diff --git a/yarn.lock b/yarn.lock index a5cfc3738e..4da5407e9b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2431,10 +2431,9 @@ resolved "https://registry.yarnpkg.com/@dhis2/data-engine/-/data-engine-3.17.3.tgz#0347416e9919efbf4d9739c4141fa543f89669ad" integrity sha512-hLXt7LFrFitR7QgKfGQ3ComTLrY5IAdtERonhdo/SIrsRYWoeVaMiCOkUUzC48pEaeo1/BL5qwA7Tw7jZgROQw== -"@dhis2/maps-gl@^4.3.1": +"@dhis2/maps-gl@git+https://github.com/d2-ci/maps-gl.git#90476d118e5d9b62b6d7d97ac1d2d41e7a3fb840": version "4.3.1" - resolved "https://registry.yarnpkg.com/@dhis2/maps-gl/-/maps-gl-4.3.1.tgz#d180a37ea9a3e207ced29b6bc5b7d0e0612e43e0" - integrity sha512-Kmde1gKEHFg8DQAKbKovvL+TfEWF1+mHo1VabDZzXWE0L/0qGIx92/UIqQjz5YHs3gogiew9gmAe8LncPRMyMA== + resolved "git+https://github.com/d2-ci/maps-gl.git#90476d118e5d9b62b6d7d97ac1d2d41e7a3fb840" dependencies: "@mapbox/sphericalmercator" "^1.2.0" "@turf/area" "^7.3.5" From 392805a3b210f8b1568adaaa672c8a608764835e Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Mon, 13 Jul 2026 20:43:42 +0200 Subject: [PATCH 019/205] chore: sonarqube issues --- src/components/map/layers/Layer.js | 103 +++++++++------ src/components/map/layers/ThematicLayer.jsx | 132 ++++++++------------ 2 files changed, 111 insertions(+), 124 deletions(-) diff --git a/src/components/map/layers/Layer.js b/src/components/map/layers/Layer.js index ff16e13de0..ab8c8a13f7 100644 --- a/src/components/map/layers/Layer.js +++ b/src/components/map/layers/Layer.js @@ -51,61 +51,75 @@ class Layer extends PureComponent { } componentDidUpdate(prevProps, prevState = {}) { - const { - id, - data, - index, - opacity, - isVisible, - editCounter, - dataFilters, - feature, - selection, - highlightColor, - showOnlySelected, - } = this.props + this.handleDataOrPeriodChange(prevProps, prevState) + this.handleIndexChange(prevProps) + this.handleOpacityChange(prevProps) + this.handleVisibilityChange(prevProps) + this.handleFeatureChange(prevProps) + this.handleSelectionChange(prevProps) + this.handleHighlightColorChange(prevProps) + this.handleVisibleIdsChange(prevProps) + } + + // Create new map if new id of editCounter is increased + handleDataOrPeriodChange(prevProps, prevState = {}) { + const { id, data, dataFilters, editCounter } = this.props const { period } = this.state const { period: prevPeriod } = prevState || {} const isEdited = editCounter !== prevProps.editCounter - // Create new map if new id of editCounter is increased if ( - id !== prevProps.id || - data !== prevProps.data || - period?.id !== prevPeriod?.id || - dataFilters !== prevProps.dataFilters || - isEdited + id === prevProps.id && + data === prevProps.data && + period?.id === prevPeriod?.id && + dataFilters === prevProps.dataFilters && + !isEdited ) { - // Reset period if edited - if (isEdited) { - this.setPeriod(this.updateLayer.bind(this)) - } else { - this.updateLayer(dataFilters !== prevProps.dataFilters) - } + return } + // Reset period if edited + if (isEdited) { + this.setPeriod(this.updateLayer.bind(this)) + } else { + this.updateLayer(dataFilters !== prevProps.dataFilters) + } + } + + handleIndexChange(prevProps) { + const { index } = this.props if (index !== undefined && index !== prevProps.index) { this.setLayerOrder() } + } - if (opacity !== prevProps.opacity) { + handleOpacityChange(prevProps) { + if (this.props.opacity !== prevProps.opacity) { this.setLayerOpacity() } + } - if (isVisible !== prevProps.isVisible) { + handleVisibilityChange(prevProps) { + if (this.props.isVisible !== prevProps.isVisible) { this.setLayerVisibility() } + } - if (feature !== prevProps.feature) { - this.handleFeatureUpdate(feature) + handleFeatureChange(prevProps) { + const { feature } = this.props + if (feature === prevProps.feature) { + return + } - if ( - this.getHoverId(prevProps.feature) !== this.getHoverId(feature) - ) { - this.highlightFeature() - } + this.handleFeatureUpdate(feature) + + if (this.getHoverId(prevProps.feature) !== this.getHoverId(feature)) { + this.highlightFeature() } + } + handleSelectionChange(prevProps) { + const { selection } = this.props if ( selection !== prevProps.selection && !idsEqual( @@ -115,16 +129,23 @@ class Layer extends PureComponent { ) { this.selectFeatures() } + } - if (highlightColor !== prevProps.highlightColor) { - if (this.getHoverId()) { - this.highlightFeature() - } - if (this.getSelectedIds().length) { - this.selectFeatures() - } + handleHighlightColorChange(prevProps) { + if (this.props.highlightColor === prevProps.highlightColor) { + return + } + + if (this.getHoverId()) { + this.highlightFeature() } + if (this.getSelectedIds().length) { + this.selectFeatures() + } + } + handleVisibleIdsChange(prevProps) { + const { selection, showOnlySelected } = this.props if ( !idsEqual( this.getVisibleIds( diff --git a/src/components/map/layers/ThematicLayer.jsx b/src/components/map/layers/ThematicLayer.jsx index dd451d26a0..d9572e50cd 100644 --- a/src/components/map/layers/ThematicLayer.jsx +++ b/src/components/map/layers/ThematicLayer.jsx @@ -21,7 +21,7 @@ import { } from '../../../util/periods.js' import { poleOfInaccessibility } from '../MapApi.js' import Popup from '../Popup.jsx' -import Layer, { idsEqual } from './Layer.js' +import Layer from './Layer.js' import styles from './styles/Popup.module.css' export const ThematicLayerContext = React.createContext() @@ -193,73 +193,37 @@ class ThematicLayer extends Layer { } componentDidUpdate(prevProps) { + if (this.canSkipRebuild(prevProps)) { + this.handleIndexChange(prevProps) + this.handleOpacityChange(prevProps) + this.handleVisibilityChange(prevProps) + this.handleFeatureChange(prevProps) + this.handleSelectionChange(prevProps) + this.handleHighlightColorChange(prevProps) + this.handleVisibleIdsChange(prevProps) + return + } + + this.rebuildPeriodData() + this.syncPopupForNewPeriod() + } + + canSkipRebuild(prevProps) { const prevPeriodId = prevProps.externalPeriod?.id const newPeriodId = this.props.externalPeriod?.id - const dataChanged = prevProps.data !== this.props.data - const valuesChanged = - prevProps.valuesByPeriod !== this.props.valuesByPeriod - const filtersChanged = prevProps.dataFilters !== this.props.dataFilters - const renderingChanged = - prevProps.renderingStrategy !== this.props.renderingStrategy - - if ( - !dataChanged && - !valuesChanged && - !filtersChanged && - !renderingChanged && + return ( + prevProps.data === this.props.data && + prevProps.valuesByPeriod === this.props.valuesByPeriod && + prevProps.dataFilters === this.props.dataFilters && + prevProps.renderingStrategy === this.props.renderingStrategy && prevPeriodId === newPeriodId - ) { - this.setLayerOpacity() - this.setLayerVisibility() - this.setLayerOrder() - const { feature, selection, highlightColor, showOnlySelected } = - this.props - if (feature !== prevProps.feature) { - this.handleFeatureUpdate(feature) - - if ( - this.getHoverId(prevProps.feature) !== - this.getHoverId(feature) - ) { - this.highlightFeature() - } - } - if ( - selection !== prevProps.selection && - !idsEqual( - this.getSelectedIds(prevProps.selection), - this.getSelectedIds(selection) - ) - ) { - this.selectFeatures() - } - if (highlightColor !== prevProps.highlightColor) { - if (this.getHoverId()) { - this.highlightFeature() - } - if (this.getSelectedIds().length) { - this.selectFeatures() - } - } - if ( - !idsEqual( - this.getVisibleIds( - prevProps.selection, - prevProps.showOnlySelected - ) ?? [], - this.getVisibleIds(selection, showOnlySelected) ?? [] - ) - ) { - this.updateVisibleIds() - } - return - } - - const { valuesByPeriod, thematicMapType = THEMATIC_CHOROPLETH } = - this.props + ) + } - // Rebuild the period-specific data the same way as in createLayer + // Rebuild the period-specific data the same way as in createLayer + rebuildPeriodData() { + const { thematicMapType = THEMATIC_CHOROPLETH } = this.props const bubbleMap = thematicMapType === THEMATIC_BUBBLE const filteredData = this.buildPeriodData() @@ -284,30 +248,32 @@ class ThematicLayer extends Layer { // Recreate layer to pick up changes this.updateLayer() } + } - // Sync popup contents if open + // Sync popup contents if open + syncPopupForNewPeriod() { const { popup } = this.state - if (popup && this.props.externalPeriod) { - const newValues = - (valuesByPeriod && - this.props.externalPeriod && - valuesByPeriod[this.props.externalPeriod.id]) || - {} - const updatedPopup = { - ...popup, - feature: { - ...popup.feature, - properties: { - ...popup.feature.properties, - ...(newValues[popup.feature.properties.id] || { - value: i18n.t('Not set'), - }), - }, - }, - } + if (!popup || !this.props.externalPeriod) { + return + } - this.setState({ popup: updatedPopup }) + const { valuesByPeriod, externalPeriod } = this.props + const newValues = + (valuesByPeriod && valuesByPeriod[externalPeriod.id]) || {} + const updatedPopup = { + ...popup, + feature: { + ...popup.feature, + properties: { + ...popup.feature.properties, + ...(newValues[popup.feature.properties.id] || { + value: i18n.t('Not set'), + }), + }, + }, } + + this.setState({ popup: updatedPopup }) } onFeatureClick(evt) { From 20e047b942bec08f80462d45e23dabcd374d7e64 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Mon, 13 Jul 2026 20:49:32 +0200 Subject: [PATCH 020/205] chore: cypress tests update --- cypress/elements/map_context_menu.js | 4 ++ cypress/integration/dataTable.cy.js | 56 +++++++++---------- .../integration/layers/thematiclayer.cy.js | 6 ++ 3 files changed, 38 insertions(+), 28 deletions(-) diff --git a/cypress/elements/map_context_menu.js b/cypress/elements/map_context_menu.js index 3f856a91c4..fb760ad292 100644 --- a/cypress/elements/map_context_menu.js +++ b/cypress/elements/map_context_menu.js @@ -5,6 +5,8 @@ export const DRILL_UP = 'context-menu-drill-up' export const DRILL_DOWN = 'context-menu-drill-down' export const VIEW_PROFILE = 'context-menu-view-profile' export const ZOOM_TO_FEATURE = 'context-menu-zoom-to-feature' +export const ZOOM_TO_LAYER = 'context-menu-zoom-to-layer' +export const ZOOM_TO_SELECTED = 'context-menu-zoom-to-selected' export const SHOW_LONG_LAT = 'context-menu-show-long-lat' const ALL_OPTIONS = [ @@ -12,6 +14,8 @@ const ALL_OPTIONS = [ DRILL_DOWN, VIEW_PROFILE, ZOOM_TO_FEATURE, + ZOOM_TO_LAYER, + ZOOM_TO_SELECTED, SHOW_LONG_LAT, ] diff --git a/cypress/integration/dataTable.cy.js b/cypress/integration/dataTable.cy.js index c40d3da3ed..6944abe354 100644 --- a/cypress/integration/dataTable.cy.js +++ b/cypress/integration/dataTable.cy.js @@ -80,7 +80,7 @@ describe('data table', () => { // check number of columns cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-datatablecellhead') - .should('have.length', 10) + .should('have.length', 11) // Filter by name cy.getByDataTest('data-table-column-filter-input-Name') @@ -94,15 +94,15 @@ describe('data table', () => { .should('have.length', 7) // confirm that the sort order is initially ascending by Name - checkTableCell({ row: 0, column: 1, expectedContent: 'Bargbe' }) - checkTableCell({ row: 6, column: 1, expectedContent: 'Upper Bambara' }) + checkTableCell({ row: 0, column: 2, expectedContent: 'Bargbe' }) + checkTableCell({ row: 6, column: 2, expectedContent: 'Upper Bambara' }) // Sort by name cy.getByDataTest('data-table-column-sort-button-Name').click() // confirm that the rows are sorted by Name descending - checkTableCell({ row: 0, column: 1, expectedContent: 'Upper Bambara' }) - checkTableCell({ row: 6, column: 1, expectedContent: 'Bargbe' }) + checkTableCell({ row: 0, column: 2, expectedContent: 'Upper Bambara' }) + checkTableCell({ row: 6, column: 2, expectedContent: 'Bargbe' }) // filter by Value (numeric) cy.getByDataTest('data-table-column-filter-input-Value') @@ -119,8 +119,8 @@ describe('data table', () => { cy.getByDataTest('data-table-column-sort-button-Value').click() // check that the rows are sorted by Value ascending - checkTableCell({ row: 0, column: 3, expectedContent: '35' }) - checkTableCell({ row: 4, column: 3, expectedContent: '76' }) + checkTableCell({ row: 0, column: 4, expectedContent: '35' }) + checkTableCell({ row: 4, column: 4, expectedContent: '76' }) // right-click a row and select "View profile" cy.getByDataTest('bottom-panel') @@ -179,7 +179,7 @@ describe('data table', () => { // check number of columns cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-datatablecellhead') - .should('have.length', 10) + .should('have.length', 11) cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-datatablecellhead') @@ -193,8 +193,8 @@ describe('data table', () => { .type(ouName) // check that all the rows have Org unit Moyowa - checkTableCell({ row: 0, column: 1, expectedContent: ouName }) - checkTableCell({ row: 2, column: 1, expectedContent: ouName }) + checkTableCell({ row: 0, column: 2, expectedContent: ouName }) + checkTableCell({ row: 2, column: 2, expectedContent: ouName }) cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-tablebody') @@ -236,8 +236,8 @@ describe('data table', () => { // Confirm that the rows are sorted by Age in years ascending // (the first click on a new column always sorts ascending) - checkTableCell({ row: 0, column: 7, expectedContent: '6' }) - checkTableCell({ row: 1, column: 7, expectedContent: '32' }) + checkTableCell({ row: 0, column: 8, expectedContent: '6' }) + checkTableCell({ row: 1, column: 8, expectedContent: '32' }) // right-click a row: Event layers have no profile to view cy.getByDataTest('bottom-panel') @@ -295,52 +295,52 @@ describe('data table', () => { cy.getByDataTest('bottom-panel').should('be.visible') // Confirm that the sort order is initially ascending by Name - checkTableCell({ row: 0, column: 1, expectedContent: 'Bendu CHC' }) + checkTableCell({ row: 0, column: 2, expectedContent: 'Bendu CHC' }) // First click on a new column always sorts ascending cy.getByDataTest('data-table-column-sort-button-Value').click() // Check that first row has Tihun CHC with value 28.63 - checkTableCell({ row: 0, column: 1, expectedContent: 'Tihun CHC' }) - checkTableCell({ row: 0, column: 3, expectedContent: '28.63' }) + checkTableCell({ row: 0, column: 2, expectedContent: 'Tihun CHC' }) + checkTableCell({ row: 0, column: 4, expectedContent: '28.63' }) // Check that row 5 has Gbamgbama CHC with value 117.98 - checkTableCell({ row: 5, column: 1, expectedContent: 'Gbamgbama CHC' }) - checkTableCell({ row: 5, column: 3, expectedContent: '117.98' }) + checkTableCell({ row: 5, column: 2, expectedContent: 'Gbamgbama CHC' }) + checkTableCell({ row: 5, column: 4, expectedContent: '117.98' }) // Check that row 6 has no value (undefined) - checkTableCell({ row: 6, column: 3, expectedContent: '' }) + checkTableCell({ row: 6, column: 4, expectedContent: '' }) // Sort descending by Value cy.getByDataTest('data-table-column-sort-button-Value').click() - checkTableCell({ row: 0, column: 1, expectedContent: 'Gbamgbama CHC' }) - checkTableCell({ row: 0, column: 3, expectedContent: '117.98' }) + checkTableCell({ row: 0, column: 2, expectedContent: 'Gbamgbama CHC' }) + checkTableCell({ row: 0, column: 4, expectedContent: '117.98' }) - checkTableCell({ row: 5, column: 1, expectedContent: 'Tihun CHC' }) - checkTableCell({ row: 5, column: 3, expectedContent: '28.63' }) + checkTableCell({ row: 5, column: 2, expectedContent: 'Tihun CHC' }) + checkTableCell({ row: 5, column: 4, expectedContent: '28.63' }) - checkTableCell({ row: 6, column: 3, expectedContent: '' }) + checkTableCell({ row: 6, column: 4, expectedContent: '' }) // Sort by index (a new column, so ascending) and scroll to the top cy.getByDataTest('data-table-column-sort-button-Index').click() cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') - checkTableCell({ row: 0, column: 0, expectedContent: '0' }) + checkTableCell({ row: 0, column: 1, expectedContent: '0' }) // Check that row 0 range value is empty - checkTableCell({ row: 0, column: 5, expectedContent: '' }) + checkTableCell({ row: 0, column: 6, expectedContent: '' }) // Sort by range, which is a string cy.getByDataTest('data-table-column-sort-button-Range').click() // Check that row 0 range value has value '0-40' - checkTableCell({ row: 0, column: 5, expectedContent: '0 – 40' }) + checkTableCell({ row: 0, column: 6, expectedContent: '0 – 40' }) // Check that row 5 range value has value '90 - 120' - checkTableCell({ row: 5, column: 5, expectedContent: '90 – 120' }) + checkTableCell({ row: 5, column: 6, expectedContent: '90 – 120' }) // Check that row 6 range value is empty - checkTableCell({ row: 6, column: 5, expectedContent: '' }) + checkTableCell({ row: 6, column: 6, expectedContent: '' }) }) }) diff --git a/cypress/integration/layers/thematiclayer.cy.js b/cypress/integration/layers/thematiclayer.cy.js index ca4e777b07..de4e1ef8b3 100644 --- a/cypress/integration/layers/thematiclayer.cy.js +++ b/cypress/integration/layers/thematiclayer.cy.js @@ -4,6 +4,8 @@ import { DRILL_DOWN, VIEW_PROFILE, ZOOM_TO_FEATURE, + ZOOM_TO_LAYER, + ZOOM_TO_SELECTED, SHOW_LONG_LAT, expectContextMenuOptions, } from '../../elements/map_context_menu.js' @@ -541,6 +543,8 @@ context('Thematic Layers', () => { { name: DRILL_DOWN }, { name: VIEW_PROFILE }, { name: ZOOM_TO_FEATURE }, + { name: ZOOM_TO_LAYER }, + { name: ZOOM_TO_SELECTED, disabled: true }, { name: SHOW_LONG_LAT }, ]) }) @@ -726,6 +730,8 @@ context('Thematic Layers', () => { { name: DRILL_DOWN }, { name: VIEW_PROFILE }, { name: ZOOM_TO_FEATURE }, + { name: ZOOM_TO_LAYER }, + { name: ZOOM_TO_SELECTED, disabled: true }, ]) }) From 49a1984d82247ce56db4c30afa2859b5afb48103 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Mon, 13 Jul 2026 20:52:37 +0200 Subject: [PATCH 021/205] chore: sonarqube issues --- src/components/map/layers/ThematicLayer.jsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/map/layers/ThematicLayer.jsx b/src/components/map/layers/ThematicLayer.jsx index d9572e50cd..ff968b2e18 100644 --- a/src/components/map/layers/ThematicLayer.jsx +++ b/src/components/map/layers/ThematicLayer.jsx @@ -258,8 +258,7 @@ class ThematicLayer extends Layer { } const { valuesByPeriod, externalPeriod } = this.props - const newValues = - (valuesByPeriod && valuesByPeriod[externalPeriod.id]) || {} + const newValues = valuesByPeriod?.[externalPeriod.id] || {} const updatedPopup = { ...popup, feature: { From cc11f549962c04cdbe2d18a71dbaf2363d78cc14 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 14 Jul 2026 00:35:32 +0200 Subject: [PATCH 022/205] chore: fix cypress tests --- cypress/integration/dataTable.cy.js | 32 +++++++++++++++++++++++++- src/components/datatable/DataTable.jsx | 9 +++++++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/cypress/integration/dataTable.cy.js b/cypress/integration/dataTable.cy.js index 6944abe354..67e9e388ac 100644 --- a/cypress/integration/dataTable.cy.js +++ b/cypress/integration/dataTable.cy.js @@ -77,6 +77,9 @@ describe('data table', () => { assertMapPosition(expectedBottoms2, expectedHeights2) }) + // Collapse the Layers Panel to give the table more width + cy.getByDataTest('layers-toggle-button').click() + // check number of columns cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-datatablecellhead') @@ -100,6 +103,11 @@ describe('data table', () => { // Sort by name cy.getByDataTest('data-table-column-sort-button-Name').click() + // Sorting can shift the virtualized table's scroll position + // (possibly an internal react-virtuoso quirk) + // so we reset to top before asserting on row indices below + cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') + // confirm that the rows are sorted by Name descending checkTableCell({ row: 0, column: 2, expectedContent: 'Upper Bambara' }) checkTableCell({ row: 6, column: 2, expectedContent: 'Bargbe' }) @@ -118,6 +126,9 @@ describe('data table', () => { // Sort by value cy.getByDataTest('data-table-column-sort-button-Value').click() + // Reset scroll position after sorting - see comment above + cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') + // check that the rows are sorted by Value ascending checkTableCell({ row: 0, column: 4, expectedContent: '35' }) checkTableCell({ row: 4, column: 4, expectedContent: '76' }) @@ -134,6 +145,8 @@ describe('data table', () => { // check that the org unit profile drawer is opened cy.getByDataTest('org-unit-profile').should('be.visible') + cy.getByDataTest('layers-toggle-button').click() + // close the datatable cy.getByDataTest('moremenubutton').first().click() cy.getByDataTest('more-menu') @@ -176,6 +189,9 @@ describe('data table', () => { cy.getByDataTest('bottom-panel').should('be.visible') + // Collapse the Layers Panel to give the table more width + cy.getByDataTest('layers-toggle-button').click() + // check number of columns cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-datatablecellhead') @@ -294,12 +310,18 @@ describe('data table', () => { // Check that the bottom panel is present cy.getByDataTest('bottom-panel').should('be.visible') + // Collapse the Layers Panel to give the table more width + cy.getByDataTest('layers-toggle-button').click() + // Confirm that the sort order is initially ascending by Name checkTableCell({ row: 0, column: 2, expectedContent: 'Bendu CHC' }) // First click on a new column always sorts ascending cy.getByDataTest('data-table-column-sort-button-Value').click() + // Reset scroll position after sorting - see comment above + cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') + // Check that first row has Tihun CHC with value 28.63 checkTableCell({ row: 0, column: 2, expectedContent: 'Tihun CHC' }) checkTableCell({ row: 0, column: 4, expectedContent: '28.63' }) @@ -314,6 +336,9 @@ describe('data table', () => { // Sort descending by Value cy.getByDataTest('data-table-column-sort-button-Value').click() + // Reset scroll position after sorting - see comment above + cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') + checkTableCell({ row: 0, column: 2, expectedContent: 'Gbamgbama CHC' }) checkTableCell({ row: 0, column: 4, expectedContent: '117.98' }) @@ -322,8 +347,10 @@ describe('data table', () => { checkTableCell({ row: 6, column: 4, expectedContent: '' }) - // Sort by index (a new column, so ascending) and scroll to the top + // Sort by index (a new column, so ascending) cy.getByDataTest('data-table-column-sort-button-Index').click() + + // Reset scroll position after sorting - see comment above cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') checkTableCell({ row: 0, column: 1, expectedContent: '0' }) @@ -334,6 +361,9 @@ describe('data table', () => { // Sort by range, which is a string cy.getByDataTest('data-table-column-sort-button-Range').click() + // Reset scroll position after sorting - see comment above + cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') + // Check that row 0 range value has value '0-40' checkTableCell({ row: 0, column: 6, expectedContent: '0 – 40' }) diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index 3da1faf1bd..7fd1f0eade 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -374,7 +374,11 @@ const Table = ({ availableWidth, onCountChange, showOnlySelected }) => { useEffect(() => { // Measure column widths in auto layout, then switch to fixed to prevent content shift during virtual scrolling if (columnWidths.length === 0 && headerRowRef.current) { - requestAnimationFrame(() => { + const frameId = requestAnimationFrame(() => { + if (!headerRowRef.current) { + return + } + const measuredColumnWidths = [] const dataCells = Array.from(headerRowRef.current.cells).slice( @@ -389,6 +393,8 @@ const Table = ({ availableWidth, onCountChange, showOnlySelected }) => { minColumnWidthsRef.current = measuredColumnWidths setColumnWidths(measuredColumnWidths) }) + + return () => cancelAnimationFrame(frameId) } }, [columnWidths]) @@ -437,6 +443,7 @@ const Table = ({ availableWidth, onCountChange, showOnlySelected }) => { width: '100%', }} data={rows} + computeItemKey={(index, row) => getRowId(row) ?? index} fixedHeaderContent={() => ( <DataTableRow ref={headerRowRef}> <DataTableColumnHeader From 6d9fa0b0832f90bc81244bd58586924d82eb77b2 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Mon, 27 Jul 2026 12:02:06 +0200 Subject: [PATCH 023/205] chore: sonarqube issues fix --- src/components/datatable/BottomPanel.jsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index d9cc0e2f18..8353d0e2dc 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -191,6 +191,7 @@ const BottomPanel = () => { onDoubleClick={toggleCollapsed} > <button + type="button" className={styles.toggleButton} onClick={toggleCollapsed} > @@ -273,6 +274,7 @@ const BottomPanel = () => { </Tooltip> </button> <button + type="button" className={cx(styles.toggleButton, { [styles.active]: showOnlySelected, })} From f8c32cb4f35832ddb813419fca1d600e18407397 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Mon, 13 Jul 2026 22:23:21 +0200 Subject: [PATCH 024/205] feat: preserve optionSet on event layer table headers Needed so the multi-select column filter can distinguish option-set-backed columns (which need code->name resolution) from plain categorical columns. --- src/components/datatable/useTableData.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index 55389a70ad..5abb4a47fa 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -133,6 +133,7 @@ const getEventHeaders = ({ !optionSet && numberValueTypes.includes(valueType) ? TYPE_NUMBER : TYPE_STRING, + optionSet: optionSet || null, })) customFields.push(defaultFieldsMap()[TYPE]) From 20b26b0e3dba29a185a79bdbd350823ec6bc0e79 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Mon, 13 Jul 2026 22:29:47 +0200 Subject: [PATCH 025/205] feat: compute columnOptions for categorical data table columns Adds a columnOptions memo to useTableData, allowlisted to the legend/type built-in columns plus any optionSet-backed event column, capped at 30 distinct values before falling back to free text. A blanket "any string column with few distinct values" check would wrongly turn name/id/parentName into dropdowns on small datasets. --- .../datatable/__tests__/useTableData.spec.jsx | 122 ++++++++++++++++++ src/components/datatable/useTableData.js | 40 ++++++ 2 files changed, 162 insertions(+) diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index c883914a57..7a4824437a 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -976,3 +976,125 @@ describe('useTableData showOnlySelected', () => { expect(current.rows).toHaveLength(0) }) }) + +describe('useTableData columnOptions', () => { + const store = { aggregations: {} } + + const renderTableData = (layer) => + renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + <Provider store={mockStore(store)}>{children}</Provider> + ), + } + ).result + + test('includes legend and type for a thematic layer, but not name/id', () => { + const layer = { + layer: 'thematic', + dataFilters: null, + data: [ + { + properties: { + id: 'ou1', + name: 'Org unit 1', + rawValue: 10, + legend: 'High', + range: '5 - 15', + level: 1, + parentName: 'Country', + type: 'Point', + color: '#ff0000', + }, + }, + { + properties: { + id: 'ou2', + name: 'Org unit 2', + rawValue: 20, + legend: 'Low', + range: '15 - 25', + level: 1, + parentName: 'Country', + type: 'Point', + color: '#00ff00', + }, + }, + ], + } + + const { current } = renderTableData(layer) + + expect(current.columnOptions.legend).toEqual([ + { value: 'High' }, + { value: 'Low' }, + ]) + expect(current.columnOptions.type).toEqual([{ value: 'Point' }]) + expect(current.columnOptions.name).toBeUndefined() + expect(current.columnOptions.id).toBeUndefined() + expect(current.columnOptions.parentName).toBeUndefined() + }) + + test('falls back to free text when a column has more than 30 distinct values', () => { + const layer = { + layer: 'orgUnit', + dataFilters: null, + data: Array.from({ length: 31 }, (_, i) => ({ + properties: { + id: `ou${i}`, + name: `Org unit ${i}`, + level: 1, + parentName: 'Country', + type: `Type${i}`, + }, + })), + } + + const { current } = renderTableData(layer) + + expect(current.columnOptions.type).toBeUndefined() + }) + + test('exposes optionSet on event columns for later resolution by FilterInput', () => { + const layer = { + layer: 'event', + dataFilters: null, + isExtended: true, + headers: [ + { + name: 'AbCdEfGhIjK', + column: 'Case classification', + valueType: 'TEXT', + optionSet: { id: 'xyz123' }, + }, + ], + data: [ + { + properties: { + id: 'evt1', + type: 'Point', + ouname: 'Test OU', + eventdate: '2023-01-01', + AbCdEfGhIjK: 'CONFIRMED', + }, + }, + ], + } + + const { current } = renderTableData(layer) + + const header = current.headers.find( + (h) => h.dataKey === 'AbCdEfGhIjK' + ) + expect(header.optionSet).toEqual({ id: 'xyz123' }) + expect(current.columnOptions.AbCdEfGhIjK).toEqual([ + { value: 'CONFIRMED' }, + ]) + }) +}) diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index 5abb4a47fa..1a207ce426 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -197,6 +197,10 @@ const getGeoJsonUrlHeaders = (firstDataItem) => const EMPTY_AGGREGATIONS = {} const EMPTY_LAYER = {} +const EMPTY_COLUMN_OPTIONS = {} + +const CATEGORICAL_DATA_KEYS = new Set([LEGEND, TYPE]) +const MAX_CATEGORICAL_OPTIONS = 30 export const useTableData = ({ layer, @@ -336,6 +340,41 @@ export const useTableData = ({ layerHeaders, ]) + const columnOptions = useMemo(() => { + if (!headers?.length || !dataWithAggregations?.length) { + return EMPTY_COLUMN_OPTIONS + } + + const result = {} + headers.forEach(({ dataKey, type, optionSet }) => { + if (type !== TYPE_STRING) { + return + } + if (!CATEGORICAL_DATA_KEYS.has(dataKey) && !optionSet) { + return + } + + const seen = new Set() + for (const item of dataWithAggregations) { + const val = item[dataKey] + if (val !== undefined && val !== null && val !== '') { + seen.add(String(val)) + } + if (seen.size > MAX_CATEGORICAL_OPTIONS) { + break + } + } + + if (seen.size > 0 && seen.size <= MAX_CATEGORICAL_OPTIONS) { + result[dataKey] = Array.from(seen) + .sort() + .map((value) => ({ value })) + } + }) + + return Object.keys(result).length ? result : EMPTY_COLUMN_OPTIONS + }, [headers, dataWithAggregations]) + const rows = useMemo(() => { if (errorCode.current) { return null @@ -434,5 +473,6 @@ export const useTableData = ({ error: getErrorCodeText(errorCode.current), totalCount, filteredCount, + columnOptions, } } From 1ecec00305860c3d071f9bb4e2accc4c12854443 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Mon, 13 Jul 2026 22:37:42 +0200 Subject: [PATCH 026/205] feat: support array-valued (multi-select) filters in filterData Adds an Array.isArray short-circuit before the existing numeric/string dispatch so a multi-select column filter (built from user checkbox selections) OR-matches exactly against the raw stored value, without touching stringFilter/numericFilter's existing behavior. --- src/util/__tests__/filter.spec.js | 28 ++++++++++++++++++++++++++++ src/util/filter.js | 8 ++++++++ 2 files changed, 36 insertions(+) diff --git a/src/util/__tests__/filter.spec.js b/src/util/__tests__/filter.spec.js index c23cda725b..e17e51b005 100644 --- a/src/util/__tests__/filter.spec.js +++ b/src/util/__tests__/filter.spec.js @@ -68,4 +68,32 @@ describe('filterData', () => { const filters = { a: 'a', b: 'r' } expect(filterData(data, filters)).toEqual([{ a: 'banana', b: 'horse' }]) }) + + it('should OR-match an array filter against the raw stored value', () => { + const data = [{ a: 'High' }, { a: 'Medium' }, { a: 'Low' }] + const filters = { a: ['High', 'Low'] } + expect(filterData(data, filters)).toEqual([{ a: 'High' }, { a: 'Low' }]) + }) + + it('should not filter any rows when the array filter is empty', () => { + const data = [{ a: 'High' }, { a: 'Low' }] + const filters = { a: [] } + expect(filterData(data, filters)).toEqual([{ a: 'High' }, { a: 'Low' }]) + }) + + it('should match array filters against non-string values by exact string coercion', () => { + const data = [{ a: 1 }, { a: 2 }, { a: 3 }] + const filters = { a: ['1', '3'] } + expect(filterData(data, filters)).toEqual([{ a: 1 }, { a: 3 }]) + }) + + it('should combine an array filter on one field with a string filter on another', () => { + const data = [ + { a: 'High', b: 'cow' }, + { a: 'High', b: 'horse' }, + { a: 'Low', b: 'horse' }, + ] + const filters = { a: ['High'], b: 'horse' } + expect(filterData(data, filters)).toEqual([{ a: 'High', b: 'horse' }]) + }) }) diff --git a/src/util/filter.js b/src/util/filter.js index ae5d722963..6ce619eb97 100644 --- a/src/util/filter.js +++ b/src/util/filter.js @@ -15,6 +15,14 @@ export const filterData = (data, filters) => { const props = d.properties || d // GeoJSON or plain object const value = props[field] + if (Array.isArray(filter)) { + // Multi-select: OR match against the raw stored value + return ( + filter.length === 0 || + filter.includes(value == null ? '' : String(value)) + ) + } + return typeof value === 'number' ? numericFilter(value, filter) : stringFilter(value, filter) From 4b032a22913d4a4ad93ad773546e1b148918fa26 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Mon, 13 Jul 2026 22:39:18 +0200 Subject: [PATCH 027/205] feat: add filterByGlobalSearch utility and thread globalSearch through useTableData Applies the global search after column filters and before selection filtering/sorting in the rows memo, matching case-insensitively across all string-typed columns. --- .../datatable/__tests__/useTableData.spec.jsx | 47 +++++++++++++++++++ src/components/datatable/useTableData.js | 15 +++++- src/util/__tests__/filter.spec.js | 42 ++++++++++++++++- src/util/filter.js | 16 +++++++ 4 files changed, 118 insertions(+), 2 deletions(-) diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index 7a4824437a..6501dc91d4 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -1098,3 +1098,50 @@ describe('useTableData columnOptions', () => { ]) }) }) + +describe('useTableData globalSearch', () => { + const store = { aggregations: {} } + + const layer = { + layer: 'orgUnit', + dataFilters: null, + data: [ + { properties: { id: 'a', name: 'Kampala', parentName: 'Uganda' } }, + { properties: { id: 'b', name: 'Nairobi', parentName: 'Kenya' } }, + ], + } + + const renderTableData = (globalSearch) => + renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + globalSearch, + }), + { + wrapper: ({ children }) => ( + <Provider store={mockStore(store)}>{children}</Provider> + ), + } + ).result + + test('includes all rows when the search string is empty', () => { + const { current } = renderTableData('') + expect(current.rows).toHaveLength(2) + }) + + test('matches case-insensitively across any string column', () => { + const { current } = renderTableData('uganda') + expect(current.rows).toHaveLength(1) + expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( + 'Kampala' + ) + }) + + test('shows no rows when nothing matches', () => { + const { current } = renderTableData('addis ababa') + expect(current.rows).toHaveLength(0) + }) +}) diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index 1a207ce426..f41150a44e 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -11,7 +11,7 @@ import { } from '../../constants/layers.js' import { numberValueTypes } from '../../constants/valueTypes.js' import { hasClasses } from '../../util/earthEngine.js' -import { filterData } from '../../util/filter.js' +import { filterByGlobalSearch, filterData } from '../../util/filter.js' import { getGeojsonDisplayData, isFeatureInBounds } from '../../util/geojson.js' import { parseRange } from '../../util/legend.js' import { getRoundToPrecisionFn, getPrecision } from '../../util/numbers.js' @@ -210,6 +210,7 @@ export const useTableData = ({ mapBounds, showOnlySelected, selectedIdSet, + globalSearch, }) => { const allAggregations = useSelector((state) => state.aggregations) const aggregations = allAggregations[layer.id] || EMPTY_AGGREGATIONS @@ -387,6 +388,17 @@ export const useTableData = ({ let filteredData = filterData(dataWithAggregations, dataFilters) + if (globalSearch?.trim()) { + const stringDataKeys = headers + .filter((h) => h.type === TYPE_STRING) + .map((h) => h.dataKey) + filteredData = filterByGlobalSearch( + filteredData, + globalSearch, + stringDataKeys + ) + } + if (showOnlySelected) { filteredData = filteredData.filter((item) => selectedIdSet?.has(item.id) @@ -450,6 +462,7 @@ export const useTableData = ({ headers, dataWithAggregations, dataFilters, + globalSearch, sortField, sortDirection, showOnlySelected, diff --git a/src/util/__tests__/filter.spec.js b/src/util/__tests__/filter.spec.js index e17e51b005..d2c4315b34 100644 --- a/src/util/__tests__/filter.spec.js +++ b/src/util/__tests__/filter.spec.js @@ -1,4 +1,4 @@ -import { filterData } from '../filter.js' +import { filterByGlobalSearch, filterData } from '../filter.js' describe('filterData', () => { it('should return the original data if no filters are provided', () => { @@ -97,3 +97,43 @@ describe('filterData', () => { expect(filterData(data, filters)).toEqual([{ a: 'High', b: 'horse' }]) }) }) + +describe('filterByGlobalSearch', () => { + const data = [ + { name: 'Kampala Hospital', type: 'Hospital' }, + { name: 'Entebbe Clinic', type: 'Clinic' }, + { name: 'Jinja Hospital', type: 'Hospital' }, + ] + + it('returns the original data when the search string is empty', () => { + expect(filterByGlobalSearch(data, '', ['name', 'type'])).toEqual(data) + expect(filterByGlobalSearch(data, ' ', ['name', 'type'])).toEqual( + data + ) + }) + + it('returns the original data when there are no string data keys', () => { + expect(filterByGlobalSearch(data, 'Kampala', [])).toEqual(data) + }) + + it('matches case-insensitively across any of the given fields', () => { + expect(filterByGlobalSearch(data, 'kampala', ['name', 'type'])).toEqual( + [{ name: 'Kampala Hospital', type: 'Hospital' }] + ) + }) + + it('matches rows where any field contains the search string', () => { + expect( + filterByGlobalSearch(data, 'hospital', ['name', 'type']) + ).toEqual([ + { name: 'Kampala Hospital', type: 'Hospital' }, + { name: 'Jinja Hospital', type: 'Hospital' }, + ]) + }) + + it('returns no rows when nothing matches', () => { + expect(filterByGlobalSearch(data, 'nairobi', ['name', 'type'])).toEqual( + [] + ) + }) +}) diff --git a/src/util/filter.js b/src/util/filter.js index 6ce619eb97..4faa3c85a4 100644 --- a/src/util/filter.js +++ b/src/util/filter.js @@ -48,6 +48,22 @@ export const numericFilter = (value, filter) => { }) } +// Matches rows where any of the given string-typed fields contains +// the search string (case-insensitive) +export const filterByGlobalSearch = (data, searchString, stringDataKeys) => { + if (!searchString?.trim() || !stringDataKeys?.length) { + return data + } + const lower = searchString.toLowerCase() + return data.filter((item) => { + const props = item.properties || item + return stringDataKeys.some((key) => { + const val = props[key] + return val != null && String(val).toLowerCase().includes(lower) + }) + }) +} + // Returns true if the filter is true const isTrueFilter = (value, filter) => { if (filter.includes('>=')) { From e4f4ebbfc46429f3394038c667923417f3053198 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Mon, 13 Jul 2026 22:43:10 +0200 Subject: [PATCH 028/205] feat: add multi-select dropdown filter to FilterInput Adds a Popover + checkbox-list path (reusing the pattern already established by TableContextMenu.jsx) rendered when an `options` prop is passed in. Option-set-backed columns get a separate wrapper component (OptionSetMultiSelectFilter) so useOptionSet/useDataQuery is only ever mounted when an optionSetId actually exists - legend/type columns never have one, and no test in the repo mocks useDataQuery. --- src/components/datatable/FilterInput.jsx | 134 +++++++++++++++++- .../datatable/__tests__/FilterInput.spec.jsx | 112 +++++++++++++++ .../datatable/styles/FilterInput.module.css | 18 +++ 3 files changed, 257 insertions(+), 7 deletions(-) create mode 100644 src/components/datatable/__tests__/FilterInput.spec.jsx create mode 100644 src/components/datatable/styles/FilterInput.module.css diff --git a/src/components/datatable/FilterInput.jsx b/src/components/datatable/FilterInput.jsx index 16bab4a9c8..31a7bd91e1 100644 --- a/src/components/datatable/FilterInput.jsx +++ b/src/components/datatable/FilterInput.jsx @@ -1,17 +1,113 @@ import i18n from '@dhis2/d2-i18n' -import { Input } from '@dhis2/ui' +import { Input, Popover } from '@dhis2/ui' import PropTypes from 'prop-types' -import React from 'react' +import React, { useRef, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import { setDataFilter, clearDataFilter } from '../../actions/dataFilters.js' +import useOptionSet from '../../hooks/useOptionSet.js' +import Checkbox from '../core/Checkbox.jsx' +import styles from './styles/FilterInput.module.css' -const FilterInput = ({ type, dataKey, name }) => { +// Shared popover UI — label resolution is injected so it never needs to +// know whether it's an option-set column or a plain categorical one. +const MultiSelectPopover = ({ + dataKey, + layerId, + filterValue, + options, + resolveLabel, +}) => { + const dispatch = useDispatch() + const anchorRef = useRef(null) + const [isOpen, setIsOpen] = useState(false) + const selected = Array.isArray(filterValue) ? filterValue : [] + + const toggleValue = (value) => { + const next = selected.includes(value) + ? selected.filter((v) => v !== value) + : [...selected, value] + next.length + ? dispatch(setDataFilter(layerId, dataKey, next)) + : dispatch(clearDataFilter(layerId, dataKey)) + } + + const buttonLabel = + selected.length === 0 + ? i18n.t('All') + : i18n.t('{{count}} selected', { count: selected.length }) + + return ( + <> + <button + type="button" + ref={anchorRef} + className={styles.multiSelectButton} + data-test={`data-table-column-filter-multiselect-${dataKey}`} + onClick={() => setIsOpen((o) => !o)} + > + {buttonLabel} + </button> + {isOpen && ( + <Popover + reference={anchorRef} + placement="bottom-start" + arrow={false} + onClickOutside={() => setIsOpen(false)} + > + <div className={styles.multiSelectPopover}> + {options.map(({ value }) => ( + <Checkbox + key={value} + label={resolveLabel(value)} + checked={selected.includes(value)} + onChange={() => toggleValue(value)} + /> + ))} + </div> + </Popover> + )} + </> + ) +} + +MultiSelectPopover.propTypes = { + dataKey: PropTypes.string.isRequired, + options: PropTypes.arrayOf(PropTypes.shape({ value: PropTypes.string })) + .isRequired, + resolveLabel: PropTypes.func.isRequired, + filterValue: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.arrayOf(PropTypes.string), + ]), + layerId: PropTypes.string, +} + +// Plain categorical columns (legend, type): raw value IS the display label. +const MultiSelectFilter = (props) => ( + <MultiSelectPopover {...props} resolveLabel={(value) => value} /> +) + +// Option-set-backed event columns: translate stored code -> display name. +// useOptionSet/useDataQuery is only ever mounted here, never for legend/type, +// since those columns never have an optionSetId. +const OptionSetMultiSelectFilter = ({ optionSetId, ...props }) => { + const { optionSet } = useOptionSet(optionSetId) + const resolveLabel = (value) => + optionSet?.options.find((o) => o.code === value)?.name ?? value + return <MultiSelectPopover {...props} resolveLabel={resolveLabel} /> +} + +OptionSetMultiSelectFilter.propTypes = { + optionSetId: PropTypes.string.isRequired, +} + +const FilterInput = ({ type, dataKey, name, options, optionSetId }) => { const dispatch = useDispatch() const dataTable = useSelector((state) => state.dataTable) const map = useSelector((state) => state.map) const overlay = - dataTable && map.mapViews.filter((layer) => layer.id === dataTable)[0] + dataTable && map.mapViews.find((layer) => layer.id === dataTable) let layerId let filters @@ -20,19 +116,41 @@ const FilterInput = ({ type, dataKey, name }) => { filters = overlay.dataFilters || {} } - const filterValue = filters[dataKey] || '' + const filterValue = filters?.[dataKey] + + if (options?.length) { + return optionSetId ? ( + <OptionSetMultiSelectFilter + dataKey={dataKey} + layerId={layerId} + filterValue={filterValue} + options={options} + optionSetId={optionSetId} + /> + ) : ( + <MultiSelectFilter + dataKey={dataKey} + layerId={layerId} + filterValue={filterValue} + options={options} + /> + ) + } + + const stringFilterValue = + typeof filterValue === 'string' ? filterValue : '' const onChange = ({ value }) => value !== '' ? dispatch(setDataFilter(layerId, dataKey, value)) - : dispatch(clearDataFilter(layerId, dataKey, value)) + : dispatch(clearDataFilter(layerId, dataKey)) return ( <Input dataTest={`data-table-column-filter-input-${name}`} dense placeholder={type === 'number' ? '2,>3&<8' : i18n.t('Search')} - value={filterValue} + value={stringFilterValue} onChange={onChange} /> ) @@ -42,6 +160,8 @@ FilterInput.propTypes = { dataKey: PropTypes.string.isRequired, name: PropTypes.string.isRequired, type: PropTypes.string.isRequired, + options: PropTypes.arrayOf(PropTypes.shape({ value: PropTypes.string })), + optionSetId: PropTypes.string, } export default FilterInput diff --git a/src/components/datatable/__tests__/FilterInput.spec.jsx b/src/components/datatable/__tests__/FilterInput.spec.jsx new file mode 100644 index 0000000000..380b9c48ff --- /dev/null +++ b/src/components/datatable/__tests__/FilterInput.spec.jsx @@ -0,0 +1,112 @@ +import { render, fireEvent, screen } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import configureMockStore from 'redux-mock-store' +import FilterInput from '../FilterInput.jsx' + +jest.mock('../../../hooks/useOptionSet.js', () => ({ + __esModule: true, + default: jest.fn(), +})) + +// eslint-disable-next-line import/first +import useOptionSet from '../../../hooks/useOptionSet.js' + +const mockStore = configureMockStore() + +const renderFilterInput = (props, dataFilters) => { + const store = mockStore({ + dataTable: 'layer1', + map: { + mapViews: [{ id: 'layer1', dataFilters: dataFilters || {} }], + }, + }) + return render( + <Provider store={store}> + <FilterInput dataKey="name" name="Name" type="string" {...props} /> + </Provider> + ) +} + +describe('FilterInput text/numeric path', () => { + test('renders a free-text input when no options are provided', () => { + renderFilterInput({}) + expect( + screen + .getByTestId('data-table-column-filter-input-Name') + .querySelector('input') + ).toBeInTheDocument() + }) + + test('shows the current filter value', () => { + renderFilterInput({}, { name: 'hospital' }) + expect( + screen + .getByTestId('data-table-column-filter-input-Name') + .querySelector('input') + ).toHaveValue('hospital') + }) +}) + +describe('FilterInput multi-select path (no optionSetId)', () => { + const options = [{ value: 'High' }, { value: 'Low' }] + + test('shows "All" when nothing is selected', () => { + renderFilterInput({ dataKey: 'legend', name: 'Legend', options }) + expect(screen.getByText('All')).toBeInTheDocument() + }) + + test('shows the selected count when a filter is active', () => { + renderFilterInput( + { dataKey: 'legend', name: 'Legend', options }, + { legend: ['High'] } + ) + expect(screen.getByText('1 selected')).toBeInTheDocument() + }) + + test('opens a popover with a checkbox per option, using the raw value as the label', () => { + renderFilterInput({ dataKey: 'legend', name: 'Legend', options }) + fireEvent.click(screen.getByText('All')) + expect(screen.getByLabelText('High')).toBeInTheDocument() + expect(screen.getByLabelText('Low')).toBeInTheDocument() + }) +}) + +describe('FilterInput multi-select path (optionSetId)', () => { + const options = [{ value: 'CONFIRMED' }, { value: 'PROBABLE' }] + + beforeEach(() => { + useOptionSet.mockReturnValue({ + optionSet: { + options: [ + { code: 'CONFIRMED', name: 'Confirmed case' }, + { code: 'PROBABLE', name: 'Probable case' }, + ], + }, + }) + }) + + test('resolves stored codes to display names in the popover', () => { + renderFilterInput({ + dataKey: 'caseType', + name: 'Case classification', + options, + optionSetId: 'optionSet1', + }) + fireEvent.click(screen.getByText('All')) + expect(screen.getByLabelText('Confirmed case')).toBeInTheDocument() + expect(screen.getByLabelText('Probable case')).toBeInTheDocument() + }) + + test('falls back to the raw code when the option set has not loaded yet', () => { + useOptionSet.mockReturnValue({ optionSet: null }) + renderFilterInput({ + dataKey: 'caseType', + name: 'Case classification', + options, + optionSetId: 'optionSet1', + }) + fireEvent.click(screen.getByText('All')) + expect(screen.getByLabelText('CONFIRMED')).toBeInTheDocument() + }) +}) diff --git a/src/components/datatable/styles/FilterInput.module.css b/src/components/datatable/styles/FilterInput.module.css new file mode 100644 index 0000000000..e309ad01bf --- /dev/null +++ b/src/components/datatable/styles/FilterInput.module.css @@ -0,0 +1,18 @@ +.multiSelectButton { + width: 100%; + height: 24px; + font-size: 11px; + padding: 4px 6px; + border: 1px solid var(--colors-grey400); + border-radius: 3px; + background: var(--colors-white); + cursor: pointer; + text-align: left; +} + +.multiSelectPopover { + padding: var(--spacers-dp8); + max-height: 260px; + overflow-y: auto; + min-width: 180px; +} From b703733c2c3709530c0aabba17e06c7bf197a583 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Mon, 13 Jul 2026 22:45:21 +0200 Subject: [PATCH 029/205] feat: add numeric filter syntax help tooltip Replaces the cryptic '2,>3&<8' placeholder with a simpler '> 5, < 8' and adds an info icon + Tooltip explaining the AND/OR/comparison syntax, consistent with Tooltip's existing use throughout DataTable.jsx/BottomPanel.jsx. --- src/components/datatable/FilterInput.jsx | 42 +++++++++++++++---- .../datatable/__tests__/FilterInput.spec.jsx | 14 +++++++ .../datatable/styles/FilterInput.module.css | 18 ++++++++ 3 files changed, 66 insertions(+), 8 deletions(-) diff --git a/src/components/datatable/FilterInput.jsx b/src/components/datatable/FilterInput.jsx index 31a7bd91e1..95045cc2dd 100644 --- a/src/components/datatable/FilterInput.jsx +++ b/src/components/datatable/FilterInput.jsx @@ -1,5 +1,5 @@ import i18n from '@dhis2/d2-i18n' -import { Input, Popover } from '@dhis2/ui' +import { Input, Popover, Tooltip, IconInfo16 } from '@dhis2/ui' import PropTypes from 'prop-types' import React, { useRef, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' @@ -8,6 +8,16 @@ import useOptionSet from '../../hooks/useOptionSet.js' import Checkbox from '../core/Checkbox.jsx' import styles from './styles/FilterInput.module.css' +const NUMERIC_FILTER_HELP = ( + <div> + <div>{'> 5 — ' + i18n.t('greater than 5')}</div> + <div>{'>= 5 — ' + i18n.t('greater than or equal to 5')}</div> + <div>{'< 5, <= 5 — ' + i18n.t('less than (or equal to) 5')}</div> + <div>{'2, > 8 — ' + i18n.t('equal to 2 OR greater than 8')}</div> + <div>{'> 3 & < 8 — ' + i18n.t('greater than 3 AND less than 8')}</div> + </div> +) + // Shared popover UI — label resolution is injected so it never needs to // know whether it's an option-set column or a plain categorical one. const MultiSelectPopover = ({ @@ -146,13 +156,29 @@ const FilterInput = ({ type, dataKey, name, options, optionSetId }) => { : dispatch(clearDataFilter(layerId, dataKey)) return ( - <Input - dataTest={`data-table-column-filter-input-${name}`} - dense - placeholder={type === 'number' ? '2,>3&<8' : i18n.t('Search')} - value={stringFilterValue} - onChange={onChange} - /> + <span + className={ + type === 'number' ? styles.numericFilterWrapper : undefined + } + > + <Input + dataTest={`data-table-column-filter-input-${name}`} + dense + placeholder={type === 'number' ? '> 5, < 8' : i18n.t('Search')} + value={stringFilterValue} + onChange={onChange} + /> + {type === 'number' && ( + <Tooltip content={NUMERIC_FILTER_HELP} placement="top"> + <span + className={styles.helpIcon} + data-test="data-table-numeric-filter-help" + > + <IconInfo16 /> + </span> + </Tooltip> + )} + </span> ) } diff --git a/src/components/datatable/__tests__/FilterInput.spec.jsx b/src/components/datatable/__tests__/FilterInput.spec.jsx index 380b9c48ff..92c54a1132 100644 --- a/src/components/datatable/__tests__/FilterInput.spec.jsx +++ b/src/components/datatable/__tests__/FilterInput.spec.jsx @@ -46,6 +46,20 @@ describe('FilterInput text/numeric path', () => { .querySelector('input') ).toHaveValue('hospital') }) + + test('shows a numeric filter syntax help icon for number columns', () => { + renderFilterInput({ type: 'number' }) + expect( + screen.getByTestId('data-table-numeric-filter-help') + ).toBeInTheDocument() + }) + + test('does not show the help icon for string columns', () => { + renderFilterInput({ type: 'string' }) + expect( + screen.queryByTestId('data-table-numeric-filter-help') + ).not.toBeInTheDocument() + }) }) describe('FilterInput multi-select path (no optionSetId)', () => { diff --git a/src/components/datatable/styles/FilterInput.module.css b/src/components/datatable/styles/FilterInput.module.css index e309ad01bf..c5d585d03a 100644 --- a/src/components/datatable/styles/FilterInput.module.css +++ b/src/components/datatable/styles/FilterInput.module.css @@ -16,3 +16,21 @@ overflow-y: auto; min-width: 180px; } + +.numericFilterWrapper { + display: flex; + align-items: center; + gap: 2px; +} + +.numericFilterWrapper > :global(div) { + flex: 1 1 auto; + min-width: 0; +} + +.helpIcon { + display: inline-flex; + flex-shrink: 0; + color: var(--colors-grey600); + cursor: help; +} From bcb2326aa5b54ac32fc71909252ef64cef6ec2f6 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Mon, 13 Jul 2026 23:18:46 +0200 Subject: [PATCH 030/205] feat: pass columnOptions and optionSet through DataTable to FilterInput Table now forwards globalSearch into useTableData and threads columnOptions[dataKey]/optionSet.id from the header objects into FilterInput so it can pick between free-text and multi-select rendering. --- src/components/datatable/DataTable.jsx | 141 +++++++++++++---------- src/components/datatable/FilterInput.jsx | 2 +- 2 files changed, 84 insertions(+), 59 deletions(-) diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index 7fd1f0eade..41caa6e388 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -131,7 +131,12 @@ const TableComponents = { ), } -const Table = ({ availableWidth, onCountChange, showOnlySelected }) => { +const Table = ({ + availableWidth, + onCountChange, + showOnlySelected, + globalSearch, +}) => { const { systemSettings: { keyAnalysisDigitGroupSeparator }, } = useCachedData() @@ -235,16 +240,24 @@ const Table = ({ availableWidth, onCountChange, showOnlySelected }) => { ) const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]) - const { headers, rows, isLoading, error, totalCount, filteredCount } = - useTableData({ - layer, - sortField, - sortDirection, - showOnlyFeaturesInView, - mapBounds, - showOnlySelected, - selectedIdSet, - }) + const { + headers, + rows, + isLoading, + error, + totalCount, + filteredCount, + columnOptions, + } = useTableData({ + layer, + sortField, + sortDirection, + showOnlyFeaturesInView, + mapBounds, + showOnlySelected, + selectedIdSet, + globalSearch, + }) useEffect(() => { onCountChange?.(totalCount, filteredCount) @@ -457,55 +470,66 @@ const Table = ({ availableWidth, onCountChange, showOnlySelected }) => { onChange={onToggleSelectAll} /> </DataTableColumnHeader> - {headers.map(({ name, dataKey, type }, index) => ( - <DataTableColumnHeader - className={styles.columnHeader} - key={`${dataKey}-${index}`} - onFilterIconClick={type && Function.prototype} - showFilter={!!type && dataKey !== 'index'} - name={dataKey} - filter={ - type && ( - <FilterInput - type={type} - dataKey={dataKey} - name={name} - /> - ) - } - width={ - columnWidths.length > 0 - ? `${columnWidths[index]}px` - : 'auto' - } - > - <span className={styles.headerContent}> - {name} - <Tooltip - content={i18n.t('Sort by {{column}}', { - column: name, - })} - > - <button - type="button" - className={styles.sortButton} - data-test={`data-table-column-sort-button-${name}`} - onClick={() => - sortData({ name: dataKey }) - } - > - <SortIcon - direction={ - dataKey === sortField - ? sortDirection - : null + {headers.map( + ({ name, dataKey, type, optionSet }, index) => ( + <DataTableColumnHeader + className={styles.columnHeader} + key={`${dataKey}-${index}`} + onFilterIconClick={ + type && Function.prototype + } + showFilter={!!type && dataKey !== 'index'} + name={dataKey} + filter={ + type && ( + <FilterInput + type={type} + dataKey={dataKey} + name={name} + options={ + columnOptions[dataKey] } + optionSetId={optionSet?.id} /> - </button> - </Tooltip> - </span> - </DataTableColumnHeader> - ))} + ) + } + width={ + columnWidths.length > 0 + ? `${columnWidths[index]}px` + : 'auto' + } + > + <span className={styles.headerContent}> + {name} + <Tooltip + content={i18n.t( + 'Sort by {{column}}', + { column: name } + )} + > + <button + type="button" + className={styles.sortButton} + data-test={`data-table-column-sort-button-${name}`} + onClick={() => + sortData({ + name: dataKey, + }) + } + > + <SortIcon + direction={ + dataKey === sortField + ? sortDirection + : null + } + /> + </button> + </Tooltip> + </span> + </DataTableColumnHeader> + ) + )} </DataTableRow> )} itemContent={(_, row) => { @@ -587,6 +611,7 @@ const Table = ({ availableWidth, onCountChange, showOnlySelected }) => { Table.propTypes = { availableWidth: PropTypes.number, + globalSearch: PropTypes.string, showOnlySelected: PropTypes.bool, onCountChange: PropTypes.func, } diff --git a/src/components/datatable/FilterInput.jsx b/src/components/datatable/FilterInput.jsx index 95045cc2dd..d600da6908 100644 --- a/src/components/datatable/FilterInput.jsx +++ b/src/components/datatable/FilterInput.jsx @@ -186,8 +186,8 @@ FilterInput.propTypes = { dataKey: PropTypes.string.isRequired, name: PropTypes.string.isRequired, type: PropTypes.string.isRequired, - options: PropTypes.arrayOf(PropTypes.shape({ value: PropTypes.string })), optionSetId: PropTypes.string, + options: PropTypes.arrayOf(PropTypes.shape({ value: PropTypes.string })), } export default FilterInput From 071c6f24e08c72198b9607b01991372ff5d3c9a5 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Mon, 13 Jul 2026 23:34:31 +0200 Subject: [PATCH 031/205] feat: add global search box to data table toolbar Adds a dense Input between the "Clear filters" button and the show-only toggles, sized to shrink before the layer name has to truncate further. "Clear filters" now also resets the search box, and hasActiveFilters accounts for both column filters and the search string. --- src/components/datatable/BottomPanel.jsx | 20 ++++++++++++++++--- .../datatable/styles/BottomPanel.module.css | 6 ++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index 8353d0e2dc..9fa3084f0e 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -4,6 +4,7 @@ import { IconFilter16, IconEmptyFrame16, IconCheckmarkCircle16, + Input, Tooltip, } from '@dhis2/ui' import cx from 'classnames' @@ -49,7 +50,6 @@ const BottomPanel = () => { state.map.mapViews.find((l) => l.id === activeLayerId) ) const dataFilters = activeLayer?.dataFilters ?? {} - const hasActiveFilters = Object.keys(dataFilters).length > 0 const showOnlyFeaturesInView = useSelector( (state) => state.ui.showOnlyFeaturesInView ) @@ -69,6 +69,10 @@ const BottomPanel = () => { const [filteredCount, setFilteredCount] = useState(null) const [nameTooltipPos, setNameTooltipPos] = useState(null) const [isCollapsed, setIsCollapsed] = useState(false) + const [globalSearch, setGlobalSearch] = useState('') + + const hasActiveFilters = + Object.keys(dataFilters).length > 0 || globalSearch.trim() !== '' const maxHeight = height - getCssVar('--header-height') - getCssVar('--toolbar-height') @@ -246,9 +250,10 @@ const BottomPanel = () => { <button type="button" className={styles.clearFiltersButton} - onClick={() => + onClick={() => { dispatch(clearDataFilters(activeLayerId)) - } + setGlobalSearch('') + }} > <Tooltip content={i18n.t('Clear filters')}> <span className={styles.filteredIcon}> @@ -258,6 +263,14 @@ const BottomPanel = () => { </Tooltip> </button> )} + <Input + dense + dataTest="data-table-global-search" + placeholder={i18n.t('Search all columns')} + value={globalSearch} + onChange={({ value }) => setGlobalSearch(value)} + className={styles.globalSearch} + /> <button type="button" className={cx(styles.toggleButton, { @@ -310,6 +323,7 @@ const BottomPanel = () => { availableWidth={panelWidth} onCountChange={onCountChange} showOnlySelected={showOnlySelected} + globalSearch={globalSearch} /> </ErrorBoundary> </div> diff --git a/src/components/datatable/styles/BottomPanel.module.css b/src/components/datatable/styles/BottomPanel.module.css index fc84b87494..ad8b283646 100644 --- a/src/components/datatable/styles/BottomPanel.module.css +++ b/src/components/datatable/styles/BottomPanel.module.css @@ -159,3 +159,9 @@ min-width: 18px !important; min-height: 18px !important; } + +.globalSearch { + flex: 0 1 160px; + min-width: 90px; + margin-bottom: 0 !important; +} From da9c11ab49af7328d9b745eae3876252ed50a3c1 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 14 Jul 2026 00:10:39 +0200 Subject: [PATCH 032/205] feat: wire legend item clicks to pre-populate data table filter Threads an optional onItemClick/activeLegendNames pair through Legend -> LegendItem, scoped to THEMATIC_LAYER in OverlayCard (the only layer type with a legend data-table column). Clicking a legend class adds/removes it from the legend filter array and opens the data table for that layer if it isn't already showing. Other Legend call sites (plugin, download, edit preview, classification) never pass the new props, so they're unaffected. --- .../layers/overlays/OverlayCard.jsx | 57 ++++++++- .../overlays/__tests__/OverlayCard.spec.jsx | 111 ++++++++++++++++++ src/components/legend/Legend.jsx | 10 ++ src/components/legend/LegendItem.jsx | 26 +++- .../legend/styles/LegendItem.module.css | 21 ++++ 5 files changed, 222 insertions(+), 3 deletions(-) diff --git a/src/components/layers/overlays/OverlayCard.jsx b/src/components/layers/overlays/OverlayCard.jsx index a490f28cda..e567b78b68 100644 --- a/src/components/layers/overlays/OverlayCard.jsx +++ b/src/components/layers/overlays/OverlayCard.jsx @@ -5,6 +5,10 @@ import i18n from '@dhis2/d2-i18n' import PropTypes from 'prop-types' import React, { useState } from 'react' import { connect } from 'react-redux' +import { + setDataFilter, + clearDataFilter, +} from '../../../actions/dataFilters.js' import { toggleDataTable } from '../../../actions/dataTable.js' import { editLayer, @@ -24,6 +28,7 @@ import { DATA_TABLE_LAYER_TYPES, OPEN_AS_LAYER_TYPES, EXTERNAL_LAYER, + THEMATIC_LAYER, } from '../../../constants/layers.js' import { getAnalyticalObjectFromThematicLayer, @@ -45,6 +50,9 @@ const OverlayCard = ({ toggleLayerExpand, toggleLayerVisibility, toggleDataTable, + setDataFilter, + clearDataFilter, + activeDataTableLayerId, }) => { const [showDataDownloadDialog, setShowDataDownloadDialog] = useState(false) const { baseUrl } = useConfig() @@ -61,12 +69,37 @@ const OverlayCard = ({ layer: layerType, isLoaded, loadError, + dataFilters, } = layer const canEdit = layerType !== EXTERNAL_LAYER const canToggleDataTable = DATA_TABLE_LAYER_TYPES.includes(layerType) const canDownload = DOWNLOADABLE_LAYER_TYPES.includes(layerType) const canOpenAs = OPEN_AS_LAYER_TYPES.includes(layerType) + const canFilterByLegend = layerType === THEMATIC_LAYER + + const onLegendItemClick = (item) => { + if (!item?.name) { + return + } + const currentLegendFilter = Array.isArray(dataFilters?.legend) + ? dataFilters.legend + : [] + const isActive = currentLegendFilter.includes(item.name) + const nextLegendFilter = isActive + ? currentLegendFilter.filter((n) => n !== item.name) + : [...currentLegendFilter, item.name] + + if (nextLegendFilter.length) { + setDataFilter(id, 'legend', nextLegendFilter) + } else { + clearDataFilter(id, 'legend') + } + + if (activeDataTableLayerId !== id) { + toggleDataTable(id) + } + } const getCardContent = () => { if (loadError) { @@ -84,7 +117,18 @@ const OverlayCard = ({ return ( legend && ( <div className={styles.legend}> - <Legend {...legend} /> + <Legend + {...legend} + onItemClick={ + canFilterByLegend ? onLegendItemClick : undefined + } + activeLegendNames={ + canFilterByLegend && + Array.isArray(dataFilters?.legend) + ? dataFilters.legend + : undefined + } + /> </div> ) ) @@ -156,16 +200,23 @@ const OverlayCard = ({ OverlayCard.propTypes = { changeLayerOpacity: PropTypes.func.isRequired, + clearDataFilter: PropTypes.func.isRequired, duplicateLayer: PropTypes.func.isRequired, editLayer: PropTypes.func.isRequired, layer: PropTypes.object.isRequired, removeLayer: PropTypes.func.isRequired, + setDataFilter: PropTypes.func.isRequired, toggleDataTable: PropTypes.func.isRequired, toggleLayerExpand: PropTypes.func.isRequired, toggleLayerVisibility: PropTypes.func.isRequired, + activeDataTableLayerId: PropTypes.string, } -export default connect(null, { +const mapStateToProps = (state) => ({ + activeDataTableLayerId: state.dataTable, +}) + +export default connect(mapStateToProps, { editLayer, removeLayer, duplicateLayer, @@ -173,4 +224,6 @@ export default connect(null, { toggleLayerExpand, toggleLayerVisibility, toggleDataTable, + setDataFilter, + clearDataFilter, })(OverlayCard) diff --git a/src/components/layers/overlays/__tests__/OverlayCard.spec.jsx b/src/components/layers/overlays/__tests__/OverlayCard.spec.jsx index 4da6e6ae81..c70cce431e 100644 --- a/src/components/layers/overlays/__tests__/OverlayCard.spec.jsx +++ b/src/components/layers/overlays/__tests__/OverlayCard.spec.jsx @@ -25,6 +25,19 @@ jest.mock('@dhis2/app-service-alerts', () => ({ useAlert: () => ({ show: mockShow }), })) +jest.mock('../../../cachedDataProvider/CachedDataProvider.jsx', () => ({ + useCachedData: jest.fn(() => ({ + systemSettings: { keyAnalysisDigitGroupSeparator: 'NONE' }, + })), +})) + +// jsdom has no ResizeObserver; Legend.jsx uses one to measure overflow, which +// is irrelevant to the legend-click wiring under test here. +global.ResizeObserver = class { + observe() {} + disconnect() {} +} + const mockStore = configureMockStore() describe('OverlayCard', () => { @@ -59,3 +72,101 @@ describe('OverlayCard', () => { }) }) }) + +describe('OverlayCard legend-driven filter', () => { + const layer = { + id: 'layer1', + name: 'Test layer', + layer: 'thematic', + isLoaded: true, + isExpanded: true, + isVisible: true, + opacity: 1, + dataFilters: {}, + legend: { + items: [ + { name: 'High', color: '#ff0000' }, + { name: 'Low', color: '#00ff00' }, + ], + }, + } + + const renderCard = (store) => + render( + <Provider store={store}> + <OverlayCard layer={layer} /> + </Provider> + ) + + test('opens the table and sets the legend filter on click when the table is closed', () => { + const store = mockStore({ dataTable: null, aggregations: {} }) + renderCard(store) + + fireEvent.click(screen.getByText('High')) + + const actions = store.getActions() + expect(actions).toContainEqual({ + type: 'DATA_FILTER_SET', + layerId: 'layer1', + fieldId: 'legend', + filter: ['High'], + }) + expect(actions).toContainEqual({ + type: 'DATA_TABLE_TOGGLE', + id: 'layer1', + }) + }) + + test('adds to the filter without re-toggling the table when it is already open for this layer', () => { + const store = mockStore({ dataTable: 'layer1', aggregations: {} }) + renderCard(store) + + fireEvent.click(screen.getByText('Low')) + + const actions = store.getActions() + expect(actions).toContainEqual({ + type: 'DATA_FILTER_SET', + layerId: 'layer1', + fieldId: 'legend', + filter: ['Low'], + }) + expect(actions).not.toContainEqual( + expect.objectContaining({ type: 'DATA_TABLE_TOGGLE' }) + ) + }) + + test('clears the filter when clicking an already-active legend class', () => { + const activeLayer = { + ...layer, + dataFilters: { legend: ['High'] }, + } + const store = mockStore({ dataTable: 'layer1', aggregations: {} }) + render( + <Provider store={store}> + <OverlayCard layer={activeLayer} /> + </Provider> + ) + + fireEvent.click(screen.getByText('High')) + + expect(store.getActions()).toContainEqual({ + type: 'DATA_FILTER_CLEAR', + layerId: 'layer1', + fieldId: 'legend', + }) + }) + + test('does not wire legend clicks for non-thematic layers', () => { + const facilityLayer = { ...layer, layer: 'facility' } + const store = mockStore({ dataTable: null, aggregations: {} }) + render( + <Provider store={store}> + <OverlayCard layer={facilityLayer} /> + </Provider> + ) + + fireEvent.click(screen.getByText('High')) + + expect(store.getActions()).toEqual([]) + }) +}) diff --git a/src/components/legend/Legend.jsx b/src/components/legend/Legend.jsx index ae7603787b..9bd4793a3d 100644 --- a/src/components/legend/Legend.jsx +++ b/src/components/legend/Legend.jsx @@ -98,6 +98,8 @@ const Legend = ({ orgUnitsWithoutCoordinatesCount, orgUnitsPointOnly = false, isPlugin = false, + onItemClick, + activeLegendNames, }) => { const { systemSettings: { keyAnalysisDigitGroupSeparator }, @@ -296,6 +298,12 @@ const Legend = ({ isPlugin={isPlugin} suppressRange={suppressAllRanges} forceScientific={forceScientific} + onClick={onItemClick ? () => onItemClick(item) : undefined} + isActive={ + !!activeLegendNames && + !!item.name && + activeLegendNames.includes(item.name) + } key={`${item.name ?? ''}-${item.startValue ?? ''}-${ item.endValue ?? '' }-${index}`} @@ -390,6 +398,7 @@ const Legend = ({ } Legend.propTypes = { + activeLegendNames: PropTypes.array, bubbles: PropTypes.shape({ radiusHigh: PropTypes.number.isRequired, radiusLow: PropTypes.number.isRequired, @@ -414,6 +423,7 @@ Legend.propTypes = { sourceUrl: PropTypes.string, unit: PropTypes.string, url: PropTypes.string, + onItemClick: PropTypes.func, } export default Legend diff --git a/src/components/legend/LegendItem.jsx b/src/components/legend/LegendItem.jsx index fc66495d8d..b2b9bb8662 100644 --- a/src/components/legend/LegendItem.jsx +++ b/src/components/legend/LegendItem.jsx @@ -1,3 +1,4 @@ +import cx from 'classnames' import PropTypes from 'prop-types' import React from 'react' import LegendItemRange from './LegendItemRange.jsx' @@ -26,6 +27,8 @@ const LegendItem = ({ isPlugin, suppressRange, forceScientific, + onClick, + isActive, }) => { if (!name && startValue === undefined && endValue === undefined) { return null @@ -51,7 +54,26 @@ const LegendItem = ({ const lineWeight = weight ? Math.min(weight, maxLineWeight) : null return ( - <tr className={styles.legendItem} data-test="layerlegend-item"> + <tr + className={cx(styles.legendItem, { + [styles.clickable]: !!onClick, + [styles.active]: isActive, + })} + data-test="layerlegend-item" + onClick={onClick} + role={onClick ? 'button' : undefined} + tabIndex={onClick ? 0 : undefined} + onKeyDown={ + onClick + ? (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onClick() + } + } + : undefined + } + > <th> {weight ? ( type === 'LineString' ? ( @@ -91,6 +113,7 @@ LegendItem.propTypes = { fillColor: PropTypes.string, forceScientific: PropTypes.bool, image: PropTypes.string, + isActive: PropTypes.bool, isPlugin: PropTypes.bool, name: PropTypes.string, radius: PropTypes.number, @@ -101,6 +124,7 @@ LegendItem.propTypes = { type: PropTypes.string, useCompact: PropTypes.bool, weight: PropTypes.number, + onClick: PropTypes.func, } export default LegendItem diff --git a/src/components/legend/styles/LegendItem.module.css b/src/components/legend/styles/LegendItem.module.css index fdc4d8aa72..ffc6ff3843 100644 --- a/src/components/legend/styles/LegendItem.module.css +++ b/src/components/legend/styles/LegendItem.module.css @@ -23,3 +23,24 @@ print-color-adjust: exact; /* Firefox */ } + +.clickable { + cursor: pointer; +} + +.clickable:hover { + background-color: var(--colors-grey100); +} + +.clickable:focus-visible { + outline: 2px solid var(--colors-blue600); + outline-offset: -2px; +} + +.active { + background-color: var(--colors-blue050); +} + +.active:hover { + background-color: var(--colors-blue100); +} From 5dc740cacbd87fe470b5b83bef3ce9b250817772 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 14 Jul 2026 00:13:00 +0200 Subject: [PATCH 033/205] chore: fix lint/prettier issues in PR3 files Import order in FilterInput.spec.jsx and Prettier formatting across the files touched by the filtering changes. --- src/components/datatable/DataTable.jsx | 4 +--- src/components/datatable/FilterInput.jsx | 3 +-- src/components/datatable/__tests__/FilterInput.spec.jsx | 4 +--- src/components/datatable/__tests__/useTableData.spec.jsx | 4 +--- src/components/layers/overlays/OverlayCard.jsx | 5 +---- 5 files changed, 5 insertions(+), 15 deletions(-) diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index 41caa6e388..3635cb2744 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -486,9 +486,7 @@ const Table = ({ type={type} dataKey={dataKey} name={name} - options={ - columnOptions[dataKey] - } + options={columnOptions[dataKey]} optionSetId={optionSet?.id} /> ) diff --git a/src/components/datatable/FilterInput.jsx b/src/components/datatable/FilterInput.jsx index d600da6908..f20d30342a 100644 --- a/src/components/datatable/FilterInput.jsx +++ b/src/components/datatable/FilterInput.jsx @@ -147,8 +147,7 @@ const FilterInput = ({ type, dataKey, name, options, optionSetId }) => { ) } - const stringFilterValue = - typeof filterValue === 'string' ? filterValue : '' + const stringFilterValue = typeof filterValue === 'string' ? filterValue : '' const onChange = ({ value }) => value !== '' diff --git a/src/components/datatable/__tests__/FilterInput.spec.jsx b/src/components/datatable/__tests__/FilterInput.spec.jsx index 92c54a1132..5651f45657 100644 --- a/src/components/datatable/__tests__/FilterInput.spec.jsx +++ b/src/components/datatable/__tests__/FilterInput.spec.jsx @@ -2,6 +2,7 @@ import { render, fireEvent, screen } from '@testing-library/react' import React from 'react' import { Provider } from 'react-redux' import configureMockStore from 'redux-mock-store' +import useOptionSet from '../../../hooks/useOptionSet.js' import FilterInput from '../FilterInput.jsx' jest.mock('../../../hooks/useOptionSet.js', () => ({ @@ -9,9 +10,6 @@ jest.mock('../../../hooks/useOptionSet.js', () => ({ default: jest.fn(), })) -// eslint-disable-next-line import/first -import useOptionSet from '../../../hooks/useOptionSet.js' - const mockStore = configureMockStore() const renderFilterInput = (props, dataFilters) => { diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index 6501dc91d4..55edee96ab 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -1089,9 +1089,7 @@ describe('useTableData columnOptions', () => { const { current } = renderTableData(layer) - const header = current.headers.find( - (h) => h.dataKey === 'AbCdEfGhIjK' - ) + const header = current.headers.find((h) => h.dataKey === 'AbCdEfGhIjK') expect(header.optionSet).toEqual({ id: 'xyz123' }) expect(current.columnOptions.AbCdEfGhIjK).toEqual([ { value: 'CONFIRMED' }, diff --git a/src/components/layers/overlays/OverlayCard.jsx b/src/components/layers/overlays/OverlayCard.jsx index e567b78b68..e8940790a9 100644 --- a/src/components/layers/overlays/OverlayCard.jsx +++ b/src/components/layers/overlays/OverlayCard.jsx @@ -5,10 +5,7 @@ import i18n from '@dhis2/d2-i18n' import PropTypes from 'prop-types' import React, { useState } from 'react' import { connect } from 'react-redux' -import { - setDataFilter, - clearDataFilter, -} from '../../../actions/dataFilters.js' +import { setDataFilter, clearDataFilter } from '../../../actions/dataFilters.js' import { toggleDataTable } from '../../../actions/dataTable.js' import { editLayer, From c4218fee8430afae2642e46a3fd92abe705856b6 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 14 Jul 2026 10:51:51 +0200 Subject: [PATCH 034/205] fix: toolbar polish - clear filters button, search sizing, collapse icon/bug --- i18n/en.pot | 33 ++++++++++-- src/components/core/icons.jsx | 53 ------------------- src/components/datatable/BottomPanel.jsx | 11 ++-- .../datatable/styles/BottomPanel.module.css | 20 ++++++- .../datatable/styles/DataTable.module.css | 1 + 5 files changed, 53 insertions(+), 65 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index e729fd9f52..8cf7fa8826 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-10T08:27:09.245Z\n" -"PO-Revision-Date: 2026-07-10T08:27:09.245Z\n" +"POT-Creation-Date: 2026-07-14T06:48:41.625Z\n" +"PO-Revision-Date: 2026-07-14T06:48:41.625Z\n" msgid "2020" msgstr "2020" @@ -170,6 +170,9 @@ msgstr "Collapse" msgid "Clear filters" msgstr "Clear filters" +msgid "Search all columns" +msgstr "Search all columns" + msgid "Show only features in current map view" msgstr "Show only features in current map view" @@ -194,6 +197,29 @@ msgstr "Sort by {{column}}" msgid "Something went wrong" msgstr "Something went wrong" +msgid "greater than 5" +msgstr "greater than 5" + +msgid "greater than or equal to 5" +msgstr "greater than or equal to 5" + +msgid "less than (or equal to) 5" +msgstr "less than (or equal to) 5" + +msgid "equal to 2 OR greater than 8" +msgstr "equal to 2 OR greater than 8" + +msgid "greater than 3 AND less than 8" +msgstr "greater than 3 AND less than 8" + +msgid "All" +msgstr "All" + +msgid "{{count}} selected" +msgid_plural "{{count}} selected" +msgstr[0] "{{count}} selected" +msgstr[1] "{{count}} selected" + msgid "Search" msgstr "Search" @@ -869,9 +895,6 @@ msgstr "" msgid "no value" msgstr "no value" -msgid "All" -msgstr "All" - msgid "Loading data" msgstr "Loading data" diff --git a/src/components/core/icons.jsx b/src/components/core/icons.jsx index 78de9d4a22..b46a6354f5 100644 --- a/src/components/core/icons.jsx +++ b/src/components/core/icons.jsx @@ -49,59 +49,6 @@ export const IconZoomIn16 = () => ( </svg> ) -// Two stacked chevrons — "collapse"/"restore to full height" toggle. -export const IconChevronDoubleDown16 = () => ( - <svg - height="16" - viewBox="0 0 16 16" - width="16" - xmlns="http://www.w3.org/2000/svg" - > - <path - d="M4 4L8 7L12 4" - fill="none" - stroke="currentColor" - strokeWidth="1.5" - strokeLinecap="round" - strokeLinejoin="round" - /> - <path - d="M4 9L8 12L12 9" - fill="none" - stroke="currentColor" - strokeWidth="1.5" - strokeLinecap="round" - strokeLinejoin="round" - /> - </svg> -) - -export const IconChevronDoubleUp16 = () => ( - <svg - height="16" - viewBox="0 0 16 16" - width="16" - xmlns="http://www.w3.org/2000/svg" - > - <path - d="M4 7L8 4L12 7" - fill="none" - stroke="currentColor" - strokeWidth="1.5" - strokeLinecap="round" - strokeLinejoin="round" - /> - <path - d="M4 12L8 9L12 12" - fill="none" - stroke="currentColor" - strokeWidth="1.5" - strokeLinecap="round" - strokeLinejoin="round" - /> - </svg> -) - export const IconDrag = () => ( <svg height="8" diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index 9fa3084f0e..a9be31aad1 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -4,6 +4,8 @@ import { IconFilter16, IconEmptyFrame16, IconCheckmarkCircle16, + IconChevronDown16, + IconChevronUp16, Input, Tooltip, } from '@dhis2/ui' @@ -29,10 +31,6 @@ import { import useKeyDown from '../../hooks/useKeyDown.js' import { getCssVar } from '../../util/helpers.js' import ColorPicker from '../core/ColorPicker.jsx' -import { - IconChevronDoubleDown16, - IconChevronDoubleUp16, -} from '../core/icons.jsx' import { useWindowDimensions } from '../WindowDimensionsProvider.jsx' import DataTable from './DataTable.jsx' import ErrorBoundary from './ErrorBoundary.jsx' @@ -205,9 +203,9 @@ const BottomPanel = () => { } > {isCollapsed ? ( - <IconChevronDoubleUp16 /> + <IconChevronUp16 /> ) : ( - <IconChevronDoubleDown16 /> + <IconChevronDown16 /> )} </Tooltip> </button> @@ -270,6 +268,7 @@ const BottomPanel = () => { value={globalSearch} onChange={({ value }) => setGlobalSearch(value)} className={styles.globalSearch} + onDoubleClick={(e) => e.stopPropagation()} /> <button type="button" diff --git a/src/components/datatable/styles/BottomPanel.module.css b/src/components/datatable/styles/BottomPanel.module.css index ad8b283646..5058846a56 100644 --- a/src/components/datatable/styles/BottomPanel.module.css +++ b/src/components/datatable/styles/BottomPanel.module.css @@ -135,6 +135,16 @@ background-color: var(--colors-grey300); } +.clearFiltersButton:disabled { + color: var(--colors-grey400); + cursor: not-allowed; +} + +.clearFiltersButton:disabled:hover { + color: var(--colors-grey400); + background-color: transparent; +} + .toggleButton.active { color: var(--colors-blue700); background-color: var(--colors-blue100); @@ -163,5 +173,13 @@ .globalSearch { flex: 0 1 160px; min-width: 90px; - margin-bottom: 0 !important; +} + +.globalSearch > :global(div) { + width: 100%; +} + +.globalSearch :global(input.dense) { + padding: 4px 6px; + font-size: 11px; } diff --git a/src/components/datatable/styles/DataTable.module.css b/src/components/datatable/styles/DataTable.module.css index 055c0ea101..8f09b2881c 100644 --- a/src/components/datatable/styles/DataTable.module.css +++ b/src/components/datatable/styles/DataTable.module.css @@ -75,6 +75,7 @@ td.hovered { .columnHeader :global(input.dense) { padding: 4px 6px; + font-size: 11px; } .columnHeader :global(input::placeholder) { From 09b70875b996b2e3bb56f4a78ad1bda0b6732165 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 14 Jul 2026 22:18:57 +0200 Subject: [PATCH 035/205] feat: round out data table filtering with reverse-selection, zoom-to-filtered, and a richer selection filter --- cypress/integration/dataTable.cy.js | 29 +- i18n/en.pot | 67 +- src/actions/dataTable.js | 8 +- src/components/datatable/BottomPanel.jsx | 53 +- src/components/datatable/DataTable.jsx | 437 +++++++-- src/components/datatable/FilterInput.jsx | 826 ++++++++++++++++-- src/components/datatable/TableContextMenu.jsx | 26 +- .../datatable/__tests__/DataTable.spec.jsx | 74 ++ .../datatable/__tests__/FilterInput.spec.jsx | 800 ++++++++++++++++- .../__tests__/TableContextMenu.spec.jsx | 71 ++ .../datatable/__tests__/useTableData.spec.jsx | 227 ++++- .../datatable/styles/BottomPanel.module.css | 10 + .../datatable/styles/DataTable.module.css | 151 +++- .../datatable/styles/FilterInput.module.css | 229 ++++- src/components/datatable/useTableData.js | 122 ++- src/components/map/Map.jsx | 6 +- src/components/map/MapContainer.jsx | 4 +- src/components/map/MapView.jsx | 8 +- src/components/map/SplitView.jsx | 6 +- src/components/map/layers/Layer.js | 39 +- .../map/layers/__tests__/Layer.spec.js | 69 ++ src/constants/actionTypes.js | 3 +- src/constants/selection.js | 4 + src/reducers/__tests__/ui.spec.js | 36 +- src/reducers/ui.js | 16 +- src/util/__tests__/filter.spec.js | 14 +- src/util/filter.js | 11 +- 27 files changed, 2983 insertions(+), 363 deletions(-) create mode 100644 src/components/datatable/__tests__/TableContextMenu.spec.jsx create mode 100644 src/components/map/layers/__tests__/Layer.spec.js create mode 100644 src/constants/selection.js diff --git a/cypress/integration/dataTable.cy.js b/cypress/integration/dataTable.cy.js index 67e9e388ac..741933dbd0 100644 --- a/cypress/integration/dataTable.cy.js +++ b/cypress/integration/dataTable.cy.js @@ -81,14 +81,16 @@ describe('data table', () => { cy.getByDataTest('layers-toggle-button').click() // check number of columns + // (Legend + Color are merged into one swatch+name column for + // thematic layers, so this is one fewer than the number of headers) cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-datatablecellhead') - .should('have.length', 11) + .should('have.length', 10) // Filter by name - cy.getByDataTest('data-table-column-filter-input-Name') + cy.getByDataTest('data-table-column-filter-search-Name') .find('input') - .type('bar') + .type('bar{enter}') // check that the filter returned the correct number of rows cy.getByDataTest('bottom-panel') @@ -113,9 +115,9 @@ describe('data table', () => { checkTableCell({ row: 6, column: 2, expectedContent: 'Bargbe' }) // filter by Value (numeric) - cy.getByDataTest('data-table-column-filter-input-Value') + cy.getByDataTest('data-table-column-filter-search-Value') .find('input') - .type('>26') + .type('>26{enter}') // check that the (combined) filter returned the correct number of rows cy.getByDataTest('bottom-panel') @@ -204,9 +206,9 @@ describe('data table', () => { // filter by Org unit const ouName = 'Moyowa' - cy.getByDataTest('data-table-column-filter-input-Org unit') + cy.getByDataTest('data-table-column-filter-search-Org unit') .find('input') - .type(ouName) + .type(`${ouName}{enter}`) // check that all the rows have Org unit Moyowa checkTableCell({ row: 0, column: 2, expectedContent: ouName }) @@ -218,18 +220,19 @@ describe('data table', () => { .should('have.length', 3) // filter by Mode of Discharge - cy.getByDataTest('data-table-column-filter-input-Mode of Discharge') + cy.getByDataTest('data-table-column-filter-search-Mode of Discharge') .find('input') .type('Absconded') + cy.contains('label', 'Absconded').click() cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-tablebody') .findByDataTest('dhis2-uicore-datatablerow') .should('have.length', 1) - cy.getByDataTest('data-table-column-filter-input-Mode of Discharge') - .find('input') - .clear() + cy.getByDataTest('data-table-column-filter-search-Mode of Discharge') + .find('.clear-button') + .click() cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-tablebody') @@ -237,9 +240,9 @@ describe('data table', () => { .should('have.length', 3) // filter by Age in years (numeric) - cy.getByDataTest('data-table-column-filter-input-Age in years') + cy.getByDataTest('data-table-column-filter-search-Age in years') .find('input') - .type('<51') + .type('<51{enter}') // check that the filter returned the correct number of rows cy.getByDataTest('bottom-panel') diff --git a/i18n/en.pot b/i18n/en.pot index 8cf7fa8826..a0954b8334 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-14T06:48:41.625Z\n" -"PO-Revision-Date: 2026-07-14T06:48:41.625Z\n" +"POT-Creation-Date: 2026-07-14T19:48:56.770Z\n" +"PO-Revision-Date: 2026-07-14T19:48:56.771Z\n" msgid "2020" msgstr "2020" @@ -176,21 +176,41 @@ msgstr "Search all columns" msgid "Show only features in current map view" msgstr "Show only features in current map view" -msgid "Show only selected features" -msgstr "Show only selected features" - msgid "Highlight color" msgstr "Highlight color" msgid "Close" msgstr "Close" +msgid "Selected" +msgstr "Selected" + +msgid "Not selected" +msgstr "Not selected" + +msgid "All" +msgstr "All" + +msgid "{{count}} selected" +msgid_plural "{{count}} selected" +msgstr[0] "{{count}} selected" +msgstr[1] "{{count}} selected" + +msgid "No features match your filters" +msgstr "No features match your filters" + msgid "No results found" msgstr "No results found" msgid "Select all" msgstr "Select all" +msgid "Sort by Selected" +msgstr "Sort by Selected" + +msgid "Reverse selection" +msgstr "Reverse selection" + msgid "Sort by {{column}}" msgstr "Sort by {{column}}" @@ -212,17 +232,33 @@ msgstr "equal to 2 OR greater than 8" msgid "greater than 3 AND less than 8" msgstr "greater than 3 AND less than 8" -msgid "All" -msgstr "All" +msgid "Select values, or type text to match rows that contain it." +msgstr "Select values, or type text to match rows that contain it." -msgid "{{count}} selected" -msgid_plural "{{count}} selected" -msgstr[0] "{{count}} selected" -msgstr[1] "{{count}} selected" +msgid "Use filter" +msgstr "Use filter" + +msgid "Contains" +msgstr "Contains" + +msgid "Search or type > 5, < 8…" +msgstr "Search or type > 5, < 8…" msgid "Search" msgstr "Search" +msgid "Any value" +msgstr "Any value" + +msgid "Too many values to list - type to filter this column" +msgstr "Too many values to list - type to filter this column" + +msgid "No matches" +msgstr "No matches" + +msgid "No value" +msgstr "No value" + msgid "Drill up one level" msgstr "Drill up one level" @@ -241,6 +277,9 @@ msgstr "Zoom to layer" msgid "Zoom to selected features" msgstr "Zoom to selected features" +msgid "Zoom to filtered features" +msgstr "Zoom to filtered features" + msgid "Data table is not supported when events are grouped on the server." msgstr "Data table is not supported when events are grouped on the server." @@ -287,6 +326,12 @@ msgstr "Org unit boundary" msgid "Event time" msgstr "Event time" +msgid "Loading Earth Engine data…" +msgstr "Loading Earth Engine data…" + +msgid "Loading additional events…" +msgstr "Loading additional events…" + msgid "Items" msgstr "Items" diff --git a/src/actions/dataTable.js b/src/actions/dataTable.js index 133e680b73..5b9adde54a 100644 --- a/src/actions/dataTable.js +++ b/src/actions/dataTable.js @@ -23,12 +23,8 @@ export const toggleShowOnlyFeaturesInView = () => ({ type: types.TOGGLE_SHOW_ONLY_IN_VIEW, }) -export const toggleShowOnlySelected = () => ({ - type: types.TOGGLE_SHOW_ONLY_SELECTED, -}) - -export const setShowOnlySelected = (value) => ({ - type: types.SHOW_ONLY_SELECTED_SET, +export const setSelectionFilter = (value) => ({ + type: types.SELECTION_FILTER_SET, value, }) diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index a9be31aad1..a9630ab81d 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -3,7 +3,6 @@ import { IconCross16, IconFilter16, IconEmptyFrame16, - IconCheckmarkCircle16, IconChevronDown16, IconChevronUp16, Input, @@ -24,8 +23,7 @@ import { closeDataTable, resizeDataTable, toggleShowOnlyFeaturesInView, - toggleShowOnlySelected, - setShowOnlySelected, + setSelectionFilter, setHighlightColor, } from '../../actions/dataTable.js' import useKeyDown from '../../hooks/useKeyDown.js' @@ -40,6 +38,7 @@ import styles from './styles/BottomPanel.module.css' // Must match `.dataTableControls`'s height in BottomPanel.module.css const COLLAPSED_HEIGHT = 36 const MIN_HEIGHT = 50 +const EMPTY_FILTERS = {} const BottomPanel = () => { const dataTableHeight = useSelector((state) => state.ui.dataTableHeight) @@ -47,14 +46,11 @@ const BottomPanel = () => { const activeLayer = useSelector((state) => state.map.mapViews.find((l) => l.id === activeLayerId) ) - const dataFilters = activeLayer?.dataFilters ?? {} + const dataFilters = activeLayer?.dataFilters ?? EMPTY_FILTERS const showOnlyFeaturesInView = useSelector( (state) => state.ui.showOnlyFeaturesInView ) - const showOnlySelected = useSelector((state) => state.ui.showOnlySelected) - const selection = useSelector((state) => state.selection) - const selectedCount = - selection.layerId === activeLayerId ? selection.ids.length : 0 + const selectionFilter = useSelector((state) => state.ui.selectionFilter) const highlightColor = useSelector((state) => state.ui.highlightColor) const dispatch = useDispatch() @@ -70,7 +66,9 @@ const BottomPanel = () => { const [globalSearch, setGlobalSearch] = useState('') const hasActiveFilters = - Object.keys(dataFilters).length > 0 || globalSearch.trim() !== '' + Object.keys(dataFilters).length > 0 || + globalSearch.trim() !== '' || + selectionFilter?.length > 0 const maxHeight = height - getCssVar('--header-height') - getCssVar('--toolbar-height') @@ -113,6 +111,12 @@ const BottomPanel = () => { setFilteredCount(filtered) }, []) + const onClearFilters = useCallback(() => { + dispatch(clearDataFilters(activeLayerId)) + dispatch(setSelectionFilter([])) + setGlobalSearch('') + }, [dispatch, activeLayerId]) + const onNameMouseEnter = useCallback(() => { const el = nameRef.current if (!el || el.scrollWidth <= el.offsetWidth) { @@ -165,12 +169,6 @@ const BottomPanel = () => { useKeyDown('Escape', () => dispatch(closeDataTable()), true) - useEffect(() => { - if (showOnlySelected && selectedCount === 0) { - dispatch(setShowOnlySelected(false)) - } - }, [dispatch, showOnlySelected, selectedCount]) - let rowCountLabel = null if (totalCount !== null && filteredCount !== null) { rowCountLabel = @@ -201,6 +199,7 @@ const BottomPanel = () => { content={ isCollapsed ? i18n.t('Restore') : i18n.t('Collapse') } + placement="top" > {isCollapsed ? ( <IconChevronUp16 /> @@ -281,19 +280,11 @@ const BottomPanel = () => { content={i18n.t( 'Show only features in current map view' )} + placement="top" > - <IconEmptyFrame16 /> - </Tooltip> - </button> - <button - type="button" - className={cx(styles.toggleButton, { - [styles.active]: showOnlySelected, - })} - onClick={() => dispatch(toggleShowOnlySelected())} - > - <Tooltip content={i18n.t('Show only selected features')}> - <IconCheckmarkCircle16 /> + <span className={styles.alignIcon1}> + <IconEmptyFrame16 /> + </span> </Tooltip> </button> <Tooltip content={i18n.t('Highlight color')}> @@ -310,8 +301,10 @@ const BottomPanel = () => { className={styles.closeIcon} onClick={() => dispatch(closeDataTable())} > - <Tooltip content={i18n.t('Close')}> - <IconCross16 /> + <Tooltip content={i18n.t('Close')} placement="top"> + <span className={styles.alignIcon1}> + <IconCross16 /> + </span> </Tooltip> </button> </div> @@ -321,8 +314,8 @@ const BottomPanel = () => { <DataTable availableWidth={panelWidth} onCountChange={onCountChange} - showOnlySelected={showOnlySelected} globalSearch={globalSearch} + onClearFilters={onClearFilters} /> </ErrorBoundary> </div> diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index 3635cb2744..356b2d7b2d 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -9,7 +9,10 @@ import { ComponentCover, CenteredContent, CircularLoader, - Tooltip, + Popover, + Popper, + Portal, + IconSync16, } from '@dhis2/ui' import cx from 'classnames' import PropTypes from 'prop-types' @@ -23,6 +26,7 @@ import React, { } from 'react' import { useSelector, useDispatch } from 'react-redux' import { TableVirtuoso } from 'react-virtuoso' +import { setSelectionFilter } from '../../actions/dataTable.js' import { highlightFeature } from '../../actions/feature.js' import { toggleFeatureSelection, @@ -30,14 +34,164 @@ import { selectFeatureRange, clearSelection, } from '../../actions/selection.js' +import { + SELECTION_FILTER_SELECTED, + SELECTION_FILTER_NOT_SELECTED, +} from '../../constants/selection.js' import { isDarkColor } from '../../util/colors.js' import { formatWithSeparator } from '../../util/numbers.js' import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' +import Checkbox from '../core/Checkbox.jsx' import { SortIcon } from '../core/icons.jsx' import FilterInput from './FilterInput.jsx' import styles from './styles/DataTable.module.css' import TableContextMenu from './TableContextMenu.jsx' -import { useTableData } from './useTableData.js' +import { useTableData, SELECTED_SORT_KEY } from './useTableData.js' + +const SELECTION_FILTER_OPTIONS = [ + { value: SELECTION_FILTER_SELECTED, label: i18n.t('Selected') }, + { value: SELECTION_FILTER_NOT_SELECTED, label: i18n.t('Not selected') }, +] + +// Every filterable column dispatches its dataFilters value straight through +// to filterData against each layer's real feature properties (see +// ThematicLayer.jsx/EventLayer.jsx/etc.). Index is a synthetic row number +// computed only for table display (see useTableData.js), never present on +// the underlying feature data - filtering by it narrows the table but +// can't affect the map. Still shown: it's a useful table-only tool (e.g. +// narrowing to a row-number range) even though it doesn't reach the map. +export const isFilterable = (dataKey, type) => !!type + +// Inverts selection scoped to the currently-filtered/visible rows only, +// mirroring how the "select all" checkbox already treats them (see +// onToggleSelectAll) - ids selected before a filter narrowed the rows stay +// selected (offViewSelected), only the visible portion actually flips. +export const getReversedSelection = (selectedIds, allRowIds) => { + const selectedIdSet = new Set(selectedIds) + const allRowIdSet = new Set(allRowIds) + const offViewSelected = selectedIds.filter((id) => !allRowIdSet.has(id)) + const invertedVisible = allRowIds.filter((id) => !selectedIdSet.has(id)) + return [...offViewSelected, ...invertedVisible] +} + +const SelectionFilterButton = ({ value, onChange }) => { + const anchorRef = useRef(null) + const [isOpen, setIsOpen] = useState(false) + + const toggleValue = (optionValue) => { + const next = value.includes(optionValue) + ? value.filter((v) => v !== optionValue) + : [...value, optionValue] + onChange(next) + } + + const buttonLabel = + value.length === 0 + ? i18n.t('All') + : i18n.t('{{count}} selected', { count: value.length }) + + return ( + <> + <button + type="button" + ref={anchorRef} + className={styles.selectionFilterButton} + data-test="data-table-selection-filter-button" + onClick={() => setIsOpen((o) => !o)} + > + {buttonLabel} + </button> + {isOpen && ( + <Popover + reference={anchorRef} + placement="bottom-start" + arrow={false} + onClickOutside={() => setIsOpen(false)} + > + <div className={styles.selectionFilterPopover}> + {SELECTION_FILTER_OPTIONS.map((option) => ( + <Checkbox + key={option.value} + label={option.label} + checked={value.includes(option.value)} + onChange={() => toggleValue(option.value)} + style={{ margin: '4px 0' }} + /> + ))} + </div> + </Popover> + )} + </> + ) +} + +SelectionFilterButton.propTypes = { + value: PropTypes.arrayOf(PropTypes.string).isRequired, + onChange: PropTypes.func.isRequired, +} + +const topTooltipModifiers = [{ name: 'offset', options: { offset: [0, 4] } }] + +// @dhis2/ui's Tooltip always includes a flip modifier that checks the +// nearest scrolling ancestor's clip box for room. The table header is +// position:sticky, pinned to the top of that scrolling container, so the +// flip modifier always reports "no room above" and flips the tooltip below +// the icon - even though there's plenty of room on screen. This variant +// skips the flip modifier so sort-icon tooltips stay pinned above the icon. +const TopTooltip = ({ content, children }) => { + const [open, setOpen] = useState(false) + const referenceRef = useRef(null) + const openTimerRef = useRef(null) + const closeTimerRef = useRef(null) + + const onOpen = () => { + clearTimeout(closeTimerRef.current) + openTimerRef.current = setTimeout(() => setOpen(true), 200) + } + + const onClose = () => { + clearTimeout(openTimerRef.current) + closeTimerRef.current = setTimeout(() => setOpen(false), 200) + } + + useEffect( + () => () => { + clearTimeout(openTimerRef.current) + clearTimeout(closeTimerRef.current) + }, + [] + ) + + return ( + <span + ref={referenceRef} + onMouseOver={onOpen} + onMouseOut={onClose} + onFocus={onOpen} + onBlur={onClose} + > + {children} + {open && ( + <Portal> + <Popper + placement="top" + reference={referenceRef} + modifiers={topTooltipModifiers} + > + <div className={styles.topTooltipContent}> + {content} + </div> + </Popper> + </Portal> + )} + </span> + ) +} + +TopTooltip.propTypes = { + children: PropTypes.node.isRequired, + content: PropTypes.node.isRequired, +} const ASCENDING = 'asc' const DESCENDING = 'desc' @@ -45,6 +199,20 @@ const DESCENDING = 'desc' export const shouldClearFeatureHighlight = (event) => event.relatedTarget?.tagName !== 'TD' +// Cycles a column through ascending -> descending -> none (natural order) -> +// ascending... Once sortField is null, every column looks "unsorted" again, +// so clicking any of them (including the one that was just cleared) +// naturally restarts the cycle at ascending. +export const getNextSorting = (name, { sortField, sortDirection }) => { + if (name !== sortField) { + return { sortField: name, sortDirection: ASCENDING } + } + if (sortDirection === ASCENDING) { + return { sortField: name, sortDirection: DESCENDING } + } + return { sortField: null, sortDirection: ASCENDING } +} + const getRowId = (row) => row.find((r) => r.dataKey === 'id')?.value || row[0]?.itemId @@ -115,27 +283,52 @@ DataTableRowWithVirtuosoContext.propTypes = { ), } +const EmptyPlaceholder = ({ context }) => ( + <tr> + <td colSpan={99999}> + <div className={styles.noResults}> + {context.totalCount > 0 ? ( + <> + {i18n.t('No features match your filters')} + {context.hasActiveFilters && ( + <button + type="button" + className={styles.clearFiltersLink} + onClick={context.onClearFilters} + > + {i18n.t('Clear filters')} + </button> + )} + </> + ) : ( + i18n.t('No results found') + )} + </div> + </td> + </tr> +) + +EmptyPlaceholder.propTypes = { + context: PropTypes.shape({ + hasActiveFilters: PropTypes.bool, + totalCount: PropTypes.number, + onClearFilters: PropTypes.func, + }), +} + const TableComponents = { Table: DataTableWithVirtuosoContext, TableBody: DataTableBody, TableHead: DataTableHead, TableRow: DataTableRowWithVirtuosoContext, - EmptyPlaceholder: () => ( - <tr> - <td colSpan={99999}> - <div className={styles.noResults}> - {i18n.t('No results found')} - </div> - </td> - </tr> - ), + EmptyPlaceholder, } const Table = ({ availableWidth, onCountChange, - showOnlySelected, globalSearch, + onClearFilters, }) => { const { systemSettings: { keyAnalysisDigitGroupSeparator }, @@ -155,6 +348,7 @@ const Table = ({ (state) => state.ui.showOnlyFeaturesInView ) const mapBounds = useSelector((state) => state.ui.mapBounds) + const selectionFilter = useSelector((state) => state.ui.selectionFilter) const [{ sortField, sortDirection }, setSorting] = useReducer( (sorting, newSorting) => ({ ...sorting, ...newSorting }), { @@ -167,13 +361,7 @@ const Table = ({ const sortData = useCallback( ({ name }) => { - setSorting({ - sortField: name, - sortDirection: - name === sortField && sortDirection === ASCENDING - ? DESCENDING - : ASCENDING, - }) + setSorting(getNextSorting(name, { sortField, sortDirection })) }, [sortField, sortDirection] ) @@ -244,6 +432,7 @@ const Table = ({ headers, rows, isLoading, + loadingReason, error, totalCount, filteredCount, @@ -254,7 +443,7 @@ const Table = ({ sortDirection, showOnlyFeaturesInView, mapBounds, - showOnlySelected, + selectionFilter, selectedIdSet, globalSearch, }) @@ -315,6 +504,11 @@ const Table = ({ [dispatch, layer.id] ) + const hasActiveFilters = + Object.keys(layer.dataFilters ?? {}).length > 0 || + !!globalSearch?.trim() || + selectionFilter?.length > 0 + const tableContext = useMemo( () => ({ onMouseEnter: setFeatureHighlight, @@ -323,6 +517,9 @@ const Table = ({ onRowClick, onRowDoubleClick, layout: columnWidths.length > 0 ? 'fixed' : 'auto', + totalCount, + hasActiveFilters, + onClearFilters, }), [ setFeatureHighlight, @@ -331,6 +528,9 @@ const Table = ({ onRowClick, onRowDoubleClick, columnWidths, + totalCount, + hasActiveFilters, + onClearFilters, ] ) @@ -384,6 +584,16 @@ const Table = ({ } }, [dispatch, isAllSelected, allRowIds, allRowIdSet, selectedIds, layer.id]) + const onReverseSelection = useCallback(() => { + const nextIds = getReversedSelection(selectedIds, allRowIds) + + if (nextIds.length) { + dispatch(selectAllFeatures(nextIds, layer.id)) + } else { + dispatch(clearSelection()) + } + }, [dispatch, selectedIds, allRowIds, layer.id]) + useEffect(() => { // Measure column widths in auto layout, then switch to fixed to prevent content shift during virtual scrolling if (columnWidths.length === 0 && headerRowRef.current) { @@ -445,6 +655,15 @@ const Table = ({ return <p className={styles.noSupport}>{error}</p> } + // Thematic layers carry both a `legend` (name) and `color` (hex) column; + // merge them into one swatch+name cell instead of two separate columns. + const hasLegendColorPair = + headers.some((h) => h.dataKey === 'legend') && + headers.some((h) => h.dataKey === 'color') + const visibleHeaders = hasLegendColorPair + ? headers.filter((h) => h.dataKey !== 'color') + : headers + return ( <> <TableVirtuoso @@ -457,31 +676,80 @@ const Table = ({ }} data={rows} computeItemKey={(index, row) => getRowId(row) ?? index} + increaseViewportBy={{ top: 400, bottom: 400 }} fixedHeaderContent={() => ( <DataTableRow ref={headerRowRef}> <DataTableColumnHeader className={styles.checkboxCell} - width="32px" + width="76px" + onFilterIconClick={Function.prototype} + showFilter={true} + filter={ + <SelectionFilterButton + value={selectionFilter ?? []} + onChange={(next) => + dispatch(setSelectionFilter(next)) + } + /> + } > - <input - type="checkbox" - title={i18n.t('Select all')} - checked={isAllSelected} - onChange={onToggleSelectAll} - /> + <div className={styles.checkboxHeaderContent}> + <input + type="checkbox" + title={i18n.t('Select all')} + checked={isAllSelected} + onChange={onToggleSelectAll} + /> + <TopTooltip + content={i18n.t('Reverse selection')} + > + <button + type="button" + className={styles.reverseButton} + data-test="data-table-reverse-selection" + disabled={allRowIds.length === 0} + onClick={onReverseSelection} + > + <IconSync16 /> + </button> + </TopTooltip> + <TopTooltip + content={i18n.t('Sort by Selected')} + > + <button + type="button" + className={styles.sortButton} + data-test="data-table-column-sort-button-selected" + onClick={() => + sortData({ + name: SELECTED_SORT_KEY, + }) + } + > + <SortIcon + direction={ + sortField === SELECTED_SORT_KEY + ? sortDirection + : null + } + /> + </button> + </TopTooltip> + </div> </DataTableColumnHeader> - {headers.map( + {visibleHeaders.map( ({ name, dataKey, type, optionSet }, index) => ( <DataTableColumnHeader className={styles.columnHeader} key={`${dataKey}-${index}`} onFilterIconClick={ - type && Function.prototype + isFilterable(dataKey, type) && + Function.prototype } - showFilter={!!type && dataKey !== 'index'} + showFilter={isFilterable(dataKey, type)} name={dataKey} filter={ - type && ( + isFilterable(dataKey, type) && ( <FilterInput type={type} dataKey={dataKey} @@ -499,7 +767,7 @@ const Table = ({ > <span className={styles.headerContent}> {name} - <Tooltip + <TopTooltip content={i18n.t( 'Sort by {{column}}', { column: name } @@ -523,7 +791,7 @@ const Table = ({ } /> </button> - </Tooltip> + </TopTooltip> </span> </DataTableColumnHeader> ) @@ -562,30 +830,73 @@ const Table = ({ onClick={(e) => e.stopPropagation()} /> </DataTableCell> - {row.map(({ dataKey, value, align }) => ( - <DataTableCell - key={`dtcell-${dataKey}`} - staticStyle - className={cx(styles.dataCell, { - [styles.lightText]: - dataKey === 'color' && - isDarkColor(value), - [styles.selected]: isSelected, - [styles.hovered]: isHovered, - })} - backgroundColor={ - dataKey === 'color' ? value : null - } - align={align} - > - {dataKey === 'color' - ? value?.toLowerCase() - : formatWithSeparator( - value, - keyAnalysisDigitGroupSeparator - )} - </DataTableCell> - ))} + {row + .filter( + ({ dataKey }) => + !hasLegendColorPair || + dataKey !== 'color' + ) + .map(({ dataKey, value, align }) => { + const isLegendCell = + hasLegendColorPair && + dataKey === 'legend' + const swatchColor = isLegendCell + ? row.find((c) => c.dataKey === 'color') + ?.value + : null + + return ( + <DataTableCell + key={`dtcell-${dataKey}`} + staticStyle + className={cx(styles.dataCell, { + [styles.lightText]: + !hasLegendColorPair && + dataKey === 'color' && + isDarkColor(value), + [styles.monoCell]: + dataKey === 'id', + [styles.selected]: isSelected, + [styles.hovered]: isHovered, + })} + backgroundColor={ + !hasLegendColorPair && + dataKey === 'color' + ? value + : null + } + align={align} + > + {isLegendCell ? ( + <span + className={ + styles.legendCell + } + > + {swatchColor && ( + <span + className={ + styles.legendSwatch + } + style={{ + backgroundColor: + swatchColor, + }} + /> + )} + {value} + </span> + ) : dataKey === 'color' ? ( + value?.toLowerCase() + ) : ( + formatWithSeparator( + value, + keyAnalysisDigitGroupSeparator + ) + )} + </DataTableCell> + ) + })} </> ) }} @@ -593,7 +904,14 @@ const Table = ({ {(isLoading || layer?.isLoaded === false || layer?.isLoading) && ( <ComponentCover> <CenteredContent> - <CircularLoader /> + <div className={styles.loadingContent}> + <CircularLoader /> + {loadingReason && ( + <span className={styles.loadingReason}> + {loadingReason} + </span> + )} + </div> </CenteredContent> </ComponentCover> )} @@ -601,6 +919,7 @@ const Table = ({ contextMenu={tableContextMenu} layer={layer} selectedIds={selectedIds} + filteredIds={hasActiveFilters ? allRowIds : null} onClose={() => setTableContextMenu(null)} /> </> @@ -610,7 +929,7 @@ const Table = ({ Table.propTypes = { availableWidth: PropTypes.number, globalSearch: PropTypes.string, - showOnlySelected: PropTypes.bool, + onClearFilters: PropTypes.func, onCountChange: PropTypes.func, } diff --git a/src/components/datatable/FilterInput.jsx b/src/components/datatable/FilterInput.jsx index f20d30342a..b20408c8ff 100644 --- a/src/components/datatable/FilterInput.jsx +++ b/src/components/datatable/FilterInput.jsx @@ -1,15 +1,54 @@ import i18n from '@dhis2/d2-i18n' -import { Input, Popover, Tooltip, IconInfo16 } from '@dhis2/ui' +import { + Input, + Layer, + Popper, + Portal, + IconFilter16, + IconSync16, +} from '@dhis2/ui' +import cx from 'classnames' import PropTypes from 'prop-types' -import React, { useRef, useState } from 'react' +import React, { useEffect, useRef, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' +import { Virtuoso } from 'react-virtuoso' import { setDataFilter, clearDataFilter } from '../../actions/dataFilters.js' import useOptionSet from '../../hooks/useOptionSet.js' +import { numericFilter, ANY_VALUE_KEY } from '../../util/filter.js' import Checkbox from '../core/Checkbox.jsx' import styles from './styles/FilterInput.module.css' +// Must match useTableData.js's NOT_SET_VALUE - not imported directly from +// there to avoid pulling that module's heavy transitive dependencies +// (map/earth-engine loaders) into this component. +const NOT_SET_VALUE = '' + +// Checkbox rows are a fixed height (dense label + the 4px/4px margin set +// below), so the list can be virtualized with a known row size instead of +// measuring each one - this is what lets a column's full value list (no +// cap - see useTableData.js) stay cheap to render regardless of size. +const OPTION_ROW_HEIGHT = 28 +const MAX_LIST_HEIGHT = 260 + +// Rough upper bound (in px) on the dropdown's own rendered height (the +// checkbox list capped at MAX_LIST_HEIGHT, plus its padding/gap and the +// pinned custom-filter row) - used to decide, once per render, whether +// every column's dropdown should open below or above (see +// SearchableFilterPopover's dropdownSide). All columns share the same +// header row, so this is computed the same way for each of them and they +// always agree - there's no per-column flip, just a single below/above +// choice that applies to the whole row. +const ESTIMATED_POPOVER_HEIGHT = MAX_LIST_HEIGHT + 80 + +// Rough content heights (in px) for the two tooltip variants, used only to +// decide whether there's enough room to show the tooltip at all - see +// FilterHelpTooltip's hasRoom check. +const NUMERIC_HELP_HEIGHT = 140 +const TEXT_HELP_HEIGHT = 40 + const NUMERIC_FILTER_HELP = ( <div> + <div>{i18n.t('Select values, or type a numerical filter:')}</div> <div>{'> 5 — ' + i18n.t('greater than 5')}</div> <div>{'>= 5 — ' + i18n.t('greater than or equal to 5')}</div> <div>{'< 5, <= 5 — ' + i18n.t('less than (or equal to) 5')}</div> @@ -18,73 +57,683 @@ const NUMERIC_FILTER_HELP = ( </div> ) +const TEXT_FILTER_HELP = ( + <div> + {i18n.t('Select values, or type text to match rows that contain it.')} + </div> +) + +// Everything a numeric filter expression can legally contain (see +// isTrueFilter/numericFilter in util/filter.js): digits, decimals, +// negative signs, comparison operators, and the AND/OR separators. +const NUMERIC_INPUT_DISALLOWED = /[^0-9.\-<>=,&\s]/g + +// @dhis2-ui/popper's Popper component always merges in its own base flip +// modifier unless a modifier of the same name is passed in to override it - +// omitting flip from this list does NOT turn it off, it just leaves the +// base one active. Disabling it by name (rather than simply not mentioning +// it) is what actually stops it from silently overriding `placement`. +const helpTooltipModifiers = [ + { name: 'offset', options: { offset: [0, 4] } }, + { name: 'flip', enabled: false }, +] + +// @dhis2/ui's Tooltip always includes a flip modifier that checks the +// nearest scrolling ancestor's clip box for room (see the identical +// TopTooltip in DataTable.jsx) - the column header is position:sticky, +// pinned to the top of that scrolling container, so the flip modifier +// always reports "no room above" and flips to the bottom regardless of +// the requested placement. This variant skips that modifier so it always +// opens on whichever side the caller passes (SearchableFilterPopover always +// passes the opposite of the dropdown's own side) - and, since it can no +// longer flip out of the way, it checks for itself whether there's +// actually room on that side before showing anything at all, rather than +// risk covering other UI or getting clipped. +const FilterHelpTooltip = ({ + content, + placement, + estimatedHeight, + dataTest, + children, +}) => { + const [open, setOpen] = useState(false) + const referenceRef = useRef(null) + const openTimerRef = useRef(null) + const closeTimerRef = useRef(null) + + const onOpen = () => { + clearTimeout(closeTimerRef.current) + openTimerRef.current = setTimeout(() => setOpen(true), 200) + } + + const onClose = () => { + clearTimeout(openTimerRef.current) + closeTimerRef.current = setTimeout(() => setOpen(false), 200) + } + + useEffect( + () => () => { + clearTimeout(openTimerRef.current) + clearTimeout(closeTimerRef.current) + }, + [] + ) + + const referenceRect = referenceRef.current?.getBoundingClientRect() + const spaceAvailable = referenceRect + ? placement === 'top' + ? referenceRect.top + : window.innerHeight - referenceRect.bottom + : Infinity + const hasRoom = spaceAvailable >= estimatedHeight + + return ( + <span + ref={referenceRef} + onMouseOver={onOpen} + onMouseOut={onClose} + onFocus={onOpen} + onBlur={onClose} + data-test={`${dataTest}-reference`} + > + {children} + {open && hasRoom && ( + <Portal> + <Popper + placement={placement} + reference={referenceRef} + modifiers={helpTooltipModifiers} + > + <div + className={styles.filterHelpTooltip} + data-test={`${dataTest}-content`} + > + {content} + </div> + </Popper> + </Portal> + )} + </span> + ) +} + +FilterHelpTooltip.propTypes = { + children: PropTypes.node.isRequired, + content: PropTypes.node.isRequired, + dataTest: PropTypes.string.isRequired, + estimatedHeight: PropTypes.number.isRequired, + placement: PropTypes.oneOf(['top', 'bottom']).isRequired, +} + +// See helpTooltipModifiers above - the flip modifier has to be disabled by +// name, not just left out, or the Popper component's own base flip modifier +// stays active underneath and keeps overriding `placement` per-column. +const dropdownModifiers = [ + { name: 'offset', options: { offset: [0, 0] } }, + { name: 'flip', enabled: false }, +] + +// @dhis2/ui's Popover always includes its own flip modifier with no way to +// opt out, which lets each column decide independently based on its own +// available space - a column near the bottom of the table could open +// upward while every other column opens downward. This reimplements just +// enough of Popover - Layer for the backdrop/click-outside behavior, Popper +// for positioning, flip disabled - so `placement` is always honored exactly; +// the caller (SearchableFilterPopover) computes one placement per render +// from the shared header row's position, so every column's dropdown agrees. +const FilterDropdownPopover = ({ + reference, + placement, + onClickOutside, + className, + children, +}) => ( + <Layer onBackdropClick={onClickOutside}> + <Popper + placement={placement} + reference={reference} + modifiers={dropdownModifiers} + className={className} + > + {children} + </Popper> + </Layer> +) + +FilterDropdownPopover.propTypes = { + children: PropTypes.node.isRequired, + placement: PropTypes.oneOf(['top-start', 'bottom-start']).isRequired, + reference: PropTypes.object.isRequired, + onClickOutside: PropTypes.func.isRequired, + className: PropTypes.string, +} + // Shared popover UI — label resolution is injected so it never needs to // know whether it's an option-set column or a plain categorical one. -const MultiSelectPopover = ({ +// State is derived straight from the applied `filterValue` (never tracked +// in parallel), so picking a value and applying a custom filter stay +// mutually exclusive for free: whichever one is dispatched last is what +// `filterValue` holds, and both branches read from it the same way. +const SearchableFilterPopover = ({ dataKey, + name, layerId, filterValue, options, resolveLabel, + type, + allowCustomFilter = true, }) => { const dispatch = useDispatch() const anchorRef = useRef(null) + const listRef = useRef(null) const [isOpen, setIsOpen] = useState(false) + const [searchText, setSearchText] = useState('') + const [highlightedIndex, setHighlightedIndex] = useState(-1) + const selected = Array.isArray(filterValue) ? filterValue : [] + const appliedString = typeof filterValue === 'string' ? filterValue : '' + + const openPopover = () => { + setSearchText(appliedString) + setHighlightedIndex(-1) + setIsOpen(true) + } + + const closePopover = () => setIsOpen(false) + + // Read directly from the DOM rather than a resize observer: the trigger + // is already mounted (this only matters once isOpen is true, by which + // point it's had at least one paint) and column widths only change on + // table resize, when the popover is closed anyway. + const anchorRect = anchorRef.current?.getBoundingClientRect() + const anchorWidth = anchorRect?.width + + // Every column's filter trigger sits in the same header row, so this + // resolves to the same answer for all of them - below by default, or + // above if the row doesn't have room to open the dropdown downward + // (e.g. the table is short, or the page is scrolled so the row sits + // near the bottom of the viewport). That single choice is what keeps + // every column's dropdown opening on the same side. The help tooltip + // always takes the opposite side, so the two never compete for space. + const dropdownSide = + anchorRect != null && + window.innerHeight - anchorRect.bottom < ESTIMATED_POPOVER_HEIGHT + ? 'top' + : 'bottom' + const dropdownPlacement = `${dropdownSide}-start` + const tooltipPlacement = dropdownSide === 'top' ? 'bottom' : 'top' + + const applyValues = (next) => + next.length + ? dispatch(setDataFilter(layerId, dataKey, next)) + : dispatch(clearDataFilter(layerId, dataKey)) const toggleValue = (value) => { const next = selected.includes(value) ? selected.filter((v) => v !== value) : [...selected, value] - next.length - ? dispatch(setDataFilter(layerId, dataKey, next)) + applyValues(next) + } + + const applyCustomFilter = (text) => + text + ? dispatch(setDataFilter(layerId, dataKey, text)) : dispatch(clearDataFilter(layerId, dataKey)) + + // "No value" is pinned above the list with "Any value" (see the render + // below) rather than mixed in among the column's real distinct values, + // so it's excluded here and never part of the virtualized/searchable + // list. + const hasNotSetOption = options.some(({ value }) => value === NOT_SET_VALUE) + const realOptions = options.filter(({ value }) => value !== NOT_SET_VALUE) + const anyValueActive = selected.includes(ANY_VALUE_KEY) + + // Toggling "Any value" always rebuilds the selection from scratch + // rather than adding/removing just the one key - turning it on + // collapses any individually-picked real values into the wildcard + // (they're now redundant), and turning it off unticks every real + // value along with it (there's nothing meaningful to "fall back" to). + // "No value" is independent either way and carries over untouched. + const onToggleAnyValue = () => { + const keepNotSet = selected.includes(NOT_SET_VALUE) + applyValues( + anyValueActive + ? keepNotSet + ? [NOT_SET_VALUE] + : [] + : keepNotSet + ? [ANY_VALUE_KEY, NOT_SET_VALUE] + : [ANY_VALUE_KEY] + ) } - const buttonLabel = - selected.length === 0 - ? i18n.t('All') - : i18n.t('{{count}} selected', { count: selected.length }) + // Every value "Reverse selection" can flip - the column's full value + // domain, not just whatever the current search happens to narrow the + // list down to (search is for finding/toggling individual values, not + // for scoping a bulk action). + const invertibleValues = hasNotSetOption + ? [NOT_SET_VALUE, ...realOptions.map((o) => o.value)] + : realOptions.map((o) => o.value) + + // A real value's checkbox is ticked either because it's individually + // selected, or because "Any value" is active (which stands for "every + // real value" - "No value" is the one exception, handled on its own + // below). Clicking one while "Any value" is active means "everything + // except this one" - not "add this one on top of Any value" - so it + // has to expand Any value into its concrete equivalent (every real + // value but this one) rather than going through the plain toggle, + // which would otherwise just add the clicked value to an array that + // still has ANY_VALUE_KEY in it and leave every other checkbox ticked + // for the wrong reason. + const onToggleRealValue = (value) => { + if (anyValueActive) { + const next = realOptions + .map((o) => o.value) + .filter((v) => v !== value) + applyValues( + selected.includes(NOT_SET_VALUE) + ? [...next, NOT_SET_VALUE] + : next + ) + return + } + + const next = selected.includes(value) + ? selected.filter((v) => v !== value) + : [...selected, value] + + // Checking every real value one by one ends up in the same place + // as checking "Any value" directly - collapse to that instead of + // leaving a literal array that happens to list them all, so the + // two are always the same underlying state. + const allRealValuesChecked = + realOptions.length > 0 && + realOptions.every((o) => next.includes(o.value)) + applyValues( + allRealValuesChecked + ? next.includes(NOT_SET_VALUE) + ? [ANY_VALUE_KEY, NOT_SET_VALUE] + : [ANY_VALUE_KEY] + : next + ) + } + + // Reverses every checkbox's *effective* ticked state, not just literal + // array membership - while "Any value" is active every real value + // reads as ticked (see onToggleRealValue above), so reversing has to + // untick all of them (and "Any value" along with them, since there's + // no way to represent "every real value unticked" while it's still + // set) rather than leaving them all ticked and only toggling values + // that were never literally in the array to begin with. "No value" is + // unaffected by "Any value" and simply flips on its own. + const onReverseSelection = () => { + const invertedRealValues = anyValueActive + ? [] + : realOptions + .map((o) => o.value) + .filter((v) => !selected.includes(v)) + const invertedNotSet = + hasNotSetOption && !selected.includes(NOT_SET_VALUE) + + // Same rule as onToggleRealValue: ending up with every real value + // ticked (e.g. reversing a selection of just "No value") is the + // same state as "Any value" being active, so it collapses into + // that rather than a literal array that happens to list them all. + const allRealValuesInverted = + !anyValueActive && + realOptions.length > 0 && + invertedRealValues.length === realOptions.length + + if (allRealValuesInverted) { + applyValues( + invertedNotSet + ? [ANY_VALUE_KEY, NOT_SET_VALUE] + : [ANY_VALUE_KEY] + ) + return + } + + applyValues( + invertedNotSet + ? [NOT_SET_VALUE, ...invertedRealValues] + : invertedRealValues + ) + } + + const trimmedSearch = searchText.trim() + const normalizedSearch = trimmedSearch.toLowerCase() + // Numeric columns narrow the list using the same comparison the typed + // text would apply to the table's rows (>, <, ranges, ...), so what's + // checked here always matches what "Use filter" would actually select - + // a plain substring match wouldn't understand "> 100" against "150". + const filteredOptions = !trimmedSearch + ? realOptions + : type === 'number' + ? realOptions.filter(({ value }) => + numericFilter(Number(value), trimmedSearch) + ) + : realOptions.filter(({ value }) => + resolveLabel(value).toLowerCase().includes(normalizedSearch) + ) + const hasExactMatch = filteredOptions.some( + ({ value }) => resolveLabel(value).toLowerCase() === normalizedSearch + ) + const showCustomFilterRow = + allowCustomFilter && normalizedSearch !== '' && !hasExactMatch + const totalCount = filteredOptions.length + (showCustomFilterRow ? 1 : 0) + + const customFilterTag = + type === 'number' ? i18n.t('Use filter') : i18n.t('Contains') + + const hasActiveFilter = selected.length > 0 || appliedString !== '' + + // Applies (or clears) live as the user types - typing a value that + // doesn't match an existing option filters the table immediately, + // exactly like picking a checkbox already does, rather than waiting + // for an explicit commit step. Clearing the text (including via the + // input's own built-in clear button) clears whatever filter is active, + // whether it's picked values or a typed one. + const onSearchChange = ({ value }) => { + // Numeric columns only ever match a numericFilter expression + // (digits, comparison operators, & / ,) - letters could never + // apply to a number column, so strip them as they're typed rather + // than accepting them and silently matching nothing. + const sanitized = + type === 'number' + ? value.replace(NUMERIC_INPUT_DISALLOWED, '') + : value + setSearchText(sanitized) + setHighlightedIndex(-1) + + const trimmed = sanitized.trim() + if (trimmed === '') { + if (hasActiveFilter) { + dispatch(clearDataFilter(layerId, dataKey)) + } + return + } + + if (!allowCustomFilter) { + return + } + + const normalized = trimmed.toLowerCase() + const exactMatch = options.some( + ({ value: optionValue }) => + resolveLabel(optionValue).toLowerCase() === normalized + ) + if (!exactMatch) { + applyCustomFilter(trimmed) + } + } + + // The checkbox list is virtualized, so scrolling the highlighted row + // into view has to be requested explicitly rather than relying on the + // browser's native scrollIntoView over an already-rendered DOM node. + const scrollHighlightedIntoView = (index) => { + const optionIndex = showCustomFilterRow ? index - 1 : index + if (optionIndex >= 0 && optionIndex < filteredOptions.length) { + listRef.current?.scrollToIndex({ + index: optionIndex, + align: 'center', + }) + } + } + + const onSearchKeyDown = (_, event) => { + switch (event.key) { + case 'ArrowDown': + event.preventDefault() + setHighlightedIndex((i) => { + const next = totalCount ? (i + 1) % totalCount : -1 + scrollHighlightedIntoView(next) + return next + }) + break + case 'ArrowUp': + event.preventDefault() + setHighlightedIndex((i) => { + const next = totalCount + ? (i - 1 + totalCount) % totalCount + : -1 + scrollHighlightedIntoView(next) + return next + }) + break + case 'Enter': { + event.preventDefault() + // The custom-filter row (when shown) sits first, matching + // its visual position above the checkbox list. It's + // usually already applied live by this point (see + // onSearchChange) - toggling it again here is a no-op. + if (highlightedIndex === -1) { + if (showCustomFilterRow) { + applyCustomFilter(searchText.trim()) + } + } else if (showCustomFilterRow && highlightedIndex === 0) { + applyCustomFilter(searchText.trim()) + } else { + const optionIndex = showCustomFilterRow + ? highlightedIndex - 1 + : highlightedIndex + if ( + optionIndex >= 0 && + optionIndex < filteredOptions.length + ) { + toggleValue(filteredOptions[optionIndex].value) + } + } + closePopover() + break + } + case 'Escape': + event.preventDefault() + closePopover() + break + default: + break + } + } + + // Closed, this reads like the old trigger button ("3 selected", the + // applied filter text, or empty so the "Search" placeholder shows). + // Open, it's a live, editable search/filter field - the same input + // serves both roles instead of a button revealing a separate one. + const displayValue = isOpen + ? searchText + : selected.length + ? i18n.t('{{count}} selected', { count: selected.length }) + : appliedString + + const mainInput = ( + <Input + dense + clearable + dataTest={`data-table-column-filter-search-${name}`} + placeholder={ + type === 'number' + ? i18n.t('Search or type > 5, < 8…') + : i18n.t('Search') + } + value={displayValue} + onFocus={() => { + if (!isOpen) { + openPopover() + } + }} + onChange={onSearchChange} + onKeyDown={onSearchKeyDown} + /> + ) return ( - <> - <button - type="button" - ref={anchorRef} - className={styles.multiSelectButton} - data-test={`data-table-column-filter-multiselect-${dataKey}`} - onClick={() => setIsOpen((o) => !o)} + <div className={styles.filterTrigger} ref={anchorRef}> + <FilterHelpTooltip + content={ + type === 'number' ? NUMERIC_FILTER_HELP : TEXT_FILTER_HELP + } + placement={tooltipPlacement} + estimatedHeight={ + type === 'number' ? NUMERIC_HELP_HEIGHT : TEXT_HELP_HEIGHT + } + dataTest="data-table-filter-help" > - {buttonLabel} - </button> + {mainInput} + </FilterHelpTooltip> {isOpen && ( - <Popover + <FilterDropdownPopover reference={anchorRef} - placement="bottom-start" - arrow={false} - onClickOutside={() => setIsOpen(false)} + placement={dropdownPlacement} + onClickOutside={closePopover} + className={cx( + styles.dropdownPopper, + dropdownSide === 'top' && styles.dropdownPopperAbove + )} > - <div className={styles.multiSelectPopover}> - {options.map(({ value }) => ( + <div + className={cx(styles.searchableFilterPopover, { + [styles.reversedOrder]: dropdownSide === 'top', + })} + style={{ + minWidth: anchorWidth + ? `${anchorWidth}px` + : undefined, + }} + > + {showCustomFilterRow && ( + <button + type="button" + className={cx(styles.customFilterRow, { + [styles.highlighted]: + highlightedIndex === 0, + })} + data-test={`data-table-column-filter-custom-${name}`} + onClick={() => { + applyCustomFilter(searchText.trim()) + closePopover() + }} + > + <IconFilter16 /> + <span className={styles.customFilterTag}> + {customFilterTag} + </span> + <span className={styles.customFilterExpr}> + {searchText.trim()} + </span> + </button> + )} + <div className={styles.pinnedOptions}> + <button + type="button" + className={styles.reverseSelectionButton} + disabled={invertibleValues.length === 0} + title={i18n.t('Reverse selection')} + aria-label={i18n.t('Reverse selection')} + data-test={`data-table-column-filter-reverse-${name}`} + onClick={onReverseSelection} + > + <IconSync16 /> + </button> <Checkbox - key={value} - label={resolveLabel(value)} - checked={selected.includes(value)} - onChange={() => toggleValue(value)} + label={i18n.t('Any value')} + checked={anyValueActive} + onChange={onToggleAnyValue} + className={styles.specialOption} + style={{ margin: '4px 0' }} + dataTest={`data-table-column-filter-any-${name}`} /> - ))} + {hasNotSetOption && ( + <Checkbox + label={resolveLabel(NOT_SET_VALUE)} + checked={selected.includes(NOT_SET_VALUE)} + onChange={() => toggleValue(NOT_SET_VALUE)} + className={styles.specialOption} + style={{ margin: '4px 0' }} + dataTest={`data-table-column-filter-novalue-${name}`} + /> + )} + </div> + <div className={styles.multiSelectPopover}> + {!showCustomFilterRow && + (options.length === 0 ? ( + <div className={styles.noResults}> + {i18n.t( + 'Too many values to list - type to filter this column' + )} + </div> + ) : ( + filteredOptions.length === 0 && ( + <div className={styles.noResults}> + {i18n.t('No matches')} + </div> + ) + ))} + {filteredOptions.length > 0 && ( + <Virtuoso + ref={listRef} + style={{ + height: Math.min( + filteredOptions.length * + OPTION_ROW_HEIGHT, + MAX_LIST_HEIGHT + ), + }} + // Renders a couple of rows beyond the + // container's own visible height, so + // when the list is capped there's + // always a (clipped) row peeking in at + // the bottom as a "scroll for more" cue, + // rather than blank space below the + // last row Virtuoso would otherwise + // bother mounting. + increaseViewportBy={{ + top: 0, + bottom: OPTION_ROW_HEIGHT * 2, + }} + data={filteredOptions} + fixedItemHeight={OPTION_ROW_HEIGHT} + computeItemKey={(_, option) => option.value} + itemContent={(index, option) => ( + <Checkbox + label={resolveLabel(option.value)} + checked={ + anyValueActive || + selected.includes(option.value) + } + onChange={() => + onToggleRealValue(option.value) + } + className={cx( + dataKey === 'id' && + styles.monoOption, + highlightedIndex === + (showCustomFilterRow + ? index + 1 + : index) && + styles.highlighted + )} + style={{ margin: '4px 0' }} + /> + )} + /> + )} + </div> </div> - </Popover> + </FilterDropdownPopover> )} - </> + </div> ) } -MultiSelectPopover.propTypes = { +SearchableFilterPopover.propTypes = { dataKey: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, options: PropTypes.arrayOf(PropTypes.shape({ value: PropTypes.string })) .isRequired, resolveLabel: PropTypes.func.isRequired, + type: PropTypes.string.isRequired, + allowCustomFilter: PropTypes.bool, filterValue: PropTypes.oneOfType([ PropTypes.string, PropTypes.arrayOf(PropTypes.string), @@ -92,27 +741,51 @@ MultiSelectPopover.propTypes = { layerId: PropTypes.string, } -// Plain categorical columns (legend, type): raw value IS the display label. -const MultiSelectFilter = (props) => ( - <MultiSelectPopover {...props} resolveLabel={(value) => value} /> +// Plain categorical columns (legend, type, and every other column discovered +// generically by useTableData): raw value IS the display label, except for +// the NOT_SET_VALUE sentinel representing blank/missing cells. +const PlainSearchableFilter = (props) => ( + <SearchableFilterPopover + {...props} + resolveLabel={(value) => + value === NOT_SET_VALUE ? i18n.t('No value') : value + } + /> ) // Option-set-backed event columns: translate stored code -> display name. -// useOptionSet/useDataQuery is only ever mounted here, never for legend/type, -// since those columns never have an optionSetId. -const OptionSetMultiSelectFilter = ({ optionSetId, ...props }) => { +// useOptionSet/useDataQuery is only ever mounted here, never for other +// columns, since those never have an optionSetId. The custom-filter row is +// disabled here: filterData matches the raw stored code, not the resolved +// name the user sees, so free text typed against the visible label couldn't +// be applied correctly - and since option sets are a closed, fully +// enumerable set already covered by the checkbox list, there's no real gap +// left for free text to fill. +const OptionSetSearchableFilter = ({ optionSetId, ...props }) => { const { optionSet } = useOptionSet(optionSetId) const resolveLabel = (value) => - optionSet?.options.find((o) => o.code === value)?.name ?? value - return <MultiSelectPopover {...props} resolveLabel={resolveLabel} /> + value === NOT_SET_VALUE + ? i18n.t('No value') + : optionSet?.options.find((o) => o.code === value)?.name ?? value + return ( + <SearchableFilterPopover + {...props} + resolveLabel={resolveLabel} + allowCustomFilter={false} + /> + ) } -OptionSetMultiSelectFilter.propTypes = { +OptionSetSearchableFilter.propTypes = { optionSetId: PropTypes.string.isRequired, } +// Every column (aside from the checkbox/selection column, which has its own +// SelectionFilterButton in DataTable.jsx) gets the same searchable popover, +// even ones with no known distinct values (over the cap, or not yet loaded) +// - they just render with an empty options list, which still lets the +// custom-filter row work exactly as it always has. const FilterInput = ({ type, dataKey, name, options, optionSetId }) => { - const dispatch = useDispatch() const dataTable = useSelector((state) => state.dataTable) const map = useSelector((state) => state.map) @@ -128,56 +801,25 @@ const FilterInput = ({ type, dataKey, name, options, optionSetId }) => { const filterValue = filters?.[dataKey] - if (options?.length) { - return optionSetId ? ( - <OptionSetMultiSelectFilter - dataKey={dataKey} - layerId={layerId} - filterValue={filterValue} - options={options} - optionSetId={optionSetId} - /> - ) : ( - <MultiSelectFilter - dataKey={dataKey} - layerId={layerId} - filterValue={filterValue} - options={options} - /> - ) - } - - const stringFilterValue = typeof filterValue === 'string' ? filterValue : '' - - const onChange = ({ value }) => - value !== '' - ? dispatch(setDataFilter(layerId, dataKey, value)) - : dispatch(clearDataFilter(layerId, dataKey)) - - return ( - <span - className={ - type === 'number' ? styles.numericFilterWrapper : undefined - } - > - <Input - dataTest={`data-table-column-filter-input-${name}`} - dense - placeholder={type === 'number' ? '> 5, < 8' : i18n.t('Search')} - value={stringFilterValue} - onChange={onChange} - /> - {type === 'number' && ( - <Tooltip content={NUMERIC_FILTER_HELP} placement="top"> - <span - className={styles.helpIcon} - data-test="data-table-numeric-filter-help" - > - <IconInfo16 /> - </span> - </Tooltip> - )} - </span> + return optionSetId ? ( + <OptionSetSearchableFilter + dataKey={dataKey} + name={name} + layerId={layerId} + filterValue={filterValue} + options={options ?? []} + optionSetId={optionSetId} + type={type} + /> + ) : ( + <PlainSearchableFilter + dataKey={dataKey} + name={name} + layerId={layerId} + filterValue={filterValue} + options={options ?? []} + type={type} + /> ) } diff --git a/src/components/datatable/TableContextMenu.jsx b/src/components/datatable/TableContextMenu.jsx index 21f582693e..add98bcb67 100644 --- a/src/components/datatable/TableContextMenu.jsx +++ b/src/components/datatable/TableContextMenu.jsx @@ -24,7 +24,13 @@ import { drillUpDown } from '../../util/map.js' import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' import { IconZoomIn16 } from '../core/icons.jsx' -const TableContextMenu = ({ contextMenu, layer, selectedIds, onClose }) => { +const TableContextMenu = ({ + contextMenu, + layer, + selectedIds, + filteredIds, + onClose, +}) => { const anchorRef = useRef() const dispatch = useDispatch() const { @@ -191,6 +197,23 @@ const TableContextMenu = ({ contextMenu, layer, selectedIds, onClose }) => { onClose() }} /> + <MenuItem + dataTest="data-table-context-menu-zoom-to-filtered" + label={i18n.t('Zoom to filtered features')} + icon={<IconZoomIn16 />} + disabled={!filteredIds?.length} + onClick={() => { + dispatch( + highlightFeature({ + ids: filteredIds, + layerId: layer.id, + origin: 'table', + zoom: true, + }) + ) + onClose() + }} + /> </Menu> </Popover> </> @@ -205,6 +228,7 @@ TableContextMenu.propTypes = { x: PropTypes.number, y: PropTypes.number, }), + filteredIds: PropTypes.array, selectedIds: PropTypes.array, } diff --git a/src/components/datatable/__tests__/DataTable.spec.jsx b/src/components/datatable/__tests__/DataTable.spec.jsx index e236e51833..347a57d60a 100644 --- a/src/components/datatable/__tests__/DataTable.spec.jsx +++ b/src/components/datatable/__tests__/DataTable.spec.jsx @@ -1,6 +1,9 @@ import { shouldClearFeatureHighlight, getRowClickAction, + getNextSorting, + isFilterable, + getReversedSelection, } from '../DataTable.jsx' // DataTable.jsx transitively imports MapApi.js (maplibre-gl), which is not @@ -82,3 +85,74 @@ describe('getRowClickAction', () => { ).toEqual({ type: 'range', ids: ['a', 'b', 'c'] }) }) }) + +describe('getNextSorting', () => { + test('clicking an unsorted column starts at ascending', () => { + expect( + getNextSorting('name', { sortField: null, sortDirection: 'asc' }) + ).toEqual({ sortField: 'name', sortDirection: 'asc' }) + }) + + test('clicking the ascending-sorted column moves to descending', () => { + expect( + getNextSorting('name', { sortField: 'name', sortDirection: 'asc' }) + ).toEqual({ sortField: 'name', sortDirection: 'desc' }) + }) + + test('clicking the descending-sorted column clears back to natural order', () => { + expect( + getNextSorting('name', { sortField: 'name', sortDirection: 'desc' }) + ).toEqual({ sortField: null, sortDirection: 'asc' }) + }) + + test('clicking a different column restarts the cycle at ascending', () => { + expect( + getNextSorting('type', { sortField: 'name', sortDirection: 'desc' }) + ).toEqual({ sortField: 'type', sortDirection: 'asc' }) + }) +}) + +describe('isFilterable', () => { + test('allows the Index column - it filters the table by row-number range even though it cannot narrow the map', () => { + expect(isFilterable('index', 'number')).toBe(true) + }) + + test('allows other numeric and string columns, which are real feature properties', () => { + expect(isFilterable('rawValue', 'number')).toBe(true) + expect(isFilterable('name', 'string')).toBe(true) + }) + + test('excludes columns with no type (no known filter UI for them)', () => { + expect(isFilterable('someKey', undefined)).toBe(false) + }) +}) + +describe('getReversedSelection', () => { + test('selects every visible row when nothing is currently selected', () => { + expect(getReversedSelection([], ['a', 'b', 'c'])).toEqual([ + 'a', + 'b', + 'c', + ]) + }) + + test('deselects every visible row when all are currently selected', () => { + expect(getReversedSelection(['a', 'b', 'c'], ['a', 'b', 'c'])).toEqual( + [] + ) + }) + + test('flips only the visible rows, keeping the rest of the selection as-is', () => { + expect(getReversedSelection(['a'], ['a', 'b', 'c'])).toEqual(['b', 'c']) + }) + + test('preserves ids selected outside the current filtered view untouched', () => { + // "z" was selected before a column filter narrowed the visible rows + // down to just a/b/c - reversing must not drop it. + expect(getReversedSelection(['a', 'z'], ['a', 'b', 'c'])).toEqual([ + 'z', + 'b', + 'c', + ]) + }) +}) diff --git a/src/components/datatable/__tests__/FilterInput.spec.jsx b/src/components/datatable/__tests__/FilterInput.spec.jsx index 5651f45657..d373541ca6 100644 --- a/src/components/datatable/__tests__/FilterInput.spec.jsx +++ b/src/components/datatable/__tests__/FilterInput.spec.jsx @@ -1,8 +1,14 @@ import { render, fireEvent, screen } from '@testing-library/react' import React from 'react' import { Provider } from 'react-redux' +import { VirtuosoMockContext } from 'react-virtuoso' import configureMockStore from 'redux-mock-store' +import { + DATA_FILTER_SET, + DATA_FILTER_CLEAR, +} from '../../../constants/actionTypes.js' import useOptionSet from '../../../hooks/useOptionSet.js' +import { ANY_VALUE_KEY } from '../../../util/filter.js' import FilterInput from '../FilterInput.jsx' jest.mock('../../../hooks/useOptionSet.js', () => ({ @@ -19,69 +25,180 @@ const renderFilterInput = (props, dataFilters) => { mapViews: [{ id: 'layer1', dataFilters: dataFilters || {} }], }, }) - return render( + // The checkbox list is virtualized (react-virtuoso) - jsdom doesn't do + // real layout, so without this fixed-size mock context Virtuoso thinks + // the viewport is 0px tall and renders no rows at all. + const result = render( <Provider store={store}> - <FilterInput dataKey="name" name="Name" type="string" {...props} /> + <VirtuosoMockContext.Provider + value={{ viewportHeight: 300, itemHeight: 28 }} + > + <FilterInput + dataKey="name" + name="Name" + type="string" + {...props} + /> + </VirtuosoMockContext.Provider> </Provider> ) + return { ...result, store } } -describe('FilterInput text/numeric path', () => { - test('renders a free-text input when no options are provided', () => { +// The trigger and the dropdown's search field are the same <Input> (see +// FilterInput.jsx) - test-ids are keyed by the column's display `name`, +// not its `dataKey`, since dataKey can be an opaque uid for event custom +// fields but name is always the human-readable label cypress/users see. +const getInput = (name) => + screen + .getByTestId(`data-table-column-filter-search-${name}`) + .querySelector('input') + +const openPopover = (name) => fireEvent.focus(getInput(name)) + +describe('FilterInput with no known options (over the cap, or not yet loaded)', () => { + test('shows an empty input with a "Search" placeholder by default', () => { + renderFilterInput({}) + const input = getInput('Name') + expect(input).toHaveValue('') + expect(input).toHaveAttribute('placeholder', 'Search') + }) + + test('explains there is no picklist instead of showing an empty popover', () => { renderFilterInput({}) + openPopover('Name') expect( - screen - .getByTestId('data-table-column-filter-input-Name') - .querySelector('input') + screen.getByText( + 'Too many values to list - type to filter this column' + ) ).toBeInTheDocument() }) - test('shows the current filter value', () => { - renderFilterInput({}, { name: 'hospital' }) + test('hides that hint once the user starts typing', () => { + renderFilterInput({}) + openPopover('Name') + fireEvent.change(getInput('Name'), { + target: { value: 'hospital' }, + }) expect( - screen - .getByTestId('data-table-column-filter-input-Name') - .querySelector('input') - ).toHaveValue('hospital') + screen.queryByText( + 'Too many values to list - type to filter this column' + ) + ).not.toBeInTheDocument() }) - test('shows a numeric filter syntax help icon for number columns', () => { + test('shows the current filter value on the (closed) trigger', () => { + renderFilterInput({}, { name: 'hospital' }) + expect(getInput('Name')).toHaveValue('hospital') + }) + + test('applies a custom filter live, as soon as it no longer matches an option', () => { + const { store } = renderFilterInput({}) + openPopover('Name') + fireEvent.change(getInput('Name'), { + target: { value: 'hospital' }, + }) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'name', + filter: 'hospital', + }) + }) + + test('wraps the search input in a numeric syntax help tooltip for number columns', () => { renderFilterInput({ type: 'number' }) expect( - screen.getByTestId('data-table-numeric-filter-help') + screen.getByTestId('data-table-filter-help-reference') ).toBeInTheDocument() }) - test('does not show the help icon for string columns', () => { + test('also wraps the search input in a help tooltip for string columns', () => { renderFilterInput({ type: 'string' }) expect( - screen.queryByTestId('data-table-numeric-filter-help') - ).not.toBeInTheDocument() + screen.getByTestId('data-table-filter-help-reference') + ).toBeInTheDocument() }) }) describe('FilterInput multi-select path (no optionSetId)', () => { const options = [{ value: 'High' }, { value: 'Low' }] - test('shows "All" when nothing is selected', () => { + test('shows an empty input when nothing is selected', () => { renderFilterInput({ dataKey: 'legend', name: 'Legend', options }) - expect(screen.getByText('All')).toBeInTheDocument() + expect(getInput('Legend')).toHaveValue('') }) - test('shows the selected count when a filter is active', () => { + test('shows the selected count on the closed trigger', () => { renderFilterInput( { dataKey: 'legend', name: 'Legend', options }, { legend: ['High'] } ) - expect(screen.getByText('1 selected')).toBeInTheDocument() + expect(getInput('Legend')).toHaveValue('1 selected') }) test('opens a popover with a checkbox per option, using the raw value as the label', () => { renderFilterInput({ dataKey: 'legend', name: 'Legend', options }) - fireEvent.click(screen.getByText('All')) + openPopover('Legend') expect(screen.getByLabelText('High')).toBeInTheDocument() expect(screen.getByLabelText('Low')).toBeInTheDocument() }) + + test('shows a "No value" label for the blank-value sentinel option', () => { + renderFilterInput({ + dataKey: 'parentName', + name: 'Parent', + options: [{ value: '' }, { value: 'Country' }], + }) + openPopover('Parent') + expect(screen.getByLabelText('No value')).toBeInTheDocument() + expect(screen.getByLabelText('Country')).toBeInTheDocument() + }) + + test('"No value" is grouped with "Any value" above the divider, not mixed into the searchable list', () => { + renderFilterInput({ + dataKey: 'parentName', + name: 'Parent', + options: [{ value: '' }, { value: 'Country' }], + }) + openPopover('Parent') + expect( + screen.getByLabelText('No value').closest('.pinnedOptions') + ).toBeInTheDocument() + expect( + screen.getByLabelText('Any value').closest('.pinnedOptions') + ).toBeInTheDocument() + }) + + test('"No value" stays visible while searching, unlike the narrowed checkbox list', () => { + renderFilterInput({ + dataKey: 'parentName', + name: 'Parent', + options: [{ value: '' }, { value: 'Country' }], + }) + openPopover('Parent') + fireEvent.change(getInput('Parent'), { target: { value: 'zzz' } }) + expect(screen.getByLabelText('No value')).toBeInTheDocument() + expect(screen.queryByLabelText('Country')).not.toBeInTheDocument() + }) + + test('omits "No value" entirely when the column has no blank cells', () => { + renderFilterInput({ dataKey: 'legend', name: 'Legend', options }) + openPopover('Legend') + expect(screen.queryByLabelText('No value')).not.toBeInTheDocument() + }) + + test('renders Id column options in monospace', () => { + renderFilterInput({ + dataKey: 'id', + name: 'Id', + options: [{ value: 'abc123' }], + }) + openPopover('Id') + expect( + screen.getByLabelText('abc123').closest('.monoOption') + ).toBeInTheDocument() + }) }) describe('FilterInput multi-select path (optionSetId)', () => { @@ -105,7 +222,7 @@ describe('FilterInput multi-select path (optionSetId)', () => { options, optionSetId: 'optionSet1', }) - fireEvent.click(screen.getByText('All')) + openPopover('Case classification') expect(screen.getByLabelText('Confirmed case')).toBeInTheDocument() expect(screen.getByLabelText('Probable case')).toBeInTheDocument() }) @@ -118,7 +235,640 @@ describe('FilterInput multi-select path (optionSetId)', () => { options, optionSetId: 'optionSet1', }) - fireEvent.click(screen.getByText('All')) + openPopover('Case classification') expect(screen.getByLabelText('CONFIRMED')).toBeInTheDocument() }) + + test('search narrows the list by the resolved label, not the raw code', () => { + renderFilterInput({ + dataKey: 'caseType', + name: 'Case classification', + options, + optionSetId: 'optionSet1', + }) + openPopover('Case classification') + fireEvent.change(getInput('Case classification'), { + target: { value: 'confirmed' }, + }) + expect(screen.getByLabelText('Confirmed case')).toBeInTheDocument() + expect(screen.queryByLabelText('Probable case')).not.toBeInTheDocument() + }) + + test('never shows a custom-filter row, even for non-matching text', () => { + renderFilterInput({ + dataKey: 'caseType', + name: 'Case classification', + options, + optionSetId: 'optionSet1', + }) + openPopover('Case classification') + fireEvent.change(getInput('Case classification'), { + target: { value: 'no such option anywhere' }, + }) + expect( + screen.queryByTestId( + 'data-table-column-filter-custom-Case classification' + ) + ).not.toBeInTheDocument() + }) + + test('typing never dispatches a filter (custom filter disabled)', () => { + const { store } = renderFilterInput({ + dataKey: 'caseType', + name: 'Case classification', + options, + optionSetId: 'optionSet1', + }) + openPopover('Case classification') + fireEvent.change(getInput('Case classification'), { + target: { value: 'no such option anywhere' }, + }) + expect(store.getActions()).toEqual([]) + }) +}) + +describe('FilterInput searchable popover — search', () => { + const options = [{ value: 'High' }, { value: 'Low' }] + + test('narrows the checkbox list to matching labels', () => { + renderFilterInput({ dataKey: 'legend', name: 'Legend', options }) + openPopover('Legend') + fireEvent.change(getInput('Legend'), { + target: { value: 'hi' }, + }) + expect(screen.getByLabelText('High')).toBeInTheDocument() + expect(screen.queryByLabelText('Low')).not.toBeInTheDocument() + }) + + test("narrows a numeric column's checkbox list using the typed filter expression, not substring match", () => { + renderFilterInput({ + dataKey: 'value', + name: 'Value', + type: 'number', + options: [{ value: '10' }, { value: '150' }, { value: '200' }], + }) + openPopover('Value') + fireEvent.change(getInput('Value'), { + target: { value: '< 100' }, + }) + expect(screen.getByLabelText('10')).toBeInTheDocument() + expect(screen.queryByLabelText('150')).not.toBeInTheDocument() + expect(screen.queryByLabelText('200')).not.toBeInTheDocument() + }) + + test('shows a "no matches" message when nothing matches and no custom filter applies', () => { + useOptionSet.mockReturnValue({ + optionSet: { options: [{ code: 'CONFIRMED', name: 'Confirmed' }] }, + }) + renderFilterInput({ + dataKey: 'caseType', + name: 'Case classification', + options: [{ value: 'CONFIRMED' }], + optionSetId: 'optionSet1', + }) + openPopover('Case classification') + fireEvent.change(getInput('Case classification'), { + target: { value: 'zzz' }, + }) + expect(screen.getByText('No matches')).toBeInTheDocument() + }) +}) + +describe('FilterInput searchable popover — custom filter row', () => { + test('offers "Use filter" wording and dispatches the typed text live for number columns', () => { + const { store } = renderFilterInput({ + dataKey: 'value', + name: 'Value', + type: 'number', + options: [{ value: '10' }, { value: '20' }], + }) + openPopover('Value') + fireEvent.change(getInput('Value'), { + target: { value: '> 15' }, + }) + const row = screen.getByTestId('data-table-column-filter-custom-Value') + expect(row).toHaveTextContent('Use filter') + expect(row).toHaveTextContent('> 15') + + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'value', + filter: '> 15', + }) + }) + + test('strips letters typed into a numeric column, keeping the filter syntax characters', () => { + renderFilterInput({ + dataKey: 'value', + name: 'Value', + type: 'number', + options: [{ value: '10' }, { value: '20' }], + }) + openPopover('Value') + fireEvent.change(getInput('Value'), { + target: { value: 'abc> 1x5xyz' }, + }) + expect(getInput('Value')).toHaveValue('> 15') + }) + + test('offers "Contains" wording for string columns', () => { + const options = [{ value: 'High' }, { value: 'Low' }] + renderFilterInput({ dataKey: 'legend', name: 'Legend', options }) + openPopover('Legend') + fireEvent.change(getInput('Legend'), { + target: { value: 'medium' }, + }) + const row = screen.getByTestId('data-table-column-filter-custom-Legend') + expect(row).toHaveTextContent('Contains') + expect(row).toHaveTextContent('medium') + }) + + test('is hidden when the typed text exactly matches an existing option', () => { + const options = [{ value: 'High' }, { value: 'Low' }] + renderFilterInput({ dataKey: 'legend', name: 'Legend', options }) + openPopover('Legend') + fireEvent.change(getInput('Legend'), { + target: { value: 'High' }, + }) + expect( + screen.queryByTestId('data-table-column-filter-custom-Legend') + ).not.toBeInTheDocument() + }) + + test('clearing the typed text clears an already-applied custom filter live', () => { + const options = [{ value: 'High' }, { value: 'Low' }] + const { store } = renderFilterInput( + { dataKey: 'legend', name: 'Legend', options }, + { legend: 'medium' } + ) + openPopover('Legend') + fireEvent.change(getInput('Legend'), { target: { value: '' } }) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_CLEAR, + layerId: 'layer1', + fieldId: 'legend', + }) + }) +}) + +describe('FilterInput searchable popover — keyboard behavior', () => { + const options = [{ value: 'High' }, { value: 'Low' }] + + test('Enter applies the highlighted checkbox and closes the popover', () => { + const { store } = renderFilterInput({ + dataKey: 'legend', + name: 'Legend', + options, + }) + openPopover('Legend') + const input = getInput('Legend') + fireEvent.keyDown(input, { key: 'ArrowDown' }) + fireEvent.keyDown(input, { key: 'Enter' }) + + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'legend', + filter: ['High'], + }) + expect(screen.queryByLabelText('High')).not.toBeInTheDocument() + }) + + test('Enter applies the custom filter directly with no prior arrow-navigation, and closes', () => { + const { store } = renderFilterInput({ + dataKey: 'legend', + name: 'Legend', + options, + }) + openPopover('Legend') + const input = getInput('Legend') + fireEvent.change(input, { target: { value: 'medium' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'legend', + filter: 'medium', + }) + expect( + screen.queryByTestId('data-table-column-filter-custom-Legend') + ).not.toBeInTheDocument() + }) + + test('Escape closes the popover without dispatching anything further', () => { + const { store } = renderFilterInput({ + dataKey: 'legend', + name: 'Legend', + options, + }) + openPopover('Legend') + const input = getInput('Legend') + fireEvent.keyDown(input, { key: 'Escape' }) + + expect(store.getActions()).toEqual([]) + expect( + screen.queryByTestId('data-table-column-filter-custom-Legend') + ).not.toBeInTheDocument() + }) +}) + +describe('FilterInput searchable popover — clear filter', () => { + const options = [{ value: 'High' }, { value: 'Low' }] + + // The clear-x is @dhis2/ui's own Input `clearable` button now (not a + // custom-positioned element) - it fires the same onChange({value:''}) + // as manually clearing the text, which is exactly what's exercised here. + + test('clearing an active array (checkbox) filter closes it out', () => { + const { store } = renderFilterInput( + { dataKey: 'legend', name: 'Legend', options }, + { legend: ['High'] } + ) + // Cleared from the closed state, where the input shows "1 selected" - + // opening first would reset the search text to '' (array filters + // have no string to prefill), leaving nothing to actually clear. + fireEvent.change(getInput('Legend'), { target: { value: '' } }) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_CLEAR, + layerId: 'layer1', + fieldId: 'legend', + }) + }) + + test('clearing an active custom-string filter closes it out', () => { + const { store } = renderFilterInput( + { dataKey: 'legend', name: 'Legend', options }, + { legend: 'medium' } + ) + openPopover('Legend') + fireEvent.change(getInput('Legend'), { target: { value: '' } }) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_CLEAR, + layerId: 'layer1', + fieldId: 'legend', + }) + }) + + test('clearing empty text with no active filter dispatches nothing', () => { + const { store } = renderFilterInput({ + dataKey: 'legend', + name: 'Legend', + options, + }) + openPopover('Legend') + fireEvent.change(getInput('Legend'), { target: { value: '' } }) + expect(store.getActions()).toEqual([]) + }) + + test('checking the pinned "Any value" option collapses any existing selection into just ANY_VALUE_KEY', () => { + const { store } = renderFilterInput( + { dataKey: 'legend', name: 'Legend', options }, + { legend: ['High'] } + ) + openPopover('Legend') + fireEvent.click(screen.getByLabelText('Any value')) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'legend', + filter: [ANY_VALUE_KEY], + }) + }) + + test('"Any value" is only checked when it is itself part of the selection - it does not track whether any filter is active', () => { + renderFilterInput( + { dataKey: 'legend', name: 'Legend', options }, + { legend: ['High'] } + ) + openPopover('Legend') + expect(screen.getByLabelText('Any value')).not.toBeChecked() + }) + + test('"Any value" stays checked when it is explicitly selected', () => { + renderFilterInput( + { dataKey: 'legend', name: 'Legend', options }, + { legend: [ANY_VALUE_KEY] } + ) + openPopover('Legend') + expect(screen.getByLabelText('Any value')).toBeChecked() + }) + + test('"Any value" stays visible while searching, unlike the narrowed checkbox list', () => { + renderFilterInput({ dataKey: 'legend', name: 'Legend', options }) + openPopover('Legend') + fireEvent.change(getInput('Legend'), { target: { value: 'hi' } }) + expect(screen.getByLabelText('Any value')).toBeInTheDocument() + }) +}) + +describe('FilterInput searchable popover — reopening state', () => { + test('pre-fills the search box with an already-applied custom filter', () => { + const options = [{ value: '10' }, { value: '20' }] + renderFilterInput( + { dataKey: 'value', name: 'Value', type: 'number', options }, + { value: '> 5' } + ) + openPopover('Value') + expect(getInput('Value')).toHaveValue('> 5') + }) +}) + +describe('FilterInput searchable popover — dropdown placement', () => { + test('opens below the trigger by default', () => { + const options = [{ value: 'High' }, { value: 'Low' }] + renderFilterInput({ dataKey: 'legend', name: 'Legend', options }) + openPopover('Legend') + expect( + screen.getByLabelText('High').closest('.dropdownPopperAbove') + ).not.toBeInTheDocument() + }) + + test('flips above when the shared header row has no room to open below', () => { + const options = [{ value: 'High' }, { value: 'Low' }] + renderFilterInput({ dataKey: 'legend', name: 'Legend', options }) + const trigger = getInput('Legend').closest('.filterTrigger') + jest.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({ + bottom: window.innerHeight - 50, + top: window.innerHeight - 78, + width: 100, + }) + openPopover('Legend') + expect( + screen.getByLabelText('High').closest('.dropdownPopperAbove') + ).toBeInTheDocument() + }) +}) + +describe('FilterInput searchable popover — reverse selection', () => { + const getReverseButton = (name) => + screen.getByTestId(`data-table-column-filter-reverse-${name}`) + + test('selects "Any value" when nothing is currently selected - every real value ends up ticked, which collapses to the wildcard', () => { + const options = [ + { value: 'High' }, + { value: 'Medium' }, + { value: 'Low' }, + ] + const { store } = renderFilterInput({ + dataKey: 'legend', + name: 'Legend', + options, + }) + openPopover('Legend') + fireEvent.click(getReverseButton('Legend')) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'legend', + filter: [ANY_VALUE_KEY], + }) + }) + + test("flips each value's checked state relative to the current selection", () => { + const options = [ + { value: 'High' }, + { value: 'Medium' }, + { value: 'Low' }, + ] + const { store } = renderFilterInput( + { dataKey: 'legend', name: 'Legend', options }, + { legend: ['High'] } + ) + openPopover('Legend') + fireEvent.click(getReverseButton('Legend')) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'legend', + filter: ['Medium', 'Low'], + }) + }) + + test('includes "No value" in the values it flips', () => { + const options = [ + { value: '' }, + { value: 'Country' }, + { value: 'District' }, + ] + const { store } = renderFilterInput( + { dataKey: 'parentName', name: 'Parent', options }, + { parentName: ['Country'] } + ) + openPopover('Parent') + fireEvent.click(getReverseButton('Parent')) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'parentName', + filter: ['', 'District'], + }) + }) + + test('collapses to "Any value" (plus "No value" if that was unset) when reversing ends up ticking every real value', () => { + const options = [{ value: '' }, { value: 'Country' }] + const { store } = renderFilterInput({ + dataKey: 'parentName', + name: 'Parent', + options, + }) + openPopover('Parent') + fireEvent.click(getReverseButton('Parent')) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'parentName', + filter: [ANY_VALUE_KEY, ''], + }) + }) + + test('turns off "Any value" (and every real value with it) when it was active - there is no way to represent "all unticked" while it stays on', () => { + const options = [{ value: 'High' }, { value: 'Low' }] + const { store } = renderFilterInput( + { dataKey: 'legend', name: 'Legend', options }, + { legend: ['High', ANY_VALUE_KEY] } + ) + openPopover('Legend') + fireEvent.click(getReverseButton('Legend')) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_CLEAR, + layerId: 'layer1', + fieldId: 'legend', + }) + }) + + test('reversing while "Any value" is active flips "No value" independently, since it is unaffected by "Any value"', () => { + const options = [{ value: '' }, { value: 'Country' }] + const { store } = renderFilterInput( + { dataKey: 'parentName', name: 'Parent', options }, + { parentName: [ANY_VALUE_KEY] } + ) + openPopover('Parent') + fireEvent.click(getReverseButton('Parent')) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'parentName', + filter: [''], + }) + }) + + test('ignores the current search text - inverts the full value list, not just what is visible', () => { + const options = [ + { value: 'High' }, + { value: 'Medium' }, + { value: 'Low' }, + ] + const { store } = renderFilterInput( + { dataKey: 'legend', name: 'Legend', options }, + { legend: ['High'] } + ) + openPopover('Legend') + // Narrows the visible checkbox list down to just "Low" - if reverse + // scoped itself to that, the result would only ever mention "Low". + fireEvent.change(getInput('Legend'), { target: { value: 'lo' } }) + fireEvent.click(getReverseButton('Legend')) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'legend', + filter: ['Medium', 'Low'], + }) + }) + + test('is disabled when the column has no known values to invert', () => { + renderFilterInput({ dataKey: 'name', name: 'Name' }) + openPopover('Name') + expect(getReverseButton('Name')).toBeDisabled() + }) +}) + +describe('FilterInput searchable popover — "Any value" / real value interaction', () => { + const options = [{ value: 'High' }, { value: 'Medium' }, { value: 'Low' }] + + test('every real value reads as checked while "Any value" is active', () => { + renderFilterInput( + { dataKey: 'legend', name: 'Legend', options }, + { legend: [ANY_VALUE_KEY] } + ) + openPopover('Legend') + expect(screen.getByLabelText('High')).toBeChecked() + expect(screen.getByLabelText('Medium')).toBeChecked() + expect(screen.getByLabelText('Low')).toBeChecked() + }) + + test('"No value" does not read as checked just because "Any value" is active', () => { + renderFilterInput( + { + dataKey: 'parentName', + name: 'Parent', + options: [{ value: '' }, { value: 'Country' }], + }, + { parentName: [ANY_VALUE_KEY] } + ) + openPopover('Parent') + expect(screen.getByLabelText('No value')).not.toBeChecked() + }) + + test('unticking one real value while "Any value" is active unticks "Any value" too, but keeps every other value ticked', () => { + const { store } = renderFilterInput( + { dataKey: 'legend', name: 'Legend', options }, + { legend: [ANY_VALUE_KEY] } + ) + openPopover('Legend') + fireEvent.click(screen.getByLabelText('Medium')) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'legend', + filter: ['High', 'Low'], + }) + }) + + test('unticking a real value while "Any value" is active preserves "No value" if it was set', () => { + const { store } = renderFilterInput( + { + dataKey: 'parentName', + name: 'Parent', + options: [{ value: '' }, { value: 'A' }, { value: 'B' }], + }, + { parentName: [ANY_VALUE_KEY, ''] } + ) + openPopover('Parent') + fireEvent.click(screen.getByLabelText('A')) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'parentName', + filter: ['B', ''], + }) + }) + + test('manually ticking every real value collapses the selection into "Any value"', () => { + const { store } = renderFilterInput( + { dataKey: 'legend', name: 'Legend', options }, + { legend: ['High', 'Medium'] } + ) + openPopover('Legend') + fireEvent.click(screen.getByLabelText('Low')) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'legend', + filter: [ANY_VALUE_KEY], + }) + }) + + test('manually ticking every real value preserves "No value" while collapsing to "Any value"', () => { + const { store } = renderFilterInput( + { + dataKey: 'parentName', + name: 'Parent', + options: [{ value: '' }, { value: 'A' }, { value: 'B' }], + }, + { parentName: ['A', ''] } + ) + openPopover('Parent') + fireEvent.click(screen.getByLabelText('B')) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'parentName', + filter: [ANY_VALUE_KEY, ''], + }) + }) + + test('unchecking "Any value" unticks every real value too, not just the wildcard', () => { + const { store } = renderFilterInput( + { dataKey: 'legend', name: 'Legend', options }, + { legend: [ANY_VALUE_KEY] } + ) + openPopover('Legend') + fireEvent.click(screen.getByLabelText('Any value')) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_CLEAR, + layerId: 'layer1', + fieldId: 'legend', + }) + }) + + test('unchecking "Any value" preserves "No value" independently', () => { + const { store } = renderFilterInput( + { + dataKey: 'parentName', + name: 'Parent', + options: [{ value: '' }, { value: 'Country' }], + }, + { parentName: [ANY_VALUE_KEY, ''] } + ) + openPopover('Parent') + fireEvent.click(screen.getByLabelText('Any value')) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'parentName', + filter: [''], + }) + }) }) diff --git a/src/components/datatable/__tests__/TableContextMenu.spec.jsx b/src/components/datatable/__tests__/TableContextMenu.spec.jsx new file mode 100644 index 0000000000..112b6f7a39 --- /dev/null +++ b/src/components/datatable/__tests__/TableContextMenu.spec.jsx @@ -0,0 +1,71 @@ +import { render, fireEvent, screen } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import configureMockStore from 'redux-mock-store' +import { FEATURE_HIGHLIGHT } from '../../../constants/actionTypes.js' +import { FACILITY_LAYER } from '../../../constants/layers.js' +import TableContextMenu from '../TableContextMenu.jsx' + +jest.mock('../../cachedDataProvider/CachedDataProvider.jsx', () => ({ + useCachedData: () => ({ + systemSettings: { keyAnalysisDigitGroupSeparator: ',' }, + }), +})) + +const mockStore = configureMockStore() + +const layer = { id: 'layer1', layer: FACILITY_LAYER, name: 'Test layer' } +const contextMenu = { x: 10, y: 10, featureProps: {} } + +// @dhis2/ui's MenuItem puts `data-test` on the outer <li>, but `aria-disabled` +// and the click handler both live on the inner <a role="menuitem">. +const getZoomToFilteredLink = () => + screen + .getByTestId('data-table-context-menu-zoom-to-filtered') + .querySelector('a') + +const renderMenu = (props) => { + const store = mockStore({}) + const result = render( + <Provider store={store}> + <TableContextMenu + contextMenu={contextMenu} + layer={layer} + onClose={jest.fn()} + {...props} + /> + </Provider> + ) + return { ...result, store } +} + +describe('TableContextMenu — zoom to filtered features', () => { + test('is disabled when no filter is active (filteredIds is null)', () => { + renderMenu({ filteredIds: null }) + expect(getZoomToFilteredLink()).toHaveAttribute('aria-disabled', 'true') + }) + + test('is disabled when the filtered id list is empty', () => { + renderMenu({ filteredIds: [] }) + expect(getZoomToFilteredLink()).toHaveAttribute('aria-disabled', 'true') + }) + + test('dispatches highlightFeature with the filtered ids and closes the menu when clicked', () => { + const onClose = jest.fn() + const { store } = renderMenu({ + filteredIds: ['a', 'b', 'c'], + onClose, + }) + fireEvent.click(getZoomToFilteredLink()) + expect(store.getActions()).toContainEqual({ + type: FEATURE_HIGHLIGHT, + payload: { + ids: ['a', 'b', 'c'], + layerId: 'layer1', + origin: 'table', + zoom: true, + }, + }) + expect(onClose).toHaveBeenCalled() + }) +}) diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index 55edee96ab..881e591cf4 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -2,7 +2,11 @@ import { renderHook } from '@testing-library/react' import React from 'react' import { Provider } from 'react-redux' import configureMockStore from 'redux-mock-store' -import { useTableData } from '../useTableData.js' +import { + useTableData, + SELECTED_SORT_KEY, + NOT_SET_VALUE, +} from '../useTableData.js' jest.mock('../../map/MapApi.js', () => ({ loadEarthEngineWorker: jest.fn(), @@ -837,6 +841,93 @@ describe('useTableData sorting', () => { const valueColumn = result.current.rows.map((row) => row[3]?.value) // Value column expect(valueColumn).toEqual([null, null, null]) }) + + test('falls back to natural (index) order when sortField is null', () => { + // Deliberately not alphabetical/numerical, so this only passes if the + // natural input order is preserved rather than some other sort. + const layerInInputOrder = { + id: 'test-layer', + layer: 'thematic', + dataFilters: null, + data: [ + { properties: { id: '1', name: 'Item C', rawValue: 3 } }, + { properties: { id: '2', name: 'Item A', rawValue: 1 } }, + { properties: { id: '3', name: 'Item B', rawValue: 2 } }, + ], + } + const store = { aggregations: {} } + const { result } = renderHook( + () => + useTableData({ + layer: layerInInputOrder, + sortField: null, + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + <Provider store={mockStore(store)}>{children}</Provider> + ), + } + ) + + const names = result.current.rows.map( + (row) => row.find((c) => c.dataKey === 'name')?.value + ) + expect(names).toEqual(['Item C', 'Item A', 'Item B']) + }) + + describe('sorting by selection state', () => { + // `id` must live inside `properties` - useTableData flattens rows via + // `{...d.properties}`, so a top-level `id` (as used by the rest of + // this describe block's `mockLayer`) would not survive flattening. + const layerWithIds = { + id: 'test-layer', + layer: 'thematic', + dataFilters: null, + data: [ + { properties: { id: '1', name: 'Item A' } }, + { properties: { id: '2', name: 'Item B' } }, + { properties: { id: '3', name: 'Item C' } }, + { properties: { id: '4', name: 'Item D' } }, + { properties: { id: '5', name: 'Item E' } }, + ], + } + const store = { aggregations: {} } + + const renderSorted = (sortDirection) => + renderHook( + () => + useTableData({ + layer: layerWithIds, + sortField: SELECTED_SORT_KEY, + sortDirection, + selectedIdSet: new Set(['2', '4']), + }), + { + wrapper: ({ children }) => ( + <Provider store={mockStore(store)}>{children}</Provider> + ), + } + ).result + + test('ascending (the default on first click) puts selected rows first', () => { + const { current } = renderSorted('asc') + const ids = current.rows.map( + (row) => row.find((c) => c.dataKey === 'id')?.value + ) + expect(ids.slice(0, 2).sort()).toEqual(['2', '4']) + expect(ids.slice(2).sort()).toEqual(['1', '3', '5']) + }) + + test('descending puts selected rows last', () => { + const { current } = renderSorted('desc') + const ids = current.rows.map( + (row) => row.find((c) => c.dataKey === 'id')?.value + ) + expect(ids.slice(0, 3).sort()).toEqual(['1', '3', '5']) + expect(ids.slice(3).sort()).toEqual(['2', '4']) + }) + }) }) describe('useTableData showOnlyFeaturesInView', () => { @@ -920,7 +1011,7 @@ describe('useTableData showOnlyFeaturesInView', () => { }) }) -describe('useTableData showOnlySelected', () => { +describe('useTableData selectionFilter', () => { const store = { aggregations: {} } const layer = { @@ -940,23 +1031,23 @@ describe('useTableData showOnlySelected', () => { ), }).result - test('includes all rows when the toggle is off', () => { + test('includes all rows when no filter is applied', () => { const { current } = renderTableData({ layer, sortField: 'name', sortDirection: 'asc', - showOnlySelected: false, + selectionFilter: [], selectedIdSet: new Set(['a']), }) expect(current.rows).toHaveLength(2) }) - test('includes only selected rows when the toggle is on', () => { + test('includes only selected rows when filtered to "selected"', () => { const { current } = renderTableData({ layer, sortField: 'name', sortDirection: 'asc', - showOnlySelected: true, + selectionFilter: ['selected'], selectedIdSet: new Set(['a']), }) expect(current.rows).toHaveLength(1) @@ -965,12 +1056,37 @@ describe('useTableData showOnlySelected', () => { ) }) - test('shows no rows when the toggle is on and nothing is selected', () => { + test('includes only non-selected rows when filtered to "not-selected"', () => { + const { current } = renderTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + selectionFilter: ['not-selected'], + selectedIdSet: new Set(['a']), + }) + expect(current.rows).toHaveLength(1) + expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( + 'Item B' + ) + }) + + test('includes all rows when both options are checked', () => { + const { current } = renderTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + selectionFilter: ['selected', 'not-selected'], + selectedIdSet: new Set(['a']), + }) + expect(current.rows).toHaveLength(2) + }) + + test('shows no rows when filtered to "selected" and nothing is selected', () => { const { current } = renderTableData({ layer, sortField: 'name', sortDirection: 'asc', - showOnlySelected: true, + selectionFilter: ['selected'], selectedIdSet: new Set(), }) expect(current.rows).toHaveLength(0) @@ -995,7 +1111,7 @@ describe('useTableData columnOptions', () => { } ).result - test('includes legend and type for a thematic layer, but not name/id', () => { + test('gives options to every column type once distinct values are within the cap', () => { const layer = { layer: 'thematic', dataFilters: null, @@ -1036,12 +1152,24 @@ describe('useTableData columnOptions', () => { { value: 'Low' }, ]) expect(current.columnOptions.type).toEqual([{ value: 'Point' }]) - expect(current.columnOptions.name).toBeUndefined() - expect(current.columnOptions.id).toBeUndefined() - expect(current.columnOptions.parentName).toBeUndefined() + expect(current.columnOptions.name).toEqual([ + { value: 'Org unit 1' }, + { value: 'Org unit 2' }, + ]) + expect(current.columnOptions.id).toEqual([ + { value: 'ou1' }, + { value: 'ou2' }, + ]) + expect(current.columnOptions.parentName).toEqual([{ value: 'Country' }]) + // Numeric columns (previously excluded outright) qualify too now. + expect(current.columnOptions.rawValue).toEqual([ + { value: '10' }, + { value: '20' }, + ]) + expect(current.columnOptions.level).toEqual([{ value: '1' }]) }) - test('falls back to free text when a column has more than 30 distinct values', () => { + test('gives options to a column even with many distinct values - no cap', () => { const layer = { layer: 'orgUnit', dataFilters: null, @@ -1058,7 +1186,78 @@ describe('useTableData columnOptions', () => { const { current } = renderTableData(layer) - expect(current.columnOptions.type).toBeUndefined() + expect(current.columnOptions.type).toHaveLength(31) + }) + + test('sorts numeric column options numerically, not lexically', () => { + const layer = { + layer: 'thematic', + dataFilters: null, + data: [10, 2, 33].map((rawValue, i) => ({ + properties: { + id: `ou${i}`, + name: `Org unit ${i}`, + rawValue, + legend: 'High', + range: '0 - 100', + level: 1, + parentName: 'Country', + type: 'Point', + color: '#ff0000', + }, + })), + } + + const { current } = renderTableData(layer) + + expect(current.columnOptions.rawValue).toEqual([ + { value: '2' }, + { value: '10' }, + { value: '33' }, + ]) + }) + + test('includes a NOT_SET_VALUE option when some rows have a blank value', () => { + const layer = { + layer: 'orgUnit', + dataFilters: null, + data: [ + { + properties: { + id: 'ou1', + name: 'Org unit 1', + level: 1, + parentName: 'Country', + type: 'Point', + }, + }, + { + properties: { + id: 'ou2', + name: 'Org unit 2', + level: 1, + parentName: '', + type: 'Point', + }, + }, + { + properties: { + id: 'ou3', + name: 'Org unit 3', + level: 1, + // parentName omitted entirely (undefined) + type: 'Point', + }, + }, + ], + } + + const { current } = renderTableData(layer) + + expect(current.columnOptions.parentName).toEqual([ + { value: NOT_SET_VALUE }, + { value: 'Country' }, + ]) }) test('exposes optionSet on event columns for later resolution by FilterInput', () => { diff --git a/src/components/datatable/styles/BottomPanel.module.css b/src/components/datatable/styles/BottomPanel.module.css index 5058846a56..e95268f60a 100644 --- a/src/components/datatable/styles/BottomPanel.module.css +++ b/src/components/datatable/styles/BottomPanel.module.css @@ -128,6 +128,16 @@ padding: 0; } +.alignIcon1 { + display: flex; + margin-top: 1px; +} + +.alignIcon2 { + display: flex; + margin-top: 2px; +} + .clearFiltersButton:hover, .closeIcon:hover, .toggleButton:hover { diff --git a/src/components/datatable/styles/DataTable.module.css b/src/components/datatable/styles/DataTable.module.css index 8f09b2881c..39d96c664b 100644 --- a/src/components/datatable/styles/DataTable.module.css +++ b/src/components/datatable/styles/DataTable.module.css @@ -2,6 +2,22 @@ height: 1px; } +/* @dhis2/ui's Table draws a 1px border on all sides via its own scoped + styled-jsx rule, which our .dataTable class alone can't reliably + out-specificity. Pairing it with the table's stable data-test attribute + (with !important) guarantees this wins, while staying scoped to just this + table via .dataTable (a bare data-test selector would match every + DataTable instance in the app). That border (not just the top side) is + what makes the browser compute the sticky header's normal-flow and stuck + positions on very slightly different subpixel grids, causing it to + visibly snap by ~1-2px the instant scrolling engages position:sticky. + Removing the table's own border entirely (confirmed via devtools) removes + the snap; row/column delineation still comes from each cell's own + border-bottom/border-inline-end. */ +.dataTable[data-test='dhis2-uicore-datatable'] { + border: none !important; +} + .dataTable > :global(thead) { user-select: none; } @@ -21,15 +37,65 @@ td.lightText { color: var(--colors-white); } +td.monoCell { + font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', 'Consolas', monospace; +} + +.legendCell { + display: flex; + align-items: center; + gap: var(--spacers-dp8); +} + +.legendSwatch { + flex-shrink: 0; + width: 10px; + height: 10px; + border-radius: 2px; + border: 1px solid var(--colors-grey400); +} + th.checkboxCell, td.checkboxCell { - width: 32px; - min-width: 32px; - max-width: 32px; + width: 76px; + min-width: 76px; + max-width: 76px; text-align: center; padding: 0; } +.checkboxHeaderContent { + display: flex; + align-items: center; + justify-content: center; + gap: 2px; +} + +.selectionFilterButton { + width: 100%; + height: 24px; + font-size: 11px; + padding: 4px 6px; + border: 1px solid var(--colors-grey500); + border-radius: 3px; + box-shadow: inset 0 0 1px 0 rgba(48, 54, 60, 0.1); + background: var(--colors-white); + cursor: pointer; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.selectionFilterPopover { + padding: var(--spacers-dp8); + min-width: 140px; +} + +.selectionFilterPopover :global(label) { + font-size: 11px !important; +} + td.selected { background-color: var(--colors-blue050); } @@ -50,6 +116,21 @@ td.hovered { gap: 2px; } +.reverseButton { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 26px; + height: 26px; + padding-top: 1px; + margin-right: -8px; + border: none; + border-radius: 4px; + background: transparent; + cursor: pointer; +} + .sortButton { display: inline-flex; align-items: center; @@ -64,8 +145,8 @@ td.hovered { cursor: pointer; } -.sortButton:hover, -.sortButton:focus-visible { +.sortButton:hover:not(:disabled), +.sortButton:focus-visible:not(:disabled) { background: var(--colors-grey400); } @@ -73,6 +154,24 @@ td.hovered { outline: none; } +.sortButton:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.topTooltipContent { + z-index: 2000; + max-width: 300px; + padding: 4px 6px; + background-color: var(--colors-grey900); + border-radius: 3px; + color: var(--colors-white); + font-size: 13px; + line-height: 17px; + word-break: normal; + overflow-wrap: break-word; +} + .columnHeader :global(input.dense) { padding: 4px 6px; font-size: 11px; @@ -82,12 +181,21 @@ td.hovered { color: var(--colors-grey400); } -/* Hide the filter icon */ +/* The filter icon button is a required prop of DataTableColumnHeader + whenever a filter is passed, but the filter itself is always shown (see + the "Filtering: Inline" pattern), so the icon has no real toggle behavior. + Remove it from layout entirely instead of just hiding it, to reclaim the + header's horizontal space. Applies to the checkbox column too, since it + also has its own filter (the selection filter). */ .columnHeader + > :global(span.container) + > :global(span.top) + > button:last-of-type, +.checkboxCell > :global(span.container) > :global(span.top) > button:last-of-type { - visibility: hidden; + display: none; } .noResults { @@ -95,10 +203,39 @@ td.hovered { color: var(--colors-grey600); align-items: center; justify-content: center; + gap: var(--spacers-dp8); + font-size: 12px; font-style: italic; min-height: 40px; } +.clearFiltersLink { + font-size: 12px; + font-style: normal; + color: var(--colors-blue600); + background: transparent; + border: none; + padding: 0; + cursor: pointer; + text-decoration: underline; +} + +.clearFiltersLink:hover { + color: var(--colors-blue700); +} + +.loadingContent { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacers-dp8); +} + +.loadingReason { + font-size: 12px; + color: var(--colors-grey700); +} + .noSupport { position: absolute; top: 50%; diff --git a/src/components/datatable/styles/FilterInput.module.css b/src/components/datatable/styles/FilterInput.module.css index c5d585d03a..28f2e3e54a 100644 --- a/src/components/datatable/styles/FilterInput.module.css +++ b/src/components/datatable/styles/FilterInput.module.css @@ -1,36 +1,227 @@ -.multiSelectButton { +.filterTrigger { + position: relative; + display: flex; + align-items: center; width: 100%; - height: 24px; - font-size: 11px; +} + +/* The trigger and the dropdown's search field are the same <Input> now + (see FilterInput.jsx) - style it to match the old compact trigger + button's size regardless of which role it's playing at the moment. + Padding-right for the built-in clear button is handled by @dhis2/ui's + own `.input-clearable input` rule - don't compete with it here. */ +.filterTrigger :global(input.dense) { padding: 4px 6px; - border: 1px solid var(--colors-grey400); - border-radius: 3px; - background: var(--colors-white); + font-size: 11px; +} + +.filterTrigger :global(input::placeholder) { + color: var(--colors-grey400); +} + +/* Every column's dropdown opens on the same side (see dropdownSide in + FilterInput.jsx - below by default, or above if the shared header row + doesn't have room to open downward) - this supplies the same look + @dhis2/ui's Popover would (white background, elevation shadow, rounded + corners), minus whichever corner touches the trigger, which is squared + off so it reads as a continuation of the input rather than a separate + floating box. */ +.dropdownPopper { + background-color: var(--colors-white); + border-radius: 4px; + border-top-left-radius: 0; + border-top-right-radius: 0; + box-shadow: 0 4px 12px rgba(12, 14, 16, 0.15), + 0 0 0 1px rgba(12, 14, 16, 0.05); +} + +.dropdownPopperAbove { + border-top-left-radius: 4px; + border-top-right-radius: 4px; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.searchableFilterPopover { + display: flex; + flex-direction: column; + gap: var(--spacers-dp2); + padding: var(--spacers-dp8); + min-width: 140px; + box-sizing: border-box; +} + +/* When the dropdown opens above the trigger, flip the whole stack so the + real value list ends up farthest from the input and the custom-filter + row ends up right next to it, same as when it opens below - dropdownSide + is computed once in JS (see FilterInput.jsx), so this is a plain class + toggle rather than a Popper-attribute selector. Ties in flex `order` + fall back to DOM order, which would put "Any value"/"No value" closest + to the input instead of the custom-filter row - giving each group its + own order keeps the same relative arrangement as the non-flipped case, + just mirrored. */ +.reversedOrder .multiSelectPopover { + order: 0; +} + +.reversedOrder .pinnedOptions { + order: 1; +} + +.reversedOrder .customFilterRow { + order: 2; +} + +/* "Any value" and "No value" are grouped together above a divider, + separate from the column's real distinct values below - no horizontal + padding of its own (the 8px inset already comes from + .searchableFilterPopover's padding, same as .multiSelectPopover below - + adding more here would indent these two checkboxes further than the + rest) and the same font-size as the list underneath, so they read as + part of the same control rather than a visually distinct element bolted + on top. `min-width: 0` overrides the flex item default of `auto` (which + floors a flex item's size at its content's min-content width) - without + it, this row's own content could force `.searchableFilterPopover` wider + than the trigger input on narrow columns even though "Any value"/"No + value" are short, fixed strings that never need the extra room the + real-value list below is allowed to take. `width: 100%` makes it match + the popover's own (input-matching) width explicitly, rather than + relying only on the flex stretch default - belt-and-braces against this + row silently narrowing the popover below the trigger input's width. */ +.pinnedOptions { + position: relative; + width: 100%; + min-width: 0; + box-sizing: border-box; + border-bottom: 1px solid var(--colors-grey300); +} + +.pinnedOptions :global(label) { + font-size: 11px !important; +} + +/* "Reverse selection" - a ghost icon button, absolutely positioned over + the "Any value" row so it never contributes to that row's own width. */ +.reverseSelectionButton { + position: absolute; + top: 0; + right: 0; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + color: var(--colors-grey700); cursor: pointer; - text-align: left; +} + +.reverseSelectionButton:hover:not(:disabled) { + background: var(--colors-grey100); +} + +.reverseSelectionButton:disabled { + color: var(--colors-grey400); + cursor: not-allowed; +} + +/* When the dropdown opens above the trigger, this group sits at the + bottom of the stack (closest to the input - see the order swap above), + so the divider needs to move to its top edge instead: it should always + separate the pinned group from the real value list, regardless of + which one is visually on top. */ +.reversedOrder .pinnedOptions { + border-bottom: none; + border-top: 1px solid var(--colors-grey300); } .multiSelectPopover { - padding: var(--spacers-dp8); max-height: 260px; overflow-y: auto; - min-width: 180px; } -.numericFilterWrapper { +.multiSelectPopover :global(label) { + font-size: 11px !important; +} + +.monoOption :global(label) { + font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', 'Consolas', monospace; +} + +/* "Any value" (matches any non-blank value) and "No value" (the blank-cell + sentinel) are opposite ends of the same predicate, not real data values - + italicize and mute them so they read as special/meta options rather than + something that could appear in the underlying data. */ +.specialOption :global(label) { + color: var(--colors-grey700) !important; + font-style: italic; +} + +.noResults { + padding: 4px 2px; + font-size: 11px; + font-style: italic; + color: var(--colors-grey600); +} + +/* Styled like an active/selectable row (blue, the app's convention for + "actionable/selected" - see td.selected in DataTable.module.css) rather + than a warning, since applying a filter isn't an exceptional action. */ +.customFilterRow { display: flex; align-items: center; - gap: 2px; + gap: 6px; + width: 100%; + text-align: left; + font-size: 11px; + padding: 6px 8px; + border: none; + border-radius: 3px; + background: var(--colors-blue050); + color: var(--colors-blue700); + cursor: pointer; } -.numericFilterWrapper > :global(div) { - flex: 1 1 auto; - min-width: 0; +.customFilterRow svg { + flex-shrink: 0; } -.helpIcon { - display: inline-flex; - flex-shrink: 0; - color: var(--colors-grey600); - cursor: help; +.customFilterRow:hover, +.customFilterRow.highlighted { + background: var(--colors-blue100); +} + +.customFilterTag { + color: var(--colors-blue700); + white-space: nowrap; +} + +.customFilterExpr { + font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', 'Consolas', monospace; + font-weight: 600; + word-break: break-word; +} + +.multiSelectPopover .highlighted { + background: var(--colors-grey100); +} + +/* Matches DataTable.module.css's .topTooltipContent - this is the same + no-flip tooltip pattern (see FilterHelpTooltip in FilterInput.jsx), just + rendering its own dark tooltip box instead of relying on @dhis2/ui's + Tooltip (which supplies that styling itself). */ +.filterHelpTooltip { + z-index: 2000; + max-width: 300px; + padding: 4px 6px; + background-color: var(--colors-grey900); + border-radius: 3px; + color: var(--colors-white); + font-size: 11px; + line-height: 17px; + word-break: normal; + overflow-wrap: break-word; } diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index f41150a44e..c65aaad2b7 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -9,6 +9,10 @@ import { FACILITY_LAYER, GEOJSON_URL_LAYER, } from '../../constants/layers.js' +import { + SELECTION_FILTER_SELECTED, + SELECTION_FILTER_NOT_SELECTED, +} from '../../constants/selection.js' import { numberValueTypes } from '../../constants/valueTypes.js' import { hasClasses } from '../../util/earthEngine.js' import { filterByGlobalSearch, filterData } from '../../util/filter.js' @@ -19,11 +23,25 @@ import { isValidUid } from '../../util/uid.js' const ASCENDING = 'asc' +// Sentinel sortField value for the checkbox column - not a real dataKey +export const SELECTED_SORT_KEY = '__selected' + +// Sentinel option value representing missing/blank cells in a column's +// distinct-value list - matches filterData's existing null/undefined -> +// empty-string coercion (src/util/filter.js), so no filtering logic changes +// are needed to support it. +export const NOT_SET_VALUE = '' + const TYPE_NUMBER = 'number' const TYPE_STRING = 'string' const TYPE_DATE = 'date' -const INDEX = 'index' +// The Index column is a synthetic row number computed only for table +// display (see the `index` assigned from array position in +// dataWithAggregations below) - it's never written onto the underlying +// layer's actual feature data, so it can't be used as a map filter (see +// DataTable.jsx, which excludes it from getting a FilterInput at all). +export const INDEX = 'index' const NAME = 'name' const ID = 'id' const VALUE = 'rawValue' @@ -199,16 +217,13 @@ const EMPTY_AGGREGATIONS = {} const EMPTY_LAYER = {} const EMPTY_COLUMN_OPTIONS = {} -const CATEGORICAL_DATA_KEYS = new Set([LEGEND, TYPE]) -const MAX_CATEGORICAL_OPTIONS = 30 - export const useTableData = ({ layer, sortField, sortDirection, showOnlyFeaturesInView, mapBounds, - showOnlySelected, + selectionFilter, selectedIdSet, globalSearch, }) => { @@ -341,34 +356,49 @@ export const useTableData = ({ layerHeaders, ]) + // The full distinct-value list for every column, regardless of size - + // the filter popover virtualizes its checkbox list (renders only what's + // near the viewport) and narrows live as the user searches, so there's + // no longer a rendering-cost reason to cap or omit this. const columnOptions = useMemo(() => { if (!headers?.length || !dataWithAggregations?.length) { return EMPTY_COLUMN_OPTIONS } const result = {} - headers.forEach(({ dataKey, type, optionSet }) => { - if (type !== TYPE_STRING) { - return - } - if (!CATEGORICAL_DATA_KEYS.has(dataKey) && !optionSet) { - return - } - + headers.forEach(({ dataKey, type }) => { const seen = new Set() for (const item of dataWithAggregations) { const val = item[dataKey] - if (val !== undefined && val !== null && val !== '') { - seen.add(String(val)) - } - if (seen.size > MAX_CATEGORICAL_OPTIONS) { - break - } + seen.add( + val === undefined || val === null || val === '' + ? NOT_SET_VALUE + : String(val) + ) } - if (seen.size > 0 && seen.size <= MAX_CATEGORICAL_OPTIONS) { + if (seen.size > 0) { result[dataKey] = Array.from(seen) - .sort() + .sort((a, b) => { + if (a === NOT_SET_VALUE) { + return -1 + } + if (b === NOT_SET_VALUE) { + return 1 + } + // Distinct values are stored as strings (they're + // sourced alongside string columns) - sort numeric + // columns by numeric value rather than lexically + // (so 2 sorts before 10), everything else keeps the + // default string ordering. + return type === TYPE_NUMBER + ? Number(a) - Number(b) + : a < b + ? -1 + : a > b + ? 1 + : 0 + }) .map((value) => ({ value })) } }) @@ -399,14 +429,38 @@ export const useTableData = ({ ) } - if (showOnlySelected) { - filteredData = filteredData.filter((item) => - selectedIdSet?.has(item.id) + if (selectionFilter?.length) { + const wantSelected = selectionFilter.includes( + SELECTION_FILTER_SELECTED + ) + const wantNotSelected = selectionFilter.includes( + SELECTION_FILTER_NOT_SELECTED ) + // Both (or neither) checked means "show everything" + if (wantSelected !== wantNotSelected) { + filteredData = filteredData.filter( + (item) => !!selectedIdSet?.has(item.id) === wantSelected + ) + } } //sort filteredData.sort((a, b) => { + // "None" (third click of the cycle) - fall back to natural order + if (!sortField) { + return a.index - b.index + } + + if (sortField === SELECTED_SORT_KEY) { + const aSelected = selectedIdSet?.has(a.id) ? 1 : 0 + const bSelected = selectedIdSet?.has(b.id) ? 1 : 0 + // Ascending (the default on first click) puts selected rows + // first, since that's what a user clicking this is after. + return sortDirection === ASCENDING + ? bSelected - aSelected + : aSelected - bSelected + } + const aVal = a[sortField] const bVal = b[sortField] @@ -465,16 +519,23 @@ export const useTableData = ({ globalSearch, sortField, sortDirection, - showOnlySelected, + selectionFilter, selectedIdSet, ]) // EE layers and event layers may be loading additional data - const isLoading = - (layerType === EARTH_ENGINE_LAYER && - aggregationType?.length && - (!aggregations || aggregations === EMPTY_AGGREGATIONS)) || - (layerType === EVENT_LAYER && !layer.isExtended && !serverCluster) + const isLoadingAggregations = + layerType === EARTH_ENGINE_LAYER && + aggregationType?.length && + (!aggregations || aggregations === EMPTY_AGGREGATIONS) + const isExtendingEvents = + layerType === EVENT_LAYER && !layer.isExtended && !serverCluster + const isLoading = isLoadingAggregations || isExtendingEvents + const loadingReason = isLoadingAggregations + ? i18n.t('Loading Earth Engine data…') + : isExtendingEvents + ? i18n.t('Loading additional events…') + : null const totalCount = dataWithAggregations?.length ?? 0 const filteredCount = rows?.length ?? 0 @@ -483,6 +544,7 @@ export const useTableData = ({ headers, rows, isLoading, + loadingReason, error: getErrorCodeText(errorCode.current), totalCount, filteredCount, diff --git a/src/components/map/Map.jsx b/src/components/map/Map.jsx index 3340801468..3358c2f28f 100644 --- a/src/components/map/Map.jsx +++ b/src/components/map/Map.jsx @@ -57,10 +57,10 @@ class Map extends Component { nameProperty: PropTypes.string, resizeCount: PropTypes.number, selection: PropTypes.object, + selectionFilter: PropTypes.array, setAggregations: PropTypes.func, setFeatureProfile: PropTypes.func, setMapObject: PropTypes.func, - showOnlySelected: PropTypes.bool, toggleFeatureSelection: PropTypes.func, zoom: PropTypes.number, } @@ -185,7 +185,7 @@ class Map extends Component { selection, highlightFeature, highlightColor, - showOnlySelected, + selectionFilter, clickFeature, toggleFeatureSelection, coordinatePopup: coordinates, @@ -237,7 +237,7 @@ class Map extends Component { selection={selection} highlightFeature={highlightFeature} highlightColor={highlightColor} - showOnlySelected={showOnlySelected} + selectionFilter={selectionFilter} clickFeature={clickFeature} toggleFeatureSelection={ toggleFeatureSelection diff --git a/src/components/map/MapContainer.jsx b/src/components/map/MapContainer.jsx index 97a5acda70..6db6795fed 100644 --- a/src/components/map/MapContainer.jsx +++ b/src/components/map/MapContainer.jsx @@ -24,7 +24,7 @@ const MapContainer = ({ resizeCount, setMap }) => { ) const feature = useSelector((state) => state.feature) const selection = useSelector((state) => state.selection) - const { layersSorting, highlightColor, showOnlySelected } = useSelector( + const { layersSorting, highlightColor, selectionFilter } = useSelector( (state) => state.ui ) const basemapConfig = useBasemapConfig(basemap) @@ -52,7 +52,7 @@ const MapContainer = ({ resizeCount, setMap }) => { feature={feature} selection={selection} highlightColor={highlightColor} - showOnlySelected={showOnlySelected} + selectionFilter={selectionFilter} highlightFeature={debouncedHighlightFeature} clickFeature={(payload) => dispatch(clickFeature(payload))} toggleFeatureSelection={(id, layerId) => diff --git a/src/components/map/MapView.jsx b/src/components/map/MapView.jsx index 92a5624177..53e354b3d4 100644 --- a/src/components/map/MapView.jsx +++ b/src/components/map/MapView.jsx @@ -20,7 +20,7 @@ const MapView = (props) => { selection, highlightFeature, highlightColor, - showOnlySelected, + selectionFilter, clickFeature, toggleFeatureSelection, bounds, @@ -66,7 +66,7 @@ const MapView = (props) => { selection={selection} highlightFeature={highlightFeature} highlightColor={highlightColor} - showOnlySelected={showOnlySelected} + selectionFilter={selectionFilter} clickFeature={clickFeature} toggleFeatureSelection={toggleFeatureSelection} interpretationModalOpen={interpretationModalOpen} @@ -87,7 +87,7 @@ const MapView = (props) => { selection={selection} highlightFeature={highlightFeature} highlightColor={highlightColor} - showOnlySelected={showOnlySelected} + selectionFilter={selectionFilter} clickFeature={clickFeature} toggleFeatureSelection={toggleFeatureSelection} coordinatePopup={coordinatePopup} @@ -124,8 +124,8 @@ MapView.propTypes = { openContextMenu: PropTypes.func, resizeCount: PropTypes.number, selection: PropTypes.object, + selectionFilter: PropTypes.array, setMapObject: PropTypes.func, - showOnlySelected: PropTypes.bool, toggleFeatureSelection: PropTypes.func, } diff --git a/src/components/map/SplitView.jsx b/src/components/map/SplitView.jsx index c6cfab5e73..0a1f13bcdf 100644 --- a/src/components/map/SplitView.jsx +++ b/src/components/map/SplitView.jsx @@ -16,7 +16,7 @@ const SplitView = ({ selection, highlightFeature, highlightColor, - showOnlySelected, + selectionFilter, clickFeature, toggleFeatureSelection, controls, @@ -101,7 +101,7 @@ const SplitView = ({ selection={selection} highlightFeature={highlightFeature} highlightColor={highlightColor} - showOnlySelected={showOnlySelected} + selectionFilter={selectionFilter} clickFeature={clickFeature} toggleFeatureSelection={toggleFeatureSelection} openContextMenu={openContextMenu} @@ -136,8 +136,8 @@ SplitView.propTypes = { layersSorting: PropTypes.bool, resizeCount: PropTypes.number, selection: PropTypes.object, + selectionFilter: PropTypes.array, setMapObject: PropTypes.func, - showOnlySelected: PropTypes.bool, toggleFeatureSelection: PropTypes.func, } diff --git a/src/components/map/layers/Layer.js b/src/components/map/layers/Layer.js index ab8c8a13f7..a274a071b6 100644 --- a/src/components/map/layers/Layer.js +++ b/src/components/map/layers/Layer.js @@ -7,6 +7,10 @@ import { PADDING_DEFAULT, DURATION_DEFAULT, } from '../../../constants/layers.js' +import { + SELECTION_FILTER_SELECTED, + SELECTION_FILTER_NOT_SELECTED, +} from '../../../constants/selection.js' export const idsEqual = (a, b) => a.length === b.length && a.every((id, i) => id === b[i]) @@ -33,7 +37,7 @@ class Layer extends PureComponent { opacity: PropTypes.number, openContextMenu: PropTypes.func, selection: PropTypes.object, - showOnlySelected: PropTypes.bool, + selectionFilter: PropTypes.array, toggleFeatureSelection: PropTypes.func, } @@ -145,14 +149,14 @@ class Layer extends PureComponent { } handleVisibleIdsChange(prevProps) { - const { selection, showOnlySelected } = this.props + const { selection, selectionFilter } = this.props if ( !idsEqual( this.getVisibleIds( prevProps.selection, - prevProps.showOnlySelected + prevProps.selectionFilter ) ?? [], - this.getVisibleIds(selection, showOnlySelected) ?? [] + this.getVisibleIds(selection, selectionFilter) ?? [] ) ) { this.updateVisibleIds() @@ -286,12 +290,33 @@ class Layer extends PureComponent { getVisibleIds( selection = this.props.selection, - showOnlySelected = this.props.showOnlySelected + selectionFilter = this.props.selectionFilter ) { - if (!showOnlySelected || selection?.layerId !== this.props.id) { + if (!selectionFilter?.length || selection?.layerId !== this.props.id) { + return null + } + + const wantSelected = selectionFilter.includes(SELECTION_FILTER_SELECTED) + const wantNotSelected = selectionFilter.includes( + SELECTION_FILTER_NOT_SELECTED + ) + + // Both (or neither, though that's already handled above) checked + // means "show everything" - same as no filter at all. + if (wantSelected === wantNotSelected) { return null } - return this.getSelectedIds(selection) + + const selectedIds = this.getSelectedIds(selection) + + if (wantSelected) { + return selectedIds + } + + const selectedIdSet = new Set(selectedIds) + return (this.props.data ?? []) + .map((feature) => feature.properties?.id ?? feature.id) + .filter((id) => id != null && !selectedIdSet.has(id)) } updateVisibleIds() { diff --git a/src/components/map/layers/__tests__/Layer.spec.js b/src/components/map/layers/__tests__/Layer.spec.js new file mode 100644 index 0000000000..bb5d18d1fb --- /dev/null +++ b/src/components/map/layers/__tests__/Layer.spec.js @@ -0,0 +1,69 @@ +import Layer from '../Layer.js' + +// Layer's constructor touches this.context.map (only available once mounted +// by React), but getVisibleIds/getSelectedIds only read this.props, so an +// un-constructed instance (no context, no maps-gl layer) is enough to test +// this method in isolation. +const createLayer = (props) => { + const instance = Object.create(Layer.prototype) + instance.props = props + return instance +} + +describe('Layer#getVisibleIds', () => { + const data = [ + { properties: { id: 'a' } }, + { properties: { id: 'b' } }, + { properties: { id: 'c' } }, + ] + + test('returns null (show everything) when selectionFilter is empty', () => { + const layer = createLayer({ + id: 'layer1', + data, + selection: { layerId: 'layer1', ids: ['a'] }, + selectionFilter: [], + }) + expect(layer.getVisibleIds()).toBe(null) + }) + + test('returns null when the selection belongs to a different layer', () => { + const layer = createLayer({ + id: 'layer1', + data, + selection: { layerId: 'other-layer', ids: ['a'] }, + selectionFilter: ['selected'], + }) + expect(layer.getVisibleIds()).toBe(null) + }) + + test('returns only the selected ids when filtered to "selected"', () => { + const layer = createLayer({ + id: 'layer1', + data, + selection: { layerId: 'layer1', ids: ['a', 'c'] }, + selectionFilter: ['selected'], + }) + expect(layer.getVisibleIds()).toEqual(['a', 'c']) + }) + + test('returns only the non-selected ids when filtered to "not-selected"', () => { + const layer = createLayer({ + id: 'layer1', + data, + selection: { layerId: 'layer1', ids: ['a'] }, + selectionFilter: ['not-selected'], + }) + expect(layer.getVisibleIds()).toEqual(['b', 'c']) + }) + + test('returns null (show everything) when both options are checked', () => { + const layer = createLayer({ + id: 'layer1', + data, + selection: { layerId: 'layer1', ids: ['a'] }, + selectionFilter: ['selected', 'not-selected'], + }) + expect(layer.getVisibleIds()).toBe(null) + }) +}) diff --git a/src/constants/actionTypes.js b/src/constants/actionTypes.js index 7b43834cd1..0fbf8c5cec 100644 --- a/src/constants/actionTypes.js +++ b/src/constants/actionTypes.js @@ -42,8 +42,7 @@ export const DATA_TABLE_TOGGLE = 'DATA_TABLE_TOGGLE' export const DATA_TABLE_RESIZE = 'DATA_TABLE_RESIZE' export const MAP_BOUNDS_CHANGED = 'MAP_BOUNDS_CHANGED' export const TOGGLE_SHOW_ONLY_IN_VIEW = 'TOGGLE_SHOW_ONLY_IN_VIEW' -export const TOGGLE_SHOW_ONLY_SELECTED = 'TOGGLE_SHOW_ONLY_SELECTED' -export const SHOW_ONLY_SELECTED_SET = 'SHOW_ONLY_SELECTED_SET' +export const SELECTION_FILTER_SET = 'SELECTION_FILTER_SET' export const HIGHLIGHT_COLOR_SET = 'HIGHLIGHT_COLOR_SET' export const MAP_FEATURE_CLICKED = 'MAP_FEATURE_CLICKED' diff --git a/src/constants/selection.js b/src/constants/selection.js new file mode 100644 index 0000000000..1be35537b4 --- /dev/null +++ b/src/constants/selection.js @@ -0,0 +1,4 @@ +// Values for state.ui.selectionFilter - controls which features are shown +// (in both the data table and on the map) based on their selection state. +export const SELECTION_FILTER_SELECTED = 'selected' +export const SELECTION_FILTER_NOT_SELECTED = 'not-selected' diff --git a/src/reducers/__tests__/ui.spec.js b/src/reducers/__tests__/ui.spec.js index 70adf262ae..e3e53fb768 100644 --- a/src/reducers/__tests__/ui.spec.js +++ b/src/reducers/__tests__/ui.spec.js @@ -26,29 +26,18 @@ describe('ui reducer — highlightColor', () => { }) }) -describe('ui reducer — showOnlySelected', () => { - it('defaults to false', () => { - expect(ui(undefined, {}).showOnlySelected).toBe(false) +describe('ui reducer — selectionFilter', () => { + it('defaults to an empty array', () => { + expect(ui(undefined, {}).selectionFilter).toEqual([]) }) - it('toggles on TOGGLE_SHOW_ONLY_SELECTED', () => { - const state = ui(undefined, { type: types.TOGGLE_SHOW_ONLY_SELECTED }) - expect(state.showOnlySelected).toBe(true) - - const toggledBack = ui(state, { - type: types.TOGGLE_SHOW_ONLY_SELECTED, - }) - expect(toggledBack.showOnlySelected).toBe(false) - }) - - it('sets an explicit value on SHOW_ONLY_SELECTED_SET', () => { - const prevState = { ...ui(undefined, {}), showOnlySelected: true } - const state = ui(prevState, { - type: types.SHOW_ONLY_SELECTED_SET, - value: false, + it('sets an explicit value on SELECTION_FILTER_SET', () => { + const state = ui(undefined, { + type: types.SELECTION_FILTER_SET, + value: ['selected'], }) - expect(state.showOnlySelected).toBe(false) + expect(state.selectionFilter).toEqual(['selected']) }) it.each([ @@ -56,11 +45,14 @@ describe('ui reducer — showOnlySelected', () => { types.MAP_SET, types.DATA_TABLE_CLOSE, types.DATA_TABLE_TOGGLE, - ])('resets to false on %s', (type) => { - const prevState = { ...ui(undefined, {}), showOnlySelected: true } + ])('resets to an empty array on %s', (type) => { + const prevState = { + ...ui(undefined, {}), + selectionFilter: ['selected'], + } const state = ui(prevState, { type }) - expect(state.showOnlySelected).toBe(false) + expect(state.selectionFilter).toEqual([]) }) }) diff --git a/src/reducers/ui.js b/src/reducers/ui.js index b5d4ca63da..45fb37be3c 100644 --- a/src/reducers/ui.js +++ b/src/reducers/ui.js @@ -11,7 +11,7 @@ const defaultState = { layersSorting: false, mapBounds: null, showOnlyFeaturesInView: false, - showOnlySelected: false, + selectionFilter: [], highlightColor: null, lastClickedFeature: null, } @@ -51,7 +51,7 @@ const ui = (state = defaultState, action) => { return { ...state, rightPanelOpen: false, - showOnlySelected: false, + selectionFilter: [], lastClickedFeature: null, } @@ -59,7 +59,7 @@ const ui = (state = defaultState, action) => { case types.DATA_TABLE_TOGGLE: return { ...state, - showOnlySelected: false, + selectionFilter: [], } case types.DOWNLOAD_MODE_OPEN: @@ -103,16 +103,10 @@ const ui = (state = defaultState, action) => { showOnlyFeaturesInView: !state.showOnlyFeaturesInView, } - case types.TOGGLE_SHOW_ONLY_SELECTED: + case types.SELECTION_FILTER_SET: return { ...state, - showOnlySelected: !state.showOnlySelected, - } - - case types.SHOW_ONLY_SELECTED_SET: - return { - ...state, - showOnlySelected: action.value, + selectionFilter: action.value, } case types.HIGHLIGHT_COLOR_SET: diff --git a/src/util/__tests__/filter.spec.js b/src/util/__tests__/filter.spec.js index d2c4315b34..c2490ea941 100644 --- a/src/util/__tests__/filter.spec.js +++ b/src/util/__tests__/filter.spec.js @@ -1,4 +1,4 @@ -import { filterByGlobalSearch, filterData } from '../filter.js' +import { filterByGlobalSearch, filterData, ANY_VALUE_KEY } from '../filter.js' describe('filterData', () => { it('should return the original data if no filters are provided', () => { @@ -96,6 +96,18 @@ describe('filterData', () => { const filters = { a: ['High'], b: 'horse' } expect(filterData(data, filters)).toEqual([{ a: 'High', b: 'horse' }]) }) + + it('ANY_VALUE_KEY matches every row with a non-blank value, the opposite of the blank sentinel', () => { + const data = [{ a: 'High' }, { a: '' }, { a: null }, { a: 'Low' }] + const filters = { a: [ANY_VALUE_KEY] } + expect(filterData(data, filters)).toEqual([{ a: 'High' }, { a: 'Low' }]) + }) + + it('combining ANY_VALUE_KEY with the blank sentinel ("") matches every row', () => { + const data = [{ a: 'High' }, { a: '' }, { a: null }] + const filters = { a: [ANY_VALUE_KEY, ''] } + expect(filterData(data, filters)).toEqual(data) + }) }) describe('filterByGlobalSearch', () => { diff --git a/src/util/filter.js b/src/util/filter.js index 4faa3c85a4..1c1d823422 100644 --- a/src/util/filter.js +++ b/src/util/filter.js @@ -1,3 +1,10 @@ +// Pseudo-value for multi-select filters meaning "the field has any +// non-blank value" - the logical opposite of selecting the blank-cell +// sentinel (''). Works generically even for columns with too many distinct +// values to list individually, since it's a predicate ("is it blank or +// not?"), not a membership check against a known list of values. +export const ANY_VALUE_KEY = '__any_value__' + // Filters an array of object with a set of filters export const filterData = (data, filters) => { if (!filters) { @@ -17,9 +24,11 @@ export const filterData = (data, filters) => { if (Array.isArray(filter)) { // Multi-select: OR match against the raw stored value + const stringValue = value == null ? '' : String(value) return ( filter.length === 0 || - filter.includes(value == null ? '' : String(value)) + filter.includes(stringValue) || + (stringValue !== '' && filter.includes(ANY_VALUE_KEY)) ) } From 6a1fcbad8bdb068e33922fe5d9d993907faaea05 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 14 Jul 2026 22:35:47 +0200 Subject: [PATCH 036/205] revert: remove legend-click-to-filter, deferred to a future PR Reverts c2b5aac0. The thematic-only implementation is being pulled out of this PR - the feature should work across bubble, event, facility, and org-unit layers too, which needs its own investigation and belongs in a separate PR rather than this one. --- .../layers/overlays/OverlayCard.jsx | 54 +-------- .../overlays/__tests__/OverlayCard.spec.jsx | 111 ------------------ src/components/legend/Legend.jsx | 10 -- src/components/legend/LegendItem.jsx | 26 +--- .../legend/styles/LegendItem.module.css | 21 ---- 5 files changed, 3 insertions(+), 219 deletions(-) diff --git a/src/components/layers/overlays/OverlayCard.jsx b/src/components/layers/overlays/OverlayCard.jsx index e8940790a9..a490f28cda 100644 --- a/src/components/layers/overlays/OverlayCard.jsx +++ b/src/components/layers/overlays/OverlayCard.jsx @@ -5,7 +5,6 @@ import i18n from '@dhis2/d2-i18n' import PropTypes from 'prop-types' import React, { useState } from 'react' import { connect } from 'react-redux' -import { setDataFilter, clearDataFilter } from '../../../actions/dataFilters.js' import { toggleDataTable } from '../../../actions/dataTable.js' import { editLayer, @@ -25,7 +24,6 @@ import { DATA_TABLE_LAYER_TYPES, OPEN_AS_LAYER_TYPES, EXTERNAL_LAYER, - THEMATIC_LAYER, } from '../../../constants/layers.js' import { getAnalyticalObjectFromThematicLayer, @@ -47,9 +45,6 @@ const OverlayCard = ({ toggleLayerExpand, toggleLayerVisibility, toggleDataTable, - setDataFilter, - clearDataFilter, - activeDataTableLayerId, }) => { const [showDataDownloadDialog, setShowDataDownloadDialog] = useState(false) const { baseUrl } = useConfig() @@ -66,37 +61,12 @@ const OverlayCard = ({ layer: layerType, isLoaded, loadError, - dataFilters, } = layer const canEdit = layerType !== EXTERNAL_LAYER const canToggleDataTable = DATA_TABLE_LAYER_TYPES.includes(layerType) const canDownload = DOWNLOADABLE_LAYER_TYPES.includes(layerType) const canOpenAs = OPEN_AS_LAYER_TYPES.includes(layerType) - const canFilterByLegend = layerType === THEMATIC_LAYER - - const onLegendItemClick = (item) => { - if (!item?.name) { - return - } - const currentLegendFilter = Array.isArray(dataFilters?.legend) - ? dataFilters.legend - : [] - const isActive = currentLegendFilter.includes(item.name) - const nextLegendFilter = isActive - ? currentLegendFilter.filter((n) => n !== item.name) - : [...currentLegendFilter, item.name] - - if (nextLegendFilter.length) { - setDataFilter(id, 'legend', nextLegendFilter) - } else { - clearDataFilter(id, 'legend') - } - - if (activeDataTableLayerId !== id) { - toggleDataTable(id) - } - } const getCardContent = () => { if (loadError) { @@ -114,18 +84,7 @@ const OverlayCard = ({ return ( legend && ( <div className={styles.legend}> - <Legend - {...legend} - onItemClick={ - canFilterByLegend ? onLegendItemClick : undefined - } - activeLegendNames={ - canFilterByLegend && - Array.isArray(dataFilters?.legend) - ? dataFilters.legend - : undefined - } - /> + <Legend {...legend} /> </div> ) ) @@ -197,23 +156,16 @@ const OverlayCard = ({ OverlayCard.propTypes = { changeLayerOpacity: PropTypes.func.isRequired, - clearDataFilter: PropTypes.func.isRequired, duplicateLayer: PropTypes.func.isRequired, editLayer: PropTypes.func.isRequired, layer: PropTypes.object.isRequired, removeLayer: PropTypes.func.isRequired, - setDataFilter: PropTypes.func.isRequired, toggleDataTable: PropTypes.func.isRequired, toggleLayerExpand: PropTypes.func.isRequired, toggleLayerVisibility: PropTypes.func.isRequired, - activeDataTableLayerId: PropTypes.string, } -const mapStateToProps = (state) => ({ - activeDataTableLayerId: state.dataTable, -}) - -export default connect(mapStateToProps, { +export default connect(null, { editLayer, removeLayer, duplicateLayer, @@ -221,6 +173,4 @@ export default connect(mapStateToProps, { toggleLayerExpand, toggleLayerVisibility, toggleDataTable, - setDataFilter, - clearDataFilter, })(OverlayCard) diff --git a/src/components/layers/overlays/__tests__/OverlayCard.spec.jsx b/src/components/layers/overlays/__tests__/OverlayCard.spec.jsx index c70cce431e..4da6e6ae81 100644 --- a/src/components/layers/overlays/__tests__/OverlayCard.spec.jsx +++ b/src/components/layers/overlays/__tests__/OverlayCard.spec.jsx @@ -25,19 +25,6 @@ jest.mock('@dhis2/app-service-alerts', () => ({ useAlert: () => ({ show: mockShow }), })) -jest.mock('../../../cachedDataProvider/CachedDataProvider.jsx', () => ({ - useCachedData: jest.fn(() => ({ - systemSettings: { keyAnalysisDigitGroupSeparator: 'NONE' }, - })), -})) - -// jsdom has no ResizeObserver; Legend.jsx uses one to measure overflow, which -// is irrelevant to the legend-click wiring under test here. -global.ResizeObserver = class { - observe() {} - disconnect() {} -} - const mockStore = configureMockStore() describe('OverlayCard', () => { @@ -72,101 +59,3 @@ describe('OverlayCard', () => { }) }) }) - -describe('OverlayCard legend-driven filter', () => { - const layer = { - id: 'layer1', - name: 'Test layer', - layer: 'thematic', - isLoaded: true, - isExpanded: true, - isVisible: true, - opacity: 1, - dataFilters: {}, - legend: { - items: [ - { name: 'High', color: '#ff0000' }, - { name: 'Low', color: '#00ff00' }, - ], - }, - } - - const renderCard = (store) => - render( - <Provider store={store}> - <OverlayCard layer={layer} /> - </Provider> - ) - - test('opens the table and sets the legend filter on click when the table is closed', () => { - const store = mockStore({ dataTable: null, aggregations: {} }) - renderCard(store) - - fireEvent.click(screen.getByText('High')) - - const actions = store.getActions() - expect(actions).toContainEqual({ - type: 'DATA_FILTER_SET', - layerId: 'layer1', - fieldId: 'legend', - filter: ['High'], - }) - expect(actions).toContainEqual({ - type: 'DATA_TABLE_TOGGLE', - id: 'layer1', - }) - }) - - test('adds to the filter without re-toggling the table when it is already open for this layer', () => { - const store = mockStore({ dataTable: 'layer1', aggregations: {} }) - renderCard(store) - - fireEvent.click(screen.getByText('Low')) - - const actions = store.getActions() - expect(actions).toContainEqual({ - type: 'DATA_FILTER_SET', - layerId: 'layer1', - fieldId: 'legend', - filter: ['Low'], - }) - expect(actions).not.toContainEqual( - expect.objectContaining({ type: 'DATA_TABLE_TOGGLE' }) - ) - }) - - test('clears the filter when clicking an already-active legend class', () => { - const activeLayer = { - ...layer, - dataFilters: { legend: ['High'] }, - } - const store = mockStore({ dataTable: 'layer1', aggregations: {} }) - render( - <Provider store={store}> - <OverlayCard layer={activeLayer} /> - </Provider> - ) - - fireEvent.click(screen.getByText('High')) - - expect(store.getActions()).toContainEqual({ - type: 'DATA_FILTER_CLEAR', - layerId: 'layer1', - fieldId: 'legend', - }) - }) - - test('does not wire legend clicks for non-thematic layers', () => { - const facilityLayer = { ...layer, layer: 'facility' } - const store = mockStore({ dataTable: null, aggregations: {} }) - render( - <Provider store={store}> - <OverlayCard layer={facilityLayer} /> - </Provider> - ) - - fireEvent.click(screen.getByText('High')) - - expect(store.getActions()).toEqual([]) - }) -}) diff --git a/src/components/legend/Legend.jsx b/src/components/legend/Legend.jsx index 9bd4793a3d..ae7603787b 100644 --- a/src/components/legend/Legend.jsx +++ b/src/components/legend/Legend.jsx @@ -98,8 +98,6 @@ const Legend = ({ orgUnitsWithoutCoordinatesCount, orgUnitsPointOnly = false, isPlugin = false, - onItemClick, - activeLegendNames, }) => { const { systemSettings: { keyAnalysisDigitGroupSeparator }, @@ -298,12 +296,6 @@ const Legend = ({ isPlugin={isPlugin} suppressRange={suppressAllRanges} forceScientific={forceScientific} - onClick={onItemClick ? () => onItemClick(item) : undefined} - isActive={ - !!activeLegendNames && - !!item.name && - activeLegendNames.includes(item.name) - } key={`${item.name ?? ''}-${item.startValue ?? ''}-${ item.endValue ?? '' }-${index}`} @@ -398,7 +390,6 @@ const Legend = ({ } Legend.propTypes = { - activeLegendNames: PropTypes.array, bubbles: PropTypes.shape({ radiusHigh: PropTypes.number.isRequired, radiusLow: PropTypes.number.isRequired, @@ -423,7 +414,6 @@ Legend.propTypes = { sourceUrl: PropTypes.string, unit: PropTypes.string, url: PropTypes.string, - onItemClick: PropTypes.func, } export default Legend diff --git a/src/components/legend/LegendItem.jsx b/src/components/legend/LegendItem.jsx index b2b9bb8662..fc66495d8d 100644 --- a/src/components/legend/LegendItem.jsx +++ b/src/components/legend/LegendItem.jsx @@ -1,4 +1,3 @@ -import cx from 'classnames' import PropTypes from 'prop-types' import React from 'react' import LegendItemRange from './LegendItemRange.jsx' @@ -27,8 +26,6 @@ const LegendItem = ({ isPlugin, suppressRange, forceScientific, - onClick, - isActive, }) => { if (!name && startValue === undefined && endValue === undefined) { return null @@ -54,26 +51,7 @@ const LegendItem = ({ const lineWeight = weight ? Math.min(weight, maxLineWeight) : null return ( - <tr - className={cx(styles.legendItem, { - [styles.clickable]: !!onClick, - [styles.active]: isActive, - })} - data-test="layerlegend-item" - onClick={onClick} - role={onClick ? 'button' : undefined} - tabIndex={onClick ? 0 : undefined} - onKeyDown={ - onClick - ? (e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - onClick() - } - } - : undefined - } - > + <tr className={styles.legendItem} data-test="layerlegend-item"> <th> {weight ? ( type === 'LineString' ? ( @@ -113,7 +91,6 @@ LegendItem.propTypes = { fillColor: PropTypes.string, forceScientific: PropTypes.bool, image: PropTypes.string, - isActive: PropTypes.bool, isPlugin: PropTypes.bool, name: PropTypes.string, radius: PropTypes.number, @@ -124,7 +101,6 @@ LegendItem.propTypes = { type: PropTypes.string, useCompact: PropTypes.bool, weight: PropTypes.number, - onClick: PropTypes.func, } export default LegendItem diff --git a/src/components/legend/styles/LegendItem.module.css b/src/components/legend/styles/LegendItem.module.css index ffc6ff3843..fdc4d8aa72 100644 --- a/src/components/legend/styles/LegendItem.module.css +++ b/src/components/legend/styles/LegendItem.module.css @@ -23,24 +23,3 @@ print-color-adjust: exact; /* Firefox */ } - -.clickable { - cursor: pointer; -} - -.clickable:hover { - background-color: var(--colors-grey100); -} - -.clickable:focus-visible { - outline: 2px solid var(--colors-blue600); - outline-offset: -2px; -} - -.active { - background-color: var(--colors-blue050); -} - -.active:hover { - background-color: var(--colors-blue100); -} From 954d42ec42feac2d8e2b5227ecd7491d6738af96 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 14 Jul 2026 23:01:52 +0200 Subject: [PATCH 037/205] chore: sonarqube issues --- i18n/en.pot | 10 +- src/components/datatable/DataTable.jsx | 63 ++++--- src/components/datatable/FilterInput.jsx | 165 +++++++++++------ .../datatable/styles/DataTable.module.css | 8 +- .../datatable/styles/FilterInput.module.css | 18 +- src/components/datatable/useTableData.js | 168 ++++++++++-------- 6 files changed, 247 insertions(+), 185 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index a0954b8334..eb9354f374 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-14T19:48:56.770Z\n" -"PO-Revision-Date: 2026-07-14T19:48:56.771Z\n" +"POT-Creation-Date: 2026-07-14T20:24:31.895Z\n" +"PO-Revision-Date: 2026-07-14T20:24:31.895Z\n" msgid "2020" msgstr "2020" @@ -205,12 +205,12 @@ msgstr "No results found" msgid "Select all" msgstr "Select all" -msgid "Sort by Selected" -msgstr "Sort by Selected" - msgid "Reverse selection" msgstr "Reverse selection" +msgid "Sort by Selected" +msgstr "Sort by Selected" + msgid "Sort by {{column}}" msgstr "Sort by {{column}}" diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index 356b2d7b2d..7243701117 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -241,6 +241,35 @@ export const getRowClickAction = ( return null } +// Thematic layers merge their legend name + color into one swatch+name +// cell (see hasLegendColorPair); every other column is either the raw +// color's own cell (lowercased hex) or a plain formatted value. +const getCellContent = ({ + isLegendCell, + swatchColor, + value, + dataKey, + keyAnalysisDigitGroupSeparator, +}) => { + if (isLegendCell) { + return ( + <span className={styles.legendCell}> + {swatchColor && ( + <span + className={styles.legendSwatch} + style={{ backgroundColor: swatchColor }} + /> + )} + {value} + </span> + ) + } + if (dataKey === 'color') { + return value?.toLowerCase() + } + return formatWithSeparator(value, keyAnalysisDigitGroupSeparator) +} + const DataTableWithVirtuosoContext = ({ context, ...props }) => ( <DataTable {...props} @@ -867,33 +896,13 @@ const Table = ({ } align={align} > - {isLegendCell ? ( - <span - className={ - styles.legendCell - } - > - {swatchColor && ( - <span - className={ - styles.legendSwatch - } - style={{ - backgroundColor: - swatchColor, - }} - /> - )} - {value} - </span> - ) : dataKey === 'color' ? ( - value?.toLowerCase() - ) : ( - formatWithSeparator( - value, - keyAnalysisDigitGroupSeparator - ) - )} + {getCellContent({ + isLegendCell, + swatchColor, + value, + dataKey, + keyAnalysisDigitGroupSeparator, + })} </DataTableCell> ) })} diff --git a/src/components/datatable/FilterInput.jsx b/src/components/datatable/FilterInput.jsx index b20408c8ff..2556c2964e 100644 --- a/src/components/datatable/FilterInput.jsx +++ b/src/components/datatable/FilterInput.jsx @@ -120,11 +120,13 @@ const FilterHelpTooltip = ({ ) const referenceRect = referenceRef.current?.getBoundingClientRect() - const spaceAvailable = referenceRect - ? placement === 'top' - ? referenceRect.top - : window.innerHeight - referenceRect.bottom - : Infinity + let spaceAvailable = Infinity + if (referenceRect) { + spaceAvailable = + placement === 'top' + ? referenceRect.top + : window.innerHeight - referenceRect.bottom + } const hasRoom = spaceAvailable >= estimatedHeight return ( @@ -208,6 +210,66 @@ FilterDropdownPopover.propTypes = { className: PropTypes.string, } +// See onToggleAnyValue - turning "Any value" on collapses any individually +// -picked real values into just the wildcard, off unticks every real value +// along with it. "No value" carries through untouched either way. +const getAnyValueToggleResult = (anyValueActive, keepNotSet) => { + if (anyValueActive) { + return keepNotSet ? [NOT_SET_VALUE] : [] + } + return keepNotSet ? [ANY_VALUE_KEY, NOT_SET_VALUE] : [ANY_VALUE_KEY] +} + +// See onToggleRealValue - once every real value ends up ticked, that's the +// same state as "Any value" being active, so it collapses into the wildcard +// rather than a literal array that happens to list them all. +const getRealValueToggleResult = (next, allRealValuesChecked) => { + if (!allRealValuesChecked) { + return next + } + return next.includes(NOT_SET_VALUE) + ? [ANY_VALUE_KEY, NOT_SET_VALUE] + : [ANY_VALUE_KEY] +} + +// See "Numeric columns narrow..." comment at the filteredOptions call site - +// numeric columns match the typed text as a numericFilter expression, +// string columns match it as a case-insensitive substring of the resolved +// label. +const getFilteredOptions = ({ + realOptions, + trimmedSearch, + normalizedSearch, + type, + resolveLabel, +}) => { + if (!trimmedSearch) { + return realOptions + } + if (type === 'number') { + return realOptions.filter(({ value }) => + numericFilter(Number(value), trimmedSearch) + ) + } + return realOptions.filter(({ value }) => + resolveLabel(value).toLowerCase().includes(normalizedSearch) + ) +} + +// Closed, this reads like the old trigger button ("3 selected", the applied +// filter text, or empty so the "Search" placeholder shows). Open, it's a +// live, editable search/filter field - the same input serves both roles +// instead of a button revealing a separate one. +const getDisplayValue = ({ isOpen, searchText, selected, appliedString }) => { + if (isOpen) { + return searchText + } + if (selected.length) { + return i18n.t('{{count}} selected', { count: selected.length }) + } + return appliedString +} + // Shared popover UI — label resolution is injected so it never needs to // know whether it's an option-set column or a plain categorical one. // State is derived straight from the applied `filterValue` (never tracked @@ -297,15 +359,7 @@ const SearchableFilterPopover = ({ // "No value" is independent either way and carries over untouched. const onToggleAnyValue = () => { const keepNotSet = selected.includes(NOT_SET_VALUE) - applyValues( - anyValueActive - ? keepNotSet - ? [NOT_SET_VALUE] - : [] - : keepNotSet - ? [ANY_VALUE_KEY, NOT_SET_VALUE] - : [ANY_VALUE_KEY] - ) + applyValues(getAnyValueToggleResult(anyValueActive, keepNotSet)) } // Every value "Reverse selection" can flip - the column's full value @@ -350,13 +404,7 @@ const SearchableFilterPopover = ({ const allRealValuesChecked = realOptions.length > 0 && realOptions.every((o) => next.includes(o.value)) - applyValues( - allRealValuesChecked - ? next.includes(NOT_SET_VALUE) - ? [ANY_VALUE_KEY, NOT_SET_VALUE] - : [ANY_VALUE_KEY] - : next - ) + applyValues(getRealValueToggleResult(next, allRealValuesChecked)) } // Reverses every checkbox's *effective* ticked state, not just literal @@ -407,15 +455,13 @@ const SearchableFilterPopover = ({ // text would apply to the table's rows (>, <, ranges, ...), so what's // checked here always matches what "Use filter" would actually select - // a plain substring match wouldn't understand "> 100" against "150". - const filteredOptions = !trimmedSearch - ? realOptions - : type === 'number' - ? realOptions.filter(({ value }) => - numericFilter(Number(value), trimmedSearch) - ) - : realOptions.filter(({ value }) => - resolveLabel(value).toLowerCase().includes(normalizedSearch) - ) + const filteredOptions = getFilteredOptions({ + realOptions, + trimmedSearch, + normalizedSearch, + type, + resolveLabel, + }) const hasExactMatch = filteredOptions.some( ({ value }) => resolveLabel(value).toLowerCase() === normalizedSearch ) @@ -481,6 +527,29 @@ const SearchableFilterPopover = ({ } } + // The custom-filter row (when shown) sits first, matching its visual + // position above the checkbox list. It's usually already applied live + // by this point (see onSearchChange) - toggling it again here is a + // no-op. + const onEnterKey = () => { + if (highlightedIndex === -1) { + if (showCustomFilterRow) { + applyCustomFilter(searchText.trim()) + } + return + } + if (showCustomFilterRow && highlightedIndex === 0) { + applyCustomFilter(searchText.trim()) + return + } + const optionIndex = showCustomFilterRow + ? highlightedIndex - 1 + : highlightedIndex + if (optionIndex >= 0 && optionIndex < filteredOptions.length) { + toggleValue(filteredOptions[optionIndex].value) + } + } + const onSearchKeyDown = (_, event) => { switch (event.key) { case 'ArrowDown': @@ -501,32 +570,11 @@ const SearchableFilterPopover = ({ return next }) break - case 'Enter': { + case 'Enter': event.preventDefault() - // The custom-filter row (when shown) sits first, matching - // its visual position above the checkbox list. It's - // usually already applied live by this point (see - // onSearchChange) - toggling it again here is a no-op. - if (highlightedIndex === -1) { - if (showCustomFilterRow) { - applyCustomFilter(searchText.trim()) - } - } else if (showCustomFilterRow && highlightedIndex === 0) { - applyCustomFilter(searchText.trim()) - } else { - const optionIndex = showCustomFilterRow - ? highlightedIndex - 1 - : highlightedIndex - if ( - optionIndex >= 0 && - optionIndex < filteredOptions.length - ) { - toggleValue(filteredOptions[optionIndex].value) - } - } + onEnterKey() closePopover() break - } case 'Escape': event.preventDefault() closePopover() @@ -540,11 +588,12 @@ const SearchableFilterPopover = ({ // applied filter text, or empty so the "Search" placeholder shows). // Open, it's a live, editable search/filter field - the same input // serves both roles instead of a button revealing a separate one. - const displayValue = isOpen - ? searchText - : selected.length - ? i18n.t('{{count}} selected', { count: selected.length }) - : appliedString + const displayValue = getDisplayValue({ + isOpen, + searchText, + selected, + appliedString, + }) const mainInput = ( <Input diff --git a/src/components/datatable/styles/DataTable.module.css b/src/components/datatable/styles/DataTable.module.css index 39d96c664b..e23fd77950 100644 --- a/src/components/datatable/styles/DataTable.module.css +++ b/src/components/datatable/styles/DataTable.module.css @@ -10,10 +10,10 @@ DataTable instance in the app). That border (not just the top side) is what makes the browser compute the sticky header's normal-flow and stuck positions on very slightly different subpixel grids, causing it to - visibly snap by ~1-2px the instant scrolling engages position:sticky. - Removing the table's own border entirely (confirmed via devtools) removes - the snap; row/column delineation still comes from each cell's own - border-bottom/border-inline-end. */ + visibly snap by roughly 1-2 pixels the instant scrolling engages the + sticky positioning. Removing the table's own border entirely (confirmed + via devtools) removes the snap; row and column delineation still comes + from each cell's own bottom and inline-end borders. */ .dataTable[data-test='dhis2-uicore-datatable'] { border: none !important; } diff --git a/src/components/datatable/styles/FilterInput.module.css b/src/components/datatable/styles/FilterInput.module.css index 28f2e3e54a..449414eca8 100644 --- a/src/components/datatable/styles/FilterInput.module.css +++ b/src/components/datatable/styles/FilterInput.module.css @@ -59,13 +59,17 @@ fall back to DOM order, which would put "Any value"/"No value" closest to the input instead of the custom-filter row - giving each group its own order keeps the same relative arrangement as the non-flipped case, - just mirrored. */ + just mirrored. This also flips which edge of the pinned-options group + gets the divider: it should always separate that group from the real + value list, regardless of which one ends up visually on top. */ .reversedOrder .multiSelectPopover { order: 0; } .reversedOrder .pinnedOptions { order: 1; + border-bottom: none; + border-top: 1px solid var(--colors-grey300); } .reversedOrder .customFilterRow { @@ -128,16 +132,6 @@ cursor: not-allowed; } -/* When the dropdown opens above the trigger, this group sits at the - bottom of the stack (closest to the input - see the order swap above), - so the divider needs to move to its top edge instead: it should always - separate the pinned group from the real value list, regardless of - which one is visually on top. */ -.reversedOrder .pinnedOptions { - border-bottom: none; - border-top: 1px solid var(--colors-grey300); -} - .multiSelectPopover { max-height: 260px; overflow-y: auto; @@ -202,7 +196,7 @@ .customFilterExpr { font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', 'Consolas', monospace; font-weight: 600; - word-break: break-word; + overflow-wrap: anywhere; } .multiSelectPopover .highlighted { diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index c65aaad2b7..d7f1355798 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -217,6 +217,85 @@ const EMPTY_AGGREGATIONS = {} const EMPTY_LAYER = {} const EMPTY_COLUMN_OPTIONS = {} +// Distinct values are stored as strings (they're sourced alongside string +// columns) - sort numeric columns by numeric value rather than lexically +// (so 2 sorts before 10), everything else keeps the default string +// ordering. NOT_SET_VALUE always sorts first regardless of column type. +const compareColumnOptionValues = (a, b, type) => { + if (a === NOT_SET_VALUE) { + return -1 + } + if (b === NOT_SET_VALUE) { + return 1 + } + if (type === TYPE_NUMBER) { + return Number(a) - Number(b) + } + if (a < b) { + return -1 + } + if (a > b) { + return 1 + } + return 0 +} + +// Ascending (the default on first click) puts selected rows first, since +// that's what a user clicking this is after. +const compareBySelected = (a, b, { selectedIdSet, sortDirection }) => { + const aSelected = selectedIdSet?.has(a.id) ? 1 : 0 + const bSelected = selectedIdSet?.has(b.id) ? 1 : 0 + return sortDirection === ASCENDING + ? bSelected - aSelected + : aSelected - bSelected +} + +const compareRangeValues = (aVal, bVal, sortDirection) => { + const [aStart, aEnd] = parseRange(aVal) + const [bStart, bEnd] = parseRange(bVal) + const startDiff = + sortDirection === ASCENDING ? aStart - bStart : bStart - aStart + if (startDiff !== 0) { + return startDiff + } + return sortDirection === ASCENDING ? aEnd - bEnd : bEnd - aEnd +} + +const compareFieldValues = (aVal, bVal, { sortField, sortDirection }) => { + // All undefined values should be sorted to the end + if (aVal === undefined && bVal === undefined) { + return 0 + } + if (aVal === undefined) { + return 1 + } + if (bVal === undefined) { + return -1 + } + if (typeof aVal === TYPE_NUMBER) { + return sortDirection === ASCENDING ? aVal - bVal : bVal - aVal + } + if (sortField === RANGE) { + return compareRangeValues(aVal, bVal, sortDirection) + } + // TODO: Make sure sorting works across different locales + return sortDirection === ASCENDING + ? aVal.localeCompare(bVal) + : bVal.localeCompare(aVal) +} + +const compareRows = (a, b, options) => { + const { sortField } = options + // "None" (third click of the cycle) - fall back to natural order + if (!sortField) { + return a.index - b.index + } + if (sortField === SELECTED_SORT_KEY) { + return compareBySelected(a, b, options) + } + return compareFieldValues(a[sortField], b[sortField], options) +} + export const useTableData = ({ layer, sortField, @@ -379,26 +458,7 @@ export const useTableData = ({ if (seen.size > 0) { result[dataKey] = Array.from(seen) - .sort((a, b) => { - if (a === NOT_SET_VALUE) { - return -1 - } - if (b === NOT_SET_VALUE) { - return 1 - } - // Distinct values are stored as strings (they're - // sourced alongside string columns) - sort numeric - // columns by numeric value rather than lexically - // (so 2 sorts before 10), everything else keeps the - // default string ordering. - return type === TYPE_NUMBER - ? Number(a) - Number(b) - : a < b - ? -1 - : a > b - ? 1 - : 0 - }) + .sort((a, b) => compareColumnOptionValues(a, b, type)) .map((value) => ({ value })) } }) @@ -445,60 +505,9 @@ export const useTableData = ({ } //sort - filteredData.sort((a, b) => { - // "None" (third click of the cycle) - fall back to natural order - if (!sortField) { - return a.index - b.index - } - - if (sortField === SELECTED_SORT_KEY) { - const aSelected = selectedIdSet?.has(a.id) ? 1 : 0 - const bSelected = selectedIdSet?.has(b.id) ? 1 : 0 - // Ascending (the default on first click) puts selected rows - // first, since that's what a user clicking this is after. - return sortDirection === ASCENDING - ? bSelected - aSelected - : aSelected - bSelected - } - - const aVal = a[sortField] - const bVal = b[sortField] - - // All undefined values should be sorted to the end - if (aVal === undefined && bVal === undefined) { - return 0 - } - - if (aVal === undefined) { - return 1 // aVal goes to end - } - - if (bVal === undefined) { - return -1 // bVal goes to end - } - - if (typeof aVal === TYPE_NUMBER) { - return sortDirection === ASCENDING ? aVal - bVal : bVal - aVal - } - - if (sortField === RANGE) { - const [aStart, aEnd] = parseRange(aVal) - const [bStart, bEnd] = parseRange(bVal) - const startDiff = - sortDirection === ASCENDING - ? aStart - bStart - : bStart - aStart - if (startDiff !== 0) { - return startDiff - } - return sortDirection === ASCENDING ? aEnd - bEnd : bEnd - aEnd - } - - // TODO: Make sure sorting works across different locales - return sortDirection === ASCENDING - ? aVal.localeCompare(bVal) - : bVal.localeCompare(aVal) - }) + filteredData.sort((a, b) => + compareRows(a, b, { sortField, sortDirection, selectedIdSet }) + ) return filteredData.map((item) => headers.map(({ dataKey, roundFn, type }) => { @@ -531,11 +540,12 @@ export const useTableData = ({ const isExtendingEvents = layerType === EVENT_LAYER && !layer.isExtended && !serverCluster const isLoading = isLoadingAggregations || isExtendingEvents - const loadingReason = isLoadingAggregations - ? i18n.t('Loading Earth Engine data…') - : isExtendingEvents - ? i18n.t('Loading additional events…') - : null + let loadingReason = null + if (isLoadingAggregations) { + loadingReason = i18n.t('Loading Earth Engine data…') + } else if (isExtendingEvents) { + loadingReason = i18n.t('Loading additional events…') + } const totalCount = dataWithAggregations?.length ?? 0 const filteredCount = rows?.length ?? 0 From f9f22cea50db47cb903f13a7cff9cb679c04bfa2 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 14 Jul 2026 23:07:39 +0200 Subject: [PATCH 038/205] chore: sonarqube issues --- src/components/datatable/FilterInput.jsx | 65 ++++++++++++------- .../datatable/styles/DataTable.module.css | 23 +++---- 2 files changed, 53 insertions(+), 35 deletions(-) diff --git a/src/components/datatable/FilterInput.jsx b/src/components/datatable/FilterInput.jsx index 2556c2964e..f49d77d756 100644 --- a/src/components/datatable/FilterInput.jsx +++ b/src/components/datatable/FilterInput.jsx @@ -270,6 +270,43 @@ const getDisplayValue = ({ isOpen, searchText, selected, appliedString }) => { return appliedString } +// Widget state is derived straight from the applied `filterValue` (never +// tracked in parallel) - an array means a multi-select filter, a string +// means a custom typed one, anything else (no filter applied) is neither. +const getSelectedAndAppliedString = (filterValue) => ({ + selected: Array.isArray(filterValue) ? filterValue : [], + appliedString: typeof filterValue === 'string' ? filterValue : '', +}) + +// Every column's filter trigger sits in the same header row, so this +// resolves to the same answer for all of them - below by default, or above +// if the row doesn't have room to open the dropdown downward (e.g. the +// table is short, or the page is scrolled so the row sits near the bottom +// of the viewport). That single choice is what keeps every column's +// dropdown opening on the same side. The help tooltip always takes the +// opposite side, so the two never compete for space. +const getDropdownPlacement = (anchorRect) => { + const dropdownSide = + anchorRect != null && + window.innerHeight - anchorRect.bottom < ESTIMATED_POPOVER_HEIGHT + ? 'top' + : 'bottom' + return { + dropdownSide, + dropdownPlacement: `${dropdownSide}-start`, + tooltipPlacement: dropdownSide === 'top' ? 'bottom' : 'top', + } +} + +// Every value "Reverse selection" can flip - the column's full value +// domain, not just whatever the current search happens to narrow the list +// down to (search is for finding/toggling individual values, not for +// scoping a bulk action). +const getInvertibleValues = (hasNotSetOption, realOptions) => + hasNotSetOption + ? [NOT_SET_VALUE, ...realOptions.map((o) => o.value)] + : realOptions.map((o) => o.value) + // Shared popover UI — label resolution is injected so it never needs to // know whether it's an option-set column or a plain categorical one. // State is derived straight from the applied `filterValue` (never tracked @@ -293,8 +330,7 @@ const SearchableFilterPopover = ({ const [searchText, setSearchText] = useState('') const [highlightedIndex, setHighlightedIndex] = useState(-1) - const selected = Array.isArray(filterValue) ? filterValue : [] - const appliedString = typeof filterValue === 'string' ? filterValue : '' + const { selected, appliedString } = getSelectedAndAppliedString(filterValue) const openPopover = () => { setSearchText(appliedString) @@ -310,21 +346,8 @@ const SearchableFilterPopover = ({ // table resize, when the popover is closed anyway. const anchorRect = anchorRef.current?.getBoundingClientRect() const anchorWidth = anchorRect?.width - - // Every column's filter trigger sits in the same header row, so this - // resolves to the same answer for all of them - below by default, or - // above if the row doesn't have room to open the dropdown downward - // (e.g. the table is short, or the page is scrolled so the row sits - // near the bottom of the viewport). That single choice is what keeps - // every column's dropdown opening on the same side. The help tooltip - // always takes the opposite side, so the two never compete for space. - const dropdownSide = - anchorRect != null && - window.innerHeight - anchorRect.bottom < ESTIMATED_POPOVER_HEIGHT - ? 'top' - : 'bottom' - const dropdownPlacement = `${dropdownSide}-start` - const tooltipPlacement = dropdownSide === 'top' ? 'bottom' : 'top' + const { dropdownPlacement, dropdownSide, tooltipPlacement } = + getDropdownPlacement(anchorRect) const applyValues = (next) => next.length @@ -362,13 +385,7 @@ const SearchableFilterPopover = ({ applyValues(getAnyValueToggleResult(anyValueActive, keepNotSet)) } - // Every value "Reverse selection" can flip - the column's full value - // domain, not just whatever the current search happens to narrow the - // list down to (search is for finding/toggling individual values, not - // for scoping a bulk action). - const invertibleValues = hasNotSetOption - ? [NOT_SET_VALUE, ...realOptions.map((o) => o.value)] - : realOptions.map((o) => o.value) + const invertibleValues = getInvertibleValues(hasNotSetOption, realOptions) // A real value's checkbox is ticked either because it's individually // selected, or because "Any value" is active (which stands for "every diff --git a/src/components/datatable/styles/DataTable.module.css b/src/components/datatable/styles/DataTable.module.css index e23fd77950..551233a271 100644 --- a/src/components/datatable/styles/DataTable.module.css +++ b/src/components/datatable/styles/DataTable.module.css @@ -3,17 +3,18 @@ } /* @dhis2/ui's Table draws a 1px border on all sides via its own scoped - styled-jsx rule, which our .dataTable class alone can't reliably - out-specificity. Pairing it with the table's stable data-test attribute - (with !important) guarantees this wins, while staying scoped to just this - table via .dataTable (a bare data-test selector would match every - DataTable instance in the app). That border (not just the top side) is - what makes the browser compute the sticky header's normal-flow and stuck - positions on very slightly different subpixel grids, causing it to - visibly snap by roughly 1-2 pixels the instant scrolling engages the - sticky positioning. Removing the table's own border entirely (confirmed - via devtools) removes the snap; row and column delineation still comes - from each cell's own bottom and inline-end borders. */ + styled-jsx rule, which the local dataTable class name alone can't + reliably out-specificity. Pairing that class name with the table's + stable data-test attribute (marked important) guarantees this wins, + while staying scoped to just this table (a bare attribute selector on + its own would match every DataTable instance in the app). That full + border (not just the top side) is what makes the browser compute the + sticky header's normal-flow and stuck positions on very slightly + different subpixel grids, causing a visible snap of roughly 1-2 pixels + the instant scrolling engages sticky positioning. Removing the table's + own border entirely (confirmed via devtools) removes the snap; row and + column delineation still comes from each cell's own bottom and + inline-end borders. */ .dataTable[data-test='dhis2-uicore-datatable'] { border: none !important; } From 62d0b31a15293cff9cda58819b0b8e96b109cc05 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 14 Jul 2026 23:10:29 +0200 Subject: [PATCH 039/205] chore: sonarqube issue --- .../datatable/styles/DataTable.module.css | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/components/datatable/styles/DataTable.module.css b/src/components/datatable/styles/DataTable.module.css index 551233a271..75ab48f582 100644 --- a/src/components/datatable/styles/DataTable.module.css +++ b/src/components/datatable/styles/DataTable.module.css @@ -2,19 +2,6 @@ height: 1px; } -/* @dhis2/ui's Table draws a 1px border on all sides via its own scoped - styled-jsx rule, which the local dataTable class name alone can't - reliably out-specificity. Pairing that class name with the table's - stable data-test attribute (marked important) guarantees this wins, - while staying scoped to just this table (a bare attribute selector on - its own would match every DataTable instance in the app). That full - border (not just the top side) is what makes the browser compute the - sticky header's normal-flow and stuck positions on very slightly - different subpixel grids, causing a visible snap of roughly 1-2 pixels - the instant scrolling engages sticky positioning. Removing the table's - own border entirely (confirmed via devtools) removes the snap; row and - column delineation still comes from each cell's own bottom and - inline-end borders. */ .dataTable[data-test='dhis2-uicore-datatable'] { border: none !important; } From 757a19b0e32366bfe3bcf518a26ff4386c1b4f9e Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Wed, 15 Jul 2026 00:03:42 +0200 Subject: [PATCH 040/205] fix: handle narrow columns and hide index column --- i18n/en.pot | 10 ++-- src/components/datatable/DataTable.jsx | 26 ++++++---- src/components/datatable/FilterInput.jsx | 14 +++-- .../datatable/__tests__/DataTable.spec.jsx | 6 +-- .../datatable/__tests__/useTableData.spec.jsx | 52 +++++++------------ .../datatable/styles/DataTable.module.css | 10 +++- src/components/datatable/useTableData.js | 32 ++++-------- 7 files changed, 70 insertions(+), 80 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index eb9354f374..8440e8cdce 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-14T20:24:31.895Z\n" -"PO-Revision-Date: 2026-07-14T20:24:31.895Z\n" +"POT-Creation-Date: 2026-07-14T21:42:31.767Z\n" +"PO-Revision-Date: 2026-07-14T21:42:31.767Z\n" msgid "2020" msgstr "2020" @@ -296,9 +296,6 @@ msgstr "" msgid "No valid data fields were found for this layer." msgstr "No valid data fields were found for this layer." -msgid "Index" -msgstr "Index" - msgid "Id" msgstr "Id" @@ -1797,6 +1794,9 @@ msgstr "16-day" msgid "Since February 2000" msgstr "Since February 2000" +msgid "Index" +msgstr "Index" + msgid "NDVI" msgstr "NDVI" diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index 7243701117..30c550141f 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -9,7 +9,6 @@ import { ComponentCover, CenteredContent, CircularLoader, - Popover, Popper, Portal, IconSync16, @@ -43,7 +42,10 @@ import { formatWithSeparator } from '../../util/numbers.js' import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' import Checkbox from '../core/Checkbox.jsx' import { SortIcon } from '../core/icons.jsx' -import FilterInput from './FilterInput.jsx' +import FilterInput, { + FilterDropdownPopover, + getDropdownPlacement, +} from './FilterInput.jsx' import styles from './styles/DataTable.module.css' import TableContextMenu from './TableContextMenu.jsx' import { useTableData, SELECTED_SORT_KEY } from './useTableData.js' @@ -55,11 +57,7 @@ const SELECTION_FILTER_OPTIONS = [ // Every filterable column dispatches its dataFilters value straight through // to filterData against each layer's real feature properties (see -// ThematicLayer.jsx/EventLayer.jsx/etc.). Index is a synthetic row number -// computed only for table display (see useTableData.js), never present on -// the underlying feature data - filtering by it narrows the table but -// can't affect the map. Still shown: it's a useful table-only tool (e.g. -// narrowing to a row-number range) even though it doesn't reach the map. +// ThematicLayer.jsx/EventLayer.jsx/etc.). export const isFilterable = (dataKey, type) => !!type // Inverts selection scoped to the currently-filtered/visible rows only, @@ -90,6 +88,13 @@ const SelectionFilterButton = ({ value, onChange }) => { ? i18n.t('All') : i18n.t('{{count}} selected', { count: value.length }) + // Sits in the same sticky header row as every other column's FilterInput + // dropdown, so computing its placement the same way (rather than using + // @dhis2/ui's Popover, which flips independently based on its own + // available space) keeps it opening on the same side as the rest. + const anchorRect = anchorRef.current?.getBoundingClientRect() + const { dropdownPlacement } = getDropdownPlacement(anchorRect) + return ( <> <button @@ -102,10 +107,9 @@ const SelectionFilterButton = ({ value, onChange }) => { {buttonLabel} </button> {isOpen && ( - <Popover + <FilterDropdownPopover reference={anchorRef} - placement="bottom-start" - arrow={false} + placement={dropdownPlacement} onClickOutside={() => setIsOpen(false)} > <div className={styles.selectionFilterPopover}> @@ -119,7 +123,7 @@ const SelectionFilterButton = ({ value, onChange }) => { /> ))} </div> - </Popover> + </FilterDropdownPopover> )} </> ) diff --git a/src/components/datatable/FilterInput.jsx b/src/components/datatable/FilterInput.jsx index f49d77d756..f8150cca4e 100644 --- a/src/components/datatable/FilterInput.jsx +++ b/src/components/datatable/FilterInput.jsx @@ -40,6 +40,11 @@ const MAX_LIST_HEIGHT = 260 // choice that applies to the whole row. const ESTIMATED_POPOVER_HEIGHT = MAX_LIST_HEIGHT + 80 +// Floor for the dropdown's width - narrow columns still need enough room +// for the checkbox list/search text to be usable, so the popover shouldn't +// shrink down to match a very narrow trigger's own width. +const MIN_POPOVER_WIDTH = 140 + // Rough content heights (in px) for the two tooltip variants, used only to // decide whether there's enough room to show the tooltip at all - see // FilterHelpTooltip's hasRoom check. @@ -183,7 +188,7 @@ const dropdownModifiers = [ // for positioning, flip disabled - so `placement` is always honored exactly; // the caller (SearchableFilterPopover) computes one placement per render // from the shared header row's position, so every column's dropdown agrees. -const FilterDropdownPopover = ({ +export const FilterDropdownPopover = ({ reference, placement, onClickOutside, @@ -285,7 +290,7 @@ const getSelectedAndAppliedString = (filterValue) => ({ // of the viewport). That single choice is what keeps every column's // dropdown opening on the same side. The help tooltip always takes the // opposite side, so the two never compete for space. -const getDropdownPlacement = (anchorRect) => { +export const getDropdownPlacement = (anchorRect) => { const dropdownSide = anchorRect != null && window.innerHeight - anchorRect.bottom < ESTIMATED_POPOVER_HEIGHT @@ -663,7 +668,10 @@ const SearchableFilterPopover = ({ })} style={{ minWidth: anchorWidth - ? `${anchorWidth}px` + ? `${Math.max( + anchorWidth, + MIN_POPOVER_WIDTH + )}px` : undefined, }} > diff --git a/src/components/datatable/__tests__/DataTable.spec.jsx b/src/components/datatable/__tests__/DataTable.spec.jsx index 347a57d60a..30629f1090 100644 --- a/src/components/datatable/__tests__/DataTable.spec.jsx +++ b/src/components/datatable/__tests__/DataTable.spec.jsx @@ -113,11 +113,7 @@ describe('getNextSorting', () => { }) describe('isFilterable', () => { - test('allows the Index column - it filters the table by row-number range even though it cannot narrow the map', () => { - expect(isFilterable('index', 'number')).toBe(true) - }) - - test('allows other numeric and string columns, which are real feature properties', () => { + test('allows numeric and string columns', () => { expect(isFilterable('rawValue', 'number')).toBe(true) expect(isFilterable('name', 'string')).toBe(true) }) diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index 881e591cf4..d0f3bf08c9 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -48,17 +48,15 @@ describe('useTableData headers', () => { ) const { headers, rows, isLoading } = result.current - expect(headers).toHaveLength(4) + expect(headers).toHaveLength(3) expect(headers).toMatchObject([ - { name: 'Index', dataKey: 'index', type: 'number' }, { name: 'Name', dataKey: 'name', type: 'string' }, { name: 'Id', dataKey: 'id', type: 'string' }, { name: 'Type', dataKey: 'type', type: 'string' }, ]) expect(rows).toHaveLength(1) - expect(rows[0]).toHaveLength(4) + expect(rows[0]).toHaveLength(3) expect(rows[0]).toMatchObject([ - { value: 0, dataKey: 'index' }, { value: 'Facility 1', dataKey: 'name' }, { value: 'facility-1', dataKey: 'id' }, { value: 'Point', dataKey: 'type' }, @@ -100,9 +98,8 @@ describe('useTableData headers', () => { } ) const { headers, rows, isLoading } = result.current - expect(headers).toHaveLength(6) + expect(headers).toHaveLength(5) expect(headers).toMatchObject([ - { name: 'Index', dataKey: 'index', type: 'number' }, { name: 'Name', dataKey: 'name', type: 'string' }, { name: 'Id', dataKey: 'id', type: 'string' }, { name: 'Level', dataKey: 'level', type: 'number' }, @@ -110,9 +107,8 @@ describe('useTableData headers', () => { { name: 'Type', dataKey: 'type', type: 'string' }, ]) expect(rows).toHaveLength(1) - expect(rows[0]).toHaveLength(6) + expect(rows[0]).toHaveLength(5) expect(rows[0]).toMatchObject([ - { value: 0, dataKey: 'index' }, { value: 'OrgUnitName 1', dataKey: 'name' }, { value: 'orgunit-id-1', dataKey: 'id' }, { value: 3, dataKey: 'level' }, @@ -160,9 +156,8 @@ describe('useTableData headers', () => { } ) const { headers, rows, isLoading } = result.current - expect(headers).toHaveLength(10) + expect(headers).toHaveLength(9) expect(headers).toMatchObject([ - { name: 'Index', dataKey: 'index', type: 'number' }, { name: 'Name', dataKey: 'name', type: 'string' }, { name: 'Id', dataKey: 'id', type: 'string' }, { name: 'Value', dataKey: 'rawValue', type: 'number' }, @@ -179,9 +174,8 @@ describe('useTableData headers', () => { }, ]) expect(rows).toHaveLength(1) - expect(rows[0]).toHaveLength(10) + expect(rows[0]).toHaveLength(9) expect(rows[0]).toMatchObject([ - { value: 0, dataKey: 'index' }, { value: 'Ngelehun CHC', dataKey: 'name' }, { value: 'thematicId-1', dataKey: 'id' }, { value: 106.3, dataKey: 'rawValue' }, @@ -262,9 +256,8 @@ describe('useTableData headers', () => { } ) const { headers, rows, isLoading } = result.current - expect(headers).toHaveLength(8) + expect(headers).toHaveLength(7) expect(headers).toMatchObject([ - { name: 'Index', dataKey: 'index', type: 'number' }, { name: 'Org unit', dataKey: 'ouname', type: 'string' }, { name: 'Id', dataKey: 'id', type: 'string' }, { @@ -279,9 +272,8 @@ describe('useTableData headers', () => { { name: 'Type', dataKey: 'type', type: 'string' }, ]) expect(rows).toHaveLength(1) - expect(rows[0]).toHaveLength(8) + expect(rows[0]).toHaveLength(7) expect(rows[0]).toMatchObject([ - { value: 0, dataKey: 'index' }, { value: 'Lumley Hospital', dataKey: 'ouname' }, { value: 'a9712323629', dataKey: 'id' }, { value: '2023-05-15 00:00:00.0', dataKey: 'eventdate' }, @@ -442,9 +434,8 @@ describe('useTableData headers', () => { ) const { headers, rows, isLoading } = result.current - expect(headers).toHaveLength(6) + expect(headers).toHaveLength(5) expect(headers).toMatchObject([ - { name: 'Index', dataKey: 'index', type: 'number' }, { name: 'Name', dataKey: 'name', type: 'string' }, { name: 'Id', dataKey: 'id', type: 'string' }, { name: 'Type', dataKey: 'type', type: 'string' }, @@ -461,12 +452,11 @@ describe('useTableData headers', () => { type: 'number', }, ]) + expect(headers[3].roundFn).toBeInstanceOf(Function) expect(headers[4].roundFn).toBeInstanceOf(Function) - expect(headers[5].roundFn).toBeInstanceOf(Function) expect(rows).toHaveLength(2) - expect(rows[0]).toHaveLength(6) + expect(rows[0]).toHaveLength(5) expect(rows[0]).toMatchObject([ - { value: 0, dataKey: 'index' }, { value: 'Bo', dataKey: 'name' }, { value: 'boOu', dataKey: 'id' }, { value: 'Polygon', dataKey: 'type' }, @@ -599,9 +589,8 @@ describe('useTableData headers', () => { ) const { headers, rows, isLoading } = result.current - expect(headers).toHaveLength(6) + expect(headers).toHaveLength(5) expect(headers).toMatchObject([ - { name: 'Index', dataKey: 'index', type: 'number' }, { name: 'Name', dataKey: 'name', type: 'string' }, { name: 'Id', dataKey: 'id', type: 'string' }, { name: 'Type', dataKey: 'type', type: 'string' }, @@ -618,12 +607,11 @@ describe('useTableData headers', () => { type: 'number', }, ]) + expect(headers[3].roundFn).toBeInstanceOf(Function) expect(headers[4].roundFn).toBeInstanceOf(Function) - expect(headers[5].roundFn).toBeInstanceOf(Function) expect(rows).toHaveLength(2) - expect(rows[0]).toHaveLength(6) + expect(rows[0]).toHaveLength(5) expect(rows[0]).toMatchObject([ - { value: 0, dataKey: 'index' }, { value: 'Badija', dataKey: 'name' }, { value: 'boOU', dataKey: 'id' }, { value: 'Polygon', dataKey: 'type' }, @@ -666,7 +654,7 @@ describe('useTableData sorting', () => { } ) - const valueColumn = result.current.rows.map((row) => row[3]?.value) // Value column + const valueColumn = result.current.rows.map((row) => row[2]?.value) // Value column expect(valueColumn).toEqual([5, 10, 15, null, null]) }) @@ -688,7 +676,7 @@ describe('useTableData sorting', () => { } ) - const valueColumn = result.current.rows.map((row) => row[3]?.value) // Value column + const valueColumn = result.current.rows.map((row) => row[2]?.value) // Value column expect(valueColumn).toEqual([15, 10, 5, null, null]) }) @@ -722,7 +710,7 @@ describe('useTableData sorting', () => { } ) - const nameColumn = result.current.rows.map((row) => row[1]?.value) // Name column + const nameColumn = result.current.rows.map((row) => row[0]?.value) // Name column expect(nameColumn).toEqual(['Apple', 'Banana', 'Zebra', undefined]) }) @@ -756,7 +744,7 @@ describe('useTableData sorting', () => { } ) - const nameColumn = result.current.rows.map((row) => row[1]?.value) // Name column + const nameColumn = result.current.rows.map((row) => row[0]?.value) // Name column expect(nameColumn).toEqual(['Zebra', 'Banana', 'Apple', undefined]) }) @@ -796,7 +784,7 @@ describe('useTableData sorting', () => { } ) - const valueColumn = result.current.rows.map((row) => row[3]?.value) // Value column + const valueColumn = result.current.rows.map((row) => row[2]?.value) // Value column expect(valueColumn).toEqual([5, 10, null, null]) }) @@ -838,7 +826,7 @@ describe('useTableData sorting', () => { } ) - const valueColumn = result.current.rows.map((row) => row[3]?.value) // Value column + const valueColumn = result.current.rows.map((row) => row[2]?.value) // Value column expect(valueColumn).toEqual([null, null, null]) }) diff --git a/src/components/datatable/styles/DataTable.module.css b/src/components/datatable/styles/DataTable.module.css index 75ab48f582..38b6a32cf5 100644 --- a/src/components/datatable/styles/DataTable.module.css +++ b/src/components/datatable/styles/DataTable.module.css @@ -75,9 +75,16 @@ td.checkboxCell { white-space: nowrap; } +/* FilterDropdownPopover (Layer + Popper, see FilterInput.jsx) renders no + background/elevation of its own - unlike @dhis2/ui's Popover, which this + replaced, styling that is left entirely to the caller. */ .selectionFilterPopover { padding: var(--spacers-dp8); min-width: 140px; + background-color: var(--colors-white); + border-radius: 4px; + box-shadow: 0 4px 12px rgba(12, 14, 16, 0.15), + 0 0 0 1px rgba(12, 14, 16, 0.05); } .selectionFilterPopover :global(label) { @@ -93,7 +100,8 @@ td.hovered { background-color: var(--colors-blue100); } -.columnHeader > :global(span.container) { +.columnHeader > :global(span.container), +.checkboxCell > :global(span.container) { justify-content: space-between; } diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index d7f1355798..19ffa18786 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -36,12 +36,6 @@ const TYPE_NUMBER = 'number' const TYPE_STRING = 'string' const TYPE_DATE = 'date' -// The Index column is a synthetic row number computed only for table -// display (see the `index` assigned from array position in -// dataWithAggregations below) - it's never written onto the underlying -// layer's actual feature data, so it can't be used as a map filter (see -// DataTable.jsx, which excludes it from getting a FilterInput at all). -export const INDEX = 'index' const NAME = 'name' const ID = 'id' const VALUE = 'rawValue' @@ -82,7 +76,6 @@ const getErrorCodeText = (code) => { } const defaultFieldsMap = () => ({ - [INDEX]: { name: i18n.t('Index'), dataKey: INDEX, type: TYPE_NUMBER }, [NAME]: { name: i18n.t('Name'), dataKey: NAME, type: TYPE_STRING }, [ID]: { name: i18n.t('Id'), dataKey: ID, type: TYPE_STRING }, [LEVEL]: { name: i18n.t('Level'), dataKey: LEVEL, type: TYPE_NUMBER }, @@ -116,25 +109,16 @@ const defaultFieldsMap = () => ({ }) const getThematicHeaders = () => - [ - INDEX, - NAME, - ID, - VALUE, - LEGEND, - RANGE, - LEVEL, - PARENT_NAME, - TYPE, - COLOR, - ].map((field) => defaultFieldsMap()[field]) + [NAME, ID, VALUE, LEGEND, RANGE, LEVEL, PARENT_NAME, TYPE, COLOR].map( + (field) => defaultFieldsMap()[field] + ) const getEventHeaders = ({ layerHeaders = [], styleDataItem, countEventsOutsideOrgUnits, }) => { - const fields = [INDEX, OUNAME, ID, EVENTDATE].map( + const fields = [OUNAME, ID, EVENTDATE].map( (field) => defaultFieldsMap()[field] ) @@ -164,12 +148,12 @@ const getEventHeaders = ({ } const getOrgUnitHeaders = () => - [INDEX, NAME, ID, LEVEL, PARENT_NAME, TYPE].map( + [NAME, ID, LEVEL, PARENT_NAME, TYPE].map( (field) => defaultFieldsMap()[field] ) const getFacilityHeaders = () => - [INDEX, NAME, ID, TYPE].map((field) => defaultFieldsMap()[field]) + [NAME, ID, TYPE].map((field) => defaultFieldsMap()[field]) const toTitleCase = (str) => str.replace( @@ -205,7 +189,7 @@ const getEarthEngineHeaders = ({ aggregationType, legend, data }) => { }) } - return [INDEX, NAME, ID, TYPE] + return [NAME, ID, TYPE] .map((field) => defaultFieldsMap()[field]) .concat(customFields) } @@ -357,6 +341,8 @@ export const useTableData = ({ .map((d, index) => ({ ...(d.properties || d), ...aggregations[d.id], + // Row-order tie-breaker for compareRows when no sortField is + // set - not a real column, not shown or filterable in the table. index, })) // boundsDependency intentionally proxies mapBounds only while the toggle is on From 11604ed1804a77890a84be1ee720c7fa28a2e409 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 16 Jul 2026 10:47:34 +0200 Subject: [PATCH 041/205] fix: rollback merging range and color columns --- i18n/en.pot | 17 ++- src/components/datatable/BottomPanel.jsx | 26 +++- src/components/datatable/DataTable.jsx | 120 +++++------------- src/components/datatable/FilterInput.jsx | 8 +- src/components/datatable/ResizeHandle.jsx | 106 +++++++++------- .../datatable/styles/BottomPanel.module.css | 7 + .../datatable/styles/DataTable.module.css | 14 -- .../datatable/styles/ResizeHandle.module.css | 18 ++- 8 files changed, 149 insertions(+), 167 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 8440e8cdce..a39e400c3c 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-14T21:42:31.767Z\n" -"PO-Revision-Date: 2026-07-14T21:42:31.767Z\n" +"POT-Creation-Date: 2026-07-16T08:26:26.615Z\n" +"PO-Revision-Date: 2026-07-16T08:26:26.615Z\n" msgid "2020" msgstr "2020" @@ -167,6 +167,9 @@ msgstr "Restore" msgid "Collapse" msgstr "Collapse" +msgid "Highlight color" +msgstr "Highlight color" + msgid "Clear filters" msgstr "Clear filters" @@ -176,9 +179,6 @@ msgstr "Search all columns" msgid "Show only features in current map view" msgstr "Show only features in current map view" -msgid "Highlight color" -msgstr "Highlight color" - msgid "Close" msgstr "Close" @@ -232,8 +232,11 @@ msgstr "equal to 2 OR greater than 8" msgid "greater than 3 AND less than 8" msgstr "greater than 3 AND less than 8" -msgid "Select values, or type text to match rows that contain it." -msgstr "Select values, or type text to match rows that contain it." +msgid "Select values, or type text" +msgstr "Select values, or type text" + +msgid "to match rows that contain it" +msgstr "to match rows that contain it" msgid "Use filter" msgstr "Use filter" diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index a9630ab81d..6e62214be8 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -68,7 +68,8 @@ const BottomPanel = () => { const hasActiveFilters = Object.keys(dataFilters).length > 0 || globalSearch.trim() !== '' || - selectionFilter?.length > 0 + selectionFilter?.length > 0 || + showOnlyFeaturesInView const maxHeight = height - getCssVar('--header-height') - getCssVar('--toolbar-height') @@ -115,7 +116,12 @@ const BottomPanel = () => { dispatch(clearDataFilters(activeLayerId)) dispatch(setSelectionFilter([])) setGlobalSearch('') - }, [dispatch, activeLayerId]) + // toggleShowOnlyFeaturesInView flips the flag, so only dispatch it + // when the toggle is actually on - otherwise this would turn it on. + if (showOnlyFeaturesInView) { + dispatch(toggleShowOnlyFeaturesInView()) + } + }, [dispatch, activeLayerId, showOnlyFeaturesInView]) const onNameMouseEnter = useCallback(() => { const el = nameRef.current @@ -208,6 +214,7 @@ const BottomPanel = () => { )} </Tooltip> </button> + <span className={styles.divider} /> <span ref={nameRef} className={styles.layerName} @@ -233,6 +240,21 @@ const BottomPanel = () => { </div>, document.body )} + <span className={styles.divider} /> + <Tooltip content={i18n.t('Highlight color')} placement="top"> + <span className={styles.alignIcon2}> + <ColorPicker + className={styles.highlightColorPicker} + color={highlightColor} + width={22} + height={22} + centerIcon + onChange={(color) => + dispatch(setHighlightColor(color)) + } + /> + </span> + </Tooltip> <ResizeHandle maxHeight={maxHeight} minHeight={MIN_HEIGHT} diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index 30c550141f..20c085ec09 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -245,35 +245,6 @@ export const getRowClickAction = ( return null } -// Thematic layers merge their legend name + color into one swatch+name -// cell (see hasLegendColorPair); every other column is either the raw -// color's own cell (lowercased hex) or a plain formatted value. -const getCellContent = ({ - isLegendCell, - swatchColor, - value, - dataKey, - keyAnalysisDigitGroupSeparator, -}) => { - if (isLegendCell) { - return ( - <span className={styles.legendCell}> - {swatchColor && ( - <span - className={styles.legendSwatch} - style={{ backgroundColor: swatchColor }} - /> - )} - {value} - </span> - ) - } - if (dataKey === 'color') { - return value?.toLowerCase() - } - return formatWithSeparator(value, keyAnalysisDigitGroupSeparator) -} - const DataTableWithVirtuosoContext = ({ context, ...props }) => ( <DataTable {...props} @@ -688,15 +659,6 @@ const Table = ({ return <p className={styles.noSupport}>{error}</p> } - // Thematic layers carry both a `legend` (name) and `color` (hex) column; - // merge them into one swatch+name cell instead of two separate columns. - const hasLegendColorPair = - headers.some((h) => h.dataKey === 'legend') && - headers.some((h) => h.dataKey === 'color') - const visibleHeaders = hasLegendColorPair - ? headers.filter((h) => h.dataKey !== 'color') - : headers - return ( <> <TableVirtuoso @@ -770,7 +732,7 @@ const Table = ({ </TopTooltip> </div> </DataTableColumnHeader> - {visibleHeaders.map( + {headers.map( ({ name, dataKey, type, optionSet }, index) => ( <DataTableColumnHeader className={styles.columnHeader} @@ -863,53 +825,39 @@ const Table = ({ onClick={(e) => e.stopPropagation()} /> </DataTableCell> - {row - .filter( - ({ dataKey }) => - !hasLegendColorPair || - dataKey !== 'color' - ) - .map(({ dataKey, value, align }) => { - const isLegendCell = - hasLegendColorPair && - dataKey === 'legend' - const swatchColor = isLegendCell - ? row.find((c) => c.dataKey === 'color') - ?.value - : null - - return ( - <DataTableCell - key={`dtcell-${dataKey}`} - staticStyle - className={cx(styles.dataCell, { - [styles.lightText]: - !hasLegendColorPair && - dataKey === 'color' && - isDarkColor(value), - [styles.monoCell]: - dataKey === 'id', - [styles.selected]: isSelected, - [styles.hovered]: isHovered, - })} - backgroundColor={ - !hasLegendColorPair && - dataKey === 'color' - ? value - : null - } - align={align} - > - {getCellContent({ - isLegendCell, - swatchColor, - value, - dataKey, - keyAnalysisDigitGroupSeparator, - })} - </DataTableCell> - ) - })} + {row.map(({ dataKey, value, align }) => ( + <DataTableCell + key={`dtcell-${dataKey}`} + staticStyle + className={cx(styles.dataCell, { + [styles.lightText]: + dataKey === 'color' && + isDarkColor(value), + [styles.monoCell]: + dataKey === 'id' || + dataKey === 'color', + // The color cell's own background + // (below) is the whole point of that + // column - don't let hover/selection + // tint it out. + [styles.selected]: + isSelected && dataKey !== 'color', + [styles.hovered]: + isHovered && dataKey !== 'color', + })} + backgroundColor={ + dataKey === 'color' ? value : null + } + align={align} + > + {dataKey === 'color' + ? value?.toLowerCase() + : formatWithSeparator( + value, + keyAnalysisDigitGroupSeparator + )} + </DataTableCell> + ))} </> ) }} diff --git a/src/components/datatable/FilterInput.jsx b/src/components/datatable/FilterInput.jsx index f8150cca4e..ff3b30e184 100644 --- a/src/components/datatable/FilterInput.jsx +++ b/src/components/datatable/FilterInput.jsx @@ -49,7 +49,7 @@ const MIN_POPOVER_WIDTH = 140 // decide whether there's enough room to show the tooltip at all - see // FilterHelpTooltip's hasRoom check. const NUMERIC_HELP_HEIGHT = 140 -const TEXT_HELP_HEIGHT = 40 +const TEXT_HELP_HEIGHT = 56 const NUMERIC_FILTER_HELP = ( <div> @@ -64,7 +64,8 @@ const NUMERIC_FILTER_HELP = ( const TEXT_FILTER_HELP = ( <div> - {i18n.t('Select values, or type text to match rows that contain it.')} + <div>{i18n.t('Select values, or type text')}</div> + <div>{i18n.t('to match rows that contain it')}</div> </div> ) @@ -779,7 +780,8 @@ const SearchableFilterPopover = ({ onToggleRealValue(option.value) } className={cx( - dataKey === 'id' && + (dataKey === 'id' || + dataKey === 'color') && styles.monoOption, highlightedIndex === (showCustomFilterRow diff --git a/src/components/datatable/ResizeHandle.jsx b/src/components/datatable/ResizeHandle.jsx index 13985be16c..82da4ec76a 100644 --- a/src/components/datatable/ResizeHandle.jsx +++ b/src/components/datatable/ResizeHandle.jsx @@ -1,15 +1,16 @@ import PropTypes from 'prop-types' -import React from 'react' +import React, { useEffect, useRef } from 'react' import { IconDrag } from '../core/icons.jsx' import styles from './styles/ResizeHandle.module.css' -// Pre-decoded so setDragImage doesn't fall back to the macOS Chrome globe icon -// when the image isn't yet `complete` on the dragstart tick. -// https://www.sam.today/blog/html5-dnd-globe-icon -const EMPTY_DRAG_IMAGE = new Image(1, 1) -EMPTY_DRAG_IMAGE.src = - 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' - +// Pointer Events + setPointerCapture, not HTML5 drag-and-drop: a native drag +// session shows the browser's own drop-target cursor (grabbing/not-allowed/ +// move) instead of any CSS cursor rule, and elements with no drop handling +// of their own (e.g. the map's WebGL canvas) count as invalid drop targets - +// showing "not-allowed" for as long as the pointer is over them. Pointer +// capture sidesteps that protocol entirely, and keeps reporting move/up +// events to this element even if the pointer leaves it (or the window) +// mid-drag - more reliable than a plain mousemove/mouseup pair for that case. const ResizeHandle = ({ onResize, onResizeStart, @@ -17,63 +18,70 @@ const ResizeHandle = ({ minHeight = 50, maxHeight = 500, }) => { - let dragHeight = 0 - - const onDragStart = (evt) => { - // https://stackoverflow.com/questions/7680285/how-do-you-turn-off-setdragimage - if (EMPTY_DRAG_IMAGE.complete) { - evt.dataTransfer.setDragImage(EMPTY_DRAG_IMAGE, 0, 0) - } + const isDraggingRef = useRef(false) + + const getHeight = (clientY) => { + const height = window.innerHeight - clientY + return height < minHeight + ? minHeight + : height > maxHeight + ? maxHeight + : height + } - evt.dataTransfer.setData('text/plain', 'node') // Required to initialize dragging in Firefox + const onPointerDown = (evt) => { + evt.preventDefault() // avoid text selection while dragging + evt.currentTarget.setPointerCapture(evt.pointerId) + isDraggingRef.current = true onResizeStart?.() - - // https://stackoverflow.com/questions/23992091/drag-and-drop-directive-no-e-clientx-or-e-clienty-on-drag-event-in-firefox - document.ondragover = onDrag + // Set on both the handle and the body: the handle's own `cursor: + // grab` CSS rule otherwise beats body's *inherited* cursor while the + // pointer is over it, so body alone never actually shows grabbing + // here - only once the pointer strays over something with no cursor + // rule of its own (e.g. the map). + evt.currentTarget.style.cursor = 'grabbing' + document.body.style.cursor = 'grabbing' } - const onDrag = (evt) => { - const height = getHeight(evt || window.event) - - if (height && onResize) { - onResize(height) - dragHeight = height + const onPointerMove = (evt) => { + if (isDraggingRef.current) { + onResize?.(getHeight(evt.clientY)) } } - const onDragEnd = (evt) => { - const height = getHeight(evt) - - if (height && onResizeEnd) { - onResizeEnd(height) + const onPointerUp = (evt) => { + if (!isDraggingRef.current) { + return } - - document.ondragover = null + isDraggingRef.current = false + evt.currentTarget.releasePointerCapture(evt.pointerId) + evt.currentTarget.style.removeProperty('cursor') + document.body.style.removeProperty('cursor') + onResizeEnd?.(getHeight(evt.clientY)) } - const getHeight = (evt) => { - if (evt.pageY) { - const height = window.innerHeight - evt.pageY - dragHeight = - height < minHeight - ? minHeight - : height > maxHeight - ? maxHeight - : height - } - - return dragHeight - } + // In case the handle/panel unmounts mid-drag + useEffect( + () => () => { + if (isDraggingRef.current) { + document.body.style.removeProperty('cursor') + } + }, + [] + ) return ( <div className={styles.resizeHandle} - draggable={true} - onDragStart={(evt) => onDragStart(evt)} - onDragEnd={(evt) => onDragEnd(evt)} + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + onPointerCancel={onPointerUp} > - <IconDrag /> + <span className={styles.gripBox}> + <IconDrag /> + </span> </div> ) } diff --git a/src/components/datatable/styles/BottomPanel.module.css b/src/components/datatable/styles/BottomPanel.module.css index e95268f60a..d9e75af487 100644 --- a/src/components/datatable/styles/BottomPanel.module.css +++ b/src/components/datatable/styles/BottomPanel.module.css @@ -47,6 +47,13 @@ flex-shrink: 0; } +.divider { + width: 1px; + height: 20px; + background-color: var(--colors-grey300); + flex-shrink: 0; +} + @keyframes tooltipExpandRight { from { clip-path: inset(0 100% 0 0); diff --git a/src/components/datatable/styles/DataTable.module.css b/src/components/datatable/styles/DataTable.module.css index 38b6a32cf5..19eaf7cfce 100644 --- a/src/components/datatable/styles/DataTable.module.css +++ b/src/components/datatable/styles/DataTable.module.css @@ -29,20 +29,6 @@ td.monoCell { font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', 'Consolas', monospace; } -.legendCell { - display: flex; - align-items: center; - gap: var(--spacers-dp8); -} - -.legendSwatch { - flex-shrink: 0; - width: 10px; - height: 10px; - border-radius: 2px; - border: 1px solid var(--colors-grey400); -} - th.checkboxCell, td.checkboxCell { width: 76px; diff --git a/src/components/datatable/styles/ResizeHandle.module.css b/src/components/datatable/styles/ResizeHandle.module.css index 2bec5229a8..bec35a033f 100644 --- a/src/components/datatable/styles/ResizeHandle.module.css +++ b/src/components/datatable/styles/ResizeHandle.module.css @@ -9,12 +9,18 @@ cursor: grab; } -.resizeHandle:hover, -.resizeHandle:active { - background-color: var(--colors-grey300); - color: var(--colors-grey900); +.gripBox { + width: 99%; + height: 24px; + border-radius: 3px; + display: flex; + align-items: center; + justify-content: center; + color: var(--colors-grey600); } -.resizeHandle:active { - cursor: grabbing; +.resizeHandle:hover .gripBox, +.resizeHandle:active .gripBox { + background-color: var(--colors-grey200); + color: var(--colors-grey900); } From 3b282b5a5df03574c8b739d85d0d870ae197fc70 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 16 Jul 2026 13:35:05 +0200 Subject: [PATCH 042/205] chore: PR clean-up --- cypress/integration/dataTable.cy.js | 50 ++- i18n/en.pot | 15 +- src/components/datatable/BottomPanel.jsx | 2 - src/components/datatable/DataTable.jsx | 191 +++------ .../datatable/FilterDropdownPopover.jsx | 50 +++ src/components/datatable/FilterInput.jsx | 363 ++---------------- src/components/datatable/ResizeHandle.jsx | 13 - .../datatable/__tests__/DataTable.spec.jsx | 8 +- .../datatable/__tests__/FilterInput.spec.jsx | 50 +-- .../__tests__/TableContextMenu.spec.jsx | 2 - .../datatable/__tests__/useTableData.spec.jsx | 108 +++++- .../datatable/styles/BottomPanel.module.css | 8 +- .../datatable/styles/DataTable.module.css | 16 +- .../datatable/styles/FilterInput.module.css | 59 +-- src/components/datatable/useColumnWidths.js | 66 ++++ src/components/datatable/useRowSelection.js | 57 +++ src/components/datatable/useTableData.js | 114 +----- .../map/layers/__tests__/Layer.spec.js | 4 - src/constants/dataTable.js | 6 + src/constants/selection.js | 2 - src/util/__tests__/filter.spec.js | 11 +- src/util/__tests__/filterSelection.spec.js | 145 +++++++ src/util/__tests__/tableSort.spec.js | 190 +++++++++ src/util/filter.js | 12 +- src/util/filterSelection.js | 66 ++++ src/util/tableSort.js | 86 +++++ 26 files changed, 937 insertions(+), 757 deletions(-) create mode 100644 src/components/datatable/FilterDropdownPopover.jsx create mode 100644 src/components/datatable/useColumnWidths.js create mode 100644 src/components/datatable/useRowSelection.js create mode 100644 src/constants/dataTable.js create mode 100644 src/util/__tests__/filterSelection.spec.js create mode 100644 src/util/__tests__/tableSort.spec.js create mode 100644 src/util/filterSelection.js create mode 100644 src/util/tableSort.js diff --git a/cypress/integration/dataTable.cy.js b/cypress/integration/dataTable.cy.js index 741933dbd0..0bfffeaddf 100644 --- a/cypress/integration/dataTable.cy.js +++ b/cypress/integration/dataTable.cy.js @@ -36,7 +36,7 @@ const checkTableCell = ({ row = 0, column = 0, expectedContent }) => { } describe('data table', () => { - it('opens data table and filters and sorts', () => { + it('opens data table for a Thematic layer and filters and sorts', () => { const viewportHeight = Cypress.config('viewportHeight') const expectedBottoms1 = [viewportHeight] const expectedHeights1 = [ @@ -47,7 +47,7 @@ describe('data table', () => { cy.visit(`/#/${map.id}`) cy.get('canvas', EXTENDED_TIMEOUT).should('be.visible') - //check that the map resizes properly + // Check that the map resizes properly assertMapPosition(expectedBottoms1, expectedHeights1) cy.getByDataTest('moremenubutton').first().click() @@ -62,10 +62,10 @@ describe('data table', () => { .contains('Show data table') .click() - //check that the bottom panel is present + // Check that the bottom panel is present cy.getByDataTest('bottom-panel').should('be.visible') - //check that the map resizes properly + // Check that the map resizes properly cy.getByDataTest('bottom-panel') .invoke('height') .then((height) => { @@ -80,9 +80,7 @@ describe('data table', () => { // Collapse the Layers Panel to give the table more width cy.getByDataTest('layers-toggle-button').click() - // check number of columns - // (Legend + Color are merged into one swatch+name column for - // thematic layers, so this is one fewer than the number of headers) + // Check number of columns cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-datatablecellhead') .should('have.length', 10) @@ -92,13 +90,13 @@ describe('data table', () => { .find('input') .type('bar{enter}') - // check that the filter returned the correct number of rows + // Check that the filter returned the correct number of rows cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-tablebody') .findByDataTest('dhis2-uicore-datatablerow') .should('have.length', 7) - // confirm that the sort order is initially ascending by Name + // Confirm that the sort order is initially ascending by Name checkTableCell({ row: 0, column: 2, expectedContent: 'Bargbe' }) checkTableCell({ row: 6, column: 2, expectedContent: 'Upper Bambara' }) @@ -110,16 +108,16 @@ describe('data table', () => { // so we reset to top before asserting on row indices below cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') - // confirm that the rows are sorted by Name descending + // Confirm that the rows are sorted by Name descending checkTableCell({ row: 0, column: 2, expectedContent: 'Upper Bambara' }) checkTableCell({ row: 6, column: 2, expectedContent: 'Bargbe' }) - // filter by Value (numeric) + // Filter by Value (numeric) cy.getByDataTest('data-table-column-filter-search-Value') .find('input') .type('>26{enter}') - // check that the (combined) filter returned the correct number of rows + // Check that the (combined) filter returned the correct number of rows cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-tablebody') .findByDataTest('dhis2-uicore-datatablerow') @@ -131,11 +129,11 @@ describe('data table', () => { // Reset scroll position after sorting - see comment above cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') - // check that the rows are sorted by Value ascending + // Check that the rows are sorted by Value ascending checkTableCell({ row: 0, column: 4, expectedContent: '35' }) checkTableCell({ row: 4, column: 4, expectedContent: '76' }) - // right-click a row and select "View profile" + // Right-click a row and select "View profile" cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-tablebody') .findByDataTest('dhis2-uicore-datatablerow') @@ -144,22 +142,22 @@ describe('data table', () => { cy.getByDataTest('data-table-context-menu-view-profile').click() - // check that the org unit profile drawer is opened + // Check that the org unit profile drawer is opened cy.getByDataTest('org-unit-profile').should('be.visible') cy.getByDataTest('layers-toggle-button').click() - // close the datatable + // Close the datatable cy.getByDataTest('moremenubutton').first().click() cy.getByDataTest('more-menu') .find('li') .contains('Hide data table') .click() - //check that the bottom panel is closed + // Check that the bottom panel is closed cy.getByDataTest('bottom-panel').should('not.exist') - //check that the map resizes properly + // Check that the map resizes properly assertMapPosition(expectedBottoms1, expectedHeights1) }) @@ -194,7 +192,7 @@ describe('data table', () => { // Collapse the Layers Panel to give the table more width cy.getByDataTest('layers-toggle-button').click() - // check number of columns + // Check number of columns cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-datatablecellhead') .should('have.length', 11) @@ -204,13 +202,13 @@ describe('data table', () => { .contains('Age in years', { matchCase: false }) .should('be.visible') - // filter by Org unit + // Filter by Org unit const ouName = 'Moyowa' cy.getByDataTest('data-table-column-filter-search-Org unit') .find('input') .type(`${ouName}{enter}`) - // check that all the rows have Org unit Moyowa + // Check that all the rows have Org unit Moyowa checkTableCell({ row: 0, column: 2, expectedContent: ouName }) checkTableCell({ row: 2, column: 2, expectedContent: ouName }) @@ -219,7 +217,7 @@ describe('data table', () => { .findByDataTest('dhis2-uicore-datatablerow') .should('have.length', 3) - // filter by Mode of Discharge + // Filter by Mode of Discharge cy.getByDataTest('data-table-column-filter-search-Mode of Discharge') .find('input') .type('Absconded') @@ -239,12 +237,12 @@ describe('data table', () => { .findByDataTest('dhis2-uicore-datatablerow') .should('have.length', 3) - // filter by Age in years (numeric) + // Filter by Age in years (numeric) cy.getByDataTest('data-table-column-filter-search-Age in years') .find('input') .type('<51{enter}') - // check that the filter returned the correct number of rows + // Check that the filter returned the correct number of rows cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-tablebody') .findByDataTest('dhis2-uicore-datatablerow') @@ -258,7 +256,7 @@ describe('data table', () => { checkTableCell({ row: 0, column: 8, expectedContent: '6' }) checkTableCell({ row: 1, column: 8, expectedContent: '32' }) - // right-click a row: Event layers have no profile to view + // Right-click a row: Event layers have no profile to view cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-tablebody') .findByDataTest('dhis2-uicore-datatablerow') @@ -269,7 +267,7 @@ describe('data table', () => { 'not.exist' ) - // check that the org unit profile drawer is NOT opened + // Check that the org unit profile drawer is NOT opened cy.getByDataTest('org-unit-profile').should('not.exist') }) diff --git a/i18n/en.pot b/i18n/en.pot index a39e400c3c..a28648bb60 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-16T08:26:26.615Z\n" -"PO-Revision-Date: 2026-07-16T08:26:26.615Z\n" +"POT-Creation-Date: 2026-07-16T11:34:34.211Z\n" +"PO-Revision-Date: 2026-07-16T11:34:34.212Z\n" msgid "2020" msgstr "2020" @@ -202,11 +202,11 @@ msgstr "No features match your filters" msgid "No results found" msgstr "No results found" -msgid "Select all" -msgstr "Select all" +msgid "Select all visible rows" +msgstr "Select all visible rows" -msgid "Reverse selection" -msgstr "Reverse selection" +msgid "Reverse selection of visible rows" +msgstr "Reverse selection of visible rows" msgid "Sort by Selected" msgstr "Sort by Selected" @@ -250,6 +250,9 @@ msgstr "Search or type > 5, < 8…" msgid "Search" msgstr "Search" +msgid "Reverse selection" +msgstr "Reverse selection" + msgid "Any value" msgstr "Any value" diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index 6e62214be8..7f8edc200f 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -116,8 +116,6 @@ const BottomPanel = () => { dispatch(clearDataFilters(activeLayerId)) dispatch(setSelectionFilter([])) setGlobalSearch('') - // toggleShowOnlyFeaturesInView flips the flag, so only dispatch it - // when the toggle is actually on - otherwise this would turn it on. if (showOnlyFeaturesInView) { dispatch(toggleShowOnlyFeaturesInView()) } diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index 20c085ec09..5b72ec12d7 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -29,10 +29,13 @@ import { setSelectionFilter } from '../../actions/dataTable.js' import { highlightFeature } from '../../actions/feature.js' import { toggleFeatureSelection, - selectAllFeatures, selectFeatureRange, - clearSelection, } from '../../actions/selection.js' +import { + SENTINEL_SELECTED_ROW, + SORT_ASCENDING, + SORT_DESCENDING, +} from '../../constants/dataTable.js' import { SELECTION_FILTER_SELECTED, SELECTION_FILTER_NOT_SELECTED, @@ -42,36 +45,24 @@ import { formatWithSeparator } from '../../util/numbers.js' import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' import Checkbox from '../core/Checkbox.jsx' import { SortIcon } from '../core/icons.jsx' -import FilterInput, { +import { FilterDropdownPopover, getDropdownPlacement, -} from './FilterInput.jsx' +} from './FilterDropdownPopover.jsx' +import FilterInput from './FilterInput.jsx' import styles from './styles/DataTable.module.css' import TableContextMenu from './TableContextMenu.jsx' -import { useTableData, SELECTED_SORT_KEY } from './useTableData.js' +import { useColumnWidths } from './useColumnWidths.js' +import { useRowSelection } from './useRowSelection.js' +import { useTableData } from './useTableData.js' const SELECTION_FILTER_OPTIONS = [ { value: SELECTION_FILTER_SELECTED, label: i18n.t('Selected') }, { value: SELECTION_FILTER_NOT_SELECTED, label: i18n.t('Not selected') }, ] -// Every filterable column dispatches its dataFilters value straight through -// to filterData against each layer's real feature properties (see -// ThematicLayer.jsx/EventLayer.jsx/etc.). export const isFilterable = (dataKey, type) => !!type -// Inverts selection scoped to the currently-filtered/visible rows only, -// mirroring how the "select all" checkbox already treats them (see -// onToggleSelectAll) - ids selected before a filter narrowed the rows stay -// selected (offViewSelected), only the visible portion actually flips. -export const getReversedSelection = (selectedIds, allRowIds) => { - const selectedIdSet = new Set(selectedIds) - const allRowIdSet = new Set(allRowIds) - const offViewSelected = selectedIds.filter((id) => !allRowIdSet.has(id)) - const invertedVisible = allRowIds.filter((id) => !selectedIdSet.has(id)) - return [...offViewSelected, ...invertedVisible] -} - const SelectionFilterButton = ({ value, onChange }) => { const anchorRef = useRef(null) const [isOpen, setIsOpen] = useState(false) @@ -88,10 +79,6 @@ const SelectionFilterButton = ({ value, onChange }) => { ? i18n.t('All') : i18n.t('{{count}} selected', { count: value.length }) - // Sits in the same sticky header row as every other column's FilterInput - // dropdown, so computing its placement the same way (rather than using - // @dhis2/ui's Popover, which flips independently based on its own - // available space) keeps it opening on the same side as the rest. const anchorRect = anchorRef.current?.getBoundingClientRect() const { dropdownPlacement } = getDropdownPlacement(anchorRect) @@ -136,12 +123,6 @@ SelectionFilterButton.propTypes = { const topTooltipModifiers = [{ name: 'offset', options: { offset: [0, 4] } }] -// @dhis2/ui's Tooltip always includes a flip modifier that checks the -// nearest scrolling ancestor's clip box for room. The table header is -// position:sticky, pinned to the top of that scrolling container, so the -// flip modifier always reports "no room above" and flips the tooltip below -// the icon - even though there's plenty of room on screen. This variant -// skips the flip modifier so sort-icon tooltips stay pinned above the icon. const TopTooltip = ({ content, children }) => { const [open, setOpen] = useState(false) const referenceRef = useRef(null) @@ -197,24 +178,17 @@ TopTooltip.propTypes = { content: PropTypes.node.isRequired, } -const ASCENDING = 'asc' -const DESCENDING = 'desc' - export const shouldClearFeatureHighlight = (event) => event.relatedTarget?.tagName !== 'TD' -// Cycles a column through ascending -> descending -> none (natural order) -> -// ascending... Once sortField is null, every column looks "unsorted" again, -// so clicking any of them (including the one that was just cleared) -// naturally restarts the cycle at ascending. export const getNextSorting = (name, { sortField, sortDirection }) => { if (name !== sortField) { - return { sortField: name, sortDirection: ASCENDING } + return { sortField: name, sortDirection: SORT_ASCENDING } } - if (sortDirection === ASCENDING) { - return { sortField: name, sortDirection: DESCENDING } + if (sortDirection === SORT_ASCENDING) { + return { sortField: name, sortDirection: SORT_DESCENDING } } - return { sortField: null, sortDirection: ASCENDING } + return { sortField: null, sortDirection: SORT_ASCENDING } } const getRowId = (row) => @@ -338,10 +312,7 @@ const Table = ({ systemSettings: { keyAnalysisDigitGroupSeparator }, } = useCachedData() - const headerRowRef = useRef(null) const virtuosoRef = useRef(null) - const [columnWidths, setColumnWidths] = useState([]) - const minColumnWidthsRef = useRef([]) const { mapViews } = useSelector((state) => state.map) const activeLayerId = useSelector((state) => state.dataTable) @@ -357,7 +328,7 @@ const Table = ({ (sorting, newSorting) => ({ ...sorting, ...newSorting }), { sortField: 'name', - sortDirection: ASCENDING, + sortDirection: SORT_ASCENDING, } ) @@ -452,6 +423,12 @@ const Table = ({ globalSearch, }) + const { headerRowRef, columnWidths } = useColumnWidths({ + availableWidth, + headers, + error, + }) + useEffect(() => { onCountChange?.(totalCount, filteredCount) }, [onCountChange, totalCount, filteredCount]) @@ -567,93 +544,14 @@ const Table = ({ () => rows?.map(getRowId).filter(Boolean) ?? [], [rows] ) - const allRowIdSet = useMemo(() => new Set(allRowIds), [allRowIds]) - - const isAllSelected = useMemo( - () => - allRowIds.length > 0 && - allRowIds.every((id) => selectedIdSet.has(id)), - [allRowIds, selectedIdSet] - ) - - const onToggleSelectAll = useCallback(() => { - const nextIds = isAllSelected - ? selectedIds.filter((id) => !allRowIdSet.has(id)) - : [...new Set([...selectedIds, ...allRowIds])] - - if (nextIds.length) { - dispatch(selectAllFeatures(nextIds, layer.id)) - } else { - dispatch(clearSelection()) - } - }, [dispatch, isAllSelected, allRowIds, allRowIdSet, selectedIds, layer.id]) - - const onReverseSelection = useCallback(() => { - const nextIds = getReversedSelection(selectedIds, allRowIds) - - if (nextIds.length) { - dispatch(selectAllFeatures(nextIds, layer.id)) - } else { - dispatch(clearSelection()) - } - }, [dispatch, selectedIds, allRowIds, layer.id]) - - useEffect(() => { - // Measure column widths in auto layout, then switch to fixed to prevent content shift during virtual scrolling - if (columnWidths.length === 0 && headerRowRef.current) { - const frameId = requestAnimationFrame(() => { - if (!headerRowRef.current) { - return - } - - const measuredColumnWidths = [] - - const dataCells = Array.from(headerRowRef.current.cells).slice( - 1 - ) - - for (const cell of dataCells) { - const rect = cell.getBoundingClientRect() - measuredColumnWidths.push(Math.floor(rect.width)) - } - - minColumnWidthsRef.current = measuredColumnWidths - setColumnWidths(measuredColumnWidths) - }) - - return () => cancelAnimationFrame(frameId) - } - }, [columnWidths]) - useEffect(() => { - // Reset to auto layout for re-measurement when headers change - if (!error) { - minColumnWidthsRef.current = [] - setColumnWidths([]) - } - }, [headers, error]) - - useEffect(() => { - // Scale column widths proportionally on resize, clamped to initial measured widths - if (!error) { - setColumnWidths((prev) => { - if (prev.length === 0) { - return prev - } - const prevTotal = prev.reduce((sum, w) => sum + w, 0) - if (prevTotal === 0 || availableWidth === 0) { - return [] - } - const minWidths = minColumnWidthsRef.current - return prev.map((w, i) => - Math.max( - minWidths[i] ?? 0, - Math.round((w / prevTotal) * availableWidth) - ) - ) - }) - } - }, [availableWidth, error]) + const { isAllSelected, onToggleSelectAll, onReverseSelection } = + useRowSelection({ + selectedIds, + selectedIdSet, + allRowIds, + layerId: layer.id, + }) if (error) { return <p className={styles.noSupport}>{error}</p> @@ -689,14 +587,22 @@ const Table = ({ } > <div className={styles.checkboxHeaderContent}> - <input - type="checkbox" - title={i18n.t('Select all')} - checked={isAllSelected} - onChange={onToggleSelectAll} - /> <TopTooltip - content={i18n.t('Reverse selection')} + content={i18n.t('Select all visible rows')} + > + <input + type="checkbox" + aria-label={i18n.t( + 'Select all visible rows' + )} + checked={isAllSelected} + onChange={onToggleSelectAll} + /> + </TopTooltip> + <TopTooltip + content={i18n.t( + 'Reverse selection of visible rows' + )} > <button type="button" @@ -717,13 +623,14 @@ const Table = ({ data-test="data-table-column-sort-button-selected" onClick={() => sortData({ - name: SELECTED_SORT_KEY, + name: SENTINEL_SELECTED_ROW, }) } > <SortIcon direction={ - sortField === SELECTED_SORT_KEY + sortField === + SENTINEL_SELECTED_ROW ? sortDirection : null } @@ -836,10 +743,6 @@ const Table = ({ [styles.monoCell]: dataKey === 'id' || dataKey === 'color', - // The color cell's own background - // (below) is the whole point of that - // column - don't let hover/selection - // tint it out. [styles.selected]: isSelected && dataKey !== 'color', [styles.hovered]: diff --git a/src/components/datatable/FilterDropdownPopover.jsx b/src/components/datatable/FilterDropdownPopover.jsx new file mode 100644 index 0000000000..ae2f257c0c --- /dev/null +++ b/src/components/datatable/FilterDropdownPopover.jsx @@ -0,0 +1,50 @@ +import { Layer, Popper } from '@dhis2/ui' +import PropTypes from 'prop-types' +import React from 'react' + +const ESTIMATED_POPOVER_HEIGHT = 340 // Rough popover height used to flip the dropdown when there isn't room to open downward + +const dropdownModifiers = [ + { name: 'offset', options: { offset: [0, 0] } }, + { name: 'flip', enabled: false }, +] + +export const getDropdownPlacement = (anchorRect) => { + const dropdownSide = + anchorRect != null && + window.innerHeight - anchorRect.bottom < ESTIMATED_POPOVER_HEIGHT + ? 'top' + : 'bottom' + return { + dropdownSide, + dropdownPlacement: `${dropdownSide}-start`, + tooltipPlacement: dropdownSide === 'top' ? 'bottom' : 'top', + } +} + +export const FilterDropdownPopover = ({ + reference, + placement, + onClickOutside, + className, + children, +}) => ( + <Layer onBackdropClick={onClickOutside}> + <Popper + placement={placement} + reference={reference} + modifiers={dropdownModifiers} + className={className} + > + {children} + </Popper> + </Layer> +) + +FilterDropdownPopover.propTypes = { + children: PropTypes.node.isRequired, + placement: PropTypes.oneOf(['top-start', 'bottom-start']).isRequired, + reference: PropTypes.object.isRequired, + onClickOutside: PropTypes.func.isRequired, + className: PropTypes.string, +} diff --git a/src/components/datatable/FilterInput.jsx b/src/components/datatable/FilterInput.jsx index ff3b30e184..5d27317ce7 100644 --- a/src/components/datatable/FilterInput.jsx +++ b/src/components/datatable/FilterInput.jsx @@ -1,56 +1,35 @@ import i18n from '@dhis2/d2-i18n' -import { - Input, - Layer, - Popper, - Portal, - IconFilter16, - IconSync16, -} from '@dhis2/ui' +import { Input, Popper, Portal, IconFilter16, IconSync16 } from '@dhis2/ui' import cx from 'classnames' import PropTypes from 'prop-types' import React, { useEffect, useRef, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import { Virtuoso } from 'react-virtuoso' import { setDataFilter, clearDataFilter } from '../../actions/dataFilters.js' +import { + SENTINEL_ANY_VALUE, + SENTINEL_NO_VALUE, +} from '../../constants/dataTable.js' import useOptionSet from '../../hooks/useOptionSet.js' -import { numericFilter, ANY_VALUE_KEY } from '../../util/filter.js' +import { numericFilter } from '../../util/filter.js' +import { + getInvertibleValues, + reverseSelection, + toggleAnyValue, + toggleRealValue, +} from '../../util/filterSelection.js' import Checkbox from '../core/Checkbox.jsx' +import { + FilterDropdownPopover, + getDropdownPlacement, +} from './FilterDropdownPopover.jsx' import styles from './styles/FilterInput.module.css' -// Must match useTableData.js's NOT_SET_VALUE - not imported directly from -// there to avoid pulling that module's heavy transitive dependencies -// (map/earth-engine loaders) into this component. -const NOT_SET_VALUE = '' - -// Checkbox rows are a fixed height (dense label + the 4px/4px margin set -// below), so the list can be virtualized with a known row size instead of -// measuring each one - this is what lets a column's full value list (no -// cap - see useTableData.js) stay cheap to render regardless of size. -const OPTION_ROW_HEIGHT = 28 +const OPTION_ROW_HEIGHT = 28 // Checkbox rows are a fixed height so the list can be virtualized const MAX_LIST_HEIGHT = 260 - -// Rough upper bound (in px) on the dropdown's own rendered height (the -// checkbox list capped at MAX_LIST_HEIGHT, plus its padding/gap and the -// pinned custom-filter row) - used to decide, once per render, whether -// every column's dropdown should open below or above (see -// SearchableFilterPopover's dropdownSide). All columns share the same -// header row, so this is computed the same way for each of them and they -// always agree - there's no per-column flip, just a single below/above -// choice that applies to the whole row. -const ESTIMATED_POPOVER_HEIGHT = MAX_LIST_HEIGHT + 80 - -// Floor for the dropdown's width - narrow columns still need enough room -// for the checkbox list/search text to be usable, so the popover shouldn't -// shrink down to match a very narrow trigger's own width. const MIN_POPOVER_WIDTH = 140 - -// Rough content heights (in px) for the two tooltip variants, used only to -// decide whether there's enough room to show the tooltip at all - see -// FilterHelpTooltip's hasRoom check. const NUMERIC_HELP_HEIGHT = 140 const TEXT_HELP_HEIGHT = 56 - const NUMERIC_FILTER_HELP = ( <div> <div>{i18n.t('Select values, or type a numerical filter:')}</div> @@ -61,40 +40,19 @@ const NUMERIC_FILTER_HELP = ( <div>{'> 3 & < 8 — ' + i18n.t('greater than 3 AND less than 8')}</div> </div> ) - const TEXT_FILTER_HELP = ( <div> <div>{i18n.t('Select values, or type text')}</div> <div>{i18n.t('to match rows that contain it')}</div> </div> ) - -// Everything a numeric filter expression can legally contain (see -// isTrueFilter/numericFilter in util/filter.js): digits, decimals, -// negative signs, comparison operators, and the AND/OR separators. const NUMERIC_INPUT_DISALLOWED = /[^0-9.\-<>=,&\s]/g -// @dhis2-ui/popper's Popper component always merges in its own base flip -// modifier unless a modifier of the same name is passed in to override it - -// omitting flip from this list does NOT turn it off, it just leaves the -// base one active. Disabling it by name (rather than simply not mentioning -// it) is what actually stops it from silently overriding `placement`. const helpTooltipModifiers = [ { name: 'offset', options: { offset: [0, 4] } }, { name: 'flip', enabled: false }, ] -// @dhis2/ui's Tooltip always includes a flip modifier that checks the -// nearest scrolling ancestor's clip box for room (see the identical -// TopTooltip in DataTable.jsx) - the column header is position:sticky, -// pinned to the top of that scrolling container, so the flip modifier -// always reports "no room above" and flips to the bottom regardless of -// the requested placement. This variant skips that modifier so it always -// opens on whichever side the caller passes (SearchableFilterPopover always -// passes the opposite of the dropdown's own side) - and, since it can no -// longer flip out of the way, it checks for itself whether there's -// actually room on that side before showing anything at all, rather than -// risk covering other UI or getting clipped. const FilterHelpTooltip = ({ content, placement, @@ -173,75 +131,6 @@ FilterHelpTooltip.propTypes = { placement: PropTypes.oneOf(['top', 'bottom']).isRequired, } -// See helpTooltipModifiers above - the flip modifier has to be disabled by -// name, not just left out, or the Popper component's own base flip modifier -// stays active underneath and keeps overriding `placement` per-column. -const dropdownModifiers = [ - { name: 'offset', options: { offset: [0, 0] } }, - { name: 'flip', enabled: false }, -] - -// @dhis2/ui's Popover always includes its own flip modifier with no way to -// opt out, which lets each column decide independently based on its own -// available space - a column near the bottom of the table could open -// upward while every other column opens downward. This reimplements just -// enough of Popover - Layer for the backdrop/click-outside behavior, Popper -// for positioning, flip disabled - so `placement` is always honored exactly; -// the caller (SearchableFilterPopover) computes one placement per render -// from the shared header row's position, so every column's dropdown agrees. -export const FilterDropdownPopover = ({ - reference, - placement, - onClickOutside, - className, - children, -}) => ( - <Layer onBackdropClick={onClickOutside}> - <Popper - placement={placement} - reference={reference} - modifiers={dropdownModifiers} - className={className} - > - {children} - </Popper> - </Layer> -) - -FilterDropdownPopover.propTypes = { - children: PropTypes.node.isRequired, - placement: PropTypes.oneOf(['top-start', 'bottom-start']).isRequired, - reference: PropTypes.object.isRequired, - onClickOutside: PropTypes.func.isRequired, - className: PropTypes.string, -} - -// See onToggleAnyValue - turning "Any value" on collapses any individually -// -picked real values into just the wildcard, off unticks every real value -// along with it. "No value" carries through untouched either way. -const getAnyValueToggleResult = (anyValueActive, keepNotSet) => { - if (anyValueActive) { - return keepNotSet ? [NOT_SET_VALUE] : [] - } - return keepNotSet ? [ANY_VALUE_KEY, NOT_SET_VALUE] : [ANY_VALUE_KEY] -} - -// See onToggleRealValue - once every real value ends up ticked, that's the -// same state as "Any value" being active, so it collapses into the wildcard -// rather than a literal array that happens to list them all. -const getRealValueToggleResult = (next, allRealValuesChecked) => { - if (!allRealValuesChecked) { - return next - } - return next.includes(NOT_SET_VALUE) - ? [ANY_VALUE_KEY, NOT_SET_VALUE] - : [ANY_VALUE_KEY] -} - -// See "Numeric columns narrow..." comment at the filteredOptions call site - -// numeric columns match the typed text as a numericFilter expression, -// string columns match it as a case-insensitive substring of the resolved -// label. const getFilteredOptions = ({ realOptions, trimmedSearch, @@ -262,10 +151,6 @@ const getFilteredOptions = ({ ) } -// Closed, this reads like the old trigger button ("3 selected", the applied -// filter text, or empty so the "Search" placeholder shows). Open, it's a -// live, editable search/filter field - the same input serves both roles -// instead of a button revealing a separate one. const getDisplayValue = ({ isOpen, searchText, selected, appliedString }) => { if (isOpen) { return searchText @@ -276,49 +161,11 @@ const getDisplayValue = ({ isOpen, searchText, selected, appliedString }) => { return appliedString } -// Widget state is derived straight from the applied `filterValue` (never -// tracked in parallel) - an array means a multi-select filter, a string -// means a custom typed one, anything else (no filter applied) is neither. const getSelectedAndAppliedString = (filterValue) => ({ selected: Array.isArray(filterValue) ? filterValue : [], appliedString: typeof filterValue === 'string' ? filterValue : '', }) -// Every column's filter trigger sits in the same header row, so this -// resolves to the same answer for all of them - below by default, or above -// if the row doesn't have room to open the dropdown downward (e.g. the -// table is short, or the page is scrolled so the row sits near the bottom -// of the viewport). That single choice is what keeps every column's -// dropdown opening on the same side. The help tooltip always takes the -// opposite side, so the two never compete for space. -export const getDropdownPlacement = (anchorRect) => { - const dropdownSide = - anchorRect != null && - window.innerHeight - anchorRect.bottom < ESTIMATED_POPOVER_HEIGHT - ? 'top' - : 'bottom' - return { - dropdownSide, - dropdownPlacement: `${dropdownSide}-start`, - tooltipPlacement: dropdownSide === 'top' ? 'bottom' : 'top', - } -} - -// Every value "Reverse selection" can flip - the column's full value -// domain, not just whatever the current search happens to narrow the list -// down to (search is for finding/toggling individual values, not for -// scoping a bulk action). -const getInvertibleValues = (hasNotSetOption, realOptions) => - hasNotSetOption - ? [NOT_SET_VALUE, ...realOptions.map((o) => o.value)] - : realOptions.map((o) => o.value) - -// Shared popover UI — label resolution is injected so it never needs to -// know whether it's an option-set column or a plain categorical one. -// State is derived straight from the applied `filterValue` (never tracked -// in parallel), so picking a value and applying a custom filter stay -// mutually exclusive for free: whichever one is dispatched last is what -// `filterValue` holds, and both branches read from it the same way. const SearchableFilterPopover = ({ dataKey, name, @@ -346,10 +193,6 @@ const SearchableFilterPopover = ({ const closePopover = () => setIsOpen(false) - // Read directly from the DOM rather than a resize observer: the trigger - // is already mounted (this only matters once isOpen is true, by which - // point it's had at least one paint) and column widths only change on - // table resize, when the popover is closed anyway. const anchorRect = anchorRef.current?.getBoundingClientRect() const anchorWidth = anchorRect?.width const { dropdownPlacement, dropdownSide, tooltipPlacement } = @@ -372,112 +215,27 @@ const SearchableFilterPopover = ({ ? dispatch(setDataFilter(layerId, dataKey, text)) : dispatch(clearDataFilter(layerId, dataKey)) - // "No value" is pinned above the list with "Any value" (see the render - // below) rather than mixed in among the column's real distinct values, - // so it's excluded here and never part of the virtualized/searchable - // list. - const hasNotSetOption = options.some(({ value }) => value === NOT_SET_VALUE) - const realOptions = options.filter(({ value }) => value !== NOT_SET_VALUE) - const anyValueActive = selected.includes(ANY_VALUE_KEY) - - // Toggling "Any value" always rebuilds the selection from scratch - // rather than adding/removing just the one key - turning it on - // collapses any individually-picked real values into the wildcard - // (they're now redundant), and turning it off unticks every real - // value along with it (there's nothing meaningful to "fall back" to). - // "No value" is independent either way and carries over untouched. - const onToggleAnyValue = () => { - const keepNotSet = selected.includes(NOT_SET_VALUE) - applyValues(getAnyValueToggleResult(anyValueActive, keepNotSet)) - } - - const invertibleValues = getInvertibleValues(hasNotSetOption, realOptions) - - // A real value's checkbox is ticked either because it's individually - // selected, or because "Any value" is active (which stands for "every - // real value" - "No value" is the one exception, handled on its own - // below). Clicking one while "Any value" is active means "everything - // except this one" - not "add this one on top of Any value" - so it - // has to expand Any value into its concrete equivalent (every real - // value but this one) rather than going through the plain toggle, - // which would otherwise just add the clicked value to an array that - // still has ANY_VALUE_KEY in it and leave every other checkbox ticked - // for the wrong reason. - const onToggleRealValue = (value) => { - if (anyValueActive) { - const next = realOptions - .map((o) => o.value) - .filter((v) => v !== value) - applyValues( - selected.includes(NOT_SET_VALUE) - ? [...next, NOT_SET_VALUE] - : next - ) - return - } + const hasNotSetOption = options.some( + ({ value }) => value === SENTINEL_NO_VALUE + ) + const realOptions = options.filter( + ({ value }) => value !== SENTINEL_NO_VALUE + ) + const realValues = realOptions.map((o) => o.value) + const anyValueActive = selected.includes(SENTINEL_ANY_VALUE) - const next = selected.includes(value) - ? selected.filter((v) => v !== value) - : [...selected, value] + const onToggleAnyValue = () => applyValues(toggleAnyValue(selected)) - // Checking every real value one by one ends up in the same place - // as checking "Any value" directly - collapse to that instead of - // leaving a literal array that happens to list them all, so the - // two are always the same underlying state. - const allRealValuesChecked = - realOptions.length > 0 && - realOptions.every((o) => next.includes(o.value)) - applyValues(getRealValueToggleResult(next, allRealValuesChecked)) - } + const invertibleValues = getInvertibleValues(hasNotSetOption, realValues) - // Reverses every checkbox's *effective* ticked state, not just literal - // array membership - while "Any value" is active every real value - // reads as ticked (see onToggleRealValue above), so reversing has to - // untick all of them (and "Any value" along with them, since there's - // no way to represent "every real value unticked" while it's still - // set) rather than leaving them all ticked and only toggling values - // that were never literally in the array to begin with. "No value" is - // unaffected by "Any value" and simply flips on its own. - const onReverseSelection = () => { - const invertedRealValues = anyValueActive - ? [] - : realOptions - .map((o) => o.value) - .filter((v) => !selected.includes(v)) - const invertedNotSet = - hasNotSetOption && !selected.includes(NOT_SET_VALUE) - - // Same rule as onToggleRealValue: ending up with every real value - // ticked (e.g. reversing a selection of just "No value") is the - // same state as "Any value" being active, so it collapses into - // that rather than a literal array that happens to list them all. - const allRealValuesInverted = - !anyValueActive && - realOptions.length > 0 && - invertedRealValues.length === realOptions.length - - if (allRealValuesInverted) { - applyValues( - invertedNotSet - ? [ANY_VALUE_KEY, NOT_SET_VALUE] - : [ANY_VALUE_KEY] - ) - return - } + const onToggleRealValue = (value) => + applyValues(toggleRealValue(selected, value, realValues)) - applyValues( - invertedNotSet - ? [NOT_SET_VALUE, ...invertedRealValues] - : invertedRealValues - ) - } + const onReverseSelection = () => + applyValues(reverseSelection(selected, realValues, hasNotSetOption)) const trimmedSearch = searchText.trim() const normalizedSearch = trimmedSearch.toLowerCase() - // Numeric columns narrow the list using the same comparison the typed - // text would apply to the table's rows (>, <, ranges, ...), so what's - // checked here always matches what "Use filter" would actually select - - // a plain substring match wouldn't understand "> 100" against "150". const filteredOptions = getFilteredOptions({ realOptions, trimmedSearch, @@ -497,17 +255,7 @@ const SearchableFilterPopover = ({ const hasActiveFilter = selected.length > 0 || appliedString !== '' - // Applies (or clears) live as the user types - typing a value that - // doesn't match an existing option filters the table immediately, - // exactly like picking a checkbox already does, rather than waiting - // for an explicit commit step. Clearing the text (including via the - // input's own built-in clear button) clears whatever filter is active, - // whether it's picked values or a typed one. const onSearchChange = ({ value }) => { - // Numeric columns only ever match a numericFilter expression - // (digits, comparison operators, & / ,) - letters could never - // apply to a number column, so strip them as they're typed rather - // than accepting them and silently matching nothing. const sanitized = type === 'number' ? value.replace(NUMERIC_INPUT_DISALLOWED, '') @@ -537,9 +285,6 @@ const SearchableFilterPopover = ({ } } - // The checkbox list is virtualized, so scrolling the highlighted row - // into view has to be requested explicitly rather than relying on the - // browser's native scrollIntoView over an already-rendered DOM node. const scrollHighlightedIntoView = (index) => { const optionIndex = showCustomFilterRow ? index - 1 : index if (optionIndex >= 0 && optionIndex < filteredOptions.length) { @@ -550,10 +295,6 @@ const SearchableFilterPopover = ({ } } - // The custom-filter row (when shown) sits first, matching its visual - // position above the checkbox list. It's usually already applied live - // by this point (see onSearchChange) - toggling it again here is a - // no-op. const onEnterKey = () => { if (highlightedIndex === -1) { if (showCustomFilterRow) { @@ -607,10 +348,6 @@ const SearchableFilterPopover = ({ } } - // Closed, this reads like the old trigger button ("3 selected", the - // applied filter text, or empty so the "Search" placeholder shows). - // Open, it's a live, editable search/filter field - the same input - // serves both roles instead of a button revealing a separate one. const displayValue = getDisplayValue({ isOpen, searchText, @@ -720,9 +457,13 @@ const SearchableFilterPopover = ({ /> {hasNotSetOption && ( <Checkbox - label={resolveLabel(NOT_SET_VALUE)} - checked={selected.includes(NOT_SET_VALUE)} - onChange={() => toggleValue(NOT_SET_VALUE)} + label={resolveLabel(SENTINEL_NO_VALUE)} + checked={selected.includes( + SENTINEL_NO_VALUE + )} + onChange={() => + toggleValue(SENTINEL_NO_VALUE) + } className={styles.specialOption} style={{ margin: '4px 0' }} dataTest={`data-table-column-filter-novalue-${name}`} @@ -754,14 +495,6 @@ const SearchableFilterPopover = ({ MAX_LIST_HEIGHT ), }} - // Renders a couple of rows beyond the - // container's own visible height, so - // when the list is capped there's - // always a (clipped) row peeking in at - // the bottom as a "scroll for more" cue, - // rather than blank space below the - // last row Virtuoso would otherwise - // bother mounting. increaseViewportBy={{ top: 0, bottom: OPTION_ROW_HEIGHT * 2, @@ -817,30 +550,19 @@ SearchableFilterPopover.propTypes = { layerId: PropTypes.string, } -// Plain categorical columns (legend, type, and every other column discovered -// generically by useTableData): raw value IS the display label, except for -// the NOT_SET_VALUE sentinel representing blank/missing cells. const PlainSearchableFilter = (props) => ( <SearchableFilterPopover {...props} resolveLabel={(value) => - value === NOT_SET_VALUE ? i18n.t('No value') : value + value === SENTINEL_NO_VALUE ? i18n.t('No value') : value } /> ) -// Option-set-backed event columns: translate stored code -> display name. -// useOptionSet/useDataQuery is only ever mounted here, never for other -// columns, since those never have an optionSetId. The custom-filter row is -// disabled here: filterData matches the raw stored code, not the resolved -// name the user sees, so free text typed against the visible label couldn't -// be applied correctly - and since option sets are a closed, fully -// enumerable set already covered by the checkbox list, there's no real gap -// left for free text to fill. const OptionSetSearchableFilter = ({ optionSetId, ...props }) => { const { optionSet } = useOptionSet(optionSetId) const resolveLabel = (value) => - value === NOT_SET_VALUE + value === SENTINEL_NO_VALUE ? i18n.t('No value') : optionSet?.options.find((o) => o.code === value)?.name ?? value return ( @@ -856,11 +578,6 @@ OptionSetSearchableFilter.propTypes = { optionSetId: PropTypes.string.isRequired, } -// Every column (aside from the checkbox/selection column, which has its own -// SelectionFilterButton in DataTable.jsx) gets the same searchable popover, -// even ones with no known distinct values (over the cap, or not yet loaded) -// - they just render with an empty options list, which still lets the -// custom-filter row work exactly as it always has. const FilterInput = ({ type, dataKey, name, options, optionSetId }) => { const dataTable = useSelector((state) => state.dataTable) const map = useSelector((state) => state.map) diff --git a/src/components/datatable/ResizeHandle.jsx b/src/components/datatable/ResizeHandle.jsx index 82da4ec76a..50711045f2 100644 --- a/src/components/datatable/ResizeHandle.jsx +++ b/src/components/datatable/ResizeHandle.jsx @@ -3,14 +3,6 @@ import React, { useEffect, useRef } from 'react' import { IconDrag } from '../core/icons.jsx' import styles from './styles/ResizeHandle.module.css' -// Pointer Events + setPointerCapture, not HTML5 drag-and-drop: a native drag -// session shows the browser's own drop-target cursor (grabbing/not-allowed/ -// move) instead of any CSS cursor rule, and elements with no drop handling -// of their own (e.g. the map's WebGL canvas) count as invalid drop targets - -// showing "not-allowed" for as long as the pointer is over them. Pointer -// capture sidesteps that protocol entirely, and keeps reporting move/up -// events to this element even if the pointer leaves it (or the window) -// mid-drag - more reliable than a plain mousemove/mouseup pair for that case. const ResizeHandle = ({ onResize, onResizeStart, @@ -35,11 +27,6 @@ const ResizeHandle = ({ isDraggingRef.current = true onResizeStart?.() - // Set on both the handle and the body: the handle's own `cursor: - // grab` CSS rule otherwise beats body's *inherited* cursor while the - // pointer is over it, so body alone never actually shows grabbing - // here - only once the pointer strays over something with no cursor - // rule of its own (e.g. the map). evt.currentTarget.style.cursor = 'grabbing' document.body.style.cursor = 'grabbing' } diff --git a/src/components/datatable/__tests__/DataTable.spec.jsx b/src/components/datatable/__tests__/DataTable.spec.jsx index 30629f1090..28dc4de68a 100644 --- a/src/components/datatable/__tests__/DataTable.spec.jsx +++ b/src/components/datatable/__tests__/DataTable.spec.jsx @@ -3,19 +3,17 @@ import { getRowClickAction, getNextSorting, isFilterable, - getReversedSelection, } from '../DataTable.jsx' +import { getReversedSelection } from '../useRowSelection.js' -// DataTable.jsx transitively imports MapApi.js (maplibre-gl), which is not -// needed here and fails to load under jsdom. +// DataTable.jsx transitively imports MapApi.js (maplibre-gl), +// which is not needed here and fails to load under jsdom. jest.mock('../../map/MapApi.js', () => ({ loadEarthEngineWorker: jest.fn(), })) describe('shouldClearFeatureHighlight', () => { test('clears when leaving to no element (cursor exits the window)', () => { - // Regression: relatedTarget is null on window exit, which previously - // threw `null.tagName` and crashed the table via the ErrorBoundary. expect(shouldClearFeatureHighlight({ relatedTarget: null })).toBe(true) }) diff --git a/src/components/datatable/__tests__/FilterInput.spec.jsx b/src/components/datatable/__tests__/FilterInput.spec.jsx index d373541ca6..67c9eb5744 100644 --- a/src/components/datatable/__tests__/FilterInput.spec.jsx +++ b/src/components/datatable/__tests__/FilterInput.spec.jsx @@ -7,8 +7,8 @@ import { DATA_FILTER_SET, DATA_FILTER_CLEAR, } from '../../../constants/actionTypes.js' +import { SENTINEL_ANY_VALUE } from '../../../constants/dataTable.js' import useOptionSet from '../../../hooks/useOptionSet.js' -import { ANY_VALUE_KEY } from '../../../util/filter.js' import FilterInput from '../FilterInput.jsx' jest.mock('../../../hooks/useOptionSet.js', () => ({ @@ -25,9 +25,7 @@ const renderFilterInput = (props, dataFilters) => { mapViews: [{ id: 'layer1', dataFilters: dataFilters || {} }], }, }) - // The checkbox list is virtualized (react-virtuoso) - jsdom doesn't do - // real layout, so without this fixed-size mock context Virtuoso thinks - // the viewport is 0px tall and renders no rows at all. + // The checkbox list is virtualized (react-virtuoso) const result = render( <Provider store={store}> <VirtuosoMockContext.Provider @@ -45,10 +43,7 @@ const renderFilterInput = (props, dataFilters) => { return { ...result, store } } -// The trigger and the dropdown's search field are the same <Input> (see -// FilterInput.jsx) - test-ids are keyed by the column's display `name`, -// not its `dataKey`, since dataKey can be an opaque uid for event custom -// fields but name is always the human-readable label cypress/users see. +// The trigger and the dropdown's search field are the same <Input> const getInput = (name) => screen .getByTestId(`data-table-column-filter-search-${name}`) @@ -477,18 +472,11 @@ describe('FilterInput searchable popover — keyboard behavior', () => { describe('FilterInput searchable popover — clear filter', () => { const options = [{ value: 'High' }, { value: 'Low' }] - // The clear-x is @dhis2/ui's own Input `clearable` button now (not a - // custom-positioned element) - it fires the same onChange({value:''}) - // as manually clearing the text, which is exactly what's exercised here. - test('clearing an active array (checkbox) filter closes it out', () => { const { store } = renderFilterInput( { dataKey: 'legend', name: 'Legend', options }, { legend: ['High'] } ) - // Cleared from the closed state, where the input shows "1 selected" - - // opening first would reset the search text to '' (array filters - // have no string to prefill), leaving nothing to actually clear. fireEvent.change(getInput('Legend'), { target: { value: '' } }) expect(store.getActions()).toContainEqual({ type: DATA_FILTER_CLEAR, @@ -522,7 +510,7 @@ describe('FilterInput searchable popover — clear filter', () => { expect(store.getActions()).toEqual([]) }) - test('checking the pinned "Any value" option collapses any existing selection into just ANY_VALUE_KEY', () => { + test('checking the pinned "Any value" option collapses any existing selection into just SENTINEL_ANY_VALUE', () => { const { store } = renderFilterInput( { dataKey: 'legend', name: 'Legend', options }, { legend: ['High'] } @@ -533,7 +521,7 @@ describe('FilterInput searchable popover — clear filter', () => { type: DATA_FILTER_SET, layerId: 'layer1', fieldId: 'legend', - filter: [ANY_VALUE_KEY], + filter: [SENTINEL_ANY_VALUE], }) }) @@ -549,7 +537,7 @@ describe('FilterInput searchable popover — clear filter', () => { test('"Any value" stays checked when it is explicitly selected', () => { renderFilterInput( { dataKey: 'legend', name: 'Legend', options }, - { legend: [ANY_VALUE_KEY] } + { legend: [SENTINEL_ANY_VALUE] } ) openPopover('Legend') expect(screen.getByLabelText('Any value')).toBeChecked() @@ -622,7 +610,7 @@ describe('FilterInput searchable popover — reverse selection', () => { type: DATA_FILTER_SET, layerId: 'layer1', fieldId: 'legend', - filter: [ANY_VALUE_KEY], + filter: [SENTINEL_ANY_VALUE], }) }) @@ -679,7 +667,7 @@ describe('FilterInput searchable popover — reverse selection', () => { type: DATA_FILTER_SET, layerId: 'layer1', fieldId: 'parentName', - filter: [ANY_VALUE_KEY, ''], + filter: [SENTINEL_ANY_VALUE, ''], }) }) @@ -687,7 +675,7 @@ describe('FilterInput searchable popover — reverse selection', () => { const options = [{ value: 'High' }, { value: 'Low' }] const { store } = renderFilterInput( { dataKey: 'legend', name: 'Legend', options }, - { legend: ['High', ANY_VALUE_KEY] } + { legend: ['High', SENTINEL_ANY_VALUE] } ) openPopover('Legend') fireEvent.click(getReverseButton('Legend')) @@ -702,7 +690,7 @@ describe('FilterInput searchable popover — reverse selection', () => { const options = [{ value: '' }, { value: 'Country' }] const { store } = renderFilterInput( { dataKey: 'parentName', name: 'Parent', options }, - { parentName: [ANY_VALUE_KEY] } + { parentName: [SENTINEL_ANY_VALUE] } ) openPopover('Parent') fireEvent.click(getReverseButton('Parent')) @@ -725,8 +713,6 @@ describe('FilterInput searchable popover — reverse selection', () => { { legend: ['High'] } ) openPopover('Legend') - // Narrows the visible checkbox list down to just "Low" - if reverse - // scoped itself to that, the result would only ever mention "Low". fireEvent.change(getInput('Legend'), { target: { value: 'lo' } }) fireEvent.click(getReverseButton('Legend')) expect(store.getActions()).toContainEqual({ @@ -750,7 +736,7 @@ describe('FilterInput searchable popover — "Any value" / real value interactio test('every real value reads as checked while "Any value" is active', () => { renderFilterInput( { dataKey: 'legend', name: 'Legend', options }, - { legend: [ANY_VALUE_KEY] } + { legend: [SENTINEL_ANY_VALUE] } ) openPopover('Legend') expect(screen.getByLabelText('High')).toBeChecked() @@ -765,7 +751,7 @@ describe('FilterInput searchable popover — "Any value" / real value interactio name: 'Parent', options: [{ value: '' }, { value: 'Country' }], }, - { parentName: [ANY_VALUE_KEY] } + { parentName: [SENTINEL_ANY_VALUE] } ) openPopover('Parent') expect(screen.getByLabelText('No value')).not.toBeChecked() @@ -774,7 +760,7 @@ describe('FilterInput searchable popover — "Any value" / real value interactio test('unticking one real value while "Any value" is active unticks "Any value" too, but keeps every other value ticked', () => { const { store } = renderFilterInput( { dataKey: 'legend', name: 'Legend', options }, - { legend: [ANY_VALUE_KEY] } + { legend: [SENTINEL_ANY_VALUE] } ) openPopover('Legend') fireEvent.click(screen.getByLabelText('Medium')) @@ -793,7 +779,7 @@ describe('FilterInput searchable popover — "Any value" / real value interactio name: 'Parent', options: [{ value: '' }, { value: 'A' }, { value: 'B' }], }, - { parentName: [ANY_VALUE_KEY, ''] } + { parentName: [SENTINEL_ANY_VALUE, ''] } ) openPopover('Parent') fireEvent.click(screen.getByLabelText('A')) @@ -816,7 +802,7 @@ describe('FilterInput searchable popover — "Any value" / real value interactio type: DATA_FILTER_SET, layerId: 'layer1', fieldId: 'legend', - filter: [ANY_VALUE_KEY], + filter: [SENTINEL_ANY_VALUE], }) }) @@ -835,14 +821,14 @@ describe('FilterInput searchable popover — "Any value" / real value interactio type: DATA_FILTER_SET, layerId: 'layer1', fieldId: 'parentName', - filter: [ANY_VALUE_KEY, ''], + filter: [SENTINEL_ANY_VALUE, ''], }) }) test('unchecking "Any value" unticks every real value too, not just the wildcard', () => { const { store } = renderFilterInput( { dataKey: 'legend', name: 'Legend', options }, - { legend: [ANY_VALUE_KEY] } + { legend: [SENTINEL_ANY_VALUE] } ) openPopover('Legend') fireEvent.click(screen.getByLabelText('Any value')) @@ -860,7 +846,7 @@ describe('FilterInput searchable popover — "Any value" / real value interactio name: 'Parent', options: [{ value: '' }, { value: 'Country' }], }, - { parentName: [ANY_VALUE_KEY, ''] } + { parentName: [SENTINEL_ANY_VALUE, ''] } ) openPopover('Parent') fireEvent.click(screen.getByLabelText('Any value')) diff --git a/src/components/datatable/__tests__/TableContextMenu.spec.jsx b/src/components/datatable/__tests__/TableContextMenu.spec.jsx index 112b6f7a39..71f81c655e 100644 --- a/src/components/datatable/__tests__/TableContextMenu.spec.jsx +++ b/src/components/datatable/__tests__/TableContextMenu.spec.jsx @@ -17,8 +17,6 @@ const mockStore = configureMockStore() const layer = { id: 'layer1', layer: FACILITY_LAYER, name: 'Test layer' } const contextMenu = { x: 10, y: 10, featureProps: {} } -// @dhis2/ui's MenuItem puts `data-test` on the outer <li>, but `aria-disabled` -// and the click handler both live on the inner <a role="menuitem">. const getZoomToFilteredLink = () => screen .getByTestId('data-table-context-menu-zoom-to-filtered') diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index d0f3bf08c9..ae6f606a20 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -3,10 +3,10 @@ import React from 'react' import { Provider } from 'react-redux' import configureMockStore from 'redux-mock-store' import { - useTableData, - SELECTED_SORT_KEY, - NOT_SET_VALUE, -} from '../useTableData.js' + SENTINEL_SELECTED_ROW, + SENTINEL_NO_VALUE, +} from '../../../constants/dataTable.js' +import { useTableData } from '../useTableData.js' jest.mock('../../map/MapApi.js', () => ({ loadEarthEngineWorker: jest.fn(), @@ -831,8 +831,6 @@ describe('useTableData sorting', () => { }) test('falls back to natural (index) order when sortField is null', () => { - // Deliberately not alphabetical/numerical, so this only passes if the - // natural input order is preserved rather than some other sort. const layerInInputOrder = { id: 'test-layer', layer: 'thematic', @@ -865,9 +863,6 @@ describe('useTableData sorting', () => { }) describe('sorting by selection state', () => { - // `id` must live inside `properties` - useTableData flattens rows via - // `{...d.properties}`, so a top-level `id` (as used by the rest of - // this describe block's `mockLayer`) would not survive flattening. const layerWithIds = { id: 'test-layer', layer: 'thematic', @@ -887,7 +882,7 @@ describe('useTableData sorting', () => { () => useTableData({ layer: layerWithIds, - sortField: SELECTED_SORT_KEY, + sortField: SENTINEL_SELECTED_ROW, sortDirection, selectedIdSet: new Set(['2', '4']), }), @@ -1149,7 +1144,6 @@ describe('useTableData columnOptions', () => { { value: 'ou2' }, ]) expect(current.columnOptions.parentName).toEqual([{ value: 'Country' }]) - // Numeric columns (previously excluded outright) qualify too now. expect(current.columnOptions.rawValue).toEqual([ { value: '10' }, { value: '20' }, @@ -1205,7 +1199,35 @@ describe('useTableData columnOptions', () => { ]) }) - test('includes a NOT_SET_VALUE option when some rows have a blank value', () => { + test('sorts range column options by their parsed bounds, not lexically', () => { + const layer = { + layer: 'thematic', + dataFilters: null, + data: ['90 - 120', '9 - 12', '10 - 20'].map((range, i) => ({ + properties: { + id: `ou${i}`, + name: `Org unit ${i}`, + rawValue: 1, + legend: 'High', + range, + level: 1, + parentName: 'Country', + type: 'Point', + color: '#ff0000', + }, + })), + } + + const { current } = renderTableData(layer) + + expect(current.columnOptions.range).toEqual([ + { value: '9 - 12' }, + { value: '10 - 20' }, + { value: '90 - 120' }, + ]) + }) + + test('includes a SENTINEL_NO_VALUE option when some rows have a blank value', () => { const layer = { layer: 'orgUnit', dataFilters: null, @@ -1243,7 +1265,7 @@ describe('useTableData columnOptions', () => { const { current } = renderTableData(layer) expect(current.columnOptions.parentName).toEqual([ - { value: NOT_SET_VALUE }, + { value: SENTINEL_NO_VALUE }, { value: 'Country' }, ]) }) @@ -1282,6 +1304,66 @@ describe('useTableData columnOptions', () => { { value: 'CONFIRMED' }, ]) }) + + test('matches the currently sorted column direction, leaving other columns ascending', () => { + const layer = { + layer: 'thematic', + dataFilters: null, + data: [ + { + properties: { + id: 'ou1', + name: 'Org unit 1', + rawValue: 10, + legend: 'High', + level: 1, + parentName: 'Country', + type: 'Point', + }, + }, + { + properties: { + id: 'ou2', + name: 'Org unit 2', + rawValue: 20, + legend: 'Low', + level: 1, + parentName: 'Country', + type: 'Point', + }, + }, + ], + } + + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'desc', + }), + { + wrapper: ({ children }) => ( + <Provider store={mockStore(store)}>{children}</Provider> + ), + } + ) + + // Sorted column (name, desc) is reversed to match... + expect(result.current.columnOptions.name).toEqual([ + { value: 'Org unit 2' }, + { value: 'Org unit 1' }, + ]) + // ...while every other column stays in its default ascending order. + expect(result.current.columnOptions.rawValue).toEqual([ + { value: '10' }, + { value: '20' }, + ]) + expect(result.current.columnOptions.legend).toEqual([ + { value: 'High' }, + { value: 'Low' }, + ]) + }) }) describe('useTableData globalSearch', () => { diff --git a/src/components/datatable/styles/BottomPanel.module.css b/src/components/datatable/styles/BottomPanel.module.css index d9e75af487..3ef7841083 100644 --- a/src/components/datatable/styles/BottomPanel.module.css +++ b/src/components/datatable/styles/BottomPanel.module.css @@ -5,7 +5,7 @@ width: 100%; height: var(--data-table-height); z-index: 1040; - background: #fff; + background: var(--colors-white); display: flex; flex-direction: column; } @@ -88,8 +88,8 @@ .clearBadge { position: absolute; - bottom: 0px; - right: 0px; + bottom: 0; + right: 0; width: 8px; height: 8px; background: var(--colors-grey100); @@ -171,6 +171,7 @@ background-color: var(--colors-blue200); } +/* !important beats @dhis2/ui's own ColorPicker field margin. */ .highlightColorPicker { margin-bottom: 0 !important; flex-shrink: 0; @@ -180,6 +181,7 @@ top: -1px; } +/* !important beats @dhis2/ui's own ColorPicker label size. */ .highlightColorPicker label { box-sizing: border-box; overflow: hidden; diff --git a/src/components/datatable/styles/DataTable.module.css b/src/components/datatable/styles/DataTable.module.css index 19eaf7cfce..1a9bb9e78f 100644 --- a/src/components/datatable/styles/DataTable.module.css +++ b/src/components/datatable/styles/DataTable.module.css @@ -2,7 +2,7 @@ height: 1px; } -.dataTable[data-test='dhis2-uicore-datatable'] { +.dataTable { border: none !important; } @@ -61,18 +61,15 @@ td.checkboxCell { white-space: nowrap; } -/* FilterDropdownPopover (Layer + Popper, see FilterInput.jsx) renders no - background/elevation of its own - unlike @dhis2/ui's Popover, which this - replaced, styling that is left entirely to the caller. */ .selectionFilterPopover { padding: var(--spacers-dp8); min-width: 140px; background-color: var(--colors-white); border-radius: 4px; - box-shadow: 0 4px 12px rgba(12, 14, 16, 0.15), - 0 0 0 1px rgba(12, 14, 16, 0.05); + box-shadow: var(--elevations-popover); } +/* !important beats @dhis2/ui's own Checkbox label font-size. */ .selectionFilterPopover :global(label) { font-size: 11px !important; } @@ -81,7 +78,6 @@ td.selected { background-color: var(--colors-blue050); } -/* Declared after .selected so a hovered and selected row still shows the hover color */ td.hovered { background-color: var(--colors-blue100); } @@ -163,12 +159,6 @@ td.hovered { color: var(--colors-grey400); } -/* The filter icon button is a required prop of DataTableColumnHeader - whenever a filter is passed, but the filter itself is always shown (see - the "Filtering: Inline" pattern), so the icon has no real toggle behavior. - Remove it from layout entirely instead of just hiding it, to reclaim the - header's horizontal space. Applies to the checkbox column too, since it - also has its own filter (the selection filter). */ .columnHeader > :global(span.container) > :global(span.top) diff --git a/src/components/datatable/styles/FilterInput.module.css b/src/components/datatable/styles/FilterInput.module.css index 449414eca8..e3fec49328 100644 --- a/src/components/datatable/styles/FilterInput.module.css +++ b/src/components/datatable/styles/FilterInput.module.css @@ -5,11 +5,6 @@ width: 100%; } -/* The trigger and the dropdown's search field are the same <Input> now - (see FilterInput.jsx) - style it to match the old compact trigger - button's size regardless of which role it's playing at the moment. - Padding-right for the built-in clear button is handled by @dhis2/ui's - own `.input-clearable input` rule - don't compete with it here. */ .filterTrigger :global(input.dense) { padding: 4px 6px; font-size: 11px; @@ -19,20 +14,12 @@ color: var(--colors-grey400); } -/* Every column's dropdown opens on the same side (see dropdownSide in - FilterInput.jsx - below by default, or above if the shared header row - doesn't have room to open downward) - this supplies the same look - @dhis2/ui's Popover would (white background, elevation shadow, rounded - corners), minus whichever corner touches the trigger, which is squared - off so it reads as a continuation of the input rather than a separate - floating box. */ .dropdownPopper { background-color: var(--colors-white); border-radius: 4px; border-top-left-radius: 0; border-top-right-radius: 0; - box-shadow: 0 4px 12px rgba(12, 14, 16, 0.15), - 0 0 0 1px rgba(12, 14, 16, 0.05); + box-shadow: var(--elevations-popover); } .dropdownPopperAbove { @@ -45,23 +32,11 @@ .searchableFilterPopover { display: flex; flex-direction: column; - gap: var(--spacers-dp2); padding: var(--spacers-dp8); min-width: 140px; box-sizing: border-box; } -/* When the dropdown opens above the trigger, flip the whole stack so the - real value list ends up farthest from the input and the custom-filter - row ends up right next to it, same as when it opens below - dropdownSide - is computed once in JS (see FilterInput.jsx), so this is a plain class - toggle rather than a Popper-attribute selector. Ties in flex `order` - fall back to DOM order, which would put "Any value"/"No value" closest - to the input instead of the custom-filter row - giving each group its - own order keeps the same relative arrangement as the non-flipped case, - just mirrored. This also flips which edge of the pinned-options group - gets the divider: it should always separate that group from the real - value list, regardless of which one ends up visually on top. */ .reversedOrder .multiSelectPopover { order: 0; } @@ -76,22 +51,6 @@ order: 2; } -/* "Any value" and "No value" are grouped together above a divider, - separate from the column's real distinct values below - no horizontal - padding of its own (the 8px inset already comes from - .searchableFilterPopover's padding, same as .multiSelectPopover below - - adding more here would indent these two checkboxes further than the - rest) and the same font-size as the list underneath, so they read as - part of the same control rather than a visually distinct element bolted - on top. `min-width: 0` overrides the flex item default of `auto` (which - floors a flex item's size at its content's min-content width) - without - it, this row's own content could force `.searchableFilterPopover` wider - than the trigger input on narrow columns even though "Any value"/"No - value" are short, fixed strings that never need the extra room the - real-value list below is allowed to take. `width: 100%` makes it match - the popover's own (input-matching) width explicitly, rather than - relying only on the flex stretch default - belt-and-braces against this - row silently narrowing the popover below the trigger input's width. */ .pinnedOptions { position: relative; width: 100%; @@ -100,12 +59,11 @@ border-bottom: 1px solid var(--colors-grey300); } +/* !important beats @dhis2/ui's own Checkbox label font-size. */ .pinnedOptions :global(label) { font-size: 11px !important; } -/* "Reverse selection" - a ghost icon button, absolutely positioned over - the "Any value" row so it never contributes to that row's own width. */ .reverseSelectionButton { position: absolute; top: 0; @@ -137,6 +95,7 @@ overflow-y: auto; } +/* Same override as .pinnedOptions above. */ .multiSelectPopover :global(label) { font-size: 11px !important; } @@ -145,10 +104,7 @@ font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', 'Consolas', monospace; } -/* "Any value" (matches any non-blank value) and "No value" (the blank-cell - sentinel) are opposite ends of the same predicate, not real data values - - italicize and mute them so they read as special/meta options rather than - something that could appear in the underlying data. */ +/* !important beats @dhis2/ui's own Checkbox label color. */ .specialOption :global(label) { color: var(--colors-grey700) !important; font-style: italic; @@ -161,9 +117,6 @@ color: var(--colors-grey600); } -/* Styled like an active/selectable row (blue, the app's convention for - "actionable/selected" - see td.selected in DataTable.module.css) rather - than a warning, since applying a filter isn't an exceptional action. */ .customFilterRow { display: flex; align-items: center; @@ -203,10 +156,6 @@ background: var(--colors-grey100); } -/* Matches DataTable.module.css's .topTooltipContent - this is the same - no-flip tooltip pattern (see FilterHelpTooltip in FilterInput.jsx), just - rendering its own dark tooltip box instead of relying on @dhis2/ui's - Tooltip (which supplies that styling itself). */ .filterHelpTooltip { z-index: 2000; max-width: 300px; diff --git a/src/components/datatable/useColumnWidths.js b/src/components/datatable/useColumnWidths.js new file mode 100644 index 0000000000..2890420d80 --- /dev/null +++ b/src/components/datatable/useColumnWidths.js @@ -0,0 +1,66 @@ +import { useEffect, useRef, useState } from 'react' + +export const useColumnWidths = ({ availableWidth, headers, error }) => { + const headerRowRef = useRef(null) + const minColumnWidthsRef = useRef([]) + const [columnWidths, setColumnWidths] = useState([]) + + useEffect(() => { + // Measure column widths in auto layout, then switch to fixed to prevent content shift during virtual scrolling + if (columnWidths.length === 0 && headerRowRef.current) { + const frameId = requestAnimationFrame(() => { + if (!headerRowRef.current) { + return + } + + const measuredColumnWidths = [] + + const dataCells = Array.from(headerRowRef.current.cells).slice( + 1 + ) + + for (const cell of dataCells) { + const rect = cell.getBoundingClientRect() + measuredColumnWidths.push(Math.floor(rect.width)) + } + + minColumnWidthsRef.current = measuredColumnWidths + setColumnWidths(measuredColumnWidths) + }) + + return () => cancelAnimationFrame(frameId) + } + }, [columnWidths]) + + useEffect(() => { + // Reset to auto layout for re-measurement when headers change + if (!error) { + minColumnWidthsRef.current = [] + setColumnWidths([]) + } + }, [headers, error]) + + useEffect(() => { + // Scale column widths proportionally on resize, clamped to initial measured widths + if (!error) { + setColumnWidths((prev) => { + if (prev.length === 0) { + return prev + } + const prevTotal = prev.reduce((sum, w) => sum + w, 0) + if (prevTotal === 0 || availableWidth === 0) { + return [] + } + const minWidths = minColumnWidthsRef.current + return prev.map((w, i) => + Math.max( + minWidths[i] ?? 0, + Math.round((w / prevTotal) * availableWidth) + ) + ) + }) + } + }, [availableWidth, error]) + + return { headerRowRef, columnWidths } +} diff --git a/src/components/datatable/useRowSelection.js b/src/components/datatable/useRowSelection.js new file mode 100644 index 0000000000..870bda6bd1 --- /dev/null +++ b/src/components/datatable/useRowSelection.js @@ -0,0 +1,57 @@ +import { useCallback, useMemo } from 'react' +import { useDispatch } from 'react-redux' +import { selectAllFeatures, clearSelection } from '../../actions/selection.js' + +export const getReversedSelection = (selectedIds, allRowIds) => { + const selectedIdSet = new Set(selectedIds) + const allRowIdSet = new Set(allRowIds) + const offViewSelected = selectedIds.filter((id) => !allRowIdSet.has(id)) + const invertedVisible = allRowIds.filter((id) => !selectedIdSet.has(id)) + return [...offViewSelected, ...invertedVisible] +} + +export const useRowSelection = ({ + selectedIds, + selectedIdSet, + allRowIds, + layerId, +}) => { + const dispatch = useDispatch() + + const allRowIdSet = useMemo(() => new Set(allRowIds), [allRowIds]) + + const isAllSelected = useMemo( + () => + allRowIds.length > 0 && + allRowIds.every((id) => selectedIdSet.has(id)), + [allRowIds, selectedIdSet] + ) + + const onToggleSelectAll = useCallback(() => { + const nextIds = isAllSelected + ? selectedIds.filter((id) => !allRowIdSet.has(id)) + : [...new Set([...selectedIds, ...allRowIds])] + + if (nextIds.length) { + dispatch(selectAllFeatures(nextIds, layerId)) + } else { + dispatch(clearSelection()) + } + }, [dispatch, isAllSelected, allRowIds, allRowIdSet, selectedIds, layerId]) + + const onReverseSelection = useCallback(() => { + const nextIds = getReversedSelection(selectedIds, allRowIds) + + if (nextIds.length) { + dispatch(selectAllFeatures(nextIds, layerId)) + } else { + dispatch(clearSelection()) + } + }, [dispatch, selectedIds, allRowIds, layerId]) + + return { + isAllSelected, + onToggleSelectAll, + onReverseSelection, + } +} diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index 19ffa18786..21a20c0802 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -1,6 +1,7 @@ import i18n from '@dhis2/d2-i18n' import { useMemo, useRef } from 'react' import { useSelector } from 'react-redux' +import { SENTINEL_NO_VALUE, SORT_ASCENDING } from '../../constants/dataTable.js' import { EVENT_LAYER, THEMATIC_LAYER, @@ -17,21 +18,10 @@ import { numberValueTypes } from '../../constants/valueTypes.js' import { hasClasses } from '../../util/earthEngine.js' import { filterByGlobalSearch, filterData } from '../../util/filter.js' import { getGeojsonDisplayData, isFeatureInBounds } from '../../util/geojson.js' -import { parseRange } from '../../util/legend.js' import { getRoundToPrecisionFn, getPrecision } from '../../util/numbers.js' +import { compareColumnOptionValues, compareRows } from '../../util/tableSort.js' import { isValidUid } from '../../util/uid.js' -const ASCENDING = 'asc' - -// Sentinel sortField value for the checkbox column - not a real dataKey -export const SELECTED_SORT_KEY = '__selected' - -// Sentinel option value representing missing/blank cells in a column's -// distinct-value list - matches filterData's existing null/undefined -> -// empty-string coercion (src/util/filter.js), so no filtering logic changes -// are needed to support it. -export const NOT_SET_VALUE = '' - const TYPE_NUMBER = 'number' const TYPE_STRING = 'string' const TYPE_DATE = 'date' @@ -201,85 +191,6 @@ const EMPTY_AGGREGATIONS = {} const EMPTY_LAYER = {} const EMPTY_COLUMN_OPTIONS = {} -// Distinct values are stored as strings (they're sourced alongside string -// columns) - sort numeric columns by numeric value rather than lexically -// (so 2 sorts before 10), everything else keeps the default string -// ordering. NOT_SET_VALUE always sorts first regardless of column type. -const compareColumnOptionValues = (a, b, type) => { - if (a === NOT_SET_VALUE) { - return -1 - } - if (b === NOT_SET_VALUE) { - return 1 - } - if (type === TYPE_NUMBER) { - return Number(a) - Number(b) - } - if (a < b) { - return -1 - } - if (a > b) { - return 1 - } - return 0 -} - -// Ascending (the default on first click) puts selected rows first, since -// that's what a user clicking this is after. -const compareBySelected = (a, b, { selectedIdSet, sortDirection }) => { - const aSelected = selectedIdSet?.has(a.id) ? 1 : 0 - const bSelected = selectedIdSet?.has(b.id) ? 1 : 0 - return sortDirection === ASCENDING - ? bSelected - aSelected - : aSelected - bSelected -} - -const compareRangeValues = (aVal, bVal, sortDirection) => { - const [aStart, aEnd] = parseRange(aVal) - const [bStart, bEnd] = parseRange(bVal) - const startDiff = - sortDirection === ASCENDING ? aStart - bStart : bStart - aStart - if (startDiff !== 0) { - return startDiff - } - return sortDirection === ASCENDING ? aEnd - bEnd : bEnd - aEnd -} - -const compareFieldValues = (aVal, bVal, { sortField, sortDirection }) => { - // All undefined values should be sorted to the end - if (aVal === undefined && bVal === undefined) { - return 0 - } - if (aVal === undefined) { - return 1 - } - if (bVal === undefined) { - return -1 - } - if (typeof aVal === TYPE_NUMBER) { - return sortDirection === ASCENDING ? aVal - bVal : bVal - aVal - } - if (sortField === RANGE) { - return compareRangeValues(aVal, bVal, sortDirection) - } - // TODO: Make sure sorting works across different locales - return sortDirection === ASCENDING - ? aVal.localeCompare(bVal) - : bVal.localeCompare(aVal) -} - -const compareRows = (a, b, options) => { - const { sortField } = options - // "None" (third click of the cycle) - fall back to natural order - if (!sortField) { - return a.index - b.index - } - if (sortField === SELECTED_SORT_KEY) { - return compareBySelected(a, b, options) - } - return compareFieldValues(a[sortField], b[sortField], options) -} - export const useTableData = ({ layer, sortField, @@ -341,8 +252,7 @@ export const useTableData = ({ .map((d, index) => ({ ...(d.properties || d), ...aggregations[d.id], - // Row-order tie-breaker for compareRows when no sortField is - // set - not a real column, not shown or filterable in the table. + // Row-order tie-breaker for compareRows when no sortField is set index, })) // boundsDependency intentionally proxies mapBounds only while the toggle is on @@ -421,10 +331,6 @@ export const useTableData = ({ layerHeaders, ]) - // The full distinct-value list for every column, regardless of size - - // the filter popover virtualizes its checkbox list (renders only what's - // near the viewport) and narrows live as the user searches, so there's - // no longer a rendering-cost reason to cap or omit this. const columnOptions = useMemo(() => { if (!headers?.length || !dataWithAggregations?.length) { return EMPTY_COLUMN_OPTIONS @@ -437,20 +343,28 @@ export const useTableData = ({ const val = item[dataKey] seen.add( val === undefined || val === null || val === '' - ? NOT_SET_VALUE + ? SENTINEL_NO_VALUE : String(val) ) } if (seen.size > 0) { + const direction = + dataKey === sortField ? sortDirection : SORT_ASCENDING result[dataKey] = Array.from(seen) - .sort((a, b) => compareColumnOptionValues(a, b, type)) + .sort((a, b) => + compareColumnOptionValues(a, b, { + dataKey, + type, + direction, + }) + ) .map((value) => ({ value })) } }) return Object.keys(result).length ? result : EMPTY_COLUMN_OPTIONS - }, [headers, dataWithAggregations]) + }, [headers, dataWithAggregations, sortField, sortDirection]) const rows = useMemo(() => { if (errorCode.current) { diff --git a/src/components/map/layers/__tests__/Layer.spec.js b/src/components/map/layers/__tests__/Layer.spec.js index bb5d18d1fb..77d7660b8f 100644 --- a/src/components/map/layers/__tests__/Layer.spec.js +++ b/src/components/map/layers/__tests__/Layer.spec.js @@ -1,9 +1,5 @@ import Layer from '../Layer.js' -// Layer's constructor touches this.context.map (only available once mounted -// by React), but getVisibleIds/getSelectedIds only read this.props, so an -// un-constructed instance (no context, no maps-gl layer) is enough to test -// this method in isolation. const createLayer = (props) => { const instance = Object.create(Layer.prototype) instance.props = props diff --git a/src/constants/dataTable.js b/src/constants/dataTable.js new file mode 100644 index 0000000000..10d2e59675 --- /dev/null +++ b/src/constants/dataTable.js @@ -0,0 +1,6 @@ +export const SENTINEL_NO_VALUE = '' // Matches filterData's existing null/undefined -> empty-string coercion (src/util/filter.js) +export const SENTINEL_ANY_VALUE = '__any_value__' +export const SENTINEL_SELECTED_ROW = '__selected__' + +export const SORT_ASCENDING = 'asc' +export const SORT_DESCENDING = 'desc' diff --git a/src/constants/selection.js b/src/constants/selection.js index 1be35537b4..68174c2b82 100644 --- a/src/constants/selection.js +++ b/src/constants/selection.js @@ -1,4 +1,2 @@ -// Values for state.ui.selectionFilter - controls which features are shown -// (in both the data table and on the map) based on their selection state. export const SELECTION_FILTER_SELECTED = 'selected' export const SELECTION_FILTER_NOT_SELECTED = 'not-selected' diff --git a/src/util/__tests__/filter.spec.js b/src/util/__tests__/filter.spec.js index c2490ea941..9f376df8ea 100644 --- a/src/util/__tests__/filter.spec.js +++ b/src/util/__tests__/filter.spec.js @@ -1,4 +1,5 @@ -import { filterByGlobalSearch, filterData, ANY_VALUE_KEY } from '../filter.js' +import { SENTINEL_ANY_VALUE } from '../../constants/dataTable.js' +import { filterByGlobalSearch, filterData } from '../filter.js' describe('filterData', () => { it('should return the original data if no filters are provided', () => { @@ -97,15 +98,15 @@ describe('filterData', () => { expect(filterData(data, filters)).toEqual([{ a: 'High', b: 'horse' }]) }) - it('ANY_VALUE_KEY matches every row with a non-blank value, the opposite of the blank sentinel', () => { + it('SENTINEL_ANY_VALUE matches every row with a non-blank value, the opposite of the blank sentinel', () => { const data = [{ a: 'High' }, { a: '' }, { a: null }, { a: 'Low' }] - const filters = { a: [ANY_VALUE_KEY] } + const filters = { a: [SENTINEL_ANY_VALUE] } expect(filterData(data, filters)).toEqual([{ a: 'High' }, { a: 'Low' }]) }) - it('combining ANY_VALUE_KEY with the blank sentinel ("") matches every row', () => { + it('combining SENTINEL_ANY_VALUE with the blank sentinel ("") matches every row', () => { const data = [{ a: 'High' }, { a: '' }, { a: null }] - const filters = { a: [ANY_VALUE_KEY, ''] } + const filters = { a: [SENTINEL_ANY_VALUE, ''] } expect(filterData(data, filters)).toEqual(data) }) }) diff --git a/src/util/__tests__/filterSelection.spec.js b/src/util/__tests__/filterSelection.spec.js new file mode 100644 index 0000000000..7be52a8436 --- /dev/null +++ b/src/util/__tests__/filterSelection.spec.js @@ -0,0 +1,145 @@ +import { + SENTINEL_ANY_VALUE, + SENTINEL_NO_VALUE, +} from '../../constants/dataTable.js' +import { + getInvertibleValues, + reverseSelection, + toggleAnyValue, + toggleRealValue, +} from '../filterSelection.js' + +describe('getInvertibleValues', () => { + it('includes the "No value" sentinel ahead of the real values when the column has blank cells', () => { + expect(getInvertibleValues(true, ['High', 'Low'])).toEqual([ + SENTINEL_NO_VALUE, + 'High', + 'Low', + ]) + }) + + it('omits the sentinel entirely when the column has no blank cells', () => { + expect(getInvertibleValues(false, ['High', 'Low'])).toEqual([ + 'High', + 'Low', + ]) + }) +}) + +describe('toggleAnyValue', () => { + it('activates "Any value" alone from an empty selection', () => { + expect(toggleAnyValue([])).toEqual([SENTINEL_ANY_VALUE]) + }) + + it('activates "Any value" while preserving an already-set "No value"', () => { + expect(toggleAnyValue([SENTINEL_NO_VALUE])).toEqual([ + SENTINEL_ANY_VALUE, + SENTINEL_NO_VALUE, + ]) + }) + + it('clears everything when deactivating "Any value" with "No value" unset', () => { + expect(toggleAnyValue([SENTINEL_ANY_VALUE])).toEqual([]) + }) + + it('deactivating "Any value" preserves "No value" independently', () => { + expect(toggleAnyValue([SENTINEL_ANY_VALUE, SENTINEL_NO_VALUE])).toEqual( + [SENTINEL_NO_VALUE] + ) + }) +}) + +describe('toggleRealValue', () => { + const realValues = ['High', 'Medium', 'Low'] + + it('adds a value to the selection', () => { + expect(toggleRealValue(['High'], 'Low', realValues)).toEqual([ + 'High', + 'Low', + ]) + }) + + it('removes an already-selected value', () => { + expect(toggleRealValue(['High', 'Low'], 'Low', realValues)).toEqual([ + 'High', + ]) + }) + + it('collapses to "Any value" once every real value ends up checked', () => { + expect(toggleRealValue(['High', 'Medium'], 'Low', realValues)).toEqual([ + SENTINEL_ANY_VALUE, + ]) + }) + + it('collapsing to "Any value" preserves "No value" if it was set', () => { + expect( + toggleRealValue( + ['High', 'Medium', SENTINEL_NO_VALUE], + 'Low', + realValues + ) + ).toEqual([SENTINEL_ANY_VALUE, SENTINEL_NO_VALUE]) + }) + + it('unticking one real value while "Any value" is active splits it back into an explicit list', () => { + expect( + toggleRealValue([SENTINEL_ANY_VALUE], 'Medium', realValues) + ).toEqual(['High', 'Low']) + }) + + it('unticking a real value while "Any value" is active preserves "No value" if it was set', () => { + expect( + toggleRealValue( + [SENTINEL_ANY_VALUE, SENTINEL_NO_VALUE], + 'Medium', + realValues + ) + ).toEqual(['High', 'Low', SENTINEL_NO_VALUE]) + }) +}) + +describe('reverseSelection', () => { + const realValues = ['High', 'Medium', 'Low'] + + it('selects "Any value" when nothing is currently selected', () => { + expect(reverseSelection([], realValues, false)).toEqual([ + SENTINEL_ANY_VALUE, + ]) + }) + + it("flips each value's checked state relative to the current selection", () => { + expect(reverseSelection(['High'], realValues, false)).toEqual([ + 'Medium', + 'Low', + ]) + }) + + it('includes "No value" in the values it flips', () => { + expect( + reverseSelection(['Country'], ['Country', 'District'], true) + ).toEqual([SENTINEL_NO_VALUE, 'District']) + }) + + it('collapses to "Any value" (plus "No value" if that was unset) when reversing ends up ticking every real value', () => { + expect(reverseSelection([], ['Country'], true)).toEqual([ + SENTINEL_ANY_VALUE, + SENTINEL_NO_VALUE, + ]) + }) + + it('clears everything when reversing turns off an active "Any value" (there is no way to represent "all unticked" while it stays on)', () => { + expect( + reverseSelection(['High', SENTINEL_ANY_VALUE], realValues, false) + ).toEqual([]) + }) + + it('reversing while "Any value" is active flips "No value" independently, since it is unaffected by "Any value"', () => { + expect( + reverseSelection([SENTINEL_ANY_VALUE], ['Country'], true) + ).toEqual([SENTINEL_NO_VALUE]) + }) + + it('is a no-op array when there are no invertible values', () => { + expect(reverseSelection([], [], false)).toEqual([]) + }) +}) diff --git a/src/util/__tests__/tableSort.spec.js b/src/util/__tests__/tableSort.spec.js new file mode 100644 index 0000000000..b34c46350b --- /dev/null +++ b/src/util/__tests__/tableSort.spec.js @@ -0,0 +1,190 @@ +import { + SENTINEL_NO_VALUE, + SENTINEL_SELECTED_ROW, +} from '../../constants/dataTable.js' +import { + compareBySelected, + compareColumnOptionValues, + compareFieldValues, + compareRangeValues, + compareRows, +} from '../tableSort.js' + +describe('compareFieldValues', () => { + it('sorts numbers ascending', () => { + expect( + compareFieldValues(5, 10, { sortDirection: 'asc' }) + ).toBeLessThan(0) + }) + + it('sorts numbers descending', () => { + expect( + compareFieldValues(5, 10, { sortDirection: 'desc' }) + ).toBeGreaterThan(0) + }) + + it('sorts strings ascending, locale-aware', () => { + expect( + compareFieldValues('Apple', 'Banana', { sortDirection: 'asc' }) + ).toBeLessThan(0) + }) + + it('sorts strings descending', () => { + expect( + compareFieldValues('Apple', 'Banana', { sortDirection: 'desc' }) + ).toBeGreaterThan(0) + }) + + it('sorts undefined values to the end regardless of direction', () => { + expect( + compareFieldValues(undefined, 5, { sortDirection: 'asc' }) + ).toBeGreaterThan(0) + expect( + compareFieldValues(5, undefined, { sortDirection: 'desc' }) + ).toBeLessThan(0) + }) + + it('treats two undefined values as equal', () => { + expect( + compareFieldValues(undefined, undefined, { sortDirection: 'asc' }) + ).toBe(0) + }) + + it('delegates to compareRangeValues for the Range column', () => { + expect( + compareFieldValues('5-10', '1-3', { + sortField: 'range', + sortDirection: 'asc', + }) + ).toBeGreaterThan(0) + }) +}) + +describe('compareRangeValues', () => { + it('compares by range start first', () => { + expect(compareRangeValues('10-20', '1-5', 'asc')).toBeGreaterThan(0) + }) + + it('falls back to range end when starts are equal', () => { + expect(compareRangeValues('1-20', '1-5', 'asc')).toBeGreaterThan(0) + }) + + it('reverses order when descending', () => { + expect(compareRangeValues('10-20', '1-5', 'desc')).toBeLessThan(0) + }) +}) + +describe('compareBySelected', () => { + const selectedIdSet = new Set(['1']) + + it('puts selected rows first when ascending', () => { + expect( + compareBySelected( + { id: '1' }, + { id: '2' }, + { selectedIdSet, sortDirection: 'asc' } + ) + ).toBeLessThan(0) + }) + + it('puts selected rows last when descending', () => { + expect( + compareBySelected( + { id: '1' }, + { id: '2' }, + { selectedIdSet, sortDirection: 'desc' } + ) + ).toBeGreaterThan(0) + }) +}) + +describe('compareRows', () => { + it('falls back to natural (index) order when there is no sortField', () => { + expect( + compareRows({ index: 2 }, { index: 0 }, { sortField: null }) + ).toBeGreaterThan(0) + }) + + it('sorts by the selected-row sentinel field via compareBySelected', () => { + const selectedIdSet = new Set(['1']) + expect( + compareRows( + { id: '1' }, + { id: '2' }, + { + sortField: SENTINEL_SELECTED_ROW, + sortDirection: 'asc', + selectedIdSet, + } + ) + ).toBeLessThan(0) + }) + + it('otherwise sorts by the named field via compareFieldValues', () => { + expect( + compareRows( + { rawValue: 5 }, + { rawValue: 10 }, + { sortField: 'rawValue', sortDirection: 'asc' } + ) + ).toBeLessThan(0) + }) +}) + +describe('compareColumnOptionValues', () => { + it('sorts the blank-cell sentinel first, regardless of direction', () => { + expect( + compareColumnOptionValues(SENTINEL_NO_VALUE, 'High', { + dataKey: 'legend', + type: 'string', + direction: 'desc', + }) + ).toBeLessThan(0) + expect( + compareColumnOptionValues('High', SENTINEL_NO_VALUE, { + dataKey: 'legend', + type: 'string', + direction: 'asc', + }) + ).toBeGreaterThan(0) + }) + + it('compares numeric-typed columns numerically', () => { + expect( + compareColumnOptionValues('10', '2', { + dataKey: 'value', + type: 'number', + direction: 'asc', + }) + ).toBeGreaterThan(0) + }) + + it('compares string-typed columns lexically', () => { + expect( + compareColumnOptionValues('Apple', 'Banana', { + dataKey: 'legend', + type: 'string', + direction: 'asc', + }) + ).toBeLessThan(0) + }) + + it('delegates Range columns to compareRangeValues', () => { + expect( + compareColumnOptionValues('10-20', '1-5', { + dataKey: 'range', + type: 'string', + direction: 'asc', + }) + ).toBeGreaterThan(0) + }) + + it('defaults to ascending when no direction is given', () => { + expect( + compareColumnOptionValues('Apple', 'Banana', { + dataKey: 'legend', + type: 'string', + }) + ).toBeLessThan(0) + }) +}) diff --git a/src/util/filter.js b/src/util/filter.js index 1c1d823422..f4793a2964 100644 --- a/src/util/filter.js +++ b/src/util/filter.js @@ -1,9 +1,4 @@ -// Pseudo-value for multi-select filters meaning "the field has any -// non-blank value" - the logical opposite of selecting the blank-cell -// sentinel (''). Works generically even for columns with too many distinct -// values to list individually, since it's a predicate ("is it blank or -// not?"), not a membership check against a known list of values. -export const ANY_VALUE_KEY = '__any_value__' +import { SENTINEL_ANY_VALUE } from '../constants/dataTable.js' // Filters an array of object with a set of filters export const filterData = (data, filters) => { @@ -28,7 +23,7 @@ export const filterData = (data, filters) => { return ( filter.length === 0 || filter.includes(stringValue) || - (stringValue !== '' && filter.includes(ANY_VALUE_KEY)) + (stringValue !== '' && filter.includes(SENTINEL_ANY_VALUE)) ) } @@ -57,8 +52,7 @@ export const numericFilter = (value, filter) => { }) } -// Matches rows where any of the given string-typed fields contains -// the search string (case-insensitive) +// Case-insensitive match against any of the given string fields export const filterByGlobalSearch = (data, searchString, stringDataKeys) => { if (!searchString?.trim() || !stringDataKeys?.length) { return data diff --git a/src/util/filterSelection.js b/src/util/filterSelection.js new file mode 100644 index 0000000000..cd2aa564b7 --- /dev/null +++ b/src/util/filterSelection.js @@ -0,0 +1,66 @@ +import { + SENTINEL_ANY_VALUE, + SENTINEL_NO_VALUE, +} from '../constants/dataTable.js' + +export const getInvertibleValues = (hasNotSetOption, realValues) => + hasNotSetOption ? [SENTINEL_NO_VALUE, ...realValues] : realValues + +export const toggleAnyValue = (selected) => { + const anyValueActive = selected.includes(SENTINEL_ANY_VALUE) + const keepNotSet = selected.includes(SENTINEL_NO_VALUE) + if (anyValueActive) { + return keepNotSet ? [SENTINEL_NO_VALUE] : [] + } + return keepNotSet + ? [SENTINEL_ANY_VALUE, SENTINEL_NO_VALUE] + : [SENTINEL_ANY_VALUE] +} + +export const toggleRealValue = (selected, value, realValues) => { + const anyValueActive = selected.includes(SENTINEL_ANY_VALUE) + const keepNotSet = selected.includes(SENTINEL_NO_VALUE) + + if (anyValueActive) { + const next = realValues.filter((v) => v !== value) + return keepNotSet ? [...next, SENTINEL_NO_VALUE] : next + } + + const next = selected.includes(value) + ? selected.filter((v) => v !== value) + : [...selected, value] + + const allRealValuesChecked = + realValues.length > 0 && realValues.every((v) => next.includes(v)) + if (!allRealValuesChecked) { + return next + } + return next.includes(SENTINEL_NO_VALUE) + ? [SENTINEL_ANY_VALUE, SENTINEL_NO_VALUE] + : [SENTINEL_ANY_VALUE] +} + +export const reverseSelection = (selected, realValues, hasNotSetOption) => { + const anyValueActive = selected.includes(SENTINEL_ANY_VALUE) + + const invertedRealValues = anyValueActive + ? [] + : realValues.filter((v) => !selected.includes(v)) + const invertedNotSet = + hasNotSetOption && !selected.includes(SENTINEL_NO_VALUE) + + const allRealValuesInverted = + !anyValueActive && + realValues.length > 0 && + invertedRealValues.length === realValues.length + + if (allRealValuesInverted) { + return invertedNotSet + ? [SENTINEL_ANY_VALUE, SENTINEL_NO_VALUE] + : [SENTINEL_ANY_VALUE] + } + + return invertedNotSet + ? [SENTINEL_NO_VALUE, ...invertedRealValues] + : invertedRealValues +} diff --git a/src/util/tableSort.js b/src/util/tableSort.js new file mode 100644 index 0000000000..ea944c7b38 --- /dev/null +++ b/src/util/tableSort.js @@ -0,0 +1,86 @@ +import { + SENTINEL_NO_VALUE, + SENTINEL_SELECTED_ROW, + SORT_ASCENDING, +} from '../constants/dataTable.js' +import { parseRange } from './legend.js' + +const RANGE = 'range' + +export const compareColumnOptionValues = ( + a, + b, + { dataKey, type, direction = SORT_ASCENDING } +) => { + if (a === SENTINEL_NO_VALUE) { + return -1 + } + if (b === SENTINEL_NO_VALUE) { + return 1 + } + if (dataKey === RANGE) { + return compareRangeValues(a, b, direction) + } + const comparison = + type === 'number' ? Number(a) - Number(b) : a < b ? -1 : a > b ? 1 : 0 + return direction === SORT_ASCENDING ? comparison : -comparison +} + +// Ascending (the default on first click) puts selected rows first +export const compareBySelected = (a, b, { selectedIdSet, sortDirection }) => { + const aSelected = selectedIdSet?.has(a.id) ? 1 : 0 + const bSelected = selectedIdSet?.has(b.id) ? 1 : 0 + return sortDirection === SORT_ASCENDING + ? bSelected - aSelected + : aSelected - bSelected +} + +export const compareRangeValues = (aVal, bVal, sortDirection) => { + const [aStart, aEnd] = parseRange(aVal) + const [bStart, bEnd] = parseRange(bVal) + const startDiff = + sortDirection === SORT_ASCENDING ? aStart - bStart : bStart - aStart + if (startDiff !== 0) { + return startDiff + } + return sortDirection === SORT_ASCENDING ? aEnd - bEnd : bEnd - aEnd +} + +export const compareFieldValues = ( + aVal, + bVal, + { sortField, sortDirection } +) => { + // All undefined values should be sorted to the end + if (aVal === undefined && bVal === undefined) { + return 0 + } + if (aVal === undefined) { + return 1 + } + if (bVal === undefined) { + return -1 + } + if (typeof aVal === 'number') { + return sortDirection === SORT_ASCENDING ? aVal - bVal : bVal - aVal + } + if (sortField === RANGE) { + return compareRangeValues(aVal, bVal, sortDirection) + } + // TODO: Make sure sorting works across different locales + return sortDirection === SORT_ASCENDING + ? aVal.localeCompare(bVal) + : bVal.localeCompare(aVal) +} + +export const compareRows = (a, b, options) => { + const { sortField } = options + // "None" (third click of the cycle) - fall back to natural order + if (!sortField) { + return a.index - b.index + } + if (sortField === SENTINEL_SELECTED_ROW) { + return compareBySelected(a, b, options) + } + return compareFieldValues(a[sortField], b[sortField], options) +} From faa0a7b0e67064395d987f3bbaa52075c49c18c5 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 16 Jul 2026 13:40:40 +0200 Subject: [PATCH 043/205] chore: sonarqube issues --- src/components/datatable/styles/DataTable.module.css | 3 --- src/util/tableSort.js | 12 +++++++++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/components/datatable/styles/DataTable.module.css b/src/components/datatable/styles/DataTable.module.css index 1a9bb9e78f..068594371e 100644 --- a/src/components/datatable/styles/DataTable.module.css +++ b/src/components/datatable/styles/DataTable.module.css @@ -1,8 +1,5 @@ .dataTable { height: 1px; -} - -.dataTable { border: none !important; } diff --git a/src/util/tableSort.js b/src/util/tableSort.js index ea944c7b38..6af6052b89 100644 --- a/src/util/tableSort.js +++ b/src/util/tableSort.js @@ -7,6 +7,16 @@ import { parseRange } from './legend.js' const RANGE = 'range' +const compareStrings = (a, b) => { + if (a < b) { + return -1 + } + if (a > b) { + return 1 + } + return 0 +} + export const compareColumnOptionValues = ( a, b, @@ -22,7 +32,7 @@ export const compareColumnOptionValues = ( return compareRangeValues(a, b, direction) } const comparison = - type === 'number' ? Number(a) - Number(b) : a < b ? -1 : a > b ? 1 : 0 + type === 'number' ? Number(a) - Number(b) : compareStrings(a, b) return direction === SORT_ASCENDING ? comparison : -comparison } From 5f49e1b00c28d183fc4a6a54eec27fd44307c446 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 16 Jul 2026 14:11:14 +0200 Subject: [PATCH 044/205] fix: properly format dropdown numeric values --- src/components/datatable/FilterInput.jsx | 29 ++++++++++++++----- .../datatable/__tests__/FilterInput.spec.jsx | 27 +++++++++++++++++ 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/components/datatable/FilterInput.jsx b/src/components/datatable/FilterInput.jsx index 5d27317ce7..ee2fd45156 100644 --- a/src/components/datatable/FilterInput.jsx +++ b/src/components/datatable/FilterInput.jsx @@ -18,6 +18,8 @@ import { toggleAnyValue, toggleRealValue, } from '../../util/filterSelection.js' +import { formatWithSeparator } from '../../util/numbers.js' +import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' import Checkbox from '../core/Checkbox.jsx' import { FilterDropdownPopover, @@ -550,14 +552,27 @@ SearchableFilterPopover.propTypes = { layerId: PropTypes.string, } -const PlainSearchableFilter = (props) => ( - <SearchableFilterPopover - {...props} - resolveLabel={(value) => - value === SENTINEL_NO_VALUE ? i18n.t('No value') : value +const PlainSearchableFilter = (props) => { + const { type } = props + const { + systemSettings: { keyAnalysisDigitGroupSeparator }, + } = useCachedData() + + const resolveLabel = (value) => { + if (value === SENTINEL_NO_VALUE) { + return i18n.t('No value') } - /> -) + return type === 'number' + ? formatWithSeparator(Number(value), keyAnalysisDigitGroupSeparator) + : value + } + + return <SearchableFilterPopover {...props} resolveLabel={resolveLabel} /> +} + +PlainSearchableFilter.propTypes = { + type: PropTypes.string, +} const OptionSetSearchableFilter = ({ optionSetId, ...props }) => { const { optionSet } = useOptionSet(optionSetId) diff --git a/src/components/datatable/__tests__/FilterInput.spec.jsx b/src/components/datatable/__tests__/FilterInput.spec.jsx index 67c9eb5744..ea143ce676 100644 --- a/src/components/datatable/__tests__/FilterInput.spec.jsx +++ b/src/components/datatable/__tests__/FilterInput.spec.jsx @@ -16,6 +16,12 @@ jest.mock('../../../hooks/useOptionSet.js', () => ({ default: jest.fn(), })) +jest.mock('../../cachedDataProvider/CachedDataProvider.jsx', () => ({ + useCachedData: () => ({ + systemSettings: { keyAnalysisDigitGroupSeparator: 'COMMA' }, + }), +})) + const mockStore = configureMockStore() const renderFilterInput = (props, dataFilters) => { @@ -194,6 +200,27 @@ describe('FilterInput multi-select path (no optionSetId)', () => { screen.getByLabelText('abc123').closest('.monoOption') ).toBeInTheDocument() }) + + test('formats numeric column options with the system digit group separator', () => { + renderFilterInput({ + dataKey: 'value', + name: 'Value', + type: 'number', + options: [{ value: '1234567' }], + }) + openPopover('Value') + expect(screen.getByLabelText('1,234,567')).toBeInTheDocument() + }) + + test('does not format non-numeric column options', () => { + renderFilterInput({ + dataKey: 'legend', + name: 'Legend', + options: [{ value: '1000' }], + }) + openPopover('Legend') + expect(screen.getByLabelText('1000')).toBeInTheDocument() + }) }) describe('FilterInput multi-select path (optionSetId)', () => { From bf4c5faf9095569fc8d88e3a60d61d14269a8e89 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 16 Jul 2026 14:16:48 +0200 Subject: [PATCH 045/205] chore: fix cypress tests --- cypress/integration/dataTable.cy.js | 59 ++++++++++++++--------------- 1 file changed, 29 insertions(+), 30 deletions(-) diff --git a/cypress/integration/dataTable.cy.js b/cypress/integration/dataTable.cy.js index 0bfffeaddf..f74cafac08 100644 --- a/cypress/integration/dataTable.cy.js +++ b/cypress/integration/dataTable.cy.js @@ -97,8 +97,8 @@ describe('data table', () => { .should('have.length', 7) // Confirm that the sort order is initially ascending by Name - checkTableCell({ row: 0, column: 2, expectedContent: 'Bargbe' }) - checkTableCell({ row: 6, column: 2, expectedContent: 'Upper Bambara' }) + checkTableCell({ row: 0, column: 1, expectedContent: 'Bargbe' }) + checkTableCell({ row: 6, column: 1, expectedContent: 'Upper Bambara' }) // Sort by name cy.getByDataTest('data-table-column-sort-button-Name').click() @@ -109,8 +109,8 @@ describe('data table', () => { cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') // Confirm that the rows are sorted by Name descending - checkTableCell({ row: 0, column: 2, expectedContent: 'Upper Bambara' }) - checkTableCell({ row: 6, column: 2, expectedContent: 'Bargbe' }) + checkTableCell({ row: 0, column: 1, expectedContent: 'Upper Bambara' }) + checkTableCell({ row: 6, column: 1, expectedContent: 'Bargbe' }) // Filter by Value (numeric) cy.getByDataTest('data-table-column-filter-search-Value') @@ -130,8 +130,8 @@ describe('data table', () => { cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') // Check that the rows are sorted by Value ascending - checkTableCell({ row: 0, column: 4, expectedContent: '35' }) - checkTableCell({ row: 4, column: 4, expectedContent: '76' }) + checkTableCell({ row: 0, column: 3, expectedContent: '35' }) + checkTableCell({ row: 4, column: 3, expectedContent: '76' }) // Right-click a row and select "View profile" cy.getByDataTest('bottom-panel') @@ -195,7 +195,7 @@ describe('data table', () => { // Check number of columns cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-datatablecellhead') - .should('have.length', 11) + .should('have.length', 10) cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-datatablecellhead') @@ -209,8 +209,8 @@ describe('data table', () => { .type(`${ouName}{enter}`) // Check that all the rows have Org unit Moyowa - checkTableCell({ row: 0, column: 2, expectedContent: ouName }) - checkTableCell({ row: 2, column: 2, expectedContent: ouName }) + checkTableCell({ row: 0, column: 1, expectedContent: ouName }) + checkTableCell({ row: 2, column: 1, expectedContent: ouName }) cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-tablebody') @@ -253,8 +253,8 @@ describe('data table', () => { // Confirm that the rows are sorted by Age in years ascending // (the first click on a new column always sorts ascending) - checkTableCell({ row: 0, column: 8, expectedContent: '6' }) - checkTableCell({ row: 1, column: 8, expectedContent: '32' }) + checkTableCell({ row: 0, column: 7, expectedContent: '6' }) + checkTableCell({ row: 1, column: 7, expectedContent: '32' }) // Right-click a row: Event layers have no profile to view cy.getByDataTest('bottom-panel') @@ -315,7 +315,7 @@ describe('data table', () => { cy.getByDataTest('layers-toggle-button').click() // Confirm that the sort order is initially ascending by Name - checkTableCell({ row: 0, column: 2, expectedContent: 'Bendu CHC' }) + checkTableCell({ row: 0, column: 1, expectedContent: 'Bendu CHC' }) // First click on a new column always sorts ascending cy.getByDataTest('data-table-column-sort-button-Value').click() @@ -324,15 +324,15 @@ describe('data table', () => { cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') // Check that first row has Tihun CHC with value 28.63 - checkTableCell({ row: 0, column: 2, expectedContent: 'Tihun CHC' }) - checkTableCell({ row: 0, column: 4, expectedContent: '28.63' }) + checkTableCell({ row: 0, column: 1, expectedContent: 'Tihun CHC' }) + checkTableCell({ row: 0, column: 3, expectedContent: '28.63' }) // Check that row 5 has Gbamgbama CHC with value 117.98 - checkTableCell({ row: 5, column: 2, expectedContent: 'Gbamgbama CHC' }) - checkTableCell({ row: 5, column: 4, expectedContent: '117.98' }) + checkTableCell({ row: 5, column: 1, expectedContent: 'Gbamgbama CHC' }) + checkTableCell({ row: 5, column: 3, expectedContent: '117.98' }) // Check that row 6 has no value (undefined) - checkTableCell({ row: 6, column: 4, expectedContent: '' }) + checkTableCell({ row: 6, column: 3, expectedContent: '' }) // Sort descending by Value cy.getByDataTest('data-table-column-sort-button-Value').click() @@ -340,24 +340,23 @@ describe('data table', () => { // Reset scroll position after sorting - see comment above cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') - checkTableCell({ row: 0, column: 2, expectedContent: 'Gbamgbama CHC' }) - checkTableCell({ row: 0, column: 4, expectedContent: '117.98' }) + checkTableCell({ row: 0, column: 1, expectedContent: 'Gbamgbama CHC' }) + checkTableCell({ row: 0, column: 3, expectedContent: '117.98' }) - checkTableCell({ row: 5, column: 2, expectedContent: 'Tihun CHC' }) - checkTableCell({ row: 5, column: 4, expectedContent: '28.63' }) + checkTableCell({ row: 5, column: 1, expectedContent: 'Tihun CHC' }) + checkTableCell({ row: 5, column: 3, expectedContent: '28.63' }) - checkTableCell({ row: 6, column: 4, expectedContent: '' }) + checkTableCell({ row: 6, column: 3, expectedContent: '' }) - // Sort by index (a new column, so ascending) - cy.getByDataTest('data-table-column-sort-button-Index').click() + // Third click on the same column cycles back to natural (unsorted) + // order - there's no dedicated Index column/button any more + cy.getByDataTest('data-table-column-sort-button-Value').click() // Reset scroll position after sorting - see comment above cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') - checkTableCell({ row: 0, column: 1, expectedContent: '0' }) - // Check that row 0 range value is empty - checkTableCell({ row: 0, column: 6, expectedContent: '' }) + checkTableCell({ row: 0, column: 5, expectedContent: '' }) // Sort by range, which is a string cy.getByDataTest('data-table-column-sort-button-Range').click() @@ -366,12 +365,12 @@ describe('data table', () => { cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') // Check that row 0 range value has value '0-40' - checkTableCell({ row: 0, column: 6, expectedContent: '0 – 40' }) + checkTableCell({ row: 0, column: 5, expectedContent: '0 – 40' }) // Check that row 5 range value has value '90 - 120' - checkTableCell({ row: 5, column: 6, expectedContent: '90 – 120' }) + checkTableCell({ row: 5, column: 5, expectedContent: '90 – 120' }) // Check that row 6 range value is empty - checkTableCell({ row: 6, column: 6, expectedContent: '' }) + checkTableCell({ row: 6, column: 5, expectedContent: '' }) }) }) From 7e0bc73e4862f7a0538e731dc8e324f3c8fd852a Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Fri, 17 Jul 2026 00:24:51 +0200 Subject: [PATCH 046/205] chore: cypress test fix --- cypress/integration/dataTable.cy.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cypress/integration/dataTable.cy.js b/cypress/integration/dataTable.cy.js index f74cafac08..8867f18b5f 100644 --- a/cypress/integration/dataTable.cy.js +++ b/cypress/integration/dataTable.cy.js @@ -228,6 +228,10 @@ describe('data table', () => { .findByDataTest('dhis2-uicore-datatablerow') .should('have.length', 1) + cy.getByDataTest('data-table-column-filter-search-Mode of Discharge') + .find('input') + .type('{enter}') + cy.getByDataTest('data-table-column-filter-search-Mode of Discharge') .find('.clear-button') .click() From 57a520a2d5fbe494ac79b8ebd13b07da747db1e4 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Fri, 17 Jul 2026 00:43:28 +0200 Subject: [PATCH 047/205] chore: cypress test fix --- cypress/integration/dataTable.cy.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/cypress/integration/dataTable.cy.js b/cypress/integration/dataTable.cy.js index 8867f18b5f..c72da9f17c 100644 --- a/cypress/integration/dataTable.cy.js +++ b/cypress/integration/dataTable.cy.js @@ -228,9 +228,7 @@ describe('data table', () => { .findByDataTest('dhis2-uicore-datatablerow') .should('have.length', 1) - cy.getByDataTest('data-table-column-filter-search-Mode of Discharge') - .find('input') - .type('{enter}') + cy.get('.backdrop').click() cy.getByDataTest('data-table-column-filter-search-Mode of Discharge') .find('.clear-button') From f3ecf06d392f3054f140f9968122ae40484e1036 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Fri, 17 Jul 2026 10:47:51 +0200 Subject: [PATCH 048/205] chore: cypress test fix --- cypress/integration/dataTable.cy.js | 2 +- src/components/datatable/DataTable.jsx | 46 ++++++++++++++------------ 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/cypress/integration/dataTable.cy.js b/cypress/integration/dataTable.cy.js index c72da9f17c..45eb84debd 100644 --- a/cypress/integration/dataTable.cy.js +++ b/cypress/integration/dataTable.cy.js @@ -228,7 +228,7 @@ describe('data table', () => { .findByDataTest('dhis2-uicore-datatablerow') .should('have.length', 1) - cy.get('.backdrop').click() + cy.get('.backdrop').last().click() cy.getByDataTest('data-table-column-filter-search-Mode of Discharge') .find('.clear-button') diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index 5b72ec12d7..7e05c4b3f8 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -262,28 +262,30 @@ DataTableRowWithVirtuosoContext.propTypes = { } const EmptyPlaceholder = ({ context }) => ( - <tr> - <td colSpan={99999}> - <div className={styles.noResults}> - {context.totalCount > 0 ? ( - <> - {i18n.t('No features match your filters')} - {context.hasActiveFilters && ( - <button - type="button" - className={styles.clearFiltersLink} - onClick={context.onClearFilters} - > - {i18n.t('Clear filters')} - </button> - )} - </> - ) : ( - i18n.t('No results found') - )} - </div> - </td> - </tr> + <tbody> + <tr> + <td colSpan={99999}> + <div className={styles.noResults}> + {context.totalCount > 0 ? ( + <> + {i18n.t('No features match your filters')} + {context.hasActiveFilters && ( + <button + type="button" + className={styles.clearFiltersLink} + onClick={context.onClearFilters} + > + {i18n.t('Clear filters')} + </button> + )} + </> + ) : ( + i18n.t('No results found') + )} + </div> + </td> + </tr> + </tbody> ) EmptyPlaceholder.propTypes = { From 3dd5d742128c4e345a49182c03c1892d70a39c07 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Sun, 19 Jul 2026 15:31:12 +0200 Subject: [PATCH 049/205] chore: create and use replica accounts in CI --- cypress.config.js | 30 ---------------------------- cypress/plugins/e2eReplicaAccount.js | 3 +-- cypress/support/util.js | 3 +-- 3 files changed, 2 insertions(+), 34 deletions(-) diff --git a/cypress.config.js b/cypress.config.js index a54646481d..3f4d51c1a5 100644 --- a/cypress.config.js +++ b/cypress.config.js @@ -29,36 +29,6 @@ async function setupNodeEvents(on, config) { ) } - config.env.useReplicaAccount = !!process.env.CI - - if (config.env.useReplicaAccount) { - try { - const { username, password, replicaUserId } = - await createReplicaAccountForRun({ - baseUrl: config.env.dhis2BaseUrl, - username: config.env.dhis2Username, - password: config.env.dhis2Password, - }) - - config.env.replicaUsername = username - config.env.replicaPassword = password - - on('after:run', () => - deleteReplicaAccount({ - baseUrl: config.env.dhis2BaseUrl, - username: config.env.dhis2Username, - password: config.env.dhis2Password, - replicaUserId, - }) - ) - } catch (error) { - console.warn( - `WARNING: could not create e2e replica account, falling back to the standard account: ${error.message}` - ) - config.env.useReplicaAccount = false - } - } - return config } diff --git a/cypress/plugins/e2eReplicaAccount.js b/cypress/plugins/e2eReplicaAccount.js index 7fa7d2fe7c..5658ee88bb 100644 --- a/cypress/plugins/e2eReplicaAccount.js +++ b/cypress/plugins/e2eReplicaAccount.js @@ -59,8 +59,7 @@ const dhis2Fetch = async ( } const buildReplicaUsername = () => - `e2e_mapsapp_run${ - process.env.GITHUB_RUN_ID ?? 'local' + `e2e_mapsapp_run${process.env.GITHUB_RUN_ID ?? 'local' }_${uniqueId().replaceAll('-', '_')}` const createReplicaUser = async ({ baseUrl, adminId, auth }) => { diff --git a/cypress/support/util.js b/cypress/support/util.js index 23e7255907..1093391d01 100644 --- a/cypress/support/util.js +++ b/cypress/support/util.js @@ -326,8 +326,7 @@ export const assertIntercepts = ({ normalizeErrors(errors).forEach((error) => { // Single intercept cy.log( - `[${n}] Intercepting single: ${alias}${ - error !== undefined ? ` - ${error}` : '' + `[${n}] Intercepting single: ${alias}${error !== undefined ? ` - ${error}` : '' }` ) From 3ef2c716308196d50f030e755e8565e8aca60b03 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Mon, 20 Jul 2026 14:01:12 +0200 Subject: [PATCH 050/205] chore: update e2eReplicaAccount.js --- cypress.config.js | 30 ++++++++++++++++++++++++++++ cypress/plugins/e2eReplicaAccount.js | 3 ++- cypress/support/util.js | 3 ++- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/cypress.config.js b/cypress.config.js index 3f4d51c1a5..a54646481d 100644 --- a/cypress.config.js +++ b/cypress.config.js @@ -29,6 +29,36 @@ async function setupNodeEvents(on, config) { ) } + config.env.useReplicaAccount = !!process.env.CI + + if (config.env.useReplicaAccount) { + try { + const { username, password, replicaUserId } = + await createReplicaAccountForRun({ + baseUrl: config.env.dhis2BaseUrl, + username: config.env.dhis2Username, + password: config.env.dhis2Password, + }) + + config.env.replicaUsername = username + config.env.replicaPassword = password + + on('after:run', () => + deleteReplicaAccount({ + baseUrl: config.env.dhis2BaseUrl, + username: config.env.dhis2Username, + password: config.env.dhis2Password, + replicaUserId, + }) + ) + } catch (error) { + console.warn( + `WARNING: could not create e2e replica account, falling back to the standard account: ${error.message}` + ) + config.env.useReplicaAccount = false + } + } + return config } diff --git a/cypress/plugins/e2eReplicaAccount.js b/cypress/plugins/e2eReplicaAccount.js index 5658ee88bb..7fa7d2fe7c 100644 --- a/cypress/plugins/e2eReplicaAccount.js +++ b/cypress/plugins/e2eReplicaAccount.js @@ -59,7 +59,8 @@ const dhis2Fetch = async ( } const buildReplicaUsername = () => - `e2e_mapsapp_run${process.env.GITHUB_RUN_ID ?? 'local' + `e2e_mapsapp_run${ + process.env.GITHUB_RUN_ID ?? 'local' }_${uniqueId().replaceAll('-', '_')}` const createReplicaUser = async ({ baseUrl, adminId, auth }) => { diff --git a/cypress/support/util.js b/cypress/support/util.js index 1093391d01..23e7255907 100644 --- a/cypress/support/util.js +++ b/cypress/support/util.js @@ -326,7 +326,8 @@ export const assertIntercepts = ({ normalizeErrors(errors).forEach((error) => { // Single intercept cy.log( - `[${n}] Intercepting single: ${alias}${error !== undefined ? ` - ${error}` : '' + `[${n}] Intercepting single: ${alias}${ + error !== undefined ? ` - ${error}` : '' }` ) From 31db3e6a476deaba1a7539800898573347399fa3 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Mon, 6 Jul 2026 19:12:30 +0200 Subject: [PATCH 051/205] feat: add toolbar, row context menu, and fix highlight persistence --- i18n/en.pot | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index a28648bb60..e0da3b9172 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-16T11:34:34.211Z\n" -"PO-Revision-Date: 2026-07-16T11:34:34.212Z\n" +"POT-Creation-Date: 2026-07-02T11:52:20.816Z\n" +"PO-Revision-Date: 2026-07-02T11:52:20.816Z\n" msgid "2020" msgstr "2020" From 759b54d6a58dbdb636dc086ba2a4e86ee92d5cff Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Mon, 13 Jul 2026 17:36:21 +0200 Subject: [PATCH 052/205] feat: add bidirectional map/table selection sync and collapsible data table --- i18n/en.pot | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index e0da3b9172..de3f95f2c9 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-02T11:52:20.816Z\n" -"PO-Revision-Date: 2026-07-02T11:52:20.816Z\n" +"POT-Creation-Date: 2026-07-13T09:39:24.427Z\n" +"PO-Revision-Date: 2026-07-13T09:39:24.427Z\n" msgid "2020" msgstr "2020" From 940bd8482f8203d7982b173099062f5915300562 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 14 Jul 2026 10:51:51 +0200 Subject: [PATCH 053/205] fix: toolbar polish - clear filters button, search sizing, collapse icon/bug --- i18n/en.pot | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index de3f95f2c9..93feb1dd5c 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-13T09:39:24.427Z\n" -"PO-Revision-Date: 2026-07-13T09:39:24.427Z\n" +"POT-Creation-Date: 2026-07-14T06:48:41.625Z\n" +"PO-Revision-Date: 2026-07-14T06:48:41.625Z\n" msgid "2020" msgstr "2020" From dc0e3d7e7e93ebc31de5572cfd213e7d7278ab3d Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 14 Jul 2026 22:18:57 +0200 Subject: [PATCH 054/205] feat: round out data table filtering with reverse-selection, zoom-to-filtered, and a richer selection filter --- i18n/en.pot | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 93feb1dd5c..3a2f965ffb 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-14T06:48:41.625Z\n" -"PO-Revision-Date: 2026-07-14T06:48:41.625Z\n" +"POT-Creation-Date: 2026-07-14T19:48:56.770Z\n" +"PO-Revision-Date: 2026-07-14T19:48:56.771Z\n" msgid "2020" msgstr "2020" From ff32bb4449672d2a731b2c74cc2acde883a93f84 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 16 Jul 2026 13:35:05 +0200 Subject: [PATCH 055/205] chore: PR clean-up --- i18n/en.pot | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 3a2f965ffb..a28648bb60 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-14T19:48:56.770Z\n" -"PO-Revision-Date: 2026-07-14T19:48:56.771Z\n" +"POT-Creation-Date: 2026-07-16T11:34:34.211Z\n" +"PO-Revision-Date: 2026-07-16T11:34:34.212Z\n" msgid "2020" msgstr "2020" From 9ba799b0d24d4069c866942861f1d4948986981c Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 16 Jul 2026 15:05:09 +0200 Subject: [PATCH 056/205] feat: add DATA_TABLE_COLUMN_CONFIG_SET reducer action and persistence plumbing Adds the Redux action/reducer for a per-layer dataTableColumnConfig field and wires it through favorites.js save/load and all layer loaders, mirroring the existing legendDecimalPlaces config-blob pattern. No UI yet. --- src/actions/dataTable.js | 6 ++++++ src/constants/actionTypes.js | 1 + src/loaders/eventLoader.js | 4 ++++ src/loaders/facilityLoader.js | 10 ++++++++-- src/loaders/geoJsonUrlLoader.js | 7 ++++++- src/loaders/orgUnitLoader.js | 10 ++++++++-- src/loaders/thematicLoader.js | 4 ++++ src/loaders/trackedEntityLoader.js | 8 +++++++- src/reducers/map.js | 11 +++++++++++ src/util/favorites.js | 30 ++++++++++++++++++++++++++++-- 10 files changed, 83 insertions(+), 8 deletions(-) diff --git a/src/actions/dataTable.js b/src/actions/dataTable.js index 5b9adde54a..ceb7ed56e3 100644 --- a/src/actions/dataTable.js +++ b/src/actions/dataTable.js @@ -32,3 +32,9 @@ export const setHighlightColor = (color) => ({ type: types.HIGHLIGHT_COLOR_SET, color, }) + +export const setDataTableColumnConfig = (layerId, config) => ({ + type: types.DATA_TABLE_COLUMN_CONFIG_SET, + layerId, + config, +}) diff --git a/src/constants/actionTypes.js b/src/constants/actionTypes.js index 0fbf8c5cec..ea24c6b490 100644 --- a/src/constants/actionTypes.js +++ b/src/constants/actionTypes.js @@ -45,6 +45,7 @@ export const TOGGLE_SHOW_ONLY_IN_VIEW = 'TOGGLE_SHOW_ONLY_IN_VIEW' export const SELECTION_FILTER_SET = 'SELECTION_FILTER_SET' export const HIGHLIGHT_COLOR_SET = 'HIGHLIGHT_COLOR_SET' export const MAP_FEATURE_CLICKED = 'MAP_FEATURE_CLICKED' +export const DATA_TABLE_COLUMN_CONFIG_SET = 'DATA_TABLE_COLUMN_CONFIG_SET' /* DATA FILTER */ export const DATA_FILTER_SET = 'DATA_FILTER_SET' diff --git a/src/loaders/eventLoader.js b/src/loaders/eventLoader.js index da3b847c16..e5d517a341 100644 --- a/src/loaders/eventLoader.js +++ b/src/loaders/eventLoader.js @@ -161,6 +161,7 @@ const loadEventLayer = async ({ unclassifiedLegend: unclassifiedLegendFromConfig, noDataLegend: noDataLegendFromConfig, labelDataItem, + dataTableColumnConfig, } = parseJsonConfig(config.config) if (countFeaturesWithoutCoordinates) { config.countFeaturesWithoutCoordinates = true @@ -196,6 +197,9 @@ const loadEventLayer = async ({ if (noDataLegendFromConfig) { config.noDataLegend = noDataLegendFromConfig } + if (dataTableColumnConfig) { + config.dataTableColumnConfig = dataTableColumnConfig + } if (config.noDataColor) { config.noDataLegend = { ...noDataLegendFromConfig, diff --git a/src/loaders/facilityLoader.js b/src/loaders/facilityLoader.js index db0be8196b..ef8e07ed77 100644 --- a/src/loaders/facilityLoader.js +++ b/src/loaders/facilityLoader.js @@ -65,14 +65,20 @@ const facilityLoader = async ({ // Config parsing // ----- - const { countFeaturesWithoutCoordinates, unclassifiedLegend } = - parseJsonConfig(config.config) + const { + countFeaturesWithoutCoordinates, + unclassifiedLegend, + dataTableColumnConfig, + } = parseJsonConfig(config.config) if (countFeaturesWithoutCoordinates) { config.countFeaturesWithoutCoordinates = true } if (unclassifiedLegend) { config.unclassifiedLegend = unclassifiedLegend } + if (dataTableColumnConfig) { + config.dataTableColumnConfig = dataTableColumnConfig + } delete config.config // Data loading diff --git a/src/loaders/geoJsonUrlLoader.js b/src/loaders/geoJsonUrlLoader.js index 2d96c21922..ed26dbce8a 100644 --- a/src/loaders/geoJsonUrlLoader.js +++ b/src/loaders/geoJsonUrlLoader.js @@ -59,15 +59,19 @@ const geoJsonUrlLoader = async ({ let newConfig let featureStyle - // keep featureStyle property outside of config while in app + let dataTableColumnConfig + // keep featureStyle and dataTableColumnConfig properties outside of config while in app if (typeof config === 'string') { // External layer is loaded in analytical object newConfig = await parseLayerConfig(config, engine) featureStyle = { ...newConfig.featureStyle } || EMPTY_FEATURE_STYLE + dataTableColumnConfig = newConfig.dataTableColumnConfig delete newConfig.featureStyle + delete newConfig.dataTableColumnConfig } else { newConfig = { ...config } featureStyle = layer.featureStyle || EMPTY_FEATURE_STYLE + dataTableColumnConfig = layer.dataTableColumnConfig } let geoJson @@ -129,6 +133,7 @@ const geoJsonUrlLoader = async ({ keyAnalysisDigitGroupSeparator, config: newConfig, featureStyle, + dataTableColumnConfig, isLoaded: true, isLoading: false, isExpanded: true, diff --git a/src/loaders/orgUnitLoader.js b/src/loaders/orgUnitLoader.js index 64f2711f35..1df10a1527 100644 --- a/src/loaders/orgUnitLoader.js +++ b/src/loaders/orgUnitLoader.js @@ -76,14 +76,20 @@ const orgUnitLoader = async ({ // Config parsing // ----- - const { countFeaturesWithoutCoordinates, unclassifiedLegend } = - parseJsonConfig(config.config) + const { + countFeaturesWithoutCoordinates, + unclassifiedLegend, + dataTableColumnConfig, + } = parseJsonConfig(config.config) if (countFeaturesWithoutCoordinates) { config.countFeaturesWithoutCoordinates = true } if (unclassifiedLegend) { config.unclassifiedLegend = unclassifiedLegend } + if (dataTableColumnConfig) { + config.dataTableColumnConfig = dataTableColumnConfig + } delete config.config // Data loading diff --git a/src/loaders/thematicLoader.js b/src/loaders/thematicLoader.js index 4e4998c125..1e3dba4a08 100644 --- a/src/loaders/thematicLoader.js +++ b/src/loaders/thematicLoader.js @@ -85,6 +85,7 @@ const thematicLoader = async ({ legendIsolated, unclassifiedLegend: unclassifiedLegendFromConfig, noDataLegend: noDataLegendFromConfig, + dataTableColumnConfig, } = parseJsonConfig(config.config) if (countFeaturesWithoutCoordinates) { config.countFeaturesWithoutCoordinates = true @@ -101,6 +102,9 @@ const thematicLoader = async ({ if (noDataLegendFromConfig) { config.noDataLegend = noDataLegendFromConfig } + if (dataTableColumnConfig) { + config.dataTableColumnConfig = dataTableColumnConfig + } if (config.noDataColor) { config.noDataLegend = { ...noDataLegendFromConfig, diff --git a/src/loaders/trackedEntityLoader.js b/src/loaders/trackedEntityLoader.js index 90ffe7b192..0a30a3bdce 100644 --- a/src/loaders/trackedEntityLoader.js +++ b/src/loaders/trackedEntityLoader.js @@ -115,7 +115,9 @@ export const parseJsonConfig = (config) => { } try { - const { relationships, periodType } = JSON.parse(config.config) + const { relationships, periodType, dataTableColumnConfig } = JSON.parse( + config.config + ) if (relationships) { config.relationshipType = relationships.type @@ -127,6 +129,10 @@ export const parseJsonConfig = (config) => { } config.periodType = periodType + + if (dataTableColumnConfig) { + config.dataTableColumnConfig = dataTableColumnConfig + } } catch (e) { // Malformed config JSON } diff --git a/src/reducers/map.js b/src/reducers/map.js index 9f7828d331..d32818a36d 100644 --- a/src/reducers/map.js +++ b/src/reducers/map.js @@ -171,6 +171,16 @@ const layer = (state, action) => { dataFilters: {}, } + case types.DATA_TABLE_COLUMN_CONFIG_SET: + if (state.id !== action.layerId) { + return state + } + + return { + ...state, + dataTableColumnConfig: action.config, + } + case types.MAP_ALERTS_CLEAR: return { ...state, @@ -311,6 +321,7 @@ const map = (state = defaultState, action) => { case types.DATA_FILTER_SET: case types.DATA_FILTER_CLEAR: case types.DATA_FILTERS_CLEAR_ALL: + case types.DATA_TABLE_COLUMN_CONFIG_SET: case types.MAP_EARTH_ENGINE_VALUE_SHOW: return { ...state, diff --git a/src/util/favorites.js b/src/util/favorites.js index 520c919873..96032a36aa 100644 --- a/src/util/favorites.js +++ b/src/util/favorites.js @@ -37,6 +37,7 @@ const validLayerProperties = [ 'columns', 'config', 'created', + 'dataTableColumnConfig', 'datasetId', 'displayName', 'endDate', @@ -180,6 +181,9 @@ const buildCommonLayerConfigData = (layer) => { if (layer.labelDataItem) { configData.labelDataItem = layer.labelDataItem } + if (layer.dataTableColumnConfig) { + configData.dataTableColumnConfig = layer.dataTableColumnConfig + } return configData } @@ -194,11 +198,26 @@ const deleteCommonLayerConfigProps = (layer) => { delete layer.countFeaturesWithoutCoordinates delete layer.countEventsOutsideOrgUnits delete layer.labelDataItem + delete layer.dataTableColumnConfig } const buildEarthEngineLayerConfigData = (layer) => { - const { layerId: id, band, style, aggregationType, period } = layer - return omitBy(isNil, { id, style, band, aggregationType, period }) + const { + layerId: id, + band, + style, + aggregationType, + period, + dataTableColumnConfig, + } = layer + return omitBy(isNil, { + id, + style, + band, + aggregationType, + period, + dataTableColumnConfig, + }) } const deleteEarthEngineLayerProps = (layer) => { @@ -211,6 +230,7 @@ const deleteEarthEngineLayerProps = (layer) => { delete layer.periodType delete layer.aggregationType delete layer.band + delete layer.dataTableColumnConfig } const buildTrackedEntityLayerConfigData = (layer) => ({ @@ -224,6 +244,7 @@ const buildTrackedEntityLayerConfigData = (layer) => ({ } : null, periodType: layer.periodType, + dataTableColumnConfig: layer.dataTableColumnConfig, }) const deleteTrackedEntityLayerProps = (layer) => { @@ -233,6 +254,7 @@ const deleteTrackedEntityLayerProps = (layer) => { delete layer.relationshipLineColor delete layer.relationshipOutsideProgram delete layer.periodType + delete layer.dataTableColumnConfig } // TODO: This feels hacky, find better way to clean map configs before saving @@ -268,9 +290,13 @@ const models2objects = (layer, cleanMapviewConfig) => { layer.config = { ...layer.config, featureStyle: { ...layer.featureStyle }, + ...(layer.dataTableColumnConfig !== undefined && { + dataTableColumnConfig: layer.dataTableColumnConfig, + }), } } delete layer.featureStyle + delete layer.dataTableColumnConfig } else if ( layerType === EVENT_LAYER || layerType === THEMATIC_LAYER || From 8d73544f39bc6e03eab27ac829c13231ff4a09d4 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 16 Jul 2026 15:35:05 +0200 Subject: [PATCH 057/205] feat: add pure column visibility/order/pin computation getVisibleHeaders/getPinnedLeftOffsets in a new src/util/tableColumns.js, directly unit-tested, matching the filterSelection.js/tableSort.js pattern. Not yet wired into DataTable.jsx. --- src/util/__tests__/tableColumns.spec.js | 178 ++++++++++++++++++++++++ src/util/tableColumns.js | 71 ++++++++++ 2 files changed, 249 insertions(+) create mode 100644 src/util/__tests__/tableColumns.spec.js create mode 100644 src/util/tableColumns.js diff --git a/src/util/__tests__/tableColumns.spec.js b/src/util/__tests__/tableColumns.spec.js new file mode 100644 index 0000000000..6d7eabfa4a --- /dev/null +++ b/src/util/__tests__/tableColumns.spec.js @@ -0,0 +1,178 @@ +import { getPinnedLeftOffsets, getVisibleHeaders } from '../tableColumns.js' + +const headers = [ + { name: 'Name', dataKey: 'name' }, + { name: 'Id', dataKey: 'id' }, + { name: 'Value', dataKey: 'rawValue' }, + { name: 'Legend', dataKey: 'legend' }, +] + +describe('getVisibleHeaders', () => { + it('returns all headers unchanged when there is no saved config', () => { + expect(getVisibleHeaders(headers, null)).toEqual(headers) + }) + + it('passes through a null/undefined headers list', () => { + expect(getVisibleHeaders(null, null)).toBe(null) + }) + + it('treats an explicit null for any config field the same as it being absent', () => { + const result = getVisibleHeaders(headers, { + visibleKeys: null, + orderedKeys: null, + pinnedKeys: null, + }) + expect(result).toEqual(headers) + }) + + it('hides every column when visibleKeys is an explicit empty array', () => { + expect(getVisibleHeaders(headers, { visibleKeys: [] })).toEqual([]) + }) + + it('filters out headers not in visibleKeys', () => { + const result = getVisibleHeaders(headers, { + visibleKeys: ['name', 'legend'], + }) + expect(result.map((h) => h.dataKey)).toEqual(['name', 'legend']) + }) + + it('keeps a header visible when visibleKeys is not set at all', () => { + const result = getVisibleHeaders(headers, { pinnedKeys: ['name'] }) + expect(result.map((h) => h.dataKey)).toEqual([ + 'name', + 'id', + 'rawValue', + 'legend', + ]) + }) + + it('reorders headers according to orderedKeys', () => { + const result = getVisibleHeaders(headers, { + orderedKeys: ['legend', 'name', 'id', 'rawValue'], + }) + expect(result.map((h) => h.dataKey)).toEqual([ + 'legend', + 'name', + 'id', + 'rawValue', + ]) + }) + + it('appends headers missing from orderedKeys at the end, preserving their relative order', () => { + const result = getVisibleHeaders(headers, { + orderedKeys: ['rawValue'], + }) + expect(result.map((h) => h.dataKey)).toEqual([ + 'rawValue', + 'name', + 'id', + 'legend', + ]) + }) + + it('drops a stale dataKey in orderedKeys/visibleKeys that no longer matches any header', () => { + const result = getVisibleHeaders(headers, { + orderedKeys: ['deletedColumn', 'legend', 'name', 'id', 'rawValue'], + visibleKeys: ['deletedColumn', 'name', 'legend'], + }) + expect(result.map((h) => h.dataKey)).toEqual(['legend', 'name']) + }) + + it('moves pinned columns to the front, regardless of orderedKeys', () => { + const result = getVisibleHeaders(headers, { + orderedKeys: ['name', 'id', 'rawValue', 'legend'], + pinnedKeys: ['rawValue'], + }) + expect(result.map((h) => h.dataKey)).toEqual([ + 'rawValue', + 'name', + 'id', + 'legend', + ]) + }) + + it('preserves relative order among multiple pinned columns', () => { + const result = getVisibleHeaders(headers, { + pinnedKeys: ['legend', 'id'], + }) + expect(result.map((h) => h.dataKey)).toEqual([ + 'id', + 'legend', + 'name', + 'rawValue', + ]) + }) + + it('combines ordering, visibility, and pinning together', () => { + const result = getVisibleHeaders(headers, { + orderedKeys: ['legend', 'name', 'id', 'rawValue'], + visibleKeys: ['legend', 'name', 'rawValue'], + pinnedKeys: ['rawValue'], + }) + expect(result.map((h) => h.dataKey)).toEqual([ + 'rawValue', + 'legend', + 'name', + ]) + }) +}) + +describe('getPinnedLeftOffsets', () => { + const visibleHeaders = [ + { name: 'Value', dataKey: 'rawValue' }, + { name: 'Name', dataKey: 'name' }, + { name: 'Id', dataKey: 'id' }, + ] + const columnWidths = [100, 150, 80] + + it('returns no offsets when there are no pinned keys', () => { + expect(getPinnedLeftOffsets(visibleHeaders, [], columnWidths)).toEqual( + {} + ) + }) + + it('returns no offsets when column widths have not been measured yet', () => { + expect(getPinnedLeftOffsets(visibleHeaders, ['rawValue'], [])).toEqual( + {} + ) + }) + + it('starts the first pinned column after the checkbox column', () => { + const offsets = getPinnedLeftOffsets( + visibleHeaders, + ['rawValue'], + columnWidths + ) + expect(offsets).toEqual({ rawValue: 76 }) + }) + + it('accumulates offsets for consecutive pinned columns', () => { + const offsets = getPinnedLeftOffsets( + visibleHeaders, + ['rawValue', 'name'], + columnWidths + ) + expect(offsets).toEqual({ rawValue: 76, name: 176 }) + }) + + it('only offsets columns that are actually pinned', () => { + const offsets = getPinnedLeftOffsets( + visibleHeaders, + ['id'], + columnWidths + ) + expect(offsets).toEqual({ id: 76 }) + }) + + it('does not let an unpinned column contribute width when pinned columns are not contiguous', () => { + // Not a realistic input in practice (getVisibleHeaders always + // makes pinned columns contiguous first), but the function itself + // shouldn't silently corrupt offsets if that invariant is broken. + const offsets = getPinnedLeftOffsets( + visibleHeaders, + ['rawValue', 'id'], + columnWidths + ) + expect(offsets).toEqual({ rawValue: 76, id: 176 }) + }) +}) diff --git a/src/util/tableColumns.js b/src/util/tableColumns.js new file mode 100644 index 0000000000..1e3567e619 --- /dev/null +++ b/src/util/tableColumns.js @@ -0,0 +1,71 @@ +const CHECKBOX_COLUMN_WIDTH = 76 + +const getOrderIndex = (dataKey, orderedKeys) => { + const index = orderedKeys.indexOf(dataKey) + return index === -1 ? orderedKeys.length : index +} + +// Computes the headers actually shown, in display order, from the full +// header list and a saved dataTableColumnConfig. Handles headers whose +// dataKey no longer exists (harmlessly dropped, since this always starts +// from the current `headers`) and headers that exist but were never part +// of a saved config (kept visible, ordered last). +export const getVisibleHeaders = (headers, columnConfig) => { + if (!headers) { + return headers + } + + const { visibleKeys, orderedKeys } = columnConfig ?? {} + const pinnedKeys = columnConfig?.pinnedKeys ?? [] + + let result = orderedKeys + ? [...headers].sort( + (a, b) => + getOrderIndex(a.dataKey, orderedKeys) - + getOrderIndex(b.dataKey, orderedKeys) + ) + : headers + + // visibleKeys is only set once a user has actually configured columns - + // before that, columnConfig is null and every header shows. Once set, + // it's the definitive "on" list: a dataKey added later (e.g. a new EE + // band) that was never part of that saved list stays hidden until the + // user explicitly turns it on, rather than reappearing unexpectedly. + if (visibleKeys) { + result = result.filter((h) => visibleKeys.includes(h.dataKey)) + } + + if (pinnedKeys.length) { + // position: sticky only freezes columns that are actually + // contiguous at the start of display order, so pinned columns + // must be moved to the front here, not just flagged for styling. + const pinned = result.filter((h) => pinnedKeys.includes(h.dataKey)) + const rest = result.filter((h) => !pinnedKeys.includes(h.dataKey)) + result = [...pinned, ...rest] + } + + return result +} + +// Left offset (px) for each pinned column's sticky positioning, keyed by +// dataKey. `visibleHeaders`/`columnWidths` must be in the same display +// order (i.e. already passed through getVisibleHeaders). +export const getPinnedLeftOffsets = ( + visibleHeaders, + pinnedKeys, + columnWidths +) => { + const offsets = {} + if (!pinnedKeys?.length || !columnWidths?.length) { + return offsets + } + + let offset = CHECKBOX_COLUMN_WIDTH + visibleHeaders.forEach((header, index) => { + if (pinnedKeys.includes(header.dataKey)) { + offsets[header.dataKey] = offset + offset += columnWidths[index] ?? 0 + } + }) + return offsets +} From a7a296f41a654b4a0677fcaa4cf3527beafeaef5 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 16 Jul 2026 15:58:16 +0200 Subject: [PATCH 058/205] feat: wire column visibility/pin/order into DataTable rendering Computes visibleHeaders from dataTableColumnConfig, feeds it into useColumnWidths, and applies @dhis2/ui's built-in fixed/left sticky-column support for pinned columns. The checkbox column only becomes fixed when something else is pinned, since @dhis2/ui renders fixed cells as <th> instead of <td> - doing this unconditionally would break every existing Cypress td-index assertion. Also fires onHeadersChange with the full unfiltered header list for a future ColumnPicker to consume. CSS selectors that were td-only (dataCell/lightText/monoCell/selected/ hovered) now also match th, since a pinned column's cells render as <th>. --- src/components/datatable/DataTable.jsx | 281 ++++++++++++------ .../datatable/styles/DataTable.module.css | 24 +- 2 files changed, 213 insertions(+), 92 deletions(-) diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index 7e05c4b3f8..8b13fd20bd 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -42,6 +42,10 @@ import { } from '../../constants/selection.js' import { isDarkColor } from '../../util/colors.js' import { formatWithSeparator } from '../../util/numbers.js' +import { + getPinnedLeftOffsets, + getVisibleHeaders, +} from '../../util/tableColumns.js' import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' import Checkbox from '../core/Checkbox.jsx' import { SortIcon } from '../core/icons.jsx' @@ -307,6 +311,7 @@ const TableComponents = { const Table = ({ availableWidth, onCountChange, + onHeadersChange, globalSearch, onClearFilters, }) => { @@ -425,12 +430,72 @@ const Table = ({ globalSearch, }) + useEffect(() => { + onHeadersChange?.(headers) + }, [onHeadersChange, headers]) + + const columnConfig = layer.dataTableColumnConfig + const pinnedKeys = useMemo( + () => columnConfig?.pinnedKeys ?? [], + [columnConfig] + ) + + const visibleHeaders = useMemo( + () => getVisibleHeaders(headers, columnConfig), + [headers, columnConfig] + ) + const { headerRowRef, columnWidths } = useColumnWidths({ availableWidth, - headers, + headers: visibleHeaders, error, }) + // Only the leading columns of visibleHeaders can ever be pinned - + // getVisibleHeaders already moves pinned columns to the front - so the + // pinned section's size is just how many headers match pinnedKeys + // before the first one that doesn't. + const pinnedColumnCount = useMemo(() => { + if (!pinnedKeys.length || !visibleHeaders) { + return 0 + } + let count = 0 + for (const header of visibleHeaders) { + if (!pinnedKeys.includes(header.dataKey)) { + break + } + count++ + } + return count + }, [visibleHeaders, pinnedKeys]) + + const pinnedLeftOffsets = useMemo( + () => getPinnedLeftOffsets(visibleHeaders, pinnedKeys, columnWidths), + [visibleHeaders, pinnedKeys, columnWidths] + ) + const pinnedOffsetsReady = Object.keys(pinnedLeftOffsets).length > 0 + + // The checkbox column only becomes sticky when something else is + // actually pinned (and its offset is ready) - otherwise it stays a + // plain (non-`fixed`) cell, since @dhis2/ui renders `fixed` cells as + // `<th>` rather than `<td>`, which would needlessly change the DOM + // shape for the common, nothing-pinned case, and briefly during column + // widths being (re)measured after a config change. + const isCheckboxColumnPinned = pinnedColumnCount > 0 && pinnedOffsetsReady + + // @dhis2/ui requires `width` whenever `fixed` is passed - unpinned + // cells keep their existing (unset) width behavior. + const getPinnedCellProps = (dataKey, index) => { + const leftOffset = pinnedLeftOffsets[dataKey] + const isPinned = index < pinnedColumnCount && leftOffset !== undefined + return { + fixed: isPinned, + left: isPinned ? `${leftOffset}px` : undefined, + width: isPinned ? `${columnWidths[index] ?? 0}px` : undefined, + isLastPinned: index === pinnedColumnCount - 1, + } + } + useEffect(() => { onCountChange?.(totalCount, filteredCount) }, [onCountChange, totalCount, filteredCount]) @@ -577,6 +642,8 @@ const Table = ({ <DataTableColumnHeader className={styles.checkboxCell} width="76px" + fixed={isCheckboxColumnPinned} + left={isCheckboxColumnPinned ? '0px' : undefined} onFilterIconClick={Function.prototype} showFilter={true} filter={ @@ -641,64 +708,78 @@ const Table = ({ </TopTooltip> </div> </DataTableColumnHeader> - {headers.map( - ({ name, dataKey, type, optionSet }, index) => ( - <DataTableColumnHeader - className={styles.columnHeader} - key={`${dataKey}-${index}`} - onFilterIconClick={ - isFilterable(dataKey, type) && - Function.prototype - } - showFilter={isFilterable(dataKey, type)} - name={dataKey} - filter={ - isFilterable(dataKey, type) && ( - <FilterInput - type={type} - dataKey={dataKey} - name={name} - options={columnOptions[dataKey]} - optionSetId={optionSet?.id} - /> - ) - } - width={ - columnWidths.length > 0 - ? `${columnWidths[index]}px` - : 'auto' - } - > - <span className={styles.headerContent}> - {name} - <TopTooltip - content={i18n.t( - 'Sort by {{column}}', - { column: name } - )} - > - <button - type="button" - className={styles.sortButton} - data-test={`data-table-column-sort-button-${name}`} - onClick={() => - sortData({ - name: dataKey, - }) - } - > - <SortIcon - direction={ - dataKey === sortField - ? sortDirection - : null + {visibleHeaders.map( + ({ name, dataKey, type, optionSet }, index) => { + const { fixed, left, isLastPinned } = + getPinnedCellProps(dataKey, index) + return ( + <DataTableColumnHeader + className={cx(styles.columnHeader, { + [styles.pinnedColumnShadow]: + isLastPinned, + })} + key={`${dataKey}-${index}`} + fixed={fixed} + left={left} + onFilterIconClick={ + isFilterable(dataKey, type) && + Function.prototype + } + showFilter={isFilterable(dataKey, type)} + name={dataKey} + filter={ + isFilterable(dataKey, type) && ( + <FilterInput + type={type} + dataKey={dataKey} + name={name} + options={ + columnOptions[dataKey] } + optionSetId={optionSet?.id} /> - </button> - </TopTooltip> - </span> - </DataTableColumnHeader> - ) + ) + } + width={ + columnWidths.length > 0 + ? `${columnWidths[index]}px` + : 'auto' + } + > + <span className={styles.headerContent}> + {name} + <TopTooltip + content={i18n.t( + 'Sort by {{column}}', + { column: name } + )} + > + <button + type="button" + className={ + styles.sortButton + } + data-test={`data-table-column-sort-button-${name}`} + onClick={() => + sortData({ + name: dataKey, + }) + } + > + <SortIcon + direction={ + dataKey === + sortField + ? sortDirection + : null + } + /> + </button> + </TopTooltip> + </span> + </DataTableColumnHeader> + ) + } )} </DataTableRow> )} @@ -710,10 +791,21 @@ const Table = ({ feature?.id === rowId && feature?.layerId === layer.id + const cellsByDataKey = new Map( + row.map((cell) => [cell.dataKey, cell]) + ) + return ( <> <DataTableCell staticStyle + fixed={isCheckboxColumnPinned} + left={ + isCheckboxColumnPinned ? '0px' : undefined + } + width={ + isCheckboxColumnPinned ? '76px' : undefined + } className={cx(styles.checkboxCell, { [styles.selected]: isSelected, [styles.hovered]: isHovered, @@ -734,35 +826,51 @@ const Table = ({ onClick={(e) => e.stopPropagation()} /> </DataTableCell> - {row.map(({ dataKey, value, align }) => ( - <DataTableCell - key={`dtcell-${dataKey}`} - staticStyle - className={cx(styles.dataCell, { - [styles.lightText]: - dataKey === 'color' && - isDarkColor(value), - [styles.monoCell]: - dataKey === 'id' || - dataKey === 'color', - [styles.selected]: - isSelected && dataKey !== 'color', - [styles.hovered]: - isHovered && dataKey !== 'color', - })} - backgroundColor={ - dataKey === 'color' ? value : null - } - align={align} - > - {dataKey === 'color' - ? value?.toLowerCase() - : formatWithSeparator( - value, - keyAnalysisDigitGroupSeparator - )} - </DataTableCell> - ))} + {visibleHeaders.map(({ dataKey }, index) => { + const cell = cellsByDataKey.get(dataKey) + if (!cell) { + return null + } + const { value, align } = cell + const { fixed, left, width, isLastPinned } = + getPinnedCellProps(dataKey, index) + return ( + <DataTableCell + key={`dtcell-${dataKey}`} + staticStyle + fixed={fixed} + left={left} + width={width} + className={cx(styles.dataCell, { + [styles.lightText]: + dataKey === 'color' && + isDarkColor(value), + [styles.monoCell]: + dataKey === 'id' || + dataKey === 'color', + [styles.selected]: + isSelected && + dataKey !== 'color', + [styles.hovered]: + isHovered && + dataKey !== 'color', + [styles.pinnedColumnShadow]: + isLastPinned, + })} + backgroundColor={ + dataKey === 'color' ? value : null + } + align={align} + > + {dataKey === 'color' + ? value?.toLowerCase() + : formatWithSeparator( + value, + keyAnalysisDigitGroupSeparator + )} + </DataTableCell> + ) + })} </> ) }} @@ -797,6 +905,7 @@ Table.propTypes = { globalSearch: PropTypes.string, onClearFilters: PropTypes.func, onCountChange: PropTypes.func, + onHeadersChange: PropTypes.func, } export default Table diff --git a/src/components/datatable/styles/DataTable.module.css b/src/components/datatable/styles/DataTable.module.css index 068594371e..b49f201729 100644 --- a/src/components/datatable/styles/DataTable.module.css +++ b/src/components/datatable/styles/DataTable.module.css @@ -7,22 +7,28 @@ user-select: none; } -td.dataCell { +/* A pinned column's cells render as <th> (@dhis2/ui's DataTableCell switches + element on `fixed`), so these need to match both td and th. */ +td.dataCell, +th.dataCell { padding-top: var(--spacers-dp8); padding-bottom: var(--spacers-dp8); font-size: 11px; overflow-wrap: anywhere; } -td.dataCell:hover { +td.dataCell:hover, +th.dataCell:hover { cursor: default; } -td.lightText { +td.lightText, +th.lightText { color: var(--colors-white); } -td.monoCell { +td.monoCell, +th.monoCell { font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', 'Consolas', monospace; } @@ -71,14 +77,20 @@ td.checkboxCell { font-size: 11px !important; } -td.selected { +td.selected, +th.selected { background-color: var(--colors-blue050); } -td.hovered { +td.hovered, +th.hovered { background-color: var(--colors-blue100); } +.pinnedColumnShadow { + box-shadow: 2px 0 4px -2px rgba(12, 14, 16, 0.15); +} + .columnHeader > :global(span.container), .checkboxCell > :global(span.container) { justify-content: space-between; From f4d36dde0c675bac2a89bcc7a98ca1aff17952bf Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 16 Jul 2026 16:12:43 +0200 Subject: [PATCH 059/205] feat: add ColumnPicker component for show/hide, pin, and reorder New popover, built on the existing FilterDropdownPopover shell and the same @dnd-kit drag-reorder pattern already used in LayersPanel.jsx (not the raw HTML5 drag-and-drop the original design sketch used). Dispatches setDataTableColumnConfig directly; not yet wired into BottomPanel. --- src/components/datatable/ColumnPicker.jsx | 297 ++++++++++++++++++ .../datatable/styles/ColumnPicker.module.css | 118 +++++++ 2 files changed, 415 insertions(+) create mode 100644 src/components/datatable/ColumnPicker.jsx create mode 100644 src/components/datatable/styles/ColumnPicker.module.css diff --git a/src/components/datatable/ColumnPicker.jsx b/src/components/datatable/ColumnPicker.jsx new file mode 100644 index 0000000000..1e2c9e9ee6 --- /dev/null +++ b/src/components/datatable/ColumnPicker.jsx @@ -0,0 +1,297 @@ +import i18n from '@dhis2/d2-i18n' +import { + IconDragHandle16, + IconLayoutColumns16, + IconLock16, + IconLockOpen16, +} from '@dhis2/ui' +import { + DndContext, + DragOverlay, + closestCenter, + KeyboardSensor, + MouseSensor, + TouchSensor, + useSensor, + useSensors, +} from '@dnd-kit/core' +import { restrictToVerticalAxis } from '@dnd-kit/modifiers' +import { + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import { arrayMoveImmutable } from 'array-move' +import cx from 'classnames' +import PropTypes from 'prop-types' +import React, { useRef, useState } from 'react' +import { useDispatch } from 'react-redux' +import { setDataTableColumnConfig } from '../../actions/dataTable.js' +import { getVisibleHeaders } from '../../util/tableColumns.js' +import Checkbox from '../core/Checkbox.jsx' +import { + FilterDropdownPopover, + getDropdownPlacement, +} from './FilterDropdownPopover.jsx' +import styles from './styles/ColumnPicker.module.css' + +const ColumnRow = ({ + header, + isVisible, + isPinned, + onToggleVisible, + onTogglePinned, +}) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: header.dataKey }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + zIndex: isDragging ? 1 : undefined, + opacity: isDragging ? 0 : 1, + } + + const pinLabel = isPinned + ? i18n.t('Unpin column') + : i18n.t('Pin column to the left') + + return ( + <div ref={setNodeRef} style={style} className={styles.columnRow}> + <button + type="button" + className={styles.dragHandle} + title={i18n.t('Drag to reorder')} + aria-label={i18n.t('Drag to reorder')} + data-test={`data-table-column-picker-drag-${header.dataKey}`} + {...attributes} + {...listeners} + > + <IconDragHandle16 /> + </button> + <Checkbox + label={header.name} + checked={isVisible} + onChange={(checked) => onToggleVisible(header.dataKey, checked)} + className={styles.columnRowCheckbox} + dataTest={`data-table-column-picker-visible-${header.dataKey}`} + /> + <button + type="button" + className={cx(styles.pinButton, { + [styles.pinButtonActive]: isPinned, + })} + title={pinLabel} + aria-label={pinLabel} + data-test={`data-table-column-picker-pin-${header.dataKey}`} + onClick={() => onTogglePinned(header.dataKey)} + > + {isPinned ? <IconLock16 /> : <IconLockOpen16 />} + </button> + </div> + ) +} + +ColumnRow.propTypes = { + header: PropTypes.shape({ + dataKey: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + }).isRequired, + isPinned: PropTypes.bool.isRequired, + isVisible: PropTypes.bool.isRequired, + onTogglePinned: PropTypes.func.isRequired, + onToggleVisible: PropTypes.func.isRequired, +} + +const ColumnPicker = ({ layerId, allHeaders, columnConfig }) => { + const dispatch = useDispatch() + const anchorRef = useRef(null) + const [isOpen, setIsOpen] = useState(false) + const [activeId, setActiveId] = useState(null) + + // useTableData can legitimately return a null headers list (e.g. while + // loading or on error) - guard here rather than trust callers to. + const headers = allHeaders ?? [] + + const visibleKeys = + columnConfig?.visibleKeys ?? headers.map((h) => h.dataKey) + const pinnedKeys = columnConfig?.pinnedKeys ?? [] + const orderedKeys = + columnConfig?.orderedKeys ?? headers.map((h) => h.dataKey) + + // Same reorder-then-pin-to-front logic the table itself renders with, + // so the picker's row order always matches the table's actual column + // order. visibleKeys is deliberately not passed here - every column + // gets a row in the picker (hidden ones just show an unchecked box). + // Dragging a column across the pinned/unpinned boundary still snaps it + // back to whichever side its own pinned state puts it on next render - + // pin state is the button's job, not drag's. + const orderedHeaders = getVisibleHeaders(headers, { + orderedKeys, + pinnedKeys, + }) + + const updateConfig = (partial) => + dispatch( + setDataTableColumnConfig(layerId, { + visibleKeys, + pinnedKeys, + orderedKeys, + ...partial, + }) + ) + + const onToggleVisible = (dataKey, checked) => { + const next = checked + ? [...visibleKeys, dataKey] + : visibleKeys.filter((k) => k !== dataKey) + updateConfig({ visibleKeys: next }) + } + + const onTogglePinned = (dataKey) => { + const next = pinnedKeys.includes(dataKey) + ? pinnedKeys.filter((k) => k !== dataKey) + : [...pinnedKeys, dataKey] + updateConfig({ pinnedKeys: next }) + } + + const sensors = useSensors( + useSensor(MouseSensor, { + // Require a small movement so a click on the handle isn't a drag + activationConstraint: { distance: 5 }, + }), + useSensor(TouchSensor, { + activationConstraint: { delay: 250, tolerance: 5 }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ) + + const onDragEnd = ({ active, over }) => { + setActiveId(null) + + if (over && active.id !== over.id) { + const oldIndex = orderedHeaders.findIndex( + (h) => h.dataKey === active.id + ) + const newIndex = orderedHeaders.findIndex( + (h) => h.dataKey === over.id + ) + + if (oldIndex !== -1 && newIndex !== -1) { + const nextOrder = arrayMoveImmutable( + orderedHeaders, + oldIndex, + newIndex + ).map((h) => h.dataKey) + updateConfig({ orderedKeys: nextOrder }) + } + } + } + + const activeHeader = orderedHeaders.find((h) => h.dataKey === activeId) + + const anchorRect = anchorRef.current?.getBoundingClientRect() + const { dropdownPlacement } = getDropdownPlacement(anchorRect) + + return ( + <> + <button + type="button" + ref={anchorRef} + className={styles.triggerButton} + disabled={!headers.length} + title={i18n.t('Configure columns')} + aria-label={i18n.t('Configure columns')} + data-test="data-table-column-picker-button" + onClick={() => setIsOpen((o) => !o)} + > + <IconLayoutColumns16 /> + </button> + {isOpen && ( + <FilterDropdownPopover + reference={anchorRef} + placement={dropdownPlacement} + onClickOutside={() => setIsOpen(false)} + > + <div className={styles.columnPickerPopover}> + <p className={styles.columnPickerHint}> + {i18n.t( + 'Drag to reorder, check to show or hide, lock to pin left' + )} + </p> + <DndContext + sensors={sensors} + collisionDetection={closestCenter} + modifiers={[restrictToVerticalAxis]} + onDragStart={({ active }) => setActiveId(active.id)} + onDragEnd={onDragEnd} + onDragCancel={() => setActiveId(null)} + > + <SortableContext + items={orderedHeaders.map((h) => h.dataKey)} + strategy={verticalListSortingStrategy} + > + <div className={styles.columnList}> + {orderedHeaders.map((header) => ( + <ColumnRow + key={header.dataKey} + header={header} + isVisible={visibleKeys.includes( + header.dataKey + )} + isPinned={pinnedKeys.includes( + header.dataKey + )} + onToggleVisible={onToggleVisible} + onTogglePinned={onTogglePinned} + /> + ))} + </div> + </SortableContext> + <DragOverlay modifiers={[restrictToVerticalAxis]}> + {activeHeader ? ( + <div + className={cx( + styles.columnRow, + styles.dragOverlay + )} + > + <IconDragHandle16 /> + {activeHeader.name} + </div> + ) : null} + </DragOverlay> + </DndContext> + </div> + </FilterDropdownPopover> + )} + </> + ) +} + +ColumnPicker.propTypes = { + layerId: PropTypes.string.isRequired, + allHeaders: PropTypes.arrayOf( + PropTypes.shape({ + dataKey: PropTypes.string, + name: PropTypes.string, + }) + ), + columnConfig: PropTypes.shape({ + orderedKeys: PropTypes.arrayOf(PropTypes.string), + pinnedKeys: PropTypes.arrayOf(PropTypes.string), + visibleKeys: PropTypes.arrayOf(PropTypes.string), + }), +} + +export default ColumnPicker diff --git a/src/components/datatable/styles/ColumnPicker.module.css b/src/components/datatable/styles/ColumnPicker.module.css new file mode 100644 index 0000000000..0e0abf6e68 --- /dev/null +++ b/src/components/datatable/styles/ColumnPicker.module.css @@ -0,0 +1,118 @@ +.triggerButton { + cursor: pointer; + color: var(--colors-grey800); + background-color: transparent; + width: 24px; + height: 24px; + border: none; + border-radius: 3px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + padding: 0; +} + +.triggerButton:hover:not(:disabled) { + color: var(--colors-grey900); + background-color: var(--colors-grey300); +} + +.triggerButton:disabled { + color: var(--colors-grey400); + cursor: not-allowed; +} + +.columnPickerPopover { + padding: var(--spacers-dp8); + min-width: 220px; + background-color: var(--colors-white); + border-radius: 4px; + box-shadow: var(--elevations-popover); +} + +.columnPickerHint { + margin: 0 0 var(--spacers-dp8); + font-size: 11px; + font-style: italic; + color: var(--colors-grey600); +} + +.columnList { + display: flex; + flex-direction: column; + max-height: 260px; + overflow-y: auto; +} + +.columnRow { + display: flex; + align-items: center; + gap: var(--spacers-dp4); + padding: 2px 0; +} + +.columnRowCheckbox { + flex: 1; + min-width: 0; +} + +.dragHandle { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 20px; + height: 20px; + padding: 0; + border: none; + border-radius: 3px; + background: transparent; + color: var(--colors-grey600); + cursor: grab; + touch-action: none; +} + +.dragHandle:hover { + background: var(--colors-grey100); + color: var(--colors-grey800); +} + +.pinButton { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 20px; + height: 20px; + padding: 0; + border: none; + border-radius: 3px; + background: transparent; + color: var(--colors-grey500); + cursor: pointer; +} + +.pinButton:hover { + background: var(--colors-grey100); + color: var(--colors-grey800); +} + +.pinButtonActive { + color: var(--colors-blue700); +} + +.pinButtonActive:hover { + color: var(--colors-blue800); +} + +.dragOverlay { + display: flex; + align-items: center; + gap: var(--spacers-dp4); + padding: 2px var(--spacers-dp8); + background-color: var(--colors-white); + border-radius: 3px; + box-shadow: var(--elevations-popover); + font-size: 12px; +} From cbe7ee2dd90375ddb3331a6b9911120fc889bef6 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 16 Jul 2026 16:32:10 +0200 Subject: [PATCH 060/205] test: add coverage for dataTableColumnConfig persistence and ColumnPicker favorites.spec.js: end-to-end cleanMapConfig round-trip for dataTableColumnConfig across thematic/earthEngine/TEI/geojson layer types, verified to actually depend on the validLayerProperties whitelist entry. ColumnPicker.spec.jsx: render-and-interact tests (disabled state, default visibility, toggle/pin dispatch with full config assertions, pinned-first ordering). Drag-reorder (onDragEnd) isn't covered - confirmed by direct experiment that dnd-kit's KeyboardSensor doesn't produce a usable drag in jsdom without much heavier mocking than is worthwhile here. --- .../datatable/__tests__/ColumnPicker.spec.jsx | 151 ++++++++++++++++++ src/util/__tests__/favorites.spec.js | 104 ++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 src/components/datatable/__tests__/ColumnPicker.spec.jsx diff --git a/src/components/datatable/__tests__/ColumnPicker.spec.jsx b/src/components/datatable/__tests__/ColumnPicker.spec.jsx new file mode 100644 index 0000000000..f1dd502a10 --- /dev/null +++ b/src/components/datatable/__tests__/ColumnPicker.spec.jsx @@ -0,0 +1,151 @@ +import { render, fireEvent, screen } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import configureMockStore from 'redux-mock-store' +import { DATA_TABLE_COLUMN_CONFIG_SET } from '../../../constants/actionTypes.js' +import ColumnPicker from '../ColumnPicker.jsx' + +const mockStore = configureMockStore() + +const headers = [ + { name: 'Name', dataKey: 'name' }, + { name: 'Value', dataKey: 'rawValue' }, + { name: 'Legend', dataKey: 'legend' }, +] + +const renderColumnPicker = (props) => { + const store = mockStore({}) + const result = render( + <Provider store={store}> + <ColumnPicker layerId="layer1" allHeaders={headers} {...props} /> + </Provider> + ) + return { ...result, store } +} + +const openPicker = () => + fireEvent.click(screen.getByTestId('data-table-column-picker-button')) + +describe('ColumnPicker trigger', () => { + test('is disabled when there are no headers yet', () => { + renderColumnPicker({ allHeaders: null }) + expect( + screen.getByTestId('data-table-column-picker-button') + ).toBeDisabled() + }) + + test('is disabled when allHeaders is an empty array', () => { + renderColumnPicker({ allHeaders: [] }) + expect( + screen.getByTestId('data-table-column-picker-button') + ).toBeDisabled() + }) + + test('is enabled once headers are available', () => { + renderColumnPicker() + expect( + screen.getByTestId('data-table-column-picker-button') + ).not.toBeDisabled() + }) + + test('a click on the disabled trigger does not open the popover', () => { + renderColumnPicker({ allHeaders: [] }) + openPicker() + expect(screen.queryByLabelText('Name')).not.toBeInTheDocument() + }) + + test('opens a popover listing every column, checked by default', () => { + renderColumnPicker() + openPicker() + expect(screen.getByLabelText('Name')).toBeChecked() + expect(screen.getByLabelText('Value')).toBeChecked() + expect(screen.getByLabelText('Legend')).toBeChecked() + }) +}) + +describe('ColumnPicker visibility toggling', () => { + test('unchecking a column dispatches visibleKeys without that column, leaving order/pinning untouched', () => { + const { store } = renderColumnPicker() + openPicker() + fireEvent.click(screen.getByLabelText('Value')) + expect(store.getActions()).toContainEqual({ + type: DATA_TABLE_COLUMN_CONFIG_SET, + layerId: 'layer1', + config: { + visibleKeys: ['name', 'legend'], + pinnedKeys: [], + orderedKeys: ['name', 'rawValue', 'legend'], + }, + }) + }) + + test('rechecking a hidden column dispatches visibleKeys with it added back, leaving order/pinning untouched', () => { + const { store } = renderColumnPicker({ + columnConfig: { visibleKeys: ['name', 'legend'] }, + }) + openPicker() + expect(screen.getByLabelText('Value')).not.toBeChecked() + fireEvent.click(screen.getByLabelText('Value')) + expect(store.getActions()).toContainEqual({ + type: DATA_TABLE_COLUMN_CONFIG_SET, + layerId: 'layer1', + config: { + visibleKeys: ['name', 'legend', 'rawValue'], + pinnedKeys: [], + orderedKeys: ['name', 'rawValue', 'legend'], + }, + }) + }) +}) + +describe('ColumnPicker pinning', () => { + test('pinning a column dispatches pinnedKeys including it, leaving visibility/order untouched', () => { + const { store } = renderColumnPicker() + openPicker() + fireEvent.click( + screen.getByTestId('data-table-column-picker-pin-rawValue') + ) + expect(store.getActions()).toContainEqual({ + type: DATA_TABLE_COLUMN_CONFIG_SET, + layerId: 'layer1', + config: { + visibleKeys: ['name', 'rawValue', 'legend'], + pinnedKeys: ['rawValue'], + orderedKeys: ['name', 'rawValue', 'legend'], + }, + }) + }) + + test('unpinning an already-pinned column dispatches pinnedKeys without it, leaving visibility/order untouched', () => { + const { store } = renderColumnPicker({ + columnConfig: { pinnedKeys: ['rawValue'] }, + }) + openPicker() + fireEvent.click( + screen.getByTestId('data-table-column-picker-pin-rawValue') + ) + expect(store.getActions()).toContainEqual({ + type: DATA_TABLE_COLUMN_CONFIG_SET, + layerId: 'layer1', + config: { + visibleKeys: ['name', 'rawValue', 'legend'], + pinnedKeys: [], + orderedKeys: ['name', 'rawValue', 'legend'], + }, + }) + }) + + test('renders pinned columns first, ahead of orderedKeys', () => { + renderColumnPicker({ + columnConfig: { + orderedKeys: ['name', 'rawValue', 'legend'], + pinnedKeys: ['legend'], + }, + }) + openPicker() + const labels = screen + .getAllByRole('checkbox') + .map((el) => el.closest('label')?.textContent) + expect(labels).toEqual(['Legend', 'Name', 'Value']) + }) +}) diff --git a/src/util/__tests__/favorites.spec.js b/src/util/__tests__/favorites.spec.js index 5db9075a14..92d9d97642 100644 --- a/src/util/__tests__/favorites.spec.js +++ b/src/util/__tests__/favorites.spec.js @@ -921,4 +921,108 @@ describe('cleanMapConfig', () => { ]) expect(cleanedConfig.mapViews[0].config).toBeUndefined() }) + + test('serializes dataTableColumnConfig into config JSON for thematic layer', () => { + const dataTableColumnConfig = { + visibleKeys: ['name', 'rawValue'], + pinnedKeys: ['name'], + orderedKeys: ['rawValue', 'name'], + } + const config = { + mapViews: [ + { + layer: 'thematic', + name: 'Test', + rows: [], + dataTableColumnConfig, + }, + ], + } + const cleanedConfig = cleanMapConfig({ + config, + defaultBasemapId: 'default', + }) + const mapView = cleanedConfig.mapViews[0] + const parsedConfig = JSON.parse(mapView.config) + expect(parsedConfig.dataTableColumnConfig).toEqual( + dataTableColumnConfig + ) + expect(mapView).not.toHaveProperty('dataTableColumnConfig') + }) + + test('serializes dataTableColumnConfig into config JSON for earth engine layer', () => { + const dataTableColumnConfig = { visibleKeys: ['name'] } + const config = { + mapViews: [ + { + layer: 'earthEngine', + layerId: 'MODIS/006/MOD13A2', + rows: [], + dataTableColumnConfig, + }, + ], + } + const cleanedConfig = cleanMapConfig({ + config, + defaultBasemapId: 'default', + }) + const mapView = cleanedConfig.mapViews[0] + const parsedConfig = JSON.parse(mapView.config) + expect(parsedConfig.dataTableColumnConfig).toEqual( + dataTableColumnConfig + ) + expect(mapView).not.toHaveProperty('dataTableColumnConfig') + }) + + test('serializes dataTableColumnConfig into config JSON for TEI layer', () => { + const dataTableColumnConfig = { pinnedKeys: ['id'] } + const config = { + mapViews: [ + { + layer: 'trackedEntity', + name: 'Tracked entity', + rows: [], + dataTableColumnConfig, + }, + ], + } + const cleanedConfig = cleanMapConfig({ + config, + defaultBasemapId: 'default', + }) + const mapView = cleanedConfig.mapViews[0] + const parsedConfig = JSON.parse(mapView.config) + expect(parsedConfig.dataTableColumnConfig).toEqual( + dataTableColumnConfig + ) + expect(mapView).not.toHaveProperty('dataTableColumnConfig') + }) + + test('serializes dataTableColumnConfig into config JSON for geojson layer', () => { + const dataTableColumnConfig = { orderedKeys: ['name', 'id'] } + const config = { + mapViews: [ + { + layer: 'geoJsonUrl', + name: 'My GeoJSON', + rows: [], + config: { + id: 'abc', + url: 'https://example.com/geo.json', + }, + dataTableColumnConfig, + }, + ], + } + const cleanedConfig = cleanMapConfig({ + config, + defaultBasemapId: 'default', + }) + const mapView = cleanedConfig.mapViews[0] + const parsedConfig = JSON.parse(mapView.config) + expect(parsedConfig.dataTableColumnConfig).toEqual( + dataTableColumnConfig + ) + expect(mapView).not.toHaveProperty('dataTableColumnConfig') + }) }) From b456a999ab26b71c1746ba05c3eac7b921d83592 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 16 Jul 2026 16:44:37 +0200 Subject: [PATCH 061/205] chore: extract i18n strings for ColumnPicker Auto-generated by the i18n extraction tooling to include the new translatable strings introduced in ColumnPicker.jsx/BottomPanel.jsx. --- i18n/en.pot | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index a28648bb60..aa26346c31 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-16T11:34:34.211Z\n" -"PO-Revision-Date: 2026-07-16T11:34:34.212Z\n" +"POT-Creation-Date: 2026-07-16T14:26:22.933Z\n" +"PO-Revision-Date: 2026-07-16T14:26:22.933Z\n" msgid "2020" msgstr "2020" @@ -182,6 +182,21 @@ msgstr "Show only features in current map view" msgid "Close" msgstr "Close" +msgid "Unpin column" +msgstr "Unpin column" + +msgid "Pin column to the left" +msgstr "Pin column to the left" + +msgid "Drag to reorder" +msgstr "Drag to reorder" + +msgid "Configure columns" +msgstr "Configure columns" + +msgid "Drag to reorder, check to show or hide, lock to pin left" +msgstr "Drag to reorder, check to show or hide, lock to pin left" + msgid "Selected" msgstr "Selected" From a1e51f579dc0b279919be6efa5440541357240f4 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Fri, 17 Jul 2026 00:05:07 +0200 Subject: [PATCH 062/205] fix: small improvements --- i18n/en.pot | 13 +- src/components/core/Checkbox.jsx | 2 +- src/components/core/icons.jsx | 4 +- src/components/datatable/BottomPanel.jsx | 30 ++- src/components/datatable/ColumnPicker.jsx | 232 ++++++++++++++---- src/components/datatable/FilterInput.jsx | 61 ++++- .../datatable/styles/ColumnPicker.module.css | 68 +++-- .../datatable/styles/DataTable.module.css | 22 +- 8 files changed, 335 insertions(+), 97 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index aa26346c31..f61371b5fc 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-16T14:26:22.933Z\n" -"PO-Revision-Date: 2026-07-16T14:26:22.933Z\n" +"POT-Creation-Date: 2026-07-16T17:33:16.764Z\n" +"PO-Revision-Date: 2026-07-16T17:33:16.764Z\n" msgid "2020" msgstr "2020" @@ -182,21 +182,18 @@ msgstr "Show only features in current map view" msgid "Close" msgstr "Close" +msgid "Drag to reorder" +msgstr "Drag to reorder" + msgid "Unpin column" msgstr "Unpin column" msgid "Pin column to the left" msgstr "Pin column to the left" -msgid "Drag to reorder" -msgstr "Drag to reorder" - msgid "Configure columns" msgstr "Configure columns" -msgid "Drag to reorder, check to show or hide, lock to pin left" -msgstr "Drag to reorder, check to show or hide, lock to pin left" - msgid "Selected" msgstr "Selected" diff --git a/src/components/core/Checkbox.jsx b/src/components/core/Checkbox.jsx index 18a7798a40..5567af53e2 100644 --- a/src/components/core/Checkbox.jsx +++ b/src/components/core/Checkbox.jsx @@ -43,7 +43,7 @@ Checkbox.propTypes = { dataTest: PropTypes.string, dense: PropTypes.bool, disabled: PropTypes.bool, - label: PropTypes.string, + label: PropTypes.node, style: PropTypes.object, tooltip: PropTypes.string, } diff --git a/src/components/core/icons.jsx b/src/components/core/icons.jsx index b46a6354f5..1a067ea7bd 100644 --- a/src/components/core/icons.jsx +++ b/src/components/core/icons.jsx @@ -13,7 +13,7 @@ export const SortIcon = ({ direction }) => ( <polygon fill={ direction === 'desc' - ? 'var(--colors-blue700)' + ? 'var(--colors-teal600)' : 'var(--colors-grey500)' } points="4 9 12 9 8 14" @@ -21,7 +21,7 @@ export const SortIcon = ({ direction }) => ( <polygon fill={ direction === 'asc' - ? 'var(--colors-blue700)' + ? 'var(--colors-teal600)' : 'var(--colors-grey500)' } points="4 7 12 7 8 2" diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index 7f8edc200f..e7982a1b79 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -30,6 +30,7 @@ import useKeyDown from '../../hooks/useKeyDown.js' import { getCssVar } from '../../util/helpers.js' import ColorPicker from '../core/ColorPicker.jsx' import { useWindowDimensions } from '../WindowDimensionsProvider.jsx' +import ColumnPicker from './ColumnPicker.jsx' import DataTable from './DataTable.jsx' import ErrorBoundary from './ErrorBoundary.jsx' import ResizeHandle from './ResizeHandle.jsx' @@ -64,6 +65,7 @@ const BottomPanel = () => { const [nameTooltipPos, setNameTooltipPos] = useState(null) const [isCollapsed, setIsCollapsed] = useState(false) const [globalSearch, setGlobalSearch] = useState('') + const [headersByLayer, setHeadersByLayer] = useState(null) const hasActiveFilters = Object.keys(dataFilters).length > 0 || @@ -82,6 +84,16 @@ const BottomPanel = () => { [] ) + const onControlsDoubleClick = useCallback( + (e) => { + if (e.target.closest('button, input, label')) { + return + } + toggleCollapsed() + }, + [toggleCollapsed] + ) + const onResizeStart = useCallback(() => { isDraggingRef.current = true }, []) @@ -112,6 +124,15 @@ const BottomPanel = () => { setFilteredCount(filtered) }, []) + const onHeadersChange = useCallback((headers, layerId) => { + setHeadersByLayer({ layerId, headers }) + }, []) + + const allHeaders = + headersByLayer?.layerId === activeLayerId + ? headersByLayer.headers + : null + const onClearFilters = useCallback(() => { dispatch(clearDataFilters(activeLayerId)) dispatch(setSelectionFilter([])) @@ -192,7 +213,7 @@ const BottomPanel = () => { > <div className={styles.dataTableControls} - onDoubleClick={toggleCollapsed} + onDoubleClick={onControlsDoubleClick} > <button type="button" @@ -253,6 +274,12 @@ const BottomPanel = () => { /> </span> </Tooltip> + <ColumnPicker + layerId={activeLayerId} + allHeaders={allHeaders} + columnConfig={activeLayer?.dataTableColumnConfig} + /> + <span className={styles.divider} /> <ResizeHandle maxHeight={maxHeight} minHeight={MIN_HEIGHT} @@ -334,6 +361,7 @@ const BottomPanel = () => { <DataTable availableWidth={panelWidth} onCountChange={onCountChange} + onHeadersChange={onHeadersChange} globalSearch={globalSearch} onClearFilters={onClearFilters} /> diff --git a/src/components/datatable/ColumnPicker.jsx b/src/components/datatable/ColumnPicker.jsx index 1e2c9e9ee6..4eea47177b 100644 --- a/src/components/datatable/ColumnPicker.jsx +++ b/src/components/datatable/ColumnPicker.jsx @@ -4,6 +4,7 @@ import { IconLayoutColumns16, IconLock16, IconLockOpen16, + Tooltip, } from '@dhis2/ui' import { DndContext, @@ -27,6 +28,7 @@ import { arrayMoveImmutable } from 'array-move' import cx from 'classnames' import PropTypes from 'prop-types' import React, { useRef, useState } from 'react' +import { createPortal } from 'react-dom' import { useDispatch } from 'react-redux' import { setDataTableColumnConfig } from '../../actions/dataTable.js' import { getVisibleHeaders } from '../../util/tableColumns.js' @@ -37,65 +39,148 @@ import { } from './FilterDropdownPopover.jsx' import styles from './styles/ColumnPicker.module.css' -const ColumnRow = ({ +// Higher than this codebase's usual z-index: 2000 "float above everything" +// convention (e.g. DataTable.module.css's .topTooltipContent), since the +// overlay must render above the popover itself, which relies on that same +// convention for its own stacking. +const DRAG_OVERLAY_Z_INDEX = 2100 + +const noop = () => {} + +// Shared visual content for a column row - used both by the interactive +// ColumnRow (which wraps it with useSortable's drag styling) and by the +// DragOverlay preview, so the dragged clone can never visually drift from +// the real row it's standing in for. dragHandleProps/onToggle* are omitted +// for the (non-interactive) overlay preview, and dataTestSuffix keeps its +// data-test ids from colliding with the real row's while both are mounted +// during an active drag. +const ColumnRowFields = ({ header, isVisible, isPinned, - onToggleVisible, - onTogglePinned, + dragHandleProps, + dataTestSuffix = '', + suppressTooltips = false, + onToggleVisible = noop, + onTogglePinned = noop, }) => { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: header.dataKey }) - - const style = { - transform: CSS.Transform.toString(transform), - transition, - zIndex: isDragging ? 1 : undefined, - opacity: isDragging ? 0 : 1, - } - + const dragLabel = i18n.t('Drag to reorder') const pinLabel = isPinned ? i18n.t('Unpin column') : i18n.t('Pin column to the left') + // While a drag is in progress, the cursor keeps passing over every + // row's drag handle/pin button - those still fire real mouseover + // events, so without this guard, other rows' tooltips would pop open + // mid-drag. Not rendering the Tooltip wrapper (rather than e.g. hiding + // its content) also unmounts any tooltip that was already open. + const dragIcon = <IconDragHandle16 /> + const pinIcon = isPinned ? <IconLock16 /> : <IconLockOpen16 /> + return ( - <div ref={setNodeRef} style={style} className={styles.columnRow}> + <> <button type="button" className={styles.dragHandle} - title={i18n.t('Drag to reorder')} - aria-label={i18n.t('Drag to reorder')} - data-test={`data-table-column-picker-drag-${header.dataKey}`} - {...attributes} - {...listeners} + aria-label={dragLabel} + data-test={`data-table-column-picker-drag-${header.dataKey}${dataTestSuffix}`} + draggable={false} + {...dragHandleProps} > - <IconDragHandle16 /> + {suppressTooltips ? ( + dragIcon + ) : ( + <Tooltip content={dragLabel} placement="top"> + {dragIcon} + </Tooltip> + )} </button> <Checkbox - label={header.name} + label={ + <span className={styles.columnRowLabel}>{header.name}</span> + } checked={isVisible} onChange={(checked) => onToggleVisible(header.dataKey, checked)} className={styles.columnRowCheckbox} - dataTest={`data-table-column-picker-visible-${header.dataKey}`} + dataTest={`data-table-column-picker-visible-${header.dataKey}${dataTestSuffix}`} /> <button type="button" className={cx(styles.pinButton, { [styles.pinButtonActive]: isPinned, })} - title={pinLabel} aria-label={pinLabel} - data-test={`data-table-column-picker-pin-${header.dataKey}`} + data-test={`data-table-column-picker-pin-${header.dataKey}${dataTestSuffix}`} onClick={() => onTogglePinned(header.dataKey)} > - {isPinned ? <IconLock16 /> : <IconLockOpen16 />} + {suppressTooltips ? ( + pinIcon + ) : ( + <Tooltip content={pinLabel} placement="top"> + {pinIcon} + </Tooltip> + )} </button> + </> + ) +} + +ColumnRowFields.propTypes = { + header: PropTypes.shape({ + dataKey: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + }).isRequired, + isPinned: PropTypes.bool.isRequired, + isVisible: PropTypes.bool.isRequired, + dataTestSuffix: PropTypes.string, + dragHandleProps: PropTypes.object, + suppressTooltips: PropTypes.bool, + onTogglePinned: PropTypes.func, + onToggleVisible: PropTypes.func, +} + +const ColumnRow = ({ + header, + isVisible, + isPinned, + isPinnedGroupEnd, + isDragActive, + onToggleVisible, + onTogglePinned, +}) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: header.dataKey }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + zIndex: isDragging ? 1 : undefined, + opacity: isDragging ? 0 : 1, + } + + return ( + <div + ref={setNodeRef} + style={style} + className={cx(styles.columnRow, { + [styles.columnRowDivider]: isPinnedGroupEnd, + })} + > + <ColumnRowFields + header={header} + isVisible={isVisible} + isPinned={isPinned} + dragHandleProps={{ ...attributes, ...listeners }} + suppressTooltips={isDragActive} + onToggleVisible={onToggleVisible} + onTogglePinned={onTogglePinned} + /> </div> ) } @@ -105,7 +190,9 @@ ColumnRow.propTypes = { dataKey: PropTypes.string.isRequired, name: PropTypes.string.isRequired, }).isRequired, + isDragActive: PropTypes.bool.isRequired, isPinned: PropTypes.bool.isRequired, + isPinnedGroupEnd: PropTypes.bool.isRequired, isVisible: PropTypes.bool.isRequired, onTogglePinned: PropTypes.func.isRequired, onToggleVisible: PropTypes.func.isRequired, @@ -139,6 +226,17 @@ const ColumnPicker = ({ layerId, allHeaders, columnConfig }) => { pinnedKeys, }) + // Mirrors DataTable.jsx's pinnedColumnCount: getVisibleHeaders already + // moves pinned columns to the front, so the pinned group's size is just + // how many headers match pinnedKeys before the first one that doesn't. + let pinnedCount = 0 + for (const header of orderedHeaders) { + if (!pinnedKeys.includes(header.dataKey)) { + break + } + pinnedCount++ + } + const updateConfig = (partial) => dispatch( setDataTableColumnConfig(layerId, { @@ -210,12 +308,15 @@ const ColumnPicker = ({ layerId, allHeaders, columnConfig }) => { ref={anchorRef} className={styles.triggerButton} disabled={!headers.length} - title={i18n.t('Configure columns')} aria-label={i18n.t('Configure columns')} data-test="data-table-column-picker-button" onClick={() => setIsOpen((o) => !o)} > - <IconLayoutColumns16 /> + <Tooltip content={i18n.t('Configure columns')} placement="top"> + <span className={styles.alignIcon1}> + <IconLayoutColumns16 /> + </span> + </Tooltip> </button> {isOpen && ( <FilterDropdownPopover @@ -224,11 +325,6 @@ const ColumnPicker = ({ layerId, allHeaders, columnConfig }) => { onClickOutside={() => setIsOpen(false)} > <div className={styles.columnPickerPopover}> - <p className={styles.columnPickerHint}> - {i18n.t( - 'Drag to reorder, check to show or hide, lock to pin left' - )} - </p> <DndContext sensors={sensors} collisionDetection={closestCenter} @@ -242,7 +338,7 @@ const ColumnPicker = ({ layerId, allHeaders, columnConfig }) => { strategy={verticalListSortingStrategy} > <div className={styles.columnList}> - {orderedHeaders.map((header) => ( + {orderedHeaders.map((header, index) => ( <ColumnRow key={header.dataKey} header={header} @@ -252,25 +348,57 @@ const ColumnPicker = ({ layerId, allHeaders, columnConfig }) => { isPinned={pinnedKeys.includes( header.dataKey )} + isPinnedGroupEnd={ + index === pinnedCount - 1 && + pinnedCount < + orderedHeaders.length + } + isDragActive={activeId != null} onToggleVisible={onToggleVisible} onTogglePinned={onTogglePinned} /> ))} </div> </SortableContext> - <DragOverlay modifiers={[restrictToVerticalAxis]}> - {activeHeader ? ( - <div - className={cx( - styles.columnRow, - styles.dragOverlay - )} - > - <IconDragHandle16 /> - {activeHeader.name} - </div> - ) : null} - </DragOverlay> + {createPortal( + // DragOverlay renders inline wherever it's + // placed and relies on `position: fixed` to + // escape into the viewport - but it's nested + // inside FilterDropdownPopover's Popper, + // which positions itself via a CSS + // `transform`. A `transform` on an ancestor + // creates a new containing block for + // `position: fixed` descendants, so without + // this portal the overlay ends up positioned + // relative to the popover instead of the + // viewport (rendering off-screen or hidden). + <DragOverlay + modifiers={[restrictToVerticalAxis]} + zIndex={DRAG_OVERLAY_Z_INDEX} + > + {activeHeader ? ( + <div + className={cx( + styles.columnRow, + styles.dragOverlay + )} + > + <ColumnRowFields + header={activeHeader} + isVisible={visibleKeys.includes( + activeHeader.dataKey + )} + isPinned={pinnedKeys.includes( + activeHeader.dataKey + )} + dataTestSuffix="-preview" + suppressTooltips + /> + </div> + ) : null} + </DragOverlay>, + document.body + )} </DndContext> </div> </FilterDropdownPopover> diff --git a/src/components/datatable/FilterInput.jsx b/src/components/datatable/FilterInput.jsx index ee2fd45156..65f0d0b604 100644 --- a/src/components/datatable/FilterInput.jsx +++ b/src/components/datatable/FilterInput.jsx @@ -2,7 +2,7 @@ import i18n from '@dhis2/d2-i18n' import { Input, Popper, Portal, IconFilter16, IconSync16 } from '@dhis2/ui' import cx from 'classnames' import PropTypes from 'prop-types' -import React, { useEffect, useRef, useState } from 'react' +import React, { useEffect, useMemo, useRef, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import { Virtuoso } from 'react-virtuoso' import { setDataFilter, clearDataFilter } from '../../actions/dataFilters.js' @@ -30,6 +30,11 @@ import styles from './styles/FilterInput.module.css' const OPTION_ROW_HEIGHT = 28 // Checkbox rows are a fixed height so the list can be virtualized const MAX_LIST_HEIGHT = 260 const MIN_POPOVER_WIDTH = 140 +const MAX_POPOVER_WIDTH = 280 +// Checkbox icon + its margin, popover padding (both sides) and the +// scrollbar .multiSelectPopover's overflow-y: auto can show - none of +// which is part of the label text itself. +const POPOVER_ROW_CHROME_WIDTH = 56 const NUMERIC_HELP_HEIGHT = 140 const TEXT_HELP_HEIGHT = 56 const NUMERIC_FILTER_HELP = ( @@ -50,6 +55,24 @@ const TEXT_FILTER_HELP = ( ) const NUMERIC_INPUT_DISALLOWED = /[^0-9.\-<>=,&\s]/g +// Options render through react-virtuoso, which positions rows absolutely +// for virtualization - out-of-flow content like that is excluded from CSS's +// own intrinsic (max-content) sizing, so a container can never grow to fit +// virtualized content via CSS alone. Measuring the label text directly is +// the standard workaround. +let measureCanvasContext = null +const measureMaxTextWidth = (texts, font) => { + if (!measureCanvasContext) { + measureCanvasContext = document.createElement('canvas').getContext('2d') + } + measureCanvasContext.font = font + return texts.reduce( + (max, text) => + Math.max(max, measureCanvasContext.measureText(text).width), + 0 + ) +} + const helpTooltipModifiers = [ { name: 'offset', options: { offset: [0, 4] } }, { name: 'flip', enabled: false }, @@ -196,7 +219,6 @@ const SearchableFilterPopover = ({ const closePopover = () => setIsOpen(false) const anchorRect = anchorRef.current?.getBoundingClientRect() - const anchorWidth = anchorRect?.width const { dropdownPlacement, dropdownSide, tooltipPlacement } = getDropdownPlacement(anchorRect) @@ -226,6 +248,23 @@ const SearchableFilterPopover = ({ const realValues = realOptions.map((o) => o.value) const anyValueActive = selected.includes(SENTINEL_ANY_VALUE) + const popoverWidth = useMemo(() => { + const labels = realOptions.map((o) => resolveLabel(o.value)) + if (hasNotSetOption) { + labels.push(resolveLabel(SENTINEL_NO_VALUE)) + } + const font = `11px ${getComputedStyle(document.body).fontFamily}` + const maxLabelWidth = measureMaxTextWidth(labels, font) + return Math.min( + Math.max( + maxLabelWidth + POPOVER_ROW_CHROME_WIDTH, + MIN_POPOVER_WIDTH + ), + MAX_POPOVER_WIDTH + ) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [realOptions, hasNotSetOption]) + const onToggleAnyValue = () => applyValues(toggleAnyValue(selected)) const invertibleValues = getInvertibleValues(hasNotSetOption, realValues) @@ -406,14 +445,7 @@ const SearchableFilterPopover = ({ className={cx(styles.searchableFilterPopover, { [styles.reversedOrder]: dropdownSide === 'top', })} - style={{ - minWidth: anchorWidth - ? `${Math.max( - anchorWidth, - MIN_POPOVER_WIDTH - )}px` - : undefined, - }} + style={{ width: `${popoverWidth}px` }} > {showCustomFilterRow && ( <button @@ -454,7 +486,7 @@ const SearchableFilterPopover = ({ checked={anyValueActive} onChange={onToggleAnyValue} className={styles.specialOption} - style={{ margin: '4px 0' }} + style={{ margin: 0, padding: '4px 0' }} dataTest={`data-table-column-filter-any-${name}`} /> {hasNotSetOption && ( @@ -467,7 +499,7 @@ const SearchableFilterPopover = ({ toggleValue(SENTINEL_NO_VALUE) } className={styles.specialOption} - style={{ margin: '4px 0' }} + style={{ margin: 0, padding: '4px 0' }} dataTest={`data-table-column-filter-novalue-${name}`} /> )} @@ -524,7 +556,10 @@ const SearchableFilterPopover = ({ : index) && styles.highlighted )} - style={{ margin: '4px 0' }} + style={{ + margin: 0, + padding: '4px 0', + }} /> )} /> diff --git a/src/components/datatable/styles/ColumnPicker.module.css b/src/components/datatable/styles/ColumnPicker.module.css index 0e0abf6e68..b1306cff6e 100644 --- a/src/components/datatable/styles/ColumnPicker.module.css +++ b/src/components/datatable/styles/ColumnPicker.module.css @@ -1,3 +1,18 @@ +.alignIcon1 { + display: flex; + margin-top: 1px; +} + +.alignIcon1 svg { + width: 18px; + height: 18px; +} + +.alignIcon2 { + display: flex; + margin-top: 2px; +} + .triggerButton { cursor: pointer; color: var(--colors-grey800); @@ -25,19 +40,12 @@ .columnPickerPopover { padding: var(--spacers-dp8); - min-width: 220px; + min-width: 190px; background-color: var(--colors-white); border-radius: 4px; box-shadow: var(--elevations-popover); } -.columnPickerHint { - margin: 0 0 var(--spacers-dp8); - font-size: 11px; - font-style: italic; - color: var(--colors-grey600); -} - .columnList { display: flex; flex-direction: column; @@ -49,12 +57,38 @@ display: flex; align-items: center; gap: var(--spacers-dp4); - padding: 2px 0; + padding: 2px var(--spacers-dp4); + border-radius: 3px; +} + +.columnRow:hover { + background: var(--colors-grey100); +} + +.columnRowDivider { + border-bottom: 1px solid var(--colors-grey300); } .columnRowCheckbox { flex: 1; min-width: 0; + /* Checkbox.module.css's shared `.checkbox` sets a 16px/8px vertical + margin meant for standalone form fields - reset it here so row + height is driven by .columnRow's own padding instead. */ + margin: 0; +} + +.columnRowCheckbox :global(label) { + min-width: 0; +} + +.columnRowLabel { + flex: 1; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: 12px; } .dragHandle { @@ -68,6 +102,10 @@ border: none; border-radius: 3px; background: transparent; + /* Prevent the browser's own native drag (e.g. dragging the inline + svg icon as an image) from hijacking dnd-kit's mouse sensor. */ + -webkit-user-drag: none; + user-select: none; color: var(--colors-grey600); cursor: grab; touch-action: none; @@ -89,7 +127,7 @@ border: none; border-radius: 3px; background: transparent; - color: var(--colors-grey500); + color: var(--colors-grey700); cursor: pointer; } @@ -99,20 +137,14 @@ } .pinButtonActive { - color: var(--colors-blue700); + color: var(--colors-teal600); } .pinButtonActive:hover { - color: var(--colors-blue800); + color: var(--colors-teal700); } .dragOverlay { - display: flex; - align-items: center; - gap: var(--spacers-dp4); - padding: 2px var(--spacers-dp8); background-color: var(--colors-white); - border-radius: 3px; box-shadow: var(--elevations-popover); - font-size: 12px; } diff --git a/src/components/datatable/styles/DataTable.module.css b/src/components/datatable/styles/DataTable.module.css index b49f201729..4dc3d2d3a8 100644 --- a/src/components/datatable/styles/DataTable.module.css +++ b/src/components/datatable/styles/DataTable.module.css @@ -8,13 +8,18 @@ } /* A pinned column's cells render as <th> (@dhis2/ui's DataTableCell switches - element on `fixed`), so these need to match both td and th. */ + element on `fixed`), so these need to match both td and th. table-data-cell + sets no vertical-align on <td> (browser default: middle), but @dhis2/ui's + own th styles explicitly set `vertical-align: top` - without overriding it + here, a pinned column's cells sit top-aligned while the rest of the row + stays middle-aligned. */ td.dataCell, th.dataCell { padding-top: var(--spacers-dp8); padding-bottom: var(--spacers-dp8); font-size: 11px; overflow-wrap: anywhere; + vertical-align: middle; } td.dataCell:hover, @@ -39,6 +44,11 @@ td.checkboxCell { max-width: 76px; text-align: center; padding: 0; + vertical-align: middle; +} + +.checkboxCell input[type='checkbox'] { + accent-color: var(--colors-teal600); } .checkboxHeaderContent { @@ -98,7 +108,7 @@ th.hovered { .headerContent { display: flex; - align-items: center; + align-items: flex-start; min-width: 0; gap: 2px; } @@ -118,6 +128,14 @@ th.hovered { cursor: pointer; } +.reverseButton svg { + flex-shrink: 0; +} + +.reverseButton:hover:not(:disabled) { + background: var(--colors-grey400); +} + .sortButton { display: inline-flex; align-items: center; From 5d495d79a4f646b92fb4cac1cda4f212c1458a90 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Fri, 17 Jul 2026 14:15:26 +0200 Subject: [PATCH 063/205] chore: datatable controls refactor --- i18n/en.pot | 85 ++++----- src/components/core/IconButton.jsx | 64 ++++--- src/components/datatable/BottomPanel.jsx | 132 ++++---------- ....spec.jsx => ColumnPickerControl.spec.jsx} | 8 +- .../datatable/controls/ActiveLayerControl.jsx | 75 ++++++++ .../controls/ClearFiltersControl.jsx | 26 +++ .../datatable/controls/CloseControl.jsx | 17 ++ .../datatable/controls/CollapseControl.jsx | 21 +++ .../ColumnPickerControl.jsx} | 38 ++-- .../controls/GlobalSearchControl.jsx | 32 ++++ .../controls/HighlightColorControl.jsx | 28 +++ .../ResizeHandleControl.jsx} | 10 +- .../datatable/controls/RowCountControl.jsx | 27 +++ .../datatable/controls/ShowInViewControl.jsx | 22 +++ .../datatable/controls/ToolbarIconButton.jsx | 40 +++++ .../styles/ActiveLayerControl.module.css | 33 ++++ .../styles/ClearFiltersControl.module.css | 40 +++++ .../styles/ColumnPickerControl.module.css} | 72 ++------ .../styles/GlobalSearchControl.module.css | 13 ++ .../styles/HighlightColorControl.module.css | 31 ++++ .../styles/ResizeHandleControl.module.css} | 0 .../styles/RowCountControl.module.css | 6 + .../styles/ToolbarIconButton.module.css | 57 ++++++ .../datatable/styles/BottomPanel.module.css | 167 ------------------ .../datatable/styles/DataTable.module.css | 6 - 25 files changed, 616 insertions(+), 434 deletions(-) rename src/components/datatable/__tests__/{ColumnPicker.spec.jsx => ColumnPickerControl.spec.jsx} (96%) create mode 100644 src/components/datatable/controls/ActiveLayerControl.jsx create mode 100644 src/components/datatable/controls/ClearFiltersControl.jsx create mode 100644 src/components/datatable/controls/CloseControl.jsx create mode 100644 src/components/datatable/controls/CollapseControl.jsx rename src/components/datatable/{ColumnPicker.jsx => controls/ColumnPickerControl.jsx} (94%) create mode 100644 src/components/datatable/controls/GlobalSearchControl.jsx create mode 100644 src/components/datatable/controls/HighlightColorControl.jsx rename src/components/datatable/{ResizeHandle.jsx => controls/ResizeHandleControl.jsx} (90%) create mode 100644 src/components/datatable/controls/RowCountControl.jsx create mode 100644 src/components/datatable/controls/ShowInViewControl.jsx create mode 100644 src/components/datatable/controls/ToolbarIconButton.jsx create mode 100644 src/components/datatable/controls/styles/ActiveLayerControl.module.css create mode 100644 src/components/datatable/controls/styles/ClearFiltersControl.module.css rename src/components/datatable/{styles/ColumnPicker.module.css => controls/styles/ColumnPickerControl.module.css} (64%) create mode 100644 src/components/datatable/controls/styles/GlobalSearchControl.module.css create mode 100644 src/components/datatable/controls/styles/HighlightColorControl.module.css rename src/components/datatable/{styles/ResizeHandle.module.css => controls/styles/ResizeHandleControl.module.css} (100%) create mode 100644 src/components/datatable/controls/styles/RowCountControl.module.css create mode 100644 src/components/datatable/controls/styles/ToolbarIconButton.module.css diff --git a/i18n/en.pot b/i18n/en.pot index f61371b5fc..59cafb6c62 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-16T17:33:16.764Z\n" -"PO-Revision-Date: 2026-07-16T17:33:16.764Z\n" +"POT-Creation-Date: 2026-07-17T11:21:32.485Z\n" +"PO-Revision-Date: 2026-07-17T11:21:32.485Z\n" msgid "2020" msgstr "2020" @@ -155,45 +155,6 @@ msgstr "Operator" msgid "Date" msgstr "Date" -msgid "{{filtered}} of {{total}} rows" -msgstr "{{filtered}} of {{total}} rows" - -msgid "{{total}} rows" -msgstr "{{total}} rows" - -msgid "Restore" -msgstr "Restore" - -msgid "Collapse" -msgstr "Collapse" - -msgid "Highlight color" -msgstr "Highlight color" - -msgid "Clear filters" -msgstr "Clear filters" - -msgid "Search all columns" -msgstr "Search all columns" - -msgid "Show only features in current map view" -msgstr "Show only features in current map view" - -msgid "Close" -msgstr "Close" - -msgid "Drag to reorder" -msgstr "Drag to reorder" - -msgid "Unpin column" -msgstr "Unpin column" - -msgid "Pin column to the left" -msgstr "Pin column to the left" - -msgid "Configure columns" -msgstr "Configure columns" - msgid "Selected" msgstr "Selected" @@ -211,6 +172,9 @@ msgstr[1] "{{count}} selected" msgid "No features match your filters" msgstr "No features match your filters" +msgid "Clear filters" +msgstr "Clear filters" + msgid "No results found" msgstr "No results found" @@ -298,6 +262,45 @@ msgstr "Zoom to selected features" msgid "Zoom to filtered features" msgstr "Zoom to filtered features" +msgid "Close" +msgstr "Close" + +msgid "Restore" +msgstr "Restore" + +msgid "Collapse" +msgstr "Collapse" + +msgid "Drag to reorder" +msgstr "Drag to reorder" + +msgid "Unpin column" +msgstr "Unpin column" + +msgid "Pin column to the left" +msgstr "Pin column to the left" + +msgid "Configure columns" +msgstr "Configure columns" + +msgid "Search across all visible columns" +msgstr "Search across all visible columns" + +msgid "Search all columns" +msgstr "Search all columns" + +msgid "Highlight color" +msgstr "Highlight color" + +msgid "{{filtered}} of {{total}} rows" +msgstr "{{filtered}} of {{total}} rows" + +msgid "{{total}} rows" +msgstr "{{total}} rows" + +msgid "Show only features in current map view" +msgstr "Show only features in current map view" + msgid "Data table is not supported when events are grouped on the server." msgstr "Data table is not supported when events are grouped on the server." diff --git a/src/components/core/IconButton.jsx b/src/components/core/IconButton.jsx index c7b4e49b89..0e8660f535 100644 --- a/src/components/core/IconButton.jsx +++ b/src/components/core/IconButton.jsx @@ -1,36 +1,44 @@ import { Tooltip } from '@dhis2/ui' import cx from 'classnames' import PropTypes from 'prop-types' -import React from 'react' +import React, { forwardRef } from 'react' import styles from './styles/IconButton.module.css' -const IconButton = ({ - tooltip, - onClick, - className, - children, - dataTest, - disabled, - ariaLabel, -}) => { - return ( - <button - onClick={onClick} - className={cx(styles.iconButton, className, { - [styles.disabled]: !!disabled, - })} - data-test={dataTest} - disabled={disabled} - aria-label={ariaLabel} - > - {tooltip ? ( - <Tooltip content={tooltip}>{children}</Tooltip> - ) : ( - children - )} - </button> - ) -} +const IconButton = forwardRef( + ( + { + tooltip, + onClick, + className, + children, + dataTest, + disabled, + ariaLabel, + }, + ref + ) => { + return ( + <button + ref={ref} + onClick={onClick} + className={cx(styles.iconButton, className, { + [styles.disabled]: !!disabled, + })} + data-test={dataTest} + disabled={disabled} + aria-label={ariaLabel} + > + {tooltip ? ( + <Tooltip content={tooltip}>{children}</Tooltip> + ) : ( + children + )} + </button> + ) + } +) + +IconButton.displayName = 'IconButton' IconButton.propTypes = { ariaLabel: PropTypes.string, diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index e7982a1b79..8cb11cd16b 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -1,14 +1,3 @@ -import i18n from '@dhis2/d2-i18n' -import { - IconCross16, - IconFilter16, - IconEmptyFrame16, - IconChevronDown16, - IconChevronUp16, - Input, - Tooltip, -} from '@dhis2/ui' -import cx from 'classnames' import React, { useRef, useCallback, @@ -16,7 +5,6 @@ import React, { useEffect, useLayoutEffect, } from 'react' -import { createPortal } from 'react-dom' import { useSelector, useDispatch } from 'react-redux' import { clearDataFilters } from '../../actions/dataFilters.js' import { @@ -28,12 +16,19 @@ import { } from '../../actions/dataTable.js' import useKeyDown from '../../hooks/useKeyDown.js' import { getCssVar } from '../../util/helpers.js' -import ColorPicker from '../core/ColorPicker.jsx' import { useWindowDimensions } from '../WindowDimensionsProvider.jsx' -import ColumnPicker from './ColumnPicker.jsx' +import ActiveLayerControl from './controls/ActiveLayerControl.jsx' +import ClearFiltersControl from './controls/ClearFiltersControl.jsx' +import CloseControl from './controls/CloseControl.jsx' +import CollapseControl from './controls/CollapseControl.jsx' +import ColumnPickerControl from './controls/ColumnPickerControl.jsx' +import GlobalSearchControl from './controls/GlobalSearchControl.jsx' +import HighlightColorControl from './controls/HighlightColorControl.jsx' +import ResizeHandleControl from './controls/ResizeHandleControl.jsx' +import RowCountControl from './controls/RowCountControl.jsx' +import ShowInViewControl from './controls/ShowInViewControl.jsx' import DataTable from './DataTable.jsx' import ErrorBoundary from './ErrorBoundary.jsx' -import ResizeHandle from './ResizeHandle.jsx' import styles from './styles/BottomPanel.module.css' // Must match `.dataTableControls`'s height in BottomPanel.module.css @@ -57,12 +52,10 @@ const BottomPanel = () => { const dispatch = useDispatch() const { height } = useWindowDimensions() const panelRef = useRef(null) - const nameRef = useRef(null) const isDraggingRef = useRef(false) const [panelWidth, setPanelWidth] = useState(0) const [totalCount, setTotalCount] = useState(null) const [filteredCount, setFilteredCount] = useState(null) - const [nameTooltipPos, setNameTooltipPos] = useState(null) const [isCollapsed, setIsCollapsed] = useState(false) const [globalSearch, setGlobalSearch] = useState('') const [headersByLayer, setHeadersByLayer] = useState(null) @@ -142,25 +135,18 @@ const BottomPanel = () => { } }, [dispatch, activeLayerId, showOnlyFeaturesInView]) - const onNameMouseEnter = useCallback(() => { - const el = nameRef.current - if (!el || el.scrollWidth <= el.offsetWidth) { - return - } - const rect = el.getBoundingClientRect() - const computed = getComputedStyle(el) - const lineHeight = Number.parseFloat(computed.lineHeight) - setNameTooltipPos({ - top: rect.top + (rect.height - lineHeight) / 2, - left: rect.left, - color: computed.color, - fontSize: computed.fontSize, - lineHeight: `${lineHeight}px`, - paddingLeft: computed.paddingLeft, - }) - }, []) + const onToggleShowOnlyFeaturesInView = useCallback(() => { + dispatch(toggleShowOnlyFeaturesInView()) + }, [dispatch]) - const onNameMouseLeave = useCallback(() => setNameTooltipPos(null), []) + const onCloseDataTable = useCallback(() => { + dispatch(closeDataTable()) + }, [dispatch]) + + const onHighlightColorChange = useCallback( + (color) => dispatch(setHighlightColor(color)), + [dispatch] + ) useLayoutEffect(() => { if (isDraggingRef.current) { @@ -192,18 +178,7 @@ const BottomPanel = () => { return () => observer.disconnect() }, []) - useKeyDown('Escape', () => dispatch(closeDataTable()), true) - - let rowCountLabel = null - if (totalCount !== null && filteredCount !== null) { - rowCountLabel = - filteredCount < totalCount - ? i18n.t('{{filtered}} of {{total}} rows', { - filtered: filteredCount, - total: totalCount, - }) - : i18n.t('{{total}} rows', { total: totalCount }) - } + useKeyDown('Escape', onCloseDataTable, true) return ( <div @@ -219,68 +194,21 @@ const BottomPanel = () => { type="button" className={styles.toggleButton} onClick={toggleCollapsed} - > - <Tooltip - content={ - isCollapsed ? i18n.t('Restore') : i18n.t('Collapse') - } - placement="top" - > - {isCollapsed ? ( - <IconChevronUp16 /> - ) : ( - <IconChevronDown16 /> - )} - </Tooltip> - </button> + /> <span className={styles.divider} /> - <span - ref={nameRef} - className={styles.layerName} - onMouseEnter={onNameMouseEnter} - onMouseLeave={onNameMouseLeave} - > - {activeLayer?.name} - </span> - {nameTooltipPos && - createPortal( - <div - className={styles.nameTooltip} - style={{ - top: nameTooltipPos.top, - left: nameTooltipPos.left, - color: nameTooltipPos.color, - fontSize: nameTooltipPos.fontSize, - lineHeight: nameTooltipPos.lineHeight, - paddingLeft: nameTooltipPos.paddingLeft, - }} - > - {activeLayer?.name} - </div>, - document.body - )} + <ActiveLayerControl name={activeLayer?.name} /> <span className={styles.divider} /> - <Tooltip content={i18n.t('Highlight color')} placement="top"> - <span className={styles.alignIcon2}> - <ColorPicker - className={styles.highlightColorPicker} - color={highlightColor} - width={22} - height={22} - centerIcon - onChange={(color) => - dispatch(setHighlightColor(color)) - } - /> - </span> - </Tooltip> - <ColumnPicker + <HighlightColorControl + color={highlightColor} + onChange={onHighlightColorChange} + /> + <ColumnPickerControl layerId={activeLayerId} allHeaders={allHeaders} columnConfig={activeLayer?.dataTableColumnConfig} /> <span className={styles.divider} /> - <ResizeHandle + <ResizeHandleControl maxHeight={maxHeight} minHeight={MIN_HEIGHT} onResizeStart={onResizeStart} diff --git a/src/components/datatable/__tests__/ColumnPicker.spec.jsx b/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx similarity index 96% rename from src/components/datatable/__tests__/ColumnPicker.spec.jsx rename to src/components/datatable/__tests__/ColumnPickerControl.spec.jsx index f1dd502a10..f0292e500c 100644 --- a/src/components/datatable/__tests__/ColumnPicker.spec.jsx +++ b/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx @@ -3,7 +3,7 @@ import React from 'react' import { Provider } from 'react-redux' import configureMockStore from 'redux-mock-store' import { DATA_TABLE_COLUMN_CONFIG_SET } from '../../../constants/actionTypes.js' -import ColumnPicker from '../ColumnPicker.jsx' +import ColumnPickerControl from '../controls/ColumnPickerControl.jsx' const mockStore = configureMockStore() @@ -17,7 +17,11 @@ const renderColumnPicker = (props) => { const store = mockStore({}) const result = render( <Provider store={store}> - <ColumnPicker layerId="layer1" allHeaders={headers} {...props} /> + <ColumnPickerControl + layerId="layer1" + allHeaders={headers} + {...props} + /> </Provider> ) return { ...result, store } diff --git a/src/components/datatable/controls/ActiveLayerControl.jsx b/src/components/datatable/controls/ActiveLayerControl.jsx new file mode 100644 index 0000000000..0d076955c5 --- /dev/null +++ b/src/components/datatable/controls/ActiveLayerControl.jsx @@ -0,0 +1,75 @@ +import PropTypes from 'prop-types' +import React, { useCallback, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import styles from './styles/ActiveLayerControl.module.css' + +// Must match .nameTooltip's top/bottom padding in ActiveLayerControl.module.css - +// offsets the tooltip's top so that extra padding grows the background +// without shifting the text's own vertical position. +const TOOLTIP_VERTICAL_PADDING = 3 + +const ActiveLayerControl = ({ name }) => { + const nameRef = useRef(null) + const [nameTooltipPos, setNameTooltipPos] = useState(null) + + const onMouseEnter = useCallback(() => { + const el = nameRef.current + if (!el || el.scrollWidth <= el.offsetWidth) { + return + } + const rect = el.getBoundingClientRect() + const computed = getComputedStyle(el) + const lineHeight = Number.parseFloat(computed.lineHeight) + setNameTooltipPos({ + top: + rect.top + + (rect.height - lineHeight) / 2 - + TOOLTIP_VERTICAL_PADDING, + left: rect.left, + color: computed.color, + fontSize: computed.fontSize, + fontWeight: computed.fontWeight, + lineHeight: `${lineHeight}px`, + paddingLeft: computed.paddingLeft, + }) + }, []) + + const onMouseLeave = useCallback(() => setNameTooltipPos(null), []) + + return ( + <> + <span + ref={nameRef} + className={styles.layerName} + onMouseEnter={onMouseEnter} + onMouseLeave={onMouseLeave} + > + {name} + </span> + {nameTooltipPos && + createPortal( + <div + className={styles.nameTooltip} + style={{ + top: nameTooltipPos.top, + left: nameTooltipPos.left, + color: nameTooltipPos.color, + fontSize: nameTooltipPos.fontSize, + fontWeight: nameTooltipPos.fontWeight, + lineHeight: nameTooltipPos.lineHeight, + paddingLeft: nameTooltipPos.paddingLeft, + }} + > + {name} + </div>, + document.body + )} + </> + ) +} + +ActiveLayerControl.propTypes = { + name: PropTypes.string, +} + +export default ActiveLayerControl diff --git a/src/components/datatable/controls/ClearFiltersControl.jsx b/src/components/datatable/controls/ClearFiltersControl.jsx new file mode 100644 index 0000000000..902e0fc7ed --- /dev/null +++ b/src/components/datatable/controls/ClearFiltersControl.jsx @@ -0,0 +1,26 @@ +import i18n from '@dhis2/d2-i18n' +import { IconFilter16 } from '@dhis2/ui' +import PropTypes from 'prop-types' +import React from 'react' +import styles from './styles/ClearFiltersControl.module.css' +import ToolbarIconButton from './ToolbarIconButton.jsx' + +const ClearFiltersControl = ({ disabled, onClick }) => ( + <ToolbarIconButton + tooltip={i18n.t('Clear filters')} + onClick={onClick} + disabled={disabled} + > + <span className={styles.filteredIcon}> + <IconFilter16 /> + <span className={styles.clearBadge} /> + </span> + </ToolbarIconButton> +) + +ClearFiltersControl.propTypes = { + onClick: PropTypes.func.isRequired, + disabled: PropTypes.bool, +} + +export default ClearFiltersControl diff --git a/src/components/datatable/controls/CloseControl.jsx b/src/components/datatable/controls/CloseControl.jsx new file mode 100644 index 0000000000..1240bcc442 --- /dev/null +++ b/src/components/datatable/controls/CloseControl.jsx @@ -0,0 +1,17 @@ +import i18n from '@dhis2/d2-i18n' +import { IconCross16 } from '@dhis2/ui' +import PropTypes from 'prop-types' +import React from 'react' +import ToolbarIconButton from './ToolbarIconButton.jsx' + +const CloseControl = ({ onClick }) => ( + <ToolbarIconButton tooltip={i18n.t('Close')} onClick={onClick}> + <IconCross16 /> + </ToolbarIconButton> +) + +CloseControl.propTypes = { + onClick: PropTypes.func.isRequired, +} + +export default CloseControl diff --git a/src/components/datatable/controls/CollapseControl.jsx b/src/components/datatable/controls/CollapseControl.jsx new file mode 100644 index 0000000000..1f7f896c01 --- /dev/null +++ b/src/components/datatable/controls/CollapseControl.jsx @@ -0,0 +1,21 @@ +import i18n from '@dhis2/d2-i18n' +import { IconChevronDown16, IconChevronUp16 } from '@dhis2/ui' +import PropTypes from 'prop-types' +import React from 'react' +import ToolbarIconButton from './ToolbarIconButton.jsx' + +const CollapseControl = ({ isCollapsed, onClick }) => ( + <ToolbarIconButton + tooltip={isCollapsed ? i18n.t('Restore') : i18n.t('Collapse')} + onClick={onClick} + > + {isCollapsed ? <IconChevronUp16 /> : <IconChevronDown16 />} + </ToolbarIconButton> +) + +CollapseControl.propTypes = { + isCollapsed: PropTypes.bool.isRequired, + onClick: PropTypes.func.isRequired, +} + +export default CollapseControl diff --git a/src/components/datatable/ColumnPicker.jsx b/src/components/datatable/controls/ColumnPickerControl.jsx similarity index 94% rename from src/components/datatable/ColumnPicker.jsx rename to src/components/datatable/controls/ColumnPickerControl.jsx index 4eea47177b..3a44f8619a 100644 --- a/src/components/datatable/ColumnPicker.jsx +++ b/src/components/datatable/controls/ColumnPickerControl.jsx @@ -30,14 +30,15 @@ import PropTypes from 'prop-types' import React, { useRef, useState } from 'react' import { createPortal } from 'react-dom' import { useDispatch } from 'react-redux' -import { setDataTableColumnConfig } from '../../actions/dataTable.js' -import { getVisibleHeaders } from '../../util/tableColumns.js' -import Checkbox from '../core/Checkbox.jsx' +import { setDataTableColumnConfig } from '../../../actions/dataTable.js' +import { getVisibleHeaders } from '../../../util/tableColumns.js' +import Checkbox from '../../core/Checkbox.jsx' import { FilterDropdownPopover, getDropdownPlacement, -} from './FilterDropdownPopover.jsx' -import styles from './styles/ColumnPicker.module.css' +} from '../FilterDropdownPopover.jsx' +import styles from './styles/ColumnPickerControl.module.css' +import ToolbarIconButton from './ToolbarIconButton.jsx' // Higher than this codebase's usual z-index: 2000 "float above everything" // convention (e.g. DataTable.module.css's .topTooltipContent), since the @@ -81,7 +82,7 @@ const ColumnRowFields = ({ <> <button type="button" - className={styles.dragHandle} + className={cx(styles.rowIconButton, styles.dragHandle)} aria-label={dragLabel} data-test={`data-table-column-picker-drag-${header.dataKey}${dataTestSuffix}`} draggable={false} @@ -106,7 +107,7 @@ const ColumnRowFields = ({ /> <button type="button" - className={cx(styles.pinButton, { + className={cx(styles.rowIconButton, styles.pinButton, { [styles.pinButtonActive]: isPinned, })} aria-label={pinLabel} @@ -198,7 +199,7 @@ ColumnRow.propTypes = { onToggleVisible: PropTypes.func.isRequired, } -const ColumnPicker = ({ layerId, allHeaders, columnConfig }) => { +const ColumnPickerControl = ({ layerId, allHeaders, columnConfig }) => { const dispatch = useDispatch() const anchorRef = useRef(null) const [isOpen, setIsOpen] = useState(false) @@ -303,21 +304,16 @@ const ColumnPicker = ({ layerId, allHeaders, columnConfig }) => { return ( <> - <button - type="button" + <ToolbarIconButton ref={anchorRef} - className={styles.triggerButton} + tooltip={i18n.t('Configure columns')} + ariaLabel={i18n.t('Configure columns')} + dataTest="data-table-column-picker-button" disabled={!headers.length} - aria-label={i18n.t('Configure columns')} - data-test="data-table-column-picker-button" onClick={() => setIsOpen((o) => !o)} > - <Tooltip content={i18n.t('Configure columns')} placement="top"> - <span className={styles.alignIcon1}> - <IconLayoutColumns16 /> - </span> - </Tooltip> - </button> + <IconLayoutColumns16 /> + </ToolbarIconButton> {isOpen && ( <FilterDropdownPopover reference={anchorRef} @@ -407,7 +403,7 @@ const ColumnPicker = ({ layerId, allHeaders, columnConfig }) => { ) } -ColumnPicker.propTypes = { +ColumnPickerControl.propTypes = { layerId: PropTypes.string.isRequired, allHeaders: PropTypes.arrayOf( PropTypes.shape({ @@ -422,4 +418,4 @@ ColumnPicker.propTypes = { }), } -export default ColumnPicker +export default ColumnPickerControl diff --git a/src/components/datatable/controls/GlobalSearchControl.jsx b/src/components/datatable/controls/GlobalSearchControl.jsx new file mode 100644 index 0000000000..1547538f11 --- /dev/null +++ b/src/components/datatable/controls/GlobalSearchControl.jsx @@ -0,0 +1,32 @@ +import i18n from '@dhis2/d2-i18n' +import { Input, Tooltip } from '@dhis2/ui' +import PropTypes from 'prop-types' +import React from 'react' +import styles from './styles/GlobalSearchControl.module.css' + +const GlobalSearchControl = ({ value, onChange }) => ( + <Tooltip + content={i18n.t('Search across all visible columns')} + placement="top" + > + <div + className={styles.globalSearch} + onDoubleClick={(e) => e.stopPropagation()} + > + <Input + dense + dataTest="data-table-global-search" + placeholder={i18n.t('Search all columns')} + value={value} + onChange={({ value }) => onChange(value)} + /> + </div> + </Tooltip> +) + +GlobalSearchControl.propTypes = { + onChange: PropTypes.func.isRequired, + value: PropTypes.string, +} + +export default GlobalSearchControl diff --git a/src/components/datatable/controls/HighlightColorControl.jsx b/src/components/datatable/controls/HighlightColorControl.jsx new file mode 100644 index 0000000000..9534c95ba8 --- /dev/null +++ b/src/components/datatable/controls/HighlightColorControl.jsx @@ -0,0 +1,28 @@ +import i18n from '@dhis2/d2-i18n' +import { Tooltip } from '@dhis2/ui' +import PropTypes from 'prop-types' +import React from 'react' +import ColorPicker from '../../core/ColorPicker.jsx' +import styles from './styles/HighlightColorControl.module.css' + +const HighlightColorControl = ({ color, onChange }) => ( + <span className={styles.wrapper}> + <Tooltip content={i18n.t('Highlight color')} placement="top"> + <ColorPicker + className={styles.colorPicker} + color={color} + width={16} + height={16} + centerIcon + onChange={onChange} + /> + </Tooltip> + </span> +) + +HighlightColorControl.propTypes = { + onChange: PropTypes.func.isRequired, + color: PropTypes.string, +} + +export default HighlightColorControl diff --git a/src/components/datatable/ResizeHandle.jsx b/src/components/datatable/controls/ResizeHandleControl.jsx similarity index 90% rename from src/components/datatable/ResizeHandle.jsx rename to src/components/datatable/controls/ResizeHandleControl.jsx index 50711045f2..8ec306ba78 100644 --- a/src/components/datatable/ResizeHandle.jsx +++ b/src/components/datatable/controls/ResizeHandleControl.jsx @@ -1,9 +1,9 @@ import PropTypes from 'prop-types' import React, { useEffect, useRef } from 'react' -import { IconDrag } from '../core/icons.jsx' -import styles from './styles/ResizeHandle.module.css' +import { IconDrag } from '../../core/icons.jsx' +import styles from './styles/ResizeHandleControl.module.css' -const ResizeHandle = ({ +const ResizeHandleControl = ({ onResize, onResizeStart, onResizeEnd, @@ -73,7 +73,7 @@ const ResizeHandle = ({ ) } -ResizeHandle.propTypes = { +ResizeHandleControl.propTypes = { maxHeight: PropTypes.number.isRequired, minHeight: PropTypes.number, onResize: PropTypes.func, @@ -81,4 +81,4 @@ ResizeHandle.propTypes = { onResizeStart: PropTypes.func, } -export default ResizeHandle +export default ResizeHandleControl diff --git a/src/components/datatable/controls/RowCountControl.jsx b/src/components/datatable/controls/RowCountControl.jsx new file mode 100644 index 0000000000..274ab4fd1d --- /dev/null +++ b/src/components/datatable/controls/RowCountControl.jsx @@ -0,0 +1,27 @@ +import i18n from '@dhis2/d2-i18n' +import PropTypes from 'prop-types' +import React from 'react' +import styles from './styles/RowCountControl.module.css' + +const RowCountControl = ({ totalCount, filteredCount }) => { + if (totalCount === null || filteredCount === null) { + return null + } + + const label = + filteredCount < totalCount + ? i18n.t('{{filtered}} of {{total}} rows', { + filtered: filteredCount, + total: totalCount, + }) + : i18n.t('{{total}} rows', { total: totalCount }) + + return <span className={styles.rowCount}>{label}</span> +} + +RowCountControl.propTypes = { + filteredCount: PropTypes.number, + totalCount: PropTypes.number, +} + +export default RowCountControl diff --git a/src/components/datatable/controls/ShowInViewControl.jsx b/src/components/datatable/controls/ShowInViewControl.jsx new file mode 100644 index 0000000000..a1297b0ae7 --- /dev/null +++ b/src/components/datatable/controls/ShowInViewControl.jsx @@ -0,0 +1,22 @@ +import i18n from '@dhis2/d2-i18n' +import { IconEmptyFrame16 } from '@dhis2/ui' +import PropTypes from 'prop-types' +import React from 'react' +import ToolbarIconButton from './ToolbarIconButton.jsx' + +const ShowInViewControl = ({ active, onClick }) => ( + <ToolbarIconButton + tooltip={i18n.t('Show only features in current map view')} + onClick={onClick} + active={active} + > + <IconEmptyFrame16 /> + </ToolbarIconButton> +) + +ShowInViewControl.propTypes = { + onClick: PropTypes.func.isRequired, + active: PropTypes.bool, +} + +export default ShowInViewControl diff --git a/src/components/datatable/controls/ToolbarIconButton.jsx b/src/components/datatable/controls/ToolbarIconButton.jsx new file mode 100644 index 0000000000..0c58ee11d6 --- /dev/null +++ b/src/components/datatable/controls/ToolbarIconButton.jsx @@ -0,0 +1,40 @@ +import cx from 'classnames' +import PropTypes from 'prop-types' +import React, { forwardRef } from 'react' +import IconButton from '../../core/IconButton.jsx' +import styles from './styles/ToolbarIconButton.module.css' + +const ToolbarIconButton = forwardRef( + ( + { tooltip, onClick, children, dataTest, disabled, ariaLabel, active }, + ref + ) => ( + <IconButton + ref={ref} + tooltip={tooltip} + onClick={onClick} + className={cx(styles.toolbarIconButton, { + [styles.active]: active, + })} + dataTest={dataTest} + disabled={disabled} + ariaLabel={ariaLabel} + > + {children} + </IconButton> + ) +) + +ToolbarIconButton.displayName = 'ToolbarIconButton' + +ToolbarIconButton.propTypes = { + active: PropTypes.bool, + ariaLabel: PropTypes.string, + children: PropTypes.node, + dataTest: PropTypes.string, + disabled: PropTypes.bool, + tooltip: PropTypes.string, + onClick: PropTypes.func, +} + +export default ToolbarIconButton diff --git a/src/components/datatable/controls/styles/ActiveLayerControl.module.css b/src/components/datatable/controls/styles/ActiveLayerControl.module.css new file mode 100644 index 0000000000..d292736485 --- /dev/null +++ b/src/components/datatable/controls/styles/ActiveLayerControl.module.css @@ -0,0 +1,33 @@ +.layerName { + font-weight: 500; + font-size: 12px; + color: var(--colors-grey800); + flex: 0 1 auto; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + min-width: 0; +} + +@keyframes tooltipExpandRight { + from { + clip-path: inset(0 100% 0 0); + } + + to { + clip-path: inset(0 0% 0 0); + } +} + +.nameTooltip { + animation: tooltipExpandRight 160ms ease-out; + background: var(--colors-grey100); + border-radius: 3px; + -webkit-mask-image: linear-gradient(to left, transparent, black 2em); + mask-image: linear-gradient(to left, transparent, black 2em); + padding: 3px 2em 3px 0; + pointer-events: none; + position: fixed; + white-space: nowrap; + z-index: 2000; +} diff --git a/src/components/datatable/controls/styles/ClearFiltersControl.module.css b/src/components/datatable/controls/styles/ClearFiltersControl.module.css new file mode 100644 index 0000000000..6742e21179 --- /dev/null +++ b/src/components/datatable/controls/styles/ClearFiltersControl.module.css @@ -0,0 +1,40 @@ +.filteredIcon { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; +} + +.clearBadge { + position: absolute; + bottom: 0; + right: 0; + width: 8px; + height: 8px; + background: var(--colors-grey100); +} + +:global(button):hover .clearBadge { + background: var(--colors-grey300); +} + +.clearBadge::before, +.clearBadge::after { + content: ''; + position: absolute; + width: 5px; + height: 1px; + background: currentColor; + top: 50%; + left: 50%; +} + +.clearBadge::before { + transform: translate(-50%, -50%) rotate(45deg); +} + +.clearBadge::after { + transform: translate(-50%, -50%) rotate(-45deg); +} diff --git a/src/components/datatable/styles/ColumnPicker.module.css b/src/components/datatable/controls/styles/ColumnPickerControl.module.css similarity index 64% rename from src/components/datatable/styles/ColumnPicker.module.css rename to src/components/datatable/controls/styles/ColumnPickerControl.module.css index b1306cff6e..06075f9e19 100644 --- a/src/components/datatable/styles/ColumnPicker.module.css +++ b/src/components/datatable/controls/styles/ColumnPickerControl.module.css @@ -1,43 +1,3 @@ -.alignIcon1 { - display: flex; - margin-top: 1px; -} - -.alignIcon1 svg { - width: 18px; - height: 18px; -} - -.alignIcon2 { - display: flex; - margin-top: 2px; -} - -.triggerButton { - cursor: pointer; - color: var(--colors-grey800); - background-color: transparent; - width: 24px; - height: 24px; - border: none; - border-radius: 3px; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - padding: 0; -} - -.triggerButton:hover:not(:disabled) { - color: var(--colors-grey900); - background-color: var(--colors-grey300); -} - -.triggerButton:disabled { - color: var(--colors-grey400); - cursor: not-allowed; -} - .columnPickerPopover { padding: var(--spacers-dp8); min-width: 190px; @@ -57,7 +17,7 @@ display: flex; align-items: center; gap: var(--spacers-dp4); - padding: 2px var(--spacers-dp4); + padding: var(--spacers-dp2) var(--spacers-dp4); border-radius: 3px; } @@ -91,7 +51,7 @@ font-size: 12px; } -.dragHandle { +.rowIconButton { display: flex; align-items: center; justify-content: center; @@ -102,6 +62,14 @@ border: none; border-radius: 3px; background: transparent; +} + +.rowIconButton:hover { + background: var(--colors-grey100); + color: var(--colors-grey800); +} + +.dragHandle { /* Prevent the browser's own native drag (e.g. dragging the inline svg icon as an image) from hijacking dnd-kit's mouse sensor. */ -webkit-user-drag: none; @@ -111,31 +79,11 @@ touch-action: none; } -.dragHandle:hover { - background: var(--colors-grey100); - color: var(--colors-grey800); -} - .pinButton { - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - width: 20px; - height: 20px; - padding: 0; - border: none; - border-radius: 3px; - background: transparent; color: var(--colors-grey700); cursor: pointer; } -.pinButton:hover { - background: var(--colors-grey100); - color: var(--colors-grey800); -} - .pinButtonActive { color: var(--colors-teal600); } diff --git a/src/components/datatable/controls/styles/GlobalSearchControl.module.css b/src/components/datatable/controls/styles/GlobalSearchControl.module.css new file mode 100644 index 0000000000..620d4db1fa --- /dev/null +++ b/src/components/datatable/controls/styles/GlobalSearchControl.module.css @@ -0,0 +1,13 @@ +.globalSearch { + flex: 0 1 160px; + min-width: 90px; +} + +.globalSearch > :global(div) { + width: 100%; +} + +.globalSearch :global(input.dense) { + padding: 4px 6px; + font-size: 11px; +} diff --git a/src/components/datatable/controls/styles/HighlightColorControl.module.css b/src/components/datatable/controls/styles/HighlightColorControl.module.css new file mode 100644 index 0000000000..e6c938caea --- /dev/null +++ b/src/components/datatable/controls/styles/HighlightColorControl.module.css @@ -0,0 +1,31 @@ +.wrapper { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 3px; +} + +.wrapper:hover { + background-color: var(--colors-grey300); +} + +/* !important beats @dhis2/ui's own ColorPicker field margin. */ +.colorPicker { + margin-bottom: 0 !important; + flex-shrink: 0; + display: flex; + align-items: center; +} + +/* !important beats @dhis2/ui's own ColorPicker label size, matching the + other toolbar controls' 16px icon size exactly (same top edge, so + Tooltip's placement="top" lines up with theirs too). It still sits + inside the same 24px hover box as the other controls (.wrapper above). */ +.colorPicker label { + box-sizing: border-box; + overflow: hidden; + min-width: 16px !important; + min-height: 16px !important; +} diff --git a/src/components/datatable/styles/ResizeHandle.module.css b/src/components/datatable/controls/styles/ResizeHandleControl.module.css similarity index 100% rename from src/components/datatable/styles/ResizeHandle.module.css rename to src/components/datatable/controls/styles/ResizeHandleControl.module.css diff --git a/src/components/datatable/controls/styles/RowCountControl.module.css b/src/components/datatable/controls/styles/RowCountControl.module.css new file mode 100644 index 0000000000..55e8f60b24 --- /dev/null +++ b/src/components/datatable/controls/styles/RowCountControl.module.css @@ -0,0 +1,6 @@ +.rowCount { + font-size: 11px; + color: var(--colors-grey600); + white-space: nowrap; + flex-shrink: 0; +} diff --git a/src/components/datatable/controls/styles/ToolbarIconButton.module.css b/src/components/datatable/controls/styles/ToolbarIconButton.module.css new file mode 100644 index 0000000000..9b1a43bbf8 --- /dev/null +++ b/src/components/datatable/controls/styles/ToolbarIconButton.module.css @@ -0,0 +1,57 @@ +/* !important beats core/IconButton's own 28px/grey700/grey200-hover defaults, + to keep BottomPanel's established 24px/grey800/grey300-hover look. */ +.toolbarIconButton { + width: 24px !important; + height: 24px !important; + padding: 0 !important; + border-radius: 3px !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + flex-shrink: 0; +} + +.toolbarIconButton svg { + color: var(--colors-grey800) !important; +} + +/* @dhis2/ui's Tooltip wraps its child in its own <span> sized by + line-height (text baseline layout), which sits a 16px icon a couple of + pixels above true center. Re-flexing that wrapper overrides the + baseline layout so the icon centers in the 24px button regardless of + whether the icon is passed directly or through a control's own + wrapper span (e.g. ClearFiltersControl's badge wrapper). */ +.toolbarIconButton > span { + display: flex !important; + align-items: center; + justify-content: center; + line-height: 0; +} + +.toolbarIconButton:not(:disabled):hover { + background-color: var(--colors-grey300) !important; +} + +.toolbarIconButton:not(:disabled):hover svg { + color: var(--colors-grey900) !important; +} + +.toolbarIconButton:disabled { + cursor: not-allowed; +} + +.toolbarIconButton:disabled svg { + color: var(--colors-grey400) !important; +} + +.active { + background-color: var(--colors-blue100) !important; +} + +.active svg { + color: var(--colors-blue700) !important; +} + +.active:hover { + background-color: var(--colors-blue200) !important; +} diff --git a/src/components/datatable/styles/BottomPanel.module.css b/src/components/datatable/styles/BottomPanel.module.css index 3ef7841083..6468801870 100644 --- a/src/components/datatable/styles/BottomPanel.module.css +++ b/src/components/datatable/styles/BottomPanel.module.css @@ -29,176 +29,9 @@ border-bottom: 1px solid var(--colors-grey300); } -.layerName { - font-weight: 500; - font-size: 12px; - color: var(--colors-grey800); - flex: 0 1 auto; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - min-width: 0; -} - -.rowCount { - font-size: 11px; - color: var(--colors-grey600); - white-space: nowrap; - flex-shrink: 0; -} - .divider { width: 1px; height: 20px; background-color: var(--colors-grey300); flex-shrink: 0; } - -@keyframes tooltipExpandRight { - from { - clip-path: inset(0 100% 0 0); - } - - to { - clip-path: inset(0 0% 0 0); - } -} - -.nameTooltip { - animation: tooltipExpandRight 160ms ease-out; - background: var(--colors-white); - border-radius: 3px; - -webkit-mask-image: linear-gradient(to left, transparent, black 2em); - mask-image: linear-gradient(to left, transparent, black 2em); - padding: 0 2em 0 0; - pointer-events: none; - position: fixed; - white-space: nowrap; - z-index: 1000; -} - -.filteredIcon { - position: relative; - display: flex; - align-items: center; - justify-content: center; - width: 16px; - height: 16px; -} - -.clearBadge { - position: absolute; - bottom: 0; - right: 0; - width: 8px; - height: 8px; - background: var(--colors-grey100); -} - -.clearFiltersButton:hover .clearBadge { - background: var(--colors-grey300); -} - -.clearBadge::before, -.clearBadge::after { - content: ''; - position: absolute; - width: 5px; - height: 1px; - background: currentColor; - top: 50%; - left: 50%; -} - -.clearBadge::before { - transform: translate(-50%, -50%) rotate(45deg); -} - -.clearBadge::after { - transform: translate(-50%, -50%) rotate(-45deg); -} - -.clearFiltersButton, -.closeIcon, -.toggleButton { - cursor: pointer; - color: var(--colors-grey800); - background-color: transparent; - width: 24px; - height: 24px; - border: none; - border-radius: 3px; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - padding: 0; -} - -.alignIcon1 { - display: flex; - margin-top: 1px; -} - -.alignIcon2 { - display: flex; - margin-top: 2px; -} - -.clearFiltersButton:hover, -.closeIcon:hover, -.toggleButton:hover { - color: var(--colors-grey900); - background-color: var(--colors-grey300); -} - -.clearFiltersButton:disabled { - color: var(--colors-grey400); - cursor: not-allowed; -} - -.clearFiltersButton:disabled:hover { - color: var(--colors-grey400); - background-color: transparent; -} - -.toggleButton.active { - color: var(--colors-blue700); - background-color: var(--colors-blue100); -} - -.toggleButton.active:hover { - background-color: var(--colors-blue200); -} - -/* !important beats @dhis2/ui's own ColorPicker field margin. */ -.highlightColorPicker { - margin-bottom: 0 !important; - flex-shrink: 0; - display: flex; - align-items: center; - position: relative; - top: -1px; -} - -/* !important beats @dhis2/ui's own ColorPicker label size. */ -.highlightColorPicker label { - box-sizing: border-box; - overflow: hidden; - min-width: 18px !important; - min-height: 18px !important; -} - -.globalSearch { - flex: 0 1 160px; - min-width: 90px; -} - -.globalSearch > :global(div) { - width: 100%; -} - -.globalSearch :global(input.dense) { - padding: 4px 6px; - font-size: 11px; -} diff --git a/src/components/datatable/styles/DataTable.module.css b/src/components/datatable/styles/DataTable.module.css index 4dc3d2d3a8..a24abe4350 100644 --- a/src/components/datatable/styles/DataTable.module.css +++ b/src/components/datatable/styles/DataTable.module.css @@ -7,12 +7,6 @@ user-select: none; } -/* A pinned column's cells render as <th> (@dhis2/ui's DataTableCell switches - element on `fixed`), so these need to match both td and th. table-data-cell - sets no vertical-align on <td> (browser default: middle), but @dhis2/ui's - own th styles explicitly set `vertical-align: top` - without overriding it - here, a pinned column's cells sit top-aligned while the rest of the row - stays middle-aligned. */ td.dataCell, th.dataCell { padding-top: var(--spacers-dp8); From 80ae27c2f2a13ae88155d13d250041d02a47ed3d Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Fri, 17 Jul 2026 16:53:42 +0200 Subject: [PATCH 064/205] fix: column picker cleanup --- i18n/en.pot | 10 +- .../__tests__/ColumnPickerControl.spec.jsx | 209 ++++++++++++++++++ .../controls/ColumnPickerControl.jsx | 142 ++++++++++-- .../styles/ColumnPickerControl.module.css | 55 +++++ 4 files changed, 398 insertions(+), 18 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 59cafb6c62..c86c680085 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-17T11:21:32.485Z\n" -"PO-Revision-Date: 2026-07-17T11:21:32.485Z\n" +"POT-Creation-Date: 2026-07-17T13:55:55.639Z\n" +"PO-Revision-Date: 2026-07-17T13:55:55.639Z\n" msgid "2020" msgstr "2020" @@ -283,6 +283,12 @@ msgstr "Pin column to the left" msgid "Configure columns" msgstr "Configure columns" +msgid "Select all columns" +msgstr "Select all columns" + +msgid "Reset to defaults" +msgstr "Reset to defaults" + msgid "Search across all visible columns" msgstr "Search across all visible columns" diff --git a/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx b/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx index f0292e500c..7fa2ce6fde 100644 --- a/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx +++ b/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx @@ -150,6 +150,215 @@ describe('ColumnPicker pinning', () => { const labels = screen .getAllByRole('checkbox') .map((el) => el.closest('label')?.textContent) + // Excludes the bulk "select all" checkbox, which isn't + // wrapped in a <label> and isn't part of the column order. + .filter(Boolean) expect(labels).toEqual(['Legend', 'Name', 'Value']) }) }) + +describe('ColumnPicker bulk actions', () => { + test('the select-all checkbox is checked when every column is already visible', () => { + renderColumnPicker() + openPicker() + expect( + screen.getByTestId('data-table-column-picker-select-all') + ).toBeChecked() + }) + + test('the select-all checkbox is unchecked when some columns are hidden', () => { + renderColumnPicker({ columnConfig: { visibleKeys: ['name'] } }) + openPicker() + expect( + screen.getByTestId('data-table-column-picker-select-all') + ).not.toBeChecked() + }) + + test('checking it when some columns are hidden shows every column, leaving pinning/order untouched', () => { + const { store } = renderColumnPicker({ + columnConfig: { visibleKeys: ['name'], pinnedKeys: ['legend'] }, + }) + openPicker() + fireEvent.click( + screen.getByTestId('data-table-column-picker-select-all') + ) + expect(store.getActions()).toContainEqual({ + type: DATA_TABLE_COLUMN_CONFIG_SET, + layerId: 'layer1', + config: { + visibleKeys: ['name', 'rawValue', 'legend'], + pinnedKeys: ['legend'], + orderedKeys: ['name', 'rawValue', 'legend'], + }, + }) + }) + + test('unchecking it when every column is visible hides them all, leaving pinnedKeys untouched', () => { + const { store } = renderColumnPicker({ + columnConfig: { pinnedKeys: ['legend'] }, + }) + openPicker() + fireEvent.click( + screen.getByTestId('data-table-column-picker-select-all') + ) + expect(store.getActions()).toContainEqual({ + type: DATA_TABLE_COLUMN_CONFIG_SET, + layerId: 'layer1', + config: { + visibleKeys: [], + pinnedKeys: ['legend'], + orderedKeys: ['name', 'rawValue', 'legend'], + }, + }) + }) + + test("reverse selection swaps every column's visibility, leaving pinning/order untouched", () => { + const { store } = renderColumnPicker({ + columnConfig: { visibleKeys: ['name'], pinnedKeys: ['legend'] }, + }) + openPicker() + fireEvent.click(screen.getByTestId('data-table-column-picker-reverse')) + expect(store.getActions()).toContainEqual({ + type: DATA_TABLE_COLUMN_CONFIG_SET, + layerId: 'layer1', + config: { + visibleKeys: ['rawValue', 'legend'], + pinnedKeys: ['legend'], + orderedKeys: ['name', 'rawValue', 'legend'], + }, + }) + }) + + test('reset to defaults is disabled when there is no columnConfig yet', () => { + renderColumnPicker() + openPicker() + expect( + screen.getByTestId('data-table-column-picker-reset') + ).toBeDisabled() + }) + + test('reset to defaults dispatches an undefined config', () => { + const { store } = renderColumnPicker({ + columnConfig: { visibleKeys: ['name'] }, + }) + openPicker() + fireEvent.click(screen.getByTestId('data-table-column-picker-reset')) + expect(store.getActions()).toContainEqual({ + type: DATA_TABLE_COLUMN_CONFIG_SET, + layerId: 'layer1', + config: undefined, + }) + }) +}) + +describe('ColumnPicker search box visibility', () => { + // jsdom never lays elements out, so scrollHeight/clientHeight are both + // 0 by default - which conveniently already matches "list is short + // enough, no scrollbar" for the negative case below with no mocking. + + test('is hidden when the column list is short enough to fit without scrolling', () => { + renderColumnPicker() + openPicker() + expect( + screen.queryByTestId('data-table-column-picker-search') + ).not.toBeInTheDocument() + }) + + test('is shown when the column list overflows its max-height', () => { + const scrollHeightSpy = jest + .spyOn(Element.prototype, 'scrollHeight', 'get') + .mockReturnValue(500) + const clientHeightSpy = jest + .spyOn(Element.prototype, 'clientHeight', 'get') + .mockReturnValue(260) + renderColumnPicker() + openPicker() + expect( + screen.getByTestId('data-table-column-picker-search') + ).toBeInTheDocument() + scrollHeightSpy.mockRestore() + clientHeightSpy.mockRestore() + }) +}) + +describe('ColumnPicker search', () => { + let scrollHeightSpy + let clientHeightSpy + + beforeEach(() => { + // These tests exercise search behavior assuming the box is + // showing - its conditional visibility is covered separately + // above, so force it on here regardless of list length. + scrollHeightSpy = jest + .spyOn(Element.prototype, 'scrollHeight', 'get') + .mockReturnValue(500) + clientHeightSpy = jest + .spyOn(Element.prototype, 'clientHeight', 'get') + .mockReturnValue(260) + }) + + afterEach(() => { + scrollHeightSpy.mockRestore() + clientHeightSpy.mockRestore() + }) + + const search = (value) => + fireEvent.change( + screen.getByTestId('data-table-column-picker-search'), + { target: { value } } + ) + + test('filters which columns are shown by name', () => { + renderColumnPicker() + openPicker() + search('val') + expect(screen.getByLabelText('Value')).toBeInTheDocument() + expect(screen.queryByLabelText('Name')).not.toBeInTheDocument() + expect(screen.queryByLabelText('Legend')).not.toBeInTheDocument() + }) + + test('matches case-insensitively', () => { + renderColumnPicker() + openPicker() + search('NAME') + expect(screen.getByLabelText('Name')).toBeInTheDocument() + expect(screen.queryByLabelText('Value')).not.toBeInTheDocument() + }) + + test('placeholder text is just "Search"', () => { + renderColumnPicker() + openPicker() + expect(screen.getByPlaceholderText('Search')).toBeInTheDocument() + }) + + test('resets when the popover is closed and reopened', () => { + renderColumnPicker() + openPicker() + search('val') + openPicker() // closes + openPicker() // reopens + expect( + screen.getByTestId('data-table-column-picker-search') + ).toHaveValue('') + }) + + test('the select-all checkbox still targets every column, not just the filtered ones', () => { + const { store } = renderColumnPicker({ + columnConfig: { visibleKeys: ['name'] }, + }) + openPicker() + search('val') + fireEvent.click( + screen.getByTestId('data-table-column-picker-select-all') + ) + expect(store.getActions()).toContainEqual({ + type: DATA_TABLE_COLUMN_CONFIG_SET, + layerId: 'layer1', + config: { + visibleKeys: ['name', 'rawValue', 'legend'], + pinnedKeys: [], + orderedKeys: ['name', 'rawValue', 'legend'], + }, + }) + }) +}) diff --git a/src/components/datatable/controls/ColumnPickerControl.jsx b/src/components/datatable/controls/ColumnPickerControl.jsx index 3a44f8619a..0c8f345d64 100644 --- a/src/components/datatable/controls/ColumnPickerControl.jsx +++ b/src/components/datatable/controls/ColumnPickerControl.jsx @@ -4,6 +4,8 @@ import { IconLayoutColumns16, IconLock16, IconLockOpen16, + IconSync16, + IconUndo16, Tooltip, } from '@dhis2/ui' import { @@ -27,16 +29,13 @@ import { CSS } from '@dnd-kit/utilities' import { arrayMoveImmutable } from 'array-move' import cx from 'classnames' import PropTypes from 'prop-types' -import React, { useRef, useState } from 'react' +import React, { useCallback, useLayoutEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { useDispatch } from 'react-redux' import { setDataTableColumnConfig } from '../../../actions/dataTable.js' import { getVisibleHeaders } from '../../../util/tableColumns.js' import Checkbox from '../../core/Checkbox.jsx' -import { - FilterDropdownPopover, - getDropdownPlacement, -} from '../FilterDropdownPopover.jsx' +import { FilterDropdownPopover } from '../FilterDropdownPopover.jsx' import styles from './styles/ColumnPickerControl.module.css' import ToolbarIconButton from './ToolbarIconButton.jsx' @@ -91,7 +90,7 @@ const ColumnRowFields = ({ {suppressTooltips ? ( dragIcon ) : ( - <Tooltip content={dragLabel} placement="top"> + <Tooltip content={dragLabel} placement="left"> {dragIcon} </Tooltip> )} @@ -117,7 +116,7 @@ const ColumnRowFields = ({ {suppressTooltips ? ( pinIcon ) : ( - <Tooltip content={pinLabel} placement="top"> + <Tooltip content={pinLabel} placement="right"> {pinIcon} </Tooltip> )} @@ -204,6 +203,39 @@ const ColumnPickerControl = ({ layerId, allHeaders, columnConfig }) => { const anchorRef = useRef(null) const [isOpen, setIsOpen] = useState(false) const [activeId, setActiveId] = useState(null) + const [search, setSearch] = useState('') + // Whether the (unfiltered) column list actually overflows its own + // max-height - the search box only earns its keep when there's enough + // columns to make scrolling through them worth searching instead. + const [hasScroll, setHasScroll] = useState(false) + // The popover's own natural, content-driven width - measured once per + // open (from the full, unfiltered list) and then held fixed, so + // narrowing the list via search never shrinks/grows the popover itself. + const [popoverWidth, setPopoverWidth] = useState(null) + + useLayoutEffect(() => { + if (isOpen) { + setSearch('') + } + }, [isOpen]) + + // FilterDropdownPopover's content (including these) mounts a render + // after its Popper placement resolves, not synchronously with `isOpen` + // becoming true - a plain ref read in a `[isOpen]`-keyed effect would + // run too early and see `null`. Callback refs instead fire exactly + // when React actually attaches the node, whenever that is, and only + // once per open (the div isn't recreated by search/typing re-renders). + const columnListRef = useCallback((el) => { + if (el) { + setHasScroll(el.scrollHeight > el.clientHeight) + } + }, []) + + const popoverRef = useCallback((el) => { + if (el) { + setPopoverWidth(el.offsetWidth) + } + }, []) // useTableData can legitimately return a null headers list (e.g. while // loading or on error) - guard here rather than trust callers to. @@ -262,6 +294,26 @@ const ColumnPickerControl = ({ layerId, allHeaders, columnConfig }) => { updateConfig({ pinnedKeys: next }) } + const isAllVisible = visibleKeys.length === headers.length + const onToggleSelectAll = () => + updateConfig({ + visibleKeys: isAllVisible ? [] : headers.map((h) => h.dataKey), + }) + + const onReverseSelection = () => + updateConfig({ + visibleKeys: headers + .filter((h) => !visibleKeys.includes(h.dataKey)) + .map((h) => h.dataKey), + }) + + const onResetToDefaults = () => + dispatch(setDataTableColumnConfig(layerId, undefined)) + + const filteredHeaders = orderedHeaders.filter((h) => + h.name.toLowerCase().includes(search.trim().toLowerCase()) + ) + const sensors = useSensors( useSensor(MouseSensor, { // Require a small movement so a click on the handle isn't a drag @@ -299,9 +351,6 @@ const ColumnPickerControl = ({ layerId, allHeaders, columnConfig }) => { const activeHeader = orderedHeaders.find((h) => h.dataKey === activeId) - const anchorRect = anchorRef.current?.getBoundingClientRect() - const { dropdownPlacement } = getDropdownPlacement(anchorRect) - return ( <> <ToolbarIconButton @@ -317,10 +366,64 @@ const ColumnPickerControl = ({ layerId, allHeaders, columnConfig }) => { {isOpen && ( <FilterDropdownPopover reference={anchorRef} - placement={dropdownPlacement} + // Always opens upward: this control lives in the + // bottom panel's own toolbar strip, at the very top + // of the table area - there's rarely reliable room + // below it, unlike the per-column filter popovers + // that reuse this same component from the header row. + placement="top-start" onClickOutside={() => setIsOpen(false)} > - <div className={styles.columnPickerPopover}> + <div + ref={popoverRef} + className={styles.columnPickerPopover} + style={ + popoverWidth != null + ? { width: popoverWidth } + : undefined + } + > + {hasScroll && ( + <input + type="text" + className={styles.searchInput} + placeholder={i18n.t('Search')} + value={search} + onChange={(e) => setSearch(e.target.value)} + data-test="data-table-column-picker-search" + /> + )} + <div className={styles.bulkActionsRow}> + <span className={styles.selectAllSpacer} /> + <Tooltip + content={i18n.t('Select all columns')} + placement="top" + > + <input + type="checkbox" + aria-label={i18n.t('Select all columns')} + data-test="data-table-column-picker-select-all" + checked={isAllVisible} + onChange={onToggleSelectAll} + /> + </Tooltip> + <ToolbarIconButton + tooltip={i18n.t('Reverse selection')} + dataTest="data-table-column-picker-reverse" + onClick={onReverseSelection} + > + <IconSync16 /> + </ToolbarIconButton> + <span className={styles.bulkActionsDivider} /> + <ToolbarIconButton + tooltip={i18n.t('Reset to defaults')} + dataTest="data-table-column-picker-reset" + disabled={!columnConfig} + onClick={onResetToDefaults} + > + <IconUndo16 /> + </ToolbarIconButton> + </div> <DndContext sensors={sensors} collisionDetection={closestCenter} @@ -333,8 +436,11 @@ const ColumnPickerControl = ({ layerId, allHeaders, columnConfig }) => { items={orderedHeaders.map((h) => h.dataKey)} strategy={verticalListSortingStrategy} > - <div className={styles.columnList}> - {orderedHeaders.map((header, index) => ( + <div + ref={columnListRef} + className={styles.columnList} + > + {filteredHeaders.map((header) => ( <ColumnRow key={header.dataKey} header={header} @@ -345,9 +451,13 @@ const ColumnPickerControl = ({ layerId, allHeaders, columnConfig }) => { header.dataKey )} isPinnedGroupEnd={ - index === pinnedCount - 1 && + pinnedCount > 0 && pinnedCount < - orderedHeaders.length + orderedHeaders.length && + header.dataKey === + orderedHeaders[ + pinnedCount - 1 + ].dataKey } isDragActive={activeId != null} onToggleVisible={onToggleVisible} diff --git a/src/components/datatable/controls/styles/ColumnPickerControl.module.css b/src/components/datatable/controls/styles/ColumnPickerControl.module.css index 06075f9e19..1921648ce5 100644 --- a/src/components/datatable/controls/styles/ColumnPickerControl.module.css +++ b/src/components/datatable/controls/styles/ColumnPickerControl.module.css @@ -6,6 +6,61 @@ box-shadow: var(--elevations-popover); } +.searchInput { + box-sizing: border-box; + width: 100%; + margin-bottom: var(--spacers-dp8); + padding: var(--spacers-dp4) 6px; + font-size: 11px; + border: 1px solid var(--colors-grey500); + border-radius: 3px; +} + +.searchInput:focus { + outline: none; + border-color: var(--colors-blue600); +} + +.bulkActionsRow { + display: flex; + align-items: center; + gap: var(--spacers-dp4); + padding-bottom: var(--spacers-dp8); + margin-bottom: var(--spacers-dp8); + border-bottom: 1px solid var(--colors-grey300); +} + +/* Matches DataTable.module.css's own row-selection checkbox accent, and + sized to match the 14px dense checkbox icon each row below renders + (core/Checkbox.jsx always passes dense=true to @dhis2/ui's Checkbox). + The small relative nudge lines it up with the icon buttons next to it + (vertically) and the row checkboxes below it (horizontally) - a native + checkbox's own box-model doesn't center/align the same way those do. */ +.bulkActionsRow input[type='checkbox'] { + width: 14px; + height: 14px; + position: relative; + top: 2px; + left: 2px; + accent-color: var(--colors-teal600); +} + +/* Same width as .rowIconButton (the drag handle each row starts with), so + the "select all" checkbox lines up with the checkboxes in the rows + below rather than sitting flush against the popover's left edge. */ +.selectAllSpacer { + display: inline-block; + width: 20px; + flex-shrink: 0; +} + +.bulkActionsDivider { + width: 1px; + height: 20px; + background-color: var(--colors-grey300); + flex-shrink: 0; +} + .columnList { display: flex; flex-direction: column; From c0410a69566a47095563677c2e54582c3fe07d16 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Fri, 17 Jul 2026 20:25:56 +0200 Subject: [PATCH 065/205] fix: pr cleanup --- src/components/datatable/BottomPanel.jsx | 22 +-- .../datatable/FilterHelpTooltip.jsx | 89 ++++++++++ src/components/datatable/FilterInput.jsx | 163 ++---------------- .../datatable/controls/ActiveLayerControl.jsx | 14 +- .../styles/ActiveLayerControl.module.css | 7 +- .../styles/ColumnPickerControl.module.css | 14 -- .../styles/HighlightColorControl.module.css | 5 +- .../styles/ToolbarIconButton.module.css | 9 +- .../datatable/styles/BottomPanel.module.css | 6 +- .../styles/FilterHelpTooltip.module.css | 12 ++ .../datatable/styles/FilterInput.module.css | 13 -- src/util/__tests__/filterInput.spec.js | 138 +++++++++++++++ src/util/__tests__/tableColumns.spec.js | 5 +- src/util/filterInput.js | 68 ++++++++ src/util/tableColumns.js | 16 -- 15 files changed, 350 insertions(+), 231 deletions(-) create mode 100644 src/components/datatable/FilterHelpTooltip.jsx create mode 100644 src/components/datatable/styles/FilterHelpTooltip.module.css create mode 100644 src/util/__tests__/filterInput.spec.js create mode 100644 src/util/filterInput.js diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index 8cb11cd16b..52c69c784b 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -31,8 +31,6 @@ import DataTable from './DataTable.jsx' import ErrorBoundary from './ErrorBoundary.jsx' import styles from './styles/BottomPanel.module.css' -// Must match `.dataTableControls`'s height in BottomPanel.module.css -const COLLAPSED_HEIGHT = 36 const MIN_HEIGHT = 50 const EMPTY_FILTERS = {} @@ -70,7 +68,8 @@ const BottomPanel = () => { height - getCssVar('--header-height') - getCssVar('--toolbar-height') const tableHeight = dataTableHeight < maxHeight ? dataTableHeight : maxHeight - const displayHeight = isCollapsed ? COLLAPSED_HEIGHT : tableHeight + const collapsedHeight = getCssVar('--data-table-controls-height') + const displayHeight = isCollapsed ? collapsedHeight : tableHeight const toggleCollapsed = useCallback( () => setIsCollapsed((collapsed) => !collapsed), @@ -91,13 +90,16 @@ const BottomPanel = () => { isDraggingRef.current = true }, []) - const onResize = useCallback((h) => { - setIsCollapsed(h <= MIN_HEIGHT) - document.documentElement.style.setProperty( - '--data-table-height', - `${h <= MIN_HEIGHT ? COLLAPSED_HEIGHT : h}px` - ) - }, []) + const onResize = useCallback( + (h) => { + setIsCollapsed(h <= MIN_HEIGHT) + document.documentElement.style.setProperty( + '--data-table-height', + `${h <= MIN_HEIGHT ? collapsedHeight : h}px` + ) + }, + [collapsedHeight] + ) const onResizeEnd = useCallback( (h) => { diff --git a/src/components/datatable/FilterHelpTooltip.jsx b/src/components/datatable/FilterHelpTooltip.jsx new file mode 100644 index 0000000000..10e8506580 --- /dev/null +++ b/src/components/datatable/FilterHelpTooltip.jsx @@ -0,0 +1,89 @@ +import { Popper, Portal } from '@dhis2/ui' +import PropTypes from 'prop-types' +import React, { useEffect, useRef, useState } from 'react' +import styles from './styles/FilterHelpTooltip.module.css' + +const helpTooltipModifiers = [ + { name: 'offset', options: { offset: [0, 4] } }, + { name: 'flip', enabled: false }, +] + +const FilterHelpTooltip = ({ + content, + placement, + estimatedHeight, + dataTest, + children, +}) => { + const [open, setOpen] = useState(false) + const referenceRef = useRef(null) + const openTimerRef = useRef(null) + const closeTimerRef = useRef(null) + + const onOpen = () => { + clearTimeout(closeTimerRef.current) + openTimerRef.current = setTimeout(() => setOpen(true), 200) + } + + const onClose = () => { + clearTimeout(openTimerRef.current) + closeTimerRef.current = setTimeout(() => setOpen(false), 200) + } + + useEffect( + () => () => { + clearTimeout(openTimerRef.current) + clearTimeout(closeTimerRef.current) + }, + [] + ) + + const referenceRect = referenceRef.current?.getBoundingClientRect() + let spaceAvailable = Infinity + if (referenceRect) { + spaceAvailable = + placement === 'top' + ? referenceRect.top + : window.innerHeight - referenceRect.bottom + } + const hasRoom = spaceAvailable >= estimatedHeight + + return ( + <span + ref={referenceRef} + onMouseOver={onOpen} + onMouseOut={onClose} + onFocus={onOpen} + onBlur={onClose} + data-test={`${dataTest}-reference`} + > + {children} + {open && hasRoom && ( + <Portal> + <Popper + placement={placement} + reference={referenceRef} + modifiers={helpTooltipModifiers} + > + <div + className={styles.filterHelpTooltip} + data-test={`${dataTest}-content`} + > + {content} + </div> + </Popper> + </Portal> + )} + </span> + ) +} + +FilterHelpTooltip.propTypes = { + children: PropTypes.node.isRequired, + content: PropTypes.node.isRequired, + dataTest: PropTypes.string.isRequired, + estimatedHeight: PropTypes.number.isRequired, + placement: PropTypes.oneOf(['top', 'bottom']).isRequired, +} + +export default FilterHelpTooltip diff --git a/src/components/datatable/FilterInput.jsx b/src/components/datatable/FilterInput.jsx index 65f0d0b604..15de171fd1 100644 --- a/src/components/datatable/FilterInput.jsx +++ b/src/components/datatable/FilterInput.jsx @@ -1,8 +1,8 @@ import i18n from '@dhis2/d2-i18n' -import { Input, Popper, Portal, IconFilter16, IconSync16 } from '@dhis2/ui' +import { Input, IconFilter16, IconSync16 } from '@dhis2/ui' import cx from 'classnames' import PropTypes from 'prop-types' -import React, { useEffect, useMemo, useRef, useState } from 'react' +import React, { useMemo, useRef, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import { Virtuoso } from 'react-virtuoso' import { setDataFilter, clearDataFilter } from '../../actions/dataFilters.js' @@ -11,7 +11,13 @@ import { SENTINEL_NO_VALUE, } from '../../constants/dataTable.js' import useOptionSet from '../../hooks/useOptionSet.js' -import { numericFilter } from '../../util/filter.js' +import { + getDisplayValue, + getFilteredOptions, + getPopoverWidth, + getSelectedAndAppliedString, + measureMaxTextWidth, +} from '../../util/filterInput.js' import { getInvertibleValues, reverseSelection, @@ -25,16 +31,11 @@ import { FilterDropdownPopover, getDropdownPlacement, } from './FilterDropdownPopover.jsx' +import FilterHelpTooltip from './FilterHelpTooltip.jsx' import styles from './styles/FilterInput.module.css' const OPTION_ROW_HEIGHT = 28 // Checkbox rows are a fixed height so the list can be virtualized const MAX_LIST_HEIGHT = 260 -const MIN_POPOVER_WIDTH = 140 -const MAX_POPOVER_WIDTH = 280 -// Checkbox icon + its margin, popover padding (both sides) and the -// scrollbar .multiSelectPopover's overflow-y: auto can show - none of -// which is part of the label text itself. -const POPOVER_ROW_CHROME_WIDTH = 56 const NUMERIC_HELP_HEIGHT = 140 const TEXT_HELP_HEIGHT = 56 const NUMERIC_FILTER_HELP = ( @@ -55,142 +56,6 @@ const TEXT_FILTER_HELP = ( ) const NUMERIC_INPUT_DISALLOWED = /[^0-9.\-<>=,&\s]/g -// Options render through react-virtuoso, which positions rows absolutely -// for virtualization - out-of-flow content like that is excluded from CSS's -// own intrinsic (max-content) sizing, so a container can never grow to fit -// virtualized content via CSS alone. Measuring the label text directly is -// the standard workaround. -let measureCanvasContext = null -const measureMaxTextWidth = (texts, font) => { - if (!measureCanvasContext) { - measureCanvasContext = document.createElement('canvas').getContext('2d') - } - measureCanvasContext.font = font - return texts.reduce( - (max, text) => - Math.max(max, measureCanvasContext.measureText(text).width), - 0 - ) -} - -const helpTooltipModifiers = [ - { name: 'offset', options: { offset: [0, 4] } }, - { name: 'flip', enabled: false }, -] - -const FilterHelpTooltip = ({ - content, - placement, - estimatedHeight, - dataTest, - children, -}) => { - const [open, setOpen] = useState(false) - const referenceRef = useRef(null) - const openTimerRef = useRef(null) - const closeTimerRef = useRef(null) - - const onOpen = () => { - clearTimeout(closeTimerRef.current) - openTimerRef.current = setTimeout(() => setOpen(true), 200) - } - - const onClose = () => { - clearTimeout(openTimerRef.current) - closeTimerRef.current = setTimeout(() => setOpen(false), 200) - } - - useEffect( - () => () => { - clearTimeout(openTimerRef.current) - clearTimeout(closeTimerRef.current) - }, - [] - ) - - const referenceRect = referenceRef.current?.getBoundingClientRect() - let spaceAvailable = Infinity - if (referenceRect) { - spaceAvailable = - placement === 'top' - ? referenceRect.top - : window.innerHeight - referenceRect.bottom - } - const hasRoom = spaceAvailable >= estimatedHeight - - return ( - <span - ref={referenceRef} - onMouseOver={onOpen} - onMouseOut={onClose} - onFocus={onOpen} - onBlur={onClose} - data-test={`${dataTest}-reference`} - > - {children} - {open && hasRoom && ( - <Portal> - <Popper - placement={placement} - reference={referenceRef} - modifiers={helpTooltipModifiers} - > - <div - className={styles.filterHelpTooltip} - data-test={`${dataTest}-content`} - > - {content} - </div> - </Popper> - </Portal> - )} - </span> - ) -} - -FilterHelpTooltip.propTypes = { - children: PropTypes.node.isRequired, - content: PropTypes.node.isRequired, - dataTest: PropTypes.string.isRequired, - estimatedHeight: PropTypes.number.isRequired, - placement: PropTypes.oneOf(['top', 'bottom']).isRequired, -} - -const getFilteredOptions = ({ - realOptions, - trimmedSearch, - normalizedSearch, - type, - resolveLabel, -}) => { - if (!trimmedSearch) { - return realOptions - } - if (type === 'number') { - return realOptions.filter(({ value }) => - numericFilter(Number(value), trimmedSearch) - ) - } - return realOptions.filter(({ value }) => - resolveLabel(value).toLowerCase().includes(normalizedSearch) - ) -} - -const getDisplayValue = ({ isOpen, searchText, selected, appliedString }) => { - if (isOpen) { - return searchText - } - if (selected.length) { - return i18n.t('{{count}} selected', { count: selected.length }) - } - return appliedString -} - -const getSelectedAndAppliedString = (filterValue) => ({ - selected: Array.isArray(filterValue) ? filterValue : [], - appliedString: typeof filterValue === 'string' ? filterValue : '', -}) - const SearchableFilterPopover = ({ dataKey, name, @@ -255,13 +120,7 @@ const SearchableFilterPopover = ({ } const font = `11px ${getComputedStyle(document.body).fontFamily}` const maxLabelWidth = measureMaxTextWidth(labels, font) - return Math.min( - Math.max( - maxLabelWidth + POPOVER_ROW_CHROME_WIDTH, - MIN_POPOVER_WIDTH - ), - MAX_POPOVER_WIDTH - ) + return getPopoverWidth(maxLabelWidth) // eslint-disable-next-line react-hooks/exhaustive-deps }, [realOptions, hasNotSetOption]) diff --git a/src/components/datatable/controls/ActiveLayerControl.jsx b/src/components/datatable/controls/ActiveLayerControl.jsx index 0d076955c5..3e28a4ba20 100644 --- a/src/components/datatable/controls/ActiveLayerControl.jsx +++ b/src/components/datatable/controls/ActiveLayerControl.jsx @@ -1,13 +1,9 @@ import PropTypes from 'prop-types' import React, { useCallback, useRef, useState } from 'react' import { createPortal } from 'react-dom' +import { getCssVar } from '../../../util/helpers.js' import styles from './styles/ActiveLayerControl.module.css' -// Must match .nameTooltip's top/bottom padding in ActiveLayerControl.module.css - -// offsets the tooltip's top so that extra padding grows the background -// without shifting the text's own vertical position. -const TOOLTIP_VERTICAL_PADDING = 3 - const ActiveLayerControl = ({ name }) => { const nameRef = useRef(null) const [nameTooltipPos, setNameTooltipPos] = useState(null) @@ -20,11 +16,11 @@ const ActiveLayerControl = ({ name }) => { const rect = el.getBoundingClientRect() const computed = getComputedStyle(el) const lineHeight = Number.parseFloat(computed.lineHeight) + const verticalPadding = getCssVar( + '--data-table-name-tooltip-vertical-padding' + ) setNameTooltipPos({ - top: - rect.top + - (rect.height - lineHeight) / 2 - - TOOLTIP_VERTICAL_PADDING, + top: rect.top + (rect.height - lineHeight) / 2 - verticalPadding, left: rect.left, color: computed.color, fontSize: computed.fontSize, diff --git a/src/components/datatable/controls/styles/ActiveLayerControl.module.css b/src/components/datatable/controls/styles/ActiveLayerControl.module.css index d292736485..75d05c3372 100644 --- a/src/components/datatable/controls/styles/ActiveLayerControl.module.css +++ b/src/components/datatable/controls/styles/ActiveLayerControl.module.css @@ -1,3 +1,7 @@ +:root { + --data-table-name-tooltip-vertical-padding: 3px; +} + .layerName { font-weight: 500; font-size: 12px; @@ -25,7 +29,8 @@ border-radius: 3px; -webkit-mask-image: linear-gradient(to left, transparent, black 2em); mask-image: linear-gradient(to left, transparent, black 2em); - padding: 3px 2em 3px 0; + padding: var(--data-table-name-tooltip-vertical-padding) 2em + var(--data-table-name-tooltip-vertical-padding) 0; pointer-events: none; position: fixed; white-space: nowrap; diff --git a/src/components/datatable/controls/styles/ColumnPickerControl.module.css b/src/components/datatable/controls/styles/ColumnPickerControl.module.css index 1921648ce5..fa974e6fd3 100644 --- a/src/components/datatable/controls/styles/ColumnPickerControl.module.css +++ b/src/components/datatable/controls/styles/ColumnPickerControl.module.css @@ -30,12 +30,6 @@ border-bottom: 1px solid var(--colors-grey300); } -/* Matches DataTable.module.css's own row-selection checkbox accent, and - sized to match the 14px dense checkbox icon each row below renders - (core/Checkbox.jsx always passes dense=true to @dhis2/ui's Checkbox). - The small relative nudge lines it up with the icon buttons next to it - (vertically) and the row checkboxes below it (horizontally) - a native - checkbox's own box-model doesn't center/align the same way those do. */ .bulkActionsRow input[type='checkbox'] { width: 14px; height: 14px; @@ -45,9 +39,6 @@ accent-color: var(--colors-teal600); } -/* Same width as .rowIconButton (the drag handle each row starts with), so - the "select all" checkbox lines up with the checkboxes in the rows - below rather than sitting flush against the popover's left edge. */ .selectAllSpacer { display: inline-block; width: 20px; @@ -87,9 +78,6 @@ .columnRowCheckbox { flex: 1; min-width: 0; - /* Checkbox.module.css's shared `.checkbox` sets a 16px/8px vertical - margin meant for standalone form fields - reset it here so row - height is driven by .columnRow's own padding instead. */ margin: 0; } @@ -125,8 +113,6 @@ } .dragHandle { - /* Prevent the browser's own native drag (e.g. dragging the inline - svg icon as an image) from hijacking dnd-kit's mouse sensor. */ -webkit-user-drag: none; user-select: none; color: var(--colors-grey600); diff --git a/src/components/datatable/controls/styles/HighlightColorControl.module.css b/src/components/datatable/controls/styles/HighlightColorControl.module.css index e6c938caea..7b8c95bd96 100644 --- a/src/components/datatable/controls/styles/HighlightColorControl.module.css +++ b/src/components/datatable/controls/styles/HighlightColorControl.module.css @@ -19,10 +19,7 @@ align-items: center; } -/* !important beats @dhis2/ui's own ColorPicker label size, matching the - other toolbar controls' 16px icon size exactly (same top edge, so - Tooltip's placement="top" lines up with theirs too). It still sits - inside the same 24px hover box as the other controls (.wrapper above). */ +/* !important beats @dhis2/ui's own ColorPicker label size. */ .colorPicker label { box-sizing: border-box; overflow: hidden; diff --git a/src/components/datatable/controls/styles/ToolbarIconButton.module.css b/src/components/datatable/controls/styles/ToolbarIconButton.module.css index 9b1a43bbf8..a47826bc45 100644 --- a/src/components/datatable/controls/styles/ToolbarIconButton.module.css +++ b/src/components/datatable/controls/styles/ToolbarIconButton.module.css @@ -1,5 +1,4 @@ -/* !important beats core/IconButton's own 28px/grey700/grey200-hover defaults, - to keep BottomPanel's established 24px/grey800/grey300-hover look. */ +/* !important beats core/IconButton's own 28px/grey700/grey200-hover defaults. */ .toolbarIconButton { width: 24px !important; height: 24px !important; @@ -15,12 +14,6 @@ color: var(--colors-grey800) !important; } -/* @dhis2/ui's Tooltip wraps its child in its own <span> sized by - line-height (text baseline layout), which sits a 16px icon a couple of - pixels above true center. Re-flexing that wrapper overrides the - baseline layout so the icon centers in the 24px button regardless of - whether the icon is passed directly or through a control's own - wrapper span (e.g. ClearFiltersControl's badge wrapper). */ .toolbarIconButton > span { display: flex !important; align-items: center; diff --git a/src/components/datatable/styles/BottomPanel.module.css b/src/components/datatable/styles/BottomPanel.module.css index 6468801870..9ab8b8aaf2 100644 --- a/src/components/datatable/styles/BottomPanel.module.css +++ b/src/components/datatable/styles/BottomPanel.module.css @@ -1,3 +1,7 @@ +:root { + --data-table-controls-height: 36px; +} + .bottomPanel { position: absolute; left: 0; @@ -18,7 +22,7 @@ .dataTableControls { width: 100%; - height: 36px; + height: var(--data-table-controls-height); background-color: var(--colors-grey100); position: relative; display: flex; diff --git a/src/components/datatable/styles/FilterHelpTooltip.module.css b/src/components/datatable/styles/FilterHelpTooltip.module.css new file mode 100644 index 0000000000..91062c37b7 --- /dev/null +++ b/src/components/datatable/styles/FilterHelpTooltip.module.css @@ -0,0 +1,12 @@ +.filterHelpTooltip { + z-index: 2000; + max-width: 300px; + padding: 4px 6px; + background-color: var(--colors-grey900); + border-radius: 3px; + color: var(--colors-white); + font-size: 11px; + line-height: 17px; + word-break: normal; + overflow-wrap: break-word; +} diff --git a/src/components/datatable/styles/FilterInput.module.css b/src/components/datatable/styles/FilterInput.module.css index e3fec49328..b668e7a415 100644 --- a/src/components/datatable/styles/FilterInput.module.css +++ b/src/components/datatable/styles/FilterInput.module.css @@ -155,16 +155,3 @@ .multiSelectPopover .highlighted { background: var(--colors-grey100); } - -.filterHelpTooltip { - z-index: 2000; - max-width: 300px; - padding: 4px 6px; - background-color: var(--colors-grey900); - border-radius: 3px; - color: var(--colors-white); - font-size: 11px; - line-height: 17px; - word-break: normal; - overflow-wrap: break-word; -} diff --git a/src/util/__tests__/filterInput.spec.js b/src/util/__tests__/filterInput.spec.js new file mode 100644 index 0000000000..48c894fa6c --- /dev/null +++ b/src/util/__tests__/filterInput.spec.js @@ -0,0 +1,138 @@ +import { + getDisplayValue, + getFilteredOptions, + getPopoverWidth, + getSelectedAndAppliedString, + measureMaxTextWidth, +} from '../filterInput.js' + +describe('getSelectedAndAppliedString', () => { + it('treats an array filterValue as the selected checkboxes', () => { + expect(getSelectedAndAppliedString(['a', 'b'])).toEqual({ + selected: ['a', 'b'], + appliedString: '', + }) + }) + + it('treats a string filterValue as an applied custom filter', () => { + expect(getSelectedAndAppliedString('> 5')).toEqual({ + selected: [], + appliedString: '> 5', + }) + }) + + it('returns empty defaults when there is no filterValue yet', () => { + expect(getSelectedAndAppliedString(undefined)).toEqual({ + selected: [], + appliedString: '', + }) + }) +}) + +describe('getDisplayValue', () => { + it('shows the live search text while the popover is open, regardless of other state', () => { + expect( + getDisplayValue({ + isOpen: true, + searchText: 'typing…', + selected: ['a'], + appliedString: '> 5', + }) + ).toBe('typing…') + }) + + it('shows a selection count when closed with checkboxes selected', () => { + expect( + getDisplayValue({ + isOpen: false, + searchText: '', + selected: ['a', 'b'], + appliedString: '', + }) + ).toBe('2 selected') + }) + + it('falls back to the applied custom filter string when closed with nothing selected', () => { + expect( + getDisplayValue({ + isOpen: false, + searchText: '', + selected: [], + appliedString: '> 5', + }) + ).toBe('> 5') + }) +}) + +describe('getFilteredOptions', () => { + const realOptions = [{ value: '3' }, { value: '7' }, { value: '12' }] + + it('returns every option unchanged when there is no search text', () => { + expect( + getFilteredOptions({ + realOptions, + trimmedSearch: '', + normalizedSearch: '', + type: 'number', + resolveLabel: (v) => v, + }) + ).toBe(realOptions) + }) + + it('filters numeric columns using the typed filter expression, not substring match', () => { + const result = getFilteredOptions({ + realOptions, + trimmedSearch: '> 5', + normalizedSearch: '> 5', + type: 'number', + resolveLabel: (v) => v, + }) + expect(result.map((o) => o.value)).toEqual(['7', '12']) + }) + + it('filters string columns by case-insensitive substring match on the resolved label', () => { + const stringOptions = [{ value: 'a' }, { value: 'b' }, { value: 'c' }] + const resolveLabel = (v) => + ({ a: 'Apple', b: 'Banana', c: 'Cherry' }[v]) + const result = getFilteredOptions({ + realOptions: stringOptions, + trimmedSearch: 'AN', + normalizedSearch: 'an', + type: 'string', + resolveLabel, + }) + expect(result.map((o) => o.value)).toEqual(['b']) + }) +}) + +describe('measureMaxTextWidth', () => { + it('returns the width of the longest of several strings', () => { + const font = '11px sans-serif' + const short = measureMaxTextWidth(['a'], font) + const long = measureMaxTextWidth(['a much longer piece of text'], font) + const max = measureMaxTextWidth( + ['a', 'a much longer piece of text'], + font + ) + expect(max).toBe(long) + expect(long).toBeGreaterThan(short) + }) + + it('returns 0 for an empty list of strings', () => { + expect(measureMaxTextWidth([], '11px sans-serif')).toBe(0) + }) +}) + +describe('getPopoverWidth', () => { + it('clamps up to the minimum width for a small measured label', () => { + expect(getPopoverWidth(1)).toBe(140) + }) + + it('clamps down to the maximum width for a very wide measured label', () => { + expect(getPopoverWidth(1000)).toBe(280) + }) + + it('passes a mid-range measurement through with the non-label width added', () => { + expect(getPopoverWidth(100)).toBe(156) + }) +}) diff --git a/src/util/__tests__/tableColumns.spec.js b/src/util/__tests__/tableColumns.spec.js index 6d7eabfa4a..d0bc193f2e 100644 --- a/src/util/__tests__/tableColumns.spec.js +++ b/src/util/__tests__/tableColumns.spec.js @@ -165,9 +165,8 @@ describe('getPinnedLeftOffsets', () => { }) it('does not let an unpinned column contribute width when pinned columns are not contiguous', () => { - // Not a realistic input in practice (getVisibleHeaders always - // makes pinned columns contiguous first), but the function itself - // shouldn't silently corrupt offsets if that invariant is broken. + // 'name' sits between the two pinned columns here and must not + // inflate 'id's offset. const offsets = getPinnedLeftOffsets( visibleHeaders, ['rawValue', 'id'], diff --git a/src/util/filterInput.js b/src/util/filterInput.js new file mode 100644 index 0000000000..dc86ddd3ec --- /dev/null +++ b/src/util/filterInput.js @@ -0,0 +1,68 @@ +import i18n from '@dhis2/d2-i18n' +import { numericFilter } from './filter.js' + +const POPOVER_ROW_NON_LABEL_WIDTH = 56 +const MIN_POPOVER_WIDTH = 140 +const MAX_POPOVER_WIDTH = 280 + +export const getSelectedAndAppliedString = (filterValue) => ({ + selected: Array.isArray(filterValue) ? filterValue : [], + appliedString: typeof filterValue === 'string' ? filterValue : '', +}) + +export const getDisplayValue = ({ + isOpen, + searchText, + selected, + appliedString, +}) => { + if (isOpen) { + return searchText + } + if (selected.length) { + return i18n.t('{{count}} selected', { count: selected.length }) + } + return appliedString +} + +export const getFilteredOptions = ({ + realOptions, + trimmedSearch, + normalizedSearch, + type, + resolveLabel, +}) => { + if (!trimmedSearch) { + return realOptions + } + if (type === 'number') { + return realOptions.filter(({ value }) => + numericFilter(Number(value), trimmedSearch) + ) + } + return realOptions.filter(({ value }) => + resolveLabel(value).toLowerCase().includes(normalizedSearch) + ) +} + +let measureCanvasContext = null +export const measureMaxTextWidth = (texts, font) => { + if (!measureCanvasContext) { + measureCanvasContext = document.createElement('canvas').getContext('2d') + } + measureCanvasContext.font = font + return texts.reduce( + (max, text) => + Math.max(max, measureCanvasContext.measureText(text).width), + 0 + ) +} + +export const getPopoverWidth = (maxLabelWidth) => + Math.min( + Math.max( + maxLabelWidth + POPOVER_ROW_NON_LABEL_WIDTH, + MIN_POPOVER_WIDTH + ), + MAX_POPOVER_WIDTH + ) diff --git a/src/util/tableColumns.js b/src/util/tableColumns.js index 1e3567e619..e555e6c1dd 100644 --- a/src/util/tableColumns.js +++ b/src/util/tableColumns.js @@ -5,11 +5,6 @@ const getOrderIndex = (dataKey, orderedKeys) => { return index === -1 ? orderedKeys.length : index } -// Computes the headers actually shown, in display order, from the full -// header list and a saved dataTableColumnConfig. Handles headers whose -// dataKey no longer exists (harmlessly dropped, since this always starts -// from the current `headers`) and headers that exist but were never part -// of a saved config (kept visible, ordered last). export const getVisibleHeaders = (headers, columnConfig) => { if (!headers) { return headers @@ -26,19 +21,11 @@ export const getVisibleHeaders = (headers, columnConfig) => { ) : headers - // visibleKeys is only set once a user has actually configured columns - - // before that, columnConfig is null and every header shows. Once set, - // it's the definitive "on" list: a dataKey added later (e.g. a new EE - // band) that was never part of that saved list stays hidden until the - // user explicitly turns it on, rather than reappearing unexpectedly. if (visibleKeys) { result = result.filter((h) => visibleKeys.includes(h.dataKey)) } if (pinnedKeys.length) { - // position: sticky only freezes columns that are actually - // contiguous at the start of display order, so pinned columns - // must be moved to the front here, not just flagged for styling. const pinned = result.filter((h) => pinnedKeys.includes(h.dataKey)) const rest = result.filter((h) => !pinnedKeys.includes(h.dataKey)) result = [...pinned, ...rest] @@ -47,9 +34,6 @@ export const getVisibleHeaders = (headers, columnConfig) => { return result } -// Left offset (px) for each pinned column's sticky positioning, keyed by -// dataKey. `visibleHeaders`/`columnWidths` must be in the same display -// order (i.e. already passed through getVisibleHeaders). export const getPinnedLeftOffsets = ( visibleHeaders, pinnedKeys, From 3f25a5eb85fcbf9919115f762114692d728e6478 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Fri, 17 Jul 2026 20:50:19 +0200 Subject: [PATCH 066/205] chore: fix sonarqube issues --- src/components/core/IconButton.jsx | 1 + src/components/datatable/controls/ResizeHandleControl.jsx | 6 +----- .../__tests__/__snapshots__/LayerToolbar.spec.jsx.snap | 4 ++++ 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/components/core/IconButton.jsx b/src/components/core/IconButton.jsx index 0e8660f535..9ccb118bff 100644 --- a/src/components/core/IconButton.jsx +++ b/src/components/core/IconButton.jsx @@ -20,6 +20,7 @@ const IconButton = forwardRef( return ( <button ref={ref} + type="button" onClick={onClick} className={cx(styles.iconButton, className, { [styles.disabled]: !!disabled, diff --git a/src/components/datatable/controls/ResizeHandleControl.jsx b/src/components/datatable/controls/ResizeHandleControl.jsx index 8ec306ba78..8d38cc833a 100644 --- a/src/components/datatable/controls/ResizeHandleControl.jsx +++ b/src/components/datatable/controls/ResizeHandleControl.jsx @@ -14,11 +14,7 @@ const ResizeHandleControl = ({ const getHeight = (clientY) => { const height = window.innerHeight - clientY - return height < minHeight - ? minHeight - : height > maxHeight - ? maxHeight - : height + return Math.min(Math.max(height, minHeight), maxHeight) } const onPointerDown = (evt) => { diff --git a/src/components/layers/toolbar/__tests__/__snapshots__/LayerToolbar.spec.jsx.snap b/src/components/layers/toolbar/__tests__/__snapshots__/LayerToolbar.spec.jsx.snap index 37ad5d4ab0..f3fc0eb58f 100644 --- a/src/components/layers/toolbar/__tests__/__snapshots__/LayerToolbar.spec.jsx.snap +++ b/src/components/layers/toolbar/__tests__/__snapshots__/LayerToolbar.spec.jsx.snap @@ -9,6 +9,7 @@ exports[`LayerToolbar Should render edit button 1`] = ` <button class="iconButton editButton" data-test="layer-edit-button" + type="button" > <div> <svg @@ -27,6 +28,7 @@ exports[`LayerToolbar Should render edit button 1`] = ` <button class="iconButton visible" data-test="visibilitybutton" + type="button" > <div> <div> @@ -72,6 +74,7 @@ exports[`LayerToolbar Should render only a visibility toggle and opacity slider <button class="iconButton visible" data-test="visibilitybutton" + type="button" > <div> <div> @@ -117,6 +120,7 @@ exports[`LayerToolbar Should show SvgViewOff24 when not visible 1`] = ` <button class="iconButton notvisible" data-test="visibilitybutton" + type="button" > <div> <div> From c19f31e353e8bf0e037970cc26d8999cdbafe995 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Fri, 17 Jul 2026 20:54:15 +0200 Subject: [PATCH 067/205] chore: use css module --- src/components/datatable/FilterInput.jsx | 17 +++++++++-------- .../datatable/styles/FilterInput.module.css | 5 +++++ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/components/datatable/FilterInput.jsx b/src/components/datatable/FilterInput.jsx index 15de171fd1..91d4759221 100644 --- a/src/components/datatable/FilterInput.jsx +++ b/src/components/datatable/FilterInput.jsx @@ -344,8 +344,10 @@ const SearchableFilterPopover = ({ label={i18n.t('Any value')} checked={anyValueActive} onChange={onToggleAnyValue} - className={styles.specialOption} - style={{ margin: 0, padding: '4px 0' }} + className={cx( + styles.specialOption, + styles.denseCheckbox + )} dataTest={`data-table-column-filter-any-${name}`} /> {hasNotSetOption && ( @@ -357,8 +359,10 @@ const SearchableFilterPopover = ({ onChange={() => toggleValue(SENTINEL_NO_VALUE) } - className={styles.specialOption} - style={{ margin: 0, padding: '4px 0' }} + className={cx( + styles.specialOption, + styles.denseCheckbox + )} dataTest={`data-table-column-filter-novalue-${name}`} /> )} @@ -406,6 +410,7 @@ const SearchableFilterPopover = ({ onToggleRealValue(option.value) } className={cx( + styles.denseCheckbox, (dataKey === 'id' || dataKey === 'color') && styles.monoOption, @@ -415,10 +420,6 @@ const SearchableFilterPopover = ({ : index) && styles.highlighted )} - style={{ - margin: 0, - padding: '4px 0', - }} /> )} /> diff --git a/src/components/datatable/styles/FilterInput.module.css b/src/components/datatable/styles/FilterInput.module.css index b668e7a415..336b3c496a 100644 --- a/src/components/datatable/styles/FilterInput.module.css +++ b/src/components/datatable/styles/FilterInput.module.css @@ -104,6 +104,11 @@ font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', 'Consolas', monospace; } +.denseCheckbox { + margin: 0; + padding: 4px 0; +} + /* !important beats @dhis2/ui's own Checkbox label color. */ .specialOption :global(label) { color: var(--colors-grey700) !important; From bd6ef6c0de6fbd627112eeec3b2ac93561f9554b Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Fri, 17 Jul 2026 21:06:50 +0200 Subject: [PATCH 068/205] chore: pr cleanup --- .../__tests__/ColumnPickerControl.spec.jsx | 7 - .../controls/ColumnPickerControl.jsx | 265 ++---------------- .../datatable/controls/ColumnRow.jsx | 157 +++++++++++ src/util/__tests__/tableColumns.spec.js | 76 ++++- src/util/tableColumns.js | 31 ++ 5 files changed, 286 insertions(+), 250 deletions(-) create mode 100644 src/components/datatable/controls/ColumnRow.jsx diff --git a/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx b/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx index 7fa2ce6fde..94c25351a7 100644 --- a/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx +++ b/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx @@ -252,10 +252,6 @@ describe('ColumnPicker bulk actions', () => { }) describe('ColumnPicker search box visibility', () => { - // jsdom never lays elements out, so scrollHeight/clientHeight are both - // 0 by default - which conveniently already matches "list is short - // enough, no scrollbar" for the negative case below with no mocking. - test('is hidden when the column list is short enough to fit without scrolling', () => { renderColumnPicker() openPicker() @@ -286,9 +282,6 @@ describe('ColumnPicker search', () => { let clientHeightSpy beforeEach(() => { - // These tests exercise search behavior assuming the box is - // showing - its conditional visibility is covered separately - // above, so force it on here regardless of list length. scrollHeightSpy = jest .spyOn(Element.prototype, 'scrollHeight', 'get') .mockReturnValue(500) diff --git a/src/components/datatable/controls/ColumnPickerControl.jsx b/src/components/datatable/controls/ColumnPickerControl.jsx index 0c8f345d64..6f9e52ad61 100644 --- a/src/components/datatable/controls/ColumnPickerControl.jsx +++ b/src/components/datatable/controls/ColumnPickerControl.jsx @@ -1,13 +1,5 @@ import i18n from '@dhis2/d2-i18n' -import { - IconDragHandle16, - IconLayoutColumns16, - IconLock16, - IconLockOpen16, - IconSync16, - IconUndo16, - Tooltip, -} from '@dhis2/ui' +import { IconLayoutColumns16, IconSync16, IconUndo16, Tooltip } from '@dhis2/ui' import { DndContext, DragOverlay, @@ -22,10 +14,8 @@ import { restrictToVerticalAxis } from '@dnd-kit/modifiers' import { SortableContext, sortableKeyboardCoordinates, - useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable' -import { CSS } from '@dnd-kit/utilities' import { arrayMoveImmutable } from 'array-move' import cx from 'classnames' import PropTypes from 'prop-types' @@ -33,184 +23,28 @@ import React, { useCallback, useLayoutEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { useDispatch } from 'react-redux' import { setDataTableColumnConfig } from '../../../actions/dataTable.js' -import { getVisibleHeaders } from '../../../util/tableColumns.js' -import Checkbox from '../../core/Checkbox.jsx' +import { + getPinnedCount, + getVisibleHeaders, + isPinnedGroupEnd, + reverseVisibleKeys, + togglePinnedKey, + toggleVisibleKey, +} from '../../../util/tableColumns.js' import { FilterDropdownPopover } from '../FilterDropdownPopover.jsx' +import ColumnRow, { ColumnRowFields } from './ColumnRow.jsx' import styles from './styles/ColumnPickerControl.module.css' import ToolbarIconButton from './ToolbarIconButton.jsx' -// Higher than this codebase's usual z-index: 2000 "float above everything" -// convention (e.g. DataTable.module.css's .topTooltipContent), since the -// overlay must render above the popover itself, which relies on that same -// convention for its own stacking. const DRAG_OVERLAY_Z_INDEX = 2100 -const noop = () => {} - -// Shared visual content for a column row - used both by the interactive -// ColumnRow (which wraps it with useSortable's drag styling) and by the -// DragOverlay preview, so the dragged clone can never visually drift from -// the real row it's standing in for. dragHandleProps/onToggle* are omitted -// for the (non-interactive) overlay preview, and dataTestSuffix keeps its -// data-test ids from colliding with the real row's while both are mounted -// during an active drag. -const ColumnRowFields = ({ - header, - isVisible, - isPinned, - dragHandleProps, - dataTestSuffix = '', - suppressTooltips = false, - onToggleVisible = noop, - onTogglePinned = noop, -}) => { - const dragLabel = i18n.t('Drag to reorder') - const pinLabel = isPinned - ? i18n.t('Unpin column') - : i18n.t('Pin column to the left') - - // While a drag is in progress, the cursor keeps passing over every - // row's drag handle/pin button - those still fire real mouseover - // events, so without this guard, other rows' tooltips would pop open - // mid-drag. Not rendering the Tooltip wrapper (rather than e.g. hiding - // its content) also unmounts any tooltip that was already open. - const dragIcon = <IconDragHandle16 /> - const pinIcon = isPinned ? <IconLock16 /> : <IconLockOpen16 /> - - return ( - <> - <button - type="button" - className={cx(styles.rowIconButton, styles.dragHandle)} - aria-label={dragLabel} - data-test={`data-table-column-picker-drag-${header.dataKey}${dataTestSuffix}`} - draggable={false} - {...dragHandleProps} - > - {suppressTooltips ? ( - dragIcon - ) : ( - <Tooltip content={dragLabel} placement="left"> - {dragIcon} - </Tooltip> - )} - </button> - <Checkbox - label={ - <span className={styles.columnRowLabel}>{header.name}</span> - } - checked={isVisible} - onChange={(checked) => onToggleVisible(header.dataKey, checked)} - className={styles.columnRowCheckbox} - dataTest={`data-table-column-picker-visible-${header.dataKey}${dataTestSuffix}`} - /> - <button - type="button" - className={cx(styles.rowIconButton, styles.pinButton, { - [styles.pinButtonActive]: isPinned, - })} - aria-label={pinLabel} - data-test={`data-table-column-picker-pin-${header.dataKey}${dataTestSuffix}`} - onClick={() => onTogglePinned(header.dataKey)} - > - {suppressTooltips ? ( - pinIcon - ) : ( - <Tooltip content={pinLabel} placement="right"> - {pinIcon} - </Tooltip> - )} - </button> - </> - ) -} - -ColumnRowFields.propTypes = { - header: PropTypes.shape({ - dataKey: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - }).isRequired, - isPinned: PropTypes.bool.isRequired, - isVisible: PropTypes.bool.isRequired, - dataTestSuffix: PropTypes.string, - dragHandleProps: PropTypes.object, - suppressTooltips: PropTypes.bool, - onTogglePinned: PropTypes.func, - onToggleVisible: PropTypes.func, -} - -const ColumnRow = ({ - header, - isVisible, - isPinned, - isPinnedGroupEnd, - isDragActive, - onToggleVisible, - onTogglePinned, -}) => { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: header.dataKey }) - - const style = { - transform: CSS.Transform.toString(transform), - transition, - zIndex: isDragging ? 1 : undefined, - opacity: isDragging ? 0 : 1, - } - - return ( - <div - ref={setNodeRef} - style={style} - className={cx(styles.columnRow, { - [styles.columnRowDivider]: isPinnedGroupEnd, - })} - > - <ColumnRowFields - header={header} - isVisible={isVisible} - isPinned={isPinned} - dragHandleProps={{ ...attributes, ...listeners }} - suppressTooltips={isDragActive} - onToggleVisible={onToggleVisible} - onTogglePinned={onTogglePinned} - /> - </div> - ) -} - -ColumnRow.propTypes = { - header: PropTypes.shape({ - dataKey: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - }).isRequired, - isDragActive: PropTypes.bool.isRequired, - isPinned: PropTypes.bool.isRequired, - isPinnedGroupEnd: PropTypes.bool.isRequired, - isVisible: PropTypes.bool.isRequired, - onTogglePinned: PropTypes.func.isRequired, - onToggleVisible: PropTypes.func.isRequired, -} - const ColumnPickerControl = ({ layerId, allHeaders, columnConfig }) => { const dispatch = useDispatch() const anchorRef = useRef(null) const [isOpen, setIsOpen] = useState(false) const [activeId, setActiveId] = useState(null) const [search, setSearch] = useState('') - // Whether the (unfiltered) column list actually overflows its own - // max-height - the search box only earns its keep when there's enough - // columns to make scrolling through them worth searching instead. const [hasScroll, setHasScroll] = useState(false) - // The popover's own natural, content-driven width - measured once per - // open (from the full, unfiltered list) and then held fixed, so - // narrowing the list via search never shrinks/grows the popover itself. const [popoverWidth, setPopoverWidth] = useState(null) useLayoutEffect(() => { @@ -219,12 +53,6 @@ const ColumnPickerControl = ({ layerId, allHeaders, columnConfig }) => { } }, [isOpen]) - // FilterDropdownPopover's content (including these) mounts a render - // after its Popper placement resolves, not synchronously with `isOpen` - // becoming true - a plain ref read in a `[isOpen]`-keyed effect would - // run too early and see `null`. Callback refs instead fire exactly - // when React actually attaches the node, whenever that is, and only - // once per open (the div isn't recreated by search/typing re-renders). const columnListRef = useCallback((el) => { if (el) { setHasScroll(el.scrollHeight > el.clientHeight) @@ -237,8 +65,6 @@ const ColumnPickerControl = ({ layerId, allHeaders, columnConfig }) => { } }, []) - // useTableData can legitimately return a null headers list (e.g. while - // loading or on error) - guard here rather than trust callers to. const headers = allHeaders ?? [] const visibleKeys = @@ -247,28 +73,12 @@ const ColumnPickerControl = ({ layerId, allHeaders, columnConfig }) => { const orderedKeys = columnConfig?.orderedKeys ?? headers.map((h) => h.dataKey) - // Same reorder-then-pin-to-front logic the table itself renders with, - // so the picker's row order always matches the table's actual column - // order. visibleKeys is deliberately not passed here - every column - // gets a row in the picker (hidden ones just show an unchecked box). - // Dragging a column across the pinned/unpinned boundary still snaps it - // back to whichever side its own pinned state puts it on next render - - // pin state is the button's job, not drag's. const orderedHeaders = getVisibleHeaders(headers, { orderedKeys, pinnedKeys, }) - // Mirrors DataTable.jsx's pinnedColumnCount: getVisibleHeaders already - // moves pinned columns to the front, so the pinned group's size is just - // how many headers match pinnedKeys before the first one that doesn't. - let pinnedCount = 0 - for (const header of orderedHeaders) { - if (!pinnedKeys.includes(header.dataKey)) { - break - } - pinnedCount++ - } + const pinnedCount = getPinnedCount(orderedHeaders, pinnedKeys) const updateConfig = (partial) => dispatch( @@ -280,19 +90,13 @@ const ColumnPickerControl = ({ layerId, allHeaders, columnConfig }) => { }) ) - const onToggleVisible = (dataKey, checked) => { - const next = checked - ? [...visibleKeys, dataKey] - : visibleKeys.filter((k) => k !== dataKey) - updateConfig({ visibleKeys: next }) - } + const onToggleVisible = (dataKey, checked) => + updateConfig({ + visibleKeys: toggleVisibleKey(visibleKeys, dataKey, checked), + }) - const onTogglePinned = (dataKey) => { - const next = pinnedKeys.includes(dataKey) - ? pinnedKeys.filter((k) => k !== dataKey) - : [...pinnedKeys, dataKey] - updateConfig({ pinnedKeys: next }) - } + const onTogglePinned = (dataKey) => + updateConfig({ pinnedKeys: togglePinnedKey(pinnedKeys, dataKey) }) const isAllVisible = visibleKeys.length === headers.length const onToggleSelectAll = () => @@ -302,9 +106,7 @@ const ColumnPickerControl = ({ layerId, allHeaders, columnConfig }) => { const onReverseSelection = () => updateConfig({ - visibleKeys: headers - .filter((h) => !visibleKeys.includes(h.dataKey)) - .map((h) => h.dataKey), + visibleKeys: reverseVisibleKeys(headers, visibleKeys), }) const onResetToDefaults = () => @@ -316,7 +118,6 @@ const ColumnPickerControl = ({ layerId, allHeaders, columnConfig }) => { const sensors = useSensors( useSensor(MouseSensor, { - // Require a small movement so a click on the handle isn't a drag activationConstraint: { distance: 5 }, }), useSensor(TouchSensor, { @@ -366,11 +167,6 @@ const ColumnPickerControl = ({ layerId, allHeaders, columnConfig }) => { {isOpen && ( <FilterDropdownPopover reference={anchorRef} - // Always opens upward: this control lives in the - // bottom panel's own toolbar strip, at the very top - // of the table area - there's rarely reliable room - // below it, unlike the per-column filter popovers - // that reuse this same component from the header row. placement="top-start" onClickOutside={() => setIsOpen(false)} > @@ -450,15 +246,11 @@ const ColumnPickerControl = ({ layerId, allHeaders, columnConfig }) => { isPinned={pinnedKeys.includes( header.dataKey )} - isPinnedGroupEnd={ - pinnedCount > 0 && - pinnedCount < - orderedHeaders.length && - header.dataKey === - orderedHeaders[ - pinnedCount - 1 - ].dataKey - } + isPinnedGroupEnd={isPinnedGroupEnd( + header, + pinnedCount, + orderedHeaders + )} isDragActive={activeId != null} onToggleVisible={onToggleVisible} onTogglePinned={onTogglePinned} @@ -467,17 +259,6 @@ const ColumnPickerControl = ({ layerId, allHeaders, columnConfig }) => { </div> </SortableContext> {createPortal( - // DragOverlay renders inline wherever it's - // placed and relies on `position: fixed` to - // escape into the viewport - but it's nested - // inside FilterDropdownPopover's Popper, - // which positions itself via a CSS - // `transform`. A `transform` on an ancestor - // creates a new containing block for - // `position: fixed` descendants, so without - // this portal the overlay ends up positioned - // relative to the popover instead of the - // viewport (rendering off-screen or hidden). <DragOverlay modifiers={[restrictToVerticalAxis]} zIndex={DRAG_OVERLAY_Z_INDEX} diff --git a/src/components/datatable/controls/ColumnRow.jsx b/src/components/datatable/controls/ColumnRow.jsx new file mode 100644 index 0000000000..28628fc7b9 --- /dev/null +++ b/src/components/datatable/controls/ColumnRow.jsx @@ -0,0 +1,157 @@ +import i18n from '@dhis2/d2-i18n' +import { + IconDragHandle16, + IconLock16, + IconLockOpen16, + Tooltip, +} from '@dhis2/ui' +import { useSortable } from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import cx from 'classnames' +import PropTypes from 'prop-types' +import React from 'react' +import Checkbox from '../../core/Checkbox.jsx' +import styles from './styles/ColumnPickerControl.module.css' + +const noop = () => {} + +export const ColumnRowFields = ({ + header, + isVisible, + isPinned, + dragHandleProps, + dataTestSuffix = '', + suppressTooltips = false, + onToggleVisible = noop, + onTogglePinned = noop, +}) => { + const dragLabel = i18n.t('Drag to reorder') + const pinLabel = isPinned + ? i18n.t('Unpin column') + : i18n.t('Pin column to the left') + + const dragIcon = <IconDragHandle16 /> + const pinIcon = isPinned ? <IconLock16 /> : <IconLockOpen16 /> + + return ( + <> + <button + type="button" + className={cx(styles.rowIconButton, styles.dragHandle)} + aria-label={dragLabel} + data-test={`data-table-column-picker-drag-${header.dataKey}${dataTestSuffix}`} + draggable={false} + {...dragHandleProps} + > + {suppressTooltips ? ( + dragIcon + ) : ( + <Tooltip content={dragLabel} placement="left"> + {dragIcon} + </Tooltip> + )} + </button> + <Checkbox + label={ + <span className={styles.columnRowLabel}>{header.name}</span> + } + checked={isVisible} + onChange={(checked) => onToggleVisible(header.dataKey, checked)} + className={styles.columnRowCheckbox} + dataTest={`data-table-column-picker-visible-${header.dataKey}${dataTestSuffix}`} + /> + <button + type="button" + className={cx(styles.rowIconButton, styles.pinButton, { + [styles.pinButtonActive]: isPinned, + })} + aria-label={pinLabel} + data-test={`data-table-column-picker-pin-${header.dataKey}${dataTestSuffix}`} + onClick={() => onTogglePinned(header.dataKey)} + > + {suppressTooltips ? ( + pinIcon + ) : ( + <Tooltip content={pinLabel} placement="right"> + {pinIcon} + </Tooltip> + )} + </button> + </> + ) +} + +ColumnRowFields.propTypes = { + header: PropTypes.shape({ + dataKey: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + }).isRequired, + isPinned: PropTypes.bool.isRequired, + isVisible: PropTypes.bool.isRequired, + dataTestSuffix: PropTypes.string, + dragHandleProps: PropTypes.object, + suppressTooltips: PropTypes.bool, + onTogglePinned: PropTypes.func, + onToggleVisible: PropTypes.func, +} + +const ColumnRow = ({ + header, + isVisible, + isPinned, + isPinnedGroupEnd, + isDragActive, + onToggleVisible, + onTogglePinned, +}) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: header.dataKey }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + zIndex: isDragging ? 1 : undefined, + opacity: isDragging ? 0 : 1, + } + + return ( + <div + ref={setNodeRef} + style={style} + className={cx(styles.columnRow, { + [styles.columnRowDivider]: isPinnedGroupEnd, + })} + > + <ColumnRowFields + header={header} + isVisible={isVisible} + isPinned={isPinned} + dragHandleProps={{ ...attributes, ...listeners }} + suppressTooltips={isDragActive} + onToggleVisible={onToggleVisible} + onTogglePinned={onTogglePinned} + /> + </div> + ) +} + +ColumnRow.propTypes = { + header: PropTypes.shape({ + dataKey: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + }).isRequired, + isDragActive: PropTypes.bool.isRequired, + isPinned: PropTypes.bool.isRequired, + isPinnedGroupEnd: PropTypes.bool.isRequired, + isVisible: PropTypes.bool.isRequired, + onTogglePinned: PropTypes.func.isRequired, + onToggleVisible: PropTypes.func.isRequired, +} + +export default ColumnRow diff --git a/src/util/__tests__/tableColumns.spec.js b/src/util/__tests__/tableColumns.spec.js index d0bc193f2e..3007f10bed 100644 --- a/src/util/__tests__/tableColumns.spec.js +++ b/src/util/__tests__/tableColumns.spec.js @@ -1,4 +1,12 @@ -import { getPinnedLeftOffsets, getVisibleHeaders } from '../tableColumns.js' +import { + getPinnedCount, + getPinnedLeftOffsets, + getVisibleHeaders, + isPinnedGroupEnd, + reverseVisibleKeys, + togglePinnedKey, + toggleVisibleKey, +} from '../tableColumns.js' const headers = [ { name: 'Name', dataKey: 'name' }, @@ -175,3 +183,69 @@ describe('getPinnedLeftOffsets', () => { expect(offsets).toEqual({ rawValue: 76, id: 176 }) }) }) + +describe('getPinnedCount', () => { + it('returns 0 when no headers are pinned', () => { + expect(getPinnedCount(headers, [])).toBe(0) + }) + + it('counts the leading headers that are pinned', () => { + expect(getPinnedCount(headers, ['name', 'id'])).toBe(2) + }) + + it('returns the full length when every header is pinned', () => { + const allKeys = headers.map((h) => h.dataKey) + expect(getPinnedCount(headers, allKeys)).toBe(headers.length) + }) +}) + +describe('isPinnedGroupEnd', () => { + it('is false when nothing is pinned', () => { + expect(isPinnedGroupEnd(headers[0], 0, headers)).toBe(false) + }) + + it('is false when every header is pinned (no unpinned group to separate from)', () => { + expect(isPinnedGroupEnd(headers[3], headers.length, headers)).toBe( + false + ) + }) + + it('is true only for the last pinned header when there is a mix', () => { + const pinnedCount = 2 + expect(isPinnedGroupEnd(headers[0], pinnedCount, headers)).toBe(false) + expect(isPinnedGroupEnd(headers[1], pinnedCount, headers)).toBe(true) + expect(isPinnedGroupEnd(headers[2], pinnedCount, headers)).toBe(false) + }) +}) + +describe('toggleVisibleKey', () => { + it('adds a key when checking it', () => { + expect(toggleVisibleKey(['name'], 'id', true)).toEqual(['name', 'id']) + }) + + it('removes a key when unchecking it', () => { + expect(toggleVisibleKey(['name', 'id'], 'name', false)).toEqual(['id']) + }) +}) + +describe('togglePinnedKey', () => { + it('adds a key when it is not yet pinned', () => { + expect(togglePinnedKey(['name'], 'id')).toEqual(['name', 'id']) + }) + + it('removes a key when it is already pinned', () => { + expect(togglePinnedKey(['name', 'id'], 'name')).toEqual(['id']) + }) +}) + +describe('reverseVisibleKeys', () => { + it('returns the dataKeys not currently in visibleKeys', () => { + const result = reverseVisibleKeys(headers, ['name', 'legend']) + expect(result).toEqual(['id', 'rawValue']) + }) + + it('returns every dataKey when nothing is currently visible', () => { + const result = reverseVisibleKeys(headers, []) + expect(result).toEqual(['name', 'id', 'rawValue', 'legend']) + }) +}) diff --git a/src/util/tableColumns.js b/src/util/tableColumns.js index e555e6c1dd..1904059391 100644 --- a/src/util/tableColumns.js +++ b/src/util/tableColumns.js @@ -34,6 +34,37 @@ export const getVisibleHeaders = (headers, columnConfig) => { return result } +export const getPinnedCount = (orderedHeaders, pinnedKeys) => { + let count = 0 + for (const header of orderedHeaders) { + if (!pinnedKeys.includes(header.dataKey)) { + break + } + count++ + } + return count +} + +export const isPinnedGroupEnd = (header, pinnedCount, orderedHeaders) => + pinnedCount > 0 && + pinnedCount < orderedHeaders.length && + header.dataKey === orderedHeaders[pinnedCount - 1].dataKey + +export const toggleVisibleKey = (visibleKeys, dataKey, checked) => + checked + ? [...visibleKeys, dataKey] + : visibleKeys.filter((k) => k !== dataKey) + +export const togglePinnedKey = (pinnedKeys, dataKey) => + pinnedKeys.includes(dataKey) + ? pinnedKeys.filter((k) => k !== dataKey) + : [...pinnedKeys, dataKey] + +export const reverseVisibleKeys = (headers, visibleKeys) => + headers + .filter((h) => !visibleKeys.includes(h.dataKey)) + .map((h) => h.dataKey) + export const getPinnedLeftOffsets = ( visibleHeaders, pinnedKeys, From f85a475b9fe102bd0b04cb502e23c16c42e0a291 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Fri, 17 Jul 2026 21:21:58 +0200 Subject: [PATCH 069/205] chore: pr cleanup --- i18n/en.pot | 68 ++-- src/components/datatable/DataTable.jsx | 327 ++---------------- .../datatable/SelectionFilterButton.jsx | 78 +++++ .../datatable/TableVirtuosoComponents.jsx | 97 ++++++ src/components/datatable/TopTooltip.jsx | 63 ++++ .../datatable/__tests__/DataTable.spec.jsx | 121 ------- .../datatable/styles/DataTable.module.css | 77 ----- .../styles/SelectionFilterButton.module.css | 32 ++ .../styles/TableVirtuosoComponents.module.css | 34 ++ .../datatable/styles/TopTooltip.module.css | 12 + src/util/__tests__/dataTable.spec.js | 131 +++++++ src/util/__tests__/tableColumns.spec.js | 52 +++ src/util/dataTable.js | 44 +++ src/util/tableColumns.js | 19 + 14 files changed, 622 insertions(+), 533 deletions(-) create mode 100644 src/components/datatable/SelectionFilterButton.jsx create mode 100644 src/components/datatable/TableVirtuosoComponents.jsx create mode 100644 src/components/datatable/TopTooltip.jsx create mode 100644 src/components/datatable/styles/SelectionFilterButton.module.css create mode 100644 src/components/datatable/styles/TableVirtuosoComponents.module.css create mode 100644 src/components/datatable/styles/TopTooltip.module.css create mode 100644 src/util/__tests__/dataTable.spec.js create mode 100644 src/util/dataTable.js diff --git a/i18n/en.pot b/i18n/en.pot index c86c680085..fc5570b37f 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-17T13:55:55.639Z\n" -"PO-Revision-Date: 2026-07-17T13:55:55.639Z\n" +"POT-Creation-Date: 2026-07-17T19:19:08.730Z\n" +"PO-Revision-Date: 2026-07-17T19:19:08.731Z\n" msgid "2020" msgstr "2020" @@ -155,29 +155,6 @@ msgstr "Operator" msgid "Date" msgstr "Date" -msgid "Selected" -msgstr "Selected" - -msgid "Not selected" -msgstr "Not selected" - -msgid "All" -msgstr "All" - -msgid "{{count}} selected" -msgid_plural "{{count}} selected" -msgstr[0] "{{count}} selected" -msgstr[1] "{{count}} selected" - -msgid "No features match your filters" -msgstr "No features match your filters" - -msgid "Clear filters" -msgstr "Clear filters" - -msgid "No results found" -msgstr "No results found" - msgid "Select all visible rows" msgstr "Select all visible rows" @@ -241,6 +218,20 @@ msgstr "No matches" msgid "No value" msgstr "No value" +msgid "Selected" +msgstr "Selected" + +msgid "Not selected" +msgstr "Not selected" + +msgid "All" +msgstr "All" + +msgid "{{count}} selected" +msgid_plural "{{count}} selected" +msgstr[0] "{{count}} selected" +msgstr[1] "{{count}} selected" + msgid "Drill up one level" msgstr "Drill up one level" @@ -262,6 +253,15 @@ msgstr "Zoom to selected features" msgid "Zoom to filtered features" msgstr "Zoom to filtered features" +msgid "No features match your filters" +msgstr "No features match your filters" + +msgid "Clear filters" +msgstr "Clear filters" + +msgid "No results found" +msgstr "No results found" + msgid "Close" msgstr "Close" @@ -271,15 +271,6 @@ msgstr "Restore" msgid "Collapse" msgstr "Collapse" -msgid "Drag to reorder" -msgstr "Drag to reorder" - -msgid "Unpin column" -msgstr "Unpin column" - -msgid "Pin column to the left" -msgstr "Pin column to the left" - msgid "Configure columns" msgstr "Configure columns" @@ -289,6 +280,15 @@ msgstr "Select all columns" msgid "Reset to defaults" msgstr "Reset to defaults" +msgid "Drag to reorder" +msgstr "Drag to reorder" + +msgid "Unpin column" +msgstr "Unpin column" + +msgid "Pin column to the left" +msgstr "Pin column to the left" + msgid "Search across all visible columns" msgstr "Search across all visible columns" diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index 8b13fd20bd..a4acd7a7c3 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -1,16 +1,11 @@ import i18n from '@dhis2/d2-i18n' import { - DataTable, DataTableRow, DataTableCell, DataTableColumnHeader, - DataTableHead, - DataTableBody, ComponentCover, CenteredContent, CircularLoader, - Popper, - Portal, IconSync16, } from '@dhis2/ui' import cx from 'classnames' @@ -34,280 +29,34 @@ import { import { SENTINEL_SELECTED_ROW, SORT_ASCENDING, - SORT_DESCENDING, } from '../../constants/dataTable.js' -import { - SELECTION_FILTER_SELECTED, - SELECTION_FILTER_NOT_SELECTED, -} from '../../constants/selection.js' import { isDarkColor } from '../../util/colors.js' +import { + getNextSorting, + getRowClickAction, + getRowId, + isFilterable, + shouldClearFeatureHighlight, +} from '../../util/dataTable.js' import { formatWithSeparator } from '../../util/numbers.js' import { + getPinnedCellProps, + getPinnedCount, getPinnedLeftOffsets, getVisibleHeaders, } from '../../util/tableColumns.js' import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' -import Checkbox from '../core/Checkbox.jsx' import { SortIcon } from '../core/icons.jsx' -import { - FilterDropdownPopover, - getDropdownPlacement, -} from './FilterDropdownPopover.jsx' import FilterInput from './FilterInput.jsx' +import SelectionFilterButton from './SelectionFilterButton.jsx' import styles from './styles/DataTable.module.css' import TableContextMenu from './TableContextMenu.jsx' +import TableComponents from './TableVirtuosoComponents.jsx' +import TopTooltip from './TopTooltip.jsx' import { useColumnWidths } from './useColumnWidths.js' import { useRowSelection } from './useRowSelection.js' import { useTableData } from './useTableData.js' -const SELECTION_FILTER_OPTIONS = [ - { value: SELECTION_FILTER_SELECTED, label: i18n.t('Selected') }, - { value: SELECTION_FILTER_NOT_SELECTED, label: i18n.t('Not selected') }, -] - -export const isFilterable = (dataKey, type) => !!type - -const SelectionFilterButton = ({ value, onChange }) => { - const anchorRef = useRef(null) - const [isOpen, setIsOpen] = useState(false) - - const toggleValue = (optionValue) => { - const next = value.includes(optionValue) - ? value.filter((v) => v !== optionValue) - : [...value, optionValue] - onChange(next) - } - - const buttonLabel = - value.length === 0 - ? i18n.t('All') - : i18n.t('{{count}} selected', { count: value.length }) - - const anchorRect = anchorRef.current?.getBoundingClientRect() - const { dropdownPlacement } = getDropdownPlacement(anchorRect) - - return ( - <> - <button - type="button" - ref={anchorRef} - className={styles.selectionFilterButton} - data-test="data-table-selection-filter-button" - onClick={() => setIsOpen((o) => !o)} - > - {buttonLabel} - </button> - {isOpen && ( - <FilterDropdownPopover - reference={anchorRef} - placement={dropdownPlacement} - onClickOutside={() => setIsOpen(false)} - > - <div className={styles.selectionFilterPopover}> - {SELECTION_FILTER_OPTIONS.map((option) => ( - <Checkbox - key={option.value} - label={option.label} - checked={value.includes(option.value)} - onChange={() => toggleValue(option.value)} - style={{ margin: '4px 0' }} - /> - ))} - </div> - </FilterDropdownPopover> - )} - </> - ) -} - -SelectionFilterButton.propTypes = { - value: PropTypes.arrayOf(PropTypes.string).isRequired, - onChange: PropTypes.func.isRequired, -} - -const topTooltipModifiers = [{ name: 'offset', options: { offset: [0, 4] } }] - -const TopTooltip = ({ content, children }) => { - const [open, setOpen] = useState(false) - const referenceRef = useRef(null) - const openTimerRef = useRef(null) - const closeTimerRef = useRef(null) - - const onOpen = () => { - clearTimeout(closeTimerRef.current) - openTimerRef.current = setTimeout(() => setOpen(true), 200) - } - - const onClose = () => { - clearTimeout(openTimerRef.current) - closeTimerRef.current = setTimeout(() => setOpen(false), 200) - } - - useEffect( - () => () => { - clearTimeout(openTimerRef.current) - clearTimeout(closeTimerRef.current) - }, - [] - ) - - return ( - <span - ref={referenceRef} - onMouseOver={onOpen} - onMouseOut={onClose} - onFocus={onOpen} - onBlur={onClose} - > - {children} - {open && ( - <Portal> - <Popper - placement="top" - reference={referenceRef} - modifiers={topTooltipModifiers} - > - <div className={styles.topTooltipContent}> - {content} - </div> - </Popper> - </Portal> - )} - </span> - ) -} - -TopTooltip.propTypes = { - children: PropTypes.node.isRequired, - content: PropTypes.node.isRequired, -} - -export const shouldClearFeatureHighlight = (event) => - event.relatedTarget?.tagName !== 'TD' - -export const getNextSorting = (name, { sortField, sortDirection }) => { - if (name !== sortField) { - return { sortField: name, sortDirection: SORT_ASCENDING } - } - if (sortDirection === SORT_ASCENDING) { - return { sortField: name, sortDirection: SORT_DESCENDING } - } - return { sortField: null, sortDirection: SORT_ASCENDING } -} - -const getRowId = (row) => - row.find((r) => r.dataKey === 'id')?.value || row[0]?.itemId - -export const getRowClickAction = ( - event, - { id, rowIndex, rows, lastClickedRowIndex } -) => { - if (event.shiftKey) { - if (lastClickedRowIndex === null) { - return { type: 'toggle', id } - } - const [start, end] = [lastClickedRowIndex, rowIndex].sort( - (a, b) => a - b - ) - const ids = rows - .slice(start, end + 1) - .map(getRowId) - .filter(Boolean) - return { type: 'range', ids } - } - - if (event.ctrlKey || event.metaKey) { - return { type: 'toggle', id } - } - - return null -} - -const DataTableWithVirtuosoContext = ({ context, ...props }) => ( - <DataTable - {...props} - layout={context.layout} - className={styles.dataTable} - /> -) - -DataTableWithVirtuosoContext.propTypes = { - context: PropTypes.shape({ - layout: PropTypes.string, - }), -} - -const DataTableRowWithVirtuosoContext = ({ context, item, ...props }) => ( - <DataTableRow - onMouseEnter={() => context.onMouseEnter(item)} - onMouseLeave={context.onMouseLeave} - onContextMenu={(e) => context.onContextMenu(e, item)} - onClick={(e) => context.onRowClick(item, e)} - onDoubleClick={() => context.onRowDoubleClick(item)} - {...props} - /> -) - -DataTableRowWithVirtuosoContext.propTypes = { - context: PropTypes.shape({ - onContextMenu: PropTypes.func, - onMouseEnter: PropTypes.func, - onMouseLeave: PropTypes.func, - onRowClick: PropTypes.func, - onRowDoubleClick: PropTypes.func, - }), - item: PropTypes.arrayOf( - PropTypes.shape({ - dataKey: PropTypes.string, - itemId: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), - value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), - }) - ), -} - -const EmptyPlaceholder = ({ context }) => ( - <tbody> - <tr> - <td colSpan={99999}> - <div className={styles.noResults}> - {context.totalCount > 0 ? ( - <> - {i18n.t('No features match your filters')} - {context.hasActiveFilters && ( - <button - type="button" - className={styles.clearFiltersLink} - onClick={context.onClearFilters} - > - {i18n.t('Clear filters')} - </button> - )} - </> - ) : ( - i18n.t('No results found') - )} - </div> - </td> - </tr> - </tbody> -) - -EmptyPlaceholder.propTypes = { - context: PropTypes.shape({ - hasActiveFilters: PropTypes.bool, - totalCount: PropTypes.number, - onClearFilters: PropTypes.func, - }), -} - -const TableComponents = { - Table: DataTableWithVirtuosoContext, - TableBody: DataTableBody, - TableHead: DataTableHead, - TableRow: DataTableRowWithVirtuosoContext, - EmptyPlaceholder, -} - const Table = ({ availableWidth, onCountChange, @@ -451,23 +200,10 @@ const Table = ({ error, }) - // Only the leading columns of visibleHeaders can ever be pinned - - // getVisibleHeaders already moves pinned columns to the front - so the - // pinned section's size is just how many headers match pinnedKeys - // before the first one that doesn't. - const pinnedColumnCount = useMemo(() => { - if (!pinnedKeys.length || !visibleHeaders) { - return 0 - } - let count = 0 - for (const header of visibleHeaders) { - if (!pinnedKeys.includes(header.dataKey)) { - break - } - count++ - } - return count - }, [visibleHeaders, pinnedKeys]) + const pinnedColumnCount = useMemo( + () => getPinnedCount(visibleHeaders, pinnedKeys), + [visibleHeaders, pinnedKeys] + ) const pinnedLeftOffsets = useMemo( () => getPinnedLeftOffsets(visibleHeaders, pinnedKeys, columnWidths), @@ -475,27 +211,8 @@ const Table = ({ ) const pinnedOffsetsReady = Object.keys(pinnedLeftOffsets).length > 0 - // The checkbox column only becomes sticky when something else is - // actually pinned (and its offset is ready) - otherwise it stays a - // plain (non-`fixed`) cell, since @dhis2/ui renders `fixed` cells as - // `<th>` rather than `<td>`, which would needlessly change the DOM - // shape for the common, nothing-pinned case, and briefly during column - // widths being (re)measured after a config change. const isCheckboxColumnPinned = pinnedColumnCount > 0 && pinnedOffsetsReady - // @dhis2/ui requires `width` whenever `fixed` is passed - unpinned - // cells keep their existing (unset) width behavior. - const getPinnedCellProps = (dataKey, index) => { - const leftOffset = pinnedLeftOffsets[dataKey] - const isPinned = index < pinnedColumnCount && leftOffset !== undefined - return { - fixed: isPinned, - left: isPinned ? `${leftOffset}px` : undefined, - width: isPinned ? `${columnWidths[index] ?? 0}px` : undefined, - isLastPinned: index === pinnedColumnCount - 1, - } - } - useEffect(() => { onCountChange?.(totalCount, filteredCount) }, [onCountChange, totalCount, filteredCount]) @@ -711,7 +428,11 @@ const Table = ({ {visibleHeaders.map( ({ name, dataKey, type, optionSet }, index) => { const { fixed, left, isLastPinned } = - getPinnedCellProps(dataKey, index) + getPinnedCellProps(dataKey, index, { + pinnedLeftOffsets, + pinnedColumnCount, + columnWidths, + }) return ( <DataTableColumnHeader className={cx(styles.columnHeader, { @@ -833,7 +554,11 @@ const Table = ({ } const { value, align } = cell const { fixed, left, width, isLastPinned } = - getPinnedCellProps(dataKey, index) + getPinnedCellProps(dataKey, index, { + pinnedLeftOffsets, + pinnedColumnCount, + columnWidths, + }) return ( <DataTableCell key={`dtcell-${dataKey}`} diff --git a/src/components/datatable/SelectionFilterButton.jsx b/src/components/datatable/SelectionFilterButton.jsx new file mode 100644 index 0000000000..8c1bd2dd46 --- /dev/null +++ b/src/components/datatable/SelectionFilterButton.jsx @@ -0,0 +1,78 @@ +import i18n from '@dhis2/d2-i18n' +import PropTypes from 'prop-types' +import React, { useRef, useState } from 'react' +import { + SELECTION_FILTER_SELECTED, + SELECTION_FILTER_NOT_SELECTED, +} from '../../constants/selection.js' +import Checkbox from '../core/Checkbox.jsx' +import { + FilterDropdownPopover, + getDropdownPlacement, +} from './FilterDropdownPopover.jsx' +import styles from './styles/SelectionFilterButton.module.css' + +const SELECTION_FILTER_OPTIONS = [ + { value: SELECTION_FILTER_SELECTED, label: i18n.t('Selected') }, + { value: SELECTION_FILTER_NOT_SELECTED, label: i18n.t('Not selected') }, +] + +const SelectionFilterButton = ({ value, onChange }) => { + const anchorRef = useRef(null) + const [isOpen, setIsOpen] = useState(false) + + const toggleValue = (optionValue) => { + const next = value.includes(optionValue) + ? value.filter((v) => v !== optionValue) + : [...value, optionValue] + onChange(next) + } + + const buttonLabel = + value.length === 0 + ? i18n.t('All') + : i18n.t('{{count}} selected', { count: value.length }) + + const anchorRect = anchorRef.current?.getBoundingClientRect() + const { dropdownPlacement } = getDropdownPlacement(anchorRect) + + return ( + <> + <button + type="button" + ref={anchorRef} + className={styles.selectionFilterButton} + data-test="data-table-selection-filter-button" + onClick={() => setIsOpen((o) => !o)} + > + {buttonLabel} + </button> + {isOpen && ( + <FilterDropdownPopover + reference={anchorRef} + placement={dropdownPlacement} + onClickOutside={() => setIsOpen(false)} + > + <div className={styles.selectionFilterPopover}> + {SELECTION_FILTER_OPTIONS.map((option) => ( + <Checkbox + key={option.value} + label={option.label} + checked={value.includes(option.value)} + onChange={() => toggleValue(option.value)} + className={styles.denseCheckbox} + /> + ))} + </div> + </FilterDropdownPopover> + )} + </> + ) +} + +SelectionFilterButton.propTypes = { + value: PropTypes.arrayOf(PropTypes.string).isRequired, + onChange: PropTypes.func.isRequired, +} + +export default SelectionFilterButton diff --git a/src/components/datatable/TableVirtuosoComponents.jsx b/src/components/datatable/TableVirtuosoComponents.jsx new file mode 100644 index 0000000000..46dd3b5964 --- /dev/null +++ b/src/components/datatable/TableVirtuosoComponents.jsx @@ -0,0 +1,97 @@ +import i18n from '@dhis2/d2-i18n' +import { + DataTable, + DataTableRow, + DataTableBody, + DataTableHead, +} from '@dhis2/ui' +import PropTypes from 'prop-types' +import React from 'react' +import styles from './styles/TableVirtuosoComponents.module.css' + +const DataTableWithVirtuosoContext = ({ context, ...props }) => ( + <DataTable + {...props} + layout={context.layout} + className={styles.dataTable} + /> +) + +DataTableWithVirtuosoContext.propTypes = { + context: PropTypes.shape({ + layout: PropTypes.string, + }), +} + +const DataTableRowWithVirtuosoContext = ({ context, item, ...props }) => ( + <DataTableRow + onMouseEnter={() => context.onMouseEnter(item)} + onMouseLeave={context.onMouseLeave} + onContextMenu={(e) => context.onContextMenu(e, item)} + onClick={(e) => context.onRowClick(item, e)} + onDoubleClick={() => context.onRowDoubleClick(item)} + {...props} + /> +) + +DataTableRowWithVirtuosoContext.propTypes = { + context: PropTypes.shape({ + onContextMenu: PropTypes.func, + onMouseEnter: PropTypes.func, + onMouseLeave: PropTypes.func, + onRowClick: PropTypes.func, + onRowDoubleClick: PropTypes.func, + }), + item: PropTypes.arrayOf( + PropTypes.shape({ + dataKey: PropTypes.string, + itemId: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + }) + ), +} + +const EmptyPlaceholder = ({ context }) => ( + <tbody> + <tr> + <td colSpan={99999}> + <div className={styles.noResults}> + {context.totalCount > 0 ? ( + <> + {i18n.t('No features match your filters')} + {context.hasActiveFilters && ( + <button + type="button" + className={styles.clearFiltersLink} + onClick={context.onClearFilters} + > + {i18n.t('Clear filters')} + </button> + )} + </> + ) : ( + i18n.t('No results found') + )} + </div> + </td> + </tr> + </tbody> +) + +EmptyPlaceholder.propTypes = { + context: PropTypes.shape({ + hasActiveFilters: PropTypes.bool, + totalCount: PropTypes.number, + onClearFilters: PropTypes.func, + }), +} + +const TableComponents = { + Table: DataTableWithVirtuosoContext, + TableBody: DataTableBody, + TableHead: DataTableHead, + TableRow: DataTableRowWithVirtuosoContext, + EmptyPlaceholder, +} + +export default TableComponents diff --git a/src/components/datatable/TopTooltip.jsx b/src/components/datatable/TopTooltip.jsx new file mode 100644 index 0000000000..3e7ed0f249 --- /dev/null +++ b/src/components/datatable/TopTooltip.jsx @@ -0,0 +1,63 @@ +import { Popper, Portal } from '@dhis2/ui' +import PropTypes from 'prop-types' +import React, { useEffect, useRef, useState } from 'react' +import styles from './styles/TopTooltip.module.css' + +const topTooltipModifiers = [{ name: 'offset', options: { offset: [0, 4] } }] + +const TopTooltip = ({ content, children }) => { + const [open, setOpen] = useState(false) + const referenceRef = useRef(null) + const openTimerRef = useRef(null) + const closeTimerRef = useRef(null) + + const onOpen = () => { + clearTimeout(closeTimerRef.current) + openTimerRef.current = setTimeout(() => setOpen(true), 200) + } + + const onClose = () => { + clearTimeout(openTimerRef.current) + closeTimerRef.current = setTimeout(() => setOpen(false), 200) + } + + useEffect( + () => () => { + clearTimeout(openTimerRef.current) + clearTimeout(closeTimerRef.current) + }, + [] + ) + + return ( + <span + ref={referenceRef} + onMouseOver={onOpen} + onMouseOut={onClose} + onFocus={onOpen} + onBlur={onClose} + > + {children} + {open && ( + <Portal> + <Popper + placement="top" + reference={referenceRef} + modifiers={topTooltipModifiers} + > + <div className={styles.topTooltipContent}> + {content} + </div> + </Popper> + </Portal> + )} + </span> + ) +} + +TopTooltip.propTypes = { + children: PropTypes.node.isRequired, + content: PropTypes.node.isRequired, +} + +export default TopTooltip diff --git a/src/components/datatable/__tests__/DataTable.spec.jsx b/src/components/datatable/__tests__/DataTable.spec.jsx index 28dc4de68a..6203a28cfa 100644 --- a/src/components/datatable/__tests__/DataTable.spec.jsx +++ b/src/components/datatable/__tests__/DataTable.spec.jsx @@ -1,126 +1,5 @@ -import { - shouldClearFeatureHighlight, - getRowClickAction, - getNextSorting, - isFilterable, -} from '../DataTable.jsx' import { getReversedSelection } from '../useRowSelection.js' -// DataTable.jsx transitively imports MapApi.js (maplibre-gl), -// which is not needed here and fails to load under jsdom. -jest.mock('../../map/MapApi.js', () => ({ - loadEarthEngineWorker: jest.fn(), -})) - -describe('shouldClearFeatureHighlight', () => { - test('clears when leaving to no element (cursor exits the window)', () => { - expect(shouldClearFeatureHighlight({ relatedTarget: null })).toBe(true) - }) - - test('does not clear when hovering to an adjacent row cell (TD)', () => { - expect( - shouldClearFeatureHighlight({ relatedTarget: { tagName: 'TD' } }) - ).toBe(false) - }) - - test('clears when leaving to a non-TD element', () => { - expect( - shouldClearFeatureHighlight({ relatedTarget: { tagName: 'DIV' } }) - ).toBe(true) - }) -}) - -describe('getRowClickAction', () => { - const rows = [ - [{ dataKey: 'id', value: 'a', itemId: 'a' }], - [{ dataKey: 'id', value: 'b', itemId: 'b' }], - [{ dataKey: 'id', value: 'c', itemId: 'c' }], - [{ dataKey: 'id', value: 'd', itemId: 'd' }], - ] - - test('plain click is ignored', () => { - expect( - getRowClickAction( - {}, - { id: 'b', rowIndex: 1, rows, lastClickedRowIndex: null } - ) - ).toBeNull() - }) - - test('ctrl-click toggles just that row', () => { - expect( - getRowClickAction( - { ctrlKey: true }, - { id: 'b', rowIndex: 1, rows, lastClickedRowIndex: null } - ) - ).toEqual({ type: 'toggle', id: 'b' }) - }) - - test('shift-click with no prior anchor falls back to a single-row toggle', () => { - expect( - getRowClickAction( - { shiftKey: true }, - { id: 'c', rowIndex: 2, rows, lastClickedRowIndex: null } - ) - ).toEqual({ type: 'toggle', id: 'c' }) - }) - - test('shift-click with a prior anchor selects the range between them', () => { - expect( - getRowClickAction( - { shiftKey: true }, - { id: 'd', rowIndex: 3, rows, lastClickedRowIndex: 1 } - ) - ).toEqual({ type: 'range', ids: ['b', 'c', 'd'] }) - }) - - test('shift-click range works regardless of anchor/target order', () => { - expect( - getRowClickAction( - { shiftKey: true }, - { id: 'a', rowIndex: 0, rows, lastClickedRowIndex: 2 } - ) - ).toEqual({ type: 'range', ids: ['a', 'b', 'c'] }) - }) -}) - -describe('getNextSorting', () => { - test('clicking an unsorted column starts at ascending', () => { - expect( - getNextSorting('name', { sortField: null, sortDirection: 'asc' }) - ).toEqual({ sortField: 'name', sortDirection: 'asc' }) - }) - - test('clicking the ascending-sorted column moves to descending', () => { - expect( - getNextSorting('name', { sortField: 'name', sortDirection: 'asc' }) - ).toEqual({ sortField: 'name', sortDirection: 'desc' }) - }) - - test('clicking the descending-sorted column clears back to natural order', () => { - expect( - getNextSorting('name', { sortField: 'name', sortDirection: 'desc' }) - ).toEqual({ sortField: null, sortDirection: 'asc' }) - }) - - test('clicking a different column restarts the cycle at ascending', () => { - expect( - getNextSorting('type', { sortField: 'name', sortDirection: 'desc' }) - ).toEqual({ sortField: 'type', sortDirection: 'asc' }) - }) -}) - -describe('isFilterable', () => { - test('allows numeric and string columns', () => { - expect(isFilterable('rawValue', 'number')).toBe(true) - expect(isFilterable('name', 'string')).toBe(true) - }) - - test('excludes columns with no type (no known filter UI for them)', () => { - expect(isFilterable('someKey', undefined)).toBe(false) - }) -}) - describe('getReversedSelection', () => { test('selects every visible row when nothing is currently selected', () => { expect(getReversedSelection([], ['a', 'b', 'c'])).toEqual([ diff --git a/src/components/datatable/styles/DataTable.module.css b/src/components/datatable/styles/DataTable.module.css index a24abe4350..8f6c8de3f4 100644 --- a/src/components/datatable/styles/DataTable.module.css +++ b/src/components/datatable/styles/DataTable.module.css @@ -1,12 +1,3 @@ -.dataTable { - height: 1px; - border: none !important; -} - -.dataTable > :global(thead) { - user-select: none; -} - td.dataCell, th.dataCell { padding-top: var(--spacers-dp8); @@ -52,35 +43,6 @@ td.checkboxCell { gap: 2px; } -.selectionFilterButton { - width: 100%; - height: 24px; - font-size: 11px; - padding: 4px 6px; - border: 1px solid var(--colors-grey500); - border-radius: 3px; - box-shadow: inset 0 0 1px 0 rgba(48, 54, 60, 0.1); - background: var(--colors-white); - cursor: pointer; - text-align: left; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.selectionFilterPopover { - padding: var(--spacers-dp8); - min-width: 140px; - background-color: var(--colors-white); - border-radius: 4px; - box-shadow: var(--elevations-popover); -} - -/* !important beats @dhis2/ui's own Checkbox label font-size. */ -.selectionFilterPopover :global(label) { - font-size: 11px !important; -} - td.selected, th.selected { background-color: var(--colors-blue050); @@ -158,19 +120,6 @@ th.hovered { cursor: not-allowed; } -.topTooltipContent { - z-index: 2000; - max-width: 300px; - padding: 4px 6px; - background-color: var(--colors-grey900); - border-radius: 3px; - color: var(--colors-white); - font-size: 13px; - line-height: 17px; - word-break: normal; - overflow-wrap: break-word; -} - .columnHeader :global(input.dense) { padding: 4px 6px; font-size: 11px; @@ -191,32 +140,6 @@ th.hovered { display: none; } -.noResults { - display: flex; - color: var(--colors-grey600); - align-items: center; - justify-content: center; - gap: var(--spacers-dp8); - font-size: 12px; - font-style: italic; - min-height: 40px; -} - -.clearFiltersLink { - font-size: 12px; - font-style: normal; - color: var(--colors-blue600); - background: transparent; - border: none; - padding: 0; - cursor: pointer; - text-decoration: underline; -} - -.clearFiltersLink:hover { - color: var(--colors-blue700); -} - .loadingContent { display: flex; flex-direction: column; diff --git a/src/components/datatable/styles/SelectionFilterButton.module.css b/src/components/datatable/styles/SelectionFilterButton.module.css new file mode 100644 index 0000000000..d3028a073d --- /dev/null +++ b/src/components/datatable/styles/SelectionFilterButton.module.css @@ -0,0 +1,32 @@ +.selectionFilterButton { + width: 100%; + height: 24px; + font-size: 11px; + padding: 4px 6px; + border: 1px solid var(--colors-grey500); + border-radius: 3px; + box-shadow: inset 0 0 1px 0 rgba(48, 54, 60, 0.1); + background: var(--colors-white); + cursor: pointer; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.selectionFilterPopover { + padding: var(--spacers-dp8); + min-width: 140px; + background-color: var(--colors-white); + border-radius: 4px; + box-shadow: var(--elevations-popover); +} + +/* !important beats @dhis2/ui's own Checkbox label font-size. */ +.selectionFilterPopover :global(label) { + font-size: 11px !important; +} + +.denseCheckbox { + margin: 4px 0; +} diff --git a/src/components/datatable/styles/TableVirtuosoComponents.module.css b/src/components/datatable/styles/TableVirtuosoComponents.module.css new file mode 100644 index 0000000000..ff259de9fc --- /dev/null +++ b/src/components/datatable/styles/TableVirtuosoComponents.module.css @@ -0,0 +1,34 @@ +.dataTable { + height: 1px; + border: none !important; +} + +.dataTable > :global(thead) { + user-select: none; +} + +.noResults { + display: flex; + color: var(--colors-grey600); + align-items: center; + justify-content: center; + gap: var(--spacers-dp8); + font-size: 12px; + font-style: italic; + min-height: 40px; +} + +.clearFiltersLink { + font-size: 12px; + font-style: normal; + color: var(--colors-blue600); + background: transparent; + border: none; + padding: 0; + cursor: pointer; + text-decoration: underline; +} + +.clearFiltersLink:hover { + color: var(--colors-blue700); +} diff --git a/src/components/datatable/styles/TopTooltip.module.css b/src/components/datatable/styles/TopTooltip.module.css new file mode 100644 index 0000000000..06d8f2b933 --- /dev/null +++ b/src/components/datatable/styles/TopTooltip.module.css @@ -0,0 +1,12 @@ +.topTooltipContent { + z-index: 2000; + max-width: 300px; + padding: 4px 6px; + background-color: var(--colors-grey900); + border-radius: 3px; + color: var(--colors-white); + font-size: 13px; + line-height: 17px; + word-break: normal; + overflow-wrap: break-word; +} diff --git a/src/util/__tests__/dataTable.spec.js b/src/util/__tests__/dataTable.spec.js new file mode 100644 index 0000000000..94d7c6026d --- /dev/null +++ b/src/util/__tests__/dataTable.spec.js @@ -0,0 +1,131 @@ +import { + getNextSorting, + getRowClickAction, + getRowId, + isFilterable, + shouldClearFeatureHighlight, +} from '../dataTable.js' + +describe('shouldClearFeatureHighlight', () => { + test('clears when leaving to no element (cursor exits the window)', () => { + expect(shouldClearFeatureHighlight({ relatedTarget: null })).toBe(true) + }) + + test('does not clear when hovering to an adjacent row cell (TD)', () => { + expect( + shouldClearFeatureHighlight({ relatedTarget: { tagName: 'TD' } }) + ).toBe(false) + }) + + test('clears when leaving to a non-TD element', () => { + expect( + shouldClearFeatureHighlight({ relatedTarget: { tagName: 'DIV' } }) + ).toBe(true) + }) +}) + +describe('getRowId', () => { + test('returns the id-keyed cell value when present', () => { + const row = [ + { dataKey: 'name', value: 'Foo' }, + { dataKey: 'id', value: 'abc123' }, + ] + expect(getRowId(row)).toBe('abc123') + }) + + test('falls back to the first cell itemId when there is no id cell', () => { + const row = [{ dataKey: 'name', value: 'Foo', itemId: 'xyz789' }] + expect(getRowId(row)).toBe('xyz789') + }) +}) + +describe('getRowClickAction', () => { + const rows = [ + [{ dataKey: 'id', value: 'a', itemId: 'a' }], + [{ dataKey: 'id', value: 'b', itemId: 'b' }], + [{ dataKey: 'id', value: 'c', itemId: 'c' }], + [{ dataKey: 'id', value: 'd', itemId: 'd' }], + ] + + test('plain click is ignored', () => { + expect( + getRowClickAction( + {}, + { id: 'b', rowIndex: 1, rows, lastClickedRowIndex: null } + ) + ).toBeNull() + }) + + test('ctrl-click toggles just that row', () => { + expect( + getRowClickAction( + { ctrlKey: true }, + { id: 'b', rowIndex: 1, rows, lastClickedRowIndex: null } + ) + ).toEqual({ type: 'toggle', id: 'b' }) + }) + + test('shift-click with no prior anchor falls back to a single-row toggle', () => { + expect( + getRowClickAction( + { shiftKey: true }, + { id: 'c', rowIndex: 2, rows, lastClickedRowIndex: null } + ) + ).toEqual({ type: 'toggle', id: 'c' }) + }) + + test('shift-click with a prior anchor selects the range between them', () => { + expect( + getRowClickAction( + { shiftKey: true }, + { id: 'd', rowIndex: 3, rows, lastClickedRowIndex: 1 } + ) + ).toEqual({ type: 'range', ids: ['b', 'c', 'd'] }) + }) + + test('shift-click range works regardless of anchor/target order', () => { + expect( + getRowClickAction( + { shiftKey: true }, + { id: 'a', rowIndex: 0, rows, lastClickedRowIndex: 2 } + ) + ).toEqual({ type: 'range', ids: ['a', 'b', 'c'] }) + }) +}) + +describe('getNextSorting', () => { + test('clicking an unsorted column starts at ascending', () => { + expect( + getNextSorting('name', { sortField: null, sortDirection: 'asc' }) + ).toEqual({ sortField: 'name', sortDirection: 'asc' }) + }) + + test('clicking the ascending-sorted column moves to descending', () => { + expect( + getNextSorting('name', { sortField: 'name', sortDirection: 'asc' }) + ).toEqual({ sortField: 'name', sortDirection: 'desc' }) + }) + + test('clicking the descending-sorted column clears back to natural order', () => { + expect( + getNextSorting('name', { sortField: 'name', sortDirection: 'desc' }) + ).toEqual({ sortField: null, sortDirection: 'asc' }) + }) + + test('clicking a different column restarts the cycle at ascending', () => { + expect( + getNextSorting('type', { sortField: 'name', sortDirection: 'desc' }) + ).toEqual({ sortField: 'type', sortDirection: 'asc' }) + }) +}) + +describe('isFilterable', () => { + test('allows numeric and string columns', () => { + expect(isFilterable('rawValue', 'number')).toBe(true) + expect(isFilterable('name', 'string')).toBe(true) + }) + + test('excludes columns with no type (no known filter UI for them)', () => { + expect(isFilterable('someKey', undefined)).toBe(false) + }) +}) diff --git a/src/util/__tests__/tableColumns.spec.js b/src/util/__tests__/tableColumns.spec.js index 3007f10bed..77db56a701 100644 --- a/src/util/__tests__/tableColumns.spec.js +++ b/src/util/__tests__/tableColumns.spec.js @@ -1,4 +1,5 @@ import { + getPinnedCellProps, getPinnedCount, getPinnedLeftOffsets, getVisibleHeaders, @@ -249,3 +250,54 @@ describe('reverseVisibleKeys', () => { expect(result).toEqual(['name', 'id', 'rawValue', 'legend']) }) }) + +describe('getPinnedCellProps', () => { + const pinnedLeftOffsets = { rawValue: 76, name: 176 } + const pinnedColumnCount = 2 + const columnWidths = [100, 150, 80, 120] + + it('marks a pinned-in-range column as fixed with its left/width offsets', () => { + expect( + getPinnedCellProps('rawValue', 0, { + pinnedLeftOffsets, + pinnedColumnCount, + columnWidths, + }) + ).toEqual({ + fixed: true, + left: '76px', + width: '100px', + isLastPinned: false, + }) + }) + + it('leaves an unpinned column unfixed with no left/width offsets', () => { + expect( + getPinnedCellProps('id', 2, { + pinnedLeftOffsets, + pinnedColumnCount, + columnWidths, + }) + ).toEqual({ + fixed: false, + left: undefined, + width: undefined, + isLastPinned: false, + }) + }) + + it('flags isLastPinned only at the final pinned index', () => { + expect( + getPinnedCellProps('name', 1, { + pinnedLeftOffsets, + pinnedColumnCount, + columnWidths, + }) + ).toEqual({ + fixed: true, + left: '176px', + width: '150px', + isLastPinned: true, + }) + }) +}) diff --git a/src/util/dataTable.js b/src/util/dataTable.js new file mode 100644 index 0000000000..3760d7d45f --- /dev/null +++ b/src/util/dataTable.js @@ -0,0 +1,44 @@ +import { SORT_ASCENDING, SORT_DESCENDING } from '../constants/dataTable.js' + +export const isFilterable = (dataKey, type) => !!type + +export const shouldClearFeatureHighlight = (event) => + event.relatedTarget?.tagName !== 'TD' + +export const getNextSorting = (name, { sortField, sortDirection }) => { + if (name !== sortField) { + return { sortField: name, sortDirection: SORT_ASCENDING } + } + if (sortDirection === SORT_ASCENDING) { + return { sortField: name, sortDirection: SORT_DESCENDING } + } + return { sortField: null, sortDirection: SORT_ASCENDING } +} + +export const getRowId = (row) => + row.find((r) => r.dataKey === 'id')?.value || row[0]?.itemId + +export const getRowClickAction = ( + event, + { id, rowIndex, rows, lastClickedRowIndex } +) => { + if (event.shiftKey) { + if (lastClickedRowIndex === null) { + return { type: 'toggle', id } + } + const [start, end] = [lastClickedRowIndex, rowIndex].sort( + (a, b) => a - b + ) + const ids = rows + .slice(start, end + 1) + .map(getRowId) + .filter(Boolean) + return { type: 'range', ids } + } + + if (event.ctrlKey || event.metaKey) { + return { type: 'toggle', id } + } + + return null +} diff --git a/src/util/tableColumns.js b/src/util/tableColumns.js index 1904059391..b6e86f87c3 100644 --- a/src/util/tableColumns.js +++ b/src/util/tableColumns.js @@ -35,6 +35,9 @@ export const getVisibleHeaders = (headers, columnConfig) => { } export const getPinnedCount = (orderedHeaders, pinnedKeys) => { + if (!orderedHeaders?.length || !pinnedKeys?.length) { + return 0 + } let count = 0 for (const header of orderedHeaders) { if (!pinnedKeys.includes(header.dataKey)) { @@ -65,6 +68,22 @@ export const reverseVisibleKeys = (headers, visibleKeys) => .filter((h) => !visibleKeys.includes(h.dataKey)) .map((h) => h.dataKey) +// @dhis2/ui requires `width` whenever `fixed` is passed +export const getPinnedCellProps = ( + dataKey, + index, + { pinnedLeftOffsets, pinnedColumnCount, columnWidths } +) => { + const leftOffset = pinnedLeftOffsets[dataKey] + const isPinned = index < pinnedColumnCount && leftOffset !== undefined + return { + fixed: isPinned, + left: isPinned ? `${leftOffset}px` : undefined, + width: isPinned ? `${columnWidths[index] ?? 0}px` : undefined, + isLastPinned: index === pinnedColumnCount - 1, + } +} + export const getPinnedLeftOffsets = ( visibleHeaders, pinnedKeys, From eac8da3c4d6c72f52b753d4bf7da61ec2e2f3701 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Mon, 20 Jul 2026 11:41:49 +0200 Subject: [PATCH 070/205] chore: add retry on delete --- cypress/plugins/e2eReplicaAccount.js | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/cypress/plugins/e2eReplicaAccount.js b/cypress/plugins/e2eReplicaAccount.js index 7fa7d2fe7c..012f14f993 100644 --- a/cypress/plugins/e2eReplicaAccount.js +++ b/cypress/plugins/e2eReplicaAccount.js @@ -59,8 +59,7 @@ const dhis2Fetch = async ( } const buildReplicaUsername = () => - `e2e_mapsapp_run${ - process.env.GITHUB_RUN_ID ?? 'local' + `e2e_mapsapp_run${process.env.GITHUB_RUN_ID ?? 'local' }_${uniqueId().replaceAll('-', '_')}` const createReplicaUser = async ({ baseUrl, adminId, auth }) => { @@ -121,12 +120,10 @@ const createReplicaAccountForRun = async ({ baseUrl, username, password }) => { return createReplicaUser({ baseUrl, adminId, auth }) } -const deleteReplicaAccount = async ({ - baseUrl, - username, - password, - replicaUserId, -}) => { +const deleteReplicaAccount = async ( + { baseUrl, username, password, replicaUserId }, + attempt = 1 +) => { const response = await dhis2Fetch(baseUrl, `/api/users/${replicaUserId}`, { method: 'DELETE', auth: { username, password }, From f2cef1600a6c6976c90dc9994798ebeaeee95e19 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Mon, 20 Jul 2026 14:01:12 +0200 Subject: [PATCH 071/205] chore: update e2eReplicaAccount.js --- cypress/plugins/e2eReplicaAccount.js | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/cypress/plugins/e2eReplicaAccount.js b/cypress/plugins/e2eReplicaAccount.js index 012f14f993..7fa7d2fe7c 100644 --- a/cypress/plugins/e2eReplicaAccount.js +++ b/cypress/plugins/e2eReplicaAccount.js @@ -59,7 +59,8 @@ const dhis2Fetch = async ( } const buildReplicaUsername = () => - `e2e_mapsapp_run${process.env.GITHUB_RUN_ID ?? 'local' + `e2e_mapsapp_run${ + process.env.GITHUB_RUN_ID ?? 'local' }_${uniqueId().replaceAll('-', '_')}` const createReplicaUser = async ({ baseUrl, adminId, auth }) => { @@ -120,10 +121,12 @@ const createReplicaAccountForRun = async ({ baseUrl, username, password }) => { return createReplicaUser({ baseUrl, adminId, auth }) } -const deleteReplicaAccount = async ( - { baseUrl, username, password, replicaUserId }, - attempt = 1 -) => { +const deleteReplicaAccount = async ({ + baseUrl, + username, + password, + replicaUserId, +}) => { const response = await dhis2Fetch(baseUrl, `/api/users/${replicaUserId}`, { method: 'DELETE', auth: { username, password }, From fa7a521312213411b49583eebddec1d6fb249df0 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Mon, 27 Jul 2026 14:01:55 +0200 Subject: [PATCH 072/205] chore: PR wrap-up --- src/components/datatable/BottomPanel.jsx | 87 ++++++------------------ 1 file changed, 19 insertions(+), 68 deletions(-) diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index 52c69c784b..44cd617e8d 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -192,9 +192,8 @@ const BottomPanel = () => { className={styles.dataTableControls} onDoubleClick={onControlsDoubleClick} > - <button - type="button" - className={styles.toggleButton} + <CollapseControl + isCollapsed={isCollapsed} onClick={toggleCollapsed} /> <span className={styles.divider} /> @@ -217,73 +216,25 @@ const BottomPanel = () => { onResize={onResize} onResizeEnd={onResizeEnd} /> - {rowCountLabel && ( - <span className={styles.rowCount}>{rowCountLabel}</span> - )} - {hasActiveFilters && ( - <button - type="button" - className={styles.clearFiltersButton} - onClick={() => { - dispatch(clearDataFilters(activeLayerId)) - setGlobalSearch('') - }} - > - <Tooltip content={i18n.t('Clear filters')}> - <span className={styles.filteredIcon}> - <IconFilter16 /> - <span className={styles.clearBadge} /> - </span> - </Tooltip> - </button> - )} - <Input - dense - dataTest="data-table-global-search" - placeholder={i18n.t('Search all columns')} + <RowCountControl + totalCount={totalCount} + filteredCount={filteredCount} + /> + <span className={styles.divider} /> + <ClearFiltersControl + disabled={!hasActiveFilters} + onClick={onClearFilters} + /> + <GlobalSearchControl value={globalSearch} - onChange={({ value }) => setGlobalSearch(value)} - className={styles.globalSearch} - onDoubleClick={(e) => e.stopPropagation()} + onChange={setGlobalSearch} /> - <button - type="button" - className={cx(styles.toggleButton, { - [styles.active]: showOnlyFeaturesInView, - })} - onClick={() => dispatch(toggleShowOnlyFeaturesInView())} - > - <Tooltip - content={i18n.t( - 'Show only features in current map view' - )} - placement="top" - > - <span className={styles.alignIcon1}> - <IconEmptyFrame16 /> - </span> - </Tooltip> - </button> - <Tooltip content={i18n.t('Highlight color')}> - <ColorPicker - className={styles.highlightColorPicker} - color={highlightColor} - width={18} - height={18} - centerIcon - onChange={(color) => dispatch(setHighlightColor(color))} - /> - </Tooltip> - <button - className={styles.closeIcon} - onClick={() => dispatch(closeDataTable())} - > - <Tooltip content={i18n.t('Close')} placement="top"> - <span className={styles.alignIcon1}> - <IconCross16 /> - </span> - </Tooltip> - </button> + <ShowInViewControl + active={showOnlyFeaturesInView} + onClick={onToggleShowOnlyFeaturesInView} + /> + <span className={styles.divider} /> + <CloseControl onClick={onCloseDataTable} /> </div> {!isCollapsed && ( <div className={styles.tableContainer}> From 997a788fbd8369298959efaaf6a484bcb6ca7e28 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Mon, 13 Jul 2026 17:36:21 +0200 Subject: [PATCH 073/205] feat: add bidirectional map/table selection sync and collapsible data table --- i18n/en.pot | 3 + src/components/core/icons.jsx | 53 +++++++ .../datatable/__tests__/useTableData.spec.jsx | 138 ++++++++++++++++++ .../datatable/styles/BottomPanel.module.css | 25 ++++ 4 files changed, 219 insertions(+) diff --git a/i18n/en.pot b/i18n/en.pot index fc5570b37f..3edf62ba57 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -164,6 +164,9 @@ msgstr "Reverse selection of visible rows" msgid "Sort by Selected" msgstr "Sort by Selected" +msgid "Select all" +msgstr "Select all" + msgid "Sort by {{column}}" msgstr "Sort by {{column}}" diff --git a/src/components/core/icons.jsx b/src/components/core/icons.jsx index 1a067ea7bd..b3a970b952 100644 --- a/src/components/core/icons.jsx +++ b/src/components/core/icons.jsx @@ -49,6 +49,59 @@ export const IconZoomIn16 = () => ( </svg> ) +// Two stacked chevrons — "collapse"/"restore to full height" toggle. +export const IconChevronDoubleDown16 = () => ( + <svg + height="16" + viewBox="0 0 16 16" + width="16" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M4 4L8 7L12 4" + fill="none" + stroke="currentColor" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + /> + <path + d="M4 9L8 12L12 9" + fill="none" + stroke="currentColor" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + /> + </svg> +) + +export const IconChevronDoubleUp16 = () => ( + <svg + height="16" + viewBox="0 0 16 16" + width="16" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M4 7L8 4L12 7" + fill="none" + stroke="currentColor" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + /> + <path + d="M4 12L8 9L12 12" + fill="none" + stroke="currentColor" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + /> + </svg> +) + export const IconDrag = () => ( <svg height="8" diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index ae6f606a20..7f0e143c3b 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -1412,3 +1412,141 @@ describe('useTableData globalSearch', () => { expect(current.rows).toHaveLength(0) }) }) + +describe('useTableData showOnlyFeaturesInView', () => { + const store = { aggregations: {} } + const bounds = [-10, -10, 10, 10] + + const layer = { + id: 'test-layer', + layer: 'orgUnit', + dataFilters: null, + data: [ + { + id: 'inview', + properties: { id: 'inview', name: 'In view' }, + geometry: { type: 'Point', coordinates: [0, 0] }, + }, + { + id: 'outofview', + properties: { id: 'outofview', name: 'Out of view' }, + geometry: { type: 'Point', coordinates: [50, 50] }, + }, + ], + } + + const renderTableData = (props) => + renderHook(() => useTableData(props), { + wrapper: ({ children }) => ( + <Provider store={mockStore(store)}>{children}</Provider> + ), + }).result + + test('includes all rows when the toggle is off', () => { + const { current } = renderTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + showOnlyFeaturesInView: false, + mapBounds: bounds, + }) + expect(current.rows).toHaveLength(2) + }) + + test('excludes features outside the current map bounds when the toggle is on', () => { + const { current } = renderTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + showOnlyFeaturesInView: true, + mapBounds: bounds, + }) + expect(current.rows).toHaveLength(1) + expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( + 'In view' + ) + }) + + test('excludes features without geometry when the toggle is on', () => { + const layerWithoutCoords = { + ...layer, + data: [layer.data[0]], + dataWithoutCoords: [ + { + id: 'nogeom', + properties: { id: 'nogeom', name: 'No geometry' }, + geometry: null, + }, + ], + } + + const { current } = renderTableData({ + layer: layerWithoutCoords, + sortField: 'name', + sortDirection: 'asc', + showOnlyFeaturesInView: true, + mapBounds: bounds, + }) + expect(current.rows).toHaveLength(1) + expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( + 'In view' + ) + }) +}) + +describe('useTableData showOnlySelected', () => { + const store = { aggregations: {} } + + const layer = { + id: 'test-layer', + layer: 'orgUnit', + dataFilters: null, + data: [ + { id: 'a', properties: { id: 'a', name: 'Item A' } }, + { id: 'b', properties: { id: 'b', name: 'Item B' } }, + ], + } + + const renderTableData = (props) => + renderHook(() => useTableData(props), { + wrapper: ({ children }) => ( + <Provider store={mockStore(store)}>{children}</Provider> + ), + }).result + + test('includes all rows when the toggle is off', () => { + const { current } = renderTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + showOnlySelected: false, + selectedIdSet: new Set(['a']), + }) + expect(current.rows).toHaveLength(2) + }) + + test('includes only selected rows when the toggle is on', () => { + const { current } = renderTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + showOnlySelected: true, + selectedIdSet: new Set(['a']), + }) + expect(current.rows).toHaveLength(1) + expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( + 'Item A' + ) + }) + + test('shows no rows when the toggle is on and nothing is selected', () => { + const { current } = renderTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + showOnlySelected: true, + selectedIdSet: new Set(), + }) + expect(current.rows).toHaveLength(0) + }) +}) diff --git a/src/components/datatable/styles/BottomPanel.module.css b/src/components/datatable/styles/BottomPanel.module.css index 9ab8b8aaf2..9041ba8821 100644 --- a/src/components/datatable/styles/BottomPanel.module.css +++ b/src/components/datatable/styles/BottomPanel.module.css @@ -39,3 +39,28 @@ background-color: var(--colors-grey300); flex-shrink: 0; } + +.toggleButton.active { + color: var(--colors-blue700); + background-color: var(--colors-blue100); +} + +.toggleButton.active:hover { + background-color: var(--colors-blue200); +} + +.highlightColorPicker { + margin-bottom: 0 !important; + flex-shrink: 0; + display: flex; + align-items: center; + position: relative; + top: -1px; +} + +.highlightColorPicker label { + box-sizing: border-box; + overflow: hidden; + min-width: 18px !important; + min-height: 18px !important; +} From 525f2e6d54b0a59b1a5e50be266c52fecd377083 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Mon, 13 Jul 2026 23:34:31 +0200 Subject: [PATCH 074/205] feat: add global search box to data table toolbar Adds a dense Input between the "Clear filters" button and the show-only toggles, sized to shrink before the layer name has to truncate further. "Clear filters" now also resets the search box, and hasActiveFilters accounts for both column filters and the search string. --- src/components/datatable/styles/BottomPanel.module.css | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/components/datatable/styles/BottomPanel.module.css b/src/components/datatable/styles/BottomPanel.module.css index 9041ba8821..8d7a23f0e3 100644 --- a/src/components/datatable/styles/BottomPanel.module.css +++ b/src/components/datatable/styles/BottomPanel.module.css @@ -64,3 +64,9 @@ min-width: 18px !important; min-height: 18px !important; } + +.globalSearch { + flex: 0 1 160px; + min-width: 90px; + margin-bottom: 0 !important; +} From cfa7fee3dc1c7191eea0ea8edab7e7bb46dfc5fa Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 14 Jul 2026 10:51:51 +0200 Subject: [PATCH 075/205] fix: toolbar polish - clear filters button, search sizing, collapse icon/bug --- src/components/core/icons.jsx | 53 ------------------- .../datatable/styles/BottomPanel.module.css | 20 ++++++- 2 files changed, 19 insertions(+), 54 deletions(-) diff --git a/src/components/core/icons.jsx b/src/components/core/icons.jsx index b3a970b952..1a067ea7bd 100644 --- a/src/components/core/icons.jsx +++ b/src/components/core/icons.jsx @@ -49,59 +49,6 @@ export const IconZoomIn16 = () => ( </svg> ) -// Two stacked chevrons — "collapse"/"restore to full height" toggle. -export const IconChevronDoubleDown16 = () => ( - <svg - height="16" - viewBox="0 0 16 16" - width="16" - xmlns="http://www.w3.org/2000/svg" - > - <path - d="M4 4L8 7L12 4" - fill="none" - stroke="currentColor" - strokeWidth="1.5" - strokeLinecap="round" - strokeLinejoin="round" - /> - <path - d="M4 9L8 12L12 9" - fill="none" - stroke="currentColor" - strokeWidth="1.5" - strokeLinecap="round" - strokeLinejoin="round" - /> - </svg> -) - -export const IconChevronDoubleUp16 = () => ( - <svg - height="16" - viewBox="0 0 16 16" - width="16" - xmlns="http://www.w3.org/2000/svg" - > - <path - d="M4 7L8 4L12 7" - fill="none" - stroke="currentColor" - strokeWidth="1.5" - strokeLinecap="round" - strokeLinejoin="round" - /> - <path - d="M4 12L8 9L12 12" - fill="none" - stroke="currentColor" - strokeWidth="1.5" - strokeLinecap="round" - strokeLinejoin="round" - /> - </svg> -) - export const IconDrag = () => ( <svg height="8" diff --git a/src/components/datatable/styles/BottomPanel.module.css b/src/components/datatable/styles/BottomPanel.module.css index 8d7a23f0e3..8573b64a04 100644 --- a/src/components/datatable/styles/BottomPanel.module.css +++ b/src/components/datatable/styles/BottomPanel.module.css @@ -40,6 +40,16 @@ flex-shrink: 0; } +.clearFiltersButton:disabled { + color: var(--colors-grey400); + cursor: not-allowed; +} + +.clearFiltersButton:disabled:hover { + color: var(--colors-grey400); + background-color: transparent; +} + .toggleButton.active { color: var(--colors-blue700); background-color: var(--colors-blue100); @@ -68,5 +78,13 @@ .globalSearch { flex: 0 1 160px; min-width: 90px; - margin-bottom: 0 !important; +} + +.globalSearch > :global(div) { + width: 100%; +} + +.globalSearch :global(input.dense) { + padding: 4px 6px; + font-size: 11px; } From 3d4ffae251028aa21c0ba9e8891b94ff1250457d Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 14 Jul 2026 22:18:57 +0200 Subject: [PATCH 076/205] feat: round out data table filtering with reverse-selection, zoom-to-filtered, and a richer selection filter --- i18n/en.pot | 18 +++++++-- .../datatable/__tests__/useTableData.spec.jsx | 39 +++++++++++++++---- 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 3edf62ba57..b4993513a4 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -167,6 +167,12 @@ msgstr "Sort by Selected" msgid "Select all" msgstr "Select all" +msgid "Sort by Selected" +msgstr "Sort by Selected" + +msgid "Reverse selection" +msgstr "Reverse selection" + msgid "Sort by {{column}}" msgstr "Sort by {{column}}" @@ -230,10 +236,14 @@ msgstr "Not selected" msgid "All" msgstr "All" -msgid "{{count}} selected" -msgid_plural "{{count}} selected" -msgstr[0] "{{count}} selected" -msgstr[1] "{{count}} selected" +msgid "Use filter" +msgstr "Use filter" + +msgid "Contains" +msgstr "Contains" + +msgid "Search or type > 5, < 8…" +msgstr "Search or type > 5, < 8…" msgid "Drill up one level" msgstr "Drill up one level" diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index 7f0e143c3b..17c0ae892a 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -1494,7 +1494,7 @@ describe('useTableData showOnlyFeaturesInView', () => { }) }) -describe('useTableData showOnlySelected', () => { +describe('useTableData selectionFilter', () => { const store = { aggregations: {} } const layer = { @@ -1514,23 +1514,23 @@ describe('useTableData showOnlySelected', () => { ), }).result - test('includes all rows when the toggle is off', () => { + test('includes all rows when no filter is applied', () => { const { current } = renderTableData({ layer, sortField: 'name', sortDirection: 'asc', - showOnlySelected: false, + selectionFilter: [], selectedIdSet: new Set(['a']), }) expect(current.rows).toHaveLength(2) }) - test('includes only selected rows when the toggle is on', () => { + test('includes only selected rows when filtered to "selected"', () => { const { current } = renderTableData({ layer, sortField: 'name', sortDirection: 'asc', - showOnlySelected: true, + selectionFilter: ['selected'], selectedIdSet: new Set(['a']), }) expect(current.rows).toHaveLength(1) @@ -1539,12 +1539,37 @@ describe('useTableData showOnlySelected', () => { ) }) - test('shows no rows when the toggle is on and nothing is selected', () => { + test('includes only non-selected rows when filtered to "not-selected"', () => { const { current } = renderTableData({ layer, sortField: 'name', sortDirection: 'asc', - showOnlySelected: true, + selectionFilter: ['not-selected'], + selectedIdSet: new Set(['a']), + }) + expect(current.rows).toHaveLength(1) + expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( + 'Item B' + ) + }) + + test('includes all rows when both options are checked', () => { + const { current } = renderTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + selectionFilter: ['selected', 'not-selected'], + selectedIdSet: new Set(['a']), + }) + expect(current.rows).toHaveLength(2) + }) + + test('shows no rows when filtered to "selected" and nothing is selected', () => { + const { current } = renderTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + selectionFilter: ['selected'], selectedIdSet: new Set(), }) expect(current.rows).toHaveLength(0) From 7bf7e332f4c6a4a041b0f8f75b78d174b9f04533 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 16 Jul 2026 13:35:05 +0200 Subject: [PATCH 077/205] chore: PR clean-up --- i18n/en.pot | 4 ++-- src/components/datatable/styles/BottomPanel.module.css | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index b4993513a4..10b0dbb274 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -164,8 +164,8 @@ msgstr "Reverse selection of visible rows" msgid "Sort by Selected" msgstr "Sort by Selected" -msgid "Select all" -msgstr "Select all" +msgid "Select all visible rows" +msgstr "Select all visible rows" msgid "Sort by Selected" msgstr "Sort by Selected" diff --git a/src/components/datatable/styles/BottomPanel.module.css b/src/components/datatable/styles/BottomPanel.module.css index 8573b64a04..083bac7ce9 100644 --- a/src/components/datatable/styles/BottomPanel.module.css +++ b/src/components/datatable/styles/BottomPanel.module.css @@ -59,6 +59,7 @@ background-color: var(--colors-blue200); } +/* !important beats @dhis2/ui's own ColorPicker field margin. */ .highlightColorPicker { margin-bottom: 0 !important; flex-shrink: 0; @@ -68,6 +69,7 @@ top: -1px; } +/* !important beats @dhis2/ui's own ColorPicker label size. */ .highlightColorPicker label { box-sizing: border-box; overflow: hidden; From e34991dbdde50cc09dce9ec0b32b8f0a1c02e408 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 16 Jul 2026 16:43:54 +0200 Subject: [PATCH 078/205] fix: ColumnPicker permanently disabled for layer types with synchronous headers React fires child effects before parent effects within the same commit. DataTable's header-reporting effect (child) and BottomPanel's own "reset allHeaders on layer switch" effect (parent) both fired on mount/layer-switch; the parent's reset always ran second in that commit, clobbering the real headers DataTable had just reported. Layer types with a synchronous header computation (Thematic/OrgUnit/Facility) never got a second chance to set it, leaving the picker's trigger button permanently disabled - Event layers only worked because their extended-events loading triggers a second, later header recompute that escapes the race. Fix: track headers keyed by which layer produced them (headersByLayer = {layerId, headers}, tagged by DataTable's own effect closure, not a "latest activeLayerId" guess) and derive staleness at render time via a plain comparison, instead of a second effect racing to clear the same state. --- src/components/datatable/DataTable.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index a4acd7a7c3..729053a30b 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -180,8 +180,8 @@ const Table = ({ }) useEffect(() => { - onHeadersChange?.(headers) - }, [onHeadersChange, headers]) + onHeadersChange?.(headers, layer.id) + }, [onHeadersChange, headers, layer.id]) const columnConfig = layer.dataTableColumnConfig const pinnedKeys = useMemo( From dbfbba143737717bb45832e006ff3b0baffd6953 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Fri, 17 Jul 2026 22:14:41 +0200 Subject: [PATCH 079/205] feat: support tracked entity layers in the data table Fetches tracked entity attribute values via the tracker API and exposes them as data table columns, following the same headers/dataKey pattern event layers already use. --- .../datatable/__tests__/useTableData.spec.jsx | 61 ++++++++++++++++++ src/components/datatable/useTableData.js | 24 +++++++ src/constants/layers.js | 1 + .../__tests__/trackedEntityLoader.spec.js | 62 ++++++++++++++++++- src/loaders/trackedEntityLoader.js | 31 +++++++++- 5 files changed, 176 insertions(+), 3 deletions(-) diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index 17c0ae892a..35ab0fb9a1 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -285,6 +285,67 @@ describe('useTableData headers', () => { expect(isLoading).toBe(false) }) + test('gets headers and rows for tracked entity layer', () => { + const store = { + aggregations: {}, + } + const layer = { + layer: 'trackedEntity', + dataFilters: null, + headers: [ + { + name: 'First name', + dataKey: 'w75KJ2mc4zz', + valueType: 'TEXT', + }, + { + name: 'Age', + dataKey: 'zDhUuAYrxNC', + valueType: 'NUMBER', + }, + ], + data: [ + { + properties: { + id: 'PsgJS8BUxZd', + w75KJ2mc4zz: 'Gabrielle', + zDhUuAYrxNC: 28, + }, + }, + ], + } + + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + <Provider store={mockStore(store)}>{children}</Provider> + ), + } + ) + + const { headers, rows, isLoading } = result.current + expect(headers).toHaveLength(3) + expect(headers).toMatchObject([ + { name: 'Id', dataKey: 'id', type: 'string' }, + { name: 'First name', dataKey: 'w75KJ2mc4zz', type: 'string' }, + { name: 'Age', dataKey: 'zDhUuAYrxNC', type: 'number' }, + ]) + expect(rows).toHaveLength(1) + expect(rows[0]).toHaveLength(3) + expect(rows[0]).toMatchObject([ + { value: 'PsgJS8BUxZd', dataKey: 'id' }, + { value: 'Gabrielle', dataKey: 'w75KJ2mc4zz' }, + { value: 28, dataKey: 'zDhUuAYrxNC' }, + ]) + expect(isLoading).toBe(false) + }) + test('treats NUMBER header with optionSet as string type', () => { const store = { aggregations: {} } const layer = { diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index 21a20c0802..1843824033 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -9,6 +9,7 @@ import { EARTH_ENGINE_LAYER, FACILITY_LAYER, GEOJSON_URL_LAYER, + TRACKED_ENTITY_LAYER, } from '../../constants/layers.js' import { SELECTION_FILTER_SELECTED, @@ -142,6 +143,26 @@ const getOrgUnitHeaders = () => (field) => defaultFieldsMap()[field] ) +// Unlike getEventHeaders's layerHeaders (raw analytics response shape, +// name=uid/column=display), trackedEntityLoader.js already builds its +// headers in the final {name, dataKey, valueType} shape - only the +// valueType -> table type classification needs doing here. +const getTrackedEntityHeaders = ({ layerHeaders = [] }) => { + const fields = [ID].map((field) => defaultFieldsMap()[field]) + + const customFields = layerHeaders + .filter(({ dataKey }) => isValidUid(dataKey)) + .map(({ name, dataKey, valueType }) => ({ + name, + dataKey, + type: numberValueTypes.includes(valueType) + ? TYPE_NUMBER + : TYPE_STRING, + })) + + return fields.concat(customFields) +} + const getFacilityHeaders = () => [NAME, ID, TYPE].map((field) => defaultFieldsMap()[field]) @@ -287,6 +308,9 @@ export const useTableData = ({ case ORG_UNIT_LAYER: headers = getOrgUnitHeaders() break + case TRACKED_ENTITY_LAYER: + headers = getTrackedEntityHeaders({ layerHeaders }) + break case EARTH_ENGINE_LAYER: headers = getEarthEngineHeaders({ aggregationType, diff --git a/src/constants/layers.js b/src/constants/layers.js index bb31be7119..f88c1e1123 100644 --- a/src/constants/layers.js +++ b/src/constants/layers.js @@ -51,6 +51,7 @@ export const DATA_TABLE_LAYER_TYPES = [ THEMATIC_LAYER, ORG_UNIT_LAYER, EVENT_LAYER, + TRACKED_ENTITY_LAYER, EARTH_ENGINE_LAYER, GEOJSON_URL_LAYER, ] diff --git a/src/loaders/__tests__/trackedEntityLoader.spec.js b/src/loaders/__tests__/trackedEntityLoader.spec.js index 634049846e..f0b39c2fc6 100644 --- a/src/loaders/__tests__/trackedEntityLoader.spec.js +++ b/src/loaders/__tests__/trackedEntityLoader.spec.js @@ -1,9 +1,69 @@ -import { parseJsonConfig } from '../trackedEntityLoader.js' +import { + getAttributeHeaders, + getAttributeProperties, + parseJsonConfig, +} from '../trackedEntityLoader.js' jest.mock('../../components/map/MapApi.js', () => ({ loadEarthEngineWorker: jest.fn(), })) +describe('getAttributeProperties', () => { + it('maps each attribute uid to its value', () => { + const attributes = [ + { attribute: 'w75KJ2mc4zz', value: 'Gabrielle' }, + { attribute: 'zDhUuAYrxNC', value: 'Schmidt' }, + ] + expect(getAttributeProperties(attributes)).toEqual({ + w75KJ2mc4zz: 'Gabrielle', + zDhUuAYrxNC: 'Schmidt', + }) + }) + + it('returns an empty object when there are no attributes', () => { + expect(getAttributeProperties(undefined)).toEqual({}) + expect(getAttributeProperties([])).toEqual({}) + }) +}) + +describe('getAttributeHeaders', () => { + it('returns one header per unique attribute uid seen across instances', () => { + const instances = [ + { + attributes: [ + { + attribute: 'w75KJ2mc4zz', + displayName: 'First name', + valueType: 'TEXT', + }, + ], + }, + { + attributes: [ + { + attribute: 'w75KJ2mc4zz', + displayName: 'First name', + valueType: 'TEXT', + }, + { + attribute: 'zDhUuAYrxNC', + displayName: 'Last name', + valueType: 'TEXT', + }, + ], + }, + ] + expect(getAttributeHeaders(instances)).toEqual([ + { name: 'First name', dataKey: 'w75KJ2mc4zz', valueType: 'TEXT' }, + { name: 'Last name', dataKey: 'zDhUuAYrxNC', valueType: 'TEXT' }, + ]) + }) + + it('returns an empty array when no instance has attributes', () => { + expect(getAttributeHeaders([{ attributes: [] }, {}])).toEqual([]) + }) +}) + describe('parseJsonConfig', () => { it('extracts periodType when relationships is null', () => { const config = { diff --git a/src/loaders/trackedEntityLoader.js b/src/loaders/trackedEntityLoader.js index 0a30a3bdce..1f4e7a533c 100644 --- a/src/loaders/trackedEntityLoader.js +++ b/src/loaders/trackedEntityLoader.js @@ -19,7 +19,7 @@ import { import { getDataWithRelationships } from '../util/teiRelationshipsParser.js' import { trimTime, formatStartEndDate, getDateArray } from '../util/time.js' -const fields = ['trackedEntity~rename(id)', 'geometry'] +const fields = ['trackedEntity~rename(id)', 'geometry', 'attributes'] // Valid geometry types for TEIs const teiGeometryTypes = new Set([ @@ -100,12 +100,36 @@ const TRACKED_ENTITY_TYPES_QUERY = { }, } +export const getAttributeProperties = (attributes) => + Object.fromEntries( + (attributes ?? []).map(({ attribute, value }) => [attribute, value]) + ) + +// One header per unique attribute uid seen across all instances - not every +// instance necessarily has a value for every attribute. +export const getAttributeHeaders = (instances) => { + const headersByAttribute = new Map() + instances.forEach(({ attributes }) => { + ;(attributes ?? []).forEach(({ attribute, displayName, valueType }) => { + if (!headersByAttribute.has(attribute)) { + headersByAttribute.set(attribute, { + name: displayName, + dataKey: attribute, + valueType, + }) + } + }) + }) + return [...headersByAttribute.values()] +} + const toGeoJson = (instances) => - instances.map(({ id, geometry }) => ({ + instances.map(({ id, geometry, attributes }) => ({ type: GEO_TYPE_FEATURE, geometry, properties: { id, + ...getAttributeProperties(attributes), }, })) @@ -326,6 +350,8 @@ const trackedEntityLoader = async ({ instance.geometry?.coordinates ) + const headers = getAttributeHeaders(instances) + let alert if (!instances.length) { @@ -362,6 +388,7 @@ const trackedEntityLoader = async ({ ...config, name, data, + headers, keyAnalysisDigitGroupSeparator, relationships, secondaryData, From d85ff89a5479f3e36435bd2062ee02d9950a0511 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Fri, 17 Jul 2026 22:45:12 +0200 Subject: [PATCH 080/205] feat: support timeline/split-by-period thematic layers in the data table Timeline layers get a Value/Legend/Range/Color column for the currently active period, updating live as the slider moves. Both timeline and split-by-period layers can add extra, user-picked period columns via a new "Periods" section in the column picker - split has no default period column since it has no single "current" period the way timeline does. --- i18n/en.pot | 13 +- src/components/datatable/BottomPanel.jsx | 2 + .../__tests__/ColumnPickerControl.spec.jsx | 114 +++++++++++ .../datatable/__tests__/useTableData.spec.jsx | 181 ++++++++++++++++++ .../controls/ColumnPickerControl.jsx | 74 ++++++- .../styles/ColumnPickerControl.module.css | 47 +++++ src/components/datatable/useTableData.js | 117 ++++++++++- src/loaders/thematicLoader.js | 4 + src/util/__tests__/tableColumns.spec.js | 16 ++ src/util/tableColumns.js | 5 + 10 files changed, 563 insertions(+), 10 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 10b0dbb274..bace2e893a 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-17T19:19:08.730Z\n" -"PO-Revision-Date: 2026-07-17T19:19:08.731Z\n" +"POT-Creation-Date: 2026-07-17T20:38:05.509Z\n" +"PO-Revision-Date: 2026-07-17T20:38:05.509Z\n" msgid "2020" msgstr "2020" @@ -293,6 +293,9 @@ msgstr "Select all columns" msgid "Reset to defaults" msgstr "Reset to defaults" +msgid "Add period columns" +msgstr "Add period columns" + msgid "Drag to reorder" msgstr "Drag to reorder" @@ -363,6 +366,12 @@ msgstr "Org unit boundary" msgid "Event time" msgstr "Event time" +msgid "Current period" +msgstr "Current period" + +msgid "Value ({{period}})" +msgstr "Value ({{period}})" + msgid "Loading Earth Engine data…" msgstr "Loading Earth Engine data…" diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index 44cd617e8d..dcd8f117e8 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -207,6 +207,8 @@ const BottomPanel = () => { layerId={activeLayerId} allHeaders={allHeaders} columnConfig={activeLayer?.dataTableColumnConfig} + renderingStrategy={activeLayer?.renderingStrategy} + periods={activeLayer?.periods} /> <span className={styles.divider} /> <ResizeHandleControl diff --git a/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx b/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx index 94c25351a7..942f50eb48 100644 --- a/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx +++ b/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx @@ -3,6 +3,10 @@ import React from 'react' import { Provider } from 'react-redux' import configureMockStore from 'redux-mock-store' import { DATA_TABLE_COLUMN_CONFIG_SET } from '../../../constants/actionTypes.js' +import { + RENDERING_STRATEGY_SPLIT_BY_PERIOD, + RENDERING_STRATEGY_TIMELINE, +} from '../../../constants/layers.js' import ColumnPickerControl from '../controls/ColumnPickerControl.jsx' const mockStore = configureMockStore() @@ -79,6 +83,7 @@ describe('ColumnPicker visibility toggling', () => { visibleKeys: ['name', 'legend'], pinnedKeys: [], orderedKeys: ['name', 'rawValue', 'legend'], + extraPeriodIds: [], }, }) }) @@ -97,6 +102,7 @@ describe('ColumnPicker visibility toggling', () => { visibleKeys: ['name', 'legend', 'rawValue'], pinnedKeys: [], orderedKeys: ['name', 'rawValue', 'legend'], + extraPeriodIds: [], }, }) }) @@ -116,6 +122,7 @@ describe('ColumnPicker pinning', () => { visibleKeys: ['name', 'rawValue', 'legend'], pinnedKeys: ['rawValue'], orderedKeys: ['name', 'rawValue', 'legend'], + extraPeriodIds: [], }, }) }) @@ -135,6 +142,7 @@ describe('ColumnPicker pinning', () => { visibleKeys: ['name', 'rawValue', 'legend'], pinnedKeys: [], orderedKeys: ['name', 'rawValue', 'legend'], + extraPeriodIds: [], }, }) }) @@ -189,6 +197,7 @@ describe('ColumnPicker bulk actions', () => { visibleKeys: ['name', 'rawValue', 'legend'], pinnedKeys: ['legend'], orderedKeys: ['name', 'rawValue', 'legend'], + extraPeriodIds: [], }, }) }) @@ -208,6 +217,7 @@ describe('ColumnPicker bulk actions', () => { visibleKeys: [], pinnedKeys: ['legend'], orderedKeys: ['name', 'rawValue', 'legend'], + extraPeriodIds: [], }, }) }) @@ -225,6 +235,7 @@ describe('ColumnPicker bulk actions', () => { visibleKeys: ['rawValue', 'legend'], pinnedKeys: ['legend'], orderedKeys: ['name', 'rawValue', 'legend'], + extraPeriodIds: [], }, }) }) @@ -351,6 +362,109 @@ describe('ColumnPicker search', () => { visibleKeys: ['name', 'rawValue', 'legend'], pinnedKeys: [], orderedKeys: ['name', 'rawValue', 'legend'], + extraPeriodIds: [], + }, + }) + }) +}) + +describe('ColumnPicker periods section', () => { + const periods = [ + { id: '202301', name: 'January 2023' }, + { id: '202302', name: 'February 2023' }, + ] + + test('is absent for a single-period (non-multi-period) layer', () => { + renderColumnPicker({ periods }) + openPicker() + expect(screen.queryByText('January 2023')).not.toBeInTheDocument() + }) + + test('is absent when there are no available periods', () => { + renderColumnPicker({ renderingStrategy: RENDERING_STRATEGY_TIMELINE }) + openPicker() + expect(screen.queryByText('Add period columns')).not.toBeInTheDocument() + }) + + test('lists available periods for a timeline layer', () => { + renderColumnPicker({ + renderingStrategy: RENDERING_STRATEGY_TIMELINE, + periods, + }) + openPicker() + expect(screen.getByText('Add period columns')).toBeInTheDocument() + expect(screen.getByLabelText('January 2023')).not.toBeChecked() + expect(screen.getByLabelText('February 2023')).not.toBeChecked() + }) + + test('lists available periods for a split-by-period layer too', () => { + renderColumnPicker({ + renderingStrategy: RENDERING_STRATEGY_SPLIT_BY_PERIOD, + periods, + }) + openPicker() + expect(screen.getByText('Add period columns')).toBeInTheDocument() + }) + + test('checking a period dispatches extraPeriodIds with it added', () => { + const { store } = renderColumnPicker({ + renderingStrategy: RENDERING_STRATEGY_TIMELINE, + periods, + }) + openPicker() + fireEvent.click(screen.getByLabelText('January 2023')) + expect(store.getActions()).toContainEqual({ + type: DATA_TABLE_COLUMN_CONFIG_SET, + layerId: 'layer1', + config: { + visibleKeys: ['name', 'rawValue', 'legend'], + pinnedKeys: [], + orderedKeys: ['name', 'rawValue', 'legend'], + extraPeriodIds: ['202301'], + }, + }) + }) + + test('checking a period also adds its dataKey to an already-customized visibleKeys allowlist', () => { + // visibleKeys, once customized, acts as an allowlist (getVisibleHeaders + // filters out anything not in it) - the new period column's dataKey + // must be added too, or it would never actually render. + const { store } = renderColumnPicker({ + renderingStrategy: RENDERING_STRATEGY_TIMELINE, + periods, + columnConfig: { visibleKeys: ['name'] }, + }) + openPicker() + fireEvent.click(screen.getByLabelText('January 2023')) + expect(store.getActions()).toContainEqual({ + type: DATA_TABLE_COLUMN_CONFIG_SET, + layerId: 'layer1', + config: { + visibleKeys: ['name', 'period_202301_rawValue'], + pinnedKeys: [], + orderedKeys: ['name', 'rawValue', 'legend'], + extraPeriodIds: ['202301'], + }, + }) + }) + + test('unchecking an already-added period dispatches extraPeriodIds without it', () => { + const { store } = renderColumnPicker({ + renderingStrategy: RENDERING_STRATEGY_TIMELINE, + periods, + columnConfig: { extraPeriodIds: ['202301', '202302'] }, + }) + openPicker() + expect(screen.getByLabelText('January 2023')).toBeChecked() + fireEvent.click(screen.getByLabelText('January 2023')) + expect(store.getActions()).toContainEqual({ + type: DATA_TABLE_COLUMN_CONFIG_SET, + layerId: 'layer1', + config: { + visibleKeys: ['name', 'rawValue', 'legend'], + pinnedKeys: [], + orderedKeys: ['name', 'rawValue', 'legend'], + extraPeriodIds: ['202302'], }, }) }) diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index 35ab0fb9a1..8620d18f65 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -189,6 +189,187 @@ describe('useTableData headers', () => { expect(isLoading).toBe(false) }) + test('gets current-period Value/Legend/Range/Color for a timeline thematic layer', () => { + const store = { aggregations: {} } + const layer = { + layer: 'thematic', + renderingStrategy: 'TIMELINE', + externalPeriod: { id: '202302', name: 'February 2023' }, + valuesByPeriod: { + 202301: { + 'ou-1': { value: 100, color: '#aaaaaa', legend: 'Low' }, + }, + 202302: { + 'ou-1': { + value: 200, + color: '#bbbbbb', + legend: 'High', + range: '150 – 250', + }, + }, + }, + dataFilters: null, + data: [ + { + properties: { + id: 'ou-1', + name: 'Ngelehun CHC', + type: 'Point', + }, + }, + ], + } + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + <Provider store={mockStore(store)}>{children}</Provider> + ), + } + ) + const { headers, rows } = result.current + expect(headers).toMatchObject([ + { name: 'Name', dataKey: 'name' }, + { name: 'Id', dataKey: 'id' }, + { name: 'Value (February 2023)', dataKey: 'rawValue' }, + { name: 'Legend (February 2023)', dataKey: 'legend' }, + { name: 'Range (February 2023)', dataKey: 'range' }, + { name: 'Level', dataKey: 'level' }, + { name: 'Parent', dataKey: 'parentName' }, + { name: 'Type', dataKey: 'type' }, + { name: 'Color (February 2023)', dataKey: 'color' }, + ]) + expect(rows[0]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ value: 200, dataKey: 'rawValue' }), + expect.objectContaining({ value: 'High', dataKey: 'legend' }), + expect.objectContaining({ + value: '150 – 250', + dataKey: 'range', + }), + ]) + ) + }) + + test('adds a raw-value-only extra period column for a timeline thematic layer', () => { + const store = { aggregations: {} } + const layer = { + layer: 'thematic', + renderingStrategy: 'TIMELINE', + externalPeriod: { id: '202302', name: 'February 2023' }, + periods: [ + { id: '202301', name: 'January 2023' }, + { id: '202302', name: 'February 2023' }, + ], + dataTableColumnConfig: { extraPeriodIds: ['202301'] }, + valuesByPeriod: { + 202301: { 'ou-1': { value: 100 } }, + 202302: { 'ou-1': { value: 200 } }, + }, + dataFilters: null, + data: [ + { + properties: { + id: 'ou-1', + name: 'Ngelehun CHC', + type: 'Point', + }, + }, + ], + } + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + <Provider store={mockStore(store)}>{children}</Provider> + ), + } + ) + const { headers, rows } = result.current + expect(headers).toContainEqual({ + name: 'Value (January 2023)', + dataKey: 'period_202301_rawValue', + type: 'number', + }) + expect(rows[0]).toContainEqual( + expect.objectContaining({ + value: 100, + dataKey: 'period_202301_rawValue', + }) + ) + }) + + test('split-by-period thematic layer has no default current-period column, only extras', () => { + const store = { aggregations: {} } + const layer = { + layer: 'thematic', + renderingStrategy: 'SPLIT_BY_PERIOD', + externalPeriod: { id: '202302', name: 'February 2023' }, + periods: [{ id: '202301', name: 'January 2023' }], + dataTableColumnConfig: { extraPeriodIds: ['202301'] }, + valuesByPeriod: { + 202301: { 'ou-1': { value: 100 } }, + 202302: { + 'ou-1': { value: 200, color: '#bbbbbb', legend: 'High' }, + }, + }, + dataFilters: null, + data: [ + { + properties: { + id: 'ou-1', + name: 'Ngelehun CHC', + type: 'Point', + }, + }, + ], + } + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + <Provider store={mockStore(store)}>{children}</Provider> + ), + } + ) + const { headers, rows } = result.current + expect(headers).toMatchObject([ + { name: 'Name', dataKey: 'name' }, + { name: 'Id', dataKey: 'id' }, + { name: 'Level', dataKey: 'level' }, + { name: 'Parent', dataKey: 'parentName' }, + { name: 'Type', dataKey: 'type' }, + { + name: 'Value (January 2023)', + dataKey: 'period_202301_rawValue', + }, + ]) + expect(rows[0]).not.toContainEqual( + expect.objectContaining({ dataKey: 'rawValue' }) + ) + expect(rows[0]).toContainEqual( + expect.objectContaining({ + value: 100, + dataKey: 'period_202301_rawValue', + }) + ) + }) + test('gets headers and rows for event layer', () => { const store = { aggregations: {}, diff --git a/src/components/datatable/controls/ColumnPickerControl.jsx b/src/components/datatable/controls/ColumnPickerControl.jsx index 6f9e52ad61..2033bf59c7 100644 --- a/src/components/datatable/controls/ColumnPickerControl.jsx +++ b/src/components/datatable/controls/ColumnPickerControl.jsx @@ -23,11 +23,13 @@ import React, { useCallback, useLayoutEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { useDispatch } from 'react-redux' import { setDataTableColumnConfig } from '../../../actions/dataTable.js' +import { RENDERING_STRATEGY_SINGLE } from '../../../constants/layers.js' import { getPinnedCount, getVisibleHeaders, isPinnedGroupEnd, reverseVisibleKeys, + togglePeriodId, togglePinnedKey, toggleVisibleKey, } from '../../../util/tableColumns.js' @@ -38,7 +40,13 @@ import ToolbarIconButton from './ToolbarIconButton.jsx' const DRAG_OVERLAY_Z_INDEX = 2100 -const ColumnPickerControl = ({ layerId, allHeaders, columnConfig }) => { +const ColumnPickerControl = ({ + layerId, + allHeaders, + columnConfig, + renderingStrategy, + periods, +}) => { const dispatch = useDispatch() const anchorRef = useRef(null) const [isOpen, setIsOpen] = useState(false) @@ -79,6 +87,9 @@ const ColumnPickerControl = ({ layerId, allHeaders, columnConfig }) => { }) const pinnedCount = getPinnedCount(orderedHeaders, pinnedKeys) + const extraPeriodIds = columnConfig?.extraPeriodIds ?? [] + const isMultiPeriodThematic = + renderingStrategy && renderingStrategy !== RENDERING_STRATEGY_SINGLE const updateConfig = (partial) => dispatch( @@ -86,6 +97,7 @@ const ColumnPickerControl = ({ layerId, allHeaders, columnConfig }) => { visibleKeys, pinnedKeys, orderedKeys, + extraPeriodIds, ...partial, }) ) @@ -112,6 +124,25 @@ const ColumnPickerControl = ({ layerId, allHeaders, columnConfig }) => { const onResetToDefaults = () => dispatch(setDataTableColumnConfig(layerId, undefined)) + const onTogglePeriodId = (periodId) => { + const isAdding = !extraPeriodIds.includes(periodId) + updateConfig({ + extraPeriodIds: togglePeriodId(extraPeriodIds, periodId), + // A newly-added period's column only has a header once + // useTableData sees the updated extraPeriodIds - but + // visibleKeys, once customized, is an allowlist, so its new + // dataKey needs adding here too or the column would never + // actually render. + ...(isAdding && + columnConfig?.visibleKeys && { + visibleKeys: [ + ...visibleKeys, + `period_${periodId}_rawValue`, + ], + }), + }) + } + const filteredHeaders = orderedHeaders.filter((h) => h.name.toLowerCase().includes(search.trim().toLowerCase()) ) @@ -287,6 +318,39 @@ const ColumnPickerControl = ({ layerId, allHeaders, columnConfig }) => { document.body )} </DndContext> + {isMultiPeriodThematic && periods?.length > 0 && ( + <div className={styles.periodsSection}> + <p className={styles.periodsSectionLabel}> + {i18n.t('Add period columns')} + </p> + <div className={styles.periodsList}> + {periods.map(({ id, name }) => ( + <label + key={id} + className={styles.periodRow} + > + <input + type="checkbox" + checked={extraPeriodIds.includes( + id + )} + onChange={() => + onTogglePeriodId(id) + } + data-test={`data-table-column-picker-period-${id}`} + /> + <span + className={ + styles.periodRowLabel + } + > + {name} + </span> + </label> + ))} + </div> + </div> + )} </div> </FilterDropdownPopover> )} @@ -303,10 +367,18 @@ ColumnPickerControl.propTypes = { }) ), columnConfig: PropTypes.shape({ + extraPeriodIds: PropTypes.arrayOf(PropTypes.string), orderedKeys: PropTypes.arrayOf(PropTypes.string), pinnedKeys: PropTypes.arrayOf(PropTypes.string), visibleKeys: PropTypes.arrayOf(PropTypes.string), }), + periods: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string, + name: PropTypes.string, + }) + ), + renderingStrategy: PropTypes.string, } export default ColumnPickerControl diff --git a/src/components/datatable/controls/styles/ColumnPickerControl.module.css b/src/components/datatable/controls/styles/ColumnPickerControl.module.css index fa974e6fd3..6c56ce200e 100644 --- a/src/components/datatable/controls/styles/ColumnPickerControl.module.css +++ b/src/components/datatable/controls/styles/ColumnPickerControl.module.css @@ -137,3 +137,50 @@ background-color: var(--colors-white); box-shadow: var(--elevations-popover); } + +.periodsSection { + margin-top: var(--spacers-dp8); + padding-top: var(--spacers-dp8); + border-top: 1px solid var(--colors-grey300); +} + +.periodsSectionLabel { + margin: 0 0 var(--spacers-dp4); + font-size: 11px; + font-weight: 600; + color: var(--colors-grey700); +} + +.periodsList { + display: flex; + flex-direction: column; + max-height: 150px; + overflow-y: auto; +} + +.periodRow { + display: flex; + align-items: center; + gap: var(--spacers-dp4); + padding: var(--spacers-dp2) var(--spacers-dp4); + border-radius: 3px; + cursor: pointer; +} + +.periodRow:hover { + background: var(--colors-grey100); +} + +.periodRow input[type='checkbox'] { + flex-shrink: 0; + accent-color: var(--colors-teal600); +} + +.periodRowLabel { + flex: 1; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: 12px; +} diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index 1843824033..bc9a24ecbe 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -10,6 +10,8 @@ import { FACILITY_LAYER, GEOJSON_URL_LAYER, TRACKED_ENTITY_LAYER, + RENDERING_STRATEGY_SINGLE, + RENDERING_STRATEGY_TIMELINE, } from '../../constants/layers.js' import { SELECTION_FILTER_SELECTED, @@ -104,6 +106,44 @@ const getThematicHeaders = () => (field) => defaultFieldsMap()[field] ) +// Timeline gets the standard Value/Legend/Range/Color columns, relabeled +// with the active period's name (updates live as the timeline slider +// moves). Split-by-period has no single "current" period to privilege, so +// it only gets the base org unit columns - same shape as getOrgUnitHeaders. +// Both strategies can add extra, raw-value-only period columns via the +// column picker's "Periods" section. +const getMultiPeriodThematicHeaders = ({ + isTimelineThematic, + externalPeriod, + extraPeriodIds, + periods, +}) => { + const headers = isTimelineThematic + ? getThematicHeaders().map((header) => + [VALUE, LEGEND, RANGE, COLOR].includes(header.dataKey) + ? { + ...header, + name: `${header.name} (${ + externalPeriod?.name ?? i18n.t('Current period') + })`, + } + : header + ) + : getOrgUnitHeaders() + + extraPeriodIds.forEach((periodId) => { + const periodName = + periods?.find((p) => p.id === periodId)?.name ?? periodId + headers.push({ + name: i18n.t('Value ({{period}})', { period: periodName }), + dataKey: `period_${periodId}_rawValue`, + type: TYPE_NUMBER, + }) + }) + + return headers +} + const getEventHeaders = ({ layerHeaders = [], styleDataItem, @@ -238,8 +278,25 @@ export const useTableData = ({ dataFilters, headers: layerHeaders, serverCluster, + renderingStrategy, + valuesByPeriod, + externalPeriod, + periods, + dataTableColumnConfig, } = layer || EMPTY_LAYER + const isMultiPeriodThematic = + layerType === THEMATIC_LAYER && + renderingStrategy && + renderingStrategy !== RENDERING_STRATEGY_SINGLE + const isTimelineThematic = + isMultiPeriodThematic && + renderingStrategy === RENDERING_STRATEGY_TIMELINE + const extraPeriodIds = useMemo( + () => dataTableColumnConfig?.extraPeriodIds ?? [], + [dataTableColumnConfig] + ) + const boundsDependency = showOnlyFeaturesInView ? mapBounds : null const dataWithAggregations = useMemo(() => { @@ -270,12 +327,41 @@ export const useTableData = ({ return inViewData .filter((d) => !d.properties.hasAdditionalGeometry) - .map((d, index) => ({ - ...(d.properties || d), - ...aggregations[d.id], - // Row-order tie-breaker for compareRows when no sortField is set - index, - })) + .map((d, index) => { + const properties = d.properties || d + + if (!isMultiPeriodThematic) { + return { + ...properties, + ...aggregations[d.id], + // Row-order tie-breaker for compareRows when no sortField is set + index, + } + } + + const orgUnitId = properties.id + const currentPeriodItem = isTimelineThematic + ? valuesByPeriod?.[externalPeriod?.id]?.[orgUnitId] + : null + const extraPeriodValues = {} + extraPeriodIds.forEach((pid) => { + extraPeriodValues[`period_${pid}_rawValue`] = + valuesByPeriod?.[pid]?.[orgUnitId]?.value ?? null + }) + + return { + ...properties, + ...(currentPeriodItem && { + rawValue: currentPeriodItem.value, + color: currentPeriodItem.color, + legend: currentPeriodItem.legend, + range: currentPeriodItem.range, + }), + ...extraPeriodValues, + ...aggregations[d.id], + index, + } + }) // boundsDependency intentionally proxies mapBounds only while the toggle is on // eslint-disable-next-line react-hooks/exhaustive-deps }, [ @@ -286,6 +372,11 @@ export const useTableData = ({ layerType, showOnlyFeaturesInView, boundsDependency, + isMultiPeriodThematic, + isTimelineThematic, + valuesByPeriod, + externalPeriod, + extraPeriodIds, ]) const headers = useMemo(() => { @@ -296,7 +387,14 @@ export const useTableData = ({ let headers = null switch (layerType) { case THEMATIC_LAYER: - headers = getThematicHeaders() + headers = isMultiPeriodThematic + ? getMultiPeriodThematicHeaders({ + isTimelineThematic, + externalPeriod, + extraPeriodIds, + periods, + }) + : getThematicHeaders() break case EVENT_LAYER: headers = getEventHeaders({ @@ -353,6 +451,11 @@ export const useTableData = ({ dataWithAggregations, data, layerHeaders, + isMultiPeriodThematic, + isTimelineThematic, + externalPeriod, + extraPeriodIds, + periods, ]) const columnOptions = useMemo(() => { diff --git a/src/loaders/thematicLoader.js b/src/loaders/thematicLoader.js index 1e3dba4a08..5d01d4fdbd 100644 --- a/src/loaders/thematicLoader.js +++ b/src/loaders/thematicLoader.js @@ -544,6 +544,10 @@ const thematicLoader = async ({ isNoData, isUnclassified, }), + ...getFeatureLegend(legendItem, { + isNoData, + isUnclassified, + }), ...getFeatureRadius( legendItem, { isNoData, isUnclassified }, diff --git a/src/util/__tests__/tableColumns.spec.js b/src/util/__tests__/tableColumns.spec.js index 77db56a701..b76c71825b 100644 --- a/src/util/__tests__/tableColumns.spec.js +++ b/src/util/__tests__/tableColumns.spec.js @@ -5,6 +5,7 @@ import { getVisibleHeaders, isPinnedGroupEnd, reverseVisibleKeys, + togglePeriodId, togglePinnedKey, toggleVisibleKey, } from '../tableColumns.js' @@ -301,3 +302,18 @@ describe('getPinnedCellProps', () => { }) }) }) + +describe('togglePeriodId', () => { + it('adds a period id when it is not yet added', () => { + expect(togglePeriodId(['202301'], '202302')).toEqual([ + '202301', + '202302', + ]) + }) + + it('removes a period id when it is already added', () => { + expect(togglePeriodId(['202301', '202302'], '202301')).toEqual([ + '202302', + ]) + }) +}) diff --git a/src/util/tableColumns.js b/src/util/tableColumns.js index b6e86f87c3..8980ae0a0e 100644 --- a/src/util/tableColumns.js +++ b/src/util/tableColumns.js @@ -68,6 +68,11 @@ export const reverseVisibleKeys = (headers, visibleKeys) => .filter((h) => !visibleKeys.includes(h.dataKey)) .map((h) => h.dataKey) +export const togglePeriodId = (extraPeriodIds, periodId) => + extraPeriodIds.includes(periodId) + ? extraPeriodIds.filter((id) => id !== periodId) + : [...extraPeriodIds, periodId] + // @dhis2/ui requires `width` whenever `fixed` is passed export const getPinnedCellProps = ( dataKey, From aaeb144592d8a3007416ec5fd5c3505e26b1e575 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Sat, 18 Jul 2026 05:37:17 +0200 Subject: [PATCH 081/205] feat: add Legend/Range columns for data-item-styled event layers Event layers styled by a data item already computed per-feature color/colorGroup; this surfaces the matching legend item's name/range as data table columns too, alongside the existing Color column. --- src/components/datatable/DataTable.jsx | 1 + .../datatable/__tests__/useTableData.spec.jsx | 186 ++++++++++++++++++ src/components/datatable/useTableData.js | 40 +++- 3 files changed, 225 insertions(+), 2 deletions(-) diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index 729053a30b..0fcfb68480 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -177,6 +177,7 @@ const Table = ({ selectionFilter, selectedIdSet, globalSearch, + keyAnalysisDigitGroupSeparator, }) useEffect(() => { diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index 8620d18f65..45d9bb2847 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -618,6 +618,192 @@ describe('useTableData headers', () => { expect(scoreHeader.type).toBe('number') }) + test('adds Legend/Range/Color columns for an event layer styled by a numeric data item', () => { + const store = { aggregations: {} } + const layer = { + layer: 'event', + dataFilters: null, + isExtended: true, + styleDataItem: { id: 'AbCdEfGhIjK' }, + legend: { + items: [ + { + name: 'Low', + color: '#aaaaaa', + startValue: 0, + endValue: 50, + colorGroup: 0, + }, + { + name: 'High', + color: '#bbbbbb', + startValue: 50, + endValue: 100, + colorGroup: 1, + }, + ], + }, + headers: [ + { name: 'AbCdEfGhIjK', column: 'Score', valueType: 'NUMBER' }, + ], + data: [ + { + properties: { + id: 'evt1', + type: 'Point', + ouname: 'Test OU', + eventdate: '2023-01-01', + AbCdEfGhIjK: 75, + value: 75, + color: '#bbbbbb', + colorGroup: 1, + }, + }, + ], + } + + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + <Provider store={mockStore(store)}>{children}</Provider> + ), + } + ) + + const { headers, rows } = result.current + expect(headers).toContainEqual({ + name: 'Legend', + dataKey: 'legend', + type: 'string', + }) + expect(headers).toContainEqual({ + name: 'Range', + dataKey: 'range', + type: 'string', + }) + expect(rows[0]).toContainEqual( + expect.objectContaining({ value: 'High', dataKey: 'legend' }) + ) + expect(rows[0]).toContainEqual( + expect.objectContaining({ value: '50 – 100', dataKey: 'range' }) + ) + }) + + test('formats an event layer’s Range using the layer’s own legendDecimalPlaces', () => { + const store = { aggregations: {} } + const layer = { + layer: 'event', + dataFilters: null, + isExtended: true, + styleDataItem: { id: 'AbCdEfGhIjK' }, + legendDecimalPlaces: 1, + legend: { + items: [ + { + name: 'High', + color: '#bbbbbb', + startValue: 50.256, + endValue: 100.789, + colorGroup: 0, + }, + ], + }, + headers: [ + { name: 'AbCdEfGhIjK', column: 'Score', valueType: 'NUMBER' }, + ], + data: [ + { + properties: { + id: 'evt1', + type: 'Point', + ouname: 'Test OU', + eventdate: '2023-01-01', + AbCdEfGhIjK: 75, + value: 75, + color: '#bbbbbb', + colorGroup: 0, + }, + }, + ], + } + + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + <Provider store={mockStore(store)}>{children}</Provider> + ), + } + ) + + expect(result.current.rows[0]).toContainEqual( + expect.objectContaining({ value: '50.3 – 100.8', dataKey: 'range' }) + ) + }) + + test('leaves Range empty for an event layer styled by a non-numeric (option set) data item', () => { + const store = { aggregations: {} } + const layer = { + layer: 'event', + dataFilters: null, + isExtended: true, + styleDataItem: { id: 'AbCdEfGhIjK', optionSet: { id: 'os1' } }, + legend: { + items: [{ name: 'Yes', color: '#00ff00', colorGroup: 0 }], + }, + headers: [ + { name: 'AbCdEfGhIjK', column: 'Answer', valueType: 'TEXT' }, + ], + data: [ + { + properties: { + id: 'evt1', + type: 'Point', + ouname: 'Test OU', + eventdate: '2023-01-01', + AbCdEfGhIjK: 'Yes', + value: 'Yes', + color: '#00ff00', + colorGroup: 0, + }, + }, + ], + } + + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + <Provider store={mockStore(store)}>{children}</Provider> + ), + } + ) + + const { rows } = result.current + expect(rows[0]).toContainEqual( + expect.objectContaining({ value: 'Yes', dataKey: 'legend' }) + ) + expect(rows[0]).toContainEqual( + expect.objectContaining({ value: undefined, dataKey: 'range' }) + ) + }) + test('gets headers and rows for EE population layer', () => { const store = { aggregations: { diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index bc9a24ecbe..70f0ac47aa 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -21,7 +21,11 @@ import { numberValueTypes } from '../../constants/valueTypes.js' import { hasClasses } from '../../util/earthEngine.js' import { filterByGlobalSearch, filterData } from '../../util/filter.js' import { getGeojsonDisplayData, isFeatureInBounds } from '../../util/geojson.js' -import { getRoundToPrecisionFn, getPrecision } from '../../util/numbers.js' +import { + formatRangeWithSeparator, + getRoundToPrecisionFn, + getPrecision, +} from '../../util/numbers.js' import { compareColumnOptionValues, compareRows } from '../../util/tableSort.js' import { isValidUid } from '../../util/uid.js' @@ -172,7 +176,11 @@ const getEventHeaders = ({ customFields.push(defaultFieldsMap()[TYPE]) if (styleDataItem) { - customFields.push(defaultFieldsMap()[COLOR]) + customFields.push( + defaultFieldsMap()[LEGEND], + defaultFieldsMap()[RANGE], + defaultFieldsMap()[COLOR] + ) } return fields.concat(customFields) @@ -261,6 +269,7 @@ export const useTableData = ({ selectionFilter, selectedIdSet, globalSearch, + keyAnalysisDigitGroupSeparator, }) => { const allAggregations = useSelector((state) => state.aggregations) const aggregations = allAggregations[layer.id] || EMPTY_AGGREGATIONS @@ -283,6 +292,7 @@ export const useTableData = ({ externalPeriod, periods, dataTableColumnConfig, + legendDecimalPlaces, } = layer || EMPTY_LAYER const isMultiPeriodThematic = @@ -296,6 +306,7 @@ export const useTableData = ({ () => dataTableColumnConfig?.extraPeriodIds ?? [], [dataTableColumnConfig] ) + const isStyledEvent = layerType === EVENT_LAYER && !!styleDataItem const boundsDependency = showOnlyFeaturesInView ? mapBounds : null @@ -330,6 +341,27 @@ export const useTableData = ({ .map((d, index) => { const properties = d.properties || d + if (isStyledEvent) { + // The event's own styling pass already classified this + // feature into legend.items[colorGroup] (color/radius) - + // Legend/Range are just a lookup, not new classification. + const legendItem = legend?.items?.[properties.colorGroup] + return { + ...properties, + legend: legendItem?.name, + range: + legendItem && 'startValue' in legendItem + ? formatRangeWithSeparator( + legendItem, + keyAnalysisDigitGroupSeparator, + { precision: legendDecimalPlaces } + ) + : undefined, + ...aggregations[d.id], + index, + } + } + if (!isMultiPeriodThematic) { return { ...properties, @@ -377,6 +409,10 @@ export const useTableData = ({ valuesByPeriod, externalPeriod, extraPeriodIds, + isStyledEvent, + legend, + keyAnalysisDigitGroupSeparator, + legendDecimalPlaces, ]) const headers = useMemo(() => { From 4cc1fc76ee0d2b24f8b0bfbb59352ee10c58b573 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Sat, 18 Jul 2026 05:49:23 +0200 Subject: [PATCH 082/205] feat: add Color/Icon/Group columns for group-set-styled org unit and facility layers Surfaces the color/icon/group data group-set styling already computes per feature as data table columns, data-driven so each column only appears when the current styling actually produced it. Adds a new image-thumbnail cell type to the data table for the Icon column. --- i18n/en.pot | 10 +- src/components/datatable/DataTable.jsx | 25 +++-- .../datatable/__tests__/useTableData.spec.jsx | 100 ++++++++++++++++++ .../datatable/styles/DataTable.module.css | 7 ++ src/components/datatable/useTableData.js | 45 ++++++-- src/util/__tests__/orgUnits.spec.js | 1 + src/util/orgUnits.js | 13 ++- 7 files changed, 181 insertions(+), 20 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index bace2e893a..96b0c39574 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-17T20:38:05.509Z\n" -"PO-Revision-Date: 2026-07-17T20:38:05.509Z\n" +"POT-Creation-Date: 2026-07-18T03:48:43.826Z\n" +"PO-Revision-Date: 2026-07-18T03:48:43.826Z\n" msgid "2020" msgstr "2020" @@ -366,6 +366,12 @@ msgstr "Org unit boundary" msgid "Event time" msgstr "Event time" +msgid "Group" +msgstr "Group" + +msgid "Icon" +msgstr "Icon" + msgid "Current period" msgstr "Current period" diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index 0fcfb68480..bb85c7a3b9 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -588,12 +588,25 @@ const Table = ({ } align={align} > - {dataKey === 'color' - ? value?.toLowerCase() - : formatWithSeparator( - value, - keyAnalysisDigitGroupSeparator - )} + {dataKey === 'color' && + value?.toLowerCase()} + {dataKey === 'iconUrl' && value && ( + <img + className={styles.iconCell} + src={value} + alt="" + onError={(e) => { + e.target.style.visibility = + 'hidden' + }} + /> + )} + {dataKey !== 'color' && + dataKey !== 'iconUrl' && + formatWithSeparator( + value, + keyAnalysisDigitGroupSeparator + )} </DataTableCell> ) })} diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index 45d9bb2847..b2b4f74129 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -64,6 +64,106 @@ describe('useTableData headers', () => { expect(isLoading).toBe(false) }) + test('adds an Icon column for a facility layer styled by group set symbol', () => { + const store = { aggregations: {} } + const layer = { + layer: 'facility', + dataFilters: null, + data: [ + { + properties: { + id: 'facility-1', + name: 'Facility 1', + type: 'Point', + iconUrl: 'https://server/images/orgunitgroup/1.png', + group: 'Hospitals', + }, + }, + ], + } + + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + <Provider store={mockStore(store)}>{children}</Provider> + ), + } + ) + + const { headers, rows } = result.current + expect(headers).toContainEqual({ + name: 'Icon', + dataKey: 'iconUrl', + type: 'string', + renderer: 'rendericon', + }) + expect(headers).toContainEqual({ + name: 'Group', + dataKey: 'group', + type: 'string', + }) + expect(headers).not.toContainEqual( + expect.objectContaining({ dataKey: 'color' }) + ) + expect(rows[0]).toContainEqual( + expect.objectContaining({ + value: 'https://server/images/orgunitgroup/1.png', + dataKey: 'iconUrl', + }) + ) + }) + + test('adds a Color column for an orgUnit layer styled by group set color', () => { + const store = { aggregations: {} } + const layer = { + layer: 'orgUnit', + dataFilters: null, + data: [ + { + properties: { + id: 'ou-1', + name: 'Bo District', + type: 'MultiPolygon', + level: 2, + color: '#ff0000', + group: 'Rural', + }, + }, + ], + } + + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + <Provider store={mockStore(store)}>{children}</Provider> + ), + } + ) + + const { headers } = result.current + expect(headers).toContainEqual( + expect.objectContaining({ name: 'Color', dataKey: 'color' }) + ) + expect(headers).toContainEqual( + expect.objectContaining({ name: 'Group', dataKey: 'group' }) + ) + expect(headers).not.toContainEqual( + expect.objectContaining({ dataKey: 'iconUrl' }) + ) + }) + test('gets headers and rows for orgUnit layer', () => { const store = { aggregations: {}, diff --git a/src/components/datatable/styles/DataTable.module.css b/src/components/datatable/styles/DataTable.module.css index 8f6c8de3f4..41cb48405d 100644 --- a/src/components/datatable/styles/DataTable.module.css +++ b/src/components/datatable/styles/DataTable.module.css @@ -22,6 +22,13 @@ th.monoCell { font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', 'Consolas', monospace; } +.iconCell { + display: block; + width: 20px; + height: 20px; + object-fit: contain; +} + th.checkboxCell, td.checkboxCell { width: 76px; diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index 70f0ac47aa..811af60d20 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -42,6 +42,8 @@ const LEVEL = 'level' const PARENT_NAME = 'parentName' const TYPE = 'type' const COLOR = 'color' +const GROUP = 'group' +const ICON = 'iconUrl' const OUNAME = 'ouname' const OUBOUNDARY = 'ouBoundary' const EVENTDATE = 'eventdate' @@ -103,6 +105,13 @@ const defaultFieldsMap = () => ({ type: TYPE_STRING, renderer: 'rendercolor', }, + [GROUP]: { name: i18n.t('Group'), dataKey: GROUP, type: TYPE_STRING }, + [ICON]: { + name: i18n.t('Icon'), + dataKey: ICON, + type: TYPE_STRING, + renderer: 'rendericon', + }, }) const getThematicHeaders = () => @@ -186,10 +195,28 @@ const getEventHeaders = ({ return fields.concat(customFields) } -const getOrgUnitHeaders = () => - [NAME, ID, LEVEL, PARENT_NAME, TYPE].map( - (field) => defaultFieldsMap()[field] - ) +// Facility/org unit layers only get Color/Icon/Group columns when the +// current group-set styling actually produced them - style type (and +// whether every org unit matched a group) isn't known up front, so this +// checks the resolved row data rather than re-deriving that logic here. +const getGroupSetStyleHeaders = (data) => { + const headers = [] + if (data?.some((d) => d.color != null)) { + headers.push(defaultFieldsMap()[COLOR]) + } + if (data?.some((d) => d.iconUrl != null)) { + headers.push(defaultFieldsMap()[ICON]) + } + if (data?.some((d) => d.group != null)) { + headers.push(defaultFieldsMap()[GROUP]) + } + return headers +} + +const getOrgUnitHeaders = (data) => + [NAME, ID, LEVEL, PARENT_NAME, TYPE] + .map((field) => defaultFieldsMap()[field]) + .concat(getGroupSetStyleHeaders(data)) // Unlike getEventHeaders's layerHeaders (raw analytics response shape, // name=uid/column=display), trackedEntityLoader.js already builds its @@ -211,8 +238,10 @@ const getTrackedEntityHeaders = ({ layerHeaders = [] }) => { return fields.concat(customFields) } -const getFacilityHeaders = () => - [NAME, ID, TYPE].map((field) => defaultFieldsMap()[field]) +const getFacilityHeaders = (data) => + [NAME, ID, TYPE] + .map((field) => defaultFieldsMap()[field]) + .concat(getGroupSetStyleHeaders(data)) const toTitleCase = (str) => str.replace( @@ -440,7 +469,7 @@ export const useTableData = ({ }) break case ORG_UNIT_LAYER: - headers = getOrgUnitHeaders() + headers = getOrgUnitHeaders(dataWithAggregations) break case TRACKED_ENTITY_LAYER: headers = getTrackedEntityHeaders({ layerHeaders }) @@ -453,7 +482,7 @@ export const useTableData = ({ }) break case FACILITY_LAYER: - headers = getFacilityHeaders() + headers = getFacilityHeaders(dataWithAggregations) break case GEOJSON_URL_LAYER: { if ( diff --git a/src/util/__tests__/orgUnits.spec.js b/src/util/__tests__/orgUnits.spec.js index 3a7f54155b..55362e6e8d 100644 --- a/src/util/__tests__/orgUnits.spec.js +++ b/src/util/__tests__/orgUnits.spec.js @@ -358,6 +358,7 @@ describe('getStyledOrgUnits', () => { expect(result.legend.items).toContainEqual( expect.objectContaining({ name: 'Unclassified', color: '#cccccc' }) ) + expect(result.styledFeatures[0].properties.group).toBe('Group1') }) it('should include unclassified orgunit with unclassifiedLegend color when set', () => { diff --git a/src/util/orgUnits.js b/src/util/orgUnits.js index 2c6d4dbfcb..746217ae19 100644 --- a/src/util/orgUnits.js +++ b/src/util/orgUnits.js @@ -159,10 +159,11 @@ export const getStyledOrgUnits = ({ .map((f) => { const isPoint = f.geometry.type === 'Point' const { hasAdditionalGeometry } = f.properties - const { color, symbol } = getOrgUnitStyle( - f.properties.dimensions, - groupSet - ) + const { + name: groupName, + color, + symbol, + } = getOrgUnitStyle(f.properties.dimensions, groupSet) const isUnclassified = !!groupSet.id && !color && !symbol let radius @@ -187,6 +188,10 @@ export const getStyledOrgUnits = ({ properties.iconUrl = `${baseUrl}/images/orgunitgroup/${symbol}` } + if (groupName) { + properties.group = groupName + } + if (properties.level && levelWeight) { properties.weight = levelWeight(f.properties.level) } From b73a3e9011b546273c0b3550d9e532a638f515aa Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Sat, 18 Jul 2026 10:00:01 +0200 Subject: [PATCH 083/205] feat: add Color column for GeoJSON URL and tracked entity layers GeoJSON URL layers get a per-geometry-type color, matching the map legend, without ever overwriting a feature's own pre-existing color (maps-gl's colorExpr already prefers that over the layer's uniform style). Tracked entity layers get their fixed point color - coarse today, but the column now exists for when TE styling gains real per-instance classification. --- .../datatable/__tests__/useTableData.spec.jsx | 50 ++++++++++++- src/components/datatable/useTableData.js | 9 ++- .../__tests__/geoJsonUrlLoader.spec.js | 70 +++++++++++++++++++ .../__tests__/trackedEntityLoader.spec.js | 27 +++++++ src/loaders/geoJsonUrlLoader.js | 24 ++++++- src/loaders/trackedEntityLoader.js | 16 +++-- 6 files changed, 188 insertions(+), 8 deletions(-) create mode 100644 src/loaders/__tests__/geoJsonUrlLoader.spec.js diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index b2b4f74129..f231acd0b6 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -591,6 +591,7 @@ describe('useTableData headers', () => { id: 'PsgJS8BUxZd', w75KJ2mc4zz: 'Gabrielle', zDhUuAYrxNC: 28, + color: '#e57200', }, }, ], @@ -611,18 +612,20 @@ describe('useTableData headers', () => { ) const { headers, rows, isLoading } = result.current - expect(headers).toHaveLength(3) + expect(headers).toHaveLength(4) expect(headers).toMatchObject([ { name: 'Id', dataKey: 'id', type: 'string' }, { name: 'First name', dataKey: 'w75KJ2mc4zz', type: 'string' }, { name: 'Age', dataKey: 'zDhUuAYrxNC', type: 'number' }, + { name: 'Color', dataKey: 'color', type: 'string' }, ]) expect(rows).toHaveLength(1) - expect(rows[0]).toHaveLength(3) + expect(rows[0]).toHaveLength(4) expect(rows[0]).toMatchObject([ { value: 'PsgJS8BUxZd', dataKey: 'id' }, { value: 'Gabrielle', dataKey: 'w75KJ2mc4zz' }, { value: 28, dataKey: 'zDhUuAYrxNC' }, + { value: '#e57200', dataKey: 'color' }, ]) expect(isLoading).toBe(false) }) @@ -1148,6 +1151,49 @@ describe('useTableData headers', () => { ]) expect(isLoading).toBe(false) }) + + test('gets headers and rows for a geoJsonUrl layer, labeling the synthetic color property "Color"', () => { + const store = { aggregations: {} } + const layer = { + layer: 'geoJsonUrl', + dataFilters: null, + data: [ + { + geometry: { type: 'Point' }, + properties: { + id: 'feature-1', + name: 'Feature 1', + color: '#ff0000', + }, + }, + ], + } + + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + <Provider store={mockStore(store)}>{children}</Provider> + ), + } + ) + + const { headers, rows } = result.current + expect(headers).toContainEqual({ + name: 'Color', + dataKey: 'color', + type: 'string', + renderer: 'rendercolor', + }) + expect(rows[0]).toContainEqual( + expect.objectContaining({ value: '#ff0000', dataKey: 'color' }) + ) + }) }) describe('useTableData sorting', () => { diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index 811af60d20..5c5ffb0aa7 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -235,6 +235,8 @@ const getTrackedEntityHeaders = ({ layerHeaders = [] }) => { : TYPE_STRING, })) + customFields.push(defaultFieldsMap()[COLOR]) + return fields.concat(customFields) } @@ -282,8 +284,13 @@ const getEarthEngineHeaders = ({ aggregationType, legend, data }) => { .concat(customFields) } +// The synthetic per-geometry-type `color` property gets the same +// canonical, translated Color header every other layer type uses, +// rather than being treated as just another arbitrary uploaded field. const getGeoJsonUrlHeaders = (firstDataItem) => - getGeojsonDisplayData(firstDataItem) + getGeojsonDisplayData(firstDataItem).map((header) => + header.dataKey === COLOR ? defaultFieldsMap()[COLOR] : header + ) const EMPTY_AGGREGATIONS = {} const EMPTY_LAYER = {} diff --git a/src/loaders/__tests__/geoJsonUrlLoader.spec.js b/src/loaders/__tests__/geoJsonUrlLoader.spec.js new file mode 100644 index 0000000000..e3d655d858 --- /dev/null +++ b/src/loaders/__tests__/geoJsonUrlLoader.spec.js @@ -0,0 +1,70 @@ +import { stampFeatureColors } from '../geoJsonUrlLoader.js' + +describe('stampFeatureColors', () => { + it('stamps each feature with its matching geometry-type color', () => { + const features = [ + { geometry: { type: 'Point' }, properties: { id: '1' } }, + { geometry: { type: 'Polygon' }, properties: { id: '2' } }, + ] + const legendItemsByType = { + Point: { color: '#ff0000' }, + Polygon: { color: '#00ff00' }, + } + + const result = stampFeatureColors(features, legendItemsByType) + + expect(result[0].properties.color).toBe('#ff0000') + expect(result[1].properties.color).toBe('#00ff00') + }) + + it('normalizes Multi* geometry types to their base type before matching', () => { + const features = [ + { geometry: { type: 'MultiPolygon' }, properties: { id: '1' } }, + ] + const legendItemsByType = { Polygon: { color: '#00ff00' } } + + const result = stampFeatureColors(features, legendItemsByType) + + expect(result[0].properties.color).toBe('#00ff00') + }) + + it('leaves a feature unchanged when its geometry type has no matching color', () => { + const features = [ + { geometry: { type: 'LineString' }, properties: { id: '1' } }, + ] + + const result = stampFeatureColors(features, {}) + + expect(result[0].properties.color).toBeUndefined() + expect(result[0]).toEqual(features[0]) + }) + + it('does not mutate the original feature objects', () => { + const features = [ + { geometry: { type: 'Point' }, properties: { id: '1' } }, + ] + const legendItemsByType = { Point: { color: '#ff0000' } } + + stampFeatureColors(features, legendItemsByType) + + expect(features[0].properties.color).toBeUndefined() + }) + + it('never overwrites a feature that already has its own color', () => { + // maps-gl's colorExpr prefers a feature's own properties.color over + // the layer's uniform style color, so a user-uploaded file with its + // own per-feature colors must keep rendering with them. + const features = [ + { + geometry: { type: 'Point' }, + properties: { id: '1', color: '#123456' }, + }, + ] + const legendItemsByType = { Point: { color: '#ff0000' } } + + const result = stampFeatureColors(features, legendItemsByType) + + expect(result[0].properties.color).toBe('#123456') + expect(result[0]).toBe(features[0]) + }) +}) diff --git a/src/loaders/__tests__/trackedEntityLoader.spec.js b/src/loaders/__tests__/trackedEntityLoader.spec.js index f0b39c2fc6..e25cda3c91 100644 --- a/src/loaders/__tests__/trackedEntityLoader.spec.js +++ b/src/loaders/__tests__/trackedEntityLoader.spec.js @@ -2,6 +2,7 @@ import { getAttributeHeaders, getAttributeProperties, parseJsonConfig, + toGeoJson, } from '../trackedEntityLoader.js' jest.mock('../../components/map/MapApi.js', () => ({ @@ -114,3 +115,29 @@ describe('parseJsonConfig', () => { expect(config.config).toBeUndefined() }) }) + +describe('toGeoJson', () => { + it('stamps the given color onto every instance, alongside its id and attributes', () => { + const instances = [ + { + id: 'tei-1', + geometry: { type: 'Point', coordinates: [1, 2] }, + attributes: [{ attribute: 'w75KJ2mc4zz', value: 'Gabrielle' }], + }, + ] + + const result = toGeoJson(instances, '#ff0000') + + expect(result).toEqual([ + { + type: 'Feature', + geometry: { type: 'Point', coordinates: [1, 2] }, + properties: { + id: 'tei-1', + color: '#ff0000', + w75KJ2mc4zz: 'Gabrielle', + }, + }, + ]) + }) +}) diff --git a/src/loaders/geoJsonUrlLoader.js b/src/loaders/geoJsonUrlLoader.js index ed26dbce8a..f601e7bae3 100644 --- a/src/loaders/geoJsonUrlLoader.js +++ b/src/loaders/geoJsonUrlLoader.js @@ -7,6 +7,22 @@ import { GEO_TYPE_POLYGON, } from '../util/geojson.js' +// features of different (non-Multi-normalized) geometry types get their +// own color, matching the map legend's own per-type color - never +// overwrites a feature's own pre-existing color (maps-gl's colorExpr +// already prefers a per-feature properties.color over the layer's +// uniform style color, so a feature that already has one is rendered +// with it, and the data table should reflect the same real color). +export const stampFeatureColors = (features, legendItemsByType) => + features.map((f) => { + if (f.properties.color != null) { + return f + } + const nonMultiType = f.geometry.type.replaceAll('Multi', '') + const color = legendItemsByType[nonMultiType]?.color + return color ? { ...f, properties: { ...f.properties, color } } : f + }) + const fetchData = async (url, engine, baseUrl) => { if (url.includes(baseUrl)) { // API route, use engine @@ -92,9 +108,9 @@ const geoJsonUrlLoader = async ({ } if (!loadError) { const { featureCollection, types } = buildGeoJsonFeatures(geoJson) - data = featureCollection const oneType = types.length === 1 + const legendItemsByType = {} types.forEach((type) => { let legendItem @@ -122,7 +138,13 @@ const geoJsonUrlLoader = async ({ } } legend.items.push(legendItem) + legendItemsByType[type] = legendItem }) + + // A per-geometry-type color, for the data table's Color column - + // features of different types in the same file get different + // colors here, matching what the map legend already shows per type. + data = stampFeatureColors(featureCollection, legendItemsByType) } return { diff --git a/src/loaders/trackedEntityLoader.js b/src/loaders/trackedEntityLoader.js index 1f4e7a533c..4431dfeec8 100644 --- a/src/loaders/trackedEntityLoader.js +++ b/src/loaders/trackedEntityLoader.js @@ -123,12 +123,17 @@ export const getAttributeHeaders = (instances) => { return [...headersByAttribute.values()] } -const toGeoJson = (instances) => +// The main tracked entity marker's own color is currently fixed for every +// instance (no per-instance classification yet, unlike thematic/event) - +// still stamped here so the data table's Color column has real data ready +// to become meaningful once that changes. +export const toGeoJson = (instances, color) => instances.map(({ id, geometry, attributes }) => ({ type: GEO_TYPE_FEATURE, geometry, properties: { id, + color, ...getAttributeProperties(attributes), }, })) @@ -174,6 +179,7 @@ const fetchRelationshipData = async ({ relatedPointColor, relatedPointRadius, relationshipLineColor, + pointColor, legend, }) => { const { relationshipType } = await engine.query( @@ -222,7 +228,7 @@ const fetchRelationshipData = async ({ }) return { - data: toGeoJson(dataWithRels.primary), + data: toGeoJson(dataWithRels.primary, pointColor), relationships: dataWithRels.relationships, secondaryData: toGeoJson(dataWithRels.secondary), } @@ -290,6 +296,7 @@ const trackedEntityLoader = async ({ } = config const name = program ? program.name : i18n.t('Tracked entity') + const pointColor = eventPointColor || TEI_COLOR const legend = { title: name, @@ -302,7 +309,7 @@ const trackedEntityLoader = async ({ name: trackedEntityType.name + (areaRadius ? ` + ${areaRadius} ${'m'} ${'buffer'}` : ''), - color: eventPointColor || TEI_COLOR, + color: pointColor, radius: eventPointRadius || TEI_RADIUS, }, ], @@ -374,10 +381,11 @@ const trackedEntityLoader = async ({ relatedPointColor, relatedPointRadius, relationshipLineColor, + pointColor, legend, })) } else { - data = toGeoJson(instances) + data = toGeoJson(instances, pointColor) } if (explanation) { From 2dff72e40211919e2880794483665acf276e2e5c Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Sun, 19 Jul 2026 11:24:25 +0200 Subject: [PATCH 084/205] chore: pr cleanup --- i18n/en.pot | 7 +- src/actions/__tests__/dataTable.spec.js | 11 ++ src/actions/dataTable.js | 5 + src/components/datatable/BottomPanel.jsx | 2 - src/components/datatable/FilterInput.jsx | 23 ++- .../__tests__/ColumnPickerControl.spec.jsx | 126 ++++------------ .../datatable/__tests__/FilterInput.spec.jsx | 18 +++ .../datatable/__tests__/useTableData.spec.jsx | 50 ++++--- .../controls/ColumnPickerControl.jsx | 81 +---------- .../styles/ColumnPickerControl.module.css | 47 ------ .../datatable/styles/FilterInput.module.css | 17 +++ src/components/datatable/useTableData.js | 135 +++++++++++------- src/components/map/Map.jsx | 8 +- src/components/map/MapContainer.jsx | 4 + src/constants/actionTypes.js | 1 + src/reducers/__tests__/ui.spec.js | 16 +++ src/reducers/ui.js | 7 + src/util/__tests__/tableColumns.spec.js | 100 +++++++++++-- src/util/__tests__/tableSort.spec.js | 18 +++ src/util/tableColumns.js | 40 ++++-- src/util/tableSort.js | 11 +- 21 files changed, 404 insertions(+), 323 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 96b0c39574..e52dbfea8d 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-18T03:48:43.826Z\n" -"PO-Revision-Date: 2026-07-18T03:48:43.826Z\n" +"POT-Creation-Date: 2026-07-18T09:56:04.530Z\n" +"PO-Revision-Date: 2026-07-18T09:56:04.530Z\n" msgid "2020" msgstr "2020" @@ -293,9 +293,6 @@ msgstr "Select all columns" msgid "Reset to defaults" msgstr "Reset to defaults" -msgid "Add period columns" -msgstr "Add period columns" - msgid "Drag to reorder" msgstr "Drag to reorder" diff --git a/src/actions/__tests__/dataTable.spec.js b/src/actions/__tests__/dataTable.spec.js index bc50b2763d..217c86a66c 100644 --- a/src/actions/__tests__/dataTable.spec.js +++ b/src/actions/__tests__/dataTable.spec.js @@ -3,6 +3,7 @@ import { closeDataTable, toggleDataTable, resizeDataTable, + setActiveTimelinePeriod, } from '../dataTable.js' describe('closeDataTable', () => { @@ -30,3 +31,13 @@ describe('resizeDataTable', () => { }) }) }) + +describe('setActiveTimelinePeriod', () => { + it('creates an ACTIVE_TIMELINE_PERIOD_SET action', () => { + const period = { id: '202301', name: 'January 2023' } + expect(setActiveTimelinePeriod(period)).toEqual({ + type: types.ACTIVE_TIMELINE_PERIOD_SET, + period, + }) + }) +}) diff --git a/src/actions/dataTable.js b/src/actions/dataTable.js index ceb7ed56e3..281c7e9cef 100644 --- a/src/actions/dataTable.js +++ b/src/actions/dataTable.js @@ -38,3 +38,8 @@ export const setDataTableColumnConfig = (layerId, config) => ({ layerId, config, }) + +export const setActiveTimelinePeriod = (period) => ({ + type: types.ACTIVE_TIMELINE_PERIOD_SET, + period, +}) diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index dcd8f117e8..44cd617e8d 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -207,8 +207,6 @@ const BottomPanel = () => { layerId={activeLayerId} allHeaders={allHeaders} columnConfig={activeLayer?.dataTableColumnConfig} - renderingStrategy={activeLayer?.renderingStrategy} - periods={activeLayer?.periods} /> <span className={styles.divider} /> <ResizeHandleControl diff --git a/src/components/datatable/FilterInput.jsx b/src/components/datatable/FilterInput.jsx index 91d4759221..640c1c73b7 100644 --- a/src/components/datatable/FilterInput.jsx +++ b/src/components/datatable/FilterInput.jsx @@ -104,6 +104,25 @@ const SearchableFilterPopover = ({ ? dispatch(setDataFilter(layerId, dataKey, text)) : dispatch(clearDataFilter(layerId, dataKey)) + const isIconColumn = dataKey === 'iconUrl' + + const renderOptionLabel = (value) => + isIconColumn ? ( + <span className={styles.iconOption}> + <img + className={styles.iconOptionThumbnail} + src={value} + alt="" + onError={(e) => { + e.target.style.visibility = 'hidden' + }} + /> + {value.split('/').pop()} + </span> + ) : ( + resolveLabel(value) + ) + const hasNotSetOption = options.some( ({ value }) => value === SENTINEL_NO_VALUE ) @@ -401,7 +420,9 @@ const SearchableFilterPopover = ({ computeItemKey={(_, option) => option.value} itemContent={(index, option) => ( <Checkbox - label={resolveLabel(option.value)} + label={renderOptionLabel( + option.value + )} checked={ anyValueActive || selected.includes(option.value) diff --git a/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx b/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx index 942f50eb48..6e16c5f451 100644 --- a/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx +++ b/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx @@ -3,10 +3,6 @@ import React from 'react' import { Provider } from 'react-redux' import configureMockStore from 'redux-mock-store' import { DATA_TABLE_COLUMN_CONFIG_SET } from '../../../constants/actionTypes.js' -import { - RENDERING_STRATEGY_SPLIT_BY_PERIOD, - RENDERING_STRATEGY_TIMELINE, -} from '../../../constants/layers.js' import ColumnPickerControl from '../controls/ColumnPickerControl.jsx' const mockStore = configureMockStore() @@ -83,7 +79,6 @@ describe('ColumnPicker visibility toggling', () => { visibleKeys: ['name', 'legend'], pinnedKeys: [], orderedKeys: ['name', 'rawValue', 'legend'], - extraPeriodIds: [], }, }) }) @@ -102,7 +97,6 @@ describe('ColumnPicker visibility toggling', () => { visibleKeys: ['name', 'legend', 'rawValue'], pinnedKeys: [], orderedKeys: ['name', 'rawValue', 'legend'], - extraPeriodIds: [], }, }) }) @@ -122,7 +116,6 @@ describe('ColumnPicker pinning', () => { visibleKeys: ['name', 'rawValue', 'legend'], pinnedKeys: ['rawValue'], orderedKeys: ['name', 'rawValue', 'legend'], - extraPeriodIds: [], }, }) }) @@ -142,7 +135,6 @@ describe('ColumnPicker pinning', () => { visibleKeys: ['name', 'rawValue', 'legend'], pinnedKeys: [], orderedKeys: ['name', 'rawValue', 'legend'], - extraPeriodIds: [], }, }) }) @@ -197,7 +189,6 @@ describe('ColumnPicker bulk actions', () => { visibleKeys: ['name', 'rawValue', 'legend'], pinnedKeys: ['legend'], orderedKeys: ['name', 'rawValue', 'legend'], - extraPeriodIds: [], }, }) }) @@ -217,7 +208,6 @@ describe('ColumnPicker bulk actions', () => { visibleKeys: [], pinnedKeys: ['legend'], orderedKeys: ['name', 'rawValue', 'legend'], - extraPeriodIds: [], }, }) }) @@ -235,7 +225,6 @@ describe('ColumnPicker bulk actions', () => { visibleKeys: ['rawValue', 'legend'], pinnedKeys: ['legend'], orderedKeys: ['name', 'rawValue', 'legend'], - extraPeriodIds: [], }, }) }) @@ -362,109 +351,54 @@ describe('ColumnPicker search', () => { visibleKeys: ['name', 'rawValue', 'legend'], pinnedKeys: [], orderedKeys: ['name', 'rawValue', 'legend'], - extraPeriodIds: [], }, }) }) }) -describe('ColumnPicker periods section', () => { - const periods = [ - { id: '202301', name: 'January 2023' }, - { id: '202302', name: 'February 2023' }, +describe('ColumnPicker defaultHidden headers (e.g. period columns)', () => { + // Period columns exist as regular headers for every available period, + // but start out unchecked - same mechanism as any other column, no + // dedicated "add period" UI. A defaultHidden header exercises that + // exact path without needing a real thematic/timeline layer fixture. + const headersWithHiddenColumn = [ + ...headers, + { + name: 'Value (Jan 2023)', + dataKey: 'period_202301_rawValue', + defaultHidden: true, + }, ] - test('is absent for a single-period (non-multi-period) layer', () => { - renderColumnPicker({ periods }) + test('appears in the main list, unchecked, when there is no saved config yet', () => { + renderColumnPicker({ allHeaders: headersWithHiddenColumn }) openPicker() - expect(screen.queryByText('January 2023')).not.toBeInTheDocument() + expect(screen.getByLabelText('Value (Jan 2023)')).not.toBeChecked() }) - test('is absent when there are no available periods', () => { - renderColumnPicker({ renderingStrategy: RENDERING_STRATEGY_TIMELINE }) - openPicker() - expect(screen.queryByText('Add period columns')).not.toBeInTheDocument() - }) - - test('lists available periods for a timeline layer', () => { - renderColumnPicker({ - renderingStrategy: RENDERING_STRATEGY_TIMELINE, - periods, - }) - openPicker() - expect(screen.getByText('Add period columns')).toBeInTheDocument() - expect(screen.getByLabelText('January 2023')).not.toBeChecked() - expect(screen.getByLabelText('February 2023')).not.toBeChecked() - }) - - test('lists available periods for a split-by-period layer too', () => { - renderColumnPicker({ - renderingStrategy: RENDERING_STRATEGY_SPLIT_BY_PERIOD, - periods, - }) - openPicker() - expect(screen.getByText('Add period columns')).toBeInTheDocument() - }) - - test('checking a period dispatches extraPeriodIds with it added', () => { + test('checking it dispatches visibleKeys with its dataKey added, alongside the other default-visible columns', () => { const { store } = renderColumnPicker({ - renderingStrategy: RENDERING_STRATEGY_TIMELINE, - periods, + allHeaders: headersWithHiddenColumn, }) openPicker() - fireEvent.click(screen.getByLabelText('January 2023')) + fireEvent.click(screen.getByLabelText('Value (Jan 2023)')) expect(store.getActions()).toContainEqual({ type: DATA_TABLE_COLUMN_CONFIG_SET, layerId: 'layer1', config: { - visibleKeys: ['name', 'rawValue', 'legend'], + visibleKeys: [ + 'name', + 'rawValue', + 'legend', + 'period_202301_rawValue', + ], pinnedKeys: [], - orderedKeys: ['name', 'rawValue', 'legend'], - extraPeriodIds: ['202301'], - }, - }) - }) - - test('checking a period also adds its dataKey to an already-customized visibleKeys allowlist', () => { - // visibleKeys, once customized, acts as an allowlist (getVisibleHeaders - // filters out anything not in it) - the new period column's dataKey - // must be added too, or it would never actually render. - const { store } = renderColumnPicker({ - renderingStrategy: RENDERING_STRATEGY_TIMELINE, - periods, - columnConfig: { visibleKeys: ['name'] }, - }) - openPicker() - fireEvent.click(screen.getByLabelText('January 2023')) - expect(store.getActions()).toContainEqual({ - type: DATA_TABLE_COLUMN_CONFIG_SET, - layerId: 'layer1', - config: { - visibleKeys: ['name', 'period_202301_rawValue'], - pinnedKeys: [], - orderedKeys: ['name', 'rawValue', 'legend'], - extraPeriodIds: ['202301'], - }, - }) - }) - - test('unchecking an already-added period dispatches extraPeriodIds without it', () => { - const { store } = renderColumnPicker({ - renderingStrategy: RENDERING_STRATEGY_TIMELINE, - periods, - columnConfig: { extraPeriodIds: ['202301', '202302'] }, - }) - openPicker() - expect(screen.getByLabelText('January 2023')).toBeChecked() - fireEvent.click(screen.getByLabelText('January 2023')) - expect(store.getActions()).toContainEqual({ - type: DATA_TABLE_COLUMN_CONFIG_SET, - layerId: 'layer1', - config: { - visibleKeys: ['name', 'rawValue', 'legend'], - pinnedKeys: [], - orderedKeys: ['name', 'rawValue', 'legend'], - extraPeriodIds: ['202302'], + orderedKeys: [ + 'name', + 'rawValue', + 'legend', + 'period_202301_rawValue', + ], }, }) }) diff --git a/src/components/datatable/__tests__/FilterInput.spec.jsx b/src/components/datatable/__tests__/FilterInput.spec.jsx index ea143ce676..c7414136db 100644 --- a/src/components/datatable/__tests__/FilterInput.spec.jsx +++ b/src/components/datatable/__tests__/FilterInput.spec.jsx @@ -201,6 +201,24 @@ describe('FilterInput multi-select path (no optionSetId)', () => { ).toBeInTheDocument() }) + test('renders Icon column options as a thumbnail plus filename, not the raw URL', () => { + renderFilterInput({ + dataKey: 'iconUrl', + name: 'Icon', + options: [{ value: 'https://server/api/icons/mapMarker024.png' }], + }) + openPopover('Icon') + const checkbox = screen.getByLabelText('mapMarker024.png') + expect(checkbox).toBeInTheDocument() + expect( + screen.queryByLabelText('https://server/api/icons/mapMarker024.png') + ).not.toBeInTheDocument() + expect(checkbox.closest('label').querySelector('img')).toHaveAttribute( + 'src', + 'https://server/api/icons/mapMarker024.png' + ) + }) + test('formats numeric column options with the system digit group separator', () => { renderFilterInput({ dataKey: 'value', diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index f231acd0b6..f55ca6520b 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -261,11 +261,11 @@ describe('useTableData headers', () => { { name: 'Name', dataKey: 'name', type: 'string' }, { name: 'Id', dataKey: 'id', type: 'string' }, { name: 'Value', dataKey: 'rawValue', type: 'number' }, - { name: 'Legend', dataKey: 'legend', type: 'string' }, - { name: 'Range', dataKey: 'range', type: 'string' }, { name: 'Level', dataKey: 'level', type: 'number' }, { name: 'Parent', dataKey: 'parentName', type: 'string' }, { name: 'Type', dataKey: 'type', type: 'string' }, + { name: 'Legend', dataKey: 'legend', type: 'string' }, + { name: 'Range', dataKey: 'range', type: 'string' }, { name: 'Color', dataKey: 'color', @@ -279,22 +279,28 @@ describe('useTableData headers', () => { { value: 'Ngelehun CHC', dataKey: 'name' }, { value: 'thematicId-1', dataKey: 'id' }, { value: 106.3, dataKey: 'rawValue' }, - { value: 'Great', dataKey: 'legend' }, - { value: '90 – 120', dataKey: 'range' }, { value: 4, dataKey: 'level' }, { value: 'Badjia', dataKey: 'parentName' }, { value: 'Point', dataKey: 'type' }, + { value: 'Great', dataKey: 'legend' }, + { value: '90 – 120', dataKey: 'range' }, { value: '#FFFFB2', dataKey: 'color' }, ]) expect(isLoading).toBe(false) }) test('gets current-period Value/Legend/Range/Color for a timeline thematic layer', () => { - const store = { aggregations: {} } + // The active timeline period is Map.jsx's own local UI state, synced + // into state.ui.activeTimelinePeriod (not part of the layer config). + const store = { + aggregations: {}, + ui: { + activeTimelinePeriod: { id: '202302', name: 'February 2023' }, + }, + } const layer = { layer: 'thematic', renderingStrategy: 'TIMELINE', - externalPeriod: { id: '202302', name: 'February 2023' }, valuesByPeriod: { 202301: { 'ou-1': { value: 100, color: '#aaaaaa', legend: 'Low' }, @@ -337,11 +343,11 @@ describe('useTableData headers', () => { { name: 'Name', dataKey: 'name' }, { name: 'Id', dataKey: 'id' }, { name: 'Value (February 2023)', dataKey: 'rawValue' }, - { name: 'Legend (February 2023)', dataKey: 'legend' }, - { name: 'Range (February 2023)', dataKey: 'range' }, { name: 'Level', dataKey: 'level' }, { name: 'Parent', dataKey: 'parentName' }, { name: 'Type', dataKey: 'type' }, + { name: 'Legend (February 2023)', dataKey: 'legend' }, + { name: 'Range (February 2023)', dataKey: 'range' }, { name: 'Color (February 2023)', dataKey: 'color' }, ]) expect(rows[0]).toEqual( @@ -356,17 +362,23 @@ describe('useTableData headers', () => { ) }) - test('adds a raw-value-only extra period column for a timeline thematic layer', () => { - const store = { aggregations: {} } + test('adds a defaultHidden raw-value-only column for every other period, for a timeline thematic layer', () => { + // Period columns exist for every period regardless of any saved + // config - they're just hidden by default (defaultHidden), same + // mechanism as any other column, controlled via the column picker. + const store = { + aggregations: {}, + ui: { + activeTimelinePeriod: { id: '202302', name: 'February 2023' }, + }, + } const layer = { layer: 'thematic', renderingStrategy: 'TIMELINE', - externalPeriod: { id: '202302', name: 'February 2023' }, periods: [ { id: '202301', name: 'January 2023' }, { id: '202302', name: 'February 2023' }, ], - dataTableColumnConfig: { extraPeriodIds: ['202301'] }, valuesByPeriod: { 202301: { 'ou-1': { value: 100 } }, 202302: { 'ou-1': { value: 200 } }, @@ -396,10 +408,16 @@ describe('useTableData headers', () => { } ) const { headers, rows } = result.current + // The active period (February 2023) is the Value/Legend/Range/Color + // columns, not a separate period_* column. + expect(headers).not.toContainEqual( + expect.objectContaining({ dataKey: 'period_202302_rawValue' }) + ) expect(headers).toContainEqual({ name: 'Value (January 2023)', dataKey: 'period_202301_rawValue', type: 'number', + defaultHidden: true, }) expect(rows[0]).toContainEqual( expect.objectContaining({ @@ -409,19 +427,14 @@ describe('useTableData headers', () => { ) }) - test('split-by-period thematic layer has no default current-period column, only extras', () => { + test('split-by-period thematic layer has no default current-period column, only defaultHidden period columns', () => { const store = { aggregations: {} } const layer = { layer: 'thematic', renderingStrategy: 'SPLIT_BY_PERIOD', - externalPeriod: { id: '202302', name: 'February 2023' }, periods: [{ id: '202301', name: 'January 2023' }], - dataTableColumnConfig: { extraPeriodIds: ['202301'] }, valuesByPeriod: { 202301: { 'ou-1': { value: 100 } }, - 202302: { - 'ou-1': { value: 200, color: '#bbbbbb', legend: 'High' }, - }, }, dataFilters: null, data: [ @@ -457,6 +470,7 @@ describe('useTableData headers', () => { { name: 'Value (January 2023)', dataKey: 'period_202301_rawValue', + defaultHidden: true, }, ]) expect(rows[0]).not.toContainEqual( diff --git a/src/components/datatable/controls/ColumnPickerControl.jsx b/src/components/datatable/controls/ColumnPickerControl.jsx index 2033bf59c7..d54280db7f 100644 --- a/src/components/datatable/controls/ColumnPickerControl.jsx +++ b/src/components/datatable/controls/ColumnPickerControl.jsx @@ -23,13 +23,12 @@ import React, { useCallback, useLayoutEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { useDispatch } from 'react-redux' import { setDataTableColumnConfig } from '../../../actions/dataTable.js' -import { RENDERING_STRATEGY_SINGLE } from '../../../constants/layers.js' import { + getDefaultVisibleKeys, + getOrderedHeaders, getPinnedCount, - getVisibleHeaders, isPinnedGroupEnd, reverseVisibleKeys, - togglePeriodId, togglePinnedKey, toggleVisibleKey, } from '../../../util/tableColumns.js' @@ -40,13 +39,7 @@ import ToolbarIconButton from './ToolbarIconButton.jsx' const DRAG_OVERLAY_Z_INDEX = 2100 -const ColumnPickerControl = ({ - layerId, - allHeaders, - columnConfig, - renderingStrategy, - periods, -}) => { +const ColumnPickerControl = ({ layerId, allHeaders, columnConfig }) => { const dispatch = useDispatch() const anchorRef = useRef(null) const [isOpen, setIsOpen] = useState(false) @@ -76,20 +69,17 @@ const ColumnPickerControl = ({ const headers = allHeaders ?? [] const visibleKeys = - columnConfig?.visibleKeys ?? headers.map((h) => h.dataKey) + columnConfig?.visibleKeys ?? getDefaultVisibleKeys(headers) const pinnedKeys = columnConfig?.pinnedKeys ?? [] const orderedKeys = columnConfig?.orderedKeys ?? headers.map((h) => h.dataKey) - const orderedHeaders = getVisibleHeaders(headers, { + const orderedHeaders = getOrderedHeaders(headers, { orderedKeys, pinnedKeys, }) const pinnedCount = getPinnedCount(orderedHeaders, pinnedKeys) - const extraPeriodIds = columnConfig?.extraPeriodIds ?? [] - const isMultiPeriodThematic = - renderingStrategy && renderingStrategy !== RENDERING_STRATEGY_SINGLE const updateConfig = (partial) => dispatch( @@ -97,7 +87,6 @@ const ColumnPickerControl = ({ visibleKeys, pinnedKeys, orderedKeys, - extraPeriodIds, ...partial, }) ) @@ -124,25 +113,6 @@ const ColumnPickerControl = ({ const onResetToDefaults = () => dispatch(setDataTableColumnConfig(layerId, undefined)) - const onTogglePeriodId = (periodId) => { - const isAdding = !extraPeriodIds.includes(periodId) - updateConfig({ - extraPeriodIds: togglePeriodId(extraPeriodIds, periodId), - // A newly-added period's column only has a header once - // useTableData sees the updated extraPeriodIds - but - // visibleKeys, once customized, is an allowlist, so its new - // dataKey needs adding here too or the column would never - // actually render. - ...(isAdding && - columnConfig?.visibleKeys && { - visibleKeys: [ - ...visibleKeys, - `period_${periodId}_rawValue`, - ], - }), - }) - } - const filteredHeaders = orderedHeaders.filter((h) => h.name.toLowerCase().includes(search.trim().toLowerCase()) ) @@ -318,39 +288,6 @@ const ColumnPickerControl = ({ document.body )} </DndContext> - {isMultiPeriodThematic && periods?.length > 0 && ( - <div className={styles.periodsSection}> - <p className={styles.periodsSectionLabel}> - {i18n.t('Add period columns')} - </p> - <div className={styles.periodsList}> - {periods.map(({ id, name }) => ( - <label - key={id} - className={styles.periodRow} - > - <input - type="checkbox" - checked={extraPeriodIds.includes( - id - )} - onChange={() => - onTogglePeriodId(id) - } - data-test={`data-table-column-picker-period-${id}`} - /> - <span - className={ - styles.periodRowLabel - } - > - {name} - </span> - </label> - ))} - </div> - </div> - )} </div> </FilterDropdownPopover> )} @@ -367,18 +304,10 @@ ColumnPickerControl.propTypes = { }) ), columnConfig: PropTypes.shape({ - extraPeriodIds: PropTypes.arrayOf(PropTypes.string), orderedKeys: PropTypes.arrayOf(PropTypes.string), pinnedKeys: PropTypes.arrayOf(PropTypes.string), visibleKeys: PropTypes.arrayOf(PropTypes.string), }), - periods: PropTypes.arrayOf( - PropTypes.shape({ - id: PropTypes.string, - name: PropTypes.string, - }) - ), - renderingStrategy: PropTypes.string, } export default ColumnPickerControl diff --git a/src/components/datatable/controls/styles/ColumnPickerControl.module.css b/src/components/datatable/controls/styles/ColumnPickerControl.module.css index 6c56ce200e..fa974e6fd3 100644 --- a/src/components/datatable/controls/styles/ColumnPickerControl.module.css +++ b/src/components/datatable/controls/styles/ColumnPickerControl.module.css @@ -137,50 +137,3 @@ background-color: var(--colors-white); box-shadow: var(--elevations-popover); } - -.periodsSection { - margin-top: var(--spacers-dp8); - padding-top: var(--spacers-dp8); - border-top: 1px solid var(--colors-grey300); -} - -.periodsSectionLabel { - margin: 0 0 var(--spacers-dp4); - font-size: 11px; - font-weight: 600; - color: var(--colors-grey700); -} - -.periodsList { - display: flex; - flex-direction: column; - max-height: 150px; - overflow-y: auto; -} - -.periodRow { - display: flex; - align-items: center; - gap: var(--spacers-dp4); - padding: var(--spacers-dp2) var(--spacers-dp4); - border-radius: 3px; - cursor: pointer; -} - -.periodRow:hover { - background: var(--colors-grey100); -} - -.periodRow input[type='checkbox'] { - flex-shrink: 0; - accent-color: var(--colors-teal600); -} - -.periodRowLabel { - flex: 1; - min-width: 0; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - font-size: 12px; -} diff --git a/src/components/datatable/styles/FilterInput.module.css b/src/components/datatable/styles/FilterInput.module.css index 336b3c496a..83a175c0ee 100644 --- a/src/components/datatable/styles/FilterInput.module.css +++ b/src/components/datatable/styles/FilterInput.module.css @@ -104,6 +104,23 @@ font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', 'Consolas', monospace; } +.iconOption { + display: flex; + align-items: center; + gap: 6px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.iconOptionThumbnail { + display: block; + flex: none; + width: 16px; + height: 16px; + object-fit: contain; +} + .denseCheckbox { margin: 0; padding: 4px 0; diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index 5c5ffb0aa7..f1d3172697 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -114,21 +114,56 @@ const defaultFieldsMap = () => ({ }, }) +// Canonical trailing order for classification/styling columns - shared +// across every layer type that has any subset of them, so switching +// between layer types never reshuffles where these appear relative to +// each other (e.g. Group always comes before Color, Color always comes +// before Icon, regardless of which layer type is showing them). +const getStyleHeaders = ({ + hasLegend, + hasRange, + hasGroup, + hasColor, + hasIcon, +}) => { + const headers = [] + if (hasLegend) { + headers.push(defaultFieldsMap()[LEGEND]) + } + if (hasRange) { + headers.push(defaultFieldsMap()[RANGE]) + } + if (hasGroup) { + headers.push(defaultFieldsMap()[GROUP]) + } + if (hasColor) { + headers.push(defaultFieldsMap()[COLOR]) + } + if (hasIcon) { + headers.push(defaultFieldsMap()[ICON]) + } + return headers +} + const getThematicHeaders = () => - [NAME, ID, VALUE, LEGEND, RANGE, LEVEL, PARENT_NAME, TYPE, COLOR].map( - (field) => defaultFieldsMap()[field] - ) + [NAME, ID, VALUE, LEVEL, PARENT_NAME, TYPE] + .map((field) => defaultFieldsMap()[field]) + .concat( + getStyleHeaders({ hasLegend: true, hasRange: true, hasColor: true }) + ) // Timeline gets the standard Value/Legend/Range/Color columns, relabeled // with the active period's name (updates live as the timeline slider // moves). Split-by-period has no single "current" period to privilege, so // it only gets the base org unit columns - same shape as getOrgUnitHeaders. -// Both strategies can add extra, raw-value-only period columns via the -// column picker's "Periods" section. +// Every other available period (all of them, for split - every one but the +// active one, for timeline, since that one's already the Value/Legend/ +// Range/Color columns above) gets its own raw-value-only column too, +// hidden by default (defaultHidden) so the table isn't cluttered with +// every period until the user turns one on from the column picker. const getMultiPeriodThematicHeaders = ({ isTimelineThematic, externalPeriod, - extraPeriodIds, periods, }) => { const headers = isTimelineThematic @@ -144,13 +179,16 @@ const getMultiPeriodThematicHeaders = ({ ) : getOrgUnitHeaders() - extraPeriodIds.forEach((periodId) => { - const periodName = - periods?.find((p) => p.id === periodId)?.name ?? periodId + const otherPeriods = isTimelineThematic + ? (periods ?? []).filter((p) => p.id !== externalPeriod?.id) + : periods ?? [] + + otherPeriods.forEach((period) => { headers.push({ - name: i18n.t('Value ({{period}})', { period: periodName }), - dataKey: `period_${periodId}_rawValue`, + name: i18n.t('Value ({{period}})', { period: period.name }), + dataKey: `period_${period.id}_rawValue`, type: TYPE_NUMBER, + defaultHidden: true, }) }) @@ -183,40 +221,32 @@ const getEventHeaders = ({ })) customFields.push(defaultFieldsMap()[TYPE]) - - if (styleDataItem) { - customFields.push( - defaultFieldsMap()[LEGEND], - defaultFieldsMap()[RANGE], - defaultFieldsMap()[COLOR] - ) - } + customFields.push( + ...getStyleHeaders({ + hasLegend: !!styleDataItem, + hasRange: !!styleDataItem, + hasColor: !!styleDataItem, + }) + ) return fields.concat(customFields) } -// Facility/org unit layers only get Color/Icon/Group columns when the +// Facility/org unit layers only get Group/Color/Icon columns when the // current group-set styling actually produced them - style type (and // whether every org unit matched a group) isn't known up front, so this // checks the resolved row data rather than re-deriving that logic here. -const getGroupSetStyleHeaders = (data) => { - const headers = [] - if (data?.some((d) => d.color != null)) { - headers.push(defaultFieldsMap()[COLOR]) - } - if (data?.some((d) => d.iconUrl != null)) { - headers.push(defaultFieldsMap()[ICON]) - } - if (data?.some((d) => d.group != null)) { - headers.push(defaultFieldsMap()[GROUP]) - } - return headers -} +const getOrgUnitStyleHeaders = (data) => + getStyleHeaders({ + hasGroup: data?.some((d) => d.group != null), + hasColor: data?.some((d) => d.color != null), + hasIcon: data?.some((d) => d.iconUrl != null), + }) const getOrgUnitHeaders = (data) => [NAME, ID, LEVEL, PARENT_NAME, TYPE] .map((field) => defaultFieldsMap()[field]) - .concat(getGroupSetStyleHeaders(data)) + .concat(getOrgUnitStyleHeaders(data)) // Unlike getEventHeaders's layerHeaders (raw analytics response shape, // name=uid/column=display), trackedEntityLoader.js already builds its @@ -235,7 +265,7 @@ const getTrackedEntityHeaders = ({ layerHeaders = [] }) => { : TYPE_STRING, })) - customFields.push(defaultFieldsMap()[COLOR]) + customFields.push(...getStyleHeaders({ hasColor: true })) return fields.concat(customFields) } @@ -243,7 +273,7 @@ const getTrackedEntityHeaders = ({ layerHeaders = [] }) => { const getFacilityHeaders = (data) => [NAME, ID, TYPE] .map((field) => defaultFieldsMap()[field]) - .concat(getGroupSetStyleHeaders(data)) + .concat(getOrgUnitStyleHeaders(data)) const toTitleCase = (str) => str.replace( @@ -309,6 +339,13 @@ export const useTableData = ({ }) => { const allAggregations = useSelector((state) => state.aggregations) const aggregations = allAggregations[layer.id] || EMPTY_AGGREGATIONS + // The timeline's active period is Map.jsx's own local UI state, not + // part of the layer config stored in Redux - it's synced into + // state.ui separately (see Map.jsx/MapContainer.jsx) so the data + // table, a sibling of the map, can read the same "current period". + const externalPeriod = useSelector( + (state) => state.ui?.activeTimelinePeriod + ) const errorCode = useRef(null) @@ -325,9 +362,7 @@ export const useTableData = ({ serverCluster, renderingStrategy, valuesByPeriod, - externalPeriod, periods, - dataTableColumnConfig, legendDecimalPlaces, } = layer || EMPTY_LAYER @@ -338,10 +373,6 @@ export const useTableData = ({ const isTimelineThematic = isMultiPeriodThematic && renderingStrategy === RENDERING_STRATEGY_TIMELINE - const extraPeriodIds = useMemo( - () => dataTableColumnConfig?.extraPeriodIds ?? [], - [dataTableColumnConfig] - ) const isStyledEvent = layerType === EVENT_LAYER && !!styleDataItem const boundsDependency = showOnlyFeaturesInView ? mapBounds : null @@ -411,10 +442,16 @@ export const useTableData = ({ const currentPeriodItem = isTimelineThematic ? valuesByPeriod?.[externalPeriod?.id]?.[orgUnitId] : null - const extraPeriodValues = {} - extraPeriodIds.forEach((pid) => { - extraPeriodValues[`period_${pid}_rawValue`] = - valuesByPeriod?.[pid]?.[orgUnitId]?.value ?? null + const otherPeriodValues = {} + ;(periods ?? []).forEach((period) => { + if ( + isTimelineThematic && + period.id === externalPeriod?.id + ) { + return + } + otherPeriodValues[`period_${period.id}_rawValue`] = + valuesByPeriod?.[period.id]?.[orgUnitId]?.value ?? null }) return { @@ -425,7 +462,7 @@ export const useTableData = ({ legend: currentPeriodItem.legend, range: currentPeriodItem.range, }), - ...extraPeriodValues, + ...otherPeriodValues, ...aggregations[d.id], index, } @@ -444,7 +481,7 @@ export const useTableData = ({ isTimelineThematic, valuesByPeriod, externalPeriod, - extraPeriodIds, + periods, isStyledEvent, legend, keyAnalysisDigitGroupSeparator, @@ -463,7 +500,6 @@ export const useTableData = ({ ? getMultiPeriodThematicHeaders({ isTimelineThematic, externalPeriod, - extraPeriodIds, periods, }) : getThematicHeaders() @@ -526,7 +562,6 @@ export const useTableData = ({ isMultiPeriodThematic, isTimelineThematic, externalPeriod, - extraPeriodIds, periods, ]) diff --git a/src/components/map/Map.jsx b/src/components/map/Map.jsx index 3358c2f28f..8269043eb2 100644 --- a/src/components/map/Map.jsx +++ b/src/components/map/Map.jsx @@ -58,6 +58,7 @@ class Map extends Component { resizeCount: PropTypes.number, selection: PropTypes.object, selectionFilter: PropTypes.array, + setActiveTimelinePeriod: PropTypes.func, setAggregations: PropTypes.func, setFeatureProfile: PropTypes.func, setMapObject: PropTypes.func, @@ -191,6 +192,7 @@ class Map extends Component { coordinatePopup: coordinates, closeCoordinatePopup, openContextMenu, + setActiveTimelinePeriod, setAggregations, setFeatureProfile, resizeCount, @@ -215,9 +217,10 @@ class Map extends Component { periodId={period.id} period={period} periods={timelineOverlay?.periods} - onChange={(period) => + onChange={(period) => { this.setState({ period }) - } + setActiveTimelinePeriod?.(period) + }} resizeCount={resizeCount} /> </Fragment> @@ -322,6 +325,7 @@ class Map extends Component { if (initialPeriod) { this.setState({ period: initialPeriod }) + this.props.setActiveTimelinePeriod?.(initialPeriod) } } } diff --git a/src/components/map/MapContainer.jsx b/src/components/map/MapContainer.jsx index 6db6795fed..3e0b018baf 100644 --- a/src/components/map/MapContainer.jsx +++ b/src/components/map/MapContainer.jsx @@ -2,6 +2,7 @@ import PropTypes from 'prop-types' import React, { useCallback } from 'react' import { useSelector, useDispatch } from 'react-redux' import { setAggregations } from '../../actions/aggregations.js' +import { setActiveTimelinePeriod } from '../../actions/dataTable.js' import { highlightFeature, setFeatureProfile, @@ -64,6 +65,9 @@ const MapContainer = ({ resizeCount, setMap }) => { closeCoordinatePopup={() => dispatch(closeCoordinatePopup())} setAggregations={(data) => dispatch(setAggregations(data))} setFeatureProfile={(val) => dispatch(setFeatureProfile(val))} + setActiveTimelinePeriod={(period) => + dispatch(setActiveTimelinePeriod(period)) + } resizeCount={resizeCount} setMapObject={setMap} layersSorting={layersSorting} diff --git a/src/constants/actionTypes.js b/src/constants/actionTypes.js index ea24c6b490..96ed39d896 100644 --- a/src/constants/actionTypes.js +++ b/src/constants/actionTypes.js @@ -46,6 +46,7 @@ export const SELECTION_FILTER_SET = 'SELECTION_FILTER_SET' export const HIGHLIGHT_COLOR_SET = 'HIGHLIGHT_COLOR_SET' export const MAP_FEATURE_CLICKED = 'MAP_FEATURE_CLICKED' export const DATA_TABLE_COLUMN_CONFIG_SET = 'DATA_TABLE_COLUMN_CONFIG_SET' +export const ACTIVE_TIMELINE_PERIOD_SET = 'ACTIVE_TIMELINE_PERIOD_SET' /* DATA FILTER */ export const DATA_FILTER_SET = 'DATA_FILTER_SET' diff --git a/src/reducers/__tests__/ui.spec.js b/src/reducers/__tests__/ui.spec.js index e3e53fb768..b0025f4354 100644 --- a/src/reducers/__tests__/ui.spec.js +++ b/src/reducers/__tests__/ui.spec.js @@ -81,3 +81,19 @@ describe('ui reducer — lastClickedFeature', () => { expect(state.lastClickedFeature).toBe(null) }) }) + +describe('ui reducer — activeTimelinePeriod', () => { + it('defaults to null', () => { + expect(ui(undefined, {}).activeTimelinePeriod).toBe(null) + }) + + it('sets the active timeline period on ACTIVE_TIMELINE_PERIOD_SET', () => { + const period = { id: '202301', name: 'January 2023' } + const state = ui(undefined, { + type: types.ACTIVE_TIMELINE_PERIOD_SET, + period, + }) + + expect(state.activeTimelinePeriod).toEqual(period) + }) +}) diff --git a/src/reducers/ui.js b/src/reducers/ui.js index 45fb37be3c..5789ed6e57 100644 --- a/src/reducers/ui.js +++ b/src/reducers/ui.js @@ -14,6 +14,7 @@ const defaultState = { selectionFilter: [], highlightColor: null, lastClickedFeature: null, + activeTimelinePeriod: null, } const ui = (state = defaultState, action) => { @@ -121,6 +122,12 @@ const ui = (state = defaultState, action) => { lastClickedFeature: action.payload, } + case types.ACTIVE_TIMELINE_PERIOD_SET: + return { + ...state, + activeTimelinePeriod: action.period, + } + default: return state } diff --git a/src/util/__tests__/tableColumns.spec.js b/src/util/__tests__/tableColumns.spec.js index b76c71825b..c061aeeadc 100644 --- a/src/util/__tests__/tableColumns.spec.js +++ b/src/util/__tests__/tableColumns.spec.js @@ -1,11 +1,12 @@ import { + getDefaultVisibleKeys, + getOrderedHeaders, getPinnedCellProps, getPinnedCount, getPinnedLeftOffsets, getVisibleHeaders, isPinnedGroupEnd, reverseVisibleKeys, - togglePeriodId, togglePinnedKey, toggleVisibleKey, } from '../tableColumns.js' @@ -17,6 +18,38 @@ const headers = [ { name: 'Legend', dataKey: 'legend' }, ] +describe('getOrderedHeaders', () => { + it('returns every header, ordered/pinned but never filtered by visibility - even defaultHidden ones', () => { + const withHiddenColumn = [ + ...headers, + { + name: 'Value (Jan 2023)', + dataKey: 'period_202301_rawValue', + defaultHidden: true, + }, + ] + const result = getOrderedHeaders(withHiddenColumn, {}) + expect(result).toEqual(withHiddenColumn) + }) + + it('still applies ordering and pinning', () => { + const result = getOrderedHeaders(headers, { + orderedKeys: ['legend', 'name', 'id', 'rawValue'], + pinnedKeys: ['rawValue'], + }) + expect(result.map((h) => h.dataKey)).toEqual([ + 'rawValue', + 'legend', + 'name', + 'id', + ]) + }) + + it('passes through a null/undefined headers list', () => { + expect(getOrderedHeaders(null)).toBe(null) + }) +}) + describe('getVisibleHeaders', () => { it('returns all headers unchanged when there is no saved config', () => { expect(getVisibleHeaders(headers, null)).toEqual(headers) @@ -125,6 +158,42 @@ describe('getVisibleHeaders', () => { 'name', ]) }) + + it('excludes defaultHidden headers when there is no saved config yet', () => { + const withPeriodColumn = [ + ...headers, + { + name: 'Value (Jan 2023)', + dataKey: 'period_202301_rawValue', + defaultHidden: true, + }, + ] + const result = getVisibleHeaders(withPeriodColumn, null) + expect(result.map((h) => h.dataKey)).toEqual([ + 'name', + 'id', + 'rawValue', + 'legend', + ]) + }) + + it('shows a defaultHidden header once explicitly added to visibleKeys', () => { + const withPeriodColumn = [ + ...headers, + { + name: 'Value (Jan 2023)', + dataKey: 'period_202301_rawValue', + defaultHidden: true, + }, + ] + const result = getVisibleHeaders(withPeriodColumn, { + visibleKeys: ['name', 'period_202301_rawValue'], + }) + expect(result.map((h) => h.dataKey)).toEqual([ + 'name', + 'period_202301_rawValue', + ]) + }) }) describe('getPinnedLeftOffsets', () => { @@ -303,17 +372,30 @@ describe('getPinnedCellProps', () => { }) }) -describe('togglePeriodId', () => { - it('adds a period id when it is not yet added', () => { - expect(togglePeriodId(['202301'], '202302')).toEqual([ - '202301', - '202302', +describe('getDefaultVisibleKeys', () => { + it('includes every header dataKey when none are marked defaultHidden', () => { + expect(getDefaultVisibleKeys(headers)).toEqual([ + 'name', + 'id', + 'rawValue', + 'legend', ]) }) - it('removes a period id when it is already added', () => { - expect(togglePeriodId(['202301', '202302'], '202301')).toEqual([ - '202302', + it('excludes headers marked defaultHidden', () => { + const withHidden = [ + ...headers, + { + name: 'Value (Jan 2023)', + dataKey: 'period_202301_rawValue', + defaultHidden: true, + }, + ] + expect(getDefaultVisibleKeys(withHidden)).toEqual([ + 'name', + 'id', + 'rawValue', + 'legend', ]) }) }) diff --git a/src/util/__tests__/tableSort.spec.js b/src/util/__tests__/tableSort.spec.js index b34c46350b..76cf355527 100644 --- a/src/util/__tests__/tableSort.spec.js +++ b/src/util/__tests__/tableSort.spec.js @@ -50,6 +50,24 @@ describe('compareFieldValues', () => { ).toBe(0) }) + it('sorts null values to the end too, without throwing (e.g. a period column with no data for some rows)', () => { + expect( + compareFieldValues(null, 5, { sortDirection: 'asc' }) + ).toBeGreaterThan(0) + expect( + compareFieldValues(5, null, { sortDirection: 'desc' }) + ).toBeLessThan(0) + }) + + it('treats null and undefined as equally "no value"', () => { + expect( + compareFieldValues(null, undefined, { sortDirection: 'asc' }) + ).toBe(0) + expect( + compareFieldValues(undefined, null, { sortDirection: 'asc' }) + ).toBe(0) + }) + it('delegates to compareRangeValues for the Range column', () => { expect( compareFieldValues('5-10', '1-3', { diff --git a/src/util/tableColumns.js b/src/util/tableColumns.js index 8980ae0a0e..496748c82e 100644 --- a/src/util/tableColumns.js +++ b/src/util/tableColumns.js @@ -5,13 +5,23 @@ const getOrderIndex = (dataKey, orderedKeys) => { return index === -1 ? orderedKeys.length : index } -export const getVisibleHeaders = (headers, columnConfig) => { +// A header can opt out of the "everything visible by default" rule (e.g. +// period columns, which exist for every available period but would clutter +// the table if all shown before the user picks any) - used both here and +// by ColumnPickerControl, so the table and the picker's checkboxes always +// agree on what "not yet customized" means. +export const getDefaultVisibleKeys = (headers) => + headers.filter((h) => !h.defaultHidden).map((h) => h.dataKey) + +// Ordering + pinning only, deliberately never filtered by visibility - used +// by the column picker, which needs a row for every header regardless of +// whether it's currently shown, and by getVisibleHeaders below. +export const getOrderedHeaders = (headers, config) => { if (!headers) { return headers } - const { visibleKeys, orderedKeys } = columnConfig ?? {} - const pinnedKeys = columnConfig?.pinnedKeys ?? [] + const { orderedKeys, pinnedKeys } = config ?? {} let result = orderedKeys ? [...headers].sort( @@ -21,11 +31,7 @@ export const getVisibleHeaders = (headers, columnConfig) => { ) : headers - if (visibleKeys) { - result = result.filter((h) => visibleKeys.includes(h.dataKey)) - } - - if (pinnedKeys.length) { + if (pinnedKeys?.length) { const pinned = result.filter((h) => pinnedKeys.includes(h.dataKey)) const rest = result.filter((h) => !pinnedKeys.includes(h.dataKey)) result = [...pinned, ...rest] @@ -34,6 +40,19 @@ export const getVisibleHeaders = (headers, columnConfig) => { return result } +export const getVisibleHeaders = (headers, columnConfig) => { + if (!headers) { + return headers + } + + const visibleKeys = + columnConfig?.visibleKeys ?? getDefaultVisibleKeys(headers) + + return getOrderedHeaders(headers, columnConfig).filter((h) => + visibleKeys.includes(h.dataKey) + ) +} + export const getPinnedCount = (orderedHeaders, pinnedKeys) => { if (!orderedHeaders?.length || !pinnedKeys?.length) { return 0 @@ -68,11 +87,6 @@ export const reverseVisibleKeys = (headers, visibleKeys) => .filter((h) => !visibleKeys.includes(h.dataKey)) .map((h) => h.dataKey) -export const togglePeriodId = (extraPeriodIds, periodId) => - extraPeriodIds.includes(periodId) - ? extraPeriodIds.filter((id) => id !== periodId) - : [...extraPeriodIds, periodId] - // @dhis2/ui requires `width` whenever `fixed` is passed export const getPinnedCellProps = ( dataKey, diff --git a/src/util/tableSort.js b/src/util/tableSort.js index 6af6052b89..d101d6caa1 100644 --- a/src/util/tableSort.js +++ b/src/util/tableSort.js @@ -56,19 +56,22 @@ export const compareRangeValues = (aVal, bVal, sortDirection) => { return sortDirection === SORT_ASCENDING ? aEnd - bEnd : bEnd - aEnd } +const isNoValue = (val) => val === undefined || val === null + export const compareFieldValues = ( aVal, bVal, { sortField, sortDirection } ) => { - // All undefined values should be sorted to the end - if (aVal === undefined && bVal === undefined) { + // All missing values (undefined, or null - e.g. a period column with no + // data for a given org unit) should be sorted to the end + if (isNoValue(aVal) && isNoValue(bVal)) { return 0 } - if (aVal === undefined) { + if (isNoValue(aVal)) { return 1 } - if (bVal === undefined) { + if (isNoValue(bVal)) { return -1 } if (typeof aVal === 'number') { From 7cbe705751839dfb74635419bd63d9b366dbacea Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Sun, 19 Jul 2026 12:31:31 +0200 Subject: [PATCH 085/205] chore: sonarqube issues --- src/components/datatable/useTableData.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index f1d3172697..04a3eb23a9 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -220,8 +220,8 @@ const getEventHeaders = ({ optionSet: optionSet || null, })) - customFields.push(defaultFieldsMap()[TYPE]) customFields.push( + defaultFieldsMap()[TYPE], ...getStyleHeaders({ hasLegend: !!styleDataItem, hasRange: !!styleDataItem, From c161c92a71f30edebb76311658fafbba7ff63ba3 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Mon, 20 Jul 2026 19:36:35 +0200 Subject: [PATCH 086/205] chore: fix cypress test --- cypress/integration/dataTable.cy.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cypress/integration/dataTable.cy.js b/cypress/integration/dataTable.cy.js index 45eb84debd..6e6bfc048f 100644 --- a/cypress/integration/dataTable.cy.js +++ b/cypress/integration/dataTable.cy.js @@ -358,7 +358,7 @@ describe('data table', () => { cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') // Check that row 0 range value is empty - checkTableCell({ row: 0, column: 5, expectedContent: '' }) + checkTableCell({ row: 0, column: 8, expectedContent: '' }) // Sort by range, which is a string cy.getByDataTest('data-table-column-sort-button-Range').click() @@ -367,12 +367,12 @@ describe('data table', () => { cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') // Check that row 0 range value has value '0-40' - checkTableCell({ row: 0, column: 5, expectedContent: '0 – 40' }) + checkTableCell({ row: 0, column: 8, expectedContent: '0 – 40' }) // Check that row 5 range value has value '90 - 120' - checkTableCell({ row: 5, column: 5, expectedContent: '90 – 120' }) + checkTableCell({ row: 5, column: 8, expectedContent: '90 – 120' }) // Check that row 6 range value is empty - checkTableCell({ row: 6, column: 5, expectedContent: '' }) + checkTableCell({ row: 6, column: 8, expectedContent: '' }) }) }) From e54369039525e991b291a34f4811423683a7dcc0 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 21 Jul 2026 13:22:22 +0200 Subject: [PATCH 087/205] fix: datatable performance optimisation --- package.json | 2 +- src/components/datatable/BottomPanel.jsx | 24 +- src/components/datatable/DataTable.jsx | 340 +++++++++--------- src/components/datatable/FilterInput.jsx | 103 ++++-- .../datatable/TableVirtuosoComponents.jsx | 22 +- .../controls/ColumnPickerControl.jsx | 48 ++- .../datatable/styles/BottomPanel.module.css | 1 + .../datatable/styles/DataTable.module.css | 2 +- src/components/datatable/useTableData.js | 122 ++++--- src/util/tableColumns.js | 29 +- yarn.lock | 4 +- 11 files changed, 392 insertions(+), 305 deletions(-) diff --git a/package.json b/package.json index 7363a39fa1..6021418794 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "@dhis2/analytics": "^29.5.5", "@dhis2/app-runtime": "^3.17.3", "@dhis2/app-service-datastore": "^1.0.0-beta.3", - "@dhis2/maps-gl": "git+https://github.com/d2-ci/maps-gl.git#90476d118e5d9b62b6d7d97ac1d2d41e7a3fb840", + "@dhis2/maps-gl": "git+https://github.com/d2-ci/maps-gl.git#55ba8864b811c44279dd7c85dedc37adc426e318", "@dhis2/ui": "^10.16.4", "@dnd-kit/core": "^6.0.8", "@dnd-kit/modifiers": "^9.0.0", diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index 44cd617e8d..77867e7fcb 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -236,19 +236,17 @@ const BottomPanel = () => { <span className={styles.divider} /> <CloseControl onClick={onCloseDataTable} /> </div> - {!isCollapsed && ( - <div className={styles.tableContainer}> - <ErrorBoundary> - <DataTable - availableWidth={panelWidth} - onCountChange={onCountChange} - onHeadersChange={onHeadersChange} - globalSearch={globalSearch} - onClearFilters={onClearFilters} - /> - </ErrorBoundary> - </div> - )} + <div className={styles.tableContainer}> + <ErrorBoundary> + <DataTable + availableWidth={panelWidth} + onCountChange={onCountChange} + onHeadersChange={onHeadersChange} + globalSearch={globalSearch} + onClearFilters={onClearFilters} + /> + </ErrorBoundary> + </div> </div> ) } diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index bb85c7a3b9..79ebc00b6a 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -57,6 +57,9 @@ import { useColumnWidths } from './useColumnWidths.js' import { useRowSelection } from './useRowSelection.js' import { useTableData } from './useTableData.js' +const TABLE_STYLE = { height: '100%', width: '100%' } +const VIEWPORT_OVERSCAN = { top: 400, bottom: 400 } + const Table = ({ availableWidth, onCountChange, @@ -97,11 +100,18 @@ const Table = ({ [sortField, sortDirection] ) + // Read via ref rather than a dependency, so this callback (and anything + // memoized on it, e.g. tableContext) stays stable across hovers instead + // of getting a new identity on every single mouse-enter + const featureRef = useRef(feature) + featureRef.current = feature + const setFeatureHighlight = useCallback( (row) => { const id = getRowId(row) + const currentFeature = featureRef.current - if (!id || !feature || id !== feature.id) { + if (!id || !currentFeature || id !== currentFeature.id) { dispatch( highlightFeature( id @@ -115,7 +125,7 @@ const Table = ({ ) } }, - [feature, dispatch, layer.id] + [dispatch, layer.id] ) const clearFeatureHighlight = useCallback( (event) => { @@ -338,6 +348,168 @@ const Table = ({ layerId: layer.id, }) + const computeItemKey = useCallback( + (index, row) => getRowId(row) ?? index, + [] + ) + + const fixedHeaderContent = useCallback( + () => ( + <DataTableRow ref={headerRowRef}> + <DataTableColumnHeader + className={styles.checkboxCell} + width="76px" + fixed={isCheckboxColumnPinned} + left={isCheckboxColumnPinned ? '0px' : undefined} + onFilterIconClick={Function.prototype} + showFilter={true} + filter={ + <SelectionFilterButton + value={selectionFilter ?? []} + onChange={(next) => + dispatch(setSelectionFilter(next)) + } + /> + } + > + <div className={styles.checkboxHeaderContent}> + <TopTooltip content={i18n.t('Select all visible rows')}> + <input + type="checkbox" + aria-label={i18n.t('Select all visible rows')} + checked={isAllSelected} + onChange={onToggleSelectAll} + /> + </TopTooltip> + <TopTooltip + content={i18n.t( + 'Reverse selection of visible rows' + )} + > + <button + type="button" + className={styles.reverseButton} + data-test="data-table-reverse-selection" + disabled={allRowIds.length === 0} + onClick={onReverseSelection} + > + <IconSync16 /> + </button> + </TopTooltip> + <TopTooltip content={i18n.t('Sort by Selected')}> + <button + type="button" + className={styles.sortButton} + data-test="data-table-column-sort-button-selected" + onClick={() => + sortData({ + name: SENTINEL_SELECTED_ROW, + }) + } + > + <SortIcon + direction={ + sortField === SENTINEL_SELECTED_ROW + ? sortDirection + : null + } + /> + </button> + </TopTooltip> + </div> + </DataTableColumnHeader> + {visibleHeaders.map( + ({ name, dataKey, type, optionSet }, index) => { + const { fixed, left, isLastPinned } = + getPinnedCellProps(dataKey, index, { + pinnedLeftOffsets, + pinnedColumnCount, + columnWidths, + }) + return ( + <DataTableColumnHeader + className={cx(styles.columnHeader, { + [styles.pinnedColumnShadow]: isLastPinned, + })} + key={`${dataKey}-${index}`} + fixed={fixed} + left={left} + onFilterIconClick={ + isFilterable(dataKey, type) && + Function.prototype + } + showFilter={isFilterable(dataKey, type)} + name={dataKey} + filter={ + isFilterable(dataKey, type) && ( + <FilterInput + type={type} + dataKey={dataKey} + name={name} + options={columnOptions[dataKey]} + optionSetId={optionSet?.id} + /> + ) + } + width={ + columnWidths.length > 0 + ? `${columnWidths[index]}px` + : 'auto' + } + > + <span className={styles.headerContent}> + {name} + <TopTooltip + content={i18n.t('Sort by {{column}}', { + column: name, + })} + > + <button + type="button" + className={styles.sortButton} + data-test={`data-table-column-sort-button-${name}`} + onClick={() => + sortData({ + name: dataKey, + }) + } + > + <SortIcon + direction={ + dataKey === sortField + ? sortDirection + : null + } + /> + </button> + </TopTooltip> + </span> + </DataTableColumnHeader> + ) + } + )} + </DataTableRow> + ), + [ + isCheckboxColumnPinned, + selectionFilter, + dispatch, + allRowIds, + onReverseSelection, + sortData, + sortField, + sortDirection, + visibleHeaders, + pinnedLeftOffsets, + pinnedColumnCount, + columnWidths, + columnOptions, + isAllSelected, + onToggleSelectAll, + headerRowRef, + ] + ) + if (error) { return <p className={styles.noSupport}>{error}</p> } @@ -348,163 +520,11 @@ const Table = ({ ref={virtuosoRef} context={tableContext} components={TableComponents} - style={{ - height: '100%', - width: '100%', - }} + style={TABLE_STYLE} data={rows} - computeItemKey={(index, row) => getRowId(row) ?? index} - increaseViewportBy={{ top: 400, bottom: 400 }} - fixedHeaderContent={() => ( - <DataTableRow ref={headerRowRef}> - <DataTableColumnHeader - className={styles.checkboxCell} - width="76px" - fixed={isCheckboxColumnPinned} - left={isCheckboxColumnPinned ? '0px' : undefined} - onFilterIconClick={Function.prototype} - showFilter={true} - filter={ - <SelectionFilterButton - value={selectionFilter ?? []} - onChange={(next) => - dispatch(setSelectionFilter(next)) - } - /> - } - > - <div className={styles.checkboxHeaderContent}> - <TopTooltip - content={i18n.t('Select all visible rows')} - > - <input - type="checkbox" - aria-label={i18n.t( - 'Select all visible rows' - )} - checked={isAllSelected} - onChange={onToggleSelectAll} - /> - </TopTooltip> - <TopTooltip - content={i18n.t( - 'Reverse selection of visible rows' - )} - > - <button - type="button" - className={styles.reverseButton} - data-test="data-table-reverse-selection" - disabled={allRowIds.length === 0} - onClick={onReverseSelection} - > - <IconSync16 /> - </button> - </TopTooltip> - <TopTooltip - content={i18n.t('Sort by Selected')} - > - <button - type="button" - className={styles.sortButton} - data-test="data-table-column-sort-button-selected" - onClick={() => - sortData({ - name: SENTINEL_SELECTED_ROW, - }) - } - > - <SortIcon - direction={ - sortField === - SENTINEL_SELECTED_ROW - ? sortDirection - : null - } - /> - </button> - </TopTooltip> - </div> - </DataTableColumnHeader> - {visibleHeaders.map( - ({ name, dataKey, type, optionSet }, index) => { - const { fixed, left, isLastPinned } = - getPinnedCellProps(dataKey, index, { - pinnedLeftOffsets, - pinnedColumnCount, - columnWidths, - }) - return ( - <DataTableColumnHeader - className={cx(styles.columnHeader, { - [styles.pinnedColumnShadow]: - isLastPinned, - })} - key={`${dataKey}-${index}`} - fixed={fixed} - left={left} - onFilterIconClick={ - isFilterable(dataKey, type) && - Function.prototype - } - showFilter={isFilterable(dataKey, type)} - name={dataKey} - filter={ - isFilterable(dataKey, type) && ( - <FilterInput - type={type} - dataKey={dataKey} - name={name} - options={ - columnOptions[dataKey] - } - optionSetId={optionSet?.id} - /> - ) - } - width={ - columnWidths.length > 0 - ? `${columnWidths[index]}px` - : 'auto' - } - > - <span className={styles.headerContent}> - {name} - <TopTooltip - content={i18n.t( - 'Sort by {{column}}', - { column: name } - )} - > - <button - type="button" - className={ - styles.sortButton - } - data-test={`data-table-column-sort-button-${name}`} - onClick={() => - sortData({ - name: dataKey, - }) - } - > - <SortIcon - direction={ - dataKey === - sortField - ? sortDirection - : null - } - /> - </button> - </TopTooltip> - </span> - </DataTableColumnHeader> - ) - } - )} - </DataTableRow> - )} + computeItemKey={computeItemKey} + increaseViewportBy={VIEWPORT_OVERSCAN} + fixedHeaderContent={fixedHeaderContent} itemContent={(_, row) => { const rowId = getRowId(row) const isSelected = !!rowId && selectedIdSet.has(rowId) @@ -615,10 +635,10 @@ const Table = ({ }} /> {(isLoading || layer?.isLoaded === false || layer?.isLoading) && ( - <ComponentCover> + <ComponentCover translucent> <CenteredContent> <div className={styles.loadingContent}> - <CircularLoader /> + <CircularLoader invert /> {loadingReason && ( <span className={styles.loadingReason}> {loadingReason} diff --git a/src/components/datatable/FilterInput.jsx b/src/components/datatable/FilterInput.jsx index 640c1c73b7..5fa0633ca3 100644 --- a/src/components/datatable/FilterInput.jsx +++ b/src/components/datatable/FilterInput.jsx @@ -2,7 +2,7 @@ import i18n from '@dhis2/d2-i18n' import { Input, IconFilter16, IconSync16 } from '@dhis2/ui' import cx from 'classnames' import PropTypes from 'prop-types' -import React, { useMemo, useRef, useState } from 'react' +import React, { useCallback, useMemo, useRef, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import { Virtuoso } from 'react-virtuoso' import { setDataFilter, clearDataFilter } from '../../actions/dataFilters.js' @@ -56,7 +56,7 @@ const TEXT_FILTER_HELP = ( ) const NUMERIC_INPUT_DISALLOWED = /[^0-9.\-<>=,&\s]/g -const SearchableFilterPopover = ({ +const SearchableFilterPopover = React.memo(function SearchableFilterPopover({ dataKey, name, layerId, @@ -65,7 +65,7 @@ const SearchableFilterPopover = ({ resolveLabel, type, allowCustomFilter = true, -}) => { +}) { const dispatch = useDispatch() const anchorRef = useRef(null) const listRef = useRef(null) @@ -123,13 +123,18 @@ const SearchableFilterPopover = ({ resolveLabel(value) ) - const hasNotSetOption = options.some( - ({ value }) => value === SENTINEL_NO_VALUE + const hasNotSetOption = useMemo( + () => options.some(({ value }) => value === SENTINEL_NO_VALUE), + [options] ) - const realOptions = options.filter( - ({ value }) => value !== SENTINEL_NO_VALUE + const realOptions = useMemo( + () => options.filter(({ value }) => value !== SENTINEL_NO_VALUE), + [options] + ) + const realValues = useMemo( + () => realOptions.map((o) => o.value), + [realOptions] ) - const realValues = realOptions.map((o) => o.value) const anyValueActive = selected.includes(SENTINEL_ANY_VALUE) const popoverWidth = useMemo(() => { @@ -145,7 +150,10 @@ const SearchableFilterPopover = ({ const onToggleAnyValue = () => applyValues(toggleAnyValue(selected)) - const invertibleValues = getInvertibleValues(hasNotSetOption, realValues) + const invertibleValues = useMemo( + () => getInvertibleValues(hasNotSetOption, realValues), + [hasNotSetOption, realValues] + ) const onToggleRealValue = (value) => applyValues(toggleRealValue(selected, value, realValues)) @@ -155,15 +163,24 @@ const SearchableFilterPopover = ({ const trimmedSearch = searchText.trim() const normalizedSearch = trimmedSearch.toLowerCase() - const filteredOptions = getFilteredOptions({ - realOptions, - trimmedSearch, - normalizedSearch, - type, - resolveLabel, - }) - const hasExactMatch = filteredOptions.some( - ({ value }) => resolveLabel(value).toLowerCase() === normalizedSearch + const filteredOptions = useMemo( + () => + getFilteredOptions({ + realOptions, + trimmedSearch, + normalizedSearch, + type, + resolveLabel, + }), + [realOptions, trimmedSearch, normalizedSearch, type, resolveLabel] + ) + const hasExactMatch = useMemo( + () => + filteredOptions.some( + ({ value }) => + resolveLabel(value).toLowerCase() === normalizedSearch + ), + [filteredOptions, resolveLabel, normalizedSearch] ) const showCustomFilterRow = allowCustomFilter && normalizedSearch !== '' && !hasExactMatch @@ -451,7 +468,7 @@ const SearchableFilterPopover = ({ )} </div> ) -} +}) SearchableFilterPopover.propTypes = { dataKey: PropTypes.string.isRequired, @@ -474,14 +491,20 @@ const PlainSearchableFilter = (props) => { systemSettings: { keyAnalysisDigitGroupSeparator }, } = useCachedData() - const resolveLabel = (value) => { - if (value === SENTINEL_NO_VALUE) { - return i18n.t('No value') - } - return type === 'number' - ? formatWithSeparator(Number(value), keyAnalysisDigitGroupSeparator) - : value - } + const resolveLabel = useCallback( + (value) => { + if (value === SENTINEL_NO_VALUE) { + return i18n.t('No value') + } + return type === 'number' + ? formatWithSeparator( + Number(value), + keyAnalysisDigitGroupSeparator + ) + : value + }, + [type, keyAnalysisDigitGroupSeparator] + ) return <SearchableFilterPopover {...props} resolveLabel={resolveLabel} /> } @@ -492,10 +515,18 @@ PlainSearchableFilter.propTypes = { const OptionSetSearchableFilter = ({ optionSetId, ...props }) => { const { optionSet } = useOptionSet(optionSetId) - const resolveLabel = (value) => - value === SENTINEL_NO_VALUE - ? i18n.t('No value') - : optionSet?.options.find((o) => o.code === value)?.name ?? value + const optionByCode = useMemo(() => { + const map = new Map() + optionSet?.options.forEach((o) => map.set(o.code, o)) + return map + }, [optionSet]) + const resolveLabel = useCallback( + (value) => + value === SENTINEL_NO_VALUE + ? i18n.t('No value') + : optionByCode.get(value)?.name ?? value, + [optionByCode] + ) return ( <SearchableFilterPopover {...props} @@ -509,7 +540,13 @@ OptionSetSearchableFilter.propTypes = { optionSetId: PropTypes.string.isRequired, } -const FilterInput = ({ type, dataKey, name, options, optionSetId }) => { +const FilterInput = React.memo(function FilterInput({ + type, + dataKey, + name, + options, + optionSetId, +}) { const dataTable = useSelector((state) => state.dataTable) const map = useSelector((state) => state.map) @@ -545,7 +582,7 @@ const FilterInput = ({ type, dataKey, name, options, optionSetId }) => { type={type} /> ) -} +}) FilterInput.propTypes = { dataKey: PropTypes.string.isRequired, diff --git a/src/components/datatable/TableVirtuosoComponents.jsx b/src/components/datatable/TableVirtuosoComponents.jsx index 46dd3b5964..fd58eae97b 100644 --- a/src/components/datatable/TableVirtuosoComponents.jsx +++ b/src/components/datatable/TableVirtuosoComponents.jsx @@ -23,15 +23,19 @@ DataTableWithVirtuosoContext.propTypes = { }), } -const DataTableRowWithVirtuosoContext = ({ context, item, ...props }) => ( - <DataTableRow - onMouseEnter={() => context.onMouseEnter(item)} - onMouseLeave={context.onMouseLeave} - onContextMenu={(e) => context.onContextMenu(e, item)} - onClick={(e) => context.onRowClick(item, e)} - onDoubleClick={() => context.onRowDoubleClick(item)} - {...props} - /> +const DataTableRowWithVirtuosoContext = React.memo( + function DataTableRowWithVirtuosoContext({ context, item, ...props }) { + return ( + <DataTableRow + onMouseEnter={() => context.onMouseEnter(item)} + onMouseLeave={context.onMouseLeave} + onContextMenu={(e) => context.onContextMenu(e, item)} + onClick={(e) => context.onRowClick(item, e)} + onDoubleClick={() => context.onRowDoubleClick(item)} + {...props} + /> + ) + } ) DataTableRowWithVirtuosoContext.propTypes = { diff --git a/src/components/datatable/controls/ColumnPickerControl.jsx b/src/components/datatable/controls/ColumnPickerControl.jsx index d54280db7f..11f555940e 100644 --- a/src/components/datatable/controls/ColumnPickerControl.jsx +++ b/src/components/datatable/controls/ColumnPickerControl.jsx @@ -19,7 +19,13 @@ import { import { arrayMoveImmutable } from 'array-move' import cx from 'classnames' import PropTypes from 'prop-types' -import React, { useCallback, useLayoutEffect, useRef, useState } from 'react' +import React, { + useCallback, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react' import { createPortal } from 'react-dom' import { useDispatch } from 'react-redux' import { setDataTableColumnConfig } from '../../../actions/dataTable.js' @@ -38,8 +44,14 @@ import styles from './styles/ColumnPickerControl.module.css' import ToolbarIconButton from './ToolbarIconButton.jsx' const DRAG_OVERLAY_Z_INDEX = 2100 +const EMPTY_HEADERS = [] +const EMPTY_KEYS = [] -const ColumnPickerControl = ({ layerId, allHeaders, columnConfig }) => { +const ColumnPickerControl = React.memo(function ColumnPickerControl({ + layerId, + allHeaders, + columnConfig, +}) { const dispatch = useDispatch() const anchorRef = useRef(null) const [isOpen, setIsOpen] = useState(false) @@ -66,20 +78,26 @@ const ColumnPickerControl = ({ layerId, allHeaders, columnConfig }) => { } }, []) - const headers = allHeaders ?? [] + const headers = allHeaders ?? EMPTY_HEADERS const visibleKeys = columnConfig?.visibleKeys ?? getDefaultVisibleKeys(headers) - const pinnedKeys = columnConfig?.pinnedKeys ?? [] + const pinnedKeys = columnConfig?.pinnedKeys ?? EMPTY_KEYS const orderedKeys = columnConfig?.orderedKeys ?? headers.map((h) => h.dataKey) - const orderedHeaders = getOrderedHeaders(headers, { - orderedKeys, - pinnedKeys, - }) + const orderedHeaders = useMemo( + () => + isOpen + ? getOrderedHeaders(headers, { orderedKeys, pinnedKeys }) + : EMPTY_HEADERS, + [isOpen, headers, orderedKeys, pinnedKeys] + ) - const pinnedCount = getPinnedCount(orderedHeaders, pinnedKeys) + const pinnedCount = useMemo( + () => (isOpen ? getPinnedCount(orderedHeaders, pinnedKeys) : 0), + [isOpen, orderedHeaders, pinnedKeys] + ) const updateConfig = (partial) => dispatch( @@ -113,8 +131,14 @@ const ColumnPickerControl = ({ layerId, allHeaders, columnConfig }) => { const onResetToDefaults = () => dispatch(setDataTableColumnConfig(layerId, undefined)) - const filteredHeaders = orderedHeaders.filter((h) => - h.name.toLowerCase().includes(search.trim().toLowerCase()) + const filteredHeaders = useMemo( + () => + isOpen + ? orderedHeaders.filter((h) => + h.name.toLowerCase().includes(search.trim().toLowerCase()) + ) + : EMPTY_HEADERS, + [isOpen, orderedHeaders, search] ) const sensors = useSensors( @@ -293,7 +317,7 @@ const ColumnPickerControl = ({ layerId, allHeaders, columnConfig }) => { )} </> ) -} +}) ColumnPickerControl.propTypes = { layerId: PropTypes.string.isRequired, diff --git a/src/components/datatable/styles/BottomPanel.module.css b/src/components/datatable/styles/BottomPanel.module.css index 083bac7ce9..5f23ec511b 100644 --- a/src/components/datatable/styles/BottomPanel.module.css +++ b/src/components/datatable/styles/BottomPanel.module.css @@ -17,6 +17,7 @@ .tableContainer { flex: 1; min-height: 0; + overflow: hidden; position: relative; } diff --git a/src/components/datatable/styles/DataTable.module.css b/src/components/datatable/styles/DataTable.module.css index 41cb48405d..a083a8c5e8 100644 --- a/src/components/datatable/styles/DataTable.module.css +++ b/src/components/datatable/styles/DataTable.module.css @@ -156,7 +156,7 @@ th.hovered { .loadingReason { font-size: 12px; - color: var(--colors-grey700); + color: var(--colors-white); } .noSupport { diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index 04a3eb23a9..5b22c4b730 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -1,7 +1,11 @@ import i18n from '@dhis2/d2-i18n' -import { useMemo, useRef } from 'react' +import { useDeferredValue, useMemo, useRef } from 'react' import { useSelector } from 'react-redux' -import { SENTINEL_NO_VALUE, SORT_ASCENDING } from '../../constants/dataTable.js' +import { + SENTINEL_NO_VALUE, + SENTINEL_SELECTED_ROW, + SORT_ASCENDING, +} from '../../constants/dataTable.js' import { EVENT_LAYER, THEMATIC_LAYER, @@ -114,11 +118,6 @@ const defaultFieldsMap = () => ({ }, }) -// Canonical trailing order for classification/styling columns - shared -// across every layer type that has any subset of them, so switching -// between layer types never reshuffles where these appear relative to -// each other (e.g. Group always comes before Color, Color always comes -// before Icon, regardless of which layer type is showing them). const getStyleHeaders = ({ hasLegend, hasRange, @@ -152,15 +151,6 @@ const getThematicHeaders = () => getStyleHeaders({ hasLegend: true, hasRange: true, hasColor: true }) ) -// Timeline gets the standard Value/Legend/Range/Color columns, relabeled -// with the active period's name (updates live as the timeline slider -// moves). Split-by-period has no single "current" period to privilege, so -// it only gets the base org unit columns - same shape as getOrgUnitHeaders. -// Every other available period (all of them, for split - every one but the -// active one, for timeline, since that one's already the Value/Legend/ -// Range/Color columns above) gets its own raw-value-only column too, -// hidden by default (defaultHidden) so the table isn't cluttered with -// every period until the user turns one on from the column picker. const getMultiPeriodThematicHeaders = ({ isTimelineThematic, externalPeriod, @@ -232,26 +222,29 @@ const getEventHeaders = ({ return fields.concat(customFields) } -// Facility/org unit layers only get Group/Color/Icon columns when the -// current group-set styling actually produced them - style type (and -// whether every org unit matched a group) isn't known up front, so this -// checks the resolved row data rather than re-deriving that logic here. -const getOrgUnitStyleHeaders = (data) => - getStyleHeaders({ - hasGroup: data?.some((d) => d.group != null), - hasColor: data?.some((d) => d.color != null), - hasIcon: data?.some((d) => d.iconUrl != null), - }) +const getOrgUnitStyleHeaders = (data) => { + let hasGroup = false + let hasColor = false + let hasIcon = false + + for (const d of data ?? []) { + hasGroup ||= d.group != null + hasColor ||= d.color != null + hasIcon ||= d.iconUrl != null + + if (hasGroup && hasColor && hasIcon) { + break + } + } + + return getStyleHeaders({ hasGroup, hasColor, hasIcon }) +} const getOrgUnitHeaders = (data) => [NAME, ID, LEVEL, PARENT_NAME, TYPE] .map((field) => defaultFieldsMap()[field]) .concat(getOrgUnitStyleHeaders(data)) -// Unlike getEventHeaders's layerHeaders (raw analytics response shape, -// name=uid/column=display), trackedEntityLoader.js already builds its -// headers in the final {name, dataKey, valueType} shape - only the -// valueType -> table type classification needs doing here. const getTrackedEntityHeaders = ({ layerHeaders = [] }) => { const fields = [ID].map((field) => defaultFieldsMap()[field]) @@ -314,9 +307,6 @@ const getEarthEngineHeaders = ({ aggregationType, legend, data }) => { .concat(customFields) } -// The synthetic per-geometry-type `color` property gets the same -// canonical, translated Color header every other layer type uses, -// rather than being treated as just another arbitrary uploaded field. const getGeoJsonUrlHeaders = (firstDataItem) => getGeojsonDisplayData(firstDataItem).map((header) => header.dataKey === COLOR ? defaultFieldsMap()[COLOR] : header @@ -339,10 +329,6 @@ export const useTableData = ({ }) => { const allAggregations = useSelector((state) => state.aggregations) const aggregations = allAggregations[layer.id] || EMPTY_AGGREGATIONS - // The timeline's active period is Map.jsx's own local UI state, not - // part of the layer config stored in Redux - it's synced into - // state.ui separately (see Map.jsx/MapContainer.jsx) so the data - // table, a sibling of the map, can read the same "current period". const externalPeriod = useSelector( (state) => state.ui?.activeTimelinePeriod ) @@ -376,6 +362,15 @@ export const useTableData = ({ const isStyledEvent = layerType === EVENT_LAYER && !!styleDataItem const boundsDependency = showOnlyFeaturesInView ? mapBounds : null + const selectedIdSetDependency = + sortField === SENTINEL_SELECTED_ROW || selectionFilter?.length + ? selectedIdSet + : null + const periodsDependency = isMultiPeriodThematic ? periods : null + const valuesByPeriodDependency = isMultiPeriodThematic + ? valuesByPeriod + : null + const externalPeriodDependency = isTimelineThematic ? externalPeriod : null const dataWithAggregations = useMemo(() => { errorCode.current = null @@ -409,9 +404,6 @@ export const useTableData = ({ const properties = d.properties || d if (isStyledEvent) { - // The event's own styling pass already classified this - // feature into legend.items[colorGroup] (color/radius) - - // Legend/Range are just a lookup, not new classification. const legendItem = legend?.items?.[properties.colorGroup] return { ...properties, @@ -467,7 +459,7 @@ export const useTableData = ({ index, } }) - // boundsDependency intentionally proxies mapBounds only while the toggle is on + // *Dependency vars proxy their raw counterparts (see above) // eslint-disable-next-line react-hooks/exhaustive-deps }, [ data, @@ -479,9 +471,9 @@ export const useTableData = ({ boundsDependency, isMultiPeriodThematic, isTimelineThematic, - valuesByPeriod, - externalPeriod, - periods, + valuesByPeriodDependency, + externalPeriodDependency, + periodsDependency, isStyledEvent, legend, keyAnalysisDigitGroupSeparator, @@ -550,6 +542,8 @@ export const useTableData = ({ return null } return headers + // *Dependency vars proxy their raw counterparts (see above) + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ layerType, aggregationType, @@ -561,19 +555,21 @@ export const useTableData = ({ layerHeaders, isMultiPeriodThematic, isTimelineThematic, - externalPeriod, - periods, + externalPeriodDependency, + periodsDependency, ]) - const columnOptions = useMemo(() => { - if (!headers?.length || !dataWithAggregations?.length) { - return EMPTY_COLUMN_OPTIONS + // Expensive: scans every row once per column + const deferredDataForOptions = useDeferredValue(dataWithAggregations) + const columnDistinctValues = useMemo(() => { + if (!headers?.length || !deferredDataForOptions?.length) { + return null } const result = {} headers.forEach(({ dataKey, type }) => { const seen = new Set() - for (const item of dataWithAggregations) { + for (const item of deferredDataForOptions) { const val = item[dataKey] seen.add( val === undefined || val === null || val === '' @@ -583,9 +579,25 @@ export const useTableData = ({ } if (seen.size > 0) { + result[dataKey] = { values: Array.from(seen), type } + } + }) + + return result + }, [headers, deferredDataForOptions]) + + // Cheap: just re-orders each column's already-known distinct-value list + const columnOptions = useMemo(() => { + if (!columnDistinctValues) { + return EMPTY_COLUMN_OPTIONS + } + + const result = {} + Object.entries(columnDistinctValues).forEach( + ([dataKey, { values, type }]) => { const direction = dataKey === sortField ? sortDirection : SORT_ASCENDING - result[dataKey] = Array.from(seen) + result[dataKey] = [...values] .sort((a, b) => compareColumnOptionValues(a, b, { dataKey, @@ -595,10 +607,10 @@ export const useTableData = ({ ) .map((value) => ({ value })) } - }) + ) return Object.keys(result).length ? result : EMPTY_COLUMN_OPTIONS - }, [headers, dataWithAggregations, sortField, sortDirection]) + }, [columnDistinctValues, sortField, sortDirection]) const rows = useMemo(() => { if (errorCode.current) { @@ -655,6 +667,8 @@ export const useTableData = ({ } }) ) + // *Dependency vars proxy their raw counterparts (see above) + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ headers, dataWithAggregations, @@ -663,7 +677,7 @@ export const useTableData = ({ sortField, sortDirection, selectionFilter, - selectedIdSet, + selectedIdSetDependency, ]) // EE layers and event layers may be loading additional data diff --git a/src/util/tableColumns.js b/src/util/tableColumns.js index 496748c82e..fba5004a08 100644 --- a/src/util/tableColumns.js +++ b/src/util/tableColumns.js @@ -1,21 +1,8 @@ const CHECKBOX_COLUMN_WIDTH = 76 -const getOrderIndex = (dataKey, orderedKeys) => { - const index = orderedKeys.indexOf(dataKey) - return index === -1 ? orderedKeys.length : index -} - -// A header can opt out of the "everything visible by default" rule (e.g. -// period columns, which exist for every available period but would clutter -// the table if all shown before the user picks any) - used both here and -// by ColumnPickerControl, so the table and the picker's checkboxes always -// agree on what "not yet customized" means. export const getDefaultVisibleKeys = (headers) => headers.filter((h) => !h.defaultHidden).map((h) => h.dataKey) -// Ordering + pinning only, deliberately never filtered by visibility - used -// by the column picker, which needs a row for every header regardless of -// whether it's currently shown, and by getVisibleHeaders below. export const getOrderedHeaders = (headers, config) => { if (!headers) { return headers @@ -23,13 +10,15 @@ export const getOrderedHeaders = (headers, config) => { const { orderedKeys, pinnedKeys } = config ?? {} - let result = orderedKeys - ? [...headers].sort( - (a, b) => - getOrderIndex(a.dataKey, orderedKeys) - - getOrderIndex(b.dataKey, orderedKeys) - ) - : headers + let result = headers + if (orderedKeys) { + const orderIndex = new Map(orderedKeys.map((key, i) => [key, i])) + const getOrderIndex = (dataKey) => + orderIndex.get(dataKey) ?? orderedKeys.length + result = [...headers].sort( + (a, b) => getOrderIndex(a.dataKey) - getOrderIndex(b.dataKey) + ) + } if (pinnedKeys?.length) { const pinned = result.filter((h) => pinnedKeys.includes(h.dataKey)) diff --git a/yarn.lock b/yarn.lock index 4da5407e9b..7953bde0f5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2431,9 +2431,9 @@ resolved "https://registry.yarnpkg.com/@dhis2/data-engine/-/data-engine-3.17.3.tgz#0347416e9919efbf4d9739c4141fa543f89669ad" integrity sha512-hLXt7LFrFitR7QgKfGQ3ComTLrY5IAdtERonhdo/SIrsRYWoeVaMiCOkUUzC48pEaeo1/BL5qwA7Tw7jZgROQw== -"@dhis2/maps-gl@git+https://github.com/d2-ci/maps-gl.git#90476d118e5d9b62b6d7d97ac1d2d41e7a3fb840": +"@dhis2/maps-gl@git+https://github.com/d2-ci/maps-gl.git#55ba8864b811c44279dd7c85dedc37adc426e318": version "4.3.1" - resolved "git+https://github.com/d2-ci/maps-gl.git#90476d118e5d9b62b6d7d97ac1d2d41e7a3fb840" + resolved "git+https://github.com/d2-ci/maps-gl.git#55ba8864b811c44279dd7c85dedc37adc426e318" dependencies: "@mapbox/sphericalmercator" "^1.2.0" "@turf/area" "^7.3.5" From d2b2cd6a4403fa8995498585b4ab06e959b3d863 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 23 Jul 2026 11:06:12 +0200 Subject: [PATCH 088/205] chore: PR clean-up --- src/components/datatable/BottomPanel.jsx | 31 +- src/components/datatable/DataTable.jsx | 66 +-- src/components/datatable/FilterInput.jsx | 69 ++- .../datatable/__tests__/FilterInput.spec.jsx | 1 + .../controls/ColumnPickerControl.jsx | 24 +- src/components/datatable/useTableData.js | 498 +++--------------- src/constants/dataTable.js | 7 + .../__tests__/trackedEntityLoader.spec.js | 12 +- src/loaders/thematicLoader.js | 1 + src/loaders/trackedEntityLoader.js | 40 +- src/util/__tests__/dataTable.spec.js | 118 +++++ src/util/__tests__/filterInput.spec.js | 59 +++ src/util/__tests__/tableColumns.spec.js | 97 ++++ src/util/__tests__/tableHeaders.spec.js | 234 ++++++++ src/util/__tests__/tableRows.spec.js | 189 +++++++ src/util/dataTable.js | 39 ++ src/util/filter.js | 11 +- src/util/filterInput.js | 17 +- src/util/tableColumns.js | 66 +++ src/util/tableHeaders.js | 332 ++++++++++++ src/util/tableRows.js | 108 ++++ src/util/tableSort.js | 3 +- 22 files changed, 1463 insertions(+), 559 deletions(-) create mode 100644 src/util/__tests__/tableHeaders.spec.js create mode 100644 src/util/__tests__/tableRows.spec.js create mode 100644 src/util/tableHeaders.js create mode 100644 src/util/tableRows.js diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index 77867e7fcb..0eb97deedf 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -15,6 +15,10 @@ import { setHighlightColor, } from '../../actions/dataTable.js' import useKeyDown from '../../hooks/useKeyDown.js' +import { + getPanelHeights, + hasActiveDataTableFilters, +} from '../../util/dataTable.js' import { getCssVar } from '../../util/helpers.js' import { useWindowDimensions } from '../WindowDimensionsProvider.jsx' import ActiveLayerControl from './controls/ActiveLayerControl.jsx' @@ -58,18 +62,21 @@ const BottomPanel = () => { const [globalSearch, setGlobalSearch] = useState('') const [headersByLayer, setHeadersByLayer] = useState(null) - const hasActiveFilters = - Object.keys(dataFilters).length > 0 || - globalSearch.trim() !== '' || - selectionFilter?.length > 0 || - showOnlyFeaturesInView - - const maxHeight = - height - getCssVar('--header-height') - getCssVar('--toolbar-height') - const tableHeight = - dataTableHeight < maxHeight ? dataTableHeight : maxHeight - const collapsedHeight = getCssVar('--data-table-controls-height') - const displayHeight = isCollapsed ? collapsedHeight : tableHeight + const hasActiveFilters = hasActiveDataTableFilters({ + dataFilters, + globalSearch, + selectionFilter, + showOnlyFeaturesInView, + }) + + const { maxHeight, collapsedHeight, displayHeight } = getPanelHeights({ + windowHeight: height, + dataTableHeight, + isCollapsed, + headerHeight: getCssVar('--header-height'), + toolbarHeight: getCssVar('--toolbar-height'), + controlsHeight: getCssVar('--data-table-controls-height'), + }) const toggleCollapsed = useCallback( () => setIsCollapsed((collapsed) => !collapsed), diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index 79ebc00b6a..29f5430638 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -29,12 +29,16 @@ import { import { SENTINEL_SELECTED_ROW, SORT_ASCENDING, + RENDERER_COLOR, + RENDERER_ICON, } from '../../constants/dataTable.js' import { isDarkColor } from '../../util/colors.js' import { + buildFeatureIndex, getNextSorting, getRowClickAction, getRowId, + hasActiveDataTableFilters, isFilterable, shouldClearFeatureHighlight, } from '../../util/dataTable.js' @@ -100,9 +104,8 @@ const Table = ({ [sortField, sortDirection] ) - // Read via ref rather than a dependency, so this callback (and anything - // memoized on it, e.g. tableContext) stays stable across hovers instead - // of getting a new identity on every single mouse-enter + // Read via ref rather than a dependency, so this callback stays stable + // across hovers instead of getting a new identity on every single mouse-enter const featureRef = useRef(feature) featureRef.current = feature @@ -136,16 +139,10 @@ const Table = ({ [dispatch] ) - const featureById = useMemo(() => { - const map = new Map() - layer.data?.forEach((f) => { - const id = f.properties?.id ?? f.id - if (id != null) { - map.set(id, f) - } - }) - return map - }, [layer.data]) + const featureById = useMemo( + () => buildFeatureIndex(layer.data), + [layer.data] + ) const [tableContextMenu, setTableContextMenu] = useState(null) @@ -205,6 +202,11 @@ const Table = ({ [headers, columnConfig] ) + const rendererByDataKey = useMemo( + () => new Map(visibleHeaders.map((h) => [h.dataKey, h.renderer])), + [visibleHeaders] + ) + const { headerRowRef, columnWidths } = useColumnWidths({ availableWidth, headers: visibleHeaders, @@ -280,10 +282,12 @@ const Table = ({ [dispatch, layer.id] ) - const hasActiveFilters = - Object.keys(layer.dataFilters ?? {}).length > 0 || - !!globalSearch?.trim() || - selectionFilter?.length > 0 + const hasActiveFilters = hasActiveDataTableFilters({ + dataFilters: layer.dataFilters, + globalSearch, + selectionFilter, + showOnlyFeaturesInView, + }) const tableContext = useMemo( () => ({ @@ -419,7 +423,7 @@ const Table = ({ </div> </DataTableColumnHeader> {visibleHeaders.map( - ({ name, dataKey, type, optionSet }, index) => { + ({ name, dataKey, type, optionSet, renderer }, index) => { const { fixed, left, isLastPinned } = getPinnedCellProps(dataKey, index, { pinnedLeftOffsets, @@ -448,6 +452,7 @@ const Table = ({ name={name} options={columnOptions[dataKey]} optionSetId={optionSet?.id} + renderer={renderer} /> ) } @@ -580,6 +585,9 @@ const Table = ({ pinnedColumnCount, columnWidths, }) + const renderer = rendererByDataKey.get(dataKey) + const isColorCell = renderer === RENDERER_COLOR + const isIconCell = renderer === RENDERER_ICON return ( <DataTableCell key={`dtcell-${dataKey}`} @@ -589,28 +597,24 @@ const Table = ({ width={width} className={cx(styles.dataCell, { [styles.lightText]: - dataKey === 'color' && + isColorCell && isDarkColor(value), [styles.monoCell]: - dataKey === 'id' || - dataKey === 'color', + dataKey === 'id' || isColorCell, [styles.selected]: - isSelected && - dataKey !== 'color', + isSelected && !isColorCell, [styles.hovered]: - isHovered && - dataKey !== 'color', + isHovered && !isColorCell, [styles.pinnedColumnShadow]: isLastPinned, })} backgroundColor={ - dataKey === 'color' ? value : null + isColorCell ? value : null } align={align} > - {dataKey === 'color' && - value?.toLowerCase()} - {dataKey === 'iconUrl' && value && ( + {isColorCell && value?.toLowerCase()} + {isIconCell && value && ( <img className={styles.iconCell} src={value} @@ -621,8 +625,8 @@ const Table = ({ }} /> )} - {dataKey !== 'color' && - dataKey !== 'iconUrl' && + {!isColorCell && + !isIconCell && formatWithSeparator( value, keyAnalysisDigitGroupSeparator diff --git a/src/components/datatable/FilterInput.jsx b/src/components/datatable/FilterInput.jsx index 5fa0633ca3..e536d6d6cb 100644 --- a/src/components/datatable/FilterInput.jsx +++ b/src/components/datatable/FilterInput.jsx @@ -9,14 +9,21 @@ import { setDataFilter, clearDataFilter } from '../../actions/dataFilters.js' import { SENTINEL_ANY_VALUE, SENTINEL_NO_VALUE, + RENDERER_COLOR, + RENDERER_ICON, + TYPE_NUMBER, } from '../../constants/dataTable.js' import useOptionSet from '../../hooks/useOptionSet.js' import { + getCyclicIndex, getDisplayValue, getFilteredOptions, getPopoverWidth, getSelectedAndAppliedString, + hasMatchingOptionLabel, measureMaxTextWidth, + toHighlightedIndex, + toOptionIndex, } from '../../util/filterInput.js' import { getInvertibleValues, @@ -64,6 +71,7 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({ options, resolveLabel, type, + renderer, allowCustomFilter = true, }) { const dispatch = useDispatch() @@ -104,7 +112,7 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({ ? dispatch(setDataFilter(layerId, dataKey, text)) : dispatch(clearDataFilter(layerId, dataKey)) - const isIconColumn = dataKey === 'iconUrl' + const isIconColumn = renderer === RENDERER_ICON const renderOptionLabel = (value) => isIconColumn ? ( @@ -145,6 +153,7 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({ const font = `11px ${getComputedStyle(document.body).fontFamily}` const maxLabelWidth = measureMaxTextWidth(labels, font) return getPopoverWidth(maxLabelWidth) + // resolveLabel's identity only changes alongside type/optionSet, which don't change without realOptions changing too // eslint-disable-next-line react-hooks/exhaustive-deps }, [realOptions, hasNotSetOption]) @@ -176,9 +185,10 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({ ) const hasExactMatch = useMemo( () => - filteredOptions.some( - ({ value }) => - resolveLabel(value).toLowerCase() === normalizedSearch + hasMatchingOptionLabel( + filteredOptions, + resolveLabel, + normalizedSearch ), [filteredOptions, resolveLabel, normalizedSearch] ) @@ -187,13 +197,13 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({ const totalCount = filteredOptions.length + (showCustomFilterRow ? 1 : 0) const customFilterTag = - type === 'number' ? i18n.t('Use filter') : i18n.t('Contains') + type === TYPE_NUMBER ? i18n.t('Use filter') : i18n.t('Contains') const hasActiveFilter = selected.length > 0 || appliedString !== '' const onSearchChange = ({ value }) => { const sanitized = - type === 'number' + type === TYPE_NUMBER ? value.replace(NUMERIC_INPUT_DISALLOWED, '') : value setSearchText(sanitized) @@ -212,9 +222,10 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({ } const normalized = trimmed.toLowerCase() - const exactMatch = options.some( - ({ value: optionValue }) => - resolveLabel(optionValue).toLowerCase() === normalized + const exactMatch = hasMatchingOptionLabel( + options, + resolveLabel, + normalized ) if (!exactMatch) { applyCustomFilter(trimmed) @@ -222,7 +233,7 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({ } const scrollHighlightedIntoView = (index) => { - const optionIndex = showCustomFilterRow ? index - 1 : index + const optionIndex = toOptionIndex(index, showCustomFilterRow) if (optionIndex >= 0 && optionIndex < filteredOptions.length) { listRef.current?.scrollToIndex({ index: optionIndex, @@ -242,9 +253,7 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({ applyCustomFilter(searchText.trim()) return } - const optionIndex = showCustomFilterRow - ? highlightedIndex - 1 - : highlightedIndex + const optionIndex = toOptionIndex(highlightedIndex, showCustomFilterRow) if (optionIndex >= 0 && optionIndex < filteredOptions.length) { toggleValue(filteredOptions[optionIndex].value) } @@ -255,7 +264,7 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({ case 'ArrowDown': event.preventDefault() setHighlightedIndex((i) => { - const next = totalCount ? (i + 1) % totalCount : -1 + const next = getCyclicIndex(i, totalCount, 1) scrollHighlightedIntoView(next) return next }) @@ -263,9 +272,7 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({ case 'ArrowUp': event.preventDefault() setHighlightedIndex((i) => { - const next = totalCount - ? (i - 1 + totalCount) % totalCount - : -1 + const next = getCyclicIndex(i, totalCount, -1) scrollHighlightedIntoView(next) return next }) @@ -297,7 +304,7 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({ clearable dataTest={`data-table-column-filter-search-${name}`} placeholder={ - type === 'number' + type === TYPE_NUMBER ? i18n.t('Search or type > 5, < 8…') : i18n.t('Search') } @@ -316,11 +323,15 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({ <div className={styles.filterTrigger} ref={anchorRef}> <FilterHelpTooltip content={ - type === 'number' ? NUMERIC_FILTER_HELP : TEXT_FILTER_HELP + type === TYPE_NUMBER + ? NUMERIC_FILTER_HELP + : TEXT_FILTER_HELP } placement={tooltipPlacement} estimatedHeight={ - type === 'number' ? NUMERIC_HELP_HEIGHT : TEXT_HELP_HEIGHT + type === TYPE_NUMBER + ? NUMERIC_HELP_HEIGHT + : TEXT_HELP_HEIGHT } dataTest="data-table-filter-help" > @@ -450,13 +461,14 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({ className={cx( styles.denseCheckbox, (dataKey === 'id' || - dataKey === 'color') && + renderer === + RENDERER_COLOR) && styles.monoOption, highlightedIndex === - (showCustomFilterRow - ? index + 1 - : index) && - styles.highlighted + toHighlightedIndex( + index, + showCustomFilterRow + ) && styles.highlighted )} /> )} @@ -483,6 +495,7 @@ SearchableFilterPopover.propTypes = { PropTypes.arrayOf(PropTypes.string), ]), layerId: PropTypes.string, + renderer: PropTypes.string, } const PlainSearchableFilter = (props) => { @@ -496,7 +509,7 @@ const PlainSearchableFilter = (props) => { if (value === SENTINEL_NO_VALUE) { return i18n.t('No value') } - return type === 'number' + return type === TYPE_NUMBER ? formatWithSeparator( Number(value), keyAnalysisDigitGroupSeparator @@ -546,6 +559,7 @@ const FilterInput = React.memo(function FilterInput({ name, options, optionSetId, + renderer, }) { const dataTable = useSelector((state) => state.dataTable) const map = useSelector((state) => state.map) @@ -571,6 +585,7 @@ const FilterInput = React.memo(function FilterInput({ options={options ?? []} optionSetId={optionSetId} type={type} + renderer={renderer} /> ) : ( <PlainSearchableFilter @@ -580,6 +595,7 @@ const FilterInput = React.memo(function FilterInput({ filterValue={filterValue} options={options ?? []} type={type} + renderer={renderer} /> ) }) @@ -590,6 +606,7 @@ FilterInput.propTypes = { type: PropTypes.string.isRequired, optionSetId: PropTypes.string, options: PropTypes.arrayOf(PropTypes.shape({ value: PropTypes.string })), + renderer: PropTypes.string, } export default FilterInput diff --git a/src/components/datatable/__tests__/FilterInput.spec.jsx b/src/components/datatable/__tests__/FilterInput.spec.jsx index c7414136db..928d2e6da3 100644 --- a/src/components/datatable/__tests__/FilterInput.spec.jsx +++ b/src/components/datatable/__tests__/FilterInput.spec.jsx @@ -205,6 +205,7 @@ describe('FilterInput multi-select path (no optionSetId)', () => { renderFilterInput({ dataKey: 'iconUrl', name: 'Icon', + renderer: 'rendericon', options: [{ value: 'https://server/api/icons/mapMarker024.png' }], }) openPopover('Icon') diff --git a/src/components/datatable/controls/ColumnPickerControl.jsx b/src/components/datatable/controls/ColumnPickerControl.jsx index 11f555940e..fe7ca0a70f 100644 --- a/src/components/datatable/controls/ColumnPickerControl.jsx +++ b/src/components/datatable/controls/ColumnPickerControl.jsx @@ -16,7 +16,6 @@ import { sortableKeyboardCoordinates, verticalListSortingStrategy, } from '@dnd-kit/sortable' -import { arrayMoveImmutable } from 'array-move' import cx from 'classnames' import PropTypes from 'prop-types' import React, { @@ -30,10 +29,12 @@ import { createPortal } from 'react-dom' import { useDispatch } from 'react-redux' import { setDataTableColumnConfig } from '../../../actions/dataTable.js' import { + filterHeadersByName, getDefaultVisibleKeys, getOrderedHeaders, getPinnedCount, isPinnedGroupEnd, + reorderHeaderKeys, reverseVisibleKeys, togglePinnedKey, toggleVisibleKey, @@ -134,9 +135,7 @@ const ColumnPickerControl = React.memo(function ColumnPickerControl({ const filteredHeaders = useMemo( () => isOpen - ? orderedHeaders.filter((h) => - h.name.toLowerCase().includes(search.trim().toLowerCase()) - ) + ? filterHeadersByName(orderedHeaders, search) : EMPTY_HEADERS, [isOpen, orderedHeaders, search] ) @@ -157,19 +156,12 @@ const ColumnPickerControl = React.memo(function ColumnPickerControl({ setActiveId(null) if (over && active.id !== over.id) { - const oldIndex = orderedHeaders.findIndex( - (h) => h.dataKey === active.id + const nextOrder = reorderHeaderKeys( + orderedHeaders, + active.id, + over.id ) - const newIndex = orderedHeaders.findIndex( - (h) => h.dataKey === over.id - ) - - if (oldIndex !== -1 && newIndex !== -1) { - const nextOrder = arrayMoveImmutable( - orderedHeaders, - oldIndex, - newIndex - ).map((h) => h.dataKey) + if (nextOrder) { updateConfig({ orderedKeys: nextOrder }) } } diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index 5b22c4b730..67fc24745c 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -2,18 +2,13 @@ import i18n from '@dhis2/d2-i18n' import { useDeferredValue, useMemo, useRef } from 'react' import { useSelector } from 'react-redux' import { - SENTINEL_NO_VALUE, SENTINEL_SELECTED_ROW, SORT_ASCENDING, } from '../../constants/dataTable.js' import { EVENT_LAYER, THEMATIC_LAYER, - ORG_UNIT_LAYER, EARTH_ENGINE_LAYER, - FACILITY_LAYER, - GEOJSON_URL_LAYER, - TRACKED_ENTITY_LAYER, RENDERING_STRATEGY_SINGLE, RENDERING_STRATEGY_TIMELINE, } from '../../constants/layers.js' @@ -21,41 +16,24 @@ import { SELECTION_FILTER_SELECTED, SELECTION_FILTER_NOT_SELECTED, } from '../../constants/selection.js' -import { numberValueTypes } from '../../constants/valueTypes.js' -import { hasClasses } from '../../util/earthEngine.js' import { filterByGlobalSearch, filterData } from '../../util/filter.js' -import { getGeojsonDisplayData, isFeatureInBounds } from '../../util/geojson.js' import { - formatRangeWithSeparator, - getRoundToPrecisionFn, - getPrecision, -} from '../../util/numbers.js' + buildRowCells, + getColumnDistinctValues, +} from '../../util/tableColumns.js' +import { + TYPE_STRING, + ERROR_NON_HOMOGENOUS_FEATURES, + getHeadersForLayer, +} from '../../util/tableHeaders.js' +import { + ERROR_SERVER_CLUSTER, + ERROR_NO_VALID_DATA, + buildTableData, +} from '../../util/tableRows.js' import { compareColumnOptionValues, compareRows } from '../../util/tableSort.js' -import { isValidUid } from '../../util/uid.js' - -const TYPE_NUMBER = 'number' -const TYPE_STRING = 'string' -const TYPE_DATE = 'date' -const NAME = 'name' -const ID = 'id' -const VALUE = 'rawValue' -const LEGEND = 'legend' -const RANGE = 'range' -const LEVEL = 'level' -const PARENT_NAME = 'parentName' -const TYPE = 'type' -const COLOR = 'color' -const GROUP = 'group' -const ICON = 'iconUrl' -const OUNAME = 'ouname' -const OUBOUNDARY = 'ouBoundary' -const EVENTDATE = 'eventdate' - -const ERROR_SERVER_CLUSTER = 'SERVER_CLUSTER' -const ERROR_NO_VALID_DATA = 'NO_VALID_DATA' const ERROR_NO_HEADERS = 'NO_HEADERS' -const ERROR_NON_HOMOGENOUS_FEATURES = 'NON_HOMOGENOUS_FEATURES' const getErrorCodeText = (code) => { switch (code) { @@ -78,240 +56,6 @@ const getErrorCodeText = (code) => { } } -const defaultFieldsMap = () => ({ - [NAME]: { name: i18n.t('Name'), dataKey: NAME, type: TYPE_STRING }, - [ID]: { name: i18n.t('Id'), dataKey: ID, type: TYPE_STRING }, - [LEVEL]: { name: i18n.t('Level'), dataKey: LEVEL, type: TYPE_NUMBER }, - [PARENT_NAME]: { - name: i18n.t('Parent'), - dataKey: PARENT_NAME, - type: TYPE_STRING, - }, - [TYPE]: { name: i18n.t('Type'), dataKey: TYPE, type: TYPE_STRING }, - [VALUE]: { name: i18n.t('Value'), dataKey: VALUE, type: TYPE_NUMBER }, - [LEGEND]: { name: i18n.t('Legend'), dataKey: LEGEND, type: TYPE_STRING }, - [RANGE]: { name: i18n.t('Range'), dataKey: RANGE, type: TYPE_STRING }, - [OUNAME]: { name: i18n.t('Org unit'), dataKey: OUNAME, type: TYPE_STRING }, - [OUBOUNDARY]: { - name: i18n.t('Org unit boundary'), - dataKey: OUBOUNDARY, - type: TYPE_STRING, - }, - [EVENTDATE]: { - name: i18n.t('Event time'), - dataKey: EVENTDATE, - type: TYPE_DATE, - renderer: 'formatTime...', - }, - [COLOR]: { - name: i18n.t('Color'), - dataKey: COLOR, - type: TYPE_STRING, - renderer: 'rendercolor', - }, - [GROUP]: { name: i18n.t('Group'), dataKey: GROUP, type: TYPE_STRING }, - [ICON]: { - name: i18n.t('Icon'), - dataKey: ICON, - type: TYPE_STRING, - renderer: 'rendericon', - }, -}) - -const getStyleHeaders = ({ - hasLegend, - hasRange, - hasGroup, - hasColor, - hasIcon, -}) => { - const headers = [] - if (hasLegend) { - headers.push(defaultFieldsMap()[LEGEND]) - } - if (hasRange) { - headers.push(defaultFieldsMap()[RANGE]) - } - if (hasGroup) { - headers.push(defaultFieldsMap()[GROUP]) - } - if (hasColor) { - headers.push(defaultFieldsMap()[COLOR]) - } - if (hasIcon) { - headers.push(defaultFieldsMap()[ICON]) - } - return headers -} - -const getThematicHeaders = () => - [NAME, ID, VALUE, LEVEL, PARENT_NAME, TYPE] - .map((field) => defaultFieldsMap()[field]) - .concat( - getStyleHeaders({ hasLegend: true, hasRange: true, hasColor: true }) - ) - -const getMultiPeriodThematicHeaders = ({ - isTimelineThematic, - externalPeriod, - periods, -}) => { - const headers = isTimelineThematic - ? getThematicHeaders().map((header) => - [VALUE, LEGEND, RANGE, COLOR].includes(header.dataKey) - ? { - ...header, - name: `${header.name} (${ - externalPeriod?.name ?? i18n.t('Current period') - })`, - } - : header - ) - : getOrgUnitHeaders() - - const otherPeriods = isTimelineThematic - ? (periods ?? []).filter((p) => p.id !== externalPeriod?.id) - : periods ?? [] - - otherPeriods.forEach((period) => { - headers.push({ - name: i18n.t('Value ({{period}})', { period: period.name }), - dataKey: `period_${period.id}_rawValue`, - type: TYPE_NUMBER, - defaultHidden: true, - }) - }) - - return headers -} - -const getEventHeaders = ({ - layerHeaders = [], - styleDataItem, - countEventsOutsideOrgUnits, -}) => { - const fields = [OUNAME, ID, EVENTDATE].map( - (field) => defaultFieldsMap()[field] - ) - - if (countEventsOutsideOrgUnits) { - fields.push(defaultFieldsMap()[OUBOUNDARY]) - } - - const customFields = layerHeaders - .filter(({ name }) => isValidUid(name)) - .map(({ name: dataKey, column: name, valueType, optionSet }) => ({ - name, - dataKey, - type: - !optionSet && numberValueTypes.includes(valueType) - ? TYPE_NUMBER - : TYPE_STRING, - optionSet: optionSet || null, - })) - - customFields.push( - defaultFieldsMap()[TYPE], - ...getStyleHeaders({ - hasLegend: !!styleDataItem, - hasRange: !!styleDataItem, - hasColor: !!styleDataItem, - }) - ) - - return fields.concat(customFields) -} - -const getOrgUnitStyleHeaders = (data) => { - let hasGroup = false - let hasColor = false - let hasIcon = false - - for (const d of data ?? []) { - hasGroup ||= d.group != null - hasColor ||= d.color != null - hasIcon ||= d.iconUrl != null - - if (hasGroup && hasColor && hasIcon) { - break - } - } - - return getStyleHeaders({ hasGroup, hasColor, hasIcon }) -} - -const getOrgUnitHeaders = (data) => - [NAME, ID, LEVEL, PARENT_NAME, TYPE] - .map((field) => defaultFieldsMap()[field]) - .concat(getOrgUnitStyleHeaders(data)) - -const getTrackedEntityHeaders = ({ layerHeaders = [] }) => { - const fields = [ID].map((field) => defaultFieldsMap()[field]) - - const customFields = layerHeaders - .filter(({ dataKey }) => isValidUid(dataKey)) - .map(({ name, dataKey, valueType }) => ({ - name, - dataKey, - type: numberValueTypes.includes(valueType) - ? TYPE_NUMBER - : TYPE_STRING, - })) - - customFields.push(...getStyleHeaders({ hasColor: true })) - - return fields.concat(customFields) -} - -const getFacilityHeaders = (data) => - [NAME, ID, TYPE] - .map((field) => defaultFieldsMap()[field]) - .concat(getOrgUnitStyleHeaders(data)) - -const toTitleCase = (str) => - str.replace( - /\w\S*/g, - (txt) => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase() - ) - -const getEarthEngineHeaders = ({ aggregationType, legend, data }) => { - const { title, items } = legend - - let customFields = [] - - if (hasClasses(aggregationType) && items) { - customFields = items.map(({ value, name }) => ({ - name, - dataKey: String(value), - roundFn: getRoundToPrecisionFn(2), - type: TYPE_NUMBER, - })) - } else if (Array.isArray(aggregationType) && aggregationType.length) { - customFields = aggregationType.map((type) => { - let roundFn = null - if (data?.length) { - const precision = getPrecision(data.map((d) => d[type])) - roundFn = getRoundToPrecisionFn(precision) - } - return { - name: toTitleCase(`${type} ${title}`), - dataKey: type, - roundFn, - type: TYPE_NUMBER, - } - }) - } - - return [NAME, ID, TYPE] - .map((field) => defaultFieldsMap()[field]) - .concat(customFields) -} - -const getGeoJsonUrlHeaders = (firstDataItem) => - getGeojsonDisplayData(firstDataItem).map((header) => - header.dataKey === COLOR ? defaultFieldsMap()[COLOR] : header - ) - const EMPTY_AGGREGATIONS = {} const EMPTY_LAYER = {} const EMPTY_COLUMN_OPTIONS = {} @@ -373,92 +117,29 @@ export const useTableData = ({ const externalPeriodDependency = isTimelineThematic ? externalPeriod : null const dataWithAggregations = useMemo(() => { - errorCode.current = null - if (serverCluster) { - errorCode.current = ERROR_SERVER_CLUSTER - return null - } - - const allData = dataWithoutCoords?.length - ? [...(data || []), ...dataWithoutCoords] - : data - - if (!allData?.length) { - errorCode.current = ERROR_NO_VALID_DATA - return null - } - - const inViewData = showOnlyFeaturesInView - ? allData.filter((d) => isFeatureInBounds(d, mapBounds)) - : allData - - if (layerType === GEOJSON_URL_LAYER) { - return inViewData.map((d) => ({ - ...d.properties, - })) - } - - return inViewData - .filter((d) => !d.properties.hasAdditionalGeometry) - .map((d, index) => { - const properties = d.properties || d - - if (isStyledEvent) { - const legendItem = legend?.items?.[properties.colorGroup] - return { - ...properties, - legend: legendItem?.name, - range: - legendItem && 'startValue' in legendItem - ? formatRangeWithSeparator( - legendItem, - keyAnalysisDigitGroupSeparator, - { precision: legendDecimalPlaces } - ) - : undefined, - ...aggregations[d.id], - index, - } - } - - if (!isMultiPeriodThematic) { - return { - ...properties, - ...aggregations[d.id], - // Row-order tie-breaker for compareRows when no sortField is set - index, - } - } - - const orgUnitId = properties.id - const currentPeriodItem = isTimelineThematic - ? valuesByPeriod?.[externalPeriod?.id]?.[orgUnitId] - : null - const otherPeriodValues = {} - ;(periods ?? []).forEach((period) => { - if ( - isTimelineThematic && - period.id === externalPeriod?.id - ) { - return - } - otherPeriodValues[`period_${period.id}_rawValue`] = - valuesByPeriod?.[period.id]?.[orgUnitId]?.value ?? null - }) + const { data: rows, errorCode: rowsErrorCode } = buildTableData( + layerType, + { + data, + dataWithoutCoords, + serverCluster, + showOnlyFeaturesInView, + mapBounds, + aggregations, + isStyledEvent, + isMultiPeriodThematic, + isTimelineThematic, + legend, + valuesByPeriod, + externalPeriod, + periods, + keyAnalysisDigitGroupSeparator, + legendDecimalPlaces, + } + ) - return { - ...properties, - ...(currentPeriodItem && { - rawValue: currentPeriodItem.value, - color: currentPeriodItem.color, - legend: currentPeriodItem.legend, - range: currentPeriodItem.range, - }), - ...otherPeriodValues, - ...aggregations[d.id], - index, - } - }) + errorCode.current = rowsErrorCode ?? null + return rowsErrorCode ? null : rows // *Dependency vars proxy their raw counterparts (see above) // eslint-disable-next-line react-hooks/exhaustive-deps }, [ @@ -485,56 +166,26 @@ export const useTableData = ({ return null } - let headers = null - switch (layerType) { - case THEMATIC_LAYER: - headers = isMultiPeriodThematic - ? getMultiPeriodThematicHeaders({ - isTimelineThematic, - externalPeriod, - periods, - }) - : getThematicHeaders() - break - case EVENT_LAYER: - headers = getEventHeaders({ - layerHeaders, - styleDataItem, - countEventsOutsideOrgUnits, - }) - break - case ORG_UNIT_LAYER: - headers = getOrgUnitHeaders(dataWithAggregations) - break - case TRACKED_ENTITY_LAYER: - headers = getTrackedEntityHeaders({ layerHeaders }) - break - case EARTH_ENGINE_LAYER: - headers = getEarthEngineHeaders({ - aggregationType, - legend, - data: dataWithAggregations, - }) - break - case FACILITY_LAYER: - headers = getFacilityHeaders(dataWithAggregations) - break - case GEOJSON_URL_LAYER: { - if ( - data.some( - (feature) => - feature.geometry.type !== data[0].geometry.type - ) - ) { - errorCode.current = ERROR_NON_HOMOGENOUS_FEATURES - return null - } - - headers = getGeoJsonUrlHeaders(data[0]) - break + const { headers, errorCode: headersErrorCode } = getHeadersForLayer( + layerType, + { + isMultiPeriodThematic, + isTimelineThematic, + externalPeriod, + periods, + layerHeaders, + styleDataItem, + countEventsOutsideOrgUnits, + aggregationType, + legend, + data: dataWithAggregations, + rawData: data, } - default: - break + ) + + if (headersErrorCode) { + errorCode.current = headersErrorCode + return null } if (!headers?.length) { @@ -561,30 +212,10 @@ export const useTableData = ({ // Expensive: scans every row once per column const deferredDataForOptions = useDeferredValue(dataWithAggregations) - const columnDistinctValues = useMemo(() => { - if (!headers?.length || !deferredDataForOptions?.length) { - return null - } - - const result = {} - headers.forEach(({ dataKey, type }) => { - const seen = new Set() - for (const item of deferredDataForOptions) { - const val = item[dataKey] - seen.add( - val === undefined || val === null || val === '' - ? SENTINEL_NO_VALUE - : String(val) - ) - } - - if (seen.size > 0) { - result[dataKey] = { values: Array.from(seen), type } - } - }) - - return result - }, [headers, deferredDataForOptions]) + const columnDistinctValues = useMemo( + () => getColumnDistinctValues(headers, deferredDataForOptions), + [headers, deferredDataForOptions] + ) // Cheap: just re-orders each column's already-known distinct-value list const columnOptions = useMemo(() => { @@ -655,18 +286,7 @@ export const useTableData = ({ compareRows(a, b, { sortField, sortDirection, selectedIdSet }) ) - return filteredData.map((item) => - headers.map(({ dataKey, roundFn, type }) => { - const value = roundFn ? roundFn(item[dataKey]) : item[dataKey] - - return { - dataKey, - value: type === TYPE_NUMBER && isNaN(value) ? null : value, - align: type === TYPE_NUMBER ? 'right' : 'left', - itemId: item.id, - } - }) - ) + return filteredData.map((item) => buildRowCells(item, headers)) // *Dependency vars proxy their raw counterparts (see above) // eslint-disable-next-line react-hooks/exhaustive-deps }, [ diff --git a/src/constants/dataTable.js b/src/constants/dataTable.js index 10d2e59675..94d4a9cb05 100644 --- a/src/constants/dataTable.js +++ b/src/constants/dataTable.js @@ -4,3 +4,10 @@ export const SENTINEL_SELECTED_ROW = '__selected__' export const SORT_ASCENDING = 'asc' export const SORT_DESCENDING = 'desc' + +export const RENDERER_COLOR = 'rendercolor' +export const RENDERER_ICON = 'rendericon' + +export const TYPE_NUMBER = 'number' +export const TYPE_STRING = 'string' +export const TYPE_DATE = 'date' diff --git a/src/loaders/__tests__/trackedEntityLoader.spec.js b/src/loaders/__tests__/trackedEntityLoader.spec.js index e25cda3c91..4f322ca4e4 100644 --- a/src/loaders/__tests__/trackedEntityLoader.spec.js +++ b/src/loaders/__tests__/trackedEntityLoader.spec.js @@ -1,7 +1,7 @@ import { getAttributeHeaders, getAttributeProperties, - parseJsonConfig, + applyParsedConfig, toGeoJson, } from '../trackedEntityLoader.js' @@ -65,7 +65,7 @@ describe('getAttributeHeaders', () => { }) }) -describe('parseJsonConfig', () => { +describe('applyParsedConfig', () => { it('extracts periodType when relationships is null', () => { const config = { config: JSON.stringify({ @@ -73,7 +73,7 @@ describe('parseJsonConfig', () => { periodType: 'program', }), } - parseJsonConfig(config) + applyParsedConfig(config) expect(config.periodType).toBe('program') expect(config.relationshipType).toBeUndefined() expect(config.config).toBeUndefined() @@ -92,7 +92,7 @@ describe('parseJsonConfig', () => { periodType: 'program', }), } - parseJsonConfig(config) + applyParsedConfig(config) expect(config.periodType).toBe('program') expect(config.relationshipType).toBe('rel-type-id') expect(config.relatedPointColor).toBe('#ff0000') @@ -104,13 +104,13 @@ describe('parseJsonConfig', () => { it('does nothing when config.config is absent', () => { const config = { layer: 'trackedEntity' } - parseJsonConfig(config) + applyParsedConfig(config) expect(config).toEqual({ layer: 'trackedEntity' }) }) it('does not throw and leaves config intact on malformed JSON', () => { const config = { config: 'not-valid-json' } - expect(() => parseJsonConfig(config)).not.toThrow() + expect(() => applyParsedConfig(config)).not.toThrow() expect(config.periodType).toBeUndefined() expect(config.config).toBeUndefined() }) diff --git a/src/loaders/thematicLoader.js b/src/loaders/thematicLoader.js index 5d01d4fdbd..fbe8feac80 100644 --- a/src/loaders/thematicLoader.js +++ b/src/loaders/thematicLoader.js @@ -179,6 +179,7 @@ const thematicLoader = async ({ legend: null, isLoaded: true, isLoading: false, + isExpanded: true, loadError, } } diff --git a/src/loaders/trackedEntityLoader.js b/src/loaders/trackedEntityLoader.js index 4431dfeec8..7373e0f3c8 100644 --- a/src/loaders/trackedEntityLoader.js +++ b/src/loaders/trackedEntityLoader.js @@ -9,6 +9,7 @@ import { } from '../constants/layers.js' import { getProgramStatuses } from '../constants/programStatuses.js' import { getOrgUnitsFromRows } from '../util/analytics.js' +import { parseJsonConfig } from '../util/config.js' import { GEO_TYPE_POINT, GEO_TYPE_POLYGON, @@ -138,32 +139,23 @@ export const toGeoJson = (instances, color) => }, })) -export const parseJsonConfig = (config) => { - if (!config.config || typeof config.config !== 'string') { - return +export const applyParsedConfig = (config) => { + const { relationships, periodType, dataTableColumnConfig } = + parseJsonConfig(config.config) + + if (relationships) { + config.relationshipType = relationships.type + config.relatedPointColor = relationships.pointColor + config.relatedPointRadius = relationships.pointRadius + config.relationshipLineColor = relationships.lineColor + config.relationshipOutsideProgram = + relationships.relationshipOutsideProgram } - try { - const { relationships, periodType, dataTableColumnConfig } = JSON.parse( - config.config - ) - - if (relationships) { - config.relationshipType = relationships.type - config.relatedPointColor = relationships.pointColor - config.relatedPointRadius = relationships.pointRadius - config.relationshipLineColor = relationships.lineColor - config.relationshipOutsideProgram = - relationships.relationshipOutsideProgram - } - - config.periodType = periodType + config.periodType = periodType - if (dataTableColumnConfig) { - config.dataTableColumnConfig = dataTableColumnConfig - } - } catch (e) { - // Malformed config JSON + if (dataTableColumnConfig) { + config.dataTableColumnConfig = dataTableColumnConfig } delete config.config @@ -274,7 +266,7 @@ const trackedEntityLoader = async ({ keyAnalysisDigitGroupSeparator, serverVersion, }) => { - parseJsonConfig(config) + applyParsedConfig(config) const { trackedEntityType, diff --git a/src/util/__tests__/dataTable.spec.js b/src/util/__tests__/dataTable.spec.js index 94d7c6026d..27484a9981 100644 --- a/src/util/__tests__/dataTable.spec.js +++ b/src/util/__tests__/dataTable.spec.js @@ -1,7 +1,10 @@ import { + buildFeatureIndex, getNextSorting, + getPanelHeights, getRowClickAction, getRowId, + hasActiveDataTableFilters, isFilterable, shouldClearFeatureHighlight, } from '../dataTable.js' @@ -129,3 +132,118 @@ describe('isFilterable', () => { expect(isFilterable('someKey', undefined)).toBe(false) }) }) + +describe('hasActiveDataTableFilters', () => { + const empty = { + dataFilters: {}, + globalSearch: '', + selectionFilter: [], + showOnlyFeaturesInView: false, + } + + test('is false when nothing is filtered', () => { + expect(hasActiveDataTableFilters(empty)).toBe(false) + }) + + test('is true when a column filter is set', () => { + expect( + hasActiveDataTableFilters({ + ...empty, + dataFilters: { name: 'foo' }, + }) + ).toBe(true) + }) + + test('is true for a non-blank global search, trimmed', () => { + expect( + hasActiveDataTableFilters({ ...empty, globalSearch: ' ' }) + ).toBe(false) + expect( + hasActiveDataTableFilters({ ...empty, globalSearch: ' foo ' }) + ).toBe(true) + }) + + test('is true when a selection filter is applied', () => { + expect( + hasActiveDataTableFilters({ + ...empty, + selectionFilter: ['selected'], + }) + ).toBe(true) + }) + + test('is true when showOnlyFeaturesInView is on, even with nothing else set', () => { + expect( + hasActiveDataTableFilters({ + ...empty, + showOnlyFeaturesInView: true, + }) + ).toBe(true) + }) +}) + +describe('buildFeatureIndex', () => { + test('indexes features by properties.id when present', () => { + const data = [{ properties: { id: 'a' } }, { properties: { id: 'b' } }] + const index = buildFeatureIndex(data) + expect(index.get('a')).toBe(data[0]) + expect(index.get('b')).toBe(data[1]) + }) + + test('falls back to the feature’s own top-level id', () => { + const feature = { id: 'a', properties: {} } + expect(buildFeatureIndex([feature]).get('a')).toBe(feature) + }) + + test('skips features with no id anywhere', () => { + const index = buildFeatureIndex([{ properties: {} }]) + expect(index.size).toBe(0) + }) + + test('returns an empty index for missing/empty data', () => { + expect(buildFeatureIndex(undefined).size).toBe(0) + expect(buildFeatureIndex([]).size).toBe(0) + }) +}) + +describe('getPanelHeights', () => { + test('clamps the table height to the window, minus header/toolbar', () => { + const result = getPanelHeights({ + windowHeight: 800, + dataTableHeight: 1000, + isCollapsed: false, + headerHeight: 50, + toolbarHeight: 50, + controlsHeight: 32, + }) + expect(result).toEqual({ + maxHeight: 700, + collapsedHeight: 32, + displayHeight: 700, + }) + }) + + test('uses the saved height as-is when it already fits', () => { + const result = getPanelHeights({ + windowHeight: 800, + dataTableHeight: 300, + isCollapsed: false, + headerHeight: 50, + toolbarHeight: 50, + controlsHeight: 32, + }) + expect(result.displayHeight).toBe(300) + }) + + test('collapses to just the controls height, regardless of the saved height', () => { + const result = getPanelHeights({ + windowHeight: 800, + dataTableHeight: 300, + isCollapsed: true, + headerHeight: 50, + toolbarHeight: 50, + controlsHeight: 32, + }) + expect(result.displayHeight).toBe(32) + }) +}) diff --git a/src/util/__tests__/filterInput.spec.js b/src/util/__tests__/filterInput.spec.js index 48c894fa6c..747d6063fe 100644 --- a/src/util/__tests__/filterInput.spec.js +++ b/src/util/__tests__/filterInput.spec.js @@ -1,9 +1,13 @@ import { + getCyclicIndex, getDisplayValue, getFilteredOptions, getPopoverWidth, getSelectedAndAppliedString, + hasMatchingOptionLabel, measureMaxTextWidth, + toHighlightedIndex, + toOptionIndex, } from '../filterInput.js' describe('getSelectedAndAppliedString', () => { @@ -136,3 +140,58 @@ describe('getPopoverWidth', () => { expect(getPopoverWidth(100)).toBe(156) }) }) + +describe('hasMatchingOptionLabel', () => { + const options = [{ value: 'a' }, { value: 'b' }] + const resolveLabel = (v) => ({ a: 'Apple', b: 'Banana' }[v]) + + it('is true when some option resolves to exactly the given text', () => { + expect(hasMatchingOptionLabel(options, resolveLabel, 'apple')).toBe( + true + ) + }) + + it('is false for a partial match', () => { + expect(hasMatchingOptionLabel(options, resolveLabel, 'app')).toBe(false) + }) + + it('is false when no option matches', () => { + expect(hasMatchingOptionLabel(options, resolveLabel, 'cherry')).toBe( + false + ) + }) +}) + +describe('getCyclicIndex', () => { + it('moves forward within range', () => { + expect(getCyclicIndex(0, 3, 1)).toBe(1) + }) + + it('wraps from the last index back to the first when moving forward', () => { + expect(getCyclicIndex(2, 3, 1)).toBe(0) + }) + + it('moving backward from -1 (nothing highlighted) lands on index 1, matching the pre-existing arithmetic', () => { + expect(getCyclicIndex(-1, 3, -1)).toBe(1) + }) + + it('moves backward within range', () => { + expect(getCyclicIndex(2, 3, -1)).toBe(1) + }) + + it('returns -1 when there is nothing to highlight', () => { + expect(getCyclicIndex(0, 0, 1)).toBe(-1) + }) +}) + +describe('toOptionIndex / toHighlightedIndex', () => { + it('are unchanged when the custom-filter row is not shown', () => { + expect(toOptionIndex(2, false)).toBe(2) + expect(toHighlightedIndex(2, false)).toBe(2) + }) + + it('are offset by one, and invert each other, when the custom-filter row is shown', () => { + expect(toOptionIndex(1, true)).toBe(0) + expect(toHighlightedIndex(0, true)).toBe(1) + }) +}) diff --git a/src/util/__tests__/tableColumns.spec.js b/src/util/__tests__/tableColumns.spec.js index c061aeeadc..39efc86bd9 100644 --- a/src/util/__tests__/tableColumns.spec.js +++ b/src/util/__tests__/tableColumns.spec.js @@ -1,4 +1,8 @@ +import { SENTINEL_NO_VALUE, TYPE_NUMBER } from '../../constants/dataTable.js' import { + buildRowCells, + filterHeadersByName, + getColumnDistinctValues, getDefaultVisibleKeys, getOrderedHeaders, getPinnedCellProps, @@ -6,6 +10,7 @@ import { getPinnedLeftOffsets, getVisibleHeaders, isPinnedGroupEnd, + reorderHeaderKeys, reverseVisibleKeys, togglePinnedKey, toggleVisibleKey, @@ -399,3 +404,95 @@ describe('getDefaultVisibleKeys', () => { ]) }) }) + +describe('getColumnDistinctValues', () => { + const typedHeaders = [ + { dataKey: 'name', type: 'string' }, + { dataKey: 'rawValue', type: TYPE_NUMBER }, + ] + + it('returns null when there are no headers or no data yet', () => { + expect(getColumnDistinctValues([], [{ name: 'A' }])).toBe(null) + expect(getColumnDistinctValues(typedHeaders, [])).toBe(null) + }) + + it('collects the distinct string value of each column across all rows', () => { + const data = [ + { name: 'A', rawValue: 1 }, + { name: 'B', rawValue: 2 }, + { name: 'A', rawValue: 1 }, + ] + const result = getColumnDistinctValues(typedHeaders, data) + expect(result.name).toEqual({ values: ['A', 'B'], type: 'string' }) + expect(result.rawValue).toEqual({ + values: ['1', '2'], + type: TYPE_NUMBER, + }) + }) + + it('coalesces undefined/null/empty-string values to the sentinel and omits a column with none at all', () => { + const data = [{ name: '' }, { name: null }, { rawValue: 5 }] + const result = getColumnDistinctValues(typedHeaders, data) + expect(result.name.values).toEqual([SENTINEL_NO_VALUE]) + expect(result.rawValue.values).toEqual([SENTINEL_NO_VALUE, '5']) + }) +}) + +describe('buildRowCells', () => { + const rowHeaders = [ + { dataKey: 'name', type: 'string' }, + { dataKey: 'rawValue', type: TYPE_NUMBER }, + ] + + it('builds one cell per header, aligning numbers right and everything else left', () => { + const item = { id: 'a', name: 'Alpha', rawValue: 5 } + expect(buildRowCells(item, rowHeaders)).toEqual([ + { dataKey: 'name', value: 'Alpha', align: 'left', itemId: 'a' }, + { dataKey: 'rawValue', value: 5, align: 'right', itemId: 'a' }, + ]) + }) + + it('applies a column roundFn before returning the value', () => { + const item = { id: 'a', rawValue: 1.23456 } + const withRoundFn = [ + { dataKey: 'rawValue', type: TYPE_NUMBER, roundFn: Math.round }, + ] + expect(buildRowCells(item, withRoundFn)[0].value).toBe(1) + }) + + it('nulls out a non-numeric value in a number column instead of returning NaN', () => { + const item = { id: 'a', rawValue: 'not-a-number' } + expect(buildRowCells(item, rowHeaders)[1].value).toBe(null) + }) +}) + +describe('filterHeadersByName', () => { + it('keeps headers whose name contains the search text, case-insensitively', () => { + const result = filterHeadersByName(headers, 'AME') + expect(result.map((h) => h.dataKey)).toEqual(['name']) + }) + + it('trims the search text before matching', () => { + const result = filterHeadersByName(headers, ' id ') + expect(result.map((h) => h.dataKey)).toEqual(['id']) + }) + + it('returns every header when the search text is empty', () => { + expect(filterHeadersByName(headers, '')).toEqual(headers) + }) +}) + +describe('reorderHeaderKeys', () => { + it('moves the active header to the dropped-on header’s position', () => { + const result = reorderHeaderKeys(headers, 'name', 'legend') + expect(result).toEqual(['id', 'rawValue', 'legend', 'name']) + }) + + it('returns null when the active header can no longer be found', () => { + expect(reorderHeaderKeys(headers, 'deletedColumn', 'legend')).toBe(null) + }) + + it('returns null when the drop-target header can no longer be found', () => { + expect(reorderHeaderKeys(headers, 'name', 'deletedColumn')).toBe(null) + }) +}) diff --git a/src/util/__tests__/tableHeaders.spec.js b/src/util/__tests__/tableHeaders.spec.js new file mode 100644 index 0000000000..c3e0f0d798 --- /dev/null +++ b/src/util/__tests__/tableHeaders.spec.js @@ -0,0 +1,234 @@ +import { + EVENT_LAYER, + THEMATIC_LAYER, + ORG_UNIT_LAYER, + EARTH_ENGINE_LAYER, + FACILITY_LAYER, + GEOJSON_URL_LAYER, + TRACKED_ENTITY_LAYER, +} from '../../constants/layers.js' +import { + ERROR_NON_HOMOGENOUS_FEATURES, + getHeadersForLayer, + TYPE_NUMBER, + TYPE_STRING, +} from '../tableHeaders.js' + +jest.mock('../../components/map/MapApi.js', () => ({ + loadEarthEngineWorker: jest.fn(), +})) + +const dataKeys = (result) => result.headers.map((h) => h.dataKey) + +describe('getHeadersForLayer - thematic', () => { + test('single-period: fixed fields plus legend/range/color', () => { + const result = getHeadersForLayer(THEMATIC_LAYER, { + isMultiPeriodThematic: false, + }) + expect(dataKeys(result)).toEqual([ + 'name', + 'id', + 'rawValue', + 'level', + 'parentName', + 'type', + 'legend', + 'range', + 'color', + ]) + }) + + test('multi-period, non-timeline: org unit headers plus one column per other period', () => { + const periods = [ + { id: 'p1', name: 'Jan' }, + { id: 'p2', name: 'Feb' }, + ] + const result = getHeadersForLayer(THEMATIC_LAYER, { + isMultiPeriodThematic: true, + isTimelineThematic: false, + periods, + }) + expect(dataKeys(result)).toEqual( + expect.arrayContaining([ + 'name', + 'id', + 'level', + 'parentName', + 'type', + 'period_p1_rawValue', + 'period_p2_rawValue', + ]) + ) + }) + + test('multi-period timeline: excludes the external period from the extra columns and labels value/legend/range/color with it', () => { + const periods = [ + { id: 'p1', name: 'Jan' }, + { id: 'p2', name: 'Feb' }, + ] + const externalPeriod = { id: 'p1', name: 'Jan' } + const result = getHeadersForLayer(THEMATIC_LAYER, { + isMultiPeriodThematic: true, + isTimelineThematic: true, + periods, + externalPeriod, + }) + expect(dataKeys(result)).not.toContain('period_p1_rawValue') + expect(dataKeys(result)).toContain('period_p2_rawValue') + const valueHeader = result.headers.find((h) => h.dataKey === 'rawValue') + expect(valueHeader.name).toContain('Jan') + }) +}) + +describe('getHeadersForLayer - event', () => { + test('fixed org unit/id/eventdate fields plus valid-uid custom fields from layerHeaders', () => { + const layerHeaders = [ + { + name: 'w75KJ2mc4zz', + column: 'Age', + valueType: 'INTEGER', + }, + { name: 'not-a-uid', column: 'Ignored', valueType: 'TEXT' }, + ] + const result = getHeadersForLayer(EVENT_LAYER, { layerHeaders }) + expect(dataKeys(result)).toEqual( + expect.arrayContaining(['ouname', 'id', 'eventdate', 'w75KJ2mc4zz']) + ) + expect(dataKeys(result)).not.toContain('not-a-uid') + const ageHeader = result.headers.find( + (h) => h.dataKey === 'w75KJ2mc4zz' + ) + expect(ageHeader.type).toBe(TYPE_NUMBER) + }) + + test('adds the org unit boundary column only when countEventsOutsideOrgUnits is set', () => { + const without = getHeadersForLayer(EVENT_LAYER, { layerHeaders: [] }) + const withBoundary = getHeadersForLayer(EVENT_LAYER, { + layerHeaders: [], + countEventsOutsideOrgUnits: true, + }) + expect(dataKeys(without)).not.toContain('ouBoundary') + expect(dataKeys(withBoundary)).toContain('ouBoundary') + }) + + test('adds legend/range/color only when styled by a data item', () => { + const unstyled = getHeadersForLayer(EVENT_LAYER, { layerHeaders: [] }) + const styled = getHeadersForLayer(EVENT_LAYER, { + layerHeaders: [], + styleDataItem: { id: 'abc' }, + }) + expect(dataKeys(unstyled)).not.toContain('color') + expect(dataKeys(styled)).toEqual( + expect.arrayContaining(['legend', 'range', 'color']) + ) + }) +}) + +describe('getHeadersForLayer - org unit / facility', () => { + test('org unit: fixed fields plus whichever style columns the data actually has', () => { + const result = getHeadersForLayer(ORG_UNIT_LAYER, { + data: [{ color: '#fff' }, { iconUrl: 'x.png' }], + }) + expect(dataKeys(result)).toEqual( + expect.arrayContaining([ + 'name', + 'id', + 'level', + 'parentName', + 'type', + 'color', + 'iconUrl', + ]) + ) + expect(dataKeys(result)).not.toContain('group') + }) + + test('facility: same style-detection behavior as org unit, with a smaller fixed field set', () => { + const result = getHeadersForLayer(FACILITY_LAYER, { + data: [{ group: 'g1' }], + }) + expect(dataKeys(result)).toEqual(['name', 'id', 'type', 'group']) + }) +}) + +describe('getHeadersForLayer - tracked entity', () => { + test('id field plus valid-uid custom fields from layerHeaders, always with a color column', () => { + const layerHeaders = [ + { name: 'First name', dataKey: 'w75KJ2mc4zz', valueType: 'TEXT' }, + { name: 'Bad', dataKey: 'not-a-uid', valueType: 'TEXT' }, + ] + const result = getHeadersForLayer(TRACKED_ENTITY_LAYER, { + layerHeaders, + }) + expect(dataKeys(result)).toEqual(['id', 'w75KJ2mc4zz', 'color']) + const nameHeader = result.headers.find( + (h) => h.dataKey === 'w75KJ2mc4zz' + ) + expect(nameHeader.type).toBe(TYPE_STRING) + }) +}) + +describe('getHeadersForLayer - earth engine', () => { + test('class-based aggregation: one column per legend item, rounded to 2 decimal places', () => { + const result = getHeadersForLayer(EARTH_ENGINE_LAYER, { + aggregationType: 'percentage', + legend: { + title: 'Land cover', + items: [{ value: 1, name: 'Forest' }], + }, + }) + expect(dataKeys(result)).toEqual( + expect.arrayContaining(['name', 'id', 'type', '1']) + ) + const classHeader = result.headers.find((h) => h.dataKey === '1') + expect(classHeader.name).toBe('Forest') + expect(classHeader.roundFn(1.23456)).toBe(1.23) + }) + + test('non-class aggregation array: one title-cased column per aggregation type', () => { + const result = getHeadersForLayer(EARTH_ENGINE_LAYER, { + aggregationType: ['mean'], + legend: { title: 'Rainfall', items: [] }, + data: [{ mean: 12.3456 }], + }) + const meanHeader = result.headers.find((h) => h.dataKey === 'mean') + expect(meanHeader.name).toBe('Mean Rainfall') + expect(meanHeader.type).toBe(TYPE_NUMBER) + }) +}) + +describe('getHeadersForLayer - geoJsonUrl', () => { + test('homogenous features: derives headers from the first feature', () => { + const rawData = [ + { + geometry: { type: 'Point' }, + properties: { name: 'A', color: '#f00' }, + }, + { + geometry: { type: 'Point' }, + properties: { name: 'B', color: '#0f0' }, + }, + ] + const result = getHeadersForLayer(GEOJSON_URL_LAYER, { rawData }) + expect(dataKeys(result)).toEqual( + expect.arrayContaining(['name', 'color']) + ) + }) + + test('non-homogenous geometry types: returns an error code instead of headers', () => { + const rawData = [ + { geometry: { type: 'Point' }, properties: {} }, + { geometry: { type: 'LineString' }, properties: {} }, + ] + const result = getHeadersForLayer(GEOJSON_URL_LAYER, { rawData }) + expect(result).toEqual({ errorCode: ERROR_NON_HOMOGENOUS_FEATURES }) + }) +}) + +describe('getHeadersForLayer - unknown layer type', () => { + test('returns null headers rather than throwing', () => { + expect(getHeadersForLayer('somethingElse', {})).toEqual({ + headers: null, + }) + }) +}) diff --git a/src/util/__tests__/tableRows.spec.js b/src/util/__tests__/tableRows.spec.js new file mode 100644 index 0000000000..fffcf07bf9 --- /dev/null +++ b/src/util/__tests__/tableRows.spec.js @@ -0,0 +1,189 @@ +import { GEOJSON_URL_LAYER, THEMATIC_LAYER } from '../../constants/layers.js' +import { + buildTableData, + ERROR_NO_VALID_DATA, + ERROR_SERVER_CLUSTER, +} from '../tableRows.js' + +// Thematic-layer-shaped feature: id is stamped on both the top level (which +// is what aggregations are keyed by) and properties (see the deferred +// id-placement inconsistency called out for this codebase's loaders). +const feature = (id, extraProperties = {}, coordinates = [10, 10]) => ({ + id, + geometry: { type: 'Point', coordinates }, + properties: { id, ...extraProperties }, +}) + +describe('buildTableData - error paths', () => { + test('server-clustered layers return an error code instead of data', () => { + expect(buildTableData(THEMATIC_LAYER, { serverCluster: true })).toEqual( + { errorCode: ERROR_SERVER_CLUSTER } + ) + }) + + test('no data and no dataWithoutCoords returns a no-valid-data error', () => { + expect( + buildTableData(THEMATIC_LAYER, { data: [], dataWithoutCoords: [] }) + ).toEqual({ errorCode: ERROR_NO_VALID_DATA }) + expect(buildTableData(THEMATIC_LAYER, {})).toEqual({ + errorCode: ERROR_NO_VALID_DATA, + }) + }) +}) + +describe('buildTableData - geoJsonUrl layer', () => { + test('returns each feature’s properties as a row, bypassing the hasAdditionalGeometry filter', () => { + const data = [ + feature('a', { name: 'A', hasAdditionalGeometry: true }), + feature('b', { name: 'B' }), + ] + const result = buildTableData(GEOJSON_URL_LAYER, { data }) + expect(result.data).toEqual([ + { id: 'a', name: 'A', hasAdditionalGeometry: true }, + { id: 'b', name: 'B' }, + ]) + }) +}) + +describe('buildTableData - showOnlyFeaturesInView', () => { + const inBounds = feature('in', {}, [10, 10]) + const outOfBounds = feature('out', {}, [100, 100]) + const bounds = [0, 0, 20, 20] + + test('keeps all features when showOnlyFeaturesInView is off', () => { + const result = buildTableData(THEMATIC_LAYER, { + data: [inBounds, outOfBounds], + showOnlyFeaturesInView: false, + mapBounds: bounds, + aggregations: {}, + }) + expect(result.data.map((r) => r.id)).toEqual(['in', 'out']) + }) + + test('filters out features outside the given bounds when showOnlyFeaturesInView is on', () => { + const result = buildTableData(THEMATIC_LAYER, { + data: [inBounds, outOfBounds], + showOnlyFeaturesInView: true, + mapBounds: bounds, + aggregations: {}, + }) + expect(result.data.map((r) => r.id)).toEqual(['in']) + }) +}) + +describe('buildTableData - generic layer', () => { + test('merges data and dataWithoutCoords, drops features with hasAdditionalGeometry, merges aggregations and stamps a row-order index', () => { + const data = [feature('a', { name: 'A' })] + const dataWithoutCoords = [ + feature('b', { name: 'B', hasAdditionalGeometry: true }), + feature('c', { name: 'C' }), + ] + const result = buildTableData(THEMATIC_LAYER, { + data, + dataWithoutCoords, + aggregations: { a: { count: 5 } }, + }) + expect(result.data).toEqual([ + { id: 'a', name: 'A', count: 5, index: 0 }, + { id: 'c', name: 'C', index: 1 }, + ]) + }) +}) + +describe('buildTableData - styled event layer', () => { + test('derives legend name and a formatted range from the matching legend item', () => { + const data = [feature('a', { colorGroup: 0 })] + const legend = { + items: { + 0: { name: 'Low', startValue: 0, endValue: 10 }, + }, + } + const result = buildTableData('event', { + data, + aggregations: {}, + isStyledEvent: true, + legend, + keyAnalysisDigitGroupSeparator: 'SPACE', + }) + expect(result.data[0].legend).toBe('Low') + expect(result.data[0].range).toBe('0 – 10') + }) + + test('leaves range undefined when the matched legend item has no start/end value', () => { + const data = [feature('a', { colorGroup: 0 })] + const legend = { items: { 0: { name: 'Uncategorized' } } } + const result = buildTableData('event', { + data, + aggregations: {}, + isStyledEvent: true, + legend, + }) + expect(result.data[0].legend).toBe('Uncategorized') + expect(result.data[0].range).toBeUndefined() + }) +}) + +describe('buildTableData - multi-period thematic layer', () => { + const periods = [ + { id: 'p1', name: 'Jan' }, + { id: 'p2', name: 'Feb' }, + ] + const valuesByPeriod = { + p1: { a: { value: 1, color: '#f00', legend: 'Low', range: '0-1' } }, + p2: { a: { value: 2 } }, + } + + test('timeline: overlays the external period’s value/color/legend/range and adds one column per other period', () => { + const data = [feature('a')] + const result = buildTableData(THEMATIC_LAYER, { + data, + aggregations: {}, + isMultiPeriodThematic: true, + isTimelineThematic: true, + valuesByPeriod, + externalPeriod: periods[0], + periods, + }) + expect(result.data[0]).toMatchObject({ + id: 'a', + rawValue: 1, + color: '#f00', + legend: 'Low', + range: '0-1', + period_p2_rawValue: 2, + }) + expect(result.data[0].period_p1_rawValue).toBeUndefined() + }) + + test('split (non-timeline): adds one column per period, with no current-period overlay', () => { + const data = [feature('a')] + const result = buildTableData(THEMATIC_LAYER, { + data, + aggregations: {}, + isMultiPeriodThematic: true, + isTimelineThematic: false, + valuesByPeriod, + periods, + }) + expect(result.data[0]).toMatchObject({ + id: 'a', + period_p1_rawValue: 1, + period_p2_rawValue: 2, + }) + expect(result.data[0].rawValue).toBeUndefined() + }) + + test('falls back to null for a period with no recorded value for that org unit', () => { + const data = [feature('a')] + const result = buildTableData(THEMATIC_LAYER, { + data, + aggregations: {}, + isMultiPeriodThematic: true, + isTimelineThematic: false, + valuesByPeriod: { p1: {} }, + periods, + }) + expect(result.data[0].period_p1_rawValue).toBeNull() + expect(result.data[0].period_p2_rawValue).toBeNull() + }) +}) diff --git a/src/util/dataTable.js b/src/util/dataTable.js index 3760d7d45f..1eba91fa02 100644 --- a/src/util/dataTable.js +++ b/src/util/dataTable.js @@ -42,3 +42,42 @@ export const getRowClickAction = ( return null } + +export const hasActiveDataTableFilters = ({ + dataFilters, + globalSearch, + selectionFilter, + showOnlyFeaturesInView, +}) => + Object.keys(dataFilters ?? {}).length > 0 || + !!globalSearch?.trim() || + selectionFilter?.length > 0 || + !!showOnlyFeaturesInView + +export const buildFeatureIndex = (data) => { + const index = new Map() + data?.forEach((f) => { + const id = f.properties?.id ?? f.id + if (id != null) { + index.set(id, f) + } + }) + return index +} + +export const getPanelHeights = ({ + windowHeight, + dataTableHeight, + isCollapsed, + headerHeight, + toolbarHeight, + controlsHeight, +}) => { + const maxHeight = windowHeight - headerHeight - toolbarHeight + const tableHeight = Math.min(dataTableHeight, maxHeight) + return { + maxHeight, + collapsedHeight: controlsHeight, + displayHeight: isCollapsed ? controlsHeight : tableHeight, + } +} diff --git a/src/util/filter.js b/src/util/filter.js index f4793a2964..7189e2568c 100644 --- a/src/util/filter.js +++ b/src/util/filter.js @@ -1,4 +1,7 @@ -import { SENTINEL_ANY_VALUE } from '../constants/dataTable.js' +import { + SENTINEL_ANY_VALUE, + SENTINEL_NO_VALUE, +} from '../constants/dataTable.js' // Filters an array of object with a set of filters export const filterData = (data, filters) => { @@ -19,11 +22,13 @@ export const filterData = (data, filters) => { if (Array.isArray(filter)) { // Multi-select: OR match against the raw stored value - const stringValue = value == null ? '' : String(value) + const stringValue = + value == null ? SENTINEL_NO_VALUE : String(value) return ( filter.length === 0 || filter.includes(stringValue) || - (stringValue !== '' && filter.includes(SENTINEL_ANY_VALUE)) + (stringValue !== SENTINEL_NO_VALUE && + filter.includes(SENTINEL_ANY_VALUE)) ) } diff --git a/src/util/filterInput.js b/src/util/filterInput.js index dc86ddd3ec..db972db347 100644 --- a/src/util/filterInput.js +++ b/src/util/filterInput.js @@ -1,4 +1,5 @@ import i18n from '@dhis2/d2-i18n' +import { TYPE_NUMBER } from '../constants/dataTable.js' import { numericFilter } from './filter.js' const POPOVER_ROW_NON_LABEL_WIDTH = 56 @@ -35,7 +36,7 @@ export const getFilteredOptions = ({ if (!trimmedSearch) { return realOptions } - if (type === 'number') { + if (type === TYPE_NUMBER) { return realOptions.filter(({ value }) => numericFilter(Number(value), trimmedSearch) ) @@ -66,3 +67,17 @@ export const getPopoverWidth = (maxLabelWidth) => ), MAX_POPOVER_WIDTH ) + +export const hasMatchingOptionLabel = (options, resolveLabel, normalizedText) => + options.some( + ({ value }) => resolveLabel(value).toLowerCase() === normalizedText + ) + +export const getCyclicIndex = (current, total, delta) => + total ? (current + delta + total) % total : -1 + +export const toOptionIndex = (highlightedIndex, showCustomFilterRow) => + showCustomFilterRow ? highlightedIndex - 1 : highlightedIndex + +export const toHighlightedIndex = (optionIndex, showCustomFilterRow) => + showCustomFilterRow ? optionIndex + 1 : optionIndex diff --git a/src/util/tableColumns.js b/src/util/tableColumns.js index fba5004a08..c8dd4dbcbc 100644 --- a/src/util/tableColumns.js +++ b/src/util/tableColumns.js @@ -1,3 +1,6 @@ +import { arrayMoveImmutable } from 'array-move' +import { SENTINEL_NO_VALUE, TYPE_NUMBER } from '../constants/dataTable.js' + const CHECKBOX_COLUMN_WIDTH = 76 export const getDefaultVisibleKeys = (headers) => @@ -111,3 +114,66 @@ export const getPinnedLeftOffsets = ( }) return offsets } + +// Expensive: scans every row once per column +export const getColumnDistinctValues = (headers, data) => { + if (!headers?.length || !data?.length) { + return null + } + + const result = {} + headers.forEach(({ dataKey, type }) => { + const seen = new Set() + for (const item of data) { + const val = item[dataKey] + seen.add( + val === undefined || val === null || val === SENTINEL_NO_VALUE + ? SENTINEL_NO_VALUE + : String(val) + ) + } + + if (seen.size > 0) { + result[dataKey] = { values: Array.from(seen), type } + } + }) + + return result +} + +export const buildRowCells = (item, headers) => + headers.map(({ dataKey, roundFn, type }) => { + const value = roundFn ? roundFn(item[dataKey]) : item[dataKey] + return { + dataKey, + value: type === TYPE_NUMBER && isNaN(value) ? null : value, + align: type === TYPE_NUMBER ? 'right' : 'left', + itemId: item.id, + } + }) + +export const filterHeadersByName = (headers, search) => { + const normalizedSearch = search.trim().toLowerCase() + return headers.filter((h) => + h.name.toLowerCase().includes(normalizedSearch) + ) +} + +export const reorderHeaderKeys = ( + orderedHeaders, + activeDataKey, + overDataKey +) => { + const oldIndex = orderedHeaders.findIndex( + (h) => h.dataKey === activeDataKey + ) + const newIndex = orderedHeaders.findIndex((h) => h.dataKey === overDataKey) + + if (oldIndex === -1 || newIndex === -1) { + return null + } + + return arrayMoveImmutable(orderedHeaders, oldIndex, newIndex).map( + (h) => h.dataKey + ) +} diff --git a/src/util/tableHeaders.js b/src/util/tableHeaders.js new file mode 100644 index 0000000000..0b32fdc636 --- /dev/null +++ b/src/util/tableHeaders.js @@ -0,0 +1,332 @@ +import i18n from '@dhis2/d2-i18n' +import { + RENDERER_COLOR, + RENDERER_ICON, + TYPE_NUMBER, + TYPE_STRING, + TYPE_DATE, +} from '../constants/dataTable.js' +import { + EVENT_LAYER, + THEMATIC_LAYER, + ORG_UNIT_LAYER, + EARTH_ENGINE_LAYER, + FACILITY_LAYER, + GEOJSON_URL_LAYER, + TRACKED_ENTITY_LAYER, +} from '../constants/layers.js' +import { numberValueTypes } from '../constants/valueTypes.js' +import { hasClasses } from './earthEngine.js' +import { getGeojsonDisplayData } from './geojson.js' +import { getRoundToPrecisionFn, getPrecision } from './numbers.js' +import { isValidUid } from './uid.js' + +export { TYPE_NUMBER, TYPE_STRING, TYPE_DATE } + +const NAME = 'name' +const ID = 'id' +const VALUE = 'rawValue' +const LEGEND = 'legend' +const RANGE = 'range' +const LEVEL = 'level' +const PARENT_NAME = 'parentName' +const TYPE = 'type' +const COLOR = 'color' +const GROUP = 'group' +const ICON = 'iconUrl' +const OUNAME = 'ouname' +const OUBOUNDARY = 'ouBoundary' +const EVENTDATE = 'eventdate' + +export const ERROR_NON_HOMOGENOUS_FEATURES = 'NON_HOMOGENOUS_FEATURES' + +const defaultFieldsMap = () => ({ + [NAME]: { name: i18n.t('Name'), dataKey: NAME, type: TYPE_STRING }, + [ID]: { name: i18n.t('Id'), dataKey: ID, type: TYPE_STRING }, + [LEVEL]: { name: i18n.t('Level'), dataKey: LEVEL, type: TYPE_NUMBER }, + [PARENT_NAME]: { + name: i18n.t('Parent'), + dataKey: PARENT_NAME, + type: TYPE_STRING, + }, + [TYPE]: { name: i18n.t('Type'), dataKey: TYPE, type: TYPE_STRING }, + [VALUE]: { name: i18n.t('Value'), dataKey: VALUE, type: TYPE_NUMBER }, + [LEGEND]: { name: i18n.t('Legend'), dataKey: LEGEND, type: TYPE_STRING }, + [RANGE]: { name: i18n.t('Range'), dataKey: RANGE, type: TYPE_STRING }, + [OUNAME]: { name: i18n.t('Org unit'), dataKey: OUNAME, type: TYPE_STRING }, + [OUBOUNDARY]: { + name: i18n.t('Org unit boundary'), + dataKey: OUBOUNDARY, + type: TYPE_STRING, + }, + [EVENTDATE]: { + name: i18n.t('Event time'), + dataKey: EVENTDATE, + type: TYPE_DATE, + renderer: 'formatTime...', + }, + [COLOR]: { + name: i18n.t('Color'), + dataKey: COLOR, + type: TYPE_STRING, + renderer: RENDERER_COLOR, + }, + [GROUP]: { name: i18n.t('Group'), dataKey: GROUP, type: TYPE_STRING }, + [ICON]: { + name: i18n.t('Icon'), + dataKey: ICON, + type: TYPE_STRING, + renderer: RENDERER_ICON, + }, +}) + +const getStyleHeaders = ({ + hasLegend, + hasRange, + hasGroup, + hasColor, + hasIcon, +}) => { + const headers = [] + if (hasLegend) { + headers.push(defaultFieldsMap()[LEGEND]) + } + if (hasRange) { + headers.push(defaultFieldsMap()[RANGE]) + } + if (hasGroup) { + headers.push(defaultFieldsMap()[GROUP]) + } + if (hasColor) { + headers.push(defaultFieldsMap()[COLOR]) + } + if (hasIcon) { + headers.push(defaultFieldsMap()[ICON]) + } + return headers +} + +const getThematicHeaders = () => + [NAME, ID, VALUE, LEVEL, PARENT_NAME, TYPE] + .map((field) => defaultFieldsMap()[field]) + .concat( + getStyleHeaders({ hasLegend: true, hasRange: true, hasColor: true }) + ) + +const getMultiPeriodThematicHeaders = ({ + isTimelineThematic, + externalPeriod, + periods, +}) => { + const headers = isTimelineThematic + ? getThematicHeaders().map((header) => + [VALUE, LEGEND, RANGE, COLOR].includes(header.dataKey) + ? { + ...header, + name: `${header.name} (${ + externalPeriod?.name ?? i18n.t('Current period') + })`, + } + : header + ) + : getOrgUnitHeaders() + + const otherPeriods = isTimelineThematic + ? (periods ?? []).filter((p) => p.id !== externalPeriod?.id) + : periods ?? [] + + otherPeriods.forEach((period) => { + headers.push({ + name: i18n.t('Value ({{period}})', { period: period.name }), + dataKey: `period_${period.id}_rawValue`, + type: TYPE_NUMBER, + defaultHidden: true, + }) + }) + + return headers +} + +const getEventHeaders = ({ + layerHeaders = [], + styleDataItem, + countEventsOutsideOrgUnits, +}) => { + const fields = [OUNAME, ID, EVENTDATE].map( + (field) => defaultFieldsMap()[field] + ) + + if (countEventsOutsideOrgUnits) { + fields.push(defaultFieldsMap()[OUBOUNDARY]) + } + + const customFields = layerHeaders + .filter(({ name }) => isValidUid(name)) + .map(({ name: dataKey, column: name, valueType, optionSet }) => ({ + name, + dataKey, + type: + !optionSet && numberValueTypes.includes(valueType) + ? TYPE_NUMBER + : TYPE_STRING, + optionSet: optionSet || null, + })) + + customFields.push( + defaultFieldsMap()[TYPE], + ...getStyleHeaders({ + hasLegend: !!styleDataItem, + hasRange: !!styleDataItem, + hasColor: !!styleDataItem, + }) + ) + + return fields.concat(customFields) +} + +const getOrgUnitStyleHeaders = (data) => { + let hasGroup = false + let hasColor = false + let hasIcon = false + + for (const d of data ?? []) { + hasGroup ||= d.group != null + hasColor ||= d.color != null + hasIcon ||= d.iconUrl != null + + if (hasGroup && hasColor && hasIcon) { + break + } + } + + return getStyleHeaders({ hasGroup, hasColor, hasIcon }) +} + +// Org unit and facility headers share the same shape +const getFixedFieldsWithOrgUnitStyle = (fields, data) => + fields + .map((field) => defaultFieldsMap()[field]) + .concat(getOrgUnitStyleHeaders(data)) + +const getOrgUnitHeaders = (data) => + getFixedFieldsWithOrgUnitStyle([NAME, ID, LEVEL, PARENT_NAME, TYPE], data) + +const getTrackedEntityHeaders = ({ layerHeaders = [] }) => { + const fields = [ID].map((field) => defaultFieldsMap()[field]) + + const customFields = layerHeaders + .filter(({ dataKey }) => isValidUid(dataKey)) + .map(({ name, dataKey, valueType }) => ({ + name, + dataKey, + type: numberValueTypes.includes(valueType) + ? TYPE_NUMBER + : TYPE_STRING, + })) + + customFields.push(...getStyleHeaders({ hasColor: true })) + + return fields.concat(customFields) +} + +const getFacilityHeaders = (data) => + getFixedFieldsWithOrgUnitStyle([NAME, ID, TYPE], data) + +const toTitleCase = (str) => + str.replace( + /\w\S*/g, + (txt) => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase() + ) + +const getEarthEngineHeaders = ({ aggregationType, legend, data }) => { + const { title, items } = legend + + let customFields = [] + + if (hasClasses(aggregationType) && items) { + customFields = items.map(({ value, name }) => ({ + name, + dataKey: String(value), + roundFn: getRoundToPrecisionFn(2), + type: TYPE_NUMBER, + })) + } else if (Array.isArray(aggregationType) && aggregationType.length) { + customFields = aggregationType.map((type) => { + let roundFn = null + if (data?.length) { + const precision = getPrecision(data.map((d) => d[type])) + roundFn = getRoundToPrecisionFn(precision) + } + return { + name: toTitleCase(`${type} ${title}`), + dataKey: type, + roundFn, + type: TYPE_NUMBER, + } + }) + } + + return [NAME, ID, TYPE] + .map((field) => defaultFieldsMap()[field]) + .concat(customFields) +} + +const getGeoJsonUrlHeaders = (firstDataItem) => + getGeojsonDisplayData(firstDataItem).map((header) => + header.dataKey === COLOR ? defaultFieldsMap()[COLOR] : header + ) + +export const getHeadersForLayer = (layerType, ctx) => { + switch (layerType) { + case THEMATIC_LAYER: + return { + headers: ctx.isMultiPeriodThematic + ? getMultiPeriodThematicHeaders({ + isTimelineThematic: ctx.isTimelineThematic, + externalPeriod: ctx.externalPeriod, + periods: ctx.periods, + }) + : getThematicHeaders(), + } + case EVENT_LAYER: + return { + headers: getEventHeaders({ + layerHeaders: ctx.layerHeaders, + styleDataItem: ctx.styleDataItem, + countEventsOutsideOrgUnits: ctx.countEventsOutsideOrgUnits, + }), + } + case ORG_UNIT_LAYER: + return { headers: getOrgUnitHeaders(ctx.data) } + case TRACKED_ENTITY_LAYER: + return { + headers: getTrackedEntityHeaders({ + layerHeaders: ctx.layerHeaders, + }), + } + case EARTH_ENGINE_LAYER: + return { + headers: getEarthEngineHeaders({ + aggregationType: ctx.aggregationType, + legend: ctx.legend, + data: ctx.data, + }), + } + case FACILITY_LAYER: + return { headers: getFacilityHeaders(ctx.data) } + case GEOJSON_URL_LAYER: { + // Unlike the other cases, this reads the raw layer data + // rather than dataWithAggregations + const rawData = ctx.rawData ?? [] + const isHomogenous = rawData.every( + (feature) => feature.geometry.type === rawData[0]?.geometry.type + ) + if (!isHomogenous) { + return { errorCode: ERROR_NON_HOMOGENOUS_FEATURES } + } + return { headers: getGeoJsonUrlHeaders(rawData[0]) } + } + default: + return { headers: null } + } +} diff --git a/src/util/tableRows.js b/src/util/tableRows.js new file mode 100644 index 0000000000..bef21fa009 --- /dev/null +++ b/src/util/tableRows.js @@ -0,0 +1,108 @@ +import { GEOJSON_URL_LAYER } from '../constants/layers.js' +import { isFeatureInBounds } from './geojson.js' +import { formatRangeWithSeparator } from './numbers.js' + +export const ERROR_SERVER_CLUSTER = 'SERVER_CLUSTER' +export const ERROR_NO_VALID_DATA = 'NO_VALID_DATA' + +export const buildTableData = ( + layerType, + { + data, + dataWithoutCoords, + serverCluster, + showOnlyFeaturesInView, + mapBounds, + aggregations, + isStyledEvent, + isMultiPeriodThematic, + isTimelineThematic, + legend, + valuesByPeriod, + externalPeriod, + periods, + keyAnalysisDigitGroupSeparator, + legendDecimalPlaces, + } +) => { + if (serverCluster) { + return { errorCode: ERROR_SERVER_CLUSTER } + } + + const allData = dataWithoutCoords?.length + ? [...(data || []), ...dataWithoutCoords] + : data + + if (!allData?.length) { + return { errorCode: ERROR_NO_VALID_DATA } + } + + const inViewData = showOnlyFeaturesInView + ? allData.filter((d) => isFeatureInBounds(d, mapBounds)) + : allData + + if (layerType === GEOJSON_URL_LAYER) { + return { data: inViewData.map((d) => ({ ...d.properties })) } + } + + const rows = inViewData + .filter((d) => !d.properties.hasAdditionalGeometry) + .map((d, index) => { + const properties = d.properties || d + + if (isStyledEvent) { + const legendItem = legend?.items?.[properties.colorGroup] + return { + ...properties, + legend: legendItem?.name, + range: + legendItem && 'startValue' in legendItem + ? formatRangeWithSeparator( + legendItem, + keyAnalysisDigitGroupSeparator, + { precision: legendDecimalPlaces } + ) + : undefined, + ...aggregations[d.id], + index, + } + } + + if (!isMultiPeriodThematic) { + return { + ...properties, + ...aggregations[d.id], + // Row-order tie-breaker for compareRows when no sortField is set + index, + } + } + + const orgUnitId = properties.id + const currentPeriodItem = isTimelineThematic + ? valuesByPeriod?.[externalPeriod?.id]?.[orgUnitId] + : null + const otherPeriodValues = {} + ;(periods ?? []).forEach((period) => { + if (isTimelineThematic && period.id === externalPeriod?.id) { + return + } + otherPeriodValues[`period_${period.id}_rawValue`] = + valuesByPeriod?.[period.id]?.[orgUnitId]?.value ?? null + }) + + return { + ...properties, + ...(currentPeriodItem && { + rawValue: currentPeriodItem.value, + color: currentPeriodItem.color, + legend: currentPeriodItem.legend, + range: currentPeriodItem.range, + }), + ...otherPeriodValues, + ...aggregations[d.id], + index, + } + }) + + return { data: rows } +} diff --git a/src/util/tableSort.js b/src/util/tableSort.js index d101d6caa1..5c1f9d3999 100644 --- a/src/util/tableSort.js +++ b/src/util/tableSort.js @@ -2,6 +2,7 @@ import { SENTINEL_NO_VALUE, SENTINEL_SELECTED_ROW, SORT_ASCENDING, + TYPE_NUMBER, } from '../constants/dataTable.js' import { parseRange } from './legend.js' @@ -32,7 +33,7 @@ export const compareColumnOptionValues = ( return compareRangeValues(a, b, direction) } const comparison = - type === 'number' ? Number(a) - Number(b) : compareStrings(a, b) + type === TYPE_NUMBER ? Number(a) - Number(b) : compareStrings(a, b) return direction === SORT_ASCENDING ? comparison : -comparison } From fcb310a8a88bd233b1377709baee1a41af43c506 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 23 Jul 2026 11:10:52 +0200 Subject: [PATCH 089/205] chore: sonarqube issue --- src/util/tableColumns.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/util/tableColumns.js b/src/util/tableColumns.js index c8dd4dbcbc..5579d28d3b 100644 --- a/src/util/tableColumns.js +++ b/src/util/tableColumns.js @@ -146,7 +146,10 @@ export const buildRowCells = (item, headers) => const value = roundFn ? roundFn(item[dataKey]) : item[dataKey] return { dataKey, - value: type === TYPE_NUMBER && isNaN(value) ? null : value, + value: + type === TYPE_NUMBER && Number.isNaN(Number(value)) + ? null + : value, align: type === TYPE_NUMBER ? 'right' : 'left', itemId: item.id, } From b4d63ca011c75f4c3a1794109a758bfdf9a2bbdc Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 23 Jul 2026 11:23:31 +0200 Subject: [PATCH 090/205] chore: PR clean-up --- .../datatable/__tests__/ColumnPickerControl.spec.jsx | 4 ---- .../datatable/__tests__/useTableData.spec.jsx | 5 ----- src/loaders/__tests__/geoJsonUrlLoader.spec.js | 3 --- src/loaders/geoJsonUrlLoader.js | 11 ++--------- src/loaders/trackedEntityLoader.js | 8 ++------ src/util/tableSort.js | 3 +-- 6 files changed, 5 insertions(+), 29 deletions(-) diff --git a/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx b/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx index 6e16c5f451..9df0ad1b3c 100644 --- a/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx +++ b/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx @@ -357,10 +357,6 @@ describe('ColumnPicker search', () => { }) describe('ColumnPicker defaultHidden headers (e.g. period columns)', () => { - // Period columns exist as regular headers for every available period, - // but start out unchecked - same mechanism as any other column, no - // dedicated "add period" UI. A defaultHidden header exercises that - // exact path without needing a real thematic/timeline layer fixture. const headersWithHiddenColumn = [ ...headers, { diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index f55ca6520b..1888265add 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -363,9 +363,6 @@ describe('useTableData headers', () => { }) test('adds a defaultHidden raw-value-only column for every other period, for a timeline thematic layer', () => { - // Period columns exist for every period regardless of any saved - // config - they're just hidden by default (defaultHidden), same - // mechanism as any other column, controlled via the column picker. const store = { aggregations: {}, ui: { @@ -408,8 +405,6 @@ describe('useTableData headers', () => { } ) const { headers, rows } = result.current - // The active period (February 2023) is the Value/Legend/Range/Color - // columns, not a separate period_* column. expect(headers).not.toContainEqual( expect.objectContaining({ dataKey: 'period_202302_rawValue' }) ) diff --git a/src/loaders/__tests__/geoJsonUrlLoader.spec.js b/src/loaders/__tests__/geoJsonUrlLoader.spec.js index e3d655d858..8eac7f2bb2 100644 --- a/src/loaders/__tests__/geoJsonUrlLoader.spec.js +++ b/src/loaders/__tests__/geoJsonUrlLoader.spec.js @@ -51,9 +51,6 @@ describe('stampFeatureColors', () => { }) it('never overwrites a feature that already has its own color', () => { - // maps-gl's colorExpr prefers a feature's own properties.color over - // the layer's uniform style color, so a user-uploaded file with its - // own per-feature colors must keep rendering with them. const features = [ { geometry: { type: 'Point' }, diff --git a/src/loaders/geoJsonUrlLoader.js b/src/loaders/geoJsonUrlLoader.js index f601e7bae3..af1721bed3 100644 --- a/src/loaders/geoJsonUrlLoader.js +++ b/src/loaders/geoJsonUrlLoader.js @@ -7,12 +7,8 @@ import { GEO_TYPE_POLYGON, } from '../util/geojson.js' -// features of different (non-Multi-normalized) geometry types get their -// own color, matching the map legend's own per-type color - never -// overwrites a feature's own pre-existing color (maps-gl's colorExpr -// already prefers a per-feature properties.color over the layer's -// uniform style color, so a feature that already has one is rendered -// with it, and the data table should reflect the same real color). +// Stamps each feature with its geometry type's legend color, unless the feature already has its own +// (maps-gl's colorExpr prefers a per-feature color, so the data table must match). export const stampFeatureColors = (features, legendItemsByType) => features.map((f) => { if (f.properties.color != null) { @@ -141,9 +137,6 @@ const geoJsonUrlLoader = async ({ legendItemsByType[type] = legendItem }) - // A per-geometry-type color, for the data table's Color column - - // features of different types in the same file get different - // colors here, matching what the map legend already shows per type. data = stampFeatureColors(featureCollection, legendItemsByType) } diff --git a/src/loaders/trackedEntityLoader.js b/src/loaders/trackedEntityLoader.js index 7373e0f3c8..aa612e7fe6 100644 --- a/src/loaders/trackedEntityLoader.js +++ b/src/loaders/trackedEntityLoader.js @@ -106,8 +106,6 @@ export const getAttributeProperties = (attributes) => (attributes ?? []).map(({ attribute, value }) => [attribute, value]) ) -// One header per unique attribute uid seen across all instances - not every -// instance necessarily has a value for every attribute. export const getAttributeHeaders = (instances) => { const headersByAttribute = new Map() instances.forEach(({ attributes }) => { @@ -124,10 +122,8 @@ export const getAttributeHeaders = (instances) => { return [...headersByAttribute.values()] } -// The main tracked entity marker's own color is currently fixed for every -// instance (no per-instance classification yet, unlike thematic/event) - -// still stamped here so the data table's Color column has real data ready -// to become meaningful once that changes. +// The main tracked entity marker's own color is currently fixed still +// stamped here for when data table's Color column has real data export const toGeoJson = (instances, color) => instances.map(({ id, geometry, attributes }) => ({ type: GEO_TYPE_FEATURE, diff --git a/src/util/tableSort.js b/src/util/tableSort.js index 5c1f9d3999..ac53e00f6b 100644 --- a/src/util/tableSort.js +++ b/src/util/tableSort.js @@ -64,8 +64,7 @@ export const compareFieldValues = ( bVal, { sortField, sortDirection } ) => { - // All missing values (undefined, or null - e.g. a period column with no - // data for a given org unit) should be sorted to the end + // All missing values should be sorted to the end if (isNoValue(aVal) && isNoValue(bVal)) { return 0 } From fcb4fc00aac8d91f4f155c4cf40b0a814f44a2fe Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 23 Jul 2026 11:51:58 +0200 Subject: [PATCH 091/205] fix datatable support geometry+multigeometry mix --- src/util/__tests__/tableHeaders.spec.js | 10 ++++++++++ src/util/tableHeaders.js | 5 ++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/util/__tests__/tableHeaders.spec.js b/src/util/__tests__/tableHeaders.spec.js index c3e0f0d798..97a4e078e2 100644 --- a/src/util/__tests__/tableHeaders.spec.js +++ b/src/util/__tests__/tableHeaders.spec.js @@ -223,6 +223,16 @@ describe('getHeadersForLayer - geoJsonUrl', () => { const result = getHeadersForLayer(GEOJSON_URL_LAYER, { rawData }) expect(result).toEqual({ errorCode: ERROR_NON_HOMOGENOUS_FEATURES }) }) + + test('a Polygon/MultiPolygon mix is homogenous (matches the loader’s own Multi-normalization)', () => { + const rawData = [ + { geometry: { type: 'Polygon' }, properties: { name: 'A' } }, + { geometry: { type: 'MultiPolygon' }, properties: { name: 'B' } }, + ] + const result = getHeadersForLayer(GEOJSON_URL_LAYER, { rawData }) + expect(result.errorCode).toBeUndefined() + expect(dataKeys(result)).toEqual(expect.arrayContaining(['name'])) + }) }) describe('getHeadersForLayer - unknown layer type', () => { diff --git a/src/util/tableHeaders.js b/src/util/tableHeaders.js index 0b32fdc636..088c87fa0c 100644 --- a/src/util/tableHeaders.js +++ b/src/util/tableHeaders.js @@ -318,8 +318,11 @@ export const getHeadersForLayer = (layerType, ctx) => { // Unlike the other cases, this reads the raw layer data // rather than dataWithAggregations const rawData = ctx.rawData ?? [] + const nonMultiType = (type) => type.replaceAll('Multi', '') const isHomogenous = rawData.every( - (feature) => feature.geometry.type === rawData[0]?.geometry.type + (feature) => + nonMultiType(feature.geometry.type) === + nonMultiType(rawData[0]?.geometry.type ?? '') ) if (!isHomogenous) { return { errorCode: ERROR_NON_HOMOGENOUS_FEATURES } From a95c172ec76b7f3189b95308a752b573ff30bd31 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 23 Jul 2026 11:57:33 +0200 Subject: [PATCH 092/205] fix: coherce numeric valueTypes in TE layer for datatable --- .../__tests__/trackedEntityLoader.spec.js | 18 ++++++++++++++++++ src/loaders/trackedEntityLoader.js | 9 ++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/loaders/__tests__/trackedEntityLoader.spec.js b/src/loaders/__tests__/trackedEntityLoader.spec.js index 4f322ca4e4..a9a69cceea 100644 --- a/src/loaders/__tests__/trackedEntityLoader.spec.js +++ b/src/loaders/__tests__/trackedEntityLoader.spec.js @@ -25,6 +25,24 @@ describe('getAttributeProperties', () => { expect(getAttributeProperties(undefined)).toEqual({}) expect(getAttributeProperties([])).toEqual({}) }) + + it('coerces a numeric-valueType attribute value to a real number', () => { + const attributes = [ + { attribute: 'ageUid', value: '34', valueType: 'INTEGER' }, + { attribute: 'nameUid', value: 'Gabrielle', valueType: 'TEXT' }, + ] + expect(getAttributeProperties(attributes)).toEqual({ + ageUid: 34, + nameUid: 'Gabrielle', + }) + }) + + it('leaves a numeric-valueType value with no data as undefined, not NaN', () => { + const attributes = [ + { attribute: 'ageUid', value: '', valueType: 'INTEGER' }, + ] + expect(getAttributeProperties(attributes).ageUid).toBeUndefined() + }) }) describe('getAttributeHeaders', () => { diff --git a/src/loaders/trackedEntityLoader.js b/src/loaders/trackedEntityLoader.js index aa612e7fe6..b50e5b8e51 100644 --- a/src/loaders/trackedEntityLoader.js +++ b/src/loaders/trackedEntityLoader.js @@ -8,6 +8,7 @@ import { TEI_RELATIONSHIP_LINE_COLOR, } from '../constants/layers.js' import { getProgramStatuses } from '../constants/programStatuses.js' +import { numberValueTypes } from '../constants/valueTypes.js' import { getOrgUnitsFromRows } from '../util/analytics.js' import { parseJsonConfig } from '../util/config.js' import { @@ -17,6 +18,7 @@ import { GEO_TYPE_LINE, GEO_TYPE_FEATURE, } from '../util/geojson.js' +import { parseWithSeparator } from '../util/numbers.js' import { getDataWithRelationships } from '../util/teiRelationshipsParser.js' import { trimTime, formatStartEndDate, getDateArray } from '../util/time.js' @@ -103,7 +105,12 @@ const TRACKED_ENTITY_TYPES_QUERY = { export const getAttributeProperties = (attributes) => Object.fromEntries( - (attributes ?? []).map(({ attribute, value }) => [attribute, value]) + (attributes ?? []).map(({ attribute, value, valueType }) => [ + attribute, + numberValueTypes.includes(valueType) + ? parseWithSeparator(value) + : value, + ]) ) export const getAttributeHeaders = (instances) => { From b4fae375f77e7b05a52575f4021c13b0bb2b2045 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 23 Jul 2026 12:04:01 +0200 Subject: [PATCH 093/205] fix: no drilling and profile for TE layer --- src/components/datatable/TableContextMenu.jsx | 7 +++- .../__tests__/TableContextMenu.spec.jsx | 37 ++++++++++++++++++- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/components/datatable/TableContextMenu.jsx b/src/components/datatable/TableContextMenu.jsx index add98bcb67..24d0974dd3 100644 --- a/src/components/datatable/TableContextMenu.jsx +++ b/src/components/datatable/TableContextMenu.jsx @@ -18,6 +18,7 @@ import { EVENT_LAYER, FACILITY_LAYER, GEOJSON_URL_LAYER, + TRACKED_ENTITY_LAYER, } from '../../constants/layers.js' import { getGeojsonFeatureProfile } from '../../util/geojson.js' import { drillUpDown } from '../../util/map.js' @@ -58,9 +59,11 @@ const TableContextMenu = ({ layerType !== BOUNDARY_LAYER && layerType !== FACILITY_LAYER && layerType !== EVENT_LAYER && - layerType !== GEOJSON_URL_LAYER + layerType !== GEOJSON_URL_LAYER && + layerType !== TRACKED_ENTITY_LAYER - const canViewProfile = id && layerType !== EVENT_LAYER + const canViewProfile = + id && layerType !== EVENT_LAYER && layerType !== TRACKED_ENTITY_LAYER return ( <> diff --git a/src/components/datatable/__tests__/TableContextMenu.spec.jsx b/src/components/datatable/__tests__/TableContextMenu.spec.jsx index 71f81c655e..0384092cdc 100644 --- a/src/components/datatable/__tests__/TableContextMenu.spec.jsx +++ b/src/components/datatable/__tests__/TableContextMenu.spec.jsx @@ -2,8 +2,14 @@ import { render, fireEvent, screen } from '@testing-library/react' import React from 'react' import { Provider } from 'react-redux' import configureMockStore from 'redux-mock-store' -import { FEATURE_HIGHLIGHT } from '../../../constants/actionTypes.js' -import { FACILITY_LAYER } from '../../../constants/layers.js' +import { + FEATURE_HIGHLIGHT, + ORGANISATION_UNIT_PROFILE_SET, +} from '../../../constants/actionTypes.js' +import { + FACILITY_LAYER, + TRACKED_ENTITY_LAYER, +} from '../../../constants/layers.js' import TableContextMenu from '../TableContextMenu.jsx' jest.mock('../../cachedDataProvider/CachedDataProvider.jsx', () => ({ @@ -37,6 +43,33 @@ const renderMenu = (props) => { return { ...result, store } } +describe('TableContextMenu — view profile menu item', () => { + test('is not offered for a Tracked Entity row (id is a TEI uid, not an org unit id)', () => { + renderMenu({ + layer: { id: 'layer1', layer: TRACKED_ENTITY_LAYER }, + contextMenu: { x: 10, y: 10, featureProps: { id: 'tei1' } }, + }) + expect( + screen.queryByTestId('data-table-context-menu-view-profile') + ).not.toBeInTheDocument() + }) + + test('dispatches setOrgUnitProfile with the row id for a layer type that supports it', () => { + const { store } = renderMenu({ + contextMenu: { x: 10, y: 10, featureProps: { id: 'ou1' } }, + }) + fireEvent.click( + screen + .getByTestId('data-table-context-menu-view-profile') + .querySelector('a') + ) + expect(store.getActions()).toContainEqual({ + type: ORGANISATION_UNIT_PROFILE_SET, + payload: 'ou1', + }) + }) +}) + describe('TableContextMenu — zoom to filtered features', () => { test('is disabled when no filter is active (filteredIds is null)', () => { renderMenu({ filteredIds: null }) From 36fbc008ea99bd56d6f512ae72210f59a6d0eed3 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 23 Jul 2026 12:15:03 +0200 Subject: [PATCH 094/205] fix: cancel on resize issues --- src/components/datatable/BottomPanel.jsx | 5 ++ .../controls/ResizeHandleControl.jsx | 24 ++++-- .../__tests__/ResizeHandleControl.spec.jsx | 80 +++++++++++++++++++ 3 files changed, 104 insertions(+), 5 deletions(-) create mode 100644 src/components/datatable/controls/__tests__/ResizeHandleControl.spec.jsx diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index 0eb97deedf..2161cb7e8b 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -121,6 +121,10 @@ const BottomPanel = () => { [dispatch] ) + const onResizeCancel = useCallback(() => { + isDraggingRef.current = false + }, []) + const onCountChange = useCallback((total, filtered) => { setTotalCount(total) setFilteredCount(filtered) @@ -222,6 +226,7 @@ const BottomPanel = () => { onResizeStart={onResizeStart} onResize={onResize} onResizeEnd={onResizeEnd} + onResizeCancel={onResizeCancel} /> <RowCountControl totalCount={totalCount} diff --git a/src/components/datatable/controls/ResizeHandleControl.jsx b/src/components/datatable/controls/ResizeHandleControl.jsx index 8d38cc833a..341d5e24de 100644 --- a/src/components/datatable/controls/ResizeHandleControl.jsx +++ b/src/components/datatable/controls/ResizeHandleControl.jsx @@ -7,6 +7,7 @@ const ResizeHandleControl = ({ onResize, onResizeStart, onResizeEnd, + onResizeCancel, minHeight = 50, maxHeight = 500, }) => { @@ -33,17 +34,29 @@ const ResizeHandleControl = ({ } } - const onPointerUp = (evt) => { - if (!isDraggingRef.current) { - return - } + const endDrag = (evt) => { isDraggingRef.current = false evt.currentTarget.releasePointerCapture(evt.pointerId) evt.currentTarget.style.removeProperty('cursor') document.body.style.removeProperty('cursor') + } + + const onPointerUp = (evt) => { + if (!isDraggingRef.current) { + return + } + endDrag(evt) onResizeEnd?.(getHeight(evt.clientY)) } + const onPointerCancel = (evt) => { + if (!isDraggingRef.current) { + return + } + endDrag(evt) + onResizeCancel?.() + } + // In case the handle/panel unmounts mid-drag useEffect( () => () => { @@ -60,7 +73,7 @@ const ResizeHandleControl = ({ onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} - onPointerCancel={onPointerUp} + onPointerCancel={onPointerCancel} > <span className={styles.gripBox}> <IconDrag /> @@ -73,6 +86,7 @@ ResizeHandleControl.propTypes = { maxHeight: PropTypes.number.isRequired, minHeight: PropTypes.number, onResize: PropTypes.func, + onResizeCancel: PropTypes.func, onResizeEnd: PropTypes.func, onResizeStart: PropTypes.func, } diff --git a/src/components/datatable/controls/__tests__/ResizeHandleControl.spec.jsx b/src/components/datatable/controls/__tests__/ResizeHandleControl.spec.jsx new file mode 100644 index 0000000000..3486d59e58 --- /dev/null +++ b/src/components/datatable/controls/__tests__/ResizeHandleControl.spec.jsx @@ -0,0 +1,80 @@ +import { render, fireEvent } from '@testing-library/react' +import React from 'react' +import ResizeHandleControl from '../ResizeHandleControl.jsx' + +// jsdom doesn't implement pointer capture +beforeAll(() => { + Element.prototype.setPointerCapture = jest.fn() + Element.prototype.releasePointerCapture = jest.fn() +}) + +const renderHandle = () => { + const onResizeStart = jest.fn() + const onResize = jest.fn() + const onResizeEnd = jest.fn() + const onResizeCancel = jest.fn() + const { container } = render( + <ResizeHandleControl + maxHeight={500} + minHeight={50} + onResizeStart={onResizeStart} + onResize={onResize} + onResizeEnd={onResizeEnd} + onResizeCancel={onResizeCancel} + /> + ) + return { + handle: container.firstChild, + onResizeStart, + onResize, + onResizeEnd, + onResizeCancel, + } +} + +describe('ResizeHandleControl', () => { + test('a normal drag commits a resize on pointer up', () => { + const { handle, onResizeStart, onResize, onResizeEnd, onResizeCancel } = + renderHandle() + + fireEvent.pointerDown(handle, { pointerId: 1, clientY: 500 }) + fireEvent.pointerMove(handle, { pointerId: 1, clientY: 400 }) + fireEvent.pointerUp(handle, { pointerId: 1, clientY: 300 }) + + expect(onResizeStart).toHaveBeenCalledTimes(1) + expect(onResize).toHaveBeenCalled() + expect(onResizeEnd).toHaveBeenCalledTimes(1) + expect(onResizeCancel).not.toHaveBeenCalled() + }) + + test('a cancelled gesture resets the drag state without committing a resize', () => { + const { handle, onResizeEnd, onResizeCancel } = renderHandle() + + fireEvent.pointerDown(handle, { pointerId: 1, clientY: 500 }) + fireEvent.pointerMove(handle, { pointerId: 1, clientY: 400 }) + fireEvent.pointerCancel(handle, { pointerId: 1, clientY: 0 }) + + expect(onResizeCancel).toHaveBeenCalledTimes(1) + expect(onResizeEnd).not.toHaveBeenCalled() + }) + + test('a stray pointercancel with no active drag is a no-op', () => { + const { handle, onResizeCancel, onResizeEnd } = renderHandle() + + fireEvent.pointerCancel(handle, { pointerId: 1, clientY: 0 }) + + expect(onResizeCancel).not.toHaveBeenCalled() + expect(onResizeEnd).not.toHaveBeenCalled() + }) + + test('a second pointer up/cancel after the drag already ended is a no-op', () => { + const { handle, onResizeEnd, onResizeCancel } = renderHandle() + + fireEvent.pointerDown(handle, { pointerId: 1, clientY: 500 }) + fireEvent.pointerUp(handle, { pointerId: 1, clientY: 400 }) + fireEvent.pointerCancel(handle, { pointerId: 1, clientY: 0 }) + + expect(onResizeEnd).toHaveBeenCalledTimes(1) + expect(onResizeCancel).not.toHaveBeenCalled() + }) +}) From da30838037c279de6275a26ba7dade037b70f6f0 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 23 Jul 2026 12:48:18 +0200 Subject: [PATCH 095/205] fix: raceediting filters/columns while an event layer's data is still extending --- src/reducers/__tests__/map.spec.js | 50 ++++++++++++++++++++++++++++++ src/reducers/map.js | 4 +++ 2 files changed, 54 insertions(+) diff --git a/src/reducers/__tests__/map.spec.js b/src/reducers/__tests__/map.spec.js index 1c70810eab..8164b8551f 100644 --- a/src/reducers/__tests__/map.spec.js +++ b/src/reducers/__tests__/map.spec.js @@ -334,6 +334,56 @@ describe('map reducer - per-layer delegation', () => { }) expect(result.mapViews[1]).toBe(other) }) + + it("keeps the live dataTableColumnConfig/dataFilters instead of an async loader payload's stale snapshot", () => { + const state = { + ...defaultState, + mapViews: [ + { + id: 'layer1', + name: 'Old', + dataTableColumnConfig: { visibleKeys: ['name'] }, + dataFilters: { name: 'foo' }, + }, + ], + } + + const result = map(state, { + type: types.LAYER_UPDATE, + payload: { + id: 'layer1', + name: 'New', + // Stale: captured before the user's edits above + dataTableColumnConfig: undefined, + dataFilters: undefined, + }, + }) + + expect(result.mapViews[0].dataTableColumnConfig).toEqual({ + visibleKeys: ['name'], + }) + expect(result.mapViews[0].dataFilters).toEqual({ name: 'foo' }) + }) + + it("uses the payload's dataTableColumnConfig/dataFilters when the layer has none live yet (first load)", () => { + const state = { + ...defaultState, + mapViews: [{ id: 'layer1', name: 'Old' }], + } + + const result = map(state, { + type: types.LAYER_UPDATE, + payload: { + id: 'layer1', + name: 'New', + dataTableColumnConfig: { visibleKeys: ['id'] }, + }, + }) + + expect(result.mapViews[0].dataTableColumnConfig).toEqual({ + visibleKeys: ['id'], + }) + }) }) describe('LAYER_EDIT', () => { diff --git a/src/reducers/map.js b/src/reducers/map.js index d32818a36d..b1c69de918 100644 --- a/src/reducers/map.js +++ b/src/reducers/map.js @@ -90,6 +90,10 @@ const layer = (state, action) => { return { ...action.payload, + dataTableColumnConfig: + state.dataTableColumnConfig ?? + action.payload.dataTableColumnConfig, + dataFilters: state.dataFilters ?? action.payload.dataFilters, } case types.LAYER_CHANGE_OPACITY: From a5028aae6bf17f2b0630e960c0f9858d84b03820 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 23 Jul 2026 13:58:54 +0200 Subject: [PATCH 096/205] fix: crash + dead-code bug in parseLayerConfig --- src/components/loaders/useLoaderAlerts.js | 14 +++++ src/constants/alerts.js | 2 + src/loaders/externalLoader.js | 12 ++++- src/loaders/geoJsonUrlLoader.js | 12 ++++- src/util/__tests__/external.spec.js | 66 +++++++++++++++++++++++ src/util/external.js | 11 ++-- 6 files changed, 110 insertions(+), 7 deletions(-) diff --git a/src/components/loaders/useLoaderAlerts.js b/src/components/loaders/useLoaderAlerts.js index 949bbf936f..da9b6fc4e3 100644 --- a/src/components/loaders/useLoaderAlerts.js +++ b/src/components/loaders/useLoaderAlerts.js @@ -7,6 +7,7 @@ import { WARNING_NO_OU_COORD, WARNING_NO_GEOMETRY_COORD, WARNING_OU_BOUNDARIES_FETCH_FAILED, + WARNING_EXTERNAL_LAYER_NOT_FOUND, ERROR_CRITICAL, CUSTOM_ALERT, } from '../../constants/alerts.js' @@ -44,6 +45,11 @@ function useLoaderAlerts(loaderAlertAction = Function.prototype) { onHidden: loaderAlertAction, }) + const externalLayerNotFoundAlert = useAlert(ALERT_MESSAGE_DYNAMIC, { + warning: true, + onHidden: loaderAlertAction, + }) + const showAlerts = (alerts) => { alerts.forEach(({ message: msg, code, warning, critical }) => { switch (code) { @@ -84,6 +90,14 @@ function useLoaderAlerts(loaderAlertAction = Function.prototype) { }) break } + case WARNING_EXTERNAL_LAYER_NOT_FOUND: { + externalLayerNotFoundAlert.show({ + msg: `${msg}: ${i18n.t( + 'External layer definition not found, showing last known settings' + )}`, + }) + break + } case ERROR_CRITICAL: { errorAlert.show({ msg: `${i18n.t('Error')}: ${msg}` }) break diff --git a/src/constants/alerts.js b/src/constants/alerts.js index 682851742b..9280206e49 100644 --- a/src/constants/alerts.js +++ b/src/constants/alerts.js @@ -14,5 +14,7 @@ export const WARNING_NO_OU_COORD = 'WARNING_NO_OU_COORD' export const WARNING_NO_GEOMETRY_COORD = 'WARNING_NO_GEOMETRY_COORD' export const WARNING_OU_BOUNDARIES_FETCH_FAILED = 'WARNING_OU_BOUNDARIES_FETCH_FAILED' +export const WARNING_EXTERNAL_LAYER_NOT_FOUND = + 'WARNING_EXTERNAL_LAYER_NOT_FOUND' export const ERROR_CRITICAL = 'ERROR_CRITICAL' export const CUSTOM_ALERT = 'CUSTOM_ALERT' diff --git a/src/loaders/externalLoader.js b/src/loaders/externalLoader.js index 0e031c9dde..1cac31e94d 100644 --- a/src/loaders/externalLoader.js +++ b/src/loaders/externalLoader.js @@ -1,3 +1,4 @@ +import { WARNING_EXTERNAL_LAYER_NOT_FOUND } from '../constants/alerts.js' import { EXTERNAL_LAYER } from '../constants/layers.js' import { parseLayerConfig } from '../util/external.js' import { getPredefinedLegendItems } from '../util/legend.js' @@ -5,9 +6,17 @@ import { LEGEND_SET_QUERY } from '../util/requests.js' const externalLoader = async ({ config: layer, engine }) => { let config + const alerts = [] if (typeof layer.config === 'string') { // External layer is loaded in analytical object - config = await parseLayerConfig(layer.config, engine) + const parsed = await parseLayerConfig(layer.config, engine) + config = parsed.config + if (parsed.notFound) { + alerts.push({ + code: WARNING_EXTERNAL_LAYER_NOT_FOUND, + message: layer.name, + }) + } } else { config = { ...layer.config } } @@ -37,6 +46,7 @@ const externalLoader = async ({ config: layer, engine }) => { isLoaded: true, isLoading: false, isExpanded: true, + ...(alerts.length ? { alerts } : {}), } } diff --git a/src/loaders/geoJsonUrlLoader.js b/src/loaders/geoJsonUrlLoader.js index af1721bed3..ee372c35c1 100644 --- a/src/loaders/geoJsonUrlLoader.js +++ b/src/loaders/geoJsonUrlLoader.js @@ -1,4 +1,5 @@ import i18n from '@dhis2/d2-i18n' +import { WARNING_EXTERNAL_LAYER_NOT_FOUND } from '../constants/alerts.js' import { parseLayerConfig } from '../util/external.js' import { buildGeoJsonFeatures, @@ -72,10 +73,18 @@ const geoJsonUrlLoader = async ({ let newConfig let featureStyle let dataTableColumnConfig + const alerts = [] // keep featureStyle and dataTableColumnConfig properties outside of config while in app if (typeof config === 'string') { // External layer is loaded in analytical object - newConfig = await parseLayerConfig(config, engine) + const parsed = await parseLayerConfig(config, engine) + newConfig = parsed.config + if (parsed.notFound) { + alerts.push({ + code: WARNING_EXTERNAL_LAYER_NOT_FOUND, + message: layer.name, + }) + } featureStyle = { ...newConfig.featureStyle } || EMPTY_FEATURE_STYLE dataTableColumnConfig = newConfig.dataTableColumnConfig delete newConfig.featureStyle @@ -153,6 +162,7 @@ const geoJsonUrlLoader = async ({ isLoading: false, isExpanded: true, loadError, + ...(alerts.length ? { alerts } : {}), } } diff --git a/src/util/__tests__/external.spec.js b/src/util/__tests__/external.spec.js index 3089e956e0..30f850280c 100644 --- a/src/util/__tests__/external.spec.js +++ b/src/util/__tests__/external.spec.js @@ -9,6 +9,7 @@ import { import { createExternalBasemapLayer, createExternalOverlayLayer, + parseLayerConfig, } from '../external.js' describe('createExternalBasemapLayer', () => { @@ -254,3 +255,68 @@ describe('createExternalOverlayLayer', () => { }) }) }) + +describe('parseLayerConfig', () => { + test('returns an empty config instead of throwing on malformed JSON', async () => { + await expect(parseLayerConfig('not-valid-json', {})).resolves.toEqual({ + config: {}, + }) + }) + + test('does not throw when the JSON parses to null', async () => { + await expect(parseLayerConfig('null', {})).resolves.toEqual({ + config: null, + }) + }) + + test('returns the local config unchanged when it has no id (nothing to refresh)', async () => { + const config = { url: 'https://path-to-geojson', name: 'Local' } + await expect( + parseLayerConfig(JSON.stringify(config), {}) + ).resolves.toEqual({ config }) + }) + + test('returns a freshly-fetched config on success, carrying featureStyle/dataTableColumnConfig forward', async () => { + const localConfig = { + id: 'ext-1', + featureStyle: { color: '#ff0000' }, + dataTableColumnConfig: { visibleKeys: ['name'] }, + } + const engine = { + query: jest.fn().mockResolvedValue({ + externalLayer: { + id: 'ext-1', + name: 'Fresh name', + url: 'https://fresh-url', + mapService: 'XYZ', + imageFormat: 'PNG', + }, + }), + } + + const result = await parseLayerConfig( + JSON.stringify(localConfig), + engine + ) + + expect(result.notFound).toBeUndefined() + expect(result.config).toMatchObject({ + id: 'ext-1', + name: 'Fresh name', + url: 'https://fresh-url', + featureStyle: { color: '#ff0000' }, + dataTableColumnConfig: { visibleKeys: ['name'] }, + }) + }) + + test('falls back to the local config and flags notFound when the API fetch fails', async () => { + const localConfig = { id: 'deleted-layer', url: 'https://stale-url' } + const engine = { + query: jest.fn().mockRejectedValue(new Error('404')), + } + + await expect( + parseLayerConfig(JSON.stringify(localConfig), engine) + ).resolves.toEqual({ config: localConfig, notFound: true }) + }) +}) diff --git a/src/util/external.js b/src/util/external.js index 4387d85d44..34cb6cd3be 100644 --- a/src/util/external.js +++ b/src/util/external.js @@ -73,19 +73,18 @@ const createExternalLayerConfig = (model) => { } } -// Parse external layer config returned as a string in ao export const parseLayerConfig = async (layerConfig, engine) => { let config try { config = JSON.parse(layerConfig) } catch (error_) { - return + return { config: {} } } // We could use the config object as stored, but better to // use a fresh layer config from the API - if (config.id) { + if (config?.id) { try { const { externalLayer } = await engine.query( { externalLayer: EXTERNAL_MAP_LAYER_QUERY }, @@ -97,10 +96,12 @@ export const parseLayerConfig = async (layerConfig, engine) => { ) const newConfig = createExternalLayerConfig(externalLayer) newConfig.featureStyle = { ...config.featureStyle } + newConfig.dataTableColumnConfig = config.dataTableColumnConfig + return { config: newConfig } } catch (error_) { - return config + return { config, notFound: true } } } - return config + return { config } } From 7fe8765120524edaa3475231ad60f65f6bc7f34a Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 23 Jul 2026 14:09:29 +0200 Subject: [PATCH 097/205] fix: header titles alignement --- i18n/en.pot | 85 ++++++++++--------- src/components/datatable/DataTable.jsx | 4 +- .../datatable/styles/DataTable.module.css | 4 + 3 files changed, 51 insertions(+), 42 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index e52dbfea8d..3c572ce53b 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-18T09:56:04.530Z\n" -"PO-Revision-Date: 2026-07-18T09:56:04.530Z\n" +"POT-Creation-Date: 2026-07-23T11:59:37.478Z\n" +"PO-Revision-Date: 2026-07-23T11:59:37.479Z\n" msgid "2020" msgstr "2020" @@ -336,45 +336,6 @@ msgstr "" msgid "No valid data fields were found for this layer." msgstr "No valid data fields were found for this layer." -msgid "Id" -msgstr "Id" - -msgid "Level" -msgstr "Level" - -msgid "Parent" -msgstr "Parent" - -msgid "Type" -msgstr "Type" - -msgid "Legend" -msgstr "Legend" - -msgid "Range" -msgstr "Range" - -msgid "Org unit" -msgstr "Org unit" - -msgid "Org unit boundary" -msgstr "Org unit boundary" - -msgid "Event time" -msgstr "Event time" - -msgid "Group" -msgstr "Group" - -msgid "Icon" -msgstr "Icon" - -msgid "Current period" -msgstr "Current period" - -msgid "Value ({{period}})" -msgstr "Value ({{period}})" - msgid "Loading Earth Engine data…" msgstr "Loading Earth Engine data…" @@ -933,6 +894,9 @@ msgstr[1] "{{n}} org units without coordinates" msgid "Selected org units: No coordinates found" msgstr "Selected org units: No coordinates found" +msgid "External layer definition not found, showing last known settings" +msgstr "External layer definition not found, showing last known settings" + msgid "Error" msgstr "Error" @@ -964,12 +928,18 @@ msgstr "Could not retrieve event data" msgid "Organisation unit" msgstr "Organisation unit" +msgid "Event time" +msgstr "Event time" + msgid "Groups" msgstr "Groups" msgid "Parent unit" msgstr "Parent unit" +msgid "Level" +msgstr "Level" + msgid "Not set" msgstr "Not set" @@ -1095,6 +1065,9 @@ msgstr "No data found for this period." msgid "Image of the organisation unit" msgstr "Image of the organisation unit" +msgid "Parent" +msgstr "Parent" + msgid "Code" msgstr "Code" @@ -1214,6 +1187,9 @@ msgstr "Click to unpin legend" msgid "Click to pin legend" msgstr "Click to pin legend" +msgid "Legend" +msgstr "Legend" + msgid "Hide layer" msgstr "Hide layer" @@ -2078,6 +2054,33 @@ msgstr "Facility" msgid "GroupSet used for styling was not found" msgstr "GroupSet used for styling was not found" +msgid "Id" +msgstr "Id" + +msgid "Type" +msgstr "Type" + +msgid "Range" +msgstr "Range" + +msgid "Org unit" +msgstr "Org unit" + +msgid "Org unit boundary" +msgstr "Org unit boundary" + +msgid "Group" +msgstr "Group" + +msgid "Icon" +msgstr "Icon" + +msgid "Current period" +msgstr "Current period" + +msgid "Value ({{period}})" +msgstr "Value ({{period}})" + msgid "Start date is invalid" msgstr "Start date is invalid" diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index 29f5430638..ad3f1ab295 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -463,7 +463,9 @@ const Table = ({ } > <span className={styles.headerContent}> - {name} + <span className={styles.headerTitle}> + {name} + </span> <TopTooltip content={i18n.t('Sort by {{column}}', { column: name, diff --git a/src/components/datatable/styles/DataTable.module.css b/src/components/datatable/styles/DataTable.module.css index a083a8c5e8..9c636d8c7b 100644 --- a/src/components/datatable/styles/DataTable.module.css +++ b/src/components/datatable/styles/DataTable.module.css @@ -76,6 +76,10 @@ th.hovered { gap: 2px; } +.headerTitle { + padding-top: var(--spacers-dp4); +} + .reverseButton { display: inline-flex; align-items: center; From b0ff4e0d356c24b281fa461b0b66ab89f368437d Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 23 Jul 2026 14:44:58 +0200 Subject: [PATCH 098/205] feat: expose spatialSupport from system info for event clustering Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --- src/util/__tests__/app.spec.js | 5 ++++- src/util/app.js | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/util/__tests__/app.spec.js b/src/util/__tests__/app.spec.js index f7dee004b5..c8b819087a 100644 --- a/src/util/__tests__/app.spec.js +++ b/src/util/__tests__/app.spec.js @@ -39,7 +39,7 @@ describe('utils/app - providerDataTransformation', () => { externalMapLayers: [ { mapService: 'XYZ', - url: 'https://a.tiles.mapbox.com/v4/worldbank-education.pebkgmlc/{z}/{x}/{y}.png?access_token=pk.eyJ1Ijoid29ybGRiYW5rLWVkdWNhdGlvbiIsImEiOiJIZ2VvODFjIn0.TDw5VdwGavwEsch53sAVxA', + url: 'https://a.tiles.mapbox.com/v4/worldbank-education.pebkgmlc/{z}/{x}/{y}.png?access_token=test-token', attribution: 'OpenAerialMap / Tanzania Open Data Initiative', imageFormat: 'PNG', mapLayerPosition: 'BASEMAP', @@ -114,6 +114,7 @@ describe('utils/app - providerDataTransformation', () => { } const systemInfo = { calendar: 'gregory', + databaseInfo: { spatialSupport: true }, } const cfg = await providerDataTransformation({ @@ -124,6 +125,7 @@ describe('utils/app - providerDataTransformation', () => { systemInfo, }) + expect(cfg.spatialSupport).toBe(true) expect(cfg.basemaps).toHaveLength(10) expect(cfg.nameProperty).toEqual('displayName') expect(cfg.defaultLayerSources).toHaveLength(6) @@ -180,6 +182,7 @@ describe('utils/app - providerDataTransformation', () => { systemInfo, }) + expect(cfg.spatialSupport).toBeUndefined() expect(cfg.basemaps).toHaveLength(6) expect(cfg.nameProperty).toEqual('displayShortName') expect(cfg.defaultLayerSources).toHaveLength(6) diff --git a/src/util/app.js b/src/util/app.js index 62a88b5806..d9d45b5f8c 100644 --- a/src/util/app.js +++ b/src/util/app.js @@ -28,7 +28,7 @@ export const appQueries = { systemInfo: { resource: 'system/info', params: { - fields: 'calendar,dateFormat', + fields: 'calendar,dateFormat,databaseInfo[spatialSupport]', }, }, } @@ -76,6 +76,7 @@ export const providerDataTransformation = async ({ calendar: systemInfo.calendar, dateFormat: systemInfo.dateFormat, }, + spatialSupport: systemInfo.databaseInfo?.spatialSupport, basemaps: await getBasemapList({ externalMapLayers: externalMapLayers.externalMapLayers, systemSettings, From 93b2e443eb809883f66ac97b1dd01c61d35f01a0 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 23 Jul 2026 14:45:54 +0200 Subject: [PATCH 099/205] feat: expose spatialSupport from system info in dashboard plugin Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --- src/components/plugin/Plugin.jsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/components/plugin/Plugin.jsx b/src/components/plugin/Plugin.jsx index 9d9de5780c..64fd9313be 100644 --- a/src/components/plugin/Plugin.jsx +++ b/src/components/plugin/Plugin.jsx @@ -23,9 +23,19 @@ const query = { fields: `${CURRENT_USER_FIELDS},settings[keyAnalysisDisplayProperty]`, }, }, + systemInfo: { + resource: 'system/info', + params: { + fields: 'databaseInfo[spatialSupport]', + }, + }, } -const providerDataTransformation = ({ systemSettings, currentUser }) => { +const providerDataTransformation = ({ + systemSettings, + currentUser, + systemInfo, +}) => { return { systemSettings: { ...DEFAULT_SYSTEM_SETTINGS, @@ -47,6 +57,7 @@ const providerDataTransformation = ({ systemSettings, currentUser }) => { currentUser.settings.keyAnalysisDisplayProperty === 'name' ? 'displayName' : 'displayShortName', + spatialSupport: systemInfo.databaseInfo?.spatialSupport, } } From cdf5b4ba0599ebbd684a529e362f407403e1f19c Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 23 Jul 2026 14:47:10 +0200 Subject: [PATCH 100/205] feat: thread spatialSupport into event layer loader call sites Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --- src/components/plugin/LayerLoader.jsx | 3 +++ src/hooks/useLayersLoader.js | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/components/plugin/LayerLoader.jsx b/src/components/plugin/LayerLoader.jsx index bd20f0ddcb..e2fcdc75a7 100644 --- a/src/components/plugin/LayerLoader.jsx +++ b/src/components/plugin/LayerLoader.jsx @@ -31,6 +31,7 @@ const LayerLoader = ({ config, onLoad }) => { const { systemSettings: { keyAnalysisDigitGroupSeparator }, currentUser, + spatialSupport, } = useCachedData() const { keyAnalysisDisplayProperty, @@ -59,6 +60,7 @@ const LayerLoader = ({ config, onLoad }) => { analyticsEngine, // Thematic and Event loader periodTypeData, // Thematic and Event loader serverVersion, // Tracked entity loader + spatialSupport, // Event loader }).then((result) => { onLoad(result) }) @@ -74,6 +76,7 @@ const LayerLoader = ({ config, onLoad }) => { keyAnalysisDisplayProperty, keyAnalysisDigitGroupSeparator, serverVersion, + spatialSupport, ]) return null diff --git a/src/hooks/useLayersLoader.js b/src/hooks/useLayersLoader.js index 194765c4cf..549c345d56 100644 --- a/src/hooks/useLayersLoader.js +++ b/src/hooks/useLayersLoader.js @@ -34,6 +34,7 @@ export const useLayersLoader = () => { const { systemSettings: { keyAnalysisDigitGroupSeparator }, currentUser, + spatialSupport, } = useCachedData() const { showAlerts } = useLoaderAlerts() const allLayers = useSelector((state) => state.map.mapViews) @@ -65,6 +66,7 @@ export const useLayersLoader = () => { periodTypeData, // Thematic and Event loader serverVersion, // Tracked entity loader loadExtended: !!dataTable, // Event loader + spatialSupport, // Event loader }) if (result.alerts) { showAlerts(result.alerts) @@ -128,5 +130,6 @@ export const useLayersLoader = () => { baseUrl, dataTable, serverVersion, + spatialSupport, ]) } From 69b73f17b1eab0a0a4ce60bcd5d0e6ae037da77d Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 23 Jul 2026 14:50:36 +0200 Subject: [PATCH 101/205] feat: gate server-side event clustering on backend spatial support Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --- src/loaders/__tests__/eventLoader.spec.js | 76 ++++++++++++++++++++++- src/loaders/eventLoader.js | 25 +++++--- 2 files changed, 91 insertions(+), 10 deletions(-) diff --git a/src/loaders/__tests__/eventLoader.spec.js b/src/loaders/__tests__/eventLoader.spec.js index ab9af8ba2d..cb5337ac61 100644 --- a/src/loaders/__tests__/eventLoader.spec.js +++ b/src/loaders/__tests__/eventLoader.spec.js @@ -4,12 +4,16 @@ import { USER_ORG_UNIT_GRANDCHILDREN, } from '@dhis2/analytics' import { WARNING_OU_BOUNDARIES_FETCH_FAILED } from '../../constants/alerts.js' +import { EVENT_SERVER_CLUSTER_COUNT } from '../../constants/layers.js' import { getUserOrgUnitIdsByKeyword } from '../../util/orgUnits.js' import { GEOFEATURES_QUERY, ORG_UNITS_PATHS_QUERY, } from '../../util/requests.js' -import { excludeEventsOutsideOrgUnits } from '../eventLoader.js' +import { + excludeEventsOutsideOrgUnits, + shouldUseServerCluster, +} from '../eventLoader.js' // [0,0]-[10,10] const SQUARE_A = [ @@ -729,3 +733,73 @@ describe('excludeEventsOutsideOrgUnits', () => { expect(config.legend.orgUnitsWithoutBoundaryCount).toBeUndefined() }) }) + +describe('shouldUseServerCluster', () => { + const overThreshold = EVENT_SERVER_CLUSTER_COUNT + 1 + + test('returns true when over threshold and the backend supports spatial clustering', () => { + expect( + shouldUseServerCluster({ + count: overThreshold, + countFeaturesWithoutCoordinates: false, + countEventsOutsideOrgUnits: false, + spatialSupport: true, + }) + ).toBe(true) + }) + + test('returns false when over threshold but the backend has no spatial support', () => { + expect( + shouldUseServerCluster({ + count: overThreshold, + countFeaturesWithoutCoordinates: false, + countEventsOutsideOrgUnits: false, + spatialSupport: false, + }) + ).toBe(false) + }) + + test('returns false when over threshold but spatialSupport is undefined (fails closed)', () => { + expect( + shouldUseServerCluster({ + count: overThreshold, + countFeaturesWithoutCoordinates: false, + countEventsOutsideOrgUnits: false, + spatialSupport: undefined, + }) + ).toBe(false) + }) + + test('returns false when under threshold, regardless of spatialSupport', () => { + expect( + shouldUseServerCluster({ + count: EVENT_SERVER_CLUSTER_COUNT, + countFeaturesWithoutCoordinates: false, + countEventsOutsideOrgUnits: false, + spatialSupport: true, + }) + ).toBe(false) + }) + + test('returns false when countFeaturesWithoutCoordinates is set, regardless of spatialSupport', () => { + expect( + shouldUseServerCluster({ + count: overThreshold, + countFeaturesWithoutCoordinates: true, + countEventsOutsideOrgUnits: false, + spatialSupport: true, + }) + ).toBe(false) + }) + + test('returns false when countEventsOutsideOrgUnits is set, regardless of spatialSupport', () => { + expect( + shouldUseServerCluster({ + count: overThreshold, + countFeaturesWithoutCoordinates: false, + countEventsOutsideOrgUnits: true, + spatialSupport: true, + }) + ).toBe(false) + }) +}) diff --git a/src/loaders/eventLoader.js b/src/loaders/eventLoader.js index e5d517a341..c980a0b037 100644 --- a/src/loaders/eventLoader.js +++ b/src/loaders/eventLoader.js @@ -58,12 +58,14 @@ const expandOrgUnitKeyword = (id, userOrgUnitIdsByKeyword) => { return [id] } -// Server clustering if more than 2000 events -const shouldUseServerCluster = ( +// Server clustering if more than 2000 events, and the backend supports it +export const shouldUseServerCluster = ({ count, countFeaturesWithoutCoordinates, - countEventsOutsideOrgUnits -) => + countEventsOutsideOrgUnits, + spatialSupport, +}) => + !!spatialSupport && !countFeaturesWithoutCoordinates && !countEventsOutsideOrgUnits && count > EVENT_SERVER_CLUSTER_COUNT @@ -97,6 +99,7 @@ const eventLoader = async ({ analyticsEngine, periodTypeData, loadExtended, + spatialSupport, }) => { const config = { ...layerConfig, @@ -117,6 +120,7 @@ const eventLoader = async ({ analyticsEngine, periodTypeData, loadExtended, + spatialSupport, }) } catch (e) { if ( @@ -149,6 +153,7 @@ const loadEventLayer = async ({ analyticsEngine, periodTypeData, loadExtended, + spatialSupport, }) => { // Config normalization // ----- @@ -280,11 +285,13 @@ const loadEventLayer = async ({ if (eventClustering && !styleDataItem) { const response = await analyticsEngine.events.getCount(analyticsRequest) config.bounds = getBounds(response.extent) - config.serverCluster = shouldUseServerCluster( - response.count, - config.countFeaturesWithoutCoordinates, - config.countEventsOutsideOrgUnits - ) + config.serverCluster = shouldUseServerCluster({ + count: response.count, + countFeaturesWithoutCoordinates: + config.countFeaturesWithoutCoordinates, + countEventsOutsideOrgUnits: config.countEventsOutsideOrgUnits, + spatialSupport, + }) serverCount = response.count } From da52d4403de6cb4be0d27e126fc2c4d3367e6076 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 23 Jul 2026 14:53:13 +0200 Subject: [PATCH 102/205] feat: add Redux action for forcing client-side event clustering Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --- src/actions/layers.js | 7 +++++++ src/constants/actionTypes.js | 1 + src/reducers/__tests__/map.spec.js | 18 ++++++++++++++++++ src/reducers/map.js | 11 +++++++++++ 4 files changed, 37 insertions(+) diff --git a/src/actions/layers.js b/src/actions/layers.js index 1af92b1d58..ad555375cb 100644 --- a/src/actions/layers.js +++ b/src/actions/layers.js @@ -66,3 +66,10 @@ export const setLayerLoading = (id) => ({ type: types.LAYER_LOADING_SET, id, }) + +// Force client-side clustering for a server-clustered event layer +// (session-only; intentionally excluded from validLayerProperties in favorites.js) +export const setForceClientCluster = (id) => ({ + type: types.LAYER_FORCE_CLIENT_CLUSTER_SET, + id, +}) diff --git a/src/constants/actionTypes.js b/src/constants/actionTypes.js index 96ed39d896..cc05ef1559 100644 --- a/src/constants/actionTypes.js +++ b/src/constants/actionTypes.js @@ -35,6 +35,7 @@ export const LAYER_TOGGLE_EXPAND = 'LAYER_TOGGLE_EXPAND' export const LAYER_TOGGLE_VISIBILITY = 'LAYER_TOGGLE_VISIBILITY' export const LAYER_UPDATE = 'LAYER_UPDATE' export const LAYER_DRILL = 'LAYER_DRILL' +export const LAYER_FORCE_CLIENT_CLUSTER_SET = 'LAYER_FORCE_CLIENT_CLUSTER_SET' /* DATA TABLE */ export const DATA_TABLE_CLOSE = 'DATA_TABLE_CLOSE' diff --git a/src/reducers/__tests__/map.spec.js b/src/reducers/__tests__/map.spec.js index 8164b8551f..24087e96df 100644 --- a/src/reducers/__tests__/map.spec.js +++ b/src/reducers/__tests__/map.spec.js @@ -456,6 +456,24 @@ describe('map reducer - per-layer delegation', () => { }) }) + describe('LAYER_FORCE_CLIENT_CLUSTER_SET', () => { + it('sets forceClientCluster on the matching layer only', () => { + const other = { id: 'layer2' } + const state = { + ...defaultState, + mapViews: [{ id: 'layer1' }, other], + } + + const result = map(state, { + type: types.LAYER_FORCE_CLIENT_CLUSTER_SET, + id: 'layer1', + }) + + expect(result.mapViews[0].forceClientCluster).toBe(true) + expect(result.mapViews[1]).toBe(other) + }) + }) + describe('LAYER_TOGGLE_EXPAND', () => { it('toggles isExpanded on the matching layer only', () => { const other = { id: 'layer2', isExpanded: true } diff --git a/src/reducers/map.js b/src/reducers/map.js index b1c69de918..a4167a9314 100644 --- a/src/reducers/map.js +++ b/src/reducers/map.js @@ -126,6 +126,16 @@ const layer = (state, action) => { isVisible: !state.isVisible, } + case types.LAYER_FORCE_CLIENT_CLUSTER_SET: + if (state.id !== action.id) { + return state + } + + return { + ...state, + forceClientCluster: true, + } + case types.LAYER_TOGGLE_EXPAND: if (state.id !== action.id) { return state @@ -322,6 +332,7 @@ const map = (state = defaultState, action) => { case types.LAYER_CHANGE_OPACITY: case types.LAYER_TOGGLE_VISIBILITY: case types.LAYER_TOGGLE_EXPAND: + case types.LAYER_FORCE_CLIENT_CLUSTER_SET: case types.DATA_FILTER_SET: case types.DATA_FILTER_CLEAR: case types.DATA_FILTERS_CLEAR_ALL: From 7df1cae92954286c522e8532af6e9cb69ce911e6 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 23 Jul 2026 14:53:50 +0200 Subject: [PATCH 103/205] test: confirm forceClientCluster is never saved to favorites Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --- src/util/__tests__/favorites.spec.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/util/__tests__/favorites.spec.js b/src/util/__tests__/favorites.spec.js index 92d9d97642..e1fdcb9f17 100644 --- a/src/util/__tests__/favorites.spec.js +++ b/src/util/__tests__/favorites.spec.js @@ -501,6 +501,26 @@ describe('cleanMapConfig', () => { ) }) + test('excludes forceClientCluster (session-only, not a valid layer property)', () => { + const cleanedConfig = cleanMapConfig({ + config: { + mapViews: [ + { + layer: 'event', + name: 'Event layer', + opacity: 1, + serverCluster: true, + forceClientCluster: true, + }, + ], + }, + defaultBasemapId: 'thedefaultBasemap', + }) + expect(cleanedConfig.mapViews[0]).not.toHaveProperty( + 'forceClientCluster' + ) + }) + test('writes hidden: true for a layer with isVisible: false', () => { const cleanedConfig = cleanMapConfig({ config: { From 218761fa1867ba0f672eb467d1b8c5a0b8656673 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 23 Jul 2026 14:59:42 +0200 Subject: [PATCH 104/205] feat: reload server-clustered event layers when forceClientCluster is set Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --- src/hooks/__tests__/useLayersLoader.spec.js | 175 ++++++++++++++++++++ src/hooks/useLayersLoader.js | 2 +- 2 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 src/hooks/__tests__/useLayersLoader.spec.js diff --git a/src/hooks/__tests__/useLayersLoader.spec.js b/src/hooks/__tests__/useLayersLoader.spec.js new file mode 100644 index 0000000000..7b70f8c23f --- /dev/null +++ b/src/hooks/__tests__/useLayersLoader.spec.js @@ -0,0 +1,175 @@ +import { renderHook } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import configureMockStore from 'redux-mock-store' +import { EVENT_LAYER } from '../../constants/layers.js' +import eventLoader from '../../loaders/eventLoader.js' +import { useLayersLoader } from '../useLayersLoader.js' + +let mockCachedData + +// useLayersLoader.js pulls in earthEngineLoader.js -> util/earthEngine.js -> +// MapApi.js -> @dhis2/maps-gl, which needs browser APIs jsdom doesn't +// provide. Same workaround as earthEngineLoader.spec.js/trackedEntityLoader.spec.js. +jest.mock('../../components/map/MapApi.js', () => ({ + loadEarthEngineWorker: jest.fn(), +})) + +jest.mock('@dhis2/app-runtime', () => ({ + useDataEngine: () => ({}), + useConfig: () => ({ + baseUrl: 'https://example.org', + serverVersion: '2.42', + }), +})) + +jest.mock('@dhis2/app-service-alerts', () => ({ + useAlert: () => ({ show: jest.fn() }), +})) + +jest.mock('@dhis2/analytics', () => ({ + Analytics: { getAnalytics: jest.fn(() => ({})) }, + useDataOutputPeriodTypes: () => undefined, +})) + +jest.mock('../../components/cachedDataProvider/CachedDataProvider.jsx', () => ({ + useCachedData: () => mockCachedData, +})) + +// Never resolves - the reload-trigger tests only care about the synchronous +// setLayerLoading dispatch, not the loader's eventual result. +jest.mock('../../loaders/eventLoader.js', () => ({ + __esModule: true, + default: jest.fn(() => new Promise(() => {})), +})) + +const mockStore = configureMockStore() + +const renderWithStore = (state) => { + const store = mockStore(state) + const wrapper = ({ children }) => ( + <Provider store={store}>{children}</Provider> + ) + renderHook(() => useLayersLoader(), { wrapper }) + return { store } +} + +const baseLayer = { + id: 'a', + layer: EVENT_LAYER, + isLoaded: true, + isLoading: false, +} + +beforeEach(() => { + eventLoader.mockClear() + mockCachedData = { + systemSettings: { keyAnalysisDigitGroupSeparator: 'NONE' }, + currentUser: { + id: 'user1', + keyAnalysisDisplayProperty: 'name', + userOrgUnitIdsByKeyword: {}, + }, + spatialSupport: true, + } +}) + +describe('useLayersLoader - data table reload trigger', () => { + test('does not reload a server-clustered layer when the table opens and forceClientCluster is not set', () => { + const { store } = renderWithStore({ + map: { + mapViews: [ + { ...baseLayer, serverCluster: true, isExtended: false }, + ], + }, + dataTable: 'a', + }) + + expect(store.getActions()).toEqual([]) + }) + + test('reloads a server-clustered layer once forceClientCluster is set', () => { + const { store } = renderWithStore({ + map: { + mapViews: [ + { + ...baseLayer, + serverCluster: true, + isExtended: false, + forceClientCluster: true, + }, + ], + }, + dataTable: 'a', + }) + + expect(store.getActions()).toEqual([ + { type: 'LAYER_LOADING_SET', id: 'a' }, + ]) + }) + + test('does not reload again once isExtended is true, even with forceClientCluster set (no loop)', () => { + const { store } = renderWithStore({ + map: { + mapViews: [ + { + ...baseLayer, + serverCluster: false, + isExtended: true, + forceClientCluster: true, + }, + ], + }, + dataTable: 'a', + }) + + expect(store.getActions()).toEqual([]) + }) + + test('still reloads a non-clustered layer needing extended data (existing behavior preserved)', () => { + const { store } = renderWithStore({ + map: { + mapViews: [ + { ...baseLayer, serverCluster: false, isExtended: false }, + ], + }, + dataTable: 'a', + }) + + expect(store.getActions()).toEqual([ + { type: 'LAYER_LOADING_SET', id: 'a' }, + ]) + }) +}) + +describe('useLayersLoader - spatialSupport plumbing', () => { + test('passes spatialSupport from useCachedData into the event loader call', () => { + mockCachedData.spatialSupport = true + + renderWithStore({ + map: { + mapViews: [{ ...baseLayer, isLoaded: false }], + }, + dataTable: null, + }) + + expect(eventLoader).toHaveBeenCalledWith( + expect.objectContaining({ spatialSupport: true }) + ) + }) + + test('passes spatialSupport: false through unchanged', () => { + mockCachedData.spatialSupport = false + + renderWithStore({ + map: { + mapViews: [{ ...baseLayer, isLoaded: false }], + }, + dataTable: null, + }) + + expect(eventLoader).toHaveBeenCalledWith( + expect.objectContaining({ spatialSupport: false }) + ) + }) +}) diff --git a/src/hooks/useLayersLoader.js b/src/hooks/useLayersLoader.js index 549c345d56..66b1083b56 100644 --- a/src/hooks/useLayersLoader.js +++ b/src/hooks/useLayersLoader.js @@ -89,7 +89,7 @@ export const useLayersLoader = () => { layer.layer === EVENT_LAYER && layer.id === dataTable && !layer.isExtended && - !layer.serverCluster + (!layer.serverCluster || layer.forceClientCluster) ) { return true } From 95e0f8ceeeb0e4a5d1fa29e70ec11f807c5bf567 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 23 Jul 2026 15:02:40 +0200 Subject: [PATCH 105/205] feat: bypass server-cluster decision when forceClientCluster is set Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --- src/loaders/eventLoader.js | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/loaders/eventLoader.js b/src/loaders/eventLoader.js index c980a0b037..7dde003bfc 100644 --- a/src/loaders/eventLoader.js +++ b/src/loaders/eventLoader.js @@ -285,13 +285,15 @@ const loadEventLayer = async ({ if (eventClustering && !styleDataItem) { const response = await analyticsEngine.events.getCount(analyticsRequest) config.bounds = getBounds(response.extent) - config.serverCluster = shouldUseServerCluster({ - count: response.count, - countFeaturesWithoutCoordinates: - config.countFeaturesWithoutCoordinates, - countEventsOutsideOrgUnits: config.countEventsOutsideOrgUnits, - spatialSupport, - }) + config.serverCluster = config.forceClientCluster + ? false + : shouldUseServerCluster({ + count: response.count, + countFeaturesWithoutCoordinates: + config.countFeaturesWithoutCoordinates, + countEventsOutsideOrgUnits: config.countEventsOutsideOrgUnits, + spatialSupport, + }) serverCount = response.count } From de36edbf8941db46c2efe55138dc5fae5ce2f35a Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 23 Jul 2026 15:03:58 +0200 Subject: [PATCH 106/205] fix: allow opening the data table on a server-clustered event layer Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --- .../layers/toolbar/LayerToolbarMoreMenu.jsx | 8 +++-- .../__tests__/LayerToolbarMoreMenu.spec.jsx | 33 +++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/components/layers/toolbar/LayerToolbarMoreMenu.jsx b/src/components/layers/toolbar/LayerToolbarMoreMenu.jsx index 0e0ad2dd67..4092711c2c 100644 --- a/src/components/layers/toolbar/LayerToolbarMoreMenu.jsx +++ b/src/components/layers/toolbar/LayerToolbarMoreMenu.jsx @@ -15,7 +15,7 @@ import { import PropTypes from 'prop-types' import React, { useState, useRef } from 'react' import { connect } from 'react-redux' -import { EARTH_ENGINE_LAYER } from '../../../constants/layers.js' +import { EARTH_ENGINE_LAYER, EVENT_LAYER } from '../../../constants/layers.js' import { IconButton } from '../../core/index.js' import styles from './styles/LayerToolbarMore.module.css' @@ -170,8 +170,12 @@ export default connect( { layer = DEFAULT_EMPTY_LAYER } ) => { const isEarthEngine = layer.layer === EARTH_ENGINE_LAYER + const isServerClusteredEvent = + layer.layer === EVENT_LAYER && layer.serverCluster const hasOrgUnitData = - layer.data && (!isEarthEngine || layer.aggregationType?.length > 0) + isServerClusteredEvent || + (layer.data && + (!isEarthEngine || layer.aggregationType?.length > 0)) const isLoading = isEarthEngine && hasOrgUnitData && !aggregations[layer.id] diff --git a/src/components/layers/toolbar/__tests__/LayerToolbarMoreMenu.spec.jsx b/src/components/layers/toolbar/__tests__/LayerToolbarMoreMenu.spec.jsx index 314b731eaa..bb53373138 100644 --- a/src/components/layers/toolbar/__tests__/LayerToolbarMoreMenu.spec.jsx +++ b/src/components/layers/toolbar/__tests__/LayerToolbarMoreMenu.spec.jsx @@ -150,6 +150,39 @@ describe('LayerToolbarMoreMenu', () => { }) }) + test('enables Show data table for a server-clustered event layer with no data yet', async () => { + const store = { + aggregations: {}, + } + + const layer = { + id: 'rainbowdash', + layer: 'event', + serverCluster: true, + } + + render( + <Provider store={mockStore(store)}> + <LayerToolbarMoreMenu + layer={layer} + toggleDataTable={jest.fn()} + /> + </Provider> + ) + + fireEvent.click(screen.getByLabelText('Toggle layer menu')) + + await waitFor(() => { + expect(screen.queryByText('Show data table')).toBeTruthy() + expect( + screen + .queryByText('Show data table') + .closest('li') + .classList.contains('disabled') + ).toBe(false) + }) + }) + test('renders three MenuItems WITH divider if passed toggleDataTable, onEdit, and onRemove', async () => { const store = { aggregations: {}, From ef42b656ea89fff1159f13bbba8425a506900dd7 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 23 Jul 2026 15:09:27 +0200 Subject: [PATCH 107/205] fix: stop hard-erroring the data table for server-clustered event layers Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --- .../datatable/__tests__/useTableData.spec.jsx | 55 +++++++++++++++++++ src/components/datatable/useTableData.js | 14 ++--- src/util/__tests__/tableRows.spec.js | 10 +--- src/util/tableRows.js | 3 +- 4 files changed, 63 insertions(+), 19 deletions(-) diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index 1888265add..ee9fd055cc 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -575,6 +575,61 @@ describe('useTableData headers', () => { expect(isLoading).toBe(false) }) + test('is not "extending" a server-clustered event layer that has not been forced to client-cluster', () => { + const store = { aggregations: {} } + const layer = { + layer: 'event', + dataFilters: null, + serverCluster: true, + isExtended: false, + } + + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + <Provider store={mockStore(store)}>{children}</Provider> + ), + } + ) + + expect(result.current.isLoading).toBe(false) + expect(result.current.loadingReason).toBeNull() + }) + + test('shows "Loading additional events…" while forceClientCluster reload is in flight', () => { + const store = { aggregations: {} } + const layer = { + layer: 'event', + dataFilters: null, + serverCluster: true, + forceClientCluster: true, + isExtended: false, + } + + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + <Provider store={mockStore(store)}>{children}</Provider> + ), + } + ) + + expect(result.current.isLoading).toBe(true) + expect(result.current.loadingReason).toBe('Loading additional events…') + }) + test('gets headers and rows for tracked entity layer', () => { const store = { aggregations: {}, diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index 67fc24745c..bccecf6ea1 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -26,21 +26,13 @@ import { ERROR_NON_HOMOGENOUS_FEATURES, getHeadersForLayer, } from '../../util/tableHeaders.js' -import { - ERROR_SERVER_CLUSTER, - ERROR_NO_VALID_DATA, - buildTableData, -} from '../../util/tableRows.js' +import { ERROR_NO_VALID_DATA, buildTableData } from '../../util/tableRows.js' import { compareColumnOptionValues, compareRows } from '../../util/tableSort.js' const ERROR_NO_HEADERS = 'NO_HEADERS' const getErrorCodeText = (code) => { switch (code) { - case ERROR_SERVER_CLUSTER: - return i18n.t( - 'Data table is not supported when events are grouped on the server.' - ) case ERROR_NO_VALID_DATA: return i18n.t( 'No valid data was found for the current layer configuration.' @@ -306,7 +298,9 @@ export const useTableData = ({ aggregationType?.length && (!aggregations || aggregations === EMPTY_AGGREGATIONS) const isExtendingEvents = - layerType === EVENT_LAYER && !layer.isExtended && !serverCluster + layerType === EVENT_LAYER && + !layer.isExtended && + !!(!serverCluster || layer.forceClientCluster) const isLoading = isLoadingAggregations || isExtendingEvents let loadingReason = null if (isLoadingAggregations) { diff --git a/src/util/__tests__/tableRows.spec.js b/src/util/__tests__/tableRows.spec.js index fffcf07bf9..3945e96ab3 100644 --- a/src/util/__tests__/tableRows.spec.js +++ b/src/util/__tests__/tableRows.spec.js @@ -1,9 +1,5 @@ import { GEOJSON_URL_LAYER, THEMATIC_LAYER } from '../../constants/layers.js' -import { - buildTableData, - ERROR_NO_VALID_DATA, - ERROR_SERVER_CLUSTER, -} from '../tableRows.js' +import { buildTableData, ERROR_NO_VALID_DATA } from '../tableRows.js' // Thematic-layer-shaped feature: id is stamped on both the top level (which // is what aggregations are keyed by) and properties (see the deferred @@ -15,9 +11,9 @@ const feature = (id, extraProperties = {}, coordinates = [10, 10]) => ({ }) describe('buildTableData - error paths', () => { - test('server-clustered layers return an error code instead of data', () => { + test('server-clustered layers return empty data instead of an error', () => { expect(buildTableData(THEMATIC_LAYER, { serverCluster: true })).toEqual( - { errorCode: ERROR_SERVER_CLUSTER } + { data: [] } ) }) diff --git a/src/util/tableRows.js b/src/util/tableRows.js index bef21fa009..2c47cd34a5 100644 --- a/src/util/tableRows.js +++ b/src/util/tableRows.js @@ -2,7 +2,6 @@ import { GEOJSON_URL_LAYER } from '../constants/layers.js' import { isFeatureInBounds } from './geojson.js' import { formatRangeWithSeparator } from './numbers.js' -export const ERROR_SERVER_CLUSTER = 'SERVER_CLUSTER' export const ERROR_NO_VALID_DATA = 'NO_VALID_DATA' export const buildTableData = ( @@ -26,7 +25,7 @@ export const buildTableData = ( } ) => { if (serverCluster) { - return { errorCode: ERROR_SERVER_CLUSTER } + return { data: [] } } const allData = dataWithoutCoords?.length From d21b1dd87fdeaa96178414c4a0c35e6c59e21f9e Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 23 Jul 2026 15:10:45 +0200 Subject: [PATCH 108/205] feat: add in-table action to show event details for a clustered layer Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --- .../datatable/TableVirtuosoComponents.jsx | 19 +++++- .../TableVirtuosoComponents.spec.jsx | 61 +++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 src/components/datatable/__tests__/TableVirtuosoComponents.spec.jsx diff --git a/src/components/datatable/TableVirtuosoComponents.jsx b/src/components/datatable/TableVirtuosoComponents.jsx index fd58eae97b..d78ae2b03f 100644 --- a/src/components/datatable/TableVirtuosoComponents.jsx +++ b/src/components/datatable/TableVirtuosoComponents.jsx @@ -55,12 +55,25 @@ DataTableRowWithVirtuosoContext.propTypes = { ), } -const EmptyPlaceholder = ({ context }) => ( +export const EmptyPlaceholder = ({ context }) => ( <tbody> <tr> <td colSpan={99999}> <div className={styles.noResults}> - {context.totalCount > 0 ? ( + {context.showServerClusterAction ? ( + <> + {i18n.t( + "Event details aren't available while this layer is clustered on the server" + )} + <button + type="button" + className={styles.clearFiltersLink} + onClick={context.onForceClientCluster} + > + {i18n.t('Show event details')} + </button> + </> + ) : context.totalCount > 0 ? ( <> {i18n.t('No features match your filters')} {context.hasActiveFilters && ( @@ -85,8 +98,10 @@ const EmptyPlaceholder = ({ context }) => ( EmptyPlaceholder.propTypes = { context: PropTypes.shape({ hasActiveFilters: PropTypes.bool, + showServerClusterAction: PropTypes.bool, totalCount: PropTypes.number, onClearFilters: PropTypes.func, + onForceClientCluster: PropTypes.func, }), } diff --git a/src/components/datatable/__tests__/TableVirtuosoComponents.spec.jsx b/src/components/datatable/__tests__/TableVirtuosoComponents.spec.jsx new file mode 100644 index 0000000000..0134dc4510 --- /dev/null +++ b/src/components/datatable/__tests__/TableVirtuosoComponents.spec.jsx @@ -0,0 +1,61 @@ +import { render, fireEvent, screen } from '@testing-library/react' +import React from 'react' +import { EmptyPlaceholder } from '../TableVirtuosoComponents.jsx' + +const renderPlaceholder = (context) => + render( + <table> + <EmptyPlaceholder context={context} /> + </table> + ) + +describe('EmptyPlaceholder', () => { + test('shows the server-cluster action when showServerClusterAction is true', () => { + const onForceClientCluster = jest.fn() + renderPlaceholder({ + showServerClusterAction: true, + onForceClientCluster, + }) + + expect( + screen.getByText( + "Event details aren't available while this layer is clustered on the server" + ) + ).toBeTruthy() + + fireEvent.click(screen.getByText('Show event details')) + expect(onForceClientCluster).toHaveBeenCalledTimes(1) + + expect(screen.queryByText('No features match your filters')).toBeNull() + expect(screen.queryByText('No results found')).toBeNull() + }) + + test('shows the clear-filters action when filters produced zero rows', () => { + const onClearFilters = jest.fn() + renderPlaceholder({ + showServerClusterAction: false, + totalCount: 10, + hasActiveFilters: true, + onClearFilters, + }) + + expect(screen.getByText('No features match your filters')).toBeTruthy() + + fireEvent.click(screen.getByText('Clear filters')) + expect(onClearFilters).toHaveBeenCalledTimes(1) + + expect(screen.queryByText('Show event details')).toBeNull() + }) + + test('shows plain "No results found" when there is no data at all', () => { + renderPlaceholder({ + showServerClusterAction: false, + totalCount: 0, + hasActiveFilters: false, + }) + + expect(screen.getByText('No results found')).toBeTruthy() + expect(screen.queryByText('Show event details')).toBeNull() + expect(screen.queryByText('No features match your filters')).toBeNull() + }) +}) From 2369c2d7621c083c930eb7684ab1cb0540fe192a Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 23 Jul 2026 15:12:15 +0200 Subject: [PATCH 109/205] feat: wire the show-event-details action into the data table Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --- src/components/datatable/DataTable.jsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index ad3f1ab295..11880e8b57 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -22,6 +22,7 @@ import { useSelector, useDispatch } from 'react-redux' import { TableVirtuoso } from 'react-virtuoso' import { setSelectionFilter } from '../../actions/dataTable.js' import { highlightFeature } from '../../actions/feature.js' +import { setForceClientCluster } from '../../actions/layers.js' import { toggleFeatureSelection, selectFeatureRange, @@ -289,6 +290,14 @@ const Table = ({ showOnlyFeaturesInView, }) + const showServerClusterAction = + layer.serverCluster && !layer.forceClientCluster + + const onForceClientCluster = useCallback( + () => dispatch(setForceClientCluster(layer.id)), + [dispatch, layer.id] + ) + const tableContext = useMemo( () => ({ onMouseEnter: setFeatureHighlight, @@ -300,6 +309,8 @@ const Table = ({ totalCount, hasActiveFilters, onClearFilters, + showServerClusterAction, + onForceClientCluster, }), [ setFeatureHighlight, @@ -311,6 +322,8 @@ const Table = ({ totalCount, hasActiveFilters, onClearFilters, + showServerClusterAction, + onForceClientCluster, ] ) From facde243c1dba2c1799bcbc007482d56208f5403 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 23 Jul 2026 15:41:02 +0200 Subject: [PATCH 110/205] fix: don't mark a server-clustered event layer's table data as ready isExtended was set unconditionally before the server-cluster decision, so a layer that became server-clustered while its data table was open got isExtended:true stamped even though no client dataset was ever loaded. That silently blocked the reload useLayersLoader triggers when forceClientCluster is set, so clicking "Show event details" appeared to do nothing instead of loading the table. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --- src/loaders/__tests__/eventLoader.spec.js | 104 +++++++++++++++++++++- src/loaders/eventLoader.js | 22 +++-- 2 files changed, 118 insertions(+), 8 deletions(-) diff --git a/src/loaders/__tests__/eventLoader.spec.js b/src/loaders/__tests__/eventLoader.spec.js index cb5337ac61..0396cb5c9c 100644 --- a/src/loaders/__tests__/eventLoader.spec.js +++ b/src/loaders/__tests__/eventLoader.spec.js @@ -10,7 +10,7 @@ import { GEOFEATURES_QUERY, ORG_UNITS_PATHS_QUERY, } from '../../util/requests.js' -import { +import eventLoader, { excludeEventsOutsideOrgUnits, shouldUseServerCluster, } from '../eventLoader.js' @@ -803,3 +803,105 @@ describe('shouldUseServerCluster', () => { ).toBe(false) }) }) + +// A minimal chainable stand-in for the real analytics request builder - +// every method just returns `this` so the request-building chain in +// util/event.js's getAnalyticsRequest completes without error. +class FakeAnalyticsRequest { + withProgram() { + return this + } + withStage() { + return this + } + withCoordinatesOnly() { + return this + } + withStartDate() { + return this + } + withEndDate() { + return this + } + addPeriodFilter() { + return this + } + withRelativePeriodDate() { + return this + } + addOrgUnitDimension() { + return this + } + addDimension() { + return this + } + withCoordinateField() { + return this + } + withEventStatus() { + return this + } + withPageSize() { + return this + } +} + +describe('eventLoader - isExtended vs serverCluster', () => { + const overThreshold = EVENT_SERVER_CLUSTER_COUNT + 1 + + const baseConfig = () => ({ + program: { id: 'prog1' }, + programStage: { id: 'stage1', name: 'Stage 1' }, + columns: [], + filters: [], + rows: [], + eventClustering: true, + startDate: '2024-01-01', + endDate: '2024-01-31', + }) + + const makeArgs = (config) => ({ + config, + engine: { + query: jest.fn().mockResolvedValue({ + programStage: { programStageDataElements: [] }, + }), + }, + keyAnalysisDisplayProperty: 'name', + keyAnalysisDigitGroupSeparator: 'NONE', + analyticsEngine: { + request: FakeAnalyticsRequest, + events: { + getCount: jest + .fn() + .mockResolvedValue({ count: overThreshold, extent: null }), + getQuery: jest.fn().mockResolvedValue({ + headers: [], + metaData: { items: {}, pager: { total: 0 } }, + rows: [], + }), + }, + }, + periodTypeData: undefined, + loadExtended: true, + spatialSupport: true, + }) + + test('does not claim the table has extended data when the layer ends up server-clustered', async () => { + const result = await eventLoader(makeArgs(baseConfig())) + + expect(result.serverCluster).toBe(true) + expect(result.isExtended).toBe(false) + expect(result.data).toBeUndefined() + }) + + test('forceClientCluster loads the extended dataset instead of staying server-clustered', async () => { + const result = await eventLoader( + makeArgs({ ...baseConfig(), forceClientCluster: true }) + ) + + expect(result.serverCluster).toBe(false) + expect(result.isExtended).toBe(true) + expect(result.data).toEqual([]) + }) +}) diff --git a/src/loaders/eventLoader.js b/src/loaders/eventLoader.js index 7dde003bfc..2192a091da 100644 --- a/src/loaders/eventLoader.js +++ b/src/loaders/eventLoader.js @@ -244,13 +244,17 @@ const loadEventLayer = async ({ const dataFilters = getFiltersFromColumns(columns) - config.isExtended = loadExtended - - const analyticsRequest = await getAnalyticsRequest(config, { - analyticsEngine, - nameProperty: displayNameProp, - engine, - }) + // Request setup only - config.isExtended (the UI-facing "table has its + // extended dataset" flag) is set further down, once we know whether + // server clustering will actually skip loading that dataset. + const analyticsRequest = await getAnalyticsRequest( + { ...config, isExtended: loadExtended }, + { + analyticsEngine, + nameProperty: displayNameProp, + engine, + } + ) const alerts = [] // Legend skeleton @@ -297,6 +301,10 @@ const loadEventLayer = async ({ serverCount = response.count } + // The extended (data table) dataset is only actually loaded below when + // server clustering isn't in effect - don't claim it's ready otherwise. + config.isExtended = loadExtended && !config.serverCluster + // Load event data // ----- From e5dbe517911cee4f84e5904e7d5bb4e201d33269 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 23 Jul 2026 15:44:37 +0200 Subject: [PATCH 111/205] fix: show the loaded (capped) event count in the legend, not the raw total, once rendering client-side Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --- src/loaders/__tests__/eventLoader.spec.js | 5 +++++ src/loaders/eventLoader.js | 13 ++++++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/loaders/__tests__/eventLoader.spec.js b/src/loaders/__tests__/eventLoader.spec.js index 0396cb5c9c..e99690f9ab 100644 --- a/src/loaders/__tests__/eventLoader.spec.js +++ b/src/loaders/__tests__/eventLoader.spec.js @@ -893,6 +893,8 @@ describe('eventLoader - isExtended vs serverCluster', () => { expect(result.serverCluster).toBe(true) expect(result.isExtended).toBe(false) expect(result.data).toBeUndefined() + // Server clustering isn't capped - the legend shows the true total. + expect(result.legend.items[0].count).toBe(overThreshold) }) test('forceClientCluster loads the extended dataset instead of staying server-clustered', async () => { @@ -903,5 +905,8 @@ describe('eventLoader - isExtended vs serverCluster', () => { expect(result.serverCluster).toBe(false) expect(result.isExtended).toBe(true) expect(result.data).toEqual([]) + // Once rendering client-side, the legend must reflect what was + // actually loaded (capped), not the raw analytics total. + expect(result.legend.items[0].count).toBe(0) }) }) diff --git a/src/loaders/eventLoader.js b/src/loaders/eventLoader.js index 2192a091da..6fb1ab7b04 100644 --- a/src/loaders/eventLoader.js +++ b/src/loaders/eventLoader.js @@ -448,9 +448,16 @@ const loadEventLayer = async ({ color, strokeColor, radius: eventPointRadius || EVENT_RADIUS, - count: - serverCount || - (Array.isArray(config?.data) ? config.data.length : 0), + // Server clustering isn't capped, so the true total + // (serverCount) is accurate. Once rendering client-side + // (whether never server-clustered, or forced via + // forceClientCluster), only the loaded/capped data reflects + // what's actually shown. + count: config.serverCluster + ? serverCount + : Array.isArray(config?.data) + ? config.data.length + : 0, }, ] } From 2a1f552a0556e5200074ea794458a6687897c226 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 23 Jul 2026 16:03:47 +0200 Subject: [PATCH 112/205] chore: PR clean-up --- i18n/en.pot | 13 ++++++++----- src/actions/layers.js | 3 +-- src/hooks/__tests__/useLayersLoader.spec.js | 3 --- src/loaders/__tests__/eventLoader.spec.js | 4 +--- src/loaders/eventLoader.js | 11 ++--------- src/util/__tests__/tableRows.spec.js | 3 --- 6 files changed, 12 insertions(+), 25 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 3c572ce53b..d3b055e247 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-23T11:59:37.478Z\n" -"PO-Revision-Date: 2026-07-23T11:59:37.479Z\n" +"POT-Creation-Date: 2026-07-23T13:14:41.110Z\n" +"PO-Revision-Date: 2026-07-23T13:14:41.111Z\n" msgid "2020" msgstr "2020" @@ -266,6 +266,12 @@ msgstr "Zoom to selected features" msgid "Zoom to filtered features" msgstr "Zoom to filtered features" +msgid "Event details aren't available while this layer is clustered on the server" +msgstr "Event details aren't available while this layer is clustered on the server" + +msgid "Show event details" +msgstr "Show event details" + msgid "No features match your filters" msgstr "No features match your filters" @@ -320,9 +326,6 @@ msgstr "{{total}} rows" msgid "Show only features in current map view" msgstr "Show only features in current map view" -msgid "Data table is not supported when events are grouped on the server." -msgstr "Data table is not supported when events are grouped on the server." - msgid "No valid data was found for the current layer configuration." msgstr "No valid data was found for the current layer configuration." diff --git a/src/actions/layers.js b/src/actions/layers.js index ad555375cb..4583af12f7 100644 --- a/src/actions/layers.js +++ b/src/actions/layers.js @@ -67,8 +67,7 @@ export const setLayerLoading = (id) => ({ id, }) -// Force client-side clustering for a server-clustered event layer -// (session-only; intentionally excluded from validLayerProperties in favorites.js) +// Force client-side clustering for a server-clustered event layer (session-only) export const setForceClientCluster = (id) => ({ type: types.LAYER_FORCE_CLIENT_CLUSTER_SET, id, diff --git a/src/hooks/__tests__/useLayersLoader.spec.js b/src/hooks/__tests__/useLayersLoader.spec.js index 7b70f8c23f..19fec7e93b 100644 --- a/src/hooks/__tests__/useLayersLoader.spec.js +++ b/src/hooks/__tests__/useLayersLoader.spec.js @@ -8,9 +8,6 @@ import { useLayersLoader } from '../useLayersLoader.js' let mockCachedData -// useLayersLoader.js pulls in earthEngineLoader.js -> util/earthEngine.js -> -// MapApi.js -> @dhis2/maps-gl, which needs browser APIs jsdom doesn't -// provide. Same workaround as earthEngineLoader.spec.js/trackedEntityLoader.spec.js. jest.mock('../../components/map/MapApi.js', () => ({ loadEarthEngineWorker: jest.fn(), })) diff --git a/src/loaders/__tests__/eventLoader.spec.js b/src/loaders/__tests__/eventLoader.spec.js index e99690f9ab..7a58552fea 100644 --- a/src/loaders/__tests__/eventLoader.spec.js +++ b/src/loaders/__tests__/eventLoader.spec.js @@ -804,9 +804,7 @@ describe('shouldUseServerCluster', () => { }) }) -// A minimal chainable stand-in for the real analytics request builder - -// every method just returns `this` so the request-building chain in -// util/event.js's getAnalyticsRequest completes without error. +// A minimal chainable stand-in for the real analytics request builder class FakeAnalyticsRequest { withProgram() { return this diff --git a/src/loaders/eventLoader.js b/src/loaders/eventLoader.js index 6fb1ab7b04..fbcc194efe 100644 --- a/src/loaders/eventLoader.js +++ b/src/loaders/eventLoader.js @@ -244,9 +244,7 @@ const loadEventLayer = async ({ const dataFilters = getFiltersFromColumns(columns) - // Request setup only - config.isExtended (the UI-facing "table has its - // extended dataset" flag) is set further down, once we know whether - // server clustering will actually skip loading that dataset. + // Request setup only - config.isExtended is set further dow const analyticsRequest = await getAnalyticsRequest( { ...config, isExtended: loadExtended }, { @@ -302,7 +300,7 @@ const loadEventLayer = async ({ } // The extended (data table) dataset is only actually loaded below when - // server clustering isn't in effect - don't claim it's ready otherwise. + // server clustering isn't in effect - don't claim it's ready otherwise config.isExtended = loadExtended && !config.serverCluster // Load event data @@ -448,11 +446,6 @@ const loadEventLayer = async ({ color, strokeColor, radius: eventPointRadius || EVENT_RADIUS, - // Server clustering isn't capped, so the true total - // (serverCount) is accurate. Once rendering client-side - // (whether never server-clustered, or forced via - // forceClientCluster), only the loaded/capped data reflects - // what's actually shown. count: config.serverCluster ? serverCount : Array.isArray(config?.data) diff --git a/src/util/__tests__/tableRows.spec.js b/src/util/__tests__/tableRows.spec.js index 3945e96ab3..5fdbd97232 100644 --- a/src/util/__tests__/tableRows.spec.js +++ b/src/util/__tests__/tableRows.spec.js @@ -1,9 +1,6 @@ import { GEOJSON_URL_LAYER, THEMATIC_LAYER } from '../../constants/layers.js' import { buildTableData, ERROR_NO_VALID_DATA } from '../tableRows.js' -// Thematic-layer-shaped feature: id is stamped on both the top level (which -// is what aggregations are keyed by) and properties (see the deferred -// id-placement inconsistency called out for this codebase's loaders). const feature = (id, extraProperties = {}, coordinates = [10, 10]) => ({ id, geometry: { type: 'Point', coordinates }, From 8634fb2fdc0af9fbf4420b60f920230dbff60ab2 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 23 Jul 2026 16:09:26 +0200 Subject: [PATCH 113/205] refactor: extract nested ternaries flagged by SonarQube Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --- .../datatable/TableVirtuosoComponents.jsx | 68 +++++++++++-------- src/loaders/eventLoader.js | 13 ++-- 2 files changed, 47 insertions(+), 34 deletions(-) diff --git a/src/components/datatable/TableVirtuosoComponents.jsx b/src/components/datatable/TableVirtuosoComponents.jsx index d78ae2b03f..0acdaa1e88 100644 --- a/src/components/datatable/TableVirtuosoComponents.jsx +++ b/src/components/datatable/TableVirtuosoComponents.jsx @@ -55,40 +55,50 @@ DataTableRowWithVirtuosoContext.propTypes = { ), } +const getEmptyPlaceholderContent = (context) => { + if (context.showServerClusterAction) { + return ( + <> + {i18n.t( + "Event details aren't available while this layer is clustered on the server" + )} + <button + type="button" + className={styles.clearFiltersLink} + onClick={context.onForceClientCluster} + > + {i18n.t('Show event details')} + </button> + </> + ) + } + + if (context.totalCount > 0) { + return ( + <> + {i18n.t('No features match your filters')} + {context.hasActiveFilters && ( + <button + type="button" + className={styles.clearFiltersLink} + onClick={context.onClearFilters} + > + {i18n.t('Clear filters')} + </button> + )} + </> + ) + } + + return i18n.t('No results found') +} + export const EmptyPlaceholder = ({ context }) => ( <tbody> <tr> <td colSpan={99999}> <div className={styles.noResults}> - {context.showServerClusterAction ? ( - <> - {i18n.t( - "Event details aren't available while this layer is clustered on the server" - )} - <button - type="button" - className={styles.clearFiltersLink} - onClick={context.onForceClientCluster} - > - {i18n.t('Show event details')} - </button> - </> - ) : context.totalCount > 0 ? ( - <> - {i18n.t('No features match your filters')} - {context.hasActiveFilters && ( - <button - type="button" - className={styles.clearFiltersLink} - onClick={context.onClearFilters} - > - {i18n.t('Clear filters')} - </button> - )} - </> - ) : ( - i18n.t('No results found') - )} + {getEmptyPlaceholderContent(context)} </div> </td> </tr> diff --git a/src/loaders/eventLoader.js b/src/loaders/eventLoader.js index fbcc194efe..9cbeec0b40 100644 --- a/src/loaders/eventLoader.js +++ b/src/loaders/eventLoader.js @@ -440,17 +440,20 @@ const loadEventLayer = async ({ const color = cssColor(eventPointColor) || EVENT_COLOR const strokeColor = getContrastColor(color) + let count = 0 + if (config.serverCluster) { + count = serverCount + } else if (Array.isArray(config?.data)) { + count = config.data.length + } + config.legend.items = [ { name: i18n.t('Event'), color, strokeColor, radius: eventPointRadius || EVENT_RADIUS, - count: config.serverCluster - ? serverCount - : Array.isArray(config?.data) - ? config.data.length - : 0, + count, }, ] } From 84c1c9b26dc6947d40e570d34621fb77e95978c9 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 23 Jul 2026 17:43:40 +0200 Subject: [PATCH 114/205] fix: PR clean-up --- src/components/datatable/BottomPanel.jsx | 11 ++- src/components/datatable/DataTable.jsx | 7 ++ .../datatable/__tests__/BottomPanel.spec.jsx | 81 +++++++++++++++++++ .../datatable/__tests__/useTableData.spec.jsx | 2 +- .../__tests__/LayerToolbarMoreMenu.spec.jsx | 34 ++++++++ src/constants/dataTable.js | 1 + src/loaders/eventLoader.js | 2 +- src/util/__tests__/tableRows.spec.js | 50 +++++++++++- src/util/tableHeaders.js | 3 +- src/util/tableRows.js | 8 +- 10 files changed, 190 insertions(+), 9 deletions(-) create mode 100644 src/components/datatable/__tests__/BottomPanel.spec.jsx diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index 2161cb7e8b..9f0b78ab93 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -55,6 +55,7 @@ const BottomPanel = () => { const { height } = useWindowDimensions() const panelRef = useRef(null) const isDraggingRef = useRef(false) + const preDragCollapsedRef = useRef(false) const [panelWidth, setPanelWidth] = useState(0) const [totalCount, setTotalCount] = useState(null) const [filteredCount, setFilteredCount] = useState(null) @@ -95,7 +96,8 @@ const BottomPanel = () => { const onResizeStart = useCallback(() => { isDraggingRef.current = true - }, []) + preDragCollapsedRef.current = isCollapsed + }, [isCollapsed]) const onResize = useCallback( (h) => { @@ -123,7 +125,12 @@ const BottomPanel = () => { const onResizeCancel = useCallback(() => { isDraggingRef.current = false - }, []) + setIsCollapsed(preDragCollapsedRef.current) + document.documentElement.style.setProperty( + '--data-table-height', + `${displayHeight}px` + ) + }, [displayHeight]) const onCountChange = useCallback((total, filtered) => { setTotalCount(total) diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index 11880e8b57..4c71beca75 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -32,6 +32,7 @@ import { SORT_ASCENDING, RENDERER_COLOR, RENDERER_ICON, + RENDERER_DATE, } from '../../constants/dataTable.js' import { isDarkColor } from '../../util/colors.js' import { @@ -43,6 +44,7 @@ import { isFilterable, shouldClearFeatureHighlight, } from '../../util/dataTable.js' +import { formatDatetime } from '../../util/helpers.js' import { formatWithSeparator } from '../../util/numbers.js' import { getPinnedCellProps, @@ -603,6 +605,7 @@ const Table = ({ const renderer = rendererByDataKey.get(dataKey) const isColorCell = renderer === RENDERER_COLOR const isIconCell = renderer === RENDERER_ICON + const isDateCell = renderer === RENDERER_DATE return ( <DataTableCell key={`dtcell-${dataKey}`} @@ -640,8 +643,12 @@ const Table = ({ }} /> )} + {isDateCell && + value && + formatDatetime(value)} {!isColorCell && !isIconCell && + !isDateCell && formatWithSeparator( value, keyAnalysisDigitGroupSeparator diff --git a/src/components/datatable/__tests__/BottomPanel.spec.jsx b/src/components/datatable/__tests__/BottomPanel.spec.jsx new file mode 100644 index 0000000000..314397bfae --- /dev/null +++ b/src/components/datatable/__tests__/BottomPanel.spec.jsx @@ -0,0 +1,81 @@ +import { render, fireEvent } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import configureMockStore from 'redux-mock-store' +import WindowDimensionsProvider from '../../WindowDimensionsProvider.jsx' +import BottomPanel from '../BottomPanel.jsx' + +jest.mock('../DataTable.jsx', () => { + const DataTableMock = () => <div data-testid="datatable-mock" /> + DataTableMock.displayName = 'DataTableMock' + return DataTableMock +}) + +const mockStore = configureMockStore() + +// jsdom doesn't implement pointer capture or ResizeObserver +beforeAll(() => { + Element.prototype.setPointerCapture = jest.fn() + Element.prototype.releasePointerCapture = jest.fn() + global.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } +}) + +const DATA_TABLE_HEIGHT = 300 + +const renderBottomPanel = () => { + const store = mockStore({ + ui: { + dataTableHeight: DATA_TABLE_HEIGHT, + showOnlyFeaturesInView: false, + selectionFilter: [], + highlightColor: null, + }, + dataTable: 'layer1', + map: { mapViews: [{ id: 'layer1', name: 'Layer 1' }] }, + }) + const { container } = render( + <Provider store={store}> + <WindowDimensionsProvider> + <BottomPanel /> + </WindowDimensionsProvider> + </Provider> + ) + return { handle: container.querySelector('.resizeHandle') } +} + +const getDisplayHeight = () => + document.documentElement.style.getPropertyValue('--data-table-height') + +describe('BottomPanel resize cancel', () => { + test('cancelling a drag that never collapsed the panel reverts the transient height', () => { + const { handle } = renderBottomPanel() + expect(getDisplayHeight()).toBe(`${DATA_TABLE_HEIGHT}px`) + + fireEvent.pointerDown(handle, { pointerId: 1, clientY: 500 }) + fireEvent.pointerMove(handle, { pointerId: 1, clientY: 600 }) + expect(getDisplayHeight()).not.toBe(`${DATA_TABLE_HEIGHT}px`) + + fireEvent.pointerCancel(handle, { pointerId: 1, clientY: 600 }) + expect(getDisplayHeight()).toBe(`${DATA_TABLE_HEIGHT}px`) + }) + + test('cancelling a drag that collapsed the panel restores the pre-drag expanded height', () => { + const { handle } = renderBottomPanel() + expect(getDisplayHeight()).toBe(`${DATA_TABLE_HEIGHT}px`) + + fireEvent.pointerDown(handle, { pointerId: 1, clientY: 500 }) + // Drag far enough down to cross the collapse threshold (MIN_HEIGHT) + fireEvent.pointerMove(handle, { + pointerId: 1, + clientY: window.innerHeight, + }) + expect(getDisplayHeight()).not.toBe(`${DATA_TABLE_HEIGHT}px`) + + fireEvent.pointerCancel(handle, { pointerId: 1, clientY: 0 }) + expect(getDisplayHeight()).toBe(`${DATA_TABLE_HEIGHT}px`) + }) +}) diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index ee9fd055cc..bcfb69f92e 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -554,7 +554,7 @@ describe('useTableData headers', () => { name: 'Event time', dataKey: 'eventdate', type: 'date', - renderer: 'formatTime...', + renderer: 'renderdate', }, { name: 'Last updated on', dataKey: 'lastupdated', type: 'string' }, { name: 'Event status', dataKey: 'eventstatus', type: 'string' }, diff --git a/src/components/layers/toolbar/__tests__/LayerToolbarMoreMenu.spec.jsx b/src/components/layers/toolbar/__tests__/LayerToolbarMoreMenu.spec.jsx index bb53373138..f524fa7bce 100644 --- a/src/components/layers/toolbar/__tests__/LayerToolbarMoreMenu.spec.jsx +++ b/src/components/layers/toolbar/__tests__/LayerToolbarMoreMenu.spec.jsx @@ -183,6 +183,40 @@ describe('LayerToolbarMoreMenu', () => { }) }) + test('also enables Download data for a server-clustered event layer with no data yet', async () => { + const store = { + aggregations: {}, + } + + const layer = { + id: 'rainbowdash', + layer: 'event', + serverCluster: true, + } + + render( + <Provider store={mockStore(store)}> + <LayerToolbarMoreMenu + layer={layer} + toggleDataTable={jest.fn()} + downloadData={jest.fn()} + /> + </Provider> + ) + + fireEvent.click(screen.getByLabelText('Toggle layer menu')) + + await waitFor(() => { + expect(screen.queryByText('Download data')).toBeTruthy() + expect( + screen + .queryByText('Download data') + .closest('li') + .classList.contains('disabled') + ).toBe(false) + }) + }) + test('renders three MenuItems WITH divider if passed toggleDataTable, onEdit, and onRemove', async () => { const store = { aggregations: {}, diff --git a/src/constants/dataTable.js b/src/constants/dataTable.js index 94d4a9cb05..a89d40516f 100644 --- a/src/constants/dataTable.js +++ b/src/constants/dataTable.js @@ -7,6 +7,7 @@ export const SORT_DESCENDING = 'desc' export const RENDERER_COLOR = 'rendercolor' export const RENDERER_ICON = 'rendericon' +export const RENDERER_DATE = 'renderdate' export const TYPE_NUMBER = 'number' export const TYPE_STRING = 'string' diff --git a/src/loaders/eventLoader.js b/src/loaders/eventLoader.js index 9cbeec0b40..275ded1dbf 100644 --- a/src/loaders/eventLoader.js +++ b/src/loaders/eventLoader.js @@ -244,7 +244,7 @@ const loadEventLayer = async ({ const dataFilters = getFiltersFromColumns(columns) - // Request setup only - config.isExtended is set further dow + // Request setup only - config.isExtended is set further down const analyticsRequest = await getAnalyticsRequest( { ...config, isExtended: loadExtended }, { diff --git a/src/util/__tests__/tableRows.spec.js b/src/util/__tests__/tableRows.spec.js index 5fdbd97232..c66d39a5dd 100644 --- a/src/util/__tests__/tableRows.spec.js +++ b/src/util/__tests__/tableRows.spec.js @@ -1,4 +1,8 @@ -import { GEOJSON_URL_LAYER, THEMATIC_LAYER } from '../../constants/layers.js' +import { + GEOJSON_URL_LAYER, + THEMATIC_LAYER, + TRACKED_ENTITY_LAYER, +} from '../../constants/layers.js' import { buildTableData, ERROR_NO_VALID_DATA } from '../tableRows.js' const feature = (id, extraProperties = {}, coordinates = [10, 10]) => ({ @@ -32,10 +36,16 @@ describe('buildTableData - geoJsonUrl layer', () => { ] const result = buildTableData(GEOJSON_URL_LAYER, { data }) expect(result.data).toEqual([ - { id: 'a', name: 'A', hasAdditionalGeometry: true }, - { id: 'b', name: 'B' }, + { id: 'a', name: 'A', hasAdditionalGeometry: true, index: 0 }, + { id: 'b', name: 'B', index: 1 }, ]) }) + + test('stamps a row-order index so clearing a sort restores natural order', () => { + const data = [feature('a'), feature('b'), feature('c')] + const result = buildTableData(GEOJSON_URL_LAYER, { data }) + expect(result.data.map((r) => r.index)).toEqual([0, 1, 2]) + }) }) describe('buildTableData - showOnlyFeaturesInView', () => { @@ -83,6 +93,40 @@ describe('buildTableData - generic layer', () => { }) }) +describe('buildTableData - tracked entity layer', () => { + test('merges data and dataWithoutCoords, drops features with hasAdditionalGeometry, merges aggregations and stamps a row-order index', () => { + const data = [feature('a', { w75KJ2mc4zz: 'Gabrielle' })] + const dataWithoutCoords = [ + feature('b', { + w75KJ2mc4zz: 'Hidden', + hasAdditionalGeometry: true, + }), + feature('c', { w75KJ2mc4zz: 'Charlie' }), + ] + const result = buildTableData(TRACKED_ENTITY_LAYER, { + data, + dataWithoutCoords, + aggregations: { a: { count: 5 } }, + }) + expect(result.data).toEqual([ + { id: 'a', w75KJ2mc4zz: 'Gabrielle', count: 5, index: 0 }, + { id: 'c', w75KJ2mc4zz: 'Charlie', index: 1 }, + ]) + }) + + test('filters out-of-view features when showOnlyFeaturesInView is on', () => { + const inBounds = feature('in', {}, [10, 10]) + const outOfBounds = feature('out', {}, [100, 100]) + const result = buildTableData(TRACKED_ENTITY_LAYER, { + data: [inBounds, outOfBounds], + showOnlyFeaturesInView: true, + mapBounds: [0, 0, 20, 20], + aggregations: {}, + }) + expect(result.data.map((r) => r.id)).toEqual(['in']) + }) +}) + describe('buildTableData - styled event layer', () => { test('derives legend name and a formatted range from the matching legend item', () => { const data = [feature('a', { colorGroup: 0 })] diff --git a/src/util/tableHeaders.js b/src/util/tableHeaders.js index 088c87fa0c..3c42d4ac88 100644 --- a/src/util/tableHeaders.js +++ b/src/util/tableHeaders.js @@ -2,6 +2,7 @@ import i18n from '@dhis2/d2-i18n' import { RENDERER_COLOR, RENDERER_ICON, + RENDERER_DATE, TYPE_NUMBER, TYPE_STRING, TYPE_DATE, @@ -63,7 +64,7 @@ const defaultFieldsMap = () => ({ name: i18n.t('Event time'), dataKey: EVENTDATE, type: TYPE_DATE, - renderer: 'formatTime...', + renderer: RENDERER_DATE, }, [COLOR]: { name: i18n.t('Color'), diff --git a/src/util/tableRows.js b/src/util/tableRows.js index 2c47cd34a5..60719159d3 100644 --- a/src/util/tableRows.js +++ b/src/util/tableRows.js @@ -41,7 +41,13 @@ export const buildTableData = ( : allData if (layerType === GEOJSON_URL_LAYER) { - return { data: inViewData.map((d) => ({ ...d.properties })) } + return { + data: inViewData.map((d, index) => ({ + ...d.properties, + // Row-order tie-breaker for compareRows when no sortField is set + index, + })), + } } const rows = inViewData From 1f3ea3acdc7e4ec84be31a2881f1fff5042bf27d Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Fri, 24 Jul 2026 12:12:27 +0200 Subject: [PATCH 115/205] fix: PR clean-up --- cypress/integration/layers/eventlayer.cy.js | 2 +- i18n/en.pot | 56 +++++---- src/components/core/Checkbox.jsx | 5 +- .../core/styles/Checkbox.module.css | 2 +- src/components/datatable/DataTable.jsx | 31 ++++- src/components/datatable/ErrorBoundary.jsx | 5 +- src/components/datatable/FilterInput.jsx | 45 ++++--- .../datatable/__tests__/FilterInput.spec.jsx | 19 ++- .../__tests__/useColumnWidths.spec.jsx | 115 ++++++++++++++++++ .../datatable/__tests__/useTableData.spec.jsx | 9 +- .../datatable/styles/DataTable.module.css | 22 +++- .../datatable/styles/ErrorBoundary.module.css | 5 + .../datatable/styles/FilterInput.module.css | 25 ++++ src/components/datatable/useColumnWidths.js | 6 +- src/components/map/layers/EventPopup.jsx | 6 +- src/constants/dataTable.js | 4 + src/constants/valueTypes.js | 3 + src/util/__tests__/filter.spec.js | 96 ++++++++++++++- src/util/__tests__/filterInput.spec.js | 22 ---- src/util/__tests__/tableHeaders.spec.js | 69 +++++++++++ src/util/filter.js | 28 +++++ src/util/filterInput.js | 10 +- src/util/helpers.js | 2 +- src/util/tableHeaders.js | 72 ++++++++--- src/util/time.js | 2 +- 25 files changed, 545 insertions(+), 116 deletions(-) create mode 100644 src/components/datatable/__tests__/useColumnWidths.spec.jsx create mode 100644 src/components/datatable/styles/ErrorBoundary.module.css diff --git a/cypress/integration/layers/eventlayer.cy.js b/cypress/integration/layers/eventlayer.cy.js index 9b77e74e3a..ab813a44f7 100644 --- a/cypress/integration/layers/eventlayer.cy.js +++ b/cypress/integration/layers/eventlayer.cy.js @@ -338,7 +338,7 @@ context('Event Layers', () => { 'Event location', '-13.188339, 8.405215', 'Organisation unit', - 'Event time', + 'Event date', 'Age in years', 'Mode of Discharge', ]) diff --git a/i18n/en.pot b/i18n/en.pot index d3b055e247..b4757e4276 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-23T13:14:41.110Z\n" -"PO-Revision-Date: 2026-07-23T13:14:41.111Z\n" +"POT-Creation-Date: 2026-07-24T09:14:13.417Z\n" +"PO-Revision-Date: 2026-07-24T09:14:13.417Z\n" msgid "2020" msgstr "2020" @@ -176,6 +176,36 @@ msgstr "Reverse selection" msgid "Sort by {{column}}" msgstr "Sort by {{column}}" +msgid "Edit layer" +msgstr "Edit layer" + +msgid "Select a year, month, day or hour" +msgstr "Select a year, month, day or hour" + +msgid "to match the events under it, or type to search" +msgstr "to match the events under it, or type to search" + +msgid "Search" +msgstr "Search" + +msgid "Contains" +msgstr "Contains" + +msgid "Any value" +msgstr "Any value" + +msgid "No value" +msgstr "No value" + +msgid "No matches" +msgstr "No matches" + +msgid "Collapse {{label}}" +msgstr "Collapse {{label}}" + +msgid "Expand {{label}}" +msgstr "Expand {{label}}" + msgid "Something went wrong" msgstr "Something went wrong" @@ -203,30 +233,15 @@ msgstr "to match rows that contain it" msgid "Use filter" msgstr "Use filter" -msgid "Contains" -msgstr "Contains" - msgid "Search or type > 5, < 8…" msgstr "Search or type > 5, < 8…" -msgid "Search" -msgstr "Search" - msgid "Reverse selection" msgstr "Reverse selection" -msgid "Any value" -msgstr "Any value" - msgid "Too many values to list - type to filter this column" msgstr "Too many values to list - type to filter this column" -msgid "No matches" -msgstr "No matches" - -msgid "No value" -msgstr "No value" - msgid "Selected" msgstr "Selected" @@ -821,9 +836,6 @@ msgstr "Open in Data Visualizer app" msgid "Download data" msgstr "Download data" -msgid "Edit layer" -msgstr "Edit layer" - msgid "Duplicate layer" msgstr "Duplicate layer" @@ -931,8 +943,8 @@ msgstr "Could not retrieve event data" msgid "Organisation unit" msgstr "Organisation unit" -msgid "Event time" -msgstr "Event time" +msgid "Event date" +msgstr "Event date" msgid "Groups" msgstr "Groups" diff --git a/src/components/core/Checkbox.jsx b/src/components/core/Checkbox.jsx index 5567af53e2..2f19b9e406 100644 --- a/src/components/core/Checkbox.jsx +++ b/src/components/core/Checkbox.jsx @@ -8,6 +8,7 @@ import styles from './styles/Checkbox.module.css' const Checkbox = ({ label, checked = false, + indeterminate = false, disabled, dense = true, tooltip, @@ -23,7 +24,8 @@ const Checkbox = ({ > <UiCheckbox label={label} - checked={checked} + checked={!indeterminate && checked} + indeterminate={indeterminate} dense={dense} disabled={disabled} onChange={({ checked }) => onChange(checked)} @@ -43,6 +45,7 @@ Checkbox.propTypes = { dataTest: PropTypes.string, dense: PropTypes.bool, disabled: PropTypes.bool, + indeterminate: PropTypes.bool, label: PropTypes.node, style: PropTypes.object, tooltip: PropTypes.string, diff --git a/src/components/core/styles/Checkbox.module.css b/src/components/core/styles/Checkbox.module.css index ead0686f2f..410baa373c 100644 --- a/src/components/core/styles/Checkbox.module.css +++ b/src/components/core/styles/Checkbox.module.css @@ -5,7 +5,7 @@ } /* This styles the tooltip span containing the svg */ -.checkbox span { +.checkbox > span { margin-left: var(--spacers-dp4); display: flex; flex-direction: column; diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index 4c71beca75..a6d9691cc7 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -22,7 +22,7 @@ import { useSelector, useDispatch } from 'react-redux' import { TableVirtuoso } from 'react-virtuoso' import { setSelectionFilter } from '../../actions/dataTable.js' import { highlightFeature } from '../../actions/feature.js' -import { setForceClientCluster } from '../../actions/layers.js' +import { editLayer, setForceClientCluster } from '../../actions/layers.js' import { toggleFeatureSelection, selectFeatureRange, @@ -33,6 +33,7 @@ import { RENDERER_COLOR, RENDERER_ICON, RENDERER_DATE, + TYPE_DATE, } from '../../constants/dataTable.js' import { isDarkColor } from '../../util/colors.js' import { @@ -44,7 +45,7 @@ import { isFilterable, shouldClearFeatureHighlight, } from '../../util/dataTable.js' -import { formatDatetime } from '../../util/helpers.js' +import { formatDate, formatDatetime } from '../../util/helpers.js' import { formatWithSeparator } from '../../util/numbers.js' import { getPinnedCellProps, @@ -201,7 +202,7 @@ const Table = ({ ) const visibleHeaders = useMemo( - () => getVisibleHeaders(headers, columnConfig), + () => getVisibleHeaders(headers, columnConfig) ?? [], [headers, columnConfig] ) @@ -210,6 +211,11 @@ const Table = ({ [visibleHeaders] ) + const typeByDataKey = useMemo( + () => new Map(visibleHeaders.map((h) => [h.dataKey, h.type])), + [visibleHeaders] + ) + const { headerRowRef, columnWidths } = useColumnWidths({ availableWidth, headers: visibleHeaders, @@ -533,7 +539,18 @@ const Table = ({ ) if (error) { - return <p className={styles.noSupport}>{error}</p> + return ( + <p className={styles.noSupport}> + {error} + <button + type="button" + className={styles.editLayerLink} + onClick={() => dispatch(editLayer(layer))} + > + {i18n.t('Edit layer')} + </button> + </p> + ) } return ( @@ -606,6 +623,8 @@ const Table = ({ const isColorCell = renderer === RENDERER_COLOR const isIconCell = renderer === RENDERER_ICON const isDateCell = renderer === RENDERER_DATE + const isDateOnlyCell = + typeByDataKey.get(dataKey) === TYPE_DATE return ( <DataTableCell key={`dtcell-${dataKey}`} @@ -645,7 +664,9 @@ const Table = ({ )} {isDateCell && value && - formatDatetime(value)} + (isDateOnlyCell + ? formatDate(value) + : formatDatetime(value))} {!isColorCell && !isIconCell && !isDateCell && diff --git a/src/components/datatable/ErrorBoundary.jsx b/src/components/datatable/ErrorBoundary.jsx index d9032eab8a..b8c0db86c1 100644 --- a/src/components/datatable/ErrorBoundary.jsx +++ b/src/components/datatable/ErrorBoundary.jsx @@ -2,6 +2,7 @@ import i18n from '@dhis2/d2-i18n' import { CenteredContent } from '@dhis2/ui' import PropTypes from 'prop-types' import React, { Component } from 'react' +import styles from './styles/ErrorBoundary.module.css' class ErrorBoundary extends Component { constructor(props) { @@ -24,7 +25,9 @@ class ErrorBoundary extends Component { if (this.state.error) { return ( <CenteredContent> - <p>{i18n.t('Something went wrong')}</p> + <p className={styles.message}> + {i18n.t('Something went wrong')} + </p> </CenteredContent> ) } diff --git a/src/components/datatable/FilterInput.jsx b/src/components/datatable/FilterInput.jsx index e536d6d6cb..0741860ad6 100644 --- a/src/components/datatable/FilterInput.jsx +++ b/src/components/datatable/FilterInput.jsx @@ -12,6 +12,9 @@ import { RENDERER_COLOR, RENDERER_ICON, TYPE_NUMBER, + // TYPE_DATE, + // TYPE_DATETIME, + // TYPE_TIME, } from '../../constants/dataTable.js' import useOptionSet from '../../hooks/useOptionSet.js' import { @@ -20,10 +23,11 @@ import { getFilteredOptions, getPopoverWidth, getSelectedAndAppliedString, - hasMatchingOptionLabel, measureMaxTextWidth, toHighlightedIndex, toOptionIndex, + OPTION_ROW_HEIGHT, + MAX_LIST_HEIGHT, } from '../../util/filterInput.js' import { getInvertibleValues, @@ -34,6 +38,7 @@ import { import { formatWithSeparator } from '../../util/numbers.js' import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' import Checkbox from '../core/Checkbox.jsx' +// import DateGroupFilterInput from './DateGroupFilterInput.jsx' import { FilterDropdownPopover, getDropdownPlacement, @@ -41,8 +46,6 @@ import { import FilterHelpTooltip from './FilterHelpTooltip.jsx' import styles from './styles/FilterInput.module.css' -const OPTION_ROW_HEIGHT = 28 // Checkbox rows are a fixed height so the list can be virtualized -const MAX_LIST_HEIGHT = 260 const NUMERIC_HELP_HEIGHT = 140 const TEXT_HELP_HEIGHT = 56 const NUMERIC_FILTER_HELP = ( @@ -183,17 +186,7 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({ }), [realOptions, trimmedSearch, normalizedSearch, type, resolveLabel] ) - const hasExactMatch = useMemo( - () => - hasMatchingOptionLabel( - filteredOptions, - resolveLabel, - normalizedSearch - ), - [filteredOptions, resolveLabel, normalizedSearch] - ) - const showCustomFilterRow = - allowCustomFilter && normalizedSearch !== '' && !hasExactMatch + const showCustomFilterRow = allowCustomFilter && normalizedSearch !== '' const totalCount = filteredOptions.length + (showCustomFilterRow ? 1 : 0) const customFilterTag = @@ -221,15 +214,7 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({ return } - const normalized = trimmed.toLowerCase() - const exactMatch = hasMatchingOptionLabel( - options, - resolveLabel, - normalized - ) - if (!exactMatch) { - applyCustomFilter(trimmed) - } + applyCustomFilter(trimmed) } const scrollHighlightedIntoView = (index) => { @@ -576,6 +561,20 @@ const FilterInput = React.memo(function FilterInput({ const filterValue = filters?.[dataKey] + /* const isDateType = + type === TYPE_DATE || type === TYPE_DATETIME || type === TYPE_TIME */ + + /* return isDateType ? ( + <DateGroupFilterInput + dataKey={dataKey} + name={name} + layerId={layerId} + filterValue={filterValue} + options={options ?? []} + type={type} + /> + ) : */ + return optionSetId ? ( <OptionSetSearchableFilter dataKey={dataKey} diff --git a/src/components/datatable/__tests__/FilterInput.spec.jsx b/src/components/datatable/__tests__/FilterInput.spec.jsx index 928d2e6da3..6dbaf6f6c1 100644 --- a/src/components/datatable/__tests__/FilterInput.spec.jsx +++ b/src/components/datatable/__tests__/FilterInput.spec.jsx @@ -425,16 +425,25 @@ describe('FilterInput searchable popover — custom filter row', () => { expect(row).toHaveTextContent('medium') }) - test('is hidden when the typed text exactly matches an existing option', () => { - const options = [{ value: 'High' }, { value: 'Low' }] - renderFilterInput({ dataKey: 'legend', name: 'Legend', options }) + test('stays shown and keeps live-applying even when the typed text exactly matches an existing option', () => { + const { store } = renderFilterInput({ + dataKey: 'legend', + name: 'Legend', + options: [{ value: 'High' }, { value: 'Low' }], + }) openPopover('Legend') fireEvent.change(getInput('Legend'), { target: { value: 'High' }, }) expect( - screen.queryByTestId('data-table-column-filter-custom-Legend') - ).not.toBeInTheDocument() + screen.getByTestId('data-table-column-filter-custom-Legend') + ).toBeInTheDocument() + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'legend', + filter: 'High', + }) }) test('clearing the typed text clears an already-applied custom filter live', () => { diff --git a/src/components/datatable/__tests__/useColumnWidths.spec.jsx b/src/components/datatable/__tests__/useColumnWidths.spec.jsx new file mode 100644 index 0000000000..a39cdc00a4 --- /dev/null +++ b/src/components/datatable/__tests__/useColumnWidths.spec.jsx @@ -0,0 +1,115 @@ +import { render, act } from '@testing-library/react' +import PropTypes from 'prop-types' +import React from 'react' +import { useColumnWidths } from '../useColumnWidths.js' + +let rafCallbacks + +beforeEach(() => { + rafCallbacks = [] + jest.spyOn(global, 'requestAnimationFrame').mockImplementation((cb) => { + rafCallbacks.push(cb) + return rafCallbacks.length + }) + jest.spyOn( + HTMLElement.prototype, + 'getBoundingClientRect' + ).mockImplementation(function () { + return { width: Number(this.dataset.width) || 0 } + }) +}) + +afterEach(() => { + global.requestAnimationFrame.mockRestore() + HTMLElement.prototype.getBoundingClientRect.mockRestore() +}) + +const flushRaf = () => { + while (rafCallbacks.length) { + rafCallbacks.shift()() + } +} + +// A stable reference: the hook re-measures whenever `headers` changes +// identity, so passing a literal from the test body would spuriously +// reset the measurement on every rerender +const HEADERS = [{ dataKey: 'a' }, { dataKey: 'b' }] + +const Harness = ({ availableWidth, error, widths, onColumnWidths }) => { + const { headerRowRef, columnWidths } = useColumnWidths({ + availableWidth, + headers: HEADERS, + error, + }) + onColumnWidths(columnWidths) + return ( + <table> + <tbody> + <tr ref={headerRowRef}> + <td data-width="76" /> {/* checkbox column, skipped */} + {widths.map((w, i) => ( + <td key={i} data-width={w} /> + ))} + </tr> + </tbody> + </table> + ) +} + +Harness.propTypes = { + widths: PropTypes.arrayOf(PropTypes.number).isRequired, + onColumnWidths: PropTypes.func.isRequired, + availableWidth: PropTypes.number, + error: PropTypes.bool, +} + +describe('useColumnWidths - MIN_COLUMN_WIDTH floor', () => { + it('floors a narrower-than-minimum measured column up to the minimum, leaving wider columns untouched', () => { + let latestWidths + act(() => { + render( + <Harness + availableWidth={500} + widths={[20, 150]} + onColumnWidths={(w) => { + latestWidths = w + }} + /> + ) + }) + act(() => { + flushRaf() + }) + expect(latestWidths).toEqual([100, 150]) + }) + + it('the floored width stays the resize-clamp floor on a later shrink, instead of scaling down further', () => { + let latestWidths + const { rerender } = render( + <Harness + availableWidth={500} + widths={[20, 150]} + onColumnWidths={(w) => { + latestWidths = w + }} + /> + ) + act(() => { + flushRaf() + }) + expect(latestWidths).toEqual([100, 150]) + + act(() => { + rerender( + <Harness + availableWidth={100} + widths={[20, 150]} + onColumnWidths={(w) => { + latestWidths = w + }} + /> + ) + }) + expect(latestWidths).toEqual([100, 150]) + }) +}) diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index bcfb69f92e..0f9c59ea97 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -551,12 +551,17 @@ describe('useTableData headers', () => { { name: 'Org unit', dataKey: 'ouname', type: 'string' }, { name: 'Id', dataKey: 'id', type: 'string' }, { - name: 'Event time', + name: 'Event date', dataKey: 'eventdate', type: 'date', renderer: 'renderdate', }, - { name: 'Last updated on', dataKey: 'lastupdated', type: 'string' }, + { + name: 'Last updated on', + dataKey: 'lastupdated', + type: 'date', + renderer: 'renderdate', + }, { name: 'Event status', dataKey: 'eventstatus', type: 'string' }, { name: 'Gender', dataKey: 'oZg33kd9taw', type: 'string' }, { name: 'Type', dataKey: 'type', type: 'string' }, diff --git a/src/components/datatable/styles/DataTable.module.css b/src/components/datatable/styles/DataTable.module.css index 9c636d8c7b..416b4a55d8 100644 --- a/src/components/datatable/styles/DataTable.module.css +++ b/src/components/datatable/styles/DataTable.module.css @@ -165,9 +165,27 @@ th.hovered { .noSupport { position: absolute; - top: 50%; left: 50%; - transform: translateX(-50%) translateY(-50%); + transform: translateX(-50%); + display: flex; + align-items: center; + gap: var(--spacers-dp8); color: var(--colors-grey600); font-style: italic; + font-size: 12px; +} + +.editLayerLink { + font-size: 12px; + font-style: normal; + color: var(--colors-blue600); + background: transparent; + border: none; + padding: 0; + cursor: pointer; + text-decoration: underline; +} + +.editLayerLink:hover { + color: var(--colors-blue700); } diff --git a/src/components/datatable/styles/ErrorBoundary.module.css b/src/components/datatable/styles/ErrorBoundary.module.css new file mode 100644 index 0000000000..816db97bd3 --- /dev/null +++ b/src/components/datatable/styles/ErrorBoundary.module.css @@ -0,0 +1,5 @@ +.message { + color: var(--colors-grey600); + font-style: italic; + font-size: 12px; +} diff --git a/src/components/datatable/styles/FilterInput.module.css b/src/components/datatable/styles/FilterInput.module.css index 83a175c0ee..2541b751cf 100644 --- a/src/components/datatable/styles/FilterInput.module.css +++ b/src/components/datatable/styles/FilterInput.module.css @@ -177,3 +177,28 @@ .multiSelectPopover .highlighted { background: var(--colors-grey100); } + +.treeRow { + display: flex; + align-items: center; + width: 100%; +} + +.expandButton { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + padding: 0; + border: none; + flex-shrink: 0; + background: transparent; + color: var(--colors-grey700); + cursor: pointer; +} + +.expandButtonPlaceholder { + width: 16px; + flex-shrink: 0; +} diff --git a/src/components/datatable/useColumnWidths.js b/src/components/datatable/useColumnWidths.js index 2890420d80..cc87a4259e 100644 --- a/src/components/datatable/useColumnWidths.js +++ b/src/components/datatable/useColumnWidths.js @@ -1,5 +1,7 @@ import { useEffect, useRef, useState } from 'react' +const MIN_COLUMN_WIDTH = 100 + export const useColumnWidths = ({ availableWidth, headers, error }) => { const headerRowRef = useRef(null) const minColumnWidthsRef = useRef([]) @@ -21,7 +23,9 @@ export const useColumnWidths = ({ availableWidth, headers, error }) => { for (const cell of dataCells) { const rect = cell.getBoundingClientRect() - measuredColumnWidths.push(Math.floor(rect.width)) + measuredColumnWidths.push( + Math.max(MIN_COLUMN_WIDTH, Math.floor(rect.width)) + ) } minColumnWidthsRef.current = measuredColumnWidths diff --git a/src/components/map/layers/EventPopup.jsx b/src/components/map/layers/EventPopup.jsx index a6ffca021f..c9f1b7f4af 100644 --- a/src/components/map/layers/EventPopup.jsx +++ b/src/components/map/layers/EventPopup.jsx @@ -4,7 +4,7 @@ import PropTypes from 'prop-types' import React, { useEffect, useState } from 'react' import { EVENT_ID_FIELD } from '../../../util/geojson.js' import { - formatDatetime, + formatDate, formatCoordinate, formatValueForDisplay, } from '../../../util/helpers.js' @@ -177,8 +177,8 @@ const EventPopup = ({ )} {occurredAt && ( <tr> - <th>{i18n.t('Event time')}</th> - <td>{formatDatetime(occurredAt)}</td> + <th>{i18n.t('Event date')}</th> + <td>{formatDate(occurredAt)}</td> </tr> )} </tbody> diff --git a/src/constants/dataTable.js b/src/constants/dataTable.js index a89d40516f..0bac15662d 100644 --- a/src/constants/dataTable.js +++ b/src/constants/dataTable.js @@ -12,3 +12,7 @@ export const RENDERER_DATE = 'renderdate' export const TYPE_NUMBER = 'number' export const TYPE_STRING = 'string' export const TYPE_DATE = 'date' +export const TYPE_DATETIME = 'datetime' +export const TYPE_TIME = 'time' + +export const DATE_GROUPS_GRANULARITY = 'date-groups' diff --git a/src/constants/valueTypes.js b/src/constants/valueTypes.js index 15831f9da8..d9dbf398ce 100644 --- a/src/constants/valueTypes.js +++ b/src/constants/valueTypes.js @@ -32,6 +32,9 @@ export const dateValueTypes = ['DATE', 'AGE'] // Date-time value types export const datetimeValueTypes = ['DATETIME'] +// Time-only value types +export const timeValueTypes = ['TIME'] + // Coordinate value types export const coordinateValueTypes = ['COORDINATE'] diff --git a/src/util/__tests__/filter.spec.js b/src/util/__tests__/filter.spec.js index 9f376df8ea..5dc7069838 100644 --- a/src/util/__tests__/filter.spec.js +++ b/src/util/__tests__/filter.spec.js @@ -1,4 +1,8 @@ -import { SENTINEL_ANY_VALUE } from '../../constants/dataTable.js' +import { + SENTINEL_ANY_VALUE, + SENTINEL_NO_VALUE, + DATE_GROUPS_GRANULARITY, +} from '../../constants/dataTable.js' import { filterByGlobalSearch, filterData } from '../filter.js' describe('filterData', () => { @@ -109,6 +113,96 @@ describe('filterData', () => { const filters = { a: [SENTINEL_ANY_VALUE, ''] } expect(filterData(data, filters)).toEqual(data) }) + + describe('date-group filter ({ granularity, prefixes })', () => { + const data = [ + { a: '2023-05-15 00:00:00.0' }, + { a: '2023-05-16 03:00:00.0' }, + { a: '2024-01-01 00:00:00.0' }, + { a: null }, + ] + + it('matches every row under a single year prefix', () => { + const filters = { + a: { granularity: DATE_GROUPS_GRANULARITY, prefixes: ['2023'] }, + } + expect(filterData(data, filters)).toEqual([ + { a: '2023-05-15 00:00:00.0' }, + { a: '2023-05-16 03:00:00.0' }, + ]) + }) + + it('matches only the selected day prefix', () => { + const filters = { + a: { + granularity: DATE_GROUPS_GRANULARITY, + prefixes: ['2023-05-16'], + }, + } + expect(filterData(data, filters)).toEqual([ + { a: '2023-05-16 03:00:00.0' }, + ]) + }) + + it('ORs across prefixes of different granularities', () => { + const filters = { + a: { + granularity: DATE_GROUPS_GRANULARITY, + prefixes: ['2023-05-15', '2024'], + }, + } + expect(filterData(data, filters)).toEqual([ + { a: '2023-05-15 00:00:00.0' }, + { a: '2024-01-01 00:00:00.0' }, + ]) + }) + + it('does not treat an empty prefix list as "match nothing" (mirrors the empty-array convention: match everything)', () => { + const filters = { + a: { granularity: DATE_GROUPS_GRANULARITY, prefixes: [] }, + } + expect(filterData(data, filters)).toEqual(data) + }) + + it('SENTINEL_NO_VALUE only matches null/missing values, never startsWith("")-matching everything', () => { + const filters = { + a: { + granularity: DATE_GROUPS_GRANULARITY, + prefixes: [SENTINEL_NO_VALUE], + }, + } + expect(filterData(data, filters)).toEqual([{ a: null }]) + }) + + it('SENTINEL_ANY_VALUE matches every non-blank value', () => { + const filters = { + a: { + granularity: DATE_GROUPS_GRANULARITY, + prefixes: [SENTINEL_ANY_VALUE], + }, + } + expect(filterData(data, filters)).toEqual([ + { a: '2023-05-15 00:00:00.0' }, + { a: '2023-05-16 03:00:00.0' }, + { a: '2024-01-01 00:00:00.0' }, + ]) + }) + + it('does not throw and combines (AND) correctly with an unrelated string filter on another field', () => { + const mixedData = [ + { a: '2023-05-15 00:00:00.0', b: 'apple' }, + { a: '2023-05-16 00:00:00.0', b: 'banana' }, + { a: '2024-01-01 00:00:00.0', b: 'apple' }, + ] + const filters = { + a: { granularity: DATE_GROUPS_GRANULARITY, prefixes: ['2023'] }, + b: 'apple', + } + expect(filterData(mixedData, filters)).toEqual([ + { a: '2023-05-15 00:00:00.0', b: 'apple' }, + ]) + }) + }) }) describe('filterByGlobalSearch', () => { diff --git a/src/util/__tests__/filterInput.spec.js b/src/util/__tests__/filterInput.spec.js index 747d6063fe..62440eb572 100644 --- a/src/util/__tests__/filterInput.spec.js +++ b/src/util/__tests__/filterInput.spec.js @@ -4,7 +4,6 @@ import { getFilteredOptions, getPopoverWidth, getSelectedAndAppliedString, - hasMatchingOptionLabel, measureMaxTextWidth, toHighlightedIndex, toOptionIndex, @@ -141,27 +140,6 @@ describe('getPopoverWidth', () => { }) }) -describe('hasMatchingOptionLabel', () => { - const options = [{ value: 'a' }, { value: 'b' }] - const resolveLabel = (v) => ({ a: 'Apple', b: 'Banana' }[v]) - - it('is true when some option resolves to exactly the given text', () => { - expect(hasMatchingOptionLabel(options, resolveLabel, 'apple')).toBe( - true - ) - }) - - it('is false for a partial match', () => { - expect(hasMatchingOptionLabel(options, resolveLabel, 'app')).toBe(false) - }) - - it('is false when no option matches', () => { - expect(hasMatchingOptionLabel(options, resolveLabel, 'cherry')).toBe( - false - ) - }) -}) - describe('getCyclicIndex', () => { it('moves forward within range', () => { expect(getCyclicIndex(0, 3, 1)).toBe(1) diff --git a/src/util/__tests__/tableHeaders.spec.js b/src/util/__tests__/tableHeaders.spec.js index 97a4e078e2..b130fe4d24 100644 --- a/src/util/__tests__/tableHeaders.spec.js +++ b/src/util/__tests__/tableHeaders.spec.js @@ -1,3 +1,4 @@ +import { RENDERER_DATE } from '../../constants/dataTable.js' import { EVENT_LAYER, THEMATIC_LAYER, @@ -12,6 +13,9 @@ import { getHeadersForLayer, TYPE_NUMBER, TYPE_STRING, + TYPE_DATE, + TYPE_DATETIME, + TYPE_TIME, } from '../tableHeaders.js' jest.mock('../../components/map/MapApi.js', () => ({ @@ -99,6 +103,43 @@ describe('getHeadersForLayer - event', () => { (h) => h.dataKey === 'w75KJ2mc4zz' ) expect(ageHeader.type).toBe(TYPE_NUMBER) + const eventdateHeader = result.headers.find( + (h) => h.dataKey === 'eventdate' + ) + expect(eventdateHeader.type).toBe(TYPE_DATE) + }) + + test('custom DATE/DATETIME/TIME/AGE fields get their matching type, option-set-backed fields stay TYPE_STRING', () => { + const layerHeaders = [ + { name: 'w75KJ2mc4zz', column: 'Date of birth', valueType: 'DATE' }, + { + name: 'zDhUuAYrxNC', + column: 'Registered at', + valueType: 'DATETIME', + }, + { name: 'oZg33kd9taw', column: 'Visit time', valueType: 'TIME' }, + { name: 'a1b2c3d4e5f', column: 'Age', valueType: 'AGE' }, + { + name: 'b2c3d4e5f6a', + column: 'Gender', + valueType: 'TEXT', + optionSet: { id: 'os1' }, + }, + ] + const result = getHeadersForLayer(EVENT_LAYER, { layerHeaders }) + const headerFor = (dataKey) => + result.headers.find((h) => h.dataKey === dataKey) + const typeOf = (dataKey) => headerFor(dataKey).type + expect(typeOf('w75KJ2mc4zz')).toBe(TYPE_DATE) + expect(typeOf('zDhUuAYrxNC')).toBe(TYPE_DATETIME) + expect(typeOf('oZg33kd9taw')).toBe(TYPE_TIME) + expect(typeOf('a1b2c3d4e5f')).toBe(TYPE_DATE) + expect(typeOf('b2c3d4e5f6a')).toBe(TYPE_STRING) + expect(headerFor('w75KJ2mc4zz').renderer).toBe(RENDERER_DATE) + expect(headerFor('zDhUuAYrxNC').renderer).toBe(RENDERER_DATE) + expect(headerFor('oZg33kd9taw').renderer).toBe(RENDERER_DATE) + expect(headerFor('a1b2c3d4e5f').renderer).toBe(RENDERER_DATE) + expect(headerFor('b2c3d4e5f6a').renderer).toBeUndefined() }) test('adds the org unit boundary column only when countEventsOutsideOrgUnits is set', () => { @@ -166,6 +207,34 @@ describe('getHeadersForLayer - tracked entity', () => { ) expect(nameHeader.type).toBe(TYPE_STRING) }) + + test('custom DATE/DATETIME/TIME attributes get their matching type', () => { + const layerHeaders = [ + { + name: 'Date of birth', + dataKey: 'w75KJ2mc4zz', + valueType: 'DATE', + }, + { + name: 'Enrolled at', + dataKey: 'zDhUuAYrxNC', + valueType: 'DATETIME', + }, + { name: 'Visit time', dataKey: 'oZg33kd9taw', valueType: 'TIME' }, + ] + const result = getHeadersForLayer(TRACKED_ENTITY_LAYER, { + layerHeaders, + }) + const headerFor = (dataKey) => + result.headers.find((h) => h.dataKey === dataKey) + const typeOf = (dataKey) => headerFor(dataKey).type + expect(typeOf('w75KJ2mc4zz')).toBe(TYPE_DATE) + expect(typeOf('zDhUuAYrxNC')).toBe(TYPE_DATETIME) + expect(typeOf('oZg33kd9taw')).toBe(TYPE_TIME) + expect(headerFor('w75KJ2mc4zz').renderer).toBe(RENDERER_DATE) + expect(headerFor('zDhUuAYrxNC').renderer).toBe(RENDERER_DATE) + expect(headerFor('oZg33kd9taw').renderer).toBe(RENDERER_DATE) + }) }) describe('getHeadersForLayer - earth engine', () => { diff --git a/src/util/filter.js b/src/util/filter.js index 7189e2568c..ca6fb5c7ef 100644 --- a/src/util/filter.js +++ b/src/util/filter.js @@ -1,8 +1,32 @@ import { SENTINEL_ANY_VALUE, SENTINEL_NO_VALUE, + DATE_GROUPS_GRANULARITY, } from '../constants/dataTable.js' +// Distinguishes a date-groups filter +export const isDateGroupFilter = (filter) => + filter != null && + typeof filter === 'object' && + !Array.isArray(filter) && + filter.granularity === DATE_GROUPS_GRANULARITY + +export const dateGroupFilter = (value, { prefixes }) => { + if (!prefixes?.length) { + return true + } + const stringValue = value == null ? SENTINEL_NO_VALUE : String(value) + return prefixes.some((prefix) => { + if (prefix === SENTINEL_NO_VALUE) { + return stringValue === SENTINEL_NO_VALUE + } + if (prefix === SENTINEL_ANY_VALUE) { + return stringValue !== SENTINEL_NO_VALUE + } + return stringValue.startsWith(prefix) + }) +} + // Filters an array of object with a set of filters export const filterData = (data, filters) => { if (!filters) { @@ -20,6 +44,10 @@ export const filterData = (data, filters) => { const props = d.properties || d // GeoJSON or plain object const value = props[field] + if (isDateGroupFilter(filter)) { + return dateGroupFilter(value, filter) + } + if (Array.isArray(filter)) { // Multi-select: OR match against the raw stored value const stringValue = diff --git a/src/util/filterInput.js b/src/util/filterInput.js index db972db347..bb7773b636 100644 --- a/src/util/filterInput.js +++ b/src/util/filterInput.js @@ -6,6 +6,11 @@ const POPOVER_ROW_NON_LABEL_WIDTH = 56 const MIN_POPOVER_WIDTH = 140 const MAX_POPOVER_WIDTH = 280 +// Shared between FilterInput.jsx's SearchableFilterPopover and +// DateGroupFilterInput.jsx's tree - both virtualize a list of fixed-height rows +export const OPTION_ROW_HEIGHT = 28 +export const MAX_LIST_HEIGHT = 260 + export const getSelectedAndAppliedString = (filterValue) => ({ selected: Array.isArray(filterValue) ? filterValue : [], appliedString: typeof filterValue === 'string' ? filterValue : '', @@ -68,11 +73,6 @@ export const getPopoverWidth = (maxLabelWidth) => MAX_POPOVER_WIDTH ) -export const hasMatchingOptionLabel = (options, resolveLabel, normalizedText) => - options.some( - ({ value }) => resolveLabel(value).toLowerCase() === normalizedText - ) - export const getCyclicIndex = (current, total, delta) => total ? (current + delta + total) % total : -1 diff --git a/src/util/helpers.js b/src/util/helpers.js index d69c0d2c61..3ada5f9837 100644 --- a/src/util/helpers.js +++ b/src/util/helpers.js @@ -167,7 +167,7 @@ const formatBoolean = (value) => { } // Formats a DHIS2 date string value -const formatDate = (value) => { +export const formatDate = (value) => { const datePattern = /^(\d{4}-\d{2}-\d{2})/ const match = value.match(datePattern) return match ? match[1] : value diff --git a/src/util/tableHeaders.js b/src/util/tableHeaders.js index 3c42d4ac88..38f3afa343 100644 --- a/src/util/tableHeaders.js +++ b/src/util/tableHeaders.js @@ -6,6 +6,8 @@ import { TYPE_NUMBER, TYPE_STRING, TYPE_DATE, + TYPE_DATETIME, + TYPE_TIME, } from '../constants/dataTable.js' import { EVENT_LAYER, @@ -16,13 +18,42 @@ import { GEOJSON_URL_LAYER, TRACKED_ENTITY_LAYER, } from '../constants/layers.js' -import { numberValueTypes } from '../constants/valueTypes.js' +import { + numberValueTypes, + dateValueTypes, + datetimeValueTypes, + timeValueTypes, +} from '../constants/valueTypes.js' import { hasClasses } from './earthEngine.js' import { getGeojsonDisplayData } from './geojson.js' import { getRoundToPrecisionFn, getPrecision } from './numbers.js' import { isValidUid } from './uid.js' -export { TYPE_NUMBER, TYPE_STRING, TYPE_DATE } +export { TYPE_NUMBER, TYPE_STRING, TYPE_DATE, TYPE_DATETIME, TYPE_TIME } + +const getCustomFieldType = (valueType, hasOptionSet) => { + if (hasOptionSet) { + return TYPE_STRING + } + if (numberValueTypes.includes(valueType)) { + return TYPE_NUMBER + } + if (dateValueTypes.includes(valueType)) { + return TYPE_DATE + } + if (datetimeValueTypes.includes(valueType)) { + return TYPE_DATETIME + } + if (timeValueTypes.includes(valueType)) { + return TYPE_TIME + } + return TYPE_STRING +} + +const DATE_LIKE_TYPES = [TYPE_DATE, TYPE_DATETIME, TYPE_TIME] + +const getCustomFieldRenderer = (type) => + DATE_LIKE_TYPES.includes(type) ? RENDERER_DATE : undefined const NAME = 'name' const ID = 'id' @@ -61,7 +92,7 @@ const defaultFieldsMap = () => ({ type: TYPE_STRING, }, [EVENTDATE]: { - name: i18n.t('Event time'), + name: i18n.t('Event date'), dataKey: EVENTDATE, type: TYPE_DATE, renderer: RENDERER_DATE, @@ -163,15 +194,16 @@ const getEventHeaders = ({ const customFields = layerHeaders .filter(({ name }) => isValidUid(name)) - .map(({ name: dataKey, column: name, valueType, optionSet }) => ({ - name, - dataKey, - type: - !optionSet && numberValueTypes.includes(valueType) - ? TYPE_NUMBER - : TYPE_STRING, - optionSet: optionSet || null, - })) + .map(({ name: dataKey, column: name, valueType, optionSet }) => { + const type = getCustomFieldType(valueType, !!optionSet) + return { + name, + dataKey, + type, + renderer: getCustomFieldRenderer(type), + optionSet: optionSet || null, + } + }) customFields.push( defaultFieldsMap()[TYPE], @@ -217,13 +249,15 @@ const getTrackedEntityHeaders = ({ layerHeaders = [] }) => { const customFields = layerHeaders .filter(({ dataKey }) => isValidUid(dataKey)) - .map(({ name, dataKey, valueType }) => ({ - name, - dataKey, - type: numberValueTypes.includes(valueType) - ? TYPE_NUMBER - : TYPE_STRING, - })) + .map(({ name, dataKey, valueType }) => { + const type = getCustomFieldType(valueType, false) + return { + name, + dataKey, + type, + renderer: getCustomFieldRenderer(type), + } + }) customFields.push(...getStyleHeaders({ hasColor: true })) diff --git a/src/util/time.js b/src/util/time.js index e0d6821109..196130aecd 100644 --- a/src/util/time.js +++ b/src/util/time.js @@ -3,7 +3,7 @@ import i18n from '@dhis2/d2-i18n' const DEFAULT_LOCALE = 'en' // BCP 47 locale format -const dateLocale = (locale) => +export const dateLocale = (locale) => locale?.includes('_') ? locale.replaceAll('_', '-') : locale /** From f00285cd763d0866e0979db7e8fd08a178e88a87 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Fri, 24 Jul 2026 12:26:02 +0200 Subject: [PATCH 116/205] chore: sonarqube fix --- .../__tests__/useColumnWidths.spec.jsx | 20 +++++++++---------- src/util/tableHeaders.js | 4 ++-- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/components/datatable/__tests__/useColumnWidths.spec.jsx b/src/components/datatable/__tests__/useColumnWidths.spec.jsx index a39cdc00a4..66978648c6 100644 --- a/src/components/datatable/__tests__/useColumnWidths.spec.jsx +++ b/src/components/datatable/__tests__/useColumnWidths.spec.jsx @@ -66,17 +66,15 @@ Harness.propTypes = { describe('useColumnWidths - MIN_COLUMN_WIDTH floor', () => { it('floors a narrower-than-minimum measured column up to the minimum, leaving wider columns untouched', () => { let latestWidths - act(() => { - render( - <Harness - availableWidth={500} - widths={[20, 150]} - onColumnWidths={(w) => { - latestWidths = w - }} - /> - ) - }) + render( + <Harness + availableWidth={500} + widths={[20, 150]} + onColumnWidths={(w) => { + latestWidths = w + }} + /> + ) act(() => { flushRaf() }) diff --git a/src/util/tableHeaders.js b/src/util/tableHeaders.js index 38f3afa343..7e6be114cf 100644 --- a/src/util/tableHeaders.js +++ b/src/util/tableHeaders.js @@ -50,10 +50,10 @@ const getCustomFieldType = (valueType, hasOptionSet) => { return TYPE_STRING } -const DATE_LIKE_TYPES = [TYPE_DATE, TYPE_DATETIME, TYPE_TIME] +const DATE_LIKE_TYPES = new Set([TYPE_DATE, TYPE_DATETIME, TYPE_TIME]) const getCustomFieldRenderer = (type) => - DATE_LIKE_TYPES.includes(type) ? RENDERER_DATE : undefined + DATE_LIKE_TYPES.has(type) ? RENDERER_DATE : undefined const NAME = 'name' const ID = 'id' From f889f5d0c081f2dd14a7a7d42e554bb6d270f180 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Mon, 13 Jul 2026 17:36:21 +0200 Subject: [PATCH 117/205] feat: add bidirectional map/table selection sync and collapsible data table --- i18n/en.pot | 3 + .../datatable/__tests__/useTableData.spec.jsx | 138 ++++++++++++++++++ 2 files changed, 141 insertions(+) diff --git a/i18n/en.pot b/i18n/en.pot index b4757e4276..447bf4d396 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -173,6 +173,9 @@ msgstr "Sort by Selected" msgid "Reverse selection" msgstr "Reverse selection" +msgid "Select all" +msgstr "Select all" + msgid "Sort by {{column}}" msgstr "Sort by {{column}}" diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index 0f9c59ea97..e5b26a98ff 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -2218,3 +2218,141 @@ describe('useTableData selectionFilter', () => { expect(current.rows).toHaveLength(0) }) }) + +describe('useTableData showOnlyFeaturesInView', () => { + const store = { aggregations: {} } + const bounds = [-10, -10, 10, 10] + + const layer = { + id: 'test-layer', + layer: 'orgUnit', + dataFilters: null, + data: [ + { + id: 'inview', + properties: { id: 'inview', name: 'In view' }, + geometry: { type: 'Point', coordinates: [0, 0] }, + }, + { + id: 'outofview', + properties: { id: 'outofview', name: 'Out of view' }, + geometry: { type: 'Point', coordinates: [50, 50] }, + }, + ], + } + + const renderTableData = (props) => + renderHook(() => useTableData(props), { + wrapper: ({ children }) => ( + <Provider store={mockStore(store)}>{children}</Provider> + ), + }).result + + test('includes all rows when the toggle is off', () => { + const { current } = renderTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + showOnlyFeaturesInView: false, + mapBounds: bounds, + }) + expect(current.rows).toHaveLength(2) + }) + + test('excludes features outside the current map bounds when the toggle is on', () => { + const { current } = renderTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + showOnlyFeaturesInView: true, + mapBounds: bounds, + }) + expect(current.rows).toHaveLength(1) + expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( + 'In view' + ) + }) + + test('excludes features without geometry when the toggle is on', () => { + const layerWithoutCoords = { + ...layer, + data: [layer.data[0]], + dataWithoutCoords: [ + { + id: 'nogeom', + properties: { id: 'nogeom', name: 'No geometry' }, + geometry: null, + }, + ], + } + + const { current } = renderTableData({ + layer: layerWithoutCoords, + sortField: 'name', + sortDirection: 'asc', + showOnlyFeaturesInView: true, + mapBounds: bounds, + }) + expect(current.rows).toHaveLength(1) + expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( + 'In view' + ) + }) +}) + +describe('useTableData showOnlySelected', () => { + const store = { aggregations: {} } + + const layer = { + id: 'test-layer', + layer: 'orgUnit', + dataFilters: null, + data: [ + { id: 'a', properties: { id: 'a', name: 'Item A' } }, + { id: 'b', properties: { id: 'b', name: 'Item B' } }, + ], + } + + const renderTableData = (props) => + renderHook(() => useTableData(props), { + wrapper: ({ children }) => ( + <Provider store={mockStore(store)}>{children}</Provider> + ), + }).result + + test('includes all rows when the toggle is off', () => { + const { current } = renderTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + showOnlySelected: false, + selectedIdSet: new Set(['a']), + }) + expect(current.rows).toHaveLength(2) + }) + + test('includes only selected rows when the toggle is on', () => { + const { current } = renderTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + showOnlySelected: true, + selectedIdSet: new Set(['a']), + }) + expect(current.rows).toHaveLength(1) + expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( + 'Item A' + ) + }) + + test('shows no rows when the toggle is on and nothing is selected', () => { + const { current } = renderTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + showOnlySelected: true, + selectedIdSet: new Set(), + }) + expect(current.rows).toHaveLength(0) + }) +}) From 9ea8a24f102401d43905f3d8223ebad7028b8dff Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 14 Jul 2026 10:51:51 +0200 Subject: [PATCH 118/205] fix: toolbar polish - clear filters button, search sizing, collapse icon/bug --- i18n/en.pot | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/i18n/en.pot b/i18n/en.pot index 447bf4d396..156a0f53fa 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -188,6 +188,29 @@ msgstr "Select a year, month, day or hour" msgid "to match the events under it, or type to search" msgstr "to match the events under it, or type to search" +msgid "greater than 5" +msgstr "greater than 5" + +msgid "greater than or equal to 5" +msgstr "greater than or equal to 5" + +msgid "less than (or equal to) 5" +msgstr "less than (or equal to) 5" + +msgid "equal to 2 OR greater than 8" +msgstr "equal to 2 OR greater than 8" + +msgid "greater than 3 AND less than 8" +msgstr "greater than 3 AND less than 8" + +msgid "All" +msgstr "All" + +msgid "{{count}} selected" +msgid_plural "{{count}} selected" +msgstr[0] "{{count}} selected" +msgstr[1] "{{count}} selected" + msgid "Search" msgstr "Search" From d792fc27da076e06eadc9ef21c03c189a004aed5 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 14 Jul 2026 22:18:57 +0200 Subject: [PATCH 119/205] feat: round out data table filtering with reverse-selection, zoom-to-filtered, and a richer selection filter --- i18n/en.pot | 28 ++++++++++--- .../datatable/__tests__/useTableData.spec.jsx | 39 +++++++++++++++---- src/components/datatable/useTableData.js | 2 +- 3 files changed, 55 insertions(+), 14 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 156a0f53fa..8ff50f4eae 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -176,6 +176,12 @@ msgstr "Reverse selection" msgid "Select all" msgstr "Select all" +msgid "Sort by Selected" +msgstr "Sort by Selected" + +msgid "Reverse selection" +msgstr "Reverse selection" + msgid "Sort by {{column}}" msgstr "Sort by {{column}}" @@ -203,13 +209,17 @@ msgstr "equal to 2 OR greater than 8" msgid "greater than 3 AND less than 8" msgstr "greater than 3 AND less than 8" -msgid "All" -msgstr "All" +msgid "Select values, or type text to match rows that contain it." +msgstr "Select values, or type text to match rows that contain it." + +msgid "Use filter" +msgstr "Use filter" -msgid "{{count}} selected" -msgid_plural "{{count}} selected" -msgstr[0] "{{count}} selected" -msgstr[1] "{{count}} selected" +msgid "Contains" +msgstr "Contains" + +msgid "Search or type > 5, < 8…" +msgstr "Search or type > 5, < 8…" msgid "Search" msgstr "Search" @@ -386,6 +396,12 @@ msgstr "Loading Earth Engine data…" msgid "Loading additional events…" msgstr "Loading additional events…" +msgid "Loading Earth Engine data…" +msgstr "Loading Earth Engine data…" + +msgid "Loading additional events…" +msgstr "Loading additional events…" + msgid "Items" msgstr "Items" diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index e5b26a98ff..9f941e961c 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -2300,7 +2300,7 @@ describe('useTableData showOnlyFeaturesInView', () => { }) }) -describe('useTableData showOnlySelected', () => { +describe('useTableData selectionFilter', () => { const store = { aggregations: {} } const layer = { @@ -2320,23 +2320,23 @@ describe('useTableData showOnlySelected', () => { ), }).result - test('includes all rows when the toggle is off', () => { + test('includes all rows when no filter is applied', () => { const { current } = renderTableData({ layer, sortField: 'name', sortDirection: 'asc', - showOnlySelected: false, + selectionFilter: [], selectedIdSet: new Set(['a']), }) expect(current.rows).toHaveLength(2) }) - test('includes only selected rows when the toggle is on', () => { + test('includes only selected rows when filtered to "selected"', () => { const { current } = renderTableData({ layer, sortField: 'name', sortDirection: 'asc', - showOnlySelected: true, + selectionFilter: ['selected'], selectedIdSet: new Set(['a']), }) expect(current.rows).toHaveLength(1) @@ -2345,12 +2345,37 @@ describe('useTableData showOnlySelected', () => { ) }) - test('shows no rows when the toggle is on and nothing is selected', () => { + test('includes only non-selected rows when filtered to "not-selected"', () => { const { current } = renderTableData({ layer, sortField: 'name', sortDirection: 'asc', - showOnlySelected: true, + selectionFilter: ['not-selected'], + selectedIdSet: new Set(['a']), + }) + expect(current.rows).toHaveLength(1) + expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( + 'Item B' + ) + }) + + test('includes all rows when both options are checked', () => { + const { current } = renderTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + selectionFilter: ['selected', 'not-selected'], + selectedIdSet: new Set(['a']), + }) + expect(current.rows).toHaveLength(2) + }) + + test('shows no rows when filtered to "selected" and nothing is selected', () => { + const { current } = renderTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + selectionFilter: ['selected'], selectedIdSet: new Set(), }) expect(current.rows).toHaveLength(0) diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index bccecf6ea1..70933c77db 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -273,7 +273,7 @@ export const useTableData = ({ } } - //sort + // Sort filteredData.sort((a, b) => compareRows(a, b, { sortField, sortDirection, selectedIdSet }) ) From f93b091a42a1fce8acefece2b1455ca1c25b296c Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 16 Jul 2026 10:47:34 +0200 Subject: [PATCH 120/205] fix: rollback merging range and color columns --- i18n/en.pot | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 8ff50f4eae..148fa6cb89 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -209,8 +209,11 @@ msgstr "equal to 2 OR greater than 8" msgid "greater than 3 AND less than 8" msgstr "greater than 3 AND less than 8" -msgid "Select values, or type text to match rows that contain it." -msgstr "Select values, or type text to match rows that contain it." +msgid "Select values, or type text" +msgstr "Select values, or type text" + +msgid "to match rows that contain it" +msgstr "to match rows that contain it" msgid "Use filter" msgstr "Use filter" From ff7e87cbf42bc04aba2ada44bd4d2912c238c814 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Fri, 24 Jul 2026 12:32:17 +0200 Subject: [PATCH 121/205] feat: hierarchical drill-down filter for date/time data table columns --- .../datatable/DateGroupFilterInput.jsx | 500 ++++++++++++++++++ src/components/datatable/FilterInput.jsx | 18 +- .../__tests__/DateGroupFilterInput.spec.jsx | 378 +++++++++++++ src/util/__tests__/dateGroups.spec.js | 330 ++++++++++++ src/util/dateGroups.js | 217 ++++++++ 5 files changed, 1433 insertions(+), 10 deletions(-) create mode 100644 src/components/datatable/DateGroupFilterInput.jsx create mode 100644 src/components/datatable/__tests__/DateGroupFilterInput.spec.jsx create mode 100644 src/util/__tests__/dateGroups.spec.js create mode 100644 src/util/dateGroups.js diff --git a/src/components/datatable/DateGroupFilterInput.jsx b/src/components/datatable/DateGroupFilterInput.jsx new file mode 100644 index 0000000000..02e91c43c5 --- /dev/null +++ b/src/components/datatable/DateGroupFilterInput.jsx @@ -0,0 +1,500 @@ +import i18n from '@dhis2/d2-i18n' +import { + Input, + IconChevronRight16, + IconChevronDown16, + IconFilter16, +} from '@dhis2/ui' +import cx from 'classnames' +import PropTypes from 'prop-types' +import React, { useCallback, useMemo, useRef, useState } from 'react' +import { useDispatch } from 'react-redux' +import { Virtuoso } from 'react-virtuoso' +import { setDataFilter, clearDataFilter } from '../../actions/dataFilters.js' +import { + SENTINEL_ANY_VALUE, + SENTINEL_NO_VALUE, + DATE_GROUPS_GRANULARITY, +} from '../../constants/dataTable.js' +import { + buildDateGroupTree, + flattenVisibleNodes, + formatNodeLabel, + getNodeCheckState, + getSearchMatches, + nodeMatchesOrHasMatch, + toggleDateGroupPrefix, +} from '../../util/dateGroups.js' +import { isDateGroupFilter } from '../../util/filter.js' +import { + OPTION_ROW_HEIGHT, + MAX_LIST_HEIGHT, + getCyclicIndex, + getDisplayValue, + toOptionIndex, + toHighlightedIndex, +} from '../../util/filterInput.js' +import { toggleAnyValue } from '../../util/filterSelection.js' +import Checkbox from '../core/Checkbox.jsx' +import { + FilterDropdownPopover, + getDropdownPlacement, +} from './FilterDropdownPopover.jsx' +import FilterHelpTooltip from './FilterHelpTooltip.jsx' +import styles from './styles/FilterInput.module.css' + +const DATE_GROUP_POPOVER_WIDTH = 220 +const HELP_HEIGHT = 56 +const HELP_CONTENT = ( + <div> + <div>{i18n.t('Select a year, month, day or hour')}</div> + <div>{i18n.t('to match the events under it, or type to search')}</div> + </div> +) +const INDENT_PX = 16 +const DATE_INPUT_DISALLOWED = /[^0-9\-:. T]/g + +const DateGroupFilterInput = ({ + dataKey, + name, + layerId, + filterValue, + options, + type, +}) => { + const dispatch = useDispatch() + const anchorRef = useRef(null) + const listRef = useRef(null) + const [isOpen, setIsOpen] = useState(false) + const [searchText, setSearchText] = useState('') + const [expandedKeys, setExpandedKeys] = useState(() => new Set()) + const [highlightedIndex, setHighlightedIndex] = useState(-1) + + const selectedPrefixes = isDateGroupFilter(filterValue) + ? filterValue.prefixes + : [] + const appliedString = typeof filterValue === 'string' ? filterValue : '' + const anyValueActive = selectedPrefixes.includes(SENTINEL_ANY_VALUE) + const notSetActive = selectedPrefixes.includes(SENTINEL_NO_VALUE) + const treePrefixes = selectedPrefixes.filter( + (p) => p !== SENTINEL_ANY_VALUE && p !== SENTINEL_NO_VALUE + ) + const hasActiveFilter = selectedPrefixes.length > 0 || appliedString !== '' + + const openPopover = () => { + setSearchText(appliedString) + setHighlightedIndex(-1) + setIsOpen(true) + } + const closePopover = () => setIsOpen(false) + + const anchorRect = anchorRef.current?.getBoundingClientRect() + const { dropdownPlacement, dropdownSide, tooltipPlacement } = + getDropdownPlacement(anchorRect) + + const applyValues = useCallback( + (nextPrefixes) => + nextPrefixes.length + ? dispatch( + setDataFilter(layerId, dataKey, { + granularity: DATE_GROUPS_GRANULARITY, + prefixes: nextPrefixes, + }) + ) + : dispatch(clearDataFilter(layerId, dataKey)), + [dispatch, layerId, dataKey] + ) + + const hasNotSetOption = options.some( + ({ value }) => value === SENTINEL_NO_VALUE + ) + const realValues = useMemo( + () => + options + .filter(({ value }) => value !== SENTINEL_NO_VALUE) + .map((o) => o.value), + [options] + ) + + const tree = useMemo( + () => buildDateGroupTree(realValues, type), + [realValues, type] + ) + + const normalizedSearch = searchText.trim().toLowerCase() + const searchMatches = useMemo( + () => + normalizedSearch ? getSearchMatches(tree, normalizedSearch) : null, + [tree, normalizedSearch] + ) + const effectiveExpanded = useMemo( + () => + searchMatches + ? new Set([ + ...expandedKeys, + ...searchMatches.expandedAncestorKeys, + ]) + : expandedKeys, + [expandedKeys, searchMatches] + ) + + const visibleNodes = useMemo(() => { + const flattened = flattenVisibleNodes(tree, effectiveExpanded) + if (!searchMatches) { + return flattened + } + return flattened.filter(({ node }) => + nodeMatchesOrHasMatch(node, searchMatches.matchedKeys) + ) + }, [tree, effectiveExpanded, searchMatches]) + + const showCustomFilterRow = normalizedSearch !== '' + const totalCount = visibleNodes.length + (showCustomFilterRow ? 1 : 0) + + const onToggleExpand = (key) => + setExpandedKeys((prev) => { + const next = new Set(prev) + if (next.has(key)) { + next.delete(key) + } else { + next.add(key) + } + return next + }) + + const checkStateFor = (node) => + anyValueActive ? 'checked' : getNodeCheckState(node, treePrefixes) + + const onToggleNode = (node) => { + if (anyValueActive) { + return + } + const nextTreePrefixes = toggleDateGroupPrefix(treePrefixes, node) + applyValues( + notSetActive + ? [...nextTreePrefixes, SENTINEL_NO_VALUE] + : nextTreePrefixes + ) + } + + const onToggleAnyValue = () => applyValues(toggleAnyValue(selectedPrefixes)) + + const onToggleNotSet = () => + applyValues( + notSetActive + ? selectedPrefixes.filter((p) => p !== SENTINEL_NO_VALUE) + : [...selectedPrefixes, SENTINEL_NO_VALUE] + ) + + const applyCustomFilter = (text) => + text + ? dispatch(setDataFilter(layerId, dataKey, text)) + : dispatch(clearDataFilter(layerId, dataKey)) + + const onSearchChange = ({ value }) => { + const sanitized = value.replace(DATE_INPUT_DISALLOWED, '') + setSearchText(sanitized) + setHighlightedIndex(-1) + + const trimmed = sanitized.trim() + if (trimmed === '') { + if (hasActiveFilter) { + dispatch(clearDataFilter(layerId, dataKey)) + } + return + } + + applyCustomFilter(trimmed) + } + + const scrollHighlightedIntoView = (index) => { + const optionIndex = toOptionIndex(index, showCustomFilterRow) + if (optionIndex >= 0 && optionIndex < visibleNodes.length) { + listRef.current?.scrollToIndex({ + index: optionIndex, + align: 'center', + }) + } + } + + const onEnterKey = () => { + if (highlightedIndex === -1) { + if (showCustomFilterRow) { + applyCustomFilter(searchText.trim()) + } + return + } + if (showCustomFilterRow && highlightedIndex === 0) { + applyCustomFilter(searchText.trim()) + return + } + const optionIndex = toOptionIndex(highlightedIndex, showCustomFilterRow) + if (optionIndex >= 0 && optionIndex < visibleNodes.length) { + onToggleNode(visibleNodes[optionIndex].node) + } + } + + const onSearchKeyDown = (_, event) => { + const optionIndex = toOptionIndex(highlightedIndex, showCustomFilterRow) + const { node } = visibleNodes[optionIndex] ?? {} + switch (event.key) { + case 'ArrowDown': + event.preventDefault() + setHighlightedIndex((i) => { + const next = getCyclicIndex(i, totalCount, 1) + scrollHighlightedIntoView(next) + return next + }) + break + case 'ArrowUp': + event.preventDefault() + setHighlightedIndex((i) => { + const next = getCyclicIndex(i, totalCount, -1) + scrollHighlightedIntoView(next) + return next + }) + break + case 'ArrowRight': + if (node?.children.length && !effectiveExpanded.has(node.key)) { + event.preventDefault() + onToggleExpand(node.key) + } + break + case 'ArrowLeft': + if (node?.children.length && effectiveExpanded.has(node.key)) { + event.preventDefault() + onToggleExpand(node.key) + } + break + case 'Enter': + event.preventDefault() + onEnterKey() + closePopover() + break + case 'Escape': + event.preventDefault() + closePopover() + break + default: + break + } + } + + const displayValue = getDisplayValue({ + isOpen, + searchText, + selected: selectedPrefixes, + appliedString, + }) + + return ( + <div className={styles.filterTrigger} ref={anchorRef}> + <FilterHelpTooltip + content={HELP_CONTENT} + placement={tooltipPlacement} + estimatedHeight={HELP_HEIGHT} + dataTest="data-table-filter-help" + > + <Input + dense + clearable + dataTest={`data-table-column-filter-search-${name}`} + placeholder={i18n.t('Search')} + value={displayValue} + onFocus={() => { + if (!isOpen) { + openPopover() + } + }} + onChange={onSearchChange} + onKeyDown={onSearchKeyDown} + /> + </FilterHelpTooltip> + {isOpen && ( + <FilterDropdownPopover + reference={anchorRef} + placement={dropdownPlacement} + onClickOutside={closePopover} + className={cx( + styles.dropdownPopper, + dropdownSide === 'top' && styles.dropdownPopperAbove + )} + > + <div + className={cx(styles.searchableFilterPopover, { + [styles.reversedOrder]: dropdownSide === 'top', + })} + style={{ width: `${DATE_GROUP_POPOVER_WIDTH}px` }} + > + {showCustomFilterRow && ( + <button + type="button" + className={cx(styles.customFilterRow, { + [styles.highlighted]: + highlightedIndex === 0, + })} + data-test={`data-table-column-filter-custom-${name}`} + onClick={() => { + applyCustomFilter(searchText.trim()) + closePopover() + }} + > + <IconFilter16 /> + <span className={styles.customFilterTag}> + {i18n.t('Contains')} + </span> + <span className={styles.customFilterExpr}> + {searchText.trim()} + </span> + </button> + )} + <div className={styles.pinnedOptions}> + <Checkbox + label={i18n.t('Any value')} + checked={anyValueActive} + onChange={onToggleAnyValue} + className={cx( + styles.specialOption, + styles.denseCheckbox + )} + dataTest={`data-table-column-filter-any-${name}`} + /> + {hasNotSetOption && ( + <Checkbox + label={i18n.t('No value')} + checked={notSetActive} + onChange={onToggleNotSet} + className={cx( + styles.specialOption, + styles.denseCheckbox + )} + dataTest={`data-table-column-filter-novalue-${name}`} + /> + )} + </div> + <div className={styles.multiSelectPopover}> + {!showCustomFilterRow && + visibleNodes.length === 0 && ( + <div className={styles.noResults}> + {i18n.t('No matches')} + </div> + )} + {visibleNodes.length > 0 && ( + <Virtuoso + ref={listRef} + style={{ + height: Math.min( + visibleNodes.length * + OPTION_ROW_HEIGHT, + MAX_LIST_HEIGHT + ), + }} + increaseViewportBy={{ + top: 0, + bottom: OPTION_ROW_HEIGHT * 2, + }} + data={visibleNodes} + fixedItemHeight={OPTION_ROW_HEIGHT} + computeItemKey={(_, { node }) => node.key} + itemContent={(index, { node, depth }) => { + const state = checkStateFor(node) + const checked = state === 'checked' + const indeterminate = + state === 'indeterminate' + const isExpanded = + effectiveExpanded.has(node.key) + const label = formatNodeLabel( + node, + i18n.language + ) + return ( + <div + className={styles.treeRow} + style={{ + paddingLeft: + depth * INDENT_PX, + }} + > + {node.children.length > 0 ? ( + <button + type="button" + className={ + styles.expandButton + } + onClick={() => + onToggleExpand( + node.key + ) + } + aria-label={ + isExpanded + ? i18n.t( + 'Collapse {{label}}', + { label } + ) + : i18n.t( + 'Expand {{label}}', + { label } + ) + } + > + {isExpanded ? ( + <IconChevronDown16 /> + ) : ( + <IconChevronRight16 /> + )} + </button> + ) : ( + <span + className={ + styles.expandButtonPlaceholder + } + /> + )} + <Checkbox + label={label} + checked={checked} + indeterminate={ + indeterminate + } + onChange={() => + onToggleNode(node) + } + className={cx( + styles.denseCheckbox, + highlightedIndex === + toHighlightedIndex( + index, + showCustomFilterRow + ) && + styles.highlighted + )} + /> + </div> + ) + }} + /> + )} + </div> + </div> + </FilterDropdownPopover> + )} + </div> + ) +} + +DateGroupFilterInput.propTypes = { + dataKey: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + options: PropTypes.arrayOf(PropTypes.shape({ value: PropTypes.string })) + .isRequired, + type: PropTypes.string.isRequired, + filterValue: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.arrayOf(PropTypes.string), + PropTypes.object, + ]), + layerId: PropTypes.string, +} + +export default DateGroupFilterInput diff --git a/src/components/datatable/FilterInput.jsx b/src/components/datatable/FilterInput.jsx index 0741860ad6..c4ea35ae6e 100644 --- a/src/components/datatable/FilterInput.jsx +++ b/src/components/datatable/FilterInput.jsx @@ -12,9 +12,9 @@ import { RENDERER_COLOR, RENDERER_ICON, TYPE_NUMBER, - // TYPE_DATE, - // TYPE_DATETIME, - // TYPE_TIME, + TYPE_DATE, + TYPE_DATETIME, + TYPE_TIME, } from '../../constants/dataTable.js' import useOptionSet from '../../hooks/useOptionSet.js' import { @@ -38,7 +38,7 @@ import { import { formatWithSeparator } from '../../util/numbers.js' import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' import Checkbox from '../core/Checkbox.jsx' -// import DateGroupFilterInput from './DateGroupFilterInput.jsx' +import DateGroupFilterInput from './DateGroupFilterInput.jsx' import { FilterDropdownPopover, getDropdownPlacement, @@ -561,10 +561,10 @@ const FilterInput = React.memo(function FilterInput({ const filterValue = filters?.[dataKey] - /* const isDateType = - type === TYPE_DATE || type === TYPE_DATETIME || type === TYPE_TIME */ + const isDateType = + type === TYPE_DATE || type === TYPE_DATETIME || type === TYPE_TIME - /* return isDateType ? ( + return isDateType ? ( <DateGroupFilterInput dataKey={dataKey} name={name} @@ -573,9 +573,7 @@ const FilterInput = React.memo(function FilterInput({ options={options ?? []} type={type} /> - ) : */ - - return optionSetId ? ( + ) : optionSetId ? ( <OptionSetSearchableFilter dataKey={dataKey} name={name} diff --git a/src/components/datatable/__tests__/DateGroupFilterInput.spec.jsx b/src/components/datatable/__tests__/DateGroupFilterInput.spec.jsx new file mode 100644 index 0000000000..f3b50feaff --- /dev/null +++ b/src/components/datatable/__tests__/DateGroupFilterInput.spec.jsx @@ -0,0 +1,378 @@ +import { render, fireEvent, screen } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import { VirtuosoMockContext } from 'react-virtuoso' +import configureMockStore from 'redux-mock-store' +import { + DATA_FILTER_SET, + DATA_FILTER_CLEAR, +} from '../../../constants/actionTypes.js' +import { + SENTINEL_ANY_VALUE, + SENTINEL_NO_VALUE, + DATE_GROUPS_GRANULARITY, + TYPE_DATE, + TYPE_DATETIME, + TYPE_TIME, +} from '../../../constants/dataTable.js' +import DateGroupFilterInput from '../DateGroupFilterInput.jsx' + +const mockStore = configureMockStore() + +const DATETIME_VALUES = [ + { value: '2023-05-15 09:00:00.0' }, + { value: '2023-05-15 14:00:00.0' }, + { value: '2024-01-01 00:00:00.0' }, +] + +const renderDateGroupFilter = (props) => { + const store = mockStore({}) + const result = render( + <Provider store={store}> + <VirtuosoMockContext.Provider + value={{ viewportHeight: 300, itemHeight: 28 }} + > + <DateGroupFilterInput + dataKey="eventdate" + name="Event date" + layerId="layer1" + type={TYPE_DATETIME} + options={DATETIME_VALUES} + {...props} + /> + </VirtuosoMockContext.Provider> + </Provider> + ) + return { ...result, store } +} + +const getInput = () => + screen + .getByTestId('data-table-column-filter-search-Event date') + .querySelector('input') + +const openPopover = () => fireEvent.focus(getInput()) + +describe('DateGroupFilterInput - default (collapsed) tree', () => { + test('shows only root (year) nodes by default', () => { + renderDateGroupFilter() + openPopover() + expect(screen.getByLabelText('2023')).toBeInTheDocument() + expect(screen.getByLabelText('2024')).toBeInTheDocument() + expect(screen.queryByLabelText('May')).not.toBeInTheDocument() + }) + + test('expanding a year reveals its months', () => { + renderDateGroupFilter() + openPopover() + fireEvent.click(screen.getByLabelText('Expand 2023')) + expect(screen.getByLabelText('May')).toBeInTheDocument() + }) + + test('expanding down to the day level reveals hours for a DATETIME column, and the exact value under an hour', () => { + renderDateGroupFilter() + openPopover() + fireEvent.click(screen.getByLabelText('Expand 2023')) + fireEvent.click(screen.getByLabelText('Expand May')) + fireEvent.click(screen.getByLabelText('Expand 15 Monday')) + expect(screen.getByLabelText('09:00')).toBeInTheDocument() + expect(screen.getByLabelText('14:00')).toBeInTheDocument() + + fireEvent.click(screen.getByLabelText('Expand 09:00')) + expect(screen.getByLabelText('2023-05-15 09:00')).toBeInTheDocument() + }) + + test('collapsing a year hides its months again', () => { + renderDateGroupFilter() + openPopover() + fireEvent.click(screen.getByLabelText('Expand 2023')) + expect(screen.getByLabelText('May')).toBeInTheDocument() + fireEvent.click(screen.getByLabelText('Collapse 2023')) + expect(screen.queryByLabelText('May')).not.toBeInTheDocument() + }) +}) + +describe('DateGroupFilterInput - selection dispatches', () => { + test('checking a year dispatches the full date-group filter shape', () => { + const { store } = renderDateGroupFilter() + openPopover() + fireEvent.click(screen.getByLabelText('2023')) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'eventdate', + filter: { + granularity: DATE_GROUPS_GRANULARITY, + prefixes: ['2023'], + }, + }) + }) + + test('unchecking the only selected prefix dispatches DATA_FILTER_CLEAR', () => { + const { store } = renderDateGroupFilter({ + filterValue: { + granularity: DATE_GROUPS_GRANULARITY, + prefixes: ['2023'], + }, + }) + openPopover() + fireEvent.click(screen.getByLabelText('2023')) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_CLEAR, + layerId: 'layer1', + fieldId: 'eventdate', + }) + }) + + test('checking a month drops the now-redundant year-level ancestor selection scenario in reverse: checking a day under an unrelated selected month keeps both', () => { + const { store } = renderDateGroupFilter({ + filterValue: { + granularity: DATE_GROUPS_GRANULARITY, + prefixes: ['2024'], + }, + }) + openPopover() + fireEvent.click(screen.getByLabelText('Expand 2023')) + fireEvent.click(screen.getByLabelText('May')) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'eventdate', + filter: { + granularity: DATE_GROUPS_GRANULARITY, + prefixes: ['2024', '2023-05'], + }, + }) + }) +}) + +describe('DateGroupFilterInput - tri-state checkbox rendering', () => { + test('a year is checked when its own prefix is selected', () => { + renderDateGroupFilter({ + filterValue: { + granularity: DATE_GROUPS_GRANULARITY, + prefixes: ['2023'], + }, + }) + openPopover() + expect(screen.getByLabelText('2023')).toBeChecked() + }) + + test('a year is indeterminate when only a descendant prefix is selected', () => { + renderDateGroupFilter({ + filterValue: { + granularity: DATE_GROUPS_GRANULARITY, + prefixes: ['2023-05'], + }, + }) + openPopover() + const yearCheckbox = screen.getByLabelText('2023') + expect(yearCheckbox.indeterminate).toBe(true) + expect(yearCheckbox.checked).toBe(false) + }) + + test('a month is checked (not indeterminate) when its ancestor year is selected', () => { + renderDateGroupFilter({ + filterValue: { + granularity: DATE_GROUPS_GRANULARITY, + prefixes: ['2023'], + }, + }) + openPopover() + fireEvent.click(screen.getByLabelText('Expand 2023')) + const monthCheckbox = screen.getByLabelText('May') + expect(monthCheckbox.checked).toBe(true) + expect(monthCheckbox.indeterminate).toBe(false) + }) +}) + +describe('DateGroupFilterInput - "Any value" / "No value"', () => { + test('"No value" is only shown when the options include the not-set sentinel', () => { + renderDateGroupFilter() + openPopover() + expect(screen.queryByLabelText('No value')).not.toBeInTheDocument() + }) + + test('checking "Any value" dispatches the sentinel and clears prior selections', () => { + const { store } = renderDateGroupFilter({ + options: [...DATETIME_VALUES, { value: SENTINEL_NO_VALUE }], + filterValue: { + granularity: DATE_GROUPS_GRANULARITY, + prefixes: ['2023'], + }, + }) + openPopover() + fireEvent.click(screen.getByLabelText('Any value')) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'eventdate', + filter: { + granularity: DATE_GROUPS_GRANULARITY, + prefixes: [SENTINEL_ANY_VALUE], + }, + }) + }) + + test('checking "No value" preserves an existing tree selection alongside it', () => { + const { store } = renderDateGroupFilter({ + options: [...DATETIME_VALUES, { value: SENTINEL_NO_VALUE }], + filterValue: { + granularity: DATE_GROUPS_GRANULARITY, + prefixes: ['2023'], + }, + }) + openPopover() + fireEvent.click(screen.getByLabelText('No value')) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'eventdate', + filter: { + granularity: DATE_GROUPS_GRANULARITY, + prefixes: ['2023', SENTINEL_NO_VALUE], + }, + }) + }) + + test('clicking a tree node while "Any value" is active is a no-op (v1 scope boundary)', () => { + const { store } = renderDateGroupFilter({ + filterValue: { + granularity: DATE_GROUPS_GRANULARITY, + prefixes: [SENTINEL_ANY_VALUE], + }, + }) + openPopover() + fireEvent.click(screen.getByLabelText('2023')) + expect(store.getActions()).toEqual([]) + }) +}) + +describe('DateGroupFilterInput - clearing via the input’s clear ("x") button', () => { + test('clearing the closed trigger (showing "N selected") clears the whole filter', () => { + const { store } = renderDateGroupFilter({ + filterValue: { + granularity: DATE_GROUPS_GRANULARITY, + prefixes: ['2023'], + }, + }) + expect(getInput()).toHaveValue('1 selected') + fireEvent.change(getInput(), { target: { value: '' } }) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_CLEAR, + layerId: 'layer1', + fieldId: 'eventdate', + }) + }) + + test('clearing a typed search narrow while a selection is active also clears the selection (mirrors the flat filter variant)', () => { + const { store } = renderDateGroupFilter({ + filterValue: { + granularity: DATE_GROUPS_GRANULARITY, + prefixes: ['2023'], + }, + }) + openPopover() + fireEvent.change(getInput(), { target: { value: '2023-05' } }) + expect(screen.queryByLabelText('2024')).not.toBeInTheDocument() + + fireEvent.change(getInput(), { target: { value: '' } }) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_CLEAR, + layerId: 'layer1', + fieldId: 'eventdate', + }) + }) + + test('clearing empty search text with no active filter dispatches nothing', () => { + const { store } = renderDateGroupFilter() + openPopover() + fireEvent.change(getInput(), { target: { value: '' } }) + expect(store.getActions()).toEqual([]) + }) +}) + +describe('DateGroupFilterInput - search', () => { + test('typing narrows to matching branches and auto-expands their ancestors', () => { + renderDateGroupFilter() + openPopover() + fireEvent.change(getInput(), { target: { value: '2023-05' } }) + expect(screen.getByLabelText('2023')).toBeInTheDocument() + expect(screen.getByLabelText('May')).toBeInTheDocument() + expect(screen.queryByLabelText('2024')).not.toBeInTheDocument() + }) + + test('typed letters are stripped, so a matching numeric prefix still narrows even amid disallowed characters', () => { + renderDateGroupFilter() + openPopover() + fireEvent.change(getInput(), { target: { value: 'x2023-05y' } }) + expect(getInput()).toHaveValue('2023-05') + expect(screen.getByLabelText('May')).toBeInTheDocument() + expect(screen.queryByLabelText('2024')).not.toBeInTheDocument() + }) + + test('typing text with no exact tree match shows a live-applying "Contains" custom filter row', () => { + const { store } = renderDateGroupFilter() + openPopover() + fireEvent.change(getInput(), { target: { value: '2023-05-15 09:0' } }) + expect( + screen.getByTestId('data-table-column-filter-custom-Event date') + ).toBeInTheDocument() + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'eventdate', + filter: '2023-05-15 09:0', + }) + }) + + test('stays shown and keeps live-applying even when the typed text exactly matches a tree node prefix (e.g. a full year)', () => { + const { store } = renderDateGroupFilter() + openPopover() + fireEvent.change(getInput(), { target: { value: '202' } }) + fireEvent.change(getInput(), { target: { value: '2023' } }) + expect( + screen.getByTestId('data-table-column-filter-custom-Event date') + ).toBeInTheDocument() + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'eventdate', + filter: '2023', + }) + }) +}) + +describe('DateGroupFilterInput - granularity variants', () => { + test('TYPE_DATE stops the Y/M/D hierarchy at the day level: real values (not hour buckets) appear under it, formatted like the column', () => { + renderDateGroupFilter({ + type: TYPE_DATE, + options: [ + { value: '2023-05-15 09:00:00.0' }, + { value: '2023-05-15 14:00:00.0' }, + ], + }) + openPopover() + fireEvent.click(screen.getByLabelText('Expand 2023')) + fireEvent.click(screen.getByLabelText('Expand May')) + expect(screen.getByLabelText('15 Monday')).toBeInTheDocument() + expect(screen.queryByLabelText('09:00')).not.toBeInTheDocument() + + fireEvent.click(screen.getByLabelText('Expand 15 Monday')) + expect(screen.getAllByLabelText('2023-05-15')).toHaveLength(2) + }) + + test('TYPE_TIME shows a flat hour-only list, no year/month/day levels, with real values under each hour', () => { + renderDateGroupFilter({ + type: TYPE_TIME, + options: [{ value: '09:00:00' }, { value: '14:30:00' }], + }) + openPopover() + expect(screen.getByLabelText('09:00')).toBeInTheDocument() + expect(screen.getByLabelText('14:00')).toBeInTheDocument() + expect(screen.queryByLabelText('2023')).not.toBeInTheDocument() + + fireEvent.click(screen.getByLabelText('Expand 09:00')) + expect(screen.getByLabelText('09:00:00')).toBeInTheDocument() + }) +}) diff --git a/src/util/__tests__/dateGroups.spec.js b/src/util/__tests__/dateGroups.spec.js new file mode 100644 index 0000000000..561b49ce54 --- /dev/null +++ b/src/util/__tests__/dateGroups.spec.js @@ -0,0 +1,330 @@ +import { + TYPE_DATE, + TYPE_DATETIME, + TYPE_TIME, +} from '../../constants/dataTable.js' +import { + parseDateGroupKey, + buildDateGroupTree, + getNodeCheckState, + toggleDateGroupPrefix, + flattenVisibleNodes, + formatNodeLabel, + getSearchMatches, + nodeMatchesOrHasMatch, +} from '../dateGroups.js' + +describe('parseDateGroupKey', () => { + it('parses a space-delimited datetime, preserving the space as the hour prefix delimiter', () => { + expect( + parseDateGroupKey('2023-05-15 14:23:00.0', TYPE_DATETIME) + ).toEqual({ + year: '2023', + month: '2023-05', + day: '2023-05-15', + hour: '2023-05-15 14', + }) + }) + + it('parses a T-delimited datetime, preserving the T as the hour prefix delimiter', () => { + expect(parseDateGroupKey('2023-05-15T14:23:00', TYPE_DATETIME)).toEqual( + { + year: '2023', + month: '2023-05', + day: '2023-05-15', + hour: '2023-05-15T14', + } + ) + }) + + it('returns a null hour when the raw value has no time component', () => { + expect(parseDateGroupKey('2023-05-15', TYPE_DATETIME)).toEqual({ + year: '2023', + month: '2023-05', + day: '2023-05-15', + hour: null, + }) + }) + + it('never includes an hour prefix for TYPE_DATE granularity, even if the raw value has a time part', () => { + expect(parseDateGroupKey('2023-05-15 14:23:00.0', TYPE_DATE)).toEqual({ + year: '2023', + month: '2023-05', + day: '2023-05-15', + hour: null, + }) + }) + + it('parses a bare hour for TYPE_TIME granularity', () => { + expect(parseDateGroupKey('14:23:00', TYPE_TIME)).toEqual({ hour: '14' }) + }) + + it('returns null for unparseable values', () => { + expect(parseDateGroupKey('not-a-date', TYPE_DATETIME)).toBeNull() + expect(parseDateGroupKey('not-a-time', TYPE_TIME)).toBeNull() + }) +}) + +describe('buildDateGroupTree', () => { + it('TYPE_DATE stops the Y/M/D hierarchy at the day level: its children are the real values, formatted like the column, not hour buckets', () => { + const values = ['2023-05-15 09:00:00.0', '2023-05-15 14:00:00.0'] + const tree = buildDateGroupTree(values, TYPE_DATE) + expect(tree).toHaveLength(1) // one year + const [year] = tree + expect(year.children).toHaveLength(1) // one month + const [month] = year.children + expect(month.children).toHaveLength(1) // one day + const [day] = month.children + expect(day.children.every((c) => c.level === 'value')).toBe(true) + expect(day.children.map((c) => c.label)).toEqual([ + '2023-05-15', + '2023-05-15', + ]) + }) + + it('TYPE_DATETIME builds the full Year -> Month -> Day -> Hour -> value tree, and real values are formatted like the column', () => { + const values = [ + '2023-05-15 09:00:00.0', + '2023-05-15 09:30:00.0', + '2023-05-15 14:00:00.0', + '2023-06-01 00:00:00.0', + ] + const tree = buildDateGroupTree(values, TYPE_DATETIME) + expect(tree).toHaveLength(1) + const [year] = tree + expect(year.key).toBe('2023') + expect(year.children.map((m) => m.key)).toEqual(['2023-05', '2023-06']) + const may = year.children[0] + expect(may.children).toHaveLength(1) // one day (15th) + const day15 = may.children[0] + expect(day15.children.map((h) => h.key)).toEqual([ + '2023-05-15 09', + '2023-05-15 14', + ]) + const hour09 = day15.children[0] + expect(hour09.children.map((v) => v.label)).toEqual([ + '2023-05-15 09:00', + '2023-05-15 09:30', + ]) + }) + + it('TYPE_TIME builds a flat Hour -> value tree, real values formatted verbatim (no date to format)', () => { + const tree = buildDateGroupTree( + ['09:00:00', '14:00:00', '09:30:00'], + TYPE_TIME + ) + expect(tree.map((h) => h.key)).toEqual(['09', '14']) + expect(tree[0].children.map((v) => v.label)).toEqual([ + '09:00:00', + '09:30:00', + ]) + expect(tree[1].children.map((v) => v.label)).toEqual(['14:00:00']) + }) + + it('sorts nodes ascending at every level regardless of input order', () => { + const values = ['2024-01-01', '2023-05-15', '2023-01-01'] + const tree = buildDateGroupTree(values, TYPE_DATE) + expect(tree.map((y) => y.key)).toEqual(['2023', '2024']) + expect(tree[0].children.map((m) => m.key)).toEqual([ + '2023-01', + '2023-05', + ]) + }) + + it('buckets unparseable values as root-level leaf nodes instead of dropping them', () => { + const tree = buildDateGroupTree(['2023-05-15', 'garbage'], TYPE_DATE) + const leaf = tree.find((n) => n.level === 'leaf') + expect(leaf).toEqual({ + key: 'garbage', + level: 'leaf', + label: 'garbage', + prefix: 'garbage', + children: [], + }) + }) +}) + +describe('getNodeCheckState', () => { + const dayNode = { prefix: '2023-05-15' } + + it('is checked when the node itself is selected', () => { + expect(getNodeCheckState(dayNode, ['2023-05-15'])).toBe('checked') + }) + + it('is checked when an ancestor prefix is selected', () => { + expect(getNodeCheckState(dayNode, ['2023'])).toBe('checked') + }) + + it('is indeterminate when only a descendant prefix is selected', () => { + expect(getNodeCheckState(dayNode, ['2023-05-15 09'])).toBe( + 'indeterminate' + ) + }) + + it('is unchecked otherwise', () => { + expect(getNodeCheckState(dayNode, ['2023-06-01'])).toBe('unchecked') + expect(getNodeCheckState(dayNode, [])).toBe('unchecked') + }) +}) + +describe('toggleDateGroupPrefix', () => { + it('selects an unchecked node', () => { + expect(toggleDateGroupPrefix([], { prefix: '2023' })).toEqual(['2023']) + }) + + it('deselects a node that is checked via its own prefix', () => { + expect( + toggleDateGroupPrefix(['2023-01', '2023'], { prefix: '2023' }) + ).toEqual(['2023-01']) + }) + + it('selecting a node drops now-redundant descendant prefixes', () => { + expect( + toggleDateGroupPrefix(['2023-01', '2023-02'], { prefix: '2023' }) + ).toEqual(['2023']) + }) + + it('is a no-op when checked only via an already-selected ancestor', () => { + const selected = ['2023'] + expect( + toggleDateGroupPrefix(selected, { prefix: '2023-05-15 09' }) + ).toBe(selected) + }) + + it('selecting an indeterminate node adds it without touching unrelated selections', () => { + expect( + toggleDateGroupPrefix(['2024'], { prefix: '2023-05-15' }) + ).toEqual(['2024', '2023-05-15']) + }) +}) + +describe('flattenVisibleNodes', () => { + const tree = [ + { + key: '2023', + children: [ + { + key: '2023-05', + children: [{ key: '2023-05-15', children: [] }], + }, + ], + }, + { key: '2024', children: [] }, + ] + + it('shows only root nodes when nothing is expanded', () => { + expect( + flattenVisibleNodes(tree, new Set()).map((r) => r.node.key) + ).toEqual(['2023', '2024']) + }) + + it('shows children of an expanded node at depth + 1', () => { + const result = flattenVisibleNodes(tree, new Set(['2023'])) + expect(result.map((r) => [r.node.key, r.depth])).toEqual([ + ['2023', 0], + ['2023-05', 1], + ['2024', 0], + ]) + }) + + it('recurses into nested expanded nodes', () => { + const result = flattenVisibleNodes(tree, new Set(['2023', '2023-05'])) + expect(result.map((r) => r.node.key)).toEqual([ + '2023', + '2023-05', + '2023-05-15', + '2024', + ]) + }) +}) + +describe('formatNodeLabel', () => { + it('formats a year node verbatim', () => { + expect(formatNodeLabel({ level: 'year', key: '2023' }, 'en')).toBe( + '2023' + ) + }) + + it('formats a month node as just a localized month name (no year - the ancestor year node already shows it)', () => { + expect(formatNodeLabel({ level: 'month', key: '2023-05' }, 'en')).toBe( + 'May' + ) + }) + + it('formats a day node as the day number first, followed by the full weekday name', () => { + expect(formatNodeLabel({ level: 'day', key: '2023-05-15' }, 'en')).toBe( + '15 Monday' + ) + }) + + it('zero-pads a single-digit day number', () => { + expect(formatNodeLabel({ level: 'day', key: '2023-05-01' }, 'en')).toBe( + '01 Monday' + ) + }) + + it('formats a value node using its precomputed, column-matching label', () => { + expect( + formatNodeLabel( + { level: 'value', key: 'x', label: '2023-05-15 09:00' }, + 'en' + ) + ).toBe('2023-05-15 09:00') + }) + + it('formats an hour node (date-scoped or bare) as "HH:00"', () => { + expect( + formatNodeLabel({ level: 'hour', key: '2023-05-15 09' }, 'en') + ).toBe('09:00') + expect(formatNodeLabel({ level: 'hour', key: '09' }, 'en')).toBe( + '09:00' + ) + }) + + it('falls back to the raw key for a leaf node', () => { + expect(formatNodeLabel({ level: 'leaf', key: 'garbage' }, 'en')).toBe( + 'garbage' + ) + }) +}) + +describe('getSearchMatches / nodeMatchesOrHasMatch', () => { + const tree = buildDateGroupTree( + ['2023-05-15 09:00:00.0', '2024-01-01 00:00:00.0'], + TYPE_DATETIME + ) + + it('a year-number search matches every node whose raw prefix starts with that year, since a descendant prefix is always a literal extension of its ancestors', () => { + const { matchedKeys, expandedAncestorKeys } = getSearchMatches( + tree, + '2024' + ) + expect(matchedKeys.has('2024')).toBe(true) + expect(matchedKeys.has('2024-01')).toBe(true) + expect(matchedKeys.has('2024-01-01 00:00:00.0')).toBe(true) + expect(matchedKeys.has('2023')).toBe(false) + // the value match's ancestors get force-expanded + expect(expandedAncestorKeys.has('2024')).toBe(true) + expect(expandedAncestorKeys.has('2024-01')).toBe(true) + expect(expandedAncestorKeys.has('2024-01-01')).toBe(true) + expect(expandedAncestorKeys.has('2024-01-01 00')).toBe(true) + }) + + it('matches a deep node by a longer numeric prefix and reports every ancestor key', () => { + const { matchedKeys, expandedAncestorKeys } = getSearchMatches( + tree, + '2023-05' + ) + expect(matchedKeys.has('2023-05')).toBe(true) + expect(matchedKeys.has('2024-01')).toBe(false) + expect(expandedAncestorKeys.has('2023')).toBe(true) + }) + + it('nodeMatchesOrHasMatch is true for a match and for any ancestor of a match', () => { + const { matchedKeys } = getSearchMatches(tree, '2023-05') + const yearNode = tree.find((n) => n.key === '2023') + expect(nodeMatchesOrHasMatch(yearNode, matchedKeys)).toBe(true) + const otherYear = tree.find((n) => n.key === '2024') + expect(nodeMatchesOrHasMatch(otherYear, matchedKeys)).toBe(false) + }) +}) diff --git a/src/util/dateGroups.js b/src/util/dateGroups.js new file mode 100644 index 0000000000..b7fafc652d --- /dev/null +++ b/src/util/dateGroups.js @@ -0,0 +1,217 @@ +import { TYPE_DATETIME, TYPE_TIME } from '../constants/dataTable.js' +import { formatDate, formatDatetime } from './helpers.js' +import { dateLocale } from './time.js' + +const DATE_KEY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})(?:([T ])(\d{2}))?/ +const TIME_KEY_PATTERN = /^(\d{2}):/ + +export const parseDateGroupKey = (rawValue, granularity) => { + const str = String(rawValue) + + if (granularity === TYPE_TIME) { + const match = str.match(TIME_KEY_PATTERN) + return match ? { hour: match[1] } : null + } + + const match = str.match(DATE_KEY_PATTERN) + if (!match) { + return null + } + const [, year, month, day, delimiter, hour] = match + return { + year, + month: `${year}-${month}`, + day: `${year}-${month}-${day}`, + hour: + granularity === TYPE_DATETIME && delimiter && hour + ? `${year}-${month}-${day}${delimiter}${hour}` + : null, + } +} + +const getOrCreateNode = (childMap, { key, level, label }) => { + let node = childMap.get(key) + if (!node) { + node = { key, level, label, prefix: key, childMap: new Map() } + childMap.set(key, node) + } + return node +} + +const sortedNodes = (childMap) => + Array.from(childMap.values()) + .sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)) + .map((node) => ({ + key: node.key, + level: node.level, + label: node.label, + prefix: node.prefix, + children: sortedNodes(node.childMap), + })) + +const getValueFormatter = (granularity) => + granularity === TYPE_DATETIME || granularity === TYPE_TIME + ? formatDatetime + : formatDate + +export const buildDateGroupTree = (values, granularity) => { + const rootMap = new Map() + const unparseable = [] + const formatValue = getValueFormatter(granularity) + + values.forEach((value) => { + const parsed = parseDateGroupKey(value, granularity) + if (!parsed) { + unparseable.push(value) + return + } + + if (granularity === TYPE_TIME) { + const hourNode = getOrCreateNode(rootMap, { + key: parsed.hour, + level: 'hour', + }) + getOrCreateNode(hourNode.childMap, { + key: value, + level: 'value', + label: formatValue(value), + }) + return + } + + const yearNode = getOrCreateNode(rootMap, { + key: parsed.year, + level: 'year', + }) + const monthNode = getOrCreateNode(yearNode.childMap, { + key: parsed.month, + level: 'month', + }) + const dayNode = getOrCreateNode(monthNode.childMap, { + key: parsed.day, + level: 'day', + }) + const valueParentNode = parsed.hour + ? getOrCreateNode(dayNode.childMap, { + key: parsed.hour, + level: 'hour', + }) + : dayNode + getOrCreateNode(valueParentNode.childMap, { + key: value, + level: 'value', + label: formatValue(value), + }) + }) + + const tree = sortedNodes(rootMap) + const leaves = unparseable.map((value) => ({ + key: value, + level: 'leaf', + label: value, + prefix: value, + children: [], + })) + + return [...tree, ...leaves] +} + +export const getNodeCheckState = (node, selectedPrefixes) => { + if ( + selectedPrefixes.some( + (prefix) => node.prefix === prefix || node.prefix.startsWith(prefix) + ) + ) { + return 'checked' + } + if (selectedPrefixes.some((prefix) => prefix.startsWith(node.prefix))) { + return 'indeterminate' + } + return 'unchecked' +} + +export const toggleDateGroupPrefix = (selectedPrefixes, node) => { + const state = getNodeCheckState(node, selectedPrefixes) + if (state === 'checked') { + return selectedPrefixes.includes(node.prefix) + ? selectedPrefixes.filter((prefix) => prefix !== node.prefix) + : selectedPrefixes + } + return [ + ...selectedPrefixes.filter((prefix) => !prefix.startsWith(node.prefix)), + node.prefix, + ] +} + +export const flattenVisibleNodes = (tree, expandedKeys) => { + const result = [] + const walk = (nodes, depth) => { + nodes.forEach((node) => { + result.push({ node, depth }) + if (node.children.length && expandedKeys.has(node.key)) { + walk(node.children, depth + 1) + } + }) + } + walk(tree, 0) + return result +} + +const getHourLabel = (key) => { + const match = key.match(/(\d{2})$/) + return match ? `${match[1]}:00` : key +} + +export const formatNodeLabel = (node, locale) => { + const bcp47Locale = dateLocale(locale) + switch (node.level) { + case 'year': + return node.key + case 'month': { + const [, month] = node.key.split('-').map(Number) + return new Intl.DateTimeFormat(bcp47Locale, { + month: 'long', + }).format(new Date(2000, month - 1, 1)) + } + case 'day': { + const [year, month, day] = node.key.split('-').map(Number) + const date = new Date(year, month - 1, day) + const weekday = new Intl.DateTimeFormat(bcp47Locale, { + weekday: 'long', + }).format(date) + const dayNumber = String(date.getDate()).padStart(2, '0') + return `${dayNumber} ${weekday}` + } + case 'hour': + return getHourLabel(node.key) + case 'value': + case 'leaf': + return node.label ?? node.key + default: + return node.key + } +} + +const collectMatches = (nodes, ancestors, options) => { + const { normalizedSearch, result } = options + nodes.forEach((node) => { + const isMatch = node.prefix.toLowerCase().includes(normalizedSearch) + if (isMatch) { + result.matchedKeys.add(node.key) + ancestors.forEach((key) => result.expandedAncestorKeys.add(key)) + } + if (node.children.length) { + collectMatches(node.children, [...ancestors, node.key], options) + } + }) +} + +export const getSearchMatches = (tree, normalizedSearch) => { + const result = { matchedKeys: new Set(), expandedAncestorKeys: new Set() } + collectMatches(tree, [], { normalizedSearch, result }) + return result +} + +export const nodeMatchesOrHasMatch = (node, matchedKeys) => + matchedKeys.has(node.key) || + node.children.some((child) => nodeMatchesOrHasMatch(child, matchedKeys)) From bb9b605be16142f3a501f5ab167c9c902bb4a016 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Fri, 24 Jul 2026 20:34:54 +0200 Subject: [PATCH 122/205] feat: hierarchical drill-down filter for org unit data table columns --- i18n/en.pot | 38 +- src/components/datatable/DataTable.jsx | 33 +- src/components/datatable/FilterInput.jsx | 38 +- .../datatable/OrgUnitGroupFilterInput.jsx | 550 ++++++++++++++++++ .../OrgUnitGroupFilterInput.spec.jsx | 331 +++++++++++ .../datatable/__tests__/useTableData.spec.jsx | 416 +++++++++---- src/components/datatable/useTableData.js | 40 +- src/constants/dataTable.js | 14 + .../__tests__/useOrgUnitAncestorNames.spec.js | 64 ++ src/hooks/useOrgUnitAncestorNames.js | 54 ++ src/loaders/__tests__/eventLoader.spec.js | 54 ++ .../__tests__/trackedEntityLoader.spec.js | 16 + src/loaders/eventLoader.js | 17 + src/loaders/trackedEntityLoader.js | 13 +- src/util/__tests__/dateGroups.spec.js | 158 +---- src/util/__tests__/filter.spec.js | 86 ++- src/util/__tests__/map.spec.js | 57 +- src/util/__tests__/orgUnitGroups.spec.js | 177 ++++++ src/util/__tests__/orgUnits.spec.js | 82 +++ src/util/__tests__/prefixTree.spec.js | 145 +++++ src/util/__tests__/tableHeaders.spec.js | 76 ++- src/util/dateGroups.js | 97 +-- src/util/filter.js | 45 +- src/util/map.js | 28 +- src/util/orgUnitGroups.js | 96 +++ src/util/orgUnits.js | 45 ++ src/util/prefixTree.js | 78 +++ src/util/requests.js | 11 + src/util/tableHeaders.js | 120 ++-- 29 files changed, 2545 insertions(+), 434 deletions(-) create mode 100644 src/components/datatable/OrgUnitGroupFilterInput.jsx create mode 100644 src/components/datatable/__tests__/OrgUnitGroupFilterInput.spec.jsx create mode 100644 src/hooks/__tests__/useOrgUnitAncestorNames.spec.js create mode 100644 src/hooks/useOrgUnitAncestorNames.js create mode 100644 src/util/__tests__/orgUnitGroups.spec.js create mode 100644 src/util/__tests__/prefixTree.spec.js create mode 100644 src/util/orgUnitGroups.js create mode 100644 src/util/prefixTree.js diff --git a/i18n/en.pot b/i18n/en.pot index 148fa6cb89..6950b4c77d 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-24T09:14:13.417Z\n" -"PO-Revision-Date: 2026-07-24T09:14:13.417Z\n" +"POT-Creation-Date: 2026-07-24T13:25:30.129Z\n" +"PO-Revision-Date: 2026-07-24T13:25:30.129Z\n" msgid "2020" msgstr "2020" @@ -281,6 +281,15 @@ msgstr "Reverse selection" msgid "Too many values to list - type to filter this column" msgstr "Too many values to list - type to filter this column" +msgid "Select a country, region, district or facility" +msgstr "Select a country, region, district or facility" + +msgid "to match the rows under it, or type to search" +msgstr "to match the rows under it, or type to search" + +msgid "Select matches" +msgstr "Select matches" + msgid "Selected" msgstr "Selected" @@ -2117,18 +2126,27 @@ msgstr "GroupSet used for styling was not found" msgid "Id" msgstr "Id" -msgid "Type" -msgstr "Type" - -msgid "Range" -msgstr "Range" +msgid "Org unit Id" +msgstr "Org unit Id" msgid "Org unit" msgstr "Org unit" +msgid "Org unit level" +msgstr "Org unit level" + +msgid "Geometry type" +msgstr "Geometry type" + +msgid "Range" +msgstr "Range" + msgid "Org unit boundary" msgstr "Org unit boundary" +msgid "Org unit hierarchy" +msgstr "Org unit hierarchy" + msgid "Group" msgstr "Group" @@ -2141,6 +2159,12 @@ msgstr "Current period" msgid "Value ({{period}})" msgstr "Value ({{period}})" +msgid "Event Id" +msgstr "Event Id" + +msgid "Tracked entity Id" +msgstr "Tracked entity Id" + msgid "Start date is invalid" msgstr "Start date is invalid" diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index a6d9691cc7..b51f8a219b 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -33,7 +33,10 @@ import { RENDERER_COLOR, RENDERER_ICON, RENDERER_DATE, + RENDERER_ORG_UNIT, + RENDERER_ORG_UNIT_NAME, TYPE_DATE, + ORG_UNIT_ID_DATA_KEY, } from '../../constants/dataTable.js' import { isDarkColor } from '../../util/colors.js' import { @@ -47,6 +50,10 @@ import { } from '../../util/dataTable.js' import { formatDate, formatDatetime } from '../../util/helpers.js' import { formatWithSeparator } from '../../util/numbers.js' +import { + formatOrgUnitOwnName, + formatOrgUnitPathBreadcrumb, +} from '../../util/orgUnitGroups.js' import { getPinnedCellProps, getPinnedCount, @@ -179,6 +186,7 @@ const Table = ({ totalCount, filteredCount, columnOptions, + orgUnitIdToName, } = useTableData({ layer, sortField, @@ -474,6 +482,7 @@ const Table = ({ options={columnOptions[dataKey]} optionSetId={optionSet?.id} renderer={renderer} + orgUnitIdToName={orgUnitIdToName} /> ) } @@ -535,6 +544,7 @@ const Table = ({ isAllSelected, onToggleSelectAll, headerRowRef, + orgUnitIdToName, ] ) @@ -625,6 +635,10 @@ const Table = ({ const isDateCell = renderer === RENDERER_DATE const isDateOnlyCell = typeByDataKey.get(dataKey) === TYPE_DATE + const isOrgUnitHierarchyCell = + renderer === RENDERER_ORG_UNIT + const isOrgUnitNameCell = + renderer === RENDERER_ORG_UNIT_NAME return ( <DataTableCell key={`dtcell-${dataKey}`} @@ -637,7 +651,10 @@ const Table = ({ isColorCell && isDarkColor(value), [styles.monoCell]: - dataKey === 'id' || isColorCell, + dataKey === 'id' || + dataKey === + ORG_UNIT_ID_DATA_KEY || + isColorCell, [styles.selected]: isSelected && !isColorCell, [styles.hovered]: @@ -667,9 +684,23 @@ const Table = ({ (isDateOnlyCell ? formatDate(value) : formatDatetime(value))} + {isOrgUnitHierarchyCell && + value && + formatOrgUnitPathBreadcrumb( + value, + orgUnitIdToName + )} + {isOrgUnitNameCell && + value && + formatOrgUnitOwnName( + value, + orgUnitIdToName + )} {!isColorCell && !isIconCell && !isDateCell && + !isOrgUnitHierarchyCell && + !isOrgUnitNameCell && formatWithSeparator( value, keyAnalysisDigitGroupSeparator diff --git a/src/components/datatable/FilterInput.jsx b/src/components/datatable/FilterInput.jsx index c4ea35ae6e..d4cec63b3c 100644 --- a/src/components/datatable/FilterInput.jsx +++ b/src/components/datatable/FilterInput.jsx @@ -11,10 +11,14 @@ import { SENTINEL_NO_VALUE, RENDERER_COLOR, RENDERER_ICON, + RENDERER_ORG_UNIT, + RENDERER_ORG_UNIT_NAME, TYPE_NUMBER, TYPE_DATE, TYPE_DATETIME, TYPE_TIME, + TYPE_ORG_UNIT, + ORG_UNIT_ID_DATA_KEY, } from '../../constants/dataTable.js' import useOptionSet from '../../hooks/useOptionSet.js' import { @@ -36,6 +40,10 @@ import { toggleRealValue, } from '../../util/filterSelection.js' import { formatWithSeparator } from '../../util/numbers.js' +import { + formatOrgUnitPathBreadcrumb, + formatOrgUnitOwnName, +} from '../../util/orgUnitGroups.js' import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' import Checkbox from '../core/Checkbox.jsx' import DateGroupFilterInput from './DateGroupFilterInput.jsx' @@ -44,6 +52,7 @@ import { getDropdownPlacement, } from './FilterDropdownPopover.jsx' import FilterHelpTooltip from './FilterHelpTooltip.jsx' +import OrgUnitGroupFilterInput from './OrgUnitGroupFilterInput.jsx' import styles from './styles/FilterInput.module.css' const NUMERIC_HELP_HEIGHT = 140 @@ -156,9 +165,7 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({ const font = `11px ${getComputedStyle(document.body).fontFamily}` const maxLabelWidth = measureMaxTextWidth(labels, font) return getPopoverWidth(maxLabelWidth) - // resolveLabel's identity only changes alongside type/optionSet, which don't change without realOptions changing too - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [realOptions, hasNotSetOption]) + }, [realOptions, hasNotSetOption, resolveLabel]) const onToggleAnyValue = () => applyValues(toggleAnyValue(selected)) @@ -446,6 +453,8 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({ className={cx( styles.denseCheckbox, (dataKey === 'id' || + dataKey === + ORG_UNIT_ID_DATA_KEY || renderer === RENDERER_COLOR) && styles.monoOption, @@ -484,7 +493,7 @@ SearchableFilterPopover.propTypes = { } const PlainSearchableFilter = (props) => { - const { type } = props + const { type, renderer, orgUnitIdToName } = props const { systemSettings: { keyAnalysisDigitGroupSeparator }, } = useCachedData() @@ -494,6 +503,12 @@ const PlainSearchableFilter = (props) => { if (value === SENTINEL_NO_VALUE) { return i18n.t('No value') } + if (renderer === RENDERER_ORG_UNIT) { + return formatOrgUnitPathBreadcrumb(value, orgUnitIdToName) + } + if (renderer === RENDERER_ORG_UNIT_NAME) { + return formatOrgUnitOwnName(value, orgUnitIdToName) + } return type === TYPE_NUMBER ? formatWithSeparator( Number(value), @@ -501,13 +516,15 @@ const PlainSearchableFilter = (props) => { ) : value }, - [type, keyAnalysisDigitGroupSeparator] + [type, renderer, orgUnitIdToName, keyAnalysisDigitGroupSeparator] ) return <SearchableFilterPopover {...props} resolveLabel={resolveLabel} /> } PlainSearchableFilter.propTypes = { + orgUnitIdToName: PropTypes.instanceOf(Map), + renderer: PropTypes.string, type: PropTypes.string, } @@ -545,6 +562,7 @@ const FilterInput = React.memo(function FilterInput({ options, optionSetId, renderer, + orgUnitIdToName, }) { const dataTable = useSelector((state) => state.dataTable) const map = useSelector((state) => state.map) @@ -573,6 +591,14 @@ const FilterInput = React.memo(function FilterInput({ options={options ?? []} type={type} /> + ) : type === TYPE_ORG_UNIT ? ( + <OrgUnitGroupFilterInput + dataKey={dataKey} + name={name} + layerId={layerId} + filterValue={filterValue} + options={options ?? []} + /> ) : optionSetId ? ( <OptionSetSearchableFilter dataKey={dataKey} @@ -593,6 +619,7 @@ const FilterInput = React.memo(function FilterInput({ options={options ?? []} type={type} renderer={renderer} + orgUnitIdToName={orgUnitIdToName} /> ) }) @@ -603,6 +630,7 @@ FilterInput.propTypes = { type: PropTypes.string.isRequired, optionSetId: PropTypes.string, options: PropTypes.arrayOf(PropTypes.shape({ value: PropTypes.string })), + orgUnitIdToName: PropTypes.instanceOf(Map), renderer: PropTypes.string, } diff --git a/src/components/datatable/OrgUnitGroupFilterInput.jsx b/src/components/datatable/OrgUnitGroupFilterInput.jsx new file mode 100644 index 0000000000..e6b397f513 --- /dev/null +++ b/src/components/datatable/OrgUnitGroupFilterInput.jsx @@ -0,0 +1,550 @@ +import i18n from '@dhis2/d2-i18n' +import { + Input, + IconChevronRight16, + IconChevronDown16, + IconFilter16, +} from '@dhis2/ui' +import cx from 'classnames' +import PropTypes from 'prop-types' +import React, { useCallback, useMemo, useRef, useState } from 'react' +import { useDispatch } from 'react-redux' +import { Virtuoso } from 'react-virtuoso' +import { setDataFilter, clearDataFilter } from '../../actions/dataFilters.js' +import { + SENTINEL_ANY_VALUE, + SENTINEL_NO_VALUE, + ORG_UNIT_GROUPS_GRANULARITY, +} from '../../constants/dataTable.js' +import useOrgUnitAncestorNames from '../../hooks/useOrgUnitAncestorNames.js' +import { isOrgUnitGroupFilter } from '../../util/filter.js' +import { + OPTION_ROW_HEIGHT, + MAX_LIST_HEIGHT, + getCyclicIndex, + getDisplayValue, + toOptionIndex, + toHighlightedIndex, +} from '../../util/filterInput.js' +import { toggleAnyValue } from '../../util/filterSelection.js' +import { + buildOrgUnitGroupTree, + formatOrgUnitNodeLabel, + getOrgUnitSearchMatches, +} from '../../util/orgUnitGroups.js' +import { + getNodeCheckState, + togglePrefix, + flattenAllNodes, + flattenVisibleNodes, + nodeMatchesOrHasMatch, +} from '../../util/prefixTree.js' +import Checkbox from '../core/Checkbox.jsx' +import { + FilterDropdownPopover, + getDropdownPlacement, +} from './FilterDropdownPopover.jsx' +import FilterHelpTooltip from './FilterHelpTooltip.jsx' +import styles from './styles/FilterInput.module.css' + +const ORG_UNIT_GROUP_POPOVER_WIDTH = 220 +const HELP_HEIGHT = 56 +const HELP_CONTENT = ( + <div> + <div>{i18n.t('Select a country, region, district or facility')}</div> + <div>{i18n.t('to match the rows under it, or type to search')}</div> + </div> +) +const INDENT_PX = 16 + +const OrgUnitGroupFilterInput = ({ + dataKey, + name, + layerId, + filterValue, + options, +}) => { + const dispatch = useDispatch() + const anchorRef = useRef(null) + const listRef = useRef(null) + const [isOpen, setIsOpen] = useState(false) + const [searchText, setSearchText] = useState('') + const [expandedKeys, setExpandedKeys] = useState(() => new Set()) + const [highlightedIndex, setHighlightedIndex] = useState(-1) + + // A committed free-text search is kept out of `selectedPrefixes` on + // purpose - like every other column's typed "Contains" filter, it + // narrows the table live but does not show any checkbox as checked + // (see applyCustomFilter below). + const selectedPrefixes = + isOrgUnitGroupFilter(filterValue) && !filterValue.searchDerived + ? filterValue.prefixes + : [] + const appliedString = isOrgUnitGroupFilter(filterValue) + ? filterValue.searchDerived + ? filterValue.searchText + : '' + : typeof filterValue === 'string' + ? filterValue + : '' + const anyValueActive = selectedPrefixes.includes(SENTINEL_ANY_VALUE) + const notSetActive = selectedPrefixes.includes(SENTINEL_NO_VALUE) + const treePrefixes = selectedPrefixes.filter( + (p) => p !== SENTINEL_ANY_VALUE && p !== SENTINEL_NO_VALUE + ) + const hasActiveFilter = selectedPrefixes.length > 0 || appliedString !== '' + + const openPopover = () => { + setSearchText(appliedString) + setHighlightedIndex(-1) + setIsOpen(true) + } + const closePopover = () => setIsOpen(false) + + const anchorRect = anchorRef.current?.getBoundingClientRect() + const { dropdownPlacement, dropdownSide, tooltipPlacement } = + getDropdownPlacement(anchorRect) + + const applyValues = useCallback( + (nextPrefixes) => + nextPrefixes.length + ? dispatch( + setDataFilter(layerId, dataKey, { + granularity: ORG_UNIT_GROUPS_GRANULARITY, + prefixes: nextPrefixes, + }) + ) + : dispatch(clearDataFilter(layerId, dataKey)), + [dispatch, layerId, dataKey] + ) + + const hasNotSetOption = options.some( + ({ value }) => value === SENTINEL_NO_VALUE + ) + const realValues = useMemo( + () => + options + .filter(({ value }) => value !== SENTINEL_NO_VALUE) + .map((o) => o.value), + [options] + ) + + const tree = useMemo(() => buildOrgUnitGroupTree(realValues), [realValues]) + + const { idToName } = useOrgUnitAncestorNames(realValues) + + const nodeByKey = useMemo(() => { + const map = new Map() + flattenAllNodes(tree).forEach((node) => map.set(node.key, node)) + return map + }, [tree]) + + const normalizedSearch = searchText.trim().toLowerCase() + const searchMatches = useMemo( + () => + normalizedSearch + ? getOrgUnitSearchMatches(tree, normalizedSearch, idToName) + : null, + [tree, normalizedSearch, idToName] + ) + const effectiveExpanded = useMemo( + () => + searchMatches + ? new Set([ + ...expandedKeys, + ...searchMatches.expandedAncestorKeys, + ]) + : expandedKeys, + [expandedKeys, searchMatches] + ) + + const visibleNodes = useMemo(() => { + const flattened = flattenVisibleNodes(tree, effectiveExpanded) + if (!searchMatches) { + return flattened + } + return flattened.filter(({ node }) => + nodeMatchesOrHasMatch(node, searchMatches.matchedKeys) + ) + }, [tree, effectiveExpanded, searchMatches]) + + const showCustomFilterRow = normalizedSearch !== '' + const totalCount = visibleNodes.length + (showCustomFilterRow ? 1 : 0) + + const onToggleExpand = (key) => + setExpandedKeys((prev) => { + const next = new Set(prev) + if (next.has(key)) { + next.delete(key) + } else { + next.add(key) + } + return next + }) + + const checkStateFor = (node) => + anyValueActive ? 'checked' : getNodeCheckState(node, treePrefixes) + + const onToggleNode = (node) => { + if (anyValueActive) { + return + } + const nextTreePrefixes = togglePrefix(treePrefixes, node) + applyValues( + notSetActive + ? [...nextTreePrefixes, SENTINEL_NO_VALUE] + : nextTreePrefixes + ) + } + + const onToggleAnyValue = () => applyValues(toggleAnyValue(selectedPrefixes)) + + const onToggleNotSet = () => + applyValues( + notSetActive + ? selectedPrefixes.filter((p) => p !== SENTINEL_NO_VALUE) + : [...selectedPrefixes, SENTINEL_NO_VALUE] + ) + + // Unlike dates, an org unit's raw stored value is an id (or id path), + // never the human-readable name a user actually types here - matching + // "Contains" against that raw value would silently match nothing for + // any real-world search term. Committing free text instead narrows the + // table to every currently name/id-matched org unit - same live-as-you- + // type "Contains" semantics every other column's filter already has, + // dispatched with `searchDerived` so it (like every other column's + // typed filter) never shows as a checked box while typing. + const applyCustomFilter = (text) => { + const trimmed = text.trim() + if (!trimmed) { + dispatch(clearDataFilter(layerId, dataKey)) + return + } + const matches = getOrgUnitSearchMatches( + tree, + trimmed.toLowerCase(), + idToName + ) + const matchedPrefixes = [...matches.matchedKeys] + .map((key) => nodeByKey.get(key)) + .filter(Boolean) + .map((node) => node.prefix) + if (!matchedPrefixes.length) { + dispatch(clearDataFilter(layerId, dataKey)) + return + } + dispatch( + setDataFilter(layerId, dataKey, { + granularity: ORG_UNIT_GROUPS_GRANULARITY, + prefixes: matchedPrefixes, + searchDerived: true, + searchText: trimmed, + }) + ) + } + + const onSearchChange = ({ value }) => { + setSearchText(value) + setHighlightedIndex(-1) + + const trimmed = value.trim() + if (trimmed === '') { + if (hasActiveFilter) { + dispatch(clearDataFilter(layerId, dataKey)) + } + return + } + + applyCustomFilter(trimmed) + } + + const scrollHighlightedIntoView = (index) => { + const optionIndex = toOptionIndex(index, showCustomFilterRow) + if (optionIndex >= 0 && optionIndex < visibleNodes.length) { + listRef.current?.scrollToIndex({ + index: optionIndex, + align: 'center', + }) + } + } + + const onEnterKey = () => { + if (highlightedIndex === -1) { + if (showCustomFilterRow) { + applyCustomFilter(searchText.trim()) + } + return + } + if (showCustomFilterRow && highlightedIndex === 0) { + applyCustomFilter(searchText.trim()) + return + } + const optionIndex = toOptionIndex(highlightedIndex, showCustomFilterRow) + if (optionIndex >= 0 && optionIndex < visibleNodes.length) { + onToggleNode(visibleNodes[optionIndex].node) + } + } + + const onSearchKeyDown = (_, event) => { + const optionIndex = toOptionIndex(highlightedIndex, showCustomFilterRow) + const { node } = visibleNodes[optionIndex] ?? {} + switch (event.key) { + case 'ArrowDown': + event.preventDefault() + setHighlightedIndex((i) => { + const next = getCyclicIndex(i, totalCount, 1) + scrollHighlightedIntoView(next) + return next + }) + break + case 'ArrowUp': + event.preventDefault() + setHighlightedIndex((i) => { + const next = getCyclicIndex(i, totalCount, -1) + scrollHighlightedIntoView(next) + return next + }) + break + case 'ArrowRight': + if (node?.children.length && !effectiveExpanded.has(node.key)) { + event.preventDefault() + onToggleExpand(node.key) + } + break + case 'ArrowLeft': + if (node?.children.length && effectiveExpanded.has(node.key)) { + event.preventDefault() + onToggleExpand(node.key) + } + break + case 'Enter': + event.preventDefault() + onEnterKey() + closePopover() + break + case 'Escape': + event.preventDefault() + closePopover() + break + default: + break + } + } + + const displayValue = getDisplayValue({ + isOpen, + searchText, + selected: selectedPrefixes, + appliedString, + }) + + return ( + <div className={styles.filterTrigger} ref={anchorRef}> + <FilterHelpTooltip + content={HELP_CONTENT} + placement={tooltipPlacement} + estimatedHeight={HELP_HEIGHT} + dataTest="data-table-filter-help" + > + <Input + dense + clearable + dataTest={`data-table-column-filter-search-${name}`} + placeholder={i18n.t('Search')} + value={displayValue} + onFocus={() => { + if (!isOpen) { + openPopover() + } + }} + onChange={onSearchChange} + onKeyDown={onSearchKeyDown} + /> + </FilterHelpTooltip> + {isOpen && ( + <FilterDropdownPopover + reference={anchorRef} + placement={dropdownPlacement} + onClickOutside={closePopover} + className={cx( + styles.dropdownPopper, + dropdownSide === 'top' && styles.dropdownPopperAbove + )} + > + <div + className={cx(styles.searchableFilterPopover, { + [styles.reversedOrder]: dropdownSide === 'top', + })} + style={{ width: `${ORG_UNIT_GROUP_POPOVER_WIDTH}px` }} + > + {showCustomFilterRow && ( + <button + type="button" + className={cx(styles.customFilterRow, { + [styles.highlighted]: + highlightedIndex === 0, + })} + data-test={`data-table-column-filter-custom-${name}`} + onClick={() => { + applyCustomFilter(searchText.trim()) + closePopover() + }} + > + <IconFilter16 /> + <span className={styles.customFilterTag}> + {i18n.t('Select matches')} + </span> + <span className={styles.customFilterExpr}> + {searchText.trim()} + </span> + </button> + )} + <div className={styles.pinnedOptions}> + <Checkbox + label={i18n.t('Any value')} + checked={anyValueActive} + onChange={onToggleAnyValue} + className={cx( + styles.specialOption, + styles.denseCheckbox + )} + dataTest={`data-table-column-filter-any-${name}`} + /> + {hasNotSetOption && ( + <Checkbox + label={i18n.t('No value')} + checked={notSetActive} + onChange={onToggleNotSet} + className={cx( + styles.specialOption, + styles.denseCheckbox + )} + dataTest={`data-table-column-filter-novalue-${name}`} + /> + )} + </div> + <div className={styles.multiSelectPopover}> + {!showCustomFilterRow && + visibleNodes.length === 0 && ( + <div className={styles.noResults}> + {i18n.t('No matches')} + </div> + )} + {visibleNodes.length > 0 && ( + <Virtuoso + ref={listRef} + style={{ + height: Math.min( + visibleNodes.length * + OPTION_ROW_HEIGHT, + MAX_LIST_HEIGHT + ), + }} + increaseViewportBy={{ + top: 0, + bottom: OPTION_ROW_HEIGHT * 2, + }} + data={visibleNodes} + fixedItemHeight={OPTION_ROW_HEIGHT} + computeItemKey={(_, { node }) => node.key} + itemContent={(index, { node, depth }) => { + const state = checkStateFor(node) + const checked = state === 'checked' + const indeterminate = + state === 'indeterminate' + const isExpanded = + effectiveExpanded.has(node.key) + const label = formatOrgUnitNodeLabel( + node, + idToName + ) + return ( + <div + className={styles.treeRow} + style={{ + paddingLeft: + depth * INDENT_PX, + }} + > + {node.children.length > 0 ? ( + <button + type="button" + className={ + styles.expandButton + } + onClick={() => + onToggleExpand( + node.key + ) + } + aria-label={ + isExpanded + ? i18n.t( + 'Collapse {{label}}', + { label } + ) + : i18n.t( + 'Expand {{label}}', + { label } + ) + } + > + {isExpanded ? ( + <IconChevronDown16 /> + ) : ( + <IconChevronRight16 /> + )} + </button> + ) : ( + <span + className={ + styles.expandButtonPlaceholder + } + /> + )} + <Checkbox + label={label} + checked={checked} + indeterminate={ + indeterminate + } + onChange={() => + onToggleNode(node) + } + className={cx( + styles.denseCheckbox, + highlightedIndex === + toHighlightedIndex( + index, + showCustomFilterRow + ) && + styles.highlighted + )} + /> + </div> + ) + }} + /> + )} + </div> + </div> + </FilterDropdownPopover> + )} + </div> + ) +} + +OrgUnitGroupFilterInput.propTypes = { + dataKey: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + options: PropTypes.arrayOf(PropTypes.shape({ value: PropTypes.string })) + .isRequired, + filterValue: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.arrayOf(PropTypes.string), + PropTypes.object, + ]), + layerId: PropTypes.string, +} + +export default OrgUnitGroupFilterInput diff --git a/src/components/datatable/__tests__/OrgUnitGroupFilterInput.spec.jsx b/src/components/datatable/__tests__/OrgUnitGroupFilterInput.spec.jsx new file mode 100644 index 0000000000..3b37e7cacf --- /dev/null +++ b/src/components/datatable/__tests__/OrgUnitGroupFilterInput.spec.jsx @@ -0,0 +1,331 @@ +import { render, fireEvent, screen } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import { VirtuosoMockContext } from 'react-virtuoso' +import configureMockStore from 'redux-mock-store' +import { + DATA_FILTER_SET, + DATA_FILTER_CLEAR, +} from '../../../constants/actionTypes.js' +import { + SENTINEL_ANY_VALUE, + SENTINEL_NO_VALUE, + ORG_UNIT_GROUPS_GRANULARITY, +} from '../../../constants/dataTable.js' +import useOrgUnitAncestorNames from '../../../hooks/useOrgUnitAncestorNames.js' +import OrgUnitGroupFilterInput from '../OrgUnitGroupFilterInput.jsx' + +jest.mock('../../../hooks/useOrgUnitAncestorNames.js', () => ({ + __esModule: true, + default: jest.fn(), +})) + +const mockStore = configureMockStore() + +const ORG_UNIT_VALUES = [ + { value: '/country1/region1/facility1' }, + { value: '/country1/region2/facility2' }, + { value: '/country2/facility3' }, +] + +const renderOrgUnitGroupFilter = (props) => { + const store = mockStore({}) + const result = render( + <Provider store={store}> + <VirtuosoMockContext.Provider + value={{ viewportHeight: 300, itemHeight: 28 }} + > + <OrgUnitGroupFilterInput + dataKey="orgUnitPath" + name="Org unit" + layerId="layer1" + options={ORG_UNIT_VALUES} + {...props} + /> + </VirtuosoMockContext.Provider> + </Provider> + ) + return { ...result, store } +} + +const getInput = () => + screen + .getByTestId('data-table-column-filter-search-Org unit') + .querySelector('input') + +const openPopover = () => fireEvent.focus(getInput()) + +beforeEach(() => { + useOrgUnitAncestorNames.mockReturnValue({ + idToName: new Map(), + loading: false, + }) +}) + +describe('OrgUnitGroupFilterInput - default (collapsed) tree', () => { + test('shows only root nodes by default', () => { + renderOrgUnitGroupFilter() + openPopover() + expect(screen.getByLabelText('country1')).toBeInTheDocument() + expect(screen.getByLabelText('country2')).toBeInTheDocument() + expect(screen.queryByLabelText('region1')).not.toBeInTheDocument() + }) + + test('expanding a root node reveals its children', () => { + renderOrgUnitGroupFilter() + openPopover() + fireEvent.click(screen.getByLabelText('Expand country1')) + expect(screen.getByLabelText('region1')).toBeInTheDocument() + expect(screen.getByLabelText('region2')).toBeInTheDocument() + }) + + test('an org unit id is naturally a leaf - no separate terminal node beneath it', () => { + renderOrgUnitGroupFilter() + openPopover() + fireEvent.click(screen.getByLabelText('Expand country2')) + expect(screen.getByLabelText('facility3')).toBeInTheDocument() + expect( + screen.queryByLabelText('Expand facility3') + ).not.toBeInTheDocument() + }) + + test('collapsing a root node hides its children again', () => { + renderOrgUnitGroupFilter() + openPopover() + fireEvent.click(screen.getByLabelText('Expand country1')) + expect(screen.getByLabelText('region1')).toBeInTheDocument() + fireEvent.click(screen.getByLabelText('Collapse country1')) + expect(screen.queryByLabelText('region1')).not.toBeInTheDocument() + }) +}) + +describe('OrgUnitGroupFilterInput - label resolution', () => { + test('shows the raw id as a placeholder label until the name resolves', () => { + renderOrgUnitGroupFilter() + openPopover() + expect(screen.getByLabelText('country1')).toBeInTheDocument() + }) + + test('shows the resolved name once idToName has it', () => { + useOrgUnitAncestorNames.mockReturnValue({ + idToName: new Map([['country1', 'Sierra Leone']]), + loading: false, + }) + renderOrgUnitGroupFilter() + openPopover() + expect(screen.getByLabelText('Sierra Leone')).toBeInTheDocument() + expect(screen.queryByLabelText('country1')).not.toBeInTheDocument() + }) +}) + +describe('OrgUnitGroupFilterInput - selection dispatches', () => { + test('checking a root node dispatches the full org-unit-group filter shape', () => { + const { store } = renderOrgUnitGroupFilter() + openPopover() + fireEvent.click(screen.getByLabelText('country1')) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'orgUnitPath', + filter: { + granularity: ORG_UNIT_GROUPS_GRANULARITY, + prefixes: ['/country1'], + }, + }) + }) + + test('unchecking the only selected prefix dispatches DATA_FILTER_CLEAR', () => { + const { store } = renderOrgUnitGroupFilter({ + filterValue: { + granularity: ORG_UNIT_GROUPS_GRANULARITY, + prefixes: ['/country1'], + }, + }) + openPopover() + fireEvent.click(screen.getByLabelText('country1')) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_CLEAR, + layerId: 'layer1', + fieldId: 'orgUnitPath', + }) + }) +}) + +describe('OrgUnitGroupFilterInput - tri-state checkbox rendering', () => { + test('a root node is checked when its own prefix is selected', () => { + renderOrgUnitGroupFilter({ + filterValue: { + granularity: ORG_UNIT_GROUPS_GRANULARITY, + prefixes: ['/country1'], + }, + }) + openPopover() + expect(screen.getByLabelText('country1')).toBeChecked() + }) + + test('a root node is indeterminate when only a descendant prefix is selected', () => { + renderOrgUnitGroupFilter({ + filterValue: { + granularity: ORG_UNIT_GROUPS_GRANULARITY, + prefixes: ['/country1/region1'], + }, + }) + openPopover() + const countryCheckbox = screen.getByLabelText('country1') + expect(countryCheckbox.indeterminate).toBe(true) + expect(countryCheckbox.checked).toBe(false) + }) + + test('a descendant is checked (not indeterminate) when its ancestor is selected', () => { + renderOrgUnitGroupFilter({ + filterValue: { + granularity: ORG_UNIT_GROUPS_GRANULARITY, + prefixes: ['/country1'], + }, + }) + openPopover() + fireEvent.click(screen.getByLabelText('Expand country1')) + const regionCheckbox = screen.getByLabelText('region1') + expect(regionCheckbox.checked).toBe(true) + expect(regionCheckbox.indeterminate).toBe(false) + }) +}) + +describe('OrgUnitGroupFilterInput - "Any value" / "No value"', () => { + test('"No value" is only shown when the options include the not-set sentinel', () => { + renderOrgUnitGroupFilter() + openPopover() + expect(screen.queryByLabelText('No value')).not.toBeInTheDocument() + }) + + test('checking "Any value" dispatches the sentinel and clears prior selections', () => { + const { store } = renderOrgUnitGroupFilter({ + options: [...ORG_UNIT_VALUES, { value: SENTINEL_NO_VALUE }], + filterValue: { + granularity: ORG_UNIT_GROUPS_GRANULARITY, + prefixes: ['/country1'], + }, + }) + openPopover() + fireEvent.click(screen.getByLabelText('Any value')) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'orgUnitPath', + filter: { + granularity: ORG_UNIT_GROUPS_GRANULARITY, + prefixes: [SENTINEL_ANY_VALUE], + }, + }) + }) + + test('clicking a tree node while "Any value" is active is a no-op', () => { + const { store } = renderOrgUnitGroupFilter({ + filterValue: { + granularity: ORG_UNIT_GROUPS_GRANULARITY, + prefixes: [SENTINEL_ANY_VALUE], + }, + }) + openPopover() + fireEvent.click(screen.getByLabelText('country1')) + expect(store.getActions()).toEqual([]) + }) +}) + +describe('OrgUnitGroupFilterInput - search', () => { + test('typing narrows to matching branches and auto-expands their ancestors', () => { + renderOrgUnitGroupFilter() + openPopover() + fireEvent.change(getInput(), { target: { value: 'region1' } }) + expect(screen.getByLabelText('country1')).toBeInTheDocument() + expect(screen.getByLabelText('region1')).toBeInTheDocument() + expect(screen.queryByLabelText('country2')).not.toBeInTheDocument() + }) + + test('letters are allowed (unlike the date variant) since org unit ids/names are not purely numeric', () => { + renderOrgUnitGroupFilter() + openPopover() + fireEvent.change(getInput(), { target: { value: 'facility3' } }) + expect(getInput()).toHaveValue('facility3') + expect(screen.getByLabelText('country2')).toBeInTheDocument() + }) + + test('also narrows by resolved name, not just raw id', () => { + useOrgUnitAncestorNames.mockReturnValue({ + idToName: new Map([['country1', 'Sierra Leone']]), + loading: false, + }) + renderOrgUnitGroupFilter() + openPopover() + fireEvent.change(getInput(), { target: { value: 'Sierra' } }) + expect(screen.getByLabelText('Sierra Leone')).toBeInTheDocument() + expect(screen.queryByLabelText('country2')).not.toBeInTheDocument() + }) + + test('typing text with no tree match shows the custom filter row but clears rather than filtering by the raw id/path', () => { + const { store } = renderOrgUnitGroupFilter() + openPopover() + fireEvent.change(getInput(), { target: { value: 'Nairobi' } }) + expect( + screen.getByTestId('data-table-column-filter-custom-Org unit') + ).toBeInTheDocument() + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_CLEAR, + layerId: 'layer1', + fieldId: 'orgUnitPath', + }) + expect(store.getActions()).not.toContainEqual( + expect.objectContaining({ type: DATA_FILTER_SET }) + ) + }) + + test('committing a name-matched custom filter dispatches the matched nodes’ prefixes, not a raw substring match against the id path', () => { + useOrgUnitAncestorNames.mockReturnValue({ + idToName: new Map([['country1', 'Sierra Leone']]), + loading: false, + }) + const { store } = renderOrgUnitGroupFilter() + openPopover() + fireEvent.change(getInput(), { target: { value: 'Sierra' } }) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'orgUnitPath', + filter: { + granularity: ORG_UNIT_GROUPS_GRANULARITY, + prefixes: ['/country1'], + searchDerived: true, + searchText: 'Sierra', + }, + }) + }) + + test('a committed name-matched search narrows the table live but does not show any checkbox as checked - same as every other column’s typed "Contains" filter', () => { + useOrgUnitAncestorNames.mockReturnValue({ + idToName: new Map([['country1', 'Sierra Leone']]), + loading: false, + }) + renderOrgUnitGroupFilter({ + filterValue: { + granularity: ORG_UNIT_GROUPS_GRANULARITY, + prefixes: ['/country1'], + searchDerived: true, + searchText: 'Sierra', + }, + }) + openPopover() + expect(screen.getByLabelText('Sierra Leone').checked).toBe(false) + }) + + test('reopening after a committed search re-shows the typed text, not "N selected"', () => { + renderOrgUnitGroupFilter({ + filterValue: { + granularity: ORG_UNIT_GROUPS_GRANULARITY, + prefixes: ['/country1'], + searchDerived: true, + searchText: 'Sierra', + }, + }) + expect(getInput()).toHaveValue('Sierra') + }) +}) diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index 9f941e961c..b085b4bb71 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -6,14 +6,27 @@ import { SENTINEL_SELECTED_ROW, SENTINEL_NO_VALUE, } from '../../../constants/dataTable.js' +import useOrgUnitAncestorNames from '../../../hooks/useOrgUnitAncestorNames.js' import { useTableData } from '../useTableData.js' jest.mock('../../map/MapApi.js', () => ({ loadEarthEngineWorker: jest.fn(), })) +jest.mock('../../../hooks/useOrgUnitAncestorNames.js', () => ({ + __esModule: true, + default: jest.fn(), +})) + const mockStore = configureMockStore() +beforeEach(() => { + useOrgUnitAncestorNames.mockReturnValue({ + idToName: new Map(), + loading: false, + }) +}) + describe('useTableData headers', () => { test('gets headers and rows for facility layer', () => { const store = { @@ -48,17 +61,25 @@ describe('useTableData headers', () => { ) const { headers, rows, isLoading } = result.current - expect(headers).toHaveLength(3) + expect(headers).toHaveLength(5) expect(headers).toMatchObject([ - { name: 'Name', dataKey: 'name', type: 'string' }, - { name: 'Id', dataKey: 'id', type: 'string' }, - { name: 'Type', dataKey: 'type', type: 'string' }, + { name: 'Org unit Id', dataKey: 'id', type: 'string' }, + { name: 'Org unit', dataKey: 'orgUnitOwn', type: 'string' }, + { name: 'Org unit level', dataKey: 'level', type: 'number' }, + { + name: 'Org unit hierarchy', + dataKey: 'orgUnitPath', + type: 'orgUnit', + }, + { name: 'Geometry type', dataKey: 'type', type: 'string' }, ]) expect(rows).toHaveLength(1) - expect(rows[0]).toHaveLength(3) + expect(rows[0]).toHaveLength(5) expect(rows[0]).toMatchObject([ - { value: 'Facility 1', dataKey: 'name' }, { value: 'facility-1', dataKey: 'id' }, + { value: undefined, dataKey: 'orgUnitOwn' }, + { value: null, dataKey: 'level' }, + { value: undefined, dataKey: 'orgUnitPath' }, { value: 'Point', dataKey: 'type' }, ]) expect(isLoading).toBe(false) @@ -200,19 +221,23 @@ describe('useTableData headers', () => { const { headers, rows, isLoading } = result.current expect(headers).toHaveLength(5) expect(headers).toMatchObject([ - { name: 'Name', dataKey: 'name', type: 'string' }, - { name: 'Id', dataKey: 'id', type: 'string' }, - { name: 'Level', dataKey: 'level', type: 'number' }, - { name: 'Parent', dataKey: 'parentName', type: 'string' }, - { name: 'Type', dataKey: 'type', type: 'string' }, + { name: 'Org unit Id', dataKey: 'id', type: 'string' }, + { name: 'Org unit', dataKey: 'orgUnitOwn', type: 'string' }, + { name: 'Org unit level', dataKey: 'level', type: 'number' }, + { + name: 'Org unit hierarchy', + dataKey: 'orgUnitPath', + type: 'orgUnit', + }, + { name: 'Geometry type', dataKey: 'type', type: 'string' }, ]) expect(rows).toHaveLength(1) expect(rows[0]).toHaveLength(5) expect(rows[0]).toMatchObject([ - { value: 'OrgUnitName 1', dataKey: 'name' }, { value: 'orgunit-id-1', dataKey: 'id' }, + { value: undefined, dataKey: 'orgUnitOwn' }, { value: 3, dataKey: 'level' }, - { value: 'Bo', dataKey: 'parentName' }, + { value: undefined, dataKey: 'orgUnitPath' }, { value: 'MultiPolygon', dataKey: 'type' }, ]) expect(isLoading).toBe(false) @@ -258,12 +283,15 @@ describe('useTableData headers', () => { const { headers, rows, isLoading } = result.current expect(headers).toHaveLength(9) expect(headers).toMatchObject([ - { name: 'Name', dataKey: 'name', type: 'string' }, - { name: 'Id', dataKey: 'id', type: 'string' }, + { name: 'Org unit Id', dataKey: 'id', type: 'string' }, + { name: 'Org unit', dataKey: 'orgUnitOwn', type: 'string' }, + { name: 'Org unit level', dataKey: 'level', type: 'number' }, + { + name: 'Org unit hierarchy', + dataKey: 'orgUnitPath', + type: 'orgUnit', + }, { name: 'Value', dataKey: 'rawValue', type: 'number' }, - { name: 'Level', dataKey: 'level', type: 'number' }, - { name: 'Parent', dataKey: 'parentName', type: 'string' }, - { name: 'Type', dataKey: 'type', type: 'string' }, { name: 'Legend', dataKey: 'legend', type: 'string' }, { name: 'Range', dataKey: 'range', type: 'string' }, { @@ -272,19 +300,20 @@ describe('useTableData headers', () => { type: 'string', renderer: 'rendercolor', }, + { name: 'Geometry type', dataKey: 'type', type: 'string' }, ]) expect(rows).toHaveLength(1) expect(rows[0]).toHaveLength(9) expect(rows[0]).toMatchObject([ - { value: 'Ngelehun CHC', dataKey: 'name' }, { value: 'thematicId-1', dataKey: 'id' }, - { value: 106.3, dataKey: 'rawValue' }, + { value: undefined, dataKey: 'orgUnitOwn' }, { value: 4, dataKey: 'level' }, - { value: 'Badjia', dataKey: 'parentName' }, - { value: 'Point', dataKey: 'type' }, + { value: undefined, dataKey: 'orgUnitPath' }, + { value: 106.3, dataKey: 'rawValue' }, { value: 'Great', dataKey: 'legend' }, { value: '90 – 120', dataKey: 'range' }, { value: '#FFFFB2', dataKey: 'color' }, + { value: 'Point', dataKey: 'type' }, ]) expect(isLoading).toBe(false) }) @@ -340,15 +369,15 @@ describe('useTableData headers', () => { ) const { headers, rows } = result.current expect(headers).toMatchObject([ - { name: 'Name', dataKey: 'name' }, - { name: 'Id', dataKey: 'id' }, + { name: 'Org unit Id', dataKey: 'id' }, + { name: 'Org unit', dataKey: 'orgUnitOwn' }, + { name: 'Org unit level', dataKey: 'level' }, + { name: 'Org unit hierarchy', dataKey: 'orgUnitPath' }, { name: 'Value (February 2023)', dataKey: 'rawValue' }, - { name: 'Level', dataKey: 'level' }, - { name: 'Parent', dataKey: 'parentName' }, - { name: 'Type', dataKey: 'type' }, { name: 'Legend (February 2023)', dataKey: 'legend' }, { name: 'Range (February 2023)', dataKey: 'range' }, { name: 'Color (February 2023)', dataKey: 'color' }, + { name: 'Geometry type', dataKey: 'type' }, ]) expect(rows[0]).toEqual( expect.arrayContaining([ @@ -457,11 +486,11 @@ describe('useTableData headers', () => { ) const { headers, rows } = result.current expect(headers).toMatchObject([ - { name: 'Name', dataKey: 'name' }, - { name: 'Id', dataKey: 'id' }, - { name: 'Level', dataKey: 'level' }, - { name: 'Parent', dataKey: 'parentName' }, - { name: 'Type', dataKey: 'type' }, + { name: 'Org unit Id', dataKey: 'id' }, + { name: 'Org unit', dataKey: 'orgUnitOwn' }, + { name: 'Org unit level', dataKey: 'level' }, + { name: 'Org unit hierarchy', dataKey: 'orgUnitPath' }, + { name: 'Geometry type', dataKey: 'type' }, { name: 'Value (January 2023)', dataKey: 'period_202301_rawValue', @@ -546,10 +575,18 @@ describe('useTableData headers', () => { } ) const { headers, rows, isLoading } = result.current - expect(headers).toHaveLength(7) + expect(headers).toHaveLength(10) expect(headers).toMatchObject([ - { name: 'Org unit', dataKey: 'ouname', type: 'string' }, - { name: 'Id', dataKey: 'id', type: 'string' }, + { name: 'Event Id', dataKey: 'id', type: 'string' }, + { name: 'Org unit Id', dataKey: 'orgUnitId', type: 'string' }, + { name: 'Org unit', dataKey: 'orgUnitOwn', type: 'string' }, + { name: 'Org unit level', dataKey: 'level', type: 'number' }, + { + name: 'Org unit hierarchy', + dataKey: 'orgUnitPath', + type: 'orgUnit', + renderer: 'renderorgunit', + }, { name: 'Event date', dataKey: 'eventdate', @@ -564,13 +601,16 @@ describe('useTableData headers', () => { }, { name: 'Event status', dataKey: 'eventstatus', type: 'string' }, { name: 'Gender', dataKey: 'oZg33kd9taw', type: 'string' }, - { name: 'Type', dataKey: 'type', type: 'string' }, + { name: 'Geometry type', dataKey: 'type', type: 'string' }, ]) expect(rows).toHaveLength(1) - expect(rows[0]).toHaveLength(7) + expect(rows[0]).toHaveLength(10) expect(rows[0]).toMatchObject([ - { value: 'Lumley Hospital', dataKey: 'ouname' }, { value: 'a9712323629', dataKey: 'id' }, + { value: undefined, dataKey: 'orgUnitId' }, + { value: undefined, dataKey: 'orgUnitOwn' }, + { value: null, dataKey: 'level' }, + { value: undefined, dataKey: 'orgUnitPath' }, { value: '2023-05-15 00:00:00.0', dataKey: 'eventdate' }, { value: '2018-04-12 20:58:51.31', dataKey: 'lastupdated' }, { value: 'ACTIVE', dataKey: 'eventstatus' }, @@ -681,20 +721,34 @@ describe('useTableData headers', () => { ) const { headers, rows, isLoading } = result.current - expect(headers).toHaveLength(4) + expect(headers).toHaveLength(9) expect(headers).toMatchObject([ - { name: 'Id', dataKey: 'id', type: 'string' }, + { name: 'Tracked entity Id', dataKey: 'id', type: 'string' }, + { name: 'Org unit Id', dataKey: 'orgUnitId', type: 'string' }, + { name: 'Org unit', dataKey: 'orgUnitOwn', type: 'string' }, + { name: 'Org unit level', dataKey: 'level', type: 'number' }, + { + name: 'Org unit hierarchy', + dataKey: 'orgUnitPath', + type: 'orgUnit', + }, { name: 'First name', dataKey: 'w75KJ2mc4zz', type: 'string' }, { name: 'Age', dataKey: 'zDhUuAYrxNC', type: 'number' }, { name: 'Color', dataKey: 'color', type: 'string' }, + { name: 'Geometry type', dataKey: 'type', type: 'string' }, ]) expect(rows).toHaveLength(1) - expect(rows[0]).toHaveLength(4) + expect(rows[0]).toHaveLength(9) expect(rows[0]).toMatchObject([ { value: 'PsgJS8BUxZd', dataKey: 'id' }, + { value: undefined, dataKey: 'orgUnitId' }, + { value: undefined, dataKey: 'orgUnitOwn' }, + { value: null, dataKey: 'level' }, + { value: undefined, dataKey: 'orgUnitPath' }, { value: 'Gabrielle', dataKey: 'w75KJ2mc4zz' }, { value: 28, dataKey: 'zDhUuAYrxNC' }, { value: '#e57200', dataKey: 'color' }, + { value: undefined, dataKey: 'type' }, ]) expect(isLoading).toBe(false) }) @@ -1034,11 +1088,16 @@ describe('useTableData headers', () => { ) const { headers, rows, isLoading } = result.current - expect(headers).toHaveLength(5) + expect(headers).toHaveLength(7) expect(headers).toMatchObject([ - { name: 'Name', dataKey: 'name', type: 'string' }, - { name: 'Id', dataKey: 'id', type: 'string' }, - { name: 'Type', dataKey: 'type', type: 'string' }, + { name: 'Org unit Id', dataKey: 'id', type: 'string' }, + { name: 'Org unit', dataKey: 'orgUnitOwn', type: 'string' }, + { name: 'Org unit level', dataKey: 'level', type: 'number' }, + { + name: 'Org unit hierarchy', + dataKey: 'orgUnitPath', + type: 'orgUnit', + }, { name: 'Sum Population', dataKey: 'sum', @@ -1051,17 +1110,20 @@ describe('useTableData headers', () => { // roundFn: Function.prototype, type: 'number', }, + { name: 'Geometry type', dataKey: 'type', type: 'string' }, ]) - expect(headers[3].roundFn).toBeInstanceOf(Function) expect(headers[4].roundFn).toBeInstanceOf(Function) + expect(headers[5].roundFn).toBeInstanceOf(Function) expect(rows).toHaveLength(2) - expect(rows[0]).toHaveLength(5) + expect(rows[0]).toHaveLength(7) expect(rows[0]).toMatchObject([ - { value: 'Bo', dataKey: 'name' }, { value: 'boOu', dataKey: 'id' }, - { value: 'Polygon', dataKey: 'type' }, + { value: undefined, dataKey: 'orgUnitOwn' }, + { value: null, dataKey: 'level' }, + { value: undefined, dataKey: 'orgUnitPath' }, { value: 851091, dataKey: 'sum' }, { value: 47.35, dataKey: 'mean' }, + { value: 'Polygon', dataKey: 'type' }, ]) expect(isLoading).toBe(false) }) @@ -1189,11 +1251,16 @@ describe('useTableData headers', () => { ) const { headers, rows, isLoading } = result.current - expect(headers).toHaveLength(5) + expect(headers).toHaveLength(7) expect(headers).toMatchObject([ - { name: 'Name', dataKey: 'name', type: 'string' }, - { name: 'Id', dataKey: 'id', type: 'string' }, - { name: 'Type', dataKey: 'type', type: 'string' }, + { name: 'Org unit Id', dataKey: 'id', type: 'string' }, + { name: 'Org unit', dataKey: 'orgUnitOwn', type: 'string' }, + { name: 'Org unit level', dataKey: 'level', type: 'number' }, + { + name: 'Org unit hierarchy', + dataKey: 'orgUnitPath', + type: 'orgUnit', + }, { name: 'Sum Population Age Groups', dataKey: 'sum', @@ -1206,17 +1273,20 @@ describe('useTableData headers', () => { // roundFn: Function.prototype, type: 'number', }, + { name: 'Geometry type', dataKey: 'type', type: 'string' }, ]) - expect(headers[3].roundFn).toBeInstanceOf(Function) expect(headers[4].roundFn).toBeInstanceOf(Function) + expect(headers[5].roundFn).toBeInstanceOf(Function) expect(rows).toHaveLength(2) - expect(rows[0]).toHaveLength(5) + expect(rows[0]).toHaveLength(7) expect(rows[0]).toMatchObject([ - { value: 'Badija', dataKey: 'name' }, { value: 'boOU', dataKey: 'id' }, - { value: 'Polygon', dataKey: 'type' }, + { value: undefined, dataKey: 'orgUnitOwn' }, + { value: null, dataKey: 'level' }, + { value: undefined, dataKey: 'orgUnitPath' }, { value: 2517, dataKey: 'sum' }, { value: 3.976, dataKey: 'mean' }, + { value: 'Polygon', dataKey: 'type' }, ]) expect(isLoading).toBe(false) }) @@ -1297,7 +1367,9 @@ describe('useTableData sorting', () => { } ) - const valueColumn = result.current.rows.map((row) => row[2]?.value) // Value column + const valueColumn = result.current.rows.map( + (row) => row.find((c) => c.dataKey === 'rawValue')?.value + ) expect(valueColumn).toEqual([5, 10, 15, null, null]) }) @@ -1319,7 +1391,9 @@ describe('useTableData sorting', () => { } ) - const valueColumn = result.current.rows.map((row) => row[2]?.value) // Value column + const valueColumn = result.current.rows.map( + (row) => row.find((c) => c.dataKey === 'rawValue')?.value + ) expect(valueColumn).toEqual([15, 10, 5, null, null]) }) @@ -1329,10 +1403,10 @@ describe('useTableData sorting', () => { layer: 'thematic', dataFilters: null, data: [ - { id: '1', properties: { name: 'Zebra', value: 10 } }, - { id: '2', properties: { name: 'Apple', value: 5 } }, - { id: '3', properties: { name: undefined, value: 20 } }, - { id: '4', properties: { name: 'Banana', value: 15 } }, + { id: '1', properties: { orgUnitOwn: 'Zebra', value: 10 } }, + { id: '2', properties: { orgUnitOwn: 'Apple', value: 5 } }, + { id: '3', properties: { orgUnitOwn: undefined, value: 20 } }, + { id: '4', properties: { orgUnitOwn: 'Banana', value: 15 } }, ], } @@ -1343,7 +1417,7 @@ describe('useTableData sorting', () => { () => useTableData({ layer: layerWithStringData, - sortField: 'name', + sortField: 'orgUnitOwn', sortDirection: 'asc', }), { @@ -1353,7 +1427,9 @@ describe('useTableData sorting', () => { } ) - const nameColumn = result.current.rows.map((row) => row[0]?.value) // Name column + const nameColumn = result.current.rows.map( + (row) => row.find((c) => c.dataKey === 'orgUnitOwn')?.value + ) expect(nameColumn).toEqual(['Apple', 'Banana', 'Zebra', undefined]) }) @@ -1363,10 +1439,10 @@ describe('useTableData sorting', () => { layer: 'thematic', dataFilters: null, data: [ - { id: '1', properties: { name: 'Zebra', value: 10 } }, - { id: '2', properties: { name: 'Apple', value: 5 } }, - { id: '3', properties: { name: undefined, value: 20 } }, - { id: '4', properties: { name: 'Banana', value: 15 } }, + { id: '1', properties: { orgUnitOwn: 'Zebra', value: 10 } }, + { id: '2', properties: { orgUnitOwn: 'Apple', value: 5 } }, + { id: '3', properties: { orgUnitOwn: undefined, value: 20 } }, + { id: '4', properties: { orgUnitOwn: 'Banana', value: 15 } }, ], } @@ -1377,7 +1453,7 @@ describe('useTableData sorting', () => { () => useTableData({ layer: layerWithStringData, - sortField: 'name', + sortField: 'orgUnitOwn', sortDirection: 'desc', }), { @@ -1387,7 +1463,9 @@ describe('useTableData sorting', () => { } ) - const nameColumn = result.current.rows.map((row) => row[0]?.value) // Name column + const nameColumn = result.current.rows.map( + (row) => row.find((c) => c.dataKey === 'orgUnitOwn')?.value + ) expect(nameColumn).toEqual(['Zebra', 'Banana', 'Apple', undefined]) }) @@ -1427,7 +1505,9 @@ describe('useTableData sorting', () => { } ) - const valueColumn = result.current.rows.map((row) => row[2]?.value) // Value column + const valueColumn = result.current.rows.map( + (row) => row.find((c) => c.dataKey === 'rawValue')?.value + ) expect(valueColumn).toEqual([5, 10, null, null]) }) @@ -1469,7 +1549,9 @@ describe('useTableData sorting', () => { } ) - const valueColumn = result.current.rows.map((row) => row[2]?.value) // Value column + const valueColumn = result.current.rows.map( + (row) => row.find((c) => c.dataKey === 'rawValue')?.value + ) expect(valueColumn).toEqual([null, null, null]) }) @@ -1479,9 +1561,15 @@ describe('useTableData sorting', () => { layer: 'thematic', dataFilters: null, data: [ - { properties: { id: '1', name: 'Item C', rawValue: 3 } }, - { properties: { id: '2', name: 'Item A', rawValue: 1 } }, - { properties: { id: '3', name: 'Item B', rawValue: 2 } }, + { + properties: { id: '1', orgUnitOwn: 'Item C', rawValue: 3 }, + }, + { + properties: { id: '2', orgUnitOwn: 'Item A', rawValue: 1 }, + }, + { + properties: { id: '3', orgUnitOwn: 'Item B', rawValue: 2 }, + }, ], } const store = { aggregations: {} } @@ -1500,7 +1588,7 @@ describe('useTableData sorting', () => { ) const names = result.current.rows.map( - (row) => row.find((c) => c.dataKey === 'name')?.value + (row) => row.find((c) => c.dataKey === 'orgUnitOwn')?.value ) expect(names).toEqual(['Item C', 'Item A', 'Item B']) }) @@ -1605,8 +1693,8 @@ describe('useTableData showOnlyFeaturesInView', () => { mapBounds: bounds, }) expect(current.rows).toHaveLength(1) - expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( - 'In view' + expect(current.rows[0].find((c) => c.dataKey === 'id').value).toBe( + 'inview' ) }) @@ -1631,8 +1719,8 @@ describe('useTableData showOnlyFeaturesInView', () => { mapBounds: bounds, }) expect(current.rows).toHaveLength(1) - expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( - 'In view' + expect(current.rows[0].find((c) => c.dataKey === 'id').value).toBe( + 'inview' ) }) }) @@ -1677,9 +1765,7 @@ describe('useTableData selectionFilter', () => { selectedIdSet: new Set(['a']), }) expect(current.rows).toHaveLength(1) - expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( - 'Item A' - ) + expect(current.rows[0].find((c) => c.dataKey === 'id').value).toBe('a') }) test('includes only non-selected rows when filtered to "not-selected"', () => { @@ -1691,9 +1777,7 @@ describe('useTableData selectionFilter', () => { selectedIdSet: new Set(['a']), }) expect(current.rows).toHaveLength(1) - expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( - 'Item B' - ) + expect(current.rows[0].find((c) => c.dataKey === 'id').value).toBe('b') }) test('includes all rows when both options are checked', () => { @@ -1745,12 +1829,11 @@ describe('useTableData columnOptions', () => { { properties: { id: 'ou1', - name: 'Org unit 1', + orgUnitOwn: 'Org unit 1', rawValue: 10, legend: 'High', range: '5 - 15', level: 1, - parentName: 'Country', type: 'Point', color: '#ff0000', }, @@ -1758,12 +1841,11 @@ describe('useTableData columnOptions', () => { { properties: { id: 'ou2', - name: 'Org unit 2', + orgUnitOwn: 'Org unit 2', rawValue: 20, legend: 'Low', range: '15 - 25', level: 1, - parentName: 'Country', type: 'Point', color: '#00ff00', }, @@ -1778,7 +1860,7 @@ describe('useTableData columnOptions', () => { { value: 'Low' }, ]) expect(current.columnOptions.type).toEqual([{ value: 'Point' }]) - expect(current.columnOptions.name).toEqual([ + expect(current.columnOptions.orgUnitOwn).toEqual([ { value: 'Org unit 1' }, { value: 'Org unit 2' }, ]) @@ -1786,7 +1868,6 @@ describe('useTableData columnOptions', () => { { value: 'ou1' }, { value: 'ou2' }, ]) - expect(current.columnOptions.parentName).toEqual([{ value: 'Country' }]) expect(current.columnOptions.rawValue).toEqual([ { value: '10' }, { value: '20' }, @@ -1878,27 +1959,24 @@ describe('useTableData columnOptions', () => { { properties: { id: 'ou1', - name: 'Org unit 1', + orgUnitOwn: 'Country', level: 1, - parentName: 'Country', type: 'Point', }, }, { properties: { id: 'ou2', - name: 'Org unit 2', + orgUnitOwn: '', level: 1, - parentName: '', type: 'Point', }, }, { properties: { id: 'ou3', - name: 'Org unit 3', + // orgUnitOwn omitted entirely (undefined) level: 1, - // parentName omitted entirely (undefined) type: 'Point', }, }, @@ -1907,7 +1985,7 @@ describe('useTableData columnOptions', () => { const { current } = renderTableData(layer) - expect(current.columnOptions.parentName).toEqual([ + expect(current.columnOptions.orgUnitOwn).toEqual([ { value: SENTINEL_NO_VALUE }, { value: 'Country' }, ]) @@ -1956,22 +2034,20 @@ describe('useTableData columnOptions', () => { { properties: { id: 'ou1', - name: 'Org unit 1', + orgUnitOwn: 'Org unit 1', rawValue: 10, legend: 'High', level: 1, - parentName: 'Country', type: 'Point', }, }, { properties: { id: 'ou2', - name: 'Org unit 2', + orgUnitOwn: 'Org unit 2', rawValue: 20, legend: 'Low', level: 1, - parentName: 'Country', type: 'Point', }, }, @@ -1982,7 +2058,7 @@ describe('useTableData columnOptions', () => { () => useTableData({ layer, - sortField: 'name', + sortField: 'orgUnitOwn', sortDirection: 'desc', }), { @@ -1992,8 +2068,8 @@ describe('useTableData columnOptions', () => { } ) - // Sorted column (name, desc) is reversed to match... - expect(result.current.columnOptions.name).toEqual([ + // Sorted column (orgUnitOwn, desc) is reversed to match... + expect(result.current.columnOptions.orgUnitOwn).toEqual([ { value: 'Org unit 2' }, { value: 'Org unit 1' }, ]) @@ -2016,8 +2092,20 @@ describe('useTableData globalSearch', () => { layer: 'orgUnit', dataFilters: null, data: [ - { properties: { id: 'a', name: 'Kampala', parentName: 'Uganda' } }, - { properties: { id: 'b', name: 'Nairobi', parentName: 'Kenya' } }, + { + properties: { + id: 'facility-a', + orgUnitPath: '/country1/facility-a', + orgUnitOwn: '/country1/facility-a', + }, + }, + { + properties: { + id: 'facility-b', + orgUnitPath: '/country1/facility-b', + orgUnitOwn: '/country1/facility-b', + }, + }, ], } @@ -2026,7 +2114,7 @@ describe('useTableData globalSearch', () => { () => useTableData({ layer, - sortField: 'name', + sortField: 'id', sortDirection: 'asc', globalSearch, }), @@ -2042,11 +2130,111 @@ describe('useTableData globalSearch', () => { expect(current.rows).toHaveLength(2) }) - test('matches case-insensitively across any string column', () => { - const { current } = renderTableData('uganda') + test('matches an org-unit-typed column (Org unit/Org unit hierarchy) by its resolved name - the raw stored value is an id/path, which never contains what a user types here', () => { + useOrgUnitAncestorNames.mockReturnValue({ + idToName: new Map([ + ['country1', 'Uganda'], + ['facility-a', 'Kampala'], + ['facility-b', 'Nairobi'], + ]), + loading: false, + }) + const { current } = renderTableData('kampala') expect(current.rows).toHaveLength(1) - expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( - 'Kampala' + expect(current.rows[0].find((c) => c.dataKey === 'id').value).toBe( + 'facility-a' + ) + }) + + test('also matches a custom ORGANISATION_UNIT-valued data element on an Event layer as plain text - the events analytics query always resolves it to a name server-side, so there is no id to look up', () => { + const eventLayer = { + layer: 'event', + dataFilters: null, + isExtended: true, + headers: [ + { + name: 'c3d4e5f6a7b', + column: 'Referred by facility', + valueType: 'ORGANISATION_UNIT', + }, + ], + data: [ + { + properties: { + id: 'evt1', + type: 'Point', + eventdate: '2023-01-01', + c3d4e5f6a7b: 'Referral Hospital', + }, + }, + ], + } + const { result } = renderHook( + () => + useTableData({ + layer: eventLayer, + sortField: 'id', + sortDirection: 'asc', + globalSearch: 'referral', + }), + { + wrapper: ({ children }) => ( + <Provider store={mockStore(store)}>{children}</Provider> + ), + } + ) + expect(result.current.rows).toHaveLength(1) + expect( + result.current.rows[0].find((c) => c.dataKey === 'id').value + ).toBe('evt1') + }) + + test('matches a custom ORGANISATION_UNIT-valued attribute on a Tracked entity layer only by its raw stored value, not its resolved name - only "Org unit hierarchy" gets name-aware global search', () => { + useOrgUnitAncestorNames.mockReturnValue({ + idToName: new Map([['facility9', 'Referral Hospital']]), + loading: false, + }) + const teiLayer = { + layer: 'trackedEntity', + dataFilters: null, + headers: [ + { + name: 'Referred by facility', + dataKey: 'c3d4e5f6a7b', + valueType: 'ORGANISATION_UNIT', + }, + ], + data: [ + { + properties: { + id: 'tei1', + c3d4e5f6a7b: 'facility9', + }, + }, + ], + } + const renderTeiTableData = (globalSearch) => + renderHook( + () => + useTableData({ + layer: teiLayer, + sortField: 'id', + sortDirection: 'asc', + globalSearch, + }), + { + wrapper: ({ children }) => ( + <Provider store={mockStore(store)}>{children}</Provider> + ), + } + ).result + + expect(renderTeiTableData('referral').current.rows).toHaveLength(0) + + const { current } = renderTeiTableData('facility9') + expect(current.rows).toHaveLength(1) + expect(current.rows[0].find((c) => c.dataKey === 'id').value).toBe( + 'tei1' ) }) diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index 70933c77db..d3ef64006a 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -4,6 +4,9 @@ import { useSelector } from 'react-redux' import { SENTINEL_SELECTED_ROW, SORT_ASCENDING, + TYPE_ORG_UNIT, + RENDERER_ORG_UNIT, + RENDERER_ORG_UNIT_NAME, } from '../../constants/dataTable.js' import { EVENT_LAYER, @@ -16,6 +19,7 @@ import { SELECTION_FILTER_SELECTED, SELECTION_FILTER_NOT_SELECTED, } from '../../constants/selection.js' +import useOrgUnitAncestorNames from '../../hooks/useOrgUnitAncestorNames.js' import { filterByGlobalSearch, filterData } from '../../util/filter.js' import { buildRowCells, @@ -235,6 +239,27 @@ export const useTableData = ({ return Object.keys(result).length ? result : EMPTY_COLUMN_OPTIONS }, [columnDistinctValues, sortField, sortDirection]) + // Every column whose cell needs an id/path resolved to a readable name - + // "Org unit hierarchy" (tree-filterable) plus "Org unit" and any custom + // ORGANISATION_UNIT-valued field (plain-text filterable, but their + // cells still resolve for display) - keyed by renderer rather than + // type, since only the hierarchy column is still TYPE_ORG_UNIT. + const orgUnitPathValues = useMemo( + () => + (headers ?? []) + .filter((h) => + [RENDERER_ORG_UNIT, RENDERER_ORG_UNIT_NAME].includes( + h.renderer + ) + ) + .flatMap((h) => + (columnOptions[h.dataKey] ?? []).map((o) => o.value) + ), + [headers, columnOptions] + ) + const { idToName: orgUnitIdToName } = + useOrgUnitAncestorNames(orgUnitPathValues) + const rows = useMemo(() => { if (errorCode.current) { return null @@ -251,11 +276,14 @@ export const useTableData = ({ const stringDataKeys = headers .filter((h) => h.type === TYPE_STRING) .map((h) => h.dataKey) - filteredData = filterByGlobalSearch( - filteredData, - globalSearch, - stringDataKeys - ) + const orgUnitDataKeys = headers + .filter((h) => h.type === TYPE_ORG_UNIT) + .map((h) => h.dataKey) + filteredData = filterByGlobalSearch(filteredData, globalSearch, { + stringDataKeys, + orgUnitDataKeys, + idToName: orgUnitIdToName, + }) } if (selectionFilter?.length) { @@ -290,6 +318,7 @@ export const useTableData = ({ sortDirection, selectionFilter, selectedIdSetDependency, + orgUnitIdToName, ]) // EE layers and event layers may be loading additional data @@ -320,6 +349,7 @@ export const useTableData = ({ error: getErrorCodeText(errorCode.current), totalCount, filteredCount, + orgUnitIdToName, columnOptions, } } diff --git a/src/constants/dataTable.js b/src/constants/dataTable.js index 0bac15662d..f08391810c 100644 --- a/src/constants/dataTable.js +++ b/src/constants/dataTable.js @@ -8,11 +8,25 @@ export const SORT_DESCENDING = 'desc' export const RENDERER_COLOR = 'rendercolor' export const RENDERER_ICON = 'rendericon' export const RENDERER_DATE = 'renderdate' +export const RENDERER_ORG_UNIT = 'renderorgunit' +export const RENDERER_ORG_UNIT_NAME = 'renderorgunitname' export const TYPE_NUMBER = 'number' export const TYPE_STRING = 'string' export const TYPE_DATE = 'date' export const TYPE_DATETIME = 'datetime' export const TYPE_TIME = 'time' +export const TYPE_ORG_UNIT = 'orgUnit' export const DATE_GROUPS_GRANULARITY = 'date-groups' +export const ORG_UNIT_GROUPS_GRANULARITY = 'org-unit-groups' + +// Full ancestor path (breadcrumb renderer) - "Org unit hierarchy" column +export const ORG_UNIT_PATH_DATA_KEY = 'orgUnitPath' +// Same path value as ORG_UNIT_PATH_DATA_KEY, rendered as the leaf name only - "Org unit" column +export const ORG_UNIT_DATA_KEY = 'orgUnitOwn' +// The layer's own org unit's bare id - "Org unit Id" column (Event/Tracked entity layers only, +// whose own "Id" field is the event/tracked-entity id, not the org unit id) +export const ORG_UNIT_ID_DATA_KEY = 'orgUnitId' +// The org unit's own hierarchy depth (1 = country, 2 = region, ...) - "Org unit level" column +export const ORG_UNIT_LEVEL_DATA_KEY = 'level' diff --git a/src/hooks/__tests__/useOrgUnitAncestorNames.spec.js b/src/hooks/__tests__/useOrgUnitAncestorNames.spec.js new file mode 100644 index 0000000000..654ef013f3 --- /dev/null +++ b/src/hooks/__tests__/useOrgUnitAncestorNames.spec.js @@ -0,0 +1,64 @@ +import { renderHook, waitFor } from '@testing-library/react' +import { fetchOrgUnitPathDetails } from '../../util/orgUnits.js' +import useOrgUnitAncestorNames from '../useOrgUnitAncestorNames.js' + +// A stable reference, matching the real useDataEngine's contract - an +// unstable mock (a fresh object per call) would retrigger the hook's effect +// on every state update it causes, since `engine` is one of its deps +jest.mock('@dhis2/app-runtime', () => ({ + useDataEngine: () => mockEngine, +})) +const mockEngine = {} + +jest.mock('../../util/orgUnits.js', () => ({ + fetchOrgUnitPathDetails: jest.fn(), +})) + +beforeEach(() => { + fetchOrgUnitPathDetails.mockReset() +}) + +describe('useOrgUnitAncestorNames', () => { + it('does not fetch when there are no path values', () => { + renderHook(() => useOrgUnitAncestorNames([])) + expect(fetchOrgUnitPathDetails).not.toHaveBeenCalled() + }) + + it('fetches once with the distinct ancestor ids extracted from every path value', async () => { + fetchOrgUnitPathDetails.mockResolvedValue({}) + renderHook(() => + useOrgUnitAncestorNames([ + '/country1/region1/facility1', + '/country1/region2/facility2', + ]) + ) + await waitFor(() => { + expect(fetchOrgUnitPathDetails).toHaveBeenCalledTimes(1) + }) + expect(fetchOrgUnitPathDetails).toHaveBeenCalledWith( + {}, + expect.arrayContaining([ + 'country1', + 'region1', + 'facility1', + 'region2', + 'facility2', + ]) + ) + }) + + it('transitions from loading to a resolved idToName map', async () => { + fetchOrgUnitPathDetails.mockResolvedValue({ + country1: { name: 'Sierra Leone', level: 1 }, + }) + const { result } = renderHook(() => + useOrgUnitAncestorNames(['/country1']) + ) + expect(result.current.loading).toBe(true) + + await waitFor(() => { + expect(result.current.loading).toBe(false) + }) + expect(result.current.idToName.get('country1')).toBe('Sierra Leone') + }) +}) diff --git a/src/hooks/useOrgUnitAncestorNames.js b/src/hooks/useOrgUnitAncestorNames.js new file mode 100644 index 0000000000..ab66624c78 --- /dev/null +++ b/src/hooks/useOrgUnitAncestorNames.js @@ -0,0 +1,54 @@ +import { useDataEngine } from '@dhis2/app-runtime' +import { useEffect, useMemo, useState } from 'react' +import { fetchOrgUnitPathDetails } from '../util/orgUnits.js' + +// Resolves the distinct ancestor ids across a set of org-unit path values +// (e.g. '/ImspTQPwCqd/O6uvpzGd5pu') to real display names, batched in one +// bulk request. Ids are not human-readable on their own - unlike the date +// tree, an org unit's raw value doesn't self-describe its label. Callers +// (the table cell renderer and OrgUnitGroupFilterInput.jsx) render the raw +// id as a placeholder until `idToName` resolves, rather than blocking. +const useOrgUnitAncestorNames = (distinctPathValues) => { + const engine = useDataEngine() + const ids = useMemo( + () => [ + ...new Set( + distinctPathValues.flatMap((path) => + String(path).split('/').filter(Boolean) + ) + ), + ], + [distinctPathValues] + ) + const idsKey = ids.join(',') + + const [idToName, setIdToName] = useState(new Map()) + const [loading, setLoading] = useState(false) + + useEffect(() => { + if (!ids.length) { + return + } + let cancelled = false + setLoading(true) + fetchOrgUnitPathDetails(engine, ids).then((details) => { + if (cancelled) { + return + } + setIdToName( + new Map(Object.entries(details).map(([id, d]) => [id, d.name])) + ) + setLoading(false) + }) + return () => { + cancelled = true + } + // idsKey is the stable, content-based dependency - `ids` is a new + // array identity every render + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [engine, idsKey]) + + return { idToName, loading } +} + +export default useOrgUnitAncestorNames diff --git a/src/loaders/__tests__/eventLoader.spec.js b/src/loaders/__tests__/eventLoader.spec.js index 7a58552fea..8ddca662bd 100644 --- a/src/loaders/__tests__/eventLoader.spec.js +++ b/src/loaders/__tests__/eventLoader.spec.js @@ -11,6 +11,7 @@ import { ORG_UNITS_PATHS_QUERY, } from '../../util/requests.js' import eventLoader, { + attachOrgUnitPaths, excludeEventsOutsideOrgUnits, shouldUseServerCluster, } from '../eventLoader.js' @@ -734,6 +735,59 @@ describe('excludeEventsOutsideOrgUnits', () => { }) }) +describe('attachOrgUnitPaths', () => { + test("attaches each event's org unit path via one bulk lookup over the distinct set of org-unit ids", async () => { + const engine = makeEngine({ + orgUnitPathsById: { + fac1: '/country1/region1/fac1', + fac2: '/country1/region2/fac2', + }, + }) + const config = makeConfig( + [], + [ + pointFeature('fac1', [5, 5]), + pointFeature('fac2', [50, 50]), + pointFeature('fac1', [6, 6]), // same org unit as the first + ] + ) + + await attachOrgUnitPaths({ config, engine }) + + expect(config.data.map((d) => d.properties.orgUnitPath)).toEqual([ + '/country1/region1/fac1', + '/country1/region2/fac2', + '/country1/region1/fac1', + ]) + // only the two distinct ids were requested, not one per event + const pathsQueryCall = engine.query.mock.calls.find( + ([query]) => query === ORG_UNITS_PATHS_QUERY + ) + expect(pathsQueryCall[1].variables.ids.split(',')).toEqual([ + 'fac1', + 'fac2', + ]) + }) + + test('falls back to null when an org unit path could not be resolved', async () => { + const engine = makeEngine({ orgUnitPathsById: {} }) + const config = makeConfig([], [pointFeature('fac1', [5, 5])]) + + await attachOrgUnitPaths({ config, engine }) + + expect(config.data[0].properties.orgUnitPath).toBeNull() + }) + + test('is a no-op when there is no data', async () => { + const engine = makeEngine({}) + const config = makeConfig([], []) + + await attachOrgUnitPaths({ config, engine }) + + expect(engine.query).not.toHaveBeenCalled() + }) +}) + describe('shouldUseServerCluster', () => { const overThreshold = EVENT_SERVER_CLUSTER_COUNT + 1 diff --git a/src/loaders/__tests__/trackedEntityLoader.spec.js b/src/loaders/__tests__/trackedEntityLoader.spec.js index a9a69cceea..29f955c4f7 100644 --- a/src/loaders/__tests__/trackedEntityLoader.spec.js +++ b/src/loaders/__tests__/trackedEntityLoader.spec.js @@ -153,9 +153,25 @@ describe('toGeoJson', () => { properties: { id: 'tei-1', color: '#ff0000', + type: 'Point', w75KJ2mc4zz: 'Gabrielle', }, }, ]) }) + + it("carries the instance's own org unit id through onto properties.orgUnit", () => { + const instances = [ + { + id: 'tei-1', + geometry: { type: 'Point', coordinates: [1, 2] }, + orgUnit: 'facility1', + attributes: [], + }, + ] + + const result = toGeoJson(instances, '#ff0000') + + expect(result[0].properties.orgUnit).toBe('facility1') + }) }) diff --git a/src/loaders/eventLoader.js b/src/loaders/eventLoader.js index 275ded1dbf..6df82b4db3 100644 --- a/src/loaders/eventLoader.js +++ b/src/loaders/eventLoader.js @@ -32,6 +32,7 @@ import { } from '../util/geojson.js' import { formatWithSeparator, parseWithSeparator } from '../util/numbers.js' import { + attachOrgUnitPaths as attachOrgUnitPathsUtil, fetchAssociatedGeometries, fetchOrgUnitPaths, getPolygonItems, @@ -46,6 +47,20 @@ import { isValidUid } from '../util/uid.js' const getEventOuId = (feature) => feature.properties?.ou ?? feature.properties?.['Organisation unit'] +// Attaches each event's org unit ancestor path (data table "Org unit +// hierarchy" column) - see util/orgUnits.js's attachOrgUnitPaths, shared +// with trackedEntityLoader.js. +export const attachOrgUnitPaths = async ({ config, engine }) => { + if (!config.data?.length) { + return + } + config.data = await attachOrgUnitPathsUtil( + config.data, + engine, + getEventOuId + ) +} + // Expands USER_ORGUNIT/_CHILDREN/_GRANDCHILDREN into ids; [id] if literal. const expandOrgUnitKeyword = (id, userOrgUnitIdsByKeyword) => { if (id in userOrgUnitIdsByKeyword) { @@ -334,6 +349,8 @@ const loadEventLayer = async ({ }) } + await attachOrgUnitPaths({ config, engine }) + if (styleDataItem) { await styleByDataItem(config, engine) } diff --git a/src/loaders/trackedEntityLoader.js b/src/loaders/trackedEntityLoader.js index b50e5b8e51..7bdb84e548 100644 --- a/src/loaders/trackedEntityLoader.js +++ b/src/loaders/trackedEntityLoader.js @@ -19,10 +19,11 @@ import { GEO_TYPE_FEATURE, } from '../util/geojson.js' import { parseWithSeparator } from '../util/numbers.js' +import { attachOrgUnitPaths } from '../util/orgUnits.js' import { getDataWithRelationships } from '../util/teiRelationshipsParser.js' import { trimTime, formatStartEndDate, getDateArray } from '../util/time.js' -const fields = ['trackedEntity~rename(id)', 'geometry', 'attributes'] +const fields = ['trackedEntity~rename(id)', 'geometry', 'attributes', 'orgUnit'] // Valid geometry types for TEIs const teiGeometryTypes = new Set([ @@ -132,12 +133,14 @@ export const getAttributeHeaders = (instances) => { // The main tracked entity marker's own color is currently fixed still // stamped here for when data table's Color column has real data export const toGeoJson = (instances, color) => - instances.map(({ id, geometry, attributes }) => ({ + instances.map(({ id, geometry, attributes, orgUnit }) => ({ type: GEO_TYPE_FEATURE, geometry, properties: { id, color, + orgUnit, + type: geometry?.type, ...getAttributeProperties(attributes), }, })) @@ -383,6 +386,12 @@ const trackedEntityLoader = async ({ data = toGeoJson(instances, pointColor) } + data = await attachOrgUnitPaths( + data, + engine, + (feature) => feature.properties.orgUnit + ) + if (explanation) { legend.explanation = [explanation] } diff --git a/src/util/__tests__/dateGroups.spec.js b/src/util/__tests__/dateGroups.spec.js index 561b49ce54..7f09529e41 100644 --- a/src/util/__tests__/dateGroups.spec.js +++ b/src/util/__tests__/dateGroups.spec.js @@ -6,12 +6,7 @@ import { import { parseDateGroupKey, buildDateGroupTree, - getNodeCheckState, - toggleDateGroupPrefix, - flattenVisibleNodes, formatNodeLabel, - getSearchMatches, - nodeMatchesOrHasMatch, } from '../dateGroups.js' describe('parseDateGroupKey', () => { @@ -121,14 +116,22 @@ describe('buildDateGroupTree', () => { expect(tree[1].children.map((v) => v.label)).toEqual(['14:00:00']) }) - it('sorts nodes ascending at every level regardless of input order', () => { - const values = ['2024-01-01', '2023-05-15', '2023-01-01'] - const tree = buildDateGroupTree(values, TYPE_DATE) - expect(tree.map((y) => y.key)).toEqual(['2023', '2024']) - expect(tree[0].children.map((m) => m.key)).toEqual([ + it("preserves the input order at every level, rather than forcing ascending - callers already order values to match the column's current sort direction", () => { + const ascendingValues = ['2023-01-01', '2023-05-15', '2024-01-01'] + const ascending = buildDateGroupTree(ascendingValues, TYPE_DATE) + expect(ascending.map((y) => y.key)).toEqual(['2023', '2024']) + expect(ascending[0].children.map((m) => m.key)).toEqual([ '2023-01', '2023-05', ]) + + const descendingValues = ['2024-01-01', '2023-05-15', '2023-01-01'] + const descending = buildDateGroupTree(descendingValues, TYPE_DATE) + expect(descending.map((y) => y.key)).toEqual(['2024', '2023']) + expect(descending[1].children.map((m) => m.key)).toEqual([ + '2023-05', + '2023-01', + ]) }) it('buckets unparseable values as root-level leaf nodes instead of dropping them', () => { @@ -144,100 +147,6 @@ describe('buildDateGroupTree', () => { }) }) -describe('getNodeCheckState', () => { - const dayNode = { prefix: '2023-05-15' } - - it('is checked when the node itself is selected', () => { - expect(getNodeCheckState(dayNode, ['2023-05-15'])).toBe('checked') - }) - - it('is checked when an ancestor prefix is selected', () => { - expect(getNodeCheckState(dayNode, ['2023'])).toBe('checked') - }) - - it('is indeterminate when only a descendant prefix is selected', () => { - expect(getNodeCheckState(dayNode, ['2023-05-15 09'])).toBe( - 'indeterminate' - ) - }) - - it('is unchecked otherwise', () => { - expect(getNodeCheckState(dayNode, ['2023-06-01'])).toBe('unchecked') - expect(getNodeCheckState(dayNode, [])).toBe('unchecked') - }) -}) - -describe('toggleDateGroupPrefix', () => { - it('selects an unchecked node', () => { - expect(toggleDateGroupPrefix([], { prefix: '2023' })).toEqual(['2023']) - }) - - it('deselects a node that is checked via its own prefix', () => { - expect( - toggleDateGroupPrefix(['2023-01', '2023'], { prefix: '2023' }) - ).toEqual(['2023-01']) - }) - - it('selecting a node drops now-redundant descendant prefixes', () => { - expect( - toggleDateGroupPrefix(['2023-01', '2023-02'], { prefix: '2023' }) - ).toEqual(['2023']) - }) - - it('is a no-op when checked only via an already-selected ancestor', () => { - const selected = ['2023'] - expect( - toggleDateGroupPrefix(selected, { prefix: '2023-05-15 09' }) - ).toBe(selected) - }) - - it('selecting an indeterminate node adds it without touching unrelated selections', () => { - expect( - toggleDateGroupPrefix(['2024'], { prefix: '2023-05-15' }) - ).toEqual(['2024', '2023-05-15']) - }) -}) - -describe('flattenVisibleNodes', () => { - const tree = [ - { - key: '2023', - children: [ - { - key: '2023-05', - children: [{ key: '2023-05-15', children: [] }], - }, - ], - }, - { key: '2024', children: [] }, - ] - - it('shows only root nodes when nothing is expanded', () => { - expect( - flattenVisibleNodes(tree, new Set()).map((r) => r.node.key) - ).toEqual(['2023', '2024']) - }) - - it('shows children of an expanded node at depth + 1', () => { - const result = flattenVisibleNodes(tree, new Set(['2023'])) - expect(result.map((r) => [r.node.key, r.depth])).toEqual([ - ['2023', 0], - ['2023-05', 1], - ['2024', 0], - ]) - }) - - it('recurses into nested expanded nodes', () => { - const result = flattenVisibleNodes(tree, new Set(['2023', '2023-05'])) - expect(result.map((r) => r.node.key)).toEqual([ - '2023', - '2023-05', - '2023-05-15', - '2024', - ]) - }) -}) - describe('formatNodeLabel', () => { it('formats a year node verbatim', () => { expect(formatNodeLabel({ level: 'year', key: '2023' }, 'en')).toBe( @@ -287,44 +196,3 @@ describe('formatNodeLabel', () => { ) }) }) - -describe('getSearchMatches / nodeMatchesOrHasMatch', () => { - const tree = buildDateGroupTree( - ['2023-05-15 09:00:00.0', '2024-01-01 00:00:00.0'], - TYPE_DATETIME - ) - - it('a year-number search matches every node whose raw prefix starts with that year, since a descendant prefix is always a literal extension of its ancestors', () => { - const { matchedKeys, expandedAncestorKeys } = getSearchMatches( - tree, - '2024' - ) - expect(matchedKeys.has('2024')).toBe(true) - expect(matchedKeys.has('2024-01')).toBe(true) - expect(matchedKeys.has('2024-01-01 00:00:00.0')).toBe(true) - expect(matchedKeys.has('2023')).toBe(false) - // the value match's ancestors get force-expanded - expect(expandedAncestorKeys.has('2024')).toBe(true) - expect(expandedAncestorKeys.has('2024-01')).toBe(true) - expect(expandedAncestorKeys.has('2024-01-01')).toBe(true) - expect(expandedAncestorKeys.has('2024-01-01 00')).toBe(true) - }) - - it('matches a deep node by a longer numeric prefix and reports every ancestor key', () => { - const { matchedKeys, expandedAncestorKeys } = getSearchMatches( - tree, - '2023-05' - ) - expect(matchedKeys.has('2023-05')).toBe(true) - expect(matchedKeys.has('2024-01')).toBe(false) - expect(expandedAncestorKeys.has('2023')).toBe(true) - }) - - it('nodeMatchesOrHasMatch is true for a match and for any ancestor of a match', () => { - const { matchedKeys } = getSearchMatches(tree, '2023-05') - const yearNode = tree.find((n) => n.key === '2023') - expect(nodeMatchesOrHasMatch(yearNode, matchedKeys)).toBe(true) - const otherYear = tree.find((n) => n.key === '2024') - expect(nodeMatchesOrHasMatch(otherYear, matchedKeys)).toBe(false) - }) -}) diff --git a/src/util/__tests__/filter.spec.js b/src/util/__tests__/filter.spec.js index 5dc7069838..e07e6dec2d 100644 --- a/src/util/__tests__/filter.spec.js +++ b/src/util/__tests__/filter.spec.js @@ -2,6 +2,7 @@ import { SENTINEL_ANY_VALUE, SENTINEL_NO_VALUE, DATE_GROUPS_GRANULARITY, + ORG_UNIT_GROUPS_GRANULARITY, } from '../../constants/dataTable.js' import { filterByGlobalSearch, filterData } from '../filter.js' @@ -203,6 +204,50 @@ describe('filterData', () => { ]) }) }) + + describe('org-unit-group filter ({ granularity, prefixes }) - same prefixGroupFilter matcher, different granularity', () => { + const data = [ + { a: '/country1/region1/facility1' }, + { a: '/country1/region2/facility2' }, + { a: '/country2/region3/facility3' }, + { a: null }, + ] + + it('matches every row under a selected ancestor prefix', () => { + const filters = { + a: { + granularity: ORG_UNIT_GROUPS_GRANULARITY, + prefixes: ['/country1'], + }, + } + expect(filterData(data, filters)).toEqual([ + { a: '/country1/region1/facility1' }, + { a: '/country1/region2/facility2' }, + ]) + }) + + it('matches only the selected leaf (facility) prefix', () => { + const filters = { + a: { + granularity: ORG_UNIT_GROUPS_GRANULARITY, + prefixes: ['/country1/region1/facility1'], + }, + } + expect(filterData(data, filters)).toEqual([ + { a: '/country1/region1/facility1' }, + ]) + }) + + it('SENTINEL_NO_VALUE only matches null/missing values', () => { + const filters = { + a: { + granularity: ORG_UNIT_GROUPS_GRANULARITY, + prefixes: [SENTINEL_NO_VALUE], + }, + } + expect(filterData(data, filters)).toEqual([{ a: null }]) + }) + }) }) describe('filterByGlobalSearch', () => { @@ -211,27 +256,28 @@ describe('filterByGlobalSearch', () => { { name: 'Entebbe Clinic', type: 'Clinic' }, { name: 'Jinja Hospital', type: 'Hospital' }, ] + const stringDataKeys = ['name', 'type'] it('returns the original data when the search string is empty', () => { - expect(filterByGlobalSearch(data, '', ['name', 'type'])).toEqual(data) - expect(filterByGlobalSearch(data, ' ', ['name', 'type'])).toEqual( + expect(filterByGlobalSearch(data, '', { stringDataKeys })).toEqual(data) + expect(filterByGlobalSearch(data, ' ', { stringDataKeys })).toEqual( data ) }) - it('returns the original data when there are no string data keys', () => { - expect(filterByGlobalSearch(data, 'Kampala', [])).toEqual(data) + it('returns the original data when there are no string or org-unit data keys', () => { + expect(filterByGlobalSearch(data, 'Kampala', {})).toEqual(data) }) it('matches case-insensitively across any of the given fields', () => { - expect(filterByGlobalSearch(data, 'kampala', ['name', 'type'])).toEqual( - [{ name: 'Kampala Hospital', type: 'Hospital' }] - ) + expect( + filterByGlobalSearch(data, 'kampala', { stringDataKeys }) + ).toEqual([{ name: 'Kampala Hospital', type: 'Hospital' }]) }) it('matches rows where any field contains the search string', () => { expect( - filterByGlobalSearch(data, 'hospital', ['name', 'type']) + filterByGlobalSearch(data, 'hospital', { stringDataKeys }) ).toEqual([ { name: 'Kampala Hospital', type: 'Hospital' }, { name: 'Jinja Hospital', type: 'Hospital' }, @@ -239,8 +285,26 @@ describe('filterByGlobalSearch', () => { }) it('returns no rows when nothing matches', () => { - expect(filterByGlobalSearch(data, 'nairobi', ['name', 'type'])).toEqual( - [] - ) + expect( + filterByGlobalSearch(data, 'nairobi', { stringDataKeys }) + ).toEqual([]) + }) + + it('also matches org-unit-typed columns by their resolved name, since the raw stored value is an id/path', () => { + const orgUnitData = [ + { id: 'a', orgUnitPath: '/country1/region1/facility1' }, + { id: 'b', orgUnitPath: '/country1/region2/facility2' }, + ] + const idToName = new Map([ + ['country1', 'Sierra Leone'], + ['region1', 'Bo'], + ['facility1', 'Bo Hospital'], + ]) + expect( + filterByGlobalSearch(orgUnitData, 'bo hospital', { + orgUnitDataKeys: ['orgUnitPath'], + idToName, + }) + ).toEqual([{ id: 'a', orgUnitPath: '/country1/region1/facility1' }]) }) }) diff --git a/src/util/__tests__/map.spec.js b/src/util/__tests__/map.spec.js index 286e0f2648..39fd3bdede 100644 --- a/src/util/__tests__/map.spec.js +++ b/src/util/__tests__/map.spec.js @@ -1,4 +1,4 @@ -import { onFullscreenChange, resizeAndFitBounds } from '../map.js' +import { onFullscreenChange, resizeAndFitBounds, toGeoJson } from '../map.js' const bounds = [ [0, 0], @@ -13,6 +13,61 @@ const createMockMap = (layersBounds = bounds) => ({ toggleScrollZoom: jest.fn(), }) +describe('toGeoJson', () => { + it("builds orgUnitPath as the parent graph plus the org unit's own id", () => { + const [feature] = toGeoJson([ + { + id: 'facility1', + co: '[10,20]', + ty: 1, + na: 'Facility 1', + pg: '/country1/region1', + pi: 'region1', + pn: 'Region 1', + le: 4, + }, + ]) + expect(feature.properties.orgUnitPath).toBe( + '/country1/region1/facility1' + ) + }) + + it('falls back to just its own id when there is no parent graph (a root org unit)', () => { + const [feature] = toGeoJson([ + { + id: 'country1', + co: '[10,20]', + ty: 1, + na: 'Country 1', + pg: '', + le: 1, + }, + ]) + expect(feature.properties.orgUnitPath).toBe('/country1') + }) + + it('always adds a leading slash, even though the real geoFeatures API returns pg without one (unlike organisationUnits.path)', () => { + const [feature] = toGeoJson([ + { + id: 'facility1', + co: '[10,20]', + ty: 1, + na: 'Facility 1', + pg: 'country1/region1', + pi: 'region1', + pn: 'Region 1', + le: 4, + }, + ]) + expect(feature.properties.orgUnitPath).toBe( + '/country1/region1/facility1' + ) + expect(feature.properties.orgUnitOwn).toBe( + '/country1/region1/facility1' + ) + }) +}) + describe('resizeAndFitBounds', () => { it('resizes the map and fits bounds when layer bounds exist', () => { const map = createMockMap() diff --git a/src/util/__tests__/orgUnitGroups.spec.js b/src/util/__tests__/orgUnitGroups.spec.js new file mode 100644 index 0000000000..e803df769a --- /dev/null +++ b/src/util/__tests__/orgUnitGroups.spec.js @@ -0,0 +1,177 @@ +import { + buildOrgUnitGroupTree, + formatOrgUnitNodeLabel, + formatOrgUnitPathBreadcrumb, + getOrgUnitSearchMatches, +} from '../orgUnitGroups.js' + +describe('buildOrgUnitGroupTree', () => { + it('builds a Country -> Region -> District -> Facility tree from full path values', () => { + const tree = buildOrgUnitGroupTree([ + '/country1/region1/district1/facility1', + ]) + expect(tree).toHaveLength(1) + const [country] = tree + expect(country).toMatchObject({ + key: 'country1', + prefix: '/country1', + ouLevel: 1, + name: null, + }) + expect(country.children).toHaveLength(1) + const [region] = country.children + expect(region).toMatchObject({ + key: 'region1', + prefix: '/country1/region1', + ouLevel: 2, + }) + const [district] = region.children + expect(district).toMatchObject({ + key: 'district1', + prefix: '/country1/region1/district1', + ouLevel: 3, + }) + const [facility] = district.children + expect(facility).toMatchObject({ + key: 'facility1', + prefix: '/country1/region1/district1/facility1', + ouLevel: 4, + }) + expect(facility.children).toEqual([]) + }) + + it('handles a root-level org unit with a single-segment path', () => { + const tree = buildOrgUnitGroupTree(['/country1']) + expect(tree).toEqual([ + { + key: 'country1', + prefix: '/country1', + ouLevel: 1, + name: null, + children: [], + }, + ]) + }) + + it('deduplicates a shared ancestor across multiple rows into one node', () => { + const tree = buildOrgUnitGroupTree([ + '/country1/region1/facility1', + '/country1/region1/facility2', + '/country1/region2/facility3', + ]) + expect(tree).toHaveLength(1) // one country + const [country] = tree + expect(country.children.map((r) => r.key)).toEqual([ + 'region1', + 'region2', + ]) + const region1 = country.children[0] + expect(region1.children.map((f) => f.key)).toEqual([ + 'facility1', + 'facility2', + ]) + }) + + it("preserves the input order at every level, rather than forcing ascending - callers already order pathValues to match the column's current sort direction", () => { + const ascending = buildOrgUnitGroupTree(['/b', '/a']) + expect(ascending.map((n) => n.key)).toEqual(['b', 'a']) + + const descending = buildOrgUnitGroupTree([ + '/country1/region2/facility1', + '/country1/region1/facility2', + ]) + expect(descending.map((n) => n.key)).toEqual(['country1']) + expect(descending[0].children.map((n) => n.key)).toEqual([ + 'region2', + 'region1', + ]) + }) +}) + +describe('formatOrgUnitNodeLabel', () => { + it('falls back to the raw id when the name has not resolved yet', () => { + expect(formatOrgUnitNodeLabel({ key: 'country1' }, new Map())).toBe( + 'country1' + ) + expect(formatOrgUnitNodeLabel({ key: 'country1' }, undefined)).toBe( + 'country1' + ) + }) + + it('uses the resolved name once present in the idToName map', () => { + const idToName = new Map([['country1', 'Sierra Leone']]) + expect(formatOrgUnitNodeLabel({ key: 'country1' }, idToName)).toBe( + 'Sierra Leone' + ) + }) +}) + +describe('formatOrgUnitPathBreadcrumb', () => { + it('joins every resolved ancestor name with " / "', () => { + const idToName = new Map([ + ['country1', 'Sierra Leone'], + ['region1', 'Bo'], + ['facility1', 'Bo Hospital'], + ]) + expect( + formatOrgUnitPathBreadcrumb('/country1/region1/facility1', idToName) + ).toBe('Sierra Leone / Bo / Bo Hospital') + }) + + it('falls back to the raw id per-segment for names that have not resolved yet', () => { + const idToName = new Map([['country1', 'Sierra Leone']]) + expect( + formatOrgUnitPathBreadcrumb('/country1/region1/facility1', idToName) + ).toBe('Sierra Leone / region1 / facility1') + }) + + it('falls back to raw ids entirely when idToName is undefined', () => { + expect(formatOrgUnitPathBreadcrumb('/country1/region1')).toBe( + 'country1 / region1' + ) + }) +}) + +describe('getOrgUnitSearchMatches', () => { + const tree = buildOrgUnitGroupTree([ + '/country1/region1/facility1', + '/country1/region2/facility2', + ]) + const idToName = new Map([ + ['country1', 'Sierra Leone'], + ['region1', 'Bo'], + ['region2', 'Kailahun'], + ['facility1', 'Bo Hospital'], + ['facility2', 'Kailahun Clinic'], + ]) + + it('matches by raw id/prefix, same as the generic prefixTree.js matcher', () => { + const { matchedKeys } = getOrgUnitSearchMatches( + tree, + 'facility1', + idToName + ) + expect(matchedKeys.has('facility1')).toBe(true) + expect(matchedKeys.has('facility2')).toBe(false) + }) + + it('also matches by resolved name, unlike the generic matcher', () => { + const { matchedKeys, expandedAncestorKeys } = getOrgUnitSearchMatches( + tree, + 'kailahun', + idToName + ) + expect(matchedKeys.has('region2')).toBe(true) + expect(matchedKeys.has('facility2')).toBe(true) + expect(expandedAncestorKeys.has('country1')).toBe(true) + }) + + it('a node with no resolved name yet is still matchable by id', () => { + const { matchedKeys } = getOrgUnitSearchMatches( + tree, + 'country1', + new Map() + ) + expect(matchedKeys.has('country1')).toBe(true) + }) +}) diff --git a/src/util/__tests__/orgUnits.spec.js b/src/util/__tests__/orgUnits.spec.js index 55362e6e8d..0427f2729f 100644 --- a/src/util/__tests__/orgUnits.spec.js +++ b/src/util/__tests__/orgUnits.spec.js @@ -13,6 +13,8 @@ import { getUserOrgUnitIdsByKeyword, fetchOrgUnitDetails, fetchOrgUnitPaths, + fetchOrgUnitPathDetails, + attachOrgUnitPaths, } from '../orgUnits.js' describe('getUserOrgUnitIdsByKeyword', () => { @@ -109,6 +111,86 @@ describe('fetchOrgUnitDetails / fetchOrgUnitPaths error handling', () => { const result = await fetchOrgUnitPaths(engine, ['ou1']) expect(result).toEqual([]) }) + + it('fetchOrgUnitPathDetails returns an empty object when the query fails', async () => { + const engine = { + query: jest.fn().mockRejectedValue(new Error('Network error')), + } + const result = await fetchOrgUnitPathDetails(engine, ['ou1']) + expect(result).toEqual({}) + }) + + it('fetchOrgUnitPathDetails resolves ids to their name and level', async () => { + const engine = { + query: jest.fn().mockResolvedValue({ + orgUnits: { + organisationUnits: [ + { id: 'ou1', name: 'Sierra Leone', level: 1 }, + { id: 'ou2', name: 'Bo', level: 2 }, + ], + }, + }), + } + const result = await fetchOrgUnitPathDetails(engine, ['ou1', 'ou2']) + expect(result).toEqual({ + ou1: { name: 'Sierra Leone', level: 1 }, + ou2: { name: 'Bo', level: 2 }, + }) + }) +}) + +describe('attachOrgUnitPaths', () => { + const getOuId = (feature) => feature.properties.ouId + + it("attaches each feature's org unit path via one bulk lookup over the distinct set of ids", async () => { + const engine = { + query: jest.fn().mockResolvedValue({ + organisationUnits: { + organisationUnits: [ + { id: 'ou1', path: '/country1/ou1' }, + { id: 'ou2', path: '/country1/ou2' }, + ], + }, + }), + } + const features = [ + { properties: { ouId: 'ou1' } }, + { properties: { ouId: 'ou2' } }, + { properties: { ouId: 'ou1' } }, + ] + + const result = await attachOrgUnitPaths(features, engine, getOuId) + + expect(result.map((f) => f.properties.orgUnitPath)).toEqual([ + '/country1/ou1', + '/country1/ou2', + '/country1/ou1', + ]) + const [, { variables }] = engine.query.mock.calls[0] + expect(variables.ids).toBe('ou1,ou2') + }) + + it('falls back to null when a path could not be resolved', async () => { + const engine = { + query: jest.fn().mockResolvedValue({ + organisationUnits: { organisationUnits: [] }, + }), + } + const result = await attachOrgUnitPaths( + [{ properties: { ouId: 'ou1' } }], + engine, + getOuId + ) + expect(result[0].properties.orgUnitPath).toBeNull() + }) + + it('is a no-op that returns the input untouched when there are no features', async () => { + const engine = { query: jest.fn() } + const features = [] + const result = await attachOrgUnitPaths(features, engine, getOuId) + expect(result).toBe(features) + expect(engine.query).not.toHaveBeenCalled() + }) }) describe('getStyledOrgUnits', () => { diff --git a/src/util/__tests__/prefixTree.spec.js b/src/util/__tests__/prefixTree.spec.js new file mode 100644 index 0000000000..0282281c1f --- /dev/null +++ b/src/util/__tests__/prefixTree.spec.js @@ -0,0 +1,145 @@ +import { TYPE_DATETIME } from '../../constants/dataTable.js' +import { buildDateGroupTree } from '../dateGroups.js' +import { + getNodeCheckState, + togglePrefix, + flattenVisibleNodes, + getSearchMatches, + nodeMatchesOrHasMatch, +} from '../prefixTree.js' + +describe('getNodeCheckState', () => { + const dayNode = { prefix: '2023-05-15' } + + it('is checked when the node itself is selected', () => { + expect(getNodeCheckState(dayNode, ['2023-05-15'])).toBe('checked') + }) + + it('is checked when an ancestor prefix is selected', () => { + expect(getNodeCheckState(dayNode, ['2023'])).toBe('checked') + }) + + it('is indeterminate when only a descendant prefix is selected', () => { + expect(getNodeCheckState(dayNode, ['2023-05-15 09'])).toBe( + 'indeterminate' + ) + }) + + it('is unchecked otherwise', () => { + expect(getNodeCheckState(dayNode, ['2023-06-01'])).toBe('unchecked') + expect(getNodeCheckState(dayNode, [])).toBe('unchecked') + }) +}) + +describe('togglePrefix', () => { + it('selects an unchecked node', () => { + expect(togglePrefix([], { prefix: '2023' })).toEqual(['2023']) + }) + + it('deselects a node that is checked via its own prefix', () => { + expect(togglePrefix(['2023-01', '2023'], { prefix: '2023' })).toEqual([ + '2023-01', + ]) + }) + + it('selecting a node drops now-redundant descendant prefixes', () => { + expect( + togglePrefix(['2023-01', '2023-02'], { prefix: '2023' }) + ).toEqual(['2023']) + }) + + it('is a no-op when checked only via an already-selected ancestor', () => { + const selected = ['2023'] + expect(togglePrefix(selected, { prefix: '2023-05-15 09' })).toBe( + selected + ) + }) + + it('selecting an indeterminate node adds it without touching unrelated selections', () => { + expect(togglePrefix(['2024'], { prefix: '2023-05-15' })).toEqual([ + '2024', + '2023-05-15', + ]) + }) +}) + +describe('flattenVisibleNodes', () => { + const tree = [ + { + key: '2023', + children: [ + { + key: '2023-05', + children: [{ key: '2023-05-15', children: [] }], + }, + ], + }, + { key: '2024', children: [] }, + ] + + it('shows only root nodes when nothing is expanded', () => { + expect( + flattenVisibleNodes(tree, new Set()).map((r) => r.node.key) + ).toEqual(['2023', '2024']) + }) + + it('shows children of an expanded node at depth + 1', () => { + const result = flattenVisibleNodes(tree, new Set(['2023'])) + expect(result.map((r) => [r.node.key, r.depth])).toEqual([ + ['2023', 0], + ['2023-05', 1], + ['2024', 0], + ]) + }) + + it('recurses into nested expanded nodes', () => { + const result = flattenVisibleNodes(tree, new Set(['2023', '2023-05'])) + expect(result.map((r) => r.node.key)).toEqual([ + '2023', + '2023-05', + '2023-05-15', + '2024', + ]) + }) +}) + +describe('getSearchMatches / nodeMatchesOrHasMatch', () => { + const tree = buildDateGroupTree( + ['2023-05-15 09:00:00.0', '2024-01-01 00:00:00.0'], + TYPE_DATETIME + ) + + it('a year-number search matches every node whose raw prefix starts with that year, since a descendant prefix is always a literal extension of its ancestors', () => { + const { matchedKeys, expandedAncestorKeys } = getSearchMatches( + tree, + '2024' + ) + expect(matchedKeys.has('2024')).toBe(true) + expect(matchedKeys.has('2024-01')).toBe(true) + expect(matchedKeys.has('2024-01-01 00:00:00.0')).toBe(true) + expect(matchedKeys.has('2023')).toBe(false) + // the value match's ancestors get force-expanded + expect(expandedAncestorKeys.has('2024')).toBe(true) + expect(expandedAncestorKeys.has('2024-01')).toBe(true) + expect(expandedAncestorKeys.has('2024-01-01')).toBe(true) + expect(expandedAncestorKeys.has('2024-01-01 00')).toBe(true) + }) + + it('matches a deep node by a longer numeric prefix and reports every ancestor key', () => { + const { matchedKeys, expandedAncestorKeys } = getSearchMatches( + tree, + '2023-05' + ) + expect(matchedKeys.has('2023-05')).toBe(true) + expect(matchedKeys.has('2024-01')).toBe(false) + expect(expandedAncestorKeys.has('2023')).toBe(true) + }) + + it('nodeMatchesOrHasMatch is true for a match and for any ancestor of a match', () => { + const { matchedKeys } = getSearchMatches(tree, '2023-05') + const yearNode = tree.find((n) => n.key === '2023') + expect(nodeMatchesOrHasMatch(yearNode, matchedKeys)).toBe(true) + const otherYear = tree.find((n) => n.key === '2024') + expect(nodeMatchesOrHasMatch(otherYear, matchedKeys)).toBe(false) + }) +}) diff --git a/src/util/__tests__/tableHeaders.spec.js b/src/util/__tests__/tableHeaders.spec.js index b130fe4d24..8a322f1c94 100644 --- a/src/util/__tests__/tableHeaders.spec.js +++ b/src/util/__tests__/tableHeaders.spec.js @@ -1,4 +1,4 @@ -import { RENDERER_DATE } from '../../constants/dataTable.js' +import { RENDERER_DATE, RENDERER_ORG_UNIT } from '../../constants/dataTable.js' import { EVENT_LAYER, THEMATIC_LAYER, @@ -30,15 +30,15 @@ describe('getHeadersForLayer - thematic', () => { isMultiPeriodThematic: false, }) expect(dataKeys(result)).toEqual([ - 'name', 'id', - 'rawValue', + 'orgUnitOwn', 'level', - 'parentName', - 'type', + 'orgUnitPath', + 'rawValue', 'legend', 'range', 'color', + 'type', ]) }) @@ -54,10 +54,10 @@ describe('getHeadersForLayer - thematic', () => { }) expect(dataKeys(result)).toEqual( expect.arrayContaining([ - 'name', 'id', + 'orgUnitOwn', + 'orgUnitPath', 'level', - 'parentName', 'type', 'period_p1_rawValue', 'period_p2_rawValue', @@ -96,7 +96,14 @@ describe('getHeadersForLayer - event', () => { ] const result = getHeadersForLayer(EVENT_LAYER, { layerHeaders }) expect(dataKeys(result)).toEqual( - expect.arrayContaining(['ouname', 'id', 'eventdate', 'w75KJ2mc4zz']) + expect.arrayContaining([ + 'id', + 'orgUnitId', + 'orgUnitOwn', + 'eventdate', + 'orgUnitPath', + 'w75KJ2mc4zz', + ]) ) expect(dataKeys(result)).not.toContain('not-a-uid') const ageHeader = result.headers.find( @@ -125,6 +132,11 @@ describe('getHeadersForLayer - event', () => { valueType: 'TEXT', optionSet: { id: 'os1' }, }, + { + name: 'c3d4e5f6a7b', + column: 'Referred by facility', + valueType: 'ORGANISATION_UNIT', + }, ] const result = getHeadersForLayer(EVENT_LAYER, { layerHeaders }) const headerFor = (dataKey) => @@ -135,11 +147,18 @@ describe('getHeadersForLayer - event', () => { expect(typeOf('oZg33kd9taw')).toBe(TYPE_TIME) expect(typeOf('a1b2c3d4e5f')).toBe(TYPE_DATE) expect(typeOf('b2c3d4e5f6a')).toBe(TYPE_STRING) + // Unlike a tracked entity attribute, the events analytics query + // always resolves an ORGANISATION_UNIT-valued data element to its + // display name server-side - there's no id left to build a tree + // filter from, so it stays plain text. The cell renderer still + // applies (a harmless no-op here, since the value is already a name). + expect(typeOf('c3d4e5f6a7b')).toBe(TYPE_STRING) expect(headerFor('w75KJ2mc4zz').renderer).toBe(RENDERER_DATE) expect(headerFor('zDhUuAYrxNC').renderer).toBe(RENDERER_DATE) expect(headerFor('oZg33kd9taw').renderer).toBe(RENDERER_DATE) expect(headerFor('a1b2c3d4e5f').renderer).toBe(RENDERER_DATE) expect(headerFor('b2c3d4e5f6a').renderer).toBeUndefined() + expect(headerFor('c3d4e5f6a7b').renderer).toBe(RENDERER_ORG_UNIT) }) test('adds the org unit boundary column only when countEventsOutsideOrgUnits is set', () => { @@ -172,11 +191,11 @@ describe('getHeadersForLayer - org unit / facility', () => { }) expect(dataKeys(result)).toEqual( expect.arrayContaining([ - 'name', 'id', + 'orgUnitOwn', 'level', - 'parentName', 'type', + 'orgUnitPath', 'color', 'iconUrl', ]) @@ -188,7 +207,14 @@ describe('getHeadersForLayer - org unit / facility', () => { const result = getHeadersForLayer(FACILITY_LAYER, { data: [{ group: 'g1' }], }) - expect(dataKeys(result)).toEqual(['name', 'id', 'type', 'group']) + expect(dataKeys(result)).toEqual([ + 'id', + 'orgUnitOwn', + 'level', + 'orgUnitPath', + 'group', + 'type', + ]) }) }) @@ -201,7 +227,16 @@ describe('getHeadersForLayer - tracked entity', () => { const result = getHeadersForLayer(TRACKED_ENTITY_LAYER, { layerHeaders, }) - expect(dataKeys(result)).toEqual(['id', 'w75KJ2mc4zz', 'color']) + expect(dataKeys(result)).toEqual([ + 'id', + 'orgUnitId', + 'orgUnitOwn', + 'level', + 'orgUnitPath', + 'w75KJ2mc4zz', + 'color', + 'type', + ]) const nameHeader = result.headers.find( (h) => h.dataKey === 'w75KJ2mc4zz' ) @@ -221,6 +256,11 @@ describe('getHeadersForLayer - tracked entity', () => { valueType: 'DATETIME', }, { name: 'Visit time', dataKey: 'oZg33kd9taw', valueType: 'TIME' }, + { + name: 'Referred by facility', + dataKey: 'c3d4e5f6a7b', + valueType: 'ORGANISATION_UNIT', + }, ] const result = getHeadersForLayer(TRACKED_ENTITY_LAYER, { layerHeaders, @@ -231,9 +271,13 @@ describe('getHeadersForLayer - tracked entity', () => { expect(typeOf('w75KJ2mc4zz')).toBe(TYPE_DATE) expect(typeOf('zDhUuAYrxNC')).toBe(TYPE_DATETIME) expect(typeOf('oZg33kd9taw')).toBe(TYPE_TIME) + // Plain text now (no tree filter), but the cell renderer still + // resolves the tracker API's raw bare id to a readable name. + expect(typeOf('c3d4e5f6a7b')).toBe(TYPE_STRING) expect(headerFor('w75KJ2mc4zz').renderer).toBe(RENDERER_DATE) expect(headerFor('zDhUuAYrxNC').renderer).toBe(RENDERER_DATE) expect(headerFor('oZg33kd9taw').renderer).toBe(RENDERER_DATE) + expect(headerFor('c3d4e5f6a7b').renderer).toBe(RENDERER_ORG_UNIT) }) }) @@ -247,7 +291,13 @@ describe('getHeadersForLayer - earth engine', () => { }, }) expect(dataKeys(result)).toEqual( - expect.arrayContaining(['name', 'id', 'type', '1']) + expect.arrayContaining([ + 'id', + 'orgUnitOwn', + 'orgUnitPath', + 'type', + '1', + ]) ) const classHeader = result.headers.find((h) => h.dataKey === '1') expect(classHeader.name).toBe('Forest') diff --git a/src/util/dateGroups.js b/src/util/dateGroups.js index b7fafc652d..5eca07fd99 100644 --- a/src/util/dateGroups.js +++ b/src/util/dateGroups.js @@ -1,7 +1,16 @@ import { TYPE_DATETIME, TYPE_TIME } from '../constants/dataTable.js' import { formatDate, formatDatetime } from './helpers.js' +import { togglePrefix } from './prefixTree.js' import { dateLocale } from './time.js' +export { + getNodeCheckState, + flattenVisibleNodes, + getSearchMatches, + nodeMatchesOrHasMatch, +} from './prefixTree.js' +export const toggleDateGroupPrefix = togglePrefix + const DATE_KEY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})(?:([T ])(\d{2}))?/ const TIME_KEY_PATTERN = /^(\d{2}):/ @@ -38,16 +47,21 @@ const getOrCreateNode = (childMap, { key, level, label }) => { return node } +// Preserves encounter order rather than re-sorting: buildDateGroupTree's +// caller (DateGroupFilterInput.jsx) always receives values already ordered +// to match the column's current sort direction (see useTableData.js's +// columnOptions) - walking them in that order naturally reproduces the same +// ascending/descending order at every level of the tree, so the popover's +// checkbox order stays consistent with the column header's sort, just like +// every other filter popover's option list already does. const sortedNodes = (childMap) => - Array.from(childMap.values()) - .sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)) - .map((node) => ({ - key: node.key, - level: node.level, - label: node.label, - prefix: node.prefix, - children: sortedNodes(node.childMap), - })) + Array.from(childMap.values()).map((node) => ({ + key: node.key, + level: node.level, + label: node.label, + prefix: node.prefix, + children: sortedNodes(node.childMap), + })) const getValueFormatter = (granularity) => granularity === TYPE_DATETIME || granularity === TYPE_TIME @@ -116,47 +130,6 @@ export const buildDateGroupTree = (values, granularity) => { return [...tree, ...leaves] } -export const getNodeCheckState = (node, selectedPrefixes) => { - if ( - selectedPrefixes.some( - (prefix) => node.prefix === prefix || node.prefix.startsWith(prefix) - ) - ) { - return 'checked' - } - if (selectedPrefixes.some((prefix) => prefix.startsWith(node.prefix))) { - return 'indeterminate' - } - return 'unchecked' -} - -export const toggleDateGroupPrefix = (selectedPrefixes, node) => { - const state = getNodeCheckState(node, selectedPrefixes) - if (state === 'checked') { - return selectedPrefixes.includes(node.prefix) - ? selectedPrefixes.filter((prefix) => prefix !== node.prefix) - : selectedPrefixes - } - return [ - ...selectedPrefixes.filter((prefix) => !prefix.startsWith(node.prefix)), - node.prefix, - ] -} - -export const flattenVisibleNodes = (tree, expandedKeys) => { - const result = [] - const walk = (nodes, depth) => { - nodes.forEach((node) => { - result.push({ node, depth }) - if (node.children.length && expandedKeys.has(node.key)) { - walk(node.children, depth + 1) - } - }) - } - walk(tree, 0) - return result -} - const getHourLabel = (key) => { const match = key.match(/(\d{2})$/) return match ? `${match[1]}:00` : key @@ -191,27 +164,3 @@ export const formatNodeLabel = (node, locale) => { return node.key } } - -const collectMatches = (nodes, ancestors, options) => { - const { normalizedSearch, result } = options - nodes.forEach((node) => { - const isMatch = node.prefix.toLowerCase().includes(normalizedSearch) - if (isMatch) { - result.matchedKeys.add(node.key) - ancestors.forEach((key) => result.expandedAncestorKeys.add(key)) - } - if (node.children.length) { - collectMatches(node.children, [...ancestors, node.key], options) - } - }) -} - -export const getSearchMatches = (tree, normalizedSearch) => { - const result = { matchedKeys: new Set(), expandedAncestorKeys: new Set() } - collectMatches(tree, [], { normalizedSearch, result }) - return result -} - -export const nodeMatchesOrHasMatch = (node, matchedKeys) => - matchedKeys.has(node.key) || - node.children.some((child) => nodeMatchesOrHasMatch(child, matchedKeys)) diff --git a/src/util/filter.js b/src/util/filter.js index ca6fb5c7ef..01e0ff2bba 100644 --- a/src/util/filter.js +++ b/src/util/filter.js @@ -2,16 +2,18 @@ import { SENTINEL_ANY_VALUE, SENTINEL_NO_VALUE, DATE_GROUPS_GRANULARITY, + ORG_UNIT_GROUPS_GRANULARITY, } from '../constants/dataTable.js' +import { formatOrgUnitPathBreadcrumb } from './orgUnitGroups.js' -// Distinguishes a date-groups filter -export const isDateGroupFilter = (filter) => +// Distinguishes a prefix-group filter (date-groups, org-unit-groups, ...) +export const isPrefixGroupFilter = (filter, granularity) => filter != null && typeof filter === 'object' && !Array.isArray(filter) && - filter.granularity === DATE_GROUPS_GRANULARITY + filter.granularity === granularity -export const dateGroupFilter = (value, { prefixes }) => { +export const prefixGroupFilter = (value, { prefixes }) => { if (!prefixes?.length) { return true } @@ -27,6 +29,11 @@ export const dateGroupFilter = (value, { prefixes }) => { }) } +export const isDateGroupFilter = (filter) => + isPrefixGroupFilter(filter, DATE_GROUPS_GRANULARITY) +export const isOrgUnitGroupFilter = (filter) => + isPrefixGroupFilter(filter, ORG_UNIT_GROUPS_GRANULARITY) + // Filters an array of object with a set of filters export const filterData = (data, filters) => { if (!filters) { @@ -44,8 +51,8 @@ export const filterData = (data, filters) => { const props = d.properties || d // GeoJSON or plain object const value = props[field] - if (isDateGroupFilter(filter)) { - return dateGroupFilter(value, filter) + if (isDateGroupFilter(filter) || isOrgUnitGroupFilter(filter)) { + return prefixGroupFilter(value, filter) } if (Array.isArray(filter)) { @@ -85,18 +92,36 @@ export const numericFilter = (value, filter) => { }) } -// Case-insensitive match against any of the given string fields -export const filterByGlobalSearch = (data, searchString, stringDataKeys) => { - if (!searchString?.trim() || !stringDataKeys?.length) { +export const filterByGlobalSearch = ( + data, + searchString, + { stringDataKeys = [], orgUnitDataKeys = [], idToName } = {} +) => { + if ( + !searchString?.trim() || + (!stringDataKeys.length && !orgUnitDataKeys.length) + ) { return data } const lower = searchString.toLowerCase() return data.filter((item) => { const props = item.properties || item - return stringDataKeys.some((key) => { + const stringMatch = stringDataKeys.some((key) => { const val = props[key] return val != null && String(val).toLowerCase().includes(lower) }) + if (stringMatch) { + return true + } + return orgUnitDataKeys.some((key) => { + const val = props[key] + return ( + val != null && + formatOrgUnitPathBreadcrumb(val, idToName) + .toLowerCase() + .includes(lower) + ) + }) }) } diff --git a/src/util/map.js b/src/util/map.js index 70526c6fa8..c9305d275f 100644 --- a/src/util/map.js +++ b/src/util/map.js @@ -1,4 +1,8 @@ import { compact, sortBy, isString } from 'lodash/fp' +import { + ORG_UNIT_DATA_KEY, + ORG_UNIT_PATH_DATA_KEY, +} from '../constants/dataTable.js' import { dimConf } from '../constants/dimension.js' export const toGeoJson = (organisationUnits) => @@ -16,21 +20,19 @@ export const toGeoJson = (organisationUnits) => } } - // Grand parent - if (isString(ou.pg) && ou.pg.length) { - const ids = compact(ou.pg.split('/')) - - // Grand parent id - if (ids.length >= 2) { - gpid = ids[ids.length - 2] - } + const ancestorIds = + isString(ou.pg) && ou.pg.length ? compact(ou.pg.split('/')) : [] - // Grand parent parent graph - if (ids.length > 2) { - gppg = '/' + ids.slice(0, -2).join('/') - } + // Grand parent + if (ancestorIds.length >= 2) { + gpid = ancestorIds[ancestorIds.length - 2] + } + if (ancestorIds.length > 2) { + gppg = '/' + ancestorIds.slice(0, -2).join('/') } + const orgUnitPath = '/' + [...ancestorIds, ou.id].join('/') + return { type: 'Feature', id: ou.id, @@ -50,6 +52,8 @@ export const toGeoJson = (organisationUnits) => parentGraph: ou.pg, parentId: ou.pi, parentName: ou.pn, + [ORG_UNIT_PATH_DATA_KEY]: orgUnitPath, + [ORG_UNIT_DATA_KEY]: orgUnitPath, dimensions: ou.dimensions, }, } diff --git a/src/util/orgUnitGroups.js b/src/util/orgUnitGroups.js new file mode 100644 index 0000000000..51defde712 --- /dev/null +++ b/src/util/orgUnitGroups.js @@ -0,0 +1,96 @@ +const getOrCreateNode = (childMap, { key, prefix, ouLevel }) => { + let node = childMap.get(key) + if (!node) { + node = { key, prefix, ouLevel, name: null, childMap: new Map() } + childMap.set(key, node) + } + return node +} + +// Preserves encounter order rather than re-sorting: buildOrgUnitGroupTree's +// caller (OrgUnitGroupFilterInput.jsx) always receives pathValues already +// ordered to match the column's current sort direction (see useTableData.js's +// columnOptions) - walking them in that order naturally reproduces the same +// ascending/descending order at every level of the tree, so the popover's +// checkbox order stays consistent with the column header's sort, just like +// every other filter popover's option list already does. +const sortedNodes = (childMap) => + Array.from(childMap.values()).map((node) => ({ + key: node.key, + prefix: node.prefix, + ouLevel: node.ouLevel, + name: node.name, + children: sortedNodes(node.childMap), + })) + +// Builds an ancestor-path tree (Country -> Region -> District -> Facility, +// or however many levels a given path has) from a column's flat distinct +// full-path values (e.g. '/ImspTQPwCqd/O6uvpzGd5pu/lc3eMKXaEfw'). Unlike +// dateGroups.js's tree, an org unit's own id is naturally the tree's leaf - +// no separate terminal "value" node is needed, since the path's last +// segment already is the selectable unit. `name` starts null on every node; +// callers resolve it asynchronously and re-render (see +// src/hooks/useOrgUnitAncestorNames.js), falling back to the raw id label +// until then. +export const buildOrgUnitGroupTree = (pathValues) => { + const rootMap = new Map() + + pathValues.forEach((path) => { + const ids = String(path).split('/').filter(Boolean) + let map = rootMap + let prefix = '' + ids.forEach((id, depth) => { + prefix += `/${id}` + const node = getOrCreateNode(map, { + key: id, + prefix, + ouLevel: depth + 1, + }) + map = node.childMap + }) + }) + + return sortedNodes(rootMap) +} + +export const formatOrgUnitNodeLabel = (node, idToName) => + idToName?.get(node.key) ?? node.key + +export const formatOrgUnitPathBreadcrumb = (path, idToName) => + String(path) + .split('/') + .filter(Boolean) + .map((id) => idToName?.get(id) ?? id) + .join(' / ') + +export const formatOrgUnitOwnName = (path, idToName) => { + const leafId = String(path).split('/').filter(Boolean).pop() + return formatOrgUnitNodeLabel({ key: leafId }, idToName) +} + +const collectOrgUnitMatches = (nodes, ancestors, options) => { + const { normalizedSearch, idToName, result } = options + nodes.forEach((node) => { + const name = idToName?.get(node.key) + const isMatch = + node.prefix.toLowerCase().includes(normalizedSearch) || + (name && name.toLowerCase().includes(normalizedSearch)) + if (isMatch) { + result.matchedKeys.add(node.key) + ancestors.forEach((key) => result.expandedAncestorKeys.add(key)) + } + if (node.children.length) { + collectOrgUnitMatches( + node.children, + [...ancestors, node.key], + options + ) + } + }) +} + +export const getOrgUnitSearchMatches = (tree, normalizedSearch, idToName) => { + const result = { matchedKeys: new Set(), expandedAncestorKeys: new Set() } + collectOrgUnitMatches(tree, [], { normalizedSearch, idToName, result }) + return result +} diff --git a/src/util/orgUnits.js b/src/util/orgUnits.js index 746217ae19..e512a8bad1 100644 --- a/src/util/orgUnits.js +++ b/src/util/orgUnits.js @@ -6,6 +6,12 @@ import { import i18n from '@dhis2/d2-i18n' import { uniqBy } from 'lodash/fp' import { qualitativeColors } from '../constants/colors.js' +import { + ORG_UNIT_LEVEL_DATA_KEY, + ORG_UNIT_DATA_KEY, + ORG_UNIT_ID_DATA_KEY, + ORG_UNIT_PATH_DATA_KEY, +} from '../constants/dataTable.js' import { ORG_UNIT_COLOR, ORG_UNIT_RADIUS, @@ -22,6 +28,7 @@ import { ORG_UNITS_COUNT_QUERY, ORG_UNITS_PATHS_QUERY, ORG_UNIT_DETAILS_QUERY, + ORG_UNIT_PATH_DETAILS_QUERY, } from './requests.js' // Expands the user's org unit tree into USER_ORGUNIT keyword id lists. @@ -351,6 +358,44 @@ export const fetchOrgUnitPaths = async (engine, ids) => { return results.flatMap((r) => r.organisationUnits.organisationUnits ?? []) } +export const fetchOrgUnitPathDetails = async (engine, ids) => { + const results = await fetchInBatches(engine, ids, { + query: ORG_UNIT_PATH_DETAILS_QUERY, + buildVariables: (batch) => ({ ids: batch }), + }) + return results.reduce((acc, result) => { + result.orgUnits.organisationUnits?.forEach((ou) => { + acc[ou.id] = { name: ou.name, level: ou.level } + }) + return acc + }, {}) +} + +export const attachOrgUnitPaths = async (features, engine, getOuId) => { + if (!features?.length) { + return features + } + const distinctOuIds = [...new Set(features.map(getOuId).filter(Boolean))] + const ouPaths = await fetchOrgUnitPaths(engine, distinctOuIds) + const pathById = new Map(ouPaths.map((ou) => [ou.id, ou.path])) + return features.map((feature) => { + const ouId = getOuId(feature) + const path = pathById.get(ouId) ?? null + return { + ...feature, + properties: { + ...feature.properties, + [ORG_UNIT_ID_DATA_KEY]: ouId ?? null, + [ORG_UNIT_PATH_DATA_KEY]: path, + [ORG_UNIT_DATA_KEY]: path, + [ORG_UNIT_LEVEL_DATA_KEY]: path + ? path.split('/').filter(Boolean).length + : null, + }, + } + }) +} + export const addGroupCountsToLegend = (legendItems, features, groupSet) => { legendItems.forEach((item) => (item.count = 0)) const unclassifiedItem = legendItems.find((i) => !i.id) diff --git a/src/util/prefixTree.js b/src/util/prefixTree.js new file mode 100644 index 0000000000..1cd84f1656 --- /dev/null +++ b/src/util/prefixTree.js @@ -0,0 +1,78 @@ +export const getNodeCheckState = (node, selectedPrefixes) => { + if ( + selectedPrefixes.some( + (prefix) => node.prefix === prefix || node.prefix.startsWith(prefix) + ) + ) { + return 'checked' + } + if (selectedPrefixes.some((prefix) => prefix.startsWith(node.prefix))) { + return 'indeterminate' + } + return 'unchecked' +} + +export const togglePrefix = (selectedPrefixes, node) => { + const state = getNodeCheckState(node, selectedPrefixes) + if (state === 'checked') { + return selectedPrefixes.includes(node.prefix) + ? selectedPrefixes.filter((prefix) => prefix !== node.prefix) + : selectedPrefixes + } + return [ + ...selectedPrefixes.filter((prefix) => !prefix.startsWith(node.prefix)), + node.prefix, + ] +} + +export const flattenVisibleNodes = (tree, expandedKeys) => { + const result = [] + const walk = (nodes, depth) => { + nodes.forEach((node) => { + result.push({ node, depth }) + if (node.children.length && expandedKeys.has(node.key)) { + walk(node.children, depth + 1) + } + }) + } + walk(tree, 0) + return result +} + +const collectMatches = (nodes, ancestors, options) => { + const { normalizedSearch, result } = options + nodes.forEach((node) => { + const isMatch = node.prefix.toLowerCase().includes(normalizedSearch) + if (isMatch) { + result.matchedKeys.add(node.key) + ancestors.forEach((key) => result.expandedAncestorKeys.add(key)) + } + if (node.children.length) { + collectMatches(node.children, [...ancestors, node.key], options) + } + }) +} + +export const getSearchMatches = (tree, normalizedSearch) => { + const result = { matchedKeys: new Set(), expandedAncestorKeys: new Set() } + collectMatches(tree, [], { normalizedSearch, result }) + return result +} + +export const nodeMatchesOrHasMatch = (node, matchedKeys) => + matchedKeys.has(node.key) || + node.children.some((child) => nodeMatchesOrHasMatch(child, matchedKeys)) + +export const flattenAllNodes = (tree) => { + const result = [] + const walk = (nodes) => { + nodes.forEach((node) => { + result.push(node) + if (node.children.length) { + walk(node.children) + } + }) + } + walk(tree) + return result +} diff --git a/src/util/requests.js b/src/util/requests.js index 2e827d914c..0006ed0bf9 100644 --- a/src/util/requests.js +++ b/src/util/requests.js @@ -188,3 +188,14 @@ export const ORG_UNIT_DETAILS_QUERY = { }), }, } + +export const ORG_UNIT_PATH_DETAILS_QUERY = { + orgUnits: { + resource: 'organisationUnits', + params: ({ ids }) => ({ + filter: `id:in:[${ids.join(',')}]`, + fields: 'id,displayName~rename(name),level', + paging: false, + }), + }, +} diff --git a/src/util/tableHeaders.js b/src/util/tableHeaders.js index 7e6be114cf..c12df09110 100644 --- a/src/util/tableHeaders.js +++ b/src/util/tableHeaders.js @@ -3,11 +3,18 @@ import { RENDERER_COLOR, RENDERER_ICON, RENDERER_DATE, + RENDERER_ORG_UNIT, + RENDERER_ORG_UNIT_NAME, TYPE_NUMBER, TYPE_STRING, TYPE_DATE, TYPE_DATETIME, TYPE_TIME, + TYPE_ORG_UNIT, + ORG_UNIT_PATH_DATA_KEY, + ORG_UNIT_DATA_KEY, + ORG_UNIT_ID_DATA_KEY, + ORG_UNIT_LEVEL_DATA_KEY, } from '../constants/dataTable.js' import { EVENT_LAYER, @@ -23,6 +30,7 @@ import { dateValueTypes, datetimeValueTypes, timeValueTypes, + ouValueTypes, } from '../constants/valueTypes.js' import { hasClasses } from './earthEngine.js' import { getGeojsonDisplayData } from './geojson.js' @@ -31,6 +39,14 @@ import { isValidUid } from './uid.js' export { TYPE_NUMBER, TYPE_STRING, TYPE_DATE, TYPE_DATETIME, TYPE_TIME } +// A custom ORGANISATION_UNIT-valued field is always plain text, on both +// Event and Tracked Entity layers: the events analytics query always +// resolves it to a display name server-side (a hardcoded `_name` column +// select - no outputIdScheme param can change this), and tracker attribute +// values are a bare id with no ancestor chain to reverse-resolve safely +// (org unit names aren't guaranteed unique). Either way there's no reliable +// path/ancestor data to build a tree filter from - only "Org unit +// hierarchy" (the layer's own org unit) gets that treatment. const getCustomFieldType = (valueType, hasOptionSet) => { if (hasOptionSet) { return TYPE_STRING @@ -52,45 +68,72 @@ const getCustomFieldType = (valueType, hasOptionSet) => { const DATE_LIKE_TYPES = new Set([TYPE_DATE, TYPE_DATETIME, TYPE_TIME]) -const getCustomFieldRenderer = (type) => - DATE_LIKE_TYPES.has(type) ? RENDERER_DATE : undefined +// Keyed off valueType (not the column's TYPE_STRING type) so an +// ORGANISATION_UNIT-valued field's cell still resolves to a readable name: +// a real id->name lookup for tracker-sourced (Tracked Entity) values, and a +// harmless no-op for analytics-sourced (Event) values that are already a +// name (formatOrgUnitPathBreadcrumb falls back to the raw string when it +// finds no matching id in idToName). +const getCustomFieldRenderer = (type, valueType) => { + if (DATE_LIKE_TYPES.has(type)) { + return RENDERER_DATE + } + if (ouValueTypes.includes(valueType)) { + return RENDERER_ORG_UNIT + } + return undefined +} -const NAME = 'name' const ID = 'id' const VALUE = 'rawValue' const LEGEND = 'legend' const RANGE = 'range' -const LEVEL = 'level' -const PARENT_NAME = 'parentName' +const LEVEL = ORG_UNIT_LEVEL_DATA_KEY const TYPE = 'type' const COLOR = 'color' const GROUP = 'group' const ICON = 'iconUrl' -const OUNAME = 'ouname' const OUBOUNDARY = 'ouBoundary' const EVENTDATE = 'eventdate' +const ORG_UNIT_PATH = ORG_UNIT_PATH_DATA_KEY +const ORG_UNIT = ORG_UNIT_DATA_KEY +const ORG_UNIT_ID = ORG_UNIT_ID_DATA_KEY export const ERROR_NON_HOMOGENOUS_FEATURES = 'NON_HOMOGENOUS_FEATURES' const defaultFieldsMap = () => ({ - [NAME]: { name: i18n.t('Name'), dataKey: NAME, type: TYPE_STRING }, [ID]: { name: i18n.t('Id'), dataKey: ID, type: TYPE_STRING }, - [LEVEL]: { name: i18n.t('Level'), dataKey: LEVEL, type: TYPE_NUMBER }, - [PARENT_NAME]: { - name: i18n.t('Parent'), - dataKey: PARENT_NAME, + [ORG_UNIT_ID]: { + name: i18n.t('Org unit Id'), + dataKey: ORG_UNIT_ID, type: TYPE_STRING, }, - [TYPE]: { name: i18n.t('Type'), dataKey: TYPE, type: TYPE_STRING }, + [ORG_UNIT]: { + name: i18n.t('Org unit'), + dataKey: ORG_UNIT, + type: TYPE_STRING, + renderer: RENDERER_ORG_UNIT_NAME, + }, + [LEVEL]: { + name: i18n.t('Org unit level'), + dataKey: LEVEL, + type: TYPE_NUMBER, + }, + [TYPE]: { name: i18n.t('Geometry type'), dataKey: TYPE, type: TYPE_STRING }, [VALUE]: { name: i18n.t('Value'), dataKey: VALUE, type: TYPE_NUMBER }, [LEGEND]: { name: i18n.t('Legend'), dataKey: LEGEND, type: TYPE_STRING }, [RANGE]: { name: i18n.t('Range'), dataKey: RANGE, type: TYPE_STRING }, - [OUNAME]: { name: i18n.t('Org unit'), dataKey: OUNAME, type: TYPE_STRING }, [OUBOUNDARY]: { name: i18n.t('Org unit boundary'), dataKey: OUBOUNDARY, type: TYPE_STRING, }, + [ORG_UNIT_PATH]: { + name: i18n.t('Org unit hierarchy'), + dataKey: ORG_UNIT_PATH, + type: TYPE_ORG_UNIT, + renderer: RENDERER_ORG_UNIT, + }, [EVENTDATE]: { name: i18n.t('Event date'), dataKey: EVENTDATE, @@ -112,6 +155,16 @@ const defaultFieldsMap = () => ({ }, }) +const idFieldAs = (name) => ({ ...defaultFieldsMap()[ID], name }) + +const getOrgUnitCoreFields = (idLabel, { includeOrgUnitId = false } = {}) => [ + idFieldAs(idLabel), + ...(includeOrgUnitId ? [defaultFieldsMap()[ORG_UNIT_ID]] : []), + defaultFieldsMap()[ORG_UNIT], + defaultFieldsMap()[LEVEL], + defaultFieldsMap()[ORG_UNIT_PATH], +] + const getStyleHeaders = ({ hasLegend, hasRange, @@ -139,11 +192,12 @@ const getStyleHeaders = ({ } const getThematicHeaders = () => - [NAME, ID, VALUE, LEVEL, PARENT_NAME, TYPE] - .map((field) => defaultFieldsMap()[field]) + getOrgUnitCoreFields(i18n.t('Org unit Id')) + .concat(defaultFieldsMap()[VALUE]) .concat( getStyleHeaders({ hasLegend: true, hasRange: true, hasColor: true }) ) + .concat(defaultFieldsMap()[TYPE]) const getMultiPeriodThematicHeaders = ({ isTimelineThematic, @@ -184,9 +238,9 @@ const getEventHeaders = ({ styleDataItem, countEventsOutsideOrgUnits, }) => { - const fields = [OUNAME, ID, EVENTDATE].map( - (field) => defaultFieldsMap()[field] - ) + const fields = getOrgUnitCoreFields(i18n.t('Event Id'), { + includeOrgUnitId: true, + }).concat(defaultFieldsMap()[EVENTDATE]) if (countEventsOutsideOrgUnits) { fields.push(defaultFieldsMap()[OUBOUNDARY]) @@ -200,13 +254,12 @@ const getEventHeaders = ({ name, dataKey, type, - renderer: getCustomFieldRenderer(type), + renderer: getCustomFieldRenderer(type, valueType), optionSet: optionSet || null, } }) customFields.push( - defaultFieldsMap()[TYPE], ...getStyleHeaders({ hasLegend: !!styleDataItem, hasRange: !!styleDataItem, @@ -214,7 +267,7 @@ const getEventHeaders = ({ }) ) - return fields.concat(customFields) + return fields.concat(customFields).concat(defaultFieldsMap()[TYPE]) } const getOrgUnitStyleHeaders = (data) => { @@ -235,17 +288,17 @@ const getOrgUnitStyleHeaders = (data) => { return getStyleHeaders({ hasGroup, hasColor, hasIcon }) } -// Org unit and facility headers share the same shape -const getFixedFieldsWithOrgUnitStyle = (fields, data) => - fields - .map((field) => defaultFieldsMap()[field]) +const getFixedFieldsWithOrgUnitStyle = (data) => + getOrgUnitCoreFields(i18n.t('Org unit Id')) .concat(getOrgUnitStyleHeaders(data)) + .concat(defaultFieldsMap()[TYPE]) -const getOrgUnitHeaders = (data) => - getFixedFieldsWithOrgUnitStyle([NAME, ID, LEVEL, PARENT_NAME, TYPE], data) +const getOrgUnitHeaders = (data) => getFixedFieldsWithOrgUnitStyle(data) const getTrackedEntityHeaders = ({ layerHeaders = [] }) => { - const fields = [ID].map((field) => defaultFieldsMap()[field]) + const fields = getOrgUnitCoreFields(i18n.t('Tracked entity Id'), { + includeOrgUnitId: true, + }) const customFields = layerHeaders .filter(({ dataKey }) => isValidUid(dataKey)) @@ -255,17 +308,16 @@ const getTrackedEntityHeaders = ({ layerHeaders = [] }) => { name, dataKey, type, - renderer: getCustomFieldRenderer(type), + renderer: getCustomFieldRenderer(type, valueType), } }) customFields.push(...getStyleHeaders({ hasColor: true })) - return fields.concat(customFields) + return fields.concat(customFields).concat(defaultFieldsMap()[TYPE]) } -const getFacilityHeaders = (data) => - getFixedFieldsWithOrgUnitStyle([NAME, ID, TYPE], data) +const getFacilityHeaders = (data) => getFixedFieldsWithOrgUnitStyle(data) const toTitleCase = (str) => str.replace( @@ -301,9 +353,9 @@ const getEarthEngineHeaders = ({ aggregationType, legend, data }) => { }) } - return [NAME, ID, TYPE] - .map((field) => defaultFieldsMap()[field]) + return getOrgUnitCoreFields(i18n.t('Org unit Id')) .concat(customFields) + .concat(defaultFieldsMap()[TYPE]) } const getGeoJsonUrlHeaders = (firstDataItem) => From ac29924620a7106315fd736809eb173f1ff0f856 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Sat, 25 Jul 2026 14:57:07 +0200 Subject: [PATCH 123/205] chore: PR clean-up --- cypress/integration/dataTable.cy.js | 63 ++++++----- src/components/datatable/FilterInput.jsx | 104 ++++++++++++------ .../datatable/OrgUnitGroupFilterInput.jsx | 15 +-- .../datatable/__tests__/FilterInput.spec.jsx | 89 ++++++++++++++- src/util/__tests__/filter.spec.js | 16 +++ src/util/dateGroups.js | 7 +- src/util/filter.js | 23 +++- src/util/filterInput.js | 15 ++- src/util/orgUnitGroups.js | 4 +- 9 files changed, 256 insertions(+), 80 deletions(-) diff --git a/cypress/integration/dataTable.cy.js b/cypress/integration/dataTable.cy.js index 6e6bfc048f..d1864f59a6 100644 --- a/cypress/integration/dataTable.cy.js +++ b/cypress/integration/dataTable.cy.js @@ -85,8 +85,9 @@ describe('data table', () => { .findByDataTest('dhis2-uicore-datatablecellhead') .should('have.length', 10) - // Filter by name - cy.getByDataTest('data-table-column-filter-search-Name') + // Filter by name (the "Name" column was renamed "Org unit" and moved + // to column 2 - "Org unit Id" (the row's own id) is now column 1) + cy.getByDataTest('data-table-column-filter-search-Org unit') .find('input') .type('bar{enter}') @@ -97,11 +98,11 @@ describe('data table', () => { .should('have.length', 7) // Confirm that the sort order is initially ascending by Name - checkTableCell({ row: 0, column: 1, expectedContent: 'Bargbe' }) - checkTableCell({ row: 6, column: 1, expectedContent: 'Upper Bambara' }) + checkTableCell({ row: 0, column: 2, expectedContent: 'Bargbe' }) + checkTableCell({ row: 6, column: 2, expectedContent: 'Upper Bambara' }) // Sort by name - cy.getByDataTest('data-table-column-sort-button-Name').click() + cy.getByDataTest('data-table-column-sort-button-Org unit').click() // Sorting can shift the virtualized table's scroll position // (possibly an internal react-virtuoso quirk) @@ -109,8 +110,8 @@ describe('data table', () => { cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') // Confirm that the rows are sorted by Name descending - checkTableCell({ row: 0, column: 1, expectedContent: 'Upper Bambara' }) - checkTableCell({ row: 6, column: 1, expectedContent: 'Bargbe' }) + checkTableCell({ row: 0, column: 2, expectedContent: 'Upper Bambara' }) + checkTableCell({ row: 6, column: 2, expectedContent: 'Bargbe' }) // Filter by Value (numeric) cy.getByDataTest('data-table-column-filter-search-Value') @@ -130,8 +131,10 @@ describe('data table', () => { cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') // Check that the rows are sorted by Value ascending - checkTableCell({ row: 0, column: 3, expectedContent: '35' }) - checkTableCell({ row: 4, column: 3, expectedContent: '76' }) + // ("Value" moved from column 3 to column 5: Org unit Id, Org unit, + // Org unit level and Org unit hierarchy now precede it) + checkTableCell({ row: 0, column: 5, expectedContent: '35' }) + checkTableCell({ row: 4, column: 5, expectedContent: '76' }) // Right-click a row and select "View profile" cy.getByDataTest('bottom-panel') @@ -209,8 +212,10 @@ describe('data table', () => { .type(`${ouName}{enter}`) // Check that all the rows have Org unit Moyowa - checkTableCell({ row: 0, column: 1, expectedContent: ouName }) - checkTableCell({ row: 2, column: 1, expectedContent: ouName }) + // ("Org unit" moved from column 1 to column 3 - "Event Id" and the + // new "Org unit Id" column now precede it) + checkTableCell({ row: 0, column: 3, expectedContent: ouName }) + checkTableCell({ row: 2, column: 3, expectedContent: ouName }) cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-tablebody') @@ -317,7 +322,9 @@ describe('data table', () => { cy.getByDataTest('layers-toggle-button').click() // Confirm that the sort order is initially ascending by Name - checkTableCell({ row: 0, column: 1, expectedContent: 'Bendu CHC' }) + // ("Name" is now the "Org unit" column, at index 2 - "Org unit Id" + // (the row's own id) is column 1) + checkTableCell({ row: 0, column: 2, expectedContent: 'Bendu CHC' }) // First click on a new column always sorts ascending cy.getByDataTest('data-table-column-sort-button-Value').click() @@ -326,15 +333,17 @@ describe('data table', () => { cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') // Check that first row has Tihun CHC with value 28.63 - checkTableCell({ row: 0, column: 1, expectedContent: 'Tihun CHC' }) - checkTableCell({ row: 0, column: 3, expectedContent: '28.63' }) + // ("Value" moved from column 3 to column 5: Org unit Id, Org unit, + // Org unit level and Org unit hierarchy now precede it) + checkTableCell({ row: 0, column: 2, expectedContent: 'Tihun CHC' }) + checkTableCell({ row: 0, column: 5, expectedContent: '28.63' }) // Check that row 5 has Gbamgbama CHC with value 117.98 - checkTableCell({ row: 5, column: 1, expectedContent: 'Gbamgbama CHC' }) - checkTableCell({ row: 5, column: 3, expectedContent: '117.98' }) + checkTableCell({ row: 5, column: 2, expectedContent: 'Gbamgbama CHC' }) + checkTableCell({ row: 5, column: 5, expectedContent: '117.98' }) // Check that row 6 has no value (undefined) - checkTableCell({ row: 6, column: 3, expectedContent: '' }) + checkTableCell({ row: 6, column: 5, expectedContent: '' }) // Sort descending by Value cy.getByDataTest('data-table-column-sort-button-Value').click() @@ -342,13 +351,13 @@ describe('data table', () => { // Reset scroll position after sorting - see comment above cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') - checkTableCell({ row: 0, column: 1, expectedContent: 'Gbamgbama CHC' }) - checkTableCell({ row: 0, column: 3, expectedContent: '117.98' }) + checkTableCell({ row: 0, column: 2, expectedContent: 'Gbamgbama CHC' }) + checkTableCell({ row: 0, column: 5, expectedContent: '117.98' }) - checkTableCell({ row: 5, column: 1, expectedContent: 'Tihun CHC' }) - checkTableCell({ row: 5, column: 3, expectedContent: '28.63' }) + checkTableCell({ row: 5, column: 2, expectedContent: 'Tihun CHC' }) + checkTableCell({ row: 5, column: 5, expectedContent: '28.63' }) - checkTableCell({ row: 6, column: 3, expectedContent: '' }) + checkTableCell({ row: 6, column: 5, expectedContent: '' }) // Third click on the same column cycles back to natural (unsorted) // order - there's no dedicated Index column/button any more @@ -358,7 +367,9 @@ describe('data table', () => { cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') // Check that row 0 range value is empty - checkTableCell({ row: 0, column: 8, expectedContent: '' }) + // ("Range" moved from column 8 to column 7: Value now precedes + // Legend/Range/Color instead of following Name/Id/Value/Level/Parent) + checkTableCell({ row: 0, column: 7, expectedContent: '' }) // Sort by range, which is a string cy.getByDataTest('data-table-column-sort-button-Range').click() @@ -367,12 +378,12 @@ describe('data table', () => { cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') // Check that row 0 range value has value '0-40' - checkTableCell({ row: 0, column: 8, expectedContent: '0 – 40' }) + checkTableCell({ row: 0, column: 7, expectedContent: '0 – 40' }) // Check that row 5 range value has value '90 - 120' - checkTableCell({ row: 5, column: 8, expectedContent: '90 – 120' }) + checkTableCell({ row: 5, column: 7, expectedContent: '90 – 120' }) // Check that row 6 range value is empty - checkTableCell({ row: 6, column: 8, expectedContent: '' }) + checkTableCell({ row: 6, column: 7, expectedContent: '' }) }) }) diff --git a/src/components/datatable/FilterInput.jsx b/src/components/datatable/FilterInput.jsx index d4cec63b3c..f4733601bb 100644 --- a/src/components/datatable/FilterInput.jsx +++ b/src/components/datatable/FilterInput.jsx @@ -119,10 +119,36 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({ applyValues(next) } - const applyCustomFilter = (text) => - text - ? dispatch(setDataFilter(layerId, dataKey, text)) - : dispatch(clearDataFilter(layerId, dataKey)) + const isOrgUnitRenderer = + renderer === RENDERER_ORG_UNIT || renderer === RENDERER_ORG_UNIT_NAME + + // For an org-unit-flavored column, the typed text is a name but the + // stored value is a raw path/id - resolve it to the matching raw values + // up front, so the filter itself is always raw-value based. That keeps + // matching consistent between the data table and every map layer, which + // filter this same `dataFilters` state independently (see filter.js's + // isOrgUnitValueFilter) and have no id->name resolution of their own. + const applyCustomFilter = (text) => { + if (!text) { + dispatch(clearDataFilter(layerId, dataKey)) + return + } + if (isOrgUnitRenderer) { + const lower = text.toLowerCase() + const values = realValues.filter((value) => + resolveLabel(value).toLowerCase().includes(lower) + ) + dispatch( + setDataFilter(layerId, dataKey, { + values, + searchDerived: true, + searchText: text, + }) + ) + return + } + dispatch(setDataFilter(layerId, dataKey, text)) + } const isIconColumn = renderer === RENDERER_ICON @@ -582,35 +608,47 @@ const FilterInput = React.memo(function FilterInput({ const isDateType = type === TYPE_DATE || type === TYPE_DATETIME || type === TYPE_TIME - return isDateType ? ( - <DateGroupFilterInput - dataKey={dataKey} - name={name} - layerId={layerId} - filterValue={filterValue} - options={options ?? []} - type={type} - /> - ) : type === TYPE_ORG_UNIT ? ( - <OrgUnitGroupFilterInput - dataKey={dataKey} - name={name} - layerId={layerId} - filterValue={filterValue} - options={options ?? []} - /> - ) : optionSetId ? ( - <OptionSetSearchableFilter - dataKey={dataKey} - name={name} - layerId={layerId} - filterValue={filterValue} - options={options ?? []} - optionSetId={optionSetId} - type={type} - renderer={renderer} - /> - ) : ( + if (isDateType) { + return ( + <DateGroupFilterInput + dataKey={dataKey} + name={name} + layerId={layerId} + filterValue={filterValue} + options={options ?? []} + type={type} + /> + ) + } + + if (type === TYPE_ORG_UNIT) { + return ( + <OrgUnitGroupFilterInput + dataKey={dataKey} + name={name} + layerId={layerId} + filterValue={filterValue} + options={options ?? []} + /> + ) + } + + if (optionSetId) { + return ( + <OptionSetSearchableFilter + dataKey={dataKey} + name={name} + layerId={layerId} + filterValue={filterValue} + options={options ?? []} + optionSetId={optionSetId} + type={type} + renderer={renderer} + /> + ) + } + + return ( <PlainSearchableFilter dataKey={dataKey} name={name} diff --git a/src/components/datatable/OrgUnitGroupFilterInput.jsx b/src/components/datatable/OrgUnitGroupFilterInput.jsx index e6b397f513..1e0928df4f 100644 --- a/src/components/datatable/OrgUnitGroupFilterInput.jsx +++ b/src/components/datatable/OrgUnitGroupFilterInput.jsx @@ -57,6 +57,13 @@ const HELP_CONTENT = ( ) const INDENT_PX = 16 +const getAppliedString = (filterValue) => { + if (isOrgUnitGroupFilter(filterValue)) { + return filterValue.searchDerived ? filterValue.searchText : '' + } + return typeof filterValue === 'string' ? filterValue : '' +} + const OrgUnitGroupFilterInput = ({ dataKey, name, @@ -80,13 +87,7 @@ const OrgUnitGroupFilterInput = ({ isOrgUnitGroupFilter(filterValue) && !filterValue.searchDerived ? filterValue.prefixes : [] - const appliedString = isOrgUnitGroupFilter(filterValue) - ? filterValue.searchDerived - ? filterValue.searchText - : '' - : typeof filterValue === 'string' - ? filterValue - : '' + const appliedString = getAppliedString(filterValue) const anyValueActive = selectedPrefixes.includes(SENTINEL_ANY_VALUE) const notSetActive = selectedPrefixes.includes(SENTINEL_NO_VALUE) const treePrefixes = selectedPrefixes.filter( diff --git a/src/components/datatable/__tests__/FilterInput.spec.jsx b/src/components/datatable/__tests__/FilterInput.spec.jsx index 6dbaf6f6c1..06f5772fd4 100644 --- a/src/components/datatable/__tests__/FilterInput.spec.jsx +++ b/src/components/datatable/__tests__/FilterInput.spec.jsx @@ -7,7 +7,10 @@ import { DATA_FILTER_SET, DATA_FILTER_CLEAR, } from '../../../constants/actionTypes.js' -import { SENTINEL_ANY_VALUE } from '../../../constants/dataTable.js' +import { + SENTINEL_ANY_VALUE, + RENDERER_ORG_UNIT_NAME, +} from '../../../constants/dataTable.js' import useOptionSet from '../../../hooks/useOptionSet.js' import FilterInput from '../FilterInput.jsx' @@ -462,6 +465,90 @@ describe('FilterInput searchable popover — custom filter row', () => { }) }) +describe('FilterInput searchable popover — org-unit-flavored plain-text column', () => { + const options = [{ value: 'facility1' }, { value: 'facility2' }] + const orgUnitIdToName = new Map([ + ['facility1', 'Moyowa CHC'], + ['facility2', 'Tihun CHC'], + ]) + + // "Org unit" (and any custom ORGANISATION_UNIT-valued field) stores a + // raw path/id but is filtered via the plain "Contains" box, unlike the + // tree-filterable "Org unit hierarchy" column - typing a name must still + // resolve to the matching raw value(s) up front, not commit the typed + // text itself, so that map layers (which match dataFilters against the + // raw stored value with no name resolution of their own) stay in sync + // with what the table shows. + test('resolves typed text to the matching raw value(s), not the raw typed text', () => { + const { store } = renderFilterInput({ + dataKey: 'orgUnitOwn', + name: 'Org unit', + renderer: RENDERER_ORG_UNIT_NAME, + options, + orgUnitIdToName, + }) + openPopover('Org unit') + fireEvent.change(getInput('Org unit'), { + target: { value: 'Moyowa' }, + }) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'orgUnitOwn', + filter: { + values: ['facility1'], + searchDerived: true, + searchText: 'Moyowa', + }, + }) + }) + + test('resolves to an empty values list (matches nothing) rather than falling back to raw-text matching', () => { + const { store } = renderFilterInput({ + dataKey: 'orgUnitOwn', + name: 'Org unit', + renderer: RENDERER_ORG_UNIT_NAME, + options, + orgUnitIdToName, + }) + openPopover('Org unit') + fireEvent.change(getInput('Org unit'), { + target: { value: 'no such place' }, + }) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'orgUnitOwn', + filter: { + values: [], + searchDerived: true, + searchText: 'no such place', + }, + }) + }) + + test('does not show any checkbox as checked while the search-derived filter is active', () => { + renderFilterInput( + { + dataKey: 'orgUnitOwn', + name: 'Org unit', + renderer: RENDERER_ORG_UNIT_NAME, + options, + orgUnitIdToName, + }, + { + orgUnitOwn: { + values: ['facility1'], + searchDerived: true, + searchText: 'Moyowa', + }, + } + ) + openPopover('Org unit') + expect(screen.getByLabelText('Moyowa CHC')).not.toBeChecked() + }) +}) + describe('FilterInput searchable popover — keyboard behavior', () => { const options = [{ value: 'High' }, { value: 'Low' }] diff --git a/src/util/__tests__/filter.spec.js b/src/util/__tests__/filter.spec.js index e07e6dec2d..edcd9f3fd8 100644 --- a/src/util/__tests__/filter.spec.js +++ b/src/util/__tests__/filter.spec.js @@ -248,6 +248,22 @@ describe('filterData', () => { expect(filterData(data, filters)).toEqual([{ a: null }]) }) }) + + describe('org-unit value filter ({ values, searchDerived, searchText }) - a committed free-text search on an org-unit-flavored plain-text column, resolved to matching raw values up front (see FilterInput.jsx)', () => { + const data = [{ a: 'moyowaId' }, { a: 'otherId' }, { a: null }] + + it('matches rows whose raw value is in the resolved values list', () => { + const filters = { + a: { values: ['moyowaId'], searchDerived: true }, + } + expect(filterData(data, filters)).toEqual([{ a: 'moyowaId' }]) + }) + + it('matches no rows when nothing resolved (distinct from an empty checkbox array, which matches everything)', () => { + const filters = { a: { values: [], searchDerived: true } } + expect(filterData(data, filters)).toEqual([]) + }) + }) }) describe('filterByGlobalSearch', () => { diff --git a/src/util/dateGroups.js b/src/util/dateGroups.js index 5eca07fd99..ff9fd7eb43 100644 --- a/src/util/dateGroups.js +++ b/src/util/dateGroups.js @@ -1,6 +1,5 @@ import { TYPE_DATETIME, TYPE_TIME } from '../constants/dataTable.js' import { formatDate, formatDatetime } from './helpers.js' -import { togglePrefix } from './prefixTree.js' import { dateLocale } from './time.js' export { @@ -8,8 +7,8 @@ export { flattenVisibleNodes, getSearchMatches, nodeMatchesOrHasMatch, + togglePrefix as toggleDateGroupPrefix, } from './prefixTree.js' -export const toggleDateGroupPrefix = togglePrefix const DATE_KEY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})(?:([T ])(\d{2}))?/ const TIME_KEY_PATTERN = /^(\d{2}):/ @@ -18,11 +17,11 @@ export const parseDateGroupKey = (rawValue, granularity) => { const str = String(rawValue) if (granularity === TYPE_TIME) { - const match = str.match(TIME_KEY_PATTERN) + const match = TIME_KEY_PATTERN.exec(str) return match ? { hour: match[1] } : null } - const match = str.match(DATE_KEY_PATTERN) + const match = DATE_KEY_PATTERN.exec(str) if (!match) { return null } diff --git a/src/util/filter.js b/src/util/filter.js index 01e0ff2bba..78d6f1f898 100644 --- a/src/util/filter.js +++ b/src/util/filter.js @@ -34,6 +34,20 @@ export const isDateGroupFilter = (filter) => export const isOrgUnitGroupFilter = (filter) => isPrefixGroupFilter(filter, ORG_UNIT_GROUPS_GRANULARITY) +// A committed free-text search on an org-unit-flavored plain-text column +// (see FilterInput.jsx's applyCustomFilter) - the search text is resolved +// to matching raw stored values up front, at commit time, so the stored +// filter is always a plain list of raw values. That keeps matching +// consistent everywhere `filterData` is called (the data table AND every +// map layer, which filter the same `dataFilters` state independently and +// have no access to the id->name resolution used to interpret typed text). +export const isOrgUnitValueFilter = (filter) => + filter != null && + typeof filter === 'object' && + !Array.isArray(filter) && + Array.isArray(filter.values) && + filter.searchDerived === true + // Filters an array of object with a set of filters export const filterData = (data, filters) => { if (!filters) { @@ -55,10 +69,15 @@ export const filterData = (data, filters) => { return prefixGroupFilter(value, filter) } + const stringValue = + value == null ? SENTINEL_NO_VALUE : String(value) + + if (isOrgUnitValueFilter(filter)) { + return filter.values.includes(stringValue) + } + if (Array.isArray(filter)) { // Multi-select: OR match against the raw stored value - const stringValue = - value == null ? SENTINEL_NO_VALUE : String(value) return ( filter.length === 0 || filter.includes(stringValue) || diff --git a/src/util/filterInput.js b/src/util/filterInput.js index bb7773b636..9a3dc75d01 100644 --- a/src/util/filterInput.js +++ b/src/util/filterInput.js @@ -1,6 +1,6 @@ import i18n from '@dhis2/d2-i18n' import { TYPE_NUMBER } from '../constants/dataTable.js' -import { numericFilter } from './filter.js' +import { isOrgUnitValueFilter, numericFilter } from './filter.js' const POPOVER_ROW_NON_LABEL_WIDTH = 56 const MIN_POPOVER_WIDTH = 140 @@ -11,10 +11,15 @@ const MAX_POPOVER_WIDTH = 280 export const OPTION_ROW_HEIGHT = 28 export const MAX_LIST_HEIGHT = 260 -export const getSelectedAndAppliedString = (filterValue) => ({ - selected: Array.isArray(filterValue) ? filterValue : [], - appliedString: typeof filterValue === 'string' ? filterValue : '', -}) +export const getSelectedAndAppliedString = (filterValue) => { + if (isOrgUnitValueFilter(filterValue)) { + return { selected: [], appliedString: filterValue.searchText ?? '' } + } + return { + selected: Array.isArray(filterValue) ? filterValue : [], + appliedString: typeof filterValue === 'string' ? filterValue : '', + } +} export const getDisplayValue = ({ isOpen, diff --git a/src/util/orgUnitGroups.js b/src/util/orgUnitGroups.js index 51defde712..08d30cd6d8 100644 --- a/src/util/orgUnitGroups.js +++ b/src/util/orgUnitGroups.js @@ -64,7 +64,7 @@ export const formatOrgUnitPathBreadcrumb = (path, idToName) => .join(' / ') export const formatOrgUnitOwnName = (path, idToName) => { - const leafId = String(path).split('/').filter(Boolean).pop() + const leafId = String(path).split('/').findLast(Boolean) return formatOrgUnitNodeLabel({ key: leafId }, idToName) } @@ -74,7 +74,7 @@ const collectOrgUnitMatches = (nodes, ancestors, options) => { const name = idToName?.get(node.key) const isMatch = node.prefix.toLowerCase().includes(normalizedSearch) || - (name && name.toLowerCase().includes(normalizedSearch)) + name?.toLowerCase().includes(normalizedSearch) if (isMatch) { result.matchedKeys.add(node.key) ancestors.forEach((key) => result.expandedAncestorKeys.add(key)) From 11dc96bb677e8c95f063ce02264531c902922cac Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Sat, 25 Jul 2026 15:14:40 +0200 Subject: [PATCH 124/205] chore: GroupFilterPopover refactor --- .../datatable/DateGroupFilterInput.jsx | 249 +++------------- .../datatable/GroupFilterPopover.jsx | 269 ++++++++++++++++++ .../datatable/OrgUnitGroupFilterInput.jsx | 249 +++------------- 3 files changed, 341 insertions(+), 426 deletions(-) create mode 100644 src/components/datatable/GroupFilterPopover.jsx diff --git a/src/components/datatable/DateGroupFilterInput.jsx b/src/components/datatable/DateGroupFilterInput.jsx index 02e91c43c5..cf64ce4a56 100644 --- a/src/components/datatable/DateGroupFilterInput.jsx +++ b/src/components/datatable/DateGroupFilterInput.jsx @@ -1,15 +1,7 @@ import i18n from '@dhis2/d2-i18n' -import { - Input, - IconChevronRight16, - IconChevronDown16, - IconFilter16, -} from '@dhis2/ui' -import cx from 'classnames' import PropTypes from 'prop-types' import React, { useCallback, useMemo, useRef, useState } from 'react' import { useDispatch } from 'react-redux' -import { Virtuoso } from 'react-virtuoso' import { setDataFilter, clearDataFilter } from '../../actions/dataFilters.js' import { SENTINEL_ANY_VALUE, @@ -27,31 +19,20 @@ import { } from '../../util/dateGroups.js' import { isDateGroupFilter } from '../../util/filter.js' import { - OPTION_ROW_HEIGHT, - MAX_LIST_HEIGHT, getCyclicIndex, getDisplayValue, toOptionIndex, - toHighlightedIndex, } from '../../util/filterInput.js' import { toggleAnyValue } from '../../util/filterSelection.js' -import Checkbox from '../core/Checkbox.jsx' -import { - FilterDropdownPopover, - getDropdownPlacement, -} from './FilterDropdownPopover.jsx' -import FilterHelpTooltip from './FilterHelpTooltip.jsx' -import styles from './styles/FilterInput.module.css' +import { getDropdownPlacement } from './FilterDropdownPopover.jsx' +import GroupFilterPopover from './GroupFilterPopover.jsx' -const DATE_GROUP_POPOVER_WIDTH = 220 -const HELP_HEIGHT = 56 const HELP_CONTENT = ( <div> <div>{i18n.t('Select a year, month, day or hour')}</div> <div>{i18n.t('to match the events under it, or type to search')}</div> </div> ) -const INDENT_PX = 16 const DATE_INPUT_DISALLOWED = /[^0-9\-:. T]/g const DateGroupFilterInput = ({ @@ -288,198 +269,40 @@ const DateGroupFilterInput = ({ }) return ( - <div className={styles.filterTrigger} ref={anchorRef}> - <FilterHelpTooltip - content={HELP_CONTENT} - placement={tooltipPlacement} - estimatedHeight={HELP_HEIGHT} - dataTest="data-table-filter-help" - > - <Input - dense - clearable - dataTest={`data-table-column-filter-search-${name}`} - placeholder={i18n.t('Search')} - value={displayValue} - onFocus={() => { - if (!isOpen) { - openPopover() - } - }} - onChange={onSearchChange} - onKeyDown={onSearchKeyDown} - /> - </FilterHelpTooltip> - {isOpen && ( - <FilterDropdownPopover - reference={anchorRef} - placement={dropdownPlacement} - onClickOutside={closePopover} - className={cx( - styles.dropdownPopper, - dropdownSide === 'top' && styles.dropdownPopperAbove - )} - > - <div - className={cx(styles.searchableFilterPopover, { - [styles.reversedOrder]: dropdownSide === 'top', - })} - style={{ width: `${DATE_GROUP_POPOVER_WIDTH}px` }} - > - {showCustomFilterRow && ( - <button - type="button" - className={cx(styles.customFilterRow, { - [styles.highlighted]: - highlightedIndex === 0, - })} - data-test={`data-table-column-filter-custom-${name}`} - onClick={() => { - applyCustomFilter(searchText.trim()) - closePopover() - }} - > - <IconFilter16 /> - <span className={styles.customFilterTag}> - {i18n.t('Contains')} - </span> - <span className={styles.customFilterExpr}> - {searchText.trim()} - </span> - </button> - )} - <div className={styles.pinnedOptions}> - <Checkbox - label={i18n.t('Any value')} - checked={anyValueActive} - onChange={onToggleAnyValue} - className={cx( - styles.specialOption, - styles.denseCheckbox - )} - dataTest={`data-table-column-filter-any-${name}`} - /> - {hasNotSetOption && ( - <Checkbox - label={i18n.t('No value')} - checked={notSetActive} - onChange={onToggleNotSet} - className={cx( - styles.specialOption, - styles.denseCheckbox - )} - dataTest={`data-table-column-filter-novalue-${name}`} - /> - )} - </div> - <div className={styles.multiSelectPopover}> - {!showCustomFilterRow && - visibleNodes.length === 0 && ( - <div className={styles.noResults}> - {i18n.t('No matches')} - </div> - )} - {visibleNodes.length > 0 && ( - <Virtuoso - ref={listRef} - style={{ - height: Math.min( - visibleNodes.length * - OPTION_ROW_HEIGHT, - MAX_LIST_HEIGHT - ), - }} - increaseViewportBy={{ - top: 0, - bottom: OPTION_ROW_HEIGHT * 2, - }} - data={visibleNodes} - fixedItemHeight={OPTION_ROW_HEIGHT} - computeItemKey={(_, { node }) => node.key} - itemContent={(index, { node, depth }) => { - const state = checkStateFor(node) - const checked = state === 'checked' - const indeterminate = - state === 'indeterminate' - const isExpanded = - effectiveExpanded.has(node.key) - const label = formatNodeLabel( - node, - i18n.language - ) - return ( - <div - className={styles.treeRow} - style={{ - paddingLeft: - depth * INDENT_PX, - }} - > - {node.children.length > 0 ? ( - <button - type="button" - className={ - styles.expandButton - } - onClick={() => - onToggleExpand( - node.key - ) - } - aria-label={ - isExpanded - ? i18n.t( - 'Collapse {{label}}', - { label } - ) - : i18n.t( - 'Expand {{label}}', - { label } - ) - } - > - {isExpanded ? ( - <IconChevronDown16 /> - ) : ( - <IconChevronRight16 /> - )} - </button> - ) : ( - <span - className={ - styles.expandButtonPlaceholder - } - /> - )} - <Checkbox - label={label} - checked={checked} - indeterminate={ - indeterminate - } - onChange={() => - onToggleNode(node) - } - className={cx( - styles.denseCheckbox, - highlightedIndex === - toHighlightedIndex( - index, - showCustomFilterRow - ) && - styles.highlighted - )} - /> - </div> - ) - }} - /> - )} - </div> - </div> - </FilterDropdownPopover> - )} - </div> + <GroupFilterPopover + name={name} + helpContent={HELP_CONTENT} + customFilterTag={i18n.t('Contains')} + formatLabel={(node) => formatNodeLabel(node, i18n.language)} + anchorRef={anchorRef} + listRef={listRef} + dropdownPlacement={dropdownPlacement} + dropdownSide={dropdownSide} + tooltipPlacement={tooltipPlacement} + isOpen={isOpen} + searchText={searchText} + highlightedIndex={highlightedIndex} + displayValue={displayValue} + visibleNodes={visibleNodes} + showCustomFilterRow={showCustomFilterRow} + anyValueActive={anyValueActive} + notSetActive={notSetActive} + hasNotSetOption={hasNotSetOption} + effectiveExpanded={effectiveExpanded} + checkStateFor={checkStateFor} + openPopover={openPopover} + closePopover={closePopover} + onSearchChange={onSearchChange} + onSearchKeyDown={onSearchKeyDown} + onApplyCustomFilterClick={() => { + applyCustomFilter(searchText.trim()) + closePopover() + }} + onToggleExpand={onToggleExpand} + onToggleNode={onToggleNode} + onToggleAnyValue={onToggleAnyValue} + onToggleNotSet={onToggleNotSet} + /> ) } diff --git a/src/components/datatable/GroupFilterPopover.jsx b/src/components/datatable/GroupFilterPopover.jsx new file mode 100644 index 0000000000..36b02108de --- /dev/null +++ b/src/components/datatable/GroupFilterPopover.jsx @@ -0,0 +1,269 @@ +import i18n from '@dhis2/d2-i18n' +import { + Input, + IconChevronRight16, + IconChevronDown16, + IconFilter16, +} from '@dhis2/ui' +import cx from 'classnames' +import PropTypes from 'prop-types' +import React from 'react' +import { Virtuoso } from 'react-virtuoso' +import { + OPTION_ROW_HEIGHT, + MAX_LIST_HEIGHT, + toHighlightedIndex, +} from '../../util/filterInput.js' +import Checkbox from '../core/Checkbox.jsx' +import { FilterDropdownPopover } from './FilterDropdownPopover.jsx' +import FilterHelpTooltip from './FilterHelpTooltip.jsx' +import styles from './styles/FilterInput.module.css' + +const GROUP_POPOVER_WIDTH = 220 +const HELP_HEIGHT = 56 +const INDENT_PX = 16 + +const GroupFilterPopover = ({ + name, + helpContent, + customFilterTag, + formatLabel, + anchorRef, + listRef, + dropdownPlacement, + dropdownSide, + tooltipPlacement, + isOpen, + searchText, + highlightedIndex, + displayValue, + visibleNodes, + showCustomFilterRow, + anyValueActive, + notSetActive, + hasNotSetOption, + effectiveExpanded, + checkStateFor, + openPopover, + closePopover, + onSearchChange, + onSearchKeyDown, + onApplyCustomFilterClick, + onToggleExpand, + onToggleNode, + onToggleAnyValue, + onToggleNotSet, +}) => ( + <div className={styles.filterTrigger} ref={anchorRef}> + <FilterHelpTooltip + content={helpContent} + placement={tooltipPlacement} + estimatedHeight={HELP_HEIGHT} + dataTest="data-table-filter-help" + > + <Input + dense + clearable + dataTest={`data-table-column-filter-search-${name}`} + placeholder={i18n.t('Search')} + value={displayValue} + onFocus={() => { + if (!isOpen) { + openPopover() + } + }} + onChange={onSearchChange} + onKeyDown={onSearchKeyDown} + /> + </FilterHelpTooltip> + {isOpen && ( + <FilterDropdownPopover + reference={anchorRef} + placement={dropdownPlacement} + onClickOutside={closePopover} + className={cx( + styles.dropdownPopper, + dropdownSide === 'top' && styles.dropdownPopperAbove + )} + > + <div + className={cx(styles.searchableFilterPopover, { + [styles.reversedOrder]: dropdownSide === 'top', + })} + style={{ width: `${GROUP_POPOVER_WIDTH}px` }} + > + {showCustomFilterRow && ( + <button + type="button" + className={cx(styles.customFilterRow, { + [styles.highlighted]: highlightedIndex === 0, + })} + data-test={`data-table-column-filter-custom-${name}`} + onClick={onApplyCustomFilterClick} + > + <IconFilter16 /> + <span className={styles.customFilterTag}> + {customFilterTag} + </span> + <span className={styles.customFilterExpr}> + {searchText.trim()} + </span> + </button> + )} + <div className={styles.pinnedOptions}> + <Checkbox + label={i18n.t('Any value')} + checked={anyValueActive} + onChange={onToggleAnyValue} + className={cx( + styles.specialOption, + styles.denseCheckbox + )} + dataTest={`data-table-column-filter-any-${name}`} + /> + {hasNotSetOption && ( + <Checkbox + label={i18n.t('No value')} + checked={notSetActive} + onChange={onToggleNotSet} + className={cx( + styles.specialOption, + styles.denseCheckbox + )} + dataTest={`data-table-column-filter-novalue-${name}`} + /> + )} + </div> + <div className={styles.multiSelectPopover}> + {!showCustomFilterRow && visibleNodes.length === 0 && ( + <div className={styles.noResults}> + {i18n.t('No matches')} + </div> + )} + {visibleNodes.length > 0 && ( + <Virtuoso + ref={listRef} + style={{ + height: Math.min( + visibleNodes.length * OPTION_ROW_HEIGHT, + MAX_LIST_HEIGHT + ), + }} + increaseViewportBy={{ + top: 0, + bottom: OPTION_ROW_HEIGHT * 2, + }} + data={visibleNodes} + fixedItemHeight={OPTION_ROW_HEIGHT} + computeItemKey={(_, { node }) => node.key} + itemContent={(index, { node, depth }) => { + const state = checkStateFor(node) + const checked = state === 'checked' + const indeterminate = + state === 'indeterminate' + const isExpanded = effectiveExpanded.has( + node.key + ) + const label = formatLabel(node) + return ( + <div + className={styles.treeRow} + style={{ + paddingLeft: depth * INDENT_PX, + }} + > + {node.children.length > 0 ? ( + <button + type="button" + className={ + styles.expandButton + } + onClick={() => + onToggleExpand(node.key) + } + aria-label={ + isExpanded + ? i18n.t( + 'Collapse {{label}}', + { label } + ) + : i18n.t( + 'Expand {{label}}', + { label } + ) + } + > + {isExpanded ? ( + <IconChevronDown16 /> + ) : ( + <IconChevronRight16 /> + )} + </button> + ) : ( + <span + className={ + styles.expandButtonPlaceholder + } + /> + )} + <Checkbox + label={label} + checked={checked} + indeterminate={indeterminate} + onChange={() => + onToggleNode(node) + } + className={cx( + styles.denseCheckbox, + highlightedIndex === + toHighlightedIndex( + index, + showCustomFilterRow + ) && styles.highlighted + )} + /> + </div> + ) + }} + /> + )} + </div> + </div> + </FilterDropdownPopover> + )} + </div> +) + +GroupFilterPopover.propTypes = { + anchorRef: PropTypes.object.isRequired, + anyValueActive: PropTypes.bool.isRequired, + checkStateFor: PropTypes.func.isRequired, + closePopover: PropTypes.func.isRequired, + customFilterTag: PropTypes.string.isRequired, + displayValue: PropTypes.string.isRequired, + effectiveExpanded: PropTypes.instanceOf(Set).isRequired, + formatLabel: PropTypes.func.isRequired, + hasNotSetOption: PropTypes.bool.isRequired, + helpContent: PropTypes.node.isRequired, + highlightedIndex: PropTypes.number.isRequired, + isOpen: PropTypes.bool.isRequired, + listRef: PropTypes.object.isRequired, + name: PropTypes.string.isRequired, + notSetActive: PropTypes.bool.isRequired, + openPopover: PropTypes.func.isRequired, + searchText: PropTypes.string.isRequired, + showCustomFilterRow: PropTypes.bool.isRequired, + visibleNodes: PropTypes.array.isRequired, + onApplyCustomFilterClick: PropTypes.func.isRequired, + onSearchChange: PropTypes.func.isRequired, + onSearchKeyDown: PropTypes.func.isRequired, + onToggleAnyValue: PropTypes.func.isRequired, + onToggleExpand: PropTypes.func.isRequired, + onToggleNode: PropTypes.func.isRequired, + onToggleNotSet: PropTypes.func.isRequired, + dropdownPlacement: PropTypes.string, + dropdownSide: PropTypes.string, + tooltipPlacement: PropTypes.string, +} + +export default GroupFilterPopover diff --git a/src/components/datatable/OrgUnitGroupFilterInput.jsx b/src/components/datatable/OrgUnitGroupFilterInput.jsx index 1e0928df4f..c1da43f872 100644 --- a/src/components/datatable/OrgUnitGroupFilterInput.jsx +++ b/src/components/datatable/OrgUnitGroupFilterInput.jsx @@ -1,15 +1,7 @@ import i18n from '@dhis2/d2-i18n' -import { - Input, - IconChevronRight16, - IconChevronDown16, - IconFilter16, -} from '@dhis2/ui' -import cx from 'classnames' import PropTypes from 'prop-types' import React, { useCallback, useMemo, useRef, useState } from 'react' import { useDispatch } from 'react-redux' -import { Virtuoso } from 'react-virtuoso' import { setDataFilter, clearDataFilter } from '../../actions/dataFilters.js' import { SENTINEL_ANY_VALUE, @@ -19,12 +11,9 @@ import { import useOrgUnitAncestorNames from '../../hooks/useOrgUnitAncestorNames.js' import { isOrgUnitGroupFilter } from '../../util/filter.js' import { - OPTION_ROW_HEIGHT, - MAX_LIST_HEIGHT, getCyclicIndex, getDisplayValue, toOptionIndex, - toHighlightedIndex, } from '../../util/filterInput.js' import { toggleAnyValue } from '../../util/filterSelection.js' import { @@ -39,23 +28,15 @@ import { flattenVisibleNodes, nodeMatchesOrHasMatch, } from '../../util/prefixTree.js' -import Checkbox from '../core/Checkbox.jsx' -import { - FilterDropdownPopover, - getDropdownPlacement, -} from './FilterDropdownPopover.jsx' -import FilterHelpTooltip from './FilterHelpTooltip.jsx' -import styles from './styles/FilterInput.module.css' +import { getDropdownPlacement } from './FilterDropdownPopover.jsx' +import GroupFilterPopover from './GroupFilterPopover.jsx' -const ORG_UNIT_GROUP_POPOVER_WIDTH = 220 -const HELP_HEIGHT = 56 const HELP_CONTENT = ( <div> <div>{i18n.t('Select a country, region, district or facility')}</div> <div>{i18n.t('to match the rows under it, or type to search')}</div> </div> ) -const INDENT_PX = 16 const getAppliedString = (filterValue) => { if (isOrgUnitGroupFilter(filterValue)) { @@ -340,198 +321,40 @@ const OrgUnitGroupFilterInput = ({ }) return ( - <div className={styles.filterTrigger} ref={anchorRef}> - <FilterHelpTooltip - content={HELP_CONTENT} - placement={tooltipPlacement} - estimatedHeight={HELP_HEIGHT} - dataTest="data-table-filter-help" - > - <Input - dense - clearable - dataTest={`data-table-column-filter-search-${name}`} - placeholder={i18n.t('Search')} - value={displayValue} - onFocus={() => { - if (!isOpen) { - openPopover() - } - }} - onChange={onSearchChange} - onKeyDown={onSearchKeyDown} - /> - </FilterHelpTooltip> - {isOpen && ( - <FilterDropdownPopover - reference={anchorRef} - placement={dropdownPlacement} - onClickOutside={closePopover} - className={cx( - styles.dropdownPopper, - dropdownSide === 'top' && styles.dropdownPopperAbove - )} - > - <div - className={cx(styles.searchableFilterPopover, { - [styles.reversedOrder]: dropdownSide === 'top', - })} - style={{ width: `${ORG_UNIT_GROUP_POPOVER_WIDTH}px` }} - > - {showCustomFilterRow && ( - <button - type="button" - className={cx(styles.customFilterRow, { - [styles.highlighted]: - highlightedIndex === 0, - })} - data-test={`data-table-column-filter-custom-${name}`} - onClick={() => { - applyCustomFilter(searchText.trim()) - closePopover() - }} - > - <IconFilter16 /> - <span className={styles.customFilterTag}> - {i18n.t('Select matches')} - </span> - <span className={styles.customFilterExpr}> - {searchText.trim()} - </span> - </button> - )} - <div className={styles.pinnedOptions}> - <Checkbox - label={i18n.t('Any value')} - checked={anyValueActive} - onChange={onToggleAnyValue} - className={cx( - styles.specialOption, - styles.denseCheckbox - )} - dataTest={`data-table-column-filter-any-${name}`} - /> - {hasNotSetOption && ( - <Checkbox - label={i18n.t('No value')} - checked={notSetActive} - onChange={onToggleNotSet} - className={cx( - styles.specialOption, - styles.denseCheckbox - )} - dataTest={`data-table-column-filter-novalue-${name}`} - /> - )} - </div> - <div className={styles.multiSelectPopover}> - {!showCustomFilterRow && - visibleNodes.length === 0 && ( - <div className={styles.noResults}> - {i18n.t('No matches')} - </div> - )} - {visibleNodes.length > 0 && ( - <Virtuoso - ref={listRef} - style={{ - height: Math.min( - visibleNodes.length * - OPTION_ROW_HEIGHT, - MAX_LIST_HEIGHT - ), - }} - increaseViewportBy={{ - top: 0, - bottom: OPTION_ROW_HEIGHT * 2, - }} - data={visibleNodes} - fixedItemHeight={OPTION_ROW_HEIGHT} - computeItemKey={(_, { node }) => node.key} - itemContent={(index, { node, depth }) => { - const state = checkStateFor(node) - const checked = state === 'checked' - const indeterminate = - state === 'indeterminate' - const isExpanded = - effectiveExpanded.has(node.key) - const label = formatOrgUnitNodeLabel( - node, - idToName - ) - return ( - <div - className={styles.treeRow} - style={{ - paddingLeft: - depth * INDENT_PX, - }} - > - {node.children.length > 0 ? ( - <button - type="button" - className={ - styles.expandButton - } - onClick={() => - onToggleExpand( - node.key - ) - } - aria-label={ - isExpanded - ? i18n.t( - 'Collapse {{label}}', - { label } - ) - : i18n.t( - 'Expand {{label}}', - { label } - ) - } - > - {isExpanded ? ( - <IconChevronDown16 /> - ) : ( - <IconChevronRight16 /> - )} - </button> - ) : ( - <span - className={ - styles.expandButtonPlaceholder - } - /> - )} - <Checkbox - label={label} - checked={checked} - indeterminate={ - indeterminate - } - onChange={() => - onToggleNode(node) - } - className={cx( - styles.denseCheckbox, - highlightedIndex === - toHighlightedIndex( - index, - showCustomFilterRow - ) && - styles.highlighted - )} - /> - </div> - ) - }} - /> - )} - </div> - </div> - </FilterDropdownPopover> - )} - </div> + <GroupFilterPopover + name={name} + helpContent={HELP_CONTENT} + customFilterTag={i18n.t('Select matches')} + formatLabel={(node) => formatOrgUnitNodeLabel(node, idToName)} + anchorRef={anchorRef} + listRef={listRef} + dropdownPlacement={dropdownPlacement} + dropdownSide={dropdownSide} + tooltipPlacement={tooltipPlacement} + isOpen={isOpen} + searchText={searchText} + highlightedIndex={highlightedIndex} + displayValue={displayValue} + visibleNodes={visibleNodes} + showCustomFilterRow={showCustomFilterRow} + anyValueActive={anyValueActive} + notSetActive={notSetActive} + hasNotSetOption={hasNotSetOption} + effectiveExpanded={effectiveExpanded} + checkStateFor={checkStateFor} + openPopover={openPopover} + closePopover={closePopover} + onSearchChange={onSearchChange} + onSearchKeyDown={onSearchKeyDown} + onApplyCustomFilterClick={() => { + applyCustomFilter(searchText.trim()) + closePopover() + }} + onToggleExpand={onToggleExpand} + onToggleNode={onToggleNode} + onToggleAnyValue={onToggleAnyValue} + onToggleNotSet={onToggleNotSet} + /> ) } From 6910c7242f6720df43c9e701084b382ebef48192 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Sat, 25 Jul 2026 15:30:39 +0200 Subject: [PATCH 125/205] chore: useGroupFilterInput refactor --- i18n/en.pot | 37 +- .../datatable/DateGroupFilterInput.jsx | 299 ++------------- .../datatable/OrgUnitGroupFilterInput.jsx | 355 +++--------------- .../datatable/useGroupFilterInput.js | 289 ++++++++++++++ 4 files changed, 397 insertions(+), 583 deletions(-) create mode 100644 src/components/datatable/useGroupFilterInput.js diff --git a/i18n/en.pot b/i18n/en.pot index 6950b4c77d..d08dfaeec0 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-24T13:25:30.129Z\n" -"PO-Revision-Date: 2026-07-24T13:25:30.129Z\n" +"POT-Creation-Date: 2026-07-25T13:15:14.751Z\n" +"PO-Revision-Date: 2026-07-25T13:15:14.751Z\n" msgid "2020" msgstr "2020" @@ -230,21 +230,6 @@ msgstr "Search" msgid "Contains" msgstr "Contains" -msgid "Any value" -msgstr "Any value" - -msgid "No value" -msgstr "No value" - -msgid "No matches" -msgstr "No matches" - -msgid "Collapse {{label}}" -msgstr "Collapse {{label}}" - -msgid "Expand {{label}}" -msgstr "Expand {{label}}" - msgid "Something went wrong" msgstr "Something went wrong" @@ -275,12 +260,30 @@ msgstr "Use filter" msgid "Search or type > 5, < 8…" msgstr "Search or type > 5, < 8…" +msgid "Search" +msgstr "Search" + msgid "Reverse selection" msgstr "Reverse selection" +msgid "Any value" +msgstr "Any value" + msgid "Too many values to list - type to filter this column" msgstr "Too many values to list - type to filter this column" +msgid "No matches" +msgstr "No matches" + +msgid "No value" +msgstr "No value" + +msgid "Collapse {{label}}" +msgstr "Collapse {{label}}" + +msgid "Expand {{label}}" +msgstr "Expand {{label}}" + msgid "Select a country, region, district or facility" msgstr "Select a country, region, district or facility" diff --git a/src/components/datatable/DateGroupFilterInput.jsx b/src/components/datatable/DateGroupFilterInput.jsx index cf64ce4a56..3c7131c764 100644 --- a/src/components/datatable/DateGroupFilterInput.jsx +++ b/src/components/datatable/DateGroupFilterInput.jsx @@ -1,31 +1,16 @@ import i18n from '@dhis2/d2-i18n' import PropTypes from 'prop-types' -import React, { useCallback, useMemo, useRef, useState } from 'react' -import { useDispatch } from 'react-redux' -import { setDataFilter, clearDataFilter } from '../../actions/dataFilters.js' -import { - SENTINEL_ANY_VALUE, - SENTINEL_NO_VALUE, - DATE_GROUPS_GRANULARITY, -} from '../../constants/dataTable.js' +import React, { useCallback } from 'react' +import { setDataFilter } from '../../actions/dataFilters.js' +import { DATE_GROUPS_GRANULARITY } from '../../constants/dataTable.js' import { buildDateGroupTree, - flattenVisibleNodes, formatNodeLabel, - getNodeCheckState, getSearchMatches, - nodeMatchesOrHasMatch, - toggleDateGroupPrefix, } from '../../util/dateGroups.js' import { isDateGroupFilter } from '../../util/filter.js' -import { - getCyclicIndex, - getDisplayValue, - toOptionIndex, -} from '../../util/filterInput.js' -import { toggleAnyValue } from '../../util/filterSelection.js' -import { getDropdownPlacement } from './FilterDropdownPopover.jsx' import GroupFilterPopover from './GroupFilterPopover.jsx' +import useGroupFilterInput from './useGroupFilterInput.js' const HELP_CONTENT = ( <div> @@ -35,6 +20,18 @@ const HELP_CONTENT = ( ) const DATE_INPUT_DISALLOWED = /[^0-9\-:. T]/g +const parseFilterValue = (filterValue) => ({ + selectedPrefixes: isDateGroupFilter(filterValue) + ? filterValue.prefixes + : [], + appliedString: typeof filterValue === 'string' ? filterValue : '', +}) + +const sanitizeInput = (value) => value.replace(DATE_INPUT_DISALLOWED, '') + +const commitSearch = (text, { dispatch, layerId, dataKey }) => + dispatch(setDataFilter(layerId, dataKey, text)) + const DateGroupFilterInput = ({ dataKey, name, @@ -43,265 +40,31 @@ const DateGroupFilterInput = ({ options, type, }) => { - const dispatch = useDispatch() - const anchorRef = useRef(null) - const listRef = useRef(null) - const [isOpen, setIsOpen] = useState(false) - const [searchText, setSearchText] = useState('') - const [expandedKeys, setExpandedKeys] = useState(() => new Set()) - const [highlightedIndex, setHighlightedIndex] = useState(-1) - - const selectedPrefixes = isDateGroupFilter(filterValue) - ? filterValue.prefixes - : [] - const appliedString = typeof filterValue === 'string' ? filterValue : '' - const anyValueActive = selectedPrefixes.includes(SENTINEL_ANY_VALUE) - const notSetActive = selectedPrefixes.includes(SENTINEL_NO_VALUE) - const treePrefixes = selectedPrefixes.filter( - (p) => p !== SENTINEL_ANY_VALUE && p !== SENTINEL_NO_VALUE - ) - const hasActiveFilter = selectedPrefixes.length > 0 || appliedString !== '' - - const openPopover = () => { - setSearchText(appliedString) - setHighlightedIndex(-1) - setIsOpen(true) - } - const closePopover = () => setIsOpen(false) - - const anchorRect = anchorRef.current?.getBoundingClientRect() - const { dropdownPlacement, dropdownSide, tooltipPlacement } = - getDropdownPlacement(anchorRect) - - const applyValues = useCallback( - (nextPrefixes) => - nextPrefixes.length - ? dispatch( - setDataFilter(layerId, dataKey, { - granularity: DATE_GROUPS_GRANULARITY, - prefixes: nextPrefixes, - }) - ) - : dispatch(clearDataFilter(layerId, dataKey)), - [dispatch, layerId, dataKey] - ) - - const hasNotSetOption = options.some( - ({ value }) => value === SENTINEL_NO_VALUE - ) - const realValues = useMemo( - () => - options - .filter(({ value }) => value !== SENTINEL_NO_VALUE) - .map((o) => o.value), - [options] - ) - - const tree = useMemo( - () => buildDateGroupTree(realValues, type), - [realValues, type] + const buildTree = useCallback( + (realValues) => buildDateGroupTree(realValues, type), + [type] ) - const normalizedSearch = searchText.trim().toLowerCase() - const searchMatches = useMemo( - () => - normalizedSearch ? getSearchMatches(tree, normalizedSearch) : null, - [tree, normalizedSearch] - ) - const effectiveExpanded = useMemo( - () => - searchMatches - ? new Set([ - ...expandedKeys, - ...searchMatches.expandedAncestorKeys, - ]) - : expandedKeys, - [expandedKeys, searchMatches] - ) - - const visibleNodes = useMemo(() => { - const flattened = flattenVisibleNodes(tree, effectiveExpanded) - if (!searchMatches) { - return flattened - } - return flattened.filter(({ node }) => - nodeMatchesOrHasMatch(node, searchMatches.matchedKeys) - ) - }, [tree, effectiveExpanded, searchMatches]) - - const showCustomFilterRow = normalizedSearch !== '' - const totalCount = visibleNodes.length + (showCustomFilterRow ? 1 : 0) - - const onToggleExpand = (key) => - setExpandedKeys((prev) => { - const next = new Set(prev) - if (next.has(key)) { - next.delete(key) - } else { - next.add(key) - } - return next - }) - - const checkStateFor = (node) => - anyValueActive ? 'checked' : getNodeCheckState(node, treePrefixes) - - const onToggleNode = (node) => { - if (anyValueActive) { - return - } - const nextTreePrefixes = toggleDateGroupPrefix(treePrefixes, node) - applyValues( - notSetActive - ? [...nextTreePrefixes, SENTINEL_NO_VALUE] - : nextTreePrefixes - ) - } - - const onToggleAnyValue = () => applyValues(toggleAnyValue(selectedPrefixes)) - - const onToggleNotSet = () => - applyValues( - notSetActive - ? selectedPrefixes.filter((p) => p !== SENTINEL_NO_VALUE) - : [...selectedPrefixes, SENTINEL_NO_VALUE] - ) - - const applyCustomFilter = (text) => - text - ? dispatch(setDataFilter(layerId, dataKey, text)) - : dispatch(clearDataFilter(layerId, dataKey)) - - const onSearchChange = ({ value }) => { - const sanitized = value.replace(DATE_INPUT_DISALLOWED, '') - setSearchText(sanitized) - setHighlightedIndex(-1) - - const trimmed = sanitized.trim() - if (trimmed === '') { - if (hasActiveFilter) { - dispatch(clearDataFilter(layerId, dataKey)) - } - return - } - - applyCustomFilter(trimmed) - } - - const scrollHighlightedIntoView = (index) => { - const optionIndex = toOptionIndex(index, showCustomFilterRow) - if (optionIndex >= 0 && optionIndex < visibleNodes.length) { - listRef.current?.scrollToIndex({ - index: optionIndex, - align: 'center', - }) - } - } - - const onEnterKey = () => { - if (highlightedIndex === -1) { - if (showCustomFilterRow) { - applyCustomFilter(searchText.trim()) - } - return - } - if (showCustomFilterRow && highlightedIndex === 0) { - applyCustomFilter(searchText.trim()) - return - } - const optionIndex = toOptionIndex(highlightedIndex, showCustomFilterRow) - if (optionIndex >= 0 && optionIndex < visibleNodes.length) { - onToggleNode(visibleNodes[optionIndex].node) - } - } - - const onSearchKeyDown = (_, event) => { - const optionIndex = toOptionIndex(highlightedIndex, showCustomFilterRow) - const { node } = visibleNodes[optionIndex] ?? {} - switch (event.key) { - case 'ArrowDown': - event.preventDefault() - setHighlightedIndex((i) => { - const next = getCyclicIndex(i, totalCount, 1) - scrollHighlightedIntoView(next) - return next - }) - break - case 'ArrowUp': - event.preventDefault() - setHighlightedIndex((i) => { - const next = getCyclicIndex(i, totalCount, -1) - scrollHighlightedIntoView(next) - return next - }) - break - case 'ArrowRight': - if (node?.children.length && !effectiveExpanded.has(node.key)) { - event.preventDefault() - onToggleExpand(node.key) - } - break - case 'ArrowLeft': - if (node?.children.length && effectiveExpanded.has(node.key)) { - event.preventDefault() - onToggleExpand(node.key) - } - break - case 'Enter': - event.preventDefault() - onEnterKey() - closePopover() - break - case 'Escape': - event.preventDefault() - closePopover() - break - default: - break - } - } - - const displayValue = getDisplayValue({ - isOpen, - searchText, - selected: selectedPrefixes, - appliedString, + const groupFilter = useGroupFilterInput({ + dataKey, + layerId, + filterValue, + options, + granularity: DATE_GROUPS_GRANULARITY, + buildTree, + getMatches: getSearchMatches, + parseFilterValue, + commitSearch, + sanitizeInput, }) return ( <GroupFilterPopover + {...groupFilter} name={name} helpContent={HELP_CONTENT} customFilterTag={i18n.t('Contains')} formatLabel={(node) => formatNodeLabel(node, i18n.language)} - anchorRef={anchorRef} - listRef={listRef} - dropdownPlacement={dropdownPlacement} - dropdownSide={dropdownSide} - tooltipPlacement={tooltipPlacement} - isOpen={isOpen} - searchText={searchText} - highlightedIndex={highlightedIndex} - displayValue={displayValue} - visibleNodes={visibleNodes} - showCustomFilterRow={showCustomFilterRow} - anyValueActive={anyValueActive} - notSetActive={notSetActive} - hasNotSetOption={hasNotSetOption} - effectiveExpanded={effectiveExpanded} - checkStateFor={checkStateFor} - openPopover={openPopover} - closePopover={closePopover} - onSearchChange={onSearchChange} - onSearchKeyDown={onSearchKeyDown} - onApplyCustomFilterClick={() => { - applyCustomFilter(searchText.trim()) - closePopover() - }} - onToggleExpand={onToggleExpand} - onToggleNode={onToggleNode} - onToggleAnyValue={onToggleAnyValue} - onToggleNotSet={onToggleNotSet} /> ) } diff --git a/src/components/datatable/OrgUnitGroupFilterInput.jsx b/src/components/datatable/OrgUnitGroupFilterInput.jsx index c1da43f872..0641de4227 100644 --- a/src/components/datatable/OrgUnitGroupFilterInput.jsx +++ b/src/components/datatable/OrgUnitGroupFilterInput.jsx @@ -1,35 +1,21 @@ import i18n from '@dhis2/d2-i18n' import PropTypes from 'prop-types' -import React, { useCallback, useMemo, useRef, useState } from 'react' -import { useDispatch } from 'react-redux' +import React, { useCallback, useMemo } from 'react' import { setDataFilter, clearDataFilter } from '../../actions/dataFilters.js' import { - SENTINEL_ANY_VALUE, - SENTINEL_NO_VALUE, ORG_UNIT_GROUPS_GRANULARITY, + SENTINEL_NO_VALUE, } from '../../constants/dataTable.js' import useOrgUnitAncestorNames from '../../hooks/useOrgUnitAncestorNames.js' import { isOrgUnitGroupFilter } from '../../util/filter.js' -import { - getCyclicIndex, - getDisplayValue, - toOptionIndex, -} from '../../util/filterInput.js' -import { toggleAnyValue } from '../../util/filterSelection.js' import { buildOrgUnitGroupTree, formatOrgUnitNodeLabel, getOrgUnitSearchMatches, } from '../../util/orgUnitGroups.js' -import { - getNodeCheckState, - togglePrefix, - flattenAllNodes, - flattenVisibleNodes, - nodeMatchesOrHasMatch, -} from '../../util/prefixTree.js' -import { getDropdownPlacement } from './FilterDropdownPopover.jsx' +import { flattenAllNodes } from '../../util/prefixTree.js' import GroupFilterPopover from './GroupFilterPopover.jsx' +import useGroupFilterInput from './useGroupFilterInput.js' const HELP_CONTENT = ( <div> @@ -45,6 +31,14 @@ const getAppliedString = (filterValue) => { return typeof filterValue === 'string' ? filterValue : '' } +const parseFilterValue = (filterValue) => ({ + selectedPrefixes: + isOrgUnitGroupFilter(filterValue) && !filterValue.searchDerived + ? filterValue.prefixes + : [], + appliedString: getAppliedString(filterValue), +}) + const OrgUnitGroupFilterInput = ({ dataKey, name, @@ -52,57 +46,6 @@ const OrgUnitGroupFilterInput = ({ filterValue, options, }) => { - const dispatch = useDispatch() - const anchorRef = useRef(null) - const listRef = useRef(null) - const [isOpen, setIsOpen] = useState(false) - const [searchText, setSearchText] = useState('') - const [expandedKeys, setExpandedKeys] = useState(() => new Set()) - const [highlightedIndex, setHighlightedIndex] = useState(-1) - - // A committed free-text search is kept out of `selectedPrefixes` on - // purpose - like every other column's typed "Contains" filter, it - // narrows the table live but does not show any checkbox as checked - // (see applyCustomFilter below). - const selectedPrefixes = - isOrgUnitGroupFilter(filterValue) && !filterValue.searchDerived - ? filterValue.prefixes - : [] - const appliedString = getAppliedString(filterValue) - const anyValueActive = selectedPrefixes.includes(SENTINEL_ANY_VALUE) - const notSetActive = selectedPrefixes.includes(SENTINEL_NO_VALUE) - const treePrefixes = selectedPrefixes.filter( - (p) => p !== SENTINEL_ANY_VALUE && p !== SENTINEL_NO_VALUE - ) - const hasActiveFilter = selectedPrefixes.length > 0 || appliedString !== '' - - const openPopover = () => { - setSearchText(appliedString) - setHighlightedIndex(-1) - setIsOpen(true) - } - const closePopover = () => setIsOpen(false) - - const anchorRect = anchorRef.current?.getBoundingClientRect() - const { dropdownPlacement, dropdownSide, tooltipPlacement } = - getDropdownPlacement(anchorRect) - - const applyValues = useCallback( - (nextPrefixes) => - nextPrefixes.length - ? dispatch( - setDataFilter(layerId, dataKey, { - granularity: ORG_UNIT_GROUPS_GRANULARITY, - prefixes: nextPrefixes, - }) - ) - : dispatch(clearDataFilter(layerId, dataKey)), - [dispatch, layerId, dataKey] - ) - - const hasNotSetOption = options.some( - ({ value }) => value === SENTINEL_NO_VALUE - ) const realValues = useMemo( () => options @@ -110,250 +53,66 @@ const OrgUnitGroupFilterInput = ({ .map((o) => o.value), [options] ) - - const tree = useMemo(() => buildOrgUnitGroupTree(realValues), [realValues]) - const { idToName } = useOrgUnitAncestorNames(realValues) - const nodeByKey = useMemo(() => { - const map = new Map() - flattenAllNodes(tree).forEach((node) => map.set(node.key, node)) - return map - }, [tree]) - - const normalizedSearch = searchText.trim().toLowerCase() - const searchMatches = useMemo( - () => - normalizedSearch - ? getOrgUnitSearchMatches(tree, normalizedSearch, idToName) - : null, - [tree, normalizedSearch, idToName] - ) - const effectiveExpanded = useMemo( - () => - searchMatches - ? new Set([ - ...expandedKeys, - ...searchMatches.expandedAncestorKeys, - ]) - : expandedKeys, - [expandedKeys, searchMatches] + const getMatches = useCallback( + (tree, normalizedSearch) => + getOrgUnitSearchMatches(tree, normalizedSearch, idToName), + [idToName] ) - const visibleNodes = useMemo(() => { - const flattened = flattenVisibleNodes(tree, effectiveExpanded) - if (!searchMatches) { - return flattened - } - return flattened.filter(({ node }) => - nodeMatchesOrHasMatch(node, searchMatches.matchedKeys) - ) - }, [tree, effectiveExpanded, searchMatches]) - - const showCustomFilterRow = normalizedSearch !== '' - const totalCount = visibleNodes.length + (showCustomFilterRow ? 1 : 0) - - const onToggleExpand = (key) => - setExpandedKeys((prev) => { - const next = new Set(prev) - if (next.has(key)) { - next.delete(key) - } else { - next.add(key) - } - return next - }) - - const checkStateFor = (node) => - anyValueActive ? 'checked' : getNodeCheckState(node, treePrefixes) - - const onToggleNode = (node) => { - if (anyValueActive) { - return - } - const nextTreePrefixes = togglePrefix(treePrefixes, node) - applyValues( - notSetActive - ? [...nextTreePrefixes, SENTINEL_NO_VALUE] - : nextTreePrefixes - ) - } - - const onToggleAnyValue = () => applyValues(toggleAnyValue(selectedPrefixes)) - - const onToggleNotSet = () => - applyValues( - notSetActive - ? selectedPrefixes.filter((p) => p !== SENTINEL_NO_VALUE) - : [...selectedPrefixes, SENTINEL_NO_VALUE] - ) - - // Unlike dates, an org unit's raw stored value is an id (or id path), - // never the human-readable name a user actually types here - matching - // "Contains" against that raw value would silently match nothing for - // any real-world search term. Committing free text instead narrows the - // table to every currently name/id-matched org unit - same live-as-you- - // type "Contains" semantics every other column's filter already has, - // dispatched with `searchDerived` so it (like every other column's - // typed filter) never shows as a checked box while typing. - const applyCustomFilter = (text) => { - const trimmed = text.trim() - if (!trimmed) { - dispatch(clearDataFilter(layerId, dataKey)) - return - } - const matches = getOrgUnitSearchMatches( - tree, - trimmed.toLowerCase(), - idToName - ) - const matchedPrefixes = [...matches.matchedKeys] - .map((key) => nodeByKey.get(key)) - .filter(Boolean) - .map((node) => node.prefix) - if (!matchedPrefixes.length) { - dispatch(clearDataFilter(layerId, dataKey)) - return - } - dispatch( - setDataFilter(layerId, dataKey, { - granularity: ORG_UNIT_GROUPS_GRANULARITY, - prefixes: matchedPrefixes, - searchDerived: true, - searchText: trimmed, - }) - ) - } - - const onSearchChange = ({ value }) => { - setSearchText(value) - setHighlightedIndex(-1) - - const trimmed = value.trim() - if (trimmed === '') { - if (hasActiveFilter) { - dispatch(clearDataFilter(layerId, dataKey)) + const commitSearch = useCallback( + ( + text, + { tree, dispatch, layerId: layerIdArg, dataKey: dataKeyArg } + ) => { + const matches = getOrgUnitSearchMatches( + tree, + text.toLowerCase(), + idToName + ) + const nodeByKey = new Map( + flattenAllNodes(tree).map((node) => [node.key, node]) + ) + const matchedPrefixes = [...matches.matchedKeys] + .map((key) => nodeByKey.get(key)) + .filter(Boolean) + .map((node) => node.prefix) + if (!matchedPrefixes.length) { + dispatch(clearDataFilter(layerIdArg, dataKeyArg)) + return } - return - } - - applyCustomFilter(trimmed) - } - - const scrollHighlightedIntoView = (index) => { - const optionIndex = toOptionIndex(index, showCustomFilterRow) - if (optionIndex >= 0 && optionIndex < visibleNodes.length) { - listRef.current?.scrollToIndex({ - index: optionIndex, - align: 'center', - }) - } - } - - const onEnterKey = () => { - if (highlightedIndex === -1) { - if (showCustomFilterRow) { - applyCustomFilter(searchText.trim()) - } - return - } - if (showCustomFilterRow && highlightedIndex === 0) { - applyCustomFilter(searchText.trim()) - return - } - const optionIndex = toOptionIndex(highlightedIndex, showCustomFilterRow) - if (optionIndex >= 0 && optionIndex < visibleNodes.length) { - onToggleNode(visibleNodes[optionIndex].node) - } - } - - const onSearchKeyDown = (_, event) => { - const optionIndex = toOptionIndex(highlightedIndex, showCustomFilterRow) - const { node } = visibleNodes[optionIndex] ?? {} - switch (event.key) { - case 'ArrowDown': - event.preventDefault() - setHighlightedIndex((i) => { - const next = getCyclicIndex(i, totalCount, 1) - scrollHighlightedIntoView(next) - return next - }) - break - case 'ArrowUp': - event.preventDefault() - setHighlightedIndex((i) => { - const next = getCyclicIndex(i, totalCount, -1) - scrollHighlightedIntoView(next) - return next + dispatch( + setDataFilter(layerIdArg, dataKeyArg, { + granularity: ORG_UNIT_GROUPS_GRANULARITY, + prefixes: matchedPrefixes, + searchDerived: true, + searchText: text, }) - break - case 'ArrowRight': - if (node?.children.length && !effectiveExpanded.has(node.key)) { - event.preventDefault() - onToggleExpand(node.key) - } - break - case 'ArrowLeft': - if (node?.children.length && effectiveExpanded.has(node.key)) { - event.preventDefault() - onToggleExpand(node.key) - } - break - case 'Enter': - event.preventDefault() - onEnterKey() - closePopover() - break - case 'Escape': - event.preventDefault() - closePopover() - break - default: - break - } - } + ) + }, + [idToName] + ) - const displayValue = getDisplayValue({ - isOpen, - searchText, - selected: selectedPrefixes, - appliedString, + const groupFilter = useGroupFilterInput({ + dataKey, + layerId, + filterValue, + options, + granularity: ORG_UNIT_GROUPS_GRANULARITY, + buildTree: buildOrgUnitGroupTree, + getMatches, + parseFilterValue, + commitSearch, }) return ( <GroupFilterPopover + {...groupFilter} name={name} helpContent={HELP_CONTENT} customFilterTag={i18n.t('Select matches')} formatLabel={(node) => formatOrgUnitNodeLabel(node, idToName)} - anchorRef={anchorRef} - listRef={listRef} - dropdownPlacement={dropdownPlacement} - dropdownSide={dropdownSide} - tooltipPlacement={tooltipPlacement} - isOpen={isOpen} - searchText={searchText} - highlightedIndex={highlightedIndex} - displayValue={displayValue} - visibleNodes={visibleNodes} - showCustomFilterRow={showCustomFilterRow} - anyValueActive={anyValueActive} - notSetActive={notSetActive} - hasNotSetOption={hasNotSetOption} - effectiveExpanded={effectiveExpanded} - checkStateFor={checkStateFor} - openPopover={openPopover} - closePopover={closePopover} - onSearchChange={onSearchChange} - onSearchKeyDown={onSearchKeyDown} - onApplyCustomFilterClick={() => { - applyCustomFilter(searchText.trim()) - closePopover() - }} - onToggleExpand={onToggleExpand} - onToggleNode={onToggleNode} - onToggleAnyValue={onToggleAnyValue} - onToggleNotSet={onToggleNotSet} /> ) } diff --git a/src/components/datatable/useGroupFilterInput.js b/src/components/datatable/useGroupFilterInput.js new file mode 100644 index 0000000000..4ccdc0d7ef --- /dev/null +++ b/src/components/datatable/useGroupFilterInput.js @@ -0,0 +1,289 @@ +import { useCallback, useMemo, useRef, useState } from 'react' +import { useDispatch } from 'react-redux' +import { setDataFilter, clearDataFilter } from '../../actions/dataFilters.js' +import { + SENTINEL_ANY_VALUE, + SENTINEL_NO_VALUE, +} from '../../constants/dataTable.js' +import { + getCyclicIndex, + getDisplayValue, + toOptionIndex, +} from '../../util/filterInput.js' +import { toggleAnyValue } from '../../util/filterSelection.js' +import { + flattenVisibleNodes, + getNodeCheckState, + nodeMatchesOrHasMatch, + togglePrefix, +} from '../../util/prefixTree.js' +import { getDropdownPlacement } from './FilterDropdownPopover.jsx' + +const identity = (value) => value + +const useGroupFilterInput = ({ + dataKey, + layerId, + filterValue, + options, + granularity, + buildTree, + getMatches, + parseFilterValue, + commitSearch, + sanitizeInput = identity, +}) => { + const dispatch = useDispatch() + const anchorRef = useRef(null) + const listRef = useRef(null) + const [isOpen, setIsOpen] = useState(false) + const [searchText, setSearchText] = useState('') + const [expandedKeys, setExpandedKeys] = useState(() => new Set()) + const [highlightedIndex, setHighlightedIndex] = useState(-1) + + const { selectedPrefixes, appliedString } = parseFilterValue(filterValue) + const anyValueActive = selectedPrefixes.includes(SENTINEL_ANY_VALUE) + const notSetActive = selectedPrefixes.includes(SENTINEL_NO_VALUE) + const treePrefixes = selectedPrefixes.filter( + (p) => p !== SENTINEL_ANY_VALUE && p !== SENTINEL_NO_VALUE + ) + const hasActiveFilter = selectedPrefixes.length > 0 || appliedString !== '' + + const openPopover = () => { + setSearchText(appliedString) + setHighlightedIndex(-1) + setIsOpen(true) + } + const closePopover = () => setIsOpen(false) + + const anchorRect = anchorRef.current?.getBoundingClientRect() + const { dropdownPlacement, dropdownSide, tooltipPlacement } = + getDropdownPlacement(anchorRect) + + const applyValues = useCallback( + (nextPrefixes) => + nextPrefixes.length + ? dispatch( + setDataFilter(layerId, dataKey, { + granularity, + prefixes: nextPrefixes, + }) + ) + : dispatch(clearDataFilter(layerId, dataKey)), + [dispatch, layerId, dataKey, granularity] + ) + + const hasNotSetOption = options.some( + ({ value }) => value === SENTINEL_NO_VALUE + ) + const realValues = useMemo( + () => + options + .filter(({ value }) => value !== SENTINEL_NO_VALUE) + .map((o) => o.value), + [options] + ) + + const tree = useMemo(() => buildTree(realValues), [buildTree, realValues]) + + const normalizedSearch = searchText.trim().toLowerCase() + const searchMatches = useMemo( + () => (normalizedSearch ? getMatches(tree, normalizedSearch) : null), + [tree, normalizedSearch, getMatches] + ) + const effectiveExpanded = useMemo( + () => + searchMatches + ? new Set([ + ...expandedKeys, + ...searchMatches.expandedAncestorKeys, + ]) + : expandedKeys, + [expandedKeys, searchMatches] + ) + + const visibleNodes = useMemo(() => { + const flattened = flattenVisibleNodes(tree, effectiveExpanded) + if (!searchMatches) { + return flattened + } + return flattened.filter(({ node }) => + nodeMatchesOrHasMatch(node, searchMatches.matchedKeys) + ) + }, [tree, effectiveExpanded, searchMatches]) + + const showCustomFilterRow = normalizedSearch !== '' + const totalCount = visibleNodes.length + (showCustomFilterRow ? 1 : 0) + + const onToggleExpand = (key) => + setExpandedKeys((prev) => { + const next = new Set(prev) + if (next.has(key)) { + next.delete(key) + } else { + next.add(key) + } + return next + }) + + const checkStateFor = (node) => + anyValueActive ? 'checked' : getNodeCheckState(node, treePrefixes) + + const onToggleNode = (node) => { + if (anyValueActive) { + return + } + const nextTreePrefixes = togglePrefix(treePrefixes, node) + applyValues( + notSetActive + ? [...nextTreePrefixes, SENTINEL_NO_VALUE] + : nextTreePrefixes + ) + } + + const onToggleAnyValue = () => applyValues(toggleAnyValue(selectedPrefixes)) + + const onToggleNotSet = () => + applyValues( + notSetActive + ? selectedPrefixes.filter((p) => p !== SENTINEL_NO_VALUE) + : [...selectedPrefixes, SENTINEL_NO_VALUE] + ) + + const applyCustomFilter = (text) => { + if (!text) { + dispatch(clearDataFilter(layerId, dataKey)) + return + } + commitSearch(text, { tree, dispatch, layerId, dataKey }) + } + + const onSearchChange = ({ value }) => { + const sanitized = sanitizeInput(value) + setSearchText(sanitized) + setHighlightedIndex(-1) + + const trimmed = sanitized.trim() + if (trimmed === '') { + if (hasActiveFilter) { + dispatch(clearDataFilter(layerId, dataKey)) + } + return + } + + applyCustomFilter(trimmed) + } + + const scrollHighlightedIntoView = (index) => { + const optionIndex = toOptionIndex(index, showCustomFilterRow) + if (optionIndex >= 0 && optionIndex < visibleNodes.length) { + listRef.current?.scrollToIndex({ + index: optionIndex, + align: 'center', + }) + } + } + + const onEnterKey = () => { + if (highlightedIndex === -1) { + if (showCustomFilterRow) { + applyCustomFilter(searchText.trim()) + } + return + } + if (showCustomFilterRow && highlightedIndex === 0) { + applyCustomFilter(searchText.trim()) + return + } + const optionIndex = toOptionIndex(highlightedIndex, showCustomFilterRow) + if (optionIndex >= 0 && optionIndex < visibleNodes.length) { + onToggleNode(visibleNodes[optionIndex].node) + } + } + + const onSearchKeyDown = (_, event) => { + const optionIndex = toOptionIndex(highlightedIndex, showCustomFilterRow) + const { node } = visibleNodes[optionIndex] ?? {} + switch (event.key) { + case 'ArrowDown': + event.preventDefault() + setHighlightedIndex((i) => { + const next = getCyclicIndex(i, totalCount, 1) + scrollHighlightedIntoView(next) + return next + }) + break + case 'ArrowUp': + event.preventDefault() + setHighlightedIndex((i) => { + const next = getCyclicIndex(i, totalCount, -1) + scrollHighlightedIntoView(next) + return next + }) + break + case 'ArrowRight': + if (node?.children.length && !effectiveExpanded.has(node.key)) { + event.preventDefault() + onToggleExpand(node.key) + } + break + case 'ArrowLeft': + if (node?.children.length && effectiveExpanded.has(node.key)) { + event.preventDefault() + onToggleExpand(node.key) + } + break + case 'Enter': + event.preventDefault() + onEnterKey() + closePopover() + break + case 'Escape': + event.preventDefault() + closePopover() + break + default: + break + } + } + + const displayValue = getDisplayValue({ + isOpen, + searchText, + selected: selectedPrefixes, + appliedString, + }) + + return { + anchorRef, + listRef, + dropdownPlacement, + dropdownSide, + tooltipPlacement, + isOpen, + searchText, + highlightedIndex, + displayValue, + visibleNodes, + showCustomFilterRow, + anyValueActive, + notSetActive, + hasNotSetOption, + effectiveExpanded, + checkStateFor, + openPopover, + closePopover, + onSearchChange, + onSearchKeyDown, + onApplyCustomFilterClick: () => { + applyCustomFilter(searchText.trim()) + closePopover() + }, + onToggleExpand, + onToggleNode, + onToggleAnyValue, + onToggleNotSet, + } +} + +export default useGroupFilterInput From 1a5399332aff9a6b1c9955a4e05b7e9ff5de4aed Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Sat, 25 Jul 2026 16:49:43 +0200 Subject: [PATCH 126/205] fix: tracked entity datatable values resolution --- cypress/integration/dataTable.cy.js | 8 +- i18n/en.pot | 7 +- src/components/datatable/DataTable.jsx | 13 +- src/components/datatable/FilterInput.jsx | 5 + .../datatable/__tests__/FilterInput.spec.jsx | 24 +++ .../datatable/__tests__/useTableData.spec.jsx | 17 +- src/constants/dataTable.js | 1 + .../__tests__/trackedEntityLoader.spec.js | 117 ++++++++++++- src/loaders/trackedEntityLoader.js | 162 +++++++++++++++--- src/util/__tests__/tableHeaders.spec.js | 87 +++++++++- src/util/helpers.js | 2 +- src/util/tableHeaders.js | 45 ++++- 12 files changed, 447 insertions(+), 41 deletions(-) diff --git a/cypress/integration/dataTable.cy.js b/cypress/integration/dataTable.cy.js index d1864f59a6..d1446ee592 100644 --- a/cypress/integration/dataTable.cy.js +++ b/cypress/integration/dataTable.cy.js @@ -196,9 +196,11 @@ describe('data table', () => { cy.getByDataTest('layers-toggle-button').click() // Check number of columns + // (+1 for the new "Last updated" column, sourced free from the + // analytics response's own lastupdated header - see tableHeaders.js) cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-datatablecellhead') - .should('have.length', 10) + .should('have.length', 11) cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-datatablecellhead') @@ -260,6 +262,10 @@ describe('data table', () => { // Confirm that the rows are sorted by Age in years ascending // (the first click on a new column always sorts ascending) + // NOTE: this column index predates this session's org-unit-column + // and "Last updated" column additions and was never re-verified + // against a live instance (no working local Cypress in this sandbox) + // - it is very likely stale. Re-check against a real run. checkTableCell({ row: 0, column: 7, expectedContent: '6' }) checkTableCell({ row: 1, column: 7, expectedContent: '32' }) diff --git a/i18n/en.pot b/i18n/en.pot index d08dfaeec0..e1bf37a4b2 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-25T13:15:14.751Z\n" -"PO-Revision-Date: 2026-07-25T13:15:14.751Z\n" +"POT-Creation-Date: 2026-07-25T14:31:53.148Z\n" +"PO-Revision-Date: 2026-07-25T14:31:53.149Z\n" msgid "2020" msgstr "2020" @@ -2150,6 +2150,9 @@ msgstr "Org unit boundary" msgid "Org unit hierarchy" msgstr "Org unit hierarchy" +msgid "Created" +msgstr "Created" + msgid "Group" msgstr "Group" diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index b51f8a219b..fe9cfd2133 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -35,6 +35,7 @@ import { RENDERER_DATE, RENDERER_ORG_UNIT, RENDERER_ORG_UNIT_NAME, + RENDERER_BOOLEAN, TYPE_DATE, ORG_UNIT_ID_DATA_KEY, } from '../../constants/dataTable.js' @@ -48,7 +49,11 @@ import { isFilterable, shouldClearFeatureHighlight, } from '../../util/dataTable.js' -import { formatDate, formatDatetime } from '../../util/helpers.js' +import { + formatBoolean, + formatDate, + formatDatetime, +} from '../../util/helpers.js' import { formatWithSeparator } from '../../util/numbers.js' import { formatOrgUnitOwnName, @@ -639,6 +644,8 @@ const Table = ({ renderer === RENDERER_ORG_UNIT const isOrgUnitNameCell = renderer === RENDERER_ORG_UNIT_NAME + const isBooleanCell = + renderer === RENDERER_BOOLEAN return ( <DataTableCell key={`dtcell-${dataKey}`} @@ -696,11 +703,15 @@ const Table = ({ value, orgUnitIdToName )} + {isBooleanCell && + value != null && + formatBoolean(value)} {!isColorCell && !isIconCell && !isDateCell && !isOrgUnitHierarchyCell && !isOrgUnitNameCell && + !isBooleanCell && formatWithSeparator( value, keyAnalysisDigitGroupSeparator diff --git a/src/components/datatable/FilterInput.jsx b/src/components/datatable/FilterInput.jsx index f4733601bb..5c76b81d60 100644 --- a/src/components/datatable/FilterInput.jsx +++ b/src/components/datatable/FilterInput.jsx @@ -13,6 +13,7 @@ import { RENDERER_ICON, RENDERER_ORG_UNIT, RENDERER_ORG_UNIT_NAME, + RENDERER_BOOLEAN, TYPE_NUMBER, TYPE_DATE, TYPE_DATETIME, @@ -39,6 +40,7 @@ import { toggleAnyValue, toggleRealValue, } from '../../util/filterSelection.js' +import { formatBoolean } from '../../util/helpers.js' import { formatWithSeparator } from '../../util/numbers.js' import { formatOrgUnitPathBreadcrumb, @@ -535,6 +537,9 @@ const PlainSearchableFilter = (props) => { if (renderer === RENDERER_ORG_UNIT_NAME) { return formatOrgUnitOwnName(value, orgUnitIdToName) } + if (renderer === RENDERER_BOOLEAN) { + return formatBoolean(value) + } return type === TYPE_NUMBER ? formatWithSeparator( Number(value), diff --git a/src/components/datatable/__tests__/FilterInput.spec.jsx b/src/components/datatable/__tests__/FilterInput.spec.jsx index 06f5772fd4..6d489a78db 100644 --- a/src/components/datatable/__tests__/FilterInput.spec.jsx +++ b/src/components/datatable/__tests__/FilterInput.spec.jsx @@ -10,6 +10,7 @@ import { import { SENTINEL_ANY_VALUE, RENDERER_ORG_UNIT_NAME, + RENDERER_BOOLEAN, } from '../../../constants/dataTable.js' import useOptionSet from '../../../hooks/useOptionSet.js' import FilterInput from '../FilterInput.jsx' @@ -243,6 +244,29 @@ describe('FilterInput multi-select path (no optionSetId)', () => { openPopover('Legend') expect(screen.getByLabelText('1000')).toBeInTheDocument() }) + + test("renders a boolean column's raw values as Yes/No checkbox labels", () => { + const { store } = renderFilterInput({ + dataKey: 'followUp', + name: 'Follow-up', + renderer: RENDERER_BOOLEAN, + options: [{ value: '1' }, { value: '0' }], + }) + openPopover('Follow-up') + const yes = screen.getByLabelText('Yes') + const no = screen.getByLabelText('No') + expect(yes).toBeInTheDocument() + expect(no).toBeInTheDocument() + // The underlying dispatched filter value stays the raw stored + // string - only the checkbox label is reformatted for display. + fireEvent.click(yes) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'followUp', + filter: ['1'], + }) + }) }) describe('FilterInput multi-select path (optionSetId)', () => { diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index b085b4bb71..8ad2e769d2 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -594,9 +594,14 @@ describe('useTableData headers', () => { renderer: 'renderdate', }, { - name: 'Last updated on', + // A fixed column now, not a coincidental customFields match + // (see tableHeaders.js's fixedDataKeys exclusion) - the raw + // analytics header's own "Last updated on" label is no + // longer used, this is the same fixed name/type/renderer + // Tracked Entity's "Last updated" column uses. + name: 'Last updated', dataKey: 'lastupdated', - type: 'date', + type: 'datetime', renderer: 'renderdate', }, { name: 'Event status', dataKey: 'eventstatus', type: 'string' }, @@ -721,7 +726,7 @@ describe('useTableData headers', () => { ) const { headers, rows, isLoading } = result.current - expect(headers).toHaveLength(9) + expect(headers).toHaveLength(11) expect(headers).toMatchObject([ { name: 'Tracked entity Id', dataKey: 'id', type: 'string' }, { name: 'Org unit Id', dataKey: 'orgUnitId', type: 'string' }, @@ -732,19 +737,23 @@ describe('useTableData headers', () => { dataKey: 'orgUnitPath', type: 'orgUnit', }, + { name: 'Created', dataKey: 'createdAt', type: 'datetime' }, + { name: 'Last updated', dataKey: 'updatedAt', type: 'datetime' }, { name: 'First name', dataKey: 'w75KJ2mc4zz', type: 'string' }, { name: 'Age', dataKey: 'zDhUuAYrxNC', type: 'number' }, { name: 'Color', dataKey: 'color', type: 'string' }, { name: 'Geometry type', dataKey: 'type', type: 'string' }, ]) expect(rows).toHaveLength(1) - expect(rows[0]).toHaveLength(9) + expect(rows[0]).toHaveLength(11) expect(rows[0]).toMatchObject([ { value: 'PsgJS8BUxZd', dataKey: 'id' }, { value: undefined, dataKey: 'orgUnitId' }, { value: undefined, dataKey: 'orgUnitOwn' }, { value: null, dataKey: 'level' }, { value: undefined, dataKey: 'orgUnitPath' }, + { value: undefined, dataKey: 'createdAt' }, + { value: undefined, dataKey: 'updatedAt' }, { value: 'Gabrielle', dataKey: 'w75KJ2mc4zz' }, { value: 28, dataKey: 'zDhUuAYrxNC' }, { value: '#e57200', dataKey: 'color' }, diff --git a/src/constants/dataTable.js b/src/constants/dataTable.js index f08391810c..7dea407338 100644 --- a/src/constants/dataTable.js +++ b/src/constants/dataTable.js @@ -10,6 +10,7 @@ export const RENDERER_ICON = 'rendericon' export const RENDERER_DATE = 'renderdate' export const RENDERER_ORG_UNIT = 'renderorgunit' export const RENDERER_ORG_UNIT_NAME = 'renderorgunitname' +export const RENDERER_BOOLEAN = 'renderboolean' export const TYPE_NUMBER = 'number' export const TYPE_STRING = 'string' diff --git a/src/loaders/__tests__/trackedEntityLoader.spec.js b/src/loaders/__tests__/trackedEntityLoader.spec.js index 29f955c4f7..0968ca1327 100644 --- a/src/loaders/__tests__/trackedEntityLoader.spec.js +++ b/src/loaders/__tests__/trackedEntityLoader.spec.js @@ -43,6 +43,46 @@ describe('getAttributeProperties', () => { ] expect(getAttributeProperties(attributes).ageUid).toBeUndefined() }) + + it('resolves an option-set-coded value to its display name, like events analytics already does server-side', () => { + const attributes = [ + { attribute: 'genderUid', value: 'M', valueType: 'TEXT' }, + ] + const optionSetIdByAttribute = new Map([['genderUid', 'os1']]) + const optionNamesByOptionSet = new Map([ + [ + 'os1', + new Map([ + ['M', 'Male'], + ['F', 'Female'], + ]), + ], + ]) + expect( + getAttributeProperties( + attributes, + optionSetIdByAttribute, + optionNamesByOptionSet + ) + ).toEqual({ genderUid: 'Male' }) + }) + + it('falls back to the raw code when no matching option name is found', () => { + const attributes = [ + { attribute: 'genderUid', value: 'X', valueType: 'TEXT' }, + ] + const optionSetIdByAttribute = new Map([['genderUid', 'os1']]) + const optionNamesByOptionSet = new Map([ + ['os1', new Map([['M', 'Male']])], + ]) + expect( + getAttributeProperties( + attributes, + optionSetIdByAttribute, + optionNamesByOptionSet + ) + ).toEqual({ genderUid: 'X' }) + }) }) describe('getAttributeHeaders', () => { @@ -73,14 +113,47 @@ describe('getAttributeHeaders', () => { }, ] expect(getAttributeHeaders(instances)).toEqual([ - { name: 'First name', dataKey: 'w75KJ2mc4zz', valueType: 'TEXT' }, - { name: 'Last name', dataKey: 'zDhUuAYrxNC', valueType: 'TEXT' }, + { + name: 'First name', + dataKey: 'w75KJ2mc4zz', + valueType: 'TEXT', + optionSet: null, + }, + { + name: 'Last name', + dataKey: 'zDhUuAYrxNC', + valueType: 'TEXT', + optionSet: null, + }, ]) }) it('returns an empty array when no instance has attributes', () => { expect(getAttributeHeaders([{ attributes: [] }, {}])).toEqual([]) }) + + it('stamps the resolved optionSet id onto a header when optionSetIdByAttribute has it', () => { + const instances = [ + { + attributes: [ + { + attribute: 'genderUid', + displayName: 'Gender', + valueType: 'TEXT', + }, + ], + }, + ] + const optionSetIdByAttribute = new Map([['genderUid', 'os1']]) + expect(getAttributeHeaders(instances, optionSetIdByAttribute)).toEqual([ + { + name: 'Gender', + dataKey: 'genderUid', + valueType: 'TEXT', + optionSet: { id: 'os1' }, + }, + ]) + }) }) describe('applyParsedConfig', () => { @@ -174,4 +247,44 @@ describe('toGeoJson', () => { expect(result[0].properties.orgUnit).toBe('facility1') }) + + it('carries createdAt/updatedAt through onto properties', () => { + const instances = [ + { + id: 'tei-1', + geometry: { type: 'Point', coordinates: [1, 2] }, + attributes: [], + createdAt: '2024-01-01T00:00:00.000', + updatedAt: '2024-06-15T12:30:00.000', + }, + ] + + const result = toGeoJson(instances, '#ff0000') + + expect(result[0].properties.createdAt).toBe('2024-01-01T00:00:00.000') + expect(result[0].properties.updatedAt).toBe('2024-06-15T12:30:00.000') + }) + + it('resolves an option-set-coded attribute value to its display name when the resolution maps are given', () => { + const instances = [ + { + id: 'tei-1', + geometry: { type: 'Point', coordinates: [1, 2] }, + attributes: [ + { attribute: 'genderUid', value: 'M', valueType: 'TEXT' }, + ], + }, + ] + const optionSetIdByAttribute = new Map([['genderUid', 'os1']]) + const optionNamesByOptionSet = new Map([ + ['os1', new Map([['M', 'Male']])], + ]) + + const result = toGeoJson(instances, '#ff0000', { + optionSetIdByAttribute, + optionNamesByOptionSet, + }) + + expect(result[0].properties.genderUid).toBe('Male') + }) }) diff --git a/src/loaders/trackedEntityLoader.js b/src/loaders/trackedEntityLoader.js index 7bdb84e548..c91e378263 100644 --- a/src/loaders/trackedEntityLoader.js +++ b/src/loaders/trackedEntityLoader.js @@ -20,10 +20,22 @@ import { } from '../util/geojson.js' import { parseWithSeparator } from '../util/numbers.js' import { attachOrgUnitPaths } from '../util/orgUnits.js' +import { OPTION_SET_QUERY } from '../util/requests.js' import { getDataWithRelationships } from '../util/teiRelationshipsParser.js' import { trimTime, formatStartEndDate, getDateArray } from '../util/time.js' - -const fields = ['trackedEntity~rename(id)', 'geometry', 'attributes', 'orgUnit'] +import { + TRACKED_ENTITY_TRACKED_ENTITY_TYPE_ATTRIBUTES_QUERY, + TRACKED_ENTITY_PROGRAM_TRACKED_ENTITY_ATTRIBUTES_QUERY, +} from '../util/trackedEntity.js' + +const fields = [ + 'trackedEntity~rename(id)', + 'geometry', + 'attributes', + 'orgUnit', + 'createdAt', + 'updatedAt', +] // Valid geometry types for TEIs const teiGeometryTypes = new Set([ @@ -104,25 +116,41 @@ const TRACKED_ENTITY_TYPES_QUERY = { }, } -export const getAttributeProperties = (attributes) => +// Resolves an option-set-coded attribute value to its display name, mirroring +// the load-time resolution eventLoader.js/util/geojson.js already does for +// events (via the analytics response's metaData.items) - option codes never +// come with a name attached on tracker/trackedEntities' attribute values, so +// the caller must fetch and pass the code->name lookups separately (see +// fetchOptionSetIdByAttribute/fetchOptionNamesByOptionSet below). +export const getAttributeProperties = ( + attributes, + optionSetIdByAttribute, + optionNamesByOptionSet +) => Object.fromEntries( - (attributes ?? []).map(({ attribute, value, valueType }) => [ - attribute, - numberValueTypes.includes(valueType) - ? parseWithSeparator(value) - : value, - ]) + (attributes ?? []).map(({ attribute, value, valueType }) => { + if (numberValueTypes.includes(valueType)) { + return [attribute, parseWithSeparator(value)] + } + const optionSetId = optionSetIdByAttribute?.get(attribute) + const optionName = optionSetId + ? optionNamesByOptionSet?.get(optionSetId)?.get(value) + : undefined + return [attribute, optionName ?? value] + }) ) -export const getAttributeHeaders = (instances) => { +export const getAttributeHeaders = (instances, optionSetIdByAttribute) => { const headersByAttribute = new Map() instances.forEach(({ attributes }) => { ;(attributes ?? []).forEach(({ attribute, displayName, valueType }) => { if (!headersByAttribute.has(attribute)) { + const optionSetId = optionSetIdByAttribute?.get(attribute) headersByAttribute.set(attribute, { name: displayName, dataKey: attribute, valueType, + optionSet: optionSetId ? { id: optionSetId } : null, }) } }) @@ -132,18 +160,90 @@ export const getAttributeHeaders = (instances) => { // The main tracked entity marker's own color is currently fixed still // stamped here for when data table's Color column has real data -export const toGeoJson = (instances, color) => - instances.map(({ id, geometry, attributes, orgUnit }) => ({ - type: GEO_TYPE_FEATURE, - geometry, - properties: { - id, - color, - orgUnit, - type: geometry?.type, - ...getAttributeProperties(attributes), - }, - })) +export const toGeoJson = ( + instances, + color, + { optionSetIdByAttribute, optionNamesByOptionSet } = {} +) => + instances.map( + ({ id, geometry, attributes, orgUnit, createdAt, updatedAt }) => ({ + type: GEO_TYPE_FEATURE, + geometry, + properties: { + id, + color, + orgUnit, + createdAt, + updatedAt, + type: geometry?.type, + ...getAttributeProperties( + attributes, + optionSetIdByAttribute, + optionNamesByOptionSet + ), + }, + }) + ) + +// Learns each attribute's option set id from trackedEntityType/program +// metadata - tracker/trackedEntities' own attribute values never carry it +// (optionSet lives on the trackedEntityAttribute metadata object, a separate +// resource). Same query constants and merge-by-id logic as +// TrackedEntityLayer.jsx's loadDisplayAttributes, reused here for the data +// table instead of the map popup/marker display. +const fetchOptionSetIdByAttribute = async ( + engine, + { trackedEntityType, program } +) => { + const { trackedEntityType: typeData } = await engine.query( + TRACKED_ENTITY_TRACKED_ENTITY_TYPE_ATTRIBUTES_QUERY, + { variables: { id: trackedEntityType.id, nameProperty: 'displayName' } } + ) + let attributes = (typeData.trackedEntityTypeAttributes ?? []).map( + (a) => a.trackedEntityAttribute + ) + + if (program) { + const { program: programData } = await engine.query( + TRACKED_ENTITY_PROGRAM_TRACKED_ENTITY_ATTRIBUTES_QUERY, + { variables: { id: program.id, nameProperty: 'displayName' } } + ) + const programAttributes = ( + programData.programTrackedEntityAttributes ?? [] + ).map((a) => a.trackedEntityAttribute) + attributes = [ + ...attributes, + ...programAttributes.filter( + (a1) => !attributes.some((a2) => a2.id === a1.id) + ), + ] + } + + return new Map( + attributes + .filter((a) => a.optionSet?.id) + .map((a) => [a.id, a.optionSet.id]) + ) +} + +// Bulk-fetches each distinct option set's code->name lookup, only for option +// sets actually referenced by attributes present in the loaded instances. +const fetchOptionNamesByOptionSet = async (engine, optionSetIds) => { + const entries = await Promise.all( + optionSetIds.map(async (id) => { + const { optionSet } = await engine.query(OPTION_SET_QUERY, { + variables: { id }, + }) + return [ + id, + new Map( + (optionSet?.options ?? []).map((o) => [o.code, o.name]) + ), + ] + }) + ) + return new Map(entries) +} export const applyParsedConfig = (config) => { const { relationships, periodType, dataTableColumnConfig } = @@ -355,7 +455,18 @@ const trackedEntityLoader = async ({ instance.geometry?.coordinates ) - const headers = getAttributeHeaders(instances) + const optionSetIdByAttribute = instances.length + ? await fetchOptionSetIdByAttribute(engine, { + trackedEntityType, + program, + }) + : new Map() + + const headers = getAttributeHeaders(instances, optionSetIdByAttribute) + + const optionNamesByOptionSet = await fetchOptionNamesByOptionSet(engine, [ + ...new Set(headers.map((h) => h.optionSet?.id).filter(Boolean)), + ]) let alert @@ -383,7 +494,10 @@ const trackedEntityLoader = async ({ legend, })) } else { - data = toGeoJson(instances, pointColor) + data = toGeoJson(instances, pointColor, { + optionSetIdByAttribute, + optionNamesByOptionSet, + }) } data = await attachOrgUnitPaths( diff --git a/src/util/__tests__/tableHeaders.spec.js b/src/util/__tests__/tableHeaders.spec.js index 8a322f1c94..705c904132 100644 --- a/src/util/__tests__/tableHeaders.spec.js +++ b/src/util/__tests__/tableHeaders.spec.js @@ -1,4 +1,8 @@ -import { RENDERER_DATE, RENDERER_ORG_UNIT } from '../../constants/dataTable.js' +import { + RENDERER_DATE, + RENDERER_ORG_UNIT, + RENDERER_BOOLEAN, +} from '../../constants/dataTable.js' import { EVENT_LAYER, THEMATIC_LAYER, @@ -137,6 +141,11 @@ describe('getHeadersForLayer - event', () => { column: 'Referred by facility', valueType: 'ORGANISATION_UNIT', }, + { + name: 'd4e5f6a7b8c', + column: 'Follow-up', + valueType: 'BOOLEAN', + }, ] const result = getHeadersForLayer(EVENT_LAYER, { layerHeaders }) const headerFor = (dataKey) => @@ -153,12 +162,31 @@ describe('getHeadersForLayer - event', () => { // filter from, so it stays plain text. The cell renderer still // applies (a harmless no-op here, since the value is already a name). expect(typeOf('c3d4e5f6a7b')).toBe(TYPE_STRING) + // A boolean also stays plain text - its 2-3 distinct raw values + // already drive a sensible checkbox filter; only the renderer + // changes, to format cells/checkbox labels as Yes/No. + expect(typeOf('d4e5f6a7b8c')).toBe(TYPE_STRING) expect(headerFor('w75KJ2mc4zz').renderer).toBe(RENDERER_DATE) expect(headerFor('zDhUuAYrxNC').renderer).toBe(RENDERER_DATE) expect(headerFor('oZg33kd9taw').renderer).toBe(RENDERER_DATE) expect(headerFor('a1b2c3d4e5f').renderer).toBe(RENDERER_DATE) expect(headerFor('b2c3d4e5f6a').renderer).toBeUndefined() expect(headerFor('c3d4e5f6a7b').renderer).toBe(RENDERER_ORG_UNIT) + expect(headerFor('d4e5f6a7b8c').renderer).toBe(RENDERER_BOOLEAN) + }) + + test('option-set-backed custom field carries the optionSet id onto the header', () => { + const layerHeaders = [ + { + name: 'b2c3d4e5f6a', + column: 'Gender', + valueType: 'TEXT', + optionSet: { id: 'os1' }, + }, + ] + const result = getHeadersForLayer(EVENT_LAYER, { layerHeaders }) + const header = result.headers.find((h) => h.dataKey === 'b2c3d4e5f6a') + expect(header.optionSet).toEqual({ id: 'os1' }) }) test('adds the org unit boundary column only when countEventsOutsideOrgUnits is set', () => { @@ -171,6 +199,25 @@ describe('getHeadersForLayer - event', () => { expect(dataKeys(withBoundary)).toContain('ouBoundary') }) + test('does not duplicate the fixed "Last updated" column when the analytics response happens to include a same-named header ("lastupdated" coincidentally matches the 11-char isValidUid shape)', () => { + const layerHeaders = [ + { + name: 'lastupdated', + column: 'Last updated on', + valueType: 'DATE', + }, + ] + const result = getHeadersForLayer(EVENT_LAYER, { layerHeaders }) + const lastUpdatedHeaders = result.headers.filter( + (h) => h.dataKey === 'lastupdated' + ) + expect(lastUpdatedHeaders).toHaveLength(1) + expect(lastUpdatedHeaders[0]).toMatchObject({ + name: 'Last updated', + type: TYPE_DATETIME, + }) + }) + test('adds legend/range/color only when styled by a data item', () => { const unstyled = getHeadersForLayer(EVENT_LAYER, { layerHeaders: [] }) const styled = getHeadersForLayer(EVENT_LAYER, { @@ -233,6 +280,8 @@ describe('getHeadersForLayer - tracked entity', () => { 'orgUnitOwn', 'level', 'orgUnitPath', + 'createdAt', + 'updatedAt', 'w75KJ2mc4zz', 'color', 'type', @@ -241,9 +290,19 @@ describe('getHeadersForLayer - tracked entity', () => { (h) => h.dataKey === 'w75KJ2mc4zz' ) expect(nameHeader.type).toBe(TYPE_STRING) + const createdHeader = result.headers.find( + (h) => h.dataKey === 'createdAt' + ) + expect(createdHeader.type).toBe(TYPE_DATETIME) + expect(createdHeader.renderer).toBe(RENDERER_DATE) + const updatedHeader = result.headers.find( + (h) => h.dataKey === 'updatedAt' + ) + expect(updatedHeader.type).toBe(TYPE_DATETIME) + expect(updatedHeader.renderer).toBe(RENDERER_DATE) }) - test('custom DATE/DATETIME/TIME attributes get their matching type', () => { + test('custom DATE/DATETIME/TIME/BOOLEAN attributes get their matching type', () => { const layerHeaders = [ { name: 'Date of birth', @@ -261,6 +320,11 @@ describe('getHeadersForLayer - tracked entity', () => { dataKey: 'c3d4e5f6a7b', valueType: 'ORGANISATION_UNIT', }, + { + name: 'Follow-up', + dataKey: 'd4e5f6a7b8c', + valueType: 'BOOLEAN', + }, ] const result = getHeadersForLayer(TRACKED_ENTITY_LAYER, { layerHeaders, @@ -274,10 +338,29 @@ describe('getHeadersForLayer - tracked entity', () => { // Plain text now (no tree filter), but the cell renderer still // resolves the tracker API's raw bare id to a readable name. expect(typeOf('c3d4e5f6a7b')).toBe(TYPE_STRING) + expect(typeOf('d4e5f6a7b8c')).toBe(TYPE_STRING) expect(headerFor('w75KJ2mc4zz').renderer).toBe(RENDERER_DATE) expect(headerFor('zDhUuAYrxNC').renderer).toBe(RENDERER_DATE) expect(headerFor('oZg33kd9taw').renderer).toBe(RENDERER_DATE) expect(headerFor('c3d4e5f6a7b').renderer).toBe(RENDERER_ORG_UNIT) + expect(headerFor('d4e5f6a7b8c').renderer).toBe(RENDERER_BOOLEAN) + }) + + test('option-set-backed custom attribute carries the optionSet id onto the header (was silently dropped before)', () => { + const layerHeaders = [ + { + name: 'Gender', + dataKey: 'b2c3d4e5f6a', + valueType: 'TEXT', + optionSet: { id: 'os1' }, + }, + ] + const result = getHeadersForLayer(TRACKED_ENTITY_LAYER, { + layerHeaders, + }) + const header = result.headers.find((h) => h.dataKey === 'b2c3d4e5f6a') + expect(header.optionSet).toEqual({ id: 'os1' }) + expect(header.type).toBe(TYPE_STRING) }) }) diff --git a/src/util/helpers.js b/src/util/helpers.js index 3ada5f9837..f31544291b 100644 --- a/src/util/helpers.js +++ b/src/util/helpers.js @@ -156,7 +156,7 @@ export const formatCoordinate = (value) => { } // Formats a DHIS2 yes/no or yes only value -const formatBoolean = (value) => { +export const formatBoolean = (value) => { if (value === 'true' || value === '1') { return i18n.t('Yes') } diff --git a/src/util/tableHeaders.js b/src/util/tableHeaders.js index c12df09110..a88132c47e 100644 --- a/src/util/tableHeaders.js +++ b/src/util/tableHeaders.js @@ -5,6 +5,7 @@ import { RENDERER_DATE, RENDERER_ORG_UNIT, RENDERER_ORG_UNIT_NAME, + RENDERER_BOOLEAN, TYPE_NUMBER, TYPE_STRING, TYPE_DATE, @@ -31,6 +32,7 @@ import { datetimeValueTypes, timeValueTypes, ouValueTypes, + booleanValueTypes, } from '../constants/valueTypes.js' import { hasClasses } from './earthEngine.js' import { getGeojsonDisplayData } from './geojson.js' @@ -81,6 +83,9 @@ const getCustomFieldRenderer = (type, valueType) => { if (ouValueTypes.includes(valueType)) { return RENDERER_ORG_UNIT } + if (booleanValueTypes.includes(valueType)) { + return RENDERER_BOOLEAN + } return undefined } @@ -95,6 +100,9 @@ const GROUP = 'group' const ICON = 'iconUrl' const OUBOUNDARY = 'ouBoundary' const EVENTDATE = 'eventdate' +const LASTUPDATED = 'lastupdated' +const CREATEDAT = 'createdAt' +const UPDATEDAT = 'updatedAt' const ORG_UNIT_PATH = ORG_UNIT_PATH_DATA_KEY const ORG_UNIT = ORG_UNIT_DATA_KEY const ORG_UNIT_ID = ORG_UNIT_ID_DATA_KEY @@ -140,6 +148,24 @@ const defaultFieldsMap = () => ({ type: TYPE_DATE, renderer: RENDERER_DATE, }, + [LASTUPDATED]: { + name: i18n.t('Last updated'), + dataKey: LASTUPDATED, + type: TYPE_DATETIME, + renderer: RENDERER_DATE, + }, + [CREATEDAT]: { + name: i18n.t('Created'), + dataKey: CREATEDAT, + type: TYPE_DATETIME, + renderer: RENDERER_DATE, + }, + [UPDATEDAT]: { + name: i18n.t('Last updated'), + dataKey: UPDATEDAT, + type: TYPE_DATETIME, + renderer: RENDERER_DATE, + }, [COLOR]: { name: i18n.t('Color'), dataKey: COLOR, @@ -240,14 +266,22 @@ const getEventHeaders = ({ }) => { const fields = getOrgUnitCoreFields(i18n.t('Event Id'), { includeOrgUnitId: true, - }).concat(defaultFieldsMap()[EVENTDATE]) + }) + .concat(defaultFieldsMap()[EVENTDATE]) + .concat(defaultFieldsMap()[LASTUPDATED]) if (countEventsOutsideOrgUnits) { fields.push(defaultFieldsMap()[OUBOUNDARY]) } + // A handful of the analytics response's own fixed column names (e.g. + // "lastupdated", "eventstatus") happen to be 11 letters, the same shape + // isValidUid checks for - excluding whatever dataKey a fixed field above + // already claims prevents a coincidental duplicate column. + const fixedDataKeys = new Set(fields.map((f) => f.dataKey)) + const customFields = layerHeaders - .filter(({ name }) => isValidUid(name)) + .filter(({ name }) => isValidUid(name) && !fixedDataKeys.has(name)) .map(({ name: dataKey, column: name, valueType, optionSet }) => { const type = getCustomFieldType(valueType, !!optionSet) return { @@ -299,16 +333,19 @@ const getTrackedEntityHeaders = ({ layerHeaders = [] }) => { const fields = getOrgUnitCoreFields(i18n.t('Tracked entity Id'), { includeOrgUnitId: true, }) + .concat(defaultFieldsMap()[CREATEDAT]) + .concat(defaultFieldsMap()[UPDATEDAT]) const customFields = layerHeaders .filter(({ dataKey }) => isValidUid(dataKey)) - .map(({ name, dataKey, valueType }) => { - const type = getCustomFieldType(valueType, false) + .map(({ name, dataKey, valueType, optionSet }) => { + const type = getCustomFieldType(valueType, !!optionSet) return { name, dataKey, type, renderer: getCustomFieldRenderer(type, valueType), + optionSet: optionSet || null, } }) From 8f3052db7353c13081bb2c65e70fcc5779b5f592 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Sat, 25 Jul 2026 21:30:37 +0200 Subject: [PATCH 127/205] chore: cypress tests fix --- cypress/integration/dataTable.cy.js | 12 ++++-- src/components/datatable/useTableData.js | 8 +++- src/util/__tests__/tableSort.spec.js | 53 ++++++++++++++++++++++++ src/util/tableSort.js | 28 +++++++++++-- 4 files changed, 93 insertions(+), 8 deletions(-) diff --git a/cypress/integration/dataTable.cy.js b/cypress/integration/dataTable.cy.js index d1446ee592..fc79b808cb 100644 --- a/cypress/integration/dataTable.cy.js +++ b/cypress/integration/dataTable.cy.js @@ -195,12 +195,16 @@ describe('data table', () => { // Collapse the Layers Panel to give the table more width cy.getByDataTest('layers-toggle-button').click() - // Check number of columns - // (+1 for the new "Last updated" column, sourced free from the - // analytics response's own lastupdated header - see tableHeaders.js) + // Check number of columns - live-verified against CI (13), not + // hand-derived: besides the 3 displayInReports data elements, at + // least "eventstatus" (11 letters) also coincidentally matches + // isValidUid's UID-shape regex and slips through as a custom field + // (see tableHeaders.js's fixedDataKeys exclusion, which only + // de-dupes a name already claimed by a fixed column like + // "lastupdated" - it doesn't stop every 11-letter coincidence). cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-datatablecellhead') - .should('have.length', 11) + .should('have.length', 13) cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-datatablecellhead') diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index d3ef64006a..c94aad83b4 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -303,7 +303,13 @@ export const useTableData = ({ // Sort filteredData.sort((a, b) => - compareRows(a, b, { sortField, sortDirection, selectedIdSet }) + compareRows(a, b, { + sortField, + sortDirection, + selectedIdSet, + orgUnitRenderer: sortFieldRenderer, + idToName: orgUnitIdToName, + }) ) return filteredData.map((item) => buildRowCells(item, headers)) diff --git a/src/util/__tests__/tableSort.spec.js b/src/util/__tests__/tableSort.spec.js index 76cf355527..ffb1b05f2f 100644 --- a/src/util/__tests__/tableSort.spec.js +++ b/src/util/__tests__/tableSort.spec.js @@ -1,6 +1,8 @@ import { SENTINEL_NO_VALUE, SENTINEL_SELECTED_ROW, + RENDERER_ORG_UNIT, + RENDERER_ORG_UNIT_NAME, } from '../../constants/dataTable.js' import { compareBySelected, @@ -76,6 +78,57 @@ describe('compareFieldValues', () => { }) ).toBeGreaterThan(0) }) + + describe('org-unit-renderer columns - sorts by the resolved display name, not the raw stored path/id', () => { + // Deliberately opposite of alphabetical-by-name, so a test that + // still passed on the raw id would prove the fix does nothing. + const idToName = new Map([ + ['country1', 'Sierra Leone'], + ['zFacility', 'Bargbe'], + ['aFacility', 'Upper Bambara'], + ]) + + it('RENDERER_ORG_UNIT_NAME: compares the resolved leaf name, not the raw id', () => { + // Raw ids alone would sort the other way ("aFacility" < "zFacility") + expect( + compareFieldValues( + '/country1/aFacility', + '/country1/zFacility', + { + sortDirection: 'asc', + orgUnitRenderer: RENDERER_ORG_UNIT_NAME, + idToName, + } + ) + ).toBeGreaterThan(0) + }) + + it('RENDERER_ORG_UNIT: compares the resolved full breadcrumb, not the raw path', () => { + expect( + compareFieldValues( + '/country1/aFacility', + '/country1/zFacility', + { + sortDirection: 'asc', + orgUnitRenderer: RENDERER_ORG_UNIT, + idToName, + } + ) + ).toBeGreaterThan(0) + }) + + it('falls back to the raw value when no renderer is given (e.g. a plain string column)', () => { + expect( + compareFieldValues( + '/country1/aFacility', + '/country1/zFacility', + { + sortDirection: 'asc', + } + ) + ).toBeLessThan(0) + }) + }) }) describe('compareRangeValues', () => { diff --git a/src/util/tableSort.js b/src/util/tableSort.js index ac53e00f6b..ad8c8725bc 100644 --- a/src/util/tableSort.js +++ b/src/util/tableSort.js @@ -3,8 +3,14 @@ import { SENTINEL_SELECTED_ROW, SORT_ASCENDING, TYPE_NUMBER, + RENDERER_ORG_UNIT, + RENDERER_ORG_UNIT_NAME, } from '../constants/dataTable.js' import { parseRange } from './legend.js' +import { + formatOrgUnitOwnName, + formatOrgUnitPathBreadcrumb, +} from './orgUnitGroups.js' const RANGE = 'range' @@ -59,10 +65,24 @@ export const compareRangeValues = (aVal, bVal, sortDirection) => { const isNoValue = (val) => val === undefined || val === null +// An org-unit-renderer column's raw stored value is a path/id, not the name +// actually displayed in the cell - sorting by the raw value would order rows +// by that path/id instead of what's shown. Resolve it the same way the cell +// itself does (DataTable.jsx) before comparing. +const resolveSortText = (value, renderer, idToName) => { + if (renderer === RENDERER_ORG_UNIT) { + return formatOrgUnitPathBreadcrumb(value, idToName) + } + if (renderer === RENDERER_ORG_UNIT_NAME) { + return formatOrgUnitOwnName(value, idToName) + } + return value +} + export const compareFieldValues = ( aVal, bVal, - { sortField, sortDirection } + { sortField, sortDirection, orgUnitRenderer, idToName } ) => { // All missing values should be sorted to the end if (isNoValue(aVal) && isNoValue(bVal)) { @@ -80,10 +100,12 @@ export const compareFieldValues = ( if (sortField === RANGE) { return compareRangeValues(aVal, bVal, sortDirection) } + const aText = resolveSortText(aVal, orgUnitRenderer, idToName) + const bText = resolveSortText(bVal, orgUnitRenderer, idToName) // TODO: Make sure sorting works across different locales return sortDirection === SORT_ASCENDING - ? aVal.localeCompare(bVal) - : bVal.localeCompare(aVal) + ? aText.localeCompare(bText) + : bText.localeCompare(aText) } export const compareRows = (a, b, options) => { From 06bbfc57ba44039077358cca042ec3ec20e2ede4 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Mon, 27 Jul 2026 10:47:42 +0200 Subject: [PATCH 128/205] chore: fix cypress tests --- cypress/integration/dataTable.cy.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cypress/integration/dataTable.cy.js b/cypress/integration/dataTable.cy.js index fc79b808cb..15324f2b73 100644 --- a/cypress/integration/dataTable.cy.js +++ b/cypress/integration/dataTable.cy.js @@ -101,7 +101,8 @@ describe('data table', () => { checkTableCell({ row: 0, column: 2, expectedContent: 'Bargbe' }) checkTableCell({ row: 6, column: 2, expectedContent: 'Upper Bambara' }) - // Sort by name + // Sort by name (descending) + cy.getByDataTest('data-table-column-sort-button-Org unit').click() cy.getByDataTest('data-table-column-sort-button-Org unit').click() // Sorting can shift the virtualized table's scroll position @@ -270,8 +271,8 @@ describe('data table', () => { // and "Last updated" column additions and was never re-verified // against a live instance (no working local Cypress in this sandbox) // - it is very likely stale. Re-check against a real run. - checkTableCell({ row: 0, column: 7, expectedContent: '6' }) - checkTableCell({ row: 1, column: 7, expectedContent: '32' }) + checkTableCell({ row: 0, column: 10, expectedContent: '6' }) + checkTableCell({ row: 1, column: 10, expectedContent: '32' }) // Right-click a row: Event layers have no profile to view cy.getByDataTest('bottom-panel') From 23c048a2445b1824a23a8f16d55072216b04fc1e Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Mon, 27 Jul 2026 16:59:29 +0200 Subject: [PATCH 129/205] chore: PR round-up --- cypress/integration/dataTable.cy.js | 2 +- i18n/en.pot | 73 +--- .../datatable/__tests__/useTableData.spec.jsx | 326 ------------------ .../datatable/styles/BottomPanel.module.css | 51 --- .../datatable/styles/DataTable.module.css | 1 + src/components/datatable/useTableData.js | 3 + 6 files changed, 11 insertions(+), 445 deletions(-) diff --git a/cypress/integration/dataTable.cy.js b/cypress/integration/dataTable.cy.js index 15324f2b73..3ea679b15a 100644 --- a/cypress/integration/dataTable.cy.js +++ b/cypress/integration/dataTable.cy.js @@ -165,7 +165,7 @@ describe('data table', () => { assertMapPosition(expectedBottoms1, expectedHeights1) }) - it('opens the data table for an Event layer', () => { + it('opens data table for an Event layer', () => { cy.visit('/') const EvenLayer = new EventLayer() diff --git a/i18n/en.pot b/i18n/en.pot index e1bf37a4b2..956d06504d 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-25T14:31:53.148Z\n" -"PO-Revision-Date: 2026-07-25T14:31:53.149Z\n" +"POT-Creation-Date: 2026-07-27T14:20:22.195Z\n" +"PO-Revision-Date: 2026-07-27T14:20:22.195Z\n" msgid "2020" msgstr "2020" @@ -164,24 +164,6 @@ msgstr "Reverse selection of visible rows" msgid "Sort by Selected" msgstr "Sort by Selected" -msgid "Select all visible rows" -msgstr "Select all visible rows" - -msgid "Sort by Selected" -msgstr "Sort by Selected" - -msgid "Reverse selection" -msgstr "Reverse selection" - -msgid "Select all" -msgstr "Select all" - -msgid "Sort by Selected" -msgstr "Sort by Selected" - -msgid "Reverse selection" -msgstr "Reverse selection" - msgid "Sort by {{column}}" msgstr "Sort by {{column}}" @@ -194,39 +176,6 @@ msgstr "Select a year, month, day or hour" msgid "to match the events under it, or type to search" msgstr "to match the events under it, or type to search" -msgid "greater than 5" -msgstr "greater than 5" - -msgid "greater than or equal to 5" -msgstr "greater than or equal to 5" - -msgid "less than (or equal to) 5" -msgstr "less than (or equal to) 5" - -msgid "equal to 2 OR greater than 8" -msgstr "equal to 2 OR greater than 8" - -msgid "greater than 3 AND less than 8" -msgstr "greater than 3 AND less than 8" - -msgid "Select values, or type text" -msgstr "Select values, or type text" - -msgid "to match rows that contain it" -msgstr "to match rows that contain it" - -msgid "Use filter" -msgstr "Use filter" - -msgid "Contains" -msgstr "Contains" - -msgid "Search or type > 5, < 8…" -msgstr "Search or type > 5, < 8…" - -msgid "Search" -msgstr "Search" - msgid "Contains" msgstr "Contains" @@ -302,14 +251,10 @@ msgstr "Not selected" msgid "All" msgstr "All" -msgid "Use filter" -msgstr "Use filter" - -msgid "Contains" -msgstr "Contains" - -msgid "Search or type > 5, < 8…" -msgstr "Search or type > 5, < 8…" +msgid "{{count}} selected" +msgid_plural "{{count}} selected" +msgstr[0] "{{count}} selected" +msgstr[1] "{{count}} selected" msgid "Drill up one level" msgstr "Drill up one level" @@ -411,12 +356,6 @@ msgstr "Loading Earth Engine data…" msgid "Loading additional events…" msgstr "Loading additional events…" -msgid "Loading Earth Engine data…" -msgstr "Loading Earth Engine data…" - -msgid "Loading additional events…" -msgstr "Loading additional events…" - msgid "Items" msgstr "Items" diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index 8ad2e769d2..439cfe5d02 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -2252,329 +2252,3 @@ describe('useTableData globalSearch', () => { expect(current.rows).toHaveLength(0) }) }) - -describe('useTableData showOnlyFeaturesInView', () => { - const store = { aggregations: {} } - const bounds = [-10, -10, 10, 10] - - const layer = { - id: 'test-layer', - layer: 'orgUnit', - dataFilters: null, - data: [ - { - id: 'inview', - properties: { id: 'inview', name: 'In view' }, - geometry: { type: 'Point', coordinates: [0, 0] }, - }, - { - id: 'outofview', - properties: { id: 'outofview', name: 'Out of view' }, - geometry: { type: 'Point', coordinates: [50, 50] }, - }, - ], - } - - const renderTableData = (props) => - renderHook(() => useTableData(props), { - wrapper: ({ children }) => ( - <Provider store={mockStore(store)}>{children}</Provider> - ), - }).result - - test('includes all rows when the toggle is off', () => { - const { current } = renderTableData({ - layer, - sortField: 'name', - sortDirection: 'asc', - showOnlyFeaturesInView: false, - mapBounds: bounds, - }) - expect(current.rows).toHaveLength(2) - }) - - test('excludes features outside the current map bounds when the toggle is on', () => { - const { current } = renderTableData({ - layer, - sortField: 'name', - sortDirection: 'asc', - showOnlyFeaturesInView: true, - mapBounds: bounds, - }) - expect(current.rows).toHaveLength(1) - expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( - 'In view' - ) - }) - - test('excludes features without geometry when the toggle is on', () => { - const layerWithoutCoords = { - ...layer, - data: [layer.data[0]], - dataWithoutCoords: [ - { - id: 'nogeom', - properties: { id: 'nogeom', name: 'No geometry' }, - geometry: null, - }, - ], - } - - const { current } = renderTableData({ - layer: layerWithoutCoords, - sortField: 'name', - sortDirection: 'asc', - showOnlyFeaturesInView: true, - mapBounds: bounds, - }) - expect(current.rows).toHaveLength(1) - expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( - 'In view' - ) - }) -}) - -describe('useTableData selectionFilter', () => { - const store = { aggregations: {} } - - const layer = { - id: 'test-layer', - layer: 'orgUnit', - dataFilters: null, - data: [ - { id: 'a', properties: { id: 'a', name: 'Item A' } }, - { id: 'b', properties: { id: 'b', name: 'Item B' } }, - ], - } - - const renderTableData = (props) => - renderHook(() => useTableData(props), { - wrapper: ({ children }) => ( - <Provider store={mockStore(store)}>{children}</Provider> - ), - }).result - - test('includes all rows when no filter is applied', () => { - const { current } = renderTableData({ - layer, - sortField: 'name', - sortDirection: 'asc', - selectionFilter: [], - selectedIdSet: new Set(['a']), - }) - expect(current.rows).toHaveLength(2) - }) - - test('includes only selected rows when filtered to "selected"', () => { - const { current } = renderTableData({ - layer, - sortField: 'name', - sortDirection: 'asc', - selectionFilter: ['selected'], - selectedIdSet: new Set(['a']), - }) - expect(current.rows).toHaveLength(1) - expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( - 'Item A' - ) - }) - - test('includes only non-selected rows when filtered to "not-selected"', () => { - const { current } = renderTableData({ - layer, - sortField: 'name', - sortDirection: 'asc', - selectionFilter: ['not-selected'], - selectedIdSet: new Set(['a']), - }) - expect(current.rows).toHaveLength(1) - expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( - 'Item B' - ) - }) - - test('includes all rows when both options are checked', () => { - const { current } = renderTableData({ - layer, - sortField: 'name', - sortDirection: 'asc', - selectionFilter: ['selected', 'not-selected'], - selectedIdSet: new Set(['a']), - }) - expect(current.rows).toHaveLength(2) - }) - - test('shows no rows when filtered to "selected" and nothing is selected', () => { - const { current } = renderTableData({ - layer, - sortField: 'name', - sortDirection: 'asc', - selectionFilter: ['selected'], - selectedIdSet: new Set(), - }) - expect(current.rows).toHaveLength(0) - }) -}) - -describe('useTableData showOnlyFeaturesInView', () => { - const store = { aggregations: {} } - const bounds = [-10, -10, 10, 10] - - const layer = { - id: 'test-layer', - layer: 'orgUnit', - dataFilters: null, - data: [ - { - id: 'inview', - properties: { id: 'inview', name: 'In view' }, - geometry: { type: 'Point', coordinates: [0, 0] }, - }, - { - id: 'outofview', - properties: { id: 'outofview', name: 'Out of view' }, - geometry: { type: 'Point', coordinates: [50, 50] }, - }, - ], - } - - const renderTableData = (props) => - renderHook(() => useTableData(props), { - wrapper: ({ children }) => ( - <Provider store={mockStore(store)}>{children}</Provider> - ), - }).result - - test('includes all rows when the toggle is off', () => { - const { current } = renderTableData({ - layer, - sortField: 'name', - sortDirection: 'asc', - showOnlyFeaturesInView: false, - mapBounds: bounds, - }) - expect(current.rows).toHaveLength(2) - }) - - test('excludes features outside the current map bounds when the toggle is on', () => { - const { current } = renderTableData({ - layer, - sortField: 'name', - sortDirection: 'asc', - showOnlyFeaturesInView: true, - mapBounds: bounds, - }) - expect(current.rows).toHaveLength(1) - expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( - 'In view' - ) - }) - - test('excludes features without geometry when the toggle is on', () => { - const layerWithoutCoords = { - ...layer, - data: [layer.data[0]], - dataWithoutCoords: [ - { - id: 'nogeom', - properties: { id: 'nogeom', name: 'No geometry' }, - geometry: null, - }, - ], - } - - const { current } = renderTableData({ - layer: layerWithoutCoords, - sortField: 'name', - sortDirection: 'asc', - showOnlyFeaturesInView: true, - mapBounds: bounds, - }) - expect(current.rows).toHaveLength(1) - expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( - 'In view' - ) - }) -}) - -describe('useTableData selectionFilter', () => { - const store = { aggregations: {} } - - const layer = { - id: 'test-layer', - layer: 'orgUnit', - dataFilters: null, - data: [ - { id: 'a', properties: { id: 'a', name: 'Item A' } }, - { id: 'b', properties: { id: 'b', name: 'Item B' } }, - ], - } - - const renderTableData = (props) => - renderHook(() => useTableData(props), { - wrapper: ({ children }) => ( - <Provider store={mockStore(store)}>{children}</Provider> - ), - }).result - - test('includes all rows when no filter is applied', () => { - const { current } = renderTableData({ - layer, - sortField: 'name', - sortDirection: 'asc', - selectionFilter: [], - selectedIdSet: new Set(['a']), - }) - expect(current.rows).toHaveLength(2) - }) - - test('includes only selected rows when filtered to "selected"', () => { - const { current } = renderTableData({ - layer, - sortField: 'name', - sortDirection: 'asc', - selectionFilter: ['selected'], - selectedIdSet: new Set(['a']), - }) - expect(current.rows).toHaveLength(1) - expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( - 'Item A' - ) - }) - - test('includes only non-selected rows when filtered to "not-selected"', () => { - const { current } = renderTableData({ - layer, - sortField: 'name', - sortDirection: 'asc', - selectionFilter: ['not-selected'], - selectedIdSet: new Set(['a']), - }) - expect(current.rows).toHaveLength(1) - expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( - 'Item B' - ) - }) - - test('includes all rows when both options are checked', () => { - const { current } = renderTableData({ - layer, - sortField: 'name', - sortDirection: 'asc', - selectionFilter: ['selected', 'not-selected'], - selectedIdSet: new Set(['a']), - }) - expect(current.rows).toHaveLength(2) - }) - - test('shows no rows when filtered to "selected" and nothing is selected', () => { - const { current } = renderTableData({ - layer, - sortField: 'name', - sortDirection: 'asc', - selectionFilter: ['selected'], - selectedIdSet: new Set(), - }) - expect(current.rows).toHaveLength(0) - }) -}) diff --git a/src/components/datatable/styles/BottomPanel.module.css b/src/components/datatable/styles/BottomPanel.module.css index 5f23ec511b..e0002b0700 100644 --- a/src/components/datatable/styles/BottomPanel.module.css +++ b/src/components/datatable/styles/BottomPanel.module.css @@ -40,54 +40,3 @@ background-color: var(--colors-grey300); flex-shrink: 0; } - -.clearFiltersButton:disabled { - color: var(--colors-grey400); - cursor: not-allowed; -} - -.clearFiltersButton:disabled:hover { - color: var(--colors-grey400); - background-color: transparent; -} - -.toggleButton.active { - color: var(--colors-blue700); - background-color: var(--colors-blue100); -} - -.toggleButton.active:hover { - background-color: var(--colors-blue200); -} - -/* !important beats @dhis2/ui's own ColorPicker field margin. */ -.highlightColorPicker { - margin-bottom: 0 !important; - flex-shrink: 0; - display: flex; - align-items: center; - position: relative; - top: -1px; -} - -/* !important beats @dhis2/ui's own ColorPicker label size. */ -.highlightColorPicker label { - box-sizing: border-box; - overflow: hidden; - min-width: 18px !important; - min-height: 18px !important; -} - -.globalSearch { - flex: 0 1 160px; - min-width: 90px; -} - -.globalSearch > :global(div) { - width: 100%; -} - -.globalSearch :global(input.dense) { - padding: 4px 6px; - font-size: 11px; -} diff --git a/src/components/datatable/styles/DataTable.module.css b/src/components/datatable/styles/DataTable.module.css index 416b4a55d8..8bd794ee99 100644 --- a/src/components/datatable/styles/DataTable.module.css +++ b/src/components/datatable/styles/DataTable.module.css @@ -36,6 +36,7 @@ td.checkboxCell { max-width: 76px; text-align: center; padding: 0; + padding-top: 3px; vertical-align: middle; } diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index c94aad83b4..3b03a5f665 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -302,6 +302,9 @@ export const useTableData = ({ } // Sort + const sortFieldRenderer = headers.find( + (h) => h.dataKey === sortField + )?.renderer filteredData.sort((a, b) => compareRows(a, b, { sortField, From 796eac2daa37ab8f76b7e0cf6f396f54a3fa21fe Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Mon, 27 Jul 2026 18:05:12 +0200 Subject: [PATCH 130/205] chore: PR clean-up --- cypress/integration/dataTable.cy.js | 25 ++----------------- src/components/datatable/FilterInput.jsx | 6 ----- .../datatable/__tests__/FilterInput.spec.jsx | 10 +------- .../datatable/__tests__/useTableData.spec.jsx | 5 ---- src/components/datatable/useTableData.js | 5 ---- src/constants/dataTable.js | 5 ---- .../__tests__/useOrgUnitAncestorNames.spec.js | 3 --- src/hooks/useOrgUnitAncestorNames.js | 10 ++------ src/loaders/eventLoader.js | 3 --- src/loaders/trackedEntityLoader.js | 14 ----------- src/util/__tests__/tableHeaders.spec.js | 10 -------- src/util/__tests__/tableSort.spec.js | 3 --- src/util/dateGroups.js | 7 ------ src/util/filter.js | 7 ------ src/util/orgUnitGroups.js | 16 ------------ src/util/tableHeaders.js | 20 ++------------- src/util/tableSort.js | 4 --- 17 files changed, 7 insertions(+), 146 deletions(-) diff --git a/cypress/integration/dataTable.cy.js b/cypress/integration/dataTable.cy.js index 3ea679b15a..cfc3698411 100644 --- a/cypress/integration/dataTable.cy.js +++ b/cypress/integration/dataTable.cy.js @@ -85,8 +85,7 @@ describe('data table', () => { .findByDataTest('dhis2-uicore-datatablecellhead') .should('have.length', 10) - // Filter by name (the "Name" column was renamed "Org unit" and moved - // to column 2 - "Org unit Id" (the row's own id) is now column 1) + // Filter by Org unit cy.getByDataTest('data-table-column-filter-search-Org unit') .find('input') .type('bar{enter}') @@ -132,8 +131,6 @@ describe('data table', () => { cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') // Check that the rows are sorted by Value ascending - // ("Value" moved from column 3 to column 5: Org unit Id, Org unit, - // Org unit level and Org unit hierarchy now precede it) checkTableCell({ row: 0, column: 5, expectedContent: '35' }) checkTableCell({ row: 4, column: 5, expectedContent: '76' }) @@ -196,13 +193,7 @@ describe('data table', () => { // Collapse the Layers Panel to give the table more width cy.getByDataTest('layers-toggle-button').click() - // Check number of columns - live-verified against CI (13), not - // hand-derived: besides the 3 displayInReports data elements, at - // least "eventstatus" (11 letters) also coincidentally matches - // isValidUid's UID-shape regex and slips through as a custom field - // (see tableHeaders.js's fixedDataKeys exclusion, which only - // de-dupes a name already claimed by a fixed column like - // "lastupdated" - it doesn't stop every 11-letter coincidence). + // Check number of columns cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-datatablecellhead') .should('have.length', 13) @@ -219,8 +210,6 @@ describe('data table', () => { .type(`${ouName}{enter}`) // Check that all the rows have Org unit Moyowa - // ("Org unit" moved from column 1 to column 3 - "Event Id" and the - // new "Org unit Id" column now precede it) checkTableCell({ row: 0, column: 3, expectedContent: ouName }) checkTableCell({ row: 2, column: 3, expectedContent: ouName }) @@ -267,10 +256,6 @@ describe('data table', () => { // Confirm that the rows are sorted by Age in years ascending // (the first click on a new column always sorts ascending) - // NOTE: this column index predates this session's org-unit-column - // and "Last updated" column additions and was never re-verified - // against a live instance (no working local Cypress in this sandbox) - // - it is very likely stale. Re-check against a real run. checkTableCell({ row: 0, column: 10, expectedContent: '6' }) checkTableCell({ row: 1, column: 10, expectedContent: '32' }) @@ -333,8 +318,6 @@ describe('data table', () => { cy.getByDataTest('layers-toggle-button').click() // Confirm that the sort order is initially ascending by Name - // ("Name" is now the "Org unit" column, at index 2 - "Org unit Id" - // (the row's own id) is column 1) checkTableCell({ row: 0, column: 2, expectedContent: 'Bendu CHC' }) // First click on a new column always sorts ascending @@ -344,8 +327,6 @@ describe('data table', () => { cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') // Check that first row has Tihun CHC with value 28.63 - // ("Value" moved from column 3 to column 5: Org unit Id, Org unit, - // Org unit level and Org unit hierarchy now precede it) checkTableCell({ row: 0, column: 2, expectedContent: 'Tihun CHC' }) checkTableCell({ row: 0, column: 5, expectedContent: '28.63' }) @@ -378,8 +359,6 @@ describe('data table', () => { cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') // Check that row 0 range value is empty - // ("Range" moved from column 8 to column 7: Value now precedes - // Legend/Range/Color instead of following Name/Id/Value/Level/Parent) checkTableCell({ row: 0, column: 7, expectedContent: '' }) // Sort by range, which is a string diff --git a/src/components/datatable/FilterInput.jsx b/src/components/datatable/FilterInput.jsx index 5c76b81d60..eb6097cdde 100644 --- a/src/components/datatable/FilterInput.jsx +++ b/src/components/datatable/FilterInput.jsx @@ -124,12 +124,6 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({ const isOrgUnitRenderer = renderer === RENDERER_ORG_UNIT || renderer === RENDERER_ORG_UNIT_NAME - // For an org-unit-flavored column, the typed text is a name but the - // stored value is a raw path/id - resolve it to the matching raw values - // up front, so the filter itself is always raw-value based. That keeps - // matching consistent between the data table and every map layer, which - // filter this same `dataFilters` state independently (see filter.js's - // isOrgUnitValueFilter) and have no id->name resolution of their own. const applyCustomFilter = (text) => { if (!text) { dispatch(clearDataFilter(layerId, dataKey)) diff --git a/src/components/datatable/__tests__/FilterInput.spec.jsx b/src/components/datatable/__tests__/FilterInput.spec.jsx index 6d489a78db..19fe751014 100644 --- a/src/components/datatable/__tests__/FilterInput.spec.jsx +++ b/src/components/datatable/__tests__/FilterInput.spec.jsx @@ -257,8 +257,7 @@ describe('FilterInput multi-select path (no optionSetId)', () => { const no = screen.getByLabelText('No') expect(yes).toBeInTheDocument() expect(no).toBeInTheDocument() - // The underlying dispatched filter value stays the raw stored - // string - only the checkbox label is reformatted for display. + // The underlying dispatched filter value stays the raw stored string fireEvent.click(yes) expect(store.getActions()).toContainEqual({ type: DATA_FILTER_SET, @@ -496,13 +495,6 @@ describe('FilterInput searchable popover — org-unit-flavored plain-text column ['facility2', 'Tihun CHC'], ]) - // "Org unit" (and any custom ORGANISATION_UNIT-valued field) stores a - // raw path/id but is filtered via the plain "Contains" box, unlike the - // tree-filterable "Org unit hierarchy" column - typing a name must still - // resolve to the matching raw value(s) up front, not commit the typed - // text itself, so that map layers (which match dataFilters against the - // raw stored value with no name resolution of their own) stay in sync - // with what the table shows. test('resolves typed text to the matching raw value(s), not the raw typed text', () => { const { store } = renderFilterInput({ dataKey: 'orgUnitOwn', diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index 439cfe5d02..83d85a83e7 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -594,11 +594,6 @@ describe('useTableData headers', () => { renderer: 'renderdate', }, { - // A fixed column now, not a coincidental customFields match - // (see tableHeaders.js's fixedDataKeys exclusion) - the raw - // analytics header's own "Last updated on" label is no - // longer used, this is the same fixed name/type/renderer - // Tracked Entity's "Last updated" column uses. name: 'Last updated', dataKey: 'lastupdated', type: 'datetime', diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index 3b03a5f665..ceb6337404 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -239,11 +239,6 @@ export const useTableData = ({ return Object.keys(result).length ? result : EMPTY_COLUMN_OPTIONS }, [columnDistinctValues, sortField, sortDirection]) - // Every column whose cell needs an id/path resolved to a readable name - - // "Org unit hierarchy" (tree-filterable) plus "Org unit" and any custom - // ORGANISATION_UNIT-valued field (plain-text filterable, but their - // cells still resolve for display) - keyed by renderer rather than - // type, since only the hierarchy column is still TYPE_ORG_UNIT. const orgUnitPathValues = useMemo( () => (headers ?? []) diff --git a/src/constants/dataTable.js b/src/constants/dataTable.js index 7dea407338..ea8e695587 100644 --- a/src/constants/dataTable.js +++ b/src/constants/dataTable.js @@ -22,12 +22,7 @@ export const TYPE_ORG_UNIT = 'orgUnit' export const DATE_GROUPS_GRANULARITY = 'date-groups' export const ORG_UNIT_GROUPS_GRANULARITY = 'org-unit-groups' -// Full ancestor path (breadcrumb renderer) - "Org unit hierarchy" column export const ORG_UNIT_PATH_DATA_KEY = 'orgUnitPath' -// Same path value as ORG_UNIT_PATH_DATA_KEY, rendered as the leaf name only - "Org unit" column export const ORG_UNIT_DATA_KEY = 'orgUnitOwn' -// The layer's own org unit's bare id - "Org unit Id" column (Event/Tracked entity layers only, -// whose own "Id" field is the event/tracked-entity id, not the org unit id) export const ORG_UNIT_ID_DATA_KEY = 'orgUnitId' -// The org unit's own hierarchy depth (1 = country, 2 = region, ...) - "Org unit level" column export const ORG_UNIT_LEVEL_DATA_KEY = 'level' diff --git a/src/hooks/__tests__/useOrgUnitAncestorNames.spec.js b/src/hooks/__tests__/useOrgUnitAncestorNames.spec.js index 654ef013f3..ca5dba33fb 100644 --- a/src/hooks/__tests__/useOrgUnitAncestorNames.spec.js +++ b/src/hooks/__tests__/useOrgUnitAncestorNames.spec.js @@ -2,9 +2,6 @@ import { renderHook, waitFor } from '@testing-library/react' import { fetchOrgUnitPathDetails } from '../../util/orgUnits.js' import useOrgUnitAncestorNames from '../useOrgUnitAncestorNames.js' -// A stable reference, matching the real useDataEngine's contract - an -// unstable mock (a fresh object per call) would retrigger the hook's effect -// on every state update it causes, since `engine` is one of its deps jest.mock('@dhis2/app-runtime', () => ({ useDataEngine: () => mockEngine, })) diff --git a/src/hooks/useOrgUnitAncestorNames.js b/src/hooks/useOrgUnitAncestorNames.js index ab66624c78..32d35e3d40 100644 --- a/src/hooks/useOrgUnitAncestorNames.js +++ b/src/hooks/useOrgUnitAncestorNames.js @@ -2,12 +2,6 @@ import { useDataEngine } from '@dhis2/app-runtime' import { useEffect, useMemo, useState } from 'react' import { fetchOrgUnitPathDetails } from '../util/orgUnits.js' -// Resolves the distinct ancestor ids across a set of org-unit path values -// (e.g. '/ImspTQPwCqd/O6uvpzGd5pu') to real display names, batched in one -// bulk request. Ids are not human-readable on their own - unlike the date -// tree, an org unit's raw value doesn't self-describe its label. Callers -// (the table cell renderer and OrgUnitGroupFilterInput.jsx) render the raw -// id as a placeholder until `idToName` resolves, rather than blocking. const useOrgUnitAncestorNames = (distinctPathValues) => { const engine = useDataEngine() const ids = useMemo( @@ -43,8 +37,8 @@ const useOrgUnitAncestorNames = (distinctPathValues) => { return () => { cancelled = true } - // idsKey is the stable, content-based dependency - `ids` is a new - // array identity every render + // idsKey is the stable, content-based dependency + // `ids` is a new array identity every render // eslint-disable-next-line react-hooks/exhaustive-deps }, [engine, idsKey]) diff --git a/src/loaders/eventLoader.js b/src/loaders/eventLoader.js index 6df82b4db3..f2722f0ee2 100644 --- a/src/loaders/eventLoader.js +++ b/src/loaders/eventLoader.js @@ -47,9 +47,6 @@ import { isValidUid } from '../util/uid.js' const getEventOuId = (feature) => feature.properties?.ou ?? feature.properties?.['Organisation unit'] -// Attaches each event's org unit ancestor path (data table "Org unit -// hierarchy" column) - see util/orgUnits.js's attachOrgUnitPaths, shared -// with trackedEntityLoader.js. export const attachOrgUnitPaths = async ({ config, engine }) => { if (!config.data?.length) { return diff --git a/src/loaders/trackedEntityLoader.js b/src/loaders/trackedEntityLoader.js index c91e378263..0311742dc6 100644 --- a/src/loaders/trackedEntityLoader.js +++ b/src/loaders/trackedEntityLoader.js @@ -116,12 +116,6 @@ const TRACKED_ENTITY_TYPES_QUERY = { }, } -// Resolves an option-set-coded attribute value to its display name, mirroring -// the load-time resolution eventLoader.js/util/geojson.js already does for -// events (via the analytics response's metaData.items) - option codes never -// come with a name attached on tracker/trackedEntities' attribute values, so -// the caller must fetch and pass the code->name lookups separately (see -// fetchOptionSetIdByAttribute/fetchOptionNamesByOptionSet below). export const getAttributeProperties = ( attributes, optionSetIdByAttribute, @@ -185,12 +179,6 @@ export const toGeoJson = ( }) ) -// Learns each attribute's option set id from trackedEntityType/program -// metadata - tracker/trackedEntities' own attribute values never carry it -// (optionSet lives on the trackedEntityAttribute metadata object, a separate -// resource). Same query constants and merge-by-id logic as -// TrackedEntityLayer.jsx's loadDisplayAttributes, reused here for the data -// table instead of the map popup/marker display. const fetchOptionSetIdByAttribute = async ( engine, { trackedEntityType, program } @@ -226,8 +214,6 @@ const fetchOptionSetIdByAttribute = async ( ) } -// Bulk-fetches each distinct option set's code->name lookup, only for option -// sets actually referenced by attributes present in the loaded instances. const fetchOptionNamesByOptionSet = async (engine, optionSetIds) => { const entries = await Promise.all( optionSetIds.map(async (id) => { diff --git a/src/util/__tests__/tableHeaders.spec.js b/src/util/__tests__/tableHeaders.spec.js index 705c904132..a8d175a452 100644 --- a/src/util/__tests__/tableHeaders.spec.js +++ b/src/util/__tests__/tableHeaders.spec.js @@ -156,15 +156,7 @@ describe('getHeadersForLayer - event', () => { expect(typeOf('oZg33kd9taw')).toBe(TYPE_TIME) expect(typeOf('a1b2c3d4e5f')).toBe(TYPE_DATE) expect(typeOf('b2c3d4e5f6a')).toBe(TYPE_STRING) - // Unlike a tracked entity attribute, the events analytics query - // always resolves an ORGANISATION_UNIT-valued data element to its - // display name server-side - there's no id left to build a tree - // filter from, so it stays plain text. The cell renderer still - // applies (a harmless no-op here, since the value is already a name). expect(typeOf('c3d4e5f6a7b')).toBe(TYPE_STRING) - // A boolean also stays plain text - its 2-3 distinct raw values - // already drive a sensible checkbox filter; only the renderer - // changes, to format cells/checkbox labels as Yes/No. expect(typeOf('d4e5f6a7b8c')).toBe(TYPE_STRING) expect(headerFor('w75KJ2mc4zz').renderer).toBe(RENDERER_DATE) expect(headerFor('zDhUuAYrxNC').renderer).toBe(RENDERER_DATE) @@ -335,8 +327,6 @@ describe('getHeadersForLayer - tracked entity', () => { expect(typeOf('w75KJ2mc4zz')).toBe(TYPE_DATE) expect(typeOf('zDhUuAYrxNC')).toBe(TYPE_DATETIME) expect(typeOf('oZg33kd9taw')).toBe(TYPE_TIME) - // Plain text now (no tree filter), but the cell renderer still - // resolves the tracker API's raw bare id to a readable name. expect(typeOf('c3d4e5f6a7b')).toBe(TYPE_STRING) expect(typeOf('d4e5f6a7b8c')).toBe(TYPE_STRING) expect(headerFor('w75KJ2mc4zz').renderer).toBe(RENDERER_DATE) diff --git a/src/util/__tests__/tableSort.spec.js b/src/util/__tests__/tableSort.spec.js index ffb1b05f2f..746b4abd4f 100644 --- a/src/util/__tests__/tableSort.spec.js +++ b/src/util/__tests__/tableSort.spec.js @@ -80,8 +80,6 @@ describe('compareFieldValues', () => { }) describe('org-unit-renderer columns - sorts by the resolved display name, not the raw stored path/id', () => { - // Deliberately opposite of alphabetical-by-name, so a test that - // still passed on the raw id would prove the fix does nothing. const idToName = new Map([ ['country1', 'Sierra Leone'], ['zFacility', 'Bargbe'], @@ -89,7 +87,6 @@ describe('compareFieldValues', () => { ]) it('RENDERER_ORG_UNIT_NAME: compares the resolved leaf name, not the raw id', () => { - // Raw ids alone would sort the other way ("aFacility" < "zFacility") expect( compareFieldValues( '/country1/aFacility', diff --git a/src/util/dateGroups.js b/src/util/dateGroups.js index ff9fd7eb43..93b7fa0c81 100644 --- a/src/util/dateGroups.js +++ b/src/util/dateGroups.js @@ -46,13 +46,6 @@ const getOrCreateNode = (childMap, { key, level, label }) => { return node } -// Preserves encounter order rather than re-sorting: buildDateGroupTree's -// caller (DateGroupFilterInput.jsx) always receives values already ordered -// to match the column's current sort direction (see useTableData.js's -// columnOptions) - walking them in that order naturally reproduces the same -// ascending/descending order at every level of the tree, so the popover's -// checkbox order stays consistent with the column header's sort, just like -// every other filter popover's option list already does. const sortedNodes = (childMap) => Array.from(childMap.values()).map((node) => ({ key: node.key, diff --git a/src/util/filter.js b/src/util/filter.js index 78d6f1f898..9b8fc30f60 100644 --- a/src/util/filter.js +++ b/src/util/filter.js @@ -34,13 +34,6 @@ export const isDateGroupFilter = (filter) => export const isOrgUnitGroupFilter = (filter) => isPrefixGroupFilter(filter, ORG_UNIT_GROUPS_GRANULARITY) -// A committed free-text search on an org-unit-flavored plain-text column -// (see FilterInput.jsx's applyCustomFilter) - the search text is resolved -// to matching raw stored values up front, at commit time, so the stored -// filter is always a plain list of raw values. That keeps matching -// consistent everywhere `filterData` is called (the data table AND every -// map layer, which filter the same `dataFilters` state independently and -// have no access to the id->name resolution used to interpret typed text). export const isOrgUnitValueFilter = (filter) => filter != null && typeof filter === 'object' && diff --git a/src/util/orgUnitGroups.js b/src/util/orgUnitGroups.js index 08d30cd6d8..3e7e2657b6 100644 --- a/src/util/orgUnitGroups.js +++ b/src/util/orgUnitGroups.js @@ -7,13 +7,6 @@ const getOrCreateNode = (childMap, { key, prefix, ouLevel }) => { return node } -// Preserves encounter order rather than re-sorting: buildOrgUnitGroupTree's -// caller (OrgUnitGroupFilterInput.jsx) always receives pathValues already -// ordered to match the column's current sort direction (see useTableData.js's -// columnOptions) - walking them in that order naturally reproduces the same -// ascending/descending order at every level of the tree, so the popover's -// checkbox order stays consistent with the column header's sort, just like -// every other filter popover's option list already does. const sortedNodes = (childMap) => Array.from(childMap.values()).map((node) => ({ key: node.key, @@ -23,15 +16,6 @@ const sortedNodes = (childMap) => children: sortedNodes(node.childMap), })) -// Builds an ancestor-path tree (Country -> Region -> District -> Facility, -// or however many levels a given path has) from a column's flat distinct -// full-path values (e.g. '/ImspTQPwCqd/O6uvpzGd5pu/lc3eMKXaEfw'). Unlike -// dateGroups.js's tree, an org unit's own id is naturally the tree's leaf - -// no separate terminal "value" node is needed, since the path's last -// segment already is the selectable unit. `name` starts null on every node; -// callers resolve it asynchronously and re-render (see -// src/hooks/useOrgUnitAncestorNames.js), falling back to the raw id label -// until then. export const buildOrgUnitGroupTree = (pathValues) => { const rootMap = new Map() diff --git a/src/util/tableHeaders.js b/src/util/tableHeaders.js index a88132c47e..de376360b4 100644 --- a/src/util/tableHeaders.js +++ b/src/util/tableHeaders.js @@ -41,14 +41,6 @@ import { isValidUid } from './uid.js' export { TYPE_NUMBER, TYPE_STRING, TYPE_DATE, TYPE_DATETIME, TYPE_TIME } -// A custom ORGANISATION_UNIT-valued field is always plain text, on both -// Event and Tracked Entity layers: the events analytics query always -// resolves it to a display name server-side (a hardcoded `_name` column -// select - no outputIdScheme param can change this), and tracker attribute -// values are a bare id with no ancestor chain to reverse-resolve safely -// (org unit names aren't guaranteed unique). Either way there's no reliable -// path/ancestor data to build a tree filter from - only "Org unit -// hierarchy" (the layer's own org unit) gets that treatment. const getCustomFieldType = (valueType, hasOptionSet) => { if (hasOptionSet) { return TYPE_STRING @@ -70,12 +62,6 @@ const getCustomFieldType = (valueType, hasOptionSet) => { const DATE_LIKE_TYPES = new Set([TYPE_DATE, TYPE_DATETIME, TYPE_TIME]) -// Keyed off valueType (not the column's TYPE_STRING type) so an -// ORGANISATION_UNIT-valued field's cell still resolves to a readable name: -// a real id->name lookup for tracker-sourced (Tracked Entity) values, and a -// harmless no-op for analytics-sourced (Event) values that are already a -// name (formatOrgUnitPathBreadcrumb falls back to the raw string when it -// finds no matching id in idToName). const getCustomFieldRenderer = (type, valueType) => { if (DATE_LIKE_TYPES.has(type)) { return RENDERER_DATE @@ -274,10 +260,8 @@ const getEventHeaders = ({ fields.push(defaultFieldsMap()[OUBOUNDARY]) } - // A handful of the analytics response's own fixed column names (e.g. - // "lastupdated", "eventstatus") happen to be 11 letters, the same shape - // isValidUid checks for - excluding whatever dataKey a fixed field above - // already claims prevents a coincidental duplicate column. + // A handful of the analytics response's own fixed column names + // (e.g. "lastupdated", "eventstatus") happen to be 11 letters const fixedDataKeys = new Set(fields.map((f) => f.dataKey)) const customFields = layerHeaders diff --git a/src/util/tableSort.js b/src/util/tableSort.js index ad8c8725bc..8c11ea26e4 100644 --- a/src/util/tableSort.js +++ b/src/util/tableSort.js @@ -65,10 +65,6 @@ export const compareRangeValues = (aVal, bVal, sortDirection) => { const isNoValue = (val) => val === undefined || val === null -// An org-unit-renderer column's raw stored value is a path/id, not the name -// actually displayed in the cell - sorting by the raw value would order rows -// by that path/id instead of what's shown. Resolve it the same way the cell -// itself does (DataTable.jsx) before comparing. const resolveSortText = (value, renderer, idToName) => { if (renderer === RENDERER_ORG_UNIT) { return formatOrgUnitPathBreadcrumb(value, idToName) From 6a6827e22973763d674e81988e4c9aa26da48225 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Mon, 27 Jul 2026 20:20:45 +0200 Subject: [PATCH 131/205] fix: resolve org unit names using display-name setting and drop duplicate lookup fetch --- src/components/datatable/FilterInput.jsx | 1 + .../datatable/OrgUnitGroupFilterInput.jsx | 19 +++--------- .../OrgUnitGroupFilterInput.spec.jsx | 31 +++---------------- .../__tests__/useOrgUnitAncestorNames.spec.js | 7 ++++- src/hooks/useOrgUnitAncestorNames.js | 6 ++-- src/util/__tests__/orgUnits.spec.js | 9 ++++++ src/util/orgUnits.js | 4 +-- src/util/requests.js | 4 +-- 8 files changed, 33 insertions(+), 48 deletions(-) diff --git a/src/components/datatable/FilterInput.jsx b/src/components/datatable/FilterInput.jsx index eb6097cdde..846f23acae 100644 --- a/src/components/datatable/FilterInput.jsx +++ b/src/components/datatable/FilterInput.jsx @@ -628,6 +628,7 @@ const FilterInput = React.memo(function FilterInput({ layerId={layerId} filterValue={filterValue} options={options ?? []} + idToName={orgUnitIdToName} /> ) } diff --git a/src/components/datatable/OrgUnitGroupFilterInput.jsx b/src/components/datatable/OrgUnitGroupFilterInput.jsx index 0641de4227..84198946f9 100644 --- a/src/components/datatable/OrgUnitGroupFilterInput.jsx +++ b/src/components/datatable/OrgUnitGroupFilterInput.jsx @@ -1,12 +1,8 @@ import i18n from '@dhis2/d2-i18n' import PropTypes from 'prop-types' -import React, { useCallback, useMemo } from 'react' +import React, { useCallback } from 'react' import { setDataFilter, clearDataFilter } from '../../actions/dataFilters.js' -import { - ORG_UNIT_GROUPS_GRANULARITY, - SENTINEL_NO_VALUE, -} from '../../constants/dataTable.js' -import useOrgUnitAncestorNames from '../../hooks/useOrgUnitAncestorNames.js' +import { ORG_UNIT_GROUPS_GRANULARITY } from '../../constants/dataTable.js' import { isOrgUnitGroupFilter } from '../../util/filter.js' import { buildOrgUnitGroupTree, @@ -45,16 +41,8 @@ const OrgUnitGroupFilterInput = ({ layerId, filterValue, options, + idToName, }) => { - const realValues = useMemo( - () => - options - .filter(({ value }) => value !== SENTINEL_NO_VALUE) - .map((o) => o.value), - [options] - ) - const { idToName } = useOrgUnitAncestorNames(realValues) - const getMatches = useCallback( (tree, normalizedSearch) => getOrgUnitSearchMatches(tree, normalizedSearch, idToName), @@ -119,6 +107,7 @@ const OrgUnitGroupFilterInput = ({ OrgUnitGroupFilterInput.propTypes = { dataKey: PropTypes.string.isRequired, + idToName: PropTypes.instanceOf(Map).isRequired, name: PropTypes.string.isRequired, options: PropTypes.arrayOf(PropTypes.shape({ value: PropTypes.string })) .isRequired, diff --git a/src/components/datatable/__tests__/OrgUnitGroupFilterInput.spec.jsx b/src/components/datatable/__tests__/OrgUnitGroupFilterInput.spec.jsx index 3b37e7cacf..813687c49a 100644 --- a/src/components/datatable/__tests__/OrgUnitGroupFilterInput.spec.jsx +++ b/src/components/datatable/__tests__/OrgUnitGroupFilterInput.spec.jsx @@ -12,14 +12,8 @@ import { SENTINEL_NO_VALUE, ORG_UNIT_GROUPS_GRANULARITY, } from '../../../constants/dataTable.js' -import useOrgUnitAncestorNames from '../../../hooks/useOrgUnitAncestorNames.js' import OrgUnitGroupFilterInput from '../OrgUnitGroupFilterInput.jsx' -jest.mock('../../../hooks/useOrgUnitAncestorNames.js', () => ({ - __esModule: true, - default: jest.fn(), -})) - const mockStore = configureMockStore() const ORG_UNIT_VALUES = [ @@ -40,6 +34,7 @@ const renderOrgUnitGroupFilter = (props) => { name="Org unit" layerId="layer1" options={ORG_UNIT_VALUES} + idToName={new Map()} {...props} /> </VirtuosoMockContext.Provider> @@ -55,13 +50,6 @@ const getInput = () => const openPopover = () => fireEvent.focus(getInput()) -beforeEach(() => { - useOrgUnitAncestorNames.mockReturnValue({ - idToName: new Map(), - loading: false, - }) -}) - describe('OrgUnitGroupFilterInput - default (collapsed) tree', () => { test('shows only root nodes by default', () => { renderOrgUnitGroupFilter() @@ -107,11 +95,9 @@ describe('OrgUnitGroupFilterInput - label resolution', () => { }) test('shows the resolved name once idToName has it', () => { - useOrgUnitAncestorNames.mockReturnValue({ + renderOrgUnitGroupFilter({ idToName: new Map([['country1', 'Sierra Leone']]), - loading: false, }) - renderOrgUnitGroupFilter() openPopover() expect(screen.getByLabelText('Sierra Leone')).toBeInTheDocument() expect(screen.queryByLabelText('country1')).not.toBeInTheDocument() @@ -251,11 +237,9 @@ describe('OrgUnitGroupFilterInput - search', () => { }) test('also narrows by resolved name, not just raw id', () => { - useOrgUnitAncestorNames.mockReturnValue({ + renderOrgUnitGroupFilter({ idToName: new Map([['country1', 'Sierra Leone']]), - loading: false, }) - renderOrgUnitGroupFilter() openPopover() fireEvent.change(getInput(), { target: { value: 'Sierra' } }) expect(screen.getByLabelText('Sierra Leone')).toBeInTheDocument() @@ -280,11 +264,9 @@ describe('OrgUnitGroupFilterInput - search', () => { }) test('committing a name-matched custom filter dispatches the matched nodes’ prefixes, not a raw substring match against the id path', () => { - useOrgUnitAncestorNames.mockReturnValue({ + const { store } = renderOrgUnitGroupFilter({ idToName: new Map([['country1', 'Sierra Leone']]), - loading: false, }) - const { store } = renderOrgUnitGroupFilter() openPopover() fireEvent.change(getInput(), { target: { value: 'Sierra' } }) expect(store.getActions()).toContainEqual({ @@ -301,11 +283,8 @@ describe('OrgUnitGroupFilterInput - search', () => { }) test('a committed name-matched search narrows the table live but does not show any checkbox as checked - same as every other column’s typed "Contains" filter', () => { - useOrgUnitAncestorNames.mockReturnValue({ - idToName: new Map([['country1', 'Sierra Leone']]), - loading: false, - }) renderOrgUnitGroupFilter({ + idToName: new Map([['country1', 'Sierra Leone']]), filterValue: { granularity: ORG_UNIT_GROUPS_GRANULARITY, prefixes: ['/country1'], diff --git a/src/hooks/__tests__/useOrgUnitAncestorNames.spec.js b/src/hooks/__tests__/useOrgUnitAncestorNames.spec.js index ca5dba33fb..04bbdc1f94 100644 --- a/src/hooks/__tests__/useOrgUnitAncestorNames.spec.js +++ b/src/hooks/__tests__/useOrgUnitAncestorNames.spec.js @@ -7,6 +7,10 @@ jest.mock('@dhis2/app-runtime', () => ({ })) const mockEngine = {} +jest.mock('../../components/cachedDataProvider/CachedDataProvider.jsx', () => ({ + useCachedData: () => ({ nameProperty: 'displayShortName' }), +})) + jest.mock('../../util/orgUnits.js', () => ({ fetchOrgUnitPathDetails: jest.fn(), })) @@ -40,7 +44,8 @@ describe('useOrgUnitAncestorNames', () => { 'facility1', 'region2', 'facility2', - ]) + ]), + 'displayShortName' ) }) diff --git a/src/hooks/useOrgUnitAncestorNames.js b/src/hooks/useOrgUnitAncestorNames.js index 32d35e3d40..87761d78b2 100644 --- a/src/hooks/useOrgUnitAncestorNames.js +++ b/src/hooks/useOrgUnitAncestorNames.js @@ -1,9 +1,11 @@ import { useDataEngine } from '@dhis2/app-runtime' import { useEffect, useMemo, useState } from 'react' +import { useCachedData } from '../components/cachedDataProvider/CachedDataProvider.jsx' import { fetchOrgUnitPathDetails } from '../util/orgUnits.js' const useOrgUnitAncestorNames = (distinctPathValues) => { const engine = useDataEngine() + const { nameProperty } = useCachedData() const ids = useMemo( () => [ ...new Set( @@ -25,7 +27,7 @@ const useOrgUnitAncestorNames = (distinctPathValues) => { } let cancelled = false setLoading(true) - fetchOrgUnitPathDetails(engine, ids).then((details) => { + fetchOrgUnitPathDetails(engine, ids, nameProperty).then((details) => { if (cancelled) { return } @@ -40,7 +42,7 @@ const useOrgUnitAncestorNames = (distinctPathValues) => { // idsKey is the stable, content-based dependency // `ids` is a new array identity every render // eslint-disable-next-line react-hooks/exhaustive-deps - }, [engine, idsKey]) + }, [engine, idsKey, nameProperty]) return { idToName, loading } } diff --git a/src/util/__tests__/orgUnits.spec.js b/src/util/__tests__/orgUnits.spec.js index 0427f2729f..28cb1fc6a4 100644 --- a/src/util/__tests__/orgUnits.spec.js +++ b/src/util/__tests__/orgUnits.spec.js @@ -137,6 +137,15 @@ describe('fetchOrgUnitDetails / fetchOrgUnitPaths error handling', () => { ou2: { name: 'Bo', level: 2 }, }) }) + + it('fetchOrgUnitPathDetails threads nameProperty through to the query fields', async () => { + const engine = { + query: jest.fn().mockResolvedValue({ orgUnits: {} }), + } + await fetchOrgUnitPathDetails(engine, ['ou1'], 'displayShortName') + const [, { variables }] = engine.query.mock.calls[0] + expect(variables.nameProperty).toBe('displayShortName') + }) }) describe('attachOrgUnitPaths', () => { diff --git a/src/util/orgUnits.js b/src/util/orgUnits.js index e512a8bad1..61235952f7 100644 --- a/src/util/orgUnits.js +++ b/src/util/orgUnits.js @@ -358,10 +358,10 @@ export const fetchOrgUnitPaths = async (engine, ids) => { return results.flatMap((r) => r.organisationUnits.organisationUnits ?? []) } -export const fetchOrgUnitPathDetails = async (engine, ids) => { +export const fetchOrgUnitPathDetails = async (engine, ids, nameProperty) => { const results = await fetchInBatches(engine, ids, { query: ORG_UNIT_PATH_DETAILS_QUERY, - buildVariables: (batch) => ({ ids: batch }), + buildVariables: (batch) => ({ ids: batch, nameProperty }), }) return results.reduce((acc, result) => { result.orgUnits.organisationUnits?.forEach((ou) => { diff --git a/src/util/requests.js b/src/util/requests.js index 0006ed0bf9..b69382777a 100644 --- a/src/util/requests.js +++ b/src/util/requests.js @@ -192,9 +192,9 @@ export const ORG_UNIT_DETAILS_QUERY = { export const ORG_UNIT_PATH_DETAILS_QUERY = { orgUnits: { resource: 'organisationUnits', - params: ({ ids }) => ({ + params: ({ ids, nameProperty }) => ({ filter: `id:in:[${ids.join(',')}]`, - fields: 'id,displayName~rename(name),level', + fields: `id,${nameProperty}~rename(name),level`, paging: false, }), }, From 0a1fd0d88c2ba044d19b3f4d033db4d456e0280b Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Mon, 27 Jul 2026 21:02:00 +0200 Subject: [PATCH 132/205] fix: populate org unit hierarchy columns for rows without coordinates --- src/loaders/__tests__/eventLoader.spec.js | 20 ++++++++++++ src/loaders/earthEngineLoader.js | 8 ++++- src/loaders/eventLoader.js | 20 ++++++++---- src/loaders/facilityLoader.js | 8 ++++- src/loaders/orgUnitLoader.js | 16 +++------ src/loaders/thematicLoader.js | 40 ++++++++++++----------- src/util/__tests__/orgUnits.spec.js | 11 +------ src/util/orgUnits.js | 17 ++-------- src/util/requests.js | 11 ------- 9 files changed, 77 insertions(+), 74 deletions(-) diff --git a/src/loaders/__tests__/eventLoader.spec.js b/src/loaders/__tests__/eventLoader.spec.js index 8ddca662bd..976864bef1 100644 --- a/src/loaders/__tests__/eventLoader.spec.js +++ b/src/loaders/__tests__/eventLoader.spec.js @@ -786,6 +786,26 @@ describe('attachOrgUnitPaths', () => { expect(engine.query).not.toHaveBeenCalled() }) + + test('also attaches org unit paths to dataWithoutCoords, so those rows get the same table columns as the main dataset', async () => { + const engine = makeEngine({ + orgUnitPathsById: { + fac1: '/country1/region1/fac1', + fac3: '/country1/region3/fac3', + }, + }) + const config = makeConfig([], [pointFeature('fac1', [5, 5])]) + config.dataWithoutCoords = [ + { properties: { ou: 'fac3' } }, + { properties: { ou: 'fac3' } }, + ] + + await attachOrgUnitPaths({ config, engine }) + + expect( + config.dataWithoutCoords.map((d) => d.properties.orgUnitPath) + ).toEqual(['/country1/region3/fac3', '/country1/region3/fac3']) + }) }) describe('shouldUseServerCluster', () => { diff --git a/src/loaders/earthEngineLoader.js b/src/loaders/earthEngineLoader.js index 19ee620108..2dd4126546 100644 --- a/src/loaders/earthEngineLoader.js +++ b/src/loaders/earthEngineLoader.js @@ -20,6 +20,8 @@ import { getRoundToPrecisionFn, formatWithSeparator } from '../util/numbers.js' import { getCoordinateField, addAssociatedGeometries, + attachOrgUnitPaths, + getMissingOrgUnitId, getOrgUnitsWithoutCoordsCount, } from '../util/orgUnits.js' import { GEOFEATURES_QUERY } from '../util/requests.js' @@ -139,7 +141,11 @@ const earthEngineLoader = async ({ } else { orgUnitsWithoutCoordsCount = result.count if (result.count > 0) { - config.dataWithoutCoords = result.missingOrgUnits + config.dataWithoutCoords = await attachOrgUnitPaths( + result.missingOrgUnits, + engine, + getMissingOrgUnitId + ) } } } diff --git a/src/loaders/eventLoader.js b/src/loaders/eventLoader.js index f2722f0ee2..2a96c1bbf7 100644 --- a/src/loaders/eventLoader.js +++ b/src/loaders/eventLoader.js @@ -48,14 +48,20 @@ const getEventOuId = (feature) => feature.properties?.ou ?? feature.properties?.['Organisation unit'] export const attachOrgUnitPaths = async ({ config, engine }) => { - if (!config.data?.length) { - return + if (config.data?.length) { + config.data = await attachOrgUnitPathsUtil( + config.data, + engine, + getEventOuId + ) + } + if (config.dataWithoutCoords?.length) { + config.dataWithoutCoords = await attachOrgUnitPathsUtil( + config.dataWithoutCoords, + engine, + getEventOuId + ) } - config.data = await attachOrgUnitPathsUtil( - config.data, - engine, - getEventOuId - ) } // Expands USER_ORGUNIT/_CHILDREN/_GRANDCHILDREN into ids; [id] if literal. diff --git a/src/loaders/facilityLoader.js b/src/loaders/facilityLoader.js index ef8e07ed77..7c1c825b2b 100644 --- a/src/loaders/facilityLoader.js +++ b/src/loaders/facilityLoader.js @@ -12,6 +12,8 @@ import { getPolygonItems, getStyledOrgUnits, getCoordinateField, + attachOrgUnitPaths, + getMissingOrgUnitId, getOrgUnitsWithoutCoordsCount, addGroupCountsToLegend, loadGroupSetData, @@ -42,7 +44,11 @@ const applyMissingCoordsCount = async ( legend.orgUnitsWithoutCoordinatesCount = result.count legend.orgUnitsPointOnly = true if (result.count > 0) { - config.dataWithoutCoords = result.missingOrgUnits + config.dataWithoutCoords = await attachOrgUnitPaths( + result.missingOrgUnits, + engine, + getMissingOrgUnitId + ) } } diff --git a/src/loaders/orgUnitLoader.js b/src/loaders/orgUnitLoader.js index 1df10a1527..5b130aa9a1 100644 --- a/src/loaders/orgUnitLoader.js +++ b/src/loaders/orgUnitLoader.js @@ -11,10 +11,11 @@ import { parseJsonConfig } from '../util/config.js' import { toGeoJson } from '../util/map.js' import { addAssociatedGeometries, + attachOrgUnitPaths, getStyledOrgUnits, getCoordinateField, + getMissingOrgUnitId, getOrgUnitsWithoutCoordsCount, - fetchOrgUnitDetails, addGroupCountsToLegend, addLevelCountsToLegend, loadGroupSetData, @@ -42,18 +43,11 @@ const applyMissingCoordsCount = async ( } legend.orgUnitsWithoutCoordinatesCount = result.count if (result.count > 0) { - const details = await fetchOrgUnitDetails( + config.dataWithoutCoords = await attachOrgUnitPaths( + result.missingOrgUnits, engine, - result.missingOrgUnits.map((o) => o.id) + getMissingOrgUnitId ) - config.dataWithoutCoords = result.missingOrgUnits.map((ou) => ({ - ...ou, - properties: { - ...ou.properties, - level: details[ou.id]?.level, - parentName: details[ou.id]?.parentName, - }, - })) } } diff --git a/src/loaders/thematicLoader.js b/src/loaders/thematicLoader.js index fbe8feac80..b7410f2124 100644 --- a/src/loaders/thematicLoader.js +++ b/src/loaders/thematicLoader.js @@ -48,8 +48,9 @@ import { import { getCoordinateField, addAssociatedGeometries, + attachOrgUnitPaths, + getMissingOrgUnitId, getOrgUnitsWithoutCoordsCount, - fetchOrgUnitDetails, } from '../util/orgUnits.js' import { LEGEND_SET_QUERY, GEOFEATURES_QUERY } from '../util/requests.js' import { formatStartEndDate, getDateArray } from '../util/time.js' @@ -200,26 +201,27 @@ const thematicLoader = async ({ if (!result.error) { orgUnitsWithoutCoordsCount = result.count if (result.count > 0) { - const details = await fetchOrgUnitDetails( + const missingOrgUnitsWithPaths = await attachOrgUnitPaths( + result.missingOrgUnits, engine, - result.missingOrgUnits.map((o) => o.id) + getMissingOrgUnitId + ) + config.dataWithoutCoords = missingOrgUnitsWithPaths.map( + (ou) => ({ + ...ou, + properties: { + ...ou.properties, + rawValue: valueById[ou.id], + value: + valueById[ou.id] === undefined + ? undefined + : formatWithSeparator( + valueById[ou.id], + keyAnalysisDigitGroupSeparator + ), + }, + }) ) - config.dataWithoutCoords = result.missingOrgUnits.map((ou) => ({ - ...ou, - properties: { - ...ou.properties, - level: details[ou.id]?.level, - parentName: details[ou.id]?.parentName, - rawValue: valueById[ou.id], - value: - valueById[ou.id] === undefined - ? undefined - : formatWithSeparator( - valueById[ou.id], - keyAnalysisDigitGroupSeparator - ), - }, - })) } } } diff --git a/src/util/__tests__/orgUnits.spec.js b/src/util/__tests__/orgUnits.spec.js index 28cb1fc6a4..e916a0c0f4 100644 --- a/src/util/__tests__/orgUnits.spec.js +++ b/src/util/__tests__/orgUnits.spec.js @@ -11,7 +11,6 @@ import { fetchAndParseGroupSet, loadGroupSetData, getUserOrgUnitIdsByKeyword, - fetchOrgUnitDetails, fetchOrgUnitPaths, fetchOrgUnitPathDetails, attachOrgUnitPaths, @@ -63,15 +62,7 @@ describe('getUserOrgUnitIdsByKeyword', () => { }) }) -describe('fetchOrgUnitDetails / fetchOrgUnitPaths error handling', () => { - it('fetchOrgUnitDetails returns an empty object when the query fails', async () => { - const engine = { - query: jest.fn().mockRejectedValue(new Error('Network error')), - } - const result = await fetchOrgUnitDetails(engine, ['ou1']) - expect(result).toEqual({}) - }) - +describe('fetchOrgUnitPaths error handling', () => { it('fetchOrgUnitPaths returns an empty array when the query fails', async () => { const engine = { query: jest.fn().mockRejectedValue(new Error('Network error')), diff --git a/src/util/orgUnits.js b/src/util/orgUnits.js index 61235952f7..7da675f959 100644 --- a/src/util/orgUnits.js +++ b/src/util/orgUnits.js @@ -27,7 +27,6 @@ import { GEOFEATURES_QUERY, ORG_UNITS_COUNT_QUERY, ORG_UNITS_PATHS_QUERY, - ORG_UNIT_DETAILS_QUERY, ORG_UNIT_PATH_DETAILS_QUERY, } from './requests.js' @@ -277,6 +276,9 @@ export const getCoordinateField = ({ orgUnitField, orgUnitFieldDisplayName }) => ? { id: orgUnitField, name: orgUnitFieldDisplayName } : null +export const getMissingOrgUnitId = (feature) => + feature.properties?.id ?? feature.id + export const getOrgUnitsWithoutCoordsCount = async ({ engine, orgUnitIds, @@ -337,19 +339,6 @@ const fetchInBatches = async (engine, ids, { query, buildVariables }) => { .map((result) => result.value) } -export const fetchOrgUnitDetails = async (engine, ids) => { - const results = await fetchInBatches(engine, ids, { - query: ORG_UNIT_DETAILS_QUERY, - buildVariables: (batch) => ({ ids: batch }), - }) - return results.reduce((acc, result) => { - result.orgUnits.organisationUnits?.forEach((ou) => { - acc[ou.id] = { level: ou.level, parentName: ou.parent?.name } - }) - return acc - }, {}) -} - export const fetchOrgUnitPaths = async (engine, ids) => { const results = await fetchInBatches(engine, ids, { query: ORG_UNITS_PATHS_QUERY, diff --git a/src/util/requests.js b/src/util/requests.js index b69382777a..19d7518636 100644 --- a/src/util/requests.js +++ b/src/util/requests.js @@ -178,17 +178,6 @@ export const ORG_UNITS_COUNT_QUERY = { }, } -export const ORG_UNIT_DETAILS_QUERY = { - orgUnits: { - resource: 'organisationUnits', - params: ({ ids }) => ({ - filter: `id:in:[${ids.join(',')}]`, - fields: 'id,level,parent[displayName~rename(name)]', - paging: false, - }), - }, -} - export const ORG_UNIT_PATH_DETAILS_QUERY = { orgUnits: { resource: 'organisationUnits', From 55868664860eb46d4a4238423ad5523b64211c48 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Mon, 27 Jul 2026 21:35:16 +0200 Subject: [PATCH 133/205] chore: add tests --- src/loaders/__tests__/facilityLoader.spec.js | 126 +++++++++++++++++++ src/loaders/__tests__/orgUnitLoader.spec.js | 125 ++++++++++++++++++ src/loaders/facilityLoader.js | 2 +- src/loaders/orgUnitLoader.js | 2 +- 4 files changed, 253 insertions(+), 2 deletions(-) create mode 100644 src/loaders/__tests__/facilityLoader.spec.js create mode 100644 src/loaders/__tests__/orgUnitLoader.spec.js diff --git a/src/loaders/__tests__/facilityLoader.spec.js b/src/loaders/__tests__/facilityLoader.spec.js new file mode 100644 index 0000000000..b701a9c7fd --- /dev/null +++ b/src/loaders/__tests__/facilityLoader.spec.js @@ -0,0 +1,126 @@ +import { + FIRST_DATA_ELEMENT_QUERY, + ORG_UNITS_COUNT_QUERY, + ORG_UNITS_PATHS_QUERY, +} from '../../util/requests.js' +import { applyMissingCoordsCount } from '../facilityLoader.js' + +const makeEngine = ({ + missingOuIds = [], + ouNamesById = {}, + orgUnitPathsById = {}, +} = {}) => ({ + query: jest.fn((query, { variables } = {}) => { + if (query === FIRST_DATA_ELEMENT_QUERY) { + return Promise.resolve({ + dataElements: { dataElements: [{ id: 'de1' }] }, + }) + } + if (query === ORG_UNITS_COUNT_QUERY) { + return Promise.resolve({ + orgUnitsCount: { + metaData: { + dimensions: { ou: missingOuIds }, + items: Object.fromEntries( + missingOuIds.map((id) => [ + id, + { name: ouNamesById[id] ?? id }, + ]) + ), + }, + }, + }) + } + if (query === ORG_UNITS_PATHS_QUERY) { + const requestedIds = variables.ids.split(',') + return Promise.resolve({ + organisationUnits: { + organisationUnits: requestedIds + .filter((id) => orgUnitPathsById[id]) + .map((id) => ({ id, path: orgUnitPathsById[id] })), + }, + }) + } + throw new Error('Unexpected query') + }), +}) + +describe('applyMissingCoordsCount', () => { + test('attaches org unit path, own name and level to facilities missing a point location', async () => { + const engine = makeEngine({ + missingOuIds: ['fac2'], + ouNamesById: { fac2: 'Tihun CHC' }, + orgUnitPathsById: { fac2: '/country1/region1/fac2' }, + }) + const config = {} + const legend = {} + + await applyMissingCoordsCount(config, { + engine, + orgUnitIds: ['fac1', 'fac2'], + userId: 'user1', + features: [{ id: 'fac1' }], + legend, + alerts: [], + }) + + expect(legend.orgUnitsWithoutCoordinatesCount).toBe(1) + expect(legend.orgUnitsPointOnly).toBe(true) + expect(config.dataWithoutCoords).toEqual([ + { + id: 'fac2', + properties: { + id: 'fac2', + name: 'Tihun CHC', + orgUnitId: 'fac2', + orgUnitPath: '/country1/region1/fac2', + orgUnitOwn: '/country1/region1/fac2', + level: 3, + }, + }, + ]) + }) + + test('does not set dataWithoutCoords when nothing is missing', async () => { + const engine = makeEngine({ missingOuIds: [] }) + const config = {} + const legend = {} + + await applyMissingCoordsCount(config, { + engine, + orgUnitIds: ['fac1'], + userId: 'user1', + features: [{ id: 'fac1' }], + legend, + alerts: [], + }) + + expect(legend.orgUnitsWithoutCoordinatesCount).toBe(0) + expect(config.dataWithoutCoords).toBeUndefined() + }) + + test('pushes an alert and leaves dataWithoutCoords unset when the count query fails', async () => { + const engine = { + query: jest.fn().mockRejectedValue(new Error('Network error')), + } + const config = {} + const legend = {} + const alerts = [] + + await applyMissingCoordsCount(config, { + engine, + orgUnitIds: ['fac1'], + userId: 'user1', + features: [], + legend, + alerts, + }) + + expect(config.dataWithoutCoords).toBeUndefined() + expect(alerts).toEqual([ + expect.objectContaining({ + message: 'Could not count org units without a point location', + }), + ]) + }) +}) diff --git a/src/loaders/__tests__/orgUnitLoader.spec.js b/src/loaders/__tests__/orgUnitLoader.spec.js new file mode 100644 index 0000000000..a2082f58be --- /dev/null +++ b/src/loaders/__tests__/orgUnitLoader.spec.js @@ -0,0 +1,125 @@ +import { + FIRST_DATA_ELEMENT_QUERY, + ORG_UNITS_COUNT_QUERY, + ORG_UNITS_PATHS_QUERY, +} from '../../util/requests.js' +import { applyMissingCoordsCount } from '../orgUnitLoader.js' + +const makeEngine = ({ + missingOuIds = [], + ouNamesById = {}, + orgUnitPathsById = {}, +} = {}) => ({ + query: jest.fn((query, { variables } = {}) => { + if (query === FIRST_DATA_ELEMENT_QUERY) { + return Promise.resolve({ + dataElements: { dataElements: [{ id: 'de1' }] }, + }) + } + if (query === ORG_UNITS_COUNT_QUERY) { + return Promise.resolve({ + orgUnitsCount: { + metaData: { + dimensions: { ou: missingOuIds }, + items: Object.fromEntries( + missingOuIds.map((id) => [ + id, + { name: ouNamesById[id] ?? id }, + ]) + ), + }, + }, + }) + } + if (query === ORG_UNITS_PATHS_QUERY) { + const requestedIds = variables.ids.split(',') + return Promise.resolve({ + organisationUnits: { + organisationUnits: requestedIds + .filter((id) => orgUnitPathsById[id]) + .map((id) => ({ id, path: orgUnitPathsById[id] })), + }, + }) + } + throw new Error('Unexpected query') + }), +}) + +describe('applyMissingCoordsCount', () => { + test('attaches org unit path, own name and level to org units missing coordinates', async () => { + const engine = makeEngine({ + missingOuIds: ['ou2'], + ouNamesById: { ou2: 'District B' }, + orgUnitPathsById: { ou2: '/country1/region1/ou2' }, + }) + const config = {} + const legend = {} + + await applyMissingCoordsCount(config, { + engine, + orgUnitIds: ['ou1', 'ou2'], + userId: 'user1', + features: [{ id: 'ou1' }], + legend, + alerts: [], + }) + + expect(legend.orgUnitsWithoutCoordinatesCount).toBe(1) + expect(config.dataWithoutCoords).toEqual([ + { + id: 'ou2', + properties: { + id: 'ou2', + name: 'District B', + orgUnitId: 'ou2', + orgUnitPath: '/country1/region1/ou2', + orgUnitOwn: '/country1/region1/ou2', + level: 3, + }, + }, + ]) + }) + + test('does not set dataWithoutCoords when nothing is missing', async () => { + const engine = makeEngine({ missingOuIds: [] }) + const config = {} + const legend = {} + + await applyMissingCoordsCount(config, { + engine, + orgUnitIds: ['ou1'], + userId: 'user1', + features: [{ id: 'ou1' }], + legend, + alerts: [], + }) + + expect(legend.orgUnitsWithoutCoordinatesCount).toBe(0) + expect(config.dataWithoutCoords).toBeUndefined() + }) + + test('pushes an alert and leaves dataWithoutCoords unset when the count query fails', async () => { + const engine = { + query: jest.fn().mockRejectedValue(new Error('Network error')), + } + const config = {} + const legend = {} + const alerts = [] + + await applyMissingCoordsCount(config, { + engine, + orgUnitIds: ['ou1'], + userId: 'user1', + features: [], + legend, + alerts, + }) + + expect(config.dataWithoutCoords).toBeUndefined() + expect(alerts).toEqual([ + expect.objectContaining({ + message: 'Could not count org units without coordinates', + }), + ]) + }) +}) diff --git a/src/loaders/facilityLoader.js b/src/loaders/facilityLoader.js index 7c1c825b2b..b8b66c49bd 100644 --- a/src/loaders/facilityLoader.js +++ b/src/loaders/facilityLoader.js @@ -21,7 +21,7 @@ import { } from '../util/orgUnits.js' import { GEOFEATURES_QUERY } from '../util/requests.js' -const applyMissingCoordsCount = async ( +export const applyMissingCoordsCount = async ( config, { engine, orgUnitIds, userId, features, legend, alerts } ) => { diff --git a/src/loaders/orgUnitLoader.js b/src/loaders/orgUnitLoader.js index 5b130aa9a1..9d32ea932c 100644 --- a/src/loaders/orgUnitLoader.js +++ b/src/loaders/orgUnitLoader.js @@ -23,7 +23,7 @@ import { } from '../util/orgUnits.js' import { GEOFEATURES_QUERY } from '../util/requests.js' -const applyMissingCoordsCount = async ( +export const applyMissingCoordsCount = async ( config, { engine, orgUnitIds, userId, features, legend, alerts } ) => { From 7996d8e1b86b8fc50b9c8306f8ca9fc2a9763e05 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 28 Jul 2026 09:59:52 +0200 Subject: [PATCH 134/205] fix: scope selection reducer's DATA_TABLE_TOGGLE clear to its own layer Previously any DATA_TABLE_TOGGLE cleared the whole selection regardless of which layer's tab was toggled. Harmless with today's single-layer data table, but would silently wipe another layer's selection once multiple tabs can be open at once. --- src/reducers/__tests__/selection.spec.js | 20 +++++++++++++++++++- src/reducers/selection.js | 4 +++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/reducers/__tests__/selection.spec.js b/src/reducers/__tests__/selection.spec.js index ddb81237ae..2a0c0bf950 100644 --- a/src/reducers/__tests__/selection.spec.js +++ b/src/reducers/__tests__/selection.spec.js @@ -112,7 +112,6 @@ describe('selection reducer', () => { types.MAP_NEW, types.MAP_SET, types.DATA_TABLE_CLOSE, - types.DATA_TABLE_TOGGLE, ])('resets to default state on %s', (type) => { const state = selection( { layerId: 'layer-1', ids: ['a', 'b'] }, @@ -122,6 +121,25 @@ describe('selection reducer', () => { expect(state).toEqual({ layerId: null, ids: [] }) }) + it("resets to default state when the selected layer's data table tab is toggled (closed)", () => { + const state = selection( + { layerId: 'layer-1', ids: ['a', 'b'] }, + { type: types.DATA_TABLE_TOGGLE, id: 'layer-1' } + ) + + expect(state).toEqual({ layerId: null, ids: [] }) + }) + + it("keeps the selection when a different layer's data table tab is toggled", () => { + const prevState = { layerId: 'layer-1', ids: ['a', 'b'] } + const state = selection(prevState, { + type: types.DATA_TABLE_TOGGLE, + id: 'layer-2', + }) + + expect(state).toBe(prevState) + }) + it('resets to default state when the selected layer is removed', () => { const state = selection( { layerId: 'layer-1', ids: ['a', 'b'] }, diff --git a/src/reducers/selection.js b/src/reducers/selection.js index 338d8a952b..6703953dd5 100644 --- a/src/reducers/selection.js +++ b/src/reducers/selection.js @@ -35,9 +35,11 @@ const selection = (state = defaultState, action) => { case types.MAP_NEW: case types.MAP_SET: case types.DATA_TABLE_CLOSE: - case types.DATA_TABLE_TOGGLE: return defaultState + case types.DATA_TABLE_TOGGLE: + return state.layerId === action.id ? defaultState : state + case types.LAYER_REMOVE: return state.layerId === action.id ? defaultState : state From c92e67471156c38dba637c0fede6989c4de47efc Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 28 Jul 2026 10:04:17 +0200 Subject: [PATCH 135/205] feat: rewrite dataTable reducer to multi-layer state {openIds, combinedView, joinConfig} state.dataTable moves from a single layer id (string|null) to an object supporting multiple open tabs plus a Combined cross-layer join config. combinedView/joinConfig are deliberately independent of openIds - toggling an individual tab never touches them, only LAYER_REMOVE prunes dangling join references and auto-clears combinedView if that removal makes the join insufficient. Also restored from a saved favourite via MAP_SET's payload (wired up fully in a later persistence commit). --- src/actions/__tests__/dataTable.spec.js | 25 ++ src/actions/dataTable.js | 9 + src/constants/actionTypes.js | 2 + src/reducers/__tests__/dataTable.spec.js | 279 +++++++++++++++++++++-- src/reducers/dataTable.js | 60 ++++- 5 files changed, 346 insertions(+), 29 deletions(-) diff --git a/src/actions/__tests__/dataTable.spec.js b/src/actions/__tests__/dataTable.spec.js index 217c86a66c..0ea5eecd91 100644 --- a/src/actions/__tests__/dataTable.spec.js +++ b/src/actions/__tests__/dataTable.spec.js @@ -4,6 +4,8 @@ import { toggleDataTable, resizeDataTable, setActiveTimelinePeriod, + toggleCombinedView, + setJoinConfig, } from '../dataTable.js' describe('closeDataTable', () => { @@ -41,3 +43,26 @@ describe('setActiveTimelinePeriod', () => { }) }) }) + +describe('toggleCombinedView', () => { + it('creates a DATA_TABLE_COMBINED_VIEW_TOGGLE action', () => { + expect(toggleCombinedView()).toEqual({ + type: types.DATA_TABLE_COMBINED_VIEW_TOGGLE, + }) + }) +}) + +describe('setJoinConfig', () => { + it('creates a DATA_TABLE_JOIN_CONFIG_SET action', () => { + const config = { + level: 'spatial', + layerIds: [], + pointLayerId: 'layer1', + polygonLayerId: 'layer2', + } + expect(setJoinConfig(config)).toEqual({ + type: types.DATA_TABLE_JOIN_CONFIG_SET, + config, + }) + }) +}) diff --git a/src/actions/dataTable.js b/src/actions/dataTable.js index 281c7e9cef..9c57ab96b2 100644 --- a/src/actions/dataTable.js +++ b/src/actions/dataTable.js @@ -43,3 +43,12 @@ export const setActiveTimelinePeriod = (period) => ({ type: types.ACTIVE_TIMELINE_PERIOD_SET, period, }) + +export const toggleCombinedView = () => ({ + type: types.DATA_TABLE_COMBINED_VIEW_TOGGLE, +}) + +export const setJoinConfig = (config) => ({ + type: types.DATA_TABLE_JOIN_CONFIG_SET, + config, +}) diff --git a/src/constants/actionTypes.js b/src/constants/actionTypes.js index cc05ef1559..4a5c5d7101 100644 --- a/src/constants/actionTypes.js +++ b/src/constants/actionTypes.js @@ -48,6 +48,8 @@ export const HIGHLIGHT_COLOR_SET = 'HIGHLIGHT_COLOR_SET' export const MAP_FEATURE_CLICKED = 'MAP_FEATURE_CLICKED' export const DATA_TABLE_COLUMN_CONFIG_SET = 'DATA_TABLE_COLUMN_CONFIG_SET' export const ACTIVE_TIMELINE_PERIOD_SET = 'ACTIVE_TIMELINE_PERIOD_SET' +export const DATA_TABLE_COMBINED_VIEW_TOGGLE = 'DATA_TABLE_COMBINED_VIEW_TOGGLE' +export const DATA_TABLE_JOIN_CONFIG_SET = 'DATA_TABLE_JOIN_CONFIG_SET' /* DATA FILTER */ export const DATA_FILTER_SET = 'DATA_FILTER_SET' diff --git a/src/reducers/__tests__/dataTable.spec.js b/src/reducers/__tests__/dataTable.spec.js index 2dcf5180b8..2fa917930c 100644 --- a/src/reducers/__tests__/dataTable.spec.js +++ b/src/reducers/__tests__/dataTable.spec.js @@ -1,52 +1,285 @@ import * as types from '../../constants/actionTypes.js' import dataTable from '../dataTable.js' +const initialState = { + openIds: [], + combinedView: false, + joinConfig: { + level: 'orgUnit', + layerIds: [], + pointLayerId: null, + polygonLayerId: null, + }, +} + describe('dataTable reducer', () => { - it('returns null by default', () => { - expect(dataTable(undefined, {})).toBe(null) + it('returns the initial state by default', () => { + expect(dataTable(undefined, {})).toEqual(initialState) }) it.each([ types.DATA_TABLE_CLOSE, types.MAP_NEW, - types.MAP_SET, types.DOWNLOAD_MODE_CLOSE, types.DOWNLOAD_MODE_OPEN, - ])('clears the open data table on %s', (type) => { - expect(dataTable('layer1', { type })).toBe(null) + ])('resets to the initial state on %s', (type) => { + const state = { + openIds: ['layer1', 'layer2'], + combinedView: true, + joinConfig: { + level: 'spatial', + layerIds: [], + pointLayerId: 'layer1', + polygonLayerId: 'layer2', + }, + } + + expect(dataTable(state, { type })).toEqual(initialState) + }) + + describe('MAP_SET', () => { + it('restores dataTable state from the payload when present', () => { + const restored = { + openIds: ['layer1'], + combinedView: false, + joinConfig: { + level: 'parentOrgUnit', + layerIds: ['layer1', 'layer3'], + pointLayerId: null, + polygonLayerId: null, + }, + } + + expect( + dataTable(initialState, { + type: types.MAP_SET, + payload: { dataTable: restored }, + }) + ).toEqual(restored) + }) + + it('falls back to the initial state when the payload has no dataTable', () => { + const state = { + openIds: ['layer1'], + combinedView: false, + joinConfig: { + level: 'orgUnit', + layerIds: ['layer1', 'layer2'], + pointLayerId: null, + polygonLayerId: null, + }, + } + + expect( + dataTable(state, { + type: types.MAP_SET, + payload: {}, + }) + ).toEqual(initialState) + }) }) - it('closes the data table when toggling the currently open layer', () => { - expect( - dataTable('layer1', { + describe('DATA_TABLE_TOGGLE', () => { + it('opens a layer tab that was not open', () => { + const state = dataTable(initialState, { type: types.DATA_TABLE_TOGGLE, id: 'layer1', }) - ).toBe(null) - }) - it('opens the data table when toggling a different layer', () => { - expect( - dataTable('layer1', { + expect(state.openIds).toEqual(['layer1']) + }) + + it('appends to openIds without closing other tabs', () => { + const state = dataTable( + { ...initialState, openIds: ['layer1'] }, + { type: types.DATA_TABLE_TOGGLE, id: 'layer2' } + ) + + expect(state.openIds).toEqual(['layer1', 'layer2']) + }) + + it('closes an already-open tab', () => { + const state = dataTable( + { ...initialState, openIds: ['layer1', 'layer2'] }, + { type: types.DATA_TABLE_TOGGLE, id: 'layer1' } + ) + + expect(state.openIds).toEqual(['layer2']) + }) + + it('leaves combinedView and joinConfig untouched', () => { + const prevState = { + openIds: ['layer1'], + combinedView: true, + joinConfig: { + level: 'spatial', + layerIds: [], + pointLayerId: 'layerA', + polygonLayerId: 'layerB', + }, + } + + const state = dataTable(prevState, { type: types.DATA_TABLE_TOGGLE, id: 'layer2', }) - ).toBe('layer2') + + expect(state.combinedView).toBe(true) + expect(state.joinConfig).toBe(prevState.joinConfig) + }) }) - it('clears the open data table when its layer is removed', () => { - expect( - dataTable('layer1', { type: types.LAYER_REMOVE, id: 'layer1' }) - ).toBe(null) + describe('LAYER_REMOVE', () => { + it('removes the layer from openIds', () => { + const state = dataTable( + { ...initialState, openIds: ['layer1', 'layer2'] }, + { type: types.LAYER_REMOVE, id: 'layer1' } + ) + + expect(state.openIds).toEqual(['layer2']) + }) + + it('clears the removed layer from joinConfig.layerIds', () => { + const prevState = { + ...initialState, + joinConfig: { + level: 'orgUnit', + layerIds: ['layer1', 'layer2', 'layer3'], + pointLayerId: null, + polygonLayerId: null, + }, + } + + const state = dataTable(prevState, { + type: types.LAYER_REMOVE, + id: 'layer2', + }) + + expect(state.joinConfig.layerIds).toEqual(['layer1', 'layer3']) + }) + + it('clears a dangling pointLayerId/polygonLayerId reference', () => { + const prevState = { + ...initialState, + joinConfig: { + level: 'spatial', + layerIds: [], + pointLayerId: 'layer1', + polygonLayerId: 'layer2', + }, + } + + const state = dataTable(prevState, { + type: types.LAYER_REMOVE, + id: 'layer1', + }) + + expect(state.joinConfig.pointLayerId).toBe(null) + expect(state.joinConfig.polygonLayerId).toBe('layer2') + }) + + it('turns combinedView off when the removal makes an orgUnit/parentOrgUnit join insufficient', () => { + const prevState = { + openIds: [], + combinedView: true, + joinConfig: { + level: 'orgUnit', + layerIds: ['layer1', 'layer2'], + pointLayerId: null, + polygonLayerId: null, + }, + } + + const state = dataTable(prevState, { + type: types.LAYER_REMOVE, + id: 'layer1', + }) + + expect(state.combinedView).toBe(false) + }) + + it('turns combinedView off when the removal makes a spatial join insufficient', () => { + const prevState = { + openIds: [], + combinedView: true, + joinConfig: { + level: 'spatial', + layerIds: [], + pointLayerId: 'layer1', + polygonLayerId: 'layer2', + }, + } + + const state = dataTable(prevState, { + type: types.LAYER_REMOVE, + id: 'layer2', + }) + + expect(state.combinedView).toBe(false) + }) + + it('keeps combinedView on when the removal leaves the join sufficient', () => { + const prevState = { + openIds: [], + combinedView: true, + joinConfig: { + level: 'orgUnit', + layerIds: ['layer1', 'layer2', 'layer3'], + pointLayerId: null, + polygonLayerId: null, + }, + } + + const state = dataTable(prevState, { + type: types.LAYER_REMOVE, + id: 'layer1', + }) + + expect(state.combinedView).toBe(true) + expect(state.joinConfig.layerIds).toEqual(['layer2', 'layer3']) + }) + }) + + describe('DATA_TABLE_COMBINED_VIEW_TOGGLE', () => { + it('turns combinedView on', () => { + const state = dataTable(initialState, { + type: types.DATA_TABLE_COMBINED_VIEW_TOGGLE, + }) + + expect(state.combinedView).toBe(true) + }) + + it('turns combinedView off', () => { + const state = dataTable( + { ...initialState, combinedView: true }, + { type: types.DATA_TABLE_COMBINED_VIEW_TOGGLE } + ) + + expect(state.combinedView).toBe(false) + }) }) - it('keeps the open data table when a different layer is removed', () => { - expect( - dataTable('layer1', { type: types.LAYER_REMOVE, id: 'layer2' }) - ).toBe('layer1') + describe('DATA_TABLE_JOIN_CONFIG_SET', () => { + it('replaces joinConfig wholesale', () => { + const config = { + level: 'spatial', + layerIds: [], + pointLayerId: 'layer1', + polygonLayerId: 'layer2', + } + + const state = dataTable(initialState, { + type: types.DATA_TABLE_JOIN_CONFIG_SET, + config, + }) + + expect(state.joinConfig).toEqual(config) + }) }) it('returns the current state for unknown actions', () => { - expect(dataTable('layer1', { type: 'UNKNOWN' })).toBe('layer1') + const state = { ...initialState, openIds: ['layer1'] } + + expect(dataTable(state, { type: 'UNKNOWN' })).toBe(state) }) }) diff --git a/src/reducers/dataTable.js b/src/reducers/dataTable.js index bfd2bf6a0c..dbeeeedbba 100644 --- a/src/reducers/dataTable.js +++ b/src/reducers/dataTable.js @@ -1,19 +1,67 @@ import * as types from '../constants/actionTypes.js' -const dataTable = (state = null, action) => { +const initialState = { + openIds: [], + combinedView: false, + joinConfig: { + level: 'orgUnit', + layerIds: [], + pointLayerId: null, + polygonLayerId: null, + }, +} + +const isJoinConfigSufficient = (joinConfig) => + joinConfig.level === 'spatial' + ? !!joinConfig.pointLayerId && !!joinConfig.polygonLayerId + : joinConfig.layerIds.length >= 2 + +const clearJoinConfigRefs = (joinConfig, removedId) => ({ + ...joinConfig, + layerIds: joinConfig.layerIds.filter((id) => id !== removedId), + pointLayerId: + joinConfig.pointLayerId === removedId ? null : joinConfig.pointLayerId, + polygonLayerId: + joinConfig.polygonLayerId === removedId + ? null + : joinConfig.polygonLayerId, +}) + +const dataTable = (state = initialState, action) => { switch (action.type) { case types.DATA_TABLE_CLOSE: case types.MAP_NEW: - case types.MAP_SET: case types.DOWNLOAD_MODE_CLOSE: case types.DOWNLOAD_MODE_OPEN: - return null + return initialState + + case types.MAP_SET: + return action.payload.dataTable ?? initialState case types.DATA_TABLE_TOGGLE: - return state === action.id ? null : action.id + return { + ...state, + openIds: state.openIds.includes(action.id) + ? state.openIds.filter((id) => id !== action.id) + : [...state.openIds, action.id], + } + + case types.LAYER_REMOVE: { + const joinConfig = clearJoinConfigRefs(state.joinConfig, action.id) + return { + ...state, + openIds: state.openIds.filter((id) => id !== action.id), + joinConfig, + combinedView: + state.combinedView && isJoinConfigSufficient(joinConfig), + } + } + + case types.DATA_TABLE_COMBINED_VIEW_TOGGLE: + return { ...state, combinedView: !state.combinedView } - case types.LAYER_REMOVE: - return state === action.id ? null : state + case types.DATA_TABLE_JOIN_CONFIG_SET: + return { ...state, joinConfig: action.config } default: return state From fc6c16a057af9f32ddffbc8eca2092321625a9fd Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 28 Jul 2026 10:12:37 +0200 Subject: [PATCH 136/205] feat: update all state.dataTable consumers for the new object shape DataTable/FilterInput now receive the active layer id as a prop from BottomPanel instead of each independently reading state.dataTable, since the panel is the natural single source of truth for "which tab is active". LayerToolbarMoreMenu and useLayersLoader switch from a scalar layer-id comparison to openIds.includes(id) - the useLayersLoader change also fixes a latent imprecision where a brand-new event layer's loadExtended flag was keyed off "is any table open" rather than "is *this* layer's table open". --- src/components/app/App.jsx | 4 +- src/components/datatable/DataTable.jsx | 5 +- src/components/datatable/FilterInput.jsx | 16 ++----- .../datatable/__tests__/FilterInput.spec.jsx | 2 +- .../overlays/__tests__/OverlayCard.spec.jsx | 7 ++- .../layers/toolbar/LayerToolbarMoreMenu.jsx | 13 +++-- .../__tests__/LayerToolbarMoreMenu.spec.jsx | 47 +++++++++++++++++-- src/components/map/MapPosition.jsx | 4 +- src/hooks/__tests__/useLayersLoader.spec.js | 25 +++++++--- src/hooks/useLayersLoader.js | 8 ++-- 10 files changed, 95 insertions(+), 36 deletions(-) diff --git a/src/components/app/App.jsx b/src/components/app/App.jsx index 91a8f4ab13..0e4465dd54 100644 --- a/src/components/app/App.jsx +++ b/src/components/app/App.jsx @@ -35,7 +35,9 @@ const App = () => { const [interpretationsRenderCount, setInterpretationsRenderCount] = useState(1) - const dataTableOpen = useSelector((state) => !!state.dataTable) + const dataTableOpen = useSelector( + (state) => state.dataTable.openIds.length > 0 + ) const downloadModeOpen = useSelector((state) => !!state.ui.downloadMode) const detailsPanelOpen = useSelector( (state) => state.ui.rightPanelOpen && !state.orgUnitProfile diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index fe9cfd2133..6351f6a64f 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -81,6 +81,7 @@ const TABLE_STYLE = { height: '100%', width: '100%' } const VIEWPORT_OVERSCAN = { top: 400, bottom: 400 } const Table = ({ + activeLayerId, availableWidth, onCountChange, onHeadersChange, @@ -93,7 +94,6 @@ const Table = ({ const virtuosoRef = useRef(null) const { mapViews } = useSelector((state) => state.map) - const activeLayerId = useSelector((state) => state.dataTable) const dispatch = useDispatch() const feature = useSelector((state) => state.feature) @@ -481,6 +481,7 @@ const Table = ({ filter={ isFilterable(dataKey, type) && ( <FilterInput + layerId={activeLayerId} type={type} dataKey={dataKey} name={name} @@ -533,6 +534,7 @@ const Table = ({ </DataTableRow> ), [ + activeLayerId, isCheckboxColumnPinned, selectionFilter, dispatch, @@ -749,6 +751,7 @@ const Table = ({ } Table.propTypes = { + activeLayerId: PropTypes.string, availableWidth: PropTypes.number, globalSearch: PropTypes.string, onClearFilters: PropTypes.func, diff --git a/src/components/datatable/FilterInput.jsx b/src/components/datatable/FilterInput.jsx index 846f23acae..0475f0abb4 100644 --- a/src/components/datatable/FilterInput.jsx +++ b/src/components/datatable/FilterInput.jsx @@ -581,6 +581,7 @@ OptionSetSearchableFilter.propTypes = { } const FilterInput = React.memo(function FilterInput({ + layerId, type, dataKey, name, @@ -589,20 +590,12 @@ const FilterInput = React.memo(function FilterInput({ renderer, orgUnitIdToName, }) { - const dataTable = useSelector((state) => state.dataTable) const map = useSelector((state) => state.map) - const overlay = - dataTable && map.mapViews.find((layer) => layer.id === dataTable) - - let layerId - let filters - if (overlay) { - layerId = overlay.id - filters = overlay.dataFilters || {} - } + const overlay = map.mapViews.find((layer) => layer.id === layerId) + const filters = overlay?.dataFilters || {} - const filterValue = filters?.[dataKey] + const filterValue = filters[dataKey] const isDateType = type === TYPE_DATE || type === TYPE_DATETIME || type === TYPE_TIME @@ -666,6 +659,7 @@ FilterInput.propTypes = { dataKey: PropTypes.string.isRequired, name: PropTypes.string.isRequired, type: PropTypes.string.isRequired, + layerId: PropTypes.string, optionSetId: PropTypes.string, options: PropTypes.arrayOf(PropTypes.shape({ value: PropTypes.string })), orgUnitIdToName: PropTypes.instanceOf(Map), diff --git a/src/components/datatable/__tests__/FilterInput.spec.jsx b/src/components/datatable/__tests__/FilterInput.spec.jsx index 19fe751014..1e027e29bd 100644 --- a/src/components/datatable/__tests__/FilterInput.spec.jsx +++ b/src/components/datatable/__tests__/FilterInput.spec.jsx @@ -30,7 +30,6 @@ const mockStore = configureMockStore() const renderFilterInput = (props, dataFilters) => { const store = mockStore({ - dataTable: 'layer1', map: { mapViews: [{ id: 'layer1', dataFilters: dataFilters || {} }], }, @@ -42,6 +41,7 @@ const renderFilterInput = (props, dataFilters) => { value={{ viewportHeight: 300, itemHeight: 28 }} > <FilterInput + layerId="layer1" dataKey="name" name="Name" type="string" diff --git a/src/components/layers/overlays/__tests__/OverlayCard.spec.jsx b/src/components/layers/overlays/__tests__/OverlayCard.spec.jsx index 4da6e6ae81..ce83fef842 100644 --- a/src/components/layers/overlays/__tests__/OverlayCard.spec.jsx +++ b/src/components/layers/overlays/__tests__/OverlayCard.spec.jsx @@ -30,7 +30,12 @@ const mockStore = configureMockStore() describe('OverlayCard', () => { const renderCard = (name) => render( - <Provider store={mockStore({ dataTable: null, aggregations: {} })}> + <Provider + store={mockStore({ + dataTable: { openIds: [] }, + aggregations: {}, + })} + > <OverlayCard layer={{ id: 'layer1', diff --git a/src/components/layers/toolbar/LayerToolbarMoreMenu.jsx b/src/components/layers/toolbar/LayerToolbarMoreMenu.jsx index 4092711c2c..b774fed51f 100644 --- a/src/components/layers/toolbar/LayerToolbarMoreMenu.jsx +++ b/src/components/layers/toolbar/LayerToolbarMoreMenu.jsx @@ -27,7 +27,7 @@ const LayerToolbarMoreMenu = ({ toggleDataTable, openAs, downloadData, - dataTableOpen, + openIds, hasOrgUnitData, isLoading, hasError, @@ -43,8 +43,7 @@ const LayerToolbarMoreMenu = ({ return null } - const showDataTableDisabled = - !hasOrgUnitData && (!dataTableOpen || dataTableOpen !== layer.id) + const showDataTableDisabled = !hasOrgUnitData && !openIds.includes(layer.id) return ( <> @@ -71,7 +70,7 @@ const LayerToolbarMoreMenu = ({ {toggleDataTable && ( <MenuItem label={ - dataTableOpen === layer.id + openIds.includes(layer.id) ? i18n.t('Hide data table') : i18n.t('Show data table') } @@ -149,13 +148,13 @@ const LayerToolbarMoreMenu = ({ } LayerToolbarMoreMenu.propTypes = { - dataTableOpen: PropTypes.string, downloadData: PropTypes.func, hasError: PropTypes.bool, hasOrgUnitData: PropTypes.bool, isLoading: PropTypes.bool, layer: PropTypes.object, openAs: PropTypes.func, + openIds: PropTypes.arrayOf(PropTypes.string), toggleDataTable: PropTypes.func, onDuplicate: PropTypes.func, onEdit: PropTypes.func, @@ -166,7 +165,7 @@ const DEFAULT_EMPTY_LAYER = {} export default connect( ( - { dataTable: dataTableOpen, aggregations }, + { dataTable: { openIds }, aggregations }, { layer = DEFAULT_EMPTY_LAYER } ) => { const isEarthEngine = layer.layer === EARTH_ENGINE_LAYER @@ -179,6 +178,6 @@ export default connect( const isLoading = isEarthEngine && hasOrgUnitData && !aggregations[layer.id] - return { dataTableOpen, hasOrgUnitData, isLoading } + return { openIds, hasOrgUnitData, isLoading } } )(LayerToolbarMoreMenu) diff --git a/src/components/layers/toolbar/__tests__/LayerToolbarMoreMenu.spec.jsx b/src/components/layers/toolbar/__tests__/LayerToolbarMoreMenu.spec.jsx index f524fa7bce..cdcca16158 100644 --- a/src/components/layers/toolbar/__tests__/LayerToolbarMoreMenu.spec.jsx +++ b/src/components/layers/toolbar/__tests__/LayerToolbarMoreMenu.spec.jsx @@ -8,7 +8,7 @@ const mockStore = configureMockStore() describe('LayerToolbarMoreMenu', () => { test('does not render if no props passed', () => { - const store = {} + const store = { dataTable: { openIds: [] } } const { container } = render( <Provider store={mockStore(store)}> @@ -20,7 +20,7 @@ describe('LayerToolbarMoreMenu', () => { test('renders menu with Remove layer only', async () => { const store = { - dataTable: null, + dataTable: { openIds: [] }, aggregations: {}, } @@ -51,7 +51,7 @@ describe('LayerToolbarMoreMenu', () => { test('renders menu with Remove layer and Edit layer options', async () => { const store = { - dataTable: null, + dataTable: { openIds: [] }, aggregations: {}, } @@ -86,6 +86,7 @@ describe('LayerToolbarMoreMenu', () => { test('renders two MenuItems with no divider if only passed toggleDataTable and downloadData', async () => { const store = { + dataTable: { openIds: [] }, aggregations: {}, } @@ -120,6 +121,7 @@ describe('LayerToolbarMoreMenu', () => { test('renders only toggleDataTable menu', async () => { const store = { + dataTable: { openIds: [] }, aggregations: {}, } @@ -150,8 +152,42 @@ describe('LayerToolbarMoreMenu', () => { }) }) + test('shows "Hide data table" and keeps it enabled when this layer\'s tab is already open', async () => { + const store = { + dataTable: { openIds: ['someOtherLayer', 'rainbowdash'] }, + aggregations: {}, + } + + const layer = { + id: 'rainbowdash', + data: 'hasdata', + } + + render( + <Provider store={mockStore(store)}> + <LayerToolbarMoreMenu + layer={layer} + toggleDataTable={jest.fn()} + /> + </Provider> + ) + + fireEvent.click(screen.getByLabelText('Toggle layer menu')) + + await waitFor(() => { + expect(screen.queryByText('Hide data table')).toBeTruthy() + expect( + screen + .queryByText('Hide data table') + .closest('li') + .classList.contains('disabled') + ).toBe(false) + }) + }) + test('enables Show data table for a server-clustered event layer with no data yet', async () => { const store = { + dataTable: { openIds: [] }, aggregations: {}, } @@ -185,6 +221,7 @@ describe('LayerToolbarMoreMenu', () => { test('also enables Download data for a server-clustered event layer with no data yet', async () => { const store = { + dataTable: { openIds: [] }, aggregations: {}, } @@ -219,6 +256,7 @@ describe('LayerToolbarMoreMenu', () => { test('renders three MenuItems WITH divider if passed toggleDataTable, onEdit, and onRemove', async () => { const store = { + dataTable: { openIds: [] }, aggregations: {}, } @@ -255,6 +293,7 @@ describe('LayerToolbarMoreMenu', () => { test('renders four MenuItems WITH divider if passed toggleDataTable, downloadData, onEdit, and onRemove', async () => { const store = { + dataTable: { openIds: [] }, aggregations: {}, } @@ -291,6 +330,7 @@ describe('LayerToolbarMoreMenu', () => { test('renders Duplicate layer item between Edit layer and Remove layer', async () => { const store = { + dataTable: { openIds: [] }, aggregations: {}, } @@ -328,6 +368,7 @@ describe('LayerToolbarMoreMenu', () => { test('renders disabled menu items if there was an error', async () => { const store = { + dataTable: { openIds: [] }, aggregations: {}, } diff --git a/src/components/map/MapPosition.jsx b/src/components/map/MapPosition.jsx index 0fba1fd211..f938a51d46 100644 --- a/src/components/map/MapPosition.jsx +++ b/src/components/map/MapPosition.jsx @@ -26,7 +26,9 @@ const MapPosition = () => { const { id: mapId, mapViews: layers } = useSelector((state) => state.map) const { downloadMode, layersPanelOpen, rightPanelOpen, dataTableHeight } = useSelector((state) => state.ui) - const dataTableOpen = useSelector((state) => !!state.dataTable) + const dataTableOpen = useSelector( + (state) => state.dataTable.openIds.length > 0 + ) const downloadMapInfoOpen = downloadMode && diff --git a/src/hooks/__tests__/useLayersLoader.spec.js b/src/hooks/__tests__/useLayersLoader.spec.js index 19fec7e93b..d8de99c618 100644 --- a/src/hooks/__tests__/useLayersLoader.spec.js +++ b/src/hooks/__tests__/useLayersLoader.spec.js @@ -79,7 +79,7 @@ describe('useLayersLoader - data table reload trigger', () => { { ...baseLayer, serverCluster: true, isExtended: false }, ], }, - dataTable: 'a', + dataTable: { openIds: ['a'] }, }) expect(store.getActions()).toEqual([]) @@ -97,7 +97,7 @@ describe('useLayersLoader - data table reload trigger', () => { }, ], }, - dataTable: 'a', + dataTable: { openIds: ['a'] }, }) expect(store.getActions()).toEqual([ @@ -117,7 +117,7 @@ describe('useLayersLoader - data table reload trigger', () => { }, ], }, - dataTable: 'a', + dataTable: { openIds: ['a'] }, }) expect(store.getActions()).toEqual([]) @@ -130,13 +130,26 @@ describe('useLayersLoader - data table reload trigger', () => { { ...baseLayer, serverCluster: false, isExtended: false }, ], }, - dataTable: 'a', + dataTable: { openIds: ['a'] }, }) expect(store.getActions()).toEqual([ { type: 'LAYER_LOADING_SET', id: 'a' }, ]) }) + + test('does not reload a layer whose own tab is closed, even when another layer tab is open', () => { + const { store } = renderWithStore({ + map: { + mapViews: [ + { ...baseLayer, serverCluster: false, isExtended: false }, + ], + }, + dataTable: { openIds: ['someOtherLayer'] }, + }) + + expect(store.getActions()).toEqual([]) + }) }) describe('useLayersLoader - spatialSupport plumbing', () => { @@ -147,7 +160,7 @@ describe('useLayersLoader - spatialSupport plumbing', () => { map: { mapViews: [{ ...baseLayer, isLoaded: false }], }, - dataTable: null, + dataTable: { openIds: [] }, }) expect(eventLoader).toHaveBeenCalledWith( @@ -162,7 +175,7 @@ describe('useLayersLoader - spatialSupport plumbing', () => { map: { mapViews: [{ ...baseLayer, isLoaded: false }], }, - dataTable: null, + dataTable: { openIds: [] }, }) expect(eventLoader).toHaveBeenCalledWith( diff --git a/src/hooks/useLayersLoader.js b/src/hooks/useLayersLoader.js index 66b1083b56..ee16701a85 100644 --- a/src/hooks/useLayersLoader.js +++ b/src/hooks/useLayersLoader.js @@ -38,7 +38,7 @@ export const useLayersLoader = () => { } = useCachedData() const { showAlerts } = useLoaderAlerts() const allLayers = useSelector((state) => state.map.mapViews) - const dataTable = useSelector((state) => state.dataTable) + const openIds = useSelector((state) => state.dataTable.openIds) const dispatch = useDispatch() const { show: showLoaderAlert } = useAlert( ({ layer }) => `Could not load layer ${layer}`, @@ -65,7 +65,7 @@ export const useLayersLoader = () => { analyticsEngine, // Thematic and Event loader periodTypeData, // Thematic and Event loader serverVersion, // Tracked entity loader - loadExtended: !!dataTable, // Event loader + loadExtended: openIds.includes(config.id), // Event loader spatialSupport, // Event loader }) if (result.alerts) { @@ -87,7 +87,7 @@ export const useLayersLoader = () => { // event extended data hasn't been loaded yet - so load it if ( layer.layer === EVENT_LAYER && - layer.id === dataTable && + openIds.includes(layer.id) && !layer.isExtended && (!layer.serverCluster || layer.forceClientCluster) ) { @@ -128,7 +128,7 @@ export const useLayersLoader = () => { showAlerts, showLoaderAlert, baseUrl, - dataTable, + openIds, serverVersion, spatialSupport, ]) From 7f50f0636b1f9cfb7bba9410c2c32e9218c1271f Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 28 Jul 2026 10:21:16 +0200 Subject: [PATCH 137/205] feat: add tab bar to BottomPanel for multiple open data table layers Renders one Tab per open layer plus a "Combined" tab whenever the map has 2+ data-table-capable layers with loaded data - the Combined tab isn't gated on how many tabs happen to be open, only on how many layers exist to combine (wired up fully in a later commit). Per-layer-only toolbar controls (highlight color, column picker, clear filters, search, show-in-view) hide while Combined is active, since they don't apply to a cross-layer view. Each tab's close icon can't be a real <button> - Tab's own root element is already a <button>, and HTML forbids nesting interactive elements - so it's a role="button" span with its own click/keydown handling instead. --- src/components/datatable/BottomPanel.jsx | 135 ++++++++++++++---- .../datatable/__tests__/BottomPanel.spec.jsx | 125 +++++++++++++++- .../datatable/styles/BottomPanel.module.css | 26 ++++ 3 files changed, 254 insertions(+), 32 deletions(-) diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index 9f0b78ab93..4780f40f71 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -1,3 +1,5 @@ +import i18n from '@dhis2/d2-i18n' +import { TabBar, Tab, IconCross16 } from '@dhis2/ui' import React, { useRef, useCallback, @@ -13,7 +15,10 @@ import { toggleShowOnlyFeaturesInView, setSelectionFilter, setHighlightColor, + toggleDataTable, + toggleCombinedView, } from '../../actions/dataTable.js' +import { DATA_TABLE_LAYER_TYPES } from '../../constants/layers.js' import useKeyDown from '../../hooks/useKeyDown.js' import { getPanelHeights, @@ -40,10 +45,26 @@ const EMPTY_FILTERS = {} const BottomPanel = () => { const dataTableHeight = useSelector((state) => state.ui.dataTableHeight) - const activeLayerId = useSelector((state) => state.dataTable) - const activeLayer = useSelector((state) => - state.map.mapViews.find((l) => l.id === activeLayerId) + const { openIds, combinedView } = useSelector((state) => state.dataTable) + const mapViews = useSelector((state) => state.map.mapViews) + const [activeLayerId, setActiveLayerId] = useState(null) + + useEffect(() => { + if (openIds.length === 0) { + setActiveLayerId(null) + } else if (!openIds.includes(activeLayerId)) { + setActiveLayerId(openIds[openIds.length - 1]) + } + }, [openIds, activeLayerId]) + + const openLayers = mapViews.filter((l) => openIds.includes(l.id)) + const eligibleLayers = mapViews.filter( + (l) => DATA_TABLE_LAYER_TYPES.includes(l.layer) && l.data?.length ) + const showCombinedTab = eligibleLayers.length >= 2 + const showTabBar = openIds.length > 1 || showCombinedTab + + const activeLayer = openLayers.find((l) => l.id === activeLayerId) const dataFilters = activeLayer?.dataFilters ?? EMPTY_FILTERS const showOnlyFeaturesInView = useSelector( (state) => state.ui.showOnlyFeaturesInView @@ -217,16 +238,20 @@ const BottomPanel = () => { <span className={styles.divider} /> <ActiveLayerControl name={activeLayer?.name} /> <span className={styles.divider} /> - <HighlightColorControl - color={highlightColor} - onChange={onHighlightColorChange} - /> - <ColumnPickerControl - layerId={activeLayerId} - allHeaders={allHeaders} - columnConfig={activeLayer?.dataTableColumnConfig} - /> - <span className={styles.divider} /> + {!combinedView && ( + <> + <HighlightColorControl + color={highlightColor} + onChange={onHighlightColorChange} + /> + <ColumnPickerControl + layerId={activeLayerId} + allHeaders={allHeaders} + columnConfig={activeLayer?.dataTableColumnConfig} + /> + <span className={styles.divider} /> + </> + )} <ResizeHandleControl maxHeight={maxHeight} minHeight={MIN_HEIGHT} @@ -240,24 +265,82 @@ const BottomPanel = () => { filteredCount={filteredCount} /> <span className={styles.divider} /> - <ClearFiltersControl - disabled={!hasActiveFilters} - onClick={onClearFilters} - /> - <GlobalSearchControl - value={globalSearch} - onChange={setGlobalSearch} - /> - <ShowInViewControl - active={showOnlyFeaturesInView} - onClick={onToggleShowOnlyFeaturesInView} - /> - <span className={styles.divider} /> + {!combinedView && ( + <> + <ClearFiltersControl + disabled={!hasActiveFilters} + onClick={onClearFilters} + /> + <GlobalSearchControl + value={globalSearch} + onChange={setGlobalSearch} + /> + <ShowInViewControl + active={showOnlyFeaturesInView} + onClick={onToggleShowOnlyFeaturesInView} + /> + <span className={styles.divider} /> + </> + )} <CloseControl onClick={onCloseDataTable} /> </div> + {showTabBar && ( + <TabBar scrollable className={styles.tabBar}> + {openLayers.map((lyr) => ( + <Tab + key={lyr.id} + selected={!combinedView && lyr.id === activeLayerId} + onClick={() => { + setActiveLayerId(lyr.id) + if (combinedView) { + dispatch(toggleCombinedView()) + } + }} + > + <span className={styles.tabLabel}>{lyr.name}</span> + {/* A real <button> can't nest here - Tab's own + root element is already a <button>. */} + <span + role="button" + tabIndex={0} + className={styles.tabCloseButton} + aria-label={i18n.t('Close {{name}} tab', { + name: lyr.name, + })} + onClick={(e) => { + e.stopPropagation() + dispatch(toggleDataTable(lyr.id)) + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.stopPropagation() + e.preventDefault() + dispatch(toggleDataTable(lyr.id)) + } + }} + > + <IconCross16 /> + </span> + </Tab> + ))} + {showCombinedTab && ( + <Tab + selected={combinedView} + onClick={() => { + if (!combinedView) { + dispatch(toggleCombinedView()) + } + }} + > + {i18n.t('Combined')} + </Tab> + )} + </TabBar> + )} <div className={styles.tableContainer}> <ErrorBoundary> <DataTable + activeLayerId={activeLayerId} availableWidth={panelWidth} onCountChange={onCountChange} onHeadersChange={onHeadersChange} diff --git a/src/components/datatable/__tests__/BottomPanel.spec.jsx b/src/components/datatable/__tests__/BottomPanel.spec.jsx index 314397bfae..44b7cf2fd5 100644 --- a/src/components/datatable/__tests__/BottomPanel.spec.jsx +++ b/src/components/datatable/__tests__/BottomPanel.spec.jsx @@ -1,12 +1,16 @@ -import { render, fireEvent } from '@testing-library/react' +import { render, fireEvent, screen } from '@testing-library/react' import React from 'react' import { Provider } from 'react-redux' import configureMockStore from 'redux-mock-store' +import { THEMATIC_LAYER } from '../../../constants/layers.js' import WindowDimensionsProvider from '../../WindowDimensionsProvider.jsx' import BottomPanel from '../BottomPanel.jsx' jest.mock('../DataTable.jsx', () => { - const DataTableMock = () => <div data-testid="datatable-mock" /> + // eslint-disable-next-line react/prop-types + const DataTableMock = ({ activeLayerId }) => ( + <div data-test="datatable-mock">{activeLayerId}</div> + ) DataTableMock.displayName = 'DataTableMock' return DataTableMock }) @@ -26,7 +30,23 @@ beforeAll(() => { const DATA_TABLE_HEIGHT = 300 -const renderBottomPanel = () => { +const DEFAULT_DATA_TABLE_STATE = { + openIds: ['layer1'], + combinedView: false, + joinConfig: { + level: 'orgUnit', + layerIds: [], + pointLayerId: null, + polygonLayerId: null, + }, +} + +const DEFAULT_MAP_VIEWS = [{ id: 'layer1', name: 'Layer 1' }] + +const renderBottomPanel = ({ + dataTable = DEFAULT_DATA_TABLE_STATE, + mapViews = DEFAULT_MAP_VIEWS, +} = {}) => { const store = mockStore({ ui: { dataTableHeight: DATA_TABLE_HEIGHT, @@ -34,8 +54,8 @@ const renderBottomPanel = () => { selectionFilter: [], highlightColor: null, }, - dataTable: 'layer1', - map: { mapViews: [{ id: 'layer1', name: 'Layer 1' }] }, + dataTable, + map: { mapViews }, }) const { container } = render( <Provider store={store}> @@ -44,7 +64,7 @@ const renderBottomPanel = () => { </WindowDimensionsProvider> </Provider> ) - return { handle: container.querySelector('.resizeHandle') } + return { handle: container.querySelector('.resizeHandle'), store } } const getDisplayHeight = () => @@ -79,3 +99,96 @@ describe('BottomPanel resize cancel', () => { expect(getDisplayHeight()).toBe(`${DATA_TABLE_HEIGHT}px`) }) }) + +const twoEligibleLayers = [ + { id: 'layer1', name: 'Layer 1', layer: THEMATIC_LAYER, data: [{}] }, + { id: 'layer2', name: 'Layer 2', layer: THEMATIC_LAYER, data: [{}] }, +] + +describe('BottomPanel tabs', () => { + test('renders no tab bar with a single open layer and no other eligible layers', () => { + renderBottomPanel() + + expect(screen.queryAllByRole('tab')).toHaveLength(0) + }) + + test('renders a tab per open layer, and a Combined tab, once 2+ eligible layers exist', () => { + renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1', 'layer2'], + }, + mapViews: twoEligibleLayers, + }) + + const tabs = screen.getAllByRole('tab') + expect(tabs.map((tab) => tab.textContent)).toEqual([ + 'Layer 1', + 'Layer 2', + 'Combined', + ]) + }) + + test('shows the Combined tab once 2+ eligible layers exist even with a single open tab', () => { + renderBottomPanel({ + dataTable: DEFAULT_DATA_TABLE_STATE, + mapViews: twoEligibleLayers, + }) + + const tabs = screen.getAllByRole('tab') + // Only the open layer gets its own tab - the second eligible layer + // isn't open, so it shouldn't render a tab of its own. + expect(tabs.map((tab) => tab.textContent)).toEqual([ + 'Layer 1', + 'Combined', + ]) + }) + + test('clicking a different tab switches the active layer shown in the table', () => { + renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1', 'layer2'], + }, + mapViews: twoEligibleLayers, + }) + + expect(screen.getByTestId('datatable-mock')).toHaveTextContent('layer2') + + fireEvent.click(screen.getByText('Layer 1')) + + expect(screen.getByTestId('datatable-mock')).toHaveTextContent('layer1') + }) + + test('closing a tab dispatches toggleDataTable for that layer without switching the active tab', () => { + const { store } = renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1', 'layer2'], + }, + mapViews: twoEligibleLayers, + }) + + fireEvent.click(screen.getByLabelText('Close Layer 1 tab')) + + expect(store.getActions()).toEqual([ + { type: 'DATA_TABLE_TOGGLE', id: 'layer1' }, + ]) + }) + + test('clicking the Combined tab dispatches DATA_TABLE_COMBINED_VIEW_TOGGLE', () => { + const { store } = renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1', 'layer2'], + }, + mapViews: twoEligibleLayers, + }) + + fireEvent.click(screen.getByText('Combined')) + + expect(store.getActions()).toEqual([ + { type: 'DATA_TABLE_COMBINED_VIEW_TOGGLE' }, + ]) + }) +}) diff --git a/src/components/datatable/styles/BottomPanel.module.css b/src/components/datatable/styles/BottomPanel.module.css index e0002b0700..577e4276cd 100644 --- a/src/components/datatable/styles/BottomPanel.module.css +++ b/src/components/datatable/styles/BottomPanel.module.css @@ -40,3 +40,29 @@ background-color: var(--colors-grey300); flex-shrink: 0; } + +.tabBar { + flex-shrink: 0; +} + +.tabLabel { + max-width: 160px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tabCloseButton { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + margin-left: var(--spacers-dp4); + border-radius: 50%; + cursor: pointer; +} + +.tabCloseButton:hover { + background-color: var(--colors-grey300); +} From 7fffd2bfbb6fe7eeeca9bc8fef33f66808a711af Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 28 Jul 2026 10:31:26 +0200 Subject: [PATCH 138/205] feat: add Combined tab with join-level selector and layer picker Join level (org unit / parent org unit / spatial) is chosen via a select in the toolbar; the spatial option only appears once at least one point layer and one polygon layer exist on the map (classified by actual feature geometry, not layer type). Org unit/parent-org-unit modes pick which layers participate via the new JoinLayersControl, a checkbox popover mirroring ColumnPickerControl's pattern; spatial mode uses two selects for the point source and polygon target instead. The actual cross-layer join/render logic lands in later commits - this only wires up the config UI and dispatches DATA_TABLE_JOIN_CONFIG_SET. --- src/components/datatable/BottomPanel.jsx | 114 ++++++++++++- .../datatable/__tests__/BottomPanel.spec.jsx | 155 ++++++++++++++++++ .../__tests__/JoinLayersControl.spec.jsx | 83 ++++++++++ .../datatable/controls/JoinLayersControl.jsx | 69 ++++++++ .../styles/JoinLayersControl.module.css | 31 ++++ .../datatable/styles/BottomPanel.module.css | 10 ++ 6 files changed, 460 insertions(+), 2 deletions(-) create mode 100644 src/components/datatable/__tests__/JoinLayersControl.spec.jsx create mode 100644 src/components/datatable/controls/JoinLayersControl.jsx create mode 100644 src/components/datatable/controls/styles/JoinLayersControl.module.css diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index 4780f40f71..07deaafcd5 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -17,6 +17,7 @@ import { setHighlightColor, toggleDataTable, toggleCombinedView, + setJoinConfig, } from '../../actions/dataTable.js' import { DATA_TABLE_LAYER_TYPES } from '../../constants/layers.js' import useKeyDown from '../../hooks/useKeyDown.js' @@ -24,6 +25,11 @@ import { getPanelHeights, hasActiveDataTableFilters, } from '../../util/dataTable.js' +import { + GEO_TYPE_POINT, + GEO_TYPE_POLYGON, + GEO_TYPE_MULTIPOLYGON, +} from '../../util/geojson.js' import { getCssVar } from '../../util/helpers.js' import { useWindowDimensions } from '../WindowDimensionsProvider.jsx' import ActiveLayerControl from './controls/ActiveLayerControl.jsx' @@ -33,6 +39,7 @@ import CollapseControl from './controls/CollapseControl.jsx' import ColumnPickerControl from './controls/ColumnPickerControl.jsx' import GlobalSearchControl from './controls/GlobalSearchControl.jsx' import HighlightColorControl from './controls/HighlightColorControl.jsx' +import JoinLayersControl from './controls/JoinLayersControl.jsx' import ResizeHandleControl from './controls/ResizeHandleControl.jsx' import RowCountControl from './controls/RowCountControl.jsx' import ShowInViewControl from './controls/ShowInViewControl.jsx' @@ -43,9 +50,19 @@ import styles from './styles/BottomPanel.module.css' const MIN_HEIGHT = 50 const EMPTY_FILTERS = {} +const isPointLayer = (layer) => + layer.data?.[0]?.geometry?.type === GEO_TYPE_POINT + +const isPolygonLayer = (layer) => + [GEO_TYPE_POLYGON, GEO_TYPE_MULTIPOLYGON].includes( + layer.data?.[0]?.geometry?.type + ) + const BottomPanel = () => { const dataTableHeight = useSelector((state) => state.ui.dataTableHeight) - const { openIds, combinedView } = useSelector((state) => state.dataTable) + const { openIds, combinedView, joinConfig } = useSelector( + (state) => state.dataTable + ) const mapViews = useSelector((state) => state.map.mapViews) const [activeLayerId, setActiveLayerId] = useState(null) @@ -64,6 +81,11 @@ const BottomPanel = () => { const showCombinedTab = eligibleLayers.length >= 2 const showTabBar = openIds.length > 1 || showCombinedTab + const pointLayers = eligibleLayers.filter(isPointLayer) + const polygonLayers = eligibleLayers.filter(isPolygonLayer) + const hasSpatialCandidates = + pointLayers.length > 0 && polygonLayers.length > 0 + const activeLayer = openLayers.find((l) => l.id === activeLayerId) const dataFilters = activeLayer?.dataFilters ?? EMPTY_FILTERS const showOnlyFeaturesInView = useSelector( @@ -238,7 +260,95 @@ const BottomPanel = () => { <span className={styles.divider} /> <ActiveLayerControl name={activeLayer?.name} /> <span className={styles.divider} /> - {!combinedView && ( + {combinedView ? ( + <> + <select + className={styles.joinSelect} + value={joinConfig.level} + onChange={(e) => + dispatch( + setJoinConfig({ + ...joinConfig, + level: e.target.value, + }) + ) + } + > + <option value="orgUnit"> + {i18n.t('Join by org unit')} + </option> + <option value="parentOrgUnit"> + {i18n.t('Join by parent org unit')} + </option> + {hasSpatialCandidates && ( + <option value="spatial"> + {i18n.t('Spatial: point inside polygon')} + </option> + )} + </select> + {joinConfig.level === 'spatial' ? ( + <> + <select + className={styles.joinSelect} + value={joinConfig.pointLayerId ?? ''} + onChange={(e) => + dispatch( + setJoinConfig({ + ...joinConfig, + pointLayerId: e.target.value, + }) + ) + } + > + <option value="" disabled> + {i18n.t('Point layer')} + </option> + {pointLayers.map((lyr) => ( + <option key={lyr.id} value={lyr.id}> + {lyr.name} + </option> + ))} + </select> + <span>{i18n.t('inside')}</span> + <select + className={styles.joinSelect} + value={joinConfig.polygonLayerId ?? ''} + onChange={(e) => + dispatch( + setJoinConfig({ + ...joinConfig, + polygonLayerId: e.target.value, + }) + ) + } + > + <option value="" disabled> + {i18n.t('Polygon layer')} + </option> + {polygonLayers.map((lyr) => ( + <option key={lyr.id} value={lyr.id}> + {lyr.name} + </option> + ))} + </select> + </> + ) : ( + <JoinLayersControl + eligibleLayers={eligibleLayers} + selectedIds={joinConfig.layerIds} + onChange={(layerIds) => + dispatch( + setJoinConfig({ + ...joinConfig, + layerIds, + }) + ) + } + /> + )} + <span className={styles.divider} /> + </> + ) : ( <> <HighlightColorControl color={highlightColor} diff --git a/src/components/datatable/__tests__/BottomPanel.spec.jsx b/src/components/datatable/__tests__/BottomPanel.spec.jsx index 44b7cf2fd5..59692c27d4 100644 --- a/src/components/datatable/__tests__/BottomPanel.spec.jsx +++ b/src/components/datatable/__tests__/BottomPanel.spec.jsx @@ -192,3 +192,158 @@ describe('BottomPanel tabs', () => { ]) }) }) + +describe('BottomPanel Combined join controls', () => { + test('shows the join-level selector and layer picker, and hides per-layer-only controls, while Combined is active', () => { + renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1', 'layer2'], + combinedView: true, + }, + mapViews: twoEligibleLayers, + }) + + expect(screen.getByDisplayValue('Join by org unit')).toBeInTheDocument() + expect( + screen.getByLabelText('Choose layers to combine') + ).toBeInTheDocument() + expect( + screen.queryByLabelText('Configure columns') + ).not.toBeInTheDocument() + }) + + test('offers and renders the spatial join point/polygon selects when point+polygon candidates exist', () => { + const pointAndPolygonLayers = [ + { + id: 'points', + name: 'Points', + layer: THEMATIC_LAYER, + data: [{ geometry: { type: 'Point' } }], + }, + { + id: 'polygons', + name: 'Polygons', + layer: THEMATIC_LAYER, + data: [{ geometry: { type: 'Polygon' } }], + }, + ] + + renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['points', 'polygons'], + combinedView: true, + joinConfig: { + level: 'spatial', + layerIds: [], + pointLayerId: null, + polygonLayerId: null, + }, + }, + mapViews: pointAndPolygonLayers, + }) + + expect( + screen.getByText('Spatial: point inside polygon') + ).toBeInTheDocument() + expect(screen.getByText('Point layer')).toBeInTheDocument() + expect(screen.getByText('Polygon layer')).toBeInTheDocument() + }) + + test('choosing a point layer dispatches DATA_TABLE_JOIN_CONFIG_SET with pointLayerId set', () => { + const pointAndPolygonLayers = [ + { + id: 'points', + name: 'Points', + layer: THEMATIC_LAYER, + data: [{ geometry: { type: 'Point' } }], + }, + { + id: 'polygons', + name: 'Polygons', + layer: THEMATIC_LAYER, + data: [{ geometry: { type: 'Polygon' } }], + }, + ] + + const { store } = renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['points', 'polygons'], + combinedView: true, + joinConfig: { + level: 'spatial', + layerIds: [], + pointLayerId: null, + polygonLayerId: null, + }, + }, + mapViews: pointAndPolygonLayers, + }) + + fireEvent.change(screen.getByDisplayValue('Point layer'), { + target: { value: 'points' }, + }) + + expect(store.getActions()).toEqual([ + { + type: 'DATA_TABLE_JOIN_CONFIG_SET', + config: { + level: 'spatial', + layerIds: [], + pointLayerId: 'points', + polygonLayerId: null, + }, + }, + ]) + }) + + test('does not offer the spatial join option when there is no point/polygon pair', () => { + renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1', 'layer2'], + combinedView: true, + }, + mapViews: twoEligibleLayers, + }) + + expect( + screen.queryByText('Spatial: point inside polygon') + ).not.toBeInTheDocument() + // Regression guard: `pointLayers.length && polygonLayers.length` can + // evaluate to the number 0 rather than a real boolean, and React + // renders a stray "0" text node for that instead of nothing. + expect( + screen.getByDisplayValue('Join by org unit') + ).not.toHaveTextContent('0') + }) + + test('changing the join level dispatches DATA_TABLE_JOIN_CONFIG_SET', () => { + const { store } = renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1', 'layer2'], + combinedView: true, + }, + mapViews: twoEligibleLayers, + }) + + fireEvent.change(screen.getByDisplayValue('Join by org unit'), { + target: { value: 'parentOrgUnit' }, + }) + + expect(store.getActions()).toEqual([ + { + type: 'DATA_TABLE_JOIN_CONFIG_SET', + config: { + level: 'parentOrgUnit', + layerIds: [], + pointLayerId: null, + polygonLayerId: null, + }, + }, + ]) + }) +}) diff --git a/src/components/datatable/__tests__/JoinLayersControl.spec.jsx b/src/components/datatable/__tests__/JoinLayersControl.spec.jsx new file mode 100644 index 0000000000..1e2eb827b0 --- /dev/null +++ b/src/components/datatable/__tests__/JoinLayersControl.spec.jsx @@ -0,0 +1,83 @@ +import { render, fireEvent, screen } from '@testing-library/react' +import React from 'react' +import JoinLayersControl from '../controls/JoinLayersControl.jsx' + +const eligibleLayers = [ + { id: 'layer1', name: 'Layer 1' }, + { id: 'layer2', name: 'Layer 2' }, +] + +const renderControl = (props) => + render( + <JoinLayersControl + eligibleLayers={eligibleLayers} + selectedIds={[]} + onChange={jest.fn()} + {...props} + /> + ) + +const openPicker = () => + fireEvent.click(screen.getByTestId('data-table-join-layers-button')) + +describe('JoinLayersControl trigger', () => { + test('is disabled when there are no eligible layers', () => { + renderControl({ eligibleLayers: [] }) + expect( + screen.getByTestId('data-table-join-layers-button') + ).toBeDisabled() + }) + + test('is enabled once eligible layers are available', () => { + renderControl() + expect( + screen.getByTestId('data-table-join-layers-button') + ).not.toBeDisabled() + }) +}) + +describe('JoinLayersControl popover', () => { + test('lists a checkbox per eligible layer', () => { + renderControl() + openPicker() + + expect(screen.getByText('Layer 1')).toBeInTheDocument() + expect(screen.getByText('Layer 2')).toBeInTheDocument() + }) + + test('reflects the currently selected layer ids as checked', () => { + renderControl({ selectedIds: ['layer2'] }) + openPicker() + + expect( + screen.getByText('Layer 1').closest('label').querySelector('input') + ).not.toBeChecked() + expect( + screen.getByText('Layer 2').closest('label').querySelector('input') + ).toBeChecked() + }) + + test('checking an unselected layer adds it to the selection', () => { + const onChange = jest.fn() + renderControl({ selectedIds: ['layer1'], onChange }) + openPicker() + + fireEvent.click( + screen.getByText('Layer 2').closest('label').querySelector('input') + ) + + expect(onChange).toHaveBeenCalledWith(['layer1', 'layer2']) + }) + + test('unchecking a selected layer removes it from the selection', () => { + const onChange = jest.fn() + renderControl({ selectedIds: ['layer1', 'layer2'], onChange }) + openPicker() + + fireEvent.click( + screen.getByText('Layer 1').closest('label').querySelector('input') + ) + + expect(onChange).toHaveBeenCalledWith(['layer2']) + }) +}) diff --git a/src/components/datatable/controls/JoinLayersControl.jsx b/src/components/datatable/controls/JoinLayersControl.jsx new file mode 100644 index 0000000000..f27940681f --- /dev/null +++ b/src/components/datatable/controls/JoinLayersControl.jsx @@ -0,0 +1,69 @@ +import i18n from '@dhis2/d2-i18n' +import { IconVisualizationColumnMulti16 } from '@dhis2/ui' +import PropTypes from 'prop-types' +import React, { useRef, useState } from 'react' +import { FilterDropdownPopover } from '../FilterDropdownPopover.jsx' +import styles from './styles/JoinLayersControl.module.css' +import ToolbarIconButton from './ToolbarIconButton.jsx' + +const JoinLayersControl = ({ eligibleLayers, selectedIds, onChange }) => { + const anchorRef = useRef(null) + const [isOpen, setIsOpen] = useState(false) + + const onToggle = (layerId) => + onChange( + selectedIds.includes(layerId) + ? selectedIds.filter((id) => id !== layerId) + : [...selectedIds, layerId] + ) + + return ( + <> + <ToolbarIconButton + ref={anchorRef} + tooltip={i18n.t('Choose layers to combine')} + ariaLabel={i18n.t('Choose layers to combine')} + dataTest="data-table-join-layers-button" + disabled={!eligibleLayers.length} + onClick={() => setIsOpen((o) => !o)} + > + <IconVisualizationColumnMulti16 /> + </ToolbarIconButton> + {isOpen && ( + <FilterDropdownPopover + reference={anchorRef} + placement="top-start" + onClickOutside={() => setIsOpen(false)} + > + <div className={styles.joinLayersPopover}> + {eligibleLayers.map((layer) => ( + <label key={layer.id} className={styles.layerRow}> + <input + type="checkbox" + checked={selectedIds.includes(layer.id)} + onChange={() => onToggle(layer.id)} + /> + <span className={styles.layerName}> + {layer.name} + </span> + </label> + ))} + </div> + </FilterDropdownPopover> + )} + </> + ) +} + +JoinLayersControl.propTypes = { + eligibleLayers: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string, + name: PropTypes.string, + }) + ).isRequired, + selectedIds: PropTypes.arrayOf(PropTypes.string).isRequired, + onChange: PropTypes.func.isRequired, +} + +export default JoinLayersControl diff --git a/src/components/datatable/controls/styles/JoinLayersControl.module.css b/src/components/datatable/controls/styles/JoinLayersControl.module.css new file mode 100644 index 0000000000..04421da5a8 --- /dev/null +++ b/src/components/datatable/controls/styles/JoinLayersControl.module.css @@ -0,0 +1,31 @@ +.joinLayersPopover { + padding: var(--spacers-dp8); + min-width: 190px; + max-height: 260px; + overflow-y: auto; + background-color: var(--colors-white); + border-radius: 4px; + box-shadow: var(--elevations-popover); +} + +.layerRow { + display: flex; + align-items: center; + gap: var(--spacers-dp4); + padding: var(--spacers-dp2) var(--spacers-dp4); + border-radius: 3px; + cursor: pointer; +} + +.layerRow:hover { + background: var(--colors-grey100); +} + +.layerName { + flex: 1; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: 12px; +} diff --git a/src/components/datatable/styles/BottomPanel.module.css b/src/components/datatable/styles/BottomPanel.module.css index 577e4276cd..9c7040be04 100644 --- a/src/components/datatable/styles/BottomPanel.module.css +++ b/src/components/datatable/styles/BottomPanel.module.css @@ -66,3 +66,13 @@ .tabCloseButton:hover { background-color: var(--colors-grey300); } + +.joinSelect { + max-width: 180px; + height: 24px; + padding: 0 var(--spacers-dp4); + font-size: 12px; + border: 1px solid var(--colors-grey500); + border-radius: 3px; + background-color: var(--colors-white); +} From 63dbad7f9922936e628ddc9448d01d90547bef19 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 28 Jul 2026 10:34:45 +0200 Subject: [PATCH 139/205] feat: add spatialJoin utility using existing @turf/boolean-point-in-polygon dep Matches each point feature in one layer to the first polygon feature in another that spatially contains it, for the Combined view's spatial join mode. @turf/boolean-point-in-polygon is already a dependency (used in util/geojson.js) - no new package needed. --- src/util/__tests__/spatialJoin.spec.js | 102 +++++++++++++++++++++++++ src/util/spatialJoin.js | 28 +++++++ 2 files changed, 130 insertions(+) create mode 100644 src/util/__tests__/spatialJoin.spec.js create mode 100644 src/util/spatialJoin.js diff --git a/src/util/__tests__/spatialJoin.spec.js b/src/util/__tests__/spatialJoin.spec.js new file mode 100644 index 0000000000..d1c71b98ad --- /dev/null +++ b/src/util/__tests__/spatialJoin.spec.js @@ -0,0 +1,102 @@ +import { spatialJoin } from '../spatialJoin.js' + +const square = (id, [minX, minY, maxX, maxY]) => ({ + id, + type: 'Feature', + properties: { id, name: `Polygon ${id}` }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [minX, minY], + [maxX, minY], + [maxX, maxY], + [minX, maxY], + [minX, minY], + ], + ], + }, +}) + +const point = (id, [x, y]) => ({ + id, + type: 'Feature', + properties: { id, name: `Point ${id}` }, + geometry: { type: 'Point', coordinates: [x, y] }, +}) + +describe('spatialJoin', () => { + test('matches a point to the polygon that contains it', () => { + const pointLayer = { data: [point('p1', [1, 1])] } + const polygonLayer = { data: [square('a', [0, 0, 2, 2])] } + + const result = spatialJoin(pointLayer, polygonLayer) + + expect(result).toEqual([ + { + pointProps: { id: 'p1', name: 'Point p1' }, + polygonProps: { id: 'a', name: 'Polygon a' }, + }, + ]) + }) + + test('leaves polygonProps null for a point outside every polygon', () => { + const pointLayer = { data: [point('p1', [10, 10])] } + const polygonLayer = { data: [square('a', [0, 0, 2, 2])] } + + const result = spatialJoin(pointLayer, polygonLayer) + + expect(result).toEqual([ + { + pointProps: { id: 'p1', name: 'Point p1' }, + polygonProps: null, + }, + ]) + }) + + test('matches each point independently against multiple polygons', () => { + const pointLayer = { + data: [point('p1', [1, 1]), point('p2', [11, 11])], + } + const polygonLayer = { + data: [square('a', [0, 0, 2, 2]), square('b', [10, 10, 12, 12])], + } + + const result = spatialJoin(pointLayer, polygonLayer) + + expect(result).toEqual([ + { + pointProps: { id: 'p1', name: 'Point p1' }, + polygonProps: { id: 'a', name: 'Polygon a' }, + }, + { + pointProps: { id: 'p2', name: 'Point p2' }, + polygonProps: { id: 'b', name: 'Polygon b' }, + }, + ]) + }) + + test('ignores non-polygon features in the polygon layer', () => { + const pointLayer = { data: [point('p1', [1, 1])] } + const polygonLayer = { + data: [ + { geometry: { type: 'Point', coordinates: [1, 1] } }, + square('a', [0, 0, 2, 2]), + ], + } + + const result = spatialJoin(pointLayer, polygonLayer) + + expect(result[0].polygonProps).toEqual({ id: 'a', name: 'Polygon a' }) + }) + + test('returns an empty array when the point layer has no data', () => { + expect( + spatialJoin({ data: [] }, { data: [square('a', [0, 0, 2, 2])] }) + ).toEqual([]) + }) + + test('tolerates a missing data array on either layer', () => { + expect(spatialJoin({}, {})).toEqual([]) + }) +}) diff --git a/src/util/spatialJoin.js b/src/util/spatialJoin.js new file mode 100644 index 0000000000..037671ad6f --- /dev/null +++ b/src/util/spatialJoin.js @@ -0,0 +1,28 @@ +import { booleanPointInPolygon } from '@turf/boolean-point-in-polygon' +import { GEO_TYPE_POLYGON, GEO_TYPE_MULTIPOLYGON } from './geojson.js' + +/** + * For each point feature in pointLayer.data, finds the first polygon feature + * in polygonLayer.data that spatially contains it. + * + * @param {{ data: object[] }} pointLayer + * @param {{ data: object[] }} polygonLayer + * @returns {Array<{ pointProps: object, polygonProps: object|null }>} + */ +export const spatialJoin = (pointLayer, polygonLayer) => { + const points = pointLayer.data ?? [] + const polygons = polygonLayer.data ?? [] + + return points.map((pointFeature) => { + const matched = polygons.find( + (poly) => + [GEO_TYPE_POLYGON, GEO_TYPE_MULTIPOLYGON].includes( + poly.geometry?.type + ) && booleanPointInPolygon(pointFeature, poly) + ) + return { + pointProps: pointFeature.properties || pointFeature, + polygonProps: matched?.properties ?? null, + } + }) +} From e2db76adcb5a93c9e615cb41440c4a90c8a4c747 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 28 Jul 2026 10:37:40 +0200 Subject: [PATCH 140/205] feat: add useCombinedTableData hook (org unit, parent org unit, spatial joins) Joins the selected layers' data for the Combined view: - orgUnit: rows keyed by orgUnitId, one value/legend column pair per layer, blank for layers with no feature at that org unit. - parentOrgUnit: same, but grouped one level up the org unit hierarchy (parent id/path derived, name resolved via the existing useOrgUnitAncestorNames hook from the PR6 filters work) with numeric values averaged per group and legend left blank (no single class applies to an aggregate). - spatial: point-in-polygon via spatialJoin, with a spatialWarning flag above a 10k-feature threshold on either layer. No Index column, matching the PR3 decision to drop it entirely rather than resurrect the abandoned resolved-ids filtering approach. --- .../__tests__/useCombinedTableData.spec.js | 329 ++++++++++++++++++ .../datatable/useCombinedTableData.js | 267 ++++++++++++++ 2 files changed, 596 insertions(+) create mode 100644 src/components/datatable/__tests__/useCombinedTableData.spec.js create mode 100644 src/components/datatable/useCombinedTableData.js diff --git a/src/components/datatable/__tests__/useCombinedTableData.spec.js b/src/components/datatable/__tests__/useCombinedTableData.spec.js new file mode 100644 index 0000000000..338755ab22 --- /dev/null +++ b/src/components/datatable/__tests__/useCombinedTableData.spec.js @@ -0,0 +1,329 @@ +import { renderHook } from '@testing-library/react' +import useOrgUnitAncestorNames from '../../../hooks/useOrgUnitAncestorNames.js' +import { useCombinedTableData } from '../useCombinedTableData.js' + +jest.mock('../../../hooks/useOrgUnitAncestorNames.js', () => ({ + __esModule: true, + default: jest.fn(), +})) + +beforeEach(() => { + useOrgUnitAncestorNames.mockReturnValue({ + idToName: new Map(), + loading: false, + }) +}) + +const feature = (props) => ({ properties: props }) + +const findCell = (row, dataKey) => row.find((c) => c.dataKey === dataKey) + +describe('useCombinedTableData - org unit join', () => { + test('joins two layers by org unit id, filling blanks for unmatched org units', () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + data: [ + feature({ + orgUnitId: 'ou1', + orgUnitOwn: 'Country / Ou 1', + level: 2, + rawValue: 10, + legend: 'Low', + }), + ], + }, + { + id: 'layerB', + name: 'Layer B', + data: [ + feature({ + orgUnitId: 'ou2', + orgUnitOwn: 'Country / Ou 2', + level: 2, + rawValue: 20, + legend: 'High', + }), + ], + }, + ] + const joinConfig = { + level: 'orgUnit', + layerIds: ['layerA', 'layerB'], + pointLayerId: null, + polygonLayerId: null, + } + + const { result } = renderHook(() => + useCombinedTableData({ layers, joinConfig }) + ) + + expect(result.current.headers.map((h) => h.dataKey)).toEqual([ + 'id', + 'name', + 'level', + 'layerA_rawValue', + 'layerA_legend', + 'layerB_rawValue', + 'layerB_legend', + ]) + expect(result.current.rows).toHaveLength(2) + + const row1 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' + ) + expect(findCell(row1, 'name').value).toBe('Country / Ou 1') + expect(findCell(row1, 'layerA_rawValue').value).toBe(10) + expect(findCell(row1, 'layerB_rawValue').value).toBe(null) + + const row2 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou2' + ) + expect(findCell(row2, 'layerA_rawValue').value).toBe(null) + expect(findCell(row2, 'layerB_rawValue').value).toBe(20) + }) + + test('excludes features with hasAdditionalGeometry set', () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + data: [ + feature({ + orgUnitId: 'ou1', + rawValue: 10, + hasAdditionalGeometry: true, + }), + feature({ orgUnitId: 'ou2', rawValue: 20 }), + ], + }, + ] + const joinConfig = { + level: 'orgUnit', + layerIds: ['layerA'], + pointLayerId: null, + polygonLayerId: null, + } + + const { result } = renderHook(() => + useCombinedTableData({ layers, joinConfig }) + ) + + expect(result.current.rows).toHaveLength(1) + expect(findCell(result.current.rows[0], 'id').value).toBe('ou2') + }) +}) + +describe('useCombinedTableData - parent org unit grouping', () => { + test('groups rows by parent org unit and averages numeric values', () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + data: [ + feature({ + orgUnitId: 'ou1', + orgUnitPath: '/country1/parent1/ou1', + rawValue: 10, + }), + feature({ + orgUnitId: 'ou2', + orgUnitPath: '/country1/parent1/ou2', + rawValue: 20, + }), + ], + }, + ] + const joinConfig = { + level: 'parentOrgUnit', + layerIds: ['layerA'], + pointLayerId: null, + polygonLayerId: null, + } + + useOrgUnitAncestorNames.mockReturnValue({ + idToName: new Map([['parent1', 'Parent One']]), + loading: false, + }) + + const { result } = renderHook(() => + useCombinedTableData({ layers, joinConfig }) + ) + + expect(result.current.rows).toHaveLength(1) + const row = result.current.rows[0] + expect(findCell(row, 'id').value).toBe('parent1') + expect(findCell(row, 'name').value).toBe('Parent One') + expect(findCell(row, 'layerA_rawValue').value).toBe(15) + expect(findCell(row, 'layerA_legend').value).toBe(null) + }) + + test('groups org units with no parent path under a single "No parent" row', () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + data: [ + feature({ + orgUnitId: 'ou1', + orgUnitPath: '/ou1', + rawValue: 10, + }), + ], + }, + ] + const joinConfig = { + level: 'parentOrgUnit', + layerIds: ['layerA'], + pointLayerId: null, + polygonLayerId: null, + } + + const { result } = renderHook(() => + useCombinedTableData({ layers, joinConfig }) + ) + + expect(result.current.rows).toHaveLength(1) + expect(findCell(result.current.rows[0], 'id').value).toBe(null) + expect(findCell(result.current.rows[0], 'name').value).toBe('No parent') + }) +}) + +describe('useCombinedTableData - spatial join', () => { + const pointLayer = { + id: 'points', + name: 'Points', + data: [ + { + type: 'Feature', + properties: { id: 'p1', orgUnitOwn: 'Point One' }, + geometry: { type: 'Point', coordinates: [1, 1] }, + }, + ], + } + const polygonLayer = { + id: 'polygons', + name: 'Polygons', + data: [ + { + type: 'Feature', + properties: { id: 'poly1', rawValue: 42, legend: 'High' }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + [0, 0], + ], + ], + }, + }, + ], + } + + test('joins a point layer to a polygon layer by spatial containment', () => { + const joinConfig = { + level: 'spatial', + layerIds: [], + pointLayerId: 'points', + polygonLayerId: 'polygons', + } + + const { result } = renderHook(() => + useCombinedTableData({ + layers: [pointLayer, polygonLayer], + joinConfig, + }) + ) + + expect(result.current.headers.map((h) => h.dataKey)).toEqual([ + 'id', + 'name', + 'polygons_rawValue', + 'polygons_legend', + ]) + expect(result.current.rows).toEqual([ + [ + { dataKey: 'id', value: 'p1', align: 'left' }, + { dataKey: 'name', value: 'Point One', align: 'left' }, + { + dataKey: 'polygons_rawValue', + value: 42, + align: 'right', + }, + { dataKey: 'polygons_legend', value: 'High', align: 'left' }, + ], + ]) + expect(result.current.spatialWarning).toBe(false) + }) + + test('returns an empty result when the point or polygon layer is not found', () => { + const joinConfig = { + level: 'spatial', + layerIds: [], + pointLayerId: 'points', + polygonLayerId: null, + } + + const { result } = renderHook(() => + useCombinedTableData({ layers: [pointLayer], joinConfig }) + ) + + expect(result.current).toEqual({ + headers: [], + rows: [], + spatialWarning: false, + }) + }) + + test('sets spatialWarning when either layer exceeds the large-feature threshold', () => { + const bigPointLayer = { + ...pointLayer, + data: Array.from({ length: 10001 }, (_, i) => ({ + type: 'Feature', + properties: { id: `p${i}` }, + geometry: { type: 'Point', coordinates: [1, 1] }, + })), + } + const joinConfig = { + level: 'spatial', + layerIds: [], + pointLayerId: 'points', + polygonLayerId: 'polygons', + } + + const { result } = renderHook(() => + useCombinedTableData({ + layers: [bigPointLayer, polygonLayer], + joinConfig, + }) + ) + + expect(result.current.spatialWarning).toBe(true) + }) +}) + +describe('useCombinedTableData - empty input', () => { + test('returns an empty result when there are no layers', () => { + const joinConfig = { + level: 'orgUnit', + layerIds: [], + pointLayerId: null, + polygonLayerId: null, + } + + const { result } = renderHook(() => + useCombinedTableData({ layers: [], joinConfig }) + ) + + expect(result.current).toEqual({ + headers: [], + rows: [], + spatialWarning: false, + }) + }) +}) diff --git a/src/components/datatable/useCombinedTableData.js b/src/components/datatable/useCombinedTableData.js new file mode 100644 index 0000000000..5030e00423 --- /dev/null +++ b/src/components/datatable/useCombinedTableData.js @@ -0,0 +1,267 @@ +import i18n from '@dhis2/d2-i18n' +import { useMemo } from 'react' +import { + ORG_UNIT_ID_DATA_KEY, + ORG_UNIT_PATH_DATA_KEY, + ORG_UNIT_DATA_KEY, + ORG_UNIT_LEVEL_DATA_KEY, + TYPE_NUMBER, + TYPE_STRING, +} from '../../constants/dataTable.js' +import useOrgUnitAncestorNames from '../../hooks/useOrgUnitAncestorNames.js' +import { spatialJoin } from '../../util/spatialJoin.js' + +const VALUE_KEY = 'rawValue' +const LEGEND_KEY = 'legend' +const LARGE_FEATURE_THRESHOLD = 10000 +const NO_PARENT_KEY = '__no_parent__' + +const getPathSegments = (path) => + path ? String(path).split('/').filter(Boolean) : [] + +const getLastSegment = (path) => { + const segments = getPathSegments(path) + return segments.length ? segments[segments.length - 1] : null +} + +const getParentPath = (path) => { + const segments = getPathSegments(path) + return segments.length > 1 ? segments.slice(0, -1).join('/') : null +} + +const EMPTY_RESULT = { headers: [], rows: [], spatialWarning: false } + +export const useCombinedTableData = ({ layers, joinConfig }) => { + const { level, pointLayerId, polygonLayerId } = joinConfig + const isSpatial = level === 'spatial' + const isParentGrouped = level === 'parentOrgUnit' + + const layerMaps = useMemo(() => { + if (isSpatial) { + return [] + } + return layers.map((layer) => ({ + layer, + byOrgUnit: Object.fromEntries( + (layer.data ?? []) + .filter((d) => !d.properties?.hasAdditionalGeometry) + .map((d) => { + const props = d.properties || d + return [props[ORG_UNIT_ID_DATA_KEY], props] + }) + .filter(([id]) => id != null) + ), + })) + }, [layers, isSpatial]) + + const allIds = useMemo( + () => [ + ...new Set(layerMaps.flatMap((lm) => Object.keys(lm.byOrgUnit))), + ], + [layerMaps] + ) + + const parentPaths = useMemo(() => { + if (!isParentGrouped) { + return [] + } + const paths = new Set() + allIds.forEach((id) => { + const baseProps = layerMaps.find((lm) => lm.byOrgUnit[id]) + ?.byOrgUnit[id] + const parentPath = getParentPath( + baseProps?.[ORG_UNIT_PATH_DATA_KEY] + ) + if (parentPath) { + paths.add(parentPath) + } + }) + return [...paths] + }, [layerMaps, allIds, isParentGrouped]) + + const { idToName: parentIdToName } = useOrgUnitAncestorNames(parentPaths) + + return useMemo(() => { + if (!layers?.length) { + return EMPTY_RESULT + } + + if (isSpatial) { + const pointLayer = layers.find((l) => l.id === pointLayerId) + const polygonLayer = layers.find((l) => l.id === polygonLayerId) + if (!pointLayer || !polygonLayer) { + return EMPTY_RESULT + } + + const spatialWarning = + (pointLayer.data?.length ?? 0) > LARGE_FEATURE_THRESHOLD || + (polygonLayer.data?.length ?? 0) > LARGE_FEATURE_THRESHOLD + + const joined = spatialJoin(pointLayer, polygonLayer) + + const headers = [ + { name: i18n.t('ID'), dataKey: 'id', type: TYPE_STRING }, + { name: i18n.t('Name'), dataKey: 'name', type: TYPE_STRING }, + { + name: i18n.t('Value ({{layer}})', { + layer: polygonLayer.name, + }), + dataKey: `${polygonLayer.id}_${VALUE_KEY}`, + type: TYPE_NUMBER, + }, + { + name: i18n.t('Legend ({{layer}})', { + layer: polygonLayer.name, + }), + dataKey: `${polygonLayer.id}_${LEGEND_KEY}`, + type: TYPE_STRING, + }, + ] + + const rows = joined.map(({ pointProps, polygonProps }) => [ + { dataKey: 'id', value: pointProps.id ?? null, align: 'left' }, + { + dataKey: 'name', + value: + pointProps[ORG_UNIT_DATA_KEY] ?? + pointProps.name ?? + pointProps.id ?? + null, + align: 'left', + }, + { + dataKey: `${polygonLayer.id}_${VALUE_KEY}`, + value: polygonProps?.[VALUE_KEY] ?? null, + align: 'right', + }, + { + dataKey: `${polygonLayer.id}_${LEGEND_KEY}`, + value: polygonProps?.[LEGEND_KEY] ?? null, + align: 'left', + }, + ]) + + return { headers, rows, spatialWarning } + } + + const layerHeaders = layerMaps.flatMap(({ layer }) => [ + { + name: i18n.t('Value ({{layer}})', { layer: layer.name }), + dataKey: `${layer.id}_${VALUE_KEY}`, + type: TYPE_NUMBER, + }, + { + name: i18n.t('Legend ({{layer}})', { layer: layer.name }), + dataKey: `${layer.id}_${LEGEND_KEY}`, + type: TYPE_STRING, + }, + ]) + + if (isParentGrouped) { + const headers = [ + { name: i18n.t('ID'), dataKey: 'id', type: TYPE_STRING }, + { name: i18n.t('Name'), dataKey: 'name', type: TYPE_STRING }, + ...layerHeaders, + ] + + const groups = new Map() + allIds.forEach((id) => { + const baseProps = layerMaps.find((lm) => lm.byOrgUnit[id]) + ?.byOrgUnit[id] + const parentPath = getParentPath( + baseProps?.[ORG_UNIT_PATH_DATA_KEY] + ) + const parentId = getLastSegment(parentPath) + const key = parentId ?? NO_PARENT_KEY + if (!groups.has(key)) { + groups.set(key, { + id: parentId, + name: parentId + ? parentIdToName.get(parentId) ?? parentId + : i18n.t('No parent'), + memberIds: [], + }) + } + groups.get(key).memberIds.push(id) + }) + + const rows = [...groups.values()].map((group) => { + const cells = [ + { dataKey: 'id', value: group.id, align: 'left' }, + { dataKey: 'name', value: group.name, align: 'left' }, + ] + layerMaps.forEach(({ layer, byOrgUnit }) => { + const values = group.memberIds + .map((id) => byOrgUnit[id]?.[VALUE_KEY]) + .filter((v) => v != null) + const average = values.length + ? values.reduce((a, b) => a + b, 0) / values.length + : null + cells.push({ + dataKey: `${layer.id}_${VALUE_KEY}`, + value: average, + align: 'right', + }) + cells.push({ + dataKey: `${layer.id}_${LEGEND_KEY}`, + value: null, + align: 'left', + }) + }) + return cells + }) + + return { headers, rows, spatialWarning: false } + } + + const headers = [ + { name: i18n.t('ID'), dataKey: 'id', type: TYPE_STRING }, + { name: i18n.t('Name'), dataKey: 'name', type: TYPE_STRING }, + { name: i18n.t('Level'), dataKey: 'level', type: TYPE_NUMBER }, + ...layerHeaders, + ] + + const rows = allIds.map((id) => { + const baseProps = + layerMaps.find((lm) => lm.byOrgUnit[id])?.byOrgUnit[id] ?? {} + const cells = [ + { dataKey: 'id', value: id, align: 'left' }, + { + dataKey: 'name', + value: baseProps[ORG_UNIT_DATA_KEY] ?? null, + align: 'left', + }, + { + dataKey: 'level', + value: baseProps[ORG_UNIT_LEVEL_DATA_KEY] ?? null, + align: 'right', + }, + ] + layerMaps.forEach(({ layer, byOrgUnit }) => { + const props = byOrgUnit[id] + cells.push({ + dataKey: `${layer.id}_${VALUE_KEY}`, + value: props?.[VALUE_KEY] ?? null, + align: 'right', + }) + cells.push({ + dataKey: `${layer.id}_${LEGEND_KEY}`, + value: props?.[LEGEND_KEY] ?? null, + align: 'left', + }) + }) + return cells + }) + + return { headers, rows, spatialWarning: false } + }, [ + layers, + layerMaps, + allIds, + isSpatial, + isParentGrouped, + pointLayerId, + polygonLayerId, + parentIdToName, + ]) +} From a9fb3edf2b016db56308fa9d67bbbfdb7fd54c08 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 28 Jul 2026 10:45:14 +0200 Subject: [PATCH 141/205] feat: add CombinedDataTable component and wire into BottomPanel Renders useCombinedTableData's headers/rows via TableVirtuoso, reusing the same @dhis2/ui DataTable primitives as the single-layer table for visual consistency, with a warning banner above the 10k-feature spatial-join threshold. BottomPanel now routes to it instead of DataTable whenever combinedView is active, resolving the layer set from joinConfig. Also fixes a bug this surfaced: activeLayerId was seeded via useState(null) and only synced to openIds a render later via useEffect, so a child requiring a non-null layerId (ColumnPickerControl) saw null for one render and logged a prop-types warning. Replaced with a value derived synchronously from openIds plus the last manually-clicked tab, so there's no render where it's out of sync. Regenerated i18n/en.pot to catch up the translatable strings added across this commit and the preceding tab-bar/join-controls ones. --- i18n/en.pot | 55 +++++- src/components/datatable/BottomPanel.jsx | 54 ++++-- .../datatable/CombinedDataTable.jsx | 116 ++++++++++++ .../datatable/__tests__/BottomPanel.spec.jsx | 23 +++ .../__tests__/CombinedDataTable.spec.jsx | 166 ++++++++++++++++++ .../styles/CombinedDataTable.module.css | 33 ++++ 6 files changed, 421 insertions(+), 26 deletions(-) create mode 100644 src/components/datatable/CombinedDataTable.jsx create mode 100644 src/components/datatable/__tests__/CombinedDataTable.spec.jsx create mode 100644 src/components/datatable/styles/CombinedDataTable.module.css diff --git a/i18n/en.pot b/i18n/en.pot index 956d06504d..947a6ae2ab 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-27T14:20:22.195Z\n" -"PO-Revision-Date: 2026-07-27T14:20:22.195Z\n" +"POT-Creation-Date: 2026-07-28T08:41:25.486Z\n" +"PO-Revision-Date: 2026-07-28T08:41:25.486Z\n" msgid "2020" msgstr "2020" @@ -155,6 +155,33 @@ msgstr "Operator" msgid "Date" msgstr "Date" +msgid "Join by org unit" +msgstr "Join by org unit" + +msgid "Join by parent org unit" +msgstr "Join by parent org unit" + +msgid "Point layer" +msgstr "Point layer" + +msgid "inside" +msgstr "inside" + +msgid "Polygon layer" +msgstr "Polygon layer" + +msgid "Close {{name}} tab" +msgstr "Close {{name}} tab" + +msgid "Combined" +msgstr "Combined" + +msgid "No matching rows" +msgstr "No matching rows" + +msgid "Spatial join over large datasets may be slow (over {{threshold}} features)" +msgstr "Spatial join over large datasets may be slow (over {{threshold}} features)" + msgid "Select all visible rows" msgstr "Select all visible rows" @@ -328,6 +355,9 @@ msgstr "Search all columns" msgid "Highlight color" msgstr "Highlight color" +msgid "Choose layers to combine" +msgstr "Choose layers to combine" + msgid "{{filtered}} of {{total}} rows" msgstr "{{filtered}} of {{total}} rows" @@ -337,6 +367,21 @@ msgstr "{{total}} rows" msgid "Show only features in current map view" msgstr "Show only features in current map view" +msgid "ID" +msgstr "ID" + +msgid "Value ({{layer}})" +msgstr "Value ({{layer}})" + +msgid "Legend ({{layer}})" +msgstr "Legend ({{layer}})" + +msgid "No parent" +msgstr "No parent" + +msgid "Level" +msgstr "Level" + msgid "No valid data was found for the current layer configuration." msgstr "No valid data was found for the current layer configuration." @@ -948,9 +993,6 @@ msgstr "Groups" msgid "Parent unit" msgstr "Parent unit" -msgid "Level" -msgstr "Level" - msgid "Not set" msgstr "Not set" @@ -1109,9 +1151,6 @@ msgstr "Address" msgid "Phone" msgstr "Phone" -msgid "ID" -msgstr "ID" - msgid "Comment" msgstr "Comment" diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index 07deaafcd5..678f4cf4fd 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -32,6 +32,7 @@ import { } from '../../util/geojson.js' import { getCssVar } from '../../util/helpers.js' import { useWindowDimensions } from '../WindowDimensionsProvider.jsx' +import CombinedDataTable from './CombinedDataTable.jsx' import ActiveLayerControl from './controls/ActiveLayerControl.jsx' import ClearFiltersControl from './controls/ClearFiltersControl.jsx' import CloseControl from './controls/CloseControl.jsx' @@ -64,15 +65,16 @@ const BottomPanel = () => { (state) => state.dataTable ) const mapViews = useSelector((state) => state.map.mapViews) - const [activeLayerId, setActiveLayerId] = useState(null) - - useEffect(() => { - if (openIds.length === 0) { - setActiveLayerId(null) - } else if (!openIds.includes(activeLayerId)) { - setActiveLayerId(openIds[openIds.length - 1]) - } - }, [openIds, activeLayerId]) + // Only tracks a user's explicit tab click - falls back to the most + // recently opened tab whenever it doesn't (yet) name an open layer, so + // there's no render where this is out of sync with openIds (unlike a + // useState+useEffect pair, which would flash a stale/null value for one + // render before the effect corrects it). + const [manualActiveLayerId, setManualActiveLayerId] = useState(null) + const activeLayerId = + manualActiveLayerId && openIds.includes(manualActiveLayerId) + ? manualActiveLayerId + : openIds[openIds.length - 1] ?? null const openLayers = mapViews.filter((l) => openIds.includes(l.id)) const eligibleLayers = mapViews.filter( @@ -86,6 +88,13 @@ const BottomPanel = () => { const hasSpatialCandidates = pointLayers.length > 0 && polygonLayers.length > 0 + const combinedLayers = + joinConfig.level === 'spatial' + ? [joinConfig.pointLayerId, joinConfig.polygonLayerId] + .map((id) => mapViews.find((l) => l.id === id)) + .filter(Boolean) + : mapViews.filter((l) => joinConfig.layerIds.includes(l.id)) + const activeLayer = openLayers.find((l) => l.id === activeLayerId) const dataFilters = activeLayer?.dataFilters ?? EMPTY_FILTERS const showOnlyFeaturesInView = useSelector( @@ -401,7 +410,7 @@ const BottomPanel = () => { key={lyr.id} selected={!combinedView && lyr.id === activeLayerId} onClick={() => { - setActiveLayerId(lyr.id) + setManualActiveLayerId(lyr.id) if (combinedView) { dispatch(toggleCombinedView()) } @@ -449,14 +458,23 @@ const BottomPanel = () => { )} <div className={styles.tableContainer}> <ErrorBoundary> - <DataTable - activeLayerId={activeLayerId} - availableWidth={panelWidth} - onCountChange={onCountChange} - onHeadersChange={onHeadersChange} - globalSearch={globalSearch} - onClearFilters={onClearFilters} - /> + {combinedView ? ( + <CombinedDataTable + availableWidth={panelWidth} + layers={combinedLayers} + joinConfig={joinConfig} + onCountChange={onCountChange} + /> + ) : ( + <DataTable + activeLayerId={activeLayerId} + availableWidth={panelWidth} + onCountChange={onCountChange} + onHeadersChange={onHeadersChange} + globalSearch={globalSearch} + onClearFilters={onClearFilters} + /> + )} </ErrorBoundary> </div> </div> diff --git a/src/components/datatable/CombinedDataTable.jsx b/src/components/datatable/CombinedDataTable.jsx new file mode 100644 index 0000000000..feb8e2f1aa --- /dev/null +++ b/src/components/datatable/CombinedDataTable.jsx @@ -0,0 +1,116 @@ +import i18n from '@dhis2/d2-i18n' +import { + DataTable, + DataTableBody, + DataTableHead, + DataTableRow, + DataTableColumnHeader, + DataTableCell, +} from '@dhis2/ui' +import PropTypes from 'prop-types' +import React, { useCallback, useEffect } from 'react' +import { TableVirtuoso } from 'react-virtuoso' +import styles from './styles/CombinedDataTable.module.css' +import { useCombinedTableData } from './useCombinedTableData.js' + +const TABLE_STYLE = { height: '100%', width: '100%' } +const LARGE_FEATURE_THRESHOLD_LABEL = '10,000' + +const CombinedTable = (props) => ( + <DataTable {...props} className={styles.dataTable} /> +) + +const EmptyPlaceholder = () => ( + <tbody> + <tr> + <td colSpan={99999}> + <div className={styles.noResults}> + {i18n.t('No matching rows')} + </div> + </td> + </tr> + </tbody> +) + +const CombinedTableComponents = { + Table: CombinedTable, + TableBody: DataTableBody, + TableHead: DataTableHead, + TableRow: DataTableRow, + EmptyPlaceholder, +} + +const CombinedDataTable = ({ + availableWidth, + layers, + joinConfig, + onCountChange, +}) => { + const { headers, rows, spatialWarning } = useCombinedTableData({ + layers, + joinConfig, + }) + + useEffect(() => { + onCountChange?.(rows.length, rows.length) + }, [onCountChange, rows.length]) + + const fixedHeaderContent = useCallback( + () => ( + <DataTableRow> + {headers.map(({ name, dataKey }) => ( + <DataTableColumnHeader key={dataKey} name={dataKey}> + {name} + </DataTableColumnHeader> + ))} + </DataTableRow> + ), + [headers] + ) + + return ( + <div className={styles.container} style={{ width: availableWidth }}> + {spatialWarning && ( + <div className={styles.spatialWarning}> + {i18n.t( + 'Spatial join over large datasets may be slow (over {{threshold}} features)', + { threshold: LARGE_FEATURE_THRESHOLD_LABEL } + )} + </div> + )} + <TableVirtuoso + components={CombinedTableComponents} + style={TABLE_STYLE} + data={rows} + fixedHeaderContent={fixedHeaderContent} + itemContent={(_, row) => ( + <> + {row.map(({ dataKey, value, align }) => ( + <DataTableCell + key={dataKey} + staticStyle + align={align} + > + {value ?? '—'} + </DataTableCell> + ))} + </> + )} + /> + </div> + ) +} + +CombinedDataTable.propTypes = { + joinConfig: PropTypes.shape({ + layerIds: PropTypes.arrayOf(PropTypes.string), + level: PropTypes.string, + pointLayerId: PropTypes.string, + polygonLayerId: PropTypes.string, + }).isRequired, + layers: PropTypes.array.isRequired, + availableWidth: PropTypes.number, + onCountChange: PropTypes.func, +} + +export default CombinedDataTable diff --git a/src/components/datatable/__tests__/BottomPanel.spec.jsx b/src/components/datatable/__tests__/BottomPanel.spec.jsx index 59692c27d4..a375c097da 100644 --- a/src/components/datatable/__tests__/BottomPanel.spec.jsx +++ b/src/components/datatable/__tests__/BottomPanel.spec.jsx @@ -160,6 +160,29 @@ describe('BottomPanel tabs', () => { expect(screen.getByTestId('datatable-mock')).toHaveTextContent('layer1') }) + test('the active layer is correct on the very first render, with no transient null in between', () => { + // Regression guard: activeLayerId used to be seeded via + // useState(null) and only synced to openIds a render later via + // useEffect, so a child requiring a non-null layerId (e.g. + // ColumnPickerControl) would see `null` for one render and log a + // prop-types warning. It must now be derived synchronously. + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}) + + renderBottomPanel() + + expect(screen.getByTestId('datatable-mock')).toHaveTextContent('layer1') + const layerIdWarnings = consoleError.mock.calls.filter((args) => + args.some( + (arg) => typeof arg === 'string' && arg.includes('layerId') + ) + ) + expect(layerIdWarnings).toEqual([]) + + consoleError.mockRestore() + }) + test('closing a tab dispatches toggleDataTable for that layer without switching the active tab', () => { const { store } = renderBottomPanel({ dataTable: { diff --git a/src/components/datatable/__tests__/CombinedDataTable.spec.jsx b/src/components/datatable/__tests__/CombinedDataTable.spec.jsx new file mode 100644 index 0000000000..c712fab26c --- /dev/null +++ b/src/components/datatable/__tests__/CombinedDataTable.spec.jsx @@ -0,0 +1,166 @@ +import { render, screen } from '@testing-library/react' +import React from 'react' +import { VirtuosoMockContext } from 'react-virtuoso' +import CombinedDataTable from '../CombinedDataTable.jsx' + +const feature = (props) => ({ properties: props }) + +const renderCombinedDataTable = (props) => + render( + <VirtuosoMockContext.Provider + value={{ viewportHeight: 300, itemHeight: 28 }} + > + <CombinedDataTable + availableWidth={800} + layers={[]} + joinConfig={{ + level: 'orgUnit', + layerIds: [], + pointLayerId: null, + polygonLayerId: null, + }} + {...props} + /> + </VirtuosoMockContext.Provider> + ) + +describe('CombinedDataTable', () => { + test('renders a column header per computed header and a cell per row', () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + data: [ + feature({ + orgUnitId: 'ou1', + orgUnitOwn: 'Ou One', + level: 2, + rawValue: 10, + legend: 'Low', + }), + ], + }, + ] + + renderCombinedDataTable({ + layers, + joinConfig: { + level: 'orgUnit', + layerIds: ['layerA'], + pointLayerId: null, + polygonLayerId: null, + }, + }) + + expect(screen.getByText('ID')).toBeInTheDocument() + expect(screen.getByText('Name')).toBeInTheDocument() + expect(screen.getByText('Value (Layer A)')).toBeInTheDocument() + expect(screen.getByText('Legend (Layer A)')).toBeInTheDocument() + expect(screen.getByText('Ou One')).toBeInTheDocument() + expect(screen.getByText('10')).toBeInTheDocument() + expect(screen.getByText('Low')).toBeInTheDocument() + }) + + test('renders an em-dash for blank cell values', () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + data: [feature({ orgUnitId: 'ou1' })], + }, + ] + + renderCombinedDataTable({ + layers, + joinConfig: { + level: 'orgUnit', + layerIds: ['layerA'], + pointLayerId: null, + polygonLayerId: null, + }, + }) + + expect(screen.getAllByText('—').length).toBeGreaterThan(0) + }) + + test('shows the empty-results placeholder when there are no rows', () => { + renderCombinedDataTable({ layers: [] }) + + expect(screen.getByText('No matching rows')).toBeInTheDocument() + }) + + test('shows the spatial warning banner when a spatial join exceeds the large-feature threshold', () => { + const pointLayer = { + id: 'points', + name: 'Points', + data: Array.from({ length: 10001 }, (_, i) => ({ + type: 'Feature', + properties: { id: `p${i}` }, + geometry: { type: 'Point', coordinates: [1, 1] }, + })), + } + const polygonLayer = { + id: 'polygons', + name: 'Polygons', + data: [ + { + type: 'Feature', + properties: { id: 'poly1', rawValue: 1 }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + [0, 0], + ], + ], + }, + }, + ], + } + + renderCombinedDataTable({ + layers: [pointLayer, polygonLayer], + joinConfig: { + level: 'spatial', + layerIds: [], + pointLayerId: 'points', + polygonLayerId: 'polygons', + }, + }) + + expect( + screen.getByText(/Spatial join over large datasets may be slow/) + ).toBeInTheDocument() + }) + + test('calls onCountChange with the row count', () => { + const onCountChange = jest.fn() + const layers = [ + { + id: 'layerA', + name: 'Layer A', + data: [ + feature({ orgUnitId: 'ou1' }), + feature({ orgUnitId: 'ou2' }), + ], + }, + ] + + renderCombinedDataTable({ + layers, + joinConfig: { + level: 'orgUnit', + layerIds: ['layerA'], + pointLayerId: null, + polygonLayerId: null, + }, + onCountChange, + }) + + expect(onCountChange).toHaveBeenCalledWith(2, 2) + }) +}) diff --git a/src/components/datatable/styles/CombinedDataTable.module.css b/src/components/datatable/styles/CombinedDataTable.module.css new file mode 100644 index 0000000000..242197820a --- /dev/null +++ b/src/components/datatable/styles/CombinedDataTable.module.css @@ -0,0 +1,33 @@ +.container { + height: 100%; + display: flex; + flex-direction: column; +} + +.dataTable { + height: 1px; + border: none !important; +} + +.dataTable > :global(thead) { + user-select: none; +} + +.noResults { + display: flex; + color: var(--colors-grey600); + align-items: center; + justify-content: center; + font-size: 12px; + font-style: italic; + min-height: 40px; +} + +.spatialWarning { + flex-shrink: 0; + padding: var(--spacers-dp4) var(--spacers-dp8); + background-color: var(--colors-yellow100); + color: var(--colors-yellow800); + font-size: 12px; + border-bottom: 1px solid var(--colors-yellow300); +} From d261147a8493676679ecf758edf923fd98f0ea33 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 28 Jul 2026 11:02:29 +0200 Subject: [PATCH 142/205] fix: resolve org unit names in Combined view and reset stale combinedView state Fresh-context review of commits 1-8 caught three issues: - useCombinedTableData read orgUnitOwn directly as a display name, but it's a raw slash-path of org unit ids at the data layer (see attachOrgUnitPaths) - every other consumer resolves it through formatOrgUnitOwnName + an idToName map first. The org unit and spatial join modes were showing raw id paths instead of names; only the parentOrgUnit mode already resolved correctly. Now all three modes share one useOrgUnitAncestorNames call (passing full org-unit paths resolves every ancestor id along the way, including parents, for free). - dataTable reducer's DATA_TABLE_TOGGLE left combinedView/joinConfig untouched even when it closed the last open tab, even though the panel itself fully unmounts at that point (App.jsx gates on openIds.length). Reopening any single layer's table afterward would silently land back in a stale Combined view. Now a toggle that empties openIds gets the same full reset as an explicit DATA_TABLE_CLOSE. - BottomPanel's combinedLayers array was rebuilt with a new identity on every render, defeating useCombinedTableData's internal memoization on unrelated re-renders (row count updates, resizes). Wrapped in useMemo. Also fixes two test fixtures (useCombinedTableData.spec.js, CombinedDataTable.spec.jsx) that stubbed orgUnitOwn with an already-resolved name string, which would have passed even with the resolution bug present. --- src/components/datatable/BottomPanel.jsx | 17 ++- .../__tests__/CombinedDataTable.spec.jsx | 20 +++- .../__tests__/useCombinedTableData.spec.js | 91 ++++++++++++++- .../datatable/useCombinedTableData.js | 105 ++++++++++-------- src/reducers/__tests__/dataTable.spec.js | 20 ++++ src/reducers/dataTable.js | 18 +-- 6 files changed, 206 insertions(+), 65 deletions(-) diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index 678f4cf4fd..ba4f2c66d5 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -3,6 +3,7 @@ import { TabBar, Tab, IconCross16 } from '@dhis2/ui' import React, { useRef, useCallback, + useMemo, useState, useEffect, useLayoutEffect, @@ -88,12 +89,16 @@ const BottomPanel = () => { const hasSpatialCandidates = pointLayers.length > 0 && polygonLayers.length > 0 - const combinedLayers = - joinConfig.level === 'spatial' - ? [joinConfig.pointLayerId, joinConfig.polygonLayerId] - .map((id) => mapViews.find((l) => l.id === id)) - .filter(Boolean) - : mapViews.filter((l) => joinConfig.layerIds.includes(l.id)) + const { level, layerIds, pointLayerId, polygonLayerId } = joinConfig + const combinedLayers = useMemo( + () => + level === 'spatial' + ? [pointLayerId, polygonLayerId] + .map((id) => mapViews.find((l) => l.id === id)) + .filter(Boolean) + : mapViews.filter((l) => layerIds.includes(l.id)), + [level, layerIds, pointLayerId, polygonLayerId, mapViews] + ) const activeLayer = openLayers.find((l) => l.id === activeLayerId) const dataFilters = activeLayer?.dataFilters ?? EMPTY_FILTERS diff --git a/src/components/datatable/__tests__/CombinedDataTable.spec.jsx b/src/components/datatable/__tests__/CombinedDataTable.spec.jsx index c712fab26c..5a58b98dd7 100644 --- a/src/components/datatable/__tests__/CombinedDataTable.spec.jsx +++ b/src/components/datatable/__tests__/CombinedDataTable.spec.jsx @@ -1,8 +1,21 @@ import { render, screen } from '@testing-library/react' import React from 'react' import { VirtuosoMockContext } from 'react-virtuoso' +import useOrgUnitAncestorNames from '../../../hooks/useOrgUnitAncestorNames.js' import CombinedDataTable from '../CombinedDataTable.jsx' +jest.mock('../../../hooks/useOrgUnitAncestorNames.js', () => ({ + __esModule: true, + default: jest.fn(), +})) + +beforeEach(() => { + useOrgUnitAncestorNames.mockReturnValue({ + idToName: new Map(), + loading: false, + }) +}) + const feature = (props) => ({ properties: props }) const renderCombinedDataTable = (props) => @@ -26,6 +39,11 @@ const renderCombinedDataTable = (props) => describe('CombinedDataTable', () => { test('renders a column header per computed header and a cell per row', () => { + useOrgUnitAncestorNames.mockReturnValue({ + idToName: new Map([['ou1', 'Ou One']]), + loading: false, + }) + const layers = [ { id: 'layerA', @@ -33,7 +51,7 @@ describe('CombinedDataTable', () => { data: [ feature({ orgUnitId: 'ou1', - orgUnitOwn: 'Ou One', + orgUnitPath: '/country1/ou1', level: 2, rawValue: 10, legend: 'Low', diff --git a/src/components/datatable/__tests__/useCombinedTableData.spec.js b/src/components/datatable/__tests__/useCombinedTableData.spec.js index 338755ab22..90f4180471 100644 --- a/src/components/datatable/__tests__/useCombinedTableData.spec.js +++ b/src/components/datatable/__tests__/useCombinedTableData.spec.js @@ -20,6 +20,19 @@ const findCell = (row, dataKey) => row.find((c) => c.dataKey === dataKey) describe('useCombinedTableData - org unit join', () => { test('joins two layers by org unit id, filling blanks for unmatched org units', () => { + // orgUnitOwn is a raw id path at the data layer (see + // src/util/orgUnits.js's attachOrgUnitPaths) - real display names + // only exist via useOrgUnitAncestorNames's idToName map, resolved + // through formatOrgUnitOwnName. Using pre-resolved strings here + // would mask a bug where that resolution step is skipped. + useOrgUnitAncestorNames.mockReturnValue({ + idToName: new Map([ + ['ou1', 'Ou One'], + ['ou2', 'Ou Two'], + ]), + loading: false, + }) + const layers = [ { id: 'layerA', @@ -27,7 +40,7 @@ describe('useCombinedTableData - org unit join', () => { data: [ feature({ orgUnitId: 'ou1', - orgUnitOwn: 'Country / Ou 1', + orgUnitPath: '/country1/ou1', level: 2, rawValue: 10, legend: 'Low', @@ -40,7 +53,7 @@ describe('useCombinedTableData - org unit join', () => { data: [ feature({ orgUnitId: 'ou2', - orgUnitOwn: 'Country / Ou 2', + orgUnitPath: '/country1/ou2', level: 2, rawValue: 20, legend: 'High', @@ -73,17 +86,46 @@ describe('useCombinedTableData - org unit join', () => { const row1 = result.current.rows.find( (r) => findCell(r, 'id').value === 'ou1' ) - expect(findCell(row1, 'name').value).toBe('Country / Ou 1') + expect(findCell(row1, 'name').value).toBe('Ou One') expect(findCell(row1, 'layerA_rawValue').value).toBe(10) expect(findCell(row1, 'layerB_rawValue').value).toBe(null) const row2 = result.current.rows.find( (r) => findCell(r, 'id').value === 'ou2' ) + expect(findCell(row2, 'name').value).toBe('Ou Two') expect(findCell(row2, 'layerA_rawValue').value).toBe(null) expect(findCell(row2, 'layerB_rawValue').value).toBe(20) }) + test('falls back to the raw org unit id when its name has not resolved yet', () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + data: [ + feature({ + orgUnitId: 'ou1', + orgUnitPath: '/country1/ou1', + rawValue: 10, + }), + ], + }, + ] + const joinConfig = { + level: 'orgUnit', + layerIds: ['layerA'], + pointLayerId: null, + polygonLayerId: null, + } + + const { result } = renderHook(() => + useCombinedTableData({ layers, joinConfig }) + ) + + expect(findCell(result.current.rows[0], 'name').value).toBe('ou1') + }) + test('excludes features with hasAdditionalGeometry set', () => { const layers = [ { @@ -197,7 +239,7 @@ describe('useCombinedTableData - spatial join', () => { data: [ { type: 'Feature', - properties: { id: 'p1', orgUnitOwn: 'Point One' }, + properties: { id: 'p1', name: 'Point One' }, geometry: { type: 'Point', coordinates: [1, 1] }, }, ], @@ -225,7 +267,7 @@ describe('useCombinedTableData - spatial join', () => { ], } - test('joins a point layer to a polygon layer by spatial containment', () => { + test("falls back to the feature's own name when it has no org unit path", () => { const joinConfig = { level: 'spatial', layerIds: [], @@ -261,6 +303,45 @@ describe('useCombinedTableData - spatial join', () => { expect(result.current.spatialWarning).toBe(false) }) + test('resolves the org unit name when the point feature has an org unit path', () => { + useOrgUnitAncestorNames.mockReturnValue({ + idToName: new Map([['p1', 'Resolved Point Name']]), + loading: false, + }) + + const pointLayerWithOrgUnit = { + ...pointLayer, + data: [ + { + type: 'Feature', + properties: { + id: 'p1', + name: 'Point One', + orgUnitPath: '/country1/p1', + }, + geometry: { type: 'Point', coordinates: [1, 1] }, + }, + ], + } + const joinConfig = { + level: 'spatial', + layerIds: [], + pointLayerId: 'points', + polygonLayerId: 'polygons', + } + + const { result } = renderHook(() => + useCombinedTableData({ + layers: [pointLayerWithOrgUnit, polygonLayer], + joinConfig, + }) + ) + + expect(findCell(result.current.rows[0], 'name').value).toBe( + 'Resolved Point Name' + ) + }) + test('returns an empty result when the point or polygon layer is not found', () => { const joinConfig = { level: 'spatial', diff --git a/src/components/datatable/useCombinedTableData.js b/src/components/datatable/useCombinedTableData.js index 5030e00423..5400a4879a 100644 --- a/src/components/datatable/useCombinedTableData.js +++ b/src/components/datatable/useCombinedTableData.js @@ -3,12 +3,12 @@ import { useMemo } from 'react' import { ORG_UNIT_ID_DATA_KEY, ORG_UNIT_PATH_DATA_KEY, - ORG_UNIT_DATA_KEY, ORG_UNIT_LEVEL_DATA_KEY, TYPE_NUMBER, TYPE_STRING, } from '../../constants/dataTable.js' import useOrgUnitAncestorNames from '../../hooks/useOrgUnitAncestorNames.js' +import { formatOrgUnitOwnName } from '../../util/orgUnitGroups.js' import { spatialJoin } from '../../util/spatialJoin.js' const VALUE_KEY = 'rawValue' @@ -36,6 +36,13 @@ export const useCombinedTableData = ({ layers, joinConfig }) => { const isSpatial = level === 'spatial' const isParentGrouped = level === 'parentOrgUnit' + const pointLayer = isSpatial + ? layers.find((l) => l.id === pointLayerId) + : null + const polygonLayer = isSpatial + ? layers.find((l) => l.id === polygonLayerId) + : null + const layerMaps = useMemo(() => { if (isSpatial) { return [] @@ -61,25 +68,27 @@ export const useCombinedTableData = ({ layers, joinConfig }) => { [layerMaps] ) - const parentPaths = useMemo(() => { - if (!isParentGrouped) { - return [] + // useOrgUnitAncestorNames resolves every id along each path it's given + // (not just the leaf), so passing each matched org unit's own full path + // also resolves its parent's name for free in parentOrgUnit mode - no + // need for a separate parent-path-only list. + const orgUnitPaths = useMemo(() => { + if (isSpatial) { + return (pointLayer?.data ?? []) + .map((d) => (d.properties || d)[ORG_UNIT_PATH_DATA_KEY]) + .filter(Boolean) } - const paths = new Set() - allIds.forEach((id) => { - const baseProps = layerMaps.find((lm) => lm.byOrgUnit[id]) - ?.byOrgUnit[id] - const parentPath = getParentPath( - baseProps?.[ORG_UNIT_PATH_DATA_KEY] + return allIds + .map( + (id) => + layerMaps.find((lm) => lm.byOrgUnit[id])?.byOrgUnit[id]?.[ + ORG_UNIT_PATH_DATA_KEY + ] ) - if (parentPath) { - paths.add(parentPath) - } - }) - return [...paths] - }, [layerMaps, allIds, isParentGrouped]) + .filter(Boolean) + }, [isSpatial, pointLayer, layerMaps, allIds]) - const { idToName: parentIdToName } = useOrgUnitAncestorNames(parentPaths) + const { idToName } = useOrgUnitAncestorNames(orgUnitPaths) return useMemo(() => { if (!layers?.length) { @@ -87,8 +96,6 @@ export const useCombinedTableData = ({ layers, joinConfig }) => { } if (isSpatial) { - const pointLayer = layers.find((l) => l.id === pointLayerId) - const polygonLayer = layers.find((l) => l.id === polygonLayerId) if (!pointLayer || !polygonLayer) { return EMPTY_RESULT } @@ -118,28 +125,33 @@ export const useCombinedTableData = ({ layers, joinConfig }) => { }, ] - const rows = joined.map(({ pointProps, polygonProps }) => [ - { dataKey: 'id', value: pointProps.id ?? null, align: 'left' }, - { - dataKey: 'name', - value: - pointProps[ORG_UNIT_DATA_KEY] ?? - pointProps.name ?? - pointProps.id ?? - null, - align: 'left', - }, - { - dataKey: `${polygonLayer.id}_${VALUE_KEY}`, - value: polygonProps?.[VALUE_KEY] ?? null, - align: 'right', - }, - { - dataKey: `${polygonLayer.id}_${LEGEND_KEY}`, - value: polygonProps?.[LEGEND_KEY] ?? null, - align: 'left', - }, - ]) + const rows = joined.map(({ pointProps, polygonProps }) => { + const path = pointProps[ORG_UNIT_PATH_DATA_KEY] + return [ + { + dataKey: 'id', + value: pointProps.id ?? null, + align: 'left', + }, + { + dataKey: 'name', + value: path + ? formatOrgUnitOwnName(path, idToName) + : pointProps.name ?? pointProps.id ?? null, + align: 'left', + }, + { + dataKey: `${polygonLayer.id}_${VALUE_KEY}`, + value: polygonProps?.[VALUE_KEY] ?? null, + align: 'right', + }, + { + dataKey: `${polygonLayer.id}_${LEGEND_KEY}`, + value: polygonProps?.[LEGEND_KEY] ?? null, + align: 'left', + }, + ] + }) return { headers, rows, spatialWarning } } @@ -177,7 +189,7 @@ export const useCombinedTableData = ({ layers, joinConfig }) => { groups.set(key, { id: parentId, name: parentId - ? parentIdToName.get(parentId) ?? parentId + ? idToName.get(parentId) ?? parentId : i18n.t('No parent'), memberIds: [], }) @@ -224,11 +236,12 @@ export const useCombinedTableData = ({ layers, joinConfig }) => { const rows = allIds.map((id) => { const baseProps = layerMaps.find((lm) => lm.byOrgUnit[id])?.byOrgUnit[id] ?? {} + const path = baseProps[ORG_UNIT_PATH_DATA_KEY] const cells = [ { dataKey: 'id', value: id, align: 'left' }, { dataKey: 'name', - value: baseProps[ORG_UNIT_DATA_KEY] ?? null, + value: path ? formatOrgUnitOwnName(path, idToName) : null, align: 'left', }, { @@ -260,8 +273,8 @@ export const useCombinedTableData = ({ layers, joinConfig }) => { allIds, isSpatial, isParentGrouped, - pointLayerId, - polygonLayerId, - parentIdToName, + pointLayer, + polygonLayer, + idToName, ]) } diff --git a/src/reducers/__tests__/dataTable.spec.js b/src/reducers/__tests__/dataTable.spec.js index 2fa917930c..c1c9004263 100644 --- a/src/reducers/__tests__/dataTable.spec.js +++ b/src/reducers/__tests__/dataTable.spec.js @@ -127,6 +127,26 @@ describe('dataTable reducer', () => { expect(state.combinedView).toBe(true) expect(state.joinConfig).toBe(prevState.joinConfig) }) + + it('resets combinedView and joinConfig when closing the last open tab', () => { + const prevState = { + openIds: ['layer1'], + combinedView: true, + joinConfig: { + level: 'spatial', + layerIds: [], + pointLayerId: 'layerA', + polygonLayerId: 'layerB', + }, + } + + const state = dataTable(prevState, { + type: types.DATA_TABLE_TOGGLE, + id: 'layer1', + }) + + expect(state).toEqual(initialState) + }) }) describe('LAYER_REMOVE', () => { diff --git a/src/reducers/dataTable.js b/src/reducers/dataTable.js index dbeeeedbba..98d400386c 100644 --- a/src/reducers/dataTable.js +++ b/src/reducers/dataTable.js @@ -38,13 +38,17 @@ const dataTable = (state = initialState, action) => { case types.MAP_SET: return action.payload.dataTable ?? initialState - case types.DATA_TABLE_TOGGLE: - return { - ...state, - openIds: state.openIds.includes(action.id) - ? state.openIds.filter((id) => id !== action.id) - : [...state.openIds, action.id], - } + case types.DATA_TABLE_TOGGLE: { + const openIds = state.openIds.includes(action.id) + ? state.openIds.filter((id) => id !== action.id) + : [...state.openIds, action.id] + // Closing the last tab this way (rather than via DATA_TABLE_CLOSE) + // still means the panel is now fully closed (see App.jsx, which + // gates rendering it on openIds.length > 0) - so it gets the same + // full reset, or a stale combinedView/joinConfig would resurface + // the next time any single layer's table is reopened. + return openIds.length === 0 ? initialState : { ...state, openIds } + } case types.LAYER_REMOVE: { const joinConfig = clearJoinConfigRefs(state.joinConfig, action.id) From 2d364de5862fb82a62f13ae20f2b0d5bb6144b3a Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 28 Jul 2026 11:19:58 +0200 Subject: [PATCH 143/205] fix: fall back to feature id when orgUnitId is absent in Combined view joins The Combined table showed 0 rows for org unit and parent-org-unit joins whenever the participating layers were org-unit-identity types (thematic, org unit, facility) - confirmed live against real running data. orgUnitId is only populated by attachOrgUnitPaths() (util/orgUnits.js), used for layers whose features reference an org unit they aren't themselves (events, tracked entities). For layers where the feature IS the org unit, data is built by the older toGeoJson() (util/map.js), which sets orgUnitPath/orgUnitOwn/level but never orgUnitId - the org unit's id is just the feature's own id there. useCombinedTableData only read orgUnitId, so byOrgUnit came back empty for these layer types and every row got filtered out. Also fixes the test fixtures, which had set orgUnitId explicitly and so never exercised this path - same masking pattern as the earlier orgUnitOwn-as-resolved-name fixture bug. --- .../__tests__/useCombinedTableData.spec.js | 64 +++++++++++++++---- .../datatable/useCombinedTableData.js | 12 +++- 2 files changed, 61 insertions(+), 15 deletions(-) diff --git a/src/components/datatable/__tests__/useCombinedTableData.spec.js b/src/components/datatable/__tests__/useCombinedTableData.spec.js index 90f4180471..82137af8ef 100644 --- a/src/components/datatable/__tests__/useCombinedTableData.spec.js +++ b/src/components/datatable/__tests__/useCombinedTableData.spec.js @@ -19,12 +19,16 @@ const feature = (props) => ({ properties: props }) const findCell = (row, dataKey) => row.find((c) => c.dataKey === dataKey) describe('useCombinedTableData - org unit join', () => { - test('joins two layers by org unit id, filling blanks for unmatched org units', () => { - // orgUnitOwn is a raw id path at the data layer (see - // src/util/orgUnits.js's attachOrgUnitPaths) - real display names - // only exist via useOrgUnitAncestorNames's idToName map, resolved - // through formatOrgUnitOwnName. Using pre-resolved strings here - // would mask a bug where that resolution step is skipped. + // Thematic/org unit/facility layers - where the feature IS the org unit + // - never get an orgUnitId property: their data is built by toGeoJson() + // in util/map.js, which only sets id/orgUnitPath/orgUnitOwn. Only + // event/tracked-entity layers (via attachOrgUnitPaths in + // util/orgUnits.js, referencing an org unit the feature isn't itself) + // get a real orgUnitId. This is the shape that actually appears in + // production for the two most common layer types in this join mode - + // using orgUnitId in the fixture here would mask exactly the bug this + // guards against. + test('joins two org-unit-identity layers (no orgUnitId property) by their own id, filling blanks for unmatched org units', () => { useOrgUnitAncestorNames.mockReturnValue({ idToName: new Map([ ['ou1', 'Ou One'], @@ -39,7 +43,7 @@ describe('useCombinedTableData - org unit join', () => { name: 'Layer A', data: [ feature({ - orgUnitId: 'ou1', + id: 'ou1', orgUnitPath: '/country1/ou1', level: 2, rawValue: 10, @@ -52,7 +56,7 @@ describe('useCombinedTableData - org unit join', () => { name: 'Layer B', data: [ feature({ - orgUnitId: 'ou2', + id: 'ou2', orgUnitPath: '/country1/ou2', level: 2, rawValue: 20, @@ -98,13 +102,16 @@ describe('useCombinedTableData - org unit join', () => { expect(findCell(row2, 'layerB_rawValue').value).toBe(20) }) - test('falls back to the raw org unit id when its name has not resolved yet', () => { + test('prefers orgUnitId over id when both are present (event/tracked-entity layer shape)', () => { const layers = [ { id: 'layerA', name: 'Layer A', data: [ + // The event's own id ('evt1') is not an org unit - + // orgUnitId is the registering org unit and must win. feature({ + id: 'evt1', orgUnitId: 'ou1', orgUnitPath: '/country1/ou1', rawValue: 10, @@ -123,6 +130,35 @@ describe('useCombinedTableData - org unit join', () => { useCombinedTableData({ layers, joinConfig }) ) + expect(result.current.rows).toHaveLength(1) + expect(findCell(result.current.rows[0], 'id').value).toBe('ou1') + }) + + test('falls back to the raw org unit id when its name has not resolved yet', () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + data: [ + feature({ + id: 'ou1', + orgUnitPath: '/country1/ou1', + rawValue: 10, + }), + ], + }, + ] + const joinConfig = { + level: 'orgUnit', + layerIds: ['layerA'], + pointLayerId: null, + polygonLayerId: null, + } + + const { result } = renderHook(() => + useCombinedTableData({ layers, joinConfig }) + ) + expect(findCell(result.current.rows[0], 'name').value).toBe('ou1') }) @@ -133,11 +169,11 @@ describe('useCombinedTableData - org unit join', () => { name: 'Layer A', data: [ feature({ - orgUnitId: 'ou1', + id: 'ou1', rawValue: 10, hasAdditionalGeometry: true, }), - feature({ orgUnitId: 'ou2', rawValue: 20 }), + feature({ id: 'ou2', rawValue: 20 }), ], }, ] @@ -165,12 +201,12 @@ describe('useCombinedTableData - parent org unit grouping', () => { name: 'Layer A', data: [ feature({ - orgUnitId: 'ou1', + id: 'ou1', orgUnitPath: '/country1/parent1/ou1', rawValue: 10, }), feature({ - orgUnitId: 'ou2', + id: 'ou2', orgUnitPath: '/country1/parent1/ou2', rawValue: 20, }), @@ -208,7 +244,7 @@ describe('useCombinedTableData - parent org unit grouping', () => { name: 'Layer A', data: [ feature({ - orgUnitId: 'ou1', + id: 'ou1', orgUnitPath: '/ou1', rawValue: 10, }), diff --git a/src/components/datatable/useCombinedTableData.js b/src/components/datatable/useCombinedTableData.js index 5400a4879a..3a9cbc791a 100644 --- a/src/components/datatable/useCombinedTableData.js +++ b/src/components/datatable/useCombinedTableData.js @@ -54,7 +54,17 @@ export const useCombinedTableData = ({ layers, joinConfig }) => { .filter((d) => !d.properties?.hasAdditionalGeometry) .map((d) => { const props = d.properties || d - return [props[ORG_UNIT_ID_DATA_KEY], props] + // orgUnitId is only populated for layers where the + // feature references an org unit it isn't itself + // (events, tracked entities - via attachOrgUnitPaths + // in util/orgUnits.js). For layers where the feature + // IS the org unit (thematic, org unit, facility), + // properties are built by toGeoJson() in + // util/map.js, which never sets orgUnitId - the org + // unit's own id is just the feature's plain id there. + const orgUnitId = + props[ORG_UNIT_ID_DATA_KEY] ?? props.id + return [orgUnitId, props] }) .filter(([id]) => id != null) ), From c2597eac2f584c55e0c77f5627e67e9856185aa3 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 28 Jul 2026 11:51:38 +0200 Subject: [PATCH 144/205] feat: extend feature/selection state with additive crossLayerIds [DHIS2-20543] A Combined-view row can span multiple layers at once, but state.feature and state.selection are each scoped to a single layerId - there was no way to say "highlight these ids on layer A and those ids on layer B" simultaneously. Add an optional crossLayerIds map to both, consumed alongside the existing single-layer id in Layer.js, so every existing single-layer dispatch (map hover, table row selection) is unaffected. --- src/actions/selection.js | 5 ++ src/components/map/layers/Layer.js | 25 ++++-- .../map/layers/__tests__/Layer.spec.js | 85 +++++++++++++++++++ src/constants/actionTypes.js | 1 + src/reducers/__tests__/selection.spec.js | 59 +++++++++++++ src/reducers/selection.js | 35 +++++++- 6 files changed, 202 insertions(+), 8 deletions(-) diff --git a/src/actions/selection.js b/src/actions/selection.js index 6b5795c46e..603e9a0e9e 100644 --- a/src/actions/selection.js +++ b/src/actions/selection.js @@ -21,3 +21,8 @@ export const selectFeatureRange = (ids, layerId) => ({ export const clearSelection = () => ({ type: types.SELECTION_CLEAR, }) + +export const setCrossLayerSelection = (crossLayerIds) => ({ + type: types.SELECTION_SET_CROSS_LAYER, + crossLayerIds, +}) diff --git a/src/components/map/layers/Layer.js b/src/components/map/layers/Layer.js index a274a071b6..6b1a6a5354 100644 --- a/src/components/map/layers/Layer.js +++ b/src/components/map/layers/Layer.js @@ -117,7 +117,12 @@ class Layer extends PureComponent { this.handleFeatureUpdate(feature) - if (this.getHoverId(prevProps.feature) !== this.getHoverId(feature)) { + if ( + !idsEqual( + this.getHoverIds(prevProps.feature), + this.getHoverIds(feature) + ) + ) { this.highlightFeature() } } @@ -140,7 +145,7 @@ class Layer extends PureComponent { return } - if (this.getHoverId()) { + if (this.getHoverIds().length) { this.highlightFeature() } if (this.getSelectedIds().length) { @@ -272,16 +277,24 @@ class Layer extends PureComponent { } } - getHoverId(feature = this.props.feature) { - return feature?.layerId === this.props.id ? feature.id : null + // crossLayerIds is populated only for cross-layer highlights/selections + // (e.g. a Combined-view row spanning multiple layers) - single-layer + // hover/selection dispatches never set it, so ownId/ownIds alone still + // fully determine the result for every existing call site. + getHoverIds(feature = this.props.feature) { + const ownId = feature?.layerId === this.props.id ? feature.id : null + const crossIds = feature?.crossLayerIds?.[this.props.id] ?? [] + return ownId != null ? [ownId, ...crossIds] : crossIds } getSelectedIds(selection = this.props.selection) { - return selection?.layerId === this.props.id ? selection.ids : [] + const ownIds = selection?.layerId === this.props.id ? selection.ids : [] + const crossIds = selection?.crossLayerIds?.[this.props.id] ?? [] + return crossIds.length ? [...new Set([...ownIds, ...crossIds])] : ownIds } highlightFeature() { - this.layer?.highlight?.(this.getHoverId(), this.props.highlightColor) + this.layer?.highlight?.(this.getHoverIds(), this.props.highlightColor) } selectFeatures() { diff --git a/src/components/map/layers/__tests__/Layer.spec.js b/src/components/map/layers/__tests__/Layer.spec.js index 77d7660b8f..b03ff6e982 100644 --- a/src/components/map/layers/__tests__/Layer.spec.js +++ b/src/components/map/layers/__tests__/Layer.spec.js @@ -63,3 +63,88 @@ describe('Layer#getVisibleIds', () => { expect(layer.getVisibleIds()).toBe(null) }) }) + +describe('Layer#getHoverIds', () => { + test('returns an empty array when there is no feature', () => { + const layer = createLayer({ id: 'layer1', feature: null }) + expect(layer.getHoverIds()).toEqual([]) + }) + + test("returns this layer's own hover id when feature.layerId matches", () => { + const layer = createLayer({ + id: 'layer1', + feature: { id: 'a', layerId: 'layer1' }, + }) + expect(layer.getHoverIds()).toEqual(['a']) + }) + + test("returns an empty array when the feature belongs to a different layer and there's no crossLayerIds entry", () => { + const layer = createLayer({ + id: 'layer1', + feature: { id: 'a', layerId: 'other-layer' }, + }) + expect(layer.getHoverIds()).toEqual([]) + }) + + test("merges this layer's crossLayerIds entry alongside its own hover id", () => { + const layer = createLayer({ + id: 'layer1', + feature: { + id: 'a', + layerId: 'layer1', + crossLayerIds: { layer1: ['x', 'y'] }, + }, + }) + expect(layer.getHoverIds()).toEqual(['a', 'x', 'y']) + }) + + test('returns only crossLayerIds when the feature has no own-layer match', () => { + const layer = createLayer({ + id: 'layer1', + feature: { + layerId: null, + crossLayerIds: { layer1: ['x', 'y'], layer2: ['z'] }, + }, + }) + expect(layer.getHoverIds()).toEqual(['x', 'y']) + }) +}) + +describe('Layer#getSelectedIds', () => { + test('returns an empty array when there is no selection', () => { + const layer = createLayer({ id: 'layer1', selection: null }) + expect(layer.getSelectedIds()).toEqual([]) + }) + + test("returns this layer's own selected ids when selection.layerId matches", () => { + const layer = createLayer({ + id: 'layer1', + selection: { layerId: 'layer1', ids: ['a', 'b'] }, + }) + expect(layer.getSelectedIds()).toEqual(['a', 'b']) + }) + + test('merges crossLayerIds with a same-layer selection, deduping', () => { + const layer = createLayer({ + id: 'layer1', + selection: { + layerId: 'layer1', + ids: ['a'], + crossLayerIds: { layer1: ['a', 'b'] }, + }, + }) + expect(layer.getSelectedIds()).toEqual(['a', 'b']) + }) + + test('returns only crossLayerIds when the selection has no own-layer match', () => { + const layer = createLayer({ + id: 'layer1', + selection: { + layerId: null, + ids: [], + crossLayerIds: { layer1: ['x'], layer2: ['y'] }, + }, + }) + expect(layer.getSelectedIds()).toEqual(['x']) + }) +}) diff --git a/src/constants/actionTypes.js b/src/constants/actionTypes.js index 4a5c5d7101..5ff54f0d5d 100644 --- a/src/constants/actionTypes.js +++ b/src/constants/actionTypes.js @@ -196,6 +196,7 @@ export const FEATURE_PROFILE_CLOSE = 'FEATURE_PROFILE_CLOSE' export const FEATURE_TOGGLE_SELECTION = 'FEATURE_TOGGLE_SELECTION' export const SELECTION_SET_ALL = 'SELECTION_SET_ALL' export const SELECTION_ADD_RANGE = 'SELECTION_ADD_RANGE' +export const SELECTION_SET_CROSS_LAYER = 'SELECTION_SET_CROSS_LAYER' export const SELECTION_CLEAR = 'SELECTION_CLEAR' /* AGGREGATIONS */ diff --git a/src/reducers/__tests__/selection.spec.js b/src/reducers/__tests__/selection.spec.js index 2a0c0bf950..dde0fe4870 100644 --- a/src/reducers/__tests__/selection.spec.js +++ b/src/reducers/__tests__/selection.spec.js @@ -159,6 +159,65 @@ describe('selection reducer', () => { expect(state).toBe(prevState) }) + it('sets a cross-layer selection, clearing any single-layer selection', () => { + const state = selection( + { layerId: 'layer-1', ids: ['a'] }, + { + type: types.SELECTION_SET_CROSS_LAYER, + crossLayerIds: { layerA: ['a1'], layerB: ['b1', 'b2'] }, + } + ) + + expect(state).toEqual({ + layerId: null, + ids: [], + crossLayerIds: { layerA: ['a1'], layerB: ['b1', 'b2'] }, + }) + }) + + it('resets to default state when setting an empty cross-layer selection', () => { + const state = selection( + { + layerId: null, + ids: [], + crossLayerIds: { layerA: ['a1'] }, + }, + { type: types.SELECTION_SET_CROSS_LAYER, crossLayerIds: {} } + ) + + expect(state).toEqual({ layerId: null, ids: [] }) + }) + + it("prunes a removed layer's entry from a cross-layer selection", () => { + const state = selection( + { + layerId: null, + ids: [], + crossLayerIds: { layerA: ['a1'], layerB: ['b1'] }, + }, + { type: types.LAYER_REMOVE, id: 'layerA' } + ) + + expect(state).toEqual({ + layerId: null, + ids: [], + crossLayerIds: { layerB: ['b1'] }, + }) + }) + + it('resets to default state when removing the last layer in a cross-layer selection', () => { + const state = selection( + { + layerId: null, + ids: [], + crossLayerIds: { layerA: ['a1'] }, + }, + { type: types.LAYER_REMOVE, id: 'layerA' } + ) + + expect(state).toEqual({ layerId: null, ids: [] }) + }) + it('ignores unrelated actions', () => { const prevState = { layerId: 'layer-1', ids: ['a'] } diff --git a/src/reducers/selection.js b/src/reducers/selection.js index 6703953dd5..b736746384 100644 --- a/src/reducers/selection.js +++ b/src/reducers/selection.js @@ -2,6 +2,15 @@ import * as types from '../constants/actionTypes.js' const defaultState = { layerId: null, ids: [] } +const removeCrossLayerId = (crossLayerIds, layerId) => { + if (!crossLayerIds?.[layerId]) { + return crossLayerIds + } + return Object.fromEntries( + Object.entries(crossLayerIds).filter(([id]) => id !== layerId) + ) +} + const selection = (state = defaultState, action) => { switch (action.type) { case types.FEATURE_TOGGLE_SELECTION: { @@ -31,6 +40,15 @@ const selection = (state = defaultState, action) => { } } + case types.SELECTION_SET_CROSS_LAYER: + return Object.keys(action.crossLayerIds).length + ? { + layerId: null, + ids: [], + crossLayerIds: action.crossLayerIds, + } + : defaultState + case types.SELECTION_CLEAR: case types.MAP_NEW: case types.MAP_SET: @@ -40,8 +58,21 @@ const selection = (state = defaultState, action) => { case types.DATA_TABLE_TOGGLE: return state.layerId === action.id ? defaultState : state - case types.LAYER_REMOVE: - return state.layerId === action.id ? defaultState : state + case types.LAYER_REMOVE: { + if (state.layerId === action.id) { + return defaultState + } + const crossLayerIds = removeCrossLayerId( + state.crossLayerIds, + action.id + ) + if (crossLayerIds === state.crossLayerIds) { + return state + } + return Object.keys(crossLayerIds).length + ? { ...state, crossLayerIds } + : defaultState + } default: return state From d0608a324dfb78faa2d38ac4ae64172d7a187c64 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 28 Jul 2026 11:59:21 +0200 Subject: [PATCH 145/205] feat: expose per-row cross-layer feature ids from useCombinedTableData [DHIS2-20543] Rows had no way to say "which feature id(s) on which layer(s) does this row correspond to", blocking cross-layer map highlight/selection for Combined rows. Add featureIdsByOrgUnit (all matching feature ids per org unit per layer, not just the one whose value is displayed) and a rowFeatureIds map from each row's key to its per-layer feature ids, covering all three join modes. --- .../__tests__/useCombinedTableData.spec.js | 68 +++++++++ .../datatable/useCombinedTableData.js | 140 ++++++++++++------ 2 files changed, 163 insertions(+), 45 deletions(-) diff --git a/src/components/datatable/__tests__/useCombinedTableData.spec.js b/src/components/datatable/__tests__/useCombinedTableData.spec.js index 82137af8ef..8f4e20f533 100644 --- a/src/components/datatable/__tests__/useCombinedTableData.spec.js +++ b/src/components/datatable/__tests__/useCombinedTableData.spec.js @@ -191,6 +191,33 @@ describe('useCombinedTableData - org unit join', () => { expect(result.current.rows).toHaveLength(1) expect(findCell(result.current.rows[0], 'id').value).toBe('ou2') }) + + test('rowFeatureIds includes every feature sharing an org unit, not just the last one displayed', () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + data: [ + feature({ id: 'evt1', orgUnitId: 'ou1', rawValue: 10 }), + feature({ id: 'evt2', orgUnitId: 'ou1', rawValue: 20 }), + ], + }, + ] + const joinConfig = { + level: 'orgUnit', + layerIds: ['layerA'], + pointLayerId: null, + polygonLayerId: null, + } + + const { result } = renderHook(() => + useCombinedTableData({ layers, joinConfig }) + ) + + expect(result.current.rowFeatureIds.get('ou1')).toEqual({ + layerA: ['evt1', 'evt2'], + }) + }) }) describe('useCombinedTableData - parent org unit grouping', () => { @@ -266,6 +293,41 @@ describe('useCombinedTableData - parent org unit grouping', () => { expect(findCell(result.current.rows[0], 'id').value).toBe(null) expect(findCell(result.current.rows[0], 'name').value).toBe('No parent') }) + + test("rowFeatureIds unions every member org unit's feature ids under the parent group", () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + data: [ + feature({ + id: 'ou1', + orgUnitPath: '/country1/parent1/ou1', + rawValue: 10, + }), + feature({ + id: 'ou2', + orgUnitPath: '/country1/parent1/ou2', + rawValue: 20, + }), + ], + }, + ] + const joinConfig = { + level: 'parentOrgUnit', + layerIds: ['layerA'], + pointLayerId: null, + polygonLayerId: null, + } + + const { result } = renderHook(() => + useCombinedTableData({ layers, joinConfig }) + ) + + expect(result.current.rowFeatureIds.get('parent1')).toEqual({ + layerA: ['ou1', 'ou2'], + }) + }) }) describe('useCombinedTableData - spatial join', () => { @@ -337,6 +399,10 @@ describe('useCombinedTableData - spatial join', () => { ], ]) expect(result.current.spatialWarning).toBe(false) + expect(result.current.rowFeatureIds.get('p1')).toEqual({ + points: ['p1'], + polygons: ['poly1'], + }) }) test('resolves the org unit name when the point feature has an org unit path', () => { @@ -393,6 +459,7 @@ describe('useCombinedTableData - spatial join', () => { expect(result.current).toEqual({ headers: [], rows: [], + rowFeatureIds: new Map(), spatialWarning: false, }) }) @@ -440,6 +507,7 @@ describe('useCombinedTableData - empty input', () => { expect(result.current).toEqual({ headers: [], rows: [], + rowFeatureIds: new Map(), spatialWarning: false, }) }) diff --git a/src/components/datatable/useCombinedTableData.js b/src/components/datatable/useCombinedTableData.js index 3a9cbc791a..9c5f44786f 100644 --- a/src/components/datatable/useCombinedTableData.js +++ b/src/components/datatable/useCombinedTableData.js @@ -29,7 +29,12 @@ const getParentPath = (path) => { return segments.length > 1 ? segments.slice(0, -1).join('/') : null } -const EMPTY_RESULT = { headers: [], rows: [], spatialWarning: false } +const EMPTY_RESULT = { + headers: [], + rows: [], + rowFeatureIds: new Map(), + spatialWarning: false, +} export const useCombinedTableData = ({ layers, joinConfig }) => { const { level, pointLayerId, polygonLayerId } = joinConfig @@ -47,28 +52,41 @@ export const useCombinedTableData = ({ layers, joinConfig }) => { if (isSpatial) { return [] } - return layers.map((layer) => ({ - layer, - byOrgUnit: Object.fromEntries( - (layer.data ?? []) - .filter((d) => !d.properties?.hasAdditionalGeometry) - .map((d) => { - const props = d.properties || d - // orgUnitId is only populated for layers where the - // feature references an org unit it isn't itself - // (events, tracked entities - via attachOrgUnitPaths - // in util/orgUnits.js). For layers where the feature - // IS the org unit (thematic, org unit, facility), - // properties are built by toGeoJson() in - // util/map.js, which never sets orgUnitId - the org - // unit's own id is just the feature's plain id there. - const orgUnitId = - props[ORG_UNIT_ID_DATA_KEY] ?? props.id - return [orgUnitId, props] - }) - .filter(([id]) => id != null) - ), - })) + return layers.map((layer) => { + const byOrgUnit = {} + // Duplicate features can share one org unit (e.g. several events + // at the same facility) - byOrgUnit keeps only the last one for + // display purposes, but featureIdsByOrgUnit keeps every matching + // feature id so hover/selection can highlight all of them, not + // just the one whose value happens to be shown. + const featureIdsByOrgUnit = {} + + const data = layer.data ?? [] + data.filter((d) => !d.properties?.hasAdditionalGeometry).forEach( + (d) => { + const props = d.properties || d + // orgUnitId is only populated for layers where the + // feature references an org unit it isn't itself + // (events, tracked entities - via attachOrgUnitPaths + // in util/orgUnits.js). For layers where the feature + // IS the org unit (thematic, org unit, facility), + // properties are built by toGeoJson() in + // util/map.js, which never sets orgUnitId - the org + // unit's own id is just the feature's plain id there. + const orgUnitId = props[ORG_UNIT_ID_DATA_KEY] ?? props.id + if (orgUnitId == null) { + return + } + byOrgUnit[orgUnitId] = props + if (!featureIdsByOrgUnit[orgUnitId]) { + featureIdsByOrgUnit[orgUnitId] = [] + } + featureIdsByOrgUnit[orgUnitId].push(props.id) + } + ) + + return { layer, byOrgUnit, featureIdsByOrgUnit } + }) }, [layers, isSpatial]) const allIds = useMemo( @@ -135,8 +153,19 @@ export const useCombinedTableData = ({ layers, joinConfig }) => { }, ] + const rowFeatureIds = new Map() + const rows = joined.map(({ pointProps, polygonProps }) => { const path = pointProps[ORG_UNIT_PATH_DATA_KEY] + + if (pointProps.id != null) { + const entry = { [pointLayer.id]: [pointProps.id] } + if (polygonProps?.id != null) { + entry[polygonLayer.id] = [polygonProps.id] + } + rowFeatureIds.set(pointProps.id, entry) + } + return [ { dataKey: 'id', @@ -163,7 +192,7 @@ export const useCombinedTableData = ({ layers, joinConfig }) => { ] }) - return { headers, rows, spatialWarning } + return { headers, rows, rowFeatureIds, spatialWarning } } const layerHeaders = layerMaps.flatMap(({ layer }) => [ @@ -207,33 +236,46 @@ export const useCombinedTableData = ({ layers, joinConfig }) => { groups.get(key).memberIds.push(id) }) + const rowFeatureIds = new Map() + const rows = [...groups.values()].map((group) => { const cells = [ { dataKey: 'id', value: group.id, align: 'left' }, { dataKey: 'name', value: group.name, align: 'left' }, ] - layerMaps.forEach(({ layer, byOrgUnit }) => { - const values = group.memberIds - .map((id) => byOrgUnit[id]?.[VALUE_KEY]) - .filter((v) => v != null) - const average = values.length - ? values.reduce((a, b) => a + b, 0) / values.length - : null - cells.push({ - dataKey: `${layer.id}_${VALUE_KEY}`, - value: average, - align: 'right', - }) - cells.push({ - dataKey: `${layer.id}_${LEGEND_KEY}`, - value: null, - align: 'left', - }) - }) + const featureIds = {} + layerMaps.forEach( + ({ layer, byOrgUnit, featureIdsByOrgUnit }) => { + const values = group.memberIds + .map((id) => byOrgUnit[id]?.[VALUE_KEY]) + .filter((v) => v != null) + const average = values.length + ? values.reduce((a, b) => a + b, 0) / values.length + : null + cells.push({ + dataKey: `${layer.id}_${VALUE_KEY}`, + value: average, + align: 'right', + }) + cells.push({ + dataKey: `${layer.id}_${LEGEND_KEY}`, + value: null, + align: 'left', + }) + + const ids = group.memberIds.flatMap( + (id) => featureIdsByOrgUnit[id] ?? [] + ) + if (ids.length) { + featureIds[layer.id] = ids + } + } + ) + rowFeatureIds.set(group.id, featureIds) return cells }) - return { headers, rows, spatialWarning: false } + return { headers, rows, rowFeatureIds, spatialWarning: false } } const headers = [ @@ -243,6 +285,8 @@ export const useCombinedTableData = ({ layers, joinConfig }) => { ...layerHeaders, ] + const rowFeatureIds = new Map() + const rows = allIds.map((id) => { const baseProps = layerMaps.find((lm) => lm.byOrgUnit[id])?.byOrgUnit[id] ?? {} @@ -260,7 +304,8 @@ export const useCombinedTableData = ({ layers, joinConfig }) => { align: 'right', }, ] - layerMaps.forEach(({ layer, byOrgUnit }) => { + const featureIds = {} + layerMaps.forEach(({ layer, byOrgUnit, featureIdsByOrgUnit }) => { const props = byOrgUnit[id] cells.push({ dataKey: `${layer.id}_${VALUE_KEY}`, @@ -272,11 +317,16 @@ export const useCombinedTableData = ({ layers, joinConfig }) => { value: props?.[LEGEND_KEY] ?? null, align: 'left', }) + + if (featureIdsByOrgUnit[id]?.length) { + featureIds[layer.id] = featureIdsByOrgUnit[id] + } }) + rowFeatureIds.set(id, featureIds) return cells }) - return { headers, rows, spatialWarning: false } + return { headers, rows, rowFeatureIds, spatialWarning: false } }, [ layers, layerMaps, From 505c87283e6d7f13a9cea3b720ded7bbc3694082 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 28 Jul 2026 12:09:10 +0200 Subject: [PATCH 146/205] feat: reuse buildRowCells/filterData/compareRows for Combined sorting and filtering [DHIS2-20543] CombinedDataTable had no sorting or filtering at all. Restructure useCombinedTableData to build flat row objects per join mode and finalize them through the same filterData/filterByGlobalSearch/compareRows/ buildRowCells pipeline the single-layer table already uses, instead of hand-rolling cell arrays. Add column sort buttons and a per-column filter input to CombinedDataTable, and let global search/clear-filters in BottomPanel's toolbar apply to the Combined view too (session-only, matching its existing ephemeral column-config scope). --- i18n/en.pot | 16 +- src/components/datatable/BottomPanel.jsx | 62 ++++--- .../datatable/CombinedDataTable.jsx | 97 +++++++++- .../__tests__/CombinedDataTable.spec.jsx | 70 +++++++- .../__tests__/useCombinedTableData.spec.js | 101 ++++++++++- .../datatable/useCombinedTableData.js | 168 ++++++++++-------- 6 files changed, 398 insertions(+), 116 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 947a6ae2ab..8884cdf6c8 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-28T08:41:25.486Z\n" -"PO-Revision-Date: 2026-07-28T08:41:25.486Z\n" +"POT-Creation-Date: 2026-07-28T10:06:28.667Z\n" +"PO-Revision-Date: 2026-07-28T10:06:28.667Z\n" msgid "2020" msgstr "2020" @@ -179,6 +179,12 @@ msgstr "Combined" msgid "No matching rows" msgstr "No matching rows" +msgid "Search" +msgstr "Search" + +msgid "Sort by {{column}}" +msgstr "Sort by {{column}}" + msgid "Spatial join over large datasets may be slow (over {{threshold}} features)" msgstr "Spatial join over large datasets may be slow (over {{threshold}} features)" @@ -191,9 +197,6 @@ msgstr "Reverse selection of visible rows" msgid "Sort by Selected" msgstr "Sort by Selected" -msgid "Sort by {{column}}" -msgstr "Sort by {{column}}" - msgid "Edit layer" msgstr "Edit layer" @@ -236,9 +239,6 @@ msgstr "Use filter" msgid "Search or type > 5, < 8…" msgstr "Search or type > 5, < 8…" -msgid "Search" -msgstr "Search" - msgid "Reverse selection" msgstr "Reverse selection" diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index ba4f2c66d5..4c9f93bb6b 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -119,13 +119,16 @@ const BottomPanel = () => { const [isCollapsed, setIsCollapsed] = useState(false) const [globalSearch, setGlobalSearch] = useState('') const [headersByLayer, setHeadersByLayer] = useState(null) + const [combinedFilters, setCombinedFilters] = useState(EMPTY_FILTERS) - const hasActiveFilters = hasActiveDataTableFilters({ - dataFilters, - globalSearch, - selectionFilter, - showOnlyFeaturesInView, - }) + const hasActiveFilters = combinedView + ? Object.keys(combinedFilters).length > 0 || !!globalSearch.trim() + : hasActiveDataTableFilters({ + dataFilters, + globalSearch, + selectionFilter, + showOnlyFeaturesInView, + }) const { maxHeight, collapsedHeight, displayHeight } = getPanelHeights({ windowHeight: height, @@ -204,13 +207,17 @@ const BottomPanel = () => { : null const onClearFilters = useCallback(() => { - dispatch(clearDataFilters(activeLayerId)) - dispatch(setSelectionFilter([])) - setGlobalSearch('') - if (showOnlyFeaturesInView) { - dispatch(toggleShowOnlyFeaturesInView()) + if (combinedView) { + setCombinedFilters(EMPTY_FILTERS) + } else { + dispatch(clearDataFilters(activeLayerId)) + dispatch(setSelectionFilter([])) + if (showOnlyFeaturesInView) { + dispatch(toggleShowOnlyFeaturesInView()) + } } - }, [dispatch, activeLayerId, showOnlyFeaturesInView]) + setGlobalSearch('') + }, [dispatch, activeLayerId, showOnlyFeaturesInView, combinedView]) const onToggleShowOnlyFeaturesInView = useCallback(() => { dispatch(toggleShowOnlyFeaturesInView()) @@ -389,23 +396,21 @@ const BottomPanel = () => { filteredCount={filteredCount} /> <span className={styles.divider} /> + <ClearFiltersControl + disabled={!hasActiveFilters} + onClick={onClearFilters} + /> + <GlobalSearchControl + value={globalSearch} + onChange={setGlobalSearch} + /> {!combinedView && ( - <> - <ClearFiltersControl - disabled={!hasActiveFilters} - onClick={onClearFilters} - /> - <GlobalSearchControl - value={globalSearch} - onChange={setGlobalSearch} - /> - <ShowInViewControl - active={showOnlyFeaturesInView} - onClick={onToggleShowOnlyFeaturesInView} - /> - <span className={styles.divider} /> - </> + <ShowInViewControl + active={showOnlyFeaturesInView} + onClick={onToggleShowOnlyFeaturesInView} + /> )} + <span className={styles.divider} /> <CloseControl onClick={onCloseDataTable} /> </div> {showTabBar && ( @@ -468,6 +473,9 @@ const BottomPanel = () => { availableWidth={panelWidth} layers={combinedLayers} joinConfig={joinConfig} + filters={combinedFilters} + onFiltersChange={setCombinedFilters} + globalSearch={globalSearch} onCountChange={onCountChange} /> ) : ( diff --git a/src/components/datatable/CombinedDataTable.jsx b/src/components/datatable/CombinedDataTable.jsx index feb8e2f1aa..eef6c3b6df 100644 --- a/src/components/datatable/CombinedDataTable.jsx +++ b/src/components/datatable/CombinedDataTable.jsx @@ -6,15 +6,22 @@ import { DataTableRow, DataTableColumnHeader, DataTableCell, + Input, } from '@dhis2/ui' import PropTypes from 'prop-types' -import React, { useCallback, useEffect } from 'react' +import React, { useCallback, useEffect, useReducer } from 'react' import { TableVirtuoso } from 'react-virtuoso' +import { SORT_ASCENDING } from '../../constants/dataTable.js' +import { getNextSorting, isFilterable } from '../../util/dataTable.js' +import { SortIcon } from '../core/icons.jsx' import styles from './styles/CombinedDataTable.module.css' +import dataTableStyles from './styles/DataTable.module.css' +import TopTooltip from './TopTooltip.jsx' import { useCombinedTableData } from './useCombinedTableData.js' const TABLE_STYLE = { height: '100%', width: '100%' } const LARGE_FEATURE_THRESHOLD_LABEL = '10,000' +const EMPTY_FILTERS = {} const CombinedTable = (props) => ( <DataTable {...props} className={styles.dataTable} /> @@ -44,28 +51,105 @@ const CombinedDataTable = ({ availableWidth, layers, joinConfig, + filters, + onFiltersChange, + globalSearch, onCountChange, }) => { + const [{ sortField, sortDirection }, setSorting] = useReducer( + (sorting, newSorting) => ({ ...sorting, ...newSorting }), + { sortField: 'name', sortDirection: SORT_ASCENDING } + ) + + const sortData = useCallback( + ({ name }) => { + setSorting(getNextSorting(name, { sortField, sortDirection })) + }, + [sortField, sortDirection] + ) + const { headers, rows, spatialWarning } = useCombinedTableData({ layers, joinConfig, + sortField, + sortDirection, + filters, + globalSearch, }) useEffect(() => { onCountChange?.(rows.length, rows.length) }, [onCountChange, rows.length]) + const onFilterChange = useCallback( + (dataKey, value) => { + const next = { ...(filters ?? EMPTY_FILTERS) } + if (value) { + next[dataKey] = value + } else { + delete next[dataKey] + } + onFiltersChange?.(next) + }, + [filters, onFiltersChange] + ) + const fixedHeaderContent = useCallback( () => ( <DataTableRow> - {headers.map(({ name, dataKey }) => ( - <DataTableColumnHeader key={dataKey} name={dataKey}> - {name} + {headers.map(({ name, dataKey, type }) => ( + <DataTableColumnHeader + key={dataKey} + name={dataKey} + onFilterIconClick={ + isFilterable(dataKey, type) && Function.prototype + } + showFilter={isFilterable(dataKey, type)} + filter={ + isFilterable(dataKey, type) && ( + <Input + dense + clearable + dataTest={`combined-table-column-filter-${name}`} + placeholder={i18n.t('Search')} + value={filters?.[dataKey] ?? ''} + onChange={({ value }) => + onFilterChange(dataKey, value) + } + /> + ) + } + > + <span className={dataTableStyles.headerContent}> + <span className={dataTableStyles.headerTitle}> + {name} + </span> + <TopTooltip + content={i18n.t('Sort by {{column}}', { + column: name, + })} + > + <button + type="button" + className={dataTableStyles.sortButton} + data-test={`combined-table-column-sort-button-${name}`} + onClick={() => sortData({ name: dataKey })} + > + <SortIcon + direction={ + dataKey === sortField + ? sortDirection + : null + } + /> + </button> + </TopTooltip> + </span> </DataTableColumnHeader> ))} </DataTableRow> ), - [headers] + [headers, filters, onFilterChange, sortData, sortField, sortDirection] ) return ( @@ -110,7 +194,10 @@ CombinedDataTable.propTypes = { }).isRequired, layers: PropTypes.array.isRequired, availableWidth: PropTypes.number, + filters: PropTypes.object, + globalSearch: PropTypes.string, onCountChange: PropTypes.func, + onFiltersChange: PropTypes.func, } export default CombinedDataTable diff --git a/src/components/datatable/__tests__/CombinedDataTable.spec.jsx b/src/components/datatable/__tests__/CombinedDataTable.spec.jsx index 5a58b98dd7..04873d4b73 100644 --- a/src/components/datatable/__tests__/CombinedDataTable.spec.jsx +++ b/src/components/datatable/__tests__/CombinedDataTable.spec.jsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react' +import { render, screen, fireEvent } from '@testing-library/react' import React from 'react' import { VirtuosoMockContext } from 'react-virtuoso' import useOrgUnitAncestorNames from '../../../hooks/useOrgUnitAncestorNames.js' @@ -181,4 +181,72 @@ describe('CombinedDataTable', () => { expect(onCountChange).toHaveBeenCalledWith(2, 2) }) + + test('sorts rows when a column sort button is clicked', () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + data: [ + feature({ orgUnitId: 'ou1', rawValue: 20 }), + feature({ orgUnitId: 'ou2', rawValue: 10 }), + ], + }, + ] + + renderCombinedDataTable({ + layers, + joinConfig: { + level: 'orgUnit', + layerIds: ['layerA'], + pointLayerId: null, + polygonLayerId: null, + }, + }) + + const rowsBefore = screen.getAllByRole('row').slice(1) + expect(rowsBefore[0]).toHaveTextContent('ou1') + + fireEvent.click( + screen.getByTestId( + 'combined-table-column-sort-button-Value (Layer A)' + ) + ) + + const rowsAfter = screen.getAllByRole('row').slice(1) + expect(rowsAfter[0]).toHaveTextContent('ou2') + }) + + test('applies a per-column filter via onFiltersChange', () => { + const onFiltersChange = jest.fn() + const layers = [ + { + id: 'layerA', + name: 'Layer A', + data: [ + feature({ orgUnitId: 'ou1', rawValue: 20 }), + feature({ orgUnitId: 'ou2', rawValue: 10 }), + ], + }, + ] + + renderCombinedDataTable({ + layers, + joinConfig: { + level: 'orgUnit', + layerIds: ['layerA'], + pointLayerId: null, + polygonLayerId: null, + }, + filters: {}, + onFiltersChange, + }) + + const input = screen + .getByTestId('combined-table-column-filter-ID') + .querySelector('input') + fireEvent.change(input, { target: { value: 'ou1' } }) + + expect(onFiltersChange).toHaveBeenCalledWith({ id: 'ou1' }) + }) }) diff --git a/src/components/datatable/__tests__/useCombinedTableData.spec.js b/src/components/datatable/__tests__/useCombinedTableData.spec.js index 8f4e20f533..16cf8c6163 100644 --- a/src/components/datatable/__tests__/useCombinedTableData.spec.js +++ b/src/components/datatable/__tests__/useCombinedTableData.spec.js @@ -388,14 +388,25 @@ describe('useCombinedTableData - spatial join', () => { ]) expect(result.current.rows).toEqual([ [ - { dataKey: 'id', value: 'p1', align: 'left' }, - { dataKey: 'name', value: 'Point One', align: 'left' }, + { dataKey: 'id', value: 'p1', align: 'left', itemId: 'p1' }, + { + dataKey: 'name', + value: 'Point One', + align: 'left', + itemId: 'p1', + }, { dataKey: 'polygons_rawValue', value: 42, align: 'right', + itemId: 'p1', + }, + { + dataKey: 'polygons_legend', + value: 'High', + align: 'left', + itemId: 'p1', }, - { dataKey: 'polygons_legend', value: 'High', align: 'left' }, ], ]) expect(result.current.spatialWarning).toBe(false) @@ -491,6 +502,90 @@ describe('useCombinedTableData - spatial join', () => { }) }) +describe('useCombinedTableData - sorting and filtering', () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + data: [ + feature({ id: 'ou1', rawValue: 30 }), + feature({ id: 'ou2', rawValue: 10 }), + feature({ id: 'ou3', rawValue: 20 }), + ], + }, + ] + const joinConfig = { + level: 'orgUnit', + layerIds: ['layerA'], + pointLayerId: null, + polygonLayerId: null, + } + + test('sorts rows by a numeric column ascending', () => { + const { result } = renderHook(() => + useCombinedTableData({ + layers, + joinConfig, + sortField: 'layerA_rawValue', + sortDirection: 'asc', + }) + ) + + expect(result.current.rows.map((r) => findCell(r, 'id').value)).toEqual( + ['ou2', 'ou3', 'ou1'] + ) + }) + + test('sorts rows by a numeric column descending', () => { + const { result } = renderHook(() => + useCombinedTableData({ + layers, + joinConfig, + sortField: 'layerA_rawValue', + sortDirection: 'desc', + }) + ) + + expect(result.current.rows.map((r) => findCell(r, 'id').value)).toEqual( + ['ou1', 'ou3', 'ou2'] + ) + }) + + test('preserves natural order when there is no sort field', () => { + const { result } = renderHook(() => + useCombinedTableData({ layers, joinConfig }) + ) + + expect(result.current.rows.map((r) => findCell(r, 'id').value)).toEqual( + ['ou1', 'ou2', 'ou3'] + ) + }) + + test('applies a per-column filter', () => { + const { result } = renderHook(() => + useCombinedTableData({ + layers, + joinConfig, + filters: { layerA_rawValue: '>15' }, + }) + ) + + expect(result.current.rows.map((r) => findCell(r, 'id').value)).toEqual( + ['ou1', 'ou3'] + ) + }) + + test('applies global search across string columns', () => { + const { result } = renderHook(() => + useCombinedTableData({ layers, joinConfig, globalSearch: 'ou2' }) + ) + + expect(result.current.rows.map((r) => findCell(r, 'id').value)).toEqual( + ['ou2'] + ) + }) +}) + describe('useCombinedTableData - empty input', () => { test('returns an empty result when there are no layers', () => { const joinConfig = { diff --git a/src/components/datatable/useCombinedTableData.js b/src/components/datatable/useCombinedTableData.js index 9c5f44786f..8bab19f6c0 100644 --- a/src/components/datatable/useCombinedTableData.js +++ b/src/components/datatable/useCombinedTableData.js @@ -4,18 +4,47 @@ import { ORG_UNIT_ID_DATA_KEY, ORG_UNIT_PATH_DATA_KEY, ORG_UNIT_LEVEL_DATA_KEY, + SORT_ASCENDING, TYPE_NUMBER, TYPE_STRING, } from '../../constants/dataTable.js' import useOrgUnitAncestorNames from '../../hooks/useOrgUnitAncestorNames.js' +import { filterByGlobalSearch, filterData } from '../../util/filter.js' import { formatOrgUnitOwnName } from '../../util/orgUnitGroups.js' import { spatialJoin } from '../../util/spatialJoin.js' +import { buildRowCells } from '../../util/tableColumns.js' +import { compareRows } from '../../util/tableSort.js' const VALUE_KEY = 'rawValue' const LEGEND_KEY = 'legend' const LARGE_FEATURE_THRESHOLD = 10000 const NO_PARENT_KEY = '__no_parent__' +// Shared by all three join modes: apply Combined's own local filters/global +// search (reusing the same utilities as the single-layer table), sort by +// natural insertion order (via each flat row's index) when no sort column is +// active, then build the final {dataKey, value, align, itemId} cell shape. +const finalizeRows = ( + flatRows, + headers, + { filters, globalSearch, sortField, sortDirection } +) => { + let data = filterData(flatRows, filters) + + if (globalSearch?.trim()) { + const stringDataKeys = headers + .filter((h) => h.type === TYPE_STRING) + .map((h) => h.dataKey) + data = filterByGlobalSearch(data, globalSearch, { stringDataKeys }) + } + + data = [...data].sort((a, b) => + compareRows(a, b, { sortField, sortDirection }) + ) + + return data.map((row) => buildRowCells(row, headers)) +} + const getPathSegments = (path) => path ? String(path).split('/').filter(Boolean) : [] @@ -36,7 +65,14 @@ const EMPTY_RESULT = { spatialWarning: false, } -export const useCombinedTableData = ({ layers, joinConfig }) => { +export const useCombinedTableData = ({ + layers, + joinConfig, + sortField = null, + sortDirection = SORT_ASCENDING, + filters, + globalSearch, +}) => { const { level, pointLayerId, polygonLayerId } = joinConfig const isSpatial = level === 'spatial' const isParentGrouped = level === 'parentOrgUnit' @@ -155,41 +191,37 @@ export const useCombinedTableData = ({ layers, joinConfig }) => { const rowFeatureIds = new Map() - const rows = joined.map(({ pointProps, polygonProps }) => { - const path = pointProps[ORG_UNIT_PATH_DATA_KEY] + const flatRows = joined.map( + ({ pointProps, polygonProps }, index) => { + const path = pointProps[ORG_UNIT_PATH_DATA_KEY] - if (pointProps.id != null) { - const entry = { [pointLayer.id]: [pointProps.id] } - if (polygonProps?.id != null) { - entry[polygonLayer.id] = [polygonProps.id] + if (pointProps.id != null) { + const entry = { [pointLayer.id]: [pointProps.id] } + if (polygonProps?.id != null) { + entry[polygonLayer.id] = [polygonProps.id] + } + rowFeatureIds.set(pointProps.id, entry) } - rowFeatureIds.set(pointProps.id, entry) - } - return [ - { - dataKey: 'id', - value: pointProps.id ?? null, - align: 'left', - }, - { - dataKey: 'name', - value: path + return { + id: pointProps.id ?? null, + name: path ? formatOrgUnitOwnName(path, idToName) : pointProps.name ?? pointProps.id ?? null, - align: 'left', - }, - { - dataKey: `${polygonLayer.id}_${VALUE_KEY}`, - value: polygonProps?.[VALUE_KEY] ?? null, - align: 'right', - }, - { - dataKey: `${polygonLayer.id}_${LEGEND_KEY}`, - value: polygonProps?.[LEGEND_KEY] ?? null, - align: 'left', - }, - ] + [`${polygonLayer.id}_${VALUE_KEY}`]: + polygonProps?.[VALUE_KEY] ?? null, + [`${polygonLayer.id}_${LEGEND_KEY}`]: + polygonProps?.[LEGEND_KEY] ?? null, + index, + } + } + ) + + const rows = finalizeRows(flatRows, headers, { + filters, + globalSearch, + sortField, + sortDirection, }) return { headers, rows, rowFeatureIds, spatialWarning } @@ -238,11 +270,8 @@ export const useCombinedTableData = ({ layers, joinConfig }) => { const rowFeatureIds = new Map() - const rows = [...groups.values()].map((group) => { - const cells = [ - { dataKey: 'id', value: group.id, align: 'left' }, - { dataKey: 'name', value: group.name, align: 'left' }, - ] + const flatRows = [...groups.values()].map((group, index) => { + const row = { id: group.id, name: group.name, index } const featureIds = {} layerMaps.forEach( ({ layer, byOrgUnit, featureIdsByOrgUnit }) => { @@ -252,16 +281,8 @@ export const useCombinedTableData = ({ layers, joinConfig }) => { const average = values.length ? values.reduce((a, b) => a + b, 0) / values.length : null - cells.push({ - dataKey: `${layer.id}_${VALUE_KEY}`, - value: average, - align: 'right', - }) - cells.push({ - dataKey: `${layer.id}_${LEGEND_KEY}`, - value: null, - align: 'left', - }) + row[`${layer.id}_${VALUE_KEY}`] = average + row[`${layer.id}_${LEGEND_KEY}`] = null const ids = group.memberIds.flatMap( (id) => featureIdsByOrgUnit[id] ?? [] @@ -272,7 +293,14 @@ export const useCombinedTableData = ({ layers, joinConfig }) => { } ) rowFeatureIds.set(group.id, featureIds) - return cells + return row + }) + + const rows = finalizeRows(flatRows, headers, { + filters, + globalSearch, + sortField, + sortDirection, }) return { headers, rows, rowFeatureIds, spatialWarning: false } @@ -287,43 +315,35 @@ export const useCombinedTableData = ({ layers, joinConfig }) => { const rowFeatureIds = new Map() - const rows = allIds.map((id) => { + const flatRows = allIds.map((id, index) => { const baseProps = layerMaps.find((lm) => lm.byOrgUnit[id])?.byOrgUnit[id] ?? {} const path = baseProps[ORG_UNIT_PATH_DATA_KEY] - const cells = [ - { dataKey: 'id', value: id, align: 'left' }, - { - dataKey: 'name', - value: path ? formatOrgUnitOwnName(path, idToName) : null, - align: 'left', - }, - { - dataKey: 'level', - value: baseProps[ORG_UNIT_LEVEL_DATA_KEY] ?? null, - align: 'right', - }, - ] + const row = { + id, + name: path ? formatOrgUnitOwnName(path, idToName) : null, + level: baseProps[ORG_UNIT_LEVEL_DATA_KEY] ?? null, + index, + } const featureIds = {} layerMaps.forEach(({ layer, byOrgUnit, featureIdsByOrgUnit }) => { const props = byOrgUnit[id] - cells.push({ - dataKey: `${layer.id}_${VALUE_KEY}`, - value: props?.[VALUE_KEY] ?? null, - align: 'right', - }) - cells.push({ - dataKey: `${layer.id}_${LEGEND_KEY}`, - value: props?.[LEGEND_KEY] ?? null, - align: 'left', - }) + row[`${layer.id}_${VALUE_KEY}`] = props?.[VALUE_KEY] ?? null + row[`${layer.id}_${LEGEND_KEY}`] = props?.[LEGEND_KEY] ?? null if (featureIdsByOrgUnit[id]?.length) { featureIds[layer.id] = featureIdsByOrgUnit[id] } }) rowFeatureIds.set(id, featureIds) - return cells + return row + }) + + const rows = finalizeRows(flatRows, headers, { + filters, + globalSearch, + sortField, + sortDirection, }) return { headers, rows, rowFeatureIds, spatialWarning: false } @@ -336,5 +356,9 @@ export const useCombinedTableData = ({ layers, joinConfig }) => { pointLayer, polygonLayer, idToName, + filters, + globalSearch, + sortField, + sortDirection, ]) } From 2e1bc05121a076485f91953729b7d8a490877ddf Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 28 Jul 2026 12:15:30 +0200 Subject: [PATCH 147/205] feat: add row selection and hover-highlight to CombinedDataTable [DHIS2-20543] Combined rows had no map interaction at all. Add a checkbox column and row hover, dispatching the same highlightFeature/setCrossLayerSelection actions the single-layer table uses but with crossLayerIds (per-layer feature ids merged across every affected row) instead of a single layerId/id pair, so a Combined row lights up its matching feature on every participating layer at once. Selection stays local to the table (session-only); only the map-facing Redux dispatch is shared. --- .../datatable/CombinedDataTable.jsx | 275 ++++++++++++++++-- .../__tests__/CombinedDataTable.spec.jsx | 175 +++++++++-- .../styles/CombinedDataTable.module.css | 25 ++ 3 files changed, 437 insertions(+), 38 deletions(-) diff --git a/src/components/datatable/CombinedDataTable.jsx b/src/components/datatable/CombinedDataTable.jsx index eef6c3b6df..c39fc8acdb 100644 --- a/src/components/datatable/CombinedDataTable.jsx +++ b/src/components/datatable/CombinedDataTable.jsx @@ -8,11 +8,28 @@ import { DataTableCell, Input, } from '@dhis2/ui' +import cx from 'classnames' import PropTypes from 'prop-types' -import React, { useCallback, useEffect, useReducer } from 'react' +import React, { + useCallback, + useEffect, + useMemo, + useReducer, + useRef, + useState, +} from 'react' +import { useDispatch } from 'react-redux' import { TableVirtuoso } from 'react-virtuoso' +import { highlightFeature } from '../../actions/feature.js' +import { setCrossLayerSelection } from '../../actions/selection.js' import { SORT_ASCENDING } from '../../constants/dataTable.js' -import { getNextSorting, isFilterable } from '../../util/dataTable.js' +import { + getNextSorting, + getRowClickAction, + getRowId, + isFilterable, + shouldClearFeatureHighlight, +} from '../../util/dataTable.js' import { SortIcon } from '../core/icons.jsx' import styles from './styles/CombinedDataTable.module.css' import dataTableStyles from './styles/DataTable.module.css' @@ -23,10 +40,48 @@ const TABLE_STYLE = { height: '100%', width: '100%' } const LARGE_FEATURE_THRESHOLD_LABEL = '10,000' const EMPTY_FILTERS = {} +const mergeCrossLayerIds = (rowKeys, rowFeatureIds) => { + const merged = {} + rowKeys.forEach((key) => { + const entry = rowFeatureIds.get(key) + if (!entry) { + return + } + Object.entries(entry).forEach(([layerId, ids]) => { + merged[layerId] = [...new Set([...(merged[layerId] ?? []), ...ids])] + }) + }) + return merged +} + const CombinedTable = (props) => ( <DataTable {...props} className={styles.dataTable} /> ) +const CombinedTableRow = React.memo(function CombinedTableRow({ + context, + item, + ...props +}) { + return ( + <DataTableRow + onMouseEnter={() => context.onMouseEnter(item)} + onMouseLeave={context.onMouseLeave} + onClick={(e) => context.onRowClick(item, e)} + {...props} + /> + ) +}) + +CombinedTableRow.propTypes = { + context: PropTypes.shape({ + onMouseEnter: PropTypes.func, + onMouseLeave: PropTypes.func, + onRowClick: PropTypes.func, + }), + item: PropTypes.array, +} + const EmptyPlaceholder = () => ( <tbody> <tr> @@ -43,7 +98,7 @@ const CombinedTableComponents = { Table: CombinedTable, TableBody: DataTableBody, TableHead: DataTableHead, - TableRow: DataTableRow, + TableRow: CombinedTableRow, EmptyPlaceholder, } @@ -56,6 +111,8 @@ const CombinedDataTable = ({ globalSearch, onCountChange, }) => { + const dispatch = useDispatch() + const [{ sortField, sortDirection }, setSorting] = useReducer( (sorting, newSorting) => ({ ...sorting, ...newSorting }), { sortField: 'name', sortDirection: SORT_ASCENDING } @@ -68,19 +125,143 @@ const CombinedDataTable = ({ [sortField, sortDirection] ) - const { headers, rows, spatialWarning } = useCombinedTableData({ - layers, - joinConfig, - sortField, - sortDirection, - filters, - globalSearch, - }) + const { headers, rows, rowFeatureIds, spatialWarning } = + useCombinedTableData({ + layers, + joinConfig, + sortField, + sortDirection, + filters, + globalSearch, + }) useEffect(() => { onCountChange?.(rows.length, rows.length) }, [onCountChange, rows.length]) + // Combined rows don't belong to any single layer, so selection/hover + // here can't reuse state.selection/state.feature's single-layerId shape + // directly - it dispatches the same actions but with crossLayerIds (a + // per-layer id map merged from every affected row), and layerId: null so + // Layer.js's own-layer check never matches. The in-table + // selected/hovered highlighting stays local state; only the map-facing + // dispatch goes through Redux. + const [selectedIds, setSelectedIds] = useState([]) + const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]) + const [hoveredRowId, setHoveredRowId] = useState(null) + + // Only clear state.selection on unmount if this table ever actually set + // a cross-layer selection - otherwise merely opening and closing the + // Combined tab without selecting anything would wipe out an unrelated, + // pre-existing single-layer selection made in another tab. + const hasAppliedSelectionRef = useRef(false) + + useEffect( + () => () => { + if (hasAppliedSelectionRef.current) { + dispatch(setCrossLayerSelection({})) + } + }, + [dispatch] + ) + + const applySelection = useCallback( + (nextIds) => { + setSelectedIds(nextIds) + hasAppliedSelectionRef.current = true + dispatch( + setCrossLayerSelection( + mergeCrossLayerIds(nextIds, rowFeatureIds) + ) + ) + }, + [dispatch, rowFeatureIds] + ) + + const lastClickedRowIndexRef = useRef(null) + + const onRowClick = useCallback( + (row, event) => { + const id = getRowId(row) + if (!id || !rows) { + return + } + const rowIndex = rows.findIndex((r) => getRowId(r) === id) + const action = getRowClickAction(event, { + id, + rowIndex, + rows, + lastClickedRowIndex: lastClickedRowIndexRef.current, + }) + if (!action) { + return + } + if (action.type === 'range') { + applySelection([...new Set([...selectedIds, ...action.ids])]) + } else { + applySelection( + selectedIds.includes(action.id) + ? selectedIds.filter((i) => i !== action.id) + : [...selectedIds, action.id] + ) + } + lastClickedRowIndexRef.current = rowIndex + }, + [applySelection, selectedIds, rows] + ) + + const setFeatureHighlight = useCallback( + (row) => { + const id = getRowId(row) + setHoveredRowId(id ?? null) + const entry = id ? rowFeatureIds.get(id) : null + dispatch( + highlightFeature( + entry && Object.keys(entry).length + ? { + layerId: null, + origin: 'table', + crossLayerIds: entry, + } + : null + ) + ) + }, + [dispatch, rowFeatureIds] + ) + + const clearFeatureHighlight = useCallback( + (event) => { + if (shouldClearFeatureHighlight(event)) { + setHoveredRowId(null) + dispatch(highlightFeature(null)) + } + }, + [dispatch] + ) + + const allRowIds = useMemo(() => rows.map(getRowId).filter(Boolean), [rows]) + + const isAllSelected = + allRowIds.length > 0 && allRowIds.every((id) => selectedIdSet.has(id)) + + const onToggleSelectAll = useCallback(() => { + applySelection( + isAllSelected + ? selectedIds.filter((id) => !allRowIds.includes(id)) + : [...new Set([...selectedIds, ...allRowIds])] + ) + }, [applySelection, isAllSelected, selectedIds, allRowIds]) + + const tableContext = useMemo( + () => ({ + onMouseEnter: setFeatureHighlight, + onMouseLeave: clearFeatureHighlight, + onRowClick, + }), + [setFeatureHighlight, clearFeatureHighlight, onRowClick] + ) + const onFilterChange = useCallback( (dataKey, value) => { const next = { ...(filters ?? EMPTY_FILTERS) } @@ -97,6 +278,16 @@ const CombinedDataTable = ({ const fixedHeaderContent = useCallback( () => ( <DataTableRow> + <DataTableColumnHeader className={styles.checkboxCell}> + <TopTooltip content={i18n.t('Select all visible rows')}> + <input + type="checkbox" + aria-label={i18n.t('Select all visible rows')} + checked={isAllSelected} + onChange={onToggleSelectAll} + /> + </TopTooltip> + </DataTableColumnHeader> {headers.map(({ name, dataKey, type }) => ( <DataTableColumnHeader key={dataKey} @@ -149,7 +340,16 @@ const CombinedDataTable = ({ ))} </DataTableRow> ), - [headers, filters, onFilterChange, sortData, sortField, sortDirection] + [ + headers, + filters, + onFilterChange, + sortData, + sortField, + sortDirection, + isAllSelected, + onToggleSelectAll, + ] ) return ( @@ -163,23 +363,56 @@ const CombinedDataTable = ({ </div> )} <TableVirtuoso + context={tableContext} components={CombinedTableComponents} style={TABLE_STYLE} data={rows} fixedHeaderContent={fixedHeaderContent} - itemContent={(_, row) => ( - <> - {row.map(({ dataKey, value, align }) => ( + itemContent={(_, row) => { + const rowId = getRowId(row) + const isSelected = !!rowId && selectedIdSet.has(rowId) + const isHovered = !!rowId && rowId === hoveredRowId + return ( + <> <DataTableCell - key={dataKey} staticStyle - align={align} + className={cx(styles.checkboxCell, { + [styles.selected]: isSelected, + [styles.hovered]: isHovered, + })} > - {value ?? '—'} + <input + type="checkbox" + checked={isSelected} + onChange={() => + rowId && + applySelection( + selectedIds.includes(rowId) + ? selectedIds.filter( + (id) => id !== rowId + ) + : [...selectedIds, rowId] + ) + } + onClick={(e) => e.stopPropagation()} + /> </DataTableCell> - ))} - </> - )} + {row.map(({ dataKey, value, align }) => ( + <DataTableCell + key={dataKey} + staticStyle + align={align} + className={cx({ + [styles.selected]: isSelected, + [styles.hovered]: isHovered, + })} + > + {value ?? '—'} + </DataTableCell> + ))} + </> + ) + }} /> </div> ) diff --git a/src/components/datatable/__tests__/CombinedDataTable.spec.jsx b/src/components/datatable/__tests__/CombinedDataTable.spec.jsx index 04873d4b73..9ec72bf860 100644 --- a/src/components/datatable/__tests__/CombinedDataTable.spec.jsx +++ b/src/components/datatable/__tests__/CombinedDataTable.spec.jsx @@ -1,6 +1,8 @@ import { render, screen, fireEvent } from '@testing-library/react' import React from 'react' +import { Provider } from 'react-redux' import { VirtuosoMockContext } from 'react-virtuoso' +import configureMockStore from 'redux-mock-store' import useOrgUnitAncestorNames from '../../../hooks/useOrgUnitAncestorNames.js' import CombinedDataTable from '../CombinedDataTable.jsx' @@ -9,6 +11,8 @@ jest.mock('../../../hooks/useOrgUnitAncestorNames.js', () => ({ default: jest.fn(), })) +const mockStore = configureMockStore() + beforeEach(() => { useOrgUnitAncestorNames.mockReturnValue({ idToName: new Map(), @@ -18,24 +22,29 @@ beforeEach(() => { const feature = (props) => ({ properties: props }) -const renderCombinedDataTable = (props) => - render( - <VirtuosoMockContext.Provider - value={{ viewportHeight: 300, itemHeight: 28 }} - > - <CombinedDataTable - availableWidth={800} - layers={[]} - joinConfig={{ - level: 'orgUnit', - layerIds: [], - pointLayerId: null, - polygonLayerId: null, - }} - {...props} - /> - </VirtuosoMockContext.Provider> +const renderCombinedDataTable = (props) => { + const store = mockStore({}) + const result = render( + <Provider store={store}> + <VirtuosoMockContext.Provider + value={{ viewportHeight: 300, itemHeight: 28 }} + > + <CombinedDataTable + availableWidth={800} + layers={[]} + joinConfig={{ + level: 'orgUnit', + layerIds: [], + pointLayerId: null, + polygonLayerId: null, + }} + {...props} + /> + </VirtuosoMockContext.Provider> + </Provider> ) + return { ...result, store } +} describe('CombinedDataTable', () => { test('renders a column header per computed header and a cell per row', () => { @@ -249,4 +258,136 @@ describe('CombinedDataTable', () => { expect(onFiltersChange).toHaveBeenCalledWith({ id: 'ou1' }) }) + + test('dispatches a cross-layer highlight on row hover, and clears it on mouse leave', () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + data: [ + feature({ id: 'evtA1', orgUnitId: 'ou1', rawValue: 20 }), + ], + }, + { + id: 'layerB', + name: 'Layer B', + data: [feature({ id: 'evtB1', orgUnitId: 'ou1', rawValue: 5 })], + }, + ] + + const { store } = renderCombinedDataTable({ + layers, + joinConfig: { + level: 'orgUnit', + layerIds: ['layerA', 'layerB'], + pointLayerId: null, + polygonLayerId: null, + }, + }) + + const dataRow = screen.getAllByRole('row')[1] + fireEvent.mouseEnter(dataRow) + + expect(store.getActions()).toContainEqual({ + type: 'FEATURE_HIGHLIGHT', + payload: { + layerId: null, + origin: 'table', + crossLayerIds: { layerA: ['evtA1'], layerB: ['evtB1'] }, + }, + }) + + fireEvent.mouseLeave(dataRow, { relatedTarget: null }) + + expect(store.getActions()).toContainEqual({ + type: 'FEATURE_HIGHLIGHT', + payload: null, + }) + }) + + test('dispatches a merged cross-layer selection when rows are checked', () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + data: [ + feature({ id: 'evt1', orgUnitId: 'ou1', rawValue: 20 }), + feature({ id: 'evt2', orgUnitId: 'ou2', rawValue: 10 }), + ], + }, + ] + + const { store } = renderCombinedDataTable({ + layers, + joinConfig: { + level: 'orgUnit', + layerIds: ['layerA'], + pointLayerId: null, + polygonLayerId: null, + }, + }) + + const checkboxes = screen.getAllByRole('checkbox') + // checkboxes[0] is the header "select all" checkbox + fireEvent.click(checkboxes[1]) + + expect(store.getActions()).toContainEqual({ + type: 'SELECTION_SET_CROSS_LAYER', + crossLayerIds: { layerA: ['evt1'] }, + }) + }) + + test('does not clear selection on unmount when nothing was ever selected here', () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + data: [feature({ id: 'evt1', orgUnitId: 'ou1' })], + }, + ] + + const { store, unmount } = renderCombinedDataTable({ + layers, + joinConfig: { + level: 'orgUnit', + layerIds: ['layerA'], + pointLayerId: null, + polygonLayerId: null, + }, + }) + + unmount() + + expect(store.getActions()).not.toContainEqual( + expect.objectContaining({ type: 'SELECTION_SET_CROSS_LAYER' }) + ) + }) + + test('clears the cross-layer selection on unmount after selecting a row', () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + data: [feature({ id: 'evt1', orgUnitId: 'ou1' })], + }, + ] + + const { store, unmount } = renderCombinedDataTable({ + layers, + joinConfig: { + level: 'orgUnit', + layerIds: ['layerA'], + pointLayerId: null, + polygonLayerId: null, + }, + }) + + fireEvent.click(screen.getAllByRole('checkbox')[1]) + unmount() + + expect(store.getActions()).toContainEqual({ + type: 'SELECTION_SET_CROSS_LAYER', + crossLayerIds: {}, + }) + }) }) diff --git a/src/components/datatable/styles/CombinedDataTable.module.css b/src/components/datatable/styles/CombinedDataTable.module.css index 242197820a..72b84bfbff 100644 --- a/src/components/datatable/styles/CombinedDataTable.module.css +++ b/src/components/datatable/styles/CombinedDataTable.module.css @@ -31,3 +31,28 @@ font-size: 12px; border-bottom: 1px solid var(--colors-yellow300); } + +th.checkboxCell, +td.checkboxCell { + width: 76px; + min-width: 76px; + max-width: 76px; + text-align: center; + padding: 0; + padding-top: 3px; + vertical-align: middle; +} + +.checkboxCell input[type='checkbox'] { + accent-color: var(--colors-teal600); +} + +td.selected, +th.selected { + background-color: var(--colors-blue050); +} + +td.hovered, +th.hovered { + background-color: var(--colors-blue100); +} From 6152d51c2dd68a5b399ad7cc57784a8934c835e3 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 28 Jul 2026 12:55:07 +0200 Subject: [PATCH 148/205] refactor: share rendering/interaction architecture between DataTable and CombinedDataTable [DHIS2-20543] CombinedDataTable's selection/hover/sort logic (previous commit) hand-copied patterns already in DataTable.jsx, and its header/checkbox/cell markup and CSS were separately hand-rolled rather than shared - leaving the two tables visually and functionally inconsistent (a stray unhidden filter-icon button, missing row/header padding and font-size, and a plain text filter input instead of the real searchable/checkbox popover). Extract the callback-driven, dispatch-agnostic pieces into shared hooks/ components used by both: - useRowSelection: takes an onChange(nextIds) callback instead of dispatching Redux directly, so the caller decides where selection state lives (a single layer's Redux selection, or Combined's local state). - useRowClickSelection: shift/ctrl row-click-to-selection-action handling, extracted from DataTable.jsx. - useSortState: the three-click asc/desc/none sort useReducer. - SortableColumnHeader / SelectionCheckboxColumn: the sortable header cell and checkbox column markup, byte-for-byte identical between the tables. - FilterInput: generalized to take filterValue/onChange/onClear props instead of reading a real layer's Redux dataFilters and dispatching directly, so Combined can reuse the same searchable/checkbox popover UI against its own local, session-only filter state. DataTable.jsx's behavior and dispatched actions are unchanged. - tableColumns.js: extracted sortColumnOptions (the cheap re-sort step already used by DataTable's column filter options) so Combined's filter popover gets the same sorted distinct-value list; useCombinedTableData now computes columnOptions the same way useTableData does. - CombinedDataTable now reuses TableVirtuosoComponents' generic row/table wiring and DataTable.module.css's dataCell/columnHeader classes instead of hand-rolled duplicates. Verified live against a real DHIS2 instance: Combined's header/row/filter appearance now matches the single-layer table exactly, and a row's checkbox selection correctly sets crossLayerIds across all participating layers in both state.selection and state.feature. --- i18n/en.pot | 34 +-- .../datatable/CombinedDataTable.jsx | 282 ++++++------------ src/components/datatable/DataTable.jsx | 248 +++++---------- src/components/datatable/FilterInput.jsx | 60 ++-- .../datatable/SelectionCheckboxColumn.jsx | 131 ++++++++ .../datatable/SortableColumnHeader.jsx | 51 ++++ .../__tests__/CombinedDataTable.spec.jsx | 9 +- .../datatable/__tests__/FilterInput.spec.jsx | 14 + .../__tests__/useCombinedTableData.spec.js | 31 ++ .../__tests__/useRowClickSelection.spec.js | 61 ++++ .../__tests__/useRowSelection.spec.js | 72 +++++ .../styles/CombinedDataTable.module.css | 34 --- .../datatable/useCombinedTableData.js | 54 +++- .../datatable/useRowClickSelection.js | 40 +++ src/components/datatable/useRowSelection.js | 28 +- src/components/datatable/useSortState.js | 22 ++ src/components/datatable/useTableData.js | 36 +-- src/util/__tests__/tableColumns.spec.js | 33 ++ src/util/tableColumns.js | 40 ++- 19 files changed, 797 insertions(+), 483 deletions(-) create mode 100644 src/components/datatable/SelectionCheckboxColumn.jsx create mode 100644 src/components/datatable/SortableColumnHeader.jsx create mode 100644 src/components/datatable/__tests__/useRowClickSelection.spec.js create mode 100644 src/components/datatable/__tests__/useRowSelection.spec.js create mode 100644 src/components/datatable/useRowClickSelection.js create mode 100644 src/components/datatable/useSortState.js diff --git a/i18n/en.pot b/i18n/en.pot index 8884cdf6c8..643c47d4e1 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-28T10:06:28.667Z\n" -"PO-Revision-Date: 2026-07-28T10:06:28.667Z\n" +"POT-Creation-Date: 2026-07-28T10:47:18.465Z\n" +"PO-Revision-Date: 2026-07-28T10:47:18.465Z\n" msgid "2020" msgstr "2020" @@ -179,24 +179,9 @@ msgstr "Combined" msgid "No matching rows" msgstr "No matching rows" -msgid "Search" -msgstr "Search" - -msgid "Sort by {{column}}" -msgstr "Sort by {{column}}" - msgid "Spatial join over large datasets may be slow (over {{threshold}} features)" msgstr "Spatial join over large datasets may be slow (over {{threshold}} features)" -msgid "Select all visible rows" -msgstr "Select all visible rows" - -msgid "Reverse selection of visible rows" -msgstr "Reverse selection of visible rows" - -msgid "Sort by Selected" -msgstr "Sort by Selected" - msgid "Edit layer" msgstr "Edit layer" @@ -239,6 +224,9 @@ msgstr "Use filter" msgid "Search or type > 5, < 8…" msgstr "Search or type > 5, < 8…" +msgid "Search" +msgstr "Search" + msgid "Reverse selection" msgstr "Reverse selection" @@ -269,6 +257,15 @@ msgstr "to match the rows under it, or type to search" msgid "Select matches" msgstr "Select matches" +msgid "Select all visible rows" +msgstr "Select all visible rows" + +msgid "Reverse selection of visible rows" +msgstr "Reverse selection of visible rows" + +msgid "Sort by Selected" +msgstr "Sort by Selected" + msgid "Selected" msgstr "Selected" @@ -283,6 +280,9 @@ msgid_plural "{{count}} selected" msgstr[0] "{{count}} selected" msgstr[1] "{{count}} selected" +msgid "Sort by {{column}}" +msgstr "Sort by {{column}}" + msgid "Drill up one level" msgstr "Drill up one level" diff --git a/src/components/datatable/CombinedDataTable.jsx b/src/components/datatable/CombinedDataTable.jsx index c39fc8acdb..f3ce681a08 100644 --- a/src/components/datatable/CombinedDataTable.jsx +++ b/src/components/datatable/CombinedDataTable.jsx @@ -1,44 +1,36 @@ import i18n from '@dhis2/d2-i18n' -import { - DataTable, - DataTableBody, - DataTableHead, - DataTableRow, - DataTableColumnHeader, - DataTableCell, - Input, -} from '@dhis2/ui' +import { DataTableRow, DataTableCell } from '@dhis2/ui' import cx from 'classnames' import PropTypes from 'prop-types' -import React, { - useCallback, - useEffect, - useMemo, - useReducer, - useRef, - useState, -} from 'react' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useDispatch } from 'react-redux' import { TableVirtuoso } from 'react-virtuoso' import { highlightFeature } from '../../actions/feature.js' import { setCrossLayerSelection } from '../../actions/selection.js' -import { SORT_ASCENDING } from '../../constants/dataTable.js' +import { ORG_UNIT_ID_DATA_KEY } from '../../constants/dataTable.js' import { - getNextSorting, - getRowClickAction, - getRowId, isFilterable, + getRowId, shouldClearFeatureHighlight, } from '../../util/dataTable.js' -import { SortIcon } from '../core/icons.jsx' +import FilterInput from './FilterInput.jsx' +import { + SelectionCheckboxHeaderCell, + SelectionCheckboxCell, +} from './SelectionCheckboxColumn.jsx' +import SortableColumnHeader from './SortableColumnHeader.jsx' import styles from './styles/CombinedDataTable.module.css' import dataTableStyles from './styles/DataTable.module.css' -import TopTooltip from './TopTooltip.jsx' +import TableComponents from './TableVirtuosoComponents.jsx' import { useCombinedTableData } from './useCombinedTableData.js' +import { useRowClickSelection } from './useRowClickSelection.js' +import { useRowSelection } from './useRowSelection.js' +import { useSortState } from './useSortState.js' const TABLE_STYLE = { height: '100%', width: '100%' } const LARGE_FEATURE_THRESHOLD_LABEL = '10,000' const EMPTY_FILTERS = {} +const NOOP = () => {} const mergeCrossLayerIds = (rowKeys, rowFeatureIds) => { const merged = {} @@ -54,34 +46,6 @@ const mergeCrossLayerIds = (rowKeys, rowFeatureIds) => { return merged } -const CombinedTable = (props) => ( - <DataTable {...props} className={styles.dataTable} /> -) - -const CombinedTableRow = React.memo(function CombinedTableRow({ - context, - item, - ...props -}) { - return ( - <DataTableRow - onMouseEnter={() => context.onMouseEnter(item)} - onMouseLeave={context.onMouseLeave} - onClick={(e) => context.onRowClick(item, e)} - {...props} - /> - ) -}) - -CombinedTableRow.propTypes = { - context: PropTypes.shape({ - onMouseEnter: PropTypes.func, - onMouseLeave: PropTypes.func, - onRowClick: PropTypes.func, - }), - item: PropTypes.array, -} - const EmptyPlaceholder = () => ( <tbody> <tr> @@ -94,11 +58,12 @@ const EmptyPlaceholder = () => ( </tbody> ) +// Reuse the same generic TableVirtuoso row/table wiring DataTable.jsx uses +// (context-driven mouse/click callbacks) - only the empty-state message +// differs, since Combined doesn't have DataTable's server-cluster/ +// clear-filters messaging needs yet. const CombinedTableComponents = { - Table: CombinedTable, - TableBody: DataTableBody, - TableHead: DataTableHead, - TableRow: CombinedTableRow, + ...TableComponents, EmptyPlaceholder, } @@ -113,19 +78,9 @@ const CombinedDataTable = ({ }) => { const dispatch = useDispatch() - const [{ sortField, sortDirection }, setSorting] = useReducer( - (sorting, newSorting) => ({ ...sorting, ...newSorting }), - { sortField: 'name', sortDirection: SORT_ASCENDING } - ) + const { sortField, sortDirection, sortData } = useSortState('name') - const sortData = useCallback( - ({ name }) => { - setSorting(getNextSorting(name, { sortField, sortDirection })) - }, - [sortField, sortDirection] - ) - - const { headers, rows, rowFeatureIds, spatialWarning } = + const { headers, rows, rowFeatureIds, columnOptions, spatialWarning } = useCombinedTableData({ layers, joinConfig, @@ -178,37 +133,24 @@ const CombinedDataTable = ({ [dispatch, rowFeatureIds] ) - const lastClickedRowIndexRef = useRef(null) - - const onRowClick = useCallback( - (row, event) => { - const id = getRowId(row) - if (!id || !rows) { - return - } - const rowIndex = rows.findIndex((r) => getRowId(r) === id) - const action = getRowClickAction(event, { - id, - rowIndex, - rows, - lastClickedRowIndex: lastClickedRowIndexRef.current, - }) - if (!action) { - return - } - if (action.type === 'range') { - applySelection([...new Set([...selectedIds, ...action.ids])]) - } else { - applySelection( - selectedIds.includes(action.id) - ? selectedIds.filter((i) => i !== action.id) - : [...selectedIds, action.id] - ) - } - lastClickedRowIndexRef.current = rowIndex - }, - [applySelection, selectedIds, rows] + const onToggleRow = useCallback( + (id) => + applySelection( + selectedIds.includes(id) + ? selectedIds.filter((i) => i !== id) + : [...selectedIds, id] + ), + [applySelection, selectedIds] ) + const onSelectRowRange = useCallback( + (ids) => applySelection([...new Set([...selectedIds, ...ids])]), + [applySelection, selectedIds] + ) + const onRowClick = useRowClickSelection({ + rows, + onToggle: onToggleRow, + onSelectRange: onSelectRowRange, + }) const setFeatureHighlight = useCallback( (row) => { @@ -242,34 +184,40 @@ const CombinedDataTable = ({ const allRowIds = useMemo(() => rows.map(getRowId).filter(Boolean), [rows]) - const isAllSelected = - allRowIds.length > 0 && allRowIds.every((id) => selectedIdSet.has(id)) - - const onToggleSelectAll = useCallback(() => { - applySelection( - isAllSelected - ? selectedIds.filter((id) => !allRowIds.includes(id)) - : [...new Set([...selectedIds, ...allRowIds])] - ) - }, [applySelection, isAllSelected, selectedIds, allRowIds]) + const { isAllSelected, onToggleSelectAll, onReverseSelection } = + useRowSelection({ + selectedIds, + selectedIdSet, + allRowIds, + onChange: applySelection, + }) const tableContext = useMemo( () => ({ onMouseEnter: setFeatureHighlight, onMouseLeave: clearFeatureHighlight, onRowClick, + onContextMenu: NOOP, + onRowDoubleClick: NOOP, + layout: 'auto', }), [setFeatureHighlight, clearFeatureHighlight, onRowClick] ) const onFilterChange = useCallback( (dataKey, value) => { + onFiltersChange?.({ + ...(filters ?? EMPTY_FILTERS), + [dataKey]: value, + }) + }, + [filters, onFiltersChange] + ) + + const onFilterClear = useCallback( + (dataKey) => { const next = { ...(filters ?? EMPTY_FILTERS) } - if (value) { - next[dataKey] = value - } else { - delete next[dataKey] - } + delete next[dataKey] onFiltersChange?.(next) }, [filters, onFiltersChange] @@ -278,77 +226,58 @@ const CombinedDataTable = ({ const fixedHeaderContent = useCallback( () => ( <DataTableRow> - <DataTableColumnHeader className={styles.checkboxCell}> - <TopTooltip content={i18n.t('Select all visible rows')}> - <input - type="checkbox" - aria-label={i18n.t('Select all visible rows')} - checked={isAllSelected} - onChange={onToggleSelectAll} - /> - </TopTooltip> - </DataTableColumnHeader> + <SelectionCheckboxHeaderCell + isAllSelected={isAllSelected} + onToggleSelectAll={onToggleSelectAll} + onReverseSelection={onReverseSelection} + disabled={allRowIds.length === 0} + /> {headers.map(({ name, dataKey, type }) => ( - <DataTableColumnHeader + <SortableColumnHeader key={dataKey} - name={dataKey} + name={name} + dataKey={dataKey} + sortField={sortField} + sortDirection={sortDirection} + onSort={sortData} + dataTestPrefix="combined-table-column-sort-button" + className={dataTableStyles.columnHeader} onFilterIconClick={ isFilterable(dataKey, type) && Function.prototype } showFilter={isFilterable(dataKey, type)} filter={ isFilterable(dataKey, type) && ( - <Input - dense - clearable - dataTest={`combined-table-column-filter-${name}`} - placeholder={i18n.t('Search')} - value={filters?.[dataKey] ?? ''} - onChange={({ value }) => + <FilterInput + type={type} + dataKey={dataKey} + name={name} + options={columnOptions[dataKey]} + filterValue={filters?.[dataKey]} + onChange={(value) => onFilterChange(dataKey, value) } + onClear={() => onFilterClear(dataKey)} /> ) } - > - <span className={dataTableStyles.headerContent}> - <span className={dataTableStyles.headerTitle}> - {name} - </span> - <TopTooltip - content={i18n.t('Sort by {{column}}', { - column: name, - })} - > - <button - type="button" - className={dataTableStyles.sortButton} - data-test={`combined-table-column-sort-button-${name}`} - onClick={() => sortData({ name: dataKey })} - > - <SortIcon - direction={ - dataKey === sortField - ? sortDirection - : null - } - /> - </button> - </TopTooltip> - </span> - </DataTableColumnHeader> + /> ))} </DataTableRow> ), [ headers, filters, + columnOptions, onFilterChange, + onFilterClear, sortData, sortField, sortDirection, isAllSelected, onToggleSelectAll, + onReverseSelection, + allRowIds, ] ) @@ -374,37 +303,22 @@ const CombinedDataTable = ({ const isHovered = !!rowId && rowId === hoveredRowId return ( <> - <DataTableCell - staticStyle - className={cx(styles.checkboxCell, { - [styles.selected]: isSelected, - [styles.hovered]: isHovered, - })} - > - <input - type="checkbox" - checked={isSelected} - onChange={() => - rowId && - applySelection( - selectedIds.includes(rowId) - ? selectedIds.filter( - (id) => id !== rowId - ) - : [...selectedIds, rowId] - ) - } - onClick={(e) => e.stopPropagation()} - /> - </DataTableCell> + <SelectionCheckboxCell + isSelected={isSelected} + isHovered={isHovered} + onToggle={() => rowId && onToggleRow(rowId)} + /> {row.map(({ dataKey, value, align }) => ( <DataTableCell key={dataKey} staticStyle align={align} - className={cx({ - [styles.selected]: isSelected, - [styles.hovered]: isHovered, + className={cx(dataTableStyles.dataCell, { + [dataTableStyles.monoCell]: + dataKey === 'id' || + dataKey === ORG_UNIT_ID_DATA_KEY, + [dataTableStyles.selected]: isSelected, + [dataTableStyles.hovered]: isHovered, })} > {value ?? '—'} diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index 6351f6a64f..38fe909c83 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -2,34 +2,27 @@ import i18n from '@dhis2/d2-i18n' import { DataTableRow, DataTableCell, - DataTableColumnHeader, ComponentCover, CenteredContent, CircularLoader, - IconSync16, } from '@dhis2/ui' import cx from 'classnames' import PropTypes from 'prop-types' -import React, { - useReducer, - useCallback, - useMemo, - useEffect, - useRef, - useState, -} from 'react' +import React, { useCallback, useMemo, useEffect, useRef, useState } from 'react' import { useSelector, useDispatch } from 'react-redux' import { TableVirtuoso } from 'react-virtuoso' +import { setDataFilter, clearDataFilter } from '../../actions/dataFilters.js' import { setSelectionFilter } from '../../actions/dataTable.js' import { highlightFeature } from '../../actions/feature.js' import { editLayer, setForceClientCluster } from '../../actions/layers.js' import { toggleFeatureSelection, selectFeatureRange, + selectAllFeatures, + clearSelection, } from '../../actions/selection.js' import { SENTINEL_SELECTED_ROW, - SORT_ASCENDING, RENDERER_COLOR, RENDERER_ICON, RENDERER_DATE, @@ -42,8 +35,6 @@ import { import { isDarkColor } from '../../util/colors.js' import { buildFeatureIndex, - getNextSorting, - getRowClickAction, getRowId, hasActiveDataTableFilters, isFilterable, @@ -66,15 +57,20 @@ import { getVisibleHeaders, } from '../../util/tableColumns.js' import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' -import { SortIcon } from '../core/icons.jsx' import FilterInput from './FilterInput.jsx' +import { + SelectionCheckboxHeaderCell, + SelectionCheckboxCell, +} from './SelectionCheckboxColumn.jsx' import SelectionFilterButton from './SelectionFilterButton.jsx' +import SortableColumnHeader from './SortableColumnHeader.jsx' import styles from './styles/DataTable.module.css' import TableContextMenu from './TableContextMenu.jsx' import TableComponents from './TableVirtuosoComponents.jsx' -import TopTooltip from './TopTooltip.jsx' import { useColumnWidths } from './useColumnWidths.js' +import { useRowClickSelection } from './useRowClickSelection.js' import { useRowSelection } from './useRowSelection.js' +import { useSortState } from './useSortState.js' import { useTableData } from './useTableData.js' const TABLE_STYLE = { height: '100%', width: '100%' } @@ -103,23 +99,10 @@ const Table = ({ ) const mapBounds = useSelector((state) => state.ui.mapBounds) const selectionFilter = useSelector((state) => state.ui.selectionFilter) - const [{ sortField, sortDirection }, setSorting] = useReducer( - (sorting, newSorting) => ({ ...sorting, ...newSorting }), - { - sortField: 'name', - sortDirection: SORT_ASCENDING, - } - ) + const { sortField, sortDirection, sortData } = useSortState('name') const layer = mapViews.find((l) => l.id === activeLayerId) - const sortData = useCallback( - ({ name }) => { - setSorting(getNextSorting(name, { sortField, sortDirection })) - }, - [sortField, sortDirection] - ) - // Read via ref rather than a dependency, so this callback stays stable // across hovers instead of getting a new identity on every single mouse-enter const featureRef = useRef(feature) @@ -252,37 +235,19 @@ const Table = ({ onCountChange?.(totalCount, filteredCount) }, [onCountChange, totalCount, filteredCount]) - const lastClickedRowIndexRef = useRef(null) - - const onRowClick = useCallback( - (row, event) => { - const id = getRowId(row) - - if (!id || !rows) { - return - } - - const rowIndex = rows.findIndex((r) => getRowId(r) === id) - const action = getRowClickAction(event, { - id, - rowIndex, - rows, - lastClickedRowIndex: lastClickedRowIndexRef.current, - }) - - if (!action) { - return - } - - if (action.type === 'range') { - dispatch(selectFeatureRange(action.ids, layer.id)) - } else { - dispatch(toggleFeatureSelection(action.id, layer.id)) - } - lastClickedRowIndexRef.current = rowIndex - }, - [dispatch, layer.id, rows] + const onToggleRow = useCallback( + (id) => dispatch(toggleFeatureSelection(id, layer.id)), + [dispatch, layer.id] + ) + const onSelectRowRange = useCallback( + (ids) => dispatch(selectFeatureRange(ids, layer.id)), + [dispatch, layer.id] ) + const onRowClick = useRowClickSelection({ + rows, + onToggle: onToggleRow, + onSelectRange: onSelectRowRange, + }) const onRowDoubleClick = useCallback( (row) => { @@ -378,12 +343,23 @@ const Table = ({ [rows] ) + const onSelectionChange = useCallback( + (nextIds) => { + if (nextIds.length) { + dispatch(selectAllFeatures(nextIds, layer.id)) + } else { + dispatch(clearSelection()) + } + }, + [dispatch, layer.id] + ) + const { isAllSelected, onToggleSelectAll, onReverseSelection } = useRowSelection({ selectedIds, selectedIdSet, allRowIds, - layerId: layer.id, + onChange: onSelectionChange, }) const computeItemKey = useCallback( @@ -394,11 +370,18 @@ const Table = ({ const fixedHeaderContent = useCallback( () => ( <DataTableRow ref={headerRowRef}> - <DataTableColumnHeader - className={styles.checkboxCell} - width="76px" + <SelectionCheckboxHeaderCell fixed={isCheckboxColumnPinned} left={isCheckboxColumnPinned ? '0px' : undefined} + isAllSelected={isAllSelected} + onToggleSelectAll={onToggleSelectAll} + onReverseSelection={onReverseSelection} + disabled={allRowIds.length === 0} + sortField={sortField} + sortDirection={sortDirection} + onSortBySelected={() => + sortData({ name: SENTINEL_SELECTED_ROW }) + } onFilterIconClick={Function.prototype} showFilter={true} filter={ @@ -409,53 +392,7 @@ const Table = ({ } /> } - > - <div className={styles.checkboxHeaderContent}> - <TopTooltip content={i18n.t('Select all visible rows')}> - <input - type="checkbox" - aria-label={i18n.t('Select all visible rows')} - checked={isAllSelected} - onChange={onToggleSelectAll} - /> - </TopTooltip> - <TopTooltip - content={i18n.t( - 'Reverse selection of visible rows' - )} - > - <button - type="button" - className={styles.reverseButton} - data-test="data-table-reverse-selection" - disabled={allRowIds.length === 0} - onClick={onReverseSelection} - > - <IconSync16 /> - </button> - </TopTooltip> - <TopTooltip content={i18n.t('Sort by Selected')}> - <button - type="button" - className={styles.sortButton} - data-test="data-table-column-sort-button-selected" - onClick={() => - sortData({ - name: SENTINEL_SELECTED_ROW, - }) - } - > - <SortIcon - direction={ - sortField === SENTINEL_SELECTED_ROW - ? sortDirection - : null - } - /> - </button> - </TopTooltip> - </div> - </DataTableColumnHeader> + /> {visibleHeaders.map( ({ name, dataKey, type, optionSet, renderer }, index) => { const { fixed, left, isLastPinned } = @@ -465,11 +402,17 @@ const Table = ({ columnWidths, }) return ( - <DataTableColumnHeader + <SortableColumnHeader + key={`${dataKey}-${index}`} + name={name} + dataKey={dataKey} + sortField={sortField} + sortDirection={sortDirection} + onSort={sortData} + dataTestPrefix="data-table-column-sort-button" className={cx(styles.columnHeader, { [styles.pinnedColumnShadow]: isLastPinned, })} - key={`${dataKey}-${index}`} fixed={fixed} left={left} onFilterIconClick={ @@ -477,7 +420,6 @@ const Table = ({ Function.prototype } showFilter={isFilterable(dataKey, type)} - name={dataKey} filter={ isFilterable(dataKey, type) && ( <FilterInput @@ -489,6 +431,26 @@ const Table = ({ optionSetId={optionSet?.id} renderer={renderer} orgUnitIdToName={orgUnitIdToName} + filterValue={ + layer.dataFilters?.[dataKey] + } + onChange={(value) => + dispatch( + setDataFilter( + activeLayerId, + dataKey, + value + ) + ) + } + onClear={() => + dispatch( + clearDataFilter( + activeLayerId, + dataKey + ) + ) + } /> ) } @@ -497,37 +459,7 @@ const Table = ({ ? `${columnWidths[index]}px` : 'auto' } - > - <span className={styles.headerContent}> - <span className={styles.headerTitle}> - {name} - </span> - <TopTooltip - content={i18n.t('Sort by {{column}}', { - column: name, - })} - > - <button - type="button" - className={styles.sortButton} - data-test={`data-table-column-sort-button-${name}`} - onClick={() => - sortData({ - name: dataKey, - }) - } - > - <SortIcon - direction={ - dataKey === sortField - ? sortDirection - : null - } - /> - </button> - </TopTooltip> - </span> - </DataTableColumnHeader> + /> ) } )} @@ -545,6 +477,7 @@ const Table = ({ sortDirection, visibleHeaders, pinnedLeftOffsets, + layer.dataFilters, pinnedColumnCount, columnWidths, columnOptions, @@ -595,8 +528,7 @@ const Table = ({ return ( <> - <DataTableCell - staticStyle + <SelectionCheckboxCell fixed={isCheckboxColumnPinned} left={ isCheckboxColumnPinned ? '0px' : undefined @@ -604,26 +536,10 @@ const Table = ({ width={ isCheckboxColumnPinned ? '76px' : undefined } - className={cx(styles.checkboxCell, { - [styles.selected]: isSelected, - [styles.hovered]: isHovered, - })} - > - <input - type="checkbox" - checked={isSelected} - onChange={() => - rowId && - dispatch( - toggleFeatureSelection( - rowId, - layer.id - ) - ) - } - onClick={(e) => e.stopPropagation()} - /> - </DataTableCell> + isSelected={isSelected} + isHovered={isHovered} + onToggle={() => rowId && onToggleRow(rowId)} + /> {visibleHeaders.map(({ dataKey }, index) => { const cell = cellsByDataKey.get(dataKey) if (!cell) { diff --git a/src/components/datatable/FilterInput.jsx b/src/components/datatable/FilterInput.jsx index 0475f0abb4..b26747d8be 100644 --- a/src/components/datatable/FilterInput.jsx +++ b/src/components/datatable/FilterInput.jsx @@ -3,9 +3,7 @@ import { Input, IconFilter16, IconSync16 } from '@dhis2/ui' import cx from 'classnames' import PropTypes from 'prop-types' import React, { useCallback, useMemo, useRef, useState } from 'react' -import { useDispatch, useSelector } from 'react-redux' import { Virtuoso } from 'react-virtuoso' -import { setDataFilter, clearDataFilter } from '../../actions/dataFilters.js' import { SENTINEL_ANY_VALUE, SENTINEL_NO_VALUE, @@ -80,15 +78,15 @@ const NUMERIC_INPUT_DISALLOWED = /[^0-9.\-<>=,&\s]/g const SearchableFilterPopover = React.memo(function SearchableFilterPopover({ dataKey, name, - layerId, filterValue, options, resolveLabel, type, renderer, allowCustomFilter = true, + onChange, + onClear, }) { - const dispatch = useDispatch() const anchorRef = useRef(null) const listRef = useRef(null) const [isOpen, setIsOpen] = useState(false) @@ -109,10 +107,7 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({ const { dropdownPlacement, dropdownSide, tooltipPlacement } = getDropdownPlacement(anchorRect) - const applyValues = (next) => - next.length - ? dispatch(setDataFilter(layerId, dataKey, next)) - : dispatch(clearDataFilter(layerId, dataKey)) + const applyValues = (next) => (next.length ? onChange(next) : onClear()) const toggleValue = (value) => { const next = selected.includes(value) @@ -126,7 +121,7 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({ const applyCustomFilter = (text) => { if (!text) { - dispatch(clearDataFilter(layerId, dataKey)) + onClear() return } if (isOrgUnitRenderer) { @@ -134,16 +129,14 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({ const values = realValues.filter((value) => resolveLabel(value).toLowerCase().includes(lower) ) - dispatch( - setDataFilter(layerId, dataKey, { - values, - searchDerived: true, - searchText: text, - }) - ) + onChange({ + values, + searchDerived: true, + searchText: text, + }) return } - dispatch(setDataFilter(layerId, dataKey, text)) + onChange(text) } const isIconColumn = renderer === RENDERER_ICON @@ -234,7 +227,7 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({ const trimmed = sanitized.trim() if (trimmed === '') { if (hasActiveFilter) { - dispatch(clearDataFilter(layerId, dataKey)) + onClear() } return } @@ -505,12 +498,13 @@ SearchableFilterPopover.propTypes = { .isRequired, resolveLabel: PropTypes.func.isRequired, type: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + onClear: PropTypes.func.isRequired, allowCustomFilter: PropTypes.bool, filterValue: PropTypes.oneOfType([ PropTypes.string, PropTypes.arrayOf(PropTypes.string), ]), - layerId: PropTypes.string, renderer: PropTypes.string, } @@ -580,6 +574,11 @@ OptionSetSearchableFilter.propTypes = { optionSetId: PropTypes.string.isRequired, } +// filterValue/onChange/onClear are supplied by the caller (dispatch-agnostic, +// like useRowSelection) - a real layer's Redux dataFilters for DataTable.jsx, +// or local session-only state for CombinedDataTable.jsx. layerId is only +// used by the date/org-unit group filter paths, which still dispatch +// directly since Combined never produces those column types today. const FilterInput = React.memo(function FilterInput({ layerId, type, @@ -589,14 +588,10 @@ const FilterInput = React.memo(function FilterInput({ optionSetId, renderer, orgUnitIdToName, + filterValue, + onChange, + onClear, }) { - const map = useSelector((state) => state.map) - - const overlay = map.mapViews.find((layer) => layer.id === layerId) - const filters = overlay?.dataFilters || {} - - const filterValue = filters[dataKey] - const isDateType = type === TYPE_DATE || type === TYPE_DATETIME || type === TYPE_TIME @@ -631,12 +626,13 @@ const FilterInput = React.memo(function FilterInput({ <OptionSetSearchableFilter dataKey={dataKey} name={name} - layerId={layerId} filterValue={filterValue} options={options ?? []} optionSetId={optionSetId} type={type} renderer={renderer} + onChange={onChange} + onClear={onClear} /> ) } @@ -645,12 +641,13 @@ const FilterInput = React.memo(function FilterInput({ <PlainSearchableFilter dataKey={dataKey} name={name} - layerId={layerId} filterValue={filterValue} options={options ?? []} type={type} renderer={renderer} orgUnitIdToName={orgUnitIdToName} + onChange={onChange} + onClear={onClear} /> ) }) @@ -659,11 +656,18 @@ FilterInput.propTypes = { dataKey: PropTypes.string.isRequired, name: PropTypes.string.isRequired, type: PropTypes.string.isRequired, + filterValue: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.arrayOf(PropTypes.string), + PropTypes.object, + ]), layerId: PropTypes.string, optionSetId: PropTypes.string, options: PropTypes.arrayOf(PropTypes.shape({ value: PropTypes.string })), orgUnitIdToName: PropTypes.instanceOf(Map), renderer: PropTypes.string, + onChange: PropTypes.func, + onClear: PropTypes.func, } export default FilterInput diff --git a/src/components/datatable/SelectionCheckboxColumn.jsx b/src/components/datatable/SelectionCheckboxColumn.jsx new file mode 100644 index 0000000000..9eedc47f64 --- /dev/null +++ b/src/components/datatable/SelectionCheckboxColumn.jsx @@ -0,0 +1,131 @@ +import i18n from '@dhis2/d2-i18n' +import { DataTableColumnHeader, DataTableCell, IconSync16 } from '@dhis2/ui' +import cx from 'classnames' +import PropTypes from 'prop-types' +import React from 'react' +import { SENTINEL_SELECTED_ROW } from '../../constants/dataTable.js' +import { SortIcon } from '../core/icons.jsx' +import styles from './styles/DataTable.module.css' +import TopTooltip from './TopTooltip.jsx' + +// Shared between DataTable.jsx and CombinedDataTable.jsx - the checkbox +// column's markup and select-all/reverse-selection/sort-by-selected +// interactions are identical, only where the selection itself lives differs. +export const SelectionCheckboxHeaderCell = ({ + fixed, + left, + isAllSelected, + onToggleSelectAll, + onReverseSelection, + disabled, + sortField, + sortDirection, + onSortBySelected, + filter, + showFilter, + onFilterIconClick, +}) => ( + <DataTableColumnHeader + className={styles.checkboxCell} + width="76px" + fixed={fixed} + left={left} + onFilterIconClick={onFilterIconClick} + showFilter={showFilter} + filter={filter} + > + <div className={styles.checkboxHeaderContent}> + <TopTooltip content={i18n.t('Select all visible rows')}> + <input + type="checkbox" + aria-label={i18n.t('Select all visible rows')} + checked={isAllSelected} + onChange={onToggleSelectAll} + /> + </TopTooltip> + <TopTooltip content={i18n.t('Reverse selection of visible rows')}> + <button + type="button" + className={styles.reverseButton} + data-test="data-table-reverse-selection" + disabled={disabled} + onClick={onReverseSelection} + > + <IconSync16 /> + </button> + </TopTooltip> + {onSortBySelected && ( + <TopTooltip content={i18n.t('Sort by Selected')}> + <button + type="button" + className={styles.sortButton} + data-test="data-table-column-sort-button-selected" + onClick={onSortBySelected} + > + <SortIcon + direction={ + sortField === SENTINEL_SELECTED_ROW + ? sortDirection + : null + } + /> + </button> + </TopTooltip> + )} + </div> + </DataTableColumnHeader> +) + +SelectionCheckboxHeaderCell.propTypes = { + disabled: PropTypes.bool, + filter: PropTypes.node, + fixed: PropTypes.bool, + isAllSelected: PropTypes.bool, + left: PropTypes.string, + showFilter: PropTypes.bool, + sortDirection: PropTypes.string, + sortField: PropTypes.string, + onFilterIconClick: PropTypes.func, + onReverseSelection: PropTypes.func, + onSortBySelected: PropTypes.func, + onToggleSelectAll: PropTypes.func, +} + +export const SelectionCheckboxCell = ({ + fixed, + left, + width, + className, + isSelected, + isHovered, + onToggle, +}) => ( + <DataTableCell + staticStyle + fixed={fixed} + left={left} + width={width} + className={cx( + styles.checkboxCell, + { [styles.selected]: isSelected, [styles.hovered]: isHovered }, + className + )} + > + <input + type="checkbox" + checked={isSelected} + onChange={onToggle} + onClick={(e) => e.stopPropagation()} + /> + </DataTableCell> +) + +SelectionCheckboxCell.propTypes = { + className: PropTypes.string, + fixed: PropTypes.bool, + isHovered: PropTypes.bool, + isSelected: PropTypes.bool, + left: PropTypes.string, + width: PropTypes.string, + onToggle: PropTypes.func, +} diff --git a/src/components/datatable/SortableColumnHeader.jsx b/src/components/datatable/SortableColumnHeader.jsx new file mode 100644 index 0000000000..b00faab82a --- /dev/null +++ b/src/components/datatable/SortableColumnHeader.jsx @@ -0,0 +1,51 @@ +import i18n from '@dhis2/d2-i18n' +import { DataTableColumnHeader } from '@dhis2/ui' +import PropTypes from 'prop-types' +import React from 'react' +import { SortIcon } from '../core/icons.jsx' +import styles from './styles/DataTable.module.css' +import TopTooltip from './TopTooltip.jsx' + +// Shared between DataTable.jsx and CombinedDataTable.jsx - both produce the +// same {name, dataKey} header shape and the same sort-button interaction, +// so only the surrounding column-header props (pinning, filter) differ. +const SortableColumnHeader = ({ + name, + dataKey, + sortField, + sortDirection, + onSort, + dataTestPrefix, + ...columnHeaderProps +}) => ( + <DataTableColumnHeader name={dataKey} {...columnHeaderProps}> + <span className={styles.headerContent}> + <span className={styles.headerTitle}>{name}</span> + <TopTooltip + content={i18n.t('Sort by {{column}}', { column: name })} + > + <button + type="button" + className={styles.sortButton} + data-test={`${dataTestPrefix}-${name}`} + onClick={() => onSort({ name: dataKey })} + > + <SortIcon + direction={dataKey === sortField ? sortDirection : null} + /> + </button> + </TopTooltip> + </span> + </DataTableColumnHeader> +) + +SortableColumnHeader.propTypes = { + dataKey: PropTypes.string.isRequired, + dataTestPrefix: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + onSort: PropTypes.func.isRequired, + sortDirection: PropTypes.string, + sortField: PropTypes.string, +} + +export default SortableColumnHeader diff --git a/src/components/datatable/__tests__/CombinedDataTable.spec.jsx b/src/components/datatable/__tests__/CombinedDataTable.spec.jsx index 9ec72bf860..a36d01a7ef 100644 --- a/src/components/datatable/__tests__/CombinedDataTable.spec.jsx +++ b/src/components/datatable/__tests__/CombinedDataTable.spec.jsx @@ -11,6 +11,12 @@ jest.mock('../../../hooks/useOrgUnitAncestorNames.js', () => ({ default: jest.fn(), })) +jest.mock('../../cachedDataProvider/CachedDataProvider.jsx', () => ({ + useCachedData: () => ({ + systemSettings: { keyAnalysisDigitGroupSeparator: 'COMMA' }, + }), +})) + const mockStore = configureMockStore() beforeEach(() => { @@ -252,8 +258,9 @@ describe('CombinedDataTable', () => { }) const input = screen - .getByTestId('combined-table-column-filter-ID') + .getByTestId('data-table-column-filter-search-ID') .querySelector('input') + fireEvent.focus(input) fireEvent.change(input, { target: { value: 'ou1' } }) expect(onFiltersChange).toHaveBeenCalledWith({ id: 'ou1' }) diff --git a/src/components/datatable/__tests__/FilterInput.spec.jsx b/src/components/datatable/__tests__/FilterInput.spec.jsx index 1e027e29bd..5562fe917d 100644 --- a/src/components/datatable/__tests__/FilterInput.spec.jsx +++ b/src/components/datatable/__tests__/FilterInput.spec.jsx @@ -3,6 +3,7 @@ import React from 'react' import { Provider } from 'react-redux' import { VirtuosoMockContext } from 'react-virtuoso' import configureMockStore from 'redux-mock-store' +import { setDataFilter, clearDataFilter } from '../../../actions/dataFilters.js' import { DATA_FILTER_SET, DATA_FILTER_CLEAR, @@ -28,12 +29,18 @@ jest.mock('../../cachedDataProvider/CachedDataProvider.jsx', () => ({ const mockStore = configureMockStore() +// FilterInput is dispatch-agnostic (filterValue/onChange/onClear are caller- +// supplied) - this helper reproduces exactly what DataTable.jsx's real call +// site does (dispatch setDataFilter/clearDataFilter against a real layer), +// so every existing assertion against store.getActions() still holds. const renderFilterInput = (props, dataFilters) => { const store = mockStore({ map: { mapViews: [{ id: 'layer1', dataFilters: dataFilters || {} }], }, }) + const dataKey = props?.dataKey ?? 'name' + const filterValue = (dataFilters || {})[dataKey] // The checkbox list is virtualized (react-virtuoso) const result = render( <Provider store={store}> @@ -45,6 +52,13 @@ const renderFilterInput = (props, dataFilters) => { dataKey="name" name="Name" type="string" + filterValue={filterValue} + onChange={(value) => + store.dispatch(setDataFilter('layer1', dataKey, value)) + } + onClear={() => + store.dispatch(clearDataFilter('layer1', dataKey)) + } {...props} /> </VirtuosoMockContext.Provider> diff --git a/src/components/datatable/__tests__/useCombinedTableData.spec.js b/src/components/datatable/__tests__/useCombinedTableData.spec.js index 16cf8c6163..9d252206f0 100644 --- a/src/components/datatable/__tests__/useCombinedTableData.spec.js +++ b/src/components/datatable/__tests__/useCombinedTableData.spec.js @@ -471,6 +471,7 @@ describe('useCombinedTableData - spatial join', () => { headers: [], rows: [], rowFeatureIds: new Map(), + columnOptions: {}, spatialWarning: false, }) }) @@ -584,6 +585,35 @@ describe('useCombinedTableData - sorting and filtering', () => { ['ou2'] ) }) + + test('exposes distinct column values for the filter popover, sorted ascending by default', () => { + const { result } = renderHook(() => + useCombinedTableData({ layers, joinConfig }) + ) + + expect(result.current.columnOptions.layerA_rawValue).toEqual([ + { value: '10' }, + { value: '20' }, + { value: '30' }, + ]) + }) + + test("sorts a column's distinct values descending when it is the active sort field", () => { + const { result } = renderHook(() => + useCombinedTableData({ + layers, + joinConfig, + sortField: 'layerA_rawValue', + sortDirection: 'desc', + }) + ) + + expect(result.current.columnOptions.layerA_rawValue).toEqual([ + { value: '30' }, + { value: '20' }, + { value: '10' }, + ]) + }) }) describe('useCombinedTableData - empty input', () => { @@ -603,6 +633,7 @@ describe('useCombinedTableData - empty input', () => { headers: [], rows: [], rowFeatureIds: new Map(), + columnOptions: {}, spatialWarning: false, }) }) diff --git a/src/components/datatable/__tests__/useRowClickSelection.spec.js b/src/components/datatable/__tests__/useRowClickSelection.spec.js new file mode 100644 index 0000000000..0b7be75f09 --- /dev/null +++ b/src/components/datatable/__tests__/useRowClickSelection.spec.js @@ -0,0 +1,61 @@ +import { renderHook } from '@testing-library/react' +import { useRowClickSelection } from '../useRowClickSelection.js' + +const row = (id) => [{ dataKey: 'id', value: id, align: 'left' }] + +describe('useRowClickSelection', () => { + test('does nothing on a plain click (no modifier)', () => { + const onToggle = jest.fn() + const onSelectRange = jest.fn() + const rows = [row('a'), row('b')] + const { result } = renderHook(() => + useRowClickSelection({ rows, onToggle, onSelectRange }) + ) + + result.current(row('a'), { ctrlKey: false, shiftKey: false }) + + expect(onToggle).not.toHaveBeenCalled() + expect(onSelectRange).not.toHaveBeenCalled() + }) + + test('toggles a single row on ctrl/cmd-click', () => { + const onToggle = jest.fn() + const onSelectRange = jest.fn() + const rows = [row('a'), row('b')] + const { result } = renderHook(() => + useRowClickSelection({ rows, onToggle, onSelectRange }) + ) + + result.current(row('b'), { ctrlKey: true }) + + expect(onToggle).toHaveBeenCalledWith('b') + expect(onSelectRange).not.toHaveBeenCalled() + }) + + test('selects a range on shift-click after a prior click', () => { + const onToggle = jest.fn() + const onSelectRange = jest.fn() + const rows = [row('a'), row('b'), row('c'), row('d')] + const { result } = renderHook(() => + useRowClickSelection({ rows, onToggle, onSelectRange }) + ) + + result.current(row('a'), { ctrlKey: true }) + result.current(row('c'), { shiftKey: true }) + + expect(onSelectRange).toHaveBeenCalledWith(['a', 'b', 'c']) + }) + + test('does nothing when the row has no id', () => { + const onToggle = jest.fn() + const onSelectRange = jest.fn() + const rows = [row(null)] + const { result } = renderHook(() => + useRowClickSelection({ rows, onToggle, onSelectRange }) + ) + + result.current(row(null), { ctrlKey: true }) + + expect(onToggle).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/datatable/__tests__/useRowSelection.spec.js b/src/components/datatable/__tests__/useRowSelection.spec.js new file mode 100644 index 0000000000..61d7a78225 --- /dev/null +++ b/src/components/datatable/__tests__/useRowSelection.spec.js @@ -0,0 +1,72 @@ +import { renderHook } from '@testing-library/react' +import { useRowSelection } from '../useRowSelection.js' + +describe('useRowSelection', () => { + test('selects every visible row when nothing is selected yet', () => { + const onChange = jest.fn() + const { result } = renderHook(() => + useRowSelection({ + selectedIds: [], + selectedIdSet: new Set(), + allRowIds: ['a', 'b', 'c'], + onChange, + }) + ) + + expect(result.current.isAllSelected).toBe(false) + + result.current.onToggleSelectAll() + + expect(onChange).toHaveBeenCalledWith(['a', 'b', 'c']) + }) + + test('deselects every visible row when all are already selected', () => { + const onChange = jest.fn() + const { result } = renderHook(() => + useRowSelection({ + selectedIds: ['a', 'b', 'c'], + selectedIdSet: new Set(['a', 'b', 'c']), + allRowIds: ['a', 'b', 'c'], + onChange, + }) + ) + + expect(result.current.isAllSelected).toBe(true) + + result.current.onToggleSelectAll() + + expect(onChange).toHaveBeenCalledWith([]) + }) + + test('preserves ids selected outside the current view when toggling off', () => { + const onChange = jest.fn() + const { result } = renderHook(() => + useRowSelection({ + selectedIds: ['a', 'b', 'z'], + selectedIdSet: new Set(['a', 'b', 'z']), + allRowIds: ['a', 'b'], + onChange, + }) + ) + + result.current.onToggleSelectAll() + + expect(onChange).toHaveBeenCalledWith(['z']) + }) + + test('reverses the visible selection via onChange', () => { + const onChange = jest.fn() + const { result } = renderHook(() => + useRowSelection({ + selectedIds: ['a'], + selectedIdSet: new Set(['a']), + allRowIds: ['a', 'b', 'c'], + onChange, + }) + ) + + result.current.onReverseSelection() + + expect(onChange).toHaveBeenCalledWith(['b', 'c']) + }) +}) diff --git a/src/components/datatable/styles/CombinedDataTable.module.css b/src/components/datatable/styles/CombinedDataTable.module.css index 72b84bfbff..7c5a5dabac 100644 --- a/src/components/datatable/styles/CombinedDataTable.module.css +++ b/src/components/datatable/styles/CombinedDataTable.module.css @@ -4,15 +4,6 @@ flex-direction: column; } -.dataTable { - height: 1px; - border: none !important; -} - -.dataTable > :global(thead) { - user-select: none; -} - .noResults { display: flex; color: var(--colors-grey600); @@ -31,28 +22,3 @@ font-size: 12px; border-bottom: 1px solid var(--colors-yellow300); } - -th.checkboxCell, -td.checkboxCell { - width: 76px; - min-width: 76px; - max-width: 76px; - text-align: center; - padding: 0; - padding-top: 3px; - vertical-align: middle; -} - -.checkboxCell input[type='checkbox'] { - accent-color: var(--colors-teal600); -} - -td.selected, -th.selected { - background-color: var(--colors-blue050); -} - -td.hovered, -th.hovered { - background-color: var(--colors-blue100); -} diff --git a/src/components/datatable/useCombinedTableData.js b/src/components/datatable/useCombinedTableData.js index 8bab19f6c0..e1b7f09bee 100644 --- a/src/components/datatable/useCombinedTableData.js +++ b/src/components/datatable/useCombinedTableData.js @@ -12,7 +12,11 @@ import useOrgUnitAncestorNames from '../../hooks/useOrgUnitAncestorNames.js' import { filterByGlobalSearch, filterData } from '../../util/filter.js' import { formatOrgUnitOwnName } from '../../util/orgUnitGroups.js' import { spatialJoin } from '../../util/spatialJoin.js' -import { buildRowCells } from '../../util/tableColumns.js' +import { + buildRowCells, + getColumnDistinctValues, + sortColumnOptions, +} from '../../util/tableColumns.js' import { compareRows } from '../../util/tableSort.js' const VALUE_KEY = 'rawValue' @@ -58,10 +62,13 @@ const getParentPath = (path) => { return segments.length > 1 ? segments.slice(0, -1).join('/') : null } +const EMPTY_COLUMN_OPTIONS = {} + const EMPTY_RESULT = { headers: [], rows: [], rowFeatureIds: new Map(), + columnOptions: EMPTY_COLUMN_OPTIONS, spatialWarning: false, } @@ -223,8 +230,19 @@ export const useCombinedTableData = ({ sortField, sortDirection, }) - - return { headers, rows, rowFeatureIds, spatialWarning } + const columnOptions = + sortColumnOptions(getColumnDistinctValues(headers, flatRows), { + sortField, + sortDirection, + }) ?? EMPTY_COLUMN_OPTIONS + + return { + headers, + rows, + rowFeatureIds, + columnOptions, + spatialWarning, + } } const layerHeaders = layerMaps.flatMap(({ layer }) => [ @@ -302,8 +320,19 @@ export const useCombinedTableData = ({ sortField, sortDirection, }) - - return { headers, rows, rowFeatureIds, spatialWarning: false } + const columnOptions = + sortColumnOptions(getColumnDistinctValues(headers, flatRows), { + sortField, + sortDirection, + }) ?? EMPTY_COLUMN_OPTIONS + + return { + headers, + rows, + rowFeatureIds, + columnOptions, + spatialWarning: false, + } } const headers = [ @@ -345,8 +374,19 @@ export const useCombinedTableData = ({ sortField, sortDirection, }) - - return { headers, rows, rowFeatureIds, spatialWarning: false } + const columnOptions = + sortColumnOptions(getColumnDistinctValues(headers, flatRows), { + sortField, + sortDirection, + }) ?? EMPTY_COLUMN_OPTIONS + + return { + headers, + rows, + rowFeatureIds, + columnOptions, + spatialWarning: false, + } }, [ layers, layerMaps, diff --git a/src/components/datatable/useRowClickSelection.js b/src/components/datatable/useRowClickSelection.js new file mode 100644 index 0000000000..b4f89659ed --- /dev/null +++ b/src/components/datatable/useRowClickSelection.js @@ -0,0 +1,40 @@ +import { useCallback, useRef } from 'react' +import { getRowClickAction, getRowId } from '../../util/dataTable.js' + +// Shared row-click-to-selection-action handling: shift-click ranges, ctrl/cmd +// toggles a single row. onToggle/onSelectRange apply the result however the +// caller's selection state actually works (Redux for a single layer, local +// state for Combined's cross-layer selection). +export const useRowClickSelection = ({ rows, onToggle, onSelectRange }) => { + const lastClickedRowIndexRef = useRef(null) + + return useCallback( + (row, event) => { + const id = getRowId(row) + + if (!id || !rows) { + return + } + + const rowIndex = rows.findIndex((r) => getRowId(r) === id) + const action = getRowClickAction(event, { + id, + rowIndex, + rows, + lastClickedRowIndex: lastClickedRowIndexRef.current, + }) + + if (!action) { + return + } + + if (action.type === 'range') { + onSelectRange(action.ids) + } else { + onToggle(action.id) + } + lastClickedRowIndexRef.current = rowIndex + }, + [rows, onToggle, onSelectRange] + ) +} diff --git a/src/components/datatable/useRowSelection.js b/src/components/datatable/useRowSelection.js index 870bda6bd1..a2157c3914 100644 --- a/src/components/datatable/useRowSelection.js +++ b/src/components/datatable/useRowSelection.js @@ -1,6 +1,4 @@ import { useCallback, useMemo } from 'react' -import { useDispatch } from 'react-redux' -import { selectAllFeatures, clearSelection } from '../../actions/selection.js' export const getReversedSelection = (selectedIds, allRowIds) => { const selectedIdSet = new Set(selectedIds) @@ -10,14 +8,16 @@ export const getReversedSelection = (selectedIds, allRowIds) => { return [...offViewSelected, ...invertedVisible] } +// onChange receives the full next selection (possibly empty) - the caller +// decides how to apply it (dispatch to a single layer's Redux selection, +// set local state, etc), so this hook has no opinion on where selection +// state actually lives. export const useRowSelection = ({ selectedIds, selectedIdSet, allRowIds, - layerId, + onChange, }) => { - const dispatch = useDispatch() - const allRowIdSet = useMemo(() => new Set(allRowIds), [allRowIds]) const isAllSelected = useMemo( @@ -32,22 +32,12 @@ export const useRowSelection = ({ ? selectedIds.filter((id) => !allRowIdSet.has(id)) : [...new Set([...selectedIds, ...allRowIds])] - if (nextIds.length) { - dispatch(selectAllFeatures(nextIds, layerId)) - } else { - dispatch(clearSelection()) - } - }, [dispatch, isAllSelected, allRowIds, allRowIdSet, selectedIds, layerId]) + onChange(nextIds) + }, [isAllSelected, allRowIds, allRowIdSet, selectedIds, onChange]) const onReverseSelection = useCallback(() => { - const nextIds = getReversedSelection(selectedIds, allRowIds) - - if (nextIds.length) { - dispatch(selectAllFeatures(nextIds, layerId)) - } else { - dispatch(clearSelection()) - } - }, [dispatch, selectedIds, allRowIds, layerId]) + onChange(getReversedSelection(selectedIds, allRowIds)) + }, [selectedIds, allRowIds, onChange]) return { isAllSelected, diff --git a/src/components/datatable/useSortState.js b/src/components/datatable/useSortState.js new file mode 100644 index 0000000000..2812fcec5f --- /dev/null +++ b/src/components/datatable/useSortState.js @@ -0,0 +1,22 @@ +import { useCallback, useReducer } from 'react' +import { SORT_ASCENDING } from '../../constants/dataTable.js' +import { getNextSorting } from '../../util/dataTable.js' + +// Shared between DataTable.jsx and CombinedDataTable.jsx - each keeps its +// own independent sort state (neither is persisted), driven by the same +// three-click asc/desc/none cycle. +export const useSortState = (initialSortField = 'name') => { + const [{ sortField, sortDirection }, setSorting] = useReducer( + (sorting, newSorting) => ({ ...sorting, ...newSorting }), + { sortField: initialSortField, sortDirection: SORT_ASCENDING } + ) + + const sortData = useCallback( + ({ name }) => { + setSorting(getNextSorting(name, { sortField, sortDirection })) + }, + [sortField, sortDirection] + ) + + return { sortField, sortDirection, sortData } +} diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index ceb6337404..e347859f0e 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -3,7 +3,6 @@ import { useDeferredValue, useMemo, useRef } from 'react' import { useSelector } from 'react-redux' import { SENTINEL_SELECTED_ROW, - SORT_ASCENDING, TYPE_ORG_UNIT, RENDERER_ORG_UNIT, RENDERER_ORG_UNIT_NAME, @@ -24,6 +23,7 @@ import { filterByGlobalSearch, filterData } from '../../util/filter.js' import { buildRowCells, getColumnDistinctValues, + sortColumnOptions, } from '../../util/tableColumns.js' import { TYPE_STRING, @@ -31,7 +31,7 @@ import { getHeadersForLayer, } from '../../util/tableHeaders.js' import { ERROR_NO_VALID_DATA, buildTableData } from '../../util/tableRows.js' -import { compareColumnOptionValues, compareRows } from '../../util/tableSort.js' +import { compareRows } from '../../util/tableSort.js' const ERROR_NO_HEADERS = 'NO_HEADERS' @@ -214,30 +214,14 @@ export const useTableData = ({ ) // Cheap: just re-orders each column's already-known distinct-value list - const columnOptions = useMemo(() => { - if (!columnDistinctValues) { - return EMPTY_COLUMN_OPTIONS - } - - const result = {} - Object.entries(columnDistinctValues).forEach( - ([dataKey, { values, type }]) => { - const direction = - dataKey === sortField ? sortDirection : SORT_ASCENDING - result[dataKey] = [...values] - .sort((a, b) => - compareColumnOptionValues(a, b, { - dataKey, - type, - direction, - }) - ) - .map((value) => ({ value })) - } - ) - - return Object.keys(result).length ? result : EMPTY_COLUMN_OPTIONS - }, [columnDistinctValues, sortField, sortDirection]) + const columnOptions = useMemo( + () => + sortColumnOptions(columnDistinctValues, { + sortField, + sortDirection, + }) ?? EMPTY_COLUMN_OPTIONS, + [columnDistinctValues, sortField, sortDirection] + ) const orgUnitPathValues = useMemo( () => diff --git a/src/util/__tests__/tableColumns.spec.js b/src/util/__tests__/tableColumns.spec.js index 39efc86bd9..a22a804090 100644 --- a/src/util/__tests__/tableColumns.spec.js +++ b/src/util/__tests__/tableColumns.spec.js @@ -12,6 +12,7 @@ import { isPinnedGroupEnd, reorderHeaderKeys, reverseVisibleKeys, + sortColumnOptions, togglePinnedKey, toggleVisibleKey, } from '../tableColumns.js' @@ -438,6 +439,38 @@ describe('getColumnDistinctValues', () => { }) }) +describe('sortColumnOptions', () => { + it('returns null when there are no distinct values', () => { + expect(sortColumnOptions(null)).toBe(null) + }) + + it('sorts each column ascending by default', () => { + const distinctValues = { + rawValue: { values: ['30', '10', '20'], type: TYPE_NUMBER }, + } + expect(sortColumnOptions(distinctValues)).toEqual({ + rawValue: [{ value: '10' }, { value: '20' }, { value: '30' }], + }) + }) + + it('sorts the active sort field descending, leaving other columns ascending', () => { + const distinctValues = { + rawValue: { values: ['30', '10', '20'], type: TYPE_NUMBER }, + name: { values: ['B', 'A'], type: 'string' }, + } + const result = sortColumnOptions(distinctValues, { + sortField: 'rawValue', + sortDirection: 'desc', + }) + expect(result.rawValue).toEqual([ + { value: '30' }, + { value: '20' }, + { value: '10' }, + ]) + expect(result.name).toEqual([{ value: 'A' }, { value: 'B' }]) + }) +}) + describe('buildRowCells', () => { const rowHeaders = [ { dataKey: 'name', type: 'string' }, diff --git a/src/util/tableColumns.js b/src/util/tableColumns.js index 5579d28d3b..2c4ddd3702 100644 --- a/src/util/tableColumns.js +++ b/src/util/tableColumns.js @@ -1,5 +1,10 @@ import { arrayMoveImmutable } from 'array-move' -import { SENTINEL_NO_VALUE, TYPE_NUMBER } from '../constants/dataTable.js' +import { + SENTINEL_NO_VALUE, + SORT_ASCENDING, + TYPE_NUMBER, +} from '../constants/dataTable.js' +import { compareColumnOptionValues } from './tableSort.js' const CHECKBOX_COLUMN_WIDTH = 76 @@ -141,6 +146,39 @@ export const getColumnDistinctValues = (headers, data) => { return result } +// Cheap: just re-orders each column's already-known distinct-value list from +// getColumnDistinctValues into the {value} option list FilterInput expects. +// Kept separate from the expensive scan above so re-sorting doesn't force a +// re-scan - shared by DataTable.jsx and CombinedDataTable.jsx's filter +// popovers. +export const sortColumnOptions = ( + columnDistinctValues, + { sortField, sortDirection } = {} +) => { + if (!columnDistinctValues) { + return null + } + + const result = {} + Object.entries(columnDistinctValues).forEach( + ([dataKey, { values, type }]) => { + const direction = + dataKey === sortField ? sortDirection : SORT_ASCENDING + result[dataKey] = [...values] + .sort((a, b) => + compareColumnOptionValues(a, b, { + dataKey, + type, + direction, + }) + ) + .map((value) => ({ value })) + } + ) + + return Object.keys(result).length ? result : null +} + export const buildRowCells = (item, headers) => headers.map(({ dataKey, roundFn, type }) => { const value = roundFn ? roundFn(item[dataKey]) : item[dataKey] From 31076fcff7c1c14e7d5d80cc49d450a85a6d4333 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 28 Jul 2026 13:10:45 +0200 Subject: [PATCH 149/205] refactor: share cell value formatting between DataTable and CombinedDataTable [DHIS2-20543] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CombinedDataTable rendered raw cell values directly, so numeric columns never got digit-group formatting (or any of DataTable's renderer-specific formatting for color/icon/date/org-unit/boolean columns), unlike the single-layer table. Extract DataTable.jsx's inline cell-content logic into a shared CellValue component (plus a getCellRendererFlags helper for the className-side flags each caller still needs), used by both tables. Also unify on Combined's em-dash placeholder for blank cells (previously DataTable rendered these as empty) - CellValue now applies it uniformly regardless of renderer, so both tables show "—" for a missing value. --- src/components/datatable/CellValue.jsx | 110 +++++++++++++++++ .../datatable/CombinedDataTable.jsx | 25 +++- src/components/datatable/DataTable.jsx | 83 +++---------- .../datatable/__tests__/CellValue.spec.jsx | 112 ++++++++++++++++++ .../__tests__/CombinedDataTable.spec.jsx | 28 +++++ 5 files changed, 288 insertions(+), 70 deletions(-) create mode 100644 src/components/datatable/CellValue.jsx create mode 100644 src/components/datatable/__tests__/CellValue.spec.jsx diff --git a/src/components/datatable/CellValue.jsx b/src/components/datatable/CellValue.jsx new file mode 100644 index 0000000000..d7dcfa3420 --- /dev/null +++ b/src/components/datatable/CellValue.jsx @@ -0,0 +1,110 @@ +import PropTypes from 'prop-types' +import React from 'react' +import { + RENDERER_COLOR, + RENDERER_ICON, + RENDERER_DATE, + RENDERER_ORG_UNIT, + RENDERER_ORG_UNIT_NAME, + RENDERER_BOOLEAN, + TYPE_DATE, +} from '../../constants/dataTable.js' +import { + formatBoolean, + formatDate, + formatDatetime, +} from '../../util/helpers.js' +import { formatWithSeparator } from '../../util/numbers.js' +import { + formatOrgUnitOwnName, + formatOrgUnitPathBreadcrumb, +} from '../../util/orgUnitGroups.js' +import styles from './styles/DataTable.module.css' + +// Shared between DataTable.jsx and CombinedDataTable.jsx - which renderer a +// column uses determines both its cell content (CellValue, below) and its +// DataTableCell className (isDarkColor/monoCell/backgroundColor - computed +// by each caller since those touch component-specific selected/hovered/ +// pinned state too), so both need these same flags. +export const getCellRendererFlags = (renderer, type) => ({ + isColorCell: renderer === RENDERER_COLOR, + isIconCell: renderer === RENDERER_ICON, + isDateCell: renderer === RENDERER_DATE, + isDateOnlyCell: type === TYPE_DATE, + isOrgUnitHierarchyCell: renderer === RENDERER_ORG_UNIT, + isOrgUnitNameCell: renderer === RENDERER_ORG_UNIT_NAME, + isBooleanCell: renderer === RENDERER_BOOLEAN, +}) + +const NO_VALUE = '—' + +const CellValue = ({ + value, + renderer, + type, + orgUnitIdToName, + keyAnalysisDigitGroupSeparator, +}) => { + if (value == null) { + return NO_VALUE + } + + const { + isColorCell, + isIconCell, + isDateCell, + isDateOnlyCell, + isOrgUnitHierarchyCell, + isOrgUnitNameCell, + isBooleanCell, + } = getCellRendererFlags(renderer, type) + + if (isColorCell) { + return value.toLowerCase() + } + + if (isIconCell) { + return ( + <img + className={styles.iconCell} + src={value} + alt="" + onError={(e) => { + e.target.style.visibility = 'hidden' + }} + /> + ) + } + + if (isDateCell) { + return isDateOnlyCell ? formatDate(value) : formatDatetime(value) + } + + if (isOrgUnitHierarchyCell) { + return formatOrgUnitPathBreadcrumb(value, orgUnitIdToName) + } + + if (isOrgUnitNameCell) { + return formatOrgUnitOwnName(value, orgUnitIdToName) + } + + if (isBooleanCell) { + return formatBoolean(value) + } + + return formatWithSeparator(value, keyAnalysisDigitGroupSeparator) +} + +CellValue.propTypes = { + keyAnalysisDigitGroupSeparator: PropTypes.string, + orgUnitIdToName: PropTypes.instanceOf(Map), + renderer: PropTypes.string, + type: PropTypes.string, + value: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + PropTypes.bool, + ]), +} + +export default CellValue diff --git a/src/components/datatable/CombinedDataTable.jsx b/src/components/datatable/CombinedDataTable.jsx index f3ce681a08..e912b02d3d 100644 --- a/src/components/datatable/CombinedDataTable.jsx +++ b/src/components/datatable/CombinedDataTable.jsx @@ -13,6 +13,8 @@ import { getRowId, shouldClearFeatureHighlight, } from '../../util/dataTable.js' +import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' +import CellValue from './CellValue.jsx' import FilterInput from './FilterInput.jsx' import { SelectionCheckboxHeaderCell, @@ -77,6 +79,9 @@ const CombinedDataTable = ({ onCountChange, }) => { const dispatch = useDispatch() + const { + systemSettings: { keyAnalysisDigitGroupSeparator }, + } = useCachedData() const { sortField, sortDirection, sortData } = useSortState('name') @@ -90,6 +95,15 @@ const CombinedDataTable = ({ globalSearch, }) + const rendererByDataKey = useMemo( + () => new Map(headers.map((h) => [h.dataKey, h.renderer])), + [headers] + ) + const typeByDataKey = useMemo( + () => new Map(headers.map((h) => [h.dataKey, h.type])), + [headers] + ) + useEffect(() => { onCountChange?.(rows.length, rows.length) }, [onCountChange, rows.length]) @@ -321,7 +335,16 @@ const CombinedDataTable = ({ [dataTableStyles.hovered]: isHovered, })} > - {value ?? '—'} + <CellValue + value={value} + renderer={rendererByDataKey.get( + dataKey + )} + type={typeByDataKey.get(dataKey)} + keyAnalysisDigitGroupSeparator={ + keyAnalysisDigitGroupSeparator + } + /> </DataTableCell> ))} </> diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index 38fe909c83..46c61fb09e 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -23,13 +23,6 @@ import { } from '../../actions/selection.js' import { SENTINEL_SELECTED_ROW, - RENDERER_COLOR, - RENDERER_ICON, - RENDERER_DATE, - RENDERER_ORG_UNIT, - RENDERER_ORG_UNIT_NAME, - RENDERER_BOOLEAN, - TYPE_DATE, ORG_UNIT_ID_DATA_KEY, } from '../../constants/dataTable.js' import { isDarkColor } from '../../util/colors.js' @@ -40,16 +33,6 @@ import { isFilterable, shouldClearFeatureHighlight, } from '../../util/dataTable.js' -import { - formatBoolean, - formatDate, - formatDatetime, -} from '../../util/helpers.js' -import { formatWithSeparator } from '../../util/numbers.js' -import { - formatOrgUnitOwnName, - formatOrgUnitPathBreadcrumb, -} from '../../util/orgUnitGroups.js' import { getPinnedCellProps, getPinnedCount, @@ -57,6 +40,7 @@ import { getVisibleHeaders, } from '../../util/tableColumns.js' import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' +import CellValue, { getCellRendererFlags } from './CellValue.jsx' import FilterInput from './FilterInput.jsx' import { SelectionCheckboxHeaderCell, @@ -553,17 +537,11 @@ const Table = ({ columnWidths, }) const renderer = rendererByDataKey.get(dataKey) - const isColorCell = renderer === RENDERER_COLOR - const isIconCell = renderer === RENDERER_ICON - const isDateCell = renderer === RENDERER_DATE - const isDateOnlyCell = - typeByDataKey.get(dataKey) === TYPE_DATE - const isOrgUnitHierarchyCell = - renderer === RENDERER_ORG_UNIT - const isOrgUnitNameCell = - renderer === RENDERER_ORG_UNIT_NAME - const isBooleanCell = - renderer === RENDERER_BOOLEAN + const type = typeByDataKey.get(dataKey) + const { isColorCell } = getCellRendererFlags( + renderer, + type + ) return ( <DataTableCell key={`dtcell-${dataKey}`} @@ -592,48 +570,15 @@ const Table = ({ } align={align} > - {isColorCell && value?.toLowerCase()} - {isIconCell && value && ( - <img - className={styles.iconCell} - src={value} - alt="" - onError={(e) => { - e.target.style.visibility = - 'hidden' - }} - /> - )} - {isDateCell && - value && - (isDateOnlyCell - ? formatDate(value) - : formatDatetime(value))} - {isOrgUnitHierarchyCell && - value && - formatOrgUnitPathBreadcrumb( - value, - orgUnitIdToName - )} - {isOrgUnitNameCell && - value && - formatOrgUnitOwnName( - value, - orgUnitIdToName - )} - {isBooleanCell && - value != null && - formatBoolean(value)} - {!isColorCell && - !isIconCell && - !isDateCell && - !isOrgUnitHierarchyCell && - !isOrgUnitNameCell && - !isBooleanCell && - formatWithSeparator( - value, + <CellValue + value={value} + renderer={renderer} + type={type} + orgUnitIdToName={orgUnitIdToName} + keyAnalysisDigitGroupSeparator={ keyAnalysisDigitGroupSeparator - )} + } + /> </DataTableCell> ) })} diff --git a/src/components/datatable/__tests__/CellValue.spec.jsx b/src/components/datatable/__tests__/CellValue.spec.jsx new file mode 100644 index 0000000000..9c979af62b --- /dev/null +++ b/src/components/datatable/__tests__/CellValue.spec.jsx @@ -0,0 +1,112 @@ +import { render, screen } from '@testing-library/react' +import React from 'react' +import { + RENDERER_COLOR, + RENDERER_ICON, + RENDERER_DATE, + RENDERER_ORG_UNIT, + RENDERER_ORG_UNIT_NAME, + RENDERER_BOOLEAN, + TYPE_DATE, +} from '../../../constants/dataTable.js' +import CellValue, { getCellRendererFlags } from '../CellValue.jsx' + +describe('getCellRendererFlags', () => { + test('flags exactly one renderer at a time', () => { + expect(getCellRendererFlags(RENDERER_COLOR)).toMatchObject({ + isColorCell: true, + isIconCell: false, + isDateCell: false, + isBooleanCell: false, + }) + expect(getCellRendererFlags(RENDERER_BOOLEAN)).toMatchObject({ + isColorCell: false, + isBooleanCell: true, + }) + }) + + test('isDateOnlyCell is driven by type, independent of renderer', () => { + expect(getCellRendererFlags(RENDERER_DATE, TYPE_DATE)).toMatchObject({ + isDateCell: true, + isDateOnlyCell: true, + }) + expect(getCellRendererFlags(RENDERER_DATE, 'datetime')).toMatchObject({ + isDateCell: true, + isDateOnlyCell: false, + }) + }) +}) + +describe('CellValue', () => { + test('formats a plain number with the digit group separator', () => { + render( + <CellValue value={1234567} keyAnalysisDigitGroupSeparator="COMMA" /> + ) + expect(screen.getByText('1,234,567')).toBeInTheDocument() + }) + + test('leaves a plain string untouched', () => { + render(<CellValue value="Bo" />) + expect(screen.getByText('Bo')).toBeInTheDocument() + }) + + test('renders an em-dash placeholder for a missing value, regardless of renderer', () => { + render(<CellValue value={null} />) + expect(screen.getByText('—')).toBeInTheDocument() + }) + + test('renders an em-dash placeholder for an undefined value on a renderer-tagged column', () => { + render(<CellValue value={undefined} renderer={RENDERER_BOOLEAN} />) + expect(screen.getByText('—')).toBeInTheDocument() + }) + + test('lowercases a color value instead of formatting it as a number', () => { + render(<CellValue value="#ABCDEF" renderer={RENDERER_COLOR} />) + expect(screen.getByText('#abcdef')).toBeInTheDocument() + }) + + test('renders an icon thumbnail for an icon column', () => { + const { container } = render( + <CellValue + value="https://server/icons/marker.png" + renderer={RENDERER_ICON} + /> + ) + expect(container.querySelector('img')).toHaveAttribute( + 'src', + 'https://server/icons/marker.png' + ) + }) + + test('formats a boolean-renderer value as Yes/No', () => { + render(<CellValue value="1" renderer={RENDERER_BOOLEAN} />) + expect(screen.getByText('Yes')).toBeInTheDocument() + }) + + test('formats an org-unit-hierarchy value as a breadcrumb', () => { + const idToName = new Map([ + ['country1', 'Country'], + ['ou1', 'Facility'], + ]) + render( + <CellValue + value="/country1/ou1" + renderer={RENDERER_ORG_UNIT} + orgUnitIdToName={idToName} + /> + ) + expect(screen.getByText('Country / Facility')).toBeInTheDocument() + }) + + test("formats an org-unit-name value as just the feature's own name", () => { + const idToName = new Map([['ou1', 'Facility']]) + render( + <CellValue + value="/country1/ou1" + renderer={RENDERER_ORG_UNIT_NAME} + orgUnitIdToName={idToName} + /> + ) + expect(screen.getByText('Facility')).toBeInTheDocument() + }) +}) diff --git a/src/components/datatable/__tests__/CombinedDataTable.spec.jsx b/src/components/datatable/__tests__/CombinedDataTable.spec.jsx index a36d01a7ef..10f27685e3 100644 --- a/src/components/datatable/__tests__/CombinedDataTable.spec.jsx +++ b/src/components/datatable/__tests__/CombinedDataTable.spec.jsx @@ -94,6 +94,34 @@ describe('CombinedDataTable', () => { expect(screen.getByText('Low')).toBeInTheDocument() }) + test('formats numeric values with the system digit group separator, matching DataTable', () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + data: [ + feature({ + orgUnitId: 'ou1', + orgUnitPath: '/country1/ou1', + rawValue: 1234567, + }), + ], + }, + ] + + renderCombinedDataTable({ + layers, + joinConfig: { + level: 'orgUnit', + layerIds: ['layerA'], + pointLayerId: null, + polygonLayerId: null, + }, + }) + + expect(screen.getByText('1,234,567')).toBeInTheDocument() + }) + test('renders an em-dash for blank cell values', () => { const layers = [ { From ab6d63f7f79e1dff96fea407314cbeed5600ede2 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 28 Jul 2026 14:25:17 +0200 Subject: [PATCH 150/205] feat: add pinned columns and session-only column picker to CombinedDataTable [DHIS2-20543] Reuses the same getVisibleHeaders/getPinnedCount/getPinnedLeftOffsets/ getPinnedCellProps/useColumnWidths pipeline DataTable.jsx already has, plus the same ColumnPickerControl UI, generalized to be dispatch-agnostic (onChange callback instead of an internal setDataTableColumnConfig dispatch) so Combined can drive it with local, session-only state instead of a per-layer Redux field. --- src/components/datatable/BottomPanel.jsx | 25 +- .../datatable/CombinedDataTable.jsx | 233 +++++++++++++----- .../datatable/__tests__/BottomPanel.spec.jsx | 15 +- .../__tests__/ColumnPickerControl.spec.jsx | 10 +- .../__tests__/CombinedDataTable.spec.jsx | 82 ++++++ .../controls/ColumnPickerControl.jsx | 24 +- src/constants/dataTable.js | 4 + 7 files changed, 312 insertions(+), 81 deletions(-) diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index 4c9f93bb6b..73b8852b82 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -19,7 +19,9 @@ import { toggleDataTable, toggleCombinedView, setJoinConfig, + setDataTableColumnConfig, } from '../../actions/dataTable.js' +import { COMBINED_HEADERS_KEY } from '../../constants/dataTable.js' import { DATA_TABLE_LAYER_TYPES } from '../../constants/layers.js' import useKeyDown from '../../hooks/useKeyDown.js' import { @@ -120,6 +122,10 @@ const BottomPanel = () => { const [globalSearch, setGlobalSearch] = useState('') const [headersByLayer, setHeadersByLayer] = useState(null) const [combinedFilters, setCombinedFilters] = useState(EMPTY_FILTERS) + // Session-only, never persisted or dispatched to Redux - matches + // combinedFilters/joinConfig's existing ephemeral scope for the + // Combined view. + const [combinedColumnConfig, setCombinedColumnConfig] = useState(null) const hasActiveFilters = combinedView ? Object.keys(combinedFilters).length > 0 || !!globalSearch.trim() @@ -202,7 +208,8 @@ const BottomPanel = () => { }, []) const allHeaders = - headersByLayer?.layerId === activeLayerId + headersByLayer?.layerId === + (combinedView ? COMBINED_HEADERS_KEY : activeLayerId) ? headersByLayer.headers : null @@ -367,6 +374,11 @@ const BottomPanel = () => { } /> )} + <ColumnPickerControl + allHeaders={allHeaders} + columnConfig={combinedColumnConfig} + onChange={setCombinedColumnConfig} + /> <span className={styles.divider} /> </> ) : ( @@ -376,9 +388,16 @@ const BottomPanel = () => { onChange={onHighlightColorChange} /> <ColumnPickerControl - layerId={activeLayerId} allHeaders={allHeaders} columnConfig={activeLayer?.dataTableColumnConfig} + onChange={(config) => + dispatch( + setDataTableColumnConfig( + activeLayerId, + config + ) + ) + } /> <span className={styles.divider} /> </> @@ -477,6 +496,8 @@ const BottomPanel = () => { onFiltersChange={setCombinedFilters} globalSearch={globalSearch} onCountChange={onCountChange} + onHeadersChange={onHeadersChange} + columnConfig={combinedColumnConfig} /> ) : ( <DataTable diff --git a/src/components/datatable/CombinedDataTable.jsx b/src/components/datatable/CombinedDataTable.jsx index e912b02d3d..24f607e347 100644 --- a/src/components/datatable/CombinedDataTable.jsx +++ b/src/components/datatable/CombinedDataTable.jsx @@ -7,12 +7,21 @@ import { useDispatch } from 'react-redux' import { TableVirtuoso } from 'react-virtuoso' import { highlightFeature } from '../../actions/feature.js' import { setCrossLayerSelection } from '../../actions/selection.js' -import { ORG_UNIT_ID_DATA_KEY } from '../../constants/dataTable.js' +import { + COMBINED_HEADERS_KEY, + ORG_UNIT_ID_DATA_KEY, +} from '../../constants/dataTable.js' import { isFilterable, getRowId, shouldClearFeatureHighlight, } from '../../util/dataTable.js' +import { + getPinnedCellProps, + getPinnedCount, + getPinnedLeftOffsets, + getVisibleHeaders, +} from '../../util/tableColumns.js' import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' import CellValue from './CellValue.jsx' import FilterInput from './FilterInput.jsx' @@ -24,6 +33,7 @@ import SortableColumnHeader from './SortableColumnHeader.jsx' import styles from './styles/CombinedDataTable.module.css' import dataTableStyles from './styles/DataTable.module.css' import TableComponents from './TableVirtuosoComponents.jsx' +import { useColumnWidths } from './useColumnWidths.js' import { useCombinedTableData } from './useCombinedTableData.js' import { useRowClickSelection } from './useRowClickSelection.js' import { useRowSelection } from './useRowSelection.js' @@ -77,6 +87,8 @@ const CombinedDataTable = ({ onFiltersChange, globalSearch, onCountChange, + onHeadersChange, + columnConfig, }) => { const dispatch = useDispatch() const { @@ -95,15 +107,47 @@ const CombinedDataTable = ({ globalSearch, }) + useEffect(() => { + onHeadersChange?.(headers, COMBINED_HEADERS_KEY) + }, [onHeadersChange, headers]) + + const pinnedKeys = useMemo( + () => columnConfig?.pinnedKeys ?? [], + [columnConfig] + ) + + const visibleHeaders = useMemo( + () => getVisibleHeaders(headers, columnConfig) ?? [], + [headers, columnConfig] + ) + const rendererByDataKey = useMemo( - () => new Map(headers.map((h) => [h.dataKey, h.renderer])), - [headers] + () => new Map(visibleHeaders.map((h) => [h.dataKey, h.renderer])), + [visibleHeaders] ) const typeByDataKey = useMemo( - () => new Map(headers.map((h) => [h.dataKey, h.type])), - [headers] + () => new Map(visibleHeaders.map((h) => [h.dataKey, h.type])), + [visibleHeaders] + ) + + const { headerRowRef, columnWidths } = useColumnWidths({ + availableWidth, + headers: visibleHeaders, + }) + + const pinnedColumnCount = useMemo( + () => getPinnedCount(visibleHeaders, pinnedKeys), + [visibleHeaders, pinnedKeys] ) + const pinnedLeftOffsets = useMemo( + () => getPinnedLeftOffsets(visibleHeaders, pinnedKeys, columnWidths), + [visibleHeaders, pinnedKeys, columnWidths] + ) + const pinnedOffsetsReady = Object.keys(pinnedLeftOffsets).length > 0 + + const isCheckboxColumnPinned = pinnedColumnCount > 0 && pinnedOffsetsReady + useEffect(() => { onCountChange?.(rows.length, rows.length) }, [onCountChange, rows.length]) @@ -239,48 +283,73 @@ const CombinedDataTable = ({ const fixedHeaderContent = useCallback( () => ( - <DataTableRow> + <DataTableRow ref={headerRowRef}> <SelectionCheckboxHeaderCell + fixed={isCheckboxColumnPinned} + left={isCheckboxColumnPinned ? '0px' : undefined} isAllSelected={isAllSelected} onToggleSelectAll={onToggleSelectAll} onReverseSelection={onReverseSelection} disabled={allRowIds.length === 0} /> - {headers.map(({ name, dataKey, type }) => ( - <SortableColumnHeader - key={dataKey} - name={name} - dataKey={dataKey} - sortField={sortField} - sortDirection={sortDirection} - onSort={sortData} - dataTestPrefix="combined-table-column-sort-button" - className={dataTableStyles.columnHeader} - onFilterIconClick={ - isFilterable(dataKey, type) && Function.prototype - } - showFilter={isFilterable(dataKey, type)} - filter={ - isFilterable(dataKey, type) && ( - <FilterInput - type={type} - dataKey={dataKey} - name={name} - options={columnOptions[dataKey]} - filterValue={filters?.[dataKey]} - onChange={(value) => - onFilterChange(dataKey, value) - } - onClear={() => onFilterClear(dataKey)} - /> - ) - } - /> - ))} + {visibleHeaders.map(({ name, dataKey, type }, index) => { + const { fixed, left, isLastPinned } = getPinnedCellProps( + dataKey, + index, + { pinnedLeftOffsets, pinnedColumnCount, columnWidths } + ) + return ( + <SortableColumnHeader + key={dataKey} + name={name} + dataKey={dataKey} + sortField={sortField} + sortDirection={sortDirection} + onSort={sortData} + dataTestPrefix="combined-table-column-sort-button" + className={cx(dataTableStyles.columnHeader, { + [dataTableStyles.pinnedColumnShadow]: + isLastPinned, + })} + fixed={fixed} + left={left} + onFilterIconClick={ + isFilterable(dataKey, type) && + Function.prototype + } + showFilter={isFilterable(dataKey, type)} + filter={ + isFilterable(dataKey, type) && ( + <FilterInput + type={type} + dataKey={dataKey} + name={name} + options={columnOptions[dataKey]} + filterValue={filters?.[dataKey]} + onChange={(value) => + onFilterChange(dataKey, value) + } + onClear={() => onFilterClear(dataKey)} + /> + ) + } + width={ + columnWidths.length > 0 + ? `${columnWidths[index]}px` + : 'auto' + } + /> + ) + })} </DataTableRow> ), [ - headers, + headerRowRef, + isCheckboxColumnPinned, + visibleHeaders, + pinnedLeftOffsets, + pinnedColumnCount, + columnWidths, filters, columnOptions, onFilterChange, @@ -315,38 +384,72 @@ const CombinedDataTable = ({ const rowId = getRowId(row) const isSelected = !!rowId && selectedIdSet.has(rowId) const isHovered = !!rowId && rowId === hoveredRowId + const cellsByDataKey = new Map( + row.map((cell) => [cell.dataKey, cell]) + ) return ( <> <SelectionCheckboxCell + fixed={isCheckboxColumnPinned} + left={ + isCheckboxColumnPinned ? '0px' : undefined + } + width={ + isCheckboxColumnPinned ? '76px' : undefined + } isSelected={isSelected} isHovered={isHovered} onToggle={() => rowId && onToggleRow(rowId)} /> - {row.map(({ dataKey, value, align }) => ( - <DataTableCell - key={dataKey} - staticStyle - align={align} - className={cx(dataTableStyles.dataCell, { - [dataTableStyles.monoCell]: - dataKey === 'id' || - dataKey === ORG_UNIT_ID_DATA_KEY, - [dataTableStyles.selected]: isSelected, - [dataTableStyles.hovered]: isHovered, - })} - > - <CellValue - value={value} - renderer={rendererByDataKey.get( - dataKey + {visibleHeaders.map(({ dataKey }, index) => { + const cell = cellsByDataKey.get(dataKey) + if (!cell) { + return null + } + const { value, align } = cell + const { fixed, left, width, isLastPinned } = + getPinnedCellProps(dataKey, index, { + pinnedLeftOffsets, + pinnedColumnCount, + columnWidths, + }) + return ( + <DataTableCell + key={dataKey} + staticStyle + fixed={fixed} + left={left} + width={width} + align={align} + className={cx( + dataTableStyles.dataCell, + { + [dataTableStyles.monoCell]: + dataKey === 'id' || + dataKey === + ORG_UNIT_ID_DATA_KEY, + [dataTableStyles.selected]: + isSelected, + [dataTableStyles.hovered]: + isHovered, + [dataTableStyles.pinnedColumnShadow]: + isLastPinned, + } )} - type={typeByDataKey.get(dataKey)} - keyAnalysisDigitGroupSeparator={ - keyAnalysisDigitGroupSeparator - } - /> - </DataTableCell> - ))} + > + <CellValue + value={value} + renderer={rendererByDataKey.get( + dataKey + )} + type={typeByDataKey.get(dataKey)} + keyAnalysisDigitGroupSeparator={ + keyAnalysisDigitGroupSeparator + } + /> + </DataTableCell> + ) + })} </> ) }} @@ -364,10 +467,16 @@ CombinedDataTable.propTypes = { }).isRequired, layers: PropTypes.array.isRequired, availableWidth: PropTypes.number, + columnConfig: PropTypes.shape({ + orderedKeys: PropTypes.arrayOf(PropTypes.string), + pinnedKeys: PropTypes.arrayOf(PropTypes.string), + visibleKeys: PropTypes.arrayOf(PropTypes.string), + }), filters: PropTypes.object, globalSearch: PropTypes.string, onCountChange: PropTypes.func, onFiltersChange: PropTypes.func, + onHeadersChange: PropTypes.func, } export default CombinedDataTable diff --git a/src/components/datatable/__tests__/BottomPanel.spec.jsx b/src/components/datatable/__tests__/BottomPanel.spec.jsx index a375c097da..528bac5245 100644 --- a/src/components/datatable/__tests__/BottomPanel.spec.jsx +++ b/src/components/datatable/__tests__/BottomPanel.spec.jsx @@ -232,10 +232,23 @@ describe('BottomPanel Combined join controls', () => { screen.getByLabelText('Choose layers to combine') ).toBeInTheDocument() expect( - screen.queryByLabelText('Configure columns') + screen.queryByLabelText('Highlight color') ).not.toBeInTheDocument() }) + test('still shows the column picker while Combined is active, session-only (not the per-layer one)', () => { + renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1', 'layer2'], + combinedView: true, + }, + mapViews: twoEligibleLayers, + }) + + expect(screen.getByLabelText('Configure columns')).toBeInTheDocument() + }) + test('offers and renders the spatial join point/polygon selects when point+polygon candidates exist', () => { const pointAndPolygonLayers = [ { diff --git a/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx b/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx index 9df0ad1b3c..aa32d68c0e 100644 --- a/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx +++ b/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx @@ -2,6 +2,7 @@ import { render, fireEvent, screen } from '@testing-library/react' import React from 'react' import { Provider } from 'react-redux' import configureMockStore from 'redux-mock-store' +import { setDataTableColumnConfig } from '../../../actions/dataTable.js' import { DATA_TABLE_COLUMN_CONFIG_SET } from '../../../constants/actionTypes.js' import ColumnPickerControl from '../controls/ColumnPickerControl.jsx' @@ -13,13 +14,20 @@ const headers = [ { name: 'Legend', dataKey: 'legend' }, ] +// ColumnPickerControl is dispatch-agnostic (columnConfig/onChange are +// caller-supplied) - this helper reproduces exactly what BottomPanel.jsx's +// real single-layer call site does (dispatch setDataTableColumnConfig for a +// real layer), so every existing assertion against store.getActions() still +// holds. const renderColumnPicker = (props) => { const store = mockStore({}) const result = render( <Provider store={store}> <ColumnPickerControl - layerId="layer1" allHeaders={headers} + onChange={(config) => + store.dispatch(setDataTableColumnConfig('layer1', config)) + } {...props} /> </Provider> diff --git a/src/components/datatable/__tests__/CombinedDataTable.spec.jsx b/src/components/datatable/__tests__/CombinedDataTable.spec.jsx index 10f27685e3..fee00b126b 100644 --- a/src/components/datatable/__tests__/CombinedDataTable.spec.jsx +++ b/src/components/datatable/__tests__/CombinedDataTable.spec.jsx @@ -3,6 +3,7 @@ import React from 'react' import { Provider } from 'react-redux' import { VirtuosoMockContext } from 'react-virtuoso' import configureMockStore from 'redux-mock-store' +import { COMBINED_HEADERS_KEY } from '../../../constants/dataTable.js' import useOrgUnitAncestorNames from '../../../hooks/useOrgUnitAncestorNames.js' import CombinedDataTable from '../CombinedDataTable.jsx' @@ -425,4 +426,85 @@ describe('CombinedDataTable', () => { crossLayerIds: {}, }) }) + + test('reports computed headers up via onHeadersChange, keyed by the combined sentinel', () => { + const onHeadersChange = jest.fn() + const layers = [ + { + id: 'layerA', + name: 'Layer A', + data: [feature({ orgUnitId: 'ou1', rawValue: 1 })], + }, + ] + + renderCombinedDataTable({ + layers, + joinConfig: { + level: 'orgUnit', + layerIds: ['layerA'], + pointLayerId: null, + polygonLayerId: null, + }, + onHeadersChange, + }) + + expect(onHeadersChange).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ dataKey: 'id' }), + ]), + COMBINED_HEADERS_KEY + ) + }) + + test('hides a column excluded from columnConfig.visibleKeys', () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + data: [feature({ orgUnitId: 'ou1', rawValue: 20 })], + }, + ] + + renderCombinedDataTable({ + layers, + joinConfig: { + level: 'orgUnit', + layerIds: ['layerA'], + pointLayerId: null, + polygonLayerId: null, + }, + columnConfig: { visibleKeys: ['id', 'name'] }, + }) + + expect(screen.getByText('ID')).toBeInTheDocument() + expect(screen.queryByText('Value (Layer A)')).not.toBeInTheDocument() + expect(screen.queryByText('20')).not.toBeInTheDocument() + }) + + test('reorders columns to put pinned keys first via columnConfig.pinnedKeys', () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + data: [feature({ orgUnitId: 'ou1', rawValue: 20 })], + }, + ] + + renderCombinedDataTable({ + layers, + joinConfig: { + level: 'orgUnit', + layerIds: ['layerA'], + pointLayerId: null, + polygonLayerId: null, + }, + columnConfig: { pinnedKeys: ['level'] }, + }) + + const headerNames = screen + .getAllByRole('columnheader') + .map((el) => el.textContent) + .filter(Boolean) + expect(headerNames[0]).toBe('Level') + }) }) diff --git a/src/components/datatable/controls/ColumnPickerControl.jsx b/src/components/datatable/controls/ColumnPickerControl.jsx index fe7ca0a70f..34c4d1657a 100644 --- a/src/components/datatable/controls/ColumnPickerControl.jsx +++ b/src/components/datatable/controls/ColumnPickerControl.jsx @@ -26,8 +26,6 @@ import React, { useState, } from 'react' import { createPortal } from 'react-dom' -import { useDispatch } from 'react-redux' -import { setDataTableColumnConfig } from '../../../actions/dataTable.js' import { filterHeadersByName, getDefaultVisibleKeys, @@ -49,11 +47,10 @@ const EMPTY_HEADERS = [] const EMPTY_KEYS = [] const ColumnPickerControl = React.memo(function ColumnPickerControl({ - layerId, allHeaders, columnConfig, + onChange, }) { - const dispatch = useDispatch() const anchorRef = useRef(null) const [isOpen, setIsOpen] = useState(false) const [activeId, setActiveId] = useState(null) @@ -101,14 +98,12 @@ const ColumnPickerControl = React.memo(function ColumnPickerControl({ ) const updateConfig = (partial) => - dispatch( - setDataTableColumnConfig(layerId, { - visibleKeys, - pinnedKeys, - orderedKeys, - ...partial, - }) - ) + onChange({ + visibleKeys, + pinnedKeys, + orderedKeys, + ...partial, + }) const onToggleVisible = (dataKey, checked) => updateConfig({ @@ -129,8 +124,7 @@ const ColumnPickerControl = React.memo(function ColumnPickerControl({ visibleKeys: reverseVisibleKeys(headers, visibleKeys), }) - const onResetToDefaults = () => - dispatch(setDataTableColumnConfig(layerId, undefined)) + const onResetToDefaults = () => onChange(undefined) const filteredHeaders = useMemo( () => @@ -312,7 +306,7 @@ const ColumnPickerControl = React.memo(function ColumnPickerControl({ }) ColumnPickerControl.propTypes = { - layerId: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, allHeaders: PropTypes.arrayOf( PropTypes.shape({ dataKey: PropTypes.string, diff --git a/src/constants/dataTable.js b/src/constants/dataTable.js index ea8e695587..6c28e59876 100644 --- a/src/constants/dataTable.js +++ b/src/constants/dataTable.js @@ -26,3 +26,7 @@ export const ORG_UNIT_PATH_DATA_KEY = 'orgUnitPath' export const ORG_UNIT_DATA_KEY = 'orgUnitOwn' export const ORG_UNIT_ID_DATA_KEY = 'orgUnitId' export const ORG_UNIT_LEVEL_DATA_KEY = 'level' + +// BottomPanel.jsx's headersByLayer cache is keyed by layer id - Combined +// isn't a real layer, so it uses this sentinel key instead. +export const COMBINED_HEADERS_KEY = '__combined__' From 0ffc59bcbe42df215091244dffdac68411356771 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 28 Jul 2026 14:38:01 +0200 Subject: [PATCH 151/205] fix: forward crossLayerIds highlights to every layer they name in Map.jsx [DHIS2-20543] Map.jsx only passed each Layer instance its feature prop when feature.layerId === config.id, an ownership check that a crossLayerIds-based highlight (layerId: null, set by CombinedDataTable row hover) can never satisfy - the highlight silently reached no layer at all. Layer.js's own getHoverIds already narrows crossLayerIds down per layer, so Map.jsx only needs to forward the feature to layers actually named in it. --- src/components/map/Map.jsx | 14 +++++++----- src/util/__tests__/map.spec.js | 40 +++++++++++++++++++++++++++++++++- src/util/map.js | 12 ++++++++++ 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/src/components/map/Map.jsx b/src/components/map/Map.jsx index 8269043eb2..922bb3f038 100644 --- a/src/components/map/Map.jsx +++ b/src/components/map/Map.jsx @@ -2,7 +2,11 @@ import i18n from '@dhis2/d2-i18n' import PropTypes from 'prop-types' import React, { Component, Fragment } from 'react' import { RENDERING_STRATEGY_TIMELINE } from '../../constants/layers.js' -import { onFullscreenChange, resizeAndFitBounds } from '../../util/map.js' +import { + onFullscreenChange, + resizeAndFitBounds, + getLayerFeatureHighlight, +} from '../../util/map.js' import { sortPeriodsByLevelAndStartDate, addPeriodsDetails, @@ -227,10 +231,10 @@ class Map extends Component { )} {overlays.map((config, index) => { const Overlay = layerType[config.layer] || Layer - const highlight = - feature && feature.layerId === config.id - ? feature - : null + const highlight = getLayerFeatureHighlight( + feature, + config.id + ) return ( <Overlay diff --git a/src/util/__tests__/map.spec.js b/src/util/__tests__/map.spec.js index 39fd3bdede..bfa2957bbb 100644 --- a/src/util/__tests__/map.spec.js +++ b/src/util/__tests__/map.spec.js @@ -1,4 +1,9 @@ -import { onFullscreenChange, resizeAndFitBounds, toGeoJson } from '../map.js' +import { + getLayerFeatureHighlight, + onFullscreenChange, + resizeAndFitBounds, + toGeoJson, +} from '../map.js' const bounds = [ [0, 0], @@ -68,6 +73,39 @@ describe('toGeoJson', () => { }) }) +describe('getLayerFeatureHighlight', () => { + it('returns null when there is no active highlight', () => { + expect(getLayerFeatureHighlight(null, 'layerA')).toBeNull() + }) + + it("passes through a single-layer highlight for that layer's own id", () => { + const feature = { id: 'f1', layerId: 'layerA' } + expect(getLayerFeatureHighlight(feature, 'layerA')).toBe(feature) + }) + + it("hides a single-layer highlight from a layer that doesn't own it", () => { + const feature = { id: 'f1', layerId: 'layerA' } + expect(getLayerFeatureHighlight(feature, 'layerB')).toBeNull() + }) + + it('passes a crossLayerIds highlight through to every layer it names, despite layerId being null', () => { + const feature = { + layerId: null, + crossLayerIds: { layerA: ['f1'], layerB: ['f2'] }, + } + expect(getLayerFeatureHighlight(feature, 'layerA')).toBe(feature) + expect(getLayerFeatureHighlight(feature, 'layerB')).toBe(feature) + }) + + it('hides a crossLayerIds highlight from a layer not named in it', () => { + const feature = { + layerId: null, + crossLayerIds: { layerA: ['f1'] }, + } + expect(getLayerFeatureHighlight(feature, 'layerC')).toBeNull() + }) +}) + describe('resizeAndFitBounds', () => { it('resizes the map and fits bounds when layer bounds exist', () => { const map = createMockMap() diff --git a/src/util/map.js b/src/util/map.js index c9305d275f..3c8a39568a 100644 --- a/src/util/map.js +++ b/src/util/map.js @@ -65,6 +65,18 @@ export const toGeoJson = (organisationUnits) => geometry.coordinates.flat().length ) +// Map.jsx passes each Layer instance only the slice of state.feature it +// owns, rather than the raw global value, so a highlight never re-triggers +// componentDidUpdate on unrelated layers. A crossLayerIds-based highlight +// (layerId: null, set only by CombinedDataTable) has no single owning +// layerId, so it must be forwarded to every layer named in crossLayerIds +// instead of matched by layerId alone - Layer.js's own getHoverIds already +// narrows it down further, to just the ids belonging to that layer. +export const getLayerFeatureHighlight = (feature, layerId) => + feature && (feature.layerId === layerId || feature.crossLayerIds?.[layerId]) + ? feature + : null + //eslint-disable-next-line max-params export const drillUpDown = (layerConfig, parentId, parentGraph, level) => ({ ...layerConfig, From 392c28ef2c8942ecfbc80775c6e3845e54d35edf Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 28 Jul 2026 14:51:08 +0200 Subject: [PATCH 152/205] feat: add CombinedTableContextMenu with zoom and drill up/down [DHIS2-20543] Zoom to feature/selected/filtered features and drill up/down (orgUnit join mode only, no "View profile" since a Combined row can span several features across layers) - mirrors TableContextMenu.jsx's design, reduced to the scope confirmed for Combined. Since a Combined row has no single owning layerId, its zoom can't reuse each Layer instance's own fitBounds - multiple matching instances would race. Map.jsx now also fits a precomputed union bbox directly for a crossLayerIds zoom (fitCrossLayerZoomBounds in util/map.js), computed by CombinedDataTable from every matching feature across every participating layer's raw data (getUnionBounds in util/dataTable.js). --- .../datatable/CombinedDataTable.jsx | 49 ++-- .../datatable/CombinedTableContextMenu.jsx | 226 ++++++++++++++++++ .../CombinedTableContextMenu.spec.jsx | 149 ++++++++++++ src/components/map/Map.jsx | 14 ++ src/util/__tests__/dataTable.spec.js | 77 ++++++ src/util/__tests__/map.spec.js | 48 ++++ src/util/dataTable.js | 49 ++++ src/util/map.js | 20 ++ 8 files changed, 616 insertions(+), 16 deletions(-) create mode 100644 src/components/datatable/CombinedTableContextMenu.jsx create mode 100644 src/components/datatable/__tests__/CombinedTableContextMenu.spec.jsx diff --git a/src/components/datatable/CombinedDataTable.jsx b/src/components/datatable/CombinedDataTable.jsx index 24f607e347..c7e61a5c71 100644 --- a/src/components/datatable/CombinedDataTable.jsx +++ b/src/components/datatable/CombinedDataTable.jsx @@ -14,6 +14,7 @@ import { import { isFilterable, getRowId, + mergeCrossLayerIds, shouldClearFeatureHighlight, } from '../../util/dataTable.js' import { @@ -24,6 +25,7 @@ import { } from '../../util/tableColumns.js' import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' import CellValue from './CellValue.jsx' +import CombinedTableContextMenu from './CombinedTableContextMenu.jsx' import FilterInput from './FilterInput.jsx' import { SelectionCheckboxHeaderCell, @@ -44,20 +46,6 @@ const LARGE_FEATURE_THRESHOLD_LABEL = '10,000' const EMPTY_FILTERS = {} const NOOP = () => {} -const mergeCrossLayerIds = (rowKeys, rowFeatureIds) => { - const merged = {} - rowKeys.forEach((key) => { - const entry = rowFeatureIds.get(key) - if (!entry) { - return - } - Object.entries(entry).forEach(([layerId, ids]) => { - merged[layerId] = [...new Set([...(merged[layerId] ?? []), ...ids])] - }) - }) - return merged -} - const EmptyPlaceholder = () => ( <tbody> <tr> @@ -250,16 +238,36 @@ const CombinedDataTable = ({ onChange: applySelection, }) + const hasActiveFilters = + Object.keys(filters ?? EMPTY_FILTERS).length > 0 || + !!globalSearch?.trim() + + const [tableContextMenu, setTableContextMenu] = useState(null) + + const onRowContextMenu = useCallback((e, row) => { + e.preventDefault() + const rowId = getRowId(row) + if (!rowId) { + return + } + setTableContextMenu({ x: e.clientX, y: e.clientY, rowId }) + }, []) + const tableContext = useMemo( () => ({ onMouseEnter: setFeatureHighlight, onMouseLeave: clearFeatureHighlight, onRowClick, - onContextMenu: NOOP, + onContextMenu: onRowContextMenu, onRowDoubleClick: NOOP, layout: 'auto', }), - [setFeatureHighlight, clearFeatureHighlight, onRowClick] + [ + setFeatureHighlight, + clearFeatureHighlight, + onRowClick, + onRowContextMenu, + ] ) const onFilterChange = useCallback( @@ -454,6 +462,15 @@ const CombinedDataTable = ({ ) }} /> + <CombinedTableContextMenu + contextMenu={tableContextMenu} + layers={layers} + joinConfig={joinConfig} + rowFeatureIds={rowFeatureIds} + selectedIds={selectedIds} + filteredIds={hasActiveFilters ? allRowIds : null} + onClose={() => setTableContextMenu(null)} + /> </div> ) } diff --git a/src/components/datatable/CombinedTableContextMenu.jsx b/src/components/datatable/CombinedTableContextMenu.jsx new file mode 100644 index 0000000000..264e1c04ac --- /dev/null +++ b/src/components/datatable/CombinedTableContextMenu.jsx @@ -0,0 +1,226 @@ +import i18n from '@dhis2/d2-i18n' +import { + Popover, + Menu, + MenuItem, + IconArrowDown16, + IconArrowUp16, +} from '@dhis2/ui' +import PropTypes from 'prop-types' +import React, { useRef } from 'react' +import { useDispatch } from 'react-redux' +import { highlightFeature } from '../../actions/feature.js' +import { updateLayer } from '../../actions/layers.js' +import { + BOUNDARY_LAYER, + EVENT_LAYER, + FACILITY_LAYER, + GEOJSON_URL_LAYER, + TRACKED_ENTITY_LAYER, +} from '../../constants/layers.js' +import { + buildFeatureIndex, + getUnionBounds, + mergeCrossLayerIds, +} from '../../util/dataTable.js' +import { drillUpDown } from '../../util/map.js' +import { IconZoomIn16 } from '../core/icons.jsx' + +const NON_DRILLABLE_LAYER_TYPES = [ + BOUNDARY_LAYER, + FACILITY_LAYER, + EVENT_LAYER, + GEOJSON_URL_LAYER, + TRACKED_ENTITY_LAYER, +] + +// Drill up/down only makes sense for a row that names a single org unit +// (the 'orgUnit' join mode) - a parentOrgUnit row groups several org units +// with no single org unit to drill from, and a spatial row's point/polygon +// features aren't org units at all. +const getDrillTargets = (layers, entry) => + layers + .filter((layer) => !NON_DRILLABLE_LAYER_TYPES.includes(layer.layer)) + .map((layer) => { + const id = entry?.[layer.id]?.[0] + const featureProps = id + ? buildFeatureIndex(layer.data).get(id)?.properties + : null + return { layer, featureProps } + }) + .filter((target) => target.featureProps) + +const CombinedTableContextMenu = ({ + contextMenu, + layers, + joinConfig, + rowFeatureIds, + selectedIds, + filteredIds, + onClose, +}) => { + const anchorRef = useRef() + const dispatch = useDispatch() + + if (!contextMenu) { + return null + } + + const { x, y, rowId } = contextMenu + const entry = rowFeatureIds.get(rowId) ?? {} + + const canDrill = joinConfig.level === 'orgUnit' + const drillTargets = canDrill ? getDrillTargets(layers, entry) : [] + const hasCoordinatesUp = drillTargets.some( + ({ featureProps }) => featureProps.hasCoordinatesUp + ) + const hasCoordinatesDown = drillTargets.some( + ({ featureProps }) => featureProps.hasCoordinatesDown + ) + + const zoomTo = (idsByLayerId) => { + const bounds = getUnionBounds(layers, idsByLayerId) + dispatch( + highlightFeature({ + layerId: null, + origin: 'table', + zoom: true, + bounds, + crossLayerIds: idsByLayerId, + }) + ) + onClose() + } + + return ( + <> + <div + ref={anchorRef} + style={{ + position: 'fixed', + left: x, + top: y, + width: 0, + height: 0, + pointerEvents: 'none', + }} + /> + <Popover + reference={anchorRef} + arrow={false} + placement="right" + onClickOutside={onClose} + > + <Menu dense dataTest="combined-table-context-menu"> + {canDrill && ( + <MenuItem + dataTest="combined-table-context-menu-drill-up" + label={i18n.t('Drill up one level')} + icon={<IconArrowUp16 />} + disabled={!hasCoordinatesUp} + onClick={() => { + drillTargets + .filter( + ({ featureProps }) => + featureProps.hasCoordinatesUp + ) + .forEach(({ layer, featureProps }) => { + dispatch( + updateLayer( + drillUpDown( + layer, + featureProps.grandParentId, + featureProps.grandParentParentGraph, + Number.parseInt( + featureProps.level + ) - 1 + ) + ) + ) + }) + onClose() + }} + /> + )} + {canDrill && ( + <MenuItem + dataTest="combined-table-context-menu-drill-down" + label={i18n.t('Drill down one level')} + icon={<IconArrowDown16 />} + disabled={!hasCoordinatesDown} + onClick={() => { + drillTargets + .filter( + ({ featureProps }) => + featureProps.hasCoordinatesDown + ) + .forEach(({ layer, featureProps }) => { + dispatch( + updateLayer( + drillUpDown( + layer, + featureProps.id, + featureProps.parentGraph, + Number.parseInt( + featureProps.level + ) + 1 + ) + ) + ) + }) + onClose() + }} + /> + )} + <MenuItem + dataTest="combined-table-context-menu-zoom-to-feature" + label={i18n.t('Zoom to feature')} + icon={<IconZoomIn16 />} + disabled={!getUnionBounds(layers, entry)} + onClick={() => zoomTo(entry)} + /> + <MenuItem + dataTest="combined-table-context-menu-zoom-to-selected" + label={i18n.t('Zoom to selected features')} + icon={<IconZoomIn16 />} + disabled={!selectedIds?.length} + onClick={() => + zoomTo( + mergeCrossLayerIds(selectedIds, rowFeatureIds) + ) + } + /> + <MenuItem + dataTest="combined-table-context-menu-zoom-to-filtered" + label={i18n.t('Zoom to filtered features')} + icon={<IconZoomIn16 />} + disabled={!filteredIds?.length} + onClick={() => + zoomTo( + mergeCrossLayerIds(filteredIds, rowFeatureIds) + ) + } + /> + </Menu> + </Popover> + </> + ) +} + +CombinedTableContextMenu.propTypes = { + joinConfig: PropTypes.shape({ + level: PropTypes.string, + }).isRequired, + layers: PropTypes.array.isRequired, + rowFeatureIds: PropTypes.instanceOf(Map).isRequired, + onClose: PropTypes.func.isRequired, + contextMenu: PropTypes.shape({ + rowId: PropTypes.string, + x: PropTypes.number, + y: PropTypes.number, + }), + filteredIds: PropTypes.array, + selectedIds: PropTypes.array, +} + +export default CombinedTableContextMenu diff --git a/src/components/datatable/__tests__/CombinedTableContextMenu.spec.jsx b/src/components/datatable/__tests__/CombinedTableContextMenu.spec.jsx new file mode 100644 index 0000000000..400ad9e465 --- /dev/null +++ b/src/components/datatable/__tests__/CombinedTableContextMenu.spec.jsx @@ -0,0 +1,149 @@ +import { render, fireEvent, screen } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import configureMockStore from 'redux-mock-store' +import { + FEATURE_HIGHLIGHT, + LAYER_UPDATE, +} from '../../../constants/actionTypes.js' +import { EVENT_LAYER, THEMATIC_LAYER } from '../../../constants/layers.js' +import CombinedTableContextMenu from '../CombinedTableContextMenu.jsx' + +const mockStore = configureMockStore() + +const point = (id, coordinates, properties = {}) => ({ + type: 'Feature', + properties: { id, ...properties }, + geometry: { type: 'Point', coordinates }, +}) + +const layers = [ + { + id: 'layerA', + layer: THEMATIC_LAYER, + data: [ + point('ou1', [0, 0], { + level: '3', + hasCoordinatesUp: true, + hasCoordinatesDown: false, + grandParentId: 'gp1', + grandParentParentGraph: '/country1', + parentGraph: '/country1/region1', + }), + ], + }, + { + id: 'layerB', + layer: EVENT_LAYER, // not drillable + data: [point('evt1', [5, 5])], + }, +] + +const rowFeatureIds = new Map([['ou1', { layerA: ['ou1'], layerB: ['evt1'] }]]) + +const orgUnitJoinConfig = { level: 'orgUnit' } +const contextMenu = { x: 10, y: 10, rowId: 'ou1' } + +const getLink = (testId) => screen.getByTestId(testId).querySelector('a') + +const renderMenu = (props) => { + const store = mockStore({}) + const result = render( + <Provider store={store}> + <CombinedTableContextMenu + contextMenu={contextMenu} + layers={layers} + joinConfig={orgUnitJoinConfig} + rowFeatureIds={rowFeatureIds} + onClose={jest.fn()} + {...props} + /> + </Provider> + ) + return { ...result, store } +} + +describe('CombinedTableContextMenu — drill up/down', () => { + test('is offered in orgUnit join mode, enabled per the drillable layer(s) capability', () => { + renderMenu() + expect( + getLink('combined-table-context-menu-drill-up') + ).not.toHaveAttribute('aria-disabled', 'true') + expect( + getLink('combined-table-context-menu-drill-down') + ).toHaveAttribute('aria-disabled', 'true') + }) + + test('is not offered in parentOrgUnit join mode (no single org unit to drill from)', () => { + renderMenu({ joinConfig: { level: 'parentOrgUnit' } }) + expect( + screen.queryByTestId('combined-table-context-menu-drill-up') + ).not.toBeInTheDocument() + }) + + test('drilling up dispatches updateLayer for the drillable layer only, using its own feature props', () => { + const onClose = jest.fn() + const { store } = renderMenu({ onClose }) + fireEvent.click(getLink('combined-table-context-menu-drill-up')) + + const layerUpdates = store + .getActions() + .filter((a) => a.type === LAYER_UPDATE) + expect(layerUpdates).toHaveLength(1) + expect(layerUpdates[0].payload.id).toBe('layerA') + expect(layerUpdates[0].payload.rows[0].items).toEqual([ + { id: 'gp1', path: '/country1/gp1' }, + { id: 'LEVEL-2' }, + ]) + expect(onClose).toHaveBeenCalled() + }) +}) + +describe('CombinedTableContextMenu — zoom actions', () => { + test('zoom to feature dispatches a crossLayerIds highlight with the union bounds', () => { + const onClose = jest.fn() + const { store } = renderMenu({ onClose }) + fireEvent.click(getLink('combined-table-context-menu-zoom-to-feature')) + expect(store.getActions()).toContainEqual({ + type: FEATURE_HIGHLIGHT, + payload: { + layerId: null, + origin: 'table', + zoom: true, + bounds: [ + [0, 0], + [5, 5], + ], + crossLayerIds: { layerA: ['ou1'], layerB: ['evt1'] }, + }, + }) + expect(onClose).toHaveBeenCalled() + }) + + test('zoom to selected features is disabled when nothing is selected', () => { + renderMenu({ selectedIds: [] }) + expect( + getLink('combined-table-context-menu-zoom-to-selected') + ).toHaveAttribute('aria-disabled', 'true') + }) + + test('zoom to selected features merges every selected row before zooming', () => { + const { store } = renderMenu({ selectedIds: ['ou1'] }) + fireEvent.click(getLink('combined-table-context-menu-zoom-to-selected')) + expect(store.getActions()).toContainEqual( + expect.objectContaining({ + type: FEATURE_HIGHLIGHT, + payload: expect.objectContaining({ + crossLayerIds: { layerA: ['ou1'], layerB: ['evt1'] }, + }), + }) + ) + }) + + test('zoom to filtered features is disabled when filteredIds is null (no active filter)', () => { + renderMenu({ filteredIds: null }) + expect( + getLink('combined-table-context-menu-zoom-to-filtered') + ).toHaveAttribute('aria-disabled', 'true') + }) +}) diff --git a/src/components/map/Map.jsx b/src/components/map/Map.jsx index 922bb3f038..05b9f65ccc 100644 --- a/src/components/map/Map.jsx +++ b/src/components/map/Map.jsx @@ -6,6 +6,7 @@ import { onFullscreenChange, resizeAndFitBounds, getLayerFeatureHighlight, + fitCrossLayerZoomBounds, } from '../../util/map.js' import { sortPeriodsByLevelAndStartDate, @@ -163,11 +164,24 @@ class Map extends Component { onFullscreenChange(this.map, isFullscreen) } + this.handleCrossLayerZoom(prevProps) + const overlays = this.getLoadedLayers(layers) const timelineOverlay = this.getTimelineOverlay(overlays) this.initializeTimelinePeriod(timelineOverlay) } + // A crossLayerIds highlight/selection has no single owning layerId, so + // (unlike a single-layer zoom, which each Layer instance handles itself + // in handleFeatureUpdate) it can't be resolved by any one Layer without + // several instances racing independent fitBounds() calls. Its bounds + // are precomputed by the caller (CombinedDataTable.jsx, from the union + // of every matching feature across every participating layer) and fit + // here once, at the top level, instead. + handleCrossLayerZoom(prevProps) { + fitCrossLayerZoomBounds(this.map, this.props.feature, prevProps.feature) + } + // Remove map componentWillUnmount() { if (this._onWindowResize) { diff --git a/src/util/__tests__/dataTable.spec.js b/src/util/__tests__/dataTable.spec.js index 27484a9981..9f93174e68 100644 --- a/src/util/__tests__/dataTable.spec.js +++ b/src/util/__tests__/dataTable.spec.js @@ -4,8 +4,10 @@ import { getPanelHeights, getRowClickAction, getRowId, + getUnionBounds, hasActiveDataTableFilters, isFilterable, + mergeCrossLayerIds, shouldClearFeatureHighlight, } from '../dataTable.js' @@ -206,6 +208,81 @@ describe('buildFeatureIndex', () => { }) }) +describe('mergeCrossLayerIds', () => { + const rowFeatureIds = new Map([ + ['ou1', { layerA: ['a1'], layerB: ['b1'] }], + ['ou2', { layerA: ['a2'] }], + ]) + + test('unions per-layer id sets across every named row', () => { + expect(mergeCrossLayerIds(['ou1', 'ou2'], rowFeatureIds)).toEqual({ + layerA: ['a1', 'a2'], + layerB: ['b1'], + }) + }) + + test('dedupes ids repeated across rows for the same layer', () => { + const withOverlap = new Map([ + ['ou1', { layerA: ['a1'] }], + ['ou2', { layerA: ['a1', 'a2'] }], + ]) + expect(mergeCrossLayerIds(['ou1', 'ou2'], withOverlap)).toEqual({ + layerA: ['a1', 'a2'], + }) + }) + + test('skips row keys with no entry', () => { + expect(mergeCrossLayerIds(['ou1', 'missing'], rowFeatureIds)).toEqual({ + layerA: ['a1'], + layerB: ['b1'], + }) + }) + + test('returns an empty object for no rows', () => { + expect(mergeCrossLayerIds([], rowFeatureIds)).toEqual({}) + }) +}) + +describe('getUnionBounds', () => { + const point = (id, coordinates) => ({ + type: 'Feature', + properties: { id }, + geometry: { type: 'Point', coordinates }, + }) + + const layers = [ + { + id: 'layerA', + data: [point('a1', [0, 0]), point('a2', [10, 10])], + }, + { id: 'layerB', data: [point('b1', [5, -5])] }, + ] + + test('computes the union bbox across every named feature on every layer', () => { + expect( + getUnionBounds(layers, { layerA: ['a1', 'a2'], layerB: ['b1'] }) + ).toEqual([ + [0, -5], + [10, 10], + ]) + }) + + test('ignores layers/ids not named in idsByLayerId', () => { + expect(getUnionBounds(layers, { layerA: ['a1'] })).toEqual([ + [0, 0], + [0, 0], + ]) + }) + + test('returns null when nothing matches', () => { + expect(getUnionBounds(layers, {})).toBeNull() + }) + + test('skips ids that have no matching feature or geometry', () => { + expect(getUnionBounds(layers, { layerA: ['missing'] })).toBeNull() + }) +}) + describe('getPanelHeights', () => { test('clamps the table height to the window, minus header/toolbar', () => { const result = getPanelHeights({ diff --git a/src/util/__tests__/map.spec.js b/src/util/__tests__/map.spec.js index bfa2957bbb..8731fee175 100644 --- a/src/util/__tests__/map.spec.js +++ b/src/util/__tests__/map.spec.js @@ -1,4 +1,5 @@ import { + fitCrossLayerZoomBounds, getLayerFeatureHighlight, onFullscreenChange, resizeAndFitBounds, @@ -16,6 +17,7 @@ const createMockMap = (layersBounds = bounds) => ({ fitBounds: jest.fn(), toggleMultiTouch: jest.fn(), toggleScrollZoom: jest.fn(), + getMapGL: jest.fn(() => ({ getBearing: jest.fn(() => 0) })), }) describe('toGeoJson', () => { @@ -106,6 +108,52 @@ describe('getLayerFeatureHighlight', () => { }) }) +describe('fitCrossLayerZoomBounds', () => { + const zoomFeature = { + layerId: null, + zoom: true, + bounds, + crossLayerIds: { layerA: ['a1'] }, + } + + it('fits the map to the precomputed bounds when the feature changes and carries zoom+bounds', () => { + const map = createMockMap() + fitCrossLayerZoomBounds(map, zoomFeature, null) + expect(map.fitBounds).toHaveBeenCalledWith( + bounds, + expect.objectContaining({ essential: true }) + ) + }) + + it('does nothing when the feature reference is unchanged', () => { + const map = createMockMap() + fitCrossLayerZoomBounds(map, zoomFeature, zoomFeature) + expect(map.fitBounds).not.toHaveBeenCalled() + }) + + it('does nothing for a highlight with no zoom flag', () => { + const map = createMockMap() + fitCrossLayerZoomBounds(map, { ...zoomFeature, zoom: false }, null) + expect(map.fitBounds).not.toHaveBeenCalled() + }) + + it('does nothing for a single-layer zoom (no precomputed bounds)', () => { + const map = createMockMap() + fitCrossLayerZoomBounds( + map, + { id: 'f1', layerId: 'layerA', zoom: true }, + null + ) + expect(map.fitBounds).not.toHaveBeenCalled() + }) + + it('does nothing when there is no active feature', () => { + const map = createMockMap() + fitCrossLayerZoomBounds(map, null, zoomFeature) + expect(map.fitBounds).not.toHaveBeenCalled() + }) +}) + describe('resizeAndFitBounds', () => { it('resizes the map and fits bounds when layer bounds exist', () => { const map = createMockMap() diff --git a/src/util/dataTable.js b/src/util/dataTable.js index 1eba91fa02..cff4ebd99e 100644 --- a/src/util/dataTable.js +++ b/src/util/dataTable.js @@ -1,3 +1,4 @@ +import { bbox } from '@turf/bbox' import { SORT_ASCENDING, SORT_DESCENDING } from '../constants/dataTable.js' export const isFilterable = (dataKey, type) => !!type @@ -65,6 +66,54 @@ export const buildFeatureIndex = (data) => { return index } +// Merges the per-layer feature id sets of several Combined rows (e.g. every +// selected row, or every currently filtered row) into one map suitable for +// a single crossLayerIds highlight/selection/zoom dispatch. +export const mergeCrossLayerIds = (rowKeys, rowFeatureIds) => { + const merged = {} + rowKeys.forEach((key) => { + const entry = rowFeatureIds.get(key) + if (!entry) { + return + } + Object.entries(entry).forEach(([layerId, ids]) => { + merged[layerId] = [...new Set([...(merged[layerId] ?? []), ...ids])] + }) + }) + return merged +} + +// Same bbox-of-matching-features computation Layer.js's own panToFeature +// does for a single layer, generalized across every layer named in +// crossLayerIds - used for Combined row/selection/filtered-set zoom, where +// no single Layer instance owns the feature set being zoomed to. +export const getUnionBounds = (layers, idsByLayerId) => { + const features = layers.flatMap((layer) => { + const ids = idsByLayerId[layer.id] + if (!ids?.length) { + return [] + } + const index = buildFeatureIndex(layer.data) + return ids.map((id) => index.get(id)).filter((f) => f?.geometry) + }) + + if (!features.length) { + return null + } + + const [minLng, minLat, maxLng, maxLat] = bbox({ + type: 'FeatureCollection', + features, + }) + + return Number.isFinite(minLng) + ? [ + [minLng, minLat], + [maxLng, maxLat], + ] + : null +} + export const getPanelHeights = ({ windowHeight, dataTableHeight, diff --git a/src/util/map.js b/src/util/map.js index 3c8a39568a..d5fa959846 100644 --- a/src/util/map.js +++ b/src/util/map.js @@ -4,6 +4,7 @@ import { ORG_UNIT_PATH_DATA_KEY, } from '../constants/dataTable.js' import { dimConf } from '../constants/dimension.js' +import { PADDING_DEFAULT, DURATION_DEFAULT } from '../constants/layers.js' export const toGeoJson = (organisationUnits) => sortBy('le', organisationUnits) @@ -77,6 +78,25 @@ export const getLayerFeatureHighlight = (feature, layerId) => ? feature : null +// The crossLayerIds counterpart to each Layer instance's own +// handleFeatureUpdate/fitBounds - a crossLayerIds zoom has no single owning +// layerId, so several Layer instances would otherwise race independent +// fitBounds() calls. Map.jsx calls this once at the top level instead, with +// bounds precomputed by the caller (CombinedDataTable.jsx, via +// getUnionBounds) from every matching feature across every participating +// layer. +export const fitCrossLayerZoomBounds = (map, feature, prevFeature) => { + if (feature === prevFeature || !feature?.zoom || !feature.bounds) { + return + } + map.fitBounds(feature.bounds, { + padding: PADDING_DEFAULT, + duration: DURATION_DEFAULT, + essential: true, + bearing: map.getMapGL().getBearing(), + }) +} + //eslint-disable-next-line max-params export const drillUpDown = (layerConfig, parentId, parentGraph, level) => ({ ...layerConfig, From 9c4e345dcb4448a3c444971434d0335936dec95a Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 28 Jul 2026 15:07:16 +0200 Subject: [PATCH 153/205] fix: close remaining crossLayerIds parity gaps found in fresh-context review [DHIS2-20543] A fresh-context review of the full PR7 diff found three real gaps in the crossLayerIds mechanism, all instances of the same class of bug already fixed once this session for Map.jsx's feature highlight: - BottomPanel stays open with openIds empty when combinedView is still a valid, active state (e.g. every single-layer tab was closed while Combined stayed open) - App.jsx/MapPosition.jsx were gating the whole panel on openIds.length alone, hiding it even though Combined was still legitimately showing. New isDataTableOpen() util fixes both. - DataTable.jsx's own selectedIds (checkbox column, row highlighting) ignored selection.crossLayerIds, so a Combined-originated selection never showed as selected when switching to that layer's own tab, even though the map highlight was already correct. Extracted the merge logic Layer.js#getSelectedIds already had into a shared getLayerSelectedIds(), used by both now. - Layer.js#getVisibleIds (the "show only selected/not-selected" map filter) had the same ownership-only gate, so a crossLayerIds selection was silently treated as "no selection" for this filter. Also: useCombinedTableData.js only read layer.data, never layer.dataWithoutCoords - unlike the single-layer table (util/tableRows.js), so org units/facilities missing coordinates were silently dropped from the Combined join entirely, not just hidden from the map. --- i18n/en.pot | 34 ++++----- src/components/app/App.jsx | 5 +- src/components/datatable/DataTable.jsx | 3 +- .../__tests__/useCombinedTableData.spec.js | 39 +++++++++++ .../datatable/useCombinedTableData.js | 10 ++- src/components/map/MapPosition.jsx | 5 +- src/components/map/layers/Layer.js | 11 +-- .../map/layers/__tests__/Layer.spec.js | 28 ++++++++ src/util/__tests__/dataTable.spec.js | 70 +++++++++++++++++++ src/util/dataTable.js | 17 +++++ 10 files changed, 195 insertions(+), 27 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 643c47d4e1..feefffdc64 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-28T10:47:18.465Z\n" -"PO-Revision-Date: 2026-07-28T10:47:18.465Z\n" +"POT-Creation-Date: 2026-07-28T12:51:19.913Z\n" +"PO-Revision-Date: 2026-07-28T12:51:19.913Z\n" msgid "2020" msgstr "2020" @@ -182,6 +182,21 @@ msgstr "No matching rows" msgid "Spatial join over large datasets may be slow (over {{threshold}} features)" msgstr "Spatial join over large datasets may be slow (over {{threshold}} features)" +msgid "Drill up one level" +msgstr "Drill up one level" + +msgid "Drill down one level" +msgstr "Drill down one level" + +msgid "Zoom to feature" +msgstr "Zoom to feature" + +msgid "Zoom to selected features" +msgstr "Zoom to selected features" + +msgid "Zoom to filtered features" +msgstr "Zoom to filtered features" + msgid "Edit layer" msgstr "Edit layer" @@ -283,27 +298,12 @@ msgstr[1] "{{count}} selected" msgid "Sort by {{column}}" msgstr "Sort by {{column}}" -msgid "Drill up one level" -msgstr "Drill up one level" - -msgid "Drill down one level" -msgstr "Drill down one level" - msgid "View profile" msgstr "View profile" -msgid "Zoom to feature" -msgstr "Zoom to feature" - msgid "Zoom to layer" msgstr "Zoom to layer" -msgid "Zoom to selected features" -msgstr "Zoom to selected features" - -msgid "Zoom to filtered features" -msgstr "Zoom to filtered features" - msgid "Event details aren't available while this layer is clustered on the server" msgstr "Event details aren't available while this layer is clustered on the server" diff --git a/src/components/app/App.jsx b/src/components/app/App.jsx index 0e4465dd54..116bc033d0 100644 --- a/src/components/app/App.jsx +++ b/src/components/app/App.jsx @@ -2,6 +2,7 @@ import cx from 'classnames' import React, { useEffect, useState } from 'react' import { useSelector } from 'react-redux' import { useLayersLoader } from '../../hooks/useLayersLoader.js' +import { isDataTableOpen } from '../../util/dataTable.js' import BottomPanel from '../datatable/BottomPanel.jsx' import DownloadModeMenu from '../download/DownloadMenubar.jsx' import DownloadSettings from '../download/DownloadSettings.jsx' @@ -35,8 +36,8 @@ const App = () => { const [interpretationsRenderCount, setInterpretationsRenderCount] = useState(1) - const dataTableOpen = useSelector( - (state) => state.dataTable.openIds.length > 0 + const dataTableOpen = useSelector((state) => + isDataTableOpen(state.dataTable) ) const downloadModeOpen = useSelector((state) => !!state.ui.downloadMode) const detailsPanelOpen = useSelector( diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index 46c61fb09e..db1e514324 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -28,6 +28,7 @@ import { import { isDarkColor } from '../../util/colors.js' import { buildFeatureIndex, + getLayerSelectedIds, getRowId, hasActiveDataTableFilters, isFilterable, @@ -144,7 +145,7 @@ const Table = ({ ) const selectedIds = useMemo( - () => (selection.layerId === layer.id ? selection.ids : []), + () => getLayerSelectedIds(selection, layer.id), [selection, layer.id] ) const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]) diff --git a/src/components/datatable/__tests__/useCombinedTableData.spec.js b/src/components/datatable/__tests__/useCombinedTableData.spec.js index 9d252206f0..36ec85fcdd 100644 --- a/src/components/datatable/__tests__/useCombinedTableData.spec.js +++ b/src/components/datatable/__tests__/useCombinedTableData.spec.js @@ -102,6 +102,45 @@ describe('useCombinedTableData - org unit join', () => { expect(findCell(row2, 'layerB_rawValue').value).toBe(20) }) + test('includes rows from layer.dataWithoutCoords, matching the single-layer table', () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + data: [ + feature({ + id: 'ou1', + orgUnitPath: '/country1/ou1', + rawValue: 10, + }), + ], + dataWithoutCoords: [ + feature({ + id: 'ou2', + orgUnitPath: '/country1/ou2', + rawValue: 20, + }), + ], + }, + ] + const joinConfig = { + level: 'orgUnit', + layerIds: ['layerA'], + pointLayerId: null, + polygonLayerId: null, + } + + const { result } = renderHook(() => + useCombinedTableData({ layers, joinConfig }) + ) + + expect(result.current.rows).toHaveLength(2) + const withoutCoordsRow = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou2' + ) + expect(findCell(withoutCoordsRow, 'layerA_rawValue').value).toBe(20) + }) + test('prefers orgUnitId over id when both are present (event/tracked-entity layer shape)', () => { const layers = [ { diff --git a/src/components/datatable/useCombinedTableData.js b/src/components/datatable/useCombinedTableData.js index e1b7f09bee..10c8fd8ff7 100644 --- a/src/components/datatable/useCombinedTableData.js +++ b/src/components/datatable/useCombinedTableData.js @@ -104,7 +104,15 @@ export const useCombinedTableData = ({ // just the one whose value happens to be shown. const featureIdsByOrgUnit = {} - const data = layer.data ?? [] + // Mirrors util/tableRows.js's own data + dataWithoutCoords merge + // for the single-layer table - org units/facilities missing + // valid coordinates still belong in the join, they just can't + // render on the map (and so never contribute to zoom bounds, + // since getUnionBounds already skips features with no geometry). + const data = [ + ...(layer.data ?? []), + ...(layer.dataWithoutCoords ?? []), + ] data.filter((d) => !d.properties?.hasAdditionalGeometry).forEach( (d) => { const props = d.properties || d diff --git a/src/components/map/MapPosition.jsx b/src/components/map/MapPosition.jsx index f938a51d46..17e4cc0163 100644 --- a/src/components/map/MapPosition.jsx +++ b/src/components/map/MapPosition.jsx @@ -2,6 +2,7 @@ import cx from 'classnames' import React, { useState, useEffect, useRef } from 'react' import { useSelector, useDispatch } from 'react-redux' import { setMapBounds } from '../../actions/dataTable.js' +import { isDataTableOpen } from '../../util/dataTable.js' import { getSplitViewLayer } from '../../util/helpers.js' import DownloadMapInfo from '../download/DownloadMapInfo.jsx' import NorthArrow from '../download/NorthArrow.jsx' @@ -26,8 +27,8 @@ const MapPosition = () => { const { id: mapId, mapViews: layers } = useSelector((state) => state.map) const { downloadMode, layersPanelOpen, rightPanelOpen, dataTableHeight } = useSelector((state) => state.ui) - const dataTableOpen = useSelector( - (state) => state.dataTable.openIds.length > 0 + const dataTableOpen = useSelector((state) => + isDataTableOpen(state.dataTable) ) const downloadMapInfoOpen = diff --git a/src/components/map/layers/Layer.js b/src/components/map/layers/Layer.js index 6b1a6a5354..f9ddfbba30 100644 --- a/src/components/map/layers/Layer.js +++ b/src/components/map/layers/Layer.js @@ -11,6 +11,7 @@ import { SELECTION_FILTER_SELECTED, SELECTION_FILTER_NOT_SELECTED, } from '../../../constants/selection.js' +import { getLayerSelectedIds } from '../../../util/dataTable.js' export const idsEqual = (a, b) => a.length === b.length && a.every((id, i) => id === b[i]) @@ -288,9 +289,7 @@ class Layer extends PureComponent { } getSelectedIds(selection = this.props.selection) { - const ownIds = selection?.layerId === this.props.id ? selection.ids : [] - const crossIds = selection?.crossLayerIds?.[this.props.id] ?? [] - return crossIds.length ? [...new Set([...ownIds, ...crossIds])] : ownIds + return getLayerSelectedIds(selection, this.props.id) } highlightFeature() { @@ -305,7 +304,11 @@ class Layer extends PureComponent { selection = this.props.selection, selectionFilter = this.props.selectionFilter ) { - if (!selectionFilter?.length || selection?.layerId !== this.props.id) { + const isReferenced = + selection?.layerId === this.props.id || + !!selection?.crossLayerIds?.[this.props.id]?.length + + if (!selectionFilter?.length || !isReferenced) { return null } diff --git a/src/components/map/layers/__tests__/Layer.spec.js b/src/components/map/layers/__tests__/Layer.spec.js index b03ff6e982..5ab4c5448d 100644 --- a/src/components/map/layers/__tests__/Layer.spec.js +++ b/src/components/map/layers/__tests__/Layer.spec.js @@ -62,6 +62,34 @@ describe('Layer#getVisibleIds', () => { }) expect(layer.getVisibleIds()).toBe(null) }) + + test('recognizes a crossLayerIds-only selection (no own-layer selection.layerId)', () => { + const layer = createLayer({ + id: 'layer1', + data, + selection: { + layerId: null, + ids: [], + crossLayerIds: { layer1: ['a', 'c'] }, + }, + selectionFilter: ['selected'], + }) + expect(layer.getVisibleIds()).toEqual(['a', 'c']) + }) + + test('returns null when this layer has no entry in crossLayerIds either', () => { + const layer = createLayer({ + id: 'layer1', + data, + selection: { + layerId: null, + ids: [], + crossLayerIds: { 'other-layer': ['a'] }, + }, + selectionFilter: ['selected'], + }) + expect(layer.getVisibleIds()).toBe(null) + }) }) describe('Layer#getHoverIds', () => { diff --git a/src/util/__tests__/dataTable.spec.js b/src/util/__tests__/dataTable.spec.js index 9f93174e68..bba5d61ed7 100644 --- a/src/util/__tests__/dataTable.spec.js +++ b/src/util/__tests__/dataTable.spec.js @@ -1,11 +1,13 @@ import { buildFeatureIndex, + getLayerSelectedIds, getNextSorting, getPanelHeights, getRowClickAction, getRowId, getUnionBounds, hasActiveDataTableFilters, + isDataTableOpen, isFilterable, mergeCrossLayerIds, shouldClearFeatureHighlight, @@ -208,6 +210,74 @@ describe('buildFeatureIndex', () => { }) }) +describe('isDataTableOpen', () => { + test('is open when at least one tab is open', () => { + expect( + isDataTableOpen({ openIds: ['layer1'], combinedView: false }) + ).toBe(true) + }) + + test('is open when Combined is active, even with no open tabs', () => { + expect(isDataTableOpen({ openIds: [], combinedView: true })).toBe(true) + }) + + test('is closed when there are no open tabs and Combined is not active', () => { + expect(isDataTableOpen({ openIds: [], combinedView: false })).toBe( + false + ) + }) +}) + +describe('getLayerSelectedIds', () => { + test('returns an empty array when there is no selection', () => { + expect(getLayerSelectedIds(null, 'layer1')).toEqual([]) + }) + + test("returns this layer's own selected ids when selection.layerId matches", () => { + expect( + getLayerSelectedIds( + { layerId: 'layer1', ids: ['a', 'b'] }, + 'layer1' + ) + ).toEqual(['a', 'b']) + }) + + test('returns crossLayerIds ids when selection.layerId belongs to no single layer (Combined)', () => { + expect( + getLayerSelectedIds( + { layerId: null, ids: [], crossLayerIds: { layer1: ['x'] } }, + 'layer1' + ) + ).toEqual(['x']) + }) + + test('merges crossLayerIds with a same-layer selection, deduping', () => { + expect( + getLayerSelectedIds( + { + layerId: 'layer1', + ids: ['a'], + crossLayerIds: { layer1: ['a', 'b'] }, + }, + 'layer1' + ) + ).toEqual(['a', 'b']) + }) + + test('ignores a selection/crossLayerIds entry belonging to another layer', () => { + expect( + getLayerSelectedIds( + { + layerId: 'other-layer', + ids: ['a'], + crossLayerIds: { 'other-layer': ['a'] }, + }, + 'layer1' + ) + ).toEqual([]) + }) +}) + describe('mergeCrossLayerIds', () => { const rowFeatureIds = new Map([ ['ou1', { layerA: ['a1'], layerB: ['b1'] }], diff --git a/src/util/dataTable.js b/src/util/dataTable.js index cff4ebd99e..de80fc31ef 100644 --- a/src/util/dataTable.js +++ b/src/util/dataTable.js @@ -55,6 +55,23 @@ export const hasActiveDataTableFilters = ({ selectionFilter?.length > 0 || !!showOnlyFeaturesInView +// state.dataTable.combinedView can legitimately stay true with openIds +// empty (e.g. every single-layer tab was closed while Combined stayed +// open) - the panel must stay open in that case too, not just when a +// single-layer tab is open. +export const isDataTableOpen = ({ openIds, combinedView }) => + openIds.length > 0 || combinedView + +// A crossLayerIds selection has no single owning layerId (layerId: null), +// so a layer's own selection can't be read off selection.ids alone once +// Combined-originated selections exist - merges in whatever this layer is +// named under in crossLayerIds too. +export const getLayerSelectedIds = (selection, layerId) => { + const ownIds = selection?.layerId === layerId ? selection.ids ?? [] : [] + const crossIds = selection?.crossLayerIds?.[layerId] ?? [] + return crossIds.length ? [...new Set([...ownIds, ...crossIds])] : ownIds +} + export const buildFeatureIndex = (data) => { const index = new Map() data?.forEach((f) => { From 759aca067a8d1361cddfc15e696661d4e2e3685f Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 28 Jul 2026 15:14:19 +0200 Subject: [PATCH 154/205] fix: DATA_TABLE_TOGGLE no longer resets combinedView/joinConfig on last-tab-close [DHIS2-20543] Confirmed with the user: closing the last open single-layer tab should behave the same as removing a layer does (LAYER_REMOVE already got this right) - combinedView/joinConfig survive as long as they're still valid, matching the plan's stated decoupling of Combined from openIds. Panel visibility is entirely isDataTableOpen()'s job now, not this reducer's. --- src/reducers/__tests__/dataTable.spec.js | 6 ++++-- src/reducers/dataTable.js | 12 ++++++------ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/reducers/__tests__/dataTable.spec.js b/src/reducers/__tests__/dataTable.spec.js index c1c9004263..38d910b764 100644 --- a/src/reducers/__tests__/dataTable.spec.js +++ b/src/reducers/__tests__/dataTable.spec.js @@ -128,7 +128,7 @@ describe('dataTable reducer', () => { expect(state.joinConfig).toBe(prevState.joinConfig) }) - it('resets combinedView and joinConfig when closing the last open tab', () => { + it('leaves combinedView and joinConfig untouched even when closing the last open tab', () => { const prevState = { openIds: ['layer1'], combinedView: true, @@ -145,7 +145,9 @@ describe('dataTable reducer', () => { id: 'layer1', }) - expect(state).toEqual(initialState) + expect(state.openIds).toEqual([]) + expect(state.combinedView).toBe(true) + expect(state.joinConfig).toBe(prevState.joinConfig) }) }) diff --git a/src/reducers/dataTable.js b/src/reducers/dataTable.js index 98d400386c..e16c166ed2 100644 --- a/src/reducers/dataTable.js +++ b/src/reducers/dataTable.js @@ -42,12 +42,12 @@ const dataTable = (state = initialState, action) => { const openIds = state.openIds.includes(action.id) ? state.openIds.filter((id) => id !== action.id) : [...state.openIds, action.id] - // Closing the last tab this way (rather than via DATA_TABLE_CLOSE) - // still means the panel is now fully closed (see App.jsx, which - // gates rendering it on openIds.length > 0) - so it gets the same - // full reset, or a stale combinedView/joinConfig would resurface - // the next time any single layer's table is reopened. - return openIds.length === 0 ? initialState : { ...state, openIds } + // combinedView/joinConfig are fully decoupled from openIds - + // closing the last open tab this way doesn't touch them, even + // if that empties openIds. isDataTableOpen() (util/dataTable.js) + // is what decides whether the panel itself stays open, and it + // already accounts for combinedView independently of openIds. + return { ...state, openIds } } case types.LAYER_REMOVE: { From 2c133d5283d2b3f3f0ad716c58dc31d01267945f Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 28 Jul 2026 15:16:59 +0200 Subject: [PATCH 155/205] fix: avoid colon in the spatial join option label (breaks i18next-scanner) [DHIS2-20543] i18next-scanner's default nsSeparator is ':', so i18n.t('Spatial: point inside polygon') got parsed as namespace "Spatial" + key " point inside polygon" instead of one translation key - reproduced via `yarn start` (fails during string extraction) and `d2-app-scripts i18n extract`. --- i18n/en.pot | 7 +++++-- src/components/datatable/BottomPanel.jsx | 2 +- src/components/datatable/__tests__/BottomPanel.spec.jsx | 4 ++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index feefffdc64..c134051c8e 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-28T12:51:19.913Z\n" -"PO-Revision-Date: 2026-07-28T12:51:19.913Z\n" +"POT-Creation-Date: 2026-07-28T13:15:40.903Z\n" +"PO-Revision-Date: 2026-07-28T13:15:40.903Z\n" msgid "2020" msgstr "2020" @@ -161,6 +161,9 @@ msgstr "Join by org unit" msgid "Join by parent org unit" msgstr "Join by parent org unit" +msgid "Spatial - point inside polygon" +msgstr "Spatial - point inside polygon" + msgid "Point layer" msgstr "Point layer" diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index 73b8852b82..f44463d83d 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -310,7 +310,7 @@ const BottomPanel = () => { </option> {hasSpatialCandidates && ( <option value="spatial"> - {i18n.t('Spatial: point inside polygon')} + {i18n.t('Spatial - point inside polygon')} </option> )} </select> diff --git a/src/components/datatable/__tests__/BottomPanel.spec.jsx b/src/components/datatable/__tests__/BottomPanel.spec.jsx index 528bac5245..920a1552e8 100644 --- a/src/components/datatable/__tests__/BottomPanel.spec.jsx +++ b/src/components/datatable/__tests__/BottomPanel.spec.jsx @@ -281,7 +281,7 @@ describe('BottomPanel Combined join controls', () => { }) expect( - screen.getByText('Spatial: point inside polygon') + screen.getByText('Spatial - point inside polygon') ).toBeInTheDocument() expect(screen.getByText('Point layer')).toBeInTheDocument() expect(screen.getByText('Polygon layer')).toBeInTheDocument() @@ -346,7 +346,7 @@ describe('BottomPanel Combined join controls', () => { }) expect( - screen.queryByText('Spatial: point inside polygon') + screen.queryByText('Spatial - point inside polygon') ).not.toBeInTheDocument() // Regression guard: `pointLayers.length && polygonLayers.length` can // evaluate to the number 0 rather than a real boolean, and React From 5c29d0b53122acf02c13f510531da379719b5980 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 28 Jul 2026 15:37:26 +0200 Subject: [PATCH 156/205] feat: replace data table tabs with a layer selector dropdown, add a menu-bar Data Table shortcut [DHIS2-20543] BottomPanel no longer shows one tab per open layer plus a Combined tab - a single dropdown (LayerSelectorControl) lists every open layer by name, with Combined as an additional option, greyed out unless the map has 2+ eligible layers to join. Per-tab closing is dropped: a layer's table can already be closed via "Show data table" in that layer's own overlay-card menu, so the panel doesn't need a second, redundant close affordance. Also adds a "Data Table" button to the main menu bar, after Download. If no data table is open yet, it opens Combined (pre-populated with every eligible layer) when 2+ are eligible, or the single eligible layer's own table otherwise; it's a no-op if a table is already open, and disabled when the map has no eligible layers at all. getEligibleDataTableLayers() and isDataTableOpen() (util/dataTable.js) are now shared between BottomPanel and the new button rather than duplicated. --- i18n/en.pot | 19 ++-- src/components/app/AppMenu.jsx | 2 + src/components/datatable/BottomPanel.jsx | 84 +++++------------ src/components/datatable/DataTableButton.jsx | 55 ++++++++++++ .../datatable/__tests__/BottomPanel.spec.jsx | 58 +++++------- .../__tests__/DataTableButton.spec.jsx | 90 +++++++++++++++++++ .../__tests__/LayerSelectorControl.spec.jsx | 66 ++++++++++++++ .../datatable/controls/ActiveLayerControl.jsx | 71 --------------- .../controls/LayerSelectorControl.jsx | 57 ++++++++++++ .../styles/ActiveLayerControl.module.css | 38 -------- .../datatable/styles/BottomPanel.module.css | 39 +++----- .../styles/DataTableButton.module.css | 34 +++++++ src/util/__tests__/dataTable.spec.js | 25 ++++++ src/util/dataTable.js | 10 +++ 14 files changed, 405 insertions(+), 243 deletions(-) create mode 100644 src/components/datatable/DataTableButton.jsx create mode 100644 src/components/datatable/__tests__/DataTableButton.spec.jsx create mode 100644 src/components/datatable/__tests__/LayerSelectorControl.spec.jsx delete mode 100644 src/components/datatable/controls/ActiveLayerControl.jsx create mode 100644 src/components/datatable/controls/LayerSelectorControl.jsx delete mode 100644 src/components/datatable/controls/styles/ActiveLayerControl.module.css create mode 100644 src/components/datatable/styles/DataTableButton.module.css diff --git a/i18n/en.pot b/i18n/en.pot index c134051c8e..b7d5b6faeb 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-28T13:15:40.903Z\n" -"PO-Revision-Date: 2026-07-28T13:15:40.903Z\n" +"POT-Creation-Date: 2026-07-28T13:36:50.817Z\n" +"PO-Revision-Date: 2026-07-28T13:36:50.817Z\n" msgid "2020" msgstr "2020" @@ -173,12 +173,6 @@ msgstr "inside" msgid "Polygon layer" msgstr "Polygon layer" -msgid "Close {{name}} tab" -msgstr "Close {{name}} tab" - -msgid "Combined" -msgstr "Combined" - msgid "No matching rows" msgstr "No matching rows" @@ -203,6 +197,9 @@ msgstr "Zoom to filtered features" msgid "Edit layer" msgstr "Edit layer" +msgid "Data table" +msgstr "Data table" + msgid "Select a year, month, day or hour" msgstr "Select a year, month, day or hour" @@ -361,6 +358,12 @@ msgstr "Highlight color" msgid "Choose layers to combine" msgstr "Choose layers to combine" +msgid "Choose a data table to view" +msgstr "Choose a data table to view" + +msgid "Combined" +msgstr "Combined" + msgid "{{filtered}} of {{total}} rows" msgstr "{{filtered}} of {{total}} rows" diff --git a/src/components/app/AppMenu.jsx b/src/components/app/AppMenu.jsx index d0ac79f722..2eb8b5666f 100644 --- a/src/components/app/AppMenu.jsx +++ b/src/components/app/AppMenu.jsx @@ -1,6 +1,7 @@ import { Toolbar, HoverMenuBar } from '@dhis2/analytics' import PropTypes from 'prop-types' import React from 'react' +import DataTableButton from '../datatable/DataTableButton.jsx' import DownloadButton from '../download/DownloadButton.jsx' import InterpretationsToggle from '../interpretations/InterpretationsToggle.jsx' import AddLayerButton from '../layers/overlays/AddLayerButton.jsx' @@ -12,6 +13,7 @@ const AppMenu = ({ onFileMenuAction }) => ( <HoverMenuBar> <FileMenu onFileMenuAction={onFileMenuAction} /> <DownloadButton /> + <DataTableButton /> </HoverMenuBar> <InterpretationsToggle /> </Toolbar> diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index f44463d83d..8d01d88e28 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -1,5 +1,4 @@ import i18n from '@dhis2/d2-i18n' -import { TabBar, Tab, IconCross16 } from '@dhis2/ui' import React, { useRef, useCallback, @@ -16,15 +15,14 @@ import { toggleShowOnlyFeaturesInView, setSelectionFilter, setHighlightColor, - toggleDataTable, toggleCombinedView, setJoinConfig, setDataTableColumnConfig, } from '../../actions/dataTable.js' import { COMBINED_HEADERS_KEY } from '../../constants/dataTable.js' -import { DATA_TABLE_LAYER_TYPES } from '../../constants/layers.js' import useKeyDown from '../../hooks/useKeyDown.js' import { + getEligibleDataTableLayers, getPanelHeights, hasActiveDataTableFilters, } from '../../util/dataTable.js' @@ -36,7 +34,6 @@ import { import { getCssVar } from '../../util/helpers.js' import { useWindowDimensions } from '../WindowDimensionsProvider.jsx' import CombinedDataTable from './CombinedDataTable.jsx' -import ActiveLayerControl from './controls/ActiveLayerControl.jsx' import ClearFiltersControl from './controls/ClearFiltersControl.jsx' import CloseControl from './controls/CloseControl.jsx' import CollapseControl from './controls/CollapseControl.jsx' @@ -44,6 +41,7 @@ import ColumnPickerControl from './controls/ColumnPickerControl.jsx' import GlobalSearchControl from './controls/GlobalSearchControl.jsx' import HighlightColorControl from './controls/HighlightColorControl.jsx' import JoinLayersControl from './controls/JoinLayersControl.jsx' +import LayerSelectorControl from './controls/LayerSelectorControl.jsx' import ResizeHandleControl from './controls/ResizeHandleControl.jsx' import RowCountControl from './controls/RowCountControl.jsx' import ShowInViewControl from './controls/ShowInViewControl.jsx' @@ -80,11 +78,8 @@ const BottomPanel = () => { : openIds[openIds.length - 1] ?? null const openLayers = mapViews.filter((l) => openIds.includes(l.id)) - const eligibleLayers = mapViews.filter( - (l) => DATA_TABLE_LAYER_TYPES.includes(l.layer) && l.data?.length - ) - const showCombinedTab = eligibleLayers.length >= 2 - const showTabBar = openIds.length > 1 || showCombinedTab + const eligibleLayers = getEligibleDataTableLayers(mapViews) + const combinedEnabled = eligibleLayers.length >= 2 const pointLayers = eligibleLayers.filter(isPointLayer) const polygonLayers = eligibleLayers.filter(isPolygonLayer) @@ -286,7 +281,23 @@ const BottomPanel = () => { onClick={toggleCollapsed} /> <span className={styles.divider} /> - <ActiveLayerControl name={activeLayer?.name} /> + <LayerSelectorControl + openLayers={openLayers} + activeLayerId={activeLayerId} + combinedView={combinedView} + combinedEnabled={combinedEnabled} + onSelectLayer={(id) => { + setManualActiveLayerId(id) + if (combinedView) { + dispatch(toggleCombinedView()) + } + }} + onSelectCombined={() => { + if (!combinedView) { + dispatch(toggleCombinedView()) + } + }} + /> <span className={styles.divider} /> {combinedView ? ( <> @@ -432,59 +443,6 @@ const BottomPanel = () => { <span className={styles.divider} /> <CloseControl onClick={onCloseDataTable} /> </div> - {showTabBar && ( - <TabBar scrollable className={styles.tabBar}> - {openLayers.map((lyr) => ( - <Tab - key={lyr.id} - selected={!combinedView && lyr.id === activeLayerId} - onClick={() => { - setManualActiveLayerId(lyr.id) - if (combinedView) { - dispatch(toggleCombinedView()) - } - }} - > - <span className={styles.tabLabel}>{lyr.name}</span> - {/* A real <button> can't nest here - Tab's own - root element is already a <button>. */} - <span - role="button" - tabIndex={0} - className={styles.tabCloseButton} - aria-label={i18n.t('Close {{name}} tab', { - name: lyr.name, - })} - onClick={(e) => { - e.stopPropagation() - dispatch(toggleDataTable(lyr.id)) - }} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.stopPropagation() - e.preventDefault() - dispatch(toggleDataTable(lyr.id)) - } - }} - > - <IconCross16 /> - </span> - </Tab> - ))} - {showCombinedTab && ( - <Tab - selected={combinedView} - onClick={() => { - if (!combinedView) { - dispatch(toggleCombinedView()) - } - }} - > - {i18n.t('Combined')} - </Tab> - )} - </TabBar> - )} <div className={styles.tableContainer}> <ErrorBoundary> {combinedView ? ( diff --git a/src/components/datatable/DataTableButton.jsx b/src/components/datatable/DataTableButton.jsx new file mode 100644 index 0000000000..258dbca536 --- /dev/null +++ b/src/components/datatable/DataTableButton.jsx @@ -0,0 +1,55 @@ +import i18n from '@dhis2/d2-i18n' +import React from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { + toggleDataTable, + toggleCombinedView, + setJoinConfig, +} from '../../actions/dataTable.js' +import { + getEligibleDataTableLayers, + isDataTableOpen, +} from '../../util/dataTable.js' +import styles from './styles/DataTableButton.module.css' + +const DataTableButton = () => { + const dispatch = useDispatch() + const dataTable = useSelector((state) => state.dataTable) + const mapViews = useSelector((state) => state.map.mapViews) + const eligibleLayers = getEligibleDataTableLayers(mapViews) + + const onClick = () => { + // Only a quick-open shortcut for the closed state - if a table is + // already showing (single-layer or Combined), this is a no-op; the + // panel's own Close button is the only way to close it. + if (isDataTableOpen(dataTable)) { + return + } + if (eligibleLayers.length >= 2) { + dispatch( + setJoinConfig({ + level: 'orgUnit', + layerIds: eligibleLayers.map((l) => l.id), + pointLayerId: null, + polygonLayerId: null, + }) + ) + dispatch(toggleCombinedView()) + } else if (eligibleLayers.length === 1) { + dispatch(toggleDataTable(eligibleLayers[0].id)) + } + } + + return ( + <button + type="button" + className={styles.button} + disabled={eligibleLayers.length === 0} + onClick={onClick} + > + {i18n.t('Data table')} + </button> + ) +} + +export default DataTableButton diff --git a/src/components/datatable/__tests__/BottomPanel.spec.jsx b/src/components/datatable/__tests__/BottomPanel.spec.jsx index 920a1552e8..1321ccf1b6 100644 --- a/src/components/datatable/__tests__/BottomPanel.spec.jsx +++ b/src/components/datatable/__tests__/BottomPanel.spec.jsx @@ -105,14 +105,17 @@ const twoEligibleLayers = [ { id: 'layer2', name: 'Layer 2', layer: THEMATIC_LAYER, data: [{}] }, ] -describe('BottomPanel tabs', () => { - test('renders no tab bar with a single open layer and no other eligible layers', () => { +const getLayerSelector = () => screen.getByTestId('data-table-layer-selector') + +describe('BottomPanel layer selector', () => { + test('lists only the one open layer, Combined disabled, when no other eligible layers exist', () => { renderBottomPanel() - expect(screen.queryAllByRole('tab')).toHaveLength(0) + expect(screen.getByText('Layer 1')).toBeInTheDocument() + expect(screen.getByText('Combined')).toBeDisabled() }) - test('renders a tab per open layer, and a Combined tab, once 2+ eligible layers exist', () => { + test('lists every open layer plus an enabled Combined option once 2+ eligible layers exist', () => { renderBottomPanel({ dataTable: { ...DEFAULT_DATA_TABLE_STATE, @@ -121,30 +124,25 @@ describe('BottomPanel tabs', () => { mapViews: twoEligibleLayers, }) - const tabs = screen.getAllByRole('tab') - expect(tabs.map((tab) => tab.textContent)).toEqual([ - 'Layer 1', - 'Layer 2', - 'Combined', - ]) + expect(screen.getByText('Layer 1')).toBeInTheDocument() + expect(screen.getByText('Layer 2')).toBeInTheDocument() + expect(screen.getByText('Combined')).not.toBeDisabled() }) - test('shows the Combined tab once 2+ eligible layers exist even with a single open tab', () => { + test('offers Combined (enabled) even with just a single open tab, once 2+ eligible layers exist', () => { renderBottomPanel({ dataTable: DEFAULT_DATA_TABLE_STATE, mapViews: twoEligibleLayers, }) - const tabs = screen.getAllByRole('tab') - // Only the open layer gets its own tab - the second eligible layer - // isn't open, so it shouldn't render a tab of its own. - expect(tabs.map((tab) => tab.textContent)).toEqual([ - 'Layer 1', - 'Combined', - ]) + // Only the open layer appears as its own option - the second + // eligible layer isn't open, so it shouldn't be listed. + expect(screen.getByText('Layer 1')).toBeInTheDocument() + expect(screen.queryByText('Layer 2')).not.toBeInTheDocument() + expect(screen.getByText('Combined')).not.toBeDisabled() }) - test('clicking a different tab switches the active layer shown in the table', () => { + test('selecting a different layer switches the active layer shown in the table', () => { renderBottomPanel({ dataTable: { ...DEFAULT_DATA_TABLE_STATE, @@ -155,7 +153,7 @@ describe('BottomPanel tabs', () => { expect(screen.getByTestId('datatable-mock')).toHaveTextContent('layer2') - fireEvent.click(screen.getByText('Layer 1')) + fireEvent.change(getLayerSelector(), { target: { value: 'layer1' } }) expect(screen.getByTestId('datatable-mock')).toHaveTextContent('layer1') }) @@ -183,7 +181,7 @@ describe('BottomPanel tabs', () => { consoleError.mockRestore() }) - test('closing a tab dispatches toggleDataTable for that layer without switching the active tab', () => { + test('selecting Combined from the dropdown dispatches DATA_TABLE_COMBINED_VIEW_TOGGLE', () => { const { store } = renderBottomPanel({ dataTable: { ...DEFAULT_DATA_TABLE_STATE, @@ -192,24 +190,10 @@ describe('BottomPanel tabs', () => { mapViews: twoEligibleLayers, }) - fireEvent.click(screen.getByLabelText('Close Layer 1 tab')) - - expect(store.getActions()).toEqual([ - { type: 'DATA_TABLE_TOGGLE', id: 'layer1' }, - ]) - }) - - test('clicking the Combined tab dispatches DATA_TABLE_COMBINED_VIEW_TOGGLE', () => { - const { store } = renderBottomPanel({ - dataTable: { - ...DEFAULT_DATA_TABLE_STATE, - openIds: ['layer1', 'layer2'], - }, - mapViews: twoEligibleLayers, + fireEvent.change(getLayerSelector(), { + target: { value: '__combined__' }, }) - fireEvent.click(screen.getByText('Combined')) - expect(store.getActions()).toEqual([ { type: 'DATA_TABLE_COMBINED_VIEW_TOGGLE' }, ]) diff --git a/src/components/datatable/__tests__/DataTableButton.spec.jsx b/src/components/datatable/__tests__/DataTableButton.spec.jsx new file mode 100644 index 0000000000..f37e83fd56 --- /dev/null +++ b/src/components/datatable/__tests__/DataTableButton.spec.jsx @@ -0,0 +1,90 @@ +import { render, fireEvent, screen } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import configureMockStore from 'redux-mock-store' +import { THEMATIC_LAYER, EXTERNAL_LAYER } from '../../../constants/layers.js' +import DataTableButton from '../DataTableButton.jsx' + +const mockStore = configureMockStore() + +const layer = (id, overrides = {}) => ({ + id, + name: id, + layer: THEMATIC_LAYER, + data: [{}], + ...overrides, +}) + +const renderButton = ({ dataTable, mapViews }) => { + const store = mockStore({ + dataTable, + map: { mapViews }, + }) + const result = render( + <Provider store={store}> + <DataTableButton /> + </Provider> + ) + return { ...result, store } +} + +const CLOSED = { openIds: [], combinedView: false } + +describe('DataTableButton', () => { + test('is disabled when the map has no eligible layers', () => { + renderButton({ + dataTable: CLOSED, + mapViews: [layer('a', { layer: EXTERNAL_LAYER })], + }) + expect(screen.getByText('Data table')).toBeDisabled() + }) + + test('opens the single layer directly when only one is eligible', () => { + const { store } = renderButton({ + dataTable: CLOSED, + mapViews: [layer('a')], + }) + fireEvent.click(screen.getByText('Data table')) + expect(store.getActions()).toEqual([ + { type: 'DATA_TABLE_TOGGLE', id: 'a' }, + ]) + }) + + test('opens Combined, pre-populated with every eligible layer, when 2+ are eligible', () => { + const { store } = renderButton({ + dataTable: CLOSED, + mapViews: [layer('a'), layer('b')], + }) + fireEvent.click(screen.getByText('Data table')) + expect(store.getActions()).toEqual([ + { + type: 'DATA_TABLE_JOIN_CONFIG_SET', + config: { + level: 'orgUnit', + layerIds: ['a', 'b'], + pointLayerId: null, + polygonLayerId: null, + }, + }, + { type: 'DATA_TABLE_COMBINED_VIEW_TOGGLE' }, + ]) + }) + + test('is a no-op when a single-layer table is already open', () => { + const { store } = renderButton({ + dataTable: { openIds: ['a'], combinedView: false }, + mapViews: [layer('a'), layer('b')], + }) + fireEvent.click(screen.getByText('Data table')) + expect(store.getActions()).toEqual([]) + }) + + test('is a no-op when Combined is already open', () => { + const { store } = renderButton({ + dataTable: { openIds: [], combinedView: true }, + mapViews: [layer('a'), layer('b')], + }) + fireEvent.click(screen.getByText('Data table')) + expect(store.getActions()).toEqual([]) + }) +}) diff --git a/src/components/datatable/__tests__/LayerSelectorControl.spec.jsx b/src/components/datatable/__tests__/LayerSelectorControl.spec.jsx new file mode 100644 index 0000000000..f6cc3f8bf7 --- /dev/null +++ b/src/components/datatable/__tests__/LayerSelectorControl.spec.jsx @@ -0,0 +1,66 @@ +import { render, fireEvent, screen } from '@testing-library/react' +import React from 'react' +import LayerSelectorControl from '../controls/LayerSelectorControl.jsx' + +const openLayers = [ + { id: 'layer1', name: 'Layer 1' }, + { id: 'layer2', name: 'Layer 2' }, +] + +const renderControl = (props) => + render( + <LayerSelectorControl + openLayers={openLayers} + activeLayerId="layer1" + combinedView={false} + combinedEnabled={true} + onSelectLayer={jest.fn()} + onSelectCombined={jest.fn()} + {...props} + /> + ) + +const getSelect = () => screen.getByTestId('data-table-layer-selector') + +describe('LayerSelectorControl', () => { + test('lists every open layer by name, plus a Combined option', () => { + renderControl() + expect(screen.getByText('Layer 1')).toBeInTheDocument() + expect(screen.getByText('Layer 2')).toBeInTheDocument() + expect(screen.getByText('Combined')).toBeInTheDocument() + }) + + test('the Combined option is disabled when combinedEnabled is false', () => { + renderControl({ combinedEnabled: false }) + expect(screen.getByText('Combined')).toBeDisabled() + }) + + test('the Combined option is enabled when combinedEnabled is true', () => { + renderControl({ combinedEnabled: true }) + expect(screen.getByText('Combined')).not.toBeDisabled() + }) + + test('shows the active layer id as the selected value', () => { + renderControl({ activeLayerId: 'layer2' }) + expect(getSelect()).toHaveValue('layer2') + }) + + test('shows Combined as the selected value when combinedView is true', () => { + renderControl({ combinedView: true }) + expect(getSelect()).toHaveValue('__combined__') + }) + + test('selecting a different layer calls onSelectLayer with its id', () => { + const onSelectLayer = jest.fn() + renderControl({ onSelectLayer }) + fireEvent.change(getSelect(), { target: { value: 'layer2' } }) + expect(onSelectLayer).toHaveBeenCalledWith('layer2') + }) + + test('selecting Combined calls onSelectCombined', () => { + const onSelectCombined = jest.fn() + renderControl({ onSelectCombined }) + fireEvent.change(getSelect(), { target: { value: '__combined__' } }) + expect(onSelectCombined).toHaveBeenCalled() + }) +}) diff --git a/src/components/datatable/controls/ActiveLayerControl.jsx b/src/components/datatable/controls/ActiveLayerControl.jsx deleted file mode 100644 index 3e28a4ba20..0000000000 --- a/src/components/datatable/controls/ActiveLayerControl.jsx +++ /dev/null @@ -1,71 +0,0 @@ -import PropTypes from 'prop-types' -import React, { useCallback, useRef, useState } from 'react' -import { createPortal } from 'react-dom' -import { getCssVar } from '../../../util/helpers.js' -import styles from './styles/ActiveLayerControl.module.css' - -const ActiveLayerControl = ({ name }) => { - const nameRef = useRef(null) - const [nameTooltipPos, setNameTooltipPos] = useState(null) - - const onMouseEnter = useCallback(() => { - const el = nameRef.current - if (!el || el.scrollWidth <= el.offsetWidth) { - return - } - const rect = el.getBoundingClientRect() - const computed = getComputedStyle(el) - const lineHeight = Number.parseFloat(computed.lineHeight) - const verticalPadding = getCssVar( - '--data-table-name-tooltip-vertical-padding' - ) - setNameTooltipPos({ - top: rect.top + (rect.height - lineHeight) / 2 - verticalPadding, - left: rect.left, - color: computed.color, - fontSize: computed.fontSize, - fontWeight: computed.fontWeight, - lineHeight: `${lineHeight}px`, - paddingLeft: computed.paddingLeft, - }) - }, []) - - const onMouseLeave = useCallback(() => setNameTooltipPos(null), []) - - return ( - <> - <span - ref={nameRef} - className={styles.layerName} - onMouseEnter={onMouseEnter} - onMouseLeave={onMouseLeave} - > - {name} - </span> - {nameTooltipPos && - createPortal( - <div - className={styles.nameTooltip} - style={{ - top: nameTooltipPos.top, - left: nameTooltipPos.left, - color: nameTooltipPos.color, - fontSize: nameTooltipPos.fontSize, - fontWeight: nameTooltipPos.fontWeight, - lineHeight: nameTooltipPos.lineHeight, - paddingLeft: nameTooltipPos.paddingLeft, - }} - > - {name} - </div>, - document.body - )} - </> - ) -} - -ActiveLayerControl.propTypes = { - name: PropTypes.string, -} - -export default ActiveLayerControl diff --git a/src/components/datatable/controls/LayerSelectorControl.jsx b/src/components/datatable/controls/LayerSelectorControl.jsx new file mode 100644 index 0000000000..817d81c394 --- /dev/null +++ b/src/components/datatable/controls/LayerSelectorControl.jsx @@ -0,0 +1,57 @@ +import i18n from '@dhis2/d2-i18n' +import PropTypes from 'prop-types' +import React from 'react' +import styles from '../styles/BottomPanel.module.css' + +const COMBINED_VALUE = '__combined__' + +// Replaces the old per-layer tab strip - a single dropdown listing every +// currently open layer plus Combined (greyed out unless the map has 2+ +// eligible layers to join), rather than one tab per open layer. +const LayerSelectorControl = ({ + openLayers, + activeLayerId, + combinedView, + combinedEnabled, + onSelectLayer, + onSelectCombined, +}) => ( + <select + className={styles.layerSelect} + aria-label={i18n.t('Choose a data table to view')} + data-test="data-table-layer-selector" + value={combinedView ? COMBINED_VALUE : activeLayerId ?? ''} + onChange={(e) => { + if (e.target.value === COMBINED_VALUE) { + onSelectCombined() + } else { + onSelectLayer(e.target.value) + } + }} + > + {openLayers.map((layer) => ( + <option key={layer.id} value={layer.id}> + {layer.name} + </option> + ))} + <option value={COMBINED_VALUE} disabled={!combinedEnabled}> + {i18n.t('Combined')} + </option> + </select> +) + +LayerSelectorControl.propTypes = { + combinedEnabled: PropTypes.bool.isRequired, + combinedView: PropTypes.bool.isRequired, + openLayers: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired, + name: PropTypes.string, + }) + ).isRequired, + onSelectCombined: PropTypes.func.isRequired, + onSelectLayer: PropTypes.func.isRequired, + activeLayerId: PropTypes.string, +} + +export default LayerSelectorControl diff --git a/src/components/datatable/controls/styles/ActiveLayerControl.module.css b/src/components/datatable/controls/styles/ActiveLayerControl.module.css deleted file mode 100644 index 75d05c3372..0000000000 --- a/src/components/datatable/controls/styles/ActiveLayerControl.module.css +++ /dev/null @@ -1,38 +0,0 @@ -:root { - --data-table-name-tooltip-vertical-padding: 3px; -} - -.layerName { - font-weight: 500; - font-size: 12px; - color: var(--colors-grey800); - flex: 0 1 auto; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - min-width: 0; -} - -@keyframes tooltipExpandRight { - from { - clip-path: inset(0 100% 0 0); - } - - to { - clip-path: inset(0 0% 0 0); - } -} - -.nameTooltip { - animation: tooltipExpandRight 160ms ease-out; - background: var(--colors-grey100); - border-radius: 3px; - -webkit-mask-image: linear-gradient(to left, transparent, black 2em); - mask-image: linear-gradient(to left, transparent, black 2em); - padding: var(--data-table-name-tooltip-vertical-padding) 2em - var(--data-table-name-tooltip-vertical-padding) 0; - pointer-events: none; - position: fixed; - white-space: nowrap; - z-index: 2000; -} diff --git a/src/components/datatable/styles/BottomPanel.module.css b/src/components/datatable/styles/BottomPanel.module.css index 9c7040be04..840782afaa 100644 --- a/src/components/datatable/styles/BottomPanel.module.css +++ b/src/components/datatable/styles/BottomPanel.module.css @@ -41,32 +41,6 @@ flex-shrink: 0; } -.tabBar { - flex-shrink: 0; -} - -.tabLabel { - max-width: 160px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.tabCloseButton { - display: inline-flex; - align-items: center; - justify-content: center; - width: 16px; - height: 16px; - margin-left: var(--spacers-dp4); - border-radius: 50%; - cursor: pointer; -} - -.tabCloseButton:hover { - background-color: var(--colors-grey300); -} - .joinSelect { max-width: 180px; height: 24px; @@ -76,3 +50,16 @@ border-radius: 3px; background-color: var(--colors-white); } + +.layerSelect { + max-width: 220px; + height: 24px; + padding: 0 var(--spacers-dp4); + font-size: 12px; + font-weight: 500; + border: 1px solid var(--colors-grey500); + border-radius: 3px; + background-color: var(--colors-white); + flex: 0 1 auto; + min-width: 0; +} diff --git a/src/components/datatable/styles/DataTableButton.module.css b/src/components/datatable/styles/DataTableButton.module.css new file mode 100644 index 0000000000..13c63b6e8b --- /dev/null +++ b/src/components/datatable/styles/DataTableButton.module.css @@ -0,0 +1,34 @@ +/* Based on https: //github.com/dhis2/analytics/blob/master/src/components/Toolbar/MenuButton.styles.js */ + +.button { + all: unset; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 14px; + line-height: 14px; + padding: 0 var(--spacers-dp12); + color: var(--colors-grey900); + transition: background-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + cursor: pointer; +} + +.button:hover:enabled, +.button:active { + background-color: var(--colors-grey200); +} + +.button:focus { + outline: 3px solid var(--theme-focus); + outline-offset: -3px; +} + +/* Prevent focus styles when mouse clicking */ +.button:focus:not(:focus-visible) { + outline: none; +} + +.button:disabled { + color: var(--colors-grey500); + cursor: not-allowed; +} diff --git a/src/util/__tests__/dataTable.spec.js b/src/util/__tests__/dataTable.spec.js index bba5d61ed7..45021be24c 100644 --- a/src/util/__tests__/dataTable.spec.js +++ b/src/util/__tests__/dataTable.spec.js @@ -1,5 +1,7 @@ +import { THEMATIC_LAYER, EXTERNAL_LAYER } from '../../constants/layers.js' import { buildFeatureIndex, + getEligibleDataTableLayers, getLayerSelectedIds, getNextSorting, getPanelHeights, @@ -210,6 +212,29 @@ describe('buildFeatureIndex', () => { }) }) +describe('getEligibleDataTableLayers', () => { + test('includes data-table-capable layer types that have loaded data', () => { + const mapViews = [ + { id: 'a', layer: THEMATIC_LAYER, data: [{}] }, + { id: 'b', layer: THEMATIC_LAYER, data: [{}, {}] }, + ] + expect(getEligibleDataTableLayers(mapViews).map((l) => l.id)).toEqual([ + 'a', + 'b', + ]) + }) + + test('excludes layer types with no data table support', () => { + const mapViews = [{ id: 'a', layer: EXTERNAL_LAYER, data: [{}] }] + expect(getEligibleDataTableLayers(mapViews)).toEqual([]) + }) + + test('excludes a data-table-capable layer with no loaded data', () => { + const mapViews = [{ id: 'a', layer: THEMATIC_LAYER, data: [] }] + expect(getEligibleDataTableLayers(mapViews)).toEqual([]) + }) +}) + describe('isDataTableOpen', () => { test('is open when at least one tab is open', () => { expect( diff --git a/src/util/dataTable.js b/src/util/dataTable.js index de80fc31ef..aca1a7a548 100644 --- a/src/util/dataTable.js +++ b/src/util/dataTable.js @@ -1,5 +1,6 @@ import { bbox } from '@turf/bbox' import { SORT_ASCENDING, SORT_DESCENDING } from '../constants/dataTable.js' +import { DATA_TABLE_LAYER_TYPES } from '../constants/layers.js' export const isFilterable = (dataKey, type) => !!type @@ -62,6 +63,15 @@ export const hasActiveDataTableFilters = ({ export const isDataTableOpen = ({ openIds, combinedView }) => openIds.length > 0 || combinedView +// Map-wide, not scoped to which layers currently have an open tab - used +// both for whether the Combined option/tab can be offered at all, and to +// pick a sensible default when opening the panel from scratch (e.g. the +// "Data Table" menu button). +export const getEligibleDataTableLayers = (mapViews) => + mapViews.filter( + (l) => DATA_TABLE_LAYER_TYPES.includes(l.layer) && l.data?.length + ) + // A crossLayerIds selection has no single owning layerId (layerId: null), // so a layer's own selection can't be read off selection.ids alone once // Combined-originated selections exist - merges in whatever this layer is From 0934dcc7176a3e6bbcd4c7b87595db672c830a27 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 28 Jul 2026 15:46:18 +0200 Subject: [PATCH 157/205] feat: list every eligible layer in the data table dropdown, not just open ones [DHIS2-20543] LayerSelectorControl now lists every data-table-eligible layer on the map, whether or not its table has been opened yet - selecting one that isn't open yet opens it (dispatches toggleDataTable) as well as making it active, matching how the "Data Table" menu button already opens a layer's table on demand. --- src/components/datatable/BottomPanel.jsx | 9 ++-- .../datatable/__tests__/BottomPanel.spec.jsx | 52 ++++++++++++------- .../__tests__/LayerSelectorControl.spec.jsx | 6 +-- .../controls/LayerSelectorControl.jsx | 12 +++-- 4 files changed, 50 insertions(+), 29 deletions(-) diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index 8d01d88e28..0c39098e6d 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -15,6 +15,7 @@ import { toggleShowOnlyFeaturesInView, setSelectionFilter, setHighlightColor, + toggleDataTable, toggleCombinedView, setJoinConfig, setDataTableColumnConfig, @@ -77,7 +78,6 @@ const BottomPanel = () => { ? manualActiveLayerId : openIds[openIds.length - 1] ?? null - const openLayers = mapViews.filter((l) => openIds.includes(l.id)) const eligibleLayers = getEligibleDataTableLayers(mapViews) const combinedEnabled = eligibleLayers.length >= 2 @@ -97,7 +97,7 @@ const BottomPanel = () => { [level, layerIds, pointLayerId, polygonLayerId, mapViews] ) - const activeLayer = openLayers.find((l) => l.id === activeLayerId) + const activeLayer = mapViews.find((l) => l.id === activeLayerId) const dataFilters = activeLayer?.dataFilters ?? EMPTY_FILTERS const showOnlyFeaturesInView = useSelector( (state) => state.ui.showOnlyFeaturesInView @@ -282,7 +282,7 @@ const BottomPanel = () => { /> <span className={styles.divider} /> <LayerSelectorControl - openLayers={openLayers} + layers={eligibleLayers} activeLayerId={activeLayerId} combinedView={combinedView} combinedEnabled={combinedEnabled} @@ -291,6 +291,9 @@ const BottomPanel = () => { if (combinedView) { dispatch(toggleCombinedView()) } + if (!openIds.includes(id)) { + dispatch(toggleDataTable(id)) + } }} onSelectCombined={() => { if (!combinedView) { diff --git a/src/components/datatable/__tests__/BottomPanel.spec.jsx b/src/components/datatable/__tests__/BottomPanel.spec.jsx index 1321ccf1b6..f0a9bcdb14 100644 --- a/src/components/datatable/__tests__/BottomPanel.spec.jsx +++ b/src/components/datatable/__tests__/BottomPanel.spec.jsx @@ -41,7 +41,9 @@ const DEFAULT_DATA_TABLE_STATE = { }, } -const DEFAULT_MAP_VIEWS = [{ id: 'layer1', name: 'Layer 1' }] +const DEFAULT_MAP_VIEWS = [ + { id: 'layer1', name: 'Layer 1', layer: THEMATIC_LAYER, data: [{}] }, +] const renderBottomPanel = ({ dataTable = DEFAULT_DATA_TABLE_STATE, @@ -108,42 +110,58 @@ const twoEligibleLayers = [ const getLayerSelector = () => screen.getByTestId('data-table-layer-selector') describe('BottomPanel layer selector', () => { - test('lists only the one open layer, Combined disabled, when no other eligible layers exist', () => { + test('lists only the one eligible layer, Combined disabled, when no other eligible layers exist', () => { renderBottomPanel() expect(screen.getByText('Layer 1')).toBeInTheDocument() expect(screen.getByText('Combined')).toBeDisabled() }) - test('lists every open layer plus an enabled Combined option once 2+ eligible layers exist', () => { + test('lists every eligible layer, whether or not its table is open, plus an enabled Combined option once 2+ eligible layers exist', () => { renderBottomPanel({ - dataTable: { - ...DEFAULT_DATA_TABLE_STATE, - openIds: ['layer1', 'layer2'], - }, + dataTable: DEFAULT_DATA_TABLE_STATE, mapViews: twoEligibleLayers, }) + // layer1 is the only one open, but layer2 is still listed since it's + // eligible - the dropdown covers every eligible map layer, not just + // already-open tabs. expect(screen.getByText('Layer 1')).toBeInTheDocument() expect(screen.getByText('Layer 2')).toBeInTheDocument() expect(screen.getByText('Combined')).not.toBeDisabled() }) - test('offers Combined (enabled) even with just a single open tab, once 2+ eligible layers exist', () => { + test('selecting a different, already-open layer switches the active layer shown in the table', () => { renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1', 'layer2'], + }, + mapViews: twoEligibleLayers, + }) + + expect(screen.getByTestId('datatable-mock')).toHaveTextContent('layer2') + + fireEvent.change(getLayerSelector(), { target: { value: 'layer1' } }) + + expect(screen.getByTestId('datatable-mock')).toHaveTextContent('layer1') + }) + + test('selecting a layer that has not been opened yet opens it and makes it active', () => { + const { store } = renderBottomPanel({ dataTable: DEFAULT_DATA_TABLE_STATE, mapViews: twoEligibleLayers, }) - // Only the open layer appears as its own option - the second - // eligible layer isn't open, so it shouldn't be listed. - expect(screen.getByText('Layer 1')).toBeInTheDocument() - expect(screen.queryByText('Layer 2')).not.toBeInTheDocument() - expect(screen.getByText('Combined')).not.toBeDisabled() + fireEvent.change(getLayerSelector(), { target: { value: 'layer2' } }) + + expect(store.getActions()).toEqual([ + { type: 'DATA_TABLE_TOGGLE', id: 'layer2' }, + ]) }) - test('selecting a different layer switches the active layer shown in the table', () => { - renderBottomPanel({ + test('does not re-dispatch toggleDataTable when selecting an already-open layer', () => { + const { store } = renderBottomPanel({ dataTable: { ...DEFAULT_DATA_TABLE_STATE, openIds: ['layer1', 'layer2'], @@ -151,11 +169,9 @@ describe('BottomPanel layer selector', () => { mapViews: twoEligibleLayers, }) - expect(screen.getByTestId('datatable-mock')).toHaveTextContent('layer2') - fireEvent.change(getLayerSelector(), { target: { value: 'layer1' } }) - expect(screen.getByTestId('datatable-mock')).toHaveTextContent('layer1') + expect(store.getActions()).toEqual([]) }) test('the active layer is correct on the very first render, with no transient null in between', () => { diff --git a/src/components/datatable/__tests__/LayerSelectorControl.spec.jsx b/src/components/datatable/__tests__/LayerSelectorControl.spec.jsx index f6cc3f8bf7..b6a69ab0cd 100644 --- a/src/components/datatable/__tests__/LayerSelectorControl.spec.jsx +++ b/src/components/datatable/__tests__/LayerSelectorControl.spec.jsx @@ -2,7 +2,7 @@ import { render, fireEvent, screen } from '@testing-library/react' import React from 'react' import LayerSelectorControl from '../controls/LayerSelectorControl.jsx' -const openLayers = [ +const layers = [ { id: 'layer1', name: 'Layer 1' }, { id: 'layer2', name: 'Layer 2' }, ] @@ -10,7 +10,7 @@ const openLayers = [ const renderControl = (props) => render( <LayerSelectorControl - openLayers={openLayers} + layers={layers} activeLayerId="layer1" combinedView={false} combinedEnabled={true} @@ -23,7 +23,7 @@ const renderControl = (props) => const getSelect = () => screen.getByTestId('data-table-layer-selector') describe('LayerSelectorControl', () => { - test('lists every open layer by name, plus a Combined option', () => { + test('lists every eligible layer by name, plus a Combined option', () => { renderControl() expect(screen.getByText('Layer 1')).toBeInTheDocument() expect(screen.getByText('Layer 2')).toBeInTheDocument() diff --git a/src/components/datatable/controls/LayerSelectorControl.jsx b/src/components/datatable/controls/LayerSelectorControl.jsx index 817d81c394..2b8cd0f0e8 100644 --- a/src/components/datatable/controls/LayerSelectorControl.jsx +++ b/src/components/datatable/controls/LayerSelectorControl.jsx @@ -6,10 +6,12 @@ import styles from '../styles/BottomPanel.module.css' const COMBINED_VALUE = '__combined__' // Replaces the old per-layer tab strip - a single dropdown listing every -// currently open layer plus Combined (greyed out unless the map has 2+ -// eligible layers to join), rather than one tab per open layer. +// data-table-eligible layer on the map (whether or not its table has been +// opened yet) plus Combined (greyed out unless the map has 2+ eligible +// layers to join). Selecting a layer that isn't open yet is the caller's +// job to also open (see BottomPanel.jsx's onSelectLayer). const LayerSelectorControl = ({ - openLayers, + layers, activeLayerId, combinedView, combinedEnabled, @@ -29,7 +31,7 @@ const LayerSelectorControl = ({ } }} > - {openLayers.map((layer) => ( + {layers.map((layer) => ( <option key={layer.id} value={layer.id}> {layer.name} </option> @@ -43,7 +45,7 @@ const LayerSelectorControl = ({ LayerSelectorControl.propTypes = { combinedEnabled: PropTypes.bool.isRequired, combinedView: PropTypes.bool.isRequired, - openLayers: PropTypes.arrayOf( + layers: PropTypes.arrayOf( PropTypes.shape({ id: PropTypes.string.isRequired, name: PropTypes.string, From f9b83febe548dd218f95171aa22c3d1d012ee7cc Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 28 Jul 2026 16:24:14 +0200 Subject: [PATCH 158/205] feat: add applyAggregation utility and combined-join aggregation types [DHIS2-20543] Reuses the thematic layer's own aggregation type set (minus DEFAULT, which only makes sense for a thematic layer's own data element config) for the Combined data table's upcoming per-layer aggregation choice, plus a small reducer map to actually apply one of these types to a list of raw values. --- .../__tests__/aggregationTypes.spec.js | 19 ++++++++ src/constants/aggregationTypes.js | 6 +++ src/util/__tests__/aggregation.spec.js | 46 +++++++++++++++++++ src/util/aggregation.js | 27 +++++++++++ 4 files changed, 98 insertions(+) create mode 100644 src/constants/__tests__/aggregationTypes.spec.js create mode 100644 src/util/__tests__/aggregation.spec.js create mode 100644 src/util/aggregation.js diff --git a/src/constants/__tests__/aggregationTypes.spec.js b/src/constants/__tests__/aggregationTypes.spec.js new file mode 100644 index 0000000000..c3e7a0f6e1 --- /dev/null +++ b/src/constants/__tests__/aggregationTypes.spec.js @@ -0,0 +1,19 @@ +import { + getCombinedAggregationTypes, + getThematicAggregationTypes, +} from '../aggregationTypes.js' + +describe('getCombinedAggregationTypes', () => { + test('excludes DEFAULT (not meaningful outside a thematic layer)', () => { + expect( + getCombinedAggregationTypes().map((type) => type.id) + ).not.toContain('DEFAULT') + }) + + test('otherwise matches the thematic layer aggregation types exactly', () => { + const thematicNonDefault = getThematicAggregationTypes().filter( + (type) => type.id !== 'DEFAULT' + ) + expect(getCombinedAggregationTypes()).toEqual(thematicNonDefault) + }) +}) diff --git a/src/constants/aggregationTypes.js b/src/constants/aggregationTypes.js index 2c942a9cd9..71da3b1790 100644 --- a/src/constants/aggregationTypes.js +++ b/src/constants/aggregationTypes.js @@ -12,6 +12,12 @@ export const getThematicAggregationTypes = () => [ { id: 'MAX', name: i18n.t('Max') }, ] +// Combined data table join - same set as the thematic layer's own +// aggregation types, minus DEFAULT ("by data element"), which has no +// meaning outside a thematic layer's own data element config. +export const getCombinedAggregationTypes = () => + getThematicAggregationTypes().filter((type) => type.id !== 'DEFAULT') + // Earth Engine layer export const getEarthEngineStatisticTypes = () => [ { id: 'percentage', name: i18n.t('Percentage') }, diff --git a/src/util/__tests__/aggregation.spec.js b/src/util/__tests__/aggregation.spec.js new file mode 100644 index 0000000000..81a5a6c46e --- /dev/null +++ b/src/util/__tests__/aggregation.spec.js @@ -0,0 +1,46 @@ +import { applyAggregation } from '../aggregation.js' + +describe('applyAggregation', () => { + test('returns null for an empty input (no matching feature)', () => { + expect(applyAggregation('SUM', [])).toBeNull() + }) + + test('SUM', () => { + expect(applyAggregation('SUM', [1, 2, 3])).toBe(6) + }) + + test('AVERAGE', () => { + expect(applyAggregation('AVERAGE', [1, 2, 3])).toBe(2) + }) + + test('COUNT', () => { + expect(applyAggregation('COUNT', [10, 20, 30, 40])).toBe(4) + }) + + test('MIN', () => { + expect(applyAggregation('MIN', [5, 1, 9])).toBe(1) + }) + + test('MAX', () => { + expect(applyAggregation('MAX', [5, 1, 9])).toBe(9) + }) + + test('VARIANCE', () => { + expect(applyAggregation('VARIANCE', [2, 4, 4, 4, 5, 5, 7, 9])).toBe(4) + }) + + test('STDDEV', () => { + expect(applyAggregation('STDDEV', [2, 4, 4, 4, 5, 5, 7, 9])).toBe(2) + }) + + test('a single value aggregates to itself regardless of type', () => { + expect(applyAggregation('SUM', [42])).toBe(42) + expect(applyAggregation('AVERAGE', [42])).toBe(42) + expect(applyAggregation('MIN', [42])).toBe(42) + expect(applyAggregation('MAX', [42])).toBe(42) + }) + + test('returns null for an unknown aggregation type', () => { + expect(applyAggregation('NOT_A_TYPE', [1, 2, 3])).toBeNull() + }) +}) diff --git a/src/util/aggregation.js b/src/util/aggregation.js new file mode 100644 index 0000000000..8290faa2f6 --- /dev/null +++ b/src/util/aggregation.js @@ -0,0 +1,27 @@ +// Reducers for combining several raw values into one, keyed by the same +// ids getCombinedAggregationTypes() (constants/aggregationTypes.js) offers - +// used by the Combined data table's join when a participating layer has +// more than one feature matching a single reference org unit row. +const AGGREGATIONS = { + SUM: (values) => values.reduce((a, b) => a + b, 0), + AVERAGE: (values) => values.reduce((a, b) => a + b, 0) / values.length, + COUNT: (values) => values.length, + MIN: (values) => Math.min(...values), + MAX: (values) => Math.max(...values), + STDDEV: (values) => Math.sqrt(variance(values)), + VARIANCE: (values) => variance(values), +} + +const variance = (values) => { + const mean = values.reduce((a, b) => a + b, 0) / values.length + return values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / values.length +} + +// Returns null for an empty input (no matching feature) rather than NaN. +export const applyAggregation = (type, values) => { + if (!values.length) { + return null + } + const aggregate = AGGREGATIONS[type] + return aggregate ? aggregate(values) : null +} From 639dcbb356c98c7c65b1c08138a24089958599c8 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 28 Jul 2026 16:34:45 +0200 Subject: [PATCH 159/205] feat: add the combinedTableRef layer type, wired to orgUnitLoader/OrgUnitDialog [DHIS2-20543] A hidden, non-rendered org-unit layer type that will back the Combined data table's join (reference org unit set) - deliberately its own type rather than orgUnit + a flag, so it's excluded from DOWNLOADABLE_LAYER_TYPES/DATA_TABLE_LAYER_TYPES and the "Add layer" popover just by omission. Registered against the existing orgUnitLoader/OrgUnitDialog so it's fully functional without duplicating either. Excluded from LayersPanel's list (with its own drag-reorder index fix, since LAYER_SORT's reducer computes positions against the full mapViews array, not a filtered display list) and from both the standalone app's and dashboard plugin's map-rendering pipelines (including their respective "is everything loaded" bookkeeping, which would otherwise get stuck on a reference-only map). --- src/components/edit/LayerEdit.jsx | 2 + src/components/layers/LayersPanel.jsx | 35 +++++++- .../layers/__tests__/LayersPanel.spec.jsx | 90 +++++++++++++++++++ src/components/map/MapContainer.jsx | 13 ++- src/components/plugin/Map.jsx | 35 ++++++-- src/constants/layers.js | 5 ++ src/hooks/useLayersLoader.js | 1 + 7 files changed, 169 insertions(+), 12 deletions(-) create mode 100644 src/components/layers/__tests__/LayersPanel.spec.jsx diff --git a/src/components/edit/LayerEdit.jsx b/src/components/edit/LayerEdit.jsx index 046b1b81e0..7f7ae56419 100644 --- a/src/components/edit/LayerEdit.jsx +++ b/src/components/edit/LayerEdit.jsx @@ -30,6 +30,7 @@ const layerDialogs = { facility: FacilityDialog, thematic: ThematicDialog, orgUnit: OrgUnitDialog, + combinedTableRef: OrgUnitDialog, earthEngine: EarthEngineDialog, geoJsonUrl: GeoJsonDialog, } @@ -40,6 +41,7 @@ const getLayerNames = () => ({ facility: i18n.t('facility'), thematic: i18n.t('thematic'), orgUnit: i18n.t('org unit'), + combinedTableRef: i18n.t('reference org units'), earthEngine: i18n.t('Earth Engine'), geoJsonUrl: i18n.t('feature'), }) diff --git a/src/components/layers/LayersPanel.jsx b/src/components/layers/LayersPanel.jsx index 9ebbeb01d0..b88bf02093 100644 --- a/src/components/layers/LayersPanel.jsx +++ b/src/components/layers/LayersPanel.jsx @@ -22,6 +22,7 @@ import React, { useState } from 'react' import { useSelector, useDispatch } from 'react-redux' import { sortLayers } from '../../actions/layers.js' import { layersSortingEnd, layersSortingStart } from '../../actions/ui.js' +import { COMBINED_TABLE_REF_LAYER } from '../../constants/layers.js' import BasemapCard from '../layers/basemaps/BasemapCard.jsx' import LayersToggle from '../layers/LayersToggle.jsx' import { DragHandleCtx } from './dragHandleContext.js' @@ -60,10 +61,33 @@ SortableLayer.propTypes = { layer: PropTypes.object.isRequired, } +// LAYER_SORT's reducer (reducers/map.js) computes positions against the +// full reversed mapViews, not the displayed/draggable list - which +// excludes the Combined data table's hidden reference org unit layer, if +// one exists. Looking indices up here instead keeps a present reference +// layer from throwing off every index after its position. +export const getSortIndices = (reversedMapViews, activeId, overId) => ({ + oldIndex: reversedMapViews.findIndex((l) => l.id === activeId), + newIndex: reversedMapViews.findIndex((l) => l.id === overId), +}) + const LayersPanel = () => { const layersPanelOpen = useSelector((state) => state.ui.layersPanelOpen) - // Reversed so the last map view (top layer) is shown first - const layers = useSelector((state) => [...state.map.mapViews].reverse()) + // Reversed so the last map view (top layer) is shown first. The + // Combined data table's reference org unit layer is a hidden, + // non-rendered "ghost" - it never gets its own card, drag handle, or + // remove button here, so it's filtered out of the displayed/draggable + // list. reversedMapViews stays unfiltered - LAYER_SORT's reducer (see + // reducers/map.js) computes oldIndex/newIndex against the full reversed + // mapViews, so onDragEnd below must look indices up there, not in the + // filtered display list, or a present ghost layer would throw off every + // index after its position. + const reversedMapViews = useSelector((state) => + [...state.map.mapViews].reverse() + ) + const layers = reversedMapViews.filter( + (l) => l.layer !== COMBINED_TABLE_REF_LAYER + ) const dispatch = useDispatch() @@ -101,8 +125,11 @@ const LayersPanel = () => { stopSorting() if (over && active.id !== over.id) { - const oldIndex = layers.findIndex((l) => l.id === active.id) - const newIndex = layers.findIndex((l) => l.id === over.id) + const { oldIndex, newIndex } = getSortIndices( + reversedMapViews, + active.id, + over.id + ) if (oldIndex !== -1 && newIndex !== -1) { dispatch(sortLayers({ oldIndex, newIndex })) diff --git a/src/components/layers/__tests__/LayersPanel.spec.jsx b/src/components/layers/__tests__/LayersPanel.spec.jsx new file mode 100644 index 0000000000..4cf5c84b1e --- /dev/null +++ b/src/components/layers/__tests__/LayersPanel.spec.jsx @@ -0,0 +1,90 @@ +import { render, screen } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import configureMockStore from 'redux-mock-store' +import { + COMBINED_TABLE_REF_LAYER, + THEMATIC_LAYER, +} from '../../../constants/layers.js' +import LayersPanel, { getSortIndices } from '../LayersPanel.jsx' + +jest.mock('../overlays/OverlayCard.jsx', () => { + const PropTypes = jest.requireActual('prop-types') + const OverlayCardMock = ({ layer }) => ( + <div data-test="overlaycard-mock">{layer.name}</div> + ) + OverlayCardMock.displayName = 'OverlayCardMock' + OverlayCardMock.propTypes = { layer: PropTypes.object.isRequired } + return OverlayCardMock +}) + +jest.mock('../basemaps/BasemapCard.jsx', () => { + const BasemapCardMock = () => <div data-test="basemapcard-mock" /> + BasemapCardMock.displayName = 'BasemapCardMock' + return BasemapCardMock +}) + +jest.mock('../LayersToggle.jsx', () => { + const LayersToggleMock = () => <div data-test="layerstoggle-mock" /> + LayersToggleMock.displayName = 'LayersToggleMock' + return LayersToggleMock +}) + +const mockStore = configureMockStore() + +const renderLayersPanel = (mapViews) => { + const store = mockStore({ + ui: { layersPanelOpen: true }, + map: { mapViews }, + }) + return render( + <Provider store={store}> + <LayersPanel /> + </Provider> + ) +} + +describe('LayersPanel — reference org unit layer exclusion', () => { + test('never renders a card for the Combined data table reference layer', () => { + renderLayersPanel([ + { id: 'layer1', name: 'Layer 1', layer: THEMATIC_LAYER }, + { + id: 'ref1', + name: 'Reference', + layer: COMBINED_TABLE_REF_LAYER, + }, + { id: 'layer2', name: 'Layer 2', layer: THEMATIC_LAYER }, + ]) + + expect(screen.getByText('Layer 1')).toBeInTheDocument() + expect(screen.getByText('Layer 2')).toBeInTheDocument() + expect(screen.queryByText('Reference')).not.toBeInTheDocument() + expect(screen.getAllByTestId('overlaycard-mock')).toHaveLength(2) + }) +}) + +describe('getSortIndices', () => { + // Matches LAYER_SORT's own reducer math (reducers/map.js): indices are + // computed against the full reversed mapViews, not a filtered display + // list, so a hidden reference layer anywhere in the array doesn't + // throw off the position of every layer after it. + const reversedMapViews = [ + { id: 'layer2' }, + { id: 'ref1' }, + { id: 'layer1' }, + ] + + test('finds indices in the unfiltered reversed list, not a filtered display list', () => { + expect(getSortIndices(reversedMapViews, 'layer1', 'layer2')).toEqual({ + oldIndex: 2, + newIndex: 0, + }) + }) + + test('returns -1 for an id not present in the list', () => { + expect(getSortIndices(reversedMapViews, 'missing', 'layer2')).toEqual({ + oldIndex: -1, + newIndex: 0, + }) + }) +}) diff --git a/src/components/map/MapContainer.jsx b/src/components/map/MapContainer.jsx index 3e0b018baf..99a891caad 100644 --- a/src/components/map/MapContainer.jsx +++ b/src/components/map/MapContainer.jsx @@ -10,6 +10,7 @@ import { } from '../../actions/feature.js' import { openContextMenu, closeCoordinatePopup } from '../../actions/map.js' import { toggleFeatureSelection } from '../../actions/selection.js' +import { COMBINED_TABLE_REF_LAYER } from '../../constants/layers.js' import useBasemapConfig from '../../hooks/useBasemapConfig.js' import useDebouncedHighlightFeature from '../../hooks/useDebouncedHighlightFeature.js' import MapLoadingMask from './MapLoadingMask.jsx' @@ -39,8 +40,16 @@ const MapContainer = ({ resizeCount, setMap }) => { dispatchHighlightFeature ) - const loadedMapViews = mapViews.filter((layer) => layer.isLoaded) - const isLoading = loadedMapViews.length !== mapViews.length + // The Combined data table's reference org unit layer is hidden and + // never rendered on the map canvas - excluded from both the render + // list and the isLoading count (comparing against the raw + // mapViews.length here would leave isLoading permanently stuck true, + // since a reference layer is never included in loadedMapViews). + const renderableMapViews = mapViews.filter( + (layer) => layer.layer !== COMBINED_TABLE_REF_LAYER + ) + const loadedMapViews = renderableMapViews.filter((layer) => layer.isLoaded) + const isLoading = loadedMapViews.length !== renderableMapViews.length return ( <> diff --git a/src/components/plugin/Map.jsx b/src/components/plugin/Map.jsx index 97b9c87fe0..1223925ae5 100644 --- a/src/components/plugin/Map.jsx +++ b/src/components/plugin/Map.jsx @@ -10,8 +10,10 @@ import React, { useState, useCallback, useEffect, + useMemo, useRef, } from 'react' +import { COMBINED_TABLE_REF_LAYER } from '../../constants/layers.js' import useDebouncedHighlightFeature from '../../hooks/useDebouncedHighlightFeature.js' import { drillUpDown } from '../../util/map.js' import { didViewsChange } from '../../util/pluginHelper.js' @@ -37,19 +39,40 @@ const getFullscreenDoc = () => { const Map = forwardRef((props, ref) => { const { basemap, mapViews, controls, getResizeFunction } = props + // The Combined data table's reference org unit layer is a hidden, + // non-rendered layer with no meaning outside the standalone app's + // BottomPanel (which the dashboard plugin has no Redux store to + // render) - exclude it here rather than let its unregistered loader + // key reach LayerLoader. Memoized so it's only a new reference when + // mapViews itself changes, keeping the effect below from re-running + // (and resetting map state) on every render. + const renderableMapViews = useMemo( + () => mapViews.filter((v) => v.layer !== COMBINED_TABLE_REF_LAYER), + [mapViews] + ) + const layers = useRef( - mapViews.map((config) => ({ ...config, isLoaded: false })) + renderableMapViews.map((config) => ({ ...config, isLoaded: false })) ) useEffect(() => { - if (didViewsChange(layers.current, mapViews)) { - layers.current = mapViews.map((v) => ({ ...v, isLoaded: false })) + if (didViewsChange(layers.current, renderableMapViews)) { + layers.current = renderableMapViews.map((v) => ({ + ...v, + isLoaded: false, + })) setVisibilityOverrides({}) setMapIsLoaded(false) } - }, [mapViews]) - - const [mapIsLoaded, setMapIsLoaded] = useState(mapViews.length === 0) + }, [renderableMapViews]) + + // Matches renderableMapViews, not the raw mapViews prop - a map + // containing only a hidden reference layer and no real layers has + // nothing for any <LayerLoader> to load, so nothing would ever call + // onLayerLoad to flip this true otherwise. + const [mapIsLoaded, setMapIsLoaded] = useState( + renderableMapViews.length === 0 + ) const [contextMenu, setContextMenu] = useState() const [visibilityOverrides, setVisibilityOverrides] = useState({}) const [resizeCount, setResizeCount] = useState(0) diff --git a/src/constants/layers.js b/src/constants/layers.js index f88c1e1123..4b5a2ed463 100644 --- a/src/constants/layers.js +++ b/src/constants/layers.js @@ -17,6 +17,11 @@ export const TRACKED_ENTITY_LAYER = 'trackedEntity' export const GEOJSON_LAYER = 'geoJson' export const GROUP_LAYER = 'group' export const GEOJSON_URL_LAYER = 'geoJsonUrl' +// A hidden, non-rendered org-unit layer backing the Combined data table's +// join - deliberately its own type (not ORG_UNIT_LAYER) so it's excluded +// from DOWNLOADABLE_LAYER_TYPES/DATA_TABLE_LAYER_TYPES and the "Add layer" +// popover just by omission, with no separate flag to check everywhere. +export const COMBINED_TABLE_REF_LAYER = 'combinedTableRef' export const MAP_SERVICE_KEY_TESTS = { keyBingMapsApiKey: [ diff --git a/src/hooks/useLayersLoader.js b/src/hooks/useLayersLoader.js index ee16701a85..ca051cf14c 100644 --- a/src/hooks/useLayersLoader.js +++ b/src/hooks/useLayersLoader.js @@ -22,6 +22,7 @@ const loaders = { external: externalLoader, facility: facilityLoader, orgUnit: orgUnitLoader, + combinedTableRef: orgUnitLoader, thematic: thematicLoader, geoJsonUrl: geoJsonUrlLoader, trackedEntity: trackedEntityLoader, From b2ad60aed1734992dfe372ba8af1244fd92fc557 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 28 Jul 2026 16:41:48 +0200 Subject: [PATCH 160/205] feat: add hideStyleTab to OrgUnitDialog and a control to open/edit the reference layer [DHIS2-20543] OrgUnitDialog can now hide its Style tab entirely (no style is needed for a non-rendered reference layer) via a new hideStyleTab prop. LayerEdit.jsx computes it, and gives the reference layer type a single state-agnostic modal title ("Configure reference org units") instead of the generic Edit/Add wording every other layer type gets. New ReferenceOrgUnitControl (not yet wired into BottomPanel's toolbar - that lands with the rest of the Combined join UI rework) opens the existing reference layer for editing, or a fresh draft if none exists yet, via the same editLayer/LayerEdit.jsx flow every other layer uses. --- .../ReferenceOrgUnitControl.spec.jsx | 58 ++++++++++++ .../controls/ReferenceOrgUnitControl.jsx | 44 +++++++++ src/components/edit/LayerEdit.jsx | 16 +++- .../edit/__tests__/LayerEdit.spec.jsx | 89 +++++++++++++++++++ src/components/edit/orgUnit/OrgUnitDialog.jsx | 6 +- .../orgUnit/__tests__/OrgUnitDialog.spec.jsx | 57 ++++++++++++ 6 files changed, 266 insertions(+), 4 deletions(-) create mode 100644 src/components/datatable/__tests__/ReferenceOrgUnitControl.spec.jsx create mode 100644 src/components/datatable/controls/ReferenceOrgUnitControl.jsx create mode 100644 src/components/edit/__tests__/LayerEdit.spec.jsx create mode 100644 src/components/edit/orgUnit/__tests__/OrgUnitDialog.spec.jsx diff --git a/src/components/datatable/__tests__/ReferenceOrgUnitControl.spec.jsx b/src/components/datatable/__tests__/ReferenceOrgUnitControl.spec.jsx new file mode 100644 index 0000000000..9fc4047173 --- /dev/null +++ b/src/components/datatable/__tests__/ReferenceOrgUnitControl.spec.jsx @@ -0,0 +1,58 @@ +import { render, fireEvent, screen } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import configureMockStore from 'redux-mock-store' +import ReferenceOrgUnitControl from '../controls/ReferenceOrgUnitControl.jsx' + +const mockStore = configureMockStore() + +const renderControl = (mapViews) => { + const store = mockStore({ map: { mapViews } }) + render( + <Provider store={store}> + <ReferenceOrgUnitControl /> + </Provider> + ) + return { store } +} + +const click = () => + fireEvent.click(screen.getByTestId('data-table-reference-org-unit-button')) + +describe('ReferenceOrgUnitControl', () => { + test('opens a draft (no id) reference layer for editing when none exists yet', () => { + const { store } = renderControl([ + { id: 'layer1', name: 'Layer 1', layer: 'thematic' }, + ]) + click() + + expect(store.getActions()).toEqual([ + { + type: 'LAYER_EDIT', + payload: { + layer: 'combinedTableRef', + isVisible: false, + rows: [], + }, + }, + ]) + }) + + test('opens the existing reference layer for editing when one already exists', () => { + const existingReference = { + id: 'ref1', + layer: 'combinedTableRef', + isVisible: false, + rows: [{ dimension: 'ou', items: [{ id: 'country1' }] }], + } + const { store } = renderControl([ + { id: 'layer1', name: 'Layer 1', layer: 'thematic' }, + existingReference, + ]) + click() + + expect(store.getActions()).toEqual([ + { type: 'LAYER_EDIT', payload: existingReference }, + ]) + }) +}) diff --git a/src/components/datatable/controls/ReferenceOrgUnitControl.jsx b/src/components/datatable/controls/ReferenceOrgUnitControl.jsx new file mode 100644 index 0000000000..133ed6841f --- /dev/null +++ b/src/components/datatable/controls/ReferenceOrgUnitControl.jsx @@ -0,0 +1,44 @@ +import i18n from '@dhis2/d2-i18n' +import { IconLocation16 } from '@dhis2/ui' +import React from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { editLayer } from '../../../actions/layers.js' +import { COMBINED_TABLE_REF_LAYER } from '../../../constants/layers.js' +import ToolbarIconButton from './ToolbarIconButton.jsx' + +// Opens the Combined data table's reference org unit layer for editing via +// the same editLayer/LayerEdit.jsx flow every other layer uses - creating +// it first (as a draft, no id yet) if it doesn't already exist in +// mapViews. See CLAUDE.md/map-layer-architecture: LayerEdit.jsx routes to +// addLayer or updateLayer on save based on whether the object passed here +// has an id, so this component itself never dispatches either directly. +const ReferenceOrgUnitControl = () => { + const dispatch = useDispatch() + const referenceLayer = useSelector((state) => + state.map.mapViews.find((l) => l.layer === COMBINED_TABLE_REF_LAYER) + ) + + const onClick = () => + dispatch( + editLayer( + referenceLayer ?? { + layer: COMBINED_TABLE_REF_LAYER, + isVisible: false, + rows: [], + } + ) + ) + + return ( + <ToolbarIconButton + tooltip={i18n.t('Configure reference org units')} + ariaLabel={i18n.t('Configure reference org units')} + dataTest="data-table-reference-org-unit-button" + onClick={onClick} + > + <IconLocation16 /> + </ToolbarIconButton> + ) +} + +export default ReferenceOrgUnitControl diff --git a/src/components/edit/LayerEdit.jsx b/src/components/edit/LayerEdit.jsx index 7f7ae56419..33e2baa542 100644 --- a/src/components/edit/LayerEdit.jsx +++ b/src/components/edit/LayerEdit.jsx @@ -11,7 +11,10 @@ import PropTypes from 'prop-types' import React, { useState } from 'react' import { connect } from 'react-redux' import { addLayer, updateLayer, cancelLayer } from '../../actions/layers.js' -import { EARTH_ENGINE_LAYER } from '../../constants/layers.js' +import { + COMBINED_TABLE_REF_LAYER, + EARTH_ENGINE_LAYER, +} from '../../constants/layers.js' import useKeyDown from '../../hooks/useKeyDown.js' import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' import { useOrgUnits } from '../OrgUnitsProvider.jsx' @@ -95,7 +98,15 @@ const LayerEdit = ({ layer, addLayer, updateLayer, cancelLayer }) => { name = layer.name.toLowerCase() } - const title = layer.id + const isReferenceLayer = type === COMBINED_TABLE_REF_LAYER + + // The reference org unit layer isn't really "a layer" from the user's + // perspective (it's never visible/rendered) - a single, state-agnostic + // title reads better than the generic Edit/Add wording every other + // layer type gets. + const title = isReferenceLayer + ? i18n.t('Configure reference org units') + : layer.id ? i18n.t('Edit {{name}} layer', { name }) : i18n.t('Add new {{name}} layer', { name }) @@ -112,6 +123,7 @@ const LayerEdit = ({ layer, addLayer, updateLayer, cancelLayer }) => { orgUnits={orgUnits} validateLayer={isValidLayer} onLayerValidation={onLayerValidation} + hideStyleTab={isReferenceLayer} /> </div> </ModalContent> diff --git a/src/components/edit/__tests__/LayerEdit.spec.jsx b/src/components/edit/__tests__/LayerEdit.spec.jsx new file mode 100644 index 0000000000..47313152f2 --- /dev/null +++ b/src/components/edit/__tests__/LayerEdit.spec.jsx @@ -0,0 +1,89 @@ +import { render, screen } from '@testing-library/react' +import PropTypes from 'prop-types' +import React from 'react' +import { Provider } from 'react-redux' +import configureMockStore from 'redux-mock-store' +import LayerEdit from '../LayerEdit.jsx' + +jest.mock('../../cachedDataProvider/CachedDataProvider.jsx', () => ({ + useCachedData: () => ({ systemSettings: {}, periodsSettings: {} }), +})) + +jest.mock('../../OrgUnitsProvider.jsx', () => ({ + useOrgUnits: () => ({}), +})) + +// A function declaration (not a const arrow function) - jest.mock() factory +// calls are hoisted above regular variable declarations, so a const here +// would throw a "Cannot access before initialization" error; a hoisted +// function declaration is safe to call from those factories. +function mockDialog(testId) { + const Mock = ({ hideStyleTab }) => ( + <div data-test={testId}>{String(!!hideStyleTab)}</div> + ) + Mock.propTypes = { + hideStyleTab: PropTypes.bool, + } + Mock.displayName = testId + return Mock +} + +jest.mock('../orgUnit/OrgUnitDialog.jsx', () => + mockDialog('orgunitdialog-mock') +) +jest.mock('../event/EventDialog.jsx', () => mockDialog('eventdialog-mock')) +jest.mock('../trackedEntity/TrackedEntityDialog.jsx', () => + mockDialog('trackedentitydialog-mock') +) +jest.mock('../FacilityDialog.jsx', () => mockDialog('facilitydialog-mock')) +jest.mock('../thematic/ThematicDialog.jsx', () => + mockDialog('thematicdialog-mock') +) +jest.mock('../earthEngine/EarthEngineDialog.jsx', () => + mockDialog('earthenginedialog-mock') +) +jest.mock('../geoJson/GeoJsonDialog.jsx', () => + mockDialog('geojsondialog-mock') +) + +const mockStore = configureMockStore() + +const renderLayerEdit = (layer) => { + const store = mockStore({ layerEdit: layer }) + return render( + <Provider store={store}> + <LayerEdit /> + </Provider> + ) +} + +describe('LayerEdit — reference org unit layer', () => { + test('shows a state-agnostic "Configure reference org units" title, with no id (new)', () => { + renderLayerEdit({ layer: 'combinedTableRef', rows: [] }) + expect( + screen.getByText('Configure reference org units') + ).toBeInTheDocument() + }) + + test('shows the same title once it has an id (already saved/editing)', () => { + renderLayerEdit({ id: 'ref1', layer: 'combinedTableRef', rows: [] }) + expect( + screen.getByText('Configure reference org units') + ).toBeInTheDocument() + }) + + test('passes hideStyleTab=true to OrgUnitDialog for the reference layer type', () => { + renderLayerEdit({ layer: 'combinedTableRef', rows: [] }) + expect(screen.getByTestId('orgunitdialog-mock')).toHaveTextContent( + 'true' + ) + }) + + test('passes hideStyleTab=false to OrgUnitDialog for a real org unit layer', () => { + renderLayerEdit({ layer: 'orgUnit', rows: [] }) + expect(screen.getByTestId('orgunitdialog-mock')).toHaveTextContent( + 'false' + ) + expect(screen.getByText('Add new org unit layer')).toBeInTheDocument() + }) +}) diff --git a/src/components/edit/orgUnit/OrgUnitDialog.jsx b/src/components/edit/orgUnit/OrgUnitDialog.jsx index 02c2326dcc..294062aca2 100644 --- a/src/components/edit/orgUnit/OrgUnitDialog.jsx +++ b/src/components/edit/orgUnit/OrgUnitDialog.jsx @@ -40,6 +40,7 @@ const OrgUnitDialog = ({ rows, validateLayer, onLayerValidation, + hideStyleTab, }) => { const dispatch = useDispatch() const countFeaturesWithoutCoordinates = useSelector( @@ -70,13 +71,13 @@ const OrgUnitDialog = ({ <div className={styles.content} data-test="orgunitdialog"> <Tabs value={tab} onChange={setTab}> <Tab value={ORGUNITS_TAB}>{i18n.t('Organisation Units')}</Tab> - <Tab value="style">{i18n.t('Style')}</Tab> + {!hideStyleTab && <Tab value="style">{i18n.t('Style')}</Tab>} </Tabs> <div className={styles.tabContent}> {tab === ORGUNITS_TAB && ( <OrgUnitSelect warning={orgUnitsError} /> )} - {tab === 'style' && ( + {!hideStyleTab && tab === 'style' && ( <div className={styles.flexColumnFlow} data-test="orgunitdialog-styletab" @@ -143,6 +144,7 @@ const OrgUnitDialog = ({ OrgUnitDialog.propTypes = { validateLayer: PropTypes.bool.isRequired, onLayerValidation: PropTypes.func.isRequired, + hideStyleTab: PropTypes.bool, organisationUnitColor: PropTypes.string, organisationUnitGroupSet: PropTypes.object, radiusLow: PropTypes.number, diff --git a/src/components/edit/orgUnit/__tests__/OrgUnitDialog.spec.jsx b/src/components/edit/orgUnit/__tests__/OrgUnitDialog.spec.jsx new file mode 100644 index 0000000000..9355229459 --- /dev/null +++ b/src/components/edit/orgUnit/__tests__/OrgUnitDialog.spec.jsx @@ -0,0 +1,57 @@ +import { render, screen } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import configureMockStore from 'redux-mock-store' +import OrgUnitDialog from '../OrgUnitDialog.jsx' + +jest.mock('../../../orgunits/OrgUnitSelect.jsx', () => { + const OrgUnitSelectMock = () => <div data-test="orgunitselect-mock" /> + OrgUnitSelectMock.displayName = 'OrgUnitSelectMock' + return OrgUnitSelectMock +}) + +jest.mock('../../../groupSet/StyleByGroupSet.jsx', () => { + const StyleByGroupSetMock = () => <div data-test="stylebygroupset-mock" /> + StyleByGroupSetMock.displayName = 'StyleByGroupSetMock' + return StyleByGroupSetMock +}) + +jest.mock('../../shared/Labels.jsx', () => { + const LabelsMock = () => <div data-test="labels-mock" /> + LabelsMock.displayName = 'LabelsMock' + return LabelsMock +}) + +const mockStore = configureMockStore() + +const renderDialog = (props) => { + const store = mockStore({ layerEdit: {} }) + return render( + <Provider store={store}> + <OrgUnitDialog + validateLayer={false} + onLayerValidation={jest.fn()} + rows={[]} + {...props} + /> + </Provider> + ) +} + +describe('OrgUnitDialog — hideStyleTab', () => { + test('shows the Style tab by default (a real org unit layer)', () => { + renderDialog() + expect(screen.getByText('Style')).toBeInTheDocument() + }) + + test('omits the Style tab entirely when hideStyleTab is set (the Combined reference layer)', () => { + renderDialog({ hideStyleTab: true }) + expect(screen.queryByText('Style')).not.toBeInTheDocument() + }) + + test('still shows the Organisation Units tab and its content when hideStyleTab is set', () => { + renderDialog({ hideStyleTab: true }) + expect(screen.getByText('Organisation Units')).toBeInTheDocument() + expect(screen.getByTestId('orgunitselect-mock')).toBeInTheDocument() + }) +}) From 217c28376ffe875506f65db90cd197edc1173dbe Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 28 Jul 2026 18:21:07 +0200 Subject: [PATCH 161/205] feat: rework Combined joins around a reference org unit set [DHIS2-20543] Replaces the single global join mode (org unit / parent org unit / fixed point+polygon spatial) with a per-layer choice: each participating layer picks its own join type (org unit hierarchy, or spatial via point-in-polygon against the reference org unit's own boundary, using a centroid for non-point geometry) and aggregation type per numeric column. Rows are now always one per reference org unit, sourced directly from the combinedTableRef layer's own resolved features. Also fixes a bootstrapping bug caught during manual testing: selecting "Combined" before a reference has been configured now opens its editor directly instead of leaving the option in an unreachable disabled state, and Spatial join eligibility is decided purely by geometry rather than layer type, so layers with no org-unit identity of their own (e.g. GeoJSON URL) aren't left with no working join mechanism. --- i18n/en.pot | 52 +- src/components/datatable/BottomPanel.jsx | 139 +--- .../datatable/CombinedDataTable.jsx | 10 +- .../datatable/CombinedTableContextMenu.jsx | 128 ++-- .../datatable/__tests__/BottomPanel.spec.jsx | 216 +++--- .../__tests__/CombinedDataTable.spec.jsx | 358 +++++---- .../CombinedTableContextMenu.spec.jsx | 89 ++- .../__tests__/JoinLayersControl.spec.jsx | 171 ++++- .../__tests__/LayerSelectorControl.spec.jsx | 10 +- .../__tests__/useCombinedTableData.spec.js | 695 +++++++++--------- .../datatable/controls/JoinLayersControl.jsx | 152 +++- .../controls/LayerSelectorControl.jsx | 15 +- .../controls/ReferenceOrgUnitControl.jsx | 27 +- .../styles/JoinLayersControl.module.css | 32 +- .../datatable/useCombinedTableData.js | 461 +++++------- src/reducers/__tests__/dataTable.spec.js | 122 +-- src/reducers/dataTable.js | 42 +- src/util/__tests__/spatialJoin.spec.js | 118 +-- src/util/spatialJoin.js | 64 +- 19 files changed, 1490 insertions(+), 1411 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index b7d5b6faeb..3661a9ecef 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-28T13:36:50.817Z\n" -"PO-Revision-Date: 2026-07-28T13:36:50.817Z\n" +"POT-Creation-Date: 2026-07-28T14:57:10.660Z\n" +"PO-Revision-Date: 2026-07-28T14:57:10.661Z\n" msgid "2020" msgstr "2020" @@ -155,24 +155,6 @@ msgstr "Operator" msgid "Date" msgstr "Date" -msgid "Join by org unit" -msgstr "Join by org unit" - -msgid "Join by parent org unit" -msgstr "Join by parent org unit" - -msgid "Spatial - point inside polygon" -msgstr "Spatial - point inside polygon" - -msgid "Point layer" -msgstr "Point layer" - -msgid "inside" -msgstr "inside" - -msgid "Polygon layer" -msgstr "Polygon layer" - msgid "No matching rows" msgstr "No matching rows" @@ -358,12 +340,27 @@ msgstr "Highlight color" msgid "Choose layers to combine" msgstr "Choose layers to combine" +msgid "Join type for {{layer}}" +msgstr "Join type for {{layer}}" + +msgid "Org unit" +msgstr "Org unit" + +msgid "Spatial" +msgstr "Spatial" + +msgid "Aggregation type for {{layer}}" +msgstr "Aggregation type for {{layer}}" + msgid "Choose a data table to view" msgstr "Choose a data table to view" msgid "Combined" msgstr "Combined" +msgid "Configure reference org units" +msgstr "Configure reference org units" + msgid "{{filtered}} of {{total}} rows" msgstr "{{filtered}} of {{total}} rows" @@ -376,18 +373,15 @@ msgstr "Show only features in current map view" msgid "ID" msgstr "ID" +msgid "Level" +msgstr "Level" + msgid "Value ({{layer}})" msgstr "Value ({{layer}})" msgid "Legend ({{layer}})" msgstr "Legend ({{layer}})" -msgid "No parent" -msgstr "No parent" - -msgid "Level" -msgstr "Level" - msgid "No valid data was found for the current layer configuration." msgstr "No valid data was found for the current layer configuration." @@ -488,6 +482,9 @@ msgstr "thematic" msgid "org unit" msgstr "org unit" +msgid "reference org units" +msgstr "reference org units" + msgid "Earth Engine" msgstr "Earth Engine" @@ -2116,9 +2113,6 @@ msgstr "Id" msgid "Org unit Id" msgstr "Org unit Id" -msgid "Org unit" -msgstr "Org unit" - msgid "Org unit level" msgstr "Org unit level" diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index 0c39098e6d..28fbb643f7 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -1,4 +1,3 @@ -import i18n from '@dhis2/d2-i18n' import React, { useRef, useCallback, @@ -22,16 +21,12 @@ import { } from '../../actions/dataTable.js' import { COMBINED_HEADERS_KEY } from '../../constants/dataTable.js' import useKeyDown from '../../hooks/useKeyDown.js' +import { getOrgUnitsFromRows } from '../../util/analytics.js' import { getEligibleDataTableLayers, getPanelHeights, hasActiveDataTableFilters, } from '../../util/dataTable.js' -import { - GEO_TYPE_POINT, - GEO_TYPE_POLYGON, - GEO_TYPE_MULTIPOLYGON, -} from '../../util/geojson.js' import { getCssVar } from '../../util/helpers.js' import { useWindowDimensions } from '../WindowDimensionsProvider.jsx' import CombinedDataTable from './CombinedDataTable.jsx' @@ -43,6 +38,9 @@ import GlobalSearchControl from './controls/GlobalSearchControl.jsx' import HighlightColorControl from './controls/HighlightColorControl.jsx' import JoinLayersControl from './controls/JoinLayersControl.jsx' import LayerSelectorControl from './controls/LayerSelectorControl.jsx' +import ReferenceOrgUnitControl, { + useReferenceLayer, +} from './controls/ReferenceOrgUnitControl.jsx' import ResizeHandleControl from './controls/ResizeHandleControl.jsx' import RowCountControl from './controls/RowCountControl.jsx' import ShowInViewControl from './controls/ShowInViewControl.jsx' @@ -52,14 +50,7 @@ import styles from './styles/BottomPanel.module.css' const MIN_HEIGHT = 50 const EMPTY_FILTERS = {} - -const isPointLayer = (layer) => - layer.data?.[0]?.geometry?.type === GEO_TYPE_POINT - -const isPolygonLayer = (layer) => - [GEO_TYPE_POLYGON, GEO_TYPE_MULTIPOLYGON].includes( - layer.data?.[0]?.geometry?.type - ) +const EMPTY_JOIN_LAYERS = {} const BottomPanel = () => { const dataTableHeight = useSelector((state) => state.ui.dataTableHeight) @@ -79,22 +70,14 @@ const BottomPanel = () => { : openIds[openIds.length - 1] ?? null const eligibleLayers = getEligibleDataTableLayers(mapViews) - const combinedEnabled = eligibleLayers.length >= 2 - - const pointLayers = eligibleLayers.filter(isPointLayer) - const polygonLayers = eligibleLayers.filter(isPolygonLayer) - const hasSpatialCandidates = - pointLayers.length > 0 && polygonLayers.length > 0 + const { referenceLayer, openReferenceLayerEditor } = useReferenceLayer() + const combinedEnabled = + !!referenceLayer && getOrgUnitsFromRows(referenceLayer.rows).length > 0 - const { level, layerIds, pointLayerId, polygonLayerId } = joinConfig + const joinLayersConfig = joinConfig.layers ?? EMPTY_JOIN_LAYERS const combinedLayers = useMemo( - () => - level === 'spatial' - ? [pointLayerId, polygonLayerId] - .map((id) => mapViews.find((l) => l.id === id)) - .filter(Boolean) - : mapViews.filter((l) => layerIds.includes(l.id)), - [level, layerIds, pointLayerId, polygonLayerId, mapViews] + () => mapViews.filter((l) => joinLayersConfig[l.id]), + [mapViews, joinLayersConfig] ) const activeLayer = mapViews.find((l) => l.id === activeLayerId) @@ -285,7 +268,6 @@ const BottomPanel = () => { layers={eligibleLayers} activeLayerId={activeLayerId} combinedView={combinedView} - combinedEnabled={combinedEnabled} onSelectLayer={(id) => { setManualActiveLayerId(id) if (combinedView) { @@ -299,95 +281,27 @@ const BottomPanel = () => { if (!combinedView) { dispatch(toggleCombinedView()) } + if (!combinedEnabled) { + // No reference configured yet (or it has no org + // units selected) - there'd be nothing to show, + // so open its editor right away instead of + // landing on an empty table with no obvious way + // to fix it. + openReferenceLayerEditor() + } }} /> <span className={styles.divider} /> {combinedView ? ( <> - <select - className={styles.joinSelect} - value={joinConfig.level} - onChange={(e) => - dispatch( - setJoinConfig({ - ...joinConfig, - level: e.target.value, - }) - ) + <ReferenceOrgUnitControl /> + <JoinLayersControl + eligibleLayers={eligibleLayers} + layersConfig={joinLayersConfig} + onChange={(layers) => + dispatch(setJoinConfig({ layers })) } - > - <option value="orgUnit"> - {i18n.t('Join by org unit')} - </option> - <option value="parentOrgUnit"> - {i18n.t('Join by parent org unit')} - </option> - {hasSpatialCandidates && ( - <option value="spatial"> - {i18n.t('Spatial - point inside polygon')} - </option> - )} - </select> - {joinConfig.level === 'spatial' ? ( - <> - <select - className={styles.joinSelect} - value={joinConfig.pointLayerId ?? ''} - onChange={(e) => - dispatch( - setJoinConfig({ - ...joinConfig, - pointLayerId: e.target.value, - }) - ) - } - > - <option value="" disabled> - {i18n.t('Point layer')} - </option> - {pointLayers.map((lyr) => ( - <option key={lyr.id} value={lyr.id}> - {lyr.name} - </option> - ))} - </select> - <span>{i18n.t('inside')}</span> - <select - className={styles.joinSelect} - value={joinConfig.polygonLayerId ?? ''} - onChange={(e) => - dispatch( - setJoinConfig({ - ...joinConfig, - polygonLayerId: e.target.value, - }) - ) - } - > - <option value="" disabled> - {i18n.t('Polygon layer')} - </option> - {polygonLayers.map((lyr) => ( - <option key={lyr.id} value={lyr.id}> - {lyr.name} - </option> - ))} - </select> - </> - ) : ( - <JoinLayersControl - eligibleLayers={eligibleLayers} - selectedIds={joinConfig.layerIds} - onChange={(layerIds) => - dispatch( - setJoinConfig({ - ...joinConfig, - layerIds, - }) - ) - } - /> - )} + /> <ColumnPickerControl allHeaders={allHeaders} columnConfig={combinedColumnConfig} @@ -452,6 +366,7 @@ const BottomPanel = () => { <CombinedDataTable availableWidth={panelWidth} layers={combinedLayers} + referenceLayer={referenceLayer} joinConfig={joinConfig} filters={combinedFilters} onFiltersChange={setCombinedFilters} diff --git a/src/components/datatable/CombinedDataTable.jsx b/src/components/datatable/CombinedDataTable.jsx index c7e61a5c71..b0755e5fa5 100644 --- a/src/components/datatable/CombinedDataTable.jsx +++ b/src/components/datatable/CombinedDataTable.jsx @@ -70,6 +70,7 @@ const CombinedTableComponents = { const CombinedDataTable = ({ availableWidth, layers, + referenceLayer, joinConfig, filters, onFiltersChange, @@ -88,6 +89,7 @@ const CombinedDataTable = ({ const { headers, rows, rowFeatureIds, columnOptions, spatialWarning } = useCombinedTableData({ layers, + referenceLayer, joinConfig, sortField, sortDirection, @@ -465,7 +467,7 @@ const CombinedDataTable = ({ <CombinedTableContextMenu contextMenu={tableContextMenu} layers={layers} - joinConfig={joinConfig} + referenceLayer={referenceLayer} rowFeatureIds={rowFeatureIds} selectedIds={selectedIds} filteredIds={hasActiveFilters ? allRowIds : null} @@ -477,12 +479,10 @@ const CombinedDataTable = ({ CombinedDataTable.propTypes = { joinConfig: PropTypes.shape({ - layerIds: PropTypes.arrayOf(PropTypes.string), - level: PropTypes.string, - pointLayerId: PropTypes.string, - polygonLayerId: PropTypes.string, + layers: PropTypes.object, }).isRequired, layers: PropTypes.array.isRequired, + referenceLayer: PropTypes.object.isRequired, availableWidth: PropTypes.number, columnConfig: PropTypes.shape({ orderedKeys: PropTypes.arrayOf(PropTypes.string), diff --git a/src/components/datatable/CombinedTableContextMenu.jsx b/src/components/datatable/CombinedTableContextMenu.jsx index 264e1c04ac..8bc91705f9 100644 --- a/src/components/datatable/CombinedTableContextMenu.jsx +++ b/src/components/datatable/CombinedTableContextMenu.jsx @@ -11,13 +11,6 @@ import React, { useRef } from 'react' import { useDispatch } from 'react-redux' import { highlightFeature } from '../../actions/feature.js' import { updateLayer } from '../../actions/layers.js' -import { - BOUNDARY_LAYER, - EVENT_LAYER, - FACILITY_LAYER, - GEOJSON_URL_LAYER, - TRACKED_ENTITY_LAYER, -} from '../../constants/layers.js' import { buildFeatureIndex, getUnionBounds, @@ -26,34 +19,10 @@ import { import { drillUpDown } from '../../util/map.js' import { IconZoomIn16 } from '../core/icons.jsx' -const NON_DRILLABLE_LAYER_TYPES = [ - BOUNDARY_LAYER, - FACILITY_LAYER, - EVENT_LAYER, - GEOJSON_URL_LAYER, - TRACKED_ENTITY_LAYER, -] - -// Drill up/down only makes sense for a row that names a single org unit -// (the 'orgUnit' join mode) - a parentOrgUnit row groups several org units -// with no single org unit to drill from, and a spatial row's point/polygon -// features aren't org units at all. -const getDrillTargets = (layers, entry) => - layers - .filter((layer) => !NON_DRILLABLE_LAYER_TYPES.includes(layer.layer)) - .map((layer) => { - const id = entry?.[layer.id]?.[0] - const featureProps = id - ? buildFeatureIndex(layer.data).get(id)?.properties - : null - return { layer, featureProps } - }) - .filter((target) => target.featureProps) - const CombinedTableContextMenu = ({ contextMenu, + referenceLayer, layers, - joinConfig, rowFeatureIds, selectedIds, filteredIds, @@ -68,24 +37,27 @@ const CombinedTableContextMenu = ({ const { x, y, rowId } = contextMenu const entry = rowFeatureIds.get(rowId) ?? {} + // getUnionBounds/zoom need the reference layer's own geometry alongside + // every participating layer's, since rowFeatureIds always names the + // reference org unit too (see useCombinedTableData.js) - it's the only + // guaranteed match for a row with no participating-layer data at all. + const allLayers = [referenceLayer, ...layers] - const canDrill = joinConfig.level === 'orgUnit' - const drillTargets = canDrill ? getDrillTargets(layers, entry) : [] - const hasCoordinatesUp = drillTargets.some( - ({ featureProps }) => featureProps.hasCoordinatesUp - ) - const hasCoordinatesDown = drillTargets.some( - ({ featureProps }) => featureProps.hasCoordinatesDown - ) + // Drill up/down always targets the reference layer's own level - a row + // IS a reference org unit, so this is exactly TableContextMenu.jsx's + // own single-layer drill, just scoped to the reference layer instead of + // whichever layer a normal single-layer table belongs to. + const referenceFeatureProps = buildFeatureIndex(referenceLayer.data).get( + rowId + )?.properties const zoomTo = (idsByLayerId) => { - const bounds = getUnionBounds(layers, idsByLayerId) dispatch( highlightFeature({ layerId: null, origin: 'table', zoom: true, - bounds, + bounds: getUnionBounds(allLayers, idsByLayerId), crossLayerIds: idsByLayerId, }) ) @@ -112,62 +84,48 @@ const CombinedTableContextMenu = ({ onClickOutside={onClose} > <Menu dense dataTest="combined-table-context-menu"> - {canDrill && ( + {referenceFeatureProps && ( <MenuItem dataTest="combined-table-context-menu-drill-up" label={i18n.t('Drill up one level')} icon={<IconArrowUp16 />} - disabled={!hasCoordinatesUp} + disabled={!referenceFeatureProps.hasCoordinatesUp} onClick={() => { - drillTargets - .filter( - ({ featureProps }) => - featureProps.hasCoordinatesUp - ) - .forEach(({ layer, featureProps }) => { - dispatch( - updateLayer( - drillUpDown( - layer, - featureProps.grandParentId, - featureProps.grandParentParentGraph, - Number.parseInt( - featureProps.level - ) - 1 - ) - ) + dispatch( + updateLayer( + drillUpDown( + referenceLayer, + referenceFeatureProps.grandParentId, + referenceFeatureProps.grandParentParentGraph, + Number.parseInt( + referenceFeatureProps.level + ) - 1 ) - }) + ) + ) onClose() }} /> )} - {canDrill && ( + {referenceFeatureProps && ( <MenuItem dataTest="combined-table-context-menu-drill-down" label={i18n.t('Drill down one level')} icon={<IconArrowDown16 />} - disabled={!hasCoordinatesDown} + disabled={!referenceFeatureProps.hasCoordinatesDown} onClick={() => { - drillTargets - .filter( - ({ featureProps }) => - featureProps.hasCoordinatesDown - ) - .forEach(({ layer, featureProps }) => { - dispatch( - updateLayer( - drillUpDown( - layer, - featureProps.id, - featureProps.parentGraph, - Number.parseInt( - featureProps.level - ) + 1 - ) - ) + dispatch( + updateLayer( + drillUpDown( + referenceLayer, + referenceFeatureProps.id, + referenceFeatureProps.parentGraph, + Number.parseInt( + referenceFeatureProps.level + ) + 1 ) - }) + ) + ) onClose() }} /> @@ -176,7 +134,7 @@ const CombinedTableContextMenu = ({ dataTest="combined-table-context-menu-zoom-to-feature" label={i18n.t('Zoom to feature')} icon={<IconZoomIn16 />} - disabled={!getUnionBounds(layers, entry)} + disabled={!getUnionBounds(allLayers, entry)} onClick={() => zoomTo(entry)} /> <MenuItem @@ -208,10 +166,8 @@ const CombinedTableContextMenu = ({ } CombinedTableContextMenu.propTypes = { - joinConfig: PropTypes.shape({ - level: PropTypes.string, - }).isRequired, layers: PropTypes.array.isRequired, + referenceLayer: PropTypes.object.isRequired, rowFeatureIds: PropTypes.instanceOf(Map).isRequired, onClose: PropTypes.func.isRequired, contextMenu: PropTypes.shape({ diff --git a/src/components/datatable/__tests__/BottomPanel.spec.jsx b/src/components/datatable/__tests__/BottomPanel.spec.jsx index f0a9bcdb14..68afa6de3b 100644 --- a/src/components/datatable/__tests__/BottomPanel.spec.jsx +++ b/src/components/datatable/__tests__/BottomPanel.spec.jsx @@ -34,10 +34,7 @@ const DEFAULT_DATA_TABLE_STATE = { openIds: ['layer1'], combinedView: false, joinConfig: { - level: 'orgUnit', - layerIds: [], - pointLayerId: null, - polygonLayerId: null, + layers: {}, }, } @@ -45,6 +42,14 @@ const DEFAULT_MAP_VIEWS = [ { id: 'layer1', name: 'Layer 1', layer: THEMATIC_LAYER, data: [{}] }, ] +const referenceLayer = ( + rows = [{ dimension: 'ou', items: [{ id: 'country1' }] }] +) => ({ + id: 'ref1', + layer: 'combinedTableRef', + rows, +}) + const renderBottomPanel = ({ dataTable = DEFAULT_DATA_TABLE_STATE, mapViews = DEFAULT_MAP_VIEWS, @@ -110,14 +115,7 @@ const twoEligibleLayers = [ const getLayerSelector = () => screen.getByTestId('data-table-layer-selector') describe('BottomPanel layer selector', () => { - test('lists only the one eligible layer, Combined disabled, when no other eligible layers exist', () => { - renderBottomPanel() - - expect(screen.getByText('Layer 1')).toBeInTheDocument() - expect(screen.getByText('Combined')).toBeDisabled() - }) - - test('lists every eligible layer, whether or not its table is open, plus an enabled Combined option once 2+ eligible layers exist', () => { + test('lists every eligible layer, whether or not its table is open, plus a Combined option', () => { renderBottomPanel({ dataTable: DEFAULT_DATA_TABLE_STATE, mapViews: twoEligibleLayers, @@ -128,7 +126,7 @@ describe('BottomPanel layer selector', () => { // already-open tabs. expect(screen.getByText('Layer 1')).toBeInTheDocument() expect(screen.getByText('Layer 2')).toBeInTheDocument() - expect(screen.getByText('Combined')).not.toBeDisabled() + expect(screen.getByText('Combined')).toBeInTheDocument() }) test('selecting a different, already-open layer switches the active layer shown in the table', () => { @@ -197,13 +195,13 @@ describe('BottomPanel layer selector', () => { consoleError.mockRestore() }) - test('selecting Combined from the dropdown dispatches DATA_TABLE_COMBINED_VIEW_TOGGLE', () => { + test('selecting Combined dispatches DATA_TABLE_COMBINED_VIEW_TOGGLE, and nothing else, once a reference with org units already exists', () => { const { store } = renderBottomPanel({ dataTable: { ...DEFAULT_DATA_TABLE_STATE, openIds: ['layer1', 'layer2'], }, - mapViews: twoEligibleLayers, + mapViews: [...twoEligibleLayers, referenceLayer()], }) fireEvent.change(getLayerSelector(), { @@ -214,170 +212,154 @@ describe('BottomPanel layer selector', () => { { type: 'DATA_TABLE_COMBINED_VIEW_TOGGLE' }, ]) }) -}) -describe('BottomPanel Combined join controls', () => { - test('shows the join-level selector and layer picker, and hides per-layer-only controls, while Combined is active', () => { - renderBottomPanel({ + test('selecting Combined also opens a draft reference layer editor when none exists yet', () => { + const { store } = renderBottomPanel({ dataTable: { ...DEFAULT_DATA_TABLE_STATE, openIds: ['layer1', 'layer2'], - combinedView: true, }, mapViews: twoEligibleLayers, }) - expect(screen.getByDisplayValue('Join by org unit')).toBeInTheDocument() - expect( - screen.getByLabelText('Choose layers to combine') - ).toBeInTheDocument() - expect( - screen.queryByLabelText('Highlight color') - ).not.toBeInTheDocument() + fireEvent.change(getLayerSelector(), { + target: { value: '__combined__' }, + }) + + expect(store.getActions()).toEqual([ + { type: 'DATA_TABLE_COMBINED_VIEW_TOGGLE' }, + { + type: 'LAYER_EDIT', + payload: { + layer: 'combinedTableRef', + isVisible: false, + rows: [], + }, + }, + ]) }) - test('still shows the column picker while Combined is active, session-only (not the per-layer one)', () => { - renderBottomPanel({ + test('selecting Combined opens the existing reference layer editor when it has no org units selected yet', () => { + const emptyReference = referenceLayer([]) + const { store } = renderBottomPanel({ dataTable: { ...DEFAULT_DATA_TABLE_STATE, openIds: ['layer1', 'layer2'], - combinedView: true, }, - mapViews: twoEligibleLayers, + mapViews: [...twoEligibleLayers, emptyReference], }) - expect(screen.getByLabelText('Configure columns')).toBeInTheDocument() + fireEvent.change(getLayerSelector(), { + target: { value: '__combined__' }, + }) + + expect(store.getActions()).toEqual([ + { type: 'DATA_TABLE_COMBINED_VIEW_TOGGLE' }, + { type: 'LAYER_EDIT', payload: emptyReference }, + ]) }) +}) - test('offers and renders the spatial join point/polygon selects when point+polygon candidates exist', () => { - const pointAndPolygonLayers = [ - { - id: 'points', - name: 'Points', - layer: THEMATIC_LAYER, - data: [{ geometry: { type: 'Point' } }], - }, - { - id: 'polygons', - name: 'Polygons', - layer: THEMATIC_LAYER, - data: [{ geometry: { type: 'Polygon' } }], - }, - ] +describe('BottomPanel Combined join controls', () => { + const combinedMapViews = [...twoEligibleLayers, referenceLayer()] + test('shows the reference org unit control and join layers control, and hides per-layer-only controls, while Combined is active', () => { renderBottomPanel({ dataTable: { ...DEFAULT_DATA_TABLE_STATE, - openIds: ['points', 'polygons'], + openIds: ['layer1', 'layer2'], combinedView: true, - joinConfig: { - level: 'spatial', - layerIds: [], - pointLayerId: null, - polygonLayerId: null, - }, }, - mapViews: pointAndPolygonLayers, + mapViews: combinedMapViews, }) expect( - screen.getByText('Spatial - point inside polygon') + screen.getByLabelText('Configure reference org units') + ).toBeInTheDocument() + expect( + screen.getByLabelText('Choose layers to combine') ).toBeInTheDocument() - expect(screen.getByText('Point layer')).toBeInTheDocument() - expect(screen.getByText('Polygon layer')).toBeInTheDocument() + expect( + screen.queryByLabelText('Highlight color') + ).not.toBeInTheDocument() }) - test('choosing a point layer dispatches DATA_TABLE_JOIN_CONFIG_SET with pointLayerId set', () => { - const pointAndPolygonLayers = [ - { - id: 'points', - name: 'Points', - layer: THEMATIC_LAYER, - data: [{ geometry: { type: 'Point' } }], - }, - { - id: 'polygons', - name: 'Polygons', - layer: THEMATIC_LAYER, - data: [{ geometry: { type: 'Polygon' } }], + test('still shows the column picker while Combined is active, session-only (not the per-layer one)', () => { + renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1', 'layer2'], + combinedView: true, }, - ] + mapViews: combinedMapViews, + }) + + expect(screen.getByLabelText('Configure columns')).toBeInTheDocument() + }) + test('toggling a layer on in the join-layers popover dispatches DATA_TABLE_JOIN_CONFIG_SET with that layer added', () => { const { store } = renderBottomPanel({ dataTable: { ...DEFAULT_DATA_TABLE_STATE, - openIds: ['points', 'polygons'], + openIds: ['layer1', 'layer2'], combinedView: true, - joinConfig: { - level: 'spatial', - layerIds: [], - pointLayerId: null, - polygonLayerId: null, - }, }, - mapViews: pointAndPolygonLayers, + mapViews: combinedMapViews, }) - fireEvent.change(screen.getByDisplayValue('Point layer'), { - target: { value: 'points' }, - }) + fireEvent.click(screen.getByLabelText('Choose layers to combine')) + fireEvent.click(screen.getByRole('checkbox', { name: 'Layer 1' })) expect(store.getActions()).toEqual([ { type: 'DATA_TABLE_JOIN_CONFIG_SET', config: { - level: 'spatial', - layerIds: [], - pointLayerId: 'points', - polygonLayerId: null, + layers: { + layer1: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, }, }, ]) }) - test('does not offer the spatial join option when there is no point/polygon pair', () => { - renderBottomPanel({ - dataTable: { - ...DEFAULT_DATA_TABLE_STATE, - openIds: ['layer1', 'layer2'], - combinedView: true, - }, - mapViews: twoEligibleLayers, - }) - - expect( - screen.queryByText('Spatial - point inside polygon') - ).not.toBeInTheDocument() - // Regression guard: `pointLayers.length && polygonLayers.length` can - // evaluate to the number 0 rather than a real boolean, and React - // renders a stray "0" text node for that instead of nothing. - expect( - screen.getByDisplayValue('Join by org unit') - ).not.toHaveTextContent('0') - }) - - test('changing the join level dispatches DATA_TABLE_JOIN_CONFIG_SET', () => { + test('toggling an already-joined layer off dispatches DATA_TABLE_JOIN_CONFIG_SET with that layer removed', () => { const { store } = renderBottomPanel({ dataTable: { ...DEFAULT_DATA_TABLE_STATE, openIds: ['layer1', 'layer2'], combinedView: true, + joinConfig: { + layers: { + layer1: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + layer2: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, + }, }, - mapViews: twoEligibleLayers, + mapViews: combinedMapViews, }) - fireEvent.change(screen.getByDisplayValue('Join by org unit'), { - target: { value: 'parentOrgUnit' }, - }) + fireEvent.click(screen.getByLabelText('Choose layers to combine')) + fireEvent.click(screen.getByRole('checkbox', { name: 'Layer 1' })) expect(store.getActions()).toEqual([ { type: 'DATA_TABLE_JOIN_CONFIG_SET', config: { - level: 'parentOrgUnit', - layerIds: [], - pointLayerId: null, - polygonLayerId: null, + layers: { + layer2: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, }, }, ]) diff --git a/src/components/datatable/__tests__/CombinedDataTable.spec.jsx b/src/components/datatable/__tests__/CombinedDataTable.spec.jsx index fee00b126b..943e3be72f 100644 --- a/src/components/datatable/__tests__/CombinedDataTable.spec.jsx +++ b/src/components/datatable/__tests__/CombinedDataTable.spec.jsx @@ -4,14 +4,8 @@ import { Provider } from 'react-redux' import { VirtuosoMockContext } from 'react-virtuoso' import configureMockStore from 'redux-mock-store' import { COMBINED_HEADERS_KEY } from '../../../constants/dataTable.js' -import useOrgUnitAncestorNames from '../../../hooks/useOrgUnitAncestorNames.js' import CombinedDataTable from '../CombinedDataTable.jsx' -jest.mock('../../../hooks/useOrgUnitAncestorNames.js', () => ({ - __esModule: true, - default: jest.fn(), -})) - jest.mock('../../cachedDataProvider/CachedDataProvider.jsx', () => ({ useCachedData: () => ({ systemSettings: { keyAnalysisDigitGroupSeparator: 'COMMA' }, @@ -20,15 +14,17 @@ jest.mock('../../cachedDataProvider/CachedDataProvider.jsx', () => ({ const mockStore = configureMockStore() -beforeEach(() => { - useOrgUnitAncestorNames.mockReturnValue({ - idToName: new Map(), - loading: false, - }) -}) - const feature = (props) => ({ properties: props }) +const referenceFeature = (id, name, path) => + feature({ id, name, orgUnitPath: path, level: 2 }) + +const EMPTY_REFERENCE_LAYER = { + id: 'ref1', + layer: 'combinedTableRef', + data: [], +} + const renderCombinedDataTable = (props) => { const store = mockStore({}) const result = render( @@ -39,12 +35,8 @@ const renderCombinedDataTable = (props) => { <CombinedDataTable availableWidth={800} layers={[]} - joinConfig={{ - level: 'orgUnit', - layerIds: [], - pointLayerId: null, - polygonLayerId: null, - }} + referenceLayer={EMPTY_REFERENCE_LAYER} + joinConfig={{ layers: {} }} {...props} /> </VirtuosoMockContext.Provider> @@ -55,20 +47,17 @@ const renderCombinedDataTable = (props) => { describe('CombinedDataTable', () => { test('renders a column header per computed header and a cell per row', () => { - useOrgUnitAncestorNames.mockReturnValue({ - idToName: new Map([['ou1', 'Ou One']]), - loading: false, - }) - + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [referenceFeature('ou1', 'Ou One', '/country1/ou1')], + } const layers = [ { id: 'layerA', name: 'Layer A', data: [ feature({ - orgUnitId: 'ou1', orgUnitPath: '/country1/ou1', - level: 2, rawValue: 10, legend: 'Low', }), @@ -77,12 +66,15 @@ describe('CombinedDataTable', () => { ] renderCombinedDataTable({ + referenceLayer, layers, joinConfig: { - level: 'orgUnit', - layerIds: ['layerA'], - pointLayerId: null, - polygonLayerId: null, + layers: { + layerA: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, }, }) @@ -96,13 +88,16 @@ describe('CombinedDataTable', () => { }) test('formats numeric values with the system digit group separator, matching DataTable', () => { + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [referenceFeature('ou1', 'Ou One', '/country1/ou1')], + } const layers = [ { id: 'layerA', name: 'Layer A', data: [ feature({ - orgUnitId: 'ou1', orgUnitPath: '/country1/ou1', rawValue: 1234567, }), @@ -111,12 +106,15 @@ describe('CombinedDataTable', () => { ] renderCombinedDataTable({ + referenceLayer, layers, joinConfig: { - level: 'orgUnit', - layerIds: ['layerA'], - pointLayerId: null, - polygonLayerId: null, + layers: { + layerA: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, }, }) @@ -124,21 +122,28 @@ describe('CombinedDataTable', () => { }) test('renders an em-dash for blank cell values', () => { + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [referenceFeature('ou1', 'Ou One', '/country1/ou1')], + } const layers = [ { id: 'layerA', name: 'Layer A', - data: [feature({ orgUnitId: 'ou1' })], + data: [feature({ orgUnitPath: '/country1/ou1' })], }, ] renderCombinedDataTable({ + referenceLayer, layers, joinConfig: { - level: 'orgUnit', - layerIds: ['layerA'], - pointLayerId: null, - polygonLayerId: null, + layers: { + layerA: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, }, }) @@ -146,28 +151,23 @@ describe('CombinedDataTable', () => { }) test('shows the empty-results placeholder when there are no rows', () => { - renderCombinedDataTable({ layers: [] }) + renderCombinedDataTable() expect(screen.getByText('No matching rows')).toBeInTheDocument() }) test('shows the spatial warning banner when a spatial join exceeds the large-feature threshold', () => { - const pointLayer = { - id: 'points', - name: 'Points', - data: Array.from({ length: 10001 }, (_, i) => ({ - type: 'Feature', - properties: { id: `p${i}` }, - geometry: { type: 'Point', coordinates: [1, 1] }, - })), - } - const polygonLayer = { - id: 'polygons', - name: 'Polygons', + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, data: [ { type: 'Feature', - properties: { id: 'poly1', rawValue: 1 }, + properties: { + id: 'poly1', + name: 'Region', + orgUnitPath: '/country1/poly1', + level: 2, + }, geometry: { type: 'Polygon', coordinates: [ @@ -183,14 +183,26 @@ describe('CombinedDataTable', () => { }, ], } + const pointLayer = { + id: 'points', + name: 'Points', + data: Array.from({ length: 10001 }, (_, i) => ({ + type: 'Feature', + properties: { id: `p${i}` }, + geometry: { type: 'Point', coordinates: [1, 1] }, + })), + } renderCombinedDataTable({ - layers: [pointLayer, polygonLayer], + referenceLayer, + layers: [pointLayer], joinConfig: { - level: 'spatial', - layerIds: [], - pointLayerId: 'points', - polygonLayerId: 'polygons', + layers: { + points: { + type: 'spatial', + aggregation: { rawValue: 'SUM' }, + }, + }, }, }) @@ -201,25 +213,16 @@ describe('CombinedDataTable', () => { test('calls onCountChange with the row count', () => { const onCountChange = jest.fn() - const layers = [ - { - id: 'layerA', - name: 'Layer A', - data: [ - feature({ orgUnitId: 'ou1' }), - feature({ orgUnitId: 'ou2' }), - ], - }, - ] + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [ + referenceFeature('ou1', 'Ou One', '/country1/ou1'), + referenceFeature('ou2', 'Ou Two', '/country1/ou2'), + ], + } renderCombinedDataTable({ - layers, - joinConfig: { - level: 'orgUnit', - layerIds: ['layerA'], - pointLayerId: null, - polygonLayerId: null, - }, + referenceLayer, onCountChange, }) @@ -227,24 +230,34 @@ describe('CombinedDataTable', () => { }) test('sorts rows when a column sort button is clicked', () => { + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [ + referenceFeature('ou1', 'Ou One', '/country1/ou1'), + referenceFeature('ou2', 'Ou Two', '/country1/ou2'), + ], + } const layers = [ { id: 'layerA', name: 'Layer A', data: [ - feature({ orgUnitId: 'ou1', rawValue: 20 }), - feature({ orgUnitId: 'ou2', rawValue: 10 }), + feature({ orgUnitPath: '/country1/ou1', rawValue: 20 }), + feature({ orgUnitPath: '/country1/ou2', rawValue: 10 }), ], }, ] renderCombinedDataTable({ + referenceLayer, layers, joinConfig: { - level: 'orgUnit', - layerIds: ['layerA'], - pointLayerId: null, - polygonLayerId: null, + layers: { + layerA: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, }, }) @@ -263,25 +276,16 @@ describe('CombinedDataTable', () => { test('applies a per-column filter via onFiltersChange', () => { const onFiltersChange = jest.fn() - const layers = [ - { - id: 'layerA', - name: 'Layer A', - data: [ - feature({ orgUnitId: 'ou1', rawValue: 20 }), - feature({ orgUnitId: 'ou2', rawValue: 10 }), - ], - }, - ] + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [ + referenceFeature('ou1', 'Ou One', '/country1/ou1'), + referenceFeature('ou2', 'Ou Two', '/country1/ou2'), + ], + } renderCombinedDataTable({ - layers, - joinConfig: { - level: 'orgUnit', - layerIds: ['layerA'], - pointLayerId: null, - polygonLayerId: null, - }, + referenceLayer, filters: {}, onFiltersChange, }) @@ -296,28 +300,49 @@ describe('CombinedDataTable', () => { }) test('dispatches a cross-layer highlight on row hover, and clears it on mouse leave', () => { + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [referenceFeature('ou1', 'Ou One', '/country1/ou1')], + } const layers = [ { id: 'layerA', name: 'Layer A', data: [ - feature({ id: 'evtA1', orgUnitId: 'ou1', rawValue: 20 }), + feature({ + id: 'evtA1', + orgUnitPath: '/country1/ou1', + rawValue: 20, + }), ], }, { id: 'layerB', name: 'Layer B', - data: [feature({ id: 'evtB1', orgUnitId: 'ou1', rawValue: 5 })], + data: [ + feature({ + id: 'evtB1', + orgUnitPath: '/country1/ou1', + rawValue: 5, + }), + ], }, ] const { store } = renderCombinedDataTable({ + referenceLayer, layers, joinConfig: { - level: 'orgUnit', - layerIds: ['layerA', 'layerB'], - pointLayerId: null, - polygonLayerId: null, + layers: { + layerA: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + layerB: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, }, }) @@ -329,7 +354,11 @@ describe('CombinedDataTable', () => { payload: { layerId: null, origin: 'table', - crossLayerIds: { layerA: ['evtA1'], layerB: ['evtB1'] }, + crossLayerIds: { + ref1: ['ou1'], + layerA: ['evtA1'], + layerB: ['evtB1'], + }, }, }) @@ -342,24 +371,42 @@ describe('CombinedDataTable', () => { }) test('dispatches a merged cross-layer selection when rows are checked', () => { + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [ + referenceFeature('ou1', 'Ou One', '/country1/ou1'), + referenceFeature('ou2', 'Ou Two', '/country1/ou2'), + ], + } const layers = [ { id: 'layerA', name: 'Layer A', data: [ - feature({ id: 'evt1', orgUnitId: 'ou1', rawValue: 20 }), - feature({ id: 'evt2', orgUnitId: 'ou2', rawValue: 10 }), + feature({ + id: 'evt1', + orgUnitPath: '/country1/ou1', + rawValue: 20, + }), + feature({ + id: 'evt2', + orgUnitPath: '/country1/ou2', + rawValue: 10, + }), ], }, ] const { store } = renderCombinedDataTable({ + referenceLayer, layers, joinConfig: { - level: 'orgUnit', - layerIds: ['layerA'], - pointLayerId: null, - polygonLayerId: null, + layers: { + layerA: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, }, }) @@ -369,26 +416,33 @@ describe('CombinedDataTable', () => { expect(store.getActions()).toContainEqual({ type: 'SELECTION_SET_CROSS_LAYER', - crossLayerIds: { layerA: ['evt1'] }, + crossLayerIds: { ref1: ['ou1'], layerA: ['evt1'] }, }) }) test('does not clear selection on unmount when nothing was ever selected here', () => { + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [referenceFeature('ou1', 'Ou One', '/country1/ou1')], + } const layers = [ { id: 'layerA', name: 'Layer A', - data: [feature({ id: 'evt1', orgUnitId: 'ou1' })], + data: [feature({ id: 'evt1', orgUnitPath: '/country1/ou1' })], }, ] const { store, unmount } = renderCombinedDataTable({ + referenceLayer, layers, joinConfig: { - level: 'orgUnit', - layerIds: ['layerA'], - pointLayerId: null, - polygonLayerId: null, + layers: { + layerA: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, }, }) @@ -400,21 +454,28 @@ describe('CombinedDataTable', () => { }) test('clears the cross-layer selection on unmount after selecting a row', () => { + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [referenceFeature('ou1', 'Ou One', '/country1/ou1')], + } const layers = [ { id: 'layerA', name: 'Layer A', - data: [feature({ id: 'evt1', orgUnitId: 'ou1' })], + data: [feature({ id: 'evt1', orgUnitPath: '/country1/ou1' })], }, ] const { store, unmount } = renderCombinedDataTable({ + referenceLayer, layers, joinConfig: { - level: 'orgUnit', - layerIds: ['layerA'], - pointLayerId: null, - polygonLayerId: null, + layers: { + layerA: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, }, }) @@ -429,21 +490,28 @@ describe('CombinedDataTable', () => { test('reports computed headers up via onHeadersChange, keyed by the combined sentinel', () => { const onHeadersChange = jest.fn() + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [referenceFeature('ou1', 'Ou One', '/country1/ou1')], + } const layers = [ { id: 'layerA', name: 'Layer A', - data: [feature({ orgUnitId: 'ou1', rawValue: 1 })], + data: [feature({ orgUnitPath: '/country1/ou1', rawValue: 1 })], }, ] renderCombinedDataTable({ + referenceLayer, layers, joinConfig: { - level: 'orgUnit', - layerIds: ['layerA'], - pointLayerId: null, - polygonLayerId: null, + layers: { + layerA: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, }, onHeadersChange, }) @@ -457,21 +525,28 @@ describe('CombinedDataTable', () => { }) test('hides a column excluded from columnConfig.visibleKeys', () => { + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [referenceFeature('ou1', 'Ou One', '/country1/ou1')], + } const layers = [ { id: 'layerA', name: 'Layer A', - data: [feature({ orgUnitId: 'ou1', rawValue: 20 })], + data: [feature({ orgUnitPath: '/country1/ou1', rawValue: 20 })], }, ] renderCombinedDataTable({ + referenceLayer, layers, joinConfig: { - level: 'orgUnit', - layerIds: ['layerA'], - pointLayerId: null, - polygonLayerId: null, + layers: { + layerA: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, }, columnConfig: { visibleKeys: ['id', 'name'] }, }) @@ -482,21 +557,28 @@ describe('CombinedDataTable', () => { }) test('reorders columns to put pinned keys first via columnConfig.pinnedKeys', () => { + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [referenceFeature('ou1', 'Ou One', '/country1/ou1')], + } const layers = [ { id: 'layerA', name: 'Layer A', - data: [feature({ orgUnitId: 'ou1', rawValue: 20 })], + data: [feature({ orgUnitPath: '/country1/ou1', rawValue: 20 })], }, ] renderCombinedDataTable({ + referenceLayer, layers, joinConfig: { - level: 'orgUnit', - layerIds: ['layerA'], - pointLayerId: null, - polygonLayerId: null, + layers: { + layerA: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, }, columnConfig: { pinnedKeys: ['level'] }, }) diff --git a/src/components/datatable/__tests__/CombinedTableContextMenu.spec.jsx b/src/components/datatable/__tests__/CombinedTableContextMenu.spec.jsx index 400ad9e465..b755d5987d 100644 --- a/src/components/datatable/__tests__/CombinedTableContextMenu.spec.jsx +++ b/src/components/datatable/__tests__/CombinedTableContextMenu.spec.jsx @@ -6,7 +6,7 @@ import { FEATURE_HIGHLIGHT, LAYER_UPDATE, } from '../../../constants/actionTypes.js' -import { EVENT_LAYER, THEMATIC_LAYER } from '../../../constants/layers.js' +import { THEMATIC_LAYER } from '../../../constants/layers.js' import CombinedTableContextMenu from '../CombinedTableContextMenu.jsx' const mockStore = configureMockStore() @@ -17,31 +17,33 @@ const point = (id, coordinates, properties = {}) => ({ geometry: { type: 'Point', coordinates }, }) +const referenceLayer = { + id: 'ref1', + layer: 'combinedTableRef', + data: [ + point('ou1', [0, 0], { + level: '3', + hasCoordinatesUp: true, + hasCoordinatesDown: false, + grandParentId: 'gp1', + grandParentParentGraph: '/country1', + parentGraph: '/country1/region1', + }), + ], +} + const layers = [ { id: 'layerA', layer: THEMATIC_LAYER, - data: [ - point('ou1', [0, 0], { - level: '3', - hasCoordinatesUp: true, - hasCoordinatesDown: false, - grandParentId: 'gp1', - grandParentParentGraph: '/country1', - parentGraph: '/country1/region1', - }), - ], - }, - { - id: 'layerB', - layer: EVENT_LAYER, // not drillable - data: [point('evt1', [5, 5])], + data: [point('a1', [5, 5])], }, ] -const rowFeatureIds = new Map([['ou1', { layerA: ['ou1'], layerB: ['evt1'] }]]) +// rowFeatureIds always names the reference layer's own feature id alongside +// whichever participating layers matched - see useCombinedTableData.js. +const rowFeatureIds = new Map([['ou1', { ref1: ['ou1'], layerA: ['a1'] }]]) -const orgUnitJoinConfig = { level: 'orgUnit' } const contextMenu = { x: 10, y: 10, rowId: 'ou1' } const getLink = (testId) => screen.getByTestId(testId).querySelector('a') @@ -52,8 +54,8 @@ const renderMenu = (props) => { <Provider store={store}> <CombinedTableContextMenu contextMenu={contextMenu} + referenceLayer={referenceLayer} layers={layers} - joinConfig={orgUnitJoinConfig} rowFeatureIds={rowFeatureIds} onClose={jest.fn()} {...props} @@ -64,7 +66,7 @@ const renderMenu = (props) => { } describe('CombinedTableContextMenu — drill up/down', () => { - test('is offered in orgUnit join mode, enabled per the drillable layer(s) capability', () => { + test('is enabled/disabled per the reference layer own hasCoordinatesUp/hasCoordinatesDown', () => { renderMenu() expect( getLink('combined-table-context-menu-drill-up') @@ -74,14 +76,7 @@ describe('CombinedTableContextMenu — drill up/down', () => { ).toHaveAttribute('aria-disabled', 'true') }) - test('is not offered in parentOrgUnit join mode (no single org unit to drill from)', () => { - renderMenu({ joinConfig: { level: 'parentOrgUnit' } }) - expect( - screen.queryByTestId('combined-table-context-menu-drill-up') - ).not.toBeInTheDocument() - }) - - test('drilling up dispatches updateLayer for the drillable layer only, using its own feature props', () => { + test('drilling up dispatches updateLayer for the reference layer, using its own feature props', () => { const onClose = jest.fn() const { store } = renderMenu({ onClose }) fireEvent.click(getLink('combined-table-context-menu-drill-up')) @@ -90,17 +85,47 @@ describe('CombinedTableContextMenu — drill up/down', () => { .getActions() .filter((a) => a.type === LAYER_UPDATE) expect(layerUpdates).toHaveLength(1) - expect(layerUpdates[0].payload.id).toBe('layerA') + expect(layerUpdates[0].payload.id).toBe('ref1') expect(layerUpdates[0].payload.rows[0].items).toEqual([ { id: 'gp1', path: '/country1/gp1' }, { id: 'LEVEL-2' }, ]) expect(onClose).toHaveBeenCalled() }) + + test('drilling down dispatches updateLayer for the reference layer, using its own feature props', () => { + const onClose = jest.fn() + const { store } = renderMenu({ + onClose, + referenceLayer: { + ...referenceLayer, + data: [ + point('ou1', [0, 0], { + level: '3', + hasCoordinatesUp: false, + hasCoordinatesDown: true, + parentGraph: '/country1/region1', + }), + ], + }, + }) + fireEvent.click(getLink('combined-table-context-menu-drill-down')) + + const layerUpdates = store + .getActions() + .filter((a) => a.type === LAYER_UPDATE) + expect(layerUpdates).toHaveLength(1) + expect(layerUpdates[0].payload.id).toBe('ref1') + expect(layerUpdates[0].payload.rows[0].items).toEqual([ + { id: 'ou1', path: '/country1/region1/ou1' }, + { id: 'LEVEL-4' }, + ]) + expect(onClose).toHaveBeenCalled() + }) }) describe('CombinedTableContextMenu — zoom actions', () => { - test('zoom to feature dispatches a crossLayerIds highlight with the union bounds', () => { + test('zoom to feature dispatches a crossLayerIds highlight with the union bounds across the reference and participating layers', () => { const onClose = jest.fn() const { store } = renderMenu({ onClose }) fireEvent.click(getLink('combined-table-context-menu-zoom-to-feature')) @@ -114,7 +139,7 @@ describe('CombinedTableContextMenu — zoom actions', () => { [0, 0], [5, 5], ], - crossLayerIds: { layerA: ['ou1'], layerB: ['evt1'] }, + crossLayerIds: { ref1: ['ou1'], layerA: ['a1'] }, }, }) expect(onClose).toHaveBeenCalled() @@ -134,7 +159,7 @@ describe('CombinedTableContextMenu — zoom actions', () => { expect.objectContaining({ type: FEATURE_HIGHLIGHT, payload: expect.objectContaining({ - crossLayerIds: { layerA: ['ou1'], layerB: ['evt1'] }, + crossLayerIds: { ref1: ['ou1'], layerA: ['a1'] }, }), }) ) diff --git a/src/components/datatable/__tests__/JoinLayersControl.spec.jsx b/src/components/datatable/__tests__/JoinLayersControl.spec.jsx index 1e2eb827b0..0ac65a8dc7 100644 --- a/src/components/datatable/__tests__/JoinLayersControl.spec.jsx +++ b/src/components/datatable/__tests__/JoinLayersControl.spec.jsx @@ -1,17 +1,28 @@ -import { render, fireEvent, screen } from '@testing-library/react' +import { render, fireEvent, screen, within } from '@testing-library/react' import React from 'react' +import { GEOJSON_URL_LAYER, THEMATIC_LAYER } from '../../../constants/layers.js' import JoinLayersControl from '../controls/JoinLayersControl.jsx' const eligibleLayers = [ - { id: 'layer1', name: 'Layer 1' }, - { id: 'layer2', name: 'Layer 2' }, + { + id: 'layer1', + name: 'Layer 1', + layer: THEMATIC_LAYER, + data: [], + }, + { + id: 'layer2', + name: 'Layer 2', + layer: THEMATIC_LAYER, + data: [{ geometry: { type: 'Point' } }], + }, ] const renderControl = (props) => render( <JoinLayersControl eligibleLayers={eligibleLayers} - selectedIds={[]} + layersConfig={{}} onChange={jest.fn()} {...props} /> @@ -36,7 +47,7 @@ describe('JoinLayersControl trigger', () => { }) }) -describe('JoinLayersControl popover', () => { +describe('JoinLayersControl popover — checkbox list', () => { test('lists a checkbox per eligible layer', () => { renderControl() openPicker() @@ -45,39 +56,157 @@ describe('JoinLayersControl popover', () => { expect(screen.getByText('Layer 2')).toBeInTheDocument() }) - test('reflects the currently selected layer ids as checked', () => { - renderControl({ selectedIds: ['layer2'] }) + test('reflects the currently joined layers as checked', () => { + renderControl({ + layersConfig: { + layer2: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + }) openPicker() expect( - screen.getByText('Layer 1').closest('label').querySelector('input') + screen.getByRole('checkbox', { name: 'Layer 1' }) ).not.toBeChecked() + expect(screen.getByRole('checkbox', { name: 'Layer 2' })).toBeChecked() + }) + + test('checking an unselected layer adds it with default org-unit/SUM settings', () => { + const onChange = jest.fn() + renderControl({ + layersConfig: { + layer1: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + onChange, + }) + openPicker() + + fireEvent.click(screen.getByRole('checkbox', { name: 'Layer 2' })) + + expect(onChange).toHaveBeenCalledWith({ + layer1: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + layer2: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }) + }) + + test('unchecking a joined layer removes it from the config', () => { + const onChange = jest.fn() + renderControl({ + layersConfig: { + layer1: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + layer2: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + onChange, + }) + openPicker() + + fireEvent.click(screen.getByRole('checkbox', { name: 'Layer 1' })) + + expect(onChange).toHaveBeenCalledWith({ + layer2: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }) + }) +}) + +describe('JoinLayersControl popover — per-layer type/aggregation settings', () => { + test('shows the join type and aggregation selects only for joined layers', () => { + renderControl({ + layersConfig: { + layer1: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + }) + openPicker() + + expect( + screen.getByLabelText('Join type for Layer 1') + ).toBeInTheDocument() expect( - screen.getByText('Layer 2').closest('label').querySelector('input') - ).toBeChecked() + screen.getByLabelText('Aggregation type for Layer 1') + ).toBeInTheDocument() + expect( + screen.queryByLabelText('Join type for Layer 2') + ).not.toBeInTheDocument() }) - test('checking an unselected layer adds it to the selection', () => { + test('does not offer the Spatial join option for a layer with no geometry sample available', () => { + renderControl({ + layersConfig: { + layer1: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + layer2: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + }) + openPicker() + + expect( + within(screen.getByLabelText('Join type for Layer 1')).queryByText( + 'Spatial' + ) + ).not.toBeInTheDocument() + expect( + within(screen.getByLabelText('Join type for Layer 2')).getByText( + 'Spatial' + ) + ).toBeInTheDocument() + }) + + test('offers Spatial for polygon geometry regardless of layer type, matched via centroid - including layers with no org-unit identity of their own', () => { + renderControl({ + eligibleLayers: [ + { + id: 'geo', + name: 'Zones', + layer: GEOJSON_URL_LAYER, + data: [{ geometry: { type: 'Polygon' } }], + }, + ], + layersConfig: { + geo: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + }) + openPicker() + + expect( + within(screen.getByLabelText('Join type for Zones')).getByText( + 'Spatial' + ) + ).toBeInTheDocument() + }) + + test('changing the join type dispatches onChange with the updated type', () => { const onChange = jest.fn() - renderControl({ selectedIds: ['layer1'], onChange }) + renderControl({ + layersConfig: { + layer2: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + onChange, + }) openPicker() - fireEvent.click( - screen.getByText('Layer 2').closest('label').querySelector('input') - ) + fireEvent.change(screen.getByLabelText('Join type for Layer 2'), { + target: { value: 'spatial' }, + }) - expect(onChange).toHaveBeenCalledWith(['layer1', 'layer2']) + expect(onChange).toHaveBeenCalledWith({ + layer2: { type: 'spatial', aggregation: { rawValue: 'SUM' } }, + }) }) - test('unchecking a selected layer removes it from the selection', () => { + test('changing the aggregation type dispatches onChange with the updated aggregation for that column', () => { const onChange = jest.fn() - renderControl({ selectedIds: ['layer1', 'layer2'], onChange }) + renderControl({ + layersConfig: { + layer1: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + onChange, + }) openPicker() - fireEvent.click( - screen.getByText('Layer 1').closest('label').querySelector('input') + fireEvent.change( + screen.getByLabelText('Aggregation type for Layer 1'), + { target: { value: 'AVERAGE' } } ) - expect(onChange).toHaveBeenCalledWith(['layer2']) + expect(onChange).toHaveBeenCalledWith({ + layer1: { type: 'orgUnit', aggregation: { rawValue: 'AVERAGE' } }, + }) }) }) diff --git a/src/components/datatable/__tests__/LayerSelectorControl.spec.jsx b/src/components/datatable/__tests__/LayerSelectorControl.spec.jsx index b6a69ab0cd..85dbb4f13a 100644 --- a/src/components/datatable/__tests__/LayerSelectorControl.spec.jsx +++ b/src/components/datatable/__tests__/LayerSelectorControl.spec.jsx @@ -13,7 +13,6 @@ const renderControl = (props) => layers={layers} activeLayerId="layer1" combinedView={false} - combinedEnabled={true} onSelectLayer={jest.fn()} onSelectCombined={jest.fn()} {...props} @@ -30,13 +29,8 @@ describe('LayerSelectorControl', () => { expect(screen.getByText('Combined')).toBeInTheDocument() }) - test('the Combined option is disabled when combinedEnabled is false', () => { - renderControl({ combinedEnabled: false }) - expect(screen.getByText('Combined')).toBeDisabled() - }) - - test('the Combined option is enabled when combinedEnabled is true', () => { - renderControl({ combinedEnabled: true }) + test("the Combined option is never disabled - selecting it before a reference is configured is the caller's job to handle", () => { + renderControl() expect(screen.getByText('Combined')).not.toBeDisabled() }) diff --git a/src/components/datatable/__tests__/useCombinedTableData.spec.js b/src/components/datatable/__tests__/useCombinedTableData.spec.js index 36ec85fcdd..7d43398c7d 100644 --- a/src/components/datatable/__tests__/useCombinedTableData.spec.js +++ b/src/components/datatable/__tests__/useCombinedTableData.spec.js @@ -1,42 +1,31 @@ import { renderHook } from '@testing-library/react' -import useOrgUnitAncestorNames from '../../../hooks/useOrgUnitAncestorNames.js' +import { EVENT_LAYER } from '../../../constants/layers.js' import { useCombinedTableData } from '../useCombinedTableData.js' -jest.mock('../../../hooks/useOrgUnitAncestorNames.js', () => ({ - __esModule: true, - default: jest.fn(), -})) - -beforeEach(() => { - useOrgUnitAncestorNames.mockReturnValue({ - idToName: new Map(), - loading: false, - }) -}) - const feature = (props) => ({ properties: props }) const findCell = (row, dataKey) => row.find((c) => c.dataKey === dataKey) -describe('useCombinedTableData - org unit join', () => { - // Thematic/org unit/facility layers - where the feature IS the org unit - // - never get an orgUnitId property: their data is built by toGeoJson() - // in util/map.js, which only sets id/orgUnitPath/orgUnitOwn. Only - // event/tracked-entity layers (via attachOrgUnitPaths in - // util/orgUnits.js, referencing an org unit the feature isn't itself) - // get a real orgUnitId. This is the shape that actually appears in - // production for the two most common layer types in this join mode - - // using orgUnitId in the fixture here would mask exactly the bug this - // guards against. - test('joins two org-unit-identity layers (no orgUnitId property) by their own id, filling blanks for unmatched org units', () => { - useOrgUnitAncestorNames.mockReturnValue({ - idToName: new Map([ - ['ou1', 'Ou One'], - ['ou2', 'Ou Two'], - ]), - loading: false, - }) +const referenceLayer = { + id: 'ref1', + data: [ + feature({ + id: 'ou1', + name: 'Ou One', + orgUnitPath: '/country1/ou1', + level: 2, + }), + feature({ + id: 'ou2', + name: 'Ou Two', + orgUnitPath: '/country1/ou2', + level: 2, + }), + ], +} +describe('useCombinedTableData - org unit join', () => { + test('joins a direct match by exact org unit path, filling blanks for unmatched reference rows', () => { const layers = [ { id: 'layerA', @@ -45,35 +34,20 @@ describe('useCombinedTableData - org unit join', () => { feature({ id: 'ou1', orgUnitPath: '/country1/ou1', - level: 2, rawValue: 10, legend: 'Low', }), ], }, - { - id: 'layerB', - name: 'Layer B', - data: [ - feature({ - id: 'ou2', - orgUnitPath: '/country1/ou2', - level: 2, - rawValue: 20, - legend: 'High', - }), - ], - }, ] const joinConfig = { - level: 'orgUnit', - layerIds: ['layerA', 'layerB'], - pointLayerId: null, - polygonLayerId: null, + layers: { + layerA: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, } const { result } = renderHook(() => - useCombinedTableData({ layers, joinConfig }) + useCombinedTableData({ layers, referenceLayer, joinConfig }) ) expect(result.current.headers.map((h) => h.dataKey)).toEqual([ @@ -82,8 +56,6 @@ describe('useCombinedTableData - org unit join', () => { 'level', 'layerA_rawValue', 'layerA_legend', - 'layerB_rawValue', - 'layerB_legend', ]) expect(result.current.rows).toHaveLength(2) @@ -92,17 +64,15 @@ describe('useCombinedTableData - org unit join', () => { ) expect(findCell(row1, 'name').value).toBe('Ou One') expect(findCell(row1, 'layerA_rawValue').value).toBe(10) - expect(findCell(row1, 'layerB_rawValue').value).toBe(null) + expect(findCell(row1, 'layerA_legend').value).toBe('Low') const row2 = result.current.rows.find( (r) => findCell(r, 'id').value === 'ou2' ) - expect(findCell(row2, 'name').value).toBe('Ou Two') expect(findCell(row2, 'layerA_rawValue').value).toBe(null) - expect(findCell(row2, 'layerB_rawValue').value).toBe(20) }) - test('includes rows from layer.dataWithoutCoords, matching the single-layer table', () => { + test('takes the row name/level directly from the reference layer, not from the participating layer', () => { const layers = [ { id: 'layerA', @@ -110,284 +80,257 @@ describe('useCombinedTableData - org unit join', () => { data: [ feature({ id: 'ou1', + name: 'Some other name', orgUnitPath: '/country1/ou1', + level: 99, rawValue: 10, }), ], - dataWithoutCoords: [ - feature({ - id: 'ou2', - orgUnitPath: '/country1/ou2', - rawValue: 20, - }), - ], }, ] const joinConfig = { - level: 'orgUnit', - layerIds: ['layerA'], - pointLayerId: null, - polygonLayerId: null, + layers: { layerA: { type: 'orgUnit', aggregation: {} } }, } const { result } = renderHook(() => - useCombinedTableData({ layers, joinConfig }) + useCombinedTableData({ layers, referenceLayer, joinConfig }) ) - expect(result.current.rows).toHaveLength(2) - const withoutCoordsRow = result.current.rows.find( - (r) => findCell(r, 'id').value === 'ou2' + const row1 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' ) - expect(findCell(withoutCoordsRow, 'layerA_rawValue').value).toBe(20) + expect(findCell(row1, 'name').value).toBe('Ou One') + expect(findCell(row1, 'level').value).toBe(2) }) - test('prefers orgUnitId over id when both are present (event/tracked-entity layer shape)', () => { + test('aggregates several descendant features under the reference org unit using the chosen aggregation type', () => { const layers = [ { id: 'layerA', name: 'Layer A', data: [ - // The event's own id ('evt1') is not an org unit - - // orgUnitId is the registering org unit and must win. feature({ id: 'evt1', - orgUnitId: 'ou1', - orgUnitPath: '/country1/ou1', + orgUnitPath: '/country1/ou1/facility1', rawValue: 10, }), - ], - }, - ] - const joinConfig = { - level: 'orgUnit', - layerIds: ['layerA'], - pointLayerId: null, - polygonLayerId: null, - } - - const { result } = renderHook(() => - useCombinedTableData({ layers, joinConfig }) - ) - - expect(result.current.rows).toHaveLength(1) - expect(findCell(result.current.rows[0], 'id').value).toBe('ou1') - }) - - test('falls back to the raw org unit id when its name has not resolved yet', () => { - const layers = [ - { - id: 'layerA', - name: 'Layer A', - data: [ feature({ - id: 'ou1', - orgUnitPath: '/country1/ou1', - rawValue: 10, + id: 'evt2', + orgUnitPath: '/country1/ou1/facility2', + rawValue: 20, }), ], }, ] const joinConfig = { - level: 'orgUnit', - layerIds: ['layerA'], - pointLayerId: null, - polygonLayerId: null, + layers: { + layerA: { + type: 'orgUnit', + aggregation: { rawValue: 'AVERAGE' }, + }, + }, } const { result } = renderHook(() => - useCombinedTableData({ layers, joinConfig }) + useCombinedTableData({ layers, referenceLayer, joinConfig }) ) - expect(findCell(result.current.rows[0], 'name').value).toBe('ou1') + const row1 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' + ) + expect(findCell(row1, 'layerA_rawValue').value).toBe(15) }) - test('excludes features with hasAdditionalGeometry set', () => { + test('shows blank for a feature whose org unit is an ancestor of the reference, not a descendant', () => { const layers = [ { id: 'layerA', name: 'Layer A', data: [ feature({ - id: 'ou1', + id: 'country1', + orgUnitPath: '/country1', rawValue: 10, - hasAdditionalGeometry: true, }), - feature({ id: 'ou2', rawValue: 20 }), ], }, ] const joinConfig = { - level: 'orgUnit', - layerIds: ['layerA'], - pointLayerId: null, - polygonLayerId: null, + layers: { layerA: { type: 'orgUnit', aggregation: {} } }, } const { result } = renderHook(() => - useCombinedTableData({ layers, joinConfig }) + useCombinedTableData({ layers, referenceLayer, joinConfig }) ) - expect(result.current.rows).toHaveLength(1) - expect(findCell(result.current.rows[0], 'id').value).toBe('ou2') + result.current.rows.forEach((row) => { + expect(findCell(row, 'layerA_rawValue').value).toBe(null) + }) }) - test('rowFeatureIds includes every feature sharing an org unit, not just the last one displayed', () => { - const layers = [ - { - id: 'layerA', - name: 'Layer A', - data: [ - feature({ id: 'evt1', orgUnitId: 'ou1', rawValue: 10 }), - feature({ id: 'evt2', orgUnitId: 'ou1', rawValue: 20 }), - ], - }, - ] + test('resolves legend to the shared value when every match agrees, otherwise blank', () => { + const agreeingLayer = { + id: 'layerA', + name: 'Layer A', + data: [ + feature({ + id: 'evt1', + orgUnitPath: '/country1/ou1/f1', + legend: 'Low', + }), + feature({ + id: 'evt2', + orgUnitPath: '/country1/ou1/f2', + legend: 'Low', + }), + ], + } + const disagreeingLayer = { + id: 'layerB', + name: 'Layer B', + data: [ + feature({ + id: 'evt3', + orgUnitPath: '/country1/ou2/f1', + legend: 'Low', + }), + feature({ + id: 'evt4', + orgUnitPath: '/country1/ou2/f2', + legend: 'High', + }), + ], + } const joinConfig = { - level: 'orgUnit', - layerIds: ['layerA'], - pointLayerId: null, - polygonLayerId: null, + layers: { + layerA: { type: 'orgUnit', aggregation: {} }, + layerB: { type: 'orgUnit', aggregation: {} }, + }, } const { result } = renderHook(() => - useCombinedTableData({ layers, joinConfig }) + useCombinedTableData({ + layers: [agreeingLayer, disagreeingLayer], + referenceLayer, + joinConfig, + }) ) - expect(result.current.rowFeatureIds.get('ou1')).toEqual({ - layerA: ['evt1', 'evt2'], - }) + const row1 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' + ) + expect(findCell(row1, 'layerA_legend').value).toBe('Low') + + const row2 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou2' + ) + expect(findCell(row2, 'layerB_legend').value).toBe(null) }) -}) -describe('useCombinedTableData - parent org unit grouping', () => { - test('groups rows by parent org unit and averages numeric values', () => { + test('includes rows from layer.dataWithoutCoords, matching the single-layer table', () => { const layers = [ { id: 'layerA', name: 'Layer A', - data: [ + data: [], + dataWithoutCoords: [ feature({ id: 'ou1', - orgUnitPath: '/country1/parent1/ou1', + orgUnitPath: '/country1/ou1', rawValue: 10, }), - feature({ - id: 'ou2', - orgUnitPath: '/country1/parent1/ou2', - rawValue: 20, - }), ], }, ] const joinConfig = { - level: 'parentOrgUnit', - layerIds: ['layerA'], - pointLayerId: null, - polygonLayerId: null, + layers: { layerA: { type: 'orgUnit', aggregation: {} } }, } - useOrgUnitAncestorNames.mockReturnValue({ - idToName: new Map([['parent1', 'Parent One']]), - loading: false, - }) - const { result } = renderHook(() => - useCombinedTableData({ layers, joinConfig }) + useCombinedTableData({ layers, referenceLayer, joinConfig }) ) - expect(result.current.rows).toHaveLength(1) - const row = result.current.rows[0] - expect(findCell(row, 'id').value).toBe('parent1') - expect(findCell(row, 'name').value).toBe('Parent One') - expect(findCell(row, 'layerA_rawValue').value).toBe(15) - expect(findCell(row, 'layerA_legend').value).toBe(null) + const row1 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' + ) + expect(findCell(row1, 'layerA_rawValue').value).toBe(10) }) - test('groups org units with no parent path under a single "No parent" row', () => { + test('excludes features with hasAdditionalGeometry set', () => { const layers = [ { id: 'layerA', name: 'Layer A', data: [ feature({ - id: 'ou1', - orgUnitPath: '/ou1', - rawValue: 10, + id: 'extra', + orgUnitPath: '/country1/ou1', + rawValue: 999, + hasAdditionalGeometry: true, }), ], }, ] const joinConfig = { - level: 'parentOrgUnit', - layerIds: ['layerA'], - pointLayerId: null, - polygonLayerId: null, + layers: { layerA: { type: 'orgUnit', aggregation: {} } }, } const { result } = renderHook(() => - useCombinedTableData({ layers, joinConfig }) + useCombinedTableData({ layers, referenceLayer, joinConfig }) ) - expect(result.current.rows).toHaveLength(1) - expect(findCell(result.current.rows[0], 'id').value).toBe(null) - expect(findCell(result.current.rows[0], 'name').value).toBe('No parent') + const row1 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' + ) + expect(findCell(row1, 'layerA_rawValue').value).toBe(null) }) - test("rowFeatureIds unions every member org unit's feature ids under the parent group", () => { + test('rowFeatureIds always includes the reference layer itself, plus every matching participating feature', () => { const layers = [ { id: 'layerA', name: 'Layer A', data: [ feature({ - id: 'ou1', - orgUnitPath: '/country1/parent1/ou1', + id: 'evt1', + orgUnitPath: '/country1/ou1/f1', rawValue: 10, }), feature({ - id: 'ou2', - orgUnitPath: '/country1/parent1/ou2', + id: 'evt2', + orgUnitPath: '/country1/ou1/f2', rawValue: 20, }), ], }, ] const joinConfig = { - level: 'parentOrgUnit', - layerIds: ['layerA'], - pointLayerId: null, - polygonLayerId: null, + layers: { layerA: { type: 'orgUnit', aggregation: {} } }, } const { result } = renderHook(() => - useCombinedTableData({ layers, joinConfig }) + useCombinedTableData({ layers, referenceLayer, joinConfig }) ) - expect(result.current.rowFeatureIds.get('parent1')).toEqual({ - layerA: ['ou1', 'ou2'], + expect(result.current.rowFeatureIds.get('ou1')).toEqual({ + ref1: ['ou1'], + layerA: ['evt1', 'evt2'], + }) + // ou2 has no participating match, but the reference feature id is + // still present so "zoom to feature" always has real bounds. + expect(result.current.rowFeatureIds.get('ou2')).toEqual({ + ref1: ['ou2'], }) }) }) describe('useCombinedTableData - spatial join', () => { - const pointLayer = { - id: 'points', - name: 'Points', + const referenceOrgUnitsAsPolygons = { + id: 'ref1', data: [ { type: 'Feature', - properties: { id: 'p1', name: 'Point One' }, - geometry: { type: 'Point', coordinates: [1, 1] }, - }, - ], - } - const polygonLayer = { - id: 'polygons', - name: 'Polygons', - data: [ - { - type: 'Feature', - properties: { id: 'poly1', rawValue: 42, legend: 'High' }, + properties: { id: 'ou1', name: 'Ou One', level: 2 }, geometry: { type: 'Polygon', coordinates: [ @@ -404,136 +347,204 @@ describe('useCombinedTableData - spatial join', () => { ], } - test("falls back to the feature's own name when it has no org unit path", () => { + test('joins a point feature to the reference org unit whose polygon contains it', () => { + const layers = [ + { + id: 'points', + name: 'Points', + layer: 'event', + data: [ + { + type: 'Feature', + properties: { id: 'p1', rawValue: 42, legend: 'High' }, + geometry: { type: 'Point', coordinates: [1, 1] }, + }, + ], + }, + ] const joinConfig = { - level: 'spatial', - layerIds: [], - pointLayerId: 'points', - polygonLayerId: 'polygons', + layers: { + points: { + type: 'spatial', + aggregation: { rawValue: 'SUM' }, + }, + }, } const { result } = renderHook(() => useCombinedTableData({ - layers: [pointLayer, polygonLayer], + layers, + referenceLayer: referenceOrgUnitsAsPolygons, joinConfig, }) ) - expect(result.current.headers.map((h) => h.dataKey)).toEqual([ - 'id', - 'name', - 'polygons_rawValue', - 'polygons_legend', - ]) - expect(result.current.rows).toEqual([ - [ - { dataKey: 'id', value: 'p1', align: 'left', itemId: 'p1' }, - { - dataKey: 'name', - value: 'Point One', - align: 'left', - itemId: 'p1', - }, - { - dataKey: 'polygons_rawValue', - value: 42, - align: 'right', - itemId: 'p1', - }, - { - dataKey: 'polygons_legend', - value: 'High', - align: 'left', - itemId: 'p1', - }, - ], - ]) - expect(result.current.spatialWarning).toBe(false) - expect(result.current.rowFeatureIds.get('p1')).toEqual({ + const row1 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' + ) + expect(findCell(row1, 'points_rawValue').value).toBe(42) + expect(result.current.rowFeatureIds.get('ou1')).toEqual({ + ref1: ['ou1'], points: ['p1'], - polygons: ['poly1'], }) }) - test('resolves the org unit name when the point feature has an org unit path', () => { - useOrgUnitAncestorNames.mockReturnValue({ - idToName: new Map([['p1', 'Resolved Point Name']]), - loading: false, - }) - - const pointLayerWithOrgUnit = { - ...pointLayer, - data: [ - { - type: 'Feature', - properties: { - id: 'p1', - name: 'Point One', - orgUnitPath: '/country1/p1', + test('matches an event feature via its centroid when its geometry is a polygon', () => { + const layers = [ + { + id: 'events', + name: 'Events', + layer: EVENT_LAYER, + data: [ + { + type: 'Feature', + properties: { id: 'e1', rawValue: 7 }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [0.5, 0.5], + [1.5, 0.5], + [1.5, 1.5], + [0.5, 1.5], + [0.5, 0.5], + ], + ], + }, }, - geometry: { type: 'Point', coordinates: [1, 1] }, - }, - ], - } + ], + }, + ] const joinConfig = { - level: 'spatial', - layerIds: [], - pointLayerId: 'points', - polygonLayerId: 'polygons', + layers: { + events: { type: 'spatial', aggregation: { rawValue: 'SUM' } }, + }, } const { result } = renderHook(() => useCombinedTableData({ - layers: [pointLayerWithOrgUnit, polygonLayer], + layers, + referenceLayer: referenceOrgUnitsAsPolygons, joinConfig, }) ) - expect(findCell(result.current.rows[0], 'name').value).toBe( - 'Resolved Point Name' + const row1 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' ) + expect(findCell(row1, 'events_rawValue').value).toBe(7) }) - test('returns an empty result when the point or polygon layer is not found', () => { + test('matches via centroid regardless of layer type - not just Event/TrackedEntity', () => { + const layers = [ + { + id: 'zones', + name: 'Zones', + layer: 'geoJsonUrl', + data: [ + { + type: 'Feature', + properties: { id: 'z1', rawValue: 3 }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [0.5, 0.5], + [1.5, 0.5], + [1.5, 1.5], + [0.5, 1.5], + [0.5, 0.5], + ], + ], + }, + }, + ], + }, + ] const joinConfig = { - level: 'spatial', - layerIds: [], - pointLayerId: 'points', - polygonLayerId: null, + layers: { + zones: { type: 'spatial', aggregation: { rawValue: 'SUM' } }, + }, } const { result } = renderHook(() => - useCombinedTableData({ layers: [pointLayer], joinConfig }) + useCombinedTableData({ + layers, + referenceLayer: referenceOrgUnitsAsPolygons, + joinConfig, + }) ) - expect(result.current).toEqual({ - headers: [], - rows: [], - rowFeatureIds: new Map(), - columnOptions: {}, - spatialWarning: false, - }) + const row1 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' + ) + expect(findCell(row1, 'zones_rawValue').value).toBe(3) }) - test('sets spatialWarning when either layer exceeds the large-feature threshold', () => { - const bigPointLayer = { - ...pointLayer, - data: Array.from({ length: 10001 }, (_, i) => ({ - type: 'Feature', - properties: { id: `p${i}` }, - geometry: { type: 'Point', coordinates: [1, 1] }, - })), + test('averages several points falling inside the same reference polygon', () => { + const layers = [ + { + id: 'points', + name: 'Points', + layer: 'event', + data: [ + { + type: 'Feature', + properties: { id: 'p1', rawValue: 10 }, + geometry: { type: 'Point', coordinates: [0.5, 0.5] }, + }, + { + type: 'Feature', + properties: { id: 'p2', rawValue: 20 }, + geometry: { type: 'Point', coordinates: [1.5, 1.5] }, + }, + ], + }, + ] + const joinConfig = { + layers: { + points: { + type: 'spatial', + aggregation: { rawValue: 'AVERAGE' }, + }, + }, } + + const { result } = renderHook(() => + useCombinedTableData({ + layers, + referenceLayer: referenceOrgUnitsAsPolygons, + joinConfig, + }) + ) + + const row1 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' + ) + expect(findCell(row1, 'points_rawValue').value).toBe(15) + }) + + test('sets spatialWarning when a spatially-joined layer exceeds the large-feature threshold', () => { + const layers = [ + { + id: 'points', + name: 'Points', + layer: 'event', + data: Array.from({ length: 10001 }, (_, i) => ({ + type: 'Feature', + properties: { id: `p${i}` }, + geometry: { type: 'Point', coordinates: [1, 1] }, + })), + }, + ] const joinConfig = { - level: 'spatial', - layerIds: [], - pointLayerId: 'points', - polygonLayerId: 'polygons', + layers: { points: { type: 'spatial', aggregation: {} } }, } const { result } = renderHook(() => useCombinedTableData({ - layers: [bigPointLayer, polygonLayer], + layers, + referenceLayer: referenceOrgUnitsAsPolygons, joinConfig, }) ) @@ -548,23 +559,28 @@ describe('useCombinedTableData - sorting and filtering', () => { id: 'layerA', name: 'Layer A', data: [ - feature({ id: 'ou1', rawValue: 30 }), - feature({ id: 'ou2', rawValue: 10 }), - feature({ id: 'ou3', rawValue: 20 }), + feature({ + id: 'ou1', + orgUnitPath: '/country1/ou1', + rawValue: 30, + }), + feature({ + id: 'ou2', + orgUnitPath: '/country1/ou2', + rawValue: 10, + }), ], }, ] const joinConfig = { - level: 'orgUnit', - layerIds: ['layerA'], - pointLayerId: null, - polygonLayerId: null, + layers: { layerA: { type: 'orgUnit', aggregation: {} } }, } test('sorts rows by a numeric column ascending', () => { const { result } = renderHook(() => useCombinedTableData({ layers, + referenceLayer, joinConfig, sortField: 'layerA_rawValue', sortDirection: 'asc', @@ -572,7 +588,7 @@ describe('useCombinedTableData - sorting and filtering', () => { ) expect(result.current.rows.map((r) => findCell(r, 'id').value)).toEqual( - ['ou2', 'ou3', 'ou1'] + ['ou2', 'ou1'] ) }) @@ -580,6 +596,7 @@ describe('useCombinedTableData - sorting and filtering', () => { const { result } = renderHook(() => useCombinedTableData({ layers, + referenceLayer, joinConfig, sortField: 'layerA_rawValue', sortDirection: 'desc', @@ -587,17 +604,7 @@ describe('useCombinedTableData - sorting and filtering', () => { ) expect(result.current.rows.map((r) => findCell(r, 'id').value)).toEqual( - ['ou1', 'ou3', 'ou2'] - ) - }) - - test('preserves natural order when there is no sort field', () => { - const { result } = renderHook(() => - useCombinedTableData({ layers, joinConfig }) - ) - - expect(result.current.rows.map((r) => findCell(r, 'id').value)).toEqual( - ['ou1', 'ou2', 'ou3'] + ['ou1', 'ou2'] ) }) @@ -605,19 +612,25 @@ describe('useCombinedTableData - sorting and filtering', () => { const { result } = renderHook(() => useCombinedTableData({ layers, + referenceLayer, joinConfig, filters: { layerA_rawValue: '>15' }, }) ) expect(result.current.rows.map((r) => findCell(r, 'id').value)).toEqual( - ['ou1', 'ou3'] + ['ou1'] ) }) test('applies global search across string columns', () => { const { result } = renderHook(() => - useCombinedTableData({ layers, joinConfig, globalSearch: 'ou2' }) + useCombinedTableData({ + layers, + referenceLayer, + joinConfig, + globalSearch: 'Ou Two', + }) ) expect(result.current.rows.map((r) => findCell(r, 'id').value)).toEqual( @@ -627,45 +640,26 @@ describe('useCombinedTableData - sorting and filtering', () => { test('exposes distinct column values for the filter popover, sorted ascending by default', () => { const { result } = renderHook(() => - useCombinedTableData({ layers, joinConfig }) + useCombinedTableData({ layers, referenceLayer, joinConfig }) ) expect(result.current.columnOptions.layerA_rawValue).toEqual([ { value: '10' }, - { value: '20' }, { value: '30' }, ]) }) - - test("sorts a column's distinct values descending when it is the active sort field", () => { - const { result } = renderHook(() => - useCombinedTableData({ - layers, - joinConfig, - sortField: 'layerA_rawValue', - sortDirection: 'desc', - }) - ) - - expect(result.current.columnOptions.layerA_rawValue).toEqual([ - { value: '30' }, - { value: '20' }, - { value: '10' }, - ]) - }) }) describe('useCombinedTableData - empty input', () => { - test('returns an empty result when there are no layers', () => { - const joinConfig = { - level: 'orgUnit', - layerIds: [], - pointLayerId: null, - polygonLayerId: null, - } + test('returns an empty result when the reference layer has no org units', () => { + const joinConfig = { layers: {} } const { result } = renderHook(() => - useCombinedTableData({ layers: [], joinConfig }) + useCombinedTableData({ + layers: [], + referenceLayer: { id: 'ref1', data: [] }, + joinConfig, + }) ) expect(result.current).toEqual({ @@ -676,4 +670,19 @@ describe('useCombinedTableData - empty input', () => { spatialWarning: false, }) }) + + test('still returns one row per reference org unit when there are no participating layers', () => { + const joinConfig = { layers: {} } + + const { result } = renderHook(() => + useCombinedTableData({ layers: [], referenceLayer, joinConfig }) + ) + + expect(result.current.rows).toHaveLength(2) + expect(result.current.headers.map((h) => h.dataKey)).toEqual([ + 'id', + 'name', + 'level', + ]) + }) }) diff --git a/src/components/datatable/controls/JoinLayersControl.jsx b/src/components/datatable/controls/JoinLayersControl.jsx index f27940681f..ebc9aca2a8 100644 --- a/src/components/datatable/controls/JoinLayersControl.jsx +++ b/src/components/datatable/controls/JoinLayersControl.jsx @@ -2,20 +2,68 @@ import i18n from '@dhis2/d2-i18n' import { IconVisualizationColumnMulti16 } from '@dhis2/ui' import PropTypes from 'prop-types' import React, { useRef, useState } from 'react' +import { getCombinedAggregationTypes } from '../../../constants/aggregationTypes.js' +import { + GEO_TYPE_POINT, + GEO_TYPE_POLYGON, + GEO_TYPE_MULTIPOLYGON, +} from '../../../util/geojson.js' import { FilterDropdownPopover } from '../FilterDropdownPopover.jsx' import styles from './styles/JoinLayersControl.module.css' import ToolbarIconButton from './ToolbarIconButton.jsx' -const JoinLayersControl = ({ eligibleLayers, selectedIds, onChange }) => { +const VALUE_KEY = 'rawValue' +const DEFAULT_SETTINGS = { + type: 'orgUnit', + aggregation: { [VALUE_KEY]: 'SUM' }, +} + +// Spatial join means point-in-polygon against the reference org unit's own +// boundary - offered for any layer whose features are literally points, or +// whose geometry is a polygon/multipolygon (matched via its centroid +// instead - see util/spatialJoin.js). Geometry-based, not layer-type-based: +// this is what makes a GeoJSON URL layer (or any other layer type with no +// org-unit identity of its own) still joinable in Combined even though +// "Org unit" join can never match anything for it. +const isSpatialEligible = (layer) => { + const geometryType = layer.data?.[0]?.geometry?.type + return [GEO_TYPE_POINT, GEO_TYPE_POLYGON, GEO_TYPE_MULTIPOLYGON].includes( + geometryType + ) +} + +const JoinLayersControl = ({ eligibleLayers, layersConfig, onChange }) => { const anchorRef = useRef(null) const [isOpen, setIsOpen] = useState(false) + const aggregationTypes = getCombinedAggregationTypes() - const onToggle = (layerId) => - onChange( - selectedIds.includes(layerId) - ? selectedIds.filter((id) => id !== layerId) - : [...selectedIds, layerId] - ) + const onToggle = (layerId) => { + const next = { ...layersConfig } + if (next[layerId]) { + delete next[layerId] + } else { + next[layerId] = DEFAULT_SETTINGS + } + onChange(next) + } + + const onTypeChange = (layerId, type) => + onChange({ + ...layersConfig, + [layerId]: { ...layersConfig[layerId], type }, + }) + + const onAggregationChange = (layerId, dataKey, aggregationType) => + onChange({ + ...layersConfig, + [layerId]: { + ...layersConfig[layerId], + aggregation: { + ...layersConfig[layerId].aggregation, + [dataKey]: aggregationType, + }, + }, + }) return ( <> @@ -36,18 +84,80 @@ const JoinLayersControl = ({ eligibleLayers, selectedIds, onChange }) => { onClickOutside={() => setIsOpen(false)} > <div className={styles.joinLayersPopover}> - {eligibleLayers.map((layer) => ( - <label key={layer.id} className={styles.layerRow}> - <input - type="checkbox" - checked={selectedIds.includes(layer.id)} - onChange={() => onToggle(layer.id)} - /> - <span className={styles.layerName}> - {layer.name} - </span> - </label> - ))} + {eligibleLayers.map((layer) => { + const settings = layersConfig[layer.id] + return ( + <div key={layer.id} className={styles.layerRow}> + <label + className={styles.layerCheckboxLabel} + > + <input + type="checkbox" + checked={!!settings} + onChange={() => onToggle(layer.id)} + /> + <span className={styles.layerName}> + {layer.name} + </span> + </label> + {settings && ( + <div className={styles.layerSettings}> + <select + aria-label={i18n.t( + 'Join type for {{layer}}', + { layer: layer.name } + )} + value={settings.type} + onChange={(e) => + onTypeChange( + layer.id, + e.target.value + ) + } + > + <option value="orgUnit"> + {i18n.t('Org unit')} + </option> + {isSpatialEligible(layer) && ( + <option value="spatial"> + {i18n.t('Spatial')} + </option> + )} + </select> + <select + aria-label={i18n.t( + 'Aggregation type for {{layer}}', + { layer: layer.name } + )} + value={ + settings.aggregation?.[ + VALUE_KEY + ] ?? 'SUM' + } + onChange={(e) => + onAggregationChange( + layer.id, + VALUE_KEY, + e.target.value + ) + } + > + {aggregationTypes.map( + (type) => ( + <option + key={type.id} + value={type.id} + > + {type.name} + </option> + ) + )} + </select> + </div> + )} + </div> + ) + })} </div> </FilterDropdownPopover> )} @@ -58,11 +168,13 @@ const JoinLayersControl = ({ eligibleLayers, selectedIds, onChange }) => { JoinLayersControl.propTypes = { eligibleLayers: PropTypes.arrayOf( PropTypes.shape({ + data: PropTypes.array, id: PropTypes.string, + layer: PropTypes.string, name: PropTypes.string, }) ).isRequired, - selectedIds: PropTypes.arrayOf(PropTypes.string).isRequired, + layersConfig: PropTypes.object.isRequired, onChange: PropTypes.func.isRequired, } diff --git a/src/components/datatable/controls/LayerSelectorControl.jsx b/src/components/datatable/controls/LayerSelectorControl.jsx index 2b8cd0f0e8..3f1ca5bd08 100644 --- a/src/components/datatable/controls/LayerSelectorControl.jsx +++ b/src/components/datatable/controls/LayerSelectorControl.jsx @@ -7,14 +7,16 @@ const COMBINED_VALUE = '__combined__' // Replaces the old per-layer tab strip - a single dropdown listing every // data-table-eligible layer on the map (whether or not its table has been -// opened yet) plus Combined (greyed out unless the map has 2+ eligible -// layers to join). Selecting a layer that isn't open yet is the caller's -// job to also open (see BottomPanel.jsx's onSelectLayer). +// opened yet) plus Combined. Selecting Combined before a reference org unit +// set has been configured is the caller's job to handle (see +// BottomPanel.jsx's onSelectCombined, which opens the reference layer's +// editor in that case rather than disabling the option outright). Selecting +// a layer that isn't open yet is likewise the caller's job to also open +// (see BottomPanel.jsx's onSelectLayer). const LayerSelectorControl = ({ layers, activeLayerId, combinedView, - combinedEnabled, onSelectLayer, onSelectCombined, }) => ( @@ -36,14 +38,11 @@ const LayerSelectorControl = ({ {layer.name} </option> ))} - <option value={COMBINED_VALUE} disabled={!combinedEnabled}> - {i18n.t('Combined')} - </option> + <option value={COMBINED_VALUE}>{i18n.t('Combined')}</option> </select> ) LayerSelectorControl.propTypes = { - combinedEnabled: PropTypes.bool.isRequired, combinedView: PropTypes.bool.isRequired, layers: PropTypes.arrayOf( PropTypes.shape({ diff --git a/src/components/datatable/controls/ReferenceOrgUnitControl.jsx b/src/components/datatable/controls/ReferenceOrgUnitControl.jsx index 133ed6841f..7ac97c94bb 100644 --- a/src/components/datatable/controls/ReferenceOrgUnitControl.jsx +++ b/src/components/datatable/controls/ReferenceOrgUnitControl.jsx @@ -6,19 +6,22 @@ import { editLayer } from '../../../actions/layers.js' import { COMBINED_TABLE_REF_LAYER } from '../../../constants/layers.js' import ToolbarIconButton from './ToolbarIconButton.jsx' -// Opens the Combined data table's reference org unit layer for editing via -// the same editLayer/LayerEdit.jsx flow every other layer uses - creating -// it first (as a draft, no id yet) if it doesn't already exist in -// mapViews. See CLAUDE.md/map-layer-architecture: LayerEdit.jsx routes to -// addLayer or updateLayer on save based on whether the object passed here -// has an id, so this component itself never dispatches either directly. -const ReferenceOrgUnitControl = () => { +// Shared by ReferenceOrgUnitControl (the toolbar button) and BottomPanel.jsx +// (which also needs to open the same dialog when "Combined" is selected +// before a reference has been configured yet) - both open the reference +// layer for editing via the same editLayer/LayerEdit.jsx flow every other +// layer uses, creating it first (as a draft, no id yet) if it doesn't +// already exist in mapViews. See CLAUDE.md/map-layer-architecture: +// LayerEdit.jsx routes to addLayer or updateLayer on save based on whether +// the object passed here has an id, so neither caller dispatches either +// directly. +export const useReferenceLayer = () => { const dispatch = useDispatch() const referenceLayer = useSelector((state) => state.map.mapViews.find((l) => l.layer === COMBINED_TABLE_REF_LAYER) ) - const onClick = () => + const openReferenceLayerEditor = () => dispatch( editLayer( referenceLayer ?? { @@ -29,12 +32,18 @@ const ReferenceOrgUnitControl = () => { ) ) + return { referenceLayer, openReferenceLayerEditor } +} + +const ReferenceOrgUnitControl = () => { + const { openReferenceLayerEditor } = useReferenceLayer() + return ( <ToolbarIconButton tooltip={i18n.t('Configure reference org units')} ariaLabel={i18n.t('Configure reference org units')} dataTest="data-table-reference-org-unit-button" - onClick={onClick} + onClick={openReferenceLayerEditor} > <IconLocation16 /> </ToolbarIconButton> diff --git a/src/components/datatable/controls/styles/JoinLayersControl.module.css b/src/components/datatable/controls/styles/JoinLayersControl.module.css index 04421da5a8..864cdaaf50 100644 --- a/src/components/datatable/controls/styles/JoinLayersControl.module.css +++ b/src/components/datatable/controls/styles/JoinLayersControl.module.css @@ -1,7 +1,7 @@ .joinLayersPopover { padding: var(--spacers-dp8); - min-width: 190px; - max-height: 260px; + min-width: 220px; + max-height: 320px; overflow-y: auto; background-color: var(--colors-white); border-radius: 4px; @@ -9,18 +9,21 @@ } .layerRow { - display: flex; - align-items: center; - gap: var(--spacers-dp4); padding: var(--spacers-dp2) var(--spacers-dp4); border-radius: 3px; - cursor: pointer; } .layerRow:hover { background: var(--colors-grey100); } +.layerCheckboxLabel { + display: flex; + align-items: center; + gap: var(--spacers-dp4); + cursor: pointer; +} + .layerName { flex: 1; min-width: 0; @@ -29,3 +32,20 @@ text-overflow: ellipsis; font-size: 12px; } + +.layerSettings { + display: flex; + gap: var(--spacers-dp4); + padding: var(--spacers-dp4) 0 var(--spacers-dp4) var(--spacers-dp20); +} + +.layerSettings select { + flex: 1; + min-width: 0; + height: 24px; + padding: 0 var(--spacers-dp4); + font-size: 12px; + border: 1px solid var(--colors-grey500); + border-radius: 3px; + background-color: var(--colors-white); +} diff --git a/src/components/datatable/useCombinedTableData.js b/src/components/datatable/useCombinedTableData.js index 10c8fd8ff7..17b59bc2be 100644 --- a/src/components/datatable/useCombinedTableData.js +++ b/src/components/datatable/useCombinedTableData.js @@ -1,17 +1,15 @@ import i18n from '@dhis2/d2-i18n' import { useMemo } from 'react' import { - ORG_UNIT_ID_DATA_KEY, ORG_UNIT_PATH_DATA_KEY, ORG_UNIT_LEVEL_DATA_KEY, SORT_ASCENDING, TYPE_NUMBER, TYPE_STRING, } from '../../constants/dataTable.js' -import useOrgUnitAncestorNames from '../../hooks/useOrgUnitAncestorNames.js' +import { applyAggregation } from '../../util/aggregation.js' import { filterByGlobalSearch, filterData } from '../../util/filter.js' -import { formatOrgUnitOwnName } from '../../util/orgUnitGroups.js' -import { spatialJoin } from '../../util/spatialJoin.js' +import { matchFeaturesToReferenceOrgUnits } from '../../util/spatialJoin.js' import { buildRowCells, getColumnDistinctValues, @@ -22,9 +20,78 @@ import { compareRows } from '../../util/tableSort.js' const VALUE_KEY = 'rawValue' const LEGEND_KEY = 'legend' const LARGE_FEATURE_THRESHOLD = 10000 -const NO_PARENT_KEY = '__no_parent__' +const DEFAULT_AGGREGATION = 'SUM' + +// Mirrors util/tableRows.js's own data + dataWithoutCoords merge for the +// single-layer table - org units/facilities missing valid coordinates +// still belong in the join, they just can't render on the map. +const getJoinableFeatures = (layer) => + [...(layer?.data ?? []), ...(layer?.dataWithoutCoords ?? [])].filter( + (d) => !d.properties?.hasAdditionalGeometry + ) + +const getProps = (feature) => feature.properties || feature + +// A feature belongs to a reference org unit if it IS that org unit, or is +// one of its descendants (a path-prefix match) - "the reference OU or +// lower, using the hierarchy". Reference org units are usually all one +// level, so most features hit the direct-match Map; only a genuine +// descendant needs the O(referenceOrgUnits) prefix scan. +const matchOrgUnitReference = ( + features, + referenceOrgUnits, + referenceByPath +) => { + const byReferenceId = new Map() + features.forEach((feature) => { + const props = getProps(feature) + const path = props[ORG_UNIT_PATH_DATA_KEY] + if (!path) { + return + } + const reference = + referenceByPath.get(path) ?? + referenceOrgUnits.find((ref) => + path.startsWith(`${getProps(ref)[ORG_UNIT_PATH_DATA_KEY]}/`) + ) + if (!reference) { + return + } + const referenceId = getProps(reference).id + if (!byReferenceId.has(referenceId)) { + byReferenceId.set(referenceId, []) + } + byReferenceId.get(referenceId).push(props) + }) + return byReferenceId +} + +// useCentroid: true unconditionally - getTestPoint (spatialJoin.js) already +// tests a feature as-is when it's literally a Point, so this only takes +// effect for non-point geometry, regardless of layer type (see +// isSpatialEligible in JoinLayersControl.jsx, which is what actually +// decides whether "Spatial" is offered for a given layer in the first +// place). +const matchSpatialReference = (features, referenceOrgUnits) => { + const byReferenceId = new Map() + const matched = matchFeaturesToReferenceOrgUnits( + features, + referenceOrgUnits, + { useCentroid: true } + ) + matched.forEach(({ featureProps, referenceId }) => { + if (referenceId == null) { + return + } + if (!byReferenceId.has(referenceId)) { + byReferenceId.set(referenceId, []) + } + byReferenceId.get(referenceId).push(featureProps) + }) + return byReferenceId +} -// Shared by all three join modes: apply Combined's own local filters/global +// Shared across every row: apply Combined's own local filters/global // search (reusing the same utilities as the single-layer table), sort by // natural insertion order (via each flat row's index) when no sort column is // active, then build the final {dataKey, value, align, itemId} cell shape. @@ -49,19 +116,6 @@ const finalizeRows = ( return data.map((row) => buildRowCells(row, headers)) } -const getPathSegments = (path) => - path ? String(path).split('/').filter(Boolean) : [] - -const getLastSegment = (path) => { - const segments = getPathSegments(path) - return segments.length ? segments[segments.length - 1] : null -} - -const getParentPath = (path) => { - const segments = getPathSegments(path) - return segments.length > 1 ? segments.slice(0, -1).join('/') : null -} - const EMPTY_COLUMN_OPTIONS = {} const EMPTY_RESULT = { @@ -72,307 +126,129 @@ const EMPTY_RESULT = { spatialWarning: false, } +// layers: the participating layers (each with joinConfig.layers[layer.id] = +// {type, aggregation}), NOT including the reference layer itself. +// referenceLayer: the hidden combinedTableRef layer backing the join - its +// own fetched org units are the row set, always, regardless of whether any +// participating layer has data for a given one. export const useCombinedTableData = ({ layers, + referenceLayer, joinConfig, sortField = null, sortDirection = SORT_ASCENDING, filters, globalSearch, }) => { - const { level, pointLayerId, polygonLayerId } = joinConfig - const isSpatial = level === 'spatial' - const isParentGrouped = level === 'parentOrgUnit' - - const pointLayer = isSpatial - ? layers.find((l) => l.id === pointLayerId) - : null - const polygonLayer = isSpatial - ? layers.find((l) => l.id === polygonLayerId) - : null + const referenceOrgUnits = useMemo( + () => getJoinableFeatures(referenceLayer), + [referenceLayer] + ) - const layerMaps = useMemo(() => { - if (isSpatial) { - return [] - } - return layers.map((layer) => { - const byOrgUnit = {} - // Duplicate features can share one org unit (e.g. several events - // at the same facility) - byOrgUnit keeps only the last one for - // display purposes, but featureIdsByOrgUnit keeps every matching - // feature id so hover/selection can highlight all of them, not - // just the one whose value happens to be shown. - const featureIdsByOrgUnit = {} + const referenceByPath = useMemo( + () => + new Map( + referenceOrgUnits.map((ref) => [ + getProps(ref)[ORG_UNIT_PATH_DATA_KEY], + ref, + ]) + ), + [referenceOrgUnits] + ) - // Mirrors util/tableRows.js's own data + dataWithoutCoords merge - // for the single-layer table - org units/facilities missing - // valid coordinates still belong in the join, they just can't - // render on the map (and so never contribute to zoom bounds, - // since getUnionBounds already skips features with no geometry). - const data = [ - ...(layer.data ?? []), - ...(layer.dataWithoutCoords ?? []), - ] - data.filter((d) => !d.properties?.hasAdditionalGeometry).forEach( - (d) => { - const props = d.properties || d - // orgUnitId is only populated for layers where the - // feature references an org unit it isn't itself - // (events, tracked entities - via attachOrgUnitPaths - // in util/orgUnits.js). For layers where the feature - // IS the org unit (thematic, org unit, facility), - // properties are built by toGeoJson() in - // util/map.js, which never sets orgUnitId - the org - // unit's own id is just the feature's plain id there. - const orgUnitId = props[ORG_UNIT_ID_DATA_KEY] ?? props.id - if (orgUnitId == null) { - return - } - byOrgUnit[orgUnitId] = props - if (!featureIdsByOrgUnit[orgUnitId]) { - featureIdsByOrgUnit[orgUnitId] = [] - } - featureIdsByOrgUnit[orgUnitId].push(props.id) + const layerMatches = useMemo( + () => + layers.map((layer) => { + const settings = joinConfig.layers[layer.id] ?? { + type: 'orgUnit', + aggregation: {}, } - ) - - return { layer, byOrgUnit, featureIdsByOrgUnit } - }) - }, [layers, isSpatial]) - - const allIds = useMemo( - () => [ - ...new Set(layerMaps.flatMap((lm) => Object.keys(lm.byOrgUnit))), - ], - [layerMaps] + const features = getJoinableFeatures(layer) + const byReferenceId = + settings.type === 'spatial' + ? matchSpatialReference(features, referenceOrgUnits) + : matchOrgUnitReference( + features, + referenceOrgUnits, + referenceByPath + ) + return { layer, settings, byReferenceId } + }), + [layers, joinConfig, referenceOrgUnits, referenceByPath] ) - // useOrgUnitAncestorNames resolves every id along each path it's given - // (not just the leaf), so passing each matched org unit's own full path - // also resolves its parent's name for free in parentOrgUnit mode - no - // need for a separate parent-path-only list. - const orgUnitPaths = useMemo(() => { - if (isSpatial) { - return (pointLayer?.data ?? []) - .map((d) => (d.properties || d)[ORG_UNIT_PATH_DATA_KEY]) - .filter(Boolean) - } - return allIds - .map( - (id) => - layerMaps.find((lm) => lm.byOrgUnit[id])?.byOrgUnit[id]?.[ - ORG_UNIT_PATH_DATA_KEY - ] - ) - .filter(Boolean) - }, [isSpatial, pointLayer, layerMaps, allIds]) - - const { idToName } = useOrgUnitAncestorNames(orgUnitPaths) - return useMemo(() => { - if (!layers?.length) { + if (!referenceOrgUnits.length) { return EMPTY_RESULT } - if (isSpatial) { - if (!pointLayer || !polygonLayer) { - return EMPTY_RESULT - } - - const spatialWarning = - (pointLayer.data?.length ?? 0) > LARGE_FEATURE_THRESHOLD || - (polygonLayer.data?.length ?? 0) > LARGE_FEATURE_THRESHOLD - - const joined = spatialJoin(pointLayer, polygonLayer) + const spatialWarning = + referenceOrgUnits.length > LARGE_FEATURE_THRESHOLD || + layerMatches.some( + ({ layer, settings }) => + settings.type === 'spatial' && + (layer.data?.length ?? 0) > LARGE_FEATURE_THRESHOLD + ) - const headers = [ - { name: i18n.t('ID'), dataKey: 'id', type: TYPE_STRING }, - { name: i18n.t('Name'), dataKey: 'name', type: TYPE_STRING }, + const headers = [ + { name: i18n.t('ID'), dataKey: 'id', type: TYPE_STRING }, + { name: i18n.t('Name'), dataKey: 'name', type: TYPE_STRING }, + { name: i18n.t('Level'), dataKey: 'level', type: TYPE_NUMBER }, + ...layerMatches.flatMap(({ layer }) => [ { - name: i18n.t('Value ({{layer}})', { - layer: polygonLayer.name, - }), - dataKey: `${polygonLayer.id}_${VALUE_KEY}`, + name: i18n.t('Value ({{layer}})', { layer: layer.name }), + dataKey: `${layer.id}_${VALUE_KEY}`, type: TYPE_NUMBER, }, { - name: i18n.t('Legend ({{layer}})', { - layer: polygonLayer.name, - }), - dataKey: `${polygonLayer.id}_${LEGEND_KEY}`, + name: i18n.t('Legend ({{layer}})', { layer: layer.name }), + dataKey: `${layer.id}_${LEGEND_KEY}`, type: TYPE_STRING, }, - ] - - const rowFeatureIds = new Map() - - const flatRows = joined.map( - ({ pointProps, polygonProps }, index) => { - const path = pointProps[ORG_UNIT_PATH_DATA_KEY] - - if (pointProps.id != null) { - const entry = { [pointLayer.id]: [pointProps.id] } - if (polygonProps?.id != null) { - entry[polygonLayer.id] = [polygonProps.id] - } - rowFeatureIds.set(pointProps.id, entry) - } - - return { - id: pointProps.id ?? null, - name: path - ? formatOrgUnitOwnName(path, idToName) - : pointProps.name ?? pointProps.id ?? null, - [`${polygonLayer.id}_${VALUE_KEY}`]: - polygonProps?.[VALUE_KEY] ?? null, - [`${polygonLayer.id}_${LEGEND_KEY}`]: - polygonProps?.[LEGEND_KEY] ?? null, - index, - } - } - ) - - const rows = finalizeRows(flatRows, headers, { - filters, - globalSearch, - sortField, - sortDirection, - }) - const columnOptions = - sortColumnOptions(getColumnDistinctValues(headers, flatRows), { - sortField, - sortDirection, - }) ?? EMPTY_COLUMN_OPTIONS - - return { - headers, - rows, - rowFeatureIds, - columnOptions, - spatialWarning, - } - } - - const layerHeaders = layerMaps.flatMap(({ layer }) => [ - { - name: i18n.t('Value ({{layer}})', { layer: layer.name }), - dataKey: `${layer.id}_${VALUE_KEY}`, - type: TYPE_NUMBER, - }, - { - name: i18n.t('Legend ({{layer}})', { layer: layer.name }), - dataKey: `${layer.id}_${LEGEND_KEY}`, - type: TYPE_STRING, - }, - ]) - - if (isParentGrouped) { - const headers = [ - { name: i18n.t('ID'), dataKey: 'id', type: TYPE_STRING }, - { name: i18n.t('Name'), dataKey: 'name', type: TYPE_STRING }, - ...layerHeaders, - ] - - const groups = new Map() - allIds.forEach((id) => { - const baseProps = layerMaps.find((lm) => lm.byOrgUnit[id]) - ?.byOrgUnit[id] - const parentPath = getParentPath( - baseProps?.[ORG_UNIT_PATH_DATA_KEY] - ) - const parentId = getLastSegment(parentPath) - const key = parentId ?? NO_PARENT_KEY - if (!groups.has(key)) { - groups.set(key, { - id: parentId, - name: parentId - ? idToName.get(parentId) ?? parentId - : i18n.t('No parent'), - memberIds: [], - }) - } - groups.get(key).memberIds.push(id) - }) - - const rowFeatureIds = new Map() - - const flatRows = [...groups.values()].map((group, index) => { - const row = { id: group.id, name: group.name, index } - const featureIds = {} - layerMaps.forEach( - ({ layer, byOrgUnit, featureIdsByOrgUnit }) => { - const values = group.memberIds - .map((id) => byOrgUnit[id]?.[VALUE_KEY]) - .filter((v) => v != null) - const average = values.length - ? values.reduce((a, b) => a + b, 0) / values.length - : null - row[`${layer.id}_${VALUE_KEY}`] = average - row[`${layer.id}_${LEGEND_KEY}`] = null - - const ids = group.memberIds.flatMap( - (id) => featureIdsByOrgUnit[id] ?? [] - ) - if (ids.length) { - featureIds[layer.id] = ids - } - } - ) - rowFeatureIds.set(group.id, featureIds) - return row - }) - - const rows = finalizeRows(flatRows, headers, { - filters, - globalSearch, - sortField, - sortDirection, - }) - const columnOptions = - sortColumnOptions(getColumnDistinctValues(headers, flatRows), { - sortField, - sortDirection, - }) ?? EMPTY_COLUMN_OPTIONS - - return { - headers, - rows, - rowFeatureIds, - columnOptions, - spatialWarning: false, - } - } - - const headers = [ - { name: i18n.t('ID'), dataKey: 'id', type: TYPE_STRING }, - { name: i18n.t('Name'), dataKey: 'name', type: TYPE_STRING }, - { name: i18n.t('Level'), dataKey: 'level', type: TYPE_NUMBER }, - ...layerHeaders, + ]), ] const rowFeatureIds = new Map() - const flatRows = allIds.map((id, index) => { - const baseProps = - layerMaps.find((lm) => lm.byOrgUnit[id])?.byOrgUnit[id] ?? {} - const path = baseProps[ORG_UNIT_PATH_DATA_KEY] + const flatRows = referenceOrgUnits.map((referenceFeature, index) => { + const refProps = getProps(referenceFeature) const row = { - id, - name: path ? formatOrgUnitOwnName(path, idToName) : null, - level: baseProps[ORG_UNIT_LEVEL_DATA_KEY] ?? null, + id: refProps.id, + name: refProps.name ?? null, + level: refProps[ORG_UNIT_LEVEL_DATA_KEY] ?? null, index, } - const featureIds = {} - layerMaps.forEach(({ layer, byOrgUnit, featureIdsByOrgUnit }) => { - const props = byOrgUnit[id] - row[`${layer.id}_${VALUE_KEY}`] = props?.[VALUE_KEY] ?? null - row[`${layer.id}_${LEGEND_KEY}`] = props?.[LEGEND_KEY] ?? null - if (featureIdsByOrgUnit[id]?.length) { - featureIds[layer.id] = featureIdsByOrgUnit[id] + // Always includes the reference org unit's own feature, so + // "zoom to feature" has real bounds even when no participating + // layer has a match for this row. + const featureIds = { [referenceLayer.id]: [refProps.id] } + + layerMatches.forEach(({ layer, settings, byReferenceId }) => { + const matches = byReferenceId.get(refProps.id) ?? [] + const values = matches + .map((p) => p[VALUE_KEY]) + .filter((v) => v != null) + row[`${layer.id}_${VALUE_KEY}`] = applyAggregation( + settings.aggregation?.[VALUE_KEY] ?? DEFAULT_AGGREGATION, + values + ) + + const legends = matches + .map((p) => p[LEGEND_KEY]) + .filter((v) => v != null) + row[`${layer.id}_${LEGEND_KEY}`] = + legends.length && legends.every((l) => l === legends[0]) + ? legends[0] + : null + + const ids = matches.map((p) => p.id).filter((id) => id != null) + if (ids.length) { + featureIds[layer.id] = ids } }) - rowFeatureIds.set(id, featureIds) + + rowFeatureIds.set(refProps.id, featureIds) return row }) @@ -393,17 +269,12 @@ export const useCombinedTableData = ({ rows, rowFeatureIds, columnOptions, - spatialWarning: false, + spatialWarning, } }, [ - layers, - layerMaps, - allIds, - isSpatial, - isParentGrouped, - pointLayer, - polygonLayer, - idToName, + referenceOrgUnits, + referenceLayer, + layerMatches, filters, globalSearch, sortField, diff --git a/src/reducers/__tests__/dataTable.spec.js b/src/reducers/__tests__/dataTable.spec.js index 38d910b764..1032ec02fb 100644 --- a/src/reducers/__tests__/dataTable.spec.js +++ b/src/reducers/__tests__/dataTable.spec.js @@ -5,10 +5,7 @@ const initialState = { openIds: [], combinedView: false, joinConfig: { - level: 'orgUnit', - layerIds: [], - pointLayerId: null, - polygonLayerId: null, + layers: {}, }, } @@ -27,10 +24,9 @@ describe('dataTable reducer', () => { openIds: ['layer1', 'layer2'], combinedView: true, joinConfig: { - level: 'spatial', - layerIds: [], - pointLayerId: 'layer1', - polygonLayerId: 'layer2', + layers: { + layer1: { type: 'orgUnit', aggregation: {} }, + }, }, } @@ -43,10 +39,12 @@ describe('dataTable reducer', () => { openIds: ['layer1'], combinedView: false, joinConfig: { - level: 'parentOrgUnit', - layerIds: ['layer1', 'layer3'], - pointLayerId: null, - polygonLayerId: null, + layers: { + layer1: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, }, } @@ -63,10 +61,7 @@ describe('dataTable reducer', () => { openIds: ['layer1'], combinedView: false, joinConfig: { - level: 'orgUnit', - layerIds: ['layer1', 'layer2'], - pointLayerId: null, - polygonLayerId: null, + layers: { layer1: { type: 'orgUnit', aggregation: {} } }, }, } @@ -112,10 +107,7 @@ describe('dataTable reducer', () => { openIds: ['layer1'], combinedView: true, joinConfig: { - level: 'spatial', - layerIds: [], - pointLayerId: 'layerA', - polygonLayerId: 'layerB', + layers: { layerA: { type: 'spatial', aggregation: {} } }, }, } @@ -133,10 +125,7 @@ describe('dataTable reducer', () => { openIds: ['layer1'], combinedView: true, joinConfig: { - level: 'spatial', - layerIds: [], - pointLayerId: 'layerA', - polygonLayerId: 'layerB', + layers: { layerA: { type: 'spatial', aggregation: {} } }, }, } @@ -161,33 +150,14 @@ describe('dataTable reducer', () => { expect(state.openIds).toEqual(['layer2']) }) - it('clears the removed layer from joinConfig.layerIds', () => { + it("prunes the removed layer's own entry from joinConfig.layers", () => { const prevState = { ...initialState, joinConfig: { - level: 'orgUnit', - layerIds: ['layer1', 'layer2', 'layer3'], - pointLayerId: null, - polygonLayerId: null, - }, - } - - const state = dataTable(prevState, { - type: types.LAYER_REMOVE, - id: 'layer2', - }) - - expect(state.joinConfig.layerIds).toEqual(['layer1', 'layer3']) - }) - - it('clears a dangling pointLayerId/polygonLayerId reference', () => { - const prevState = { - ...initialState, - joinConfig: { - level: 'spatial', - layerIds: [], - pointLayerId: 'layer1', - polygonLayerId: 'layer2', + layers: { + layer1: { type: 'orgUnit', aggregation: {} }, + layer2: { type: 'spatial', aggregation: {} }, + }, }, } @@ -196,19 +166,16 @@ describe('dataTable reducer', () => { id: 'layer1', }) - expect(state.joinConfig.pointLayerId).toBe(null) - expect(state.joinConfig.polygonLayerId).toBe('layer2') + expect(state.joinConfig.layers).toEqual({ + layer2: { type: 'spatial', aggregation: {} }, + }) }) - it('turns combinedView off when the removal makes an orgUnit/parentOrgUnit join insufficient', () => { + it('is a no-op on joinConfig.layers when the removed layer was never a participant', () => { const prevState = { - openIds: [], - combinedView: true, + ...initialState, joinConfig: { - level: 'orgUnit', - layerIds: ['layer1', 'layer2'], - pointLayerId: null, - polygonLayerId: null, + layers: { layer2: { type: 'orgUnit', aggregation: {} } }, }, } @@ -217,38 +184,17 @@ describe('dataTable reducer', () => { id: 'layer1', }) - expect(state.combinedView).toBe(false) - }) - - it('turns combinedView off when the removal makes a spatial join insufficient', () => { - const prevState = { - openIds: [], - combinedView: true, - joinConfig: { - level: 'spatial', - layerIds: [], - pointLayerId: 'layer1', - polygonLayerId: 'layer2', - }, - } - - const state = dataTable(prevState, { - type: types.LAYER_REMOVE, - id: 'layer2', + expect(state.joinConfig.layers).toEqual({ + layer2: { type: 'orgUnit', aggregation: {} }, }) - - expect(state.combinedView).toBe(false) }) - it('keeps combinedView on when the removal leaves the join sufficient', () => { + it('leaves combinedView untouched (no cross-slice knowledge of the reference layer here)', () => { const prevState = { openIds: [], combinedView: true, joinConfig: { - level: 'orgUnit', - layerIds: ['layer1', 'layer2', 'layer3'], - pointLayerId: null, - polygonLayerId: null, + layers: { layer1: { type: 'orgUnit', aggregation: {} } }, }, } @@ -258,7 +204,7 @@ describe('dataTable reducer', () => { }) expect(state.combinedView).toBe(true) - expect(state.joinConfig.layerIds).toEqual(['layer2', 'layer3']) + expect(state.joinConfig.layers).toEqual({}) }) }) @@ -284,10 +230,12 @@ describe('dataTable reducer', () => { describe('DATA_TABLE_JOIN_CONFIG_SET', () => { it('replaces joinConfig wholesale', () => { const config = { - level: 'spatial', - layerIds: [], - pointLayerId: 'layer1', - polygonLayerId: 'layer2', + layers: { + layer1: { + type: 'spatial', + aggregation: { rawValue: 'AVERAGE' }, + }, + }, } const state = dataTable(initialState, { diff --git a/src/reducers/dataTable.js b/src/reducers/dataTable.js index e16c166ed2..122fb8bb8a 100644 --- a/src/reducers/dataTable.js +++ b/src/reducers/dataTable.js @@ -1,32 +1,18 @@ import * as types from '../constants/actionTypes.js' +// joinConfig.layers is keyed by participating layer id: { type: 'orgUnit' | +// 'spatial', aggregation: { [dataKey]: aggregationTypeId } }. The reference +// org unit set itself isn't stored here at all - it's derived from +// state.map.mapViews (the one layer with layer === COMBINED_TABLE_REF_LAYER), +// same as any other layer lookup, rather than duplicated into this slice. const initialState = { openIds: [], combinedView: false, joinConfig: { - level: 'orgUnit', - layerIds: [], - pointLayerId: null, - polygonLayerId: null, + layers: {}, }, } -const isJoinConfigSufficient = (joinConfig) => - joinConfig.level === 'spatial' - ? !!joinConfig.pointLayerId && !!joinConfig.polygonLayerId - : joinConfig.layerIds.length >= 2 - -const clearJoinConfigRefs = (joinConfig, removedId) => ({ - ...joinConfig, - layerIds: joinConfig.layerIds.filter((id) => id !== removedId), - pointLayerId: - joinConfig.pointLayerId === removedId ? null : joinConfig.pointLayerId, - polygonLayerId: - joinConfig.polygonLayerId === removedId - ? null - : joinConfig.polygonLayerId, -}) - const dataTable = (state = initialState, action) => { switch (action.type) { case types.DATA_TABLE_CLOSE: @@ -51,13 +37,21 @@ const dataTable = (state = initialState, action) => { } case types.LAYER_REMOVE: { - const joinConfig = clearJoinConfigRefs(state.joinConfig, action.id) + // Only prunes a removed *participating* layer's own join + // settings - this reducer only sees its own slice, not + // state.map.mapViews, so it can't tell here whether the removed + // layer was instead the reference layer itself. Not a gap in + // practice yet: the reference layer has no delete affordance of + // its own (it's hidden from the normal layer list/cards), so + // that case has no way to be triggered today. A future + // "reset reference" action would need to turn combinedView off + // itself when it removes the reference layer. + const layers = { ...state.joinConfig.layers } + delete layers[action.id] return { ...state, openIds: state.openIds.filter((id) => id !== action.id), - joinConfig, - combinedView: - state.combinedView && isJoinConfigSufficient(joinConfig), + joinConfig: { ...state.joinConfig, layers }, } } diff --git a/src/util/__tests__/spatialJoin.spec.js b/src/util/__tests__/spatialJoin.spec.js index d1c71b98ad..470c2edc2d 100644 --- a/src/util/__tests__/spatialJoin.spec.js +++ b/src/util/__tests__/spatialJoin.spec.js @@ -1,4 +1,4 @@ -import { spatialJoin } from '../spatialJoin.js' +import { matchFeaturesToReferenceOrgUnits } from '../spatialJoin.js' const square = (id, [minX, minY, maxX, maxY]) => ({ id, @@ -25,78 +25,90 @@ const point = (id, [x, y]) => ({ geometry: { type: 'Point', coordinates: [x, y] }, }) -describe('spatialJoin', () => { - test('matches a point to the polygon that contains it', () => { - const pointLayer = { data: [point('p1', [1, 1])] } - const polygonLayer = { data: [square('a', [0, 0, 2, 2])] } +describe('matchFeaturesToReferenceOrgUnits', () => { + test('matches a point to the reference org unit whose polygon contains it', () => { + const features = [point('p1', [1, 1])] + const referenceOrgUnits = [square('a', [0, 0, 2, 2])] - const result = spatialJoin(pointLayer, polygonLayer) - - expect(result).toEqual([ - { - pointProps: { id: 'p1', name: 'Point p1' }, - polygonProps: { id: 'a', name: 'Polygon a' }, - }, + expect( + matchFeaturesToReferenceOrgUnits(features, referenceOrgUnits) + ).toEqual([ + { featureProps: { id: 'p1', name: 'Point p1' }, referenceId: 'a' }, ]) }) - test('leaves polygonProps null for a point outside every polygon', () => { - const pointLayer = { data: [point('p1', [10, 10])] } - const polygonLayer = { data: [square('a', [0, 0, 2, 2])] } + test('leaves referenceId null for a point outside every reference polygon', () => { + const features = [point('p1', [10, 10])] + const referenceOrgUnits = [square('a', [0, 0, 2, 2])] - const result = spatialJoin(pointLayer, polygonLayer) + expect( + matchFeaturesToReferenceOrgUnits(features, referenceOrgUnits) + ).toEqual([ + { featureProps: { id: 'p1', name: 'Point p1' }, referenceId: null }, + ]) + }) - expect(result).toEqual([ - { - pointProps: { id: 'p1', name: 'Point p1' }, - polygonProps: null, - }, + test('matches each feature independently against multiple reference org units', () => { + const features = [point('p1', [1, 1]), point('p2', [11, 11])] + const referenceOrgUnits = [ + square('a', [0, 0, 2, 2]), + square('b', [10, 10, 12, 12]), + ] + + expect( + matchFeaturesToReferenceOrgUnits(features, referenceOrgUnits) + ).toEqual([ + { featureProps: { id: 'p1', name: 'Point p1' }, referenceId: 'a' }, + { featureProps: { id: 'p2', name: 'Point p2' }, referenceId: 'b' }, ]) }) - test('matches each point independently against multiple polygons', () => { - const pointLayer = { - data: [point('p1', [1, 1]), point('p2', [11, 11])], - } - const polygonLayer = { - data: [square('a', [0, 0, 2, 2]), square('b', [10, 10, 12, 12])], - } + test('ignores non-polygon features among the reference org units', () => { + const features = [point('p1', [1, 1])] + const referenceOrgUnits = [ + point('notAPolygon', [1, 1]), + square('a', [0, 0, 2, 2]), + ] - const result = spatialJoin(pointLayer, polygonLayer) + expect( + matchFeaturesToReferenceOrgUnits(features, referenceOrgUnits)[0] + .referenceId + ).toBe('a') + }) - expect(result).toEqual([ - { - pointProps: { id: 'p1', name: 'Point p1' }, - polygonProps: { id: 'a', name: 'Polygon a' }, - }, + test('leaves a non-point feature untestable (referenceId null) without useCentroid', () => { + const features = [square('poly1', [0.5, 0.5, 1.5, 1.5])] + const referenceOrgUnits = [square('a', [0, 0, 2, 2])] + + expect( + matchFeaturesToReferenceOrgUnits(features, referenceOrgUnits) + ).toEqual([ { - pointProps: { id: 'p2', name: 'Point p2' }, - polygonProps: { id: 'b', name: 'Polygon b' }, + featureProps: { id: 'poly1', name: 'Polygon poly1' }, + referenceId: null, }, ]) }) - test('ignores non-polygon features in the polygon layer', () => { - const pointLayer = { data: [point('p1', [1, 1])] } - const polygonLayer = { - data: [ - { geometry: { type: 'Point', coordinates: [1, 1] } }, - square('a', [0, 0, 2, 2]), - ], - } - - const result = spatialJoin(pointLayer, polygonLayer) + test('matches a non-point feature via its centroid when useCentroid is set', () => { + const features = [square('poly1', [0.5, 0.5, 1.5, 1.5])] + const referenceOrgUnits = [square('a', [0, 0, 2, 2])] - expect(result[0].polygonProps).toEqual({ id: 'a', name: 'Polygon a' }) + expect( + matchFeaturesToReferenceOrgUnits(features, referenceOrgUnits, { + useCentroid: true, + }) + ).toEqual([ + { + featureProps: { id: 'poly1', name: 'Polygon poly1' }, + referenceId: 'a', + }, + ]) }) - test('returns an empty array when the point layer has no data', () => { + test('returns an empty array when there are no features', () => { expect( - spatialJoin({ data: [] }, { data: [square('a', [0, 0, 2, 2])] }) + matchFeaturesToReferenceOrgUnits([], [square('a', [0, 0, 2, 2])]) ).toEqual([]) }) - - test('tolerates a missing data array on either layer', () => { - expect(spatialJoin({}, {})).toEqual([]) - }) }) diff --git a/src/util/spatialJoin.js b/src/util/spatialJoin.js index 037671ad6f..8db83fdd92 100644 --- a/src/util/spatialJoin.js +++ b/src/util/spatialJoin.js @@ -1,28 +1,56 @@ import { booleanPointInPolygon } from '@turf/boolean-point-in-polygon' -import { GEO_TYPE_POLYGON, GEO_TYPE_MULTIPOLYGON } from './geojson.js' +import turfCentroid from '@turf/centroid' +import { + GEO_TYPE_POINT, + GEO_TYPE_POLYGON, + GEO_TYPE_MULTIPOLYGON, +} from './geojson.js' + +const isPolygon = (geometry) => + [GEO_TYPE_POLYGON, GEO_TYPE_MULTIPOLYGON].includes(geometry?.type) + +// A feature is tested as-is when it's already a point; when it isn't (e.g. +// an Event/TrackedEntity feature whose geometry happens to be a polygon) +// and useCentroid is set, its centroid stands in for it instead. Non-point +// geometry is otherwise left untestable (returns null) rather than +// silently matching on the wrong shape. +const getTestPoint = (feature, useCentroid) => { + if (feature.geometry?.type === GEO_TYPE_POINT) { + return feature + } + return useCentroid && feature.geometry + ? turfCentroid(feature.geometry) + : null +} /** - * For each point feature in pointLayer.data, finds the first polygon feature - * in polygonLayer.data that spatially contains it. + * Matches each of `features` against whichever `referenceOrgUnits` feature's + * polygon geometry spatially contains it - used for a participating layer's + * "spatial join" against the Combined data table's reference org unit set + * (every reference org unit acts as one bucket, unlike the single fixed + * polygon layer the old point+polygon join used). * - * @param {{ data: object[] }} pointLayer - * @param {{ data: object[] }} polygonLayer - * @returns {Array<{ pointProps: object, polygonProps: object|null }>} + * @param {object[]} features + * @param {object[]} referenceOrgUnits + * @param {{ useCentroid?: boolean }} [options] + * @returns {Array<{ featureProps: object, referenceId: string|null }>} */ -export const spatialJoin = (pointLayer, polygonLayer) => { - const points = pointLayer.data ?? [] - const polygons = polygonLayer.data ?? [] +export const matchFeaturesToReferenceOrgUnits = ( + features, + referenceOrgUnits, + { useCentroid = false } = {} +) => { + const polygons = referenceOrgUnits.filter((ref) => isPolygon(ref.geometry)) + + return features.map((feature) => { + const testPoint = getTestPoint(feature, useCentroid) + const matched = testPoint + ? polygons.find((ref) => booleanPointInPolygon(testPoint, ref)) + : null - return points.map((pointFeature) => { - const matched = polygons.find( - (poly) => - [GEO_TYPE_POLYGON, GEO_TYPE_MULTIPOLYGON].includes( - poly.geometry?.type - ) && booleanPointInPolygon(pointFeature, poly) - ) return { - pointProps: pointFeature.properties || pointFeature, - polygonProps: matched?.properties ?? null, + featureProps: feature.properties || feature, + referenceId: matched ? (matched.properties || matched).id : null, } }) } From f6f4ca85e8014d4533a2500d3487970208551e40 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 28 Jul 2026 18:59:52 +0200 Subject: [PATCH 162/205] feat: persist combinedJoinConfig on the reference layer [DHIS2-20543] Stamps state.dataTable.joinConfig.layers onto the combinedTableRef mapView just before save (its own Redux slice, so it can't ride along automatically the way dataTableColumnConfig does), packs/restores it through the existing config JSON mechanism (favorites.js/ orgUnitLoader.js), and hydrates it back into session state once, the moment the reference layer finishes loading a saved map. --- src/components/app/FileMenu.jsx | 20 ++++++- src/components/datatable/BottomPanel.jsx | 18 +++++++ .../datatable/__tests__/BottomPanel.spec.jsx | 54 +++++++++++++++++++ src/loaders/orgUnitLoader.js | 7 +++ src/util/__tests__/favorites.spec.js | 24 +++++++++ src/util/favorites.js | 9 +++- 6 files changed, 129 insertions(+), 3 deletions(-) diff --git a/src/components/app/FileMenu.jsx b/src/components/app/FileMenu.jsx index cfd1b6c188..d9932e7d09 100644 --- a/src/components/app/FileMenu.jsx +++ b/src/components/app/FileMenu.jsx @@ -18,6 +18,7 @@ import { ALERT_OPTIONS_DYNAMIC, ALERT_SUCCESS_DELAY, } from '../../constants/alerts.js' +import { COMBINED_TABLE_REF_LAYER } from '../../constants/layers.js' import { cleanMapConfig } from '../../util/favorites.js' import { addOrgUnitPaths } from '../../util/helpers.js' import history from '../../util/history.js' @@ -63,8 +64,23 @@ const getSaveFailureMessage = (message) => nsSeparator: ';', }) +// state.dataTable.joinConfig.layers lives in its own Redux slice, not on the +// combinedTableRef mapView itself, so (unlike dataTableColumnConfig, which +// is already stamped directly onto its layer as it's edited) it needs an +// explicit copy onto that layer right before cleanMapConfig runs, or it +// would never reach favorites.js's packing logic at all. +const stampCombinedJoinConfig = (map, joinConfig) => ({ + ...map, + mapViews: map.mapViews.map((view) => + view.layer === COMBINED_TABLE_REF_LAYER + ? { ...view, combinedJoinConfig: joinConfig.layers } + : view + ), +}) + const FileMenu = ({ onFileMenuAction }) => { const map = useSelector((state) => state.map) + const joinConfig = useSelector((state) => state.dataTable.joinConfig) const dispatch = useDispatch() const engine = useDataEngine() const { serverVersion } = useConfig() @@ -118,7 +134,7 @@ const FileMenu = ({ onFileMenuAction }) => { }) const cleanedMap = cleanMapConfig({ - config: map, + config: stampCombinedJoinConfig(map, joinConfig), defaultBasemapId: defaultBasemap, serverVersion, }) @@ -190,7 +206,7 @@ const FileMenu = ({ onFileMenuAction }) => { const onSaveAs = async ({ name, description }) => { const cleanedMap = cleanMapConfig({ - config: map, + config: stampCombinedJoinConfig(map, joinConfig), defaultBasemapId: defaultBasemap, serverVersion, }) diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index 28fbb643f7..553b571a89 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -247,6 +247,24 @@ const BottomPanel = () => { return () => observer.disconnect() }, []) + // Restores a saved map's per-layer join type/aggregation choices once, + // the moment the reference layer finishes loading and its persisted + // combinedJoinConfig comes in (see favorites.js/orgUnitLoader.js) - the + // ref guard means it never re-fires and clobbers a live in-session edit + // (e.g. after the reference layer is later re-edited/reloaded). + const hasHydratedJoinConfigRef = useRef(false) + useEffect(() => { + if ( + hasHydratedJoinConfigRef.current || + !referenceLayer?.isLoaded || + !referenceLayer.combinedJoinConfig + ) { + return + } + hasHydratedJoinConfigRef.current = true + dispatch(setJoinConfig({ layers: referenceLayer.combinedJoinConfig })) + }, [referenceLayer, dispatch]) + useKeyDown('Escape', onCloseDataTable, true) return ( diff --git a/src/components/datatable/__tests__/BottomPanel.spec.jsx b/src/components/datatable/__tests__/BottomPanel.spec.jsx index 68afa6de3b..88eada0ec2 100644 --- a/src/components/datatable/__tests__/BottomPanel.spec.jsx +++ b/src/components/datatable/__tests__/BottomPanel.spec.jsx @@ -365,3 +365,57 @@ describe('BottomPanel Combined join controls', () => { ]) }) }) + +describe('BottomPanel joinConfig hydration from a saved reference layer', () => { + const persistedJoinConfig = { + layerA: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + } + + test("restores a loaded reference layer's persisted combinedJoinConfig once", () => { + const { store } = renderBottomPanel({ + mapViews: [ + ...DEFAULT_MAP_VIEWS, + { + ...referenceLayer(), + isLoaded: true, + combinedJoinConfig: persistedJoinConfig, + }, + ], + }) + + expect(store.getActions()).toContainEqual({ + type: 'DATA_TABLE_JOIN_CONFIG_SET', + config: { layers: persistedJoinConfig }, + }) + }) + + test('does not restore anything when the reference layer has not finished loading yet', () => { + const { store } = renderBottomPanel({ + mapViews: [ + ...DEFAULT_MAP_VIEWS, + { + ...referenceLayer(), + isLoaded: false, + combinedJoinConfig: persistedJoinConfig, + }, + ], + }) + + expect(store.getActions()).not.toContainEqual( + expect.objectContaining({ type: 'DATA_TABLE_JOIN_CONFIG_SET' }) + ) + }) + + test('does not restore anything when the reference layer has no persisted combinedJoinConfig', () => { + const { store } = renderBottomPanel({ + mapViews: [ + ...DEFAULT_MAP_VIEWS, + { ...referenceLayer(), isLoaded: true }, + ], + }) + + expect(store.getActions()).not.toContainEqual( + expect.objectContaining({ type: 'DATA_TABLE_JOIN_CONFIG_SET' }) + ) + }) +}) diff --git a/src/loaders/orgUnitLoader.js b/src/loaders/orgUnitLoader.js index 9d32ea932c..ec81772dc9 100644 --- a/src/loaders/orgUnitLoader.js +++ b/src/loaders/orgUnitLoader.js @@ -74,6 +74,7 @@ const orgUnitLoader = async ({ countFeaturesWithoutCoordinates, unclassifiedLegend, dataTableColumnConfig, + combinedJoinConfig, } = parseJsonConfig(config.config) if (countFeaturesWithoutCoordinates) { config.countFeaturesWithoutCoordinates = true @@ -84,6 +85,12 @@ const orgUnitLoader = async ({ if (dataTableColumnConfig) { config.dataTableColumnConfig = dataTableColumnConfig } + if (combinedJoinConfig) { + // Only ever set on the combinedTableRef layer - see FileMenu.jsx, + // which stamps state.dataTable.joinConfig.layers onto it just + // before save. + config.combinedJoinConfig = combinedJoinConfig + } delete config.config // Data loading diff --git a/src/util/__tests__/favorites.spec.js b/src/util/__tests__/favorites.spec.js index e1fdcb9f17..14b45f358d 100644 --- a/src/util/__tests__/favorites.spec.js +++ b/src/util/__tests__/favorites.spec.js @@ -1018,6 +1018,30 @@ describe('cleanMapConfig', () => { expect(mapView).not.toHaveProperty('dataTableColumnConfig') }) + test('serializes combinedJoinConfig into config JSON for the combinedTableRef layer', () => { + const combinedJoinConfig = { + layerA: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + } + const config = { + mapViews: [ + { + layer: 'combinedTableRef', + name: 'Reference org units', + rows: [], + combinedJoinConfig, + }, + ], + } + const cleanedConfig = cleanMapConfig({ + config, + defaultBasemapId: 'default', + }) + const mapView = cleanedConfig.mapViews[0] + const parsedConfig = JSON.parse(mapView.config) + expect(parsedConfig.combinedJoinConfig).toEqual(combinedJoinConfig) + expect(mapView).not.toHaveProperty('combinedJoinConfig') + }) + test('serializes dataTableColumnConfig into config JSON for geojson layer', () => { const dataTableColumnConfig = { orderedKeys: ['name', 'id'] } const config = { diff --git a/src/util/favorites.js b/src/util/favorites.js index 96032a36aa..ae7ae3a367 100644 --- a/src/util/favorites.js +++ b/src/util/favorites.js @@ -1,5 +1,6 @@ import { isNil, omitBy, pick, isObject, omit } from 'lodash/fp' import { + COMBINED_TABLE_REF_LAYER, EARTH_ENGINE_LAYER, EVENT_LAYER, FACILITY_LAYER, @@ -35,6 +36,7 @@ const validLayerProperties = [ 'colorLow', // Deprecated 'colorScale', 'columns', + 'combinedJoinConfig', // only ever set on the combinedTableRef layer 'config', 'created', 'dataTableColumnConfig', @@ -184,6 +186,9 @@ const buildCommonLayerConfigData = (layer) => { if (layer.dataTableColumnConfig) { configData.dataTableColumnConfig = layer.dataTableColumnConfig } + if (layer.combinedJoinConfig) { + configData.combinedJoinConfig = layer.combinedJoinConfig + } return configData } @@ -199,6 +204,7 @@ const deleteCommonLayerConfigProps = (layer) => { delete layer.countEventsOutsideOrgUnits delete layer.labelDataItem delete layer.dataTableColumnConfig + delete layer.combinedJoinConfig } const buildEarthEngineLayerConfigData = (layer) => { @@ -301,7 +307,8 @@ const models2objects = (layer, cleanMapviewConfig) => { layerType === EVENT_LAYER || layerType === THEMATIC_LAYER || layerType === ORG_UNIT_LAYER || - layerType === FACILITY_LAYER + layerType === FACILITY_LAYER || + layerType === COMBINED_TABLE_REF_LAYER ) { if (cleanMapviewConfig) { const configData = buildCommonLayerConfigData(layer) From 8825a1d015301544df492fb905d825d456788624 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 28 Jul 2026 19:17:44 +0200 Subject: [PATCH 163/205] fix: close Combined join gaps found in fresh-context review [DHIS2-20543] - DataTableButton.jsx (the menu-bar quick-open shortcut) still built a joinConfig in the old level/layerIds/pointLayerId/polygonLayerId shape and forced Combined on directly, bypassing the reference-layer requirement entirely and crashing the table - it now only opens Combined when a reference is already configured, falling back to the first eligible layer otherwise. - The dataTable reducer wiped joinConfig back to empty on DATA_TABLE_CLOSE/DOWNLOAD_MODE_OPEN/DOWNLOAD_MODE_CLOSE, silently discarding a user's join setup on the ordinary, frequent act of closing the panel - now only openIds/combinedView reset there, joinConfig itself survives (it's real, savable configuration now, not throwaway display state). - JoinLayersControl defaulted every newly-joined layer to Org unit join, which can never match for a layer with no org-unit identity of its own (e.g. GeoJSON URL) - now defaults to whichever join type can actually produce a result. --- src/components/datatable/DataTableButton.jsx | 30 ++++++++-------- .../datatable/__tests__/BottomPanel.spec.jsx | 14 ++++++-- .../__tests__/DataTableButton.spec.jsx | 30 ++++++++++------ .../__tests__/JoinLayersControl.spec.jsx | 30 +++++++++++++++- .../datatable/controls/JoinLayersControl.jsx | 30 +++++++++++----- src/reducers/__tests__/dataTable.spec.js | 35 +++++++++++++++---- src/reducers/dataTable.js | 12 ++++++- 7 files changed, 135 insertions(+), 46 deletions(-) diff --git a/src/components/datatable/DataTableButton.jsx b/src/components/datatable/DataTableButton.jsx index 258dbca536..1afb8a0094 100644 --- a/src/components/datatable/DataTableButton.jsx +++ b/src/components/datatable/DataTableButton.jsx @@ -1,15 +1,13 @@ import i18n from '@dhis2/d2-i18n' import React from 'react' import { useDispatch, useSelector } from 'react-redux' -import { - toggleDataTable, - toggleCombinedView, - setJoinConfig, -} from '../../actions/dataTable.js' +import { toggleDataTable, toggleCombinedView } from '../../actions/dataTable.js' +import { getOrgUnitsFromRows } from '../../util/analytics.js' import { getEligibleDataTableLayers, isDataTableOpen, } from '../../util/dataTable.js' +import { useReferenceLayer } from './controls/ReferenceOrgUnitControl.jsx' import styles from './styles/DataTableButton.module.css' const DataTableButton = () => { @@ -17,25 +15,25 @@ const DataTableButton = () => { const dataTable = useSelector((state) => state.dataTable) const mapViews = useSelector((state) => state.map.mapViews) const eligibleLayers = getEligibleDataTableLayers(mapViews) + const { referenceLayer } = useReferenceLayer() + const combinedEnabled = + !!referenceLayer && getOrgUnitsFromRows(referenceLayer.rows).length > 0 const onClick = () => { // Only a quick-open shortcut for the closed state - if a table is // already showing (single-layer or Combined), this is a no-op; the - // panel's own Close button is the only way to close it. + // panel's own Close button is the only way to close it. Combined + // is only auto-opened here when a reference org unit set has + // already been configured (mirrors BottomPanel.jsx's own + // combinedEnabled gate) - otherwise there'd be nothing to show, so + // this shortcut falls back to just opening the first eligible + // layer's own table instead. if (isDataTableOpen(dataTable)) { return } - if (eligibleLayers.length >= 2) { - dispatch( - setJoinConfig({ - level: 'orgUnit', - layerIds: eligibleLayers.map((l) => l.id), - pointLayerId: null, - polygonLayerId: null, - }) - ) + if (combinedEnabled) { dispatch(toggleCombinedView()) - } else if (eligibleLayers.length === 1) { + } else if (eligibleLayers.length >= 1) { dispatch(toggleDataTable(eligibleLayers[0].id)) } } diff --git a/src/components/datatable/__tests__/BottomPanel.spec.jsx b/src/components/datatable/__tests__/BottomPanel.spec.jsx index 88eada0ec2..cf43d50568 100644 --- a/src/components/datatable/__tests__/BottomPanel.spec.jsx +++ b/src/components/datatable/__tests__/BottomPanel.spec.jsx @@ -108,8 +108,18 @@ describe('BottomPanel resize cancel', () => { }) const twoEligibleLayers = [ - { id: 'layer1', name: 'Layer 1', layer: THEMATIC_LAYER, data: [{}] }, - { id: 'layer2', name: 'Layer 2', layer: THEMATIC_LAYER, data: [{}] }, + { + id: 'layer1', + name: 'Layer 1', + layer: THEMATIC_LAYER, + data: [{ properties: { orgUnitPath: '/country1/ou1' } }], + }, + { + id: 'layer2', + name: 'Layer 2', + layer: THEMATIC_LAYER, + data: [{ properties: { orgUnitPath: '/country1/ou2' } }], + }, ] const getLayerSelector = () => screen.getByTestId('data-table-layer-selector') diff --git a/src/components/datatable/__tests__/DataTableButton.spec.jsx b/src/components/datatable/__tests__/DataTableButton.spec.jsx index f37e83fd56..291b0ac824 100644 --- a/src/components/datatable/__tests__/DataTableButton.spec.jsx +++ b/src/components/datatable/__tests__/DataTableButton.spec.jsx @@ -15,6 +15,14 @@ const layer = (id, overrides = {}) => ({ ...overrides, }) +const referenceLayer = ( + rows = [{ dimension: 'ou', items: [{ id: 'country1' }] }] +) => ({ + id: 'ref1', + layer: 'combinedTableRef', + rows, +}) + const renderButton = ({ dataTable, mapViews }) => { const store = mockStore({ dataTable, @@ -50,22 +58,24 @@ describe('DataTableButton', () => { ]) }) - test('opens Combined, pre-populated with every eligible layer, when 2+ are eligible', () => { + test('opens the first eligible layer, not Combined, when 2+ are eligible but no reference is configured yet', () => { const { store } = renderButton({ dataTable: CLOSED, mapViews: [layer('a'), layer('b')], }) fireEvent.click(screen.getByText('Data table')) expect(store.getActions()).toEqual([ - { - type: 'DATA_TABLE_JOIN_CONFIG_SET', - config: { - level: 'orgUnit', - layerIds: ['a', 'b'], - pointLayerId: null, - polygonLayerId: null, - }, - }, + { type: 'DATA_TABLE_TOGGLE', id: 'a' }, + ]) + }) + + test('opens Combined directly when a reference org unit set has already been configured', () => { + const { store } = renderButton({ + dataTable: CLOSED, + mapViews: [layer('a'), layer('b'), referenceLayer()], + }) + fireEvent.click(screen.getByText('Data table')) + expect(store.getActions()).toEqual([ { type: 'DATA_TABLE_COMBINED_VIEW_TOGGLE' }, ]) }) diff --git a/src/components/datatable/__tests__/JoinLayersControl.spec.jsx b/src/components/datatable/__tests__/JoinLayersControl.spec.jsx index 0ac65a8dc7..99c44a94b5 100644 --- a/src/components/datatable/__tests__/JoinLayersControl.spec.jsx +++ b/src/components/datatable/__tests__/JoinLayersControl.spec.jsx @@ -14,7 +14,12 @@ const eligibleLayers = [ id: 'layer2', name: 'Layer 2', layer: THEMATIC_LAYER, - data: [{ geometry: { type: 'Point' } }], + data: [ + { + properties: { orgUnitPath: '/country1/ou1' }, + geometry: { type: 'Point' }, + }, + ], }, ] @@ -88,6 +93,29 @@ describe('JoinLayersControl popover — checkbox list', () => { }) }) + test('checking a layer with no org-unit identity of its own defaults to Spatial join, not Org unit', () => { + const onChange = jest.fn() + renderControl({ + eligibleLayers: [ + { + id: 'geo', + name: 'Zones', + layer: GEOJSON_URL_LAYER, + data: [{ geometry: { type: 'Point' } }], + }, + ], + layersConfig: {}, + onChange, + }) + openPicker() + + fireEvent.click(screen.getByRole('checkbox', { name: 'Zones' })) + + expect(onChange).toHaveBeenCalledWith({ + geo: { type: 'spatial', aggregation: { rawValue: 'SUM' } }, + }) + }) + test('unchecking a joined layer removes it from the config', () => { const onChange = jest.fn() renderControl({ diff --git a/src/components/datatable/controls/JoinLayersControl.jsx b/src/components/datatable/controls/JoinLayersControl.jsx index ebc9aca2a8..fa30803e83 100644 --- a/src/components/datatable/controls/JoinLayersControl.jsx +++ b/src/components/datatable/controls/JoinLayersControl.jsx @@ -3,6 +3,7 @@ import { IconVisualizationColumnMulti16 } from '@dhis2/ui' import PropTypes from 'prop-types' import React, { useRef, useState } from 'react' import { getCombinedAggregationTypes } from '../../../constants/aggregationTypes.js' +import { ORG_UNIT_PATH_DATA_KEY } from '../../../constants/dataTable.js' import { GEO_TYPE_POINT, GEO_TYPE_POLYGON, @@ -13,10 +14,6 @@ import styles from './styles/JoinLayersControl.module.css' import ToolbarIconButton from './ToolbarIconButton.jsx' const VALUE_KEY = 'rawValue' -const DEFAULT_SETTINGS = { - type: 'orgUnit', - aggregation: { [VALUE_KEY]: 'SUM' }, -} // Spatial join means point-in-polygon against the reference org unit's own // boundary - offered for any layer whose features are literally points, or @@ -32,17 +29,32 @@ const isSpatialEligible = (layer) => { ) } +// A layer with no org-unit path on its own features (e.g. GeoJSON URL) can +// never match anything under "Org unit" join - defaulting a newly-checked +// layer to that mode would silently leave every cell blank until the user +// happens to switch it to Spatial themselves. Default to whichever mode can +// actually match instead. +const hasOrgUnitIdentity = (layer) => { + const feature = layer.data?.[0] + return !!(feature?.properties ?? feature)?.[ORG_UNIT_PATH_DATA_KEY] +} + +const getDefaultSettings = (layer) => ({ + type: hasOrgUnitIdentity(layer) ? 'orgUnit' : 'spatial', + aggregation: { [VALUE_KEY]: 'SUM' }, +}) + const JoinLayersControl = ({ eligibleLayers, layersConfig, onChange }) => { const anchorRef = useRef(null) const [isOpen, setIsOpen] = useState(false) const aggregationTypes = getCombinedAggregationTypes() - const onToggle = (layerId) => { + const onToggle = (layer) => { const next = { ...layersConfig } - if (next[layerId]) { - delete next[layerId] + if (next[layer.id]) { + delete next[layer.id] } else { - next[layerId] = DEFAULT_SETTINGS + next[layer.id] = getDefaultSettings(layer) } onChange(next) } @@ -94,7 +106,7 @@ const JoinLayersControl = ({ eligibleLayers, layersConfig, onChange }) => { <input type="checkbox" checked={!!settings} - onChange={() => onToggle(layer.id)} + onChange={() => onToggle(layer)} /> <span className={styles.layerName}> {layer.name} diff --git a/src/reducers/__tests__/dataTable.spec.js b/src/reducers/__tests__/dataTable.spec.js index 1032ec02fb..ebe9f13cab 100644 --- a/src/reducers/__tests__/dataTable.spec.js +++ b/src/reducers/__tests__/dataTable.spec.js @@ -14,12 +14,7 @@ describe('dataTable reducer', () => { expect(dataTable(undefined, {})).toEqual(initialState) }) - it.each([ - types.DATA_TABLE_CLOSE, - types.MAP_NEW, - types.DOWNLOAD_MODE_CLOSE, - types.DOWNLOAD_MODE_OPEN, - ])('resets to the initial state on %s', (type) => { + it('resets fully to the initial state on MAP_NEW', () => { const state = { openIds: ['layer1', 'layer2'], combinedView: true, @@ -30,9 +25,35 @@ describe('dataTable reducer', () => { }, } - expect(dataTable(state, { type })).toEqual(initialState) + expect(dataTable(state, { type: types.MAP_NEW })).toEqual(initialState) }) + it.each([ + types.DATA_TABLE_CLOSE, + types.DOWNLOAD_MODE_CLOSE, + types.DOWNLOAD_MODE_OPEN, + ])( + 'resets openIds/combinedView but preserves joinConfig on %s - it is savable configuration, not throwaway display state', + (type) => { + const joinConfig = { + layers: { + layer1: { type: 'orgUnit', aggregation: {} }, + }, + } + const state = { + openIds: ['layer1', 'layer2'], + combinedView: true, + joinConfig, + } + + expect(dataTable(state, { type })).toEqual({ + openIds: [], + combinedView: false, + joinConfig, + }) + } + ) + describe('MAP_SET', () => { it('restores dataTable state from the payload when present', () => { const restored = { diff --git a/src/reducers/dataTable.js b/src/reducers/dataTable.js index 122fb8bb8a..58d5ee41b3 100644 --- a/src/reducers/dataTable.js +++ b/src/reducers/dataTable.js @@ -15,10 +15,20 @@ const initialState = { const dataTable = (state = initialState, action) => { switch (action.type) { + // Closes the whole panel (or leaves the data table view while + // entering/exiting download mode) - resets which tab(s) are open + // and whether Combined is the active view, but preserves joinConfig + // itself. joinConfig is real, savable configuration now (see + // favorites.js/FileMenu.jsx), not just session-only display state - + // wiping it here would silently discard it the moment a user closes + // the panel before saving, an extremely common, low-stakes action + // that has nothing to do with abandoning their join setup. case types.DATA_TABLE_CLOSE: - case types.MAP_NEW: case types.DOWNLOAD_MODE_CLOSE: case types.DOWNLOAD_MODE_OPEN: + return { ...initialState, joinConfig: state.joinConfig } + + case types.MAP_NEW: return initialState case types.MAP_SET: From 43f0905ee593c32f128157f9b240d4f981160d87 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 28 Jul 2026 21:16:14 +0200 Subject: [PATCH 164/205] fix: merge Earth Engine's aggregation values into Combined joins [DHIS2-20543] Earth Engine layers compute their value(s) client-side into their own Redux slice (state.aggregations, keyed by feature id) rather than attaching them to the feature itself like every other layer type - the single-layer table already merges this in, but the Combined join engine never did, so Earth Engine columns were always blank there. Earth Engine's value shape also isn't a single column like Thematic's rawValue - it's one column per aggregation stat (mean/min/max/etc) or one per legend class (percentage/hectares/acres), so useCombinedTableData.js's per-layer value dataKey(s) are now generic (getCombinedValueDataKeys), and JoinLayersControl.jsx renders one aggregation-type select per column a layer actually contributes, instead of a single hardcoded rawValue select. --- .../datatable/CombinedDataTable.jsx | 7 +- .../__tests__/JoinLayersControl.spec.jsx | 75 +++++++++- .../__tests__/useCombinedTableData.spec.js | 108 +++++++++++++- .../datatable/controls/JoinLayersControl.jsx | 100 +++++++++---- .../styles/JoinLayersControl.module.css | 32 ++++- .../datatable/useCombinedTableData.js | 135 +++++++++++++----- src/util/__tests__/dataTable.spec.js | 56 +++++++- src/util/dataTable.js | 59 +++++++- 8 files changed, 501 insertions(+), 71 deletions(-) diff --git a/src/components/datatable/CombinedDataTable.jsx b/src/components/datatable/CombinedDataTable.jsx index b0755e5fa5..1f4b347916 100644 --- a/src/components/datatable/CombinedDataTable.jsx +++ b/src/components/datatable/CombinedDataTable.jsx @@ -3,7 +3,7 @@ import { DataTableRow, DataTableCell } from '@dhis2/ui' import cx from 'classnames' import PropTypes from 'prop-types' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useDispatch } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import { TableVirtuoso } from 'react-virtuoso' import { highlightFeature } from '../../actions/feature.js' import { setCrossLayerSelection } from '../../actions/selection.js' @@ -83,6 +83,10 @@ const CombinedDataTable = ({ const { systemSettings: { keyAnalysisDigitGroupSeparator }, } = useCachedData() + // Earth Engine layers compute their value(s) client-side into this + // slice rather than attaching them to the feature itself - see + // useCombinedTableData.js's own mergeAggregations. + const aggregations = useSelector((state) => state.aggregations) const { sortField, sortDirection, sortData } = useSortState('name') @@ -95,6 +99,7 @@ const CombinedDataTable = ({ sortDirection, filters, globalSearch, + aggregations, }) useEffect(() => { diff --git a/src/components/datatable/__tests__/JoinLayersControl.spec.jsx b/src/components/datatable/__tests__/JoinLayersControl.spec.jsx index 99c44a94b5..6f3871e5c3 100644 --- a/src/components/datatable/__tests__/JoinLayersControl.spec.jsx +++ b/src/components/datatable/__tests__/JoinLayersControl.spec.jsx @@ -1,6 +1,10 @@ import { render, fireEvent, screen, within } from '@testing-library/react' import React from 'react' -import { GEOJSON_URL_LAYER, THEMATIC_LAYER } from '../../../constants/layers.js' +import { + EARTH_ENGINE_LAYER, + GEOJSON_URL_LAYER, + THEMATIC_LAYER, +} from '../../../constants/layers.js' import JoinLayersControl from '../controls/JoinLayersControl.jsx' const eligibleLayers = [ @@ -237,4 +241,73 @@ describe('JoinLayersControl popover — per-layer type/aggregation settings', () layer1: { type: 'orgUnit', aggregation: { rawValue: 'AVERAGE' } }, }) }) + + test('shows one labeled aggregation select per Earth Engine stat, and checking it defaults every stat to SUM', () => { + const onChange = jest.fn() + const eeLayer = { + id: 'ee', + name: 'NDVI', + layer: EARTH_ENGINE_LAYER, + aggregationType: ['mean', 'max'], + legend: { title: 'NDVI' }, + data: [{ properties: { orgUnitPath: '/country1/ou1' } }], + } + renderControl({ + eligibleLayers: [eeLayer], + layersConfig: {}, + onChange, + }) + openPicker() + + fireEvent.click(screen.getByRole('checkbox', { name: 'NDVI' })) + + expect(onChange).toHaveBeenCalledWith({ + ee: { + type: 'orgUnit', + aggregation: { mean: 'SUM', max: 'SUM' }, + }, + }) + }) + + test('changing one Earth Engine stat column aggregation leaves the other stat column untouched', () => { + const onChange = jest.fn() + const eeLayer = { + id: 'ee', + name: 'NDVI', + layer: EARTH_ENGINE_LAYER, + aggregationType: ['mean', 'max'], + legend: { title: 'NDVI' }, + data: [{ properties: { orgUnitPath: '/country1/ou1' } }], + } + renderControl({ + eligibleLayers: [eeLayer], + layersConfig: { + ee: { + type: 'orgUnit', + aggregation: { mean: 'SUM', max: 'SUM' }, + }, + }, + onChange, + }) + openPicker() + + expect( + screen.getByLabelText('Aggregation type for Mean Ndvi (NDVI)') + ).toBeInTheDocument() + expect( + screen.getByLabelText('Aggregation type for Max Ndvi (NDVI)') + ).toBeInTheDocument() + + fireEvent.change( + screen.getByLabelText('Aggregation type for Mean Ndvi (NDVI)'), + { target: { value: 'AVERAGE' } } + ) + + expect(onChange).toHaveBeenCalledWith({ + ee: { + type: 'orgUnit', + aggregation: { mean: 'AVERAGE', max: 'SUM' }, + }, + }) + }) }) diff --git a/src/components/datatable/__tests__/useCombinedTableData.spec.js b/src/components/datatable/__tests__/useCombinedTableData.spec.js index 7d43398c7d..6085e67c67 100644 --- a/src/components/datatable/__tests__/useCombinedTableData.spec.js +++ b/src/components/datatable/__tests__/useCombinedTableData.spec.js @@ -1,5 +1,5 @@ import { renderHook } from '@testing-library/react' -import { EVENT_LAYER } from '../../../constants/layers.js' +import { EARTH_ENGINE_LAYER, EVENT_LAYER } from '../../../constants/layers.js' import { useCombinedTableData } from '../useCombinedTableData.js' const feature = (props) => ({ properties: props }) @@ -686,3 +686,109 @@ describe('useCombinedTableData - empty input', () => { ]) }) }) + +describe('useCombinedTableData - Earth Engine value columns', () => { + // Earth Engine layers never carry their value(s) directly on feature + // properties (unlike every other layer type) - they're computed + // client-side into state.aggregations, keyed by layer id then feature + // id, and merged in here (see mergeAggregations) exactly like + // util/tableRows.js already does for the single-layer table. + test('merges aggregation stats in and generates one joinable column per stat, with no generic legend column', () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + layer: EARTH_ENGINE_LAYER, + aggregationType: ['mean', 'max'], + legend: { title: 'NDVI' }, + data: [feature({ id: 'f1', orgUnitPath: '/country1/ou1' })], + }, + ] + const joinConfig = { + layers: { + layerA: { + type: 'orgUnit', + aggregation: { mean: 'SUM', max: 'SUM' }, + }, + }, + } + + const { result } = renderHook(() => + useCombinedTableData({ + layers, + referenceLayer, + joinConfig, + aggregations: { layerA: { f1: { mean: 12.3, max: 20 } } }, + }) + ) + + expect(result.current.headers.map((h) => h.dataKey)).toEqual([ + 'id', + 'name', + 'level', + 'layerA_mean', + 'layerA_max', + ]) + expect( + result.current.headers.find((h) => h.dataKey === 'layerA_mean').name + ).toBe('Mean Ndvi (Layer A)') + + const row1 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' + ) + expect(findCell(row1, 'layerA_mean').value).toBe(12.3) + expect(findCell(row1, 'layerA_max').value).toBe(20) + }) + + test('merges classified aggregation values in and generates one column per legend class', () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + layer: EARTH_ENGINE_LAYER, + aggregationType: 'percentage', + legend: { + items: [ + { value: 1, name: 'Forest' }, + { value: 2, name: 'Water' }, + ], + }, + data: [feature({ id: 'f1', orgUnitPath: '/country1/ou1' })], + }, + ] + const joinConfig = { + layers: { + layerA: { + type: 'orgUnit', + aggregation: { 1: 'SUM', 2: 'SUM' }, + }, + }, + } + + const { result } = renderHook(() => + useCombinedTableData({ + layers, + referenceLayer, + joinConfig, + aggregations: { layerA: { f1: { 1: 45.2, 2: 12.1 } } }, + }) + ) + + expect(result.current.headers.map((h) => h.dataKey)).toEqual([ + 'id', + 'name', + 'level', + 'layerA_1', + 'layerA_2', + ]) + expect( + result.current.headers.find((h) => h.dataKey === 'layerA_1').name + ).toBe('Forest (Layer A)') + + const row1 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' + ) + expect(findCell(row1, 'layerA_1').value).toBe(45.2) + expect(findCell(row1, 'layerA_2').value).toBe(12.1) + }) +}) diff --git a/src/components/datatable/controls/JoinLayersControl.jsx b/src/components/datatable/controls/JoinLayersControl.jsx index fa30803e83..f134e069c7 100644 --- a/src/components/datatable/controls/JoinLayersControl.jsx +++ b/src/components/datatable/controls/JoinLayersControl.jsx @@ -4,6 +4,7 @@ import PropTypes from 'prop-types' import React, { useRef, useState } from 'react' import { getCombinedAggregationTypes } from '../../../constants/aggregationTypes.js' import { ORG_UNIT_PATH_DATA_KEY } from '../../../constants/dataTable.js' +import { getCombinedValueDataKeys } from '../../../util/dataTable.js' import { GEO_TYPE_POINT, GEO_TYPE_POLYGON, @@ -13,8 +14,6 @@ import { FilterDropdownPopover } from '../FilterDropdownPopover.jsx' import styles from './styles/JoinLayersControl.module.css' import ToolbarIconButton from './ToolbarIconButton.jsx' -const VALUE_KEY = 'rawValue' - // Spatial join means point-in-polygon against the reference org unit's own // boundary - offered for any layer whose features are literally points, or // whose geometry is a polygon/multipolygon (matched via its centroid @@ -41,7 +40,9 @@ const hasOrgUnitIdentity = (layer) => { const getDefaultSettings = (layer) => ({ type: hasOrgUnitIdentity(layer) ? 'orgUnit' : 'spatial', - aggregation: { [VALUE_KEY]: 'SUM' }, + aggregation: Object.fromEntries( + getCombinedValueDataKeys(layer).map(({ dataKey }) => [dataKey, 'SUM']) + ), }) const JoinLayersControl = ({ eligibleLayers, layersConfig, onChange }) => { @@ -136,35 +137,72 @@ const JoinLayersControl = ({ eligibleLayers, layersConfig, onChange }) => { </option> )} </select> - <select - aria-label={i18n.t( - 'Aggregation type for {{layer}}', - { layer: layer.name } - )} - value={ - settings.aggregation?.[ - VALUE_KEY - ] ?? 'SUM' - } - onChange={(e) => - onAggregationChange( - layer.id, - VALUE_KEY, - e.target.value - ) - } - > - {aggregationTypes.map( - (type) => ( - <option - key={type.id} - value={type.id} + {getCombinedValueDataKeys( + layer + ).map(({ dataKey, name }) => ( + <div + key={dataKey} + className={ + styles.aggregationRow + } + > + {name && ( + <span + className={ + styles.aggregationRowLabel + } > - {type.name} - </option> - ) - )} - </select> + {name} + </span> + )} + <select + aria-label={ + name + ? i18n.t( + 'Aggregation type for {{name}} ({{layer}})', + { + name, + layer: layer.name, + } + ) + : i18n.t( + 'Aggregation type for {{layer}}', + { + layer: layer.name, + } + ) + } + value={ + settings + .aggregation?.[ + dataKey + ] ?? 'SUM' + } + onChange={(e) => + onAggregationChange( + layer.id, + dataKey, + e.target.value + ) + } + > + {aggregationTypes.map( + (type) => ( + <option + key={ + type.id + } + value={ + type.id + } + > + {type.name} + </option> + ) + )} + </select> + </div> + ))} </div> )} </div> diff --git a/src/components/datatable/controls/styles/JoinLayersControl.module.css b/src/components/datatable/controls/styles/JoinLayersControl.module.css index 864cdaaf50..4b13bbbd15 100644 --- a/src/components/datatable/controls/styles/JoinLayersControl.module.css +++ b/src/components/datatable/controls/styles/JoinLayersControl.module.css @@ -35,11 +35,41 @@ .layerSettings { display: flex; + flex-direction: column; gap: var(--spacers-dp4); padding: var(--spacers-dp4) 0 var(--spacers-dp4) var(--spacers-dp20); } -.layerSettings select { +.layerSettings > select { + height: 24px; + padding: 0 var(--spacers-dp4); + font-size: 12px; + border: 1px solid var(--colors-grey500); + border-radius: 3px; + background-color: var(--colors-white); +} + +/* One per value column a layer contributes - usually just one (most layer + types have a single value), but Earth Engine can contribute several + (one per aggregation stat or legend class), each needing its own + aggregation-type choice and a label to tell them apart. */ +.aggregationRow { + display: flex; + align-items: center; + gap: var(--spacers-dp4); +} + +.aggregationRowLabel { + flex: 0 0 auto; + max-width: 80px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: 11px; + color: var(--colors-grey700); +} + +.aggregationRow select { flex: 1; min-width: 0; height: 24px; diff --git a/src/components/datatable/useCombinedTableData.js b/src/components/datatable/useCombinedTableData.js index 17b59bc2be..0e6e86aa53 100644 --- a/src/components/datatable/useCombinedTableData.js +++ b/src/components/datatable/useCombinedTableData.js @@ -7,7 +7,9 @@ import { TYPE_NUMBER, TYPE_STRING, } from '../../constants/dataTable.js' +import { EARTH_ENGINE_LAYER } from '../../constants/layers.js' import { applyAggregation } from '../../util/aggregation.js' +import { getCombinedValueDataKeys } from '../../util/dataTable.js' import { filterByGlobalSearch, filterData } from '../../util/filter.js' import { matchFeaturesToReferenceOrgUnits } from '../../util/spatialJoin.js' import { @@ -17,10 +19,10 @@ import { } from '../../util/tableColumns.js' import { compareRows } from '../../util/tableSort.js' -const VALUE_KEY = 'rawValue' const LEGEND_KEY = 'legend' const LARGE_FEATURE_THRESHOLD = 10000 const DEFAULT_AGGREGATION = 'SUM' +const EMPTY_AGGREGATIONS = {} // Mirrors util/tableRows.js's own data + dataWithoutCoords merge for the // single-layer table - org units/facilities missing valid coordinates @@ -32,6 +34,30 @@ const getJoinableFeatures = (layer) => const getProps = (feature) => feature.properties || feature +// Earth Engine layers compute their value(s) client-side into their own +// Redux slice (state.aggregations, keyed by feature id) rather than +// attaching them to the feature itself - util/tableRows.js's buildTableData +// does this same merge for the single-layer table. Every other layer type +// already carries its value(s) directly on properties from its loader, so +// this is a no-op for them. +const mergeAggregations = (layer, aggregationsForLayer) => { + if (layer.layer !== EARTH_ENGINE_LAYER || !aggregationsForLayer) { + return layer + } + const mergeFeature = (feature) => ({ + ...feature, + properties: { + ...getProps(feature), + ...aggregationsForLayer[feature.id ?? getProps(feature).id], + }, + }) + return { + ...layer, + data: layer.data?.map(mergeFeature), + dataWithoutCoords: layer.dataWithoutCoords?.map(mergeFeature), + } +} + // A feature belongs to a reference org unit if it IS that org unit, or is // one of its descendants (a path-prefix match) - "the reference OU or // lower, using the hierarchy". Reference org units are usually all one @@ -131,6 +157,10 @@ const EMPTY_RESULT = { // referenceLayer: the hidden combinedTableRef layer backing the join - its // own fetched org units are the row set, always, regardless of whether any // participating layer has data for a given one. +// aggregations: state.aggregations, keyed by layer id - passed in rather +// than read via useSelector here so this hook stays fully prop-driven (and +// trivially testable without a Redux Provider), matching every other input. +// Only Earth Engine layers ever have an entry (see mergeAggregations). export const useCombinedTableData = ({ layers, referenceLayer, @@ -139,6 +169,7 @@ export const useCombinedTableData = ({ sortDirection = SORT_ASCENDING, filters, globalSearch, + aggregations: allAggregations = EMPTY_AGGREGATIONS, }) => { const referenceOrgUnits = useMemo( () => getJoinableFeatures(referenceLayer), @@ -163,7 +194,12 @@ export const useCombinedTableData = ({ type: 'orgUnit', aggregation: {}, } - const features = getJoinableFeatures(layer) + const mergedLayer = mergeAggregations( + layer, + allAggregations[layer.id] ?? EMPTY_AGGREGATIONS + ) + const features = getJoinableFeatures(mergedLayer) + const valueDataKeys = getCombinedValueDataKeys(layer) const byReferenceId = settings.type === 'spatial' ? matchSpatialReference(features, referenceOrgUnits) @@ -172,9 +208,15 @@ export const useCombinedTableData = ({ referenceOrgUnits, referenceByPath ) - return { layer, settings, byReferenceId } + return { layer, settings, byReferenceId, valueDataKeys } }), - [layers, joinConfig, referenceOrgUnits, referenceByPath] + [ + layers, + joinConfig, + referenceOrgUnits, + referenceByPath, + allAggregations, + ] ) return useMemo(() => { @@ -194,17 +236,31 @@ export const useCombinedTableData = ({ { name: i18n.t('ID'), dataKey: 'id', type: TYPE_STRING }, { name: i18n.t('Name'), dataKey: 'name', type: TYPE_STRING }, { name: i18n.t('Level'), dataKey: 'level', type: TYPE_NUMBER }, - ...layerMatches.flatMap(({ layer }) => [ - { - name: i18n.t('Value ({{layer}})', { layer: layer.name }), - dataKey: `${layer.id}_${VALUE_KEY}`, + ...layerMatches.flatMap(({ layer, valueDataKeys }) => [ + ...valueDataKeys.map(({ dataKey, name }) => ({ + name: name + ? i18n.t('{{name}} ({{layer}})', { + name, + layer: layer.name, + }) + : i18n.t('Value ({{layer}})', { layer: layer.name }), + dataKey: `${layer.id}_${dataKey}`, type: TYPE_NUMBER, - }, - { - name: i18n.t('Legend ({{layer}})', { layer: layer.name }), - dataKey: `${layer.id}_${LEGEND_KEY}`, - type: TYPE_STRING, - }, + })), + // Earth Engine has no separate categorical "legend" concept + // of its own - its per-class values are already expressed + // as their own value columns above, one per legend class. + ...(layer.layer !== EARTH_ENGINE_LAYER + ? [ + { + name: i18n.t('Legend ({{layer}})', { + layer: layer.name, + }), + dataKey: `${layer.id}_${LEGEND_KEY}`, + type: TYPE_STRING, + }, + ] + : []), ]), ] @@ -224,29 +280,40 @@ export const useCombinedTableData = ({ // layer has a match for this row. const featureIds = { [referenceLayer.id]: [refProps.id] } - layerMatches.forEach(({ layer, settings, byReferenceId }) => { - const matches = byReferenceId.get(refProps.id) ?? [] - const values = matches - .map((p) => p[VALUE_KEY]) - .filter((v) => v != null) - row[`${layer.id}_${VALUE_KEY}`] = applyAggregation( - settings.aggregation?.[VALUE_KEY] ?? DEFAULT_AGGREGATION, - values - ) + layerMatches.forEach( + ({ layer, settings, byReferenceId, valueDataKeys }) => { + const matches = byReferenceId.get(refProps.id) ?? [] - const legends = matches - .map((p) => p[LEGEND_KEY]) - .filter((v) => v != null) - row[`${layer.id}_${LEGEND_KEY}`] = - legends.length && legends.every((l) => l === legends[0]) - ? legends[0] - : null + valueDataKeys.forEach(({ dataKey }) => { + const values = matches + .map((p) => p[dataKey]) + .filter((v) => v != null) + row[`${layer.id}_${dataKey}`] = applyAggregation( + settings.aggregation?.[dataKey] ?? + DEFAULT_AGGREGATION, + values + ) + }) - const ids = matches.map((p) => p.id).filter((id) => id != null) - if (ids.length) { - featureIds[layer.id] = ids + if (layer.layer !== EARTH_ENGINE_LAYER) { + const legends = matches + .map((p) => p[LEGEND_KEY]) + .filter((v) => v != null) + row[`${layer.id}_${LEGEND_KEY}`] = + legends.length && + legends.every((l) => l === legends[0]) + ? legends[0] + : null + } + + const ids = matches + .map((p) => p.id) + .filter((id) => id != null) + if (ids.length) { + featureIds[layer.id] = ids + } } - }) + ) rowFeatureIds.set(refProps.id, featureIds) return row diff --git a/src/util/__tests__/dataTable.spec.js b/src/util/__tests__/dataTable.spec.js index 45021be24c..27c93cff4c 100644 --- a/src/util/__tests__/dataTable.spec.js +++ b/src/util/__tests__/dataTable.spec.js @@ -1,6 +1,11 @@ -import { THEMATIC_LAYER, EXTERNAL_LAYER } from '../../constants/layers.js' +import { + EARTH_ENGINE_LAYER, + THEMATIC_LAYER, + EXTERNAL_LAYER, +} from '../../constants/layers.js' import { buildFeatureIndex, + getCombinedValueDataKeys, getEligibleDataTableLayers, getLayerSelectedIds, getNextSorting, @@ -15,6 +20,55 @@ import { shouldClearFeatureHighlight, } from '../dataTable.js' +describe('getCombinedValueDataKeys', () => { + test('returns a single generic rawValue column for any non-Earth-Engine layer', () => { + expect(getCombinedValueDataKeys({ layer: THEMATIC_LAYER })).toEqual([ + { dataKey: 'rawValue', name: null }, + ]) + }) + + test('returns one column per aggregation stat when aggregationType is an array', () => { + expect( + getCombinedValueDataKeys({ + layer: EARTH_ENGINE_LAYER, + aggregationType: ['mean', 'max'], + legend: { title: 'NDVI' }, + }) + ).toEqual([ + { dataKey: 'mean', name: 'Mean Ndvi' }, + { dataKey: 'max', name: 'Max Ndvi' }, + ]) + }) + + test('returns one column per legend class when aggregationType is classified', () => { + expect( + getCombinedValueDataKeys({ + layer: EARTH_ENGINE_LAYER, + aggregationType: 'percentage', + legend: { + items: [ + { value: 1, name: 'Forest' }, + { value: 2, name: 'Water' }, + ], + }, + }) + ).toEqual([ + { dataKey: '1', name: 'Forest' }, + { dataKey: '2', name: 'Water' }, + ]) + }) + + test('returns no columns for an Earth Engine layer with neither shape configured yet', () => { + expect( + getCombinedValueDataKeys({ + layer: EARTH_ENGINE_LAYER, + aggregationType: null, + legend: {}, + }) + ).toEqual([]) + }) +}) + describe('shouldClearFeatureHighlight', () => { test('clears when leaving to no element (cursor exits the window)', () => { expect(shouldClearFeatureHighlight({ relatedTarget: null })).toBe(true) diff --git a/src/util/dataTable.js b/src/util/dataTable.js index aca1a7a548..b23b91e426 100644 --- a/src/util/dataTable.js +++ b/src/util/dataTable.js @@ -1,6 +1,63 @@ import { bbox } from '@turf/bbox' import { SORT_ASCENDING, SORT_DESCENDING } from '../constants/dataTable.js' -import { DATA_TABLE_LAYER_TYPES } from '../constants/layers.js' +import { + DATA_TABLE_LAYER_TYPES, + EARTH_ENGINE_LAYER, +} from '../constants/layers.js' + +export const COMBINED_VALUE_KEY = 'rawValue' + +// Duplicated from util/earthEngine.js's own classAggregation/hasClasses +// rather than imported - that module's first import is MapApi.js, which +// pulls in the entire @dhis2/maps-gl/maplibre-gl rendering stack (breaks in +// jsdom without a MapApi.js mock). This file is a widely-shared, otherwise +// dependency-light utility imported by most of the data table test suite, +// so it deliberately doesn't take on that transitive weight for two +// constant strings. +const CLASSIFIED_EARTH_ENGINE_AGGREGATION_TYPES = [ + 'percentage', + 'hectares', + 'acres', +] + +const toTitleCase = (str) => + str.replace( + /\w\S*/g, + (txt) => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase() + ) + +// The value column(s) a layer contributes to Combined - one aggregatable +// dataKey per column. Every layer type except Earth Engine has exactly one +// (COMBINED_VALUE_KEY, paired with a non-aggregatable 'legend' categorical +// column handled separately in useCombinedTableData.js). Earth Engine's own +// value shape is genuinely different (mirrors getEarthEngineHeaders in +// tableHeaders.js, which drives its single-layer table headers the same +// way): one column per legend class when aggregationType is classified +// (percentage/hectares/acres), or one column per aggregation stat +// (mean/min/max/etc) when aggregationType is an array of stat names. +export const getCombinedValueDataKeys = (layer) => { + if (layer.layer !== EARTH_ENGINE_LAYER) { + return [{ dataKey: COMBINED_VALUE_KEY, name: null }] + } + if ( + CLASSIFIED_EARTH_ENGINE_AGGREGATION_TYPES.includes( + layer.aggregationType + ) && + layer.legend?.items + ) { + return layer.legend.items.map(({ value, name }) => ({ + dataKey: String(value), + name, + })) + } + if (Array.isArray(layer.aggregationType) && layer.aggregationType.length) { + return layer.aggregationType.map((type) => ({ + dataKey: type, + name: toTitleCase(`${type} ${layer.legend?.title ?? ''}`.trim()), + })) + } + return [] +} export const isFilterable = (dataKey, type) => !!type From 0992e166f62bd1d1fce366afc2c7d5c2fe47c609 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Wed, 29 Jul 2026 15:40:07 +0200 Subject: [PATCH 165/205] fix: various improvements --- i18n/en.pot | 20 +- package.json | 2 +- src/actions/dataTable.js | 5 + src/components/core/icons.jsx | 31 ++ src/components/datatable/BottomPanel.jsx | 67 ++- .../datatable/CombinedDataTable.jsx | 173 ++++++- src/components/datatable/DataTableButton.jsx | 21 +- .../datatable/__tests__/BottomPanel.spec.jsx | 98 +++- .../__tests__/CombinedDataTable.spec.jsx | 444 +++++++++++++++++- .../__tests__/DataTableButton.spec.jsx | 8 +- .../__tests__/useCombinedTableData.spec.js | 58 +++ .../controls/ClearFiltersControl.jsx | 2 + .../datatable/controls/JoinLayersControl.jsx | 230 ++++----- .../controls/ReferenceOrgUnitControl.jsx | 13 +- .../datatable/controls/ShowInViewControl.jsx | 2 + .../styles/JoinLayersControl.module.css | 21 +- .../datatable/useCombinedTableData.js | 179 +++---- src/components/edit/LayerEdit.jsx | 12 +- src/components/map/Map.jsx | 3 + src/components/map/MapContainer.jsx | 24 +- src/components/map/MapView.jsx | 4 + src/components/map/SplitView.jsx | 3 + src/components/map/layers/Layer.js | 42 +- .../map/layers/__tests__/Layer.spec.js | 117 +++++ src/constants/actionTypes.js | 1 + src/reducers/selection.js | 93 ++-- src/reducers/ui.js | 9 + src/util/dataTable.js | 36 +- yarn.lock | 4 +- 29 files changed, 1301 insertions(+), 421 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 3661a9ecef..d5f0ab4bdc 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-28T14:57:10.660Z\n" -"PO-Revision-Date: 2026-07-28T14:57:10.661Z\n" +"POT-Creation-Date: 2026-07-29T08:31:24.085Z\n" +"PO-Revision-Date: 2026-07-29T08:31:24.085Z\n" msgid "2020" msgstr "2020" @@ -349,6 +349,9 @@ msgstr "Org unit" msgid "Spatial" msgstr "Spatial" +msgid "Aggregation type for {{name}} ({{layer}})" +msgstr "Aggregation type for {{name}} ({{layer}})" + msgid "Aggregation type for {{layer}}" msgstr "Aggregation type for {{layer}}" @@ -370,12 +373,15 @@ msgstr "{{total}} rows" msgid "Show only features in current map view" msgstr "Show only features in current map view" -msgid "ID" -msgstr "ID" +msgid "Org unit Id" +msgstr "Org unit Id" msgid "Level" msgstr "Level" +msgid "{{name}} ({{layer}})" +msgstr "{{name}} ({{layer}})" + msgid "Value ({{layer}})" msgstr "Value ({{layer}})" @@ -1154,6 +1160,9 @@ msgstr "Address" msgid "Phone" msgstr "Phone" +msgid "ID" +msgstr "ID" + msgid "Comment" msgstr "Comment" @@ -2110,9 +2119,6 @@ msgstr "GroupSet used for styling was not found" msgid "Id" msgstr "Id" -msgid "Org unit Id" -msgstr "Org unit Id" - msgid "Org unit level" msgstr "Org unit level" diff --git a/package.json b/package.json index 6021418794..21e34bab09 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "@dhis2/analytics": "^29.5.5", "@dhis2/app-runtime": "^3.17.3", "@dhis2/app-service-datastore": "^1.0.0-beta.3", - "@dhis2/maps-gl": "git+https://github.com/d2-ci/maps-gl.git#55ba8864b811c44279dd7c85dedc37adc426e318", + "@dhis2/maps-gl": "git+https://github.com/d2-ci/maps-gl.git#3934323c80eb763e843eefa6a1faa031c341d779", "@dhis2/ui": "^10.16.4", "@dnd-kit/core": "^6.0.8", "@dnd-kit/modifiers": "^9.0.0", diff --git a/src/actions/dataTable.js b/src/actions/dataTable.js index 9c57ab96b2..4e01734799 100644 --- a/src/actions/dataTable.js +++ b/src/actions/dataTable.js @@ -52,3 +52,8 @@ export const setJoinConfig = (config) => ({ type: types.DATA_TABLE_JOIN_CONFIG_SET, config, }) + +export const setCombinedVisibleIds = (idsByLayer) => ({ + type: types.COMBINED_VISIBLE_IDS_SET, + idsByLayer, +}) diff --git a/src/components/core/icons.jsx b/src/components/core/icons.jsx index 1a067ea7bd..d7eb5b53ec 100644 --- a/src/components/core/icons.jsx +++ b/src/components/core/icons.jsx @@ -49,6 +49,37 @@ export const IconZoomIn16 = () => ( </svg> ) +export const IconLayersStack16 = () => ( + <svg + height="16" + viewBox="0 0 16 16" + width="16" + xmlns="http://www.w3.org/2000/svg" + > + <path + fill="currentColor" + fillRule="evenodd" + d="M8.316.24 15.566 4.74a.6.6 0 010 1.02L8.316 10.26a.6.6 0 01-.632 0L.434 5.76a.6.6 0 010-1.02L7.684.24a.6.6 0 01.632 0ZM8 1.456 14.112 5.25 8 9.044l-6.112-3.794Z" + /> + <path + fill="currentColor" + d="M.434 8.51 7.684 13.01a.6.6 0 00.632-1.02L1.066 7.49a.6.6 0 00-.632 1.02Z" + /> + <path + fill="currentColor" + d="M8.316 13.01 15.566 8.51a.6.6 0 00-.632-1.02L7.684 11.99a.6.6 0 00.632 1.02Z" + /> + <path + fill="currentColor" + d="M.434 11.26 7.684 15.76a.6.6 0 00.632-1.02L1.066 10.24a.6.6 0 00-.632 1.02Z" + /> + <path + fill="currentColor" + d="M8.316 15.76 15.566 11.26a.6.6 0 00-.632-1.02L7.684 14.74a.6.6 0 00.632 1.02Z" + /> + </svg> +) + export const IconDrag = () => ( <svg height="8" diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index 553b571a89..705c28f41a 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -106,7 +106,10 @@ const BottomPanel = () => { const [combinedColumnConfig, setCombinedColumnConfig] = useState(null) const hasActiveFilters = combinedView - ? Object.keys(combinedFilters).length > 0 || !!globalSearch.trim() + ? Object.keys(combinedFilters).length > 0 || + !!globalSearch.trim() || + showOnlyFeaturesInView || + !!selectionFilter?.length : hasActiveDataTableFilters({ dataFilters, globalSearch, @@ -130,7 +133,7 @@ const BottomPanel = () => { const onControlsDoubleClick = useCallback( (e) => { - if (e.target.closest('button, input, label')) { + if (e.target.closest('button, input, label, select')) { return } toggleCollapsed() @@ -185,9 +188,9 @@ const BottomPanel = () => { setHeadersByLayer({ layerId, headers }) }, []) + const activeHeadersKey = combinedView ? COMBINED_HEADERS_KEY : activeLayerId const allHeaders = - headersByLayer?.layerId === - (combinedView ? COMBINED_HEADERS_KEY : activeLayerId) + headersByLayer?.layerId === activeHeadersKey ? headersByLayer.headers : null @@ -196,13 +199,21 @@ const BottomPanel = () => { setCombinedFilters(EMPTY_FILTERS) } else { dispatch(clearDataFilters(activeLayerId)) + } + if (showOnlyFeaturesInView) { + dispatch(toggleShowOnlyFeaturesInView()) + } + if (selectionFilter?.length) { dispatch(setSelectionFilter([])) - if (showOnlyFeaturesInView) { - dispatch(toggleShowOnlyFeaturesInView()) - } } setGlobalSearch('') - }, [dispatch, activeLayerId, showOnlyFeaturesInView, combinedView]) + }, [ + dispatch, + activeLayerId, + showOnlyFeaturesInView, + selectionFilter, + combinedView, + ]) const onToggleShowOnlyFeaturesInView = useCallback(() => { dispatch(toggleShowOnlyFeaturesInView()) @@ -247,11 +258,6 @@ const BottomPanel = () => { return () => observer.disconnect() }, []) - // Restores a saved map's per-layer join type/aggregation choices once, - // the moment the reference layer finishes loading and its persisted - // combinedJoinConfig comes in (see favorites.js/orgUnitLoader.js) - the - // ref guard means it never re-fires and clobbers a live in-session edit - // (e.g. after the reference layer is later re-edited/reloaded). const hasHydratedJoinConfigRef = useRef(false) useEffect(() => { if ( @@ -300,19 +306,22 @@ const BottomPanel = () => { dispatch(toggleCombinedView()) } if (!combinedEnabled) { - // No reference configured yet (or it has no org - // units selected) - there'd be nothing to show, - // so open its editor right away instead of - // landing on an empty table with no obvious way - // to fix it. openReferenceLayerEditor() } }} /> <span className={styles.divider} /> + <HighlightColorControl + color={highlightColor} + onChange={onHighlightColorChange} + /> {combinedView ? ( <> - <ReferenceOrgUnitControl /> + <ColumnPickerControl + allHeaders={allHeaders} + columnConfig={combinedColumnConfig} + onChange={setCombinedColumnConfig} + /> <JoinLayersControl eligibleLayers={eligibleLayers} layersConfig={joinLayersConfig} @@ -320,19 +329,11 @@ const BottomPanel = () => { dispatch(setJoinConfig({ layers })) } /> - <ColumnPickerControl - allHeaders={allHeaders} - columnConfig={combinedColumnConfig} - onChange={setCombinedColumnConfig} - /> + <ReferenceOrgUnitControl /> <span className={styles.divider} /> </> ) : ( <> - <HighlightColorControl - color={highlightColor} - onChange={onHighlightColorChange} - /> <ColumnPickerControl allHeaders={allHeaders} columnConfig={activeLayer?.dataTableColumnConfig} @@ -369,12 +370,10 @@ const BottomPanel = () => { value={globalSearch} onChange={setGlobalSearch} /> - {!combinedView && ( - <ShowInViewControl - active={showOnlyFeaturesInView} - onClick={onToggleShowOnlyFeaturesInView} - /> - )} + <ShowInViewControl + active={showOnlyFeaturesInView} + onClick={onToggleShowOnlyFeaturesInView} + /> <span className={styles.divider} /> <CloseControl onClick={onCloseDataTable} /> </div> diff --git a/src/components/datatable/CombinedDataTable.jsx b/src/components/datatable/CombinedDataTable.jsx index 1f4b347916..7b89ef7f68 100644 --- a/src/components/datatable/CombinedDataTable.jsx +++ b/src/components/datatable/CombinedDataTable.jsx @@ -5,6 +5,10 @@ import PropTypes from 'prop-types' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import { TableVirtuoso } from 'react-virtuoso' +import { + setCombinedVisibleIds, + setSelectionFilter, +} from '../../actions/dataTable.js' import { highlightFeature } from '../../actions/feature.js' import { setCrossLayerSelection } from '../../actions/selection.js' import { @@ -14,6 +18,7 @@ import { import { isFilterable, getRowId, + getUnionBounds, mergeCrossLayerIds, shouldClearFeatureHighlight, } from '../../util/dataTable.js' @@ -31,6 +36,7 @@ import { SelectionCheckboxHeaderCell, SelectionCheckboxCell, } from './SelectionCheckboxColumn.jsx' +import SelectionFilterButton from './SelectionFilterButton.jsx' import SortableColumnHeader from './SortableColumnHeader.jsx' import styles from './styles/CombinedDataTable.module.css' import dataTableStyles from './styles/DataTable.module.css' @@ -44,7 +50,6 @@ import { useSortState } from './useSortState.js' const TABLE_STYLE = { height: '100%', width: '100%' } const LARGE_FEATURE_THRESHOLD_LABEL = '10,000' const EMPTY_FILTERS = {} -const NOOP = () => {} const EmptyPlaceholder = () => ( <tbody> @@ -58,10 +63,6 @@ const EmptyPlaceholder = () => ( </tbody> ) -// Reuse the same generic TableVirtuoso row/table wiring DataTable.jsx uses -// (context-driven mouse/click callbacks) - only the empty-state message -// differs, since Combined doesn't have DataTable's server-cluster/ -// clear-filters messaging needs yet. const CombinedTableComponents = { ...TableComponents, EmptyPlaceholder, @@ -83,13 +84,22 @@ const CombinedDataTable = ({ const { systemSettings: { keyAnalysisDigitGroupSeparator }, } = useCachedData() - // Earth Engine layers compute their value(s) client-side into this - // slice rather than attaching them to the feature itself - see - // useCombinedTableData.js's own mergeAggregations. const aggregations = useSelector((state) => state.aggregations) + const showOnlyFeaturesInView = useSelector( + (state) => state.ui.showOnlyFeaturesInView + ) + const mapBounds = useSelector((state) => state.ui.mapBounds) + const selectionFilter = useSelector((state) => state.ui.selectionFilter) + const currentFeature = useSelector((state) => state.feature) + const lastClickedFeature = useSelector( + (state) => state.ui.lastClickedFeature + ) const { sortField, sortDirection, sortData } = useSortState('name') + const [selectedIds, setSelectedIds] = useState([]) + const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]) + const { headers, rows, rowFeatureIds, columnOptions, spatialWarning } = useCombinedTableData({ layers, @@ -100,12 +110,35 @@ const CombinedDataTable = ({ filters, globalSearch, aggregations, + showOnlyFeaturesInView, + mapBounds, + selectionFilter, + selectedIdSet, }) useEffect(() => { onHeadersChange?.(headers, COMBINED_HEADERS_KEY) }, [onHeadersChange, headers]) + const rowIdByLayerFeature = useMemo(() => { + const index = new Map() + rowFeatureIds.forEach((entry, rowId) => { + Object.entries(entry).forEach(([layerId, ids]) => { + ids.forEach((id) => index.set(`${layerId}:${id}`, rowId)) + }) + }) + return index + }, [rowFeatureIds]) + + const mapHoveredRowId = + currentFeature?.origin === 'map' && + currentFeature?.id != null && + currentFeature?.layerId != null + ? rowIdByLayerFeature.get( + `${currentFeature.layerId}:${currentFeature.id}` + ) ?? null + : null + const pinnedKeys = useMemo( () => columnConfig?.pinnedKeys ?? [], [columnConfig] @@ -147,21 +180,8 @@ const CombinedDataTable = ({ onCountChange?.(rows.length, rows.length) }, [onCountChange, rows.length]) - // Combined rows don't belong to any single layer, so selection/hover - // here can't reuse state.selection/state.feature's single-layerId shape - // directly - it dispatches the same actions but with crossLayerIds (a - // per-layer id map merged from every affected row), and layerId: null so - // Layer.js's own-layer check never matches. The in-table - // selected/hovered highlighting stays local state; only the map-facing - // dispatch goes through Redux. - const [selectedIds, setSelectedIds] = useState([]) - const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]) const [hoveredRowId, setHoveredRowId] = useState(null) - // Only clear state.selection on unmount if this table ever actually set - // a cross-layer selection - otherwise merely opening and closing the - // Combined tab without selecting anything would wipe out an unrelated, - // pre-existing single-layer selection made in another tab. const hasAppliedSelectionRef = useRef(false) useEffect( @@ -205,6 +225,39 @@ const CombinedDataTable = ({ onSelectRange: onSelectRowRange, }) + const virtuosoRef = useRef(null) + const rowsRef = useRef(rows) + rowsRef.current = rows + const rowIdByLayerFeatureRef = useRef(rowIdByLayerFeature) + rowIdByLayerFeatureRef.current = rowIdByLayerFeature + const onToggleRowRef = useRef(onToggleRow) + onToggleRowRef.current = onToggleRow + + useEffect(() => { + if (!lastClickedFeature) { + return + } + const rowId = rowIdByLayerFeatureRef.current.get( + `${lastClickedFeature.layerId}:${lastClickedFeature.id}` + ) + if (!rowId) { + return + } + if (lastClickedFeature.multiSelect) { + onToggleRowRef.current(rowId) + } + const rowIndex = rowsRef.current.findIndex( + (row) => getRowId(row) === rowId + ) + if (rowIndex !== -1) { + virtuosoRef.current?.scrollToIndex({ + index: rowIndex, + align: 'center', + behavior: 'smooth', + }) + } + }, [lastClickedFeature]) + const setFeatureHighlight = useCallback( (row) => { const id = getRowId(row) @@ -235,6 +288,61 @@ const CombinedDataTable = ({ [dispatch] ) + const onRowDoubleClick = useCallback( + (row) => { + const id = getRowId(row) + if (!id) { + return + } + const entry = rowFeatureIds.get(id) ?? {} + dispatch( + highlightFeature({ + layerId: null, + origin: 'table', + zoom: true, + bounds: getUnionBounds([referenceLayer, ...layers], entry), + crossLayerIds: entry, + }) + ) + }, + [dispatch, rowFeatureIds, referenceLayer, layers] + ) + + const hasColumnOrSearchFilters = + Object.keys(filters ?? EMPTY_FILTERS).length > 0 || + !!globalSearch?.trim() + + const combinedVisibleIdsByLayer = useMemo(() => { + if (!hasColumnOrSearchFilters) { + return null + } + const idsByLayer = Object.fromEntries( + [referenceLayer, ...layers].map((layer) => [layer.id, []]) + ) + rows.forEach((row) => { + const rowId = getRowId(row) + const entry = rowId ? rowFeatureIds.get(rowId) : null + if (!entry) { + return + } + Object.entries(entry).forEach(([layerId, ids]) => { + idsByLayer[layerId]?.push(...ids) + }) + }) + return idsByLayer + }, [hasColumnOrSearchFilters, rows, rowFeatureIds, referenceLayer, layers]) + + useEffect(() => { + dispatch(setCombinedVisibleIds(combinedVisibleIdsByLayer)) + }, [dispatch, combinedVisibleIdsByLayer]) + + useEffect( + () => () => { + dispatch(setCombinedVisibleIds(null)) + }, + [dispatch] + ) + const allRowIds = useMemo(() => rows.map(getRowId).filter(Boolean), [rows]) const { isAllSelected, onToggleSelectAll, onReverseSelection } = @@ -247,7 +355,8 @@ const CombinedDataTable = ({ const hasActiveFilters = Object.keys(filters ?? EMPTY_FILTERS).length > 0 || - !!globalSearch?.trim() + !!globalSearch?.trim() || + !!selectionFilter?.length const [tableContextMenu, setTableContextMenu] = useState(null) @@ -266,7 +375,7 @@ const CombinedDataTable = ({ onMouseLeave: clearFeatureHighlight, onRowClick, onContextMenu: onRowContextMenu, - onRowDoubleClick: NOOP, + onRowDoubleClick, layout: 'auto', }), [ @@ -274,6 +383,7 @@ const CombinedDataTable = ({ clearFeatureHighlight, onRowClick, onRowContextMenu, + onRowDoubleClick, ] ) @@ -306,6 +416,16 @@ const CombinedDataTable = ({ onToggleSelectAll={onToggleSelectAll} onReverseSelection={onReverseSelection} disabled={allRowIds.length === 0} + onFilterIconClick={Function.prototype} + showFilter={true} + filter={ + <SelectionFilterButton + value={selectionFilter ?? []} + onChange={(next) => + dispatch(setSelectionFilter(next)) + } + /> + } /> {visibleHeaders.map(({ name, dataKey, type }, index) => { const { fixed, left, isLastPinned } = getPinnedCellProps( @@ -376,6 +496,8 @@ const CombinedDataTable = ({ onToggleSelectAll, onReverseSelection, allRowIds, + selectionFilter, + dispatch, ] ) @@ -390,6 +512,7 @@ const CombinedDataTable = ({ </div> )} <TableVirtuoso + ref={virtuosoRef} context={tableContext} components={CombinedTableComponents} style={TABLE_STYLE} @@ -398,7 +521,9 @@ const CombinedDataTable = ({ itemContent={(_, row) => { const rowId = getRowId(row) const isSelected = !!rowId && selectedIdSet.has(rowId) - const isHovered = !!rowId && rowId === hoveredRowId + const isHovered = + !!rowId && + (rowId === hoveredRowId || rowId === mapHoveredRowId) const cellsByDataKey = new Map( row.map((cell) => [cell.dataKey, cell]) ) diff --git a/src/components/datatable/DataTableButton.jsx b/src/components/datatable/DataTableButton.jsx index 1afb8a0094..18a496b04f 100644 --- a/src/components/datatable/DataTableButton.jsx +++ b/src/components/datatable/DataTableButton.jsx @@ -1,7 +1,11 @@ import i18n from '@dhis2/d2-i18n' import React from 'react' import { useDispatch, useSelector } from 'react-redux' -import { toggleDataTable, toggleCombinedView } from '../../actions/dataTable.js' +import { + closeDataTable, + toggleDataTable, + toggleCombinedView, +} from '../../actions/dataTable.js' import { getOrgUnitsFromRows } from '../../util/analytics.js' import { getEligibleDataTableLayers, @@ -20,15 +24,14 @@ const DataTableButton = () => { !!referenceLayer && getOrgUnitsFromRows(referenceLayer.rows).length > 0 const onClick = () => { - // Only a quick-open shortcut for the closed state - if a table is - // already showing (single-layer or Combined), this is a no-op; the - // panel's own Close button is the only way to close it. Combined - // is only auto-opened here when a reference org unit set has - // already been configured (mirrors BottomPanel.jsx's own - // combinedEnabled gate) - otherwise there'd be nothing to show, so - // this shortcut falls back to just opening the first eligible - // layer's own table instead. + // Toggles the panel: closes it if a table is already showing + // (single-layer or Combined), otherwise opens one. Combined is only + // auto-opened here when a reference org unit set has already been + // configured (mirrors BottomPanel.jsx's own combinedEnabled gate) - + // otherwise there'd be nothing to show, so this shortcut falls back + // to just opening the first eligible layer's own table instead. if (isDataTableOpen(dataTable)) { + dispatch(closeDataTable()) return } if (combinedEnabled) { diff --git a/src/components/datatable/__tests__/BottomPanel.spec.jsx b/src/components/datatable/__tests__/BottomPanel.spec.jsx index cf43d50568..3093ec2621 100644 --- a/src/components/datatable/__tests__/BottomPanel.spec.jsx +++ b/src/components/datatable/__tests__/BottomPanel.spec.jsx @@ -53,6 +53,7 @@ const referenceLayer = ( const renderBottomPanel = ({ dataTable = DEFAULT_DATA_TABLE_STATE, mapViews = DEFAULT_MAP_VIEWS, + ui = {}, } = {}) => { const store = mockStore({ ui: { @@ -60,6 +61,7 @@ const renderBottomPanel = ({ showOnlyFeaturesInView: false, selectionFilter: [], highlightColor: null, + ...ui, }, dataTable, map: { mapViews }, @@ -71,7 +73,11 @@ const renderBottomPanel = ({ </WindowDimensionsProvider> </Provider> ) - return { handle: container.querySelector('.resizeHandle'), store } + return { + handle: container.querySelector('.resizeHandle'), + container, + store, + } } const getDisplayHeight = () => @@ -107,6 +113,26 @@ describe('BottomPanel resize cancel', () => { }) }) +describe('BottomPanel double-click to collapse', () => { + test('double-clicking empty toolbar space toggles the collapsed state', () => { + const { container } = renderBottomPanel() + expect(getDisplayHeight()).toBe(`${DATA_TABLE_HEIGHT}px`) + + fireEvent.doubleClick(container.querySelector('.dataTableControls')) + + expect(getDisplayHeight()).not.toBe(`${DATA_TABLE_HEIGHT}px`) + }) + + test('double-clicking the layer selector does not toggle the collapsed state', () => { + renderBottomPanel() + expect(getDisplayHeight()).toBe(`${DATA_TABLE_HEIGHT}px`) + + fireEvent.doubleClick(getLayerSelector()) + + expect(getDisplayHeight()).toBe(`${DATA_TABLE_HEIGHT}px`) + }) +}) + const twoEligibleLayers = [ { id: 'layer1', @@ -131,9 +157,6 @@ describe('BottomPanel layer selector', () => { mapViews: twoEligibleLayers, }) - // layer1 is the only one open, but layer2 is still listed since it's - // eligible - the dropdown covers every eligible map layer, not just - // already-open tabs. expect(screen.getByText('Layer 1')).toBeInTheDocument() expect(screen.getByText('Layer 2')).toBeInTheDocument() expect(screen.getByText('Combined')).toBeInTheDocument() @@ -183,11 +206,6 @@ describe('BottomPanel layer selector', () => { }) test('the active layer is correct on the very first render, with no transient null in between', () => { - // Regression guard: activeLayerId used to be seeded via - // useState(null) and only synced to openIds a render later via - // useEffect, so a child requiring a non-null layerId (e.g. - // ColumnPickerControl) would see `null` for one render and log a - // prop-types warning. It must now be derived synchronously. const consoleError = jest .spyOn(console, 'error') .mockImplementation(() => {}) @@ -273,7 +291,7 @@ describe('BottomPanel layer selector', () => { describe('BottomPanel Combined join controls', () => { const combinedMapViews = [...twoEligibleLayers, referenceLayer()] - test('shows the reference org unit control and join layers control, and hides per-layer-only controls, while Combined is active', () => { + test('shows the reference org unit control and join layers control while Combined is active', () => { renderBottomPanel({ dataTable: { ...DEFAULT_DATA_TABLE_STATE, @@ -289,9 +307,65 @@ describe('BottomPanel Combined join controls', () => { expect( screen.getByLabelText('Choose layers to combine') ).toBeInTheDocument() + }) + + test('still shows the highlight color and show-in-view controls while Combined is active - they are not per-layer-only', () => { + const { container } = renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1', 'layer2'], + combinedView: true, + }, + mapViews: combinedMapViews, + }) + expect( - screen.queryByLabelText('Highlight color') - ).not.toBeInTheDocument() + container.querySelector('input[type="color"]') + ).toBeInTheDocument() + expect( + screen.getByLabelText('Show only features in current map view') + ).toBeInTheDocument() + }) + + test('the Clear filters button is enabled in Combined mode when "show only features in view" is active, and clicking it turns that off too', () => { + const { store } = renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1', 'layer2'], + combinedView: true, + }, + mapViews: combinedMapViews, + ui: { showOnlyFeaturesInView: true }, + }) + + expect(screen.getByLabelText('Clear filters')).not.toBeDisabled() + + fireEvent.click(screen.getByLabelText('Clear filters')) + + expect(store.getActions()).toContainEqual({ + type: 'TOGGLE_SHOW_ONLY_IN_VIEW', + }) + }) + + test('the Clear filters button is enabled in Combined mode when a selection filter is active, and clicking it clears that too', () => { + const { store } = renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1', 'layer2'], + combinedView: true, + }, + mapViews: combinedMapViews, + ui: { selectionFilter: ['selected'] }, + }) + + expect(screen.getByLabelText('Clear filters')).not.toBeDisabled() + + fireEvent.click(screen.getByLabelText('Clear filters')) + + expect(store.getActions()).toContainEqual({ + type: 'SELECTION_FILTER_SET', + value: [], + }) }) test('still shows the column picker while Combined is active, session-only (not the per-layer one)', () => { diff --git a/src/components/datatable/__tests__/CombinedDataTable.spec.jsx b/src/components/datatable/__tests__/CombinedDataTable.spec.jsx index 943e3be72f..9a77744558 100644 --- a/src/components/datatable/__tests__/CombinedDataTable.spec.jsx +++ b/src/components/datatable/__tests__/CombinedDataTable.spec.jsx @@ -19,6 +19,12 @@ const feature = (props) => ({ properties: props }) const referenceFeature = (id, name, path) => feature({ id, name, orgUnitPath: path, level: 2 }) +const referenceFeatureWithGeometry = ({ id, name, path, coordinates }) => ({ + type: 'Feature', + properties: { id, name, orgUnitPath: path, level: 2 }, + geometry: { type: 'Point', coordinates }, +}) + const EMPTY_REFERENCE_LAYER = { id: 'ref1', layer: 'combinedTableRef', @@ -26,7 +32,7 @@ const EMPTY_REFERENCE_LAYER = { } const renderCombinedDataTable = (props) => { - const store = mockStore({}) + const store = mockStore({ ui: {} }) const result = render( <Provider store={store}> <VirtuosoMockContext.Provider @@ -78,8 +84,8 @@ describe('CombinedDataTable', () => { }, }) - expect(screen.getByText('ID')).toBeInTheDocument() - expect(screen.getByText('Name')).toBeInTheDocument() + expect(screen.getByText('Org unit Id')).toBeInTheDocument() + expect(screen.getByText('Org unit')).toBeInTheDocument() expect(screen.getByText('Value (Layer A)')).toBeInTheDocument() expect(screen.getByText('Legend (Layer A)')).toBeInTheDocument() expect(screen.getByText('Ou One')).toBeInTheDocument() @@ -291,7 +297,7 @@ describe('CombinedDataTable', () => { }) const input = screen - .getByTestId('data-table-column-filter-search-ID') + .getByTestId('data-table-column-filter-search-Org unit Id') .querySelector('input') fireEvent.focus(input) fireEvent.change(input, { target: { value: 'ou1' } }) @@ -551,7 +557,7 @@ describe('CombinedDataTable', () => { columnConfig: { visibleKeys: ['id', 'name'] }, }) - expect(screen.getByText('ID')).toBeInTheDocument() + expect(screen.getByText('Org unit Id')).toBeInTheDocument() expect(screen.queryByText('Value (Layer A)')).not.toBeInTheDocument() expect(screen.queryByText('20')).not.toBeInTheDocument() }) @@ -587,6 +593,432 @@ describe('CombinedDataTable', () => { .getAllByRole('columnheader') .map((el) => el.textContent) .filter(Boolean) - expect(headerNames[0]).toBe('Level') + // headerNames[0] is the selection column's own filter button label + // ("All"/"N selected") - the first real data column follows it. + expect(headerNames[1]).toBe('Level') + }) + + test('dispatches setSelectionFilter when a selection-filter option is toggled', () => { + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [referenceFeature('ou1', 'Ou One', '/country1/ou1')], + } + + const { store } = renderCombinedDataTable({ referenceLayer }) + + fireEvent.click( + screen.getByTestId('data-table-selection-filter-button') + ) + fireEvent.click(screen.getByText('Selected')) + + expect(store.getActions()).toContainEqual({ + type: 'SELECTION_FILTER_SET', + value: ['selected'], + }) + }) + + test('zooms to the row union bounds on row double-click', () => { + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [ + referenceFeatureWithGeometry({ + id: 'ou1', + name: 'Ou One', + path: '/country1/ou1', + coordinates: [10, 20], + }), + ], + } + + const { store } = renderCombinedDataTable({ referenceLayer }) + + const dataRow = screen.getAllByRole('row')[1] + fireEvent.doubleClick(dataRow) + + expect(store.getActions()).toContainEqual({ + type: 'FEATURE_HIGHLIGHT', + payload: { + layerId: null, + origin: 'table', + zoom: true, + bounds: [ + [10, 20], + [10, 20], + ], + crossLayerIds: { ref1: ['ou1'] }, + }, + }) + }) + + test('highlights the table row matching a feature hovered directly on the map', () => { + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [referenceFeature('ou1', 'Ou One', '/country1/ou1')], + } + const layers = [ + { + id: 'layerA', + name: 'Layer A', + data: [ + feature({ + id: 'evtA1', + orgUnitPath: '/country1/ou1', + rawValue: 20, + }), + ], + }, + ] + + const store = mockStore({ + ui: {}, + feature: { id: 'evtA1', layerId: 'layerA', origin: 'map' }, + }) + render( + <Provider store={store}> + <VirtuosoMockContext.Provider + value={{ viewportHeight: 300, itemHeight: 28 }} + > + <CombinedDataTable + availableWidth={800} + layers={layers} + referenceLayer={referenceLayer} + joinConfig={{ + layers: { + layerA: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, + }} + /> + </VirtuosoMockContext.Provider> + </Provider> + ) + + const dataRow = screen.getAllByRole('row')[1] + expect(dataRow.querySelector('.hovered')).toBeInTheDocument() + }) + + test('ignores a stale single-layer table hover left over in state.feature', () => { + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [referenceFeature('ou1', 'Ou One', '/country1/ou1')], + } + const layers = [ + { + id: 'layerA', + name: 'Layer A', + data: [ + feature({ + id: 'evtA1', + orgUnitPath: '/country1/ou1', + rawValue: 20, + }), + ], + }, + ] + + const store = mockStore({ + ui: {}, + feature: { id: 'evtA1', layerId: 'layerA', origin: 'table' }, + }) + render( + <Provider store={store}> + <VirtuosoMockContext.Provider + value={{ viewportHeight: 300, itemHeight: 28 }} + > + <CombinedDataTable + availableWidth={800} + layers={layers} + referenceLayer={referenceLayer} + joinConfig={{ + layers: { + layerA: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, + }} + /> + </VirtuosoMockContext.Provider> + </Provider> + ) + + const dataRow = screen.getAllByRole('row')[1] + expect(dataRow.querySelector('.hovered')).not.toBeInTheDocument() + }) + + test('a plain map click on a joined feature does not select its row', () => { + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [referenceFeature('ou1', 'Ou One', '/country1/ou1')], + } + const layers = [ + { + id: 'layerA', + name: 'Layer A', + data: [ + feature({ + id: 'evtA1', + orgUnitPath: '/country1/ou1', + rawValue: 20, + }), + ], + }, + ] + + const store = mockStore({ + ui: { + lastClickedFeature: { + id: 'evtA1', + layerId: 'layerA', + multiSelect: false, + }, + }, + }) + render( + <Provider store={store}> + <VirtuosoMockContext.Provider + value={{ viewportHeight: 300, itemHeight: 28 }} + > + <CombinedDataTable + availableWidth={800} + layers={layers} + referenceLayer={referenceLayer} + joinConfig={{ + layers: { + layerA: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, + }} + /> + </VirtuosoMockContext.Provider> + </Provider> + ) + + expect(store.getActions()).not.toContainEqual( + expect.objectContaining({ type: 'SELECTION_SET_CROSS_LAYER' }) + ) + }) + + test('a ctrl/multiSelect map click on a joined feature selects its row', () => { + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [referenceFeature('ou1', 'Ou One', '/country1/ou1')], + } + const layers = [ + { + id: 'layerA', + name: 'Layer A', + data: [ + feature({ + id: 'evtA1', + orgUnitPath: '/country1/ou1', + rawValue: 20, + }), + ], + }, + ] + + const store = mockStore({ + ui: { + lastClickedFeature: { + id: 'evtA1', + layerId: 'layerA', + multiSelect: true, + }, + }, + }) + render( + <Provider store={store}> + <VirtuosoMockContext.Provider + value={{ viewportHeight: 300, itemHeight: 28 }} + > + <CombinedDataTable + availableWidth={800} + layers={layers} + referenceLayer={referenceLayer} + joinConfig={{ + layers: { + layerA: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, + }} + /> + </VirtuosoMockContext.Provider> + </Provider> + ) + + expect(store.getActions()).toContainEqual({ + type: 'SELECTION_SET_CROSS_LAYER', + crossLayerIds: { ref1: ['ou1'], layerA: ['evtA1'] }, + }) + }) + + test('a map click on a feature belonging to no joined row does nothing', () => { + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [referenceFeature('ou1', 'Ou One', '/country1/ou1')], + } + + const store = mockStore({ + ui: { + lastClickedFeature: { + id: 'unrelated', + layerId: 'someOtherLayer', + multiSelect: true, + }, + }, + }) + render( + <Provider store={store}> + <VirtuosoMockContext.Provider + value={{ viewportHeight: 300, itemHeight: 28 }} + > + <CombinedDataTable + availableWidth={800} + layers={[]} + referenceLayer={referenceLayer} + joinConfig={{ layers: {} }} + /> + </VirtuosoMockContext.Provider> + </Provider> + ) + + expect(store.getActions()).not.toContainEqual( + expect.objectContaining({ type: 'SELECTION_SET_CROSS_LAYER' }) + ) + }) + + describe('map visibility from column filters and global search', () => { + const twoOuReferenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [ + referenceFeature('ou1', 'Ou One', '/country1/ou1'), + referenceFeature('ou2', 'Ou Two', '/country1/ou2'), + ], + } + const layerA = { + id: 'layerA', + name: 'Layer A', + data: [ + feature({ + id: 'evtA1', + orgUnitPath: '/country1/ou1', + rawValue: 10, + }), + feature({ + id: 'evtA2', + orgUnitPath: '/country1/ou2', + rawValue: 20, + }), + ], + } + const joinConfig = { + layers: { + layerA: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + } + + test('dispatches combinedVisibleIds narrowed to the filtered rows when a column filter is active', () => { + const { store } = renderCombinedDataTable({ + referenceLayer: twoOuReferenceLayer, + layers: [layerA], + joinConfig, + filters: { id: 'ou1' }, + }) + + expect(store.getActions()).toContainEqual({ + type: 'COMBINED_VISIBLE_IDS_SET', + idsByLayer: { ref1: ['ou1'], layerA: ['evtA1'] }, + }) + }) + + test('dispatches an empty array (hide everything) for a layer with no surviving matches', () => { + const { store } = renderCombinedDataTable({ + referenceLayer: twoOuReferenceLayer, + layers: [layerA], + joinConfig, + filters: { id: 'ou2' }, + }) + + expect(store.getActions()).toContainEqual({ + type: 'COMBINED_VISIBLE_IDS_SET', + idsByLayer: { ref1: ['ou2'], layerA: ['evtA2'] }, + }) + }) + + test('narrows by global search too', () => { + const { store } = renderCombinedDataTable({ + referenceLayer: twoOuReferenceLayer, + layers: [layerA], + joinConfig, + globalSearch: 'Ou One', + }) + + expect(store.getActions()).toContainEqual({ + type: 'COMBINED_VISIBLE_IDS_SET', + idsByLayer: { ref1: ['ou1'], layerA: ['evtA1'] }, + }) + }) + + test('dispatches null (show everything) when no column filter or search is active', () => { + const { store } = renderCombinedDataTable({ + referenceLayer: twoOuReferenceLayer, + layers: [layerA], + joinConfig, + }) + + expect(store.getActions()).toContainEqual({ + type: 'COMBINED_VISIBLE_IDS_SET', + idsByLayer: null, + }) + }) + + test('does not narrow map visibility from the selection filter alone', () => { + const store = mockStore({ + ui: { selectionFilter: ['selected'] }, + }) + render( + <Provider store={store}> + <VirtuosoMockContext.Provider + value={{ viewportHeight: 300, itemHeight: 28 }} + > + <CombinedDataTable + availableWidth={800} + layers={[layerA]} + referenceLayer={twoOuReferenceLayer} + joinConfig={joinConfig} + /> + </VirtuosoMockContext.Provider> + </Provider> + ) + + expect(store.getActions()).toContainEqual({ + type: 'COMBINED_VISIBLE_IDS_SET', + idsByLayer: null, + }) + }) + + test('resets combinedVisibleIds to null on unmount', () => { + const { store, unmount } = renderCombinedDataTable({ + referenceLayer: twoOuReferenceLayer, + layers: [layerA], + joinConfig, + filters: { id: 'ou1' }, + }) + + unmount() + + const actions = store.getActions() + expect(actions[actions.length - 1]).toEqual({ + type: 'COMBINED_VISIBLE_IDS_SET', + idsByLayer: null, + }) + }) }) }) diff --git a/src/components/datatable/__tests__/DataTableButton.spec.jsx b/src/components/datatable/__tests__/DataTableButton.spec.jsx index 291b0ac824..87ac6757d8 100644 --- a/src/components/datatable/__tests__/DataTableButton.spec.jsx +++ b/src/components/datatable/__tests__/DataTableButton.spec.jsx @@ -80,21 +80,21 @@ describe('DataTableButton', () => { ]) }) - test('is a no-op when a single-layer table is already open', () => { + test('closes the panel when a single-layer table is already open', () => { const { store } = renderButton({ dataTable: { openIds: ['a'], combinedView: false }, mapViews: [layer('a'), layer('b')], }) fireEvent.click(screen.getByText('Data table')) - expect(store.getActions()).toEqual([]) + expect(store.getActions()).toEqual([{ type: 'DATA_TABLE_CLOSE' }]) }) - test('is a no-op when Combined is already open', () => { + test('closes the panel when Combined is already open', () => { const { store } = renderButton({ dataTable: { openIds: [], combinedView: true }, mapViews: [layer('a'), layer('b')], }) fireEvent.click(screen.getByText('Data table')) - expect(store.getActions()).toEqual([]) + expect(store.getActions()).toEqual([{ type: 'DATA_TABLE_CLOSE' }]) }) }) diff --git a/src/components/datatable/__tests__/useCombinedTableData.spec.js b/src/components/datatable/__tests__/useCombinedTableData.spec.js index 6085e67c67..fd4c28ca8f 100644 --- a/src/components/datatable/__tests__/useCombinedTableData.spec.js +++ b/src/components/datatable/__tests__/useCombinedTableData.spec.js @@ -57,6 +57,8 @@ describe('useCombinedTableData - org unit join', () => { 'layerA_rawValue', 'layerA_legend', ]) + expect(result.current.headers[0].name).toBe('Org unit Id') + expect(result.current.headers[1].name).toBe('Org unit') expect(result.current.rows).toHaveLength(2) const row1 = result.current.rows.find( @@ -792,3 +794,59 @@ describe('useCombinedTableData - Earth Engine value columns', () => { expect(findCell(row1, 'layerA_2').value).toBe(12.1) }) }) + +describe('useCombinedTableData - show only features in view', () => { + const referenceFeature = ({ id, name, path, coordinates }) => ({ + type: 'Feature', + properties: { id, name, orgUnitPath: path, level: 2 }, + geometry: { type: 'Point', coordinates }, + }) + + const referenceLayerWithGeometry = { + id: 'ref1', + data: [ + referenceFeature({ + id: 'ou1', + name: 'Ou One', + path: '/country1/ou1', + coordinates: [1, 1], + }), + referenceFeature({ + id: 'ou2', + name: 'Ou Two', + path: '/country1/ou2', + coordinates: [10, 10], + }), + ], + } + + test('includes every reference org unit when showOnlyFeaturesInView is false, regardless of mapBounds', () => { + const { result } = renderHook(() => + useCombinedTableData({ + layers: [], + referenceLayer: referenceLayerWithGeometry, + joinConfig: { layers: {} }, + showOnlyFeaturesInView: false, + mapBounds: [0, 0, 2, 2], + }) + ) + + expect(result.current.rows).toHaveLength(2) + }) + + test('only includes reference org units within mapBounds when showOnlyFeaturesInView is true', () => { + const { result } = renderHook(() => + useCombinedTableData({ + layers: [], + referenceLayer: referenceLayerWithGeometry, + joinConfig: { layers: {} }, + showOnlyFeaturesInView: true, + mapBounds: [0, 0, 2, 2], + }) + ) + + expect(result.current.rows.map((r) => findCell(r, 'id').value)).toEqual( + ['ou1'] + ) + }) +}) diff --git a/src/components/datatable/controls/ClearFiltersControl.jsx b/src/components/datatable/controls/ClearFiltersControl.jsx index 902e0fc7ed..303e2b1e14 100644 --- a/src/components/datatable/controls/ClearFiltersControl.jsx +++ b/src/components/datatable/controls/ClearFiltersControl.jsx @@ -8,6 +8,8 @@ import ToolbarIconButton from './ToolbarIconButton.jsx' const ClearFiltersControl = ({ disabled, onClick }) => ( <ToolbarIconButton tooltip={i18n.t('Clear filters')} + ariaLabel={i18n.t('Clear filters')} + dataTest="data-table-clear-filters-button" onClick={onClick} disabled={disabled} > diff --git a/src/components/datatable/controls/JoinLayersControl.jsx b/src/components/datatable/controls/JoinLayersControl.jsx index f134e069c7..ffbb00b934 100644 --- a/src/components/datatable/controls/JoinLayersControl.jsx +++ b/src/components/datatable/controls/JoinLayersControl.jsx @@ -1,5 +1,4 @@ import i18n from '@dhis2/d2-i18n' -import { IconVisualizationColumnMulti16 } from '@dhis2/ui' import PropTypes from 'prop-types' import React, { useRef, useState } from 'react' import { getCombinedAggregationTypes } from '../../../constants/aggregationTypes.js' @@ -10,17 +9,12 @@ import { GEO_TYPE_POLYGON, GEO_TYPE_MULTIPOLYGON, } from '../../../util/geojson.js' +import Checkbox from '../../core/Checkbox.jsx' +import { IconLayersStack16 } from '../../core/icons.jsx' import { FilterDropdownPopover } from '../FilterDropdownPopover.jsx' import styles from './styles/JoinLayersControl.module.css' import ToolbarIconButton from './ToolbarIconButton.jsx' -// Spatial join means point-in-polygon against the reference org unit's own -// boundary - offered for any layer whose features are literally points, or -// whose geometry is a polygon/multipolygon (matched via its centroid -// instead - see util/spatialJoin.js). Geometry-based, not layer-type-based: -// this is what makes a GeoJSON URL layer (or any other layer type with no -// org-unit identity of its own) still joinable in Combined even though -// "Org unit" join can never match anything for it. const isSpatialEligible = (layer) => { const geometryType = layer.data?.[0]?.geometry?.type return [GEO_TYPE_POINT, GEO_TYPE_POLYGON, GEO_TYPE_MULTIPOLYGON].includes( @@ -28,11 +22,6 @@ const isSpatialEligible = (layer) => { ) } -// A layer with no org-unit path on its own features (e.g. GeoJSON URL) can -// never match anything under "Org unit" join - defaulting a newly-checked -// layer to that mode would silently leave every cell blank until the user -// happens to switch it to Spatial themselves. Default to whichever mode can -// actually match instead. const hasOrgUnitIdentity = (layer) => { const feature = layer.data?.[0] return !!(feature?.properties ?? feature)?.[ORG_UNIT_PATH_DATA_KEY] @@ -88,7 +77,7 @@ const JoinLayersControl = ({ eligibleLayers, layersConfig, onChange }) => { disabled={!eligibleLayers.length} onClick={() => setIsOpen((o) => !o)} > - <IconVisualizationColumnMulti16 /> + <IconLayersStack16 /> </ToolbarIconButton> {isOpen && ( <FilterDropdownPopover @@ -97,117 +86,130 @@ const JoinLayersControl = ({ eligibleLayers, layersConfig, onChange }) => { onClickOutside={() => setIsOpen(false)} > <div className={styles.joinLayersPopover}> - {eligibleLayers.map((layer) => { - const settings = layersConfig[layer.id] - return ( - <div key={layer.id} className={styles.layerRow}> - <label - className={styles.layerCheckboxLabel} + <div className={styles.layerList}> + {eligibleLayers.map((layer) => { + const settings = layersConfig[layer.id] + return ( + <div + key={layer.id} + className={styles.layerRow} > - <input - type="checkbox" + <Checkbox + label={ + <span + className={styles.layerName} + > + {layer.name} + </span> + } checked={!!settings} onChange={() => onToggle(layer)} + className={styles.layerCheckbox} + dataTest={`data-table-join-layer-${layer.id}`} /> - <span className={styles.layerName}> - {layer.name} - </span> - </label> - {settings && ( - <div className={styles.layerSettings}> - <select - aria-label={i18n.t( - 'Join type for {{layer}}', - { layer: layer.name } - )} - value={settings.type} - onChange={(e) => - onTypeChange( - layer.id, - e.target.value - ) - } + {settings && ( + <div + className={styles.layerSettings} > - <option value="orgUnit"> - {i18n.t('Org unit')} - </option> - {isSpatialEligible(layer) && ( - <option value="spatial"> - {i18n.t('Spatial')} - </option> - )} - </select> - {getCombinedValueDataKeys( - layer - ).map(({ dataKey, name }) => ( - <div - key={dataKey} - className={ - styles.aggregationRow + <select + aria-label={i18n.t( + 'Join type for {{layer}}', + { layer: layer.name } + )} + value={settings.type} + onChange={(e) => + onTypeChange( + layer.id, + e.target.value + ) } > - {name && ( - <span - className={ - styles.aggregationRowLabel - } - > - {name} - </span> + <option value="orgUnit"> + {i18n.t('Org unit')} + </option> + {isSpatialEligible( + layer + ) && ( + <option value="spatial"> + {i18n.t('Spatial')} + </option> )} - <select - aria-label={ - name - ? i18n.t( - 'Aggregation type for {{name}} ({{layer}})', - { - name, - layer: layer.name, - } - ) - : i18n.t( - 'Aggregation type for {{layer}}', - { - layer: layer.name, - } - ) - } - value={ - settings - .aggregation?.[ - dataKey - ] ?? 'SUM' - } - onChange={(e) => - onAggregationChange( - layer.id, - dataKey, - e.target.value - ) + </select> + {getCombinedValueDataKeys( + layer + ).map(({ dataKey, name }) => ( + <div + key={dataKey} + className={ + styles.aggregationRow } > - {aggregationTypes.map( - (type) => ( - <option - key={ - type.id - } - value={ - type.id - } - > - {type.name} - </option> - ) + {name && ( + <span + className={ + styles.aggregationRowLabel + } + > + {name} + </span> )} - </select> - </div> - ))} - </div> - )} - </div> - ) - })} + <select + aria-label={ + name + ? i18n.t( + 'Aggregation type for {{name}} ({{layer}})', + { + name, + layer: layer.name, + } + ) + : i18n.t( + 'Aggregation type for {{layer}}', + { + layer: layer.name, + } + ) + } + value={ + settings + .aggregation?.[ + dataKey + ] ?? 'SUM' + } + onChange={(e) => + onAggregationChange( + layer.id, + dataKey, + e.target + .value + ) + } + > + {aggregationTypes.map( + (type) => ( + <option + key={ + type.id + } + value={ + type.id + } + > + { + type.name + } + </option> + ) + )} + </select> + </div> + ))} + </div> + )} + </div> + ) + })} + </div> </div> </FilterDropdownPopover> )} diff --git a/src/components/datatable/controls/ReferenceOrgUnitControl.jsx b/src/components/datatable/controls/ReferenceOrgUnitControl.jsx index 7ac97c94bb..0f0307b460 100644 --- a/src/components/datatable/controls/ReferenceOrgUnitControl.jsx +++ b/src/components/datatable/controls/ReferenceOrgUnitControl.jsx @@ -1,20 +1,11 @@ import i18n from '@dhis2/d2-i18n' -import { IconLocation16 } from '@dhis2/ui' +import { IconDimensionOrgUnit16 } from '@dhis2/ui' import React from 'react' import { useDispatch, useSelector } from 'react-redux' import { editLayer } from '../../../actions/layers.js' import { COMBINED_TABLE_REF_LAYER } from '../../../constants/layers.js' import ToolbarIconButton from './ToolbarIconButton.jsx' -// Shared by ReferenceOrgUnitControl (the toolbar button) and BottomPanel.jsx -// (which also needs to open the same dialog when "Combined" is selected -// before a reference has been configured yet) - both open the reference -// layer for editing via the same editLayer/LayerEdit.jsx flow every other -// layer uses, creating it first (as a draft, no id yet) if it doesn't -// already exist in mapViews. See CLAUDE.md/map-layer-architecture: -// LayerEdit.jsx routes to addLayer or updateLayer on save based on whether -// the object passed here has an id, so neither caller dispatches either -// directly. export const useReferenceLayer = () => { const dispatch = useDispatch() const referenceLayer = useSelector((state) => @@ -45,7 +36,7 @@ const ReferenceOrgUnitControl = () => { dataTest="data-table-reference-org-unit-button" onClick={openReferenceLayerEditor} > - <IconLocation16 /> + <IconDimensionOrgUnit16 /> </ToolbarIconButton> ) } diff --git a/src/components/datatable/controls/ShowInViewControl.jsx b/src/components/datatable/controls/ShowInViewControl.jsx index a1297b0ae7..0922cca3d0 100644 --- a/src/components/datatable/controls/ShowInViewControl.jsx +++ b/src/components/datatable/controls/ShowInViewControl.jsx @@ -7,6 +7,8 @@ import ToolbarIconButton from './ToolbarIconButton.jsx' const ShowInViewControl = ({ active, onClick }) => ( <ToolbarIconButton tooltip={i18n.t('Show only features in current map view')} + ariaLabel={i18n.t('Show only features in current map view')} + dataTest="data-table-show-in-view-button" onClick={onClick} active={active} > diff --git a/src/components/datatable/controls/styles/JoinLayersControl.module.css b/src/components/datatable/controls/styles/JoinLayersControl.module.css index 4b13bbbd15..64e5fcbb9e 100644 --- a/src/components/datatable/controls/styles/JoinLayersControl.module.css +++ b/src/components/datatable/controls/styles/JoinLayersControl.module.css @@ -1,13 +1,18 @@ .joinLayersPopover { padding: var(--spacers-dp8); min-width: 220px; - max-height: 320px; - overflow-y: auto; background-color: var(--colors-white); border-radius: 4px; box-shadow: var(--elevations-popover); } +.layerList { + display: flex; + flex-direction: column; + max-height: 320px; + overflow-y: auto; +} + .layerRow { padding: var(--spacers-dp2) var(--spacers-dp4); border-radius: 3px; @@ -17,15 +22,15 @@ background: var(--colors-grey100); } -.layerCheckboxLabel { - display: flex; - align-items: center; - gap: var(--spacers-dp4); - cursor: pointer; +.layerCheckbox { + margin: 0; +} + +.layerCheckbox :global(label) { + min-width: 0; } .layerName { - flex: 1; min-width: 0; overflow: hidden; white-space: nowrap; diff --git a/src/components/datatable/useCombinedTableData.js b/src/components/datatable/useCombinedTableData.js index 0e6e86aa53..8e9a2ef669 100644 --- a/src/components/datatable/useCombinedTableData.js +++ b/src/components/datatable/useCombinedTableData.js @@ -8,9 +8,14 @@ import { TYPE_STRING, } from '../../constants/dataTable.js' import { EARTH_ENGINE_LAYER } from '../../constants/layers.js' +import { + SELECTION_FILTER_SELECTED, + SELECTION_FILTER_NOT_SELECTED, +} from '../../constants/selection.js' import { applyAggregation } from '../../util/aggregation.js' import { getCombinedValueDataKeys } from '../../util/dataTable.js' import { filterByGlobalSearch, filterData } from '../../util/filter.js' +import { isFeatureInBounds } from '../../util/geojson.js' import { matchFeaturesToReferenceOrgUnits } from '../../util/spatialJoin.js' import { buildRowCells, @@ -24,9 +29,6 @@ const LARGE_FEATURE_THRESHOLD = 10000 const DEFAULT_AGGREGATION = 'SUM' const EMPTY_AGGREGATIONS = {} -// Mirrors util/tableRows.js's own data + dataWithoutCoords merge for the -// single-layer table - org units/facilities missing valid coordinates -// still belong in the join, they just can't render on the map. const getJoinableFeatures = (layer) => [...(layer?.data ?? []), ...(layer?.dataWithoutCoords ?? [])].filter( (d) => !d.properties?.hasAdditionalGeometry @@ -34,12 +36,6 @@ const getJoinableFeatures = (layer) => const getProps = (feature) => feature.properties || feature -// Earth Engine layers compute their value(s) client-side into their own -// Redux slice (state.aggregations, keyed by feature id) rather than -// attaching them to the feature itself - util/tableRows.js's buildTableData -// does this same merge for the single-layer table. Every other layer type -// already carries its value(s) directly on properties from its loader, so -// this is a no-op for them. const mergeAggregations = (layer, aggregationsForLayer) => { if (layer.layer !== EARTH_ENGINE_LAYER || !aggregationsForLayer) { return layer @@ -58,11 +54,6 @@ const mergeAggregations = (layer, aggregationsForLayer) => { } } -// A feature belongs to a reference org unit if it IS that org unit, or is -// one of its descendants (a path-prefix match) - "the reference OU or -// lower, using the hierarchy". Reference org units are usually all one -// level, so most features hit the direct-match Map; only a genuine -// descendant needs the O(referenceOrgUnits) prefix scan. const matchOrgUnitReference = ( features, referenceOrgUnits, @@ -92,12 +83,6 @@ const matchOrgUnitReference = ( return byReferenceId } -// useCentroid: true unconditionally - getTestPoint (spatialJoin.js) already -// tests a feature as-is when it's literally a Point, so this only takes -// effect for non-point geometry, regardless of layer type (see -// isSpatialEligible in JoinLayersControl.jsx, which is what actually -// decides whether "Spatial" is offered for a given layer in the first -// place). const matchSpatialReference = (features, referenceOrgUnits) => { const byReferenceId = new Map() const matched = matchFeaturesToReferenceOrgUnits( @@ -117,14 +102,17 @@ const matchSpatialReference = (features, referenceOrgUnits) => { return byReferenceId } -// Shared across every row: apply Combined's own local filters/global -// search (reusing the same utilities as the single-layer table), sort by -// natural insertion order (via each flat row's index) when no sort column is -// active, then build the final {dataKey, value, align, itemId} cell shape. const finalizeRows = ( flatRows, headers, - { filters, globalSearch, sortField, sortDirection } + { + filters, + globalSearch, + sortField, + sortDirection, + selectionFilter, + selectedIdSet, + } ) => { let data = filterData(flatRows, filters) @@ -135,6 +123,18 @@ const finalizeRows = ( data = filterByGlobalSearch(data, globalSearch, { stringDataKeys }) } + if (selectionFilter?.length) { + const wantSelected = selectionFilter.includes(SELECTION_FILTER_SELECTED) + const wantNotSelected = selectionFilter.includes( + SELECTION_FILTER_NOT_SELECTED + ) + if (wantSelected !== wantNotSelected) { + data = data.filter( + (item) => !!selectedIdSet?.has(item.id) === wantSelected + ) + } + } + data = [...data].sort((a, b) => compareRows(a, b, { sortField, sortDirection }) ) @@ -142,6 +142,34 @@ const finalizeRows = ( return data.map((row) => buildRowCells(row, headers)) } +const applyLayerMatchToRow = ({ row, featureIds, refProps }, layerMatch) => { + const { layer, settings, byReferenceId, valueDataKeys } = layerMatch + const matches = byReferenceId.get(refProps.id) ?? [] + + valueDataKeys.forEach(({ dataKey }) => { + const values = matches.map((p) => p[dataKey]).filter((v) => v != null) + row[`${layer.id}_${dataKey}`] = applyAggregation( + settings.aggregation?.[dataKey] ?? DEFAULT_AGGREGATION, + values + ) + }) + + if (layer.layer !== EARTH_ENGINE_LAYER) { + const legends = matches + .map((p) => p[LEGEND_KEY]) + .filter((v) => v != null) + row[`${layer.id}_${LEGEND_KEY}`] = + legends.length && legends.every((l) => l === legends[0]) + ? legends[0] + : null + } + + const ids = matches.map((p) => p.id).filter((id) => id != null) + if (ids.length) { + featureIds[layer.id] = ids + } +} + const EMPTY_COLUMN_OPTIONS = {} const EMPTY_RESULT = { @@ -152,15 +180,6 @@ const EMPTY_RESULT = { spatialWarning: false, } -// layers: the participating layers (each with joinConfig.layers[layer.id] = -// {type, aggregation}), NOT including the reference layer itself. -// referenceLayer: the hidden combinedTableRef layer backing the join - its -// own fetched org units are the row set, always, regardless of whether any -// participating layer has data for a given one. -// aggregations: state.aggregations, keyed by layer id - passed in rather -// than read via useSelector here so this hook stays fully prop-driven (and -// trivially testable without a Redux Provider), matching every other input. -// Only Earth Engine layers ever have an entry (see mergeAggregations). export const useCombinedTableData = ({ layers, referenceLayer, @@ -170,12 +189,26 @@ export const useCombinedTableData = ({ filters, globalSearch, aggregations: allAggregations = EMPTY_AGGREGATIONS, + showOnlyFeaturesInView = false, + mapBounds, + selectionFilter, + selectedIdSet, }) => { const referenceOrgUnits = useMemo( () => getJoinableFeatures(referenceLayer), [referenceLayer] ) + const visibleReferenceOrgUnits = useMemo( + () => + showOnlyFeaturesInView && mapBounds + ? referenceOrgUnits.filter((f) => + isFeatureInBounds(f, mapBounds) + ) + : referenceOrgUnits, + [referenceOrgUnits, showOnlyFeaturesInView, mapBounds] + ) + const referenceByPath = useMemo( () => new Map( @@ -233,8 +266,8 @@ export const useCombinedTableData = ({ ) const headers = [ - { name: i18n.t('ID'), dataKey: 'id', type: TYPE_STRING }, - { name: i18n.t('Name'), dataKey: 'name', type: TYPE_STRING }, + { name: i18n.t('Org unit Id'), dataKey: 'id', type: TYPE_STRING }, + { name: i18n.t('Org unit'), dataKey: 'name', type: TYPE_STRING }, { name: i18n.t('Level'), dataKey: 'level', type: TYPE_NUMBER }, ...layerMatches.flatMap(({ layer, valueDataKeys }) => [ ...valueDataKeys.map(({ dataKey, name }) => ({ @@ -266,64 +299,37 @@ export const useCombinedTableData = ({ const rowFeatureIds = new Map() - const flatRows = referenceOrgUnits.map((referenceFeature, index) => { - const refProps = getProps(referenceFeature) - const row = { - id: refProps.id, - name: refProps.name ?? null, - level: refProps[ORG_UNIT_LEVEL_DATA_KEY] ?? null, - index, - } - - // Always includes the reference org unit's own feature, so - // "zoom to feature" has real bounds even when no participating - // layer has a match for this row. - const featureIds = { [referenceLayer.id]: [refProps.id] } - - layerMatches.forEach( - ({ layer, settings, byReferenceId, valueDataKeys }) => { - const matches = byReferenceId.get(refProps.id) ?? [] - - valueDataKeys.forEach(({ dataKey }) => { - const values = matches - .map((p) => p[dataKey]) - .filter((v) => v != null) - row[`${layer.id}_${dataKey}`] = applyAggregation( - settings.aggregation?.[dataKey] ?? - DEFAULT_AGGREGATION, - values - ) - }) + const flatRows = visibleReferenceOrgUnits.map( + (referenceFeature, index) => { + const refProps = getProps(referenceFeature) + const row = { + id: refProps.id, + name: refProps.name ?? null, + level: refProps[ORG_UNIT_LEVEL_DATA_KEY] ?? null, + index, + } - if (layer.layer !== EARTH_ENGINE_LAYER) { - const legends = matches - .map((p) => p[LEGEND_KEY]) - .filter((v) => v != null) - row[`${layer.id}_${LEGEND_KEY}`] = - legends.length && - legends.every((l) => l === legends[0]) - ? legends[0] - : null - } + const featureIds = { [referenceLayer.id]: [refProps.id] } - const ids = matches - .map((p) => p.id) - .filter((id) => id != null) - if (ids.length) { - featureIds[layer.id] = ids - } - } - ) + layerMatches.forEach((layerMatch) => + applyLayerMatchToRow( + { row, featureIds, refProps }, + layerMatch + ) + ) - rowFeatureIds.set(refProps.id, featureIds) - return row - }) + rowFeatureIds.set(refProps.id, featureIds) + return row + } + ) const rows = finalizeRows(flatRows, headers, { filters, globalSearch, sortField, sortDirection, + selectionFilter, + selectedIdSet, }) const columnOptions = sortColumnOptions(getColumnDistinctValues(headers, flatRows), { @@ -340,11 +346,14 @@ export const useCombinedTableData = ({ } }, [ referenceOrgUnits, + visibleReferenceOrgUnits, referenceLayer, layerMatches, filters, globalSearch, sortField, sortDirection, + selectionFilter, + selectedIdSet, ]) } diff --git a/src/components/edit/LayerEdit.jsx b/src/components/edit/LayerEdit.jsx index 33e2baa542..cf18fd8530 100644 --- a/src/components/edit/LayerEdit.jsx +++ b/src/components/edit/LayerEdit.jsx @@ -100,15 +100,13 @@ const LayerEdit = ({ layer, addLayer, updateLayer, cancelLayer }) => { const isReferenceLayer = type === COMBINED_TABLE_REF_LAYER - // The reference org unit layer isn't really "a layer" from the user's - // perspective (it's never visible/rendered) - a single, state-agnostic - // title reads better than the generic Edit/Add wording every other - // layer type gets. - const title = isReferenceLayer - ? i18n.t('Configure reference org units') - : layer.id + // The reference org unit layer isn't really "a layer" from the user's perspective + const editOrAddTitle = layer.id ? i18n.t('Edit {{name}} layer', { name }) : i18n.t('Add new {{name}} layer', { name }) + const title = isReferenceLayer + ? i18n.t('Configure reference org units') + : editOrAddTitle return ( <Modal position="top" dataTest="layeredit" fluid onClose={cancelLayer}> diff --git a/src/components/map/Map.jsx b/src/components/map/Map.jsx index 05b9f65ccc..78c951562b 100644 --- a/src/components/map/Map.jsx +++ b/src/components/map/Map.jsx @@ -47,6 +47,7 @@ class Map extends Component { bounds: PropTypes.array, clickFeature: PropTypes.func, closeCoordinatePopup: PropTypes.func, + combinedVisibleIds: PropTypes.object, controls: PropTypes.array, coordinatePopup: PropTypes.array, engine: PropTypes.object, @@ -205,6 +206,7 @@ class Map extends Component { highlightFeature, highlightColor, selectionFilter, + combinedVisibleIds, clickFeature, toggleFeatureSelection, coordinatePopup: coordinates, @@ -259,6 +261,7 @@ class Map extends Component { highlightFeature={highlightFeature} highlightColor={highlightColor} selectionFilter={selectionFilter} + combinedVisibleIds={combinedVisibleIds} clickFeature={clickFeature} toggleFeatureSelection={ toggleFeatureSelection diff --git a/src/components/map/MapContainer.jsx b/src/components/map/MapContainer.jsx index 99a891caad..dbff78fcda 100644 --- a/src/components/map/MapContainer.jsx +++ b/src/components/map/MapContainer.jsx @@ -26,9 +26,13 @@ const MapContainer = ({ resizeCount, setMap }) => { ) const feature = useSelector((state) => state.feature) const selection = useSelector((state) => state.selection) - const { layersSorting, highlightColor, selectionFilter } = useSelector( - (state) => state.ui - ) + const combinedView = useSelector((state) => state.dataTable.combinedView) + const { + layersSorting, + highlightColor, + selectionFilter, + combinedVisibleIds, + } = useSelector((state) => state.ui) const basemapConfig = useBasemapConfig(basemap) const dispatch = useDispatch() @@ -40,11 +44,6 @@ const MapContainer = ({ resizeCount, setMap }) => { dispatchHighlightFeature ) - // The Combined data table's reference org unit layer is hidden and - // never rendered on the map canvas - excluded from both the render - // list and the isLoading count (comparing against the raw - // mapViews.length here would leave isLoading permanently stuck true, - // since a reference layer is never included in loadedMapViews). const renderableMapViews = mapViews.filter( (layer) => layer.layer !== COMBINED_TABLE_REF_LAYER ) @@ -63,11 +62,14 @@ const MapContainer = ({ resizeCount, setMap }) => { selection={selection} highlightColor={highlightColor} selectionFilter={selectionFilter} + combinedVisibleIds={combinedVisibleIds} highlightFeature={debouncedHighlightFeature} clickFeature={(payload) => dispatch(clickFeature(payload))} - toggleFeatureSelection={(id, layerId) => - dispatch(toggleFeatureSelection(id, layerId)) - } + toggleFeatureSelection={(id, layerId) => { + if (!combinedView) { + dispatch(toggleFeatureSelection(id, layerId)) + } + }} openContextMenu={(config) => dispatch(openContextMenu(config))} coordinatePopup={coordinatePopup} interpretationModalOpen={interpretationModalOpen} diff --git a/src/components/map/MapView.jsx b/src/components/map/MapView.jsx index 53e354b3d4..22ce5eb8dd 100644 --- a/src/components/map/MapView.jsx +++ b/src/components/map/MapView.jsx @@ -21,6 +21,7 @@ const MapView = (props) => { highlightFeature, highlightColor, selectionFilter, + combinedVisibleIds, clickFeature, toggleFeatureSelection, bounds, @@ -67,6 +68,7 @@ const MapView = (props) => { highlightFeature={highlightFeature} highlightColor={highlightColor} selectionFilter={selectionFilter} + combinedVisibleIds={combinedVisibleIds} clickFeature={clickFeature} toggleFeatureSelection={toggleFeatureSelection} interpretationModalOpen={interpretationModalOpen} @@ -88,6 +90,7 @@ const MapView = (props) => { highlightFeature={highlightFeature} highlightColor={highlightColor} selectionFilter={selectionFilter} + combinedVisibleIds={combinedVisibleIds} clickFeature={clickFeature} toggleFeatureSelection={toggleFeatureSelection} coordinatePopup={coordinatePopup} @@ -111,6 +114,7 @@ MapView.propTypes = { basemap: PropTypes.object, bounds: PropTypes.array, clickFeature: PropTypes.func, + combinedVisibleIds: PropTypes.object, controls: PropTypes.array, coordinatePopup: PropTypes.array, feature: PropTypes.object, diff --git a/src/components/map/SplitView.jsx b/src/components/map/SplitView.jsx index 0a1f13bcdf..75c5e0f2a8 100644 --- a/src/components/map/SplitView.jsx +++ b/src/components/map/SplitView.jsx @@ -17,6 +17,7 @@ const SplitView = ({ highlightFeature, highlightColor, selectionFilter, + combinedVisibleIds, clickFeature, toggleFeatureSelection, controls, @@ -102,6 +103,7 @@ const SplitView = ({ highlightFeature={highlightFeature} highlightColor={highlightColor} selectionFilter={selectionFilter} + combinedVisibleIds={combinedVisibleIds} clickFeature={clickFeature} toggleFeatureSelection={toggleFeatureSelection} openContextMenu={openContextMenu} @@ -126,6 +128,7 @@ SplitView.propTypes = { openContextMenu: PropTypes.func.isRequired, basemap: PropTypes.object, clickFeature: PropTypes.func, + combinedVisibleIds: PropTypes.object, controls: PropTypes.array, feature: PropTypes.object, highlightColor: PropTypes.string, diff --git a/src/components/map/layers/Layer.js b/src/components/map/layers/Layer.js index f9ddfbba30..ea7c0af001 100644 --- a/src/components/map/layers/Layer.js +++ b/src/components/map/layers/Layer.js @@ -25,6 +25,7 @@ class Layer extends PureComponent { static propTypes = { id: PropTypes.string.isRequired, clickFeature: PropTypes.func, + combinedVisibleIds: PropTypes.object, config: PropTypes.object, data: PropTypes.array, dataFilters: PropTypes.object, @@ -155,14 +156,19 @@ class Layer extends PureComponent { } handleVisibleIdsChange(prevProps) { - const { selection, selectionFilter } = this.props + const { selection, selectionFilter, combinedVisibleIds } = this.props if ( !idsEqual( this.getVisibleIds( prevProps.selection, - prevProps.selectionFilter + prevProps.selectionFilter, + prevProps.combinedVisibleIds ) ?? [], - this.getVisibleIds(selection, selectionFilter) ?? [] + this.getVisibleIds( + selection, + selectionFilter, + combinedVisibleIds + ) ?? [] ) ) { this.updateVisibleIds() @@ -300,10 +306,8 @@ class Layer extends PureComponent { this.layer?.select?.(this.getSelectedIds(), this.props.highlightColor) } - getVisibleIds( - selection = this.props.selection, - selectionFilter = this.props.selectionFilter - ) { + // null means "no restriction from this source, show everything". + getSelectionFilterIds(selection, selectionFilter) { const isReferenced = selection?.layerId === this.props.id || !!selection?.crossLayerIds?.[this.props.id]?.length @@ -335,6 +339,25 @@ class Layer extends PureComponent { .filter((id) => id != null && !selectedIdSet.has(id)) } + getVisibleIds( + selection = this.props.selection, + selectionFilter = this.props.selectionFilter, + combinedVisibleIds = this.props.combinedVisibleIds + ) { + const selectionIds = this.getSelectionFilterIds( + selection, + selectionFilter + ) + const combinedIds = combinedVisibleIds?.[this.props.id] ?? null + + if (selectionIds && combinedIds) { + const combinedIdSet = new Set(combinedIds) + return selectionIds.filter((id) => combinedIdSet.has(id)) + } + + return selectionIds ?? combinedIds + } + updateVisibleIds() { this.layer?.setVisibleIds?.(this.getVisibleIds()) } @@ -346,9 +369,10 @@ class Layer extends PureComponent { return } - this.props.clickFeature?.({ id, layerId: this.props.id }) + const multiSelect = this.isMultiSelectClick(evt) + this.props.clickFeature?.({ id, layerId: this.props.id, multiSelect }) - if (this.isMultiSelectClick(evt)) { + if (multiSelect) { this.props.toggleFeatureSelection?.(id, this.props.id) } } diff --git a/src/components/map/layers/__tests__/Layer.spec.js b/src/components/map/layers/__tests__/Layer.spec.js index 5ab4c5448d..4046bdc0fa 100644 --- a/src/components/map/layers/__tests__/Layer.spec.js +++ b/src/components/map/layers/__tests__/Layer.spec.js @@ -90,6 +90,44 @@ describe('Layer#getVisibleIds', () => { }) expect(layer.getVisibleIds()).toBe(null) }) + + test('returns null (show everything) when combinedVisibleIds has no entry for this layer', () => { + const layer = createLayer({ + id: 'layer1', + data, + combinedVisibleIds: null, + }) + expect(layer.getVisibleIds()).toBe(null) + }) + + test("returns only this layer's own combinedVisibleIds entry, ignoring other layers'", () => { + const layer = createLayer({ + id: 'layer1', + data, + combinedVisibleIds: { layer1: ['a', 'b'], layer2: ['c'] }, + }) + expect(layer.getVisibleIds()).toEqual(['a', 'b']) + }) + + test('returns an empty array (hide everything) when combinedVisibleIds keys this layer in with no ids', () => { + const layer = createLayer({ + id: 'layer1', + data, + combinedVisibleIds: { layer1: [] }, + }) + expect(layer.getVisibleIds()).toEqual([]) + }) + + test('intersects selectionFilter and combinedVisibleIds when both apply', () => { + const layer = createLayer({ + id: 'layer1', + data, + selection: { layerId: 'layer1', ids: ['a', 'b'] }, + selectionFilter: ['selected'], + combinedVisibleIds: { layer1: ['b', 'c'] }, + }) + expect(layer.getVisibleIds()).toEqual(['b']) + }) }) describe('Layer#getHoverIds', () => { @@ -176,3 +214,82 @@ describe('Layer#getSelectedIds', () => { expect(layer.getSelectedIds()).toEqual(['x']) }) }) + +describe('Layer#onFeatureLeftClick', () => { + const clickEvent = (id, keys = {}) => ({ + feature: { properties: { id } }, + ...keys, + }) + + test('a plain click reports clickFeature with multiSelect: false and does not toggle selection', () => { + const clickFeature = jest.fn() + const toggleFeatureSelection = jest.fn() + const layer = createLayer({ + id: 'layer1', + clickFeature, + toggleFeatureSelection, + }) + + layer.onFeatureLeftClick(clickEvent('a')) + + expect(clickFeature).toHaveBeenCalledWith({ + id: 'a', + layerId: 'layer1', + multiSelect: false, + }) + expect(toggleFeatureSelection).not.toHaveBeenCalled() + }) + + test('a ctrl-click reports clickFeature with multiSelect: true and also toggles selection', () => { + const clickFeature = jest.fn() + const toggleFeatureSelection = jest.fn() + const layer = createLayer({ + id: 'layer1', + clickFeature, + toggleFeatureSelection, + }) + + layer.onFeatureLeftClick(clickEvent('a', { ctrlKey: true })) + + expect(clickFeature).toHaveBeenCalledWith({ + id: 'a', + layerId: 'layer1', + multiSelect: true, + }) + expect(toggleFeatureSelection).toHaveBeenCalledWith('a', 'layer1') + }) + + test('a meta-click (cmd on macOS) also counts as multiSelect', () => { + const clickFeature = jest.fn() + const toggleFeatureSelection = jest.fn() + const layer = createLayer({ + id: 'layer1', + clickFeature, + toggleFeatureSelection, + }) + + layer.onFeatureLeftClick(clickEvent('a', { metaKey: true })) + + expect(clickFeature).toHaveBeenCalledWith({ + id: 'a', + layerId: 'layer1', + multiSelect: true, + }) + expect(toggleFeatureSelection).toHaveBeenCalledWith('a', 'layer1') + }) + + test('does nothing when the clicked feature has no id', () => { + const clickFeature = jest.fn() + const toggleFeatureSelection = jest.fn() + const layer = createLayer({ + id: 'layer1', + clickFeature, + toggleFeatureSelection, + }) + + layer.onFeatureLeftClick({ feature: { properties: {} } }) + + expect(clickFeature).not.toHaveBeenCalled() + expect(toggleFeatureSelection).not.toHaveBeenCalled() + }) +}) diff --git a/src/constants/actionTypes.js b/src/constants/actionTypes.js index 5ff54f0d5d..e22ae71f71 100644 --- a/src/constants/actionTypes.js +++ b/src/constants/actionTypes.js @@ -50,6 +50,7 @@ export const DATA_TABLE_COLUMN_CONFIG_SET = 'DATA_TABLE_COLUMN_CONFIG_SET' export const ACTIVE_TIMELINE_PERIOD_SET = 'ACTIVE_TIMELINE_PERIOD_SET' export const DATA_TABLE_COMBINED_VIEW_TOGGLE = 'DATA_TABLE_COMBINED_VIEW_TOGGLE' export const DATA_TABLE_JOIN_CONFIG_SET = 'DATA_TABLE_JOIN_CONFIG_SET' +export const COMBINED_VISIBLE_IDS_SET = 'COMBINED_VISIBLE_IDS_SET' /* DATA FILTER */ export const DATA_FILTER_SET = 'DATA_FILTER_SET' diff --git a/src/reducers/selection.js b/src/reducers/selection.js index b736746384..84fb3f081d 100644 --- a/src/reducers/selection.js +++ b/src/reducers/selection.js @@ -11,43 +11,61 @@ const removeCrossLayerId = (crossLayerIds, layerId) => { ) } -const selection = (state = defaultState, action) => { - switch (action.type) { - case types.FEATURE_TOGGLE_SELECTION: { - if (state.layerId !== action.layerId) { - return { layerId: action.layerId, ids: [action.id] } - } +const toggleFeatureSelection = (state, action) => { + if (state.layerId !== action.layerId) { + return { layerId: action.layerId, ids: [action.id] } + } + + const alreadySelected = state.ids.includes(action.id) + + return { + layerId: action.layerId, + ids: alreadySelected + ? state.ids.filter((id) => id !== action.id) + : [...state.ids, action.id], + } +} + +const addSelectionRange = (state, action) => { + const ids = state.layerId === action.layerId ? state.ids : [] + + return { + layerId: action.layerId, + ids: [...new Set([...ids, ...action.ids])], + } +} - const alreadySelected = state.ids.includes(action.id) +const setCrossLayerSelection = (action) => + Object.keys(action.crossLayerIds).length + ? { layerId: null, ids: [], crossLayerIds: action.crossLayerIds } + : defaultState - return { - layerId: action.layerId, - ids: alreadySelected - ? state.ids.filter((id) => id !== action.id) - : [...state.ids, action.id], - } - } +const removeLayerFromSelection = (state, action) => { + if (state.layerId === action.id) { + return defaultState + } + const crossLayerIds = removeCrossLayerId(state.crossLayerIds, action.id) + if (crossLayerIds === state.crossLayerIds) { + return state + } + return Object.keys(crossLayerIds).length + ? { ...state, crossLayerIds } + : defaultState +} + +const selection = (state = defaultState, action) => { + switch (action.type) { + case types.FEATURE_TOGGLE_SELECTION: + return toggleFeatureSelection(state, action) case types.SELECTION_SET_ALL: return { layerId: action.layerId, ids: action.ids } - case types.SELECTION_ADD_RANGE: { - const ids = state.layerId === action.layerId ? state.ids : [] - - return { - layerId: action.layerId, - ids: [...new Set([...ids, ...action.ids])], - } - } + case types.SELECTION_ADD_RANGE: + return addSelectionRange(state, action) case types.SELECTION_SET_CROSS_LAYER: - return Object.keys(action.crossLayerIds).length - ? { - layerId: null, - ids: [], - crossLayerIds: action.crossLayerIds, - } - : defaultState + return setCrossLayerSelection(action) case types.SELECTION_CLEAR: case types.MAP_NEW: @@ -58,21 +76,8 @@ const selection = (state = defaultState, action) => { case types.DATA_TABLE_TOGGLE: return state.layerId === action.id ? defaultState : state - case types.LAYER_REMOVE: { - if (state.layerId === action.id) { - return defaultState - } - const crossLayerIds = removeCrossLayerId( - state.crossLayerIds, - action.id - ) - if (crossLayerIds === state.crossLayerIds) { - return state - } - return Object.keys(crossLayerIds).length - ? { ...state, crossLayerIds } - : defaultState - } + case types.LAYER_REMOVE: + return removeLayerFromSelection(state, action) default: return state diff --git a/src/reducers/ui.js b/src/reducers/ui.js index 5789ed6e57..f28dd7f808 100644 --- a/src/reducers/ui.js +++ b/src/reducers/ui.js @@ -15,6 +15,7 @@ const defaultState = { highlightColor: null, lastClickedFeature: null, activeTimelinePeriod: null, + combinedVisibleIds: null, } const ui = (state = defaultState, action) => { @@ -54,6 +55,7 @@ const ui = (state = defaultState, action) => { rightPanelOpen: false, selectionFilter: [], lastClickedFeature: null, + combinedVisibleIds: null, } case types.DATA_TABLE_CLOSE: @@ -61,6 +63,7 @@ const ui = (state = defaultState, action) => { return { ...state, selectionFilter: [], + combinedVisibleIds: null, } case types.DOWNLOAD_MODE_OPEN: @@ -128,6 +131,12 @@ const ui = (state = defaultState, action) => { activeTimelinePeriod: action.period, } + case types.COMBINED_VISIBLE_IDS_SET: + return { + ...state, + combinedVisibleIds: action.idsByLayer, + } + default: return state } diff --git a/src/util/dataTable.js b/src/util/dataTable.js index b23b91e426..c86eeb21a4 100644 --- a/src/util/dataTable.js +++ b/src/util/dataTable.js @@ -14,11 +14,11 @@ export const COMBINED_VALUE_KEY = 'rawValue' // dependency-light utility imported by most of the data table test suite, // so it deliberately doesn't take on that transitive weight for two // constant strings. -const CLASSIFIED_EARTH_ENGINE_AGGREGATION_TYPES = [ +const CLASSIFIED_EARTH_ENGINE_AGGREGATION_TYPES = new Set([ 'percentage', 'hectares', 'acres', -] +]) const toTitleCase = (str) => str.replace( @@ -26,23 +26,12 @@ const toTitleCase = (str) => (txt) => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase() ) -// The value column(s) a layer contributes to Combined - one aggregatable -// dataKey per column. Every layer type except Earth Engine has exactly one -// (COMBINED_VALUE_KEY, paired with a non-aggregatable 'legend' categorical -// column handled separately in useCombinedTableData.js). Earth Engine's own -// value shape is genuinely different (mirrors getEarthEngineHeaders in -// tableHeaders.js, which drives its single-layer table headers the same -// way): one column per legend class when aggregationType is classified -// (percentage/hectares/acres), or one column per aggregation stat -// (mean/min/max/etc) when aggregationType is an array of stat names. export const getCombinedValueDataKeys = (layer) => { if (layer.layer !== EARTH_ENGINE_LAYER) { return [{ dataKey: COMBINED_VALUE_KEY, name: null }] } if ( - CLASSIFIED_EARTH_ENGINE_AGGREGATION_TYPES.includes( - layer.aggregationType - ) && + CLASSIFIED_EARTH_ENGINE_AGGREGATION_TYPES.has(layer.aggregationType) && layer.legend?.items ) { return layer.legend.items.map(({ value, name }) => ({ @@ -113,26 +102,14 @@ export const hasActiveDataTableFilters = ({ selectionFilter?.length > 0 || !!showOnlyFeaturesInView -// state.dataTable.combinedView can legitimately stay true with openIds -// empty (e.g. every single-layer tab was closed while Combined stayed -// open) - the panel must stay open in that case too, not just when a -// single-layer tab is open. export const isDataTableOpen = ({ openIds, combinedView }) => openIds.length > 0 || combinedView -// Map-wide, not scoped to which layers currently have an open tab - used -// both for whether the Combined option/tab can be offered at all, and to -// pick a sensible default when opening the panel from scratch (e.g. the -// "Data Table" menu button). export const getEligibleDataTableLayers = (mapViews) => mapViews.filter( (l) => DATA_TABLE_LAYER_TYPES.includes(l.layer) && l.data?.length ) -// A crossLayerIds selection has no single owning layerId (layerId: null), -// so a layer's own selection can't be read off selection.ids alone once -// Combined-originated selections exist - merges in whatever this layer is -// named under in crossLayerIds too. export const getLayerSelectedIds = (selection, layerId) => { const ownIds = selection?.layerId === layerId ? selection.ids ?? [] : [] const crossIds = selection?.crossLayerIds?.[layerId] ?? [] @@ -150,9 +127,6 @@ export const buildFeatureIndex = (data) => { return index } -// Merges the per-layer feature id sets of several Combined rows (e.g. every -// selected row, or every currently filtered row) into one map suitable for -// a single crossLayerIds highlight/selection/zoom dispatch. export const mergeCrossLayerIds = (rowKeys, rowFeatureIds) => { const merged = {} rowKeys.forEach((key) => { @@ -167,10 +141,6 @@ export const mergeCrossLayerIds = (rowKeys, rowFeatureIds) => { return merged } -// Same bbox-of-matching-features computation Layer.js's own panToFeature -// does for a single layer, generalized across every layer named in -// crossLayerIds - used for Combined row/selection/filtered-set zoom, where -// no single Layer instance owns the feature set being zoomed to. export const getUnionBounds = (layers, idsByLayerId) => { const features = layers.flatMap((layer) => { const ids = idsByLayerId[layer.id] diff --git a/yarn.lock b/yarn.lock index 7953bde0f5..b001cbec97 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2431,9 +2431,9 @@ resolved "https://registry.yarnpkg.com/@dhis2/data-engine/-/data-engine-3.17.3.tgz#0347416e9919efbf4d9739c4141fa543f89669ad" integrity sha512-hLXt7LFrFitR7QgKfGQ3ComTLrY5IAdtERonhdo/SIrsRYWoeVaMiCOkUUzC48pEaeo1/BL5qwA7Tw7jZgROQw== -"@dhis2/maps-gl@git+https://github.com/d2-ci/maps-gl.git#55ba8864b811c44279dd7c85dedc37adc426e318": +"@dhis2/maps-gl@git+https://github.com/d2-ci/maps-gl.git#3934323c80eb763e843eefa6a1faa031c341d779": version "4.3.1" - resolved "git+https://github.com/d2-ci/maps-gl.git#55ba8864b811c44279dd7c85dedc37adc426e318" + resolved "git+https://github.com/d2-ci/maps-gl.git#3934323c80eb763e843eefa6a1faa031c341d779" dependencies: "@mapbox/sphericalmercator" "^1.2.0" "@turf/area" "^7.3.5" From a6eb9d894d560dafb57bb67b24de549e3f163388 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Wed, 29 Jul 2026 16:37:29 +0200 Subject: [PATCH 166/205] fix: combined table persistance --- src/actions/dataTable.js | 5 ++ src/actions/layers.js | 3 +- src/components/app/FileMenu.jsx | 26 ++++++--- src/components/datatable/BottomPanel.jsx | 29 +++++++--- .../datatable/__tests__/BottomPanel.spec.jsx | 58 +++++++++++++++++++ .../__tests__/CombinedDataTable.spec.jsx | 20 ++++++- .../__tests__/JoinLayersControl.spec.jsx | 6 ++ .../__tests__/useCombinedTableData.spec.js | 17 ++++++ .../datatable/controls/JoinLayersControl.jsx | 26 +++++---- .../datatable/useCombinedTableData.js | 16 +++-- .../layers/overlays/OverlayCard.jsx | 2 +- src/constants/actionTypes.js | 2 + .../__tests__/trackedEntityLoader.spec.js | 7 ++- src/loaders/earthEngineLoader.js | 6 ++ src/loaders/eventLoader.js | 4 +- src/loaders/facilityLoader.js | 3 + src/loaders/geoJsonUrlLoader.js | 7 +++ src/loaders/orgUnitLoader.js | 12 ++++ src/loaders/thematicLoader.js | 3 + src/loaders/trackedEntityLoader.js | 11 +++- src/reducers/__tests__/dataTable.spec.js | 40 ++++++++++--- src/reducers/dataTable.js | 29 +++++++--- src/reducers/map.js | 5 ++ src/util/favorites.js | 19 ++++++ 24 files changed, 296 insertions(+), 60 deletions(-) diff --git a/src/actions/dataTable.js b/src/actions/dataTable.js index 4e01734799..4eeccd4dc3 100644 --- a/src/actions/dataTable.js +++ b/src/actions/dataTable.js @@ -53,6 +53,11 @@ export const setJoinConfig = (config) => ({ config, }) +export const setCombinedColumnConfig = (config) => ({ + type: types.DATA_TABLE_COMBINED_COLUMN_CONFIG_SET, + config, +}) + export const setCombinedVisibleIds = (idsByLayer) => ({ type: types.COMBINED_VISIBLE_IDS_SET, idsByLayer, diff --git a/src/actions/layers.js b/src/actions/layers.js index 4583af12f7..c1b31ebbfe 100644 --- a/src/actions/layers.js +++ b/src/actions/layers.js @@ -7,9 +7,10 @@ export const addLayer = (config) => ({ }) // Remove an overlay -export const removeLayer = (id) => ({ +export const removeLayer = (id, combinedLayerKey) => ({ type: types.LAYER_REMOVE, id, + combinedLayerKey, }) // Duplicate an overlay diff --git a/src/components/app/FileMenu.jsx b/src/components/app/FileMenu.jsx index d9932e7d09..ac2d0ebcc4 100644 --- a/src/components/app/FileMenu.jsx +++ b/src/components/app/FileMenu.jsx @@ -64,16 +64,21 @@ const getSaveFailureMessage = (message) => nsSeparator: ';', }) -// state.dataTable.joinConfig.layers lives in its own Redux slice, not on the -// combinedTableRef mapView itself, so (unlike dataTableColumnConfig, which -// is already stamped directly onto its layer as it's edited) it needs an -// explicit copy onto that layer right before cleanMapConfig runs, or it -// would never reach favorites.js's packing logic at all. -const stampCombinedJoinConfig = (map, joinConfig) => ({ +// state.dataTable.joinConfig.layers/combinedColumnConfig live in their own +// Redux slice, not on the combinedTableRef mapView itself, so (unlike +// dataTableColumnConfig, which is already stamped directly onto its layer as +// it's edited) they need an explicit copy onto that layer right before +// cleanMapConfig runs, or they'd never reach favorites.js's packing logic at +// all. +const stampCombinedConfig = (map, joinConfig, combinedColumnConfig) => ({ ...map, mapViews: map.mapViews.map((view) => view.layer === COMBINED_TABLE_REF_LAYER - ? { ...view, combinedJoinConfig: joinConfig.layers } + ? { + ...view, + combinedJoinConfig: joinConfig.layers, + combinedColumnConfig, + } : view ), }) @@ -81,6 +86,9 @@ const stampCombinedJoinConfig = (map, joinConfig) => ({ const FileMenu = ({ onFileMenuAction }) => { const map = useSelector((state) => state.map) const joinConfig = useSelector((state) => state.dataTable.joinConfig) + const combinedColumnConfig = useSelector( + (state) => state.dataTable.combinedColumnConfig + ) const dispatch = useDispatch() const engine = useDataEngine() const { serverVersion } = useConfig() @@ -134,7 +142,7 @@ const FileMenu = ({ onFileMenuAction }) => { }) const cleanedMap = cleanMapConfig({ - config: stampCombinedJoinConfig(map, joinConfig), + config: stampCombinedConfig(map, joinConfig, combinedColumnConfig), defaultBasemapId: defaultBasemap, serverVersion, }) @@ -206,7 +214,7 @@ const FileMenu = ({ onFileMenuAction }) => { const onSaveAs = async ({ name, description }) => { const cleanedMap = cleanMapConfig({ - config: stampCombinedJoinConfig(map, joinConfig), + config: stampCombinedConfig(map, joinConfig, combinedColumnConfig), defaultBasemapId: defaultBasemap, serverVersion, }) diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index 705c28f41a..5df3471f3d 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -17,6 +17,7 @@ import { toggleDataTable, toggleCombinedView, setJoinConfig, + setCombinedColumnConfig, setDataTableColumnConfig, } from '../../actions/dataTable.js' import { COMBINED_HEADERS_KEY } from '../../constants/dataTable.js' @@ -54,9 +55,8 @@ const EMPTY_JOIN_LAYERS = {} const BottomPanel = () => { const dataTableHeight = useSelector((state) => state.ui.dataTableHeight) - const { openIds, combinedView, joinConfig } = useSelector( - (state) => state.dataTable - ) + const { openIds, combinedView, joinConfig, combinedColumnConfig } = + useSelector((state) => state.dataTable) const mapViews = useSelector((state) => state.map.mapViews) // Only tracks a user's explicit tab click - falls back to the most // recently opened tab whenever it doesn't (yet) name an open layer, so @@ -76,7 +76,7 @@ const BottomPanel = () => { const joinLayersConfig = joinConfig.layers ?? EMPTY_JOIN_LAYERS const combinedLayers = useMemo( - () => mapViews.filter((l) => joinLayersConfig[l.id]), + () => mapViews.filter((l) => joinLayersConfig[l.combinedLayerKey]), [mapViews, joinLayersConfig] ) @@ -100,10 +100,6 @@ const BottomPanel = () => { const [globalSearch, setGlobalSearch] = useState('') const [headersByLayer, setHeadersByLayer] = useState(null) const [combinedFilters, setCombinedFilters] = useState(EMPTY_FILTERS) - // Session-only, never persisted or dispatched to Redux - matches - // combinedFilters/joinConfig's existing ephemeral scope for the - // Combined view. - const [combinedColumnConfig, setCombinedColumnConfig] = useState(null) const hasActiveFilters = combinedView ? Object.keys(combinedFilters).length > 0 || @@ -271,6 +267,19 @@ const BottomPanel = () => { dispatch(setJoinConfig({ layers: referenceLayer.combinedJoinConfig })) }, [referenceLayer, dispatch]) + const hasHydratedColumnConfigRef = useRef(false) + useEffect(() => { + if ( + hasHydratedColumnConfigRef.current || + !referenceLayer?.isLoaded || + !referenceLayer.combinedColumnConfig + ) { + return + } + hasHydratedColumnConfigRef.current = true + dispatch(setCombinedColumnConfig(referenceLayer.combinedColumnConfig)) + }, [referenceLayer, dispatch]) + useKeyDown('Escape', onCloseDataTable, true) return ( @@ -320,7 +329,9 @@ const BottomPanel = () => { <ColumnPickerControl allHeaders={allHeaders} columnConfig={combinedColumnConfig} - onChange={setCombinedColumnConfig} + onChange={(config) => + dispatch(setCombinedColumnConfig(config)) + } /> <JoinLayersControl eligibleLayers={eligibleLayers} diff --git a/src/components/datatable/__tests__/BottomPanel.spec.jsx b/src/components/datatable/__tests__/BottomPanel.spec.jsx index 3093ec2621..c01b92746d 100644 --- a/src/components/datatable/__tests__/BottomPanel.spec.jsx +++ b/src/components/datatable/__tests__/BottomPanel.spec.jsx @@ -137,12 +137,14 @@ const twoEligibleLayers = [ { id: 'layer1', name: 'Layer 1', + combinedLayerKey: 'layer1', layer: THEMATIC_LAYER, data: [{ properties: { orgUnitPath: '/country1/ou1' } }], }, { id: 'layer2', name: 'Layer 2', + combinedLayerKey: 'layer2', layer: THEMATIC_LAYER, data: [{ properties: { orgUnitPath: '/country1/ou2' } }], }, @@ -503,3 +505,59 @@ describe('BottomPanel joinConfig hydration from a saved reference layer', () => ) }) }) + +describe('BottomPanel combinedColumnConfig hydration from a saved reference layer', () => { + const persistedColumnConfig = { pinnedKeys: ['layerA_rawValue'] } + + test("restores a loaded reference layer's persisted combinedColumnConfig once", () => { + const { store } = renderBottomPanel({ + mapViews: [ + ...DEFAULT_MAP_VIEWS, + { + ...referenceLayer(), + isLoaded: true, + combinedColumnConfig: persistedColumnConfig, + }, + ], + }) + + expect(store.getActions()).toContainEqual({ + type: 'DATA_TABLE_COMBINED_COLUMN_CONFIG_SET', + config: persistedColumnConfig, + }) + }) + + test('does not restore anything when the reference layer has not finished loading yet', () => { + const { store } = renderBottomPanel({ + mapViews: [ + ...DEFAULT_MAP_VIEWS, + { + ...referenceLayer(), + isLoaded: false, + combinedColumnConfig: persistedColumnConfig, + }, + ], + }) + + expect(store.getActions()).not.toContainEqual( + expect.objectContaining({ + type: 'DATA_TABLE_COMBINED_COLUMN_CONFIG_SET', + }) + ) + }) + + test('does not restore anything when the reference layer has no persisted combinedColumnConfig', () => { + const { store } = renderBottomPanel({ + mapViews: [ + ...DEFAULT_MAP_VIEWS, + { ...referenceLayer(), isLoaded: true }, + ], + }) + + expect(store.getActions()).not.toContainEqual( + expect.objectContaining({ + type: 'DATA_TABLE_COMBINED_COLUMN_CONFIG_SET', + }) + ) + }) +}) diff --git a/src/components/datatable/__tests__/CombinedDataTable.spec.jsx b/src/components/datatable/__tests__/CombinedDataTable.spec.jsx index 9a77744558..04d8bfaf08 100644 --- a/src/components/datatable/__tests__/CombinedDataTable.spec.jsx +++ b/src/components/datatable/__tests__/CombinedDataTable.spec.jsx @@ -61,6 +61,7 @@ describe('CombinedDataTable', () => { { id: 'layerA', name: 'Layer A', + combinedLayerKey: 'layerA', data: [ feature({ orgUnitPath: '/country1/ou1', @@ -102,6 +103,7 @@ describe('CombinedDataTable', () => { { id: 'layerA', name: 'Layer A', + combinedLayerKey: 'layerA', data: [ feature({ orgUnitPath: '/country1/ou1', @@ -136,6 +138,7 @@ describe('CombinedDataTable', () => { { id: 'layerA', name: 'Layer A', + combinedLayerKey: 'layerA', data: [feature({ orgUnitPath: '/country1/ou1' })], }, ] @@ -192,6 +195,7 @@ describe('CombinedDataTable', () => { const pointLayer = { id: 'points', name: 'Points', + combinedLayerKey: 'points', data: Array.from({ length: 10001 }, (_, i) => ({ type: 'Feature', properties: { id: `p${i}` }, @@ -247,6 +251,7 @@ describe('CombinedDataTable', () => { { id: 'layerA', name: 'Layer A', + combinedLayerKey: 'layerA', data: [ feature({ orgUnitPath: '/country1/ou1', rawValue: 20 }), feature({ orgUnitPath: '/country1/ou2', rawValue: 10 }), @@ -314,6 +319,7 @@ describe('CombinedDataTable', () => { { id: 'layerA', name: 'Layer A', + combinedLayerKey: 'layerA', data: [ feature({ id: 'evtA1', @@ -325,6 +331,7 @@ describe('CombinedDataTable', () => { { id: 'layerB', name: 'Layer B', + combinedLayerKey: 'layerB', data: [ feature({ id: 'evtB1', @@ -388,6 +395,7 @@ describe('CombinedDataTable', () => { { id: 'layerA', name: 'Layer A', + combinedLayerKey: 'layerA', data: [ feature({ id: 'evt1', @@ -435,6 +443,7 @@ describe('CombinedDataTable', () => { { id: 'layerA', name: 'Layer A', + combinedLayerKey: 'layerA', data: [feature({ id: 'evt1', orgUnitPath: '/country1/ou1' })], }, ] @@ -468,6 +477,7 @@ describe('CombinedDataTable', () => { { id: 'layerA', name: 'Layer A', + combinedLayerKey: 'layerA', data: [feature({ id: 'evt1', orgUnitPath: '/country1/ou1' })], }, ] @@ -504,6 +514,7 @@ describe('CombinedDataTable', () => { { id: 'layerA', name: 'Layer A', + combinedLayerKey: 'layerA', data: [feature({ orgUnitPath: '/country1/ou1', rawValue: 1 })], }, ] @@ -539,6 +550,7 @@ describe('CombinedDataTable', () => { { id: 'layerA', name: 'Layer A', + combinedLayerKey: 'layerA', data: [feature({ orgUnitPath: '/country1/ou1', rawValue: 20 })], }, ] @@ -571,6 +583,7 @@ describe('CombinedDataTable', () => { { id: 'layerA', name: 'Layer A', + combinedLayerKey: 'layerA', data: [feature({ orgUnitPath: '/country1/ou1', rawValue: 20 })], }, ] @@ -595,7 +608,7 @@ describe('CombinedDataTable', () => { .filter(Boolean) // headerNames[0] is the selection column's own filter button label // ("All"/"N selected") - the first real data column follows it. - expect(headerNames[1]).toBe('Level') + expect(headerNames[1]).toBe('Org unit level') }) test('dispatches setSelectionFilter when a selection-filter option is toggled', () => { @@ -659,6 +672,7 @@ describe('CombinedDataTable', () => { { id: 'layerA', name: 'Layer A', + combinedLayerKey: 'layerA', data: [ feature({ id: 'evtA1', @@ -708,6 +722,7 @@ describe('CombinedDataTable', () => { { id: 'layerA', name: 'Layer A', + combinedLayerKey: 'layerA', data: [ feature({ id: 'evtA1', @@ -757,6 +772,7 @@ describe('CombinedDataTable', () => { { id: 'layerA', name: 'Layer A', + combinedLayerKey: 'layerA', data: [ feature({ id: 'evtA1', @@ -812,6 +828,7 @@ describe('CombinedDataTable', () => { { id: 'layerA', name: 'Layer A', + combinedLayerKey: 'layerA', data: [ feature({ id: 'evtA1', @@ -905,6 +922,7 @@ describe('CombinedDataTable', () => { const layerA = { id: 'layerA', name: 'Layer A', + combinedLayerKey: 'layerA', data: [ feature({ id: 'evtA1', diff --git a/src/components/datatable/__tests__/JoinLayersControl.spec.jsx b/src/components/datatable/__tests__/JoinLayersControl.spec.jsx index 6f3871e5c3..2a5b2a1282 100644 --- a/src/components/datatable/__tests__/JoinLayersControl.spec.jsx +++ b/src/components/datatable/__tests__/JoinLayersControl.spec.jsx @@ -11,12 +11,14 @@ const eligibleLayers = [ { id: 'layer1', name: 'Layer 1', + combinedLayerKey: 'layer1', layer: THEMATIC_LAYER, data: [], }, { id: 'layer2', name: 'Layer 2', + combinedLayerKey: 'layer2', layer: THEMATIC_LAYER, data: [ { @@ -104,6 +106,7 @@ describe('JoinLayersControl popover — checkbox list', () => { { id: 'geo', name: 'Zones', + combinedLayerKey: 'geo', layer: GEOJSON_URL_LAYER, data: [{ geometry: { type: 'Point' } }], }, @@ -186,6 +189,7 @@ describe('JoinLayersControl popover — per-layer type/aggregation settings', () { id: 'geo', name: 'Zones', + combinedLayerKey: 'geo', layer: GEOJSON_URL_LAYER, data: [{ geometry: { type: 'Polygon' } }], }, @@ -247,6 +251,7 @@ describe('JoinLayersControl popover — per-layer type/aggregation settings', () const eeLayer = { id: 'ee', name: 'NDVI', + combinedLayerKey: 'ee', layer: EARTH_ENGINE_LAYER, aggregationType: ['mean', 'max'], legend: { title: 'NDVI' }, @@ -274,6 +279,7 @@ describe('JoinLayersControl popover — per-layer type/aggregation settings', () const eeLayer = { id: 'ee', name: 'NDVI', + combinedLayerKey: 'ee', layer: EARTH_ENGINE_LAYER, aggregationType: ['mean', 'max'], legend: { title: 'NDVI' }, diff --git a/src/components/datatable/__tests__/useCombinedTableData.spec.js b/src/components/datatable/__tests__/useCombinedTableData.spec.js index fd4c28ca8f..6c3bcace79 100644 --- a/src/components/datatable/__tests__/useCombinedTableData.spec.js +++ b/src/components/datatable/__tests__/useCombinedTableData.spec.js @@ -30,6 +30,7 @@ describe('useCombinedTableData - org unit join', () => { { id: 'layerA', name: 'Layer A', + combinedLayerKey: 'layerA', data: [ feature({ id: 'ou1', @@ -79,6 +80,7 @@ describe('useCombinedTableData - org unit join', () => { { id: 'layerA', name: 'Layer A', + combinedLayerKey: 'layerA', data: [ feature({ id: 'ou1', @@ -110,6 +112,7 @@ describe('useCombinedTableData - org unit join', () => { { id: 'layerA', name: 'Layer A', + combinedLayerKey: 'layerA', data: [ feature({ id: 'evt1', @@ -148,6 +151,7 @@ describe('useCombinedTableData - org unit join', () => { { id: 'layerA', name: 'Layer A', + combinedLayerKey: 'layerA', data: [ feature({ id: 'country1', @@ -174,6 +178,7 @@ describe('useCombinedTableData - org unit join', () => { const agreeingLayer = { id: 'layerA', name: 'Layer A', + combinedLayerKey: 'layerA', data: [ feature({ id: 'evt1', @@ -190,6 +195,7 @@ describe('useCombinedTableData - org unit join', () => { const disagreeingLayer = { id: 'layerB', name: 'Layer B', + combinedLayerKey: 'layerB', data: [ feature({ id: 'evt3', @@ -234,6 +240,7 @@ describe('useCombinedTableData - org unit join', () => { { id: 'layerA', name: 'Layer A', + combinedLayerKey: 'layerA', data: [], dataWithoutCoords: [ feature({ @@ -263,6 +270,7 @@ describe('useCombinedTableData - org unit join', () => { { id: 'layerA', name: 'Layer A', + combinedLayerKey: 'layerA', data: [ feature({ id: 'extra', @@ -292,6 +300,7 @@ describe('useCombinedTableData - org unit join', () => { { id: 'layerA', name: 'Layer A', + combinedLayerKey: 'layerA', data: [ feature({ id: 'evt1', @@ -354,6 +363,7 @@ describe('useCombinedTableData - spatial join', () => { { id: 'points', name: 'Points', + combinedLayerKey: 'points', layer: 'event', data: [ { @@ -396,6 +406,7 @@ describe('useCombinedTableData - spatial join', () => { { id: 'events', name: 'Events', + combinedLayerKey: 'events', layer: EVENT_LAYER, data: [ { @@ -442,6 +453,7 @@ describe('useCombinedTableData - spatial join', () => { { id: 'zones', name: 'Zones', + combinedLayerKey: 'zones', layer: 'geoJsonUrl', data: [ { @@ -488,6 +500,7 @@ describe('useCombinedTableData - spatial join', () => { { id: 'points', name: 'Points', + combinedLayerKey: 'points', layer: 'event', data: [ { @@ -531,6 +544,7 @@ describe('useCombinedTableData - spatial join', () => { { id: 'points', name: 'Points', + combinedLayerKey: 'points', layer: 'event', data: Array.from({ length: 10001 }, (_, i) => ({ type: 'Feature', @@ -560,6 +574,7 @@ describe('useCombinedTableData - sorting and filtering', () => { { id: 'layerA', name: 'Layer A', + combinedLayerKey: 'layerA', data: [ feature({ id: 'ou1', @@ -700,6 +715,7 @@ describe('useCombinedTableData - Earth Engine value columns', () => { { id: 'layerA', name: 'Layer A', + combinedLayerKey: 'layerA', layer: EARTH_ENGINE_LAYER, aggregationType: ['mean', 'max'], legend: { title: 'NDVI' }, @@ -747,6 +763,7 @@ describe('useCombinedTableData - Earth Engine value columns', () => { { id: 'layerA', name: 'Layer A', + combinedLayerKey: 'layerA', layer: EARTH_ENGINE_LAYER, aggregationType: 'percentage', legend: { diff --git a/src/components/datatable/controls/JoinLayersControl.jsx b/src/components/datatable/controls/JoinLayersControl.jsx index ffbb00b934..b3c78d1a22 100644 --- a/src/components/datatable/controls/JoinLayersControl.jsx +++ b/src/components/datatable/controls/JoinLayersControl.jsx @@ -41,27 +41,27 @@ const JoinLayersControl = ({ eligibleLayers, layersConfig, onChange }) => { const onToggle = (layer) => { const next = { ...layersConfig } - if (next[layer.id]) { - delete next[layer.id] + if (next[layer.combinedLayerKey]) { + delete next[layer.combinedLayerKey] } else { - next[layer.id] = getDefaultSettings(layer) + next[layer.combinedLayerKey] = getDefaultSettings(layer) } onChange(next) } - const onTypeChange = (layerId, type) => + const onTypeChange = (layerKey, type) => onChange({ ...layersConfig, - [layerId]: { ...layersConfig[layerId], type }, + [layerKey]: { ...layersConfig[layerKey], type }, }) - const onAggregationChange = (layerId, dataKey, aggregationType) => + const onAggregationChange = (layerKey, dataKey, aggregationType) => onChange({ ...layersConfig, - [layerId]: { - ...layersConfig[layerId], + [layerKey]: { + ...layersConfig[layerKey], aggregation: { - ...layersConfig[layerId].aggregation, + ...layersConfig[layerKey].aggregation, [dataKey]: aggregationType, }, }, @@ -88,7 +88,8 @@ const JoinLayersControl = ({ eligibleLayers, layersConfig, onChange }) => { <div className={styles.joinLayersPopover}> <div className={styles.layerList}> {eligibleLayers.map((layer) => { - const settings = layersConfig[layer.id] + const settings = + layersConfig[layer.combinedLayerKey] return ( <div key={layer.id} @@ -119,7 +120,7 @@ const JoinLayersControl = ({ eligibleLayers, layersConfig, onChange }) => { value={settings.type} onChange={(e) => onTypeChange( - layer.id, + layer.combinedLayerKey, e.target.value ) } @@ -178,7 +179,7 @@ const JoinLayersControl = ({ eligibleLayers, layersConfig, onChange }) => { } onChange={(e) => onAggregationChange( - layer.id, + layer.combinedLayerKey, dataKey, e.target .value @@ -220,6 +221,7 @@ const JoinLayersControl = ({ eligibleLayers, layersConfig, onChange }) => { JoinLayersControl.propTypes = { eligibleLayers: PropTypes.arrayOf( PropTypes.shape({ + combinedLayerKey: PropTypes.string, data: PropTypes.array, id: PropTypes.string, layer: PropTypes.string, diff --git a/src/components/datatable/useCombinedTableData.js b/src/components/datatable/useCombinedTableData.js index 8e9a2ef669..1079055557 100644 --- a/src/components/datatable/useCombinedTableData.js +++ b/src/components/datatable/useCombinedTableData.js @@ -148,7 +148,7 @@ const applyLayerMatchToRow = ({ row, featureIds, refProps }, layerMatch) => { valueDataKeys.forEach(({ dataKey }) => { const values = matches.map((p) => p[dataKey]).filter((v) => v != null) - row[`${layer.id}_${dataKey}`] = applyAggregation( + row[`${layer.combinedLayerKey}_${dataKey}`] = applyAggregation( settings.aggregation?.[dataKey] ?? DEFAULT_AGGREGATION, values ) @@ -158,7 +158,7 @@ const applyLayerMatchToRow = ({ row, featureIds, refProps }, layerMatch) => { const legends = matches .map((p) => p[LEGEND_KEY]) .filter((v) => v != null) - row[`${layer.id}_${LEGEND_KEY}`] = + row[`${layer.combinedLayerKey}_${LEGEND_KEY}`] = legends.length && legends.every((l) => l === legends[0]) ? legends[0] : null @@ -223,7 +223,7 @@ export const useCombinedTableData = ({ const layerMatches = useMemo( () => layers.map((layer) => { - const settings = joinConfig.layers[layer.id] ?? { + const settings = joinConfig.layers[layer.combinedLayerKey] ?? { type: 'orgUnit', aggregation: {}, } @@ -268,7 +268,11 @@ export const useCombinedTableData = ({ const headers = [ { name: i18n.t('Org unit Id'), dataKey: 'id', type: TYPE_STRING }, { name: i18n.t('Org unit'), dataKey: 'name', type: TYPE_STRING }, - { name: i18n.t('Level'), dataKey: 'level', type: TYPE_NUMBER }, + { + name: i18n.t('Org unit level'), + dataKey: 'level', + type: TYPE_NUMBER, + }, ...layerMatches.flatMap(({ layer, valueDataKeys }) => [ ...valueDataKeys.map(({ dataKey, name }) => ({ name: name @@ -277,7 +281,7 @@ export const useCombinedTableData = ({ layer: layer.name, }) : i18n.t('Value ({{layer}})', { layer: layer.name }), - dataKey: `${layer.id}_${dataKey}`, + dataKey: `${layer.combinedLayerKey}_${dataKey}`, type: TYPE_NUMBER, })), // Earth Engine has no separate categorical "legend" concept @@ -289,7 +293,7 @@ export const useCombinedTableData = ({ name: i18n.t('Legend ({{layer}})', { layer: layer.name, }), - dataKey: `${layer.id}_${LEGEND_KEY}`, + dataKey: `${layer.combinedLayerKey}_${LEGEND_KEY}`, type: TYPE_STRING, }, ] diff --git a/src/components/layers/overlays/OverlayCard.jsx b/src/components/layers/overlays/OverlayCard.jsx index a490f28cda..74c4899b44 100644 --- a/src/components/layers/overlays/OverlayCard.jsx +++ b/src/components/layers/overlays/OverlayCard.jsx @@ -113,7 +113,7 @@ const OverlayCard = ({ } onDuplicate={() => duplicateLayer(id)} onRemove={() => { - removeLayer(id) + removeLayer(id, layer.combinedLayerKey) layerRemovedAlert.show({ msg: i18n.t('{{- name}} deleted.', { name }), }) diff --git a/src/constants/actionTypes.js b/src/constants/actionTypes.js index e22ae71f71..d2efacbb87 100644 --- a/src/constants/actionTypes.js +++ b/src/constants/actionTypes.js @@ -50,6 +50,8 @@ export const DATA_TABLE_COLUMN_CONFIG_SET = 'DATA_TABLE_COLUMN_CONFIG_SET' export const ACTIVE_TIMELINE_PERIOD_SET = 'ACTIVE_TIMELINE_PERIOD_SET' export const DATA_TABLE_COMBINED_VIEW_TOGGLE = 'DATA_TABLE_COMBINED_VIEW_TOGGLE' export const DATA_TABLE_JOIN_CONFIG_SET = 'DATA_TABLE_JOIN_CONFIG_SET' +export const DATA_TABLE_COMBINED_COLUMN_CONFIG_SET = + 'DATA_TABLE_COMBINED_COLUMN_CONFIG_SET' export const COMBINED_VISIBLE_IDS_SET = 'COMBINED_VISIBLE_IDS_SET' /* DATA FILTER */ diff --git a/src/loaders/__tests__/trackedEntityLoader.spec.js b/src/loaders/__tests__/trackedEntityLoader.spec.js index 0968ca1327..6876c625aa 100644 --- a/src/loaders/__tests__/trackedEntityLoader.spec.js +++ b/src/loaders/__tests__/trackedEntityLoader.spec.js @@ -193,10 +193,13 @@ describe('applyParsedConfig', () => { expect(config.config).toBeUndefined() }) - it('does nothing when config.config is absent', () => { + it('mints a combinedLayerKey but otherwise does nothing when config.config is absent', () => { const config = { layer: 'trackedEntity' } applyParsedConfig(config) - expect(config).toEqual({ layer: 'trackedEntity' }) + expect(config).toEqual({ + layer: 'trackedEntity', + combinedLayerKey: expect.any(String), + }) }) it('does not throw and leaves config intact on malformed JSON', () => { diff --git a/src/loaders/earthEngineLoader.js b/src/loaders/earthEngineLoader.js index 2dd4126546..65767f9712 100644 --- a/src/loaders/earthEngineLoader.js +++ b/src/loaders/earthEngineLoader.js @@ -25,6 +25,7 @@ import { getOrgUnitsWithoutCoordsCount, } from '../util/orgUnits.js' import { GEOFEATURES_QUERY } from '../util/requests.js' +import { generateUid } from '../util/uid.js' const earthEngineLoader = async ({ config, @@ -200,6 +201,11 @@ const earthEngineLoader = async ({ ...config, ...layerConfig, } + // Stable cross-save id used to key combinedJoinConfig/combinedColumnConfig + // entries - layer.id itself is regenerated by the server on every save. + if (!layer.combinedLayerKey) { + layer.combinedLayerKey = generateUid() + } const { unit, diff --git a/src/loaders/eventLoader.js b/src/loaders/eventLoader.js index 2a96c1bbf7..44ef4a31a7 100644 --- a/src/loaders/eventLoader.js +++ b/src/loaders/eventLoader.js @@ -41,7 +41,7 @@ import { import { OPTION_SET_QUERY } from '../util/requests.js' import { styleByDataItem } from '../util/styleByDataItem.js' import { formatStartEndDate, getDateArray } from '../util/time.js' -import { isValidUid } from '../util/uid.js' +import { generateUid, isValidUid } from '../util/uid.js' // OU dimension value is always an ID; property key depends on outputIdScheme const getEventOuId = (feature) => @@ -185,6 +185,7 @@ const loadEventLayer = async ({ noDataLegend: noDataLegendFromConfig, labelDataItem, dataTableColumnConfig, + combinedLayerKey, } = parseJsonConfig(config.config) if (countFeaturesWithoutCoordinates) { config.countFeaturesWithoutCoordinates = true @@ -223,6 +224,7 @@ const loadEventLayer = async ({ if (dataTableColumnConfig) { config.dataTableColumnConfig = dataTableColumnConfig } + config.combinedLayerKey = combinedLayerKey ?? generateUid() if (config.noDataColor) { config.noDataLegend = { ...noDataLegendFromConfig, diff --git a/src/loaders/facilityLoader.js b/src/loaders/facilityLoader.js index b8b66c49bd..f1a7967358 100644 --- a/src/loaders/facilityLoader.js +++ b/src/loaders/facilityLoader.js @@ -20,6 +20,7 @@ import { fetchAssociatedGeometries, } from '../util/orgUnits.js' import { GEOFEATURES_QUERY } from '../util/requests.js' +import { generateUid } from '../util/uid.js' export const applyMissingCoordsCount = async ( config, @@ -75,6 +76,7 @@ const facilityLoader = async ({ countFeaturesWithoutCoordinates, unclassifiedLegend, dataTableColumnConfig, + combinedLayerKey, } = parseJsonConfig(config.config) if (countFeaturesWithoutCoordinates) { config.countFeaturesWithoutCoordinates = true @@ -85,6 +87,7 @@ const facilityLoader = async ({ if (dataTableColumnConfig) { config.dataTableColumnConfig = dataTableColumnConfig } + config.combinedLayerKey = combinedLayerKey ?? generateUid() delete config.config // Data loading diff --git a/src/loaders/geoJsonUrlLoader.js b/src/loaders/geoJsonUrlLoader.js index ee372c35c1..756a840c04 100644 --- a/src/loaders/geoJsonUrlLoader.js +++ b/src/loaders/geoJsonUrlLoader.js @@ -7,6 +7,7 @@ import { GEO_TYPE_POINT, GEO_TYPE_POLYGON, } from '../util/geojson.js' +import { generateUid } from '../util/uid.js' // Stamps each feature with its geometry type's legend color, unless the feature already has its own // (maps-gl's colorExpr prefers a per-feature color, so the data table must match). @@ -73,6 +74,7 @@ const geoJsonUrlLoader = async ({ let newConfig let featureStyle let dataTableColumnConfig + let combinedLayerKey const alerts = [] // keep featureStyle and dataTableColumnConfig properties outside of config while in app if (typeof config === 'string') { @@ -87,13 +89,17 @@ const geoJsonUrlLoader = async ({ } featureStyle = { ...newConfig.featureStyle } || EMPTY_FEATURE_STYLE dataTableColumnConfig = newConfig.dataTableColumnConfig + combinedLayerKey = newConfig.combinedLayerKey delete newConfig.featureStyle delete newConfig.dataTableColumnConfig + delete newConfig.combinedLayerKey } else { newConfig = { ...config } featureStyle = layer.featureStyle || EMPTY_FEATURE_STYLE dataTableColumnConfig = layer.dataTableColumnConfig + combinedLayerKey = layer.combinedLayerKey } + combinedLayerKey = combinedLayerKey ?? generateUid() let geoJson let loadError @@ -158,6 +164,7 @@ const geoJsonUrlLoader = async ({ config: newConfig, featureStyle, dataTableColumnConfig, + combinedLayerKey, isLoaded: true, isLoading: false, isExpanded: true, diff --git a/src/loaders/orgUnitLoader.js b/src/loaders/orgUnitLoader.js index ec81772dc9..766ccb9155 100644 --- a/src/loaders/orgUnitLoader.js +++ b/src/loaders/orgUnitLoader.js @@ -22,6 +22,7 @@ import { fetchAssociatedGeometries, } from '../util/orgUnits.js' import { GEOFEATURES_QUERY } from '../util/requests.js' +import { generateUid } from '../util/uid.js' export const applyMissingCoordsCount = async ( config, @@ -75,6 +76,8 @@ const orgUnitLoader = async ({ unclassifiedLegend, dataTableColumnConfig, combinedJoinConfig, + combinedColumnConfig, + combinedLayerKey, } = parseJsonConfig(config.config) if (countFeaturesWithoutCoordinates) { config.countFeaturesWithoutCoordinates = true @@ -91,6 +94,15 @@ const orgUnitLoader = async ({ // before save. config.combinedJoinConfig = combinedJoinConfig } + if (combinedColumnConfig) { + // Only ever set on the combinedTableRef layer, same as above. + config.combinedColumnConfig = combinedColumnConfig + } + // Stable cross-save id used to key combinedJoinConfig/combinedColumnConfig + // entries - layer.id itself is regenerated by the server on every save, + // so it can't be used for those cross-layer references. Minted here if + // this map predates the field; sticks from then on. + config.combinedLayerKey = combinedLayerKey ?? generateUid() delete config.config // Data loading diff --git a/src/loaders/thematicLoader.js b/src/loaders/thematicLoader.js index b7410f2124..d5a452fd58 100644 --- a/src/loaders/thematicLoader.js +++ b/src/loaders/thematicLoader.js @@ -54,6 +54,7 @@ import { } from '../util/orgUnits.js' import { LEGEND_SET_QUERY, GEOFEATURES_QUERY } from '../util/requests.js' import { formatStartEndDate, getDateArray } from '../util/time.js' +import { generateUid } from '../util/uid.js' const thematicLoader = async ({ config, @@ -87,6 +88,7 @@ const thematicLoader = async ({ unclassifiedLegend: unclassifiedLegendFromConfig, noDataLegend: noDataLegendFromConfig, dataTableColumnConfig, + combinedLayerKey, } = parseJsonConfig(config.config) if (countFeaturesWithoutCoordinates) { config.countFeaturesWithoutCoordinates = true @@ -106,6 +108,7 @@ const thematicLoader = async ({ if (dataTableColumnConfig) { config.dataTableColumnConfig = dataTableColumnConfig } + config.combinedLayerKey = combinedLayerKey ?? generateUid() if (config.noDataColor) { config.noDataLegend = { ...noDataLegendFromConfig, diff --git a/src/loaders/trackedEntityLoader.js b/src/loaders/trackedEntityLoader.js index 0311742dc6..6bf875be7c 100644 --- a/src/loaders/trackedEntityLoader.js +++ b/src/loaders/trackedEntityLoader.js @@ -27,6 +27,7 @@ import { TRACKED_ENTITY_TRACKED_ENTITY_TYPE_ATTRIBUTES_QUERY, TRACKED_ENTITY_PROGRAM_TRACKED_ENTITY_ATTRIBUTES_QUERY, } from '../util/trackedEntity.js' +import { generateUid } from '../util/uid.js' const fields = [ 'trackedEntity~rename(id)', @@ -232,8 +233,12 @@ const fetchOptionNamesByOptionSet = async (engine, optionSetIds) => { } export const applyParsedConfig = (config) => { - const { relationships, periodType, dataTableColumnConfig } = - parseJsonConfig(config.config) + const { + relationships, + periodType, + dataTableColumnConfig, + combinedLayerKey, + } = parseJsonConfig(config.config) if (relationships) { config.relationshipType = relationships.type @@ -250,6 +255,8 @@ export const applyParsedConfig = (config) => { config.dataTableColumnConfig = dataTableColumnConfig } + config.combinedLayerKey = combinedLayerKey ?? generateUid() + delete config.config } diff --git a/src/reducers/__tests__/dataTable.spec.js b/src/reducers/__tests__/dataTable.spec.js index ebe9f13cab..05686fd4c3 100644 --- a/src/reducers/__tests__/dataTable.spec.js +++ b/src/reducers/__tests__/dataTable.spec.js @@ -7,6 +7,7 @@ const initialState = { joinConfig: { layers: {}, }, + combinedColumnConfig: null, } describe('dataTable reducer', () => { @@ -33,23 +34,26 @@ describe('dataTable reducer', () => { types.DOWNLOAD_MODE_CLOSE, types.DOWNLOAD_MODE_OPEN, ])( - 'resets openIds/combinedView but preserves joinConfig on %s - it is savable configuration, not throwaway display state', + 'resets openIds/combinedView but preserves joinConfig/combinedColumnConfig on %s - both are savable configuration, not throwaway display state', (type) => { const joinConfig = { layers: { layer1: { type: 'orgUnit', aggregation: {} }, }, } + const combinedColumnConfig = { pinnedKeys: ['layer1_value'] } const state = { openIds: ['layer1', 'layer2'], combinedView: true, joinConfig, + combinedColumnConfig, } expect(dataTable(state, { type })).toEqual({ openIds: [], combinedView: false, joinConfig, + combinedColumnConfig, }) } ) @@ -171,13 +175,13 @@ describe('dataTable reducer', () => { expect(state.openIds).toEqual(['layer2']) }) - it("prunes the removed layer's own entry from joinConfig.layers", () => { + it("prunes the removed layer's own entry from joinConfig.layers, keyed by combinedLayerKey not id", () => { const prevState = { ...initialState, joinConfig: { layers: { - layer1: { type: 'orgUnit', aggregation: {} }, - layer2: { type: 'spatial', aggregation: {} }, + layer1Key: { type: 'orgUnit', aggregation: {} }, + layer2Key: { type: 'spatial', aggregation: {} }, }, }, } @@ -185,10 +189,11 @@ describe('dataTable reducer', () => { const state = dataTable(prevState, { type: types.LAYER_REMOVE, id: 'layer1', + combinedLayerKey: 'layer1Key', }) expect(state.joinConfig.layers).toEqual({ - layer2: { type: 'spatial', aggregation: {} }, + layer2Key: { type: 'spatial', aggregation: {} }, }) }) @@ -196,17 +201,20 @@ describe('dataTable reducer', () => { const prevState = { ...initialState, joinConfig: { - layers: { layer2: { type: 'orgUnit', aggregation: {} } }, + layers: { + layer2Key: { type: 'orgUnit', aggregation: {} }, + }, }, } const state = dataTable(prevState, { type: types.LAYER_REMOVE, id: 'layer1', + combinedLayerKey: 'layer1Key', }) expect(state.joinConfig.layers).toEqual({ - layer2: { type: 'orgUnit', aggregation: {} }, + layer2Key: { type: 'orgUnit', aggregation: {} }, }) }) @@ -215,13 +223,16 @@ describe('dataTable reducer', () => { openIds: [], combinedView: true, joinConfig: { - layers: { layer1: { type: 'orgUnit', aggregation: {} } }, + layers: { + layer1Key: { type: 'orgUnit', aggregation: {} }, + }, }, } const state = dataTable(prevState, { type: types.LAYER_REMOVE, id: 'layer1', + combinedLayerKey: 'layer1Key', }) expect(state.combinedView).toBe(true) @@ -268,6 +279,19 @@ describe('dataTable reducer', () => { }) }) + describe('DATA_TABLE_COMBINED_COLUMN_CONFIG_SET', () => { + it('replaces combinedColumnConfig wholesale', () => { + const config = { pinnedKeys: ['layer1Key_value'] } + + const state = dataTable(initialState, { + type: types.DATA_TABLE_COMBINED_COLUMN_CONFIG_SET, + config, + }) + + expect(state.combinedColumnConfig).toEqual(config) + }) + }) + it('returns the current state for unknown actions', () => { const state = { ...initialState, openIds: ['layer1'] } diff --git a/src/reducers/dataTable.js b/src/reducers/dataTable.js index 58d5ee41b3..9fc430798e 100644 --- a/src/reducers/dataTable.js +++ b/src/reducers/dataTable.js @@ -11,22 +11,28 @@ const initialState = { joinConfig: { layers: {}, }, + combinedColumnConfig: null, } const dataTable = (state = initialState, action) => { switch (action.type) { // Closes the whole panel (or leaves the data table view while // entering/exiting download mode) - resets which tab(s) are open - // and whether Combined is the active view, but preserves joinConfig - // itself. joinConfig is real, savable configuration now (see - // favorites.js/FileMenu.jsx), not just session-only display state - - // wiping it here would silently discard it the moment a user closes - // the panel before saving, an extremely common, low-stakes action - // that has nothing to do with abandoning their join setup. + // and whether Combined is the active view, but preserves joinConfig/ + // combinedColumnConfig themselves. Both are real, savable + // configuration now (see favorites.js/FileMenu.jsx), not just + // session-only display state - wiping them here would silently + // discard them the moment a user closes the panel before saving, an + // extremely common, low-stakes action that has nothing to do with + // abandoning their join/column setup. case types.DATA_TABLE_CLOSE: case types.DOWNLOAD_MODE_CLOSE: case types.DOWNLOAD_MODE_OPEN: - return { ...initialState, joinConfig: state.joinConfig } + return { + ...initialState, + joinConfig: state.joinConfig, + combinedColumnConfig: state.combinedColumnConfig, + } case types.MAP_NEW: return initialState @@ -56,8 +62,12 @@ const dataTable = (state = initialState, action) => { // that case has no way to be triggered today. A future // "reset reference" action would need to turn combinedView off // itself when it removes the reference layer. + // + // Pruned by combinedLayerKey, not the volatile mapView id - see + // util/favorites.js/reducers/map.js for why joinConfig.layers is + // keyed that way. const layers = { ...state.joinConfig.layers } - delete layers[action.id] + delete layers[action.combinedLayerKey] return { ...state, openIds: state.openIds.filter((id) => id !== action.id), @@ -71,6 +81,9 @@ const dataTable = (state = initialState, action) => { case types.DATA_TABLE_JOIN_CONFIG_SET: return { ...state, joinConfig: action.config } + case types.DATA_TABLE_COMBINED_COLUMN_CONFIG_SET: + return { ...state, combinedColumnConfig: action.config } + default: return state } diff --git a/src/reducers/map.js b/src/reducers/map.js index a4167a9314..90e98c6a84 100644 --- a/src/reducers/map.js +++ b/src/reducers/map.js @@ -94,6 +94,8 @@ const layer = (state, action) => { state.dataTableColumnConfig ?? action.payload.dataTableColumnConfig, dataFilters: state.dataFilters ?? action.payload.dataFilters, + combinedLayerKey: + state.combinedLayerKey ?? action.payload.combinedLayerKey, } case types.LAYER_CHANGE_OPACITY: @@ -279,6 +281,8 @@ const map = (state = defaultState, action) => { ...action.payload, id: generateUid(), isVisible: action.payload.isVisible ?? true, + combinedLayerKey: + action.payload.combinedLayerKey ?? generateUid(), }, ], } @@ -301,6 +305,7 @@ const map = (state = defaultState, action) => { const duplicate = { ...state.mapViews[sourceIndex], id: generateUid(), + combinedLayerKey: generateUid(), } delete duplicate.isLoading delete duplicate.coordinate diff --git a/src/util/favorites.js b/src/util/favorites.js index ae7ae3a367..b1da337804 100644 --- a/src/util/favorites.js +++ b/src/util/favorites.js @@ -36,7 +36,9 @@ const validLayerProperties = [ 'colorLow', // Deprecated 'colorScale', 'columns', + 'combinedColumnConfig', // only ever set on the combinedTableRef layer 'combinedJoinConfig', // only ever set on the combinedTableRef layer + 'combinedLayerKey', // stable cross-save id, set on every layer type 'config', 'created', 'dataTableColumnConfig', @@ -189,6 +191,12 @@ const buildCommonLayerConfigData = (layer) => { if (layer.combinedJoinConfig) { configData.combinedJoinConfig = layer.combinedJoinConfig } + if (layer.combinedColumnConfig) { + configData.combinedColumnConfig = layer.combinedColumnConfig + } + if (layer.combinedLayerKey) { + configData.combinedLayerKey = layer.combinedLayerKey + } return configData } @@ -205,6 +213,8 @@ const deleteCommonLayerConfigProps = (layer) => { delete layer.labelDataItem delete layer.dataTableColumnConfig delete layer.combinedJoinConfig + delete layer.combinedColumnConfig + delete layer.combinedLayerKey } const buildEarthEngineLayerConfigData = (layer) => { @@ -215,6 +225,7 @@ const buildEarthEngineLayerConfigData = (layer) => { aggregationType, period, dataTableColumnConfig, + combinedLayerKey, } = layer return omitBy(isNil, { id, @@ -223,6 +234,7 @@ const buildEarthEngineLayerConfigData = (layer) => { aggregationType, period, dataTableColumnConfig, + combinedLayerKey, }) } @@ -237,6 +249,7 @@ const deleteEarthEngineLayerProps = (layer) => { delete layer.aggregationType delete layer.band delete layer.dataTableColumnConfig + delete layer.combinedLayerKey } const buildTrackedEntityLayerConfigData = (layer) => ({ @@ -251,6 +264,7 @@ const buildTrackedEntityLayerConfigData = (layer) => ({ : null, periodType: layer.periodType, dataTableColumnConfig: layer.dataTableColumnConfig, + combinedLayerKey: layer.combinedLayerKey, }) const deleteTrackedEntityLayerProps = (layer) => { @@ -261,6 +275,7 @@ const deleteTrackedEntityLayerProps = (layer) => { delete layer.relationshipOutsideProgram delete layer.periodType delete layer.dataTableColumnConfig + delete layer.combinedLayerKey } // TODO: This feels hacky, find better way to clean map configs before saving @@ -299,10 +314,14 @@ const models2objects = (layer, cleanMapviewConfig) => { ...(layer.dataTableColumnConfig !== undefined && { dataTableColumnConfig: layer.dataTableColumnConfig, }), + ...(layer.combinedLayerKey !== undefined && { + combinedLayerKey: layer.combinedLayerKey, + }), } } delete layer.featureStyle delete layer.dataTableColumnConfig + delete layer.combinedLayerKey } else if ( layerType === EVENT_LAYER || layerType === THEMATIC_LAYER || From 4079ce33a8325a2c29cc9601c637677093419ad7 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 30 Jul 2026 10:38:09 +0200 Subject: [PATCH 167/205] chore: simplify Combined join/column config persistence --- src/actions/__tests__/dataTable.spec.js | 22 +- src/actions/dataTable.js | 8 +- src/components/app/FileMenu.jsx | 33 +-- src/components/datatable/BottomPanel.jsx | 51 ++--- .../datatable/__tests__/BottomPanel.spec.jsx | 146 ++----------- .../__tests__/ColumnPickerControl.spec.jsx | 5 - .../datatable/__tests__/FilterInput.spec.jsx | 4 - .../controls/LayerSelectorControl.jsx | 8 - .../styles/JoinLayersControl.module.css | 4 - src/reducers/__tests__/dataTable.spec.js | 204 +----------------- src/reducers/__tests__/map.spec.js | 151 +++++++++++++ src/reducers/dataTable.js | 58 +---- src/reducers/map.js | 69 +++++- src/util/__tests__/favorites.spec.js | 16 ++ src/util/favorites.js | 17 +- 15 files changed, 313 insertions(+), 483 deletions(-) diff --git a/src/actions/__tests__/dataTable.spec.js b/src/actions/__tests__/dataTable.spec.js index 0ea5eecd91..130fbd7b87 100644 --- a/src/actions/__tests__/dataTable.spec.js +++ b/src/actions/__tests__/dataTable.spec.js @@ -6,6 +6,7 @@ import { setActiveTimelinePeriod, toggleCombinedView, setJoinConfig, + setCombinedColumnConfig, } from '../dataTable.js' describe('closeDataTable', () => { @@ -54,14 +55,23 @@ describe('toggleCombinedView', () => { describe('setJoinConfig', () => { it('creates a DATA_TABLE_JOIN_CONFIG_SET action', () => { - const config = { - level: 'spatial', - layerIds: [], - pointLayerId: 'layer1', - polygonLayerId: 'layer2', + const layers = { + layer1: { type: 'orgUnit', aggregation: {} }, } - expect(setJoinConfig(config)).toEqual({ + expect(setJoinConfig('ref1', layers)).toEqual({ type: types.DATA_TABLE_JOIN_CONFIG_SET, + layerId: 'ref1', + layers, + }) + }) +}) + +describe('setCombinedColumnConfig', () => { + it('creates a DATA_TABLE_COMBINED_COLUMN_CONFIG_SET action', () => { + const config = { pinnedKeys: ['name'] } + expect(setCombinedColumnConfig('ref1', config)).toEqual({ + type: types.DATA_TABLE_COMBINED_COLUMN_CONFIG_SET, + layerId: 'ref1', config, }) }) diff --git a/src/actions/dataTable.js b/src/actions/dataTable.js index 4eeccd4dc3..84ad32b640 100644 --- a/src/actions/dataTable.js +++ b/src/actions/dataTable.js @@ -48,13 +48,15 @@ export const toggleCombinedView = () => ({ type: types.DATA_TABLE_COMBINED_VIEW_TOGGLE, }) -export const setJoinConfig = (config) => ({ +export const setJoinConfig = (layerId, layers) => ({ type: types.DATA_TABLE_JOIN_CONFIG_SET, - config, + layerId, + layers, }) -export const setCombinedColumnConfig = (config) => ({ +export const setCombinedColumnConfig = (layerId, config) => ({ type: types.DATA_TABLE_COMBINED_COLUMN_CONFIG_SET, + layerId, config, }) diff --git a/src/components/app/FileMenu.jsx b/src/components/app/FileMenu.jsx index ac2d0ebcc4..4ec06b4464 100644 --- a/src/components/app/FileMenu.jsx +++ b/src/components/app/FileMenu.jsx @@ -18,7 +18,6 @@ import { ALERT_OPTIONS_DYNAMIC, ALERT_SUCCESS_DELAY, } from '../../constants/alerts.js' -import { COMBINED_TABLE_REF_LAYER } from '../../constants/layers.js' import { cleanMapConfig } from '../../util/favorites.js' import { addOrgUnitPaths } from '../../util/helpers.js' import history from '../../util/history.js' @@ -64,37 +63,15 @@ const getSaveFailureMessage = (message) => nsSeparator: ';', }) -// state.dataTable.joinConfig.layers/combinedColumnConfig live in their own -// Redux slice, not on the combinedTableRef mapView itself, so (unlike -// dataTableColumnConfig, which is already stamped directly onto its layer as -// it's edited) they need an explicit copy onto that layer right before -// cleanMapConfig runs, or they'd never reach favorites.js's packing logic at -// all. -const stampCombinedConfig = (map, joinConfig, combinedColumnConfig) => ({ - ...map, - mapViews: map.mapViews.map((view) => - view.layer === COMBINED_TABLE_REF_LAYER - ? { - ...view, - combinedJoinConfig: joinConfig.layers, - combinedColumnConfig, - } - : view - ), -}) - const FileMenu = ({ onFileMenuAction }) => { const map = useSelector((state) => state.map) - const joinConfig = useSelector((state) => state.dataTable.joinConfig) - const combinedColumnConfig = useSelector( - (state) => state.dataTable.combinedColumnConfig - ) const dispatch = useDispatch() const engine = useDataEngine() const { serverVersion } = useConfig() const { systemSettings, currentUser } = useCachedData() const defaultBasemap = systemSettings.keyDefaultBaseMap - //alerts + + // Alerts const saveAlert = useAlert(ALERT_MESSAGE_DYNAMIC, ALERT_OPTIONS_DYNAMIC) const renameFailedAlert = useAlert(ALERT_MESSAGE_DYNAMIC, ALERT_WARNING) const renameSuccessAlert = useAlert( @@ -142,7 +119,7 @@ const FileMenu = ({ onFileMenuAction }) => { }) const cleanedMap = cleanMapConfig({ - config: stampCombinedConfig(map, joinConfig, combinedColumnConfig), + config: map, defaultBasemapId: defaultBasemap, serverVersion, }) @@ -169,7 +146,7 @@ const FileMenu = ({ onFileMenuAction }) => { } const onRename = async ({ name, description }) => { - // fetch the original Map + // Fetch the original Map const fetchedMap = await fetchMap({ id: map.id, engine, @@ -214,7 +191,7 @@ const FileMenu = ({ onFileMenuAction }) => { const onSaveAs = async ({ name, description }) => { const cleanedMap = cleanMapConfig({ - config: stampCombinedConfig(map, joinConfig, combinedColumnConfig), + config: map, defaultBasemapId: defaultBasemap, serverVersion, }) diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index 5df3471f3d..516983157a 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -55,14 +55,8 @@ const EMPTY_JOIN_LAYERS = {} const BottomPanel = () => { const dataTableHeight = useSelector((state) => state.ui.dataTableHeight) - const { openIds, combinedView, joinConfig, combinedColumnConfig } = - useSelector((state) => state.dataTable) + const { openIds, combinedView } = useSelector((state) => state.dataTable) const mapViews = useSelector((state) => state.map.mapViews) - // Only tracks a user's explicit tab click - falls back to the most - // recently opened tab whenever it doesn't (yet) name an open layer, so - // there's no render where this is out of sync with openIds (unlike a - // useState+useEffect pair, which would flash a stale/null value for one - // render before the effect corrects it). const [manualActiveLayerId, setManualActiveLayerId] = useState(null) const activeLayerId = manualActiveLayerId && openIds.includes(manualActiveLayerId) @@ -74,7 +68,9 @@ const BottomPanel = () => { const combinedEnabled = !!referenceLayer && getOrgUnitsFromRows(referenceLayer.rows).length > 0 - const joinLayersConfig = joinConfig.layers ?? EMPTY_JOIN_LAYERS + const joinLayersConfig = + referenceLayer?.combinedJoinConfig ?? EMPTY_JOIN_LAYERS + const combinedColumnConfig = referenceLayer?.combinedColumnConfig ?? null const combinedLayers = useMemo( () => mapViews.filter((l) => joinLayersConfig[l.combinedLayerKey]), [mapViews, joinLayersConfig] @@ -254,32 +250,6 @@ const BottomPanel = () => { return () => observer.disconnect() }, []) - const hasHydratedJoinConfigRef = useRef(false) - useEffect(() => { - if ( - hasHydratedJoinConfigRef.current || - !referenceLayer?.isLoaded || - !referenceLayer.combinedJoinConfig - ) { - return - } - hasHydratedJoinConfigRef.current = true - dispatch(setJoinConfig({ layers: referenceLayer.combinedJoinConfig })) - }, [referenceLayer, dispatch]) - - const hasHydratedColumnConfigRef = useRef(false) - useEffect(() => { - if ( - hasHydratedColumnConfigRef.current || - !referenceLayer?.isLoaded || - !referenceLayer.combinedColumnConfig - ) { - return - } - hasHydratedColumnConfigRef.current = true - dispatch(setCombinedColumnConfig(referenceLayer.combinedColumnConfig)) - }, [referenceLayer, dispatch]) - useKeyDown('Escape', onCloseDataTable, true) return ( @@ -330,14 +300,21 @@ const BottomPanel = () => { allHeaders={allHeaders} columnConfig={combinedColumnConfig} onChange={(config) => - dispatch(setCombinedColumnConfig(config)) + dispatch( + setCombinedColumnConfig( + referenceLayer.id, + config + ) + ) } /> <JoinLayersControl eligibleLayers={eligibleLayers} layersConfig={joinLayersConfig} onChange={(layers) => - dispatch(setJoinConfig({ layers })) + dispatch( + setJoinConfig(referenceLayer.id, layers) + ) } /> <ReferenceOrgUnitControl /> @@ -395,7 +372,7 @@ const BottomPanel = () => { availableWidth={panelWidth} layers={combinedLayers} referenceLayer={referenceLayer} - joinConfig={joinConfig} + joinConfig={{ layers: joinLayersConfig }} filters={combinedFilters} onFiltersChange={setCombinedFilters} globalSearch={globalSearch} diff --git a/src/components/datatable/__tests__/BottomPanel.spec.jsx b/src/components/datatable/__tests__/BottomPanel.spec.jsx index c01b92746d..7180c49af5 100644 --- a/src/components/datatable/__tests__/BottomPanel.spec.jsx +++ b/src/components/datatable/__tests__/BottomPanel.spec.jsx @@ -33,9 +33,6 @@ const DATA_TABLE_HEIGHT = 300 const DEFAULT_DATA_TABLE_STATE = { openIds: ['layer1'], combinedView: false, - joinConfig: { - layers: {}, - }, } const DEFAULT_MAP_VIEWS = [ @@ -399,12 +396,11 @@ describe('BottomPanel Combined join controls', () => { expect(store.getActions()).toEqual([ { type: 'DATA_TABLE_JOIN_CONFIG_SET', - config: { - layers: { - layer1: { - type: 'orgUnit', - aggregation: { rawValue: 'SUM' }, - }, + layerId: 'ref1', + layers: { + layer1: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, }, }, }, @@ -417,8 +413,12 @@ describe('BottomPanel Combined join controls', () => { ...DEFAULT_DATA_TABLE_STATE, openIds: ['layer1', 'layer2'], combinedView: true, - joinConfig: { - layers: { + }, + mapViews: [ + ...twoEligibleLayers, + { + ...referenceLayer(), + combinedJoinConfig: { layer1: { type: 'orgUnit', aggregation: { rawValue: 'SUM' }, @@ -429,8 +429,7 @@ describe('BottomPanel Combined join controls', () => { }, }, }, - }, - mapViews: combinedMapViews, + ], }) fireEvent.click(screen.getByLabelText('Choose layers to combine')) @@ -439,125 +438,14 @@ describe('BottomPanel Combined join controls', () => { expect(store.getActions()).toEqual([ { type: 'DATA_TABLE_JOIN_CONFIG_SET', - config: { - layers: { - layer2: { - type: 'orgUnit', - aggregation: { rawValue: 'SUM' }, - }, + layerId: 'ref1', + layers: { + layer2: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, }, }, }, ]) }) }) - -describe('BottomPanel joinConfig hydration from a saved reference layer', () => { - const persistedJoinConfig = { - layerA: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, - } - - test("restores a loaded reference layer's persisted combinedJoinConfig once", () => { - const { store } = renderBottomPanel({ - mapViews: [ - ...DEFAULT_MAP_VIEWS, - { - ...referenceLayer(), - isLoaded: true, - combinedJoinConfig: persistedJoinConfig, - }, - ], - }) - - expect(store.getActions()).toContainEqual({ - type: 'DATA_TABLE_JOIN_CONFIG_SET', - config: { layers: persistedJoinConfig }, - }) - }) - - test('does not restore anything when the reference layer has not finished loading yet', () => { - const { store } = renderBottomPanel({ - mapViews: [ - ...DEFAULT_MAP_VIEWS, - { - ...referenceLayer(), - isLoaded: false, - combinedJoinConfig: persistedJoinConfig, - }, - ], - }) - - expect(store.getActions()).not.toContainEqual( - expect.objectContaining({ type: 'DATA_TABLE_JOIN_CONFIG_SET' }) - ) - }) - - test('does not restore anything when the reference layer has no persisted combinedJoinConfig', () => { - const { store } = renderBottomPanel({ - mapViews: [ - ...DEFAULT_MAP_VIEWS, - { ...referenceLayer(), isLoaded: true }, - ], - }) - - expect(store.getActions()).not.toContainEqual( - expect.objectContaining({ type: 'DATA_TABLE_JOIN_CONFIG_SET' }) - ) - }) -}) - -describe('BottomPanel combinedColumnConfig hydration from a saved reference layer', () => { - const persistedColumnConfig = { pinnedKeys: ['layerA_rawValue'] } - - test("restores a loaded reference layer's persisted combinedColumnConfig once", () => { - const { store } = renderBottomPanel({ - mapViews: [ - ...DEFAULT_MAP_VIEWS, - { - ...referenceLayer(), - isLoaded: true, - combinedColumnConfig: persistedColumnConfig, - }, - ], - }) - - expect(store.getActions()).toContainEqual({ - type: 'DATA_TABLE_COMBINED_COLUMN_CONFIG_SET', - config: persistedColumnConfig, - }) - }) - - test('does not restore anything when the reference layer has not finished loading yet', () => { - const { store } = renderBottomPanel({ - mapViews: [ - ...DEFAULT_MAP_VIEWS, - { - ...referenceLayer(), - isLoaded: false, - combinedColumnConfig: persistedColumnConfig, - }, - ], - }) - - expect(store.getActions()).not.toContainEqual( - expect.objectContaining({ - type: 'DATA_TABLE_COMBINED_COLUMN_CONFIG_SET', - }) - ) - }) - - test('does not restore anything when the reference layer has no persisted combinedColumnConfig', () => { - const { store } = renderBottomPanel({ - mapViews: [ - ...DEFAULT_MAP_VIEWS, - { ...referenceLayer(), isLoaded: true }, - ], - }) - - expect(store.getActions()).not.toContainEqual( - expect.objectContaining({ - type: 'DATA_TABLE_COMBINED_COLUMN_CONFIG_SET', - }) - ) - }) -}) diff --git a/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx b/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx index aa32d68c0e..12920fbd77 100644 --- a/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx +++ b/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx @@ -14,11 +14,6 @@ const headers = [ { name: 'Legend', dataKey: 'legend' }, ] -// ColumnPickerControl is dispatch-agnostic (columnConfig/onChange are -// caller-supplied) - this helper reproduces exactly what BottomPanel.jsx's -// real single-layer call site does (dispatch setDataTableColumnConfig for a -// real layer), so every existing assertion against store.getActions() still -// holds. const renderColumnPicker = (props) => { const store = mockStore({}) const result = render( diff --git a/src/components/datatable/__tests__/FilterInput.spec.jsx b/src/components/datatable/__tests__/FilterInput.spec.jsx index 5562fe917d..e5bf9bce80 100644 --- a/src/components/datatable/__tests__/FilterInput.spec.jsx +++ b/src/components/datatable/__tests__/FilterInput.spec.jsx @@ -29,10 +29,6 @@ jest.mock('../../cachedDataProvider/CachedDataProvider.jsx', () => ({ const mockStore = configureMockStore() -// FilterInput is dispatch-agnostic (filterValue/onChange/onClear are caller- -// supplied) - this helper reproduces exactly what DataTable.jsx's real call -// site does (dispatch setDataFilter/clearDataFilter against a real layer), -// so every existing assertion against store.getActions() still holds. const renderFilterInput = (props, dataFilters) => { const store = mockStore({ map: { diff --git a/src/components/datatable/controls/LayerSelectorControl.jsx b/src/components/datatable/controls/LayerSelectorControl.jsx index 3f1ca5bd08..a838150c62 100644 --- a/src/components/datatable/controls/LayerSelectorControl.jsx +++ b/src/components/datatable/controls/LayerSelectorControl.jsx @@ -5,14 +5,6 @@ import styles from '../styles/BottomPanel.module.css' const COMBINED_VALUE = '__combined__' -// Replaces the old per-layer tab strip - a single dropdown listing every -// data-table-eligible layer on the map (whether or not its table has been -// opened yet) plus Combined. Selecting Combined before a reference org unit -// set has been configured is the caller's job to handle (see -// BottomPanel.jsx's onSelectCombined, which opens the reference layer's -// editor in that case rather than disabling the option outright). Selecting -// a layer that isn't open yet is likewise the caller's job to also open -// (see BottomPanel.jsx's onSelectLayer). const LayerSelectorControl = ({ layers, activeLayerId, diff --git a/src/components/datatable/controls/styles/JoinLayersControl.module.css b/src/components/datatable/controls/styles/JoinLayersControl.module.css index 64e5fcbb9e..e2ee920ea0 100644 --- a/src/components/datatable/controls/styles/JoinLayersControl.module.css +++ b/src/components/datatable/controls/styles/JoinLayersControl.module.css @@ -54,10 +54,6 @@ background-color: var(--colors-white); } -/* One per value column a layer contributes - usually just one (most layer - types have a single value), but Earth Engine can contribute several - (one per aggregation stat or legend class), each needing its own - aggregation-type choice and a label to tell them apart. */ .aggregationRow { display: flex; align-items: center; diff --git a/src/reducers/__tests__/dataTable.spec.js b/src/reducers/__tests__/dataTable.spec.js index 05686fd4c3..bb8bdec734 100644 --- a/src/reducers/__tests__/dataTable.spec.js +++ b/src/reducers/__tests__/dataTable.spec.js @@ -4,10 +4,6 @@ import dataTable from '../dataTable.js' const initialState = { openIds: [], combinedView: false, - joinConfig: { - layers: {}, - }, - combinedColumnConfig: null, } describe('dataTable reducer', () => { @@ -15,88 +11,16 @@ describe('dataTable reducer', () => { expect(dataTable(undefined, {})).toEqual(initialState) }) - it('resets fully to the initial state on MAP_NEW', () => { - const state = { - openIds: ['layer1', 'layer2'], - combinedView: true, - joinConfig: { - layers: { - layer1: { type: 'orgUnit', aggregation: {} }, - }, - }, - } - - expect(dataTable(state, { type: types.MAP_NEW })).toEqual(initialState) - }) - it.each([ + types.MAP_NEW, + types.MAP_SET, types.DATA_TABLE_CLOSE, types.DOWNLOAD_MODE_CLOSE, types.DOWNLOAD_MODE_OPEN, - ])( - 'resets openIds/combinedView but preserves joinConfig/combinedColumnConfig on %s - both are savable configuration, not throwaway display state', - (type) => { - const joinConfig = { - layers: { - layer1: { type: 'orgUnit', aggregation: {} }, - }, - } - const combinedColumnConfig = { pinnedKeys: ['layer1_value'] } - const state = { - openIds: ['layer1', 'layer2'], - combinedView: true, - joinConfig, - combinedColumnConfig, - } + ])('resets fully to the initial state on %s', (type) => { + const state = { openIds: ['layer1', 'layer2'], combinedView: true } - expect(dataTable(state, { type })).toEqual({ - openIds: [], - combinedView: false, - joinConfig, - combinedColumnConfig, - }) - } - ) - - describe('MAP_SET', () => { - it('restores dataTable state from the payload when present', () => { - const restored = { - openIds: ['layer1'], - combinedView: false, - joinConfig: { - layers: { - layer1: { - type: 'orgUnit', - aggregation: { rawValue: 'SUM' }, - }, - }, - }, - } - - expect( - dataTable(initialState, { - type: types.MAP_SET, - payload: { dataTable: restored }, - }) - ).toEqual(restored) - }) - - it('falls back to the initial state when the payload has no dataTable', () => { - const state = { - openIds: ['layer1'], - combinedView: false, - joinConfig: { - layers: { layer1: { type: 'orgUnit', aggregation: {} } }, - }, - } - - expect( - dataTable(state, { - type: types.MAP_SET, - payload: {}, - }) - ).toEqual(initialState) - }) + expect(dataTable(state, { type })).toEqual(initialState) }) describe('DATA_TABLE_TOGGLE', () => { @@ -127,32 +51,8 @@ describe('dataTable reducer', () => { expect(state.openIds).toEqual(['layer2']) }) - it('leaves combinedView and joinConfig untouched', () => { - const prevState = { - openIds: ['layer1'], - combinedView: true, - joinConfig: { - layers: { layerA: { type: 'spatial', aggregation: {} } }, - }, - } - - const state = dataTable(prevState, { - type: types.DATA_TABLE_TOGGLE, - id: 'layer2', - }) - - expect(state.combinedView).toBe(true) - expect(state.joinConfig).toBe(prevState.joinConfig) - }) - - it('leaves combinedView and joinConfig untouched even when closing the last open tab', () => { - const prevState = { - openIds: ['layer1'], - combinedView: true, - joinConfig: { - layers: { layerA: { type: 'spatial', aggregation: {} } }, - }, - } + it('leaves combinedView untouched even when closing the last open tab', () => { + const prevState = { openIds: ['layer1'], combinedView: true } const state = dataTable(prevState, { type: types.DATA_TABLE_TOGGLE, @@ -161,7 +61,6 @@ describe('dataTable reducer', () => { expect(state.openIds).toEqual([]) expect(state.combinedView).toBe(true) - expect(state.joinConfig).toBe(prevState.joinConfig) }) }) @@ -175,59 +74,8 @@ describe('dataTable reducer', () => { expect(state.openIds).toEqual(['layer2']) }) - it("prunes the removed layer's own entry from joinConfig.layers, keyed by combinedLayerKey not id", () => { - const prevState = { - ...initialState, - joinConfig: { - layers: { - layer1Key: { type: 'orgUnit', aggregation: {} }, - layer2Key: { type: 'spatial', aggregation: {} }, - }, - }, - } - - const state = dataTable(prevState, { - type: types.LAYER_REMOVE, - id: 'layer1', - combinedLayerKey: 'layer1Key', - }) - - expect(state.joinConfig.layers).toEqual({ - layer2Key: { type: 'spatial', aggregation: {} }, - }) - }) - - it('is a no-op on joinConfig.layers when the removed layer was never a participant', () => { - const prevState = { - ...initialState, - joinConfig: { - layers: { - layer2Key: { type: 'orgUnit', aggregation: {} }, - }, - }, - } - - const state = dataTable(prevState, { - type: types.LAYER_REMOVE, - id: 'layer1', - combinedLayerKey: 'layer1Key', - }) - - expect(state.joinConfig.layers).toEqual({ - layer2Key: { type: 'orgUnit', aggregation: {} }, - }) - }) - - it('leaves combinedView untouched (no cross-slice knowledge of the reference layer here)', () => { - const prevState = { - openIds: [], - combinedView: true, - joinConfig: { - layers: { - layer1Key: { type: 'orgUnit', aggregation: {} }, - }, - }, - } + it('leaves combinedView untouched', () => { + const prevState = { openIds: [], combinedView: true } const state = dataTable(prevState, { type: types.LAYER_REMOVE, @@ -236,7 +84,6 @@ describe('dataTable reducer', () => { }) expect(state.combinedView).toBe(true) - expect(state.joinConfig.layers).toEqual({}) }) }) @@ -259,39 +106,6 @@ describe('dataTable reducer', () => { }) }) - describe('DATA_TABLE_JOIN_CONFIG_SET', () => { - it('replaces joinConfig wholesale', () => { - const config = { - layers: { - layer1: { - type: 'spatial', - aggregation: { rawValue: 'AVERAGE' }, - }, - }, - } - - const state = dataTable(initialState, { - type: types.DATA_TABLE_JOIN_CONFIG_SET, - config, - }) - - expect(state.joinConfig).toEqual(config) - }) - }) - - describe('DATA_TABLE_COMBINED_COLUMN_CONFIG_SET', () => { - it('replaces combinedColumnConfig wholesale', () => { - const config = { pinnedKeys: ['layer1Key_value'] } - - const state = dataTable(initialState, { - type: types.DATA_TABLE_COMBINED_COLUMN_CONFIG_SET, - config, - }) - - expect(state.combinedColumnConfig).toEqual(config) - }) - }) - it('returns the current state for unknown actions', () => { const state = { ...initialState, openIds: ['layer1'] } diff --git a/src/reducers/__tests__/map.spec.js b/src/reducers/__tests__/map.spec.js index 24087e96df..0270021070 100644 --- a/src/reducers/__tests__/map.spec.js +++ b/src/reducers/__tests__/map.spec.js @@ -1,4 +1,5 @@ import * as types from '../../constants/actionTypes.js' +import { COMBINED_TABLE_REF_LAYER } from '../../constants/layers.js' import { isValidUid } from '../../util/uid.js' import map, { defaultBasemapState } from '../map.js' @@ -257,6 +258,40 @@ describe('map reducer - LAYER_ADD', () => { }) }) +describe('map reducer - DATA_TABLE_COMBINED_VIEW_TOGGLE', () => { + it('adds a placeholder combinedTableRef mapView when none exists yet', () => { + const state = { ...defaultState, mapViews: [{ id: 'layer1' }] } + + const result = map(state, { + type: types.DATA_TABLE_COMBINED_VIEW_TOGGLE, + }) + + expect(result.mapViews).toHaveLength(2) + const placeholder = result.mapViews[1] + expect(placeholder.layer).toBe(COMBINED_TABLE_REF_LAYER) + expect(isValidUid(placeholder.id)).toBe(true) + expect(isValidUid(placeholder.combinedLayerKey)).toBe(true) + expect(placeholder.isVisible).toBe(false) + expect(placeholder.rows).toEqual([]) + }) + + it('is a no-op once a reference layer already exists, regardless of toggle direction', () => { + const state = { + ...defaultState, + mapViews: [ + { id: 'layer1' }, + { id: 'ref1', layer: COMBINED_TABLE_REF_LAYER }, + ], + } + + const result = map(state, { + type: types.DATA_TABLE_COMBINED_VIEW_TOGGLE, + }) + + expect(result).toBe(state) + }) +}) + describe('map reducer - LAYER_REMOVE / LAYER_DUPLICATE', () => { it('removes a layer by id', () => { const state = { @@ -269,6 +304,55 @@ describe('map reducer - LAYER_REMOVE / LAYER_DUPLICATE', () => { expect(result.mapViews).toEqual([{ id: 'layer2' }]) }) + it("prunes the removed layer's entry from the reference layer's combinedJoinConfig, keyed by combinedLayerKey not id", () => { + const state = { + ...defaultState, + mapViews: [ + { id: 'layer1', combinedLayerKey: 'layer1Key' }, + { + id: 'ref1', + layer: COMBINED_TABLE_REF_LAYER, + combinedJoinConfig: { + layer1Key: { type: 'orgUnit', aggregation: {} }, + layer2Key: { type: 'spatial', aggregation: {} }, + }, + }, + ], + } + + const result = map(state, { + type: types.LAYER_REMOVE, + id: 'layer1', + combinedLayerKey: 'layer1Key', + }) + + expect(result.mapViews[0].combinedJoinConfig).toEqual({ + layer2Key: { type: 'spatial', aggregation: {} }, + }) + }) + + it('is a no-op when the removed layer was never a join participant', () => { + const referenceLayer = { + id: 'ref1', + layer: COMBINED_TABLE_REF_LAYER, + combinedJoinConfig: { + layer2Key: { type: 'orgUnit', aggregation: {} }, + }, + } + const state = { + ...defaultState, + mapViews: [{ id: 'layer1' }, referenceLayer], + } + + const result = map(state, { + type: types.LAYER_REMOVE, + id: 'layer1', + combinedLayerKey: 'layer1Key', + }) + + expect(result.mapViews[0]).toBe(referenceLayer) + }) + it('returns state unchanged when duplicating a layer that is not found', () => { const state = { ...defaultState, mapViews: [{ id: 'layer1' }] } @@ -384,6 +468,37 @@ describe('map reducer - per-layer delegation', () => { visibleKeys: ['id'], }) }) + + it("keeps the live combinedJoinConfig/combinedColumnConfig instead of an async loader payload's stale snapshot", () => { + const state = { + ...defaultState, + mapViews: [ + { + id: 'ref1', + combinedJoinConfig: { + layer1Key: { type: 'orgUnit', aggregation: {} }, + }, + combinedColumnConfig: { pinnedKeys: ['name'] }, + }, + ], + } + + const result = map(state, { + type: types.LAYER_UPDATE, + payload: { + id: 'ref1', + combinedJoinConfig: undefined, + combinedColumnConfig: undefined, + }, + }) + + expect(result.mapViews[0].combinedJoinConfig).toEqual({ + layer1Key: { type: 'orgUnit', aggregation: {} }, + }) + expect(result.mapViews[0].combinedColumnConfig).toEqual({ + pinnedKeys: ['name'], + }) + }) }) describe('LAYER_EDIT', () => { @@ -539,6 +654,42 @@ describe('map reducer - per-layer delegation', () => { }) }) + describe('DATA_TABLE_JOIN_CONFIG_SET', () => { + it('sets combinedJoinConfig on the matching layer only', () => { + const other = { id: 'layer2' } + const state = { ...defaultState, mapViews: [{ id: 'ref1' }, other] } + + const result = map(state, { + type: types.DATA_TABLE_JOIN_CONFIG_SET, + layerId: 'ref1', + layers: { layer1Key: { type: 'orgUnit', aggregation: {} } }, + }) + + expect(result.mapViews[0].combinedJoinConfig).toEqual({ + layer1Key: { type: 'orgUnit', aggregation: {} }, + }) + expect(result.mapViews[1]).toBe(other) + }) + }) + + describe('DATA_TABLE_COMBINED_COLUMN_CONFIG_SET', () => { + it('sets combinedColumnConfig on the matching layer only', () => { + const other = { id: 'layer2' } + const state = { ...defaultState, mapViews: [{ id: 'ref1' }, other] } + + const result = map(state, { + type: types.DATA_TABLE_COMBINED_COLUMN_CONFIG_SET, + layerId: 'ref1', + config: { pinnedKeys: ['name'] }, + }) + + expect(result.mapViews[0].combinedColumnConfig).toEqual({ + pinnedKeys: ['name'], + }) + expect(result.mapViews[1]).toBe(other) + }) + }) + describe('MAP_EARTH_ENGINE_VALUE_SHOW', () => { it('sets the coordinate on the matching layer only', () => { const other = { id: 'layer2' } diff --git a/src/reducers/dataTable.js b/src/reducers/dataTable.js index 9fc430798e..3f7df533b4 100644 --- a/src/reducers/dataTable.js +++ b/src/reducers/dataTable.js @@ -1,89 +1,35 @@ import * as types from '../constants/actionTypes.js' -// joinConfig.layers is keyed by participating layer id: { type: 'orgUnit' | -// 'spatial', aggregation: { [dataKey]: aggregationTypeId } }. The reference -// org unit set itself isn't stored here at all - it's derived from -// state.map.mapViews (the one layer with layer === COMBINED_TABLE_REF_LAYER), -// same as any other layer lookup, rather than duplicated into this slice. const initialState = { openIds: [], combinedView: false, - joinConfig: { - layers: {}, - }, - combinedColumnConfig: null, } const dataTable = (state = initialState, action) => { switch (action.type) { - // Closes the whole panel (or leaves the data table view while - // entering/exiting download mode) - resets which tab(s) are open - // and whether Combined is the active view, but preserves joinConfig/ - // combinedColumnConfig themselves. Both are real, savable - // configuration now (see favorites.js/FileMenu.jsx), not just - // session-only display state - wiping them here would silently - // discard them the moment a user closes the panel before saving, an - // extremely common, low-stakes action that has nothing to do with - // abandoning their join/column setup. case types.DATA_TABLE_CLOSE: case types.DOWNLOAD_MODE_CLOSE: case types.DOWNLOAD_MODE_OPEN: - return { - ...initialState, - joinConfig: state.joinConfig, - combinedColumnConfig: state.combinedColumnConfig, - } - case types.MAP_NEW: - return initialState - case types.MAP_SET: - return action.payload.dataTable ?? initialState + return initialState case types.DATA_TABLE_TOGGLE: { const openIds = state.openIds.includes(action.id) ? state.openIds.filter((id) => id !== action.id) : [...state.openIds, action.id] - // combinedView/joinConfig are fully decoupled from openIds - - // closing the last open tab this way doesn't touch them, even - // if that empties openIds. isDataTableOpen() (util/dataTable.js) - // is what decides whether the panel itself stays open, and it - // already accounts for combinedView independently of openIds. return { ...state, openIds } } - case types.LAYER_REMOVE: { - // Only prunes a removed *participating* layer's own join - // settings - this reducer only sees its own slice, not - // state.map.mapViews, so it can't tell here whether the removed - // layer was instead the reference layer itself. Not a gap in - // practice yet: the reference layer has no delete affordance of - // its own (it's hidden from the normal layer list/cards), so - // that case has no way to be triggered today. A future - // "reset reference" action would need to turn combinedView off - // itself when it removes the reference layer. - // - // Pruned by combinedLayerKey, not the volatile mapView id - see - // util/favorites.js/reducers/map.js for why joinConfig.layers is - // keyed that way. - const layers = { ...state.joinConfig.layers } - delete layers[action.combinedLayerKey] + case types.LAYER_REMOVE: return { ...state, openIds: state.openIds.filter((id) => id !== action.id), - joinConfig: { ...state.joinConfig, layers }, } - } case types.DATA_TABLE_COMBINED_VIEW_TOGGLE: return { ...state, combinedView: !state.combinedView } - case types.DATA_TABLE_JOIN_CONFIG_SET: - return { ...state, joinConfig: action.config } - - case types.DATA_TABLE_COMBINED_COLUMN_CONFIG_SET: - return { ...state, combinedColumnConfig: action.config } - default: return state } diff --git a/src/reducers/map.js b/src/reducers/map.js index 90e98c6a84..f70d329b4b 100644 --- a/src/reducers/map.js +++ b/src/reducers/map.js @@ -1,5 +1,6 @@ import { arrayMoveImmutable } from 'array-move' import * as types from '../constants/actionTypes.js' +import { COMBINED_TABLE_REF_LAYER } from '../constants/layers.js' import { generateUid } from '../util/uid.js' export const defaultBasemapState = { @@ -96,6 +97,12 @@ const layer = (state, action) => { dataFilters: state.dataFilters ?? action.payload.dataFilters, combinedLayerKey: state.combinedLayerKey ?? action.payload.combinedLayerKey, + combinedJoinConfig: + state.combinedJoinConfig ?? + action.payload.combinedJoinConfig, + combinedColumnConfig: + state.combinedColumnConfig ?? + action.payload.combinedColumnConfig, } case types.LAYER_CHANGE_OPACITY: @@ -197,6 +204,37 @@ const layer = (state, action) => { dataTableColumnConfig: action.config, } + case types.DATA_TABLE_JOIN_CONFIG_SET: + if (state.id !== action.layerId) { + return state + } + + return { + ...state, + combinedJoinConfig: action.layers, + } + + case types.DATA_TABLE_COMBINED_COLUMN_CONFIG_SET: + if (state.id !== action.layerId) { + return state + } + + return { + ...state, + combinedColumnConfig: action.config, + } + + case types.LAYER_REMOVE: { + if (!state.combinedJoinConfig?.[action.combinedLayerKey]) { + return state + } + + const combinedJoinConfig = { ...state.combinedJoinConfig } + delete combinedJoinConfig[action.combinedLayerKey] + + return { ...state, combinedJoinConfig } + } + case types.MAP_ALERTS_CLEAR: return { ...state, @@ -290,9 +328,32 @@ const map = (state = defaultState, action) => { case types.LAYER_REMOVE: return { ...state, - mapViews: state.mapViews.filter( - (layer) => layer.id !== action.id - ), + mapViews: state.mapViews + .filter((mv) => mv.id !== action.id) + .map((mv) => layer(mv, action)), + } + + case types.DATA_TABLE_COMBINED_VIEW_TOGGLE: + if ( + state.mapViews.some( + (mv) => mv.layer === COMBINED_TABLE_REF_LAYER + ) + ) { + return state + } + + return { + ...state, + mapViews: [ + ...state.mapViews, + { + layer: COMBINED_TABLE_REF_LAYER, + id: generateUid(), + combinedLayerKey: generateUid(), + isVisible: false, + rows: [], + }, + ], } case types.LAYER_DUPLICATE: { @@ -342,6 +403,8 @@ const map = (state = defaultState, action) => { case types.DATA_FILTER_CLEAR: case types.DATA_FILTERS_CLEAR_ALL: case types.DATA_TABLE_COLUMN_CONFIG_SET: + case types.DATA_TABLE_JOIN_CONFIG_SET: + case types.DATA_TABLE_COMBINED_COLUMN_CONFIG_SET: case types.MAP_EARTH_ENGINE_VALUE_SHOW: return { ...state, diff --git a/src/util/__tests__/favorites.spec.js b/src/util/__tests__/favorites.spec.js index 14b45f358d..219a995e1b 100644 --- a/src/util/__tests__/favorites.spec.js +++ b/src/util/__tests__/favorites.spec.js @@ -1042,6 +1042,22 @@ describe('cleanMapConfig', () => { expect(mapView).not.toHaveProperty('combinedJoinConfig') }) + test('excludes an untouched combinedTableRef placeholder (no org units, no join layers picked) from the saved favorite', () => { + const config = { + mapViews: [ + { layer: 'thematic' }, + { layer: 'combinedTableRef', rows: [] }, + ], + } + const cleanedConfig = cleanMapConfig({ + config, + defaultBasemapId: 'default', + }) + + expect(cleanedConfig.mapViews).toHaveLength(1) + expect(cleanedConfig.mapViews[0].layer).toBe('thematic') + }) + test('serializes dataTableColumnConfig into config JSON for geojson layer', () => { const dataTableColumnConfig = { orderedKeys: ['name', 'id'] } const config = { diff --git a/src/util/favorites.js b/src/util/favorites.js index b1da337804..decdc49b0a 100644 --- a/src/util/favorites.js +++ b/src/util/favorites.js @@ -9,6 +9,7 @@ import { THEMATIC_LAYER, TRACKED_ENTITY_LAYER, } from '../constants/layers.js' +import { getOrgUnitsFromRows } from './analytics.js' // TODO: get latitude, longitude, zoom from map + basemap: 'none' const validMapProperties = [ @@ -36,8 +37,8 @@ const validLayerProperties = [ 'colorLow', // Deprecated 'colorScale', 'columns', - 'combinedColumnConfig', // only ever set on the combinedTableRef layer - 'combinedJoinConfig', // only ever set on the combinedTableRef layer + 'combinedColumnConfig', // used by combinedTableRef layer + 'combinedJoinConfig', // used by combinedTableRef layer 'combinedLayerKey', // stable cross-save id, set on every layer type 'config', 'created', @@ -123,9 +124,15 @@ export const cleanMapConfig = ({ }) => ({ ...omitBy(isNil, pick(validMapProperties, config)), ...getBasemapPayload(config.basemap, defaultBasemapId, serverVersion), - mapViews: config.mapViews.map((view) => - cleanLayerConfig(view, cleanMapviewConfig) - ), + // An untouched combinedTableRef placeholder gets cleaned-up + mapViews: config.mapViews + .filter( + (view) => + view.layer !== COMBINED_TABLE_REF_LAYER || + getOrgUnitsFromRows(view.rows).length > 0 || + Object.keys(view.combinedJoinConfig ?? {}).length > 0 + ) + .map((view) => cleanLayerConfig(view, cleanMapviewConfig)), }) // VERSION-TOGGLE: https://dhis2.atlassian.net/browse/DHIS2-20417 From 67f1bfed6b5dd08f29d13e64e93aa18d2b6dbb89 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 30 Jul 2026 12:05:26 +0200 Subject: [PATCH 168/205] chore: PR clean-up --- i18n/en.pot | 14 +++++++------- src/components/datatable/CellValue.jsx | 5 ----- .../datatable/CombinedTableContextMenu.jsx | 8 -------- src/components/datatable/DataTableButton.jsx | 6 ------ src/components/datatable/FilterInput.jsx | 5 ----- .../datatable/SelectionCheckboxColumn.jsx | 3 --- .../datatable/SortableColumnHeader.jsx | 3 --- .../datatable/__tests__/BottomPanel.spec.jsx | 7 ++++--- .../__tests__/LayerSelectorControl.spec.jsx | 7 +++++-- .../__tests__/useCombinedTableData.spec.js | 5 +---- .../datatable/controls/LayerSelectorControl.jsx | 9 ++++----- .../datatable/useCombinedTableData.js | 2 -- .../datatable/useRowClickSelection.js | 4 ---- src/components/datatable/useRowSelection.js | 4 ---- src/components/datatable/useSortState.js | 3 --- .../edit/__tests__/LayerEdit.spec.jsx | 4 ---- .../layers/__tests__/LayersPanel.spec.jsx | 4 ---- src/components/map/Map.jsx | 7 ------- src/components/map/layers/Layer.js | 4 ---- src/components/plugin/Map.jsx | 11 ----------- src/constants/aggregationTypes.js | 4 +--- src/constants/dataTable.js | 3 +-- src/constants/layers.js | 6 +----- src/loaders/earthEngineLoader.js | 2 -- src/loaders/orgUnitLoader.js | 8 -------- src/util/map.js | 16 +--------------- src/util/spatialJoin.js | 17 ----------------- src/util/tableColumns.js | 6 +----- 28 files changed, 26 insertions(+), 151 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index d5f0ab4bdc..fbc0e7448f 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-29T08:31:24.085Z\n" -"PO-Revision-Date: 2026-07-29T08:31:24.085Z\n" +"POT-Creation-Date: 2026-07-30T10:04:16.069Z\n" +"PO-Revision-Date: 2026-07-30T10:04:16.069Z\n" msgid "2020" msgstr "2020" @@ -376,8 +376,8 @@ msgstr "Show only features in current map view" msgid "Org unit Id" msgstr "Org unit Id" -msgid "Level" -msgstr "Level" +msgid "Org unit level" +msgstr "Org unit level" msgid "{{name}} ({{layer}})" msgstr "{{name}} ({{layer}})" @@ -1002,6 +1002,9 @@ msgstr "Groups" msgid "Parent unit" msgstr "Parent unit" +msgid "Level" +msgstr "Level" + msgid "Not set" msgstr "Not set" @@ -2119,9 +2122,6 @@ msgstr "GroupSet used for styling was not found" msgid "Id" msgstr "Id" -msgid "Org unit level" -msgstr "Org unit level" - msgid "Geometry type" msgstr "Geometry type" diff --git a/src/components/datatable/CellValue.jsx b/src/components/datatable/CellValue.jsx index d7dcfa3420..1c70ed0abd 100644 --- a/src/components/datatable/CellValue.jsx +++ b/src/components/datatable/CellValue.jsx @@ -21,11 +21,6 @@ import { } from '../../util/orgUnitGroups.js' import styles from './styles/DataTable.module.css' -// Shared between DataTable.jsx and CombinedDataTable.jsx - which renderer a -// column uses determines both its cell content (CellValue, below) and its -// DataTableCell className (isDarkColor/monoCell/backgroundColor - computed -// by each caller since those touch component-specific selected/hovered/ -// pinned state too), so both need these same flags. export const getCellRendererFlags = (renderer, type) => ({ isColorCell: renderer === RENDERER_COLOR, isIconCell: renderer === RENDERER_ICON, diff --git a/src/components/datatable/CombinedTableContextMenu.jsx b/src/components/datatable/CombinedTableContextMenu.jsx index 8bc91705f9..a91063690b 100644 --- a/src/components/datatable/CombinedTableContextMenu.jsx +++ b/src/components/datatable/CombinedTableContextMenu.jsx @@ -37,16 +37,8 @@ const CombinedTableContextMenu = ({ const { x, y, rowId } = contextMenu const entry = rowFeatureIds.get(rowId) ?? {} - // getUnionBounds/zoom need the reference layer's own geometry alongside - // every participating layer's, since rowFeatureIds always names the - // reference org unit too (see useCombinedTableData.js) - it's the only - // guaranteed match for a row with no participating-layer data at all. const allLayers = [referenceLayer, ...layers] - // Drill up/down always targets the reference layer's own level - a row - // IS a reference org unit, so this is exactly TableContextMenu.jsx's - // own single-layer drill, just scoped to the reference layer instead of - // whichever layer a normal single-layer table belongs to. const referenceFeatureProps = buildFeatureIndex(referenceLayer.data).get( rowId )?.properties diff --git a/src/components/datatable/DataTableButton.jsx b/src/components/datatable/DataTableButton.jsx index 18a496b04f..6ffc04c853 100644 --- a/src/components/datatable/DataTableButton.jsx +++ b/src/components/datatable/DataTableButton.jsx @@ -24,12 +24,6 @@ const DataTableButton = () => { !!referenceLayer && getOrgUnitsFromRows(referenceLayer.rows).length > 0 const onClick = () => { - // Toggles the panel: closes it if a table is already showing - // (single-layer or Combined), otherwise opens one. Combined is only - // auto-opened here when a reference org unit set has already been - // configured (mirrors BottomPanel.jsx's own combinedEnabled gate) - - // otherwise there'd be nothing to show, so this shortcut falls back - // to just opening the first eligible layer's own table instead. if (isDataTableOpen(dataTable)) { dispatch(closeDataTable()) return diff --git a/src/components/datatable/FilterInput.jsx b/src/components/datatable/FilterInput.jsx index b26747d8be..a6f7ce2259 100644 --- a/src/components/datatable/FilterInput.jsx +++ b/src/components/datatable/FilterInput.jsx @@ -574,11 +574,6 @@ OptionSetSearchableFilter.propTypes = { optionSetId: PropTypes.string.isRequired, } -// filterValue/onChange/onClear are supplied by the caller (dispatch-agnostic, -// like useRowSelection) - a real layer's Redux dataFilters for DataTable.jsx, -// or local session-only state for CombinedDataTable.jsx. layerId is only -// used by the date/org-unit group filter paths, which still dispatch -// directly since Combined never produces those column types today. const FilterInput = React.memo(function FilterInput({ layerId, type, diff --git a/src/components/datatable/SelectionCheckboxColumn.jsx b/src/components/datatable/SelectionCheckboxColumn.jsx index 9eedc47f64..22a9171d12 100644 --- a/src/components/datatable/SelectionCheckboxColumn.jsx +++ b/src/components/datatable/SelectionCheckboxColumn.jsx @@ -8,9 +8,6 @@ import { SortIcon } from '../core/icons.jsx' import styles from './styles/DataTable.module.css' import TopTooltip from './TopTooltip.jsx' -// Shared between DataTable.jsx and CombinedDataTable.jsx - the checkbox -// column's markup and select-all/reverse-selection/sort-by-selected -// interactions are identical, only where the selection itself lives differs. export const SelectionCheckboxHeaderCell = ({ fixed, left, diff --git a/src/components/datatable/SortableColumnHeader.jsx b/src/components/datatable/SortableColumnHeader.jsx index b00faab82a..9f2b4e3573 100644 --- a/src/components/datatable/SortableColumnHeader.jsx +++ b/src/components/datatable/SortableColumnHeader.jsx @@ -6,9 +6,6 @@ import { SortIcon } from '../core/icons.jsx' import styles from './styles/DataTable.module.css' import TopTooltip from './TopTooltip.jsx' -// Shared between DataTable.jsx and CombinedDataTable.jsx - both produce the -// same {name, dataKey} header shape and the same sort-button interaction, -// so only the surrounding column-header props (pinning, filter) differ. const SortableColumnHeader = ({ name, dataKey, diff --git a/src/components/datatable/__tests__/BottomPanel.spec.jsx b/src/components/datatable/__tests__/BottomPanel.spec.jsx index 7180c49af5..1bfc92a259 100644 --- a/src/components/datatable/__tests__/BottomPanel.spec.jsx +++ b/src/components/datatable/__tests__/BottomPanel.spec.jsx @@ -2,6 +2,7 @@ import { render, fireEvent, screen } from '@testing-library/react' import React from 'react' import { Provider } from 'react-redux' import configureMockStore from 'redux-mock-store' +import { SENTINEL_COMBINED_VALUE } from '../../../constants/dataTable.js' import { THEMATIC_LAYER } from '../../../constants/layers.js' import WindowDimensionsProvider from '../../WindowDimensionsProvider.jsx' import BottomPanel from '../BottomPanel.jsx' @@ -232,7 +233,7 @@ describe('BottomPanel layer selector', () => { }) fireEvent.change(getLayerSelector(), { - target: { value: '__combined__' }, + target: { value: SENTINEL_COMBINED_VALUE }, }) expect(store.getActions()).toEqual([ @@ -250,7 +251,7 @@ describe('BottomPanel layer selector', () => { }) fireEvent.change(getLayerSelector(), { - target: { value: '__combined__' }, + target: { value: SENTINEL_COMBINED_VALUE }, }) expect(store.getActions()).toEqual([ @@ -277,7 +278,7 @@ describe('BottomPanel layer selector', () => { }) fireEvent.change(getLayerSelector(), { - target: { value: '__combined__' }, + target: { value: SENTINEL_COMBINED_VALUE }, }) expect(store.getActions()).toEqual([ diff --git a/src/components/datatable/__tests__/LayerSelectorControl.spec.jsx b/src/components/datatable/__tests__/LayerSelectorControl.spec.jsx index 85dbb4f13a..ed930be79d 100644 --- a/src/components/datatable/__tests__/LayerSelectorControl.spec.jsx +++ b/src/components/datatable/__tests__/LayerSelectorControl.spec.jsx @@ -1,5 +1,6 @@ import { render, fireEvent, screen } from '@testing-library/react' import React from 'react' +import { SENTINEL_COMBINED_VALUE } from '../../../constants/dataTable.js' import LayerSelectorControl from '../controls/LayerSelectorControl.jsx' const layers = [ @@ -41,7 +42,7 @@ describe('LayerSelectorControl', () => { test('shows Combined as the selected value when combinedView is true', () => { renderControl({ combinedView: true }) - expect(getSelect()).toHaveValue('__combined__') + expect(getSelect()).toHaveValue(SENTINEL_COMBINED_VALUE) }) test('selecting a different layer calls onSelectLayer with its id', () => { @@ -54,7 +55,9 @@ describe('LayerSelectorControl', () => { test('selecting Combined calls onSelectCombined', () => { const onSelectCombined = jest.fn() renderControl({ onSelectCombined }) - fireEvent.change(getSelect(), { target: { value: '__combined__' } }) + fireEvent.change(getSelect(), { + target: { value: SENTINEL_COMBINED_VALUE }, + }) expect(onSelectCombined).toHaveBeenCalled() }) }) diff --git a/src/components/datatable/__tests__/useCombinedTableData.spec.js b/src/components/datatable/__tests__/useCombinedTableData.spec.js index 6c3bcace79..c942ebffa9 100644 --- a/src/components/datatable/__tests__/useCombinedTableData.spec.js +++ b/src/components/datatable/__tests__/useCombinedTableData.spec.js @@ -706,10 +706,7 @@ describe('useCombinedTableData - empty input', () => { describe('useCombinedTableData - Earth Engine value columns', () => { // Earth Engine layers never carry their value(s) directly on feature - // properties (unlike every other layer type) - they're computed - // client-side into state.aggregations, keyed by layer id then feature - // id, and merged in here (see mergeAggregations) exactly like - // util/tableRows.js already does for the single-layer table. + // properties they're computed client-side into state.aggregations test('merges aggregation stats in and generates one joinable column per stat, with no generic legend column', () => { const layers = [ { diff --git a/src/components/datatable/controls/LayerSelectorControl.jsx b/src/components/datatable/controls/LayerSelectorControl.jsx index a838150c62..c112d21875 100644 --- a/src/components/datatable/controls/LayerSelectorControl.jsx +++ b/src/components/datatable/controls/LayerSelectorControl.jsx @@ -1,10 +1,9 @@ import i18n from '@dhis2/d2-i18n' import PropTypes from 'prop-types' import React from 'react' +import { SENTINEL_COMBINED_VALUE } from '../../../constants/dataTable.js' import styles from '../styles/BottomPanel.module.css' -const COMBINED_VALUE = '__combined__' - const LayerSelectorControl = ({ layers, activeLayerId, @@ -16,9 +15,9 @@ const LayerSelectorControl = ({ className={styles.layerSelect} aria-label={i18n.t('Choose a data table to view')} data-test="data-table-layer-selector" - value={combinedView ? COMBINED_VALUE : activeLayerId ?? ''} + value={combinedView ? SENTINEL_COMBINED_VALUE : activeLayerId ?? ''} onChange={(e) => { - if (e.target.value === COMBINED_VALUE) { + if (e.target.value === SENTINEL_COMBINED_VALUE) { onSelectCombined() } else { onSelectLayer(e.target.value) @@ -30,7 +29,7 @@ const LayerSelectorControl = ({ {layer.name} </option> ))} - <option value={COMBINED_VALUE}>{i18n.t('Combined')}</option> + <option value={SENTINEL_COMBINED_VALUE}>{i18n.t('Combined')}</option> </select> ) diff --git a/src/components/datatable/useCombinedTableData.js b/src/components/datatable/useCombinedTableData.js index 1079055557..2e70ef007e 100644 --- a/src/components/datatable/useCombinedTableData.js +++ b/src/components/datatable/useCombinedTableData.js @@ -285,8 +285,6 @@ export const useCombinedTableData = ({ type: TYPE_NUMBER, })), // Earth Engine has no separate categorical "legend" concept - // of its own - its per-class values are already expressed - // as their own value columns above, one per legend class. ...(layer.layer !== EARTH_ENGINE_LAYER ? [ { diff --git a/src/components/datatable/useRowClickSelection.js b/src/components/datatable/useRowClickSelection.js index b4f89659ed..713c7908cb 100644 --- a/src/components/datatable/useRowClickSelection.js +++ b/src/components/datatable/useRowClickSelection.js @@ -1,10 +1,6 @@ import { useCallback, useRef } from 'react' import { getRowClickAction, getRowId } from '../../util/dataTable.js' -// Shared row-click-to-selection-action handling: shift-click ranges, ctrl/cmd -// toggles a single row. onToggle/onSelectRange apply the result however the -// caller's selection state actually works (Redux for a single layer, local -// state for Combined's cross-layer selection). export const useRowClickSelection = ({ rows, onToggle, onSelectRange }) => { const lastClickedRowIndexRef = useRef(null) diff --git a/src/components/datatable/useRowSelection.js b/src/components/datatable/useRowSelection.js index a2157c3914..18380b7d49 100644 --- a/src/components/datatable/useRowSelection.js +++ b/src/components/datatable/useRowSelection.js @@ -8,10 +8,6 @@ export const getReversedSelection = (selectedIds, allRowIds) => { return [...offViewSelected, ...invertedVisible] } -// onChange receives the full next selection (possibly empty) - the caller -// decides how to apply it (dispatch to a single layer's Redux selection, -// set local state, etc), so this hook has no opinion on where selection -// state actually lives. export const useRowSelection = ({ selectedIds, selectedIdSet, diff --git a/src/components/datatable/useSortState.js b/src/components/datatable/useSortState.js index 2812fcec5f..7d12214843 100644 --- a/src/components/datatable/useSortState.js +++ b/src/components/datatable/useSortState.js @@ -2,9 +2,6 @@ import { useCallback, useReducer } from 'react' import { SORT_ASCENDING } from '../../constants/dataTable.js' import { getNextSorting } from '../../util/dataTable.js' -// Shared between DataTable.jsx and CombinedDataTable.jsx - each keeps its -// own independent sort state (neither is persisted), driven by the same -// three-click asc/desc/none cycle. export const useSortState = (initialSortField = 'name') => { const [{ sortField, sortDirection }, setSorting] = useReducer( (sorting, newSorting) => ({ ...sorting, ...newSorting }), diff --git a/src/components/edit/__tests__/LayerEdit.spec.jsx b/src/components/edit/__tests__/LayerEdit.spec.jsx index 47313152f2..c3db00e65f 100644 --- a/src/components/edit/__tests__/LayerEdit.spec.jsx +++ b/src/components/edit/__tests__/LayerEdit.spec.jsx @@ -13,10 +13,6 @@ jest.mock('../../OrgUnitsProvider.jsx', () => ({ useOrgUnits: () => ({}), })) -// A function declaration (not a const arrow function) - jest.mock() factory -// calls are hoisted above regular variable declarations, so a const here -// would throw a "Cannot access before initialization" error; a hoisted -// function declaration is safe to call from those factories. function mockDialog(testId) { const Mock = ({ hideStyleTab }) => ( <div data-test={testId}>{String(!!hideStyleTab)}</div> diff --git a/src/components/layers/__tests__/LayersPanel.spec.jsx b/src/components/layers/__tests__/LayersPanel.spec.jsx index 4cf5c84b1e..0e7623bdfe 100644 --- a/src/components/layers/__tests__/LayersPanel.spec.jsx +++ b/src/components/layers/__tests__/LayersPanel.spec.jsx @@ -64,10 +64,6 @@ describe('LayersPanel — reference org unit layer exclusion', () => { }) describe('getSortIndices', () => { - // Matches LAYER_SORT's own reducer math (reducers/map.js): indices are - // computed against the full reversed mapViews, not a filtered display - // list, so a hidden reference layer anywhere in the array doesn't - // throw off the position of every layer after it. const reversedMapViews = [ { id: 'layer2' }, { id: 'ref1' }, diff --git a/src/components/map/Map.jsx b/src/components/map/Map.jsx index 78c951562b..31c754d6c9 100644 --- a/src/components/map/Map.jsx +++ b/src/components/map/Map.jsx @@ -172,13 +172,6 @@ class Map extends Component { this.initializeTimelinePeriod(timelineOverlay) } - // A crossLayerIds highlight/selection has no single owning layerId, so - // (unlike a single-layer zoom, which each Layer instance handles itself - // in handleFeatureUpdate) it can't be resolved by any one Layer without - // several instances racing independent fitBounds() calls. Its bounds - // are precomputed by the caller (CombinedDataTable.jsx, from the union - // of every matching feature across every participating layer) and fit - // here once, at the top level, instead. handleCrossLayerZoom(prevProps) { fitCrossLayerZoomBounds(this.map, this.props.feature, prevProps.feature) } diff --git a/src/components/map/layers/Layer.js b/src/components/map/layers/Layer.js index ea7c0af001..18a84c1423 100644 --- a/src/components/map/layers/Layer.js +++ b/src/components/map/layers/Layer.js @@ -284,10 +284,6 @@ class Layer extends PureComponent { } } - // crossLayerIds is populated only for cross-layer highlights/selections - // (e.g. a Combined-view row spanning multiple layers) - single-layer - // hover/selection dispatches never set it, so ownId/ownIds alone still - // fully determine the result for every existing call site. getHoverIds(feature = this.props.feature) { const ownId = feature?.layerId === this.props.id ? feature.id : null const crossIds = feature?.crossLayerIds?.[this.props.id] ?? [] diff --git a/src/components/plugin/Map.jsx b/src/components/plugin/Map.jsx index 1223925ae5..9c69423cb8 100644 --- a/src/components/plugin/Map.jsx +++ b/src/components/plugin/Map.jsx @@ -39,13 +39,6 @@ const getFullscreenDoc = () => { const Map = forwardRef((props, ref) => { const { basemap, mapViews, controls, getResizeFunction } = props - // The Combined data table's reference org unit layer is a hidden, - // non-rendered layer with no meaning outside the standalone app's - // BottomPanel (which the dashboard plugin has no Redux store to - // render) - exclude it here rather than let its unregistered loader - // key reach LayerLoader. Memoized so it's only a new reference when - // mapViews itself changes, keeping the effect below from re-running - // (and resetting map state) on every render. const renderableMapViews = useMemo( () => mapViews.filter((v) => v.layer !== COMBINED_TABLE_REF_LAYER), [mapViews] @@ -66,10 +59,6 @@ const Map = forwardRef((props, ref) => { } }, [renderableMapViews]) - // Matches renderableMapViews, not the raw mapViews prop - a map - // containing only a hidden reference layer and no real layers has - // nothing for any <LayerLoader> to load, so nothing would ever call - // onLayerLoad to flip this true otherwise. const [mapIsLoaded, setMapIsLoaded] = useState( renderableMapViews.length === 0 ) diff --git a/src/constants/aggregationTypes.js b/src/constants/aggregationTypes.js index 71da3b1790..53a8a9d207 100644 --- a/src/constants/aggregationTypes.js +++ b/src/constants/aggregationTypes.js @@ -12,9 +12,7 @@ export const getThematicAggregationTypes = () => [ { id: 'MAX', name: i18n.t('Max') }, ] -// Combined data table join - same set as the thematic layer's own -// aggregation types, minus DEFAULT ("by data element"), which has no -// meaning outside a thematic layer's own data element config. +// Combined data table join export const getCombinedAggregationTypes = () => getThematicAggregationTypes().filter((type) => type.id !== 'DEFAULT') diff --git a/src/constants/dataTable.js b/src/constants/dataTable.js index 6c28e59876..fb455b0886 100644 --- a/src/constants/dataTable.js +++ b/src/constants/dataTable.js @@ -1,6 +1,7 @@ export const SENTINEL_NO_VALUE = '' // Matches filterData's existing null/undefined -> empty-string coercion (src/util/filter.js) export const SENTINEL_ANY_VALUE = '__any_value__' export const SENTINEL_SELECTED_ROW = '__selected__' +export const SENTINEL_COMBINED_VALUE = '__combined_value__' export const SORT_ASCENDING = 'asc' export const SORT_DESCENDING = 'desc' @@ -27,6 +28,4 @@ export const ORG_UNIT_DATA_KEY = 'orgUnitOwn' export const ORG_UNIT_ID_DATA_KEY = 'orgUnitId' export const ORG_UNIT_LEVEL_DATA_KEY = 'level' -// BottomPanel.jsx's headersByLayer cache is keyed by layer id - Combined -// isn't a real layer, so it uses this sentinel key instead. export const COMBINED_HEADERS_KEY = '__combined__' diff --git a/src/constants/layers.js b/src/constants/layers.js index 4b5a2ed463..d397e900f6 100644 --- a/src/constants/layers.js +++ b/src/constants/layers.js @@ -17,11 +17,7 @@ export const TRACKED_ENTITY_LAYER = 'trackedEntity' export const GEOJSON_LAYER = 'geoJson' export const GROUP_LAYER = 'group' export const GEOJSON_URL_LAYER = 'geoJsonUrl' -// A hidden, non-rendered org-unit layer backing the Combined data table's -// join - deliberately its own type (not ORG_UNIT_LAYER) so it's excluded -// from DOWNLOADABLE_LAYER_TYPES/DATA_TABLE_LAYER_TYPES and the "Add layer" -// popover just by omission, with no separate flag to check everywhere. -export const COMBINED_TABLE_REF_LAYER = 'combinedTableRef' +export const COMBINED_TABLE_REF_LAYER = 'combinedTableRef' // Non-rendered org-unit layer backing the Combined data table's join export const MAP_SERVICE_KEY_TESTS = { keyBingMapsApiKey: [ diff --git a/src/loaders/earthEngineLoader.js b/src/loaders/earthEngineLoader.js index 65767f9712..ce2cb01722 100644 --- a/src/loaders/earthEngineLoader.js +++ b/src/loaders/earthEngineLoader.js @@ -201,8 +201,6 @@ const earthEngineLoader = async ({ ...config, ...layerConfig, } - // Stable cross-save id used to key combinedJoinConfig/combinedColumnConfig - // entries - layer.id itself is regenerated by the server on every save. if (!layer.combinedLayerKey) { layer.combinedLayerKey = generateUid() } diff --git a/src/loaders/orgUnitLoader.js b/src/loaders/orgUnitLoader.js index 766ccb9155..5693b38e94 100644 --- a/src/loaders/orgUnitLoader.js +++ b/src/loaders/orgUnitLoader.js @@ -89,19 +89,11 @@ const orgUnitLoader = async ({ config.dataTableColumnConfig = dataTableColumnConfig } if (combinedJoinConfig) { - // Only ever set on the combinedTableRef layer - see FileMenu.jsx, - // which stamps state.dataTable.joinConfig.layers onto it just - // before save. config.combinedJoinConfig = combinedJoinConfig } if (combinedColumnConfig) { - // Only ever set on the combinedTableRef layer, same as above. config.combinedColumnConfig = combinedColumnConfig } - // Stable cross-save id used to key combinedJoinConfig/combinedColumnConfig - // entries - layer.id itself is regenerated by the server on every save, - // so it can't be used for those cross-layer references. Minted here if - // this map predates the field; sticks from then on. config.combinedLayerKey = combinedLayerKey ?? generateUid() delete config.config diff --git a/src/util/map.js b/src/util/map.js index d5fa959846..9b60d2f5f9 100644 --- a/src/util/map.js +++ b/src/util/map.js @@ -66,25 +66,11 @@ export const toGeoJson = (organisationUnits) => geometry.coordinates.flat().length ) -// Map.jsx passes each Layer instance only the slice of state.feature it -// owns, rather than the raw global value, so a highlight never re-triggers -// componentDidUpdate on unrelated layers. A crossLayerIds-based highlight -// (layerId: null, set only by CombinedDataTable) has no single owning -// layerId, so it must be forwarded to every layer named in crossLayerIds -// instead of matched by layerId alone - Layer.js's own getHoverIds already -// narrows it down further, to just the ids belonging to that layer. export const getLayerFeatureHighlight = (feature, layerId) => feature && (feature.layerId === layerId || feature.crossLayerIds?.[layerId]) ? feature : null -// The crossLayerIds counterpart to each Layer instance's own -// handleFeatureUpdate/fitBounds - a crossLayerIds zoom has no single owning -// layerId, so several Layer instances would otherwise race independent -// fitBounds() calls. Map.jsx calls this once at the top level instead, with -// bounds precomputed by the caller (CombinedDataTable.jsx, via -// getUnionBounds) from every matching feature across every participating -// layer. export const fitCrossLayerZoomBounds = (map, feature, prevFeature) => { if (feature === prevFeature || !feature?.zoom || !feature.bounds) { return @@ -97,7 +83,7 @@ export const fitCrossLayerZoomBounds = (map, feature, prevFeature) => { }) } -//eslint-disable-next-line max-params +// eslint-disable-next-line max-params export const drillUpDown = (layerConfig, parentId, parentGraph, level) => ({ ...layerConfig, rows: [ diff --git a/src/util/spatialJoin.js b/src/util/spatialJoin.js index 8db83fdd92..def0baa60d 100644 --- a/src/util/spatialJoin.js +++ b/src/util/spatialJoin.js @@ -9,11 +9,6 @@ import { const isPolygon = (geometry) => [GEO_TYPE_POLYGON, GEO_TYPE_MULTIPOLYGON].includes(geometry?.type) -// A feature is tested as-is when it's already a point; when it isn't (e.g. -// an Event/TrackedEntity feature whose geometry happens to be a polygon) -// and useCentroid is set, its centroid stands in for it instead. Non-point -// geometry is otherwise left untestable (returns null) rather than -// silently matching on the wrong shape. const getTestPoint = (feature, useCentroid) => { if (feature.geometry?.type === GEO_TYPE_POINT) { return feature @@ -23,18 +18,6 @@ const getTestPoint = (feature, useCentroid) => { : null } -/** - * Matches each of `features` against whichever `referenceOrgUnits` feature's - * polygon geometry spatially contains it - used for a participating layer's - * "spatial join" against the Combined data table's reference org unit set - * (every reference org unit acts as one bucket, unlike the single fixed - * polygon layer the old point+polygon join used). - * - * @param {object[]} features - * @param {object[]} referenceOrgUnits - * @param {{ useCentroid?: boolean }} [options] - * @returns {Array<{ featureProps: object, referenceId: string|null }>} - */ export const matchFeaturesToReferenceOrgUnits = ( features, referenceOrgUnits, diff --git a/src/util/tableColumns.js b/src/util/tableColumns.js index 2c4ddd3702..3636f572f7 100644 --- a/src/util/tableColumns.js +++ b/src/util/tableColumns.js @@ -146,11 +146,7 @@ export const getColumnDistinctValues = (headers, data) => { return result } -// Cheap: just re-orders each column's already-known distinct-value list from -// getColumnDistinctValues into the {value} option list FilterInput expects. -// Kept separate from the expensive scan above so re-sorting doesn't force a -// re-scan - shared by DataTable.jsx and CombinedDataTable.jsx's filter -// popovers. +// Cheap: just re-orders each column's already-known distinct-value list export const sortColumnOptions = ( columnDistinctValues, { sortField, sortDirection } = {} From 6ff4ccffbc60ccacc20dec6dff0aee7825732396 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 30 Jul 2026 12:32:21 +0200 Subject: [PATCH 169/205] chore: dropdown clean-up --- src/components/datatable/BottomPanel.jsx | 2 +- src/components/datatable/styles/BottomPanel.module.css | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index 516983157a..86bfad0e9d 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -63,7 +63,7 @@ const BottomPanel = () => { ? manualActiveLayerId : openIds[openIds.length - 1] ?? null - const eligibleLayers = getEligibleDataTableLayers(mapViews) + const eligibleLayers = getEligibleDataTableLayers(mapViews).reverse() const { referenceLayer, openReferenceLayerEditor } = useReferenceLayer() const combinedEnabled = !!referenceLayer && getOrgUnitsFromRows(referenceLayer.rows).length > 0 diff --git a/src/components/datatable/styles/BottomPanel.module.css b/src/components/datatable/styles/BottomPanel.module.css index 840782afaa..c540061f95 100644 --- a/src/components/datatable/styles/BottomPanel.module.css +++ b/src/components/datatable/styles/BottomPanel.module.css @@ -63,3 +63,9 @@ flex: 0 1 auto; min-width: 0; } + +.layerSelect:focus { + outline: none; + border-color: var(--colors-blue600); + box-shadow: inset 0 0 0 2px var(--colors-blue600); +} From b83368943aed66b364da51d894b550740400a9d8 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 30 Jul 2026 13:32:58 +0200 Subject: [PATCH 170/205] fix: close data table filter popovers on Escape --- .../datatable/FilterDropdownPopover.jsx | 29 +++++++++++-------- src/components/datatable/FilterInput.jsx | 4 --- .../__tests__/ColumnPickerControl.spec.jsx | 10 +++++++ .../datatable/useGroupFilterInput.js | 4 --- src/components/layers/LayersPanel.jsx | 15 +--------- 5 files changed, 28 insertions(+), 34 deletions(-) diff --git a/src/components/datatable/FilterDropdownPopover.jsx b/src/components/datatable/FilterDropdownPopover.jsx index ae2f257c0c..676b95cddc 100644 --- a/src/components/datatable/FilterDropdownPopover.jsx +++ b/src/components/datatable/FilterDropdownPopover.jsx @@ -1,6 +1,7 @@ import { Layer, Popper } from '@dhis2/ui' import PropTypes from 'prop-types' import React from 'react' +import useKeyDown from '../../hooks/useKeyDown.js' const ESTIMATED_POPOVER_HEIGHT = 340 // Rough popover height used to flip the dropdown when there isn't room to open downward @@ -28,18 +29,22 @@ export const FilterDropdownPopover = ({ onClickOutside, className, children, -}) => ( - <Layer onBackdropClick={onClickOutside}> - <Popper - placement={placement} - reference={reference} - modifiers={dropdownModifiers} - className={className} - > - {children} - </Popper> - </Layer> -) +}) => { + useKeyDown('Escape', onClickOutside) + + return ( + <Layer onBackdropClick={onClickOutside}> + <Popper + placement={placement} + reference={reference} + modifiers={dropdownModifiers} + className={className} + > + {children} + </Popper> + </Layer> + ) +} FilterDropdownPopover.propTypes = { children: PropTypes.node.isRequired, diff --git a/src/components/datatable/FilterInput.jsx b/src/components/datatable/FilterInput.jsx index a6f7ce2259..187683a35a 100644 --- a/src/components/datatable/FilterInput.jsx +++ b/src/components/datatable/FilterInput.jsx @@ -289,10 +289,6 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({ onEnterKey() closePopover() break - case 'Escape': - event.preventDefault() - closePopover() - break default: break } diff --git a/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx b/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx index 12920fbd77..4a63469e27 100644 --- a/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx +++ b/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx @@ -68,6 +68,16 @@ describe('ColumnPicker trigger', () => { expect(screen.getByLabelText('Value')).toBeChecked() expect(screen.getByLabelText('Legend')).toBeChecked() }) + + test('pressing Escape closes the popover', () => { + renderColumnPicker() + openPicker() + expect(screen.getByLabelText('Name')).toBeInTheDocument() + + fireEvent.keyDown(window, { key: 'Escape' }) + + expect(screen.queryByLabelText('Name')).not.toBeInTheDocument() + }) }) describe('ColumnPicker visibility toggling', () => { diff --git a/src/components/datatable/useGroupFilterInput.js b/src/components/datatable/useGroupFilterInput.js index 4ccdc0d7ef..daf63e74a5 100644 --- a/src/components/datatable/useGroupFilterInput.js +++ b/src/components/datatable/useGroupFilterInput.js @@ -238,10 +238,6 @@ const useGroupFilterInput = ({ onEnterKey() closePopover() break - case 'Escape': - event.preventDefault() - closePopover() - break default: break } diff --git a/src/components/layers/LayersPanel.jsx b/src/components/layers/LayersPanel.jsx index b88bf02093..81e0d319f4 100644 --- a/src/components/layers/LayersPanel.jsx +++ b/src/components/layers/LayersPanel.jsx @@ -61,11 +61,6 @@ SortableLayer.propTypes = { layer: PropTypes.object.isRequired, } -// LAYER_SORT's reducer (reducers/map.js) computes positions against the -// full reversed mapViews, not the displayed/draggable list - which -// excludes the Combined data table's hidden reference org unit layer, if -// one exists. Looking indices up here instead keeps a present reference -// layer from throwing off every index after its position. export const getSortIndices = (reversedMapViews, activeId, overId) => ({ oldIndex: reversedMapViews.findIndex((l) => l.id === activeId), newIndex: reversedMapViews.findIndex((l) => l.id === overId), @@ -73,15 +68,7 @@ export const getSortIndices = (reversedMapViews, activeId, overId) => ({ const LayersPanel = () => { const layersPanelOpen = useSelector((state) => state.ui.layersPanelOpen) - // Reversed so the last map view (top layer) is shown first. The - // Combined data table's reference org unit layer is a hidden, - // non-rendered "ghost" - it never gets its own card, drag handle, or - // remove button here, so it's filtered out of the displayed/draggable - // list. reversedMapViews stays unfiltered - LAYER_SORT's reducer (see - // reducers/map.js) computes oldIndex/newIndex against the full reversed - // mapViews, so onDragEnd below must look indices up there, not in the - // filtered display list, or a present ghost layer would throw off every - // index after its position. + // Reversed so the last map view (top layer) is shown first const reversedMapViews = useSelector((state) => [...state.map.mapViews].reverse() ) From 2b286b266f40ecfd626933d874d8a0cb1a342cca Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 30 Jul 2026 14:02:44 +0200 Subject: [PATCH 171/205] fix: data table layer selector reliability fixes for Combined and no-data layers --- src/components/datatable/BottomPanel.jsx | 8 +++- .../datatable/__tests__/BottomPanel.spec.jsx | 30 +++++++++++- .../__tests__/DataTableButton.spec.jsx | 1 + .../controls/ReferenceOrgUnitControl.jsx | 10 ++-- .../ReferenceOrgUnitControl.spec.jsx | 46 +++++++++++++++++++ src/util/__tests__/dataTable.spec.js | 30 +++++++++--- src/util/dataTable.js | 2 +- 7 files changed, 115 insertions(+), 12 deletions(-) create mode 100644 src/components/datatable/controls/__tests__/ReferenceOrgUnitControl.spec.jsx diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index 86bfad0e9d..6d0b5f47eb 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -63,7 +63,13 @@ const BottomPanel = () => { ? manualActiveLayerId : openIds[openIds.length - 1] ?? null - const eligibleLayers = getEligibleDataTableLayers(mapViews).reverse() + const eligibleLayers = useMemo(() => { + const loaded = getEligibleDataTableLayers(mapViews).reverse() + const stillOpen = openIds + .map((id) => mapViews.find((l) => l.id === id)) + .filter((l) => l && !loaded.some((el) => el.id === l.id)) + return [...loaded, ...stillOpen] + }, [mapViews, openIds]) const { referenceLayer, openReferenceLayerEditor } = useReferenceLayer() const combinedEnabled = !!referenceLayer && getOrgUnitsFromRows(referenceLayer.rows).length > 0 diff --git a/src/components/datatable/__tests__/BottomPanel.spec.jsx b/src/components/datatable/__tests__/BottomPanel.spec.jsx index 1bfc92a259..f3bbc38d7b 100644 --- a/src/components/datatable/__tests__/BottomPanel.spec.jsx +++ b/src/components/datatable/__tests__/BottomPanel.spec.jsx @@ -37,7 +37,13 @@ const DEFAULT_DATA_TABLE_STATE = { } const DEFAULT_MAP_VIEWS = [ - { id: 'layer1', name: 'Layer 1', layer: THEMATIC_LAYER, data: [{}] }, + { + id: 'layer1', + name: 'Layer 1', + layer: THEMATIC_LAYER, + isLoaded: true, + data: [{}], + }, ] const referenceLayer = ( @@ -137,6 +143,7 @@ const twoEligibleLayers = [ name: 'Layer 1', combinedLayerKey: 'layer1', layer: THEMATIC_LAYER, + isLoaded: true, data: [{ properties: { orgUnitPath: '/country1/ou1' } }], }, { @@ -144,6 +151,7 @@ const twoEligibleLayers = [ name: 'Layer 2', combinedLayerKey: 'layer2', layer: THEMATIC_LAYER, + isLoaded: true, data: [{ properties: { orgUnitPath: '/country1/ou2' } }], }, ] @@ -162,6 +170,26 @@ describe('BottomPanel layer selector', () => { expect(screen.getByText('Combined')).toBeInTheDocument() }) + test('keeps an already-open tab listed even if its config is edited into a state where isLoaded gets stuck false', () => { + const stuckLayer = { + id: 'layer1', + name: 'Layer 1', + layer: THEMATIC_LAYER, + isLoaded: false, + data: [], + } + renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1'], + }, + mapViews: [stuckLayer, twoEligibleLayers[1]], + }) + + expect(screen.getByText('Layer 1')).toBeInTheDocument() + expect(screen.getByText('Layer 2')).toBeInTheDocument() + }) + test('selecting a different, already-open layer switches the active layer shown in the table', () => { renderBottomPanel({ dataTable: { diff --git a/src/components/datatable/__tests__/DataTableButton.spec.jsx b/src/components/datatable/__tests__/DataTableButton.spec.jsx index 87ac6757d8..a7d082c6f3 100644 --- a/src/components/datatable/__tests__/DataTableButton.spec.jsx +++ b/src/components/datatable/__tests__/DataTableButton.spec.jsx @@ -11,6 +11,7 @@ const layer = (id, overrides = {}) => ({ id, name: id, layer: THEMATIC_LAYER, + isLoaded: true, data: [{}], ...overrides, }) diff --git a/src/components/datatable/controls/ReferenceOrgUnitControl.jsx b/src/components/datatable/controls/ReferenceOrgUnitControl.jsx index 0f0307b460..ae78206ac8 100644 --- a/src/components/datatable/controls/ReferenceOrgUnitControl.jsx +++ b/src/components/datatable/controls/ReferenceOrgUnitControl.jsx @@ -1,21 +1,25 @@ import i18n from '@dhis2/d2-i18n' import { IconDimensionOrgUnit16 } from '@dhis2/ui' import React from 'react' -import { useDispatch, useSelector } from 'react-redux' +import { useDispatch, useSelector, useStore } from 'react-redux' import { editLayer } from '../../../actions/layers.js' import { COMBINED_TABLE_REF_LAYER } from '../../../constants/layers.js' import ToolbarIconButton from './ToolbarIconButton.jsx' +const findReferenceLayer = (mapViews) => + mapViews.find((l) => l.layer === COMBINED_TABLE_REF_LAYER) + export const useReferenceLayer = () => { const dispatch = useDispatch() + const store = useStore() const referenceLayer = useSelector((state) => - state.map.mapViews.find((l) => l.layer === COMBINED_TABLE_REF_LAYER) + findReferenceLayer(state.map.mapViews) ) const openReferenceLayerEditor = () => dispatch( editLayer( - referenceLayer ?? { + findReferenceLayer(store.getState().map.mapViews) ?? { layer: COMBINED_TABLE_REF_LAYER, isVisible: false, rows: [], diff --git a/src/components/datatable/controls/__tests__/ReferenceOrgUnitControl.spec.jsx b/src/components/datatable/controls/__tests__/ReferenceOrgUnitControl.spec.jsx new file mode 100644 index 0000000000..113645f1ea --- /dev/null +++ b/src/components/datatable/controls/__tests__/ReferenceOrgUnitControl.spec.jsx @@ -0,0 +1,46 @@ +import { render, fireEvent, screen } from '@testing-library/react' +import React from 'react' +import { Provider, useDispatch } from 'react-redux' +import { createStore } from 'redux' +import { toggleCombinedView } from '../../../../actions/dataTable.js' +import { COMBINED_TABLE_REF_LAYER } from '../../../../constants/layers.js' +import rootReducer from '../../../../reducers/index.js' +import { useReferenceLayer } from '../ReferenceOrgUnitControl.jsx' + +const SelectCombinedHarness = () => { + const dispatch = useDispatch() + const { openReferenceLayerEditor } = useReferenceLayer() + + return ( + <button + onClick={() => { + dispatch(toggleCombinedView()) + openReferenceLayerEditor() + }} + > + Select Combined + </button> + ) +} + +describe('useReferenceLayer', () => { + test('opens the editor for the placeholder reference layer just created by toggleCombinedView, not a second orphaned one', () => { + const store = createStore(rootReducer) + + render( + <Provider store={store}> + <SelectCombinedHarness /> + </Provider> + ) + + fireEvent.click(screen.getByText('Select Combined')) + + const referenceLayers = store + .getState() + .map.mapViews.filter((l) => l.layer === COMBINED_TABLE_REF_LAYER) + + expect(referenceLayers).toHaveLength(1) + expect(referenceLayers[0].id).toBeDefined() + expect(store.getState().layerEdit.id).toBe(referenceLayers[0].id) + }) +}) diff --git a/src/util/__tests__/dataTable.spec.js b/src/util/__tests__/dataTable.spec.js index 27c93cff4c..cb13287579 100644 --- a/src/util/__tests__/dataTable.spec.js +++ b/src/util/__tests__/dataTable.spec.js @@ -267,10 +267,15 @@ describe('buildFeatureIndex', () => { }) describe('getEligibleDataTableLayers', () => { - test('includes data-table-capable layer types that have loaded data', () => { + test('includes data-table-capable layer types that have finished loading', () => { const mapViews = [ - { id: 'a', layer: THEMATIC_LAYER, data: [{}] }, - { id: 'b', layer: THEMATIC_LAYER, data: [{}, {}] }, + { id: 'a', layer: THEMATIC_LAYER, isLoaded: true, data: [{}] }, + { + id: 'b', + layer: THEMATIC_LAYER, + isLoaded: true, + data: [{}, {}], + }, ] expect(getEligibleDataTableLayers(mapViews).map((l) => l.id)).toEqual([ 'a', @@ -279,14 +284,27 @@ describe('getEligibleDataTableLayers', () => { }) test('excludes layer types with no data table support', () => { - const mapViews = [{ id: 'a', layer: EXTERNAL_LAYER, data: [{}] }] + const mapViews = [ + { id: 'a', layer: EXTERNAL_LAYER, isLoaded: true, data: [{}] }, + ] expect(getEligibleDataTableLayers(mapViews)).toEqual([]) }) - test('excludes a data-table-capable layer with no loaded data', () => { - const mapViews = [{ id: 'a', layer: THEMATIC_LAYER, data: [] }] + test('excludes a data-table-capable layer that has not finished loading yet', () => { + const mapViews = [ + { id: 'a', layer: THEMATIC_LAYER, isLoaded: false, data: [{}] }, + ] expect(getEligibleDataTableLayers(mapViews)).toEqual([]) }) + + test('includes a loaded, data-table-capable layer with no valid data - the caller shows an explanatory message instead of hiding it', () => { + const mapViews = [ + { id: 'a', layer: THEMATIC_LAYER, isLoaded: true, data: [] }, + ] + expect(getEligibleDataTableLayers(mapViews).map((l) => l.id)).toEqual([ + 'a', + ]) + }) }) describe('isDataTableOpen', () => { diff --git a/src/util/dataTable.js b/src/util/dataTable.js index c86eeb21a4..8d806ddd6a 100644 --- a/src/util/dataTable.js +++ b/src/util/dataTable.js @@ -107,7 +107,7 @@ export const isDataTableOpen = ({ openIds, combinedView }) => export const getEligibleDataTableLayers = (mapViews) => mapViews.filter( - (l) => DATA_TABLE_LAYER_TYPES.includes(l.layer) && l.data?.length + (l) => DATA_TABLE_LAYER_TYPES.includes(l.layer) && l.isLoaded ) export const getLayerSelectedIds = (selection, layerId) => { From ab6fa1d22e40ae49542222e526e00df1d2396668 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 30 Jul 2026 14:27:27 +0200 Subject: [PATCH 172/205] fix: hide technical id/level/geometry columns by default, lowercase "Org unit id" --- .../__tests__/CombinedDataTable.spec.jsx | 37 ++++++++++++++++--- .../__tests__/useCombinedTableData.spec.js | 2 +- .../datatable/__tests__/useTableData.spec.jsx | 18 ++++----- .../datatable/useCombinedTableData.js | 8 +++- src/util/__tests__/tableHeaders.spec.js | 16 ++++++++ src/util/tableHeaders.js | 24 +++++++++--- 6 files changed, 82 insertions(+), 23 deletions(-) diff --git a/src/components/datatable/__tests__/CombinedDataTable.spec.jsx b/src/components/datatable/__tests__/CombinedDataTable.spec.jsx index 04d8bfaf08..868c01549d 100644 --- a/src/components/datatable/__tests__/CombinedDataTable.spec.jsx +++ b/src/components/datatable/__tests__/CombinedDataTable.spec.jsx @@ -83,9 +83,12 @@ describe('CombinedDataTable', () => { }, }, }, + columnConfig: { + visibleKeys: ['id', 'name', 'layerA_rawValue', 'layerA_legend'], + }, }) - expect(screen.getByText('Org unit Id')).toBeInTheDocument() + expect(screen.getByText('Org unit id')).toBeInTheDocument() expect(screen.getByText('Org unit')).toBeInTheDocument() expect(screen.getByText('Value (Layer A)')).toBeInTheDocument() expect(screen.getByText('Legend (Layer A)')).toBeInTheDocument() @@ -273,7 +276,7 @@ describe('CombinedDataTable', () => { }) const rowsBefore = screen.getAllByRole('row').slice(1) - expect(rowsBefore[0]).toHaveTextContent('ou1') + expect(rowsBefore[0]).toHaveTextContent('Ou One') fireEvent.click( screen.getByTestId( @@ -282,7 +285,7 @@ describe('CombinedDataTable', () => { ) const rowsAfter = screen.getAllByRole('row').slice(1) - expect(rowsAfter[0]).toHaveTextContent('ou2') + expect(rowsAfter[0]).toHaveTextContent('Ou Two') }) test('applies a per-column filter via onFiltersChange', () => { @@ -299,10 +302,11 @@ describe('CombinedDataTable', () => { referenceLayer, filters: {}, onFiltersChange, + columnConfig: { visibleKeys: ['id', 'name'] }, }) const input = screen - .getByTestId('data-table-column-filter-search-Org unit Id') + .getByTestId('data-table-column-filter-search-Org unit id') .querySelector('input') fireEvent.focus(input) fireEvent.change(input, { target: { value: 'ou1' } }) @@ -569,11 +573,24 @@ describe('CombinedDataTable', () => { columnConfig: { visibleKeys: ['id', 'name'] }, }) - expect(screen.getByText('Org unit Id')).toBeInTheDocument() + expect(screen.getByText('Org unit id')).toBeInTheDocument() expect(screen.queryByText('Value (Layer A)')).not.toBeInTheDocument() expect(screen.queryByText('20')).not.toBeInTheDocument() }) + test('hides Org unit id and Org unit level by default when no columnConfig is given', () => { + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [referenceFeature('ou1', 'Ou One', '/country1/ou1')], + } + + renderCombinedDataTable({ referenceLayer }) + + expect(screen.getByText('Org unit')).toBeInTheDocument() + expect(screen.queryByText('Org unit id')).not.toBeInTheDocument() + expect(screen.queryByText('Org unit level')).not.toBeInTheDocument() + }) + test('reorders columns to put pinned keys first via columnConfig.pinnedKeys', () => { const referenceLayer = { ...EMPTY_REFERENCE_LAYER, @@ -599,7 +616,15 @@ describe('CombinedDataTable', () => { }, }, }, - columnConfig: { pinnedKeys: ['level'] }, + columnConfig: { + pinnedKeys: ['level'], + visibleKeys: [ + 'level', + 'name', + 'layerA_rawValue', + 'layerA_legend', + ], + }, }) const headerNames = screen diff --git a/src/components/datatable/__tests__/useCombinedTableData.spec.js b/src/components/datatable/__tests__/useCombinedTableData.spec.js index c942ebffa9..adc5eb2b71 100644 --- a/src/components/datatable/__tests__/useCombinedTableData.spec.js +++ b/src/components/datatable/__tests__/useCombinedTableData.spec.js @@ -58,7 +58,7 @@ describe('useCombinedTableData - org unit join', () => { 'layerA_rawValue', 'layerA_legend', ]) - expect(result.current.headers[0].name).toBe('Org unit Id') + expect(result.current.headers[0].name).toBe('Org unit id') expect(result.current.headers[1].name).toBe('Org unit') expect(result.current.rows).toHaveLength(2) diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index 83d85a83e7..50f9aa3368 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -63,7 +63,7 @@ describe('useTableData headers', () => { const { headers, rows, isLoading } = result.current expect(headers).toHaveLength(5) expect(headers).toMatchObject([ - { name: 'Org unit Id', dataKey: 'id', type: 'string' }, + { name: 'Org unit id', dataKey: 'id', type: 'string' }, { name: 'Org unit', dataKey: 'orgUnitOwn', type: 'string' }, { name: 'Org unit level', dataKey: 'level', type: 'number' }, { @@ -221,7 +221,7 @@ describe('useTableData headers', () => { const { headers, rows, isLoading } = result.current expect(headers).toHaveLength(5) expect(headers).toMatchObject([ - { name: 'Org unit Id', dataKey: 'id', type: 'string' }, + { name: 'Org unit id', dataKey: 'id', type: 'string' }, { name: 'Org unit', dataKey: 'orgUnitOwn', type: 'string' }, { name: 'Org unit level', dataKey: 'level', type: 'number' }, { @@ -283,7 +283,7 @@ describe('useTableData headers', () => { const { headers, rows, isLoading } = result.current expect(headers).toHaveLength(9) expect(headers).toMatchObject([ - { name: 'Org unit Id', dataKey: 'id', type: 'string' }, + { name: 'Org unit id', dataKey: 'id', type: 'string' }, { name: 'Org unit', dataKey: 'orgUnitOwn', type: 'string' }, { name: 'Org unit level', dataKey: 'level', type: 'number' }, { @@ -369,7 +369,7 @@ describe('useTableData headers', () => { ) const { headers, rows } = result.current expect(headers).toMatchObject([ - { name: 'Org unit Id', dataKey: 'id' }, + { name: 'Org unit id', dataKey: 'id' }, { name: 'Org unit', dataKey: 'orgUnitOwn' }, { name: 'Org unit level', dataKey: 'level' }, { name: 'Org unit hierarchy', dataKey: 'orgUnitPath' }, @@ -486,7 +486,7 @@ describe('useTableData headers', () => { ) const { headers, rows } = result.current expect(headers).toMatchObject([ - { name: 'Org unit Id', dataKey: 'id' }, + { name: 'Org unit id', dataKey: 'id' }, { name: 'Org unit', dataKey: 'orgUnitOwn' }, { name: 'Org unit level', dataKey: 'level' }, { name: 'Org unit hierarchy', dataKey: 'orgUnitPath' }, @@ -578,7 +578,7 @@ describe('useTableData headers', () => { expect(headers).toHaveLength(10) expect(headers).toMatchObject([ { name: 'Event Id', dataKey: 'id', type: 'string' }, - { name: 'Org unit Id', dataKey: 'orgUnitId', type: 'string' }, + { name: 'Org unit id', dataKey: 'orgUnitId', type: 'string' }, { name: 'Org unit', dataKey: 'orgUnitOwn', type: 'string' }, { name: 'Org unit level', dataKey: 'level', type: 'number' }, { @@ -724,7 +724,7 @@ describe('useTableData headers', () => { expect(headers).toHaveLength(11) expect(headers).toMatchObject([ { name: 'Tracked entity Id', dataKey: 'id', type: 'string' }, - { name: 'Org unit Id', dataKey: 'orgUnitId', type: 'string' }, + { name: 'Org unit id', dataKey: 'orgUnitId', type: 'string' }, { name: 'Org unit', dataKey: 'orgUnitOwn', type: 'string' }, { name: 'Org unit level', dataKey: 'level', type: 'number' }, { @@ -1094,7 +1094,7 @@ describe('useTableData headers', () => { expect(headers).toHaveLength(7) expect(headers).toMatchObject([ - { name: 'Org unit Id', dataKey: 'id', type: 'string' }, + { name: 'Org unit id', dataKey: 'id', type: 'string' }, { name: 'Org unit', dataKey: 'orgUnitOwn', type: 'string' }, { name: 'Org unit level', dataKey: 'level', type: 'number' }, { @@ -1257,7 +1257,7 @@ describe('useTableData headers', () => { expect(headers).toHaveLength(7) expect(headers).toMatchObject([ - { name: 'Org unit Id', dataKey: 'id', type: 'string' }, + { name: 'Org unit id', dataKey: 'id', type: 'string' }, { name: 'Org unit', dataKey: 'orgUnitOwn', type: 'string' }, { name: 'Org unit level', dataKey: 'level', type: 'number' }, { diff --git a/src/components/datatable/useCombinedTableData.js b/src/components/datatable/useCombinedTableData.js index 2e70ef007e..67b60e745e 100644 --- a/src/components/datatable/useCombinedTableData.js +++ b/src/components/datatable/useCombinedTableData.js @@ -266,12 +266,18 @@ export const useCombinedTableData = ({ ) const headers = [ - { name: i18n.t('Org unit Id'), dataKey: 'id', type: TYPE_STRING }, + { + name: i18n.t('Org unit id'), + dataKey: 'id', + type: TYPE_STRING, + defaultHidden: true, + }, { name: i18n.t('Org unit'), dataKey: 'name', type: TYPE_STRING }, { name: i18n.t('Org unit level'), dataKey: 'level', type: TYPE_NUMBER, + defaultHidden: true, }, ...layerMatches.flatMap(({ layer, valueDataKeys }) => [ ...valueDataKeys.map(({ dataKey, name }) => ({ diff --git a/src/util/__tests__/tableHeaders.spec.js b/src/util/__tests__/tableHeaders.spec.js index a8d175a452..299d77e4df 100644 --- a/src/util/__tests__/tableHeaders.spec.js +++ b/src/util/__tests__/tableHeaders.spec.js @@ -27,6 +27,22 @@ jest.mock('../../components/map/MapApi.js', () => ({ })) const dataKeys = (result) => result.headers.map((h) => h.dataKey) +const defaultHiddenKeys = (result) => + result.headers.filter((h) => h.defaultHidden).map((h) => h.dataKey) + +describe('getHeadersForLayer - defaultHidden', () => { + test('Id, Org unit id, Org unit level, and Geometry type are hidden by default; Org unit and Org unit hierarchy are not', () => { + const result = getHeadersForLayer(THEMATIC_LAYER, { + isMultiPeriodThematic: false, + }) + expect(defaultHiddenKeys(result)).toEqual( + expect.arrayContaining(['id', 'level', 'type']) + ) + expect(defaultHiddenKeys(result)).not.toEqual( + expect.arrayContaining(['orgUnitOwn', 'orgUnitPath']) + ) + }) +}) describe('getHeadersForLayer - thematic', () => { test('single-period: fixed fields plus legend/range/color', () => { diff --git a/src/util/tableHeaders.js b/src/util/tableHeaders.js index de376360b4..d556fca40f 100644 --- a/src/util/tableHeaders.js +++ b/src/util/tableHeaders.js @@ -96,11 +96,17 @@ const ORG_UNIT_ID = ORG_UNIT_ID_DATA_KEY export const ERROR_NON_HOMOGENOUS_FEATURES = 'NON_HOMOGENOUS_FEATURES' const defaultFieldsMap = () => ({ - [ID]: { name: i18n.t('Id'), dataKey: ID, type: TYPE_STRING }, + [ID]: { + name: i18n.t('Id'), + dataKey: ID, + type: TYPE_STRING, + defaultHidden: true, + }, [ORG_UNIT_ID]: { - name: i18n.t('Org unit Id'), + name: i18n.t('Org unit id'), dataKey: ORG_UNIT_ID, type: TYPE_STRING, + defaultHidden: true, }, [ORG_UNIT]: { name: i18n.t('Org unit'), @@ -112,8 +118,14 @@ const defaultFieldsMap = () => ({ name: i18n.t('Org unit level'), dataKey: LEVEL, type: TYPE_NUMBER, + defaultHidden: true, + }, + [TYPE]: { + name: i18n.t('Geometry type'), + dataKey: TYPE, + type: TYPE_STRING, + defaultHidden: true, }, - [TYPE]: { name: i18n.t('Geometry type'), dataKey: TYPE, type: TYPE_STRING }, [VALUE]: { name: i18n.t('Value'), dataKey: VALUE, type: TYPE_NUMBER }, [LEGEND]: { name: i18n.t('Legend'), dataKey: LEGEND, type: TYPE_STRING }, [RANGE]: { name: i18n.t('Range'), dataKey: RANGE, type: TYPE_STRING }, @@ -204,7 +216,7 @@ const getStyleHeaders = ({ } const getThematicHeaders = () => - getOrgUnitCoreFields(i18n.t('Org unit Id')) + getOrgUnitCoreFields(i18n.t('Org unit id')) .concat(defaultFieldsMap()[VALUE]) .concat( getStyleHeaders({ hasLegend: true, hasRange: true, hasColor: true }) @@ -307,7 +319,7 @@ const getOrgUnitStyleHeaders = (data) => { } const getFixedFieldsWithOrgUnitStyle = (data) => - getOrgUnitCoreFields(i18n.t('Org unit Id')) + getOrgUnitCoreFields(i18n.t('Org unit id')) .concat(getOrgUnitStyleHeaders(data)) .concat(defaultFieldsMap()[TYPE]) @@ -374,7 +386,7 @@ const getEarthEngineHeaders = ({ aggregationType, legend, data }) => { }) } - return getOrgUnitCoreFields(i18n.t('Org unit Id')) + return getOrgUnitCoreFields(i18n.t('Org unit id')) .concat(customFields) .concat(defaultFieldsMap()[TYPE]) } From 86252472a2e9a72527407eb9ac9af932802ace99 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 30 Jul 2026 16:19:06 +0200 Subject: [PATCH 173/205] feat: default Combined aggregation/org units intelligently, warn on lossy joins --- i18n/en.pot | 8 +- src/components/datatable/BottomPanel.jsx | 1 + .../__tests__/JoinLayersControl.spec.jsx | 227 +++++++++++++++- .../datatable/controls/JoinLayersControl.jsx | 256 ++++++++++++------ .../controls/ReferenceOrgUnitControl.jsx | 9 +- .../styles/JoinLayersControl.module.css | 7 + .../datatable/useCombinedTableData.js | 103 ++----- .../edit/thematic/ThematicDialog.jsx | 15 +- ...temLegendSet.js => useDataItemMetadata.js} | 43 ++- src/reducers/__tests__/map.spec.js | 56 +++- src/reducers/map.js | 3 +- src/util/__tests__/aggregation.spec.js | 81 +++++- src/util/__tests__/analytics.spec.js | 11 + src/util/__tests__/combinedJoinMatch.spec.js | 168 ++++++++++++ src/util/__tests__/dataTable.spec.js | 179 ++++++++++++ src/util/aggregation.js | 46 +++- src/util/analytics.js | 1 + src/util/combinedJoinMatch.js | 103 +++++++ src/util/dataTable.js | 110 +++++++- 19 files changed, 1217 insertions(+), 210 deletions(-) rename src/hooks/{useDataItemLegendSet.js => useDataItemMetadata.js} (52%) create mode 100644 src/util/__tests__/combinedJoinMatch.spec.js create mode 100644 src/util/combinedJoinMatch.js diff --git a/i18n/en.pot b/i18n/en.pot index fbc0e7448f..16489d58f4 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-30T10:04:16.069Z\n" -"PO-Revision-Date: 2026-07-30T10:04:16.069Z\n" +"POT-Creation-Date: 2026-07-30T12:27:53.587Z\n" +"PO-Revision-Date: 2026-07-30T12:27:53.587Z\n" msgid "2020" msgstr "2020" @@ -373,8 +373,8 @@ msgstr "{{total}} rows" msgid "Show only features in current map view" msgstr "Show only features in current map view" -msgid "Org unit Id" -msgstr "Org unit Id" +msgid "Org unit id" +msgstr "Org unit id" msgid "Org unit level" msgstr "Org unit level" diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index 6d0b5f47eb..60605ab4ae 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -317,6 +317,7 @@ const BottomPanel = () => { <JoinLayersControl eligibleLayers={eligibleLayers} layersConfig={joinLayersConfig} + referenceLayer={referenceLayer} onChange={(layers) => dispatch( setJoinConfig(referenceLayer.id, layers) diff --git a/src/components/datatable/__tests__/JoinLayersControl.spec.jsx b/src/components/datatable/__tests__/JoinLayersControl.spec.jsx index 2a5b2a1282..be28e9b627 100644 --- a/src/components/datatable/__tests__/JoinLayersControl.spec.jsx +++ b/src/components/datatable/__tests__/JoinLayersControl.spec.jsx @@ -99,6 +99,45 @@ describe('JoinLayersControl popover — checkbox list', () => { }) }) + test("checking a layer defaults its aggregation to the data item's own aggregation type, not SUM", () => { + const onChange = jest.fn() + renderControl({ + eligibleLayers: [ + { + id: 'layer3', + name: 'Layer 3', + combinedLayerKey: 'layer3', + layer: THEMATIC_LAYER, + data: [{ properties: { orgUnitPath: '/country1/ou1' } }], + columns: [ + { + dimension: 'dx', + items: [ + { + id: 'de1', + name: 'DE 1', + aggregationType: 'AVERAGE', + }, + ], + }, + ], + }, + ], + layersConfig: {}, + onChange, + }) + openPicker() + + fireEvent.click(screen.getByRole('checkbox', { name: 'Layer 3' })) + + expect(onChange).toHaveBeenCalledWith({ + layer3: { + type: 'orgUnit', + aggregation: { rawValue: 'AVERAGE' }, + }, + }) + }) + test('checking a layer with no org-unit identity of its own defaults to Spatial join, not Org unit', () => { const onChange = jest.fn() renderControl({ @@ -246,7 +285,7 @@ describe('JoinLayersControl popover — per-layer type/aggregation settings', () }) }) - test('shows one labeled aggregation select per Earth Engine stat, and checking it defaults every stat to SUM', () => { + test("shows one labeled aggregation select per Earth Engine stat, and checking it defaults every stat to the first selected stat's own equivalent", () => { const onChange = jest.fn() const eeLayer = { id: 'ee', @@ -269,7 +308,7 @@ describe('JoinLayersControl popover — per-layer type/aggregation settings', () expect(onChange).toHaveBeenCalledWith({ ee: { type: 'orgUnit', - aggregation: { mean: 'SUM', max: 'SUM' }, + aggregation: { mean: 'AVERAGE', max: 'AVERAGE' }, }, }) }) @@ -317,3 +356,187 @@ describe('JoinLayersControl popover — per-layer type/aggregation settings', () }) }) }) + +describe('JoinLayersControl popover — aggregation rollup warning', () => { + const referenceLayer = { + data: [ + { + properties: { + id: 'ref1', + name: 'Ref 1', + orgUnitPath: '/country1/ref1', + level: 2, + }, + }, + ], + } + + const rollupLayer = { + id: 'layer1', + name: 'Layer 1', + combinedLayerKey: 'layer1', + layer: THEMATIC_LAYER, + data: [ + { properties: { orgUnitPath: '/country1/ref1/child1' } }, + { properties: { orgUnitPath: '/country1/ref1/child2' } }, + ], + } + + const noRollupLayer = { + id: 'layer1', + name: 'Layer 1', + combinedLayerKey: 'layer1', + layer: THEMATIC_LAYER, + data: [{ properties: { orgUnitPath: '/country1/ref1' } }], + } + + const getWarning = () => + screen.queryByTestId( + 'data-table-join-aggregation-warning-layer1-rawValue' + ) + + test('shows a warning when the layer rolls up into the reference and the aggregation is non-composable (AVERAGE)', () => { + renderControl({ + eligibleLayers: [rollupLayer], + referenceLayer, + layersConfig: { + layer1: { + type: 'orgUnit', + aggregation: { rawValue: 'AVERAGE' }, + }, + }, + }) + openPicker() + + expect(getWarning()).toBeInTheDocument() + }) + + test('does not show a warning when the layer rolls up but the aggregation is composable (SUM)', () => { + renderControl({ + eligibleLayers: [rollupLayer], + referenceLayer, + layersConfig: { + layer1: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + }) + openPicker() + + expect(getWarning()).not.toBeInTheDocument() + }) + + test('does not show a warning when the aggregation is non-composable but every reference org unit matches at most one feature', () => { + renderControl({ + eligibleLayers: [noRollupLayer], + referenceLayer, + layersConfig: { + layer1: { + type: 'orgUnit', + aggregation: { rawValue: 'AVERAGE' }, + }, + }, + }) + openPicker() + + expect(getWarning()).not.toBeInTheDocument() + }) + + test('does not show a warning when no reference layer is available yet', () => { + renderControl({ + eligibleLayers: [rollupLayer], + referenceLayer: undefined, + layersConfig: { + layer1: { + type: 'orgUnit', + aggregation: { rawValue: 'AVERAGE' }, + }, + }, + }) + openPicker() + + expect(getWarning()).not.toBeInTheDocument() + }) +}) + +describe('JoinLayersControl popover — unmatched features warning', () => { + const referenceLayer = { + data: [ + { + properties: { + id: 'ref1', + name: 'Ref 1', + orgUnitPath: '/country1/ref1', + level: 2, + }, + }, + ], + } + + const getWarning = () => + screen.queryByTestId('data-table-join-unmatched-warning-layer1') + + test('shows a warning when some of the layer features could not be matched to any reference org unit', () => { + renderControl({ + eligibleLayers: [ + { + id: 'layer1', + name: 'Layer 1', + combinedLayerKey: 'layer1', + layer: THEMATIC_LAYER, + data: [ + { properties: { orgUnitPath: '/country1/ref1' } }, + { properties: { orgUnitPath: '/country2/other' } }, + ], + }, + ], + referenceLayer, + layersConfig: { + layer1: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + }) + openPicker() + + expect(getWarning()).toBeInTheDocument() + }) + + test('does not show a warning when every layer feature matches a reference org unit', () => { + renderControl({ + eligibleLayers: [ + { + id: 'layer1', + name: 'Layer 1', + combinedLayerKey: 'layer1', + layer: THEMATIC_LAYER, + data: [{ properties: { orgUnitPath: '/country1/ref1' } }], + }, + ], + referenceLayer, + layersConfig: { + layer1: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + }) + openPicker() + + expect(getWarning()).not.toBeInTheDocument() + }) + + test('does not show a warning when no reference layer is available yet', () => { + renderControl({ + eligibleLayers: [ + { + id: 'layer1', + name: 'Layer 1', + combinedLayerKey: 'layer1', + layer: THEMATIC_LAYER, + data: [{ properties: { orgUnitPath: '/country2/other' } }], + }, + ], + referenceLayer: undefined, + layersConfig: { + layer1: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + }) + openPicker() + + expect(getWarning()).not.toBeInTheDocument() + }) +}) diff --git a/src/components/datatable/controls/JoinLayersControl.jsx b/src/components/datatable/controls/JoinLayersControl.jsx index b3c78d1a22..699eea4d88 100644 --- a/src/components/datatable/controls/JoinLayersControl.jsx +++ b/src/components/datatable/controls/JoinLayersControl.jsx @@ -1,9 +1,18 @@ import i18n from '@dhis2/d2-i18n' +import { IconWarningFilled16, Tooltip } from '@dhis2/ui' import PropTypes from 'prop-types' import React, { useRef, useState } from 'react' import { getCombinedAggregationTypes } from '../../../constants/aggregationTypes.js' import { ORG_UNIT_PATH_DATA_KEY } from '../../../constants/dataTable.js' -import { getCombinedValueDataKeys } from '../../../util/dataTable.js' +import { NON_COMPOSABLE_AGGREGATION_TYPES } from '../../../util/aggregation.js' +import { + getUnmatchedFeatureCount, + hasCombinedRollup, +} from '../../../util/combinedJoinMatch.js' +import { + getCombinedValueDataKeys, + getDefaultCombinedAggregation, +} from '../../../util/dataTable.js' import { GEO_TYPE_POINT, GEO_TYPE_POLYGON, @@ -29,12 +38,15 @@ const hasOrgUnitIdentity = (layer) => { const getDefaultSettings = (layer) => ({ type: hasOrgUnitIdentity(layer) ? 'orgUnit' : 'spatial', - aggregation: Object.fromEntries( - getCombinedValueDataKeys(layer).map(({ dataKey }) => [dataKey, 'SUM']) - ), + aggregation: getDefaultCombinedAggregation(layer), }) -const JoinLayersControl = ({ eligibleLayers, layersConfig, onChange }) => { +const JoinLayersControl = ({ + eligibleLayers, + layersConfig, + onChange, + referenceLayer, +}) => { const anchorRef = useRef(null) const [isOpen, setIsOpen] = useState(false) const aggregationTypes = getCombinedAggregationTypes() @@ -90,6 +102,22 @@ const JoinLayersControl = ({ eligibleLayers, layersConfig, onChange }) => { {eligibleLayers.map((layer) => { const settings = layersConfig[layer.combinedLayerKey] + const defaultAggregation = + getDefaultCombinedAggregation(layer) + const hasRollup = + !!settings && + hasCombinedRollup( + layer, + referenceLayer, + settings.type + ) + const unmatchedCount = settings + ? getUnmatchedFeatureCount( + layer, + referenceLayer, + settings.type + ) + : 0 return ( <div key={layer.id} @@ -112,99 +140,160 @@ const JoinLayersControl = ({ eligibleLayers, layersConfig, onChange }) => { <div className={styles.layerSettings} > - <select - aria-label={i18n.t( - 'Join type for {{layer}}', - { layer: layer.name } - )} - value={settings.type} - onChange={(e) => - onTypeChange( - layer.combinedLayerKey, - e.target.value - ) + <div + className={ + styles.aggregationRow } > - <option value="orgUnit"> - {i18n.t('Org unit')} - </option> - {isSpatialEligible( - layer - ) && ( - <option value="spatial"> - {i18n.t('Spatial')} - </option> - )} - </select> - {getCombinedValueDataKeys( - layer - ).map(({ dataKey, name }) => ( - <div - key={dataKey} - className={ - styles.aggregationRow + <select + aria-label={i18n.t( + 'Join type for {{layer}}', + { + layer: layer.name, + } + )} + value={settings.type} + onChange={(e) => + onTypeChange( + layer.combinedLayerKey, + e.target.value + ) } > - {name && ( + <option value="orgUnit"> + {i18n.t('Org unit')} + </option> + {isSpatialEligible( + layer + ) && ( + <option value="spatial"> + {i18n.t( + 'Spatial' + )} + </option> + )} + </select> + {unmatchedCount > 0 && ( + <Tooltip + content={i18n.t( + '{{count}} feature(s) from {{layer}} could not be matched to a reference org unit (wrong level, no matching parent, or outside every boundary) and will be excluded from the Combined table.', + { + count: unmatchedCount, + layer: layer.name, + } + )} + > <span className={ - styles.aggregationRowLabel + styles.aggregationWarning } + data-test={`data-table-join-unmatched-warning-${layer.id}`} > - {name} + <IconWarningFilled16 /> </span> - )} - <select - aria-label={ - name - ? i18n.t( - 'Aggregation type for {{name}} ({{layer}})', - { - name, - layer: layer.name, - } - ) - : i18n.t( - 'Aggregation type for {{layer}}', - { - layer: layer.name, - } - ) - } - value={ - settings - .aggregation?.[ - dataKey - ] ?? 'SUM' - } - onChange={(e) => - onAggregationChange( - layer.combinedLayerKey, - dataKey, - e.target - .value - ) + </Tooltip> + )} + </div> + {getCombinedValueDataKeys( + layer + ).map(({ dataKey, name }) => { + const effectiveType = + settings.aggregation?.[ + dataKey + ] ?? + defaultAggregation[ + dataKey + ] + const showWarning = + hasRollup && + NON_COMPOSABLE_AGGREGATION_TYPES.has( + effectiveType + ) + return ( + <div + key={dataKey} + className={ + styles.aggregationRow } > - {aggregationTypes.map( - (type) => ( - <option - key={ - type.id + {name && ( + <span + className={ + styles.aggregationRowLabel + } + > + {name} + </span> + )} + <select + aria-label={ + name + ? i18n.t( + 'Aggregation type for {{name}} ({{layer}})', + { + name, + layer: layer.name, + } + ) + : i18n.t( + 'Aggregation type for {{layer}}', + { + layer: layer.name, + } + ) + } + value={ + effectiveType + } + onChange={(e) => + onAggregationChange( + layer.combinedLayerKey, + dataKey, + e.target + .value + ) + } + > + {aggregationTypes.map( + (type) => ( + <option + key={ + type.id + } + value={ + type.id + } + > + { + type.name + } + </option> + ) + )} + </select> + {showWarning && ( + <Tooltip + content={i18n.t( + 'Several {{layer}} features roll up into each reference org unit here - {{type}} is an approximation of the values you can see joined in, not a recomputation over the combined area.', + { + layer: layer.name, + type: effectiveType, } - value={ - type.id + )} + > + <span + className={ + styles.aggregationWarning } + data-test={`data-table-join-aggregation-warning-${layer.id}-${dataKey}`} > - { - type.name - } - </option> - ) + <IconWarningFilled16 /> + </span> + </Tooltip> )} - </select> - </div> - ))} + </div> + ) + })} </div> )} </div> @@ -230,6 +319,7 @@ JoinLayersControl.propTypes = { ).isRequired, layersConfig: PropTypes.object.isRequired, onChange: PropTypes.func.isRequired, + referenceLayer: PropTypes.object, } export default JoinLayersControl diff --git a/src/components/datatable/controls/ReferenceOrgUnitControl.jsx b/src/components/datatable/controls/ReferenceOrgUnitControl.jsx index ae78206ac8..c9e96d02b4 100644 --- a/src/components/datatable/controls/ReferenceOrgUnitControl.jsx +++ b/src/components/datatable/controls/ReferenceOrgUnitControl.jsx @@ -4,6 +4,7 @@ import React from 'react' import { useDispatch, useSelector, useStore } from 'react-redux' import { editLayer } from '../../../actions/layers.js' import { COMBINED_TABLE_REF_LAYER } from '../../../constants/layers.js' +import { getDefaultReferenceRows } from '../../../util/dataTable.js' import ToolbarIconButton from './ToolbarIconButton.jsx' const findReferenceLayer = (mapViews) => @@ -16,16 +17,18 @@ export const useReferenceLayer = () => { findReferenceLayer(state.map.mapViews) ) - const openReferenceLayerEditor = () => + const openReferenceLayerEditor = () => { + const mapViews = store.getState().map.mapViews dispatch( editLayer( - findReferenceLayer(store.getState().map.mapViews) ?? { + findReferenceLayer(mapViews) ?? { layer: COMBINED_TABLE_REF_LAYER, isVisible: false, - rows: [], + rows: getDefaultReferenceRows(mapViews), } ) ) + } return { referenceLayer, openReferenceLayerEditor } } diff --git a/src/components/datatable/controls/styles/JoinLayersControl.module.css b/src/components/datatable/controls/styles/JoinLayersControl.module.css index e2ee920ea0..0d3593233e 100644 --- a/src/components/datatable/controls/styles/JoinLayersControl.module.css +++ b/src/components/datatable/controls/styles/JoinLayersControl.module.css @@ -80,3 +80,10 @@ border-radius: 3px; background-color: var(--colors-white); } + +.aggregationWarning { + display: flex; + flex-shrink: 0; + align-items: center; + color: var(--colors-yellow600); +} diff --git a/src/components/datatable/useCombinedTableData.js b/src/components/datatable/useCombinedTableData.js index 67b60e745e..cea2df18c6 100644 --- a/src/components/datatable/useCombinedTableData.js +++ b/src/components/datatable/useCombinedTableData.js @@ -1,7 +1,6 @@ import i18n from '@dhis2/d2-i18n' import { useMemo } from 'react' import { - ORG_UNIT_PATH_DATA_KEY, ORG_UNIT_LEVEL_DATA_KEY, SORT_ASCENDING, TYPE_NUMBER, @@ -13,10 +12,17 @@ import { SELECTION_FILTER_NOT_SELECTED, } from '../../constants/selection.js' import { applyAggregation } from '../../util/aggregation.js' -import { getCombinedValueDataKeys } from '../../util/dataTable.js' +import { + getByReferenceId, + getJoinableFeatures, + getProps, +} from '../../util/combinedJoinMatch.js' +import { + getCombinedValueDataKeys, + getDefaultCombinedAggregation, +} from '../../util/dataTable.js' import { filterByGlobalSearch, filterData } from '../../util/filter.js' import { isFeatureInBounds } from '../../util/geojson.js' -import { matchFeaturesToReferenceOrgUnits } from '../../util/spatialJoin.js' import { buildRowCells, getColumnDistinctValues, @@ -26,16 +32,8 @@ import { compareRows } from '../../util/tableSort.js' const LEGEND_KEY = 'legend' const LARGE_FEATURE_THRESHOLD = 10000 -const DEFAULT_AGGREGATION = 'SUM' const EMPTY_AGGREGATIONS = {} -const getJoinableFeatures = (layer) => - [...(layer?.data ?? []), ...(layer?.dataWithoutCoords ?? [])].filter( - (d) => !d.properties?.hasAdditionalGeometry - ) - -const getProps = (feature) => feature.properties || feature - const mergeAggregations = (layer, aggregationsForLayer) => { if (layer.layer !== EARTH_ENGINE_LAYER || !aggregationsForLayer) { return layer @@ -54,54 +52,6 @@ const mergeAggregations = (layer, aggregationsForLayer) => { } } -const matchOrgUnitReference = ( - features, - referenceOrgUnits, - referenceByPath -) => { - const byReferenceId = new Map() - features.forEach((feature) => { - const props = getProps(feature) - const path = props[ORG_UNIT_PATH_DATA_KEY] - if (!path) { - return - } - const reference = - referenceByPath.get(path) ?? - referenceOrgUnits.find((ref) => - path.startsWith(`${getProps(ref)[ORG_UNIT_PATH_DATA_KEY]}/`) - ) - if (!reference) { - return - } - const referenceId = getProps(reference).id - if (!byReferenceId.has(referenceId)) { - byReferenceId.set(referenceId, []) - } - byReferenceId.get(referenceId).push(props) - }) - return byReferenceId -} - -const matchSpatialReference = (features, referenceOrgUnits) => { - const byReferenceId = new Map() - const matched = matchFeaturesToReferenceOrgUnits( - features, - referenceOrgUnits, - { useCentroid: true } - ) - matched.forEach(({ featureProps, referenceId }) => { - if (referenceId == null) { - return - } - if (!byReferenceId.has(referenceId)) { - byReferenceId.set(referenceId, []) - } - byReferenceId.get(referenceId).push(featureProps) - }) - return byReferenceId -} - const finalizeRows = ( flatRows, headers, @@ -149,7 +99,8 @@ const applyLayerMatchToRow = ({ row, featureIds, refProps }, layerMatch) => { valueDataKeys.forEach(({ dataKey }) => { const values = matches.map((p) => p[dataKey]).filter((v) => v != null) row[`${layer.combinedLayerKey}_${dataKey}`] = applyAggregation( - settings.aggregation?.[dataKey] ?? DEFAULT_AGGREGATION, + settings.aggregation?.[dataKey] ?? + getDefaultCombinedAggregation(layer)[dataKey], values ) }) @@ -209,17 +160,6 @@ export const useCombinedTableData = ({ [referenceOrgUnits, showOnlyFeaturesInView, mapBounds] ) - const referenceByPath = useMemo( - () => - new Map( - referenceOrgUnits.map((ref) => [ - getProps(ref)[ORG_UNIT_PATH_DATA_KEY], - ref, - ]) - ), - [referenceOrgUnits] - ) - const layerMatches = useMemo( () => layers.map((layer) => { @@ -233,23 +173,14 @@ export const useCombinedTableData = ({ ) const features = getJoinableFeatures(mergedLayer) const valueDataKeys = getCombinedValueDataKeys(layer) - const byReferenceId = - settings.type === 'spatial' - ? matchSpatialReference(features, referenceOrgUnits) - : matchOrgUnitReference( - features, - referenceOrgUnits, - referenceByPath - ) + const byReferenceId = getByReferenceId( + features, + referenceOrgUnits, + settings.type + ) return { layer, settings, byReferenceId, valueDataKeys } }), - [ - layers, - joinConfig, - referenceOrgUnits, - referenceByPath, - allAggregations, - ] + [layers, joinConfig, referenceOrgUnits, allAggregations] ) return useMemo(() => { diff --git a/src/components/edit/thematic/ThematicDialog.jsx b/src/components/edit/thematic/ThematicDialog.jsx index 7e3c2910d0..1773bc323e 100644 --- a/src/components/edit/thematic/ThematicDialog.jsx +++ b/src/components/edit/thematic/ThematicDialog.jsx @@ -30,7 +30,7 @@ import { PREDEFINED_PERIODS, START_END_DATES, } from '../../../constants/periods.js' -import useDataItemLegendSet from '../../../hooks/useDataItemLegendSet.js' +import useDataItemMetadata from '../../../hooks/useDataItemMetadata.js' import useLayersPeriodSync from '../../../hooks/useLayersPeriodSync.js' import usePrevious from '../../../hooks/usePrevious.js' import { @@ -92,7 +92,7 @@ const ThematicDialog = ({ syncToOtherLayers, trySyncFromOtherLayersOnce, } = useLayersPeriodSync() - const fetchLegendSet = useDataItemLegendSet() + const fetchDataItemMetadata = useDataItemMetadata() // State management // ----- @@ -362,12 +362,15 @@ const ThematicDialog = ({ } onSelect={async ({ items }) => { const selected = items.at(-1) ?? {} - const legendSet = await fetchLegendSet( - selected - ) + const { legendSet, aggregationType } = + await fetchDataItemMetadata(selected) dispatch( setDataItem( - { ...selected, legendSet }, + { + ...selected, + legendSet, + aggregationType, + }, selected.type ) ) diff --git a/src/hooks/useDataItemLegendSet.js b/src/hooks/useDataItemMetadata.js similarity index 52% rename from src/hooks/useDataItemLegendSet.js rename to src/hooks/useDataItemMetadata.js index b074a934ae..b306ffbb93 100644 --- a/src/hooks/useDataItemLegendSet.js +++ b/src/hooks/useDataItemMetadata.js @@ -3,54 +3,73 @@ import { useCallback } from 'react' const TYPE_TO_RESOURCE = { INDICATOR: { resource: 'indicators', getUid: (id) => id }, - DATA_ELEMENT: { resource: 'dataElements', getUid: (id) => id }, + DATA_ELEMENT: { + resource: 'dataElements', + getUid: (id) => id, + hasAggregationType: true, + }, DATA_SET: { resource: 'dataSets', getUid: (id) => id }, REPORTING_RATE: { resource: 'dataSets', getUid: (id) => id.split('.')[0] }, PROGRAM_ATTRIBUTE: { resource: 'trackedEntityAttributes', getUid: (id) => id.split('.')[1], + hasAggregationType: true, }, PROGRAM_DATA_ELEMENT: { resource: 'dataElements', getUid: (id) => id.split('.')[1], + hasAggregationType: true, }, PROGRAM_ATTRIBUTE_OPTION: null, PROGRAM_DATA_ELEMENT_OPTION: null, - PROGRAM_INDICATOR: { resource: 'programIndicators', getUid: (id) => id }, + PROGRAM_INDICATOR: { + resource: 'programIndicators', + getUid: (id) => id, + hasAggregationType: true, + }, EXPRESSION_DIMENSION_ITEM: null, } -const useDataItemLegendSet = () => { +const EMPTY_METADATA = { legendSet: null, aggregationType: null } + +const useDataItemMetadata = () => { const engine = useDataEngine() - const fetchLegendSet = useCallback( + const fetchDataItemMetadata = useCallback( async (item) => { const conf = TYPE_TO_RESOURCE[item.type] if (!conf || !item.id) { - return null + return EMPTY_METADATA } const uid = conf.getUid(item.id) if (!uid) { - return null + return EMPTY_METADATA } + const fields = conf.hasAggregationType + ? 'legendSet,aggregationType' + : 'legendSet' + try { const result = await engine.query({ - legendSet: { + dataItem: { resource: `${conf.resource}/${uid}`, - params: { fields: 'legendSet' }, + params: { fields }, }, }) - return result.legendSet?.legendSet ?? null + return { + legendSet: result.dataItem.legendSet ?? null, + aggregationType: result.dataItem.aggregationType ?? null, + } } catch { - return null + return EMPTY_METADATA } }, [engine] ) - return fetchLegendSet + return fetchDataItemMetadata } -export default useDataItemLegendSet +export default useDataItemMetadata diff --git a/src/reducers/__tests__/map.spec.js b/src/reducers/__tests__/map.spec.js index 0270021070..2c17122159 100644 --- a/src/reducers/__tests__/map.spec.js +++ b/src/reducers/__tests__/map.spec.js @@ -1,5 +1,10 @@ import * as types from '../../constants/actionTypes.js' -import { COMBINED_TABLE_REF_LAYER } from '../../constants/layers.js' +import { + COMBINED_TABLE_REF_LAYER, + THEMATIC_LAYER, + ORG_UNIT_LAYER, + EARTH_ENGINE_LAYER, +} from '../../constants/layers.js' import { isValidUid } from '../../util/uid.js' import map, { defaultBasemapState } from '../map.js' @@ -290,6 +295,55 @@ describe('map reducer - DATA_TABLE_COMBINED_VIEW_TOGGLE', () => { expect(result).toBe(state) }) + + it("seeds the placeholder's rows from the first eligible layer in priority order (Thematic over Org unit)", () => { + const orgUnitRows = [ + { dimension: 'ou', items: [{ id: 'ou1', name: 'Org unit 1' }] }, + ] + const thematicRows = [ + { dimension: 'ou', items: [{ id: 'ou2', name: 'Org unit 2' }] }, + ] + const state = { + ...defaultState, + mapViews: [ + { + id: 'orgUnitLayer', + layer: ORG_UNIT_LAYER, + rows: orgUnitRows, + }, + { + id: 'thematicLayer', + layer: THEMATIC_LAYER, + rows: thematicRows, + }, + ], + } + + const result = map(state, { + type: types.DATA_TABLE_COMBINED_VIEW_TOGGLE, + }) + + expect(result.mapViews[2].rows).toBe(thematicRows) + }) + + it('skips a higher-priority layer with no org units selected and falls through to the next type', () => { + const eeRows = [ + { dimension: 'ou', items: [{ id: 'ou3', name: 'Org unit 3' }] }, + ] + const state = { + ...defaultState, + mapViews: [ + { id: 'thematicLayer', layer: THEMATIC_LAYER, rows: [] }, + { id: 'eeLayer', layer: EARTH_ENGINE_LAYER, rows: eeRows }, + ], + } + + const result = map(state, { + type: types.DATA_TABLE_COMBINED_VIEW_TOGGLE, + }) + + expect(result.mapViews[2].rows).toBe(eeRows) + }) }) describe('map reducer - LAYER_REMOVE / LAYER_DUPLICATE', () => { diff --git a/src/reducers/map.js b/src/reducers/map.js index f70d329b4b..a5a194beb5 100644 --- a/src/reducers/map.js +++ b/src/reducers/map.js @@ -1,6 +1,7 @@ import { arrayMoveImmutable } from 'array-move' import * as types from '../constants/actionTypes.js' import { COMBINED_TABLE_REF_LAYER } from '../constants/layers.js' +import { getDefaultReferenceRows } from '../util/dataTable.js' import { generateUid } from '../util/uid.js' export const defaultBasemapState = { @@ -351,7 +352,7 @@ const map = (state = defaultState, action) => { id: generateUid(), combinedLayerKey: generateUid(), isVisible: false, - rows: [], + rows: getDefaultReferenceRows(state.mapViews), }, ], } diff --git a/src/util/__tests__/aggregation.spec.js b/src/util/__tests__/aggregation.spec.js index 81a5a6c46e..6c99375624 100644 --- a/src/util/__tests__/aggregation.spec.js +++ b/src/util/__tests__/aggregation.spec.js @@ -1,4 +1,8 @@ -import { applyAggregation } from '../aggregation.js' +import { + applyAggregation, + getDefaultCombinedAggregationType, + getDefaultCombinedAggregationTypeFromEarthEngineStat, +} from '../aggregation.js' describe('applyAggregation', () => { test('returns null for an empty input (no matching feature)', () => { @@ -44,3 +48,78 @@ describe('applyAggregation', () => { expect(applyAggregation('NOT_A_TYPE', [1, 2, 3])).toBeNull() }) }) + +describe('getDefaultCombinedAggregationType', () => { + test('maps directly for types with a 1:1 equivalent', () => { + expect(getDefaultCombinedAggregationType('SUM')).toBe('SUM') + expect(getDefaultCombinedAggregationType('AVERAGE')).toBe('AVERAGE') + expect(getDefaultCombinedAggregationType('COUNT')).toBe('COUNT') + expect(getDefaultCombinedAggregationType('MIN')).toBe('MIN') + expect(getDefaultCombinedAggregationType('MAX')).toBe('MAX') + expect(getDefaultCombinedAggregationType('STDDEV')).toBe('STDDEV') + expect(getDefaultCombinedAggregationType('VARIANCE')).toBe('VARIANCE') + }) + + test('maps AVERAGE_SUM_ORG_UNIT to SUM - Combined only ever rolls up across org units', () => { + expect(getDefaultCombinedAggregationType('AVERAGE_SUM_ORG_UNIT')).toBe( + 'SUM' + ) + }) + + test('falls back to SUM for NONE, CUSTOM, unrecognized, or missing types', () => { + expect(getDefaultCombinedAggregationType('NONE')).toBe('SUM') + expect(getDefaultCombinedAggregationType('CUSTOM')).toBe('SUM') + expect(getDefaultCombinedAggregationType('NOT_A_TYPE')).toBe('SUM') + expect(getDefaultCombinedAggregationType(undefined)).toBe('SUM') + }) + + test('defaults a plain Indicator to AVERAGE regardless of aggregationType - it has none of its own, and its value is a ratio not meaningfully summed across org units', () => { + expect(getDefaultCombinedAggregationType(undefined, 'INDICATOR')).toBe( + 'AVERAGE' + ) + expect(getDefaultCombinedAggregationType('SUM', 'INDICATOR')).toBe( + 'AVERAGE' + ) + }) + + test('defaults Reporting rate to AVERAGE - same reasoning as Indicators, it has no aggregationType of its own and its value is always a ratio', () => { + expect( + getDefaultCombinedAggregationType(undefined, 'REPORTING_RATE') + ).toBe('AVERAGE') + }) +}) + +describe('getDefaultCombinedAggregationTypeFromEarthEngineStat', () => { + test('maps each Earth Engine stat id to its equivalent', () => { + expect( + getDefaultCombinedAggregationTypeFromEarthEngineStat('count') + ).toBe('COUNT') + expect( + getDefaultCombinedAggregationTypeFromEarthEngineStat('min') + ).toBe('MIN') + expect( + getDefaultCombinedAggregationTypeFromEarthEngineStat('max') + ).toBe('MAX') + expect( + getDefaultCombinedAggregationTypeFromEarthEngineStat('mean') + ).toBe('AVERAGE') + expect( + getDefaultCombinedAggregationTypeFromEarthEngineStat('sum') + ).toBe('SUM') + expect( + getDefaultCombinedAggregationTypeFromEarthEngineStat('stdDev') + ).toBe('STDDEV') + expect( + getDefaultCombinedAggregationTypeFromEarthEngineStat('variance') + ).toBe('VARIANCE') + }) + + test('falls back to SUM for median (no equivalent reducer) or an unrecognized stat', () => { + expect( + getDefaultCombinedAggregationTypeFromEarthEngineStat('median') + ).toBe('SUM') + expect( + getDefaultCombinedAggregationTypeFromEarthEngineStat('not-a-stat') + ).toBe('SUM') + }) +}) diff --git a/src/util/__tests__/analytics.spec.js b/src/util/__tests__/analytics.spec.js index 454b604951..e946a99d23 100644 --- a/src/util/__tests__/analytics.spec.js +++ b/src/util/__tests__/analytics.spec.js @@ -56,6 +56,17 @@ describe('setDataItemInColumns', () => { const dataItem = { id: 'item1', name: 'Item 1' } expect(setDataItemInColumns(dataItem, 'invalid')).toEqual([]) }) + + it('stores aggregationType alongside the data item when present', () => { + const dataItem = { + id: 'item1', + name: 'Item 1', + aggregationType: 'AVERAGE', + } + const result = setDataItemInColumns(dataItem, 'dataElement') + + expect(result[0].items[0].aggregationType).toBe('AVERAGE') + }) }) describe('getOrgUnitsFromRows', () => { diff --git a/src/util/__tests__/combinedJoinMatch.spec.js b/src/util/__tests__/combinedJoinMatch.spec.js new file mode 100644 index 0000000000..2c17275c89 --- /dev/null +++ b/src/util/__tests__/combinedJoinMatch.spec.js @@ -0,0 +1,168 @@ +import { + getUnmatchedFeatureCount, + hasCombinedRollup, +} from '../combinedJoinMatch.js' + +const referenceFeature = (id, path) => ({ + properties: { id, name: id, orgUnitPath: path, level: 2 }, +}) + +const orgUnitFeature = (path) => ({ + properties: { orgUnitPath: path }, +}) + +const pointFeature = (coordinates) => ({ + type: 'Feature', + properties: {}, + geometry: { type: 'Point', coordinates }, +}) + +const polygonReferenceFeature = (id, coordinates) => ({ + type: 'Feature', + properties: { id, name: id, level: 2 }, + geometry: { type: 'Polygon', coordinates: [coordinates] }, +}) + +describe('hasCombinedRollup', () => { + test('is false when the reference layer has no org units at all', () => { + expect( + hasCombinedRollup( + { data: [orgUnitFeature('/country1/ou1')] }, + { data: [] }, + 'orgUnit' + ) + ).toBe(false) + }) + + test('org unit join: is false when every reference org unit matches at most one feature', () => { + const referenceLayer = { + data: [ + referenceFeature('ref1', '/country1/ref1'), + referenceFeature('ref2', '/country1/ref2'), + ], + } + const layer = { + data: [ + orgUnitFeature('/country1/ref1'), + orgUnitFeature('/country1/ref2'), + ], + } + expect(hasCombinedRollup(layer, referenceLayer, 'orgUnit')).toBe(false) + }) + + test('org unit join: is true when a reference org unit matches more than one feature (a rollup)', () => { + const referenceLayer = { + data: [referenceFeature('ref1', '/country1/ref1')], + } + const layer = { + data: [ + orgUnitFeature('/country1/ref1/child1'), + orgUnitFeature('/country1/ref1/child2'), + ], + } + expect(hasCombinedRollup(layer, referenceLayer, 'orgUnit')).toBe(true) + }) + + test('spatial join: is true when a reference polygon contains more than one point feature', () => { + const referenceLayer = { + data: [ + polygonReferenceFeature('ref1', [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + [0, 0], + ]), + ], + } + const layer = { + data: [pointFeature([1, 1]), pointFeature([1.5, 1.5])], + } + expect(hasCombinedRollup(layer, referenceLayer, 'spatial')).toBe(true) + }) + + test('spatial join: is false when each reference polygon contains at most one point feature', () => { + const referenceLayer = { + data: [ + polygonReferenceFeature('ref1', [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + [0, 0], + ]), + polygonReferenceFeature('ref2', [ + [10, 10], + [12, 10], + [12, 12], + [10, 12], + [10, 10], + ]), + ], + } + const layer = { + data: [pointFeature([1, 1]), pointFeature([11, 11])], + } + expect(hasCombinedRollup(layer, referenceLayer, 'spatial')).toBe(false) + }) +}) + +describe('getUnmatchedFeatureCount', () => { + test('is 0 when the reference layer has no org units at all', () => { + expect( + getUnmatchedFeatureCount( + { data: [orgUnitFeature('/country1/ou1')] }, + { data: [] }, + 'orgUnit' + ) + ).toBe(0) + }) + + test('org unit join: is 0 when every feature matches a reference org unit', () => { + const referenceLayer = { + data: [referenceFeature('ref1', '/country1/ref1')], + } + const layer = { + data: [orgUnitFeature('/country1/ref1/child1')], + } + expect(getUnmatchedFeatureCount(layer, referenceLayer, 'orgUnit')).toBe( + 0 + ) + }) + + test('org unit join: counts features with no matching reference ancestor (wrong branch or higher level)', () => { + const referenceLayer = { + data: [referenceFeature('ref1', '/country1/ref1')], + } + const layer = { + data: [ + orgUnitFeature('/country1/ref1/child1'), + orgUnitFeature('/country2/other/child2'), + orgUnitFeature('/country1'), + ], + } + expect(getUnmatchedFeatureCount(layer, referenceLayer, 'orgUnit')).toBe( + 2 + ) + }) + + test('spatial join: counts points that fall outside every reference polygon', () => { + const referenceLayer = { + data: [ + polygonReferenceFeature('ref1', [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + [0, 0], + ]), + ], + } + const layer = { + data: [pointFeature([1, 1]), pointFeature([10, 10])], + } + expect(getUnmatchedFeatureCount(layer, referenceLayer, 'spatial')).toBe( + 1 + ) + }) +}) diff --git a/src/util/__tests__/dataTable.spec.js b/src/util/__tests__/dataTable.spec.js index cb13287579..84d4e01f92 100644 --- a/src/util/__tests__/dataTable.spec.js +++ b/src/util/__tests__/dataTable.spec.js @@ -1,11 +1,17 @@ import { EARTH_ENGINE_LAYER, THEMATIC_LAYER, + ORG_UNIT_LAYER, + FACILITY_LAYER, + EVENT_LAYER, + TRACKED_ENTITY_LAYER, EXTERNAL_LAYER, } from '../../constants/layers.js' import { buildFeatureIndex, getCombinedValueDataKeys, + getDefaultCombinedAggregation, + getDefaultReferenceRows, getEligibleDataTableLayers, getLayerSelectedIds, getNextSorting, @@ -20,6 +26,16 @@ import { shouldClearFeatureHighlight, } from '../dataTable.js' +const withDataItem = (aggregationType) => ({ + layer: THEMATIC_LAYER, + columns: [ + { + dimension: 'dx', + items: [{ id: 'de1', name: 'DE 1', aggregationType }], + }, + ], +}) + describe('getCombinedValueDataKeys', () => { test('returns a single generic rawValue column for any non-Earth-Engine layer', () => { expect(getCombinedValueDataKeys({ layer: THEMATIC_LAYER })).toEqual([ @@ -69,6 +85,169 @@ describe('getCombinedValueDataKeys', () => { }) }) +describe('getDefaultCombinedAggregation', () => { + test("defaults to the data item's own aggregation type", () => { + expect(getDefaultCombinedAggregation(withDataItem('AVERAGE'))).toEqual({ + rawValue: 'AVERAGE', + }) + }) + + test('maps AVERAGE_SUM_ORG_UNIT to SUM', () => { + expect( + getDefaultCombinedAggregation(withDataItem('AVERAGE_SUM_ORG_UNIT')) + ).toEqual({ rawValue: 'SUM' }) + }) + + test('falls back to SUM when the data item has no aggregationType', () => { + expect(getDefaultCombinedAggregation(withDataItem(undefined))).toEqual({ + rawValue: 'SUM', + }) + }) + + test('falls back to SUM for a layer with no data item at all (non-Thematic types)', () => { + expect( + getDefaultCombinedAggregation({ layer: EARTH_ENGINE_LAYER }) + ).toEqual({}) + }) + + test('defaults a plain Indicator data item to AVERAGE - it has no aggregationType of its own, and its value is a ratio not meaningfully summed across org units', () => { + expect( + getDefaultCombinedAggregation({ + layer: THEMATIC_LAYER, + columns: [ + { + dimension: 'dx', + items: [ + { + id: 'in1', + name: 'Indicator 1', + dimensionItemType: 'INDICATOR', + }, + ], + }, + ], + }) + ).toEqual({ rawValue: 'AVERAGE' }) + }) + + test("Earth Engine: defaults every stat column to the first selected stat's own equivalent", () => { + expect( + getDefaultCombinedAggregation({ + layer: EARTH_ENGINE_LAYER, + aggregationType: ['mean', 'max'], + legend: { title: 'NDVI' }, + }) + ).toEqual({ mean: 'AVERAGE', max: 'AVERAGE' }) + }) + + test('Earth Engine: classified percentage (e.g. Landcover) defaults to AVERAGE - a relative proportion is not meaningfully summed across differently-sized org units', () => { + expect( + getDefaultCombinedAggregation({ + layer: EARTH_ENGINE_LAYER, + aggregationType: 'percentage', + legend: { items: [{ value: 1, name: 'Forest' }] }, + }) + ).toEqual({ 1: 'AVERAGE' }) + }) + + test('Earth Engine: classified hectares/acres default to SUM - an absolute area is correctly additive across joined org units', () => { + expect( + getDefaultCombinedAggregation({ + layer: EARTH_ENGINE_LAYER, + aggregationType: 'hectares', + legend: { items: [{ value: 1, name: 'Forest' }] }, + }) + ).toEqual({ 1: 'SUM' }) + expect( + getDefaultCombinedAggregation({ + layer: EARTH_ENGINE_LAYER, + aggregationType: 'acres', + legend: { items: [{ value: 1, name: 'Forest' }] }, + }) + ).toEqual({ 1: 'SUM' }) + }) +}) + +const withOrgUnitRows = (layer, id) => ({ + layer, + rows: [{ dimension: 'ou', items: [{ id, name: id }] }], +}) + +describe('getDefaultReferenceRows', () => { + test('returns an empty array when no map view has an org unit selection', () => { + expect( + getDefaultReferenceRows([{ layer: THEMATIC_LAYER, rows: [] }]) + ).toEqual([]) + }) + + test('prefers a Thematic layer over every other type', () => { + const thematic = withOrgUnitRows(THEMATIC_LAYER, 'thematicOu') + expect( + getDefaultReferenceRows([ + withOrgUnitRows(TRACKED_ENTITY_LAYER, 'teOu'), + withOrgUnitRows(ORG_UNIT_LAYER, 'orgUnitOu'), + thematic, + withOrgUnitRows(EARTH_ENGINE_LAYER, 'eeOu'), + ]) + ).toBe(thematic.rows) + }) + + test('falls through to the next type in priority order when a higher-priority layer has no org units selected', () => { + const facility = withOrgUnitRows(FACILITY_LAYER, 'facilityOu') + expect( + getDefaultReferenceRows([ + { layer: THEMATIC_LAYER, rows: [] }, + { layer: ORG_UNIT_LAYER, rows: [] }, + { layer: EARTH_ENGINE_LAYER, rows: [] }, + facility, + withOrgUnitRows(EVENT_LAYER, 'eventOu'), + ]) + ).toBe(facility.rows) + }) + + test('defaults to an empty array when no map views are given', () => { + expect(getDefaultReferenceRows()).toEqual([]) + }) + + const withLevel = (layer, id, level) => ({ + ...withOrgUnitRows(layer, id), + data: [{ properties: { id, level } }], + }) + + test('prefers the coarser (lower) org unit level over the type priority order', () => { + const facility = withLevel(FACILITY_LAYER, 'facilityOu', 1) + const thematic = withLevel(THEMATIC_LAYER, 'thematicOu', 3) + expect(getDefaultReferenceRows([thematic, facility])).toBe( + facility.rows + ) + }) + + test('breaks a level tie using the type priority order', () => { + const orgUnit = withLevel(ORG_UNIT_LAYER, 'orgUnitOu', 2) + const thematic = withLevel(THEMATIC_LAYER, 'thematicOu', 2) + expect(getDefaultReferenceRows([orgUnit, thematic])).toBe(thematic.rows) + }) + + test('prefers the coarser of two layers of the same type', () => { + const fineThematic = withLevel(THEMATIC_LAYER, 'fine', 3) + const coarseThematic = withLevel(THEMATIC_LAYER, 'coarse', 1) + expect(getDefaultReferenceRows([fineThematic, coarseThematic])).toBe( + coarseThematic.rows + ) + }) + + test('falls back to type priority when a candidate has no loaded data to compare a level from yet', () => { + const thematicNotYetLoaded = withOrgUnitRows( + THEMATIC_LAYER, + 'thematicOu' + ) + const facility = withLevel(FACILITY_LAYER, 'facilityOu', 1) + expect(getDefaultReferenceRows([facility, thematicNotYetLoaded])).toBe( + thematicNotYetLoaded.rows + ) + }) +}) + describe('shouldClearFeatureHighlight', () => { test('clears when leaving to no element (cursor exits the window)', () => { expect(shouldClearFeatureHighlight({ relatedTarget: null })).toBe(true) diff --git a/src/util/aggregation.js b/src/util/aggregation.js index 8290faa2f6..1bbfa16aa6 100644 --- a/src/util/aggregation.js +++ b/src/util/aggregation.js @@ -1,7 +1,3 @@ -// Reducers for combining several raw values into one, keyed by the same -// ids getCombinedAggregationTypes() (constants/aggregationTypes.js) offers - -// used by the Combined data table's join when a participating layer has -// more than one feature matching a single reference org unit row. const AGGREGATIONS = { SUM: (values) => values.reduce((a, b) => a + b, 0), AVERAGE: (values) => values.reduce((a, b) => a + b, 0) / values.length, @@ -25,3 +21,45 @@ export const applyAggregation = (type, values) => { const aggregate = AGGREGATIONS[type] return aggregate ? aggregate(values) : null } + +export const NON_COMPOSABLE_AGGREGATION_TYPES = new Set([ + 'AVERAGE', + 'STDDEV', + 'VARIANCE', +]) + +const DHIS2_TO_COMBINED_AGGREGATION_TYPE = { + SUM: 'SUM', + AVERAGE: 'AVERAGE', + AVERAGE_SUM_ORG_UNIT: 'SUM', + COUNT: 'COUNT', + MIN: 'MIN', + MAX: 'MAX', + STDDEV: 'STDDEV', + VARIANCE: 'VARIANCE', +} + +const RATIO_DIMENSION_ITEM_TYPES = new Set(['INDICATOR', 'REPORTING_RATE']) + +export const getDefaultCombinedAggregationType = ( + dataItemAggregationType, + dimensionItemType +) => { + if (RATIO_DIMENSION_ITEM_TYPES.has(dimensionItemType)) { + return 'AVERAGE' + } + return DHIS2_TO_COMBINED_AGGREGATION_TYPE[dataItemAggregationType] ?? 'SUM' +} + +const EARTH_ENGINE_TO_COMBINED_AGGREGATION_TYPE = { + count: 'COUNT', + min: 'MIN', + max: 'MAX', + mean: 'AVERAGE', + sum: 'SUM', + stdDev: 'STDDEV', + variance: 'VARIANCE', +} + +export const getDefaultCombinedAggregationTypeFromEarthEngineStat = (stat) => + EARTH_ENGINE_TO_COMBINED_AGGREGATION_TYPE[stat] ?? 'SUM' diff --git a/src/util/analytics.js b/src/util/analytics.js index c403222a0d..b894bcaec9 100644 --- a/src/util/analytics.js +++ b/src/util/analytics.js @@ -47,6 +47,7 @@ export const setDataItemInColumns = (dataItem, dimension) => { expression: dataItem.expression, dimensionItemType: dim.itemType, legendSet: dataItem.legendSet, // TODO: Keep outside of columns? + aggregationType: dataItem.aggregationType, }, ], { objectName: dim.objectName } diff --git a/src/util/combinedJoinMatch.js b/src/util/combinedJoinMatch.js new file mode 100644 index 0000000000..41af04bcef --- /dev/null +++ b/src/util/combinedJoinMatch.js @@ -0,0 +1,103 @@ +import { ORG_UNIT_PATH_DATA_KEY } from '../constants/dataTable.js' +import { matchFeaturesToReferenceOrgUnits } from './spatialJoin.js' + +export const getJoinableFeatures = (layer) => + [...(layer?.data ?? []), ...(layer?.dataWithoutCoords ?? [])].filter( + (d) => !d.properties?.hasAdditionalGeometry + ) + +export const getProps = (feature) => feature.properties || feature + +export const matchOrgUnitReference = ( + features, + referenceOrgUnits, + referenceByPath +) => { + const byReferenceId = new Map() + features.forEach((feature) => { + const props = getProps(feature) + const path = props[ORG_UNIT_PATH_DATA_KEY] + if (!path) { + return + } + const reference = + referenceByPath.get(path) ?? + referenceOrgUnits.find((ref) => + path.startsWith(`${getProps(ref)[ORG_UNIT_PATH_DATA_KEY]}/`) + ) + if (!reference) { + return + } + const referenceId = getProps(reference).id + if (!byReferenceId.has(referenceId)) { + byReferenceId.set(referenceId, []) + } + byReferenceId.get(referenceId).push(props) + }) + return byReferenceId +} + +export const matchSpatialReference = (features, referenceOrgUnits) => { + const byReferenceId = new Map() + const matched = matchFeaturesToReferenceOrgUnits( + features, + referenceOrgUnits, + { useCentroid: true } + ) + matched.forEach(({ featureProps, referenceId }) => { + if (referenceId == null) { + return + } + if (!byReferenceId.has(referenceId)) { + byReferenceId.set(referenceId, []) + } + byReferenceId.get(referenceId).push(featureProps) + }) + return byReferenceId +} + +export const getByReferenceId = (features, referenceOrgUnits, joinType) => { + if (joinType === 'spatial') { + return matchSpatialReference(features, referenceOrgUnits) + } + const referenceByPath = new Map( + referenceOrgUnits.map((ref) => [ + getProps(ref)[ORG_UNIT_PATH_DATA_KEY], + ref, + ]) + ) + return matchOrgUnitReference(features, referenceOrgUnits, referenceByPath) +} + +export const hasCombinedRollup = (layer, referenceLayer, joinType) => { + const referenceOrgUnits = getJoinableFeatures(referenceLayer) + if (!referenceOrgUnits.length) { + return false + } + const byReferenceId = getByReferenceId( + getJoinableFeatures(layer), + referenceOrgUnits, + joinType + ) + return Array.from(byReferenceId.values()).some( + (matches) => matches.length > 1 + ) +} + +export const getUnmatchedFeatureCount = (layer, referenceLayer, joinType) => { + const referenceOrgUnits = getJoinableFeatures(referenceLayer) + const features = getJoinableFeatures(layer) + if (!referenceOrgUnits.length || !features.length) { + return 0 + } + const byReferenceId = getByReferenceId( + features, + referenceOrgUnits, + joinType + ) + const matched = Array.from(byReferenceId.values()).reduce( + (sum, matches) => sum + matches.length, + 0 + ) + return features.length - matched +} diff --git a/src/util/dataTable.js b/src/util/dataTable.js index 8d806ddd6a..1da9ea7640 100644 --- a/src/util/dataTable.js +++ b/src/util/dataTable.js @@ -3,29 +3,64 @@ import { SORT_ASCENDING, SORT_DESCENDING } from '../constants/dataTable.js' import { DATA_TABLE_LAYER_TYPES, EARTH_ENGINE_LAYER, + THEMATIC_LAYER, + ORG_UNIT_LAYER, + FACILITY_LAYER, + EVENT_LAYER, + TRACKED_ENTITY_LAYER, } from '../constants/layers.js' +import { + getDefaultCombinedAggregationType, + getDefaultCombinedAggregationTypeFromEarthEngineStat, +} from './aggregation.js' +import { getDataItemFromColumns, getOrgUnitsFromRows } from './analytics.js' +import { getJoinableFeatures } from './combinedJoinMatch.js' export const COMBINED_VALUE_KEY = 'rawValue' -// Duplicated from util/earthEngine.js's own classAggregation/hasClasses -// rather than imported - that module's first import is MapApi.js, which -// pulls in the entire @dhis2/maps-gl/maplibre-gl rendering stack (breaks in -// jsdom without a MapApi.js mock). This file is a widely-shared, otherwise -// dependency-light utility imported by most of the data table test suite, -// so it deliberately doesn't take on that transitive weight for two -// constant strings. const CLASSIFIED_EARTH_ENGINE_AGGREGATION_TYPES = new Set([ 'percentage', 'hectares', 'acres', ]) +const CLASSIFIED_EARTH_ENGINE_DEFAULT_AGGREGATION_TYPE = { + percentage: 'AVERAGE', + hectares: 'SUM', + acres: 'SUM', +} + const toTitleCase = (str) => str.replace( /\w\S*/g, (txt) => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase() ) +export const getDefaultCombinedAggregation = (layer) => { + let type + if (Array.isArray(layer.aggregationType)) { + type = getDefaultCombinedAggregationTypeFromEarthEngineStat( + layer.aggregationType[0] + ) + } else if ( + CLASSIFIED_EARTH_ENGINE_AGGREGATION_TYPES.has(layer.aggregationType) + ) { + type = + CLASSIFIED_EARTH_ENGINE_DEFAULT_AGGREGATION_TYPE[ + layer.aggregationType + ] + } else { + const dataItem = getDataItemFromColumns(layer.columns) + type = getDefaultCombinedAggregationType( + dataItem?.aggregationType, + dataItem?.dimensionItemType + ) + } + return Object.fromEntries( + getCombinedValueDataKeys(layer).map(({ dataKey }) => [dataKey, type]) + ) +} + export const getCombinedValueDataKeys = (layer) => { if (layer.layer !== EARTH_ENGINE_LAYER) { return [{ dataKey: COMBINED_VALUE_KEY, name: null }] @@ -48,6 +83,67 @@ export const getCombinedValueDataKeys = (layer) => { return [] } +const REFERENCE_ROWS_LEVEL_COMPARABLE_TYPES = [ + THEMATIC_LAYER, + ORG_UNIT_LAYER, + EARTH_ENGINE_LAYER, + FACILITY_LAYER, +] + +const REFERENCE_ROWS_FALLBACK_TYPES = [EVENT_LAYER, TRACKED_ENTITY_LAYER] + +const getMinFeatureLevel = (mapView) => { + const levels = getJoinableFeatures(mapView) + .map((f) => (f.properties ?? f).level) + .filter((level) => typeof level === 'number') + return levels.length ? Math.min(...levels) : null +} + +const isBetterReferenceCandidate = (a, b) => { + if (a.level !== null && b.level !== null && a.level !== b.level) { + return a.level < b.level + } + if (a.priority !== b.priority) { + return a.priority < b.priority + } + return a.index < b.index +} + +export const getDefaultReferenceRows = (mapViews = []) => { + const candidates = mapViews + .map((mapView, index) => ({ mapView, index })) + .filter( + ({ mapView }) => + REFERENCE_ROWS_LEVEL_COMPARABLE_TYPES.includes(mapView.layer) && + getOrgUnitsFromRows(mapView.rows).length + ) + .map(({ mapView, index }) => ({ + mapView, + index, + priority: REFERENCE_ROWS_LEVEL_COMPARABLE_TYPES.indexOf( + mapView.layer + ), + level: getMinFeatureLevel(mapView), + })) + + if (candidates.length) { + return candidates.reduce((best, candidate) => + isBetterReferenceCandidate(candidate, best) ? candidate : best + ).mapView.rows + } + + for (const layerType of REFERENCE_ROWS_FALLBACK_TYPES) { + const layer = mapViews.find( + (mv) => + mv.layer === layerType && getOrgUnitsFromRows(mv.rows).length + ) + if (layer) { + return layer.rows + } + } + return [] +} + export const isFilterable = (dataKey, type) => !!type export const shouldClearFeatureHighlight = (event) => From dc85dc6645cb779be0e5b839c35f80a31e0a63a4 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 30 Jul 2026 17:07:37 +0200 Subject: [PATCH 174/205] fix: small improvements --- src/components/datatable/DataTable.jsx | 21 ++++++- .../__tests__/useCombinedTableData.spec.js | 32 ++++++++++ .../datatable/useCombinedTableData.js | 33 +++++++---- src/components/map/layers/Layer.js | 13 +++- .../map/layers/__tests__/Layer.spec.js | 59 ++++++++++++++++++- 5 files changed, 140 insertions(+), 18 deletions(-) diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index db1e514324..d3f7ccf15e 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -12,7 +12,10 @@ import React, { useCallback, useMemo, useEffect, useRef, useState } from 'react' import { useSelector, useDispatch } from 'react-redux' import { TableVirtuoso } from 'react-virtuoso' import { setDataFilter, clearDataFilter } from '../../actions/dataFilters.js' -import { setSelectionFilter } from '../../actions/dataTable.js' +import { + setCombinedVisibleIds, + setSelectionFilter, +} from '../../actions/dataTable.js' import { highlightFeature } from '../../actions/feature.js' import { editLayer, setForceClientCluster } from '../../actions/layers.js' import { @@ -328,6 +331,22 @@ const Table = ({ [rows] ) + const hasGlobalSearch = !!globalSearch?.trim() + useEffect(() => { + dispatch( + setCombinedVisibleIds( + hasGlobalSearch ? { [layer.id]: allRowIds } : null + ) + ) + }, [dispatch, hasGlobalSearch, allRowIds, layer.id]) + + useEffect( + () => () => { + dispatch(setCombinedVisibleIds(null)) + }, + [dispatch] + ) + const onSelectionChange = useCallback( (nextIds) => { if (nextIds.length) { diff --git a/src/components/datatable/__tests__/useCombinedTableData.spec.js b/src/components/datatable/__tests__/useCombinedTableData.spec.js index adc5eb2b71..786ebccef3 100644 --- a/src/components/datatable/__tests__/useCombinedTableData.spec.js +++ b/src/components/datatable/__tests__/useCombinedTableData.spec.js @@ -665,6 +665,38 @@ describe('useCombinedTableData - sorting and filtering', () => { { value: '30' }, ]) }) + + test('keeps the same headers array reference across filter/sort/selection-only changes - a changed reference makes useColumnWidths.js reset and re-measure column widths on every keystroke', () => { + const { result, rerender } = renderHook( + (props) => useCombinedTableData(props), + { + initialProps: { + layers, + referenceLayer, + joinConfig, + sortField: null, + sortDirection: 'asc', + filters: {}, + globalSearch: '', + selectedIdSet: new Set(), + }, + } + ) + const firstHeaders = result.current.headers + + rerender({ + layers, + referenceLayer, + joinConfig, + sortField: 'layerA_rawValue', + sortDirection: 'desc', + filters: { layerA_rawValue: '>15' }, + globalSearch: 'ou', + selectedIdSet: new Set(['ou1']), + }) + + expect(result.current.headers).toBe(firstHeaders) + }) }) describe('useCombinedTableData - empty input', () => { diff --git a/src/components/datatable/useCombinedTableData.js b/src/components/datatable/useCombinedTableData.js index cea2df18c6..bd23587a67 100644 --- a/src/components/datatable/useCombinedTableData.js +++ b/src/components/datatable/useCombinedTableData.js @@ -122,9 +122,10 @@ const applyLayerMatchToRow = ({ row, featureIds, refProps }, layerMatch) => { } const EMPTY_COLUMN_OPTIONS = {} +const EMPTY_HEADERS = [] const EMPTY_RESULT = { - headers: [], + headers: EMPTY_HEADERS, rows: [], rowFeatureIds: new Map(), columnOptions: EMPTY_COLUMN_OPTIONS, @@ -183,20 +184,11 @@ export const useCombinedTableData = ({ [layers, joinConfig, referenceOrgUnits, allAggregations] ) - return useMemo(() => { + const headers = useMemo(() => { if (!referenceOrgUnits.length) { - return EMPTY_RESULT + return EMPTY_HEADERS } - - const spatialWarning = - referenceOrgUnits.length > LARGE_FEATURE_THRESHOLD || - layerMatches.some( - ({ layer, settings }) => - settings.type === 'spatial' && - (layer.data?.length ?? 0) > LARGE_FEATURE_THRESHOLD - ) - - const headers = [ + return [ { name: i18n.t('Org unit id'), dataKey: 'id', @@ -235,6 +227,20 @@ export const useCombinedTableData = ({ : []), ]), ] + }, [referenceOrgUnits, layerMatches]) + + return useMemo(() => { + if (!referenceOrgUnits.length) { + return EMPTY_RESULT + } + + const spatialWarning = + referenceOrgUnits.length > LARGE_FEATURE_THRESHOLD || + layerMatches.some( + ({ layer, settings }) => + settings.type === 'spatial' && + (layer.data?.length ?? 0) > LARGE_FEATURE_THRESHOLD + ) const rowFeatureIds = new Map() @@ -288,6 +294,7 @@ export const useCombinedTableData = ({ visibleReferenceOrgUnits, referenceLayer, layerMatches, + headers, filters, globalSearch, sortField, diff --git a/src/components/map/layers/Layer.js b/src/components/map/layers/Layer.js index 18a84c1423..e09c53b298 100644 --- a/src/components/map/layers/Layer.js +++ b/src/components/map/layers/Layer.js @@ -16,6 +16,13 @@ import { getLayerSelectedIds } from '../../../util/dataTable.js' export const idsEqual = (a, b) => a.length === b.length && a.every((id, i) => id === b[i]) +export const visibleIdsEqual = (a, b) => { + if (a === null || b === null) { + return a === b + } + return idsEqual(a, b) +} + class Layer extends PureComponent { static contextTypes = { map: PropTypes.object, @@ -158,17 +165,17 @@ class Layer extends PureComponent { handleVisibleIdsChange(prevProps) { const { selection, selectionFilter, combinedVisibleIds } = this.props if ( - !idsEqual( + !visibleIdsEqual( this.getVisibleIds( prevProps.selection, prevProps.selectionFilter, prevProps.combinedVisibleIds - ) ?? [], + ), this.getVisibleIds( selection, selectionFilter, combinedVisibleIds - ) ?? [] + ) ) ) { this.updateVisibleIds() diff --git a/src/components/map/layers/__tests__/Layer.spec.js b/src/components/map/layers/__tests__/Layer.spec.js index 4046bdc0fa..d953b94bd6 100644 --- a/src/components/map/layers/__tests__/Layer.spec.js +++ b/src/components/map/layers/__tests__/Layer.spec.js @@ -1,4 +1,4 @@ -import Layer from '../Layer.js' +import Layer, { visibleIdsEqual } from '../Layer.js' const createLayer = (props) => { const instance = Object.create(Layer.prototype) @@ -130,6 +130,63 @@ describe('Layer#getVisibleIds', () => { }) }) +describe('visibleIdsEqual', () => { + test('null and an empty array are not equal - null means "show everything", [] means "show nothing"', () => { + expect(visibleIdsEqual(null, [])).toBe(false) + expect(visibleIdsEqual([], null)).toBe(false) + }) + + test('null and null are equal', () => { + expect(visibleIdsEqual(null, null)).toBe(true) + }) + + test('two empty arrays are equal', () => { + expect(visibleIdsEqual([], [])).toBe(true) + }) + + test('two arrays with the same ids in the same order are equal', () => { + expect(visibleIdsEqual(['a', 'b'], ['a', 'b'])).toBe(true) + }) + + test('two arrays with different ids are not equal', () => { + expect(visibleIdsEqual(['a'], ['b'])).toBe(false) + }) +}) + +describe('Layer#handleVisibleIdsChange', () => { + test('calls setVisibleIds when combinedVisibleIds clears from an all-excluded entry back to no restriction - the bug where a table filter that hid every feature left the map stuck once filters were cleared', () => { + const setVisibleIds = jest.fn() + const layer = createLayer({ + id: 'layer1', + data: [{ properties: { id: 'a' } }], + combinedVisibleIds: null, + }) + layer.layer = { setVisibleIds } + + layer.handleVisibleIdsChange({ + combinedVisibleIds: { layer1: [] }, + }) + + expect(setVisibleIds).toHaveBeenCalledWith(null) + }) + + test('does not call setVisibleIds when the visible ids are unchanged', () => { + const setVisibleIds = jest.fn() + const layer = createLayer({ + id: 'layer1', + data: [{ properties: { id: 'a' } }], + combinedVisibleIds: { layer1: ['a'] }, + }) + layer.layer = { setVisibleIds } + + layer.handleVisibleIdsChange({ + combinedVisibleIds: { layer1: ['a'] }, + }) + + expect(setVisibleIds).not.toHaveBeenCalled() + }) +}) + describe('Layer#getHoverIds', () => { test('returns an empty array when there is no feature', () => { const layer = createLayer({ id: 'layer1', feature: null }) From a2e88e9178efa87978781a8575d981bdfc959276 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 30 Jul 2026 17:19:15 +0200 Subject: [PATCH 175/205] feat: add hidden Earth Engine per-band columns to the single-layer data table maps-gl already computes and returns per-band aggregation values for multi-band layers (e.g. Population's age/sex groups) alongside the bandReducer-combined main value, but nothing surfaced them as columns. --- src/components/datatable/useTableData.js | 6 ++ src/util/__tests__/tableHeaders.spec.js | 90 ++++++++++++++++++++++++ src/util/tableHeaders.js | 83 ++++++++++++++++++---- 3 files changed, 165 insertions(+), 14 deletions(-) diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index e347859f0e..ba24acef3c 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -81,6 +81,8 @@ export const useTableData = ({ legend, styleDataItem, countEventsOutsideOrgUnits, + bands, + band, data, dataWithoutCoords, dataFilters, @@ -174,6 +176,8 @@ export const useTableData = ({ countEventsOutsideOrgUnits, aggregationType, legend, + bands, + band, data: dataWithAggregations, rawData: data, } @@ -197,6 +201,8 @@ export const useTableData = ({ legend, styleDataItem, countEventsOutsideOrgUnits, + bands, + band, dataWithAggregations, data, layerHeaders, diff --git a/src/util/__tests__/tableHeaders.spec.js b/src/util/__tests__/tableHeaders.spec.js index 299d77e4df..f2afb44f1c 100644 --- a/src/util/__tests__/tableHeaders.spec.js +++ b/src/util/__tests__/tableHeaders.spec.js @@ -403,6 +403,96 @@ describe('getHeadersForLayer - earth engine', () => { expect(meanHeader.name).toBe('Mean Rainfall') expect(meanHeader.type).toBe(TYPE_NUMBER) }) + + // Real band ids/names from src/constants/earthEngineLayers/population_age_sex_Worldpop-Global2.js + const populationBands = { + multiple: true, + list: [ + { id: 'm_00', name: 'Male 0 - 1 years' }, + { id: 'f_00', name: 'Female 0 - 1 years' }, + ], + } + + test('only 1 band selected: no per-band columns, even with a multi-stat bands.multiple layer', () => { + const result = getHeadersForLayer(EARTH_ENGINE_LAYER, { + aggregationType: ['sum', 'mean'], + legend: { title: 'Population', items: [] }, + bands: populationBands, + band: ['m_00'], + data: [{ sum: 100, mean: 10 }], + }) + expect(dataKeys(result)).toEqual( + expect.arrayContaining(['sum', 'mean']) + ) + expect(dataKeys(result)).not.toEqual( + expect.arrayContaining(['m_00', 'm_00_sum']) + ) + }) + + test('2+ bands, exactly 1 stat: one bare-band-id column per band, hidden by default', () => { + const result = getHeadersForLayer(EARTH_ENGINE_LAYER, { + aggregationType: ['sum'], + legend: { title: 'Population', items: [] }, + bands: populationBands, + band: ['m_00', 'f_00'], + data: [{ sum: 100, m_00: 60, f_00: 40 }], + }) + expect(dataKeys(result)).toEqual( + expect.arrayContaining(['sum', 'm_00', 'f_00']) + ) + const maleHeader = result.headers.find((h) => h.dataKey === 'm_00') + expect(maleHeader.name).toBe('Male 0 - 1 years') + expect(maleHeader.defaultHidden).toBe(true) + expect(maleHeader.type).toBe(TYPE_NUMBER) + }) + + test('band columns get a null roundFn (not a rounds-to-whole-numbers function) before any data has loaded', () => { + const result = getHeadersForLayer(EARTH_ENGINE_LAYER, { + aggregationType: ['sum'], + legend: { title: 'Population', items: [] }, + bands: populationBands, + band: ['m_00', 'f_00'], + data: undefined, + }) + const maleHeader = result.headers.find((h) => h.dataKey === 'm_00') + expect(maleHeader.roundFn).toBe(null) + }) + + test('2+ bands, 2+ stats: one title-cased ${band}_${type} column per band per stat, hidden by default', () => { + const result = getHeadersForLayer(EARTH_ENGINE_LAYER, { + aggregationType: ['sum', 'mean'], + legend: { title: 'Population', items: [] }, + bands: populationBands, + band: ['m_00', 'f_00'], + data: [{ sum: 100, mean: 10, m_00_sum: 60, m_00_mean: 6 }], + }) + expect(dataKeys(result)).toEqual( + expect.arrayContaining([ + 'sum', + 'mean', + 'm_00_sum', + 'm_00_mean', + 'f_00_sum', + 'f_00_mean', + ]) + ) + const header = result.headers.find((h) => h.dataKey === 'm_00_sum') + expect(header.name).toBe('Sum Male 0 - 1 Years') + expect(header.defaultHidden).toBe(true) + expect(header.roundFn(6.7891234)).toBe(6.789) + }) + + test('no bands config at all: unaffected, same as an ordinary non-multi-band EE layer', () => { + const result = getHeadersForLayer(EARTH_ENGINE_LAYER, { + aggregationType: ['sum', 'mean'], + legend: { title: 'NDVI', items: [] }, + data: [{ sum: 100, mean: 10 }], + }) + expect(dataKeys(result)).toEqual( + expect.arrayContaining(['sum', 'mean']) + ) + expect(result.headers).toHaveLength(7) + }) }) describe('getHeadersForLayer - geoJsonUrl', () => { diff --git a/src/util/tableHeaders.js b/src/util/tableHeaders.js index d556fca40f..475cfbb705 100644 --- a/src/util/tableHeaders.js +++ b/src/util/tableHeaders.js @@ -358,7 +358,58 @@ const toTitleCase = (str) => (txt) => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase() ) -const getEarthEngineHeaders = ({ aggregationType, legend, data }) => { +// Mirrors the roundFn derivation in the per-stat branch just above (only +// compute one once real data is available - getPrecision([]) returns 0, +// not undefined, so skipping this guard would round every value to whole +// numbers before any data has loaded). +const getFieldRoundFn = (data, dataKey) => { + if (!data?.length) { + return null + } + return getRoundToPrecisionFn(getPrecision(data.map((d) => d[dataKey]))) +} + +// One column per selected band the layer's own bandReducer combines into +// its main value (e.g. Population's age/sex groups) - maps-gl separately +// computes and returns these already (keyed `${bandId}_${type}`, or bare +// `${bandId}` when only one stat is selected), only surfacing them as +// columns is new here. A no-op unless 2+ bands are actually selected. +const getBandFields = ({ bands, band, aggregationType, data }) => { + if (!bands?.multiple || !Array.isArray(band) || band.length < 2) { + return [] + } + const selectedBands = bands.list?.filter((b) => band.includes(b.id)) ?? [] + return selectedBands.flatMap(({ id: bandId, name: bandName }) => + aggregationType.length === 1 + ? [ + { + name: bandName, + dataKey: bandId, + roundFn: getFieldRoundFn(data, bandId), + type: TYPE_NUMBER, + defaultHidden: true, + }, + ] + : aggregationType.map((type) => { + const dataKey = `${bandId}_${type}` + return { + name: toTitleCase(`${type} ${bandName}`), + dataKey, + roundFn: getFieldRoundFn(data, dataKey), + type: TYPE_NUMBER, + defaultHidden: true, + } + }) + ) +} + +const getEarthEngineHeaders = ({ + aggregationType, + legend, + data, + bands, + band, +}) => { const { title, items } = legend let customFields = [] @@ -371,19 +422,21 @@ const getEarthEngineHeaders = ({ aggregationType, legend, data }) => { type: TYPE_NUMBER, })) } else if (Array.isArray(aggregationType) && aggregationType.length) { - customFields = aggregationType.map((type) => { - let roundFn = null - if (data?.length) { - const precision = getPrecision(data.map((d) => d[type])) - roundFn = getRoundToPrecisionFn(precision) - } - return { - name: toTitleCase(`${type} ${title}`), - dataKey: type, - roundFn, - type: TYPE_NUMBER, - } - }) + customFields = aggregationType + .map((type) => { + let roundFn = null + if (data?.length) { + const precision = getPrecision(data.map((d) => d[type])) + roundFn = getRoundToPrecisionFn(precision) + } + return { + name: toTitleCase(`${type} ${title}`), + dataKey: type, + roundFn, + type: TYPE_NUMBER, + } + }) + .concat(getBandFields({ bands, band, aggregationType, data })) } return getOrgUnitCoreFields(i18n.t('Org unit id')) @@ -430,6 +483,8 @@ export const getHeadersForLayer = (layerType, ctx) => { aggregationType: ctx.aggregationType, legend: ctx.legend, data: ctx.data, + bands: ctx.bands, + band: ctx.band, }), } case FACILITY_LAYER: From b28cbee6c2749ce61a65b89697d04905e92eed7f Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 30 Jul 2026 17:22:54 +0200 Subject: [PATCH 176/205] feat: add hidden Earth Engine per-band columns to the Combined data table Mirrors the single-layer table's equivalent addition - same per-band values maps-gl already returns, surfaced as hidden-by-default columns in the Combined join too, using the layer's existing default aggregation type. --- .../datatable/useCombinedTableData.js | 3 +- src/util/__tests__/dataTable.spec.js | 117 ++++++++++++++++++ src/util/dataTable.js | 37 +++++- 3 files changed, 152 insertions(+), 5 deletions(-) diff --git a/src/components/datatable/useCombinedTableData.js b/src/components/datatable/useCombinedTableData.js index bd23587a67..8d108b6a99 100644 --- a/src/components/datatable/useCombinedTableData.js +++ b/src/components/datatable/useCombinedTableData.js @@ -203,7 +203,7 @@ export const useCombinedTableData = ({ defaultHidden: true, }, ...layerMatches.flatMap(({ layer, valueDataKeys }) => [ - ...valueDataKeys.map(({ dataKey, name }) => ({ + ...valueDataKeys.map(({ dataKey, name, defaultHidden }) => ({ name: name ? i18n.t('{{name}} ({{layer}})', { name, @@ -212,6 +212,7 @@ export const useCombinedTableData = ({ : i18n.t('Value ({{layer}})', { layer: layer.name }), dataKey: `${layer.combinedLayerKey}_${dataKey}`, type: TYPE_NUMBER, + defaultHidden, })), // Earth Engine has no separate categorical "legend" concept ...(layer.layer !== EARTH_ENGINE_LAYER diff --git a/src/util/__tests__/dataTable.spec.js b/src/util/__tests__/dataTable.spec.js index 84d4e01f92..f89756f485 100644 --- a/src/util/__tests__/dataTable.spec.js +++ b/src/util/__tests__/dataTable.spec.js @@ -83,6 +83,98 @@ describe('getCombinedValueDataKeys', () => { }) ).toEqual([]) }) + + // Real band ids/names from src/constants/earthEngineLayers/population_age_sex_Worldpop-Global2.js + const populationBands = { + multiple: true, + list: [ + { id: 'm_00', name: 'Male 0 - 1 years' }, + { id: 'f_00', name: 'Female 0 - 1 years' }, + ], + } + + test('only 1 band selected: no extra band columns', () => { + expect( + getCombinedValueDataKeys({ + layer: EARTH_ENGINE_LAYER, + aggregationType: ['sum', 'mean'], + legend: { title: 'Population' }, + bands: populationBands, + band: ['m_00'], + }) + ).toEqual([ + { dataKey: 'sum', name: 'Sum Population' }, + { dataKey: 'mean', name: 'Mean Population' }, + ]) + }) + + test('2+ bands, exactly 1 stat: one bare-band-id column per band, hidden by default', () => { + expect( + getCombinedValueDataKeys({ + layer: EARTH_ENGINE_LAYER, + aggregationType: ['sum'], + legend: { title: 'Population' }, + bands: populationBands, + band: ['m_00', 'f_00'], + }) + ).toEqual([ + { dataKey: 'sum', name: 'Sum Population' }, + { dataKey: 'm_00', name: 'Male 0 - 1 years', defaultHidden: true }, + { + dataKey: 'f_00', + name: 'Female 0 - 1 years', + defaultHidden: true, + }, + ]) + }) + + test('2+ bands, 2+ stats: one title-cased ${band}_${type} column per band per stat, hidden by default', () => { + expect( + getCombinedValueDataKeys({ + layer: EARTH_ENGINE_LAYER, + aggregationType: ['sum', 'mean'], + legend: { title: 'Population' }, + bands: populationBands, + band: ['m_00', 'f_00'], + }) + ).toEqual([ + { dataKey: 'sum', name: 'Sum Population' }, + { dataKey: 'mean', name: 'Mean Population' }, + { + dataKey: 'm_00_sum', + name: 'Sum Male 0 - 1 Years', + defaultHidden: true, + }, + { + dataKey: 'm_00_mean', + name: 'Mean Male 0 - 1 Years', + defaultHidden: true, + }, + { + dataKey: 'f_00_sum', + name: 'Sum Female 0 - 1 Years', + defaultHidden: true, + }, + { + dataKey: 'f_00_mean', + name: 'Mean Female 0 - 1 Years', + defaultHidden: true, + }, + ]) + }) + + test('no bands config at all: unaffected, same as an ordinary non-multi-band EE layer', () => { + expect( + getCombinedValueDataKeys({ + layer: EARTH_ENGINE_LAYER, + aggregationType: ['mean', 'max'], + legend: { title: 'NDVI' }, + }) + ).toEqual([ + { dataKey: 'mean', name: 'Mean Ndvi' }, + { dataKey: 'max', name: 'Max Ndvi' }, + ]) + }) }) describe('getDefaultCombinedAggregation', () => { @@ -166,6 +258,31 @@ describe('getDefaultCombinedAggregation', () => { }) ).toEqual({ 1: 'SUM' }) }) + + test('Earth Engine: per-band columns pick up the same default aggregation type as the main stat columns, with no extra mapping needed', () => { + expect( + getDefaultCombinedAggregation({ + layer: EARTH_ENGINE_LAYER, + aggregationType: ['sum', 'mean'], + legend: { title: 'Population' }, + bands: { + multiple: true, + list: [ + { id: 'm_00', name: 'Male 0 - 1 years' }, + { id: 'f_00', name: 'Female 0 - 1 years' }, + ], + }, + band: ['m_00', 'f_00'], + }) + ).toEqual({ + sum: 'SUM', + mean: 'SUM', + m_00_sum: 'SUM', + m_00_mean: 'SUM', + f_00_sum: 'SUM', + f_00_mean: 'SUM', + }) + }) }) const withOrgUnitRows = (layer, id) => ({ diff --git a/src/util/dataTable.js b/src/util/dataTable.js index 1da9ea7640..da5ba188e9 100644 --- a/src/util/dataTable.js +++ b/src/util/dataTable.js @@ -61,6 +61,31 @@ export const getDefaultCombinedAggregation = (layer) => { ) } +// One column per selected band the layer's own bandReducer combines into +// its main value (e.g. Population's age/sex groups) - mirrors +// tableHeaders.js's getBandFields for the single-layer table. A no-op +// unless 2+ bands are actually selected. +const getEarthEngineBandValueDataKeys = (layer) => { + if ( + !layer.bands?.multiple || + !Array.isArray(layer.band) || + layer.band.length < 2 + ) { + return [] + } + const selectedBands = + layer.bands.list?.filter((b) => layer.band.includes(b.id)) ?? [] + return selectedBands.flatMap(({ id: bandId, name: bandName }) => + layer.aggregationType.length === 1 + ? [{ dataKey: bandId, name: bandName, defaultHidden: true }] + : layer.aggregationType.map((type) => ({ + dataKey: `${bandId}_${type}`, + name: toTitleCase(`${type} ${bandName}`), + defaultHidden: true, + })) + ) +} + export const getCombinedValueDataKeys = (layer) => { if (layer.layer !== EARTH_ENGINE_LAYER) { return [{ dataKey: COMBINED_VALUE_KEY, name: null }] @@ -75,10 +100,14 @@ export const getCombinedValueDataKeys = (layer) => { })) } if (Array.isArray(layer.aggregationType) && layer.aggregationType.length) { - return layer.aggregationType.map((type) => ({ - dataKey: type, - name: toTitleCase(`${type} ${layer.legend?.title ?? ''}`.trim()), - })) + return layer.aggregationType + .map((type) => ({ + dataKey: type, + name: toTitleCase( + `${type} ${layer.legend?.title ?? ''}`.trim() + ), + })) + .concat(getEarthEngineBandValueDataKeys(layer)) } return [] } From b44bd9ea56b8135aaf2d21966cf80bb284f5cd85 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 30 Jul 2026 17:47:37 +0200 Subject: [PATCH 177/205] chore: remove comments --- src/util/dataTable.js | 4 ---- src/util/tableHeaders.js | 9 --------- 2 files changed, 13 deletions(-) diff --git a/src/util/dataTable.js b/src/util/dataTable.js index da5ba188e9..9c3d81f9fe 100644 --- a/src/util/dataTable.js +++ b/src/util/dataTable.js @@ -61,10 +61,6 @@ export const getDefaultCombinedAggregation = (layer) => { ) } -// One column per selected band the layer's own bandReducer combines into -// its main value (e.g. Population's age/sex groups) - mirrors -// tableHeaders.js's getBandFields for the single-layer table. A no-op -// unless 2+ bands are actually selected. const getEarthEngineBandValueDataKeys = (layer) => { if ( !layer.bands?.multiple || diff --git a/src/util/tableHeaders.js b/src/util/tableHeaders.js index 475cfbb705..fec289b699 100644 --- a/src/util/tableHeaders.js +++ b/src/util/tableHeaders.js @@ -358,10 +358,6 @@ const toTitleCase = (str) => (txt) => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase() ) -// Mirrors the roundFn derivation in the per-stat branch just above (only -// compute one once real data is available - getPrecision([]) returns 0, -// not undefined, so skipping this guard would round every value to whole -// numbers before any data has loaded). const getFieldRoundFn = (data, dataKey) => { if (!data?.length) { return null @@ -369,11 +365,6 @@ const getFieldRoundFn = (data, dataKey) => { return getRoundToPrecisionFn(getPrecision(data.map((d) => d[dataKey]))) } -// One column per selected band the layer's own bandReducer combines into -// its main value (e.g. Population's age/sex groups) - maps-gl separately -// computes and returns these already (keyed `${bandId}_${type}`, or bare -// `${bandId}` when only one stat is selected), only surfacing them as -// columns is new here. A no-op unless 2+ bands are actually selected. const getBandFields = ({ bands, band, aggregationType, data }) => { if (!bands?.multiple || !Array.isArray(band) || band.length < 2) { return [] From a4fce0d2ad19e49f881ff4d50e51e79e0c7b123c Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 30 Jul 2026 18:09:29 +0200 Subject: [PATCH 178/205] refactor: tag Combined value dataKeys with a kind (value/count/category) Pure plumbing, no behavior change - lays the groundwork for Facility/OrgUnit/Event/TrackedEntity layers to emit count-only or per-category columns instead of an always-empty rawValue column. --- src/constants/dataTable.js | 9 ++++ src/util/__tests__/dataTable.spec.js | 57 +++++++++++++++++------ src/util/dataTable.js | 68 ++++++++++++++++++++-------- 3 files changed, 101 insertions(+), 33 deletions(-) diff --git a/src/constants/dataTable.js b/src/constants/dataTable.js index fb455b0886..0a99848c94 100644 --- a/src/constants/dataTable.js +++ b/src/constants/dataTable.js @@ -29,3 +29,12 @@ export const ORG_UNIT_ID_DATA_KEY = 'orgUnitId' export const ORG_UNIT_LEVEL_DATA_KEY = 'level' export const COMBINED_HEADERS_KEY = '__combined__' + +// Combined table value-column kinds - see getCombinedValueDataKeys() +// (util/dataTable.js): a layer's per-dataKey Value column is either a real +// numeric value (existing aggregation-type dropdown), a plain feature +// count (no classification to break down), or a per-category count/% +// breakdown (the layer is styled by a discrete classification). +export const DATA_KEY_KIND_VALUE = 'value' +export const DATA_KEY_KIND_COUNT = 'count' +export const DATA_KEY_KIND_CATEGORY = 'category' diff --git a/src/util/__tests__/dataTable.spec.js b/src/util/__tests__/dataTable.spec.js index f89756f485..06ee9fafea 100644 --- a/src/util/__tests__/dataTable.spec.js +++ b/src/util/__tests__/dataTable.spec.js @@ -1,3 +1,4 @@ +import { DATA_KEY_KIND_VALUE } from '../../constants/dataTable.js' import { EARTH_ENGINE_LAYER, THEMATIC_LAYER, @@ -39,7 +40,7 @@ const withDataItem = (aggregationType) => ({ describe('getCombinedValueDataKeys', () => { test('returns a single generic rawValue column for any non-Earth-Engine layer', () => { expect(getCombinedValueDataKeys({ layer: THEMATIC_LAYER })).toEqual([ - { dataKey: 'rawValue', name: null }, + { dataKey: 'rawValue', name: null, kind: DATA_KEY_KIND_VALUE }, ]) }) @@ -51,8 +52,8 @@ describe('getCombinedValueDataKeys', () => { legend: { title: 'NDVI' }, }) ).toEqual([ - { dataKey: 'mean', name: 'Mean Ndvi' }, - { dataKey: 'max', name: 'Max Ndvi' }, + { dataKey: 'mean', name: 'Mean Ndvi', kind: DATA_KEY_KIND_VALUE }, + { dataKey: 'max', name: 'Max Ndvi', kind: DATA_KEY_KIND_VALUE }, ]) }) @@ -69,8 +70,8 @@ describe('getCombinedValueDataKeys', () => { }, }) ).toEqual([ - { dataKey: '1', name: 'Forest' }, - { dataKey: '2', name: 'Water' }, + { dataKey: '1', name: 'Forest', kind: DATA_KEY_KIND_VALUE }, + { dataKey: '2', name: 'Water', kind: DATA_KEY_KIND_VALUE }, ]) }) @@ -103,8 +104,16 @@ describe('getCombinedValueDataKeys', () => { band: ['m_00'], }) ).toEqual([ - { dataKey: 'sum', name: 'Sum Population' }, - { dataKey: 'mean', name: 'Mean Population' }, + { + dataKey: 'sum', + name: 'Sum Population', + kind: DATA_KEY_KIND_VALUE, + }, + { + dataKey: 'mean', + name: 'Mean Population', + kind: DATA_KEY_KIND_VALUE, + }, ]) }) @@ -118,11 +127,21 @@ describe('getCombinedValueDataKeys', () => { band: ['m_00', 'f_00'], }) ).toEqual([ - { dataKey: 'sum', name: 'Sum Population' }, - { dataKey: 'm_00', name: 'Male 0 - 1 years', defaultHidden: true }, + { + dataKey: 'sum', + name: 'Sum Population', + kind: DATA_KEY_KIND_VALUE, + }, + { + dataKey: 'm_00', + name: 'Male 0 - 1 years', + kind: DATA_KEY_KIND_VALUE, + defaultHidden: true, + }, { dataKey: 'f_00', name: 'Female 0 - 1 years', + kind: DATA_KEY_KIND_VALUE, defaultHidden: true, }, ]) @@ -138,26 +157,38 @@ describe('getCombinedValueDataKeys', () => { band: ['m_00', 'f_00'], }) ).toEqual([ - { dataKey: 'sum', name: 'Sum Population' }, - { dataKey: 'mean', name: 'Mean Population' }, + { + dataKey: 'sum', + name: 'Sum Population', + kind: DATA_KEY_KIND_VALUE, + }, + { + dataKey: 'mean', + name: 'Mean Population', + kind: DATA_KEY_KIND_VALUE, + }, { dataKey: 'm_00_sum', name: 'Sum Male 0 - 1 Years', + kind: DATA_KEY_KIND_VALUE, defaultHidden: true, }, { dataKey: 'm_00_mean', name: 'Mean Male 0 - 1 Years', + kind: DATA_KEY_KIND_VALUE, defaultHidden: true, }, { dataKey: 'f_00_sum', name: 'Sum Female 0 - 1 Years', + kind: DATA_KEY_KIND_VALUE, defaultHidden: true, }, { dataKey: 'f_00_mean', name: 'Mean Female 0 - 1 Years', + kind: DATA_KEY_KIND_VALUE, defaultHidden: true, }, ]) @@ -171,8 +202,8 @@ describe('getCombinedValueDataKeys', () => { legend: { title: 'NDVI' }, }) ).toEqual([ - { dataKey: 'mean', name: 'Mean Ndvi' }, - { dataKey: 'max', name: 'Max Ndvi' }, + { dataKey: 'mean', name: 'Mean Ndvi', kind: DATA_KEY_KIND_VALUE }, + { dataKey: 'max', name: 'Max Ndvi', kind: DATA_KEY_KIND_VALUE }, ]) }) }) diff --git a/src/util/dataTable.js b/src/util/dataTable.js index 9c3d81f9fe..e426450ef2 100644 --- a/src/util/dataTable.js +++ b/src/util/dataTable.js @@ -1,5 +1,9 @@ import { bbox } from '@turf/bbox' -import { SORT_ASCENDING, SORT_DESCENDING } from '../constants/dataTable.js' +import { + DATA_KEY_KIND_VALUE, + SORT_ASCENDING, + SORT_DESCENDING, +} from '../constants/dataTable.js' import { DATA_TABLE_LAYER_TYPES, EARTH_ENGINE_LAYER, @@ -36,28 +40,36 @@ const toTitleCase = (str) => (txt) => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase() ) -export const getDefaultCombinedAggregation = (layer) => { - let type +// Only meaningful for DATA_KEY_KIND_VALUE dataKeys - a real numeric value's +// own aggregation strategy. Count/category dataKeys (added by later +// per-layer-type branches in getCombinedValueDataKeys) always default to +// 'COUNT' instead, handled uniformly in getDefaultCombinedAggregation below +// rather than here. +const getValueAggregationType = (layer) => { if (Array.isArray(layer.aggregationType)) { - type = getDefaultCombinedAggregationTypeFromEarthEngineStat( + return getDefaultCombinedAggregationTypeFromEarthEngineStat( layer.aggregationType[0] ) - } else if ( - CLASSIFIED_EARTH_ENGINE_AGGREGATION_TYPES.has(layer.aggregationType) - ) { - type = - CLASSIFIED_EARTH_ENGINE_DEFAULT_AGGREGATION_TYPE[ - layer.aggregationType - ] - } else { - const dataItem = getDataItemFromColumns(layer.columns) - type = getDefaultCombinedAggregationType( - dataItem?.aggregationType, - dataItem?.dimensionItemType - ) } + if (CLASSIFIED_EARTH_ENGINE_AGGREGATION_TYPES.has(layer.aggregationType)) { + return CLASSIFIED_EARTH_ENGINE_DEFAULT_AGGREGATION_TYPE[ + layer.aggregationType + ] + } + const dataItem = getDataItemFromColumns(layer.columns) + return getDefaultCombinedAggregationType( + dataItem?.aggregationType, + dataItem?.dimensionItemType + ) +} + +export const getDefaultCombinedAggregation = (layer) => { + const valueType = getValueAggregationType(layer) return Object.fromEntries( - getCombinedValueDataKeys(layer).map(({ dataKey }) => [dataKey, type]) + getCombinedValueDataKeys(layer).map(({ dataKey, kind }) => [ + dataKey, + kind === DATA_KEY_KIND_VALUE ? valueType : 'COUNT', + ]) ) } @@ -73,10 +85,18 @@ const getEarthEngineBandValueDataKeys = (layer) => { layer.bands.list?.filter((b) => layer.band.includes(b.id)) ?? [] return selectedBands.flatMap(({ id: bandId, name: bandName }) => layer.aggregationType.length === 1 - ? [{ dataKey: bandId, name: bandName, defaultHidden: true }] + ? [ + { + dataKey: bandId, + name: bandName, + kind: DATA_KEY_KIND_VALUE, + defaultHidden: true, + }, + ] : layer.aggregationType.map((type) => ({ dataKey: `${bandId}_${type}`, name: toTitleCase(`${type} ${bandName}`), + kind: DATA_KEY_KIND_VALUE, defaultHidden: true, })) ) @@ -84,7 +104,13 @@ const getEarthEngineBandValueDataKeys = (layer) => { export const getCombinedValueDataKeys = (layer) => { if (layer.layer !== EARTH_ENGINE_LAYER) { - return [{ dataKey: COMBINED_VALUE_KEY, name: null }] + return [ + { + dataKey: COMBINED_VALUE_KEY, + name: null, + kind: DATA_KEY_KIND_VALUE, + }, + ] } if ( CLASSIFIED_EARTH_ENGINE_AGGREGATION_TYPES.has(layer.aggregationType) && @@ -93,6 +119,7 @@ export const getCombinedValueDataKeys = (layer) => { return layer.legend.items.map(({ value, name }) => ({ dataKey: String(value), name, + kind: DATA_KEY_KIND_VALUE, })) } if (Array.isArray(layer.aggregationType) && layer.aggregationType.length) { @@ -102,6 +129,7 @@ export const getCombinedValueDataKeys = (layer) => { name: toTitleCase( `${type} ${layer.legend?.title ?? ''}`.trim() ), + kind: DATA_KEY_KIND_VALUE, })) .concat(getEarthEngineBandValueDataKeys(layer)) } From a9b81499cc0a49009420391e0a6031298bd89a43 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 30 Jul 2026 18:13:38 +0200 Subject: [PATCH 179/205] feat: add categorical/count Combined value columns for Facility/OrgUnit layers A layer styled by a real organisation unit group set now gets one count/percentage column per group (plus Unclassified, if configured) instead of an always-empty Value column. Layers with no group set configured - including OrgUnit's own level-fallback legend, which must not be mistaken for a real category breakdown - get a single Count column instead. --- src/util/__tests__/dataTable.spec.js | 121 ++++++++++++++++++++++++++- src/util/dataTable.js | 48 +++++++++++ 2 files changed, 168 insertions(+), 1 deletion(-) diff --git a/src/util/__tests__/dataTable.spec.js b/src/util/__tests__/dataTable.spec.js index 06ee9fafea..6d53e6f799 100644 --- a/src/util/__tests__/dataTable.spec.js +++ b/src/util/__tests__/dataTable.spec.js @@ -1,4 +1,8 @@ -import { DATA_KEY_KIND_VALUE } from '../../constants/dataTable.js' +import { + DATA_KEY_KIND_CATEGORY, + DATA_KEY_KIND_COUNT, + DATA_KEY_KIND_VALUE, +} from '../../constants/dataTable.js' import { EARTH_ENGINE_LAYER, THEMATIC_LAYER, @@ -14,6 +18,7 @@ import { getDefaultCombinedAggregation, getDefaultReferenceRows, getEligibleDataTableLayers, + getFeatureCategoryKey, getLayerSelectedIds, getNextSorting, getPanelHeights, @@ -206,6 +211,120 @@ describe('getCombinedValueDataKeys', () => { { dataKey: 'max', name: 'Max Ndvi', kind: DATA_KEY_KIND_VALUE }, ]) }) + + describe('Facility/OrgUnit group-set categorical columns', () => { + const groupedLayer = (layer, items) => ({ + layer, + organisationUnitGroupSet: { id: 'groupSet1' }, + legend: { items }, + }) + + test('grouped Facility with 2+ groups: one category column per group, keyed by id', () => { + expect( + getCombinedValueDataKeys( + groupedLayer(FACILITY_LAYER, [ + { id: 'group1', name: 'Hospital' }, + { id: 'group2', name: 'Clinic' }, + ]) + ) + ).toEqual([ + { + dataKey: 'group1', + name: 'Hospital', + kind: DATA_KEY_KIND_CATEGORY, + }, + { + dataKey: 'group2', + name: 'Clinic', + kind: DATA_KEY_KIND_CATEGORY, + }, + ]) + }) + + test('grouped OrgUnit with an Unclassified item present: keyed as the unclassified sentinel', () => { + expect( + getCombinedValueDataKeys( + groupedLayer(ORG_UNIT_LAYER, [ + { id: 'group1', name: 'Hospital' }, + { name: 'Unclassified' }, + ]) + ) + ).toEqual([ + { + dataKey: 'group1', + name: 'Hospital', + kind: DATA_KEY_KIND_CATEGORY, + }, + { + dataKey: 'unclassified', + name: 'Unclassified', + kind: DATA_KEY_KIND_CATEGORY, + }, + ]) + }) + + test('grouped with exactly 1 group: falls back to count-only', () => { + expect( + getCombinedValueDataKeys( + groupedLayer(FACILITY_LAYER, [ + { id: 'group1', name: 'Hospital' }, + ]) + ) + ).toEqual([ + { dataKey: 'count', name: null, kind: DATA_KEY_KIND_COUNT }, + ]) + }) + + test('ungrouped Facility: count-only', () => { + expect( + getCombinedValueDataKeys({ + layer: FACILITY_LAYER, + legend: { items: [{ name: 'Facility' }] }, + }) + ).toEqual([ + { dataKey: 'count', name: null, kind: DATA_KEY_KIND_COUNT }, + ]) + }) + + test('ungrouped OrgUnit with a level-fallback legend (items.length > 1, no group set): count-only, not one column per level', () => { + expect( + getCombinedValueDataKeys({ + layer: ORG_UNIT_LAYER, + legend: { + items: [ + { name: 'Level 1' }, + { name: 'Level 2' }, + { name: 'Level 3' }, + ], + }, + }) + ).toEqual([ + { dataKey: 'count', name: null, kind: DATA_KEY_KIND_COUNT }, + ]) + }) + }) +}) + +describe('getFeatureCategoryKey - Facility/OrgUnit', () => { + const layer = { + layer: FACILITY_LAYER, + organisationUnitGroupSet: { id: 'groupSet1' }, + } + + test("returns the feature's own group id for the layer's group set dimension", () => { + expect( + getFeatureCategoryKey(layer, { + dimensions: { groupSet1: 'group1' }, + }) + ).toBe('group1') + }) + + test('returns the unclassified sentinel when the feature has no value for that dimension', () => { + expect(getFeatureCategoryKey(layer, { dimensions: {} })).toBe( + 'unclassified' + ) + expect(getFeatureCategoryKey(layer, {})).toBe('unclassified') + }) }) describe('getDefaultCombinedAggregation', () => { diff --git a/src/util/dataTable.js b/src/util/dataTable.js index e426450ef2..bc5ef81e5a 100644 --- a/src/util/dataTable.js +++ b/src/util/dataTable.js @@ -1,5 +1,7 @@ import { bbox } from '@turf/bbox' import { + DATA_KEY_KIND_CATEGORY, + DATA_KEY_KIND_COUNT, DATA_KEY_KIND_VALUE, SORT_ASCENDING, SORT_DESCENDING, @@ -21,6 +23,8 @@ import { getDataItemFromColumns, getOrgUnitsFromRows } from './analytics.js' import { getJoinableFeatures } from './combinedJoinMatch.js' export const COMBINED_VALUE_KEY = 'rawValue' +export const COMBINED_COUNT_KEY = 'count' +export const UNCLASSIFIED_CATEGORY_KEY = 'unclassified' const CLASSIFIED_EARTH_ENGINE_AGGREGATION_TYPES = new Set([ 'percentage', @@ -102,7 +106,51 @@ const getEarthEngineBandValueDataKeys = (layer) => { ) } +// True only when the layer is actually styled by a real organisation unit +// group set with 2+ resulting legend buckets - NOT when Facility/OrgUnit +// happen to have a multi-item legend for another reason (OrgUnit's +// no-group-set fallback styles by level instead, which also produces +// legend.items.length > 1 but is a structural hierarchy artifact, not a +// meaningful join category - see getStyledOrgUnits, util/orgUnits.js). +const isOrgUnitGroupSetCategorical = (layer) => + !!layer.organisationUnitGroupSet?.id && + (layer.legend?.items?.length ?? 0) > 1 + +const getOrgUnitGroupValueDataKeys = (layer) => { + if (!isOrgUnitGroupSetCategorical(layer)) { + return [ + { + dataKey: COMBINED_COUNT_KEY, + name: null, + kind: DATA_KEY_KIND_COUNT, + }, + ] + } + return layer.legend.items.map(({ id, name }) => ({ + dataKey: id ?? UNCLASSIFIED_CATEGORY_KEY, + name, + kind: DATA_KEY_KIND_CATEGORY, + })) +} + +// Given a layer and a matched feature's properties, returns the category +// dataKey that feature belongs to (see getCombinedValueDataKeys' per-type +// branches for how that dataKey set is built). Only meaningful for +// DATA_KEY_KIND_CATEGORY columns. +export const getFeatureCategoryKey = (layer, props) => { + if (layer.layer === FACILITY_LAYER || layer.layer === ORG_UNIT_LAYER) { + return ( + props.dimensions?.[layer.organisationUnitGroupSet?.id] ?? + UNCLASSIFIED_CATEGORY_KEY + ) + } + return UNCLASSIFIED_CATEGORY_KEY +} + export const getCombinedValueDataKeys = (layer) => { + if (layer.layer === FACILITY_LAYER || layer.layer === ORG_UNIT_LAYER) { + return getOrgUnitGroupValueDataKeys(layer) + } if (layer.layer !== EARTH_ENGINE_LAYER) { return [ { From a345cd9426a8b0ee57d7db9cc936557c09fefba3 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 30 Jul 2026 18:18:21 +0200 Subject: [PATCH 180/205] feat: add categorical/numeric/count Combined value columns for Event layers An Event layer styled by a numeric data item keeps the standard aggregation-type dropdown (reading properties.value instead of the never-set rawValue). Any non-numeric styling with more than one legend item - option-set, boolean, or a plain/text item with "No data" enabled - becomes one category column per legend item, keyed by colorGroup rather than name (the display value and legend name diverge for the "No data" bucket). Everything else gets a single Count column. --- src/util/__tests__/dataTable.spec.js | 181 +++++++++++++++++++++++++++ src/util/dataTable.js | 44 +++++++ 2 files changed, 225 insertions(+) diff --git a/src/util/__tests__/dataTable.spec.js b/src/util/__tests__/dataTable.spec.js index 6d53e6f799..7a34046629 100644 --- a/src/util/__tests__/dataTable.spec.js +++ b/src/util/__tests__/dataTable.spec.js @@ -327,6 +327,140 @@ describe('getFeatureCategoryKey - Facility/OrgUnit', () => { }) }) +describe('getCombinedValueDataKeys - Event layers', () => { + test('no styleDataItem: count-only', () => { + expect( + getCombinedValueDataKeys({ + layer: EVENT_LAYER, + legend: { items: [{ name: 'Event', colorGroup: 0 }] }, + }) + ).toEqual([{ dataKey: 'count', name: null, kind: DATA_KEY_KIND_COUNT }]) + }) + + test('styleDataItem on a numeric value type: single value-kind entry, even when its own legend has 2+ classification bins', () => { + expect( + getCombinedValueDataKeys({ + layer: EVENT_LAYER, + styleDataItem: { id: 'de1', valueType: 'NUMBER' }, + legend: { + items: [ + { name: 'Low', colorGroup: 0 }, + { name: 'High', colorGroup: 1 }, + ], + }, + }) + ).toEqual([{ dataKey: 'value', name: null, kind: DATA_KEY_KIND_VALUE }]) + }) + + test('styleDataItem.optionSet with 3 options: 3 category entries keyed by colorGroup', () => { + expect( + getCombinedValueDataKeys({ + layer: EVENT_LAYER, + styleDataItem: { id: 'de1', optionSet: { id: 'os1' } }, + legend: { + items: [ + { name: 'Option A', colorGroup: 0 }, + { name: 'Option B', colorGroup: 1 }, + { name: 'Option C', colorGroup: 2 }, + ], + }, + }) + ).toEqual([ + { dataKey: '0', name: 'Option A', kind: DATA_KEY_KIND_CATEGORY }, + { dataKey: '1', name: 'Option B', kind: DATA_KEY_KIND_CATEGORY }, + { dataKey: '2', name: 'Option C', kind: DATA_KEY_KIND_CATEGORY }, + ]) + }) + + test('boolean styleDataItem (Yes/No): 2 category entries', () => { + expect( + getCombinedValueDataKeys({ + layer: EVENT_LAYER, + styleDataItem: { id: 'de1', valueType: 'BOOLEAN' }, + legend: { + items: [ + { name: 'Yes', colorGroup: 0 }, + { name: 'No', colorGroup: 1 }, + ], + }, + }) + ).toEqual([ + { dataKey: '0', name: 'Yes', kind: DATA_KEY_KIND_CATEGORY }, + { dataKey: '1', name: 'No', kind: DATA_KEY_KIND_CATEGORY }, + ]) + }) + + test('optionSet + Unclassified/No data legends configured: extra category entries keyed by their own colorGroup, not by name', () => { + expect( + getCombinedValueDataKeys({ + layer: EVENT_LAYER, + styleDataItem: { id: 'de1', optionSet: { id: 'os1' } }, + legend: { + items: [ + { name: 'Option A', colorGroup: 0 }, + { name: 'Unclassified', colorGroup: 1 }, + { name: 'No data', colorGroup: 2 }, + ], + }, + }) + ).toEqual([ + { dataKey: '0', name: 'Option A', kind: DATA_KEY_KIND_CATEGORY }, + { + dataKey: '1', + name: 'Unclassified', + kind: DATA_KEY_KIND_CATEGORY, + }, + { dataKey: '2', name: 'No data', kind: DATA_KEY_KIND_CATEGORY }, + ]) + }) + + test('styleDataItem on a TEXT value type with No data legend configured (2-item legend): 2 category columns, not count-only (resolved carve-out)', () => { + expect( + getCombinedValueDataKeys({ + layer: EVENT_LAYER, + styleDataItem: { id: 'de1', valueType: 'TEXT' }, + legend: { + items: [ + { name: 'Event', colorGroup: 0 }, + { name: 'No data', colorGroup: 1 }, + ], + }, + }) + ).toEqual([ + { dataKey: '0', name: 'Event', kind: DATA_KEY_KIND_CATEGORY }, + { dataKey: '1', name: 'No data', kind: DATA_KEY_KIND_CATEGORY }, + ]) + }) + + test('styleDataItem on a TEXT value type with no No data legend (1-item legend): count-only', () => { + expect( + getCombinedValueDataKeys({ + layer: EVENT_LAYER, + styleDataItem: { id: 'de1', valueType: 'TEXT' }, + legend: { items: [{ name: 'Event', colorGroup: 0 }] }, + }) + ).toEqual([{ dataKey: 'count', name: null, kind: DATA_KEY_KIND_COUNT }]) + }) + + test('single-option optionSet: falls back to count-only', () => { + expect( + getCombinedValueDataKeys({ + layer: EVENT_LAYER, + styleDataItem: { id: 'de1', optionSet: { id: 'os1' } }, + legend: { items: [{ name: 'Option A', colorGroup: 0 }] }, + }) + ).toEqual([{ dataKey: 'count', name: null, kind: DATA_KEY_KIND_COUNT }]) + }) +}) + +describe('getFeatureCategoryKey - Event', () => { + const layer = { layer: EVENT_LAYER } + + test("returns the feature's own colorGroup, stringified", () => { + expect(getFeatureCategoryKey(layer, { colorGroup: 1 })).toBe('1') + }) +}) + describe('getDefaultCombinedAggregation', () => { test("defaults to the data item's own aggregation type", () => { expect(getDefaultCombinedAggregation(withDataItem('AVERAGE'))).toEqual({ @@ -433,6 +567,53 @@ describe('getDefaultCombinedAggregation', () => { f_00_mean: 'SUM', }) }) + + test('Facility/OrgUnit: count-only and category dataKeys both default to COUNT', () => { + expect( + getDefaultCombinedAggregation({ + layer: FACILITY_LAYER, + legend: { items: [{ name: 'Facility' }] }, + }) + ).toEqual({ count: 'COUNT' }) + + expect( + getDefaultCombinedAggregation({ + layer: ORG_UNIT_LAYER, + organisationUnitGroupSet: { id: 'groupSet1' }, + legend: { + items: [ + { id: 'group1', name: 'Hospital' }, + { id: 'group2', name: 'Clinic' }, + ], + }, + }) + ).toEqual({ group1: 'COUNT', group2: 'COUNT' }) + }) + + test('Event: a numeric styleDataItem defaults to SUM - there is no per-data-item aggregationType metadata for event data elements the way there is for Thematic', () => { + expect( + getDefaultCombinedAggregation({ + layer: EVENT_LAYER, + styleDataItem: { id: 'de1', valueType: 'NUMBER' }, + legend: { items: [{ name: 'Low' }, { name: 'High' }] }, + }) + ).toEqual({ value: 'SUM' }) + }) + + test('Event: category dataKeys default to COUNT', () => { + expect( + getDefaultCombinedAggregation({ + layer: EVENT_LAYER, + styleDataItem: { id: 'de1', optionSet: { id: 'os1' } }, + legend: { + items: [ + { name: 'Option A', colorGroup: 0 }, + { name: 'Option B', colorGroup: 1 }, + ], + }, + }) + ).toEqual({ 0: 'COUNT', 1: 'COUNT' }) + }) }) const withOrgUnitRows = (layer, id) => ({ diff --git a/src/util/dataTable.js b/src/util/dataTable.js index bc5ef81e5a..957c65750c 100644 --- a/src/util/dataTable.js +++ b/src/util/dataTable.js @@ -15,6 +15,7 @@ import { EVENT_LAYER, TRACKED_ENTITY_LAYER, } from '../constants/layers.js' +import { numberValueTypes } from '../constants/valueTypes.js' import { getDefaultCombinedAggregationType, getDefaultCombinedAggregationTypeFromEarthEngineStat, @@ -25,6 +26,7 @@ import { getJoinableFeatures } from './combinedJoinMatch.js' export const COMBINED_VALUE_KEY = 'rawValue' export const COMBINED_COUNT_KEY = 'count' export const UNCLASSIFIED_CATEGORY_KEY = 'unclassified' +export const EVENT_STYLE_VALUE_KEY = 'value' const CLASSIFIED_EARTH_ENGINE_AGGREGATION_TYPES = new Set([ 'percentage', @@ -133,6 +135,42 @@ const getOrgUnitGroupValueDataKeys = (layer) => { })) } +// Event's own numeric-styled value takes priority over its legend's item +// count: a numeric styleDataItem's legend is just classification bins for +// coloring, not real discrete categories, so it stays on the standard +// aggregation-type path like Thematic - regardless of how many bins that +// legend happens to have. Anything else with more than one legend item +// (option-set, boolean, or a plain/text styleDataItem with a "No data" +// legend enabled - styleByDataItem.js) is a real category breakdown, +// keyed by colorGroup (the one property stampLegendItems/addFeature stamp +// identically onto both the legend item and every feature styled with it, +// unlike the display value/legend name which can diverge - see e.g. the +// "No data" bucket's value ("Not set") vs. its legend name ("No data")). +const getEventValueDataKeys = (layer) => { + if ( + layer.styleDataItem && + numberValueTypes.includes(layer.styleDataItem.valueType) + ) { + return [ + { + dataKey: EVENT_STYLE_VALUE_KEY, + name: null, + kind: DATA_KEY_KIND_VALUE, + }, + ] + } + if ((layer.legend?.items?.length ?? 0) > 1) { + return layer.legend.items.map(({ colorGroup, name }) => ({ + dataKey: String(colorGroup), + name, + kind: DATA_KEY_KIND_CATEGORY, + })) + } + return [ + { dataKey: COMBINED_COUNT_KEY, name: null, kind: DATA_KEY_KIND_COUNT }, + ] +} + // Given a layer and a matched feature's properties, returns the category // dataKey that feature belongs to (see getCombinedValueDataKeys' per-type // branches for how that dataKey set is built). Only meaningful for @@ -144,6 +182,9 @@ export const getFeatureCategoryKey = (layer, props) => { UNCLASSIFIED_CATEGORY_KEY ) } + if (layer.layer === EVENT_LAYER) { + return String(props.colorGroup) + } return UNCLASSIFIED_CATEGORY_KEY } @@ -151,6 +192,9 @@ export const getCombinedValueDataKeys = (layer) => { if (layer.layer === FACILITY_LAYER || layer.layer === ORG_UNIT_LAYER) { return getOrgUnitGroupValueDataKeys(layer) } + if (layer.layer === EVENT_LAYER) { + return getEventValueDataKeys(layer) + } if (layer.layer !== EARTH_ENGINE_LAYER) { return [ { From 3c1fc874a10a3e694d7a684807bc31c49e405e34 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 30 Jul 2026 18:20:15 +0200 Subject: [PATCH 181/205] feat: add count-only Combined value column for TrackedEntity layers TE has no classification/styling support in the loader today, so it always gets a single Count column instead of an always-empty rawValue column, regardless of legend shape. --- src/util/__tests__/dataTable.spec.js | 20 ++++++++++++++++++++ src/util/dataTable.js | 14 ++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/util/__tests__/dataTable.spec.js b/src/util/__tests__/dataTable.spec.js index 7a34046629..3c44155200 100644 --- a/src/util/__tests__/dataTable.spec.js +++ b/src/util/__tests__/dataTable.spec.js @@ -461,6 +461,26 @@ describe('getFeatureCategoryKey - Event', () => { }) }) +describe('getCombinedValueDataKeys - TrackedEntity layers', () => { + test('always count-only, regardless of legend shape (TE has no classification support today)', () => { + expect( + getCombinedValueDataKeys({ + layer: TRACKED_ENTITY_LAYER, + legend: { items: [{ name: 'Person' }] }, + }) + ).toEqual([{ dataKey: 'count', name: null, kind: DATA_KEY_KIND_COUNT }]) + }) + + test('still count-only even given a multi-item legend, defending against future drift', () => { + expect( + getCombinedValueDataKeys({ + layer: TRACKED_ENTITY_LAYER, + legend: { items: [{ name: 'Type A' }, { name: 'Type B' }] }, + }) + ).toEqual([{ dataKey: 'count', name: null, kind: DATA_KEY_KIND_COUNT }]) + }) +}) + describe('getDefaultCombinedAggregation', () => { test("defaults to the data item's own aggregation type", () => { expect(getDefaultCombinedAggregation(withDataItem('AVERAGE'))).toEqual({ diff --git a/src/util/dataTable.js b/src/util/dataTable.js index 957c65750c..800817f122 100644 --- a/src/util/dataTable.js +++ b/src/util/dataTable.js @@ -195,6 +195,20 @@ export const getCombinedValueDataKeys = (layer) => { if (layer.layer === EVENT_LAYER) { return getEventValueDataKeys(layer) } + // Tracked entity layers have no classification/styling support today + // (trackedEntityLoader.js never consumes styleDataItem, despite + // TrackedEntityDialog.jsx rendering the control for it) - always + // count-only, regardless of legend shape, until that's built as its + // own feature. + if (layer.layer === TRACKED_ENTITY_LAYER) { + return [ + { + dataKey: COMBINED_COUNT_KEY, + name: null, + kind: DATA_KEY_KIND_COUNT, + }, + ] + } if (layer.layer !== EARTH_ENGINE_LAYER) { return [ { From dba1187532cd70f7b34ea09af791019de6f629fb Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 30 Jul 2026 18:37:58 +0200 Subject: [PATCH 182/205] feat: compute count/category row values and headers in the Combined table applyLayerMatchToRow now branches per dataKey kind: count columns take the raw matched-feature count, category columns take a count or a percentage of the row's own matched features depending on the join setting, and value columns keep the existing aggregation-type path unchanged. Header naming/rounding follows the same kind. Two pre-existing spatial-join tests used an unstyled Event layer as a generic rawValue fixture - updated to reflect that an unstyled Event layer is now correctly count-only instead. --- .../__tests__/useCombinedTableData.spec.js | 197 +++++++++++++++++- .../datatable/useCombinedTableData.js | 99 +++++++-- 2 files changed, 272 insertions(+), 24 deletions(-) diff --git a/src/components/datatable/__tests__/useCombinedTableData.spec.js b/src/components/datatable/__tests__/useCombinedTableData.spec.js index 786ebccef3..addf0470ef 100644 --- a/src/components/datatable/__tests__/useCombinedTableData.spec.js +++ b/src/components/datatable/__tests__/useCombinedTableData.spec.js @@ -1,5 +1,9 @@ import { renderHook } from '@testing-library/react' -import { EARTH_ENGINE_LAYER, EVENT_LAYER } from '../../../constants/layers.js' +import { + EARTH_ENGINE_LAYER, + EVENT_LAYER, + FACILITY_LAYER, +} from '../../../constants/layers.js' import { useCombinedTableData } from '../useCombinedTableData.js' const feature = (props) => ({ properties: props }) @@ -364,7 +368,7 @@ describe('useCombinedTableData - spatial join', () => { id: 'points', name: 'Points', combinedLayerKey: 'points', - layer: 'event', + layer: 'geoJsonUrl', data: [ { type: 'Feature', @@ -411,7 +415,7 @@ describe('useCombinedTableData - spatial join', () => { data: [ { type: 'Feature', - properties: { id: 'e1', rawValue: 7 }, + properties: { id: 'e1' }, geometry: { type: 'Polygon', coordinates: [ @@ -430,7 +434,7 @@ describe('useCombinedTableData - spatial join', () => { ] const joinConfig = { layers: { - events: { type: 'spatial', aggregation: { rawValue: 'SUM' } }, + events: { type: 'spatial', aggregation: {} }, }, } @@ -445,7 +449,8 @@ describe('useCombinedTableData - spatial join', () => { const row1 = result.current.rows.find( (r) => findCell(r, 'id').value === 'ou1' ) - expect(findCell(row1, 'events_rawValue').value).toBe(7) + // An unstyled Event layer is count-only (see getCombinedValueDataKeys) + expect(findCell(row1, 'events_count').value).toBe(1) }) test('matches via centroid regardless of layer type - not just Event/TrackedEntity', () => { @@ -501,7 +506,7 @@ describe('useCombinedTableData - spatial join', () => { id: 'points', name: 'Points', combinedLayerKey: 'points', - layer: 'event', + layer: 'geoJsonUrl', data: [ { type: 'Feature', @@ -896,3 +901,183 @@ describe('useCombinedTableData - show only features in view', () => { ) }) }) + +describe('useCombinedTableData - categorical/count value columns', () => { + const facilityLayer = (data) => ({ + id: 'facility1', + name: 'Facilities', + combinedLayerKey: 'facility1', + layer: FACILITY_LAYER, + organisationUnitGroupSet: { id: 'groupSet1' }, + legend: { + items: [ + { id: 'group1', name: 'Hospital' }, + { id: 'group2', name: 'Clinic' }, + ], + }, + data, + }) + + test('a category column reflects the matched-feature count for that category, and switches to a percentage when the setting is PERCENTAGE', () => { + const layer = facilityLayer([ + feature({ + id: 'f1', + orgUnitPath: '/country1/ou1', + dimensions: { groupSet1: 'group1' }, + }), + feature({ + id: 'f2', + orgUnitPath: '/country1/ou1', + dimensions: { groupSet1: 'group2' }, + }), + ]) + + const { result } = renderHook(() => + useCombinedTableData({ + layers: [layer], + referenceLayer, + joinConfig: { + layers: { facility1: { type: 'orgUnit', aggregation: {} } }, + }, + }) + ) + const row1 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' + ) + expect(findCell(row1, 'facility1_group1').value).toBe(1) + + const { result: percentResult } = renderHook(() => + useCombinedTableData({ + layers: [layer], + referenceLayer, + joinConfig: { + layers: { + facility1: { + type: 'orgUnit', + aggregation: { group1: 'PERCENTAGE' }, + }, + }, + }, + }) + ) + const percentRow1 = percentResult.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' + ) + expect(findCell(percentRow1, 'facility1_group1').value).toBe(50) + }) + + test('a row with 0 matched features: both category columns are null, not NaN/0', () => { + const layer = facilityLayer([]) + + const { result } = renderHook(() => + useCombinedTableData({ + layers: [layer], + referenceLayer, + joinConfig: { + layers: { facility1: { type: 'orgUnit', aggregation: {} } }, + }, + }) + ) + const row1 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' + ) + expect(findCell(row1, 'facility1_group1').value).toBe(null) + expect(findCell(row1, 'facility1_group2').value).toBe(null) + }) + + test('an Event count-only column reflects raw matches.length for a matched row, and null for an unmatched row', () => { + const layer = { + id: 'events1', + name: 'Events', + combinedLayerKey: 'events1', + layer: EVENT_LAYER, + legend: { items: [{ name: 'Event' }] }, + data: [ + feature({ id: 'e1', orgUnitPath: '/country1/ou1' }), + feature({ id: 'e2', orgUnitPath: '/country1/ou1' }), + ], + } + + const { result } = renderHook(() => + useCombinedTableData({ + layers: [layer], + referenceLayer, + joinConfig: { + layers: { events1: { type: 'orgUnit', aggregation: {} } }, + }, + }) + ) + const row1 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' + ) + const row2 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou2' + ) + expect(findCell(row1, 'events1_count').value).toBe(2) + expect(findCell(row2, 'events1_count').value).toBe(null) + }) + + test('header name for a count-only Facility column', () => { + const layer = { + id: 'facility1', + name: 'My Facilities', + combinedLayerKey: 'facility1', + layer: FACILITY_LAYER, + legend: { items: [{ name: 'Facility' }] }, + data: [], + } + + const { result } = renderHook(() => + useCombinedTableData({ + layers: [layer], + referenceLayer, + joinConfig: { + layers: { facility1: { type: 'orgUnit', aggregation: {} } }, + }, + }) + ) + const header = result.current.headers.find( + (h) => h.dataKey === 'facility1_count' + ) + expect(header.name).toBe('Count (My Facilities)') + }) + + test('header name for a category column: count mode vs percentage mode, including rounding', () => { + const layer = facilityLayer([]) + + const { result: countResult } = renderHook(() => + useCombinedTableData({ + layers: [layer], + referenceLayer, + joinConfig: { + layers: { facility1: { type: 'orgUnit', aggregation: {} } }, + }, + }) + ) + const countHeader = countResult.current.headers.find( + (h) => h.dataKey === 'facility1_group1' + ) + expect(countHeader.name).toBe('Hospital (count) (Facilities)') + expect(countHeader.roundFn).toBeUndefined() + + const { result: percentResult } = renderHook(() => + useCombinedTableData({ + layers: [layer], + referenceLayer, + joinConfig: { + layers: { + facility1: { + type: 'orgUnit', + aggregation: { group1: 'PERCENTAGE' }, + }, + }, + }, + }) + ) + const percentHeader = percentResult.current.headers.find( + (h) => h.dataKey === 'facility1_group1' + ) + expect(percentHeader.name).toBe('Hospital (%) (Facilities)') + expect(percentHeader.roundFn(33.456)).toBe(33.5) + }) +}) diff --git a/src/components/datatable/useCombinedTableData.js b/src/components/datatable/useCombinedTableData.js index 8d108b6a99..ee80bd8b43 100644 --- a/src/components/datatable/useCombinedTableData.js +++ b/src/components/datatable/useCombinedTableData.js @@ -1,6 +1,8 @@ import i18n from '@dhis2/d2-i18n' import { useMemo } from 'react' import { + DATA_KEY_KIND_CATEGORY, + DATA_KEY_KIND_COUNT, ORG_UNIT_LEVEL_DATA_KEY, SORT_ASCENDING, TYPE_NUMBER, @@ -20,9 +22,11 @@ import { import { getCombinedValueDataKeys, getDefaultCombinedAggregation, + getFeatureCategoryKey, } from '../../util/dataTable.js' import { filterByGlobalSearch, filterData } from '../../util/filter.js' import { isFeatureInBounds } from '../../util/geojson.js' +import { getRoundToPrecisionFn } from '../../util/numbers.js' import { buildRowCells, getColumnDistinctValues, @@ -96,13 +100,36 @@ const applyLayerMatchToRow = ({ row, featureIds, refProps }, layerMatch) => { const { layer, settings, byReferenceId, valueDataKeys } = layerMatch const matches = byReferenceId.get(refProps.id) ?? [] - valueDataKeys.forEach(({ dataKey }) => { - const values = matches.map((p) => p[dataKey]).filter((v) => v != null) - row[`${layer.combinedLayerKey}_${dataKey}`] = applyAggregation( + valueDataKeys.forEach(({ dataKey, kind }) => { + const rowKey = `${layer.combinedLayerKey}_${dataKey}` + const effectiveType = settings.aggregation?.[dataKey] ?? - getDefaultCombinedAggregation(layer)[dataKey], - values - ) + getDefaultCombinedAggregation(layer)[dataKey] + + if (kind === DATA_KEY_KIND_COUNT) { + // A rollup that matched nothing reads as "no data" (null, same + // as every other column), not a real business zero. + row[rowKey] = matches.length || null + return + } + + if (kind === DATA_KEY_KIND_CATEGORY) { + if (!matches.length) { + row[rowKey] = null + return + } + const inCategory = matches.filter( + (p) => getFeatureCategoryKey(layer, p) === dataKey + ).length + row[rowKey] = + effectiveType === 'PERCENTAGE' + ? (inCategory / matches.length) * 100 + : inCategory + return + } + + const values = matches.map((p) => p[dataKey]).filter((v) => v != null) + row[rowKey] = applyAggregation(effectiveType, values) }) if (layer.layer !== EARTH_ENGINE_LAYER) { @@ -121,6 +148,50 @@ const applyLayerMatchToRow = ({ row, featureIds, refProps }, layerMatch) => { } } +const getValueDataKeyHeader = ( + layer, + settings, + { dataKey, name, kind, defaultHidden } +) => { + const rowKey = `${layer.combinedLayerKey}_${dataKey}` + + if (kind === DATA_KEY_KIND_COUNT) { + return { + name: i18n.t('Count ({{layer}})', { layer: layer.name }), + dataKey: rowKey, + type: TYPE_NUMBER, + defaultHidden, + } + } + + if (kind === DATA_KEY_KIND_CATEGORY) { + const effectiveType = + settings.aggregation?.[dataKey] ?? + getDefaultCombinedAggregation(layer)[dataKey] + const isPercentage = effectiveType === 'PERCENTAGE' + return { + name: i18n.t('{{name}} ({{unit}}) ({{layer}})', { + name, + unit: isPercentage ? i18n.t('%') : i18n.t('count'), + layer: layer.name, + }), + dataKey: rowKey, + type: TYPE_NUMBER, + defaultHidden, + ...(isPercentage ? { roundFn: getRoundToPrecisionFn(1) } : {}), + } + } + + return { + name: name + ? i18n.t('{{name}} ({{layer}})', { name, layer: layer.name }) + : i18n.t('Value ({{layer}})', { layer: layer.name }), + dataKey: rowKey, + type: TYPE_NUMBER, + defaultHidden, + } +} + const EMPTY_COLUMN_OPTIONS = {} const EMPTY_HEADERS = [] @@ -202,18 +273,10 @@ export const useCombinedTableData = ({ type: TYPE_NUMBER, defaultHidden: true, }, - ...layerMatches.flatMap(({ layer, valueDataKeys }) => [ - ...valueDataKeys.map(({ dataKey, name, defaultHidden }) => ({ - name: name - ? i18n.t('{{name}} ({{layer}})', { - name, - layer: layer.name, - }) - : i18n.t('Value ({{layer}})', { layer: layer.name }), - dataKey: `${layer.combinedLayerKey}_${dataKey}`, - type: TYPE_NUMBER, - defaultHidden, - })), + ...layerMatches.flatMap(({ layer, settings, valueDataKeys }) => [ + ...valueDataKeys.map((valueDataKey) => + getValueDataKeyHeader(layer, settings, valueDataKey) + ), // Earth Engine has no separate categorical "legend" concept ...(layer.layer !== EARTH_ENGINE_LAYER ? [ From 83aa44e267d1c1c26d0834edefbf9ebeb32a5afe Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 30 Jul 2026 18:44:36 +0200 Subject: [PATCH 183/205] feat: add Count/Percentage selector for categorical Combined join columns Count-only columns now show a plain "Count" label with nothing to configure. Category columns get a Count/Percentage select in place of the full aggregation-type dropdown, reusing the same onChange handler and settings shape. Value-kind columns (Thematic/Earth Engine) are unaffected. --- .../__tests__/JoinLayersControl.spec.jsx | 91 +++++++ .../datatable/controls/JoinLayersControl.jsx | 231 +++++++++++------- src/constants/aggregationTypes.js | 6 + 3 files changed, 238 insertions(+), 90 deletions(-) diff --git a/src/components/datatable/__tests__/JoinLayersControl.spec.jsx b/src/components/datatable/__tests__/JoinLayersControl.spec.jsx index be28e9b627..c953832ead 100644 --- a/src/components/datatable/__tests__/JoinLayersControl.spec.jsx +++ b/src/components/datatable/__tests__/JoinLayersControl.spec.jsx @@ -2,6 +2,7 @@ import { render, fireEvent, screen, within } from '@testing-library/react' import React from 'react' import { EARTH_ENGINE_LAYER, + FACILITY_LAYER, GEOJSON_URL_LAYER, THEMATIC_LAYER, } from '../../../constants/layers.js' @@ -540,3 +541,93 @@ describe('JoinLayersControl popover — unmatched features warning', () => { expect(getWarning()).not.toBeInTheDocument() }) }) + +describe('JoinLayersControl popover — count/category value columns', () => { + const countOnlyFacility = { + id: 'facility1', + name: 'Facilities', + combinedLayerKey: 'facility1', + layer: FACILITY_LAYER, + legend: { items: [{ name: 'Facility' }] }, + data: [{ properties: { orgUnitPath: '/country1/ou1' } }], + } + + const categoricalFacility = { + id: 'facility1', + name: 'Facilities', + combinedLayerKey: 'facility1', + layer: FACILITY_LAYER, + organisationUnitGroupSet: { id: 'groupSet1' }, + legend: { + items: [ + { id: 'group1', name: 'Hospital' }, + { id: 'group2', name: 'Clinic' }, + ], + }, + data: [{ properties: { orgUnitPath: '/country1/ou1' } }], + } + + test('a count-only layer shows a static "Count" label, not an aggregation-type select', () => { + renderControl({ + eligibleLayers: [countOnlyFacility], + layersConfig: { + facility1: { + type: 'orgUnit', + aggregation: { count: 'COUNT' }, + }, + }, + }) + openPicker() + + expect(screen.getByText('Count')).toBeInTheDocument() + expect( + screen.queryByLabelText('Aggregation type for Facilities') + ).not.toBeInTheDocument() + }) + + test('a category layer shows a Count/Percentage select per legend item, and changing it updates only that dataKey', () => { + const onChange = jest.fn() + renderControl({ + eligibleLayers: [categoricalFacility], + layersConfig: { + facility1: { + type: 'orgUnit', + aggregation: { group1: 'COUNT', group2: 'COUNT' }, + }, + }, + onChange, + }) + openPicker() + + const hospitalSelect = screen.getByLabelText( + 'Aggregation type for Hospital (Facilities)' + ) + expect( + within(hospitalSelect).getByText('Percentage') + ).toBeInTheDocument() + expect(within(hospitalSelect).getByText('Count')).toBeInTheDocument() + + fireEvent.change(hospitalSelect, { target: { value: 'PERCENTAGE' } }) + + expect(onChange).toHaveBeenCalledWith({ + facility1: { + type: 'orgUnit', + aggregation: { group1: 'PERCENTAGE', group2: 'COUNT' }, + }, + }) + }) + + test('a value-kind layer (Thematic/Earth Engine) is unaffected - keeps the full aggregation-type select', () => { + renderControl({ + layersConfig: { + layer1: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + }) + openPicker() + + const select = screen.getByLabelText('Aggregation type for Layer 1') + expect(within(select).getByText('Average')).toBeInTheDocument() + expect(within(select).getByText('Sum')).toBeInTheDocument() + expect(within(select).queryByText('Percentage')).not.toBeInTheDocument() + }) +}) diff --git a/src/components/datatable/controls/JoinLayersControl.jsx b/src/components/datatable/controls/JoinLayersControl.jsx index 699eea4d88..240800dad1 100644 --- a/src/components/datatable/controls/JoinLayersControl.jsx +++ b/src/components/datatable/controls/JoinLayersControl.jsx @@ -2,8 +2,15 @@ import i18n from '@dhis2/d2-i18n' import { IconWarningFilled16, Tooltip } from '@dhis2/ui' import PropTypes from 'prop-types' import React, { useRef, useState } from 'react' -import { getCombinedAggregationTypes } from '../../../constants/aggregationTypes.js' -import { ORG_UNIT_PATH_DATA_KEY } from '../../../constants/dataTable.js' +import { + getCategoryValueDisplayTypes, + getCombinedAggregationTypes, +} from '../../../constants/aggregationTypes.js' +import { + DATA_KEY_KIND_CATEGORY, + DATA_KEY_KIND_COUNT, + ORG_UNIT_PATH_DATA_KEY, +} from '../../../constants/dataTable.js' import { NON_COMPOSABLE_AGGREGATION_TYPES } from '../../../util/aggregation.js' import { getUnmatchedFeatureCount, @@ -196,104 +203,148 @@ const JoinLayersControl = ({ </div> {getCombinedValueDataKeys( layer - ).map(({ dataKey, name }) => { - const effectiveType = - settings.aggregation?.[ - dataKey - ] ?? - defaultAggregation[ - dataKey - ] - const showWarning = - hasRollup && - NON_COMPOSABLE_AGGREGATION_TYPES.has( - effectiveType - ) - return ( - <div - key={dataKey} - className={ - styles.aggregationRow - } - > - {name && ( - <span + ).map( + ({ + dataKey, + name, + kind, + }) => { + if ( + kind === + DATA_KEY_KIND_COUNT + ) { + return ( + <div + key={ + dataKey + } className={ - styles.aggregationRowLabel + styles.aggregationRow } > - {name} - </span> - )} - <select - aria-label={ - name - ? i18n.t( - 'Aggregation type for {{name}} ({{layer}})', - { - name, - layer: layer.name, - } - ) - : i18n.t( - 'Aggregation type for {{layer}}', - { - layer: layer.name, - } - ) - } - value={ - effectiveType - } - onChange={(e) => - onAggregationChange( - layer.combinedLayerKey, - dataKey, - e.target - .value - ) + <span + className={ + styles.aggregationRowLabel + } + > + {i18n.t( + 'Count' + )} + </span> + </div> + ) + } + + const effectiveType = + settings + .aggregation?.[ + dataKey + ] ?? + defaultAggregation[ + dataKey + ] + const showWarning = + hasRollup && + NON_COMPOSABLE_AGGREGATION_TYPES.has( + effectiveType + ) + const options = + kind === + DATA_KEY_KIND_CATEGORY + ? getCategoryValueDisplayTypes() + : aggregationTypes + + return ( + <div + key={dataKey} + className={ + styles.aggregationRow } > - {aggregationTypes.map( - (type) => ( - <option - key={ - type.id - } - value={ - type.id - } - > - { - type.name - } - </option> - ) - )} - </select> - {showWarning && ( - <Tooltip - content={i18n.t( - 'Several {{layer}} features roll up into each reference org unit here - {{type}} is an approximation of the values you can see joined in, not a recomputation over the combined area.', - { - layer: layer.name, - type: effectiveType, - } - )} - > + {name && ( <span className={ - styles.aggregationWarning + styles.aggregationRowLabel } - data-test={`data-table-join-aggregation-warning-${layer.id}-${dataKey}`} > - <IconWarningFilled16 /> + {name} </span> - </Tooltip> - )} - </div> - ) - })} + )} + <select + aria-label={ + name + ? i18n.t( + 'Aggregation type for {{name}} ({{layer}})', + { + name, + layer: layer.name, + } + ) + : i18n.t( + 'Aggregation type for {{layer}}', + { + layer: layer.name, + } + ) + } + value={ + effectiveType + } + onChange={( + e + ) => + onAggregationChange( + layer.combinedLayerKey, + dataKey, + e + .target + .value + ) + } + > + {options.map( + ( + type + ) => ( + <option + key={ + type.id + } + value={ + type.id + } + > + { + type.name + } + </option> + ) + )} + </select> + {showWarning && ( + <Tooltip + content={i18n.t( + 'Several {{layer}} features roll up into each reference org unit here - {{type}} is an approximation of the values you can see joined in, not a recomputation over the combined area.', + { + layer: layer.name, + type: effectiveType, + } + )} + > + <span + className={ + styles.aggregationWarning + } + data-test={`data-table-join-aggregation-warning-${layer.id}-${dataKey}`} + > + <IconWarningFilled16 /> + </span> + </Tooltip> + )} + </div> + ) + } + )} </div> )} </div> diff --git a/src/constants/aggregationTypes.js b/src/constants/aggregationTypes.js index 53a8a9d207..939a64746f 100644 --- a/src/constants/aggregationTypes.js +++ b/src/constants/aggregationTypes.js @@ -16,6 +16,12 @@ export const getThematicAggregationTypes = () => [ export const getCombinedAggregationTypes = () => getThematicAggregationTypes().filter((type) => type.id !== 'DEFAULT') +// Combined data table join - categorical value columns (DATA_KEY_KIND_CATEGORY) +export const getCategoryValueDisplayTypes = () => [ + { id: 'PERCENTAGE', name: i18n.t('Percentage') }, + { id: 'COUNT', name: i18n.t('Count') }, +] + // Earth Engine layer export const getEarthEngineStatisticTypes = () => [ { id: 'percentage', name: i18n.t('Percentage') }, From a7d961e33563c532699b2b653848fb53b9886718 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 30 Jul 2026 19:30:34 +0200 Subject: [PATCH 184/205] fix: exclude non-numeric Event values from Combined value-column aggregation An Event layer styled by a numeric data item stamps properties.value with the literal string "Not set" on features with no value when "No data" styling is enabled - previously reaching applyAggregation unfiltered, silently corrupting SUM into string concatenation and AVERAGE/MIN/MAX/STDDEV/VARIANCE into NaN whenever a matched event had no value. Filter to Number.isFinite instead of just non-null. --- .../__tests__/useCombinedTableData.spec.js | 38 +++++++++++++++++++ .../datatable/useCombinedTableData.js | 10 ++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/components/datatable/__tests__/useCombinedTableData.spec.js b/src/components/datatable/__tests__/useCombinedTableData.spec.js index addf0470ef..006fa0751b 100644 --- a/src/components/datatable/__tests__/useCombinedTableData.spec.js +++ b/src/components/datatable/__tests__/useCombinedTableData.spec.js @@ -1017,6 +1017,44 @@ describe('useCombinedTableData - categorical/count value columns', () => { expect(findCell(row2, 'events1_count').value).toBe(null) }) + test("a numeric Event value column ignores 'Not set' features instead of corrupting the aggregation - styleByNumeric (styleByDataItem.js) stamps that literal string on no-data features when 'No data' styling is enabled", () => { + const layer = { + id: 'events1', + name: 'Events', + combinedLayerKey: 'events1', + layer: EVENT_LAYER, + styleDataItem: { id: 'de1', valueType: 'NUMBER' }, + legend: { items: [{ name: 'Low' }, { name: 'High' }] }, + data: [ + feature({ id: 'e1', orgUnitPath: '/country1/ou1', value: 10 }), + feature({ + id: 'e2', + orgUnitPath: '/country1/ou1', + value: 'Not set', + }), + ], + } + + const { result } = renderHook(() => + useCombinedTableData({ + layers: [layer], + referenceLayer, + joinConfig: { + layers: { + events1: { + type: 'orgUnit', + aggregation: { value: 'SUM' }, + }, + }, + }, + }) + ) + const row1 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' + ) + expect(findCell(row1, 'events1_value').value).toBe(10) + }) + test('header name for a count-only Facility column', () => { const layer = { id: 'facility1', diff --git a/src/components/datatable/useCombinedTableData.js b/src/components/datatable/useCombinedTableData.js index ee80bd8b43..fccb51cd53 100644 --- a/src/components/datatable/useCombinedTableData.js +++ b/src/components/datatable/useCombinedTableData.js @@ -128,7 +128,15 @@ const applyLayerMatchToRow = ({ row, featureIds, refProps }, layerMatch) => { return } - const values = matches.map((p) => p[dataKey]).filter((v) => v != null) + // Number.isFinite, not just != null: an Event layer styled by a + // numeric data item stamps properties.value with the literal + // string i18n.t('Not set') for features with no value when "No + // data" styling is enabled (styleByDataItem.js's styleByNumeric) - + // letting that string reach applyAggregation would silently + // corrupt SUM into string concatenation and AVERAGE/etc. into NaN. + const values = matches + .map((p) => p[dataKey]) + .filter((v) => Number.isFinite(v)) row[rowKey] = applyAggregation(effectiveType, values) }) From cbfccd4794788ce5cfb368dab4eb31c699d8a082 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Fri, 31 Jul 2026 12:01:28 +0200 Subject: [PATCH 185/205] chore: PR clean-up --- .../datatable/useCombinedTableData.js | 6 --- src/constants/dataTable.js | 5 --- src/util/dataTable.js | 37 ++----------------- 3 files changed, 4 insertions(+), 44 deletions(-) diff --git a/src/components/datatable/useCombinedTableData.js b/src/components/datatable/useCombinedTableData.js index fccb51cd53..8c650cf8dc 100644 --- a/src/components/datatable/useCombinedTableData.js +++ b/src/components/datatable/useCombinedTableData.js @@ -128,12 +128,6 @@ const applyLayerMatchToRow = ({ row, featureIds, refProps }, layerMatch) => { return } - // Number.isFinite, not just != null: an Event layer styled by a - // numeric data item stamps properties.value with the literal - // string i18n.t('Not set') for features with no value when "No - // data" styling is enabled (styleByDataItem.js's styleByNumeric) - - // letting that string reach applyAggregation would silently - // corrupt SUM into string concatenation and AVERAGE/etc. into NaN. const values = matches .map((p) => p[dataKey]) .filter((v) => Number.isFinite(v)) diff --git a/src/constants/dataTable.js b/src/constants/dataTable.js index 0a99848c94..f5457f0b0d 100644 --- a/src/constants/dataTable.js +++ b/src/constants/dataTable.js @@ -30,11 +30,6 @@ export const ORG_UNIT_LEVEL_DATA_KEY = 'level' export const COMBINED_HEADERS_KEY = '__combined__' -// Combined table value-column kinds - see getCombinedValueDataKeys() -// (util/dataTable.js): a layer's per-dataKey Value column is either a real -// numeric value (existing aggregation-type dropdown), a plain feature -// count (no classification to break down), or a per-category count/% -// breakdown (the layer is styled by a discrete classification). export const DATA_KEY_KIND_VALUE = 'value' export const DATA_KEY_KIND_COUNT = 'count' export const DATA_KEY_KIND_CATEGORY = 'category' diff --git a/src/util/dataTable.js b/src/util/dataTable.js index 800817f122..c3063d1978 100644 --- a/src/util/dataTable.js +++ b/src/util/dataTable.js @@ -46,11 +46,6 @@ const toTitleCase = (str) => (txt) => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase() ) -// Only meaningful for DATA_KEY_KIND_VALUE dataKeys - a real numeric value's -// own aggregation strategy. Count/category dataKeys (added by later -// per-layer-type branches in getCombinedValueDataKeys) always default to -// 'COUNT' instead, handled uniformly in getDefaultCombinedAggregation below -// rather than here. const getValueAggregationType = (layer) => { if (Array.isArray(layer.aggregationType)) { return getDefaultCombinedAggregationTypeFromEarthEngineStat( @@ -108,12 +103,6 @@ const getEarthEngineBandValueDataKeys = (layer) => { ) } -// True only when the layer is actually styled by a real organisation unit -// group set with 2+ resulting legend buckets - NOT when Facility/OrgUnit -// happen to have a multi-item legend for another reason (OrgUnit's -// no-group-set fallback styles by level instead, which also produces -// legend.items.length > 1 but is a structural hierarchy artifact, not a -// meaningful join category - see getStyledOrgUnits, util/orgUnits.js). const isOrgUnitGroupSetCategorical = (layer) => !!layer.organisationUnitGroupSet?.id && (layer.legend?.items?.length ?? 0) > 1 @@ -135,17 +124,6 @@ const getOrgUnitGroupValueDataKeys = (layer) => { })) } -// Event's own numeric-styled value takes priority over its legend's item -// count: a numeric styleDataItem's legend is just classification bins for -// coloring, not real discrete categories, so it stays on the standard -// aggregation-type path like Thematic - regardless of how many bins that -// legend happens to have. Anything else with more than one legend item -// (option-set, boolean, or a plain/text styleDataItem with a "No data" -// legend enabled - styleByDataItem.js) is a real category breakdown, -// keyed by colorGroup (the one property stampLegendItems/addFeature stamp -// identically onto both the legend item and every feature styled with it, -// unlike the display value/legend name which can diverge - see e.g. the -// "No data" bucket's value ("Not set") vs. its legend name ("No data")). const getEventValueDataKeys = (layer) => { if ( layer.styleDataItem && @@ -171,10 +149,6 @@ const getEventValueDataKeys = (layer) => { ] } -// Given a layer and a matched feature's properties, returns the category -// dataKey that feature belongs to (see getCombinedValueDataKeys' per-type -// branches for how that dataKey set is built). Only meaningful for -// DATA_KEY_KIND_CATEGORY columns. export const getFeatureCategoryKey = (layer, props) => { if (layer.layer === FACILITY_LAYER || layer.layer === ORG_UNIT_LAYER) { return ( @@ -195,11 +169,6 @@ export const getCombinedValueDataKeys = (layer) => { if (layer.layer === EVENT_LAYER) { return getEventValueDataKeys(layer) } - // Tracked entity layers have no classification/styling support today - // (trackedEntityLoader.js never consumes styleDataItem, despite - // TrackedEntityDialog.jsx rendering the control for it) - always - // count-only, regardless of legend shape, until that's built as its - // own feature. if (layer.layer === TRACKED_ENTITY_LAYER) { return [ { @@ -286,8 +255,10 @@ export const getDefaultReferenceRows = (mapViews = []) => { })) if (candidates.length) { - return candidates.reduce((best, candidate) => - isBetterReferenceCandidate(candidate, best) ? candidate : best + return candidates.reduce( + (best, candidate) => + isBetterReferenceCandidate(candidate, best) ? candidate : best, + candidates[0] ).mapView.rows } From 9ddcf2b1450d8aced4bd1b9e4a39fa6fb2c3549b Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Fri, 31 Jul 2026 18:07:21 +0200 Subject: [PATCH 186/205] chore: PR clean up --- i18n/en.pot | 62 ++++++-- src/components/datatable/BottomPanel.jsx | 19 ++- .../datatable/__tests__/BottomPanel.spec.jsx | 58 +++++++ .../__tests__/JoinLayersControl.spec.jsx | 45 ++++-- .../__tests__/useCombinedTableData.spec.js | 7 +- .../datatable/controls/JoinLayersControl.jsx | 150 +++++++++++++++--- .../datatable/useCombinedTableData.js | 24 +-- src/util/__tests__/dataTable.spec.js | 8 +- src/util/dataTable.js | 12 +- 9 files changed, 315 insertions(+), 70 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 16489d58f4..b0d9e90b4a 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-30T12:27:53.587Z\n" -"PO-Revision-Date: 2026-07-30T12:27:53.587Z\n" +"POT-Creation-Date: 2026-07-31T15:09:46.348Z\n" +"PO-Revision-Date: 2026-07-31T15:09:46.349Z\n" msgid "2020" msgstr "2020" @@ -349,12 +349,47 @@ msgstr "Org unit" msgid "Spatial" msgstr "Spatial" +msgid "" +"{{count}} feature(s) from {{layer}} could not be matched to a reference org " +"unit (wrong level, no matching parent, or outside every boundary) and will " +"be excluded from the Combined table." +msgid_plural "" +"{{count}} feature(s) from {{layer}} could not be matched to a reference org " +"unit (wrong level, no matching parent, or outside every boundary) and will " +"be excluded from the Combined table." +msgstr[0] "" +"{{count}} feature(s) from {{layer}} could not be matched to a reference org " +"unit (wrong level, no matching parent, or outside every boundary) and will " +"be excluded from the Combined table." +msgstr[1] "" +"{{count}} feature(s) from {{layer}} could not be matched to a reference org " +"unit (wrong level, no matching parent, or outside every boundary) and will " +"be excluded from the Combined table." + +msgid "Count" +msgstr "Count" + msgid "Aggregation type for {{name}} ({{layer}})" msgstr "Aggregation type for {{name}} ({{layer}})" msgid "Aggregation type for {{layer}}" msgstr "Aggregation type for {{layer}}" +msgid "" +"Several {{layer}} features roll up into each reference org unit here - " +"{{type}} is an approximation of the values you can see joined in, not a " +"recomputation over the combined area." +msgstr "" +"Several {{layer}} features roll up into each reference org unit here - " +"{{type}} is an approximation of the values you can see joined in, not a " +"recomputation over the combined area." + +msgid "Categories" +msgstr "Categories" + +msgid "Category display for {{layer}}" +msgstr "Category display for {{layer}}" + msgid "Choose a data table to view" msgstr "Choose a data table to view" @@ -373,11 +408,17 @@ msgstr "{{total}} rows" msgid "Show only features in current map view" msgstr "Show only features in current map view" -msgid "Org unit id" -msgstr "Org unit id" +msgid "Count ({{layer}})" +msgstr "Count ({{layer}})" -msgid "Org unit level" -msgstr "Org unit level" +msgid "{{name}} ({{unit}}) ({{layer}})" +msgstr "{{name}} ({{unit}}) ({{layer}})" + +msgid "%" +msgstr "%" + +msgid "count" +msgstr "count" msgid "{{name}} ({{layer}})" msgstr "{{name}} ({{layer}})" @@ -385,6 +426,12 @@ msgstr "{{name}} ({{layer}})" msgid "Value ({{layer}})" msgstr "Value ({{layer}})" +msgid "Org unit id" +msgstr "Org unit id" + +msgid "Org unit level" +msgstr "Org unit level" + msgid "Legend ({{layer}})" msgstr "Legend ({{layer}})" @@ -1276,9 +1323,6 @@ msgstr "Tracked Entity Type" msgid "By data element" msgstr "By data element" -msgid "Count" -msgstr "Count" - msgid "Average" msgstr "Average" diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index 60605ab4ae..cb2fa7c579 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -76,6 +76,10 @@ const BottomPanel = () => { const joinLayersConfig = referenceLayer?.combinedJoinConfig ?? EMPTY_JOIN_LAYERS + const combinedJoinConfig = useMemo( + () => ({ layers: joinLayersConfig }), + [joinLayersConfig] + ) const combinedColumnConfig = referenceLayer?.combinedColumnConfig ?? null const combinedLayers = useMemo( () => mapViews.filter((l) => joinLayersConfig[l.combinedLayerKey]), @@ -131,7 +135,10 @@ const BottomPanel = () => { const onControlsDoubleClick = useCallback( (e) => { - if (e.target.closest('button, input, label, select')) { + if ( + e.target.closest('button, input, label, select') || + !e.currentTarget.contains(e.target) + ) { return } toggleCollapsed() @@ -246,9 +253,13 @@ const BottomPanel = () => { useEffect(() => { const observer = new ResizeObserver(() => { - if (panelRef.current) { - setPanelWidth(panelRef.current.getBoundingClientRect().width) + if (!panelRef.current) { + return } + const width = Math.round( + panelRef.current.getBoundingClientRect().width + ) + setPanelWidth((prev) => (prev === width ? prev : width)) }) if (panelRef.current) { observer.observe(panelRef.current) @@ -379,7 +390,7 @@ const BottomPanel = () => { availableWidth={panelWidth} layers={combinedLayers} referenceLayer={referenceLayer} - joinConfig={{ layers: joinLayersConfig }} + joinConfig={combinedJoinConfig} filters={combinedFilters} onFiltersChange={setCombinedFilters} globalSearch={globalSearch} diff --git a/src/components/datatable/__tests__/BottomPanel.spec.jsx b/src/components/datatable/__tests__/BottomPanel.spec.jsx index f3bbc38d7b..098f1d5fc2 100644 --- a/src/components/datatable/__tests__/BottomPanel.spec.jsx +++ b/src/components/datatable/__tests__/BottomPanel.spec.jsx @@ -16,6 +16,17 @@ jest.mock('../DataTable.jsx', () => { return DataTableMock }) +const mockJoinConfigCalls = [] +jest.mock('../CombinedDataTable.jsx', () => { + // eslint-disable-next-line react/prop-types + const CombinedDataTableMock = ({ joinConfig }) => { + mockJoinConfigCalls.push(joinConfig) + return <div data-test="combined-datatable-mock" /> + } + CombinedDataTableMock.displayName = 'CombinedDataTableMock' + return CombinedDataTableMock +}) + const mockStore = configureMockStore() // jsdom doesn't implement pointer capture or ResizeObserver @@ -135,6 +146,27 @@ describe('BottomPanel double-click to collapse', () => { expect(getDisplayHeight()).toBe(`${DATA_TABLE_HEIGHT}px`) }) + + test('double-clicking inside an open popover (e.g. Join layers) does not toggle the collapsed state, even on non-control content like the popover background', () => { + renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1', 'layer2'], + combinedView: true, + }, + mapViews: [...twoEligibleLayers, referenceLayer()], + }) + expect(getDisplayHeight()).toBe(`${DATA_TABLE_HEIGHT}px`) + + fireEvent.click(screen.getByLabelText('Choose layers to combine')) + // Rendered via a portal, outside renderBottomPanel()'s own container + const popover = document.querySelector('.joinLayersPopover') + expect(popover).toBeInTheDocument() + + fireEvent.doubleClick(popover) + + expect(getDisplayHeight()).toBe(`${DATA_TABLE_HEIGHT}px`) + }) }) const twoEligibleLayers = [ @@ -478,3 +510,29 @@ describe('BottomPanel Combined join controls', () => { ]) }) }) + +describe('BottomPanel Combined joinConfig prop stability', () => { + test('passes CombinedDataTable the same joinConfig object reference across an unrelated re-render, instead of a fresh {layers: ...} wrapper every time', () => { + mockJoinConfigCalls.length = 0 + renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1', 'layer2'], + combinedView: true, + }, + mapViews: [...twoEligibleLayers, referenceLayer()], + }) + + fireEvent.change(screen.getByPlaceholderText('Search all columns'), { + target: { value: 'a' }, + }) + fireEvent.change(screen.getByPlaceholderText('Search all columns'), { + target: { value: 'ab' }, + }) + + expect(mockJoinConfigCalls.length).toBeGreaterThan(1) + expect( + mockJoinConfigCalls.every((c) => c === mockJoinConfigCalls[0]) + ).toBe(true) + }) +}) diff --git a/src/components/datatable/__tests__/JoinLayersControl.spec.jsx b/src/components/datatable/__tests__/JoinLayersControl.spec.jsx index c953832ead..a58f38e671 100644 --- a/src/components/datatable/__tests__/JoinLayersControl.spec.jsx +++ b/src/components/datatable/__tests__/JoinLayersControl.spec.jsx @@ -559,6 +559,7 @@ describe('JoinLayersControl popover — count/category value columns', () => { layer: FACILITY_LAYER, organisationUnitGroupSet: { id: 'groupSet1' }, legend: { + unit: 'Facility Type', items: [ { id: 'group1', name: 'Hospital' }, { id: 'group2', name: 'Clinic' }, @@ -567,7 +568,7 @@ describe('JoinLayersControl popover — count/category value columns', () => { data: [{ properties: { orgUnitPath: '/country1/ou1' } }], } - test('a count-only layer shows a static "Count" label, not an aggregation-type select', () => { + test('a count-only layer shows a static, layer-type-specific count label, not an aggregation-type select', () => { renderControl({ eligibleLayers: [countOnlyFacility], layersConfig: { @@ -579,42 +580,66 @@ describe('JoinLayersControl popover — count/category value columns', () => { }) openPicker() - expect(screen.getByText('Count')).toBeInTheDocument() + expect(screen.getByText('Facilities count')).toBeInTheDocument() expect( screen.queryByLabelText('Aggregation type for Facilities') ).not.toBeInTheDocument() }) - test('a category layer shows a Count/Percentage select per legend item, and changing it updates only that dataKey', () => { + test('a category layer shows a single shared Count/Percentage select governing every category column, not one per category', () => { const onChange = jest.fn() renderControl({ eligibleLayers: [categoricalFacility], layersConfig: { facility1: { type: 'orgUnit', - aggregation: { group1: 'COUNT', group2: 'COUNT' }, + aggregation: { categoryDisplayType: 'COUNT' }, }, }, onChange, }) openPicker() - const hospitalSelect = screen.getByLabelText( - 'Aggregation type for Hospital (Facilities)' + expect( + screen.queryByLabelText( + 'Aggregation type for Hospital (Facilities)' + ) + ).not.toBeInTheDocument() + expect(screen.getByText('Facility Type')).toBeInTheDocument() + + const categorySelect = screen.getByLabelText( + 'Category display for Facilities' ) expect( - within(hospitalSelect).getByText('Percentage') + within(categorySelect).getByText('Percentage') ).toBeInTheDocument() - expect(within(hospitalSelect).getByText('Count')).toBeInTheDocument() + expect(within(categorySelect).getByText('Count')).toBeInTheDocument() - fireEvent.change(hospitalSelect, { target: { value: 'PERCENTAGE' } }) + fireEvent.change(categorySelect, { target: { value: 'PERCENTAGE' } }) expect(onChange).toHaveBeenCalledWith({ facility1: { type: 'orgUnit', - aggregation: { group1: 'PERCENTAGE', group2: 'COUNT' }, + aggregation: { categoryDisplayType: 'PERCENTAGE' }, + }, + }) + }) + + test('falls back to a generic "Categories" label when the layer has no legend.unit', () => { + renderControl({ + eligibleLayers: [ + { + ...categoricalFacility, + legend: { items: categoricalFacility.legend.items }, + }, + ], + layersConfig: { + facility1: { type: 'orgUnit', aggregation: {} }, }, }) + openPicker() + + expect(screen.getByText('Categories')).toBeInTheDocument() }) test('a value-kind layer (Thematic/Earth Engine) is unaffected - keeps the full aggregation-type select', () => { diff --git a/src/components/datatable/__tests__/useCombinedTableData.spec.js b/src/components/datatable/__tests__/useCombinedTableData.spec.js index 006fa0751b..ed5c31ab9c 100644 --- a/src/components/datatable/__tests__/useCombinedTableData.spec.js +++ b/src/components/datatable/__tests__/useCombinedTableData.spec.js @@ -918,7 +918,7 @@ describe('useCombinedTableData - categorical/count value columns', () => { data, }) - test('a category column reflects the matched-feature count for that category, and switches to a percentage when the setting is PERCENTAGE', () => { + test('a category column reflects the matched-feature count for that category, and the shared categoryDisplayType setting switches every category column to a percentage together', () => { const layer = facilityLayer([ feature({ id: 'f1', @@ -954,7 +954,7 @@ describe('useCombinedTableData - categorical/count value columns', () => { layers: { facility1: { type: 'orgUnit', - aggregation: { group1: 'PERCENTAGE' }, + aggregation: { categoryDisplayType: 'PERCENTAGE' }, }, }, }, @@ -964,6 +964,7 @@ describe('useCombinedTableData - categorical/count value columns', () => { (r) => findCell(r, 'id').value === 'ou1' ) expect(findCell(percentRow1, 'facility1_group1').value).toBe(50) + expect(findCell(percentRow1, 'facility1_group2').value).toBe(50) }) test('a row with 0 matched features: both category columns are null, not NaN/0', () => { @@ -1106,7 +1107,7 @@ describe('useCombinedTableData - categorical/count value columns', () => { layers: { facility1: { type: 'orgUnit', - aggregation: { group1: 'PERCENTAGE' }, + aggregation: { categoryDisplayType: 'PERCENTAGE' }, }, }, }, diff --git a/src/components/datatable/controls/JoinLayersControl.jsx b/src/components/datatable/controls/JoinLayersControl.jsx index 240800dad1..bc53dedf2f 100644 --- a/src/components/datatable/controls/JoinLayersControl.jsx +++ b/src/components/datatable/controls/JoinLayersControl.jsx @@ -1,7 +1,7 @@ import i18n from '@dhis2/d2-i18n' import { IconWarningFilled16, Tooltip } from '@dhis2/ui' import PropTypes from 'prop-types' -import React, { useRef, useState } from 'react' +import React, { useMemo, useRef, useState } from 'react' import { getCategoryValueDisplayTypes, getCombinedAggregationTypes, @@ -11,12 +11,19 @@ import { DATA_KEY_KIND_COUNT, ORG_UNIT_PATH_DATA_KEY, } from '../../../constants/dataTable.js' +import { + FACILITY_LAYER, + ORG_UNIT_LAYER, + EVENT_LAYER, + TRACKED_ENTITY_LAYER, +} from '../../../constants/layers.js' import { NON_COMPOSABLE_AGGREGATION_TYPES } from '../../../util/aggregation.js' import { getUnmatchedFeatureCount, hasCombinedRollup, } from '../../../util/combinedJoinMatch.js' import { + CATEGORY_DISPLAY_TYPE_KEY, getCombinedValueDataKeys, getDefaultCombinedAggregation, } from '../../../util/dataTable.js' @@ -48,6 +55,16 @@ const getDefaultSettings = (layer) => ({ aggregation: getDefaultCombinedAggregation(layer), }) +const COUNT_LABEL_BY_LAYER_TYPE = { + [FACILITY_LAYER]: () => i18n.t('Facilities count'), + [ORG_UNIT_LAYER]: () => i18n.t('Org units count'), + [EVENT_LAYER]: () => i18n.t('Events count'), + [TRACKED_ENTITY_LAYER]: () => i18n.t('Tracked entities count'), +} + +const getCountLabel = (layer) => + COUNT_LABEL_BY_LAYER_TYPE[layer.layer]?.() ?? i18n.t('Count') + const JoinLayersControl = ({ eligibleLayers, layersConfig, @@ -58,6 +75,29 @@ const JoinLayersControl = ({ const [isOpen, setIsOpen] = useState(false) const aggregationTypes = getCombinedAggregationTypes() + const joinQualityByLayerKey = useMemo(() => { + const result = {} + eligibleLayers.forEach((layer) => { + const settings = layersConfig[layer.combinedLayerKey] + if (!settings) { + return + } + result[layer.combinedLayerKey] = { + hasRollup: hasCombinedRollup( + layer, + referenceLayer, + settings.type + ), + unmatchedCount: getUnmatchedFeatureCount( + layer, + referenceLayer, + settings.type + ), + } + }) + return result + }, [eligibleLayers, layersConfig, referenceLayer]) + const onToggle = (layer) => { const next = { ...layersConfig } if (next[layer.combinedLayerKey]) { @@ -111,20 +151,23 @@ const JoinLayersControl = ({ layersConfig[layer.combinedLayerKey] const defaultAggregation = getDefaultCombinedAggregation(layer) - const hasRollup = - !!settings && - hasCombinedRollup( - layer, - referenceLayer, - settings.type - ) - const unmatchedCount = settings - ? getUnmatchedFeatureCount( - layer, - referenceLayer, - settings.type - ) - : 0 + const { + hasRollup = false, + unmatchedCount = 0, + } = + joinQualityByLayerKey[ + layer.combinedLayerKey + ] ?? {} + const valueDataKeys = + getCombinedValueDataKeys(layer) + const categoryDataKeys = valueDataKeys.filter( + ({ kind }) => + kind === DATA_KEY_KIND_CATEGORY + ) + const otherDataKeys = valueDataKeys.filter( + ({ kind }) => + kind !== DATA_KEY_KIND_CATEGORY + ) return ( <div key={layer.id} @@ -201,9 +244,7 @@ const JoinLayersControl = ({ </Tooltip> )} </div> - {getCombinedValueDataKeys( - layer - ).map( + {otherDataKeys.map( ({ dataKey, name, @@ -227,8 +268,8 @@ const JoinLayersControl = ({ styles.aggregationRowLabel } > - {i18n.t( - 'Count' + {getCountLabel( + layer )} </span> </div> @@ -248,11 +289,6 @@ const JoinLayersControl = ({ NON_COMPOSABLE_AGGREGATION_TYPES.has( effectiveType ) - const options = - kind === - DATA_KEY_KIND_CATEGORY - ? getCategoryValueDisplayTypes() - : aggregationTypes return ( <div @@ -302,7 +338,7 @@ const JoinLayersControl = ({ ) } > - {options.map( + {aggregationTypes.map( ( type ) => ( @@ -345,6 +381,68 @@ const JoinLayersControl = ({ ) } )} + {categoryDataKeys.length > + 0 && ( + <div + className={ + styles.aggregationRow + } + > + <span + className={ + styles.aggregationRowLabel + } + > + {layer.legend + ?.unit ?? + i18n.t( + 'Categories' + )} + </span> + <select + aria-label={i18n.t( + 'Category display for {{layer}}', + { + layer: layer.name, + } + )} + value={ + settings + .aggregation?.[ + CATEGORY_DISPLAY_TYPE_KEY + ] ?? + defaultAggregation[ + CATEGORY_DISPLAY_TYPE_KEY + ] + } + onChange={(e) => + onAggregationChange( + layer.combinedLayerKey, + CATEGORY_DISPLAY_TYPE_KEY, + e.target + .value + ) + } + > + {getCategoryValueDisplayTypes().map( + (type) => ( + <option + key={ + type.id + } + value={ + type.id + } + > + { + type.name + } + </option> + ) + )} + </select> + </div> + )} </div> )} </div> diff --git a/src/components/datatable/useCombinedTableData.js b/src/components/datatable/useCombinedTableData.js index 8c650cf8dc..294d641bd7 100644 --- a/src/components/datatable/useCombinedTableData.js +++ b/src/components/datatable/useCombinedTableData.js @@ -20,6 +20,7 @@ import { getProps, } from '../../util/combinedJoinMatch.js' import { + CATEGORY_DISPLAY_TYPE_KEY, getCombinedValueDataKeys, getDefaultCombinedAggregation, getFeatureCategoryKey, @@ -102,13 +103,8 @@ const applyLayerMatchToRow = ({ row, featureIds, refProps }, layerMatch) => { valueDataKeys.forEach(({ dataKey, kind }) => { const rowKey = `${layer.combinedLayerKey}_${dataKey}` - const effectiveType = - settings.aggregation?.[dataKey] ?? - getDefaultCombinedAggregation(layer)[dataKey] if (kind === DATA_KEY_KIND_COUNT) { - // A rollup that matched nothing reads as "no data" (null, same - // as every other column), not a real business zero. row[rowKey] = matches.length || null return } @@ -118,16 +114,22 @@ const applyLayerMatchToRow = ({ row, featureIds, refProps }, layerMatch) => { row[rowKey] = null return } + const displayType = + settings.aggregation?.[CATEGORY_DISPLAY_TYPE_KEY] ?? + getDefaultCombinedAggregation(layer)[CATEGORY_DISPLAY_TYPE_KEY] const inCategory = matches.filter( (p) => getFeatureCategoryKey(layer, p) === dataKey ).length row[rowKey] = - effectiveType === 'PERCENTAGE' + displayType === 'PERCENTAGE' ? (inCategory / matches.length) * 100 : inCategory return } + const effectiveType = + settings.aggregation?.[dataKey] ?? + getDefaultCombinedAggregation(layer)[dataKey] const values = matches .map((p) => p[dataKey]) .filter((v) => Number.isFinite(v)) @@ -167,10 +169,10 @@ const getValueDataKeyHeader = ( } if (kind === DATA_KEY_KIND_CATEGORY) { - const effectiveType = - settings.aggregation?.[dataKey] ?? - getDefaultCombinedAggregation(layer)[dataKey] - const isPercentage = effectiveType === 'PERCENTAGE' + const displayType = + settings.aggregation?.[CATEGORY_DISPLAY_TYPE_KEY] ?? + getDefaultCombinedAggregation(layer)[CATEGORY_DISPLAY_TYPE_KEY] + const isPercentage = displayType === 'PERCENTAGE' return { name: i18n.t('{{name}} ({{unit}}) ({{layer}})', { name, @@ -279,7 +281,6 @@ export const useCombinedTableData = ({ ...valueDataKeys.map((valueDataKey) => getValueDataKeyHeader(layer, settings, valueDataKey) ), - // Earth Engine has no separate categorical "legend" concept ...(layer.layer !== EARTH_ENGINE_LAYER ? [ { @@ -288,6 +289,7 @@ export const useCombinedTableData = ({ }), dataKey: `${layer.combinedLayerKey}_${LEGEND_KEY}`, type: TYPE_STRING, + defaultHidden: true, }, ] : []), diff --git a/src/util/__tests__/dataTable.spec.js b/src/util/__tests__/dataTable.spec.js index 3c44155200..0cd1fc26c6 100644 --- a/src/util/__tests__/dataTable.spec.js +++ b/src/util/__tests__/dataTable.spec.js @@ -588,7 +588,7 @@ describe('getDefaultCombinedAggregation', () => { }) }) - test('Facility/OrgUnit: count-only and category dataKeys both default to COUNT', () => { + test('Facility/OrgUnit: count-only defaults to COUNT, category defaults to a single shared categoryDisplayType of COUNT', () => { expect( getDefaultCombinedAggregation({ layer: FACILITY_LAYER, @@ -607,7 +607,7 @@ describe('getDefaultCombinedAggregation', () => { ], }, }) - ).toEqual({ group1: 'COUNT', group2: 'COUNT' }) + ).toEqual({ categoryDisplayType: 'COUNT' }) }) test('Event: a numeric styleDataItem defaults to SUM - there is no per-data-item aggregationType metadata for event data elements the way there is for Thematic', () => { @@ -620,7 +620,7 @@ describe('getDefaultCombinedAggregation', () => { ).toEqual({ value: 'SUM' }) }) - test('Event: category dataKeys default to COUNT', () => { + test('Event: category dataKeys default to a single shared categoryDisplayType of COUNT', () => { expect( getDefaultCombinedAggregation({ layer: EVENT_LAYER, @@ -632,7 +632,7 @@ describe('getDefaultCombinedAggregation', () => { ], }, }) - ).toEqual({ 0: 'COUNT', 1: 'COUNT' }) + ).toEqual({ categoryDisplayType: 'COUNT' }) }) }) diff --git a/src/util/dataTable.js b/src/util/dataTable.js index c3063d1978..4b681303f6 100644 --- a/src/util/dataTable.js +++ b/src/util/dataTable.js @@ -27,6 +27,7 @@ export const COMBINED_VALUE_KEY = 'rawValue' export const COMBINED_COUNT_KEY = 'count' export const UNCLASSIFIED_CATEGORY_KEY = 'unclassified' export const EVENT_STYLE_VALUE_KEY = 'value' +export const CATEGORY_DISPLAY_TYPE_KEY = 'categoryDisplayType' const CLASSIFIED_EARTH_ENGINE_AGGREGATION_TYPES = new Set([ 'percentage', @@ -66,12 +67,17 @@ const getValueAggregationType = (layer) => { export const getDefaultCombinedAggregation = (layer) => { const valueType = getValueAggregationType(layer) - return Object.fromEntries( - getCombinedValueDataKeys(layer).map(({ dataKey, kind }) => [ + const dataKeys = getCombinedValueDataKeys(layer) + const entries = dataKeys + .filter(({ kind }) => kind !== DATA_KEY_KIND_CATEGORY) + .map(({ dataKey, kind }) => [ dataKey, kind === DATA_KEY_KIND_VALUE ? valueType : 'COUNT', ]) - ) + if (dataKeys.some(({ kind }) => kind === DATA_KEY_KIND_CATEGORY)) { + entries.push([CATEGORY_DISPLAY_TYPE_KEY, 'COUNT']) + } + return Object.fromEntries(entries) } const getEarthEngineBandValueDataKeys = (layer) => { From 4c95effafc77ab967b80d7f02c3d9d2501a757f2 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Mon, 3 Aug 2026 10:06:22 +0200 Subject: [PATCH 187/205] chore: en.pot update --- i18n/en.pot | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index b0d9e90b4a..b1e6e4424e 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-31T15:09:46.348Z\n" -"PO-Revision-Date: 2026-07-31T15:09:46.349Z\n" +"POT-Creation-Date: 2026-08-03T07:08:14.950Z\n" +"PO-Revision-Date: 2026-08-03T07:08:14.951Z\n" msgid "2020" msgstr "2020" @@ -337,6 +337,21 @@ msgstr "Search all columns" msgid "Highlight color" msgstr "Highlight color" +msgid "Facilities count" +msgstr "Facilities count" + +msgid "Org units count" +msgstr "Org units count" + +msgid "Events count" +msgstr "Events count" + +msgid "Tracked entities count" +msgstr "Tracked entities count" + +msgid "Count" +msgstr "Count" + msgid "Choose layers to combine" msgstr "Choose layers to combine" @@ -366,9 +381,6 @@ msgstr[1] "" "unit (wrong level, no matching parent, or outside every boundary) and will " "be excluded from the Combined table." -msgid "Count" -msgstr "Count" - msgid "Aggregation type for {{name}} ({{layer}})" msgstr "Aggregation type for {{name}} ({{layer}})" From b2127b979d3e8bd3d83b30cacb706391ce0d81f9 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Mon, 3 Aug 2026 10:54:27 +0200 Subject: [PATCH 188/205] fix: consistent default sorting --- .../datatable/__tests__/useSortState.spec.js | 62 +++++++++++++++++++ src/components/datatable/useSortState.js | 10 ++- src/util/__tests__/dataTable.spec.js | 20 +++++- src/util/dataTable.js | 8 ++- 4 files changed, 94 insertions(+), 6 deletions(-) create mode 100644 src/components/datatable/__tests__/useSortState.spec.js diff --git a/src/components/datatable/__tests__/useSortState.spec.js b/src/components/datatable/__tests__/useSortState.spec.js new file mode 100644 index 0000000000..a84711fdad --- /dev/null +++ b/src/components/datatable/__tests__/useSortState.spec.js @@ -0,0 +1,62 @@ +import { act, renderHook } from '@testing-library/react' +import { useSortState } from '../useSortState.js' + +describe('useSortState', () => { + test('starts sorted by the initial field, ascending - matching what the table shows before any interaction', () => { + const { result } = renderHook(() => useSortState('name')) + + expect(result.current.sortField).toBe('name') + expect(result.current.sortDirection).toBe('asc') + }) + + test('cycling a different column 3 times returns to the same field/direction the table started with - not an unsorted state', () => { + const { result } = renderHook(() => useSortState('name')) + + act(() => result.current.sortData({ name: 'type' })) + expect(result.current).toMatchObject({ + sortField: 'type', + sortDirection: 'asc', + }) + + act(() => result.current.sortData({ name: 'type' })) + expect(result.current).toMatchObject({ + sortField: 'type', + sortDirection: 'desc', + }) + + act(() => result.current.sortData({ name: 'type' })) + expect(result.current).toMatchObject({ + sortField: 'name', + sortDirection: 'asc', + }) + }) + + test('cycling the initial/default column itself is a 2-state toggle, since it already is the default', () => { + const { result } = renderHook(() => useSortState('name')) + + act(() => result.current.sortData({ name: 'name' })) + expect(result.current).toMatchObject({ + sortField: 'name', + sortDirection: 'desc', + }) + + act(() => result.current.sortData({ name: 'name' })) + expect(result.current).toMatchObject({ + sortField: 'name', + sortDirection: 'asc', + }) + }) + + test('respects a custom initial sort field as the reset target', () => { + const { result } = renderHook(() => useSortState('level')) + + act(() => result.current.sortData({ name: 'name' })) + act(() => result.current.sortData({ name: 'name' })) + act(() => result.current.sortData({ name: 'name' })) + + expect(result.current).toMatchObject({ + sortField: 'level', + sortDirection: 'asc', + }) + }) +}) diff --git a/src/components/datatable/useSortState.js b/src/components/datatable/useSortState.js index 7d12214843..2a77e15cc8 100644 --- a/src/components/datatable/useSortState.js +++ b/src/components/datatable/useSortState.js @@ -10,9 +10,15 @@ export const useSortState = (initialSortField = 'name') => { const sortData = useCallback( ({ name }) => { - setSorting(getNextSorting(name, { sortField, sortDirection })) + setSorting( + getNextSorting( + name, + { sortField, sortDirection }, + { defaultSortField: initialSortField } + ) + ) }, - [sortField, sortDirection] + [sortField, sortDirection, initialSortField] ) return { sortField, sortDirection, sortData } diff --git a/src/util/__tests__/dataTable.spec.js b/src/util/__tests__/dataTable.spec.js index 0cd1fc26c6..f4417874a1 100644 --- a/src/util/__tests__/dataTable.spec.js +++ b/src/util/__tests__/dataTable.spec.js @@ -816,10 +816,10 @@ describe('getNextSorting', () => { ).toEqual({ sortField: 'name', sortDirection: 'desc' }) }) - test('clicking the descending-sorted column clears back to natural order', () => { + test('clicking the descending-sorted default column resets to itself ascending - a 2-state toggle since it already is the default', () => { expect( getNextSorting('name', { sortField: 'name', sortDirection: 'desc' }) - ).toEqual({ sortField: null, sortDirection: 'asc' }) + ).toEqual({ sortField: 'name', sortDirection: 'asc' }) }) test('clicking a different column restarts the cycle at ascending', () => { @@ -827,6 +827,22 @@ describe('getNextSorting', () => { getNextSorting('type', { sortField: 'name', sortDirection: 'desc' }) ).toEqual({ sortField: 'type', sortDirection: 'asc' }) }) + + test("clicking a non-default column's third time (descending) resets to the table's actual default sort, matching what it shows on initial load - not an unsorted/natural-order state", () => { + expect( + getNextSorting('type', { sortField: 'type', sortDirection: 'desc' }) + ).toEqual({ sortField: 'name', sortDirection: 'asc' }) + }) + + test('honors a custom defaultSortField/defaultSortDirection when resetting', () => { + expect( + getNextSorting( + 'type', + { sortField: 'type', sortDirection: 'desc' }, + { defaultSortField: 'level', defaultSortDirection: 'desc' } + ) + ).toEqual({ sortField: 'level', sortDirection: 'desc' }) + }) }) describe('isFilterable', () => { diff --git a/src/util/dataTable.js b/src/util/dataTable.js index 4b681303f6..18ee266f96 100644 --- a/src/util/dataTable.js +++ b/src/util/dataTable.js @@ -285,14 +285,18 @@ export const isFilterable = (dataKey, type) => !!type export const shouldClearFeatureHighlight = (event) => event.relatedTarget?.tagName !== 'TD' -export const getNextSorting = (name, { sortField, sortDirection }) => { +export const getNextSorting = ( + name, + { sortField, sortDirection }, + { defaultSortField = 'name', defaultSortDirection = SORT_ASCENDING } = {} +) => { if (name !== sortField) { return { sortField: name, sortDirection: SORT_ASCENDING } } if (sortDirection === SORT_ASCENDING) { return { sortField: name, sortDirection: SORT_DESCENDING } } - return { sortField: null, sortDirection: SORT_ASCENDING } + return { sortField: defaultSortField, sortDirection: defaultSortDirection } } export const getRowId = (row) => From a779e2821f80ee453ad596195beeda77ff2ebb9d Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Mon, 3 Aug 2026 11:42:12 +0200 Subject: [PATCH 189/205] fix: don't clear the org unit hierarchy filter on a no-match search --- .../datatable/OrgUnitGroupFilterInput.jsx | 6 +----- .../OrgUnitGroupFilterInput.spec.jsx | 12 +++++++++--- src/util/__tests__/filter.spec.js | 19 +++++++++++++++++++ src/util/filter.js | 4 ++-- 4 files changed, 31 insertions(+), 10 deletions(-) diff --git a/src/components/datatable/OrgUnitGroupFilterInput.jsx b/src/components/datatable/OrgUnitGroupFilterInput.jsx index 84198946f9..91acceb5b5 100644 --- a/src/components/datatable/OrgUnitGroupFilterInput.jsx +++ b/src/components/datatable/OrgUnitGroupFilterInput.jsx @@ -1,7 +1,7 @@ import i18n from '@dhis2/d2-i18n' import PropTypes from 'prop-types' import React, { useCallback } from 'react' -import { setDataFilter, clearDataFilter } from '../../actions/dataFilters.js' +import { setDataFilter } from '../../actions/dataFilters.js' import { ORG_UNIT_GROUPS_GRANULARITY } from '../../constants/dataTable.js' import { isOrgUnitGroupFilter } from '../../util/filter.js' import { @@ -66,10 +66,6 @@ const OrgUnitGroupFilterInput = ({ .map((key) => nodeByKey.get(key)) .filter(Boolean) .map((node) => node.prefix) - if (!matchedPrefixes.length) { - dispatch(clearDataFilter(layerIdArg, dataKeyArg)) - return - } dispatch( setDataFilter(layerIdArg, dataKeyArg, { granularity: ORG_UNIT_GROUPS_GRANULARITY, diff --git a/src/components/datatable/__tests__/OrgUnitGroupFilterInput.spec.jsx b/src/components/datatable/__tests__/OrgUnitGroupFilterInput.spec.jsx index 813687c49a..2fad2e8a7b 100644 --- a/src/components/datatable/__tests__/OrgUnitGroupFilterInput.spec.jsx +++ b/src/components/datatable/__tests__/OrgUnitGroupFilterInput.spec.jsx @@ -246,7 +246,7 @@ describe('OrgUnitGroupFilterInput - search', () => { expect(screen.queryByLabelText('country2')).not.toBeInTheDocument() }) - test('typing text with no tree match shows the custom filter row but clears rather than filtering by the raw id/path', () => { + test('typing text with no tree match shows the custom filter row and applies a filter matching nothing, rather than clearing back to unfiltered', () => { const { store } = renderOrgUnitGroupFilter() openPopover() fireEvent.change(getInput(), { target: { value: 'Nairobi' } }) @@ -254,12 +254,18 @@ describe('OrgUnitGroupFilterInput - search', () => { screen.getByTestId('data-table-column-filter-custom-Org unit') ).toBeInTheDocument() expect(store.getActions()).toContainEqual({ - type: DATA_FILTER_CLEAR, + type: DATA_FILTER_SET, layerId: 'layer1', fieldId: 'orgUnitPath', + filter: { + granularity: ORG_UNIT_GROUPS_GRANULARITY, + prefixes: [], + searchDerived: true, + searchText: 'Nairobi', + }, }) expect(store.getActions()).not.toContainEqual( - expect.objectContaining({ type: DATA_FILTER_SET }) + expect.objectContaining({ type: DATA_FILTER_CLEAR }) ) }) diff --git a/src/util/__tests__/filter.spec.js b/src/util/__tests__/filter.spec.js index edcd9f3fd8..05307048a0 100644 --- a/src/util/__tests__/filter.spec.js +++ b/src/util/__tests__/filter.spec.js @@ -247,6 +247,25 @@ describe('filterData', () => { } expect(filterData(data, filters)).toEqual([{ a: null }]) }) + + it('an empty prefixes array matches everything when not search-derived (an inactive checkbox tree)', () => { + const filters = { + a: { granularity: ORG_UNIT_GROUPS_GRANULARITY, prefixes: [] }, + } + expect(filterData(data, filters)).toEqual(data) + }) + + it('an empty prefixes array matches nothing when search-derived (a free-text search that matched no branch)', () => { + const filters = { + a: { + granularity: ORG_UNIT_GROUPS_GRANULARITY, + prefixes: [], + searchDerived: true, + searchText: 'Nairobi', + }, + } + expect(filterData(data, filters)).toEqual([]) + }) }) describe('org-unit value filter ({ values, searchDerived, searchText }) - a committed free-text search on an org-unit-flavored plain-text column, resolved to matching raw values up front (see FilterInput.jsx)', () => { diff --git a/src/util/filter.js b/src/util/filter.js index 9b8fc30f60..797464c2c1 100644 --- a/src/util/filter.js +++ b/src/util/filter.js @@ -13,9 +13,9 @@ export const isPrefixGroupFilter = (filter, granularity) => !Array.isArray(filter) && filter.granularity === granularity -export const prefixGroupFilter = (value, { prefixes }) => { +export const prefixGroupFilter = (value, { prefixes, searchDerived }) => { if (!prefixes?.length) { - return true + return !searchDerived } const stringValue = value == null ? SENTINEL_NO_VALUE : String(value) return prefixes.some((prefix) => { From 7428958e3e86bbad5015aa832be18937ba262a9d Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Mon, 3 Aug 2026 12:26:59 +0200 Subject: [PATCH 190/205] fix: match numeric, date, and org-unit columns in data table global search --- src/components/datatable/CellValue.jsx | 48 ++----- .../datatable/CombinedDataTable.jsx | 1 + .../datatable/__tests__/useTableData.spec.jsx | 8 +- .../datatable/useCombinedTableData.js | 12 +- src/components/datatable/useTableData.js | 14 +-- src/util/__tests__/cellValue.spec.js | 95 ++++++++++++++ src/util/__tests__/filter.spec.js | 117 +++++++++++++++--- src/util/cellValue.js | 41 ++++++ src/util/filter.js | 46 +++---- 9 files changed, 284 insertions(+), 98 deletions(-) create mode 100644 src/util/__tests__/cellValue.spec.js create mode 100644 src/util/cellValue.js diff --git a/src/components/datatable/CellValue.jsx b/src/components/datatable/CellValue.jsx index 1c70ed0abd..90091575ed 100644 --- a/src/components/datatable/CellValue.jsx +++ b/src/components/datatable/CellValue.jsx @@ -9,16 +9,7 @@ import { RENDERER_BOOLEAN, TYPE_DATE, } from '../../constants/dataTable.js' -import { - formatBoolean, - formatDate, - formatDatetime, -} from '../../util/helpers.js' -import { formatWithSeparator } from '../../util/numbers.js' -import { - formatOrgUnitOwnName, - formatOrgUnitPathBreadcrumb, -} from '../../util/orgUnitGroups.js' +import { formatCellText } from '../../util/cellValue.js' import styles from './styles/DataTable.module.css' export const getCellRendererFlags = (renderer, type) => ({ @@ -44,19 +35,7 @@ const CellValue = ({ return NO_VALUE } - const { - isColorCell, - isIconCell, - isDateCell, - isDateOnlyCell, - isOrgUnitHierarchyCell, - isOrgUnitNameCell, - isBooleanCell, - } = getCellRendererFlags(renderer, type) - - if (isColorCell) { - return value.toLowerCase() - } + const { isIconCell } = getCellRendererFlags(renderer, type) if (isIconCell) { return ( @@ -71,23 +50,12 @@ const CellValue = ({ ) } - if (isDateCell) { - return isDateOnlyCell ? formatDate(value) : formatDatetime(value) - } - - if (isOrgUnitHierarchyCell) { - return formatOrgUnitPathBreadcrumb(value, orgUnitIdToName) - } - - if (isOrgUnitNameCell) { - return formatOrgUnitOwnName(value, orgUnitIdToName) - } - - if (isBooleanCell) { - return formatBoolean(value) - } - - return formatWithSeparator(value, keyAnalysisDigitGroupSeparator) + return formatCellText(value, { + renderer, + type, + orgUnitIdToName, + keyAnalysisDigitGroupSeparator, + }) } CellValue.propTypes = { diff --git a/src/components/datatable/CombinedDataTable.jsx b/src/components/datatable/CombinedDataTable.jsx index 7b89ef7f68..60531dcc37 100644 --- a/src/components/datatable/CombinedDataTable.jsx +++ b/src/components/datatable/CombinedDataTable.jsx @@ -114,6 +114,7 @@ const CombinedDataTable = ({ mapBounds, selectionFilter, selectedIdSet, + keyAnalysisDigitGroupSeparator, }) useEffect(() => { diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index 50f9aa3368..6df9c562c6 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -2193,7 +2193,7 @@ describe('useTableData globalSearch', () => { ).toBe('evt1') }) - test('matches a custom ORGANISATION_UNIT-valued attribute on a Tracked entity layer only by its raw stored value, not its resolved name - only "Org unit hierarchy" gets name-aware global search', () => { + test('matches a custom ORGANISATION_UNIT-valued attribute on a Tracked entity layer by its resolved name, same as the "Org unit hierarchy" column - fixed an earlier asymmetry where custom org-unit fields were only searchable by their raw stored id', () => { useOrgUnitAncestorNames.mockReturnValue({ idToName: new Map([['facility9', 'Referral Hospital']]), loading: false, @@ -2233,13 +2233,13 @@ describe('useTableData globalSearch', () => { } ).result - expect(renderTeiTableData('referral').current.rows).toHaveLength(0) - - const { current } = renderTeiTableData('facility9') + const { current } = renderTeiTableData('referral') expect(current.rows).toHaveLength(1) expect(current.rows[0].find((c) => c.dataKey === 'id').value).toBe( 'tei1' ) + + expect(renderTeiTableData('addis ababa').current.rows).toHaveLength(0) }) test('shows no rows when nothing matches', () => { diff --git a/src/components/datatable/useCombinedTableData.js b/src/components/datatable/useCombinedTableData.js index 294d641bd7..b8d5777645 100644 --- a/src/components/datatable/useCombinedTableData.js +++ b/src/components/datatable/useCombinedTableData.js @@ -67,15 +67,16 @@ const finalizeRows = ( sortDirection, selectionFilter, selectedIdSet, + keyAnalysisDigitGroupSeparator, } ) => { let data = filterData(flatRows, filters) if (globalSearch?.trim()) { - const stringDataKeys = headers - .filter((h) => h.type === TYPE_STRING) - .map((h) => h.dataKey) - data = filterByGlobalSearch(data, globalSearch, { stringDataKeys }) + data = filterByGlobalSearch(data, globalSearch, { + headers, + keyAnalysisDigitGroupSeparator, + }) } if (selectionFilter?.length) { @@ -220,6 +221,7 @@ export const useCombinedTableData = ({ mapBounds, selectionFilter, selectedIdSet, + keyAnalysisDigitGroupSeparator, }) => { const referenceOrgUnits = useMemo( () => getJoinableFeatures(referenceLayer), @@ -343,6 +345,7 @@ export const useCombinedTableData = ({ sortDirection, selectionFilter, selectedIdSet, + keyAnalysisDigitGroupSeparator, }) const columnOptions = sortColumnOptions(getColumnDistinctValues(headers, flatRows), { @@ -369,5 +372,6 @@ export const useCombinedTableData = ({ sortDirection, selectionFilter, selectedIdSet, + keyAnalysisDigitGroupSeparator, ]) } diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index ba24acef3c..dbd8373681 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -3,7 +3,6 @@ import { useDeferredValue, useMemo, useRef } from 'react' import { useSelector } from 'react-redux' import { SENTINEL_SELECTED_ROW, - TYPE_ORG_UNIT, RENDERER_ORG_UNIT, RENDERER_ORG_UNIT_NAME, } from '../../constants/dataTable.js' @@ -26,7 +25,6 @@ import { sortColumnOptions, } from '../../util/tableColumns.js' import { - TYPE_STRING, ERROR_NON_HOMOGENOUS_FEATURES, getHeadersForLayer, } from '../../util/tableHeaders.js' @@ -258,16 +256,10 @@ export const useTableData = ({ let filteredData = filterData(dataWithAggregations, dataFilters) if (globalSearch?.trim()) { - const stringDataKeys = headers - .filter((h) => h.type === TYPE_STRING) - .map((h) => h.dataKey) - const orgUnitDataKeys = headers - .filter((h) => h.type === TYPE_ORG_UNIT) - .map((h) => h.dataKey) filteredData = filterByGlobalSearch(filteredData, globalSearch, { - stringDataKeys, - orgUnitDataKeys, - idToName: orgUnitIdToName, + headers, + orgUnitIdToName, + keyAnalysisDigitGroupSeparator, }) } diff --git a/src/util/__tests__/cellValue.spec.js b/src/util/__tests__/cellValue.spec.js new file mode 100644 index 0000000000..a9bbd7e385 --- /dev/null +++ b/src/util/__tests__/cellValue.spec.js @@ -0,0 +1,95 @@ +import { + RENDERER_COLOR, + RENDERER_DATE, + RENDERER_ORG_UNIT, + RENDERER_ORG_UNIT_NAME, + RENDERER_BOOLEAN, + TYPE_DATE, + TYPE_DATETIME, +} from '../../constants/dataTable.js' +import { formatCellText, NO_VALUE_TEXT } from '../cellValue.js' + +describe('formatCellText', () => { + it('returns the em-dash placeholder for a missing value, regardless of renderer', () => { + expect(formatCellText(null)).toBe(NO_VALUE_TEXT) + expect(formatCellText(undefined, { renderer: RENDERER_BOOLEAN })).toBe( + NO_VALUE_TEXT + ) + }) + + it('lowercases a color value', () => { + expect(formatCellText('#ABCDEF', { renderer: RENDERER_COLOR })).toBe( + '#abcdef' + ) + }) + + it('formats a date-only value with no time portion', () => { + expect( + formatCellText('2024-01-15T10:30:00.000', { + renderer: RENDERER_DATE, + type: TYPE_DATE, + }) + ).toBe('2024-01-15') + }) + + it('formats a datetime value including the time portion', () => { + expect( + formatCellText('2024-01-15T10:30:00.000', { + renderer: RENDERER_DATE, + type: TYPE_DATETIME, + }) + ).toBe('2024-01-15 10:30') + }) + + it('formats an org-unit-hierarchy value as a breadcrumb', () => { + const orgUnitIdToName = new Map([ + ['country1', 'Country'], + ['ou1', 'Facility'], + ]) + expect( + formatCellText('/country1/ou1', { + renderer: RENDERER_ORG_UNIT, + orgUnitIdToName, + }) + ).toBe('Country / Facility') + }) + + it('falls back to the raw id for an org-unit segment missing from the id-to-name map', () => { + const orgUnitIdToName = new Map([['country1', 'Country']]) + expect( + formatCellText('/country1/ou1', { + renderer: RENDERER_ORG_UNIT, + orgUnitIdToName, + }) + ).toBe('Country / ou1') + }) + + it("formats an org-unit-name value as just the feature's own name", () => { + const orgUnitIdToName = new Map([['ou1', 'Facility']]) + expect( + formatCellText('/country1/ou1', { + renderer: RENDERER_ORG_UNIT_NAME, + orgUnitIdToName, + }) + ).toBe('Facility') + }) + + it('formats a boolean-renderer value as Yes/No', () => { + expect(formatCellText('1', { renderer: RENDERER_BOOLEAN })).toBe('Yes') + expect(formatCellText('0', { renderer: RENDERER_BOOLEAN })).toBe('No') + }) + + it('formats a plain number with the digit-group separator', () => { + expect( + formatCellText(1234567, { keyAnalysisDigitGroupSeparator: 'COMMA' }) + ).toBe('1,234,567') + }) + + it('formats a plain number with no separator when none is given', () => { + expect(formatCellText(1234567)).toBe('1234567') + }) + + it('leaves a plain string untouched', () => { + expect(formatCellText('Bo')).toBe('Bo') + }) +}) diff --git a/src/util/__tests__/filter.spec.js b/src/util/__tests__/filter.spec.js index 05307048a0..603821b459 100644 --- a/src/util/__tests__/filter.spec.js +++ b/src/util/__tests__/filter.spec.js @@ -3,6 +3,13 @@ import { SENTINEL_NO_VALUE, DATE_GROUPS_GRANULARITY, ORG_UNIT_GROUPS_GRANULARITY, + TYPE_STRING, + TYPE_NUMBER, + TYPE_DATE, + TYPE_DATETIME, + TYPE_ORG_UNIT, + RENDERER_DATE, + RENDERER_ORG_UNIT, } from '../../constants/dataTable.js' import { filterByGlobalSearch, filterData } from '../filter.js' @@ -291,38 +298,35 @@ describe('filterByGlobalSearch', () => { { name: 'Entebbe Clinic', type: 'Clinic' }, { name: 'Jinja Hospital', type: 'Hospital' }, ] - const stringDataKeys = ['name', 'type'] + const headers = [ + { dataKey: 'name', type: TYPE_STRING }, + { dataKey: 'type', type: TYPE_STRING }, + ] it('returns the original data when the search string is empty', () => { - expect(filterByGlobalSearch(data, '', { stringDataKeys })).toEqual(data) - expect(filterByGlobalSearch(data, ' ', { stringDataKeys })).toEqual( - data - ) + expect(filterByGlobalSearch(data, '', { headers })).toEqual(data) + expect(filterByGlobalSearch(data, ' ', { headers })).toEqual(data) }) - it('returns the original data when there are no string or org-unit data keys', () => { + it('returns the original data when there are no headers to search', () => { expect(filterByGlobalSearch(data, 'Kampala', {})).toEqual(data) }) it('matches case-insensitively across any of the given fields', () => { - expect( - filterByGlobalSearch(data, 'kampala', { stringDataKeys }) - ).toEqual([{ name: 'Kampala Hospital', type: 'Hospital' }]) + expect(filterByGlobalSearch(data, 'kampala', { headers })).toEqual([ + { name: 'Kampala Hospital', type: 'Hospital' }, + ]) }) it('matches rows where any field contains the search string', () => { - expect( - filterByGlobalSearch(data, 'hospital', { stringDataKeys }) - ).toEqual([ + expect(filterByGlobalSearch(data, 'hospital', { headers })).toEqual([ { name: 'Kampala Hospital', type: 'Hospital' }, { name: 'Jinja Hospital', type: 'Hospital' }, ]) }) it('returns no rows when nothing matches', () => { - expect( - filterByGlobalSearch(data, 'nairobi', { stringDataKeys }) - ).toEqual([]) + expect(filterByGlobalSearch(data, 'nairobi', { headers })).toEqual([]) }) it('also matches org-unit-typed columns by their resolved name, since the raw stored value is an id/path', () => { @@ -335,11 +339,90 @@ describe('filterByGlobalSearch', () => { ['region1', 'Bo'], ['facility1', 'Bo Hospital'], ]) + const orgUnitHeaders = [ + { + dataKey: 'orgUnitPath', + type: TYPE_ORG_UNIT, + renderer: RENDERER_ORG_UNIT, + }, + ] expect( filterByGlobalSearch(orgUnitData, 'bo hospital', { - orgUnitDataKeys: ['orgUnitPath'], - idToName, + headers: orgUnitHeaders, + orgUnitIdToName: idToName, }) ).toEqual([{ id: 'a', orgUnitPath: '/country1/region1/facility1' }]) }) + + it('matches a numeric column by its raw value even when a digit-group separator would otherwise hide it', () => { + const numericData = [{ population: 1234567 }, { population: 42 }] + const numericHeaders = [{ dataKey: 'population', type: TYPE_NUMBER }] + + expect( + filterByGlobalSearch(numericData, '1234567', { + headers: numericHeaders, + keyAnalysisDigitGroupSeparator: 'COMMA', + }) + ).toEqual([{ population: 1234567 }]) + }) + + it('matches a numeric column by its formatted (digit-group-separated) value too', () => { + const numericData = [{ population: 1234567 }, { population: 42 }] + const numericHeaders = [{ dataKey: 'population', type: TYPE_NUMBER }] + + expect( + filterByGlobalSearch(numericData, '1,234,567', { + headers: numericHeaders, + keyAnalysisDigitGroupSeparator: 'COMMA', + }) + ).toEqual([{ population: 1234567 }]) + }) + + it('matches a date column by its formatted display value', () => { + const dateData = [ + { createdAt: '2024-01-15T10:30:00.000' }, + { createdAt: '2023-06-01T08:00:00.000' }, + ] + const dateHeaders = [ + { + dataKey: 'createdAt', + type: TYPE_DATE, + renderer: RENDERER_DATE, + }, + ] + + expect( + filterByGlobalSearch(dateData, '2024-01-15', { + headers: dateHeaders, + }) + ).toEqual([{ createdAt: '2024-01-15T10:30:00.000' }]) + }) + + it('matches a datetime column, including the time portion of its formatted value', () => { + const datetimeData = [{ updatedAt: '2024-01-15T10:30:00.000' }] + const datetimeHeaders = [ + { + dataKey: 'updatedAt', + type: TYPE_DATETIME, + renderer: RENDERER_DATE, + }, + ] + + expect( + filterByGlobalSearch(datetimeData, '10:30', { + headers: datetimeHeaders, + }) + ).toEqual(datetimeData) + }) + + it('ignores a header whose value is null or undefined rather than matching against "null"/"undefined"', () => { + const sparseData = [{ population: null }, { population: 42 }] + const numericHeaders = [{ dataKey: 'population', type: TYPE_NUMBER }] + + expect( + filterByGlobalSearch(sparseData, 'null', { + headers: numericHeaders, + }) + ).toEqual([]) + }) }) diff --git a/src/util/cellValue.js b/src/util/cellValue.js new file mode 100644 index 0000000000..253a380642 --- /dev/null +++ b/src/util/cellValue.js @@ -0,0 +1,41 @@ +import { + RENDERER_COLOR, + RENDERER_DATE, + RENDERER_ORG_UNIT, + RENDERER_ORG_UNIT_NAME, + RENDERER_BOOLEAN, + TYPE_DATE, +} from '../constants/dataTable.js' +import { formatBoolean, formatDate, formatDatetime } from './helpers.js' +import { formatWithSeparator } from './numbers.js' +import { + formatOrgUnitOwnName, + formatOrgUnitPathBreadcrumb, +} from './orgUnitGroups.js' + +export const NO_VALUE_TEXT = '—' + +export const formatCellText = ( + value, + { renderer, type, orgUnitIdToName, keyAnalysisDigitGroupSeparator } = {} +) => { + if (value == null) { + return NO_VALUE_TEXT + } + if (renderer === RENDERER_COLOR) { + return value.toLowerCase() + } + if (renderer === RENDERER_DATE) { + return type === TYPE_DATE ? formatDate(value) : formatDatetime(value) + } + if (renderer === RENDERER_ORG_UNIT) { + return formatOrgUnitPathBreadcrumb(value, orgUnitIdToName) + } + if (renderer === RENDERER_ORG_UNIT_NAME) { + return formatOrgUnitOwnName(value, orgUnitIdToName) + } + if (renderer === RENDERER_BOOLEAN) { + return formatBoolean(value) + } + return formatWithSeparator(value, keyAnalysisDigitGroupSeparator) +} diff --git a/src/util/filter.js b/src/util/filter.js index 797464c2c1..2a40d04ffc 100644 --- a/src/util/filter.js +++ b/src/util/filter.js @@ -3,8 +3,9 @@ import { SENTINEL_NO_VALUE, DATE_GROUPS_GRANULARITY, ORG_UNIT_GROUPS_GRANULARITY, + TYPE_NUMBER, } from '../constants/dataTable.js' -import { formatOrgUnitPathBreadcrumb } from './orgUnitGroups.js' +import { formatCellText } from './cellValue.js' // Distinguishes a prefix-group filter (date-groups, org-unit-groups, ...) export const isPrefixGroupFilter = (filter, granularity) => @@ -104,36 +105,37 @@ export const numericFilter = (value, filter) => { }) } +const getSearchableTexts = (value, header, formatArgs) => { + if (value == null) { + return [] + } + const formatted = formatCellText(value, { + renderer: header.renderer, + type: header.type, + ...formatArgs, + }) + return header.type === TYPE_NUMBER + ? [String(value), formatted] + : [formatted] +} + export const filterByGlobalSearch = ( data, searchString, - { stringDataKeys = [], orgUnitDataKeys = [], idToName } = {} + { headers = [], orgUnitIdToName, keyAnalysisDigitGroupSeparator } = {} ) => { - if ( - !searchString?.trim() || - (!stringDataKeys.length && !orgUnitDataKeys.length) - ) { + if (!searchString?.trim() || !headers.length) { return data } const lower = searchString.toLowerCase() return data.filter((item) => { const props = item.properties || item - const stringMatch = stringDataKeys.some((key) => { - const val = props[key] - return val != null && String(val).toLowerCase().includes(lower) - }) - if (stringMatch) { - return true - } - return orgUnitDataKeys.some((key) => { - const val = props[key] - return ( - val != null && - formatOrgUnitPathBreadcrumb(val, idToName) - .toLowerCase() - .includes(lower) - ) - }) + return headers.some((header) => + getSearchableTexts(props[header.dataKey], header, { + orgUnitIdToName, + keyAnalysisDigitGroupSeparator, + }).some((text) => text.toLowerCase().includes(lower)) + ) }) } From a39bbeef0cb8bbf3da2f86e9682683c04a23783d Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Mon, 3 Aug 2026 12:32:24 +0200 Subject: [PATCH 191/205] fix: keep feature selection when opening or closing the data table --- src/reducers/__tests__/selection.spec.js | 49 ++++++++++++++++-------- src/reducers/selection.js | 4 -- 2 files changed, 32 insertions(+), 21 deletions(-) diff --git a/src/reducers/__tests__/selection.spec.js b/src/reducers/__tests__/selection.spec.js index dde0fe4870..ff69b2a3aa 100644 --- a/src/reducers/__tests__/selection.spec.js +++ b/src/reducers/__tests__/selection.spec.js @@ -107,27 +107,33 @@ describe('selection reducer', () => { expect(state).toEqual({ layerId: 'layer-2', ids: ['x', 'y'] }) }) - it.each([ - types.SELECTION_CLEAR, - types.MAP_NEW, - types.MAP_SET, - types.DATA_TABLE_CLOSE, - ])('resets to default state on %s', (type) => { - const state = selection( - { layerId: 'layer-1', ids: ['a', 'b'] }, - { type } - ) + it.each([types.SELECTION_CLEAR, types.MAP_NEW, types.MAP_SET])( + 'resets to default state on %s', + (type) => { + const state = selection( + { layerId: 'layer-1', ids: ['a', 'b'] }, + { type } + ) + + expect(state).toEqual({ layerId: null, ids: [] }) + } + ) + + it('keeps the selection when the data table panel is closed', () => { + const prevState = { layerId: 'layer-1', ids: ['a', 'b'] } + const state = selection(prevState, { type: types.DATA_TABLE_CLOSE }) - expect(state).toEqual({ layerId: null, ids: [] }) + expect(state).toBe(prevState) }) - it("resets to default state when the selected layer's data table tab is toggled (closed)", () => { - const state = selection( - { layerId: 'layer-1', ids: ['a', 'b'] }, - { type: types.DATA_TABLE_TOGGLE, id: 'layer-1' } - ) + it("keeps the selection when the selected layer's own data table tab is toggled", () => { + const prevState = { layerId: 'layer-1', ids: ['a', 'b'] } + const state = selection(prevState, { + type: types.DATA_TABLE_TOGGLE, + id: 'layer-1', + }) - expect(state).toEqual({ layerId: null, ids: [] }) + expect(state).toBe(prevState) }) it("keeps the selection when a different layer's data table tab is toggled", () => { @@ -140,6 +146,15 @@ describe('selection reducer', () => { expect(state).toBe(prevState) }) + it('keeps the selection when the combined view is toggled', () => { + const prevState = { layerId: 'layer-1', ids: ['a', 'b'] } + const state = selection(prevState, { + type: types.DATA_TABLE_COMBINED_VIEW_TOGGLE, + }) + + expect(state).toBe(prevState) + }) + it('resets to default state when the selected layer is removed', () => { const state = selection( { layerId: 'layer-1', ids: ['a', 'b'] }, diff --git a/src/reducers/selection.js b/src/reducers/selection.js index 84fb3f081d..6b79378580 100644 --- a/src/reducers/selection.js +++ b/src/reducers/selection.js @@ -70,12 +70,8 @@ const selection = (state = defaultState, action) => { case types.SELECTION_CLEAR: case types.MAP_NEW: case types.MAP_SET: - case types.DATA_TABLE_CLOSE: return defaultState - case types.DATA_TABLE_TOGGLE: - return state.layerId === action.id ? defaultState : state - case types.LAYER_REMOVE: return removeLayerFromSelection(state, action) From d700cff1fecf79728fbf168cf51c48b5aa453efa Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Mon, 3 Aug 2026 12:39:05 +0200 Subject: [PATCH 192/205] fix: keep fixed period columns in thematic timeline data table alongside the current-period column --- .../datatable/__tests__/useTableData.spec.jsx | 17 +++++++++++++---- src/util/__tests__/tableHeaders.spec.js | 4 ++-- src/util/__tests__/tableRows.spec.js | 4 ++-- src/util/tableHeaders.js | 6 +----- src/util/tableRows.js | 3 --- 5 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index 6df9c562c6..0a87d6eb13 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -391,7 +391,7 @@ describe('useTableData headers', () => { ) }) - test('adds a defaultHidden raw-value-only column for every other period, for a timeline thematic layer', () => { + test('adds a defaultHidden raw-value-only column for every period, including the current one, for a timeline thematic layer', () => { const store = { aggregations: {}, ui: { @@ -434,21 +434,30 @@ describe('useTableData headers', () => { } ) const { headers, rows } = result.current - expect(headers).not.toContainEqual( - expect.objectContaining({ dataKey: 'period_202302_rawValue' }) - ) expect(headers).toContainEqual({ name: 'Value (January 2023)', dataKey: 'period_202301_rawValue', type: 'number', defaultHidden: true, }) + expect(headers).toContainEqual({ + name: 'Value (February 2023)', + dataKey: 'period_202302_rawValue', + type: 'number', + defaultHidden: true, + }) expect(rows[0]).toContainEqual( expect.objectContaining({ value: 100, dataKey: 'period_202301_rawValue', }) ) + expect(rows[0]).toContainEqual( + expect.objectContaining({ + value: 200, + dataKey: 'period_202302_rawValue', + }) + ) }) test('split-by-period thematic layer has no default current-period column, only defaultHidden period columns', () => { diff --git a/src/util/__tests__/tableHeaders.spec.js b/src/util/__tests__/tableHeaders.spec.js index f2afb44f1c..ac3be4df13 100644 --- a/src/util/__tests__/tableHeaders.spec.js +++ b/src/util/__tests__/tableHeaders.spec.js @@ -85,7 +85,7 @@ describe('getHeadersForLayer - thematic', () => { ) }) - test('multi-period timeline: excludes the external period from the extra columns and labels value/legend/range/color with it', () => { + test('multi-period timeline: keeps a fixed column for the external period alongside the current-period columns, and labels value/legend/range/color with it', () => { const periods = [ { id: 'p1', name: 'Jan' }, { id: 'p2', name: 'Feb' }, @@ -97,7 +97,7 @@ describe('getHeadersForLayer - thematic', () => { periods, externalPeriod, }) - expect(dataKeys(result)).not.toContain('period_p1_rawValue') + expect(dataKeys(result)).toContain('period_p1_rawValue') expect(dataKeys(result)).toContain('period_p2_rawValue') const valueHeader = result.headers.find((h) => h.dataKey === 'rawValue') expect(valueHeader.name).toContain('Jan') diff --git a/src/util/__tests__/tableRows.spec.js b/src/util/__tests__/tableRows.spec.js index c66d39a5dd..829517c9ed 100644 --- a/src/util/__tests__/tableRows.spec.js +++ b/src/util/__tests__/tableRows.spec.js @@ -170,7 +170,7 @@ describe('buildTableData - multi-period thematic layer', () => { p2: { a: { value: 2 } }, } - test('timeline: overlays the external period’s value/color/legend/range and adds one column per other period', () => { + test('timeline: overlays the external period’s value/color/legend/range and adds one fixed column per period, including the external one', () => { const data = [feature('a')] const result = buildTableData(THEMATIC_LAYER, { data, @@ -187,9 +187,9 @@ describe('buildTableData - multi-period thematic layer', () => { color: '#f00', legend: 'Low', range: '0-1', + period_p1_rawValue: 1, period_p2_rawValue: 2, }) - expect(result.data[0].period_p1_rawValue).toBeUndefined() }) test('split (non-timeline): adds one column per period, with no current-period overlay', () => { diff --git a/src/util/tableHeaders.js b/src/util/tableHeaders.js index fec289b699..8309ab8cb1 100644 --- a/src/util/tableHeaders.js +++ b/src/util/tableHeaders.js @@ -241,11 +241,7 @@ const getMultiPeriodThematicHeaders = ({ ) : getOrgUnitHeaders() - const otherPeriods = isTimelineThematic - ? (periods ?? []).filter((p) => p.id !== externalPeriod?.id) - : periods ?? [] - - otherPeriods.forEach((period) => { + ;(periods ?? []).forEach((period) => { headers.push({ name: i18n.t('Value ({{period}})', { period: period.name }), dataKey: `period_${period.id}_rawValue`, diff --git a/src/util/tableRows.js b/src/util/tableRows.js index 60719159d3..41e704ca1f 100644 --- a/src/util/tableRows.js +++ b/src/util/tableRows.js @@ -88,9 +88,6 @@ export const buildTableData = ( : null const otherPeriodValues = {} ;(periods ?? []).forEach((period) => { - if (isTimelineThematic && period.id === externalPeriod?.id) { - return - } otherPeriodValues[`period_${period.id}_rawValue`] = valuesByPeriod?.[period.id]?.[orgUnitId]?.value ?? null }) From 756d6dba0371e0ecab1b48bf101835cb061b86a7 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Mon, 3 Aug 2026 12:49:46 +0200 Subject: [PATCH 193/205] fix: avoid stale period label for timeline columns in the data table column picker --- .../__tests__/ColumnPickerControl.spec.jsx | 21 +++++++++++++++++++ .../datatable/controls/ColumnRow.jsx | 6 +++++- src/util/__tests__/tableColumns.spec.js | 13 ++++++++++++ src/util/__tests__/tableHeaders.spec.js | 1 + src/util/tableColumns.js | 2 +- src/util/tableHeaders.js | 3 +++ 6 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx b/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx index 4a63469e27..c79452a634 100644 --- a/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx +++ b/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx @@ -369,6 +369,27 @@ describe('ColumnPicker search', () => { }) }) +describe('ColumnPicker configName (timeline current-period columns)', () => { + test('shows configName instead of the period-specific name, when present', () => { + const headersWithConfigName = [ + ...headers, + { + name: 'Range (Jan 2023)', + configName: 'Range (Current period)', + dataKey: 'range', + }, + ] + renderColumnPicker({ allHeaders: headersWithConfigName }) + openPicker() + expect( + screen.getByLabelText('Range (Current period)') + ).toBeInTheDocument() + expect( + screen.queryByLabelText('Range (Jan 2023)') + ).not.toBeInTheDocument() + }) +}) + describe('ColumnPicker defaultHidden headers (e.g. period columns)', () => { const headersWithHiddenColumn = [ ...headers, diff --git a/src/components/datatable/controls/ColumnRow.jsx b/src/components/datatable/controls/ColumnRow.jsx index 28628fc7b9..d944040f05 100644 --- a/src/components/datatable/controls/ColumnRow.jsx +++ b/src/components/datatable/controls/ColumnRow.jsx @@ -53,7 +53,9 @@ export const ColumnRowFields = ({ </button> <Checkbox label={ - <span className={styles.columnRowLabel}>{header.name}</span> + <span className={styles.columnRowLabel}> + {header.configName ?? header.name} + </span> } checked={isVisible} onChange={(checked) => onToggleVisible(header.dataKey, checked)} @@ -85,6 +87,7 @@ ColumnRowFields.propTypes = { header: PropTypes.shape({ dataKey: PropTypes.string.isRequired, name: PropTypes.string.isRequired, + configName: PropTypes.string, }).isRequired, isPinned: PropTypes.bool.isRequired, isVisible: PropTypes.bool.isRequired, @@ -145,6 +148,7 @@ ColumnRow.propTypes = { header: PropTypes.shape({ dataKey: PropTypes.string.isRequired, name: PropTypes.string.isRequired, + configName: PropTypes.string, }).isRequired, isDragActive: PropTypes.bool.isRequired, isPinned: PropTypes.bool.isRequired, diff --git a/src/util/__tests__/tableColumns.spec.js b/src/util/__tests__/tableColumns.spec.js index a22a804090..fb3139011a 100644 --- a/src/util/__tests__/tableColumns.spec.js +++ b/src/util/__tests__/tableColumns.spec.js @@ -513,6 +513,19 @@ describe('filterHeadersByName', () => { it('returns every header when the search text is empty', () => { expect(filterHeadersByName(headers, '')).toEqual(headers) }) + + it('matches against configName instead of name when present', () => { + const withConfigName = [ + ...headers, + { + dataKey: 'rawValue2', + name: 'Value (Jan 2023)', + configName: 'Value (Current period)', + }, + ] + const result = filterHeadersByName(withConfigName, 'current period') + expect(result.map((h) => h.dataKey)).toEqual(['rawValue2']) + }) }) describe('reorderHeaderKeys', () => { diff --git a/src/util/__tests__/tableHeaders.spec.js b/src/util/__tests__/tableHeaders.spec.js index ac3be4df13..e631e642a6 100644 --- a/src/util/__tests__/tableHeaders.spec.js +++ b/src/util/__tests__/tableHeaders.spec.js @@ -101,6 +101,7 @@ describe('getHeadersForLayer - thematic', () => { expect(dataKeys(result)).toContain('period_p2_rawValue') const valueHeader = result.headers.find((h) => h.dataKey === 'rawValue') expect(valueHeader.name).toContain('Jan') + expect(valueHeader.configName).toBe('Value (Current period)') }) }) diff --git a/src/util/tableColumns.js b/src/util/tableColumns.js index 3636f572f7..41b7346ca4 100644 --- a/src/util/tableColumns.js +++ b/src/util/tableColumns.js @@ -192,7 +192,7 @@ export const buildRowCells = (item, headers) => export const filterHeadersByName = (headers, search) => { const normalizedSearch = search.trim().toLowerCase() return headers.filter((h) => - h.name.toLowerCase().includes(normalizedSearch) + (h.configName ?? h.name).toLowerCase().includes(normalizedSearch) ) } diff --git a/src/util/tableHeaders.js b/src/util/tableHeaders.js index 8309ab8cb1..4a86b64bec 100644 --- a/src/util/tableHeaders.js +++ b/src/util/tableHeaders.js @@ -236,6 +236,9 @@ const getMultiPeriodThematicHeaders = ({ name: `${header.name} (${ externalPeriod?.name ?? i18n.t('Current period') })`, + configName: `${header.name} (${i18n.t( + 'Current period' + )})`, } : header ) From 4c7969ffe868abb9ad0b87c53297f785fd47ca50 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Mon, 3 Aug 2026 14:13:58 +0200 Subject: [PATCH 194/205] fix: support timeline/split-by-period thematic layers in the combined data table --- i18n/en.pot | 22 ++- .../datatable/CombinedDataTable.jsx | 4 + .../__tests__/JoinLayersControl.spec.jsx | 41 ++++ .../__tests__/useCombinedTableData.spec.js | 187 ++++++++++++++++++ .../datatable/controls/JoinLayersControl.jsx | 26 ++- .../datatable/useCombinedTableData.js | 122 +++++++++--- src/util/__tests__/dataTable.spec.js | 123 ++++++++++++ src/util/dataTable.js | 69 ++++++- 8 files changed, 550 insertions(+), 44 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index b1e6e4424e..dfb633963f 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-08-03T07:08:14.950Z\n" -"PO-Revision-Date: 2026-08-03T07:08:14.951Z\n" +"POT-Creation-Date: 2026-08-03T11:27:11.052Z\n" +"PO-Revision-Date: 2026-08-03T11:27:11.052Z\n" msgid "2020" msgstr "2020" @@ -432,21 +432,30 @@ msgstr "%" msgid "count" msgstr "count" +msgid "Value ({{layer}}, {{period}})" +msgstr "Value ({{layer}}, {{period}})" + +msgid "Current period" +msgstr "Current period" + msgid "{{name}} ({{layer}})" msgstr "{{name}} ({{layer}})" msgid "Value ({{layer}})" msgstr "Value ({{layer}})" +msgid "Legend ({{layer}})" +msgstr "Legend ({{layer}})" + +msgid "Legend ({{layer}}, {{period}})" +msgstr "Legend ({{layer}}, {{period}})" + msgid "Org unit id" msgstr "Org unit id" msgid "Org unit level" msgstr "Org unit level" -msgid "Legend ({{layer}})" -msgstr "Legend ({{layer}})" - msgid "No valid data was found for the current layer configuration." msgstr "No valid data was found for the current layer configuration." @@ -2199,9 +2208,6 @@ msgstr "Group" msgid "Icon" msgstr "Icon" -msgid "Current period" -msgstr "Current period" - msgid "Value ({{period}})" msgstr "Value ({{period}})" diff --git a/src/components/datatable/CombinedDataTable.jsx b/src/components/datatable/CombinedDataTable.jsx index 60531dcc37..9505a41b64 100644 --- a/src/components/datatable/CombinedDataTable.jsx +++ b/src/components/datatable/CombinedDataTable.jsx @@ -89,6 +89,9 @@ const CombinedDataTable = ({ (state) => state.ui.showOnlyFeaturesInView ) const mapBounds = useSelector((state) => state.ui.mapBounds) + const externalPeriod = useSelector( + (state) => state.ui?.activeTimelinePeriod + ) const selectionFilter = useSelector((state) => state.ui.selectionFilter) const currentFeature = useSelector((state) => state.feature) const lastClickedFeature = useSelector( @@ -115,6 +118,7 @@ const CombinedDataTable = ({ selectionFilter, selectedIdSet, keyAnalysisDigitGroupSeparator, + externalPeriod, }) useEffect(() => { diff --git a/src/components/datatable/__tests__/JoinLayersControl.spec.jsx b/src/components/datatable/__tests__/JoinLayersControl.spec.jsx index a58f38e671..79debd313d 100644 --- a/src/components/datatable/__tests__/JoinLayersControl.spec.jsx +++ b/src/components/datatable/__tests__/JoinLayersControl.spec.jsx @@ -356,6 +356,47 @@ describe('JoinLayersControl popover — per-layer type/aggregation settings', () }, }) }) + + test('shows a single shared aggregation select for a multi-period thematic layer, not one per period', () => { + const onChange = jest.fn() + const timelineLayer = { + id: 'timeline', + name: 'Timeline Layer', + combinedLayerKey: 'timeline', + layer: THEMATIC_LAYER, + renderingStrategy: 'TIMELINE', + periods: [ + { id: 'p1', name: 'Jan' }, + { id: 'p2', name: 'Feb' }, + { id: 'p3', name: 'Mar' }, + ], + data: [{ properties: { orgUnitPath: '/country1/ou1' } }], + } + renderControl({ + eligibleLayers: [timelineLayer], + layersConfig: { + timeline: { type: 'orgUnit', aggregation: {} }, + }, + onChange, + }) + openPicker() + + expect( + screen.getAllByLabelText('Aggregation type for Timeline Layer') + ).toHaveLength(1) + + fireEvent.change( + screen.getByLabelText('Aggregation type for Timeline Layer'), + { target: { value: 'AVERAGE' } } + ) + + expect(onChange).toHaveBeenCalledWith({ + timeline: { + type: 'orgUnit', + aggregation: { rawValue: 'AVERAGE' }, + }, + }) + }) }) describe('JoinLayersControl popover — aggregation rollup warning', () => { diff --git a/src/components/datatable/__tests__/useCombinedTableData.spec.js b/src/components/datatable/__tests__/useCombinedTableData.spec.js index ed5c31ab9c..fc707fbd21 100644 --- a/src/components/datatable/__tests__/useCombinedTableData.spec.js +++ b/src/components/datatable/__tests__/useCombinedTableData.spec.js @@ -3,6 +3,7 @@ import { EARTH_ENGINE_LAYER, EVENT_LAYER, FACILITY_LAYER, + THEMATIC_LAYER, } from '../../../constants/layers.js' import { useCombinedTableData } from '../useCombinedTableData.js' @@ -846,6 +847,192 @@ describe('useCombinedTableData - Earth Engine value columns', () => { }) }) +describe('useCombinedTableData - thematic timeline/split-by-period value columns', () => { + const periods = [ + { id: 'p1', name: 'Jan 2023' }, + { id: 'p2', name: 'Feb 2023' }, + ] + const valuesByPeriod = { + p1: { f1: { value: 10, legend: 'Low' } }, + p2: { f1: { value: 20, legend: 'High' } }, + } + + test('TIMELINE: current column resolves from the active period, plus one fixed column per period', () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + layer: THEMATIC_LAYER, + renderingStrategy: 'TIMELINE', + periods, + valuesByPeriod, + data: [feature({ id: 'f1', orgUnitPath: '/country1/ou1' })], + }, + ] + const joinConfig = { + layers: { layerA: { type: 'orgUnit', aggregation: {} } }, + } + + const { result } = renderHook(() => + useCombinedTableData({ + layers, + referenceLayer, + joinConfig, + externalPeriod: periods[0], + }) + ) + + expect(result.current.headers.map((h) => h.dataKey)).toEqual([ + 'id', + 'name', + 'level', + 'layerA_rawValue', + 'layerA_period_p1_rawValue', + 'layerA_period_p2_rawValue', + 'layerA_legend', + ]) + + const currentHeader = result.current.headers.find( + (h) => h.dataKey === 'layerA_rawValue' + ) + expect(currentHeader.name).toBe('Value (Layer A, Jan 2023)') + expect(currentHeader.configName).toBe('Value (Layer A, Current period)') + + const legendHeader = result.current.headers.find( + (h) => h.dataKey === 'layerA_legend' + ) + expect(legendHeader.name).toBe('Legend (Layer A, Jan 2023)') + expect(legendHeader.configName).toBe('Legend (Layer A, Current period)') + + const row1 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' + ) + expect(findCell(row1, 'layerA_rawValue').value).toBe(10) + expect(findCell(row1, 'layerA_period_p1_rawValue').value).toBe(10) + expect(findCell(row1, 'layerA_period_p2_rawValue').value).toBe(20) + expect(findCell(row1, 'layerA_legend').value).toBe('Low') + }) + + test('SPLIT_BY_PERIOD: only fixed per-period columns, no current column, no legend column', () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + layer: THEMATIC_LAYER, + renderingStrategy: 'SPLIT_BY_PERIOD', + periods, + valuesByPeriod, + data: [feature({ id: 'f1', orgUnitPath: '/country1/ou1' })], + }, + ] + const joinConfig = { + layers: { layerA: { type: 'orgUnit', aggregation: {} } }, + } + + const { result } = renderHook(() => + useCombinedTableData({ layers, referenceLayer, joinConfig }) + ) + + expect(result.current.headers.map((h) => h.dataKey)).toEqual([ + 'id', + 'name', + 'level', + 'layerA_period_p1_rawValue', + 'layerA_period_p2_rawValue', + ]) + + const row1 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' + ) + expect(findCell(row1, 'layerA_period_p1_rawValue').value).toBe(10) + expect(findCell(row1, 'layerA_period_p2_rawValue').value).toBe(20) + }) + + test('aggregates several matched features per period using one shared aggregation type', () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + layer: THEMATIC_LAYER, + renderingStrategy: 'TIMELINE', + periods, + valuesByPeriod: { + p1: { f1: { value: 10 }, f2: { value: 30 } }, + p2: { f1: { value: 20 }, f2: { value: 40 } }, + }, + data: [ + feature({ id: 'f1', orgUnitPath: '/country1/ou1' }), + feature({ id: 'f2', orgUnitPath: '/country1/ou1' }), + ], + }, + ] + const joinConfig = { + layers: { + layerA: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + } + + const { result } = renderHook(() => + useCombinedTableData({ + layers, + referenceLayer, + joinConfig, + externalPeriod: periods[0], + }) + ) + + const row1 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' + ) + expect(findCell(row1, 'layerA_rawValue').value).toBe(40) + expect(findCell(row1, 'layerA_period_p1_rawValue').value).toBe(40) + expect(findCell(row1, 'layerA_period_p2_rawValue').value).toBe(60) + }) + + test('dead-reference regression: value/legend are read from valuesByPeriod, never from raw feature properties', () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + layer: THEMATIC_LAYER, + renderingStrategy: 'TIMELINE', + periods, + valuesByPeriod, + data: [ + feature({ + id: 'f1', + orgUnitPath: '/country1/ou1', + rawValue: 9999, + legend: 'Bogus', + }), + ], + }, + ] + const joinConfig = { + layers: { layerA: { type: 'orgUnit', aggregation: {} } }, + } + + const { result } = renderHook(() => + useCombinedTableData({ + layers, + referenceLayer, + joinConfig, + externalPeriod: periods[0], + }) + ) + + const row1 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' + ) + expect(findCell(row1, 'layerA_rawValue').value).toBe(10) + expect(findCell(row1, 'layerA_legend').value).toBe('Low') + }) +}) + describe('useCombinedTableData - show only features in view', () => { const referenceFeature = ({ id, name, path, coordinates }) => ({ type: 'Feature', diff --git a/src/components/datatable/controls/JoinLayersControl.jsx b/src/components/datatable/controls/JoinLayersControl.jsx index bc53dedf2f..9b4e5bc328 100644 --- a/src/components/datatable/controls/JoinLayersControl.jsx +++ b/src/components/datatable/controls/JoinLayersControl.jsx @@ -164,10 +164,20 @@ const JoinLayersControl = ({ ({ kind }) => kind === DATA_KEY_KIND_CATEGORY ) - const otherDataKeys = valueDataKeys.filter( - ({ kind }) => - kind !== DATA_KEY_KIND_CATEGORY - ) + const seenAggregationKeys = new Set() + const otherDataKeys = valueDataKeys + .filter( + ({ kind }) => + kind !== DATA_KEY_KIND_CATEGORY + ) + .filter(({ dataKey, settingsKey }) => { + const key = settingsKey ?? dataKey + if (seenAggregationKeys.has(key)) { + return false + } + seenAggregationKeys.add(key) + return true + }) return ( <div key={layer.id} @@ -249,6 +259,7 @@ const JoinLayersControl = ({ dataKey, name, kind, + settingsKey, }) => { if ( kind === @@ -276,10 +287,13 @@ const JoinLayersControl = ({ ) } + const aggregationKey = + settingsKey ?? + dataKey const effectiveType = settings .aggregation?.[ - dataKey + aggregationKey ] ?? defaultAggregation[ dataKey @@ -331,7 +345,7 @@ const JoinLayersControl = ({ ) => onAggregationChange( layer.combinedLayerKey, - dataKey, + aggregationKey, e .target .value diff --git a/src/components/datatable/useCombinedTableData.js b/src/components/datatable/useCombinedTableData.js index b8d5777645..6ad5a11d6f 100644 --- a/src/components/datatable/useCombinedTableData.js +++ b/src/components/datatable/useCombinedTableData.js @@ -21,6 +21,7 @@ import { } from '../../util/combinedJoinMatch.js' import { CATEGORY_DISPLAY_TYPE_KEY, + getCombinedLegendConfig, getCombinedValueDataKeys, getDefaultCombinedAggregation, getFeatureCategoryKey, @@ -99,10 +100,11 @@ const finalizeRows = ( } const applyLayerMatchToRow = ({ row, featureIds, refProps }, layerMatch) => { - const { layer, settings, byReferenceId, valueDataKeys } = layerMatch + const { layer, settings, byReferenceId, valueDataKeys, legendConfig } = + layerMatch const matches = byReferenceId.get(refProps.id) ?? [] - valueDataKeys.forEach(({ dataKey, kind }) => { + valueDataKeys.forEach(({ dataKey, kind, periodId, settingsKey }) => { const rowKey = `${layer.combinedLayerKey}_${dataKey}` if (kind === DATA_KEY_KIND_COUNT) { @@ -129,17 +131,29 @@ const applyLayerMatchToRow = ({ row, featureIds, refProps }, layerMatch) => { } const effectiveType = - settings.aggregation?.[dataKey] ?? + settings.aggregation?.[settingsKey ?? dataKey] ?? getDefaultCombinedAggregation(layer)[dataKey] - const values = matches - .map((p) => p[dataKey]) - .filter((v) => Number.isFinite(v)) + const values = + periodId != null + ? matches + .map( + (p) => layer.valuesByPeriod?.[periodId]?.[p.id]?.value + ) + .filter((v) => Number.isFinite(v)) + : matches + .map((p) => p[dataKey]) + .filter((v) => Number.isFinite(v)) row[rowKey] = applyAggregation(effectiveType, values) }) - if (layer.layer !== EARTH_ENGINE_LAYER) { + if (legendConfig) { const legends = matches - .map((p) => p[LEGEND_KEY]) + .map((p) => + legendConfig.periodId != null + ? layer.valuesByPeriod?.[legendConfig.periodId]?.[p.id] + ?.legend + : p[LEGEND_KEY] + ) .filter((v) => v != null) row[`${layer.combinedLayerKey}_${LEGEND_KEY}`] = legends.length && legends.every((l) => l === legends[0]) @@ -156,7 +170,7 @@ const applyLayerMatchToRow = ({ row, featureIds, refProps }, layerMatch) => { const getValueDataKeyHeader = ( layer, settings, - { dataKey, name, kind, defaultHidden } + { dataKey, name, kind, defaultHidden, periodName, isCurrentPeriod } ) => { const rowKey = `${layer.combinedLayerKey}_${dataKey}` @@ -187,6 +201,24 @@ const getValueDataKeyHeader = ( } } + if (periodName !== undefined || isCurrentPeriod) { + return { + name: i18n.t('Value ({{layer}}, {{period}})', { + layer: layer.name, + period: periodName ?? i18n.t('Current period'), + }), + dataKey: rowKey, + type: TYPE_NUMBER, + defaultHidden, + ...(isCurrentPeriod && { + configName: i18n.t('Value ({{layer}}, {{period}})', { + layer: layer.name, + period: i18n.t('Current period'), + }), + }), + } + } + return { name: name ? i18n.t('{{name}} ({{layer}})', { name, layer: layer.name }) @@ -197,6 +229,31 @@ const getValueDataKeyHeader = ( } } +const getLegendHeader = (layer, { periodName, isCurrentPeriod }) => { + const dataKey = `${layer.combinedLayerKey}_${LEGEND_KEY}` + if (!isCurrentPeriod) { + return { + name: i18n.t('Legend ({{layer}})', { layer: layer.name }), + dataKey, + type: TYPE_STRING, + defaultHidden: true, + } + } + return { + name: i18n.t('Legend ({{layer}}, {{period}})', { + layer: layer.name, + period: periodName ?? i18n.t('Current period'), + }), + configName: i18n.t('Legend ({{layer}}, {{period}})', { + layer: layer.name, + period: i18n.t('Current period'), + }), + dataKey, + type: TYPE_STRING, + defaultHidden: true, + } +} + const EMPTY_COLUMN_OPTIONS = {} const EMPTY_HEADERS = [] @@ -222,6 +279,7 @@ export const useCombinedTableData = ({ selectionFilter, selectedIdSet, keyAnalysisDigitGroupSeparator, + externalPeriod, }) => { const referenceOrgUnits = useMemo( () => getJoinableFeatures(referenceLayer), @@ -250,15 +308,28 @@ export const useCombinedTableData = ({ allAggregations[layer.id] ?? EMPTY_AGGREGATIONS ) const features = getJoinableFeatures(mergedLayer) - const valueDataKeys = getCombinedValueDataKeys(layer) + const valueDataKeys = getCombinedValueDataKeys( + layer, + externalPeriod + ) + const legendConfig = getCombinedLegendConfig( + layer, + externalPeriod + ) const byReferenceId = getByReferenceId( features, referenceOrgUnits, settings.type ) - return { layer, settings, byReferenceId, valueDataKeys } + return { + layer, + settings, + byReferenceId, + valueDataKeys, + legendConfig, + } }), - [layers, joinConfig, referenceOrgUnits, allAggregations] + [layers, joinConfig, referenceOrgUnits, allAggregations, externalPeriod] ) const headers = useMemo(() => { @@ -279,23 +350,16 @@ export const useCombinedTableData = ({ type: TYPE_NUMBER, defaultHidden: true, }, - ...layerMatches.flatMap(({ layer, settings, valueDataKeys }) => [ - ...valueDataKeys.map((valueDataKey) => - getValueDataKeyHeader(layer, settings, valueDataKey) - ), - ...(layer.layer !== EARTH_ENGINE_LAYER - ? [ - { - name: i18n.t('Legend ({{layer}})', { - layer: layer.name, - }), - dataKey: `${layer.combinedLayerKey}_${LEGEND_KEY}`, - type: TYPE_STRING, - defaultHidden: true, - }, - ] - : []), - ]), + ...layerMatches.flatMap( + ({ layer, settings, valueDataKeys, legendConfig }) => [ + ...valueDataKeys.map((valueDataKey) => + getValueDataKeyHeader(layer, settings, valueDataKey) + ), + ...(legendConfig + ? [getLegendHeader(layer, legendConfig)] + : []), + ] + ), ] }, [referenceOrgUnits, layerMatches]) diff --git a/src/util/__tests__/dataTable.spec.js b/src/util/__tests__/dataTable.spec.js index f4417874a1..a58aa34964 100644 --- a/src/util/__tests__/dataTable.spec.js +++ b/src/util/__tests__/dataTable.spec.js @@ -14,6 +14,7 @@ import { } from '../../constants/layers.js' import { buildFeatureIndex, + getCombinedLegendConfig, getCombinedValueDataKeys, getDefaultCombinedAggregation, getDefaultReferenceRows, @@ -49,6 +50,84 @@ describe('getCombinedValueDataKeys', () => { ]) }) + test('single-period thematic layer (no renderingStrategy set) still returns the single generic rawValue column', () => { + expect( + getCombinedValueDataKeys({ + layer: THEMATIC_LAYER, + renderingStrategy: 'SINGLE', + }) + ).toEqual([ + { dataKey: 'rawValue', name: null, kind: DATA_KEY_KIND_VALUE }, + ]) + }) + + test('TIMELINE thematic layer: returns a current column plus one fixed column per period', () => { + const externalPeriod = { id: 'p1', name: 'Jan' } + const periods = [ + { id: 'p1', name: 'Jan' }, + { id: 'p2', name: 'Feb' }, + ] + expect( + getCombinedValueDataKeys( + { + layer: THEMATIC_LAYER, + renderingStrategy: 'TIMELINE', + periods, + }, + externalPeriod + ) + ).toEqual([ + { + dataKey: 'rawValue', + name: null, + kind: DATA_KEY_KIND_VALUE, + periodId: 'p1', + periodName: 'Jan', + isCurrentPeriod: true, + settingsKey: 'rawValue', + }, + { + dataKey: 'period_p1_rawValue', + name: null, + kind: DATA_KEY_KIND_VALUE, + periodId: 'p1', + periodName: 'Jan', + settingsKey: 'rawValue', + defaultHidden: true, + }, + { + dataKey: 'period_p2_rawValue', + name: null, + kind: DATA_KEY_KIND_VALUE, + periodId: 'p2', + periodName: 'Feb', + settingsKey: 'rawValue', + defaultHidden: true, + }, + ]) + }) + + test('SPLIT_BY_PERIOD thematic layer: returns only fixed per-period columns, no current column', () => { + const periods = [{ id: 'p1', name: 'Jan' }] + expect( + getCombinedValueDataKeys({ + layer: THEMATIC_LAYER, + renderingStrategy: 'SPLIT_BY_PERIOD', + periods, + }) + ).toEqual([ + { + dataKey: 'period_p1_rawValue', + name: null, + kind: DATA_KEY_KIND_VALUE, + periodId: 'p1', + periodName: 'Jan', + settingsKey: 'rawValue', + defaultHidden: true, + }, + ]) + }) + test('returns one column per aggregation stat when aggregationType is an array', () => { expect( getCombinedValueDataKeys({ @@ -305,6 +384,50 @@ describe('getCombinedValueDataKeys', () => { }) }) +describe('getCombinedLegendConfig', () => { + test('Earth Engine layer: no legend column', () => { + expect( + getCombinedLegendConfig({ layer: EARTH_ENGINE_LAYER }) + ).toBeNull() + }) + + test('single-period thematic layer: generic legend, read straight off feature properties', () => { + expect( + getCombinedLegendConfig({ + layer: THEMATIC_LAYER, + renderingStrategy: 'SINGLE', + }) + ).toEqual({ periodId: null, periodName: null, isCurrentPeriod: false }) + }) + + test('non-thematic layer: generic legend, unaffected by renderingStrategy', () => { + expect(getCombinedLegendConfig({ layer: FACILITY_LAYER })).toEqual({ + periodId: null, + periodName: null, + isCurrentPeriod: false, + }) + }) + + test('TIMELINE thematic layer: legend resolved from the current period', () => { + const externalPeriod = { id: 'p1', name: 'Jan' } + expect( + getCombinedLegendConfig( + { layer: THEMATIC_LAYER, renderingStrategy: 'TIMELINE' }, + externalPeriod + ) + ).toEqual({ periodId: 'p1', periodName: 'Jan', isCurrentPeriod: true }) + }) + + test('SPLIT_BY_PERIOD thematic layer: no legend column at all', () => { + expect( + getCombinedLegendConfig({ + layer: THEMATIC_LAYER, + renderingStrategy: 'SPLIT_BY_PERIOD', + }) + ).toBeNull() + }) +}) + describe('getFeatureCategoryKey - Facility/OrgUnit', () => { const layer = { layer: FACILITY_LAYER, diff --git a/src/util/dataTable.js b/src/util/dataTable.js index 18ee266f96..141db457f0 100644 --- a/src/util/dataTable.js +++ b/src/util/dataTable.js @@ -14,6 +14,8 @@ import { FACILITY_LAYER, EVENT_LAYER, TRACKED_ENTITY_LAYER, + RENDERING_STRATEGY_SINGLE, + RENDERING_STRATEGY_TIMELINE, } from '../constants/layers.js' import { numberValueTypes } from '../constants/valueTypes.js' import { @@ -168,7 +170,69 @@ export const getFeatureCategoryKey = (layer, props) => { return UNCLASSIFIED_CATEGORY_KEY } -export const getCombinedValueDataKeys = (layer) => { +const getThematicCombinedValueDataKeys = (layer, externalPeriod) => { + const { renderingStrategy, periods } = layer + if (!renderingStrategy || renderingStrategy === RENDERING_STRATEGY_SINGLE) { + return [ + { + dataKey: COMBINED_VALUE_KEY, + name: null, + kind: DATA_KEY_KIND_VALUE, + }, + ] + } + + const periodValueKeys = (periods ?? []).map((period) => ({ + dataKey: `period_${period.id}_${COMBINED_VALUE_KEY}`, + name: null, + kind: DATA_KEY_KIND_VALUE, + periodId: period.id, + periodName: period.name, + settingsKey: COMBINED_VALUE_KEY, + defaultHidden: true, + })) + + if (renderingStrategy !== RENDERING_STRATEGY_TIMELINE) { + return periodValueKeys + } + + return [ + { + dataKey: COMBINED_VALUE_KEY, + name: null, + kind: DATA_KEY_KIND_VALUE, + periodId: externalPeriod?.id, + periodName: externalPeriod?.name, + isCurrentPeriod: true, + settingsKey: COMBINED_VALUE_KEY, + }, + ...periodValueKeys, + ] +} + +export const getCombinedLegendConfig = (layer, externalPeriod) => { + if (layer.layer === EARTH_ENGINE_LAYER) { + return null + } + if ( + layer.layer !== THEMATIC_LAYER || + !layer.renderingStrategy || + layer.renderingStrategy === RENDERING_STRATEGY_SINGLE + ) { + return { periodId: null, periodName: null, isCurrentPeriod: false } + } + if (layer.renderingStrategy === RENDERING_STRATEGY_TIMELINE) { + return { + periodId: externalPeriod?.id ?? null, + periodName: externalPeriod?.name ?? null, + isCurrentPeriod: true, + } + } + // SPLIT_BY_PERIOD: no single period to show a legend for + return null +} + +export const getCombinedValueDataKeys = (layer, externalPeriod) => { if (layer.layer === FACILITY_LAYER || layer.layer === ORG_UNIT_LAYER) { return getOrgUnitGroupValueDataKeys(layer) } @@ -184,6 +248,9 @@ export const getCombinedValueDataKeys = (layer) => { }, ] } + if (layer.layer === THEMATIC_LAYER) { + return getThematicCombinedValueDataKeys(layer, externalPeriod) + } if (layer.layer !== EARTH_ENGINE_LAYER) { return [ { From 74a6416f76895715794527cb0f4601e02d9291f9 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 4 Aug 2026 10:21:17 +0200 Subject: [PATCH 195/205] fix: apply thousands separator to rows count --- src/components/datatable/BottomPanel.jsx | 7 +++ .../datatable/__tests__/BottomPanel.spec.jsx | 6 +++ .../datatable/controls/RowCountControl.jsx | 24 +++++++--- .../__tests__/RowCountControl.spec.jsx | 44 +++++++++++++++++++ 4 files changed, 75 insertions(+), 6 deletions(-) create mode 100644 src/components/datatable/controls/__tests__/RowCountControl.spec.jsx diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index cb2fa7c579..4b4e0711ee 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -29,6 +29,7 @@ import { hasActiveDataTableFilters, } from '../../util/dataTable.js' import { getCssVar } from '../../util/helpers.js' +import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' import { useWindowDimensions } from '../WindowDimensionsProvider.jsx' import CombinedDataTable from './CombinedDataTable.jsx' import ClearFiltersControl from './controls/ClearFiltersControl.jsx' @@ -54,6 +55,9 @@ const EMPTY_FILTERS = {} const EMPTY_JOIN_LAYERS = {} const BottomPanel = () => { + const { + systemSettings: { keyAnalysisDigitGroupSeparator }, + } = useCachedData() const dataTableHeight = useSelector((state) => state.ui.dataTableHeight) const { openIds, combinedView } = useSelector((state) => state.dataTable) const mapViews = useSelector((state) => state.map.mapViews) @@ -366,6 +370,9 @@ const BottomPanel = () => { <RowCountControl totalCount={totalCount} filteredCount={filteredCount} + keyAnalysisDigitGroupSeparator={ + keyAnalysisDigitGroupSeparator + } /> <span className={styles.divider} /> <ClearFiltersControl diff --git a/src/components/datatable/__tests__/BottomPanel.spec.jsx b/src/components/datatable/__tests__/BottomPanel.spec.jsx index 098f1d5fc2..9c6f2eb4e3 100644 --- a/src/components/datatable/__tests__/BottomPanel.spec.jsx +++ b/src/components/datatable/__tests__/BottomPanel.spec.jsx @@ -7,6 +7,12 @@ import { THEMATIC_LAYER } from '../../../constants/layers.js' import WindowDimensionsProvider from '../../WindowDimensionsProvider.jsx' import BottomPanel from '../BottomPanel.jsx' +jest.mock('../../cachedDataProvider/CachedDataProvider.jsx', () => ({ + useCachedData: () => ({ + systemSettings: { keyAnalysisDigitGroupSeparator: 'COMMA' }, + }), +})) + jest.mock('../DataTable.jsx', () => { // eslint-disable-next-line react/prop-types const DataTableMock = ({ activeLayerId }) => ( diff --git a/src/components/datatable/controls/RowCountControl.jsx b/src/components/datatable/controls/RowCountControl.jsx index 274ab4fd1d..2284b416d5 100644 --- a/src/components/datatable/controls/RowCountControl.jsx +++ b/src/components/datatable/controls/RowCountControl.jsx @@ -1,26 +1,38 @@ import i18n from '@dhis2/d2-i18n' import PropTypes from 'prop-types' import React from 'react' +import { formatWithSeparator } from '../../../util/numbers.js' import styles from './styles/RowCountControl.module.css' -const RowCountControl = ({ totalCount, filteredCount }) => { +const RowCountControl = ({ + totalCount, + filteredCount, + keyAnalysisDigitGroupSeparator, +}) => { if (totalCount === null || filteredCount === null) { return null } + const total = formatWithSeparator( + totalCount, + keyAnalysisDigitGroupSeparator + ) + const filtered = formatWithSeparator( + filteredCount, + keyAnalysisDigitGroupSeparator + ) + const label = filteredCount < totalCount - ? i18n.t('{{filtered}} of {{total}} rows', { - filtered: filteredCount, - total: totalCount, - }) - : i18n.t('{{total}} rows', { total: totalCount }) + ? i18n.t('{{filtered}} of {{total}} rows', { filtered, total }) + : i18n.t('{{total}} rows', { total }) return <span className={styles.rowCount}>{label}</span> } RowCountControl.propTypes = { filteredCount: PropTypes.number, + keyAnalysisDigitGroupSeparator: PropTypes.string, totalCount: PropTypes.number, } diff --git a/src/components/datatable/controls/__tests__/RowCountControl.spec.jsx b/src/components/datatable/controls/__tests__/RowCountControl.spec.jsx new file mode 100644 index 0000000000..7c177dbc72 --- /dev/null +++ b/src/components/datatable/controls/__tests__/RowCountControl.spec.jsx @@ -0,0 +1,44 @@ +import { render, screen } from '@testing-library/react' +import React from 'react' +import RowCountControl from '../RowCountControl.jsx' + +describe('RowCountControl', () => { + test('renders nothing while counts are not yet known', () => { + const { container } = render( + <RowCountControl totalCount={null} filteredCount={null} /> + ) + expect(container).toBeEmptyDOMElement() + }) + + test('shows just the total when nothing is filtered out', () => { + render(<RowCountControl totalCount={12345} filteredCount={12345} />) + expect(screen.getByText('12345 rows')).toBeInTheDocument() + }) + + test('shows filtered/total when rows have been filtered out', () => { + render(<RowCountControl totalCount={12345} filteredCount={42} />) + expect(screen.getByText('42 of 12345 rows')).toBeInTheDocument() + }) + + test('applies the digit-group separator to both numbers', () => { + render( + <RowCountControl + totalCount={12345} + filteredCount={42} + keyAnalysisDigitGroupSeparator="COMMA" + /> + ) + expect(screen.getByText('42 of 12,345 rows')).toBeInTheDocument() + }) + + test('applies the digit-group separator to the total-only label too', () => { + render( + <RowCountControl + totalCount={12345} + filteredCount={12345} + keyAnalysisDigitGroupSeparator="COMMA" + /> + ) + expect(screen.getByText('12,345 rows')).toBeInTheDocument() + }) +}) From 0d0330969ee99891e820314e6db0ad463b09220d Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 4 Aug 2026 11:23:11 +0200 Subject: [PATCH 196/205] fix: apply per-layer filters in the combined data table and flag when they're active --- i18n/en.pot | 24 +++- src/components/core/FilterActiveIcon.jsx | 12 ++ src/components/core/index.js | 2 + .../styles/FilterActiveIcon.module.css} | 14 +-- .../core/styles/IconButton.module.css | 10 ++ src/components/datatable/BottomPanel.jsx | 28 +++++ .../datatable/__tests__/BottomPanel.spec.jsx | 47 ++++++++ .../__tests__/JoinLayersControl.spec.jsx | 82 ++++++++++++-- .../__tests__/useCombinedTableData.spec.js | 40 +++++++ .../controls/ClearFiltersControl.jsx | 8 +- .../datatable/controls/JoinLayersControl.jsx | 86 +++++++++++--- .../styles/JoinLayersControl.module.css | 22 ++++ .../datatable/styles/BottomPanel.module.css | 7 ++ .../datatable/useCombinedTableData.js | 3 +- .../layers/overlays/OverlayCard.jsx | 9 ++ .../overlays/__tests__/OverlayCard.spec.jsx | 48 ++++++-- .../layers/toolbar/LayerToolbar.jsx | 28 ++++- .../toolbar/__tests__/LayerToolbar.spec.jsx | 30 +++++ .../__snapshots__/LayerToolbar.spec.jsx.snap | 107 ++++++++++++++++-- .../toolbar/styles/LayerToolbar.module.css | 8 +- src/util/__tests__/combinedJoinMatch.spec.js | 50 ++++++++ src/util/combinedJoinMatch.js | 8 +- 22 files changed, 606 insertions(+), 67 deletions(-) create mode 100644 src/components/core/FilterActiveIcon.jsx rename src/components/{datatable/controls/styles/ClearFiltersControl.module.css => core/styles/FilterActiveIcon.module.css} (78%) diff --git a/i18n/en.pot b/i18n/en.pot index dfb633963f..f3d55b655c 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-08-03T11:27:11.052Z\n" -"PO-Revision-Date: 2026-08-03T11:27:11.052Z\n" +"POT-Creation-Date: 2026-08-04T09:12:24.029Z\n" +"PO-Revision-Date: 2026-08-04T09:12:24.029Z\n" msgid "2020" msgstr "2020" @@ -155,6 +155,13 @@ msgstr "Operator" msgid "Date" msgstr "Date" +msgid "" +"Values from {{layers}} only reflect the filter(s) applied in their own " +"table." +msgstr "" +"Values from {{layers}} only reflect the filter(s) applied in their own " +"table." + msgid "No matching rows" msgstr "No matching rows" @@ -355,6 +362,16 @@ msgstr "Count" msgid "Choose layers to combine" msgstr "Choose layers to combine" +msgid "" +"This layer has a filter active from its own table - Combined only reflects " +"the filtered records." +msgstr "" +"This layer has a filter active from its own table - Combined only reflects " +"the filtered records." + +msgid "Clear filters applied to {{layer}}" +msgstr "Clear filters applied to {{layer}}" + msgid "Join type for {{layer}}" msgstr "Join type for {{layer}}" @@ -936,6 +953,9 @@ msgstr "Layer is invalid" msgid "Set layer opacity" msgstr "Set layer opacity" +msgid "Clear filters applied in this layer’s table" +msgstr "Clear filters applied in this layer’s table" + msgid "More actions" msgstr "More actions" diff --git a/src/components/core/FilterActiveIcon.jsx b/src/components/core/FilterActiveIcon.jsx new file mode 100644 index 0000000000..437c5b7ddb --- /dev/null +++ b/src/components/core/FilterActiveIcon.jsx @@ -0,0 +1,12 @@ +import { IconFilter16 } from '@dhis2/ui' +import React from 'react' +import styles from './styles/FilterActiveIcon.module.css' + +const FilterActiveIcon = () => ( + <span className={styles.icon}> + <IconFilter16 /> + <span className={styles.badge} /> + </span> +) + +export default FilterActiveIcon diff --git a/src/components/core/index.js b/src/components/core/index.js index 75e354265d..609f50a016 100644 --- a/src/components/core/index.js +++ b/src/components/core/index.js @@ -6,6 +6,7 @@ import ColorScaleSelect from './ColorScaleSelect.jsx' import ConditionalWrapper from './ConditionalWrapper.js' import CustomRadioLabel from './CustomRadioLabel.jsx' import DatePicker from './DatePicker.jsx' +import FilterActiveIcon from './FilterActiveIcon.jsx' import FontStyle from './FontStyle.jsx' import Help from './Help.jsx' import IconButton from './IconButton.jsx' @@ -28,6 +29,7 @@ export { ColorScaleSelect, ConditionalWrapper, DatePicker, + FilterActiveIcon, FontStyle, Help, IconButton, diff --git a/src/components/datatable/controls/styles/ClearFiltersControl.module.css b/src/components/core/styles/FilterActiveIcon.module.css similarity index 78% rename from src/components/datatable/controls/styles/ClearFiltersControl.module.css rename to src/components/core/styles/FilterActiveIcon.module.css index 6742e21179..826a5e5599 100644 --- a/src/components/datatable/controls/styles/ClearFiltersControl.module.css +++ b/src/components/core/styles/FilterActiveIcon.module.css @@ -1,4 +1,4 @@ -.filteredIcon { +.icon { position: relative; display: flex; align-items: center; @@ -7,7 +7,7 @@ height: 16px; } -.clearBadge { +.badge { position: absolute; bottom: 0; right: 0; @@ -16,12 +16,12 @@ background: var(--colors-grey100); } -:global(button):hover .clearBadge { +:global(button):hover .badge { background: var(--colors-grey300); } -.clearBadge::before, -.clearBadge::after { +.badge::before, +.badge::after { content: ''; position: absolute; width: 5px; @@ -31,10 +31,10 @@ left: 50%; } -.clearBadge::before { +.badge::before { transform: translate(-50%, -50%) rotate(45deg); } -.clearBadge::after { +.badge::after { transform: translate(-50%, -50%) rotate(-45deg); } diff --git a/src/components/core/styles/IconButton.module.css b/src/components/core/styles/IconButton.module.css index e73081689e..57230bae89 100644 --- a/src/components/core/styles/IconButton.module.css +++ b/src/components/core/styles/IconButton.module.css @@ -1,6 +1,9 @@ .iconButton { width: 28px; height: 28px; + display: flex; + align-items: center; + justify-content: center; cursor: pointer; background-color: transparent; border-radius: 5px; @@ -18,6 +21,13 @@ background-color: var(--colors-grey200); } +.iconButton > span { + display: flex; + align-items: center; + justify-content: center; + line-height: 0; +} + .iconButton svg { color: var(--colors-grey700); } diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index 4b4e0711ee..0c96f9e7ed 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -1,3 +1,5 @@ +import i18n from '@dhis2/d2-i18n' +import { IconWarningFilled16, Tooltip } from '@dhis2/ui' import React, { useRef, useCallback, @@ -89,6 +91,13 @@ const BottomPanel = () => { () => mapViews.filter((l) => joinLayersConfig[l.combinedLayerKey]), [mapViews, joinLayersConfig] ) + const filteredCombinedLayers = useMemo( + () => + combinedLayers.filter( + (l) => Object.keys(l.dataFilters ?? {}).length > 0 + ), + [combinedLayers] + ) const activeLayer = mapViews.find((l) => l.id === activeLayerId) const dataFilters = activeLayer?.dataFilters ?? EMPTY_FILTERS @@ -339,6 +348,25 @@ const BottomPanel = () => { ) } /> + {filteredCombinedLayers.length > 0 && ( + <Tooltip + content={i18n.t( + 'Values from {{layers}} only reflect the filter(s) applied in their own table.', + { + layers: filteredCombinedLayers + .map((l) => l.name) + .join(', '), + } + )} + > + <span + className={styles.filteredLayersWarning} + data-test="data-table-combined-datafilters-warning" + > + <IconWarningFilled16 /> + </span> + </Tooltip> + )} <ReferenceOrgUnitControl /> <span className={styles.divider} /> </> diff --git a/src/components/datatable/__tests__/BottomPanel.spec.jsx b/src/components/datatable/__tests__/BottomPanel.spec.jsx index 9c6f2eb4e3..d1db58ef05 100644 --- a/src/components/datatable/__tests__/BottomPanel.spec.jsx +++ b/src/components/datatable/__tests__/BottomPanel.spec.jsx @@ -474,6 +474,53 @@ describe('BottomPanel Combined join controls', () => { ]) }) + test('shows no ambient filtered-layers warning when no joined layer has dataFilters', () => { + renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1', 'layer2'], + combinedView: true, + }, + mapViews: [ + ...twoEligibleLayers, + { + ...referenceLayer(), + combinedJoinConfig: { + layer1: { type: 'orgUnit', aggregation: {} }, + }, + }, + ], + }) + + expect( + screen.queryByTestId('data-table-combined-datafilters-warning') + ).not.toBeInTheDocument() + }) + + test('shows an ambient filtered-layers warning naming a joined layer that has active dataFilters', () => { + renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1', 'layer2'], + combinedView: true, + }, + mapViews: [ + { ...twoEligibleLayers[0], dataFilters: { rawValue: '>10' } }, + twoEligibleLayers[1], + { + ...referenceLayer(), + combinedJoinConfig: { + layer1: { type: 'orgUnit', aggregation: {} }, + }, + }, + ], + }) + + expect( + screen.getByTestId('data-table-combined-datafilters-warning') + ).toBeInTheDocument() + }) + test('toggling an already-joined layer off dispatches DATA_TABLE_JOIN_CONFIG_SET with that layer removed', () => { const { store } = renderBottomPanel({ dataTable: { diff --git a/src/components/datatable/__tests__/JoinLayersControl.spec.jsx b/src/components/datatable/__tests__/JoinLayersControl.spec.jsx index 79debd313d..fe22b16cc2 100644 --- a/src/components/datatable/__tests__/JoinLayersControl.spec.jsx +++ b/src/components/datatable/__tests__/JoinLayersControl.spec.jsx @@ -1,5 +1,7 @@ import { render, fireEvent, screen, within } from '@testing-library/react' import React from 'react' +import { Provider } from 'react-redux' +import configureMockStore from 'redux-mock-store' import { EARTH_ENGINE_LAYER, FACILITY_LAYER, @@ -8,6 +10,8 @@ import { } from '../../../constants/layers.js' import JoinLayersControl from '../controls/JoinLayersControl.jsx' +const mockStore = configureMockStore() + const eligibleLayers = [ { id: 'layer1', @@ -30,15 +34,22 @@ const eligibleLayers = [ }, ] -const renderControl = (props) => - render( - <JoinLayersControl - eligibleLayers={eligibleLayers} - layersConfig={{}} - onChange={jest.fn()} - {...props} - /> - ) +const renderControl = (props) => { + const store = mockStore({}) + return { + store, + ...render( + <Provider store={store}> + <JoinLayersControl + eligibleLayers={eligibleLayers} + layersConfig={{}} + onChange={jest.fn()} + {...props} + /> + </Provider> + ), + } +} const openPicker = () => fireEvent.click(screen.getByTestId('data-table-join-layers-button')) @@ -697,3 +708,56 @@ describe('JoinLayersControl popover — count/category value columns', () => { expect(within(select).queryByText('Percentage')).not.toBeInTheDocument() }) }) + +describe('JoinLayersControl dataFilters warning', () => { + test('shows no warning or clear button for a layer with no active dataFilters', () => { + renderControl() + openPicker() + + expect( + screen.queryByTestId('data-table-join-datafilters-warning-layer1') + ).not.toBeInTheDocument() + expect( + screen.queryByTestId('data-table-join-clear-datafilters-layer1') + ).not.toBeInTheDocument() + }) + + test('shows the warning and clear button for a layer with active dataFilters, even when not joined', () => { + renderControl({ + eligibleLayers: [ + { ...eligibleLayers[0], dataFilters: { population: '>100' } }, + eligibleLayers[1], + ], + }) + openPicker() + + expect( + screen.getByTestId('data-table-join-datafilters-warning-layer1') + ).toBeInTheDocument() + expect( + screen.getByTestId('data-table-join-clear-datafilters-layer1') + ).toBeInTheDocument() + expect( + screen.queryByTestId('data-table-join-datafilters-warning-layer2') + ).not.toBeInTheDocument() + }) + + test('clicking the clear button dispatches clearDataFilters for that layer', () => { + const { store } = renderControl({ + eligibleLayers: [ + { ...eligibleLayers[0], dataFilters: { population: '>100' } }, + eligibleLayers[1], + ], + }) + openPicker() + + fireEvent.click( + screen.getByTestId('data-table-join-clear-datafilters-layer1') + ) + + expect(store.getActions()).toContainEqual({ + type: 'DATA_FILTERS_CLEAR_ALL', + layerId: 'layer1', + }) + }) +}) diff --git a/src/components/datatable/__tests__/useCombinedTableData.spec.js b/src/components/datatable/__tests__/useCombinedTableData.spec.js index fc707fbd21..57d8e91c68 100644 --- a/src/components/datatable/__tests__/useCombinedTableData.spec.js +++ b/src/components/datatable/__tests__/useCombinedTableData.spec.js @@ -151,6 +151,46 @@ describe('useCombinedTableData - org unit join', () => { expect(findCell(row1, 'layerA_rawValue').value).toBe(15) }) + test("excludes a feature already removed by the layer's own dataFilters from the aggregation", () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + dataFilters: { rawValue: '>15' }, + data: [ + feature({ + id: 'evt1', + orgUnitPath: '/country1/ou1/facility1', + rawValue: 10, + }), + feature({ + id: 'evt2', + orgUnitPath: '/country1/ou1/facility2', + rawValue: 20, + }), + ], + }, + ] + const joinConfig = { + layers: { + layerA: { + type: 'orgUnit', + aggregation: { rawValue: 'AVERAGE' }, + }, + }, + } + + const { result } = renderHook(() => + useCombinedTableData({ layers, referenceLayer, joinConfig }) + ) + + const row1 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' + ) + expect(findCell(row1, 'layerA_rawValue').value).toBe(20) + }) + test('shows blank for a feature whose org unit is an ancestor of the reference, not a descendant', () => { const layers = [ { diff --git a/src/components/datatable/controls/ClearFiltersControl.jsx b/src/components/datatable/controls/ClearFiltersControl.jsx index 303e2b1e14..31d42db860 100644 --- a/src/components/datatable/controls/ClearFiltersControl.jsx +++ b/src/components/datatable/controls/ClearFiltersControl.jsx @@ -1,8 +1,7 @@ import i18n from '@dhis2/d2-i18n' -import { IconFilter16 } from '@dhis2/ui' import PropTypes from 'prop-types' import React from 'react' -import styles from './styles/ClearFiltersControl.module.css' +import { FilterActiveIcon } from '../../core/index.js' import ToolbarIconButton from './ToolbarIconButton.jsx' const ClearFiltersControl = ({ disabled, onClick }) => ( @@ -13,10 +12,7 @@ const ClearFiltersControl = ({ disabled, onClick }) => ( onClick={onClick} disabled={disabled} > - <span className={styles.filteredIcon}> - <IconFilter16 /> - <span className={styles.clearBadge} /> - </span> + <FilterActiveIcon /> </ToolbarIconButton> ) diff --git a/src/components/datatable/controls/JoinLayersControl.jsx b/src/components/datatable/controls/JoinLayersControl.jsx index 9b4e5bc328..a8118d2776 100644 --- a/src/components/datatable/controls/JoinLayersControl.jsx +++ b/src/components/datatable/controls/JoinLayersControl.jsx @@ -2,6 +2,8 @@ import i18n from '@dhis2/d2-i18n' import { IconWarningFilled16, Tooltip } from '@dhis2/ui' import PropTypes from 'prop-types' import React, { useMemo, useRef, useState } from 'react' +import { useDispatch } from 'react-redux' +import { clearDataFilters } from '../../../actions/dataFilters.js' import { getCategoryValueDisplayTypes, getCombinedAggregationTypes, @@ -34,6 +36,7 @@ import { } from '../../../util/geojson.js' import Checkbox from '../../core/Checkbox.jsx' import { IconLayersStack16 } from '../../core/icons.jsx' +import { FilterActiveIcon } from '../../core/index.js' import { FilterDropdownPopover } from '../FilterDropdownPopover.jsx' import styles from './styles/JoinLayersControl.module.css' import ToolbarIconButton from './ToolbarIconButton.jsx' @@ -74,6 +77,7 @@ const JoinLayersControl = ({ const anchorRef = useRef(null) const [isOpen, setIsOpen] = useState(false) const aggregationTypes = getCombinedAggregationTypes() + const dispatch = useDispatch() const joinQualityByLayerKey = useMemo(() => { const result = {} @@ -149,6 +153,9 @@ const JoinLayersControl = ({ {eligibleLayers.map((layer) => { const settings = layersConfig[layer.combinedLayerKey] + const hasDataFilters = + Object.keys(layer.dataFilters ?? {}) + .length > 0 const defaultAggregation = getDefaultCombinedAggregation(layer) const { @@ -183,19 +190,72 @@ const JoinLayersControl = ({ key={layer.id} className={styles.layerRow} > - <Checkbox - label={ - <span - className={styles.layerName} - > - {layer.name} - </span> - } - checked={!!settings} - onChange={() => onToggle(layer)} - className={styles.layerCheckbox} - dataTest={`data-table-join-layer-${layer.id}`} - /> + <div className={styles.layerRowHeader}> + <Checkbox + label={ + <span + className={ + styles.layerName + } + > + {layer.name} + </span> + } + checked={!!settings} + onChange={() => onToggle(layer)} + className={styles.layerCheckbox} + dataTest={`data-table-join-layer-${layer.id}`} + /> + {hasDataFilters && ( + <> + <Tooltip + content={i18n.t( + 'This layer has a filter active from its own table - Combined only reflects the filtered records.' + )} + > + <span + className={ + styles.aggregationWarning + } + data-test={`data-table-join-datafilters-warning-${layer.id}`} + > + <IconWarningFilled16 /> + </span> + </Tooltip> + <Tooltip + content={i18n.t( + 'Clear filters applied to {{layer}}', + { + layer: layer.name, + } + )} + > + <button + type="button" + className={ + styles.clearDataFiltersButton + } + onClick={() => + dispatch( + clearDataFilters( + layer.id + ) + ) + } + aria-label={i18n.t( + 'Clear filters applied to {{layer}}', + { + layer: layer.name, + } + )} + data-test={`data-table-join-clear-datafilters-${layer.id}`} + > + <FilterActiveIcon /> + </button> + </Tooltip> + </> + )} + </div> {settings && ( <div className={styles.layerSettings} diff --git a/src/components/datatable/controls/styles/JoinLayersControl.module.css b/src/components/datatable/controls/styles/JoinLayersControl.module.css index 0d3593233e..a61faf8731 100644 --- a/src/components/datatable/controls/styles/JoinLayersControl.module.css +++ b/src/components/datatable/controls/styles/JoinLayersControl.module.css @@ -18,6 +18,28 @@ border-radius: 3px; } +.layerRowHeader { + display: flex; + align-items: center; + gap: var(--spacers-dp4); +} + +.clearDataFiltersButton { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + padding: 0; + border: none; + background: none; + cursor: pointer; + color: var(--colors-grey700); +} + +.clearDataFiltersButton:hover { + color: var(--colors-grey900); +} + .layerRow:hover { background: var(--colors-grey100); } diff --git a/src/components/datatable/styles/BottomPanel.module.css b/src/components/datatable/styles/BottomPanel.module.css index c540061f95..1fa9153747 100644 --- a/src/components/datatable/styles/BottomPanel.module.css +++ b/src/components/datatable/styles/BottomPanel.module.css @@ -41,6 +41,13 @@ flex-shrink: 0; } +.filteredLayersWarning { + display: flex; + flex-shrink: 0; + align-items: center; + color: var(--colors-yellow600); +} + .joinSelect { max-width: 180px; height: 24px; diff --git a/src/components/datatable/useCombinedTableData.js b/src/components/datatable/useCombinedTableData.js index 6ad5a11d6f..07bbc06fe6 100644 --- a/src/components/datatable/useCombinedTableData.js +++ b/src/components/datatable/useCombinedTableData.js @@ -16,6 +16,7 @@ import { import { applyAggregation } from '../../util/aggregation.js' import { getByReferenceId, + getFilteredJoinableFeatures, getJoinableFeatures, getProps, } from '../../util/combinedJoinMatch.js' @@ -307,7 +308,7 @@ export const useCombinedTableData = ({ layer, allAggregations[layer.id] ?? EMPTY_AGGREGATIONS ) - const features = getJoinableFeatures(mergedLayer) + const features = getFilteredJoinableFeatures(mergedLayer) const valueDataKeys = getCombinedValueDataKeys( layer, externalPeriod diff --git a/src/components/layers/overlays/OverlayCard.jsx b/src/components/layers/overlays/OverlayCard.jsx index 74c4899b44..ff3844a175 100644 --- a/src/components/layers/overlays/OverlayCard.jsx +++ b/src/components/layers/overlays/OverlayCard.jsx @@ -5,6 +5,7 @@ import i18n from '@dhis2/d2-i18n' import PropTypes from 'prop-types' import React, { useState } from 'react' import { connect } from 'react-redux' +import { clearDataFilters } from '../../../actions/dataFilters.js' import { toggleDataTable } from '../../../actions/dataTable.js' import { editLayer, @@ -45,6 +46,7 @@ const OverlayCard = ({ toggleLayerExpand, toggleLayerVisibility, toggleDataTable, + clearDataFilters, }) => { const [showDataDownloadDialog, setShowDataDownloadDialog] = useState(false) const { baseUrl } = useConfig() @@ -61,12 +63,14 @@ const OverlayCard = ({ layer: layerType, isLoaded, loadError, + dataFilters, } = layer const canEdit = layerType !== EXTERNAL_LAYER const canToggleDataTable = DATA_TABLE_LAYER_TYPES.includes(layerType) const canDownload = DOWNLOADABLE_LAYER_TYPES.includes(layerType) const canOpenAs = OPEN_AS_LAYER_TYPES.includes(layerType) + const hasDataFilters = Object.keys(dataFilters ?? {}).length > 0 const getCardContent = () => { if (loadError) { @@ -107,6 +111,9 @@ const OverlayCard = ({ toggleDataTable={ canToggleDataTable ? () => toggleDataTable(id) : undefined } + onClearDataFilters={ + hasDataFilters ? () => clearDataFilters(id) : undefined + } toggleLayerVisibility={() => toggleLayerVisibility(id)} onOpacityChange={(newOpacity) => changeLayerOpacity(id, newOpacity) @@ -156,6 +163,7 @@ const OverlayCard = ({ OverlayCard.propTypes = { changeLayerOpacity: PropTypes.func.isRequired, + clearDataFilters: PropTypes.func.isRequired, duplicateLayer: PropTypes.func.isRequired, editLayer: PropTypes.func.isRequired, layer: PropTypes.object.isRequired, @@ -170,6 +178,7 @@ export default connect(null, { removeLayer, duplicateLayer, changeLayerOpacity, + clearDataFilters, toggleLayerExpand, toggleLayerVisibility, toggleDataTable, diff --git a/src/components/layers/overlays/__tests__/OverlayCard.spec.jsx b/src/components/layers/overlays/__tests__/OverlayCard.spec.jsx index ce83fef842..781490abf6 100644 --- a/src/components/layers/overlays/__tests__/OverlayCard.spec.jsx +++ b/src/components/layers/overlays/__tests__/OverlayCard.spec.jsx @@ -28,14 +28,13 @@ jest.mock('@dhis2/app-service-alerts', () => ({ const mockStore = configureMockStore() describe('OverlayCard', () => { - const renderCard = (name) => - render( - <Provider - store={mockStore({ - dataTable: { openIds: [] }, - aggregations: {}, - })} - > + const renderCard = (name, layerOverrides = {}) => { + const store = mockStore({ + dataTable: { openIds: [] }, + aggregations: {}, + }) + const rendered = render( + <Provider store={store}> <OverlayCard layer={{ id: 'layer1', @@ -45,14 +44,14 @@ describe('OverlayCard', () => { isExpanded: true, isVisible: true, opacity: 1, + ...layerOverrides, }} /> </Provider> ) + return { ...rendered, store } + } - // Regression test for DHIS2-19998: special characters in the layer name - // must not be HTML-escaped in the "deleted" alert (default i18next - // interpolation escapes "<" to "<"). test('shows the raw layer name with special characters in the removal alert', async () => { renderCard('Children < 5y & "others"') @@ -63,4 +62,31 @@ describe('OverlayCard', () => { msg: 'Children < 5y & "others" deleted.', }) }) + + test('does not show a clear-filters button when the layer has no active dataFilters', () => { + const { container } = renderCard('Layer 1') + expect( + container.querySelector( + '[data-test="layer-clear-data-filters-button"]' + ) + ).not.toBeInTheDocument() + }) + + test('shows a clear-filters button when the layer has active dataFilters, and dispatches clearDataFilters on click', () => { + const { container, store } = renderCard('Layer 1', { + dataFilters: { population: '>100' }, + }) + + const button = container.querySelector( + '[data-test="layer-clear-data-filters-button"]' + ) + expect(button).toBeInTheDocument() + + fireEvent.click(button) + + expect(store.getActions()).toContainEqual({ + type: 'DATA_FILTERS_CLEAR_ALL', + layerId: 'layer1', + }) + }) }) diff --git a/src/components/layers/toolbar/LayerToolbar.jsx b/src/components/layers/toolbar/LayerToolbar.jsx index 476fcc999e..a19ebdaa29 100644 --- a/src/components/layers/toolbar/LayerToolbar.jsx +++ b/src/components/layers/toolbar/LayerToolbar.jsx @@ -3,7 +3,7 @@ import { Tooltip, IconEdit24, IconView24, IconViewOff24 } from '@dhis2/ui' import cx from 'classnames' import PropTypes from 'prop-types' import React from 'react' -import { IconButton } from '../../core/index.js' +import { FilterActiveIcon, IconButton } from '../../core/index.js' import LayerToolbarMoreMenu from './LayerToolbarMoreMenu.jsx' import OpacitySlider from './OpacitySlider.jsx' import styles from './styles/LayerToolbar.module.css' @@ -14,6 +14,7 @@ const LayerToolbar = ({ isVisible, onOpacityChange, toggleLayerVisibility, + onClearDataFilters, hasError, ...expansionMenuProps }) => { @@ -60,11 +61,25 @@ const LayerToolbar = ({ /> </Tooltip> </div> - <div className={styles.menuButton}> - <LayerToolbarMoreMenu - hasError={hasError} - {...expansionMenuProps} - /> + <div className={styles.trailingActions}> + {onClearDataFilters && ( + <IconButton + tooltip={i18n.t( + 'Clear filters applied in this layer’s table' + )} + onClick={onClearDataFilters} + className={styles.clearDataFiltersButton} + dataTest="layer-clear-data-filters-button" + > + <FilterActiveIcon /> + </IconButton> + )} + <div className={styles.menuButton}> + <LayerToolbarMoreMenu + hasError={hasError} + {...expansionMenuProps} + /> + </div> </div> </div> ) @@ -77,6 +92,7 @@ LayerToolbar.propTypes = { hasOpacity: PropTypes.bool, isVisible: PropTypes.bool, opacity: PropTypes.number, + onClearDataFilters: PropTypes.func, onEdit: PropTypes.func, } diff --git a/src/components/layers/toolbar/__tests__/LayerToolbar.spec.jsx b/src/components/layers/toolbar/__tests__/LayerToolbar.spec.jsx index df7b3323ad..624b403f3a 100644 --- a/src/components/layers/toolbar/__tests__/LayerToolbar.spec.jsx +++ b/src/components/layers/toolbar/__tests__/LayerToolbar.spec.jsx @@ -93,4 +93,34 @@ describe('LayerToolbar', () => { expect(toggleVisibleFn).toHaveBeenCalledTimes(1) expect(editFn).toHaveBeenCalledTimes(1) }) + + it('Should not render a clear-filters button when the layer has no active dataFilters', () => { + const { container } = render(<LayerToolbar {...props} />) + expect( + container.querySelector( + '[data-test="layer-clear-data-filters-button"]' + ) + ).not.toBeInTheDocument() + }) + + it('Should render a clear-filters button when onClearDataFilters is provided', () => { + const { container } = render( + <LayerToolbar {...props} onClearDataFilters={jest.fn()} /> + ) + expect(container).toMatchSnapshot() + }) + + it('Should call onClearDataFilters callback on button press', async () => { + const clearDataFiltersFn = jest.fn() + const { container } = render( + <LayerToolbar {...props} onClearDataFilters={clearDataFiltersFn} /> + ) + + await fireEvent.click( + container.querySelector( + '[data-test="layer-clear-data-filters-button"]' + ) + ) + expect(clearDataFiltersFn).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/components/layers/toolbar/__tests__/__snapshots__/LayerToolbar.spec.jsx.snap b/src/components/layers/toolbar/__tests__/__snapshots__/LayerToolbar.spec.jsx.snap index f3fc0eb58f..b0ee8dc2bd 100644 --- a/src/components/layers/toolbar/__tests__/__snapshots__/LayerToolbar.spec.jsx.snap +++ b/src/components/layers/toolbar/__tests__/__snapshots__/LayerToolbar.spec.jsx.snap @@ -1,5 +1,82 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`LayerToolbar Should render a clear-filters button when onClearDataFilters is provided 1`] = ` +<div> + <div + class="toolbar" + data-test="layertoolbar" + > + <button + class="iconButton visible" + data-test="visibilitybutton" + type="button" + > + <div> + <div> + IconView24 + </div> + </div> + </button> + <div + class="sliderContainer" + > + <div> + <div + class="container" + > + <input + class="slider" + max="1" + min="0" + step="0.01" + type="range" + value="0" + /> + </div> + </div> + </div> + <div + class="trailingActions" + > + <button + class="iconButton clearDataFiltersButton" + data-test="layer-clear-data-filters-button" + type="button" + > + <div> + <span + class="icon" + > + <svg + height="16" + viewBox="0 0 16 16" + width="16" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M10 10l-1 1H7l-1-1zm3-3l-1 1H4L3 7zm2-3l-1 1H2L1 4z" + fill="currentColor" + fill-rule="evenodd" + /> + </svg> + <span + class="badge" + /> + </span> + </div> + </button> + <div + class="menuButton" + > + <div> + LayerToolbarMoreMenu + </div> + </div> + </div> + </div> +</div> +`; + exports[`LayerToolbar Should render edit button 1`] = ` <div> <div @@ -55,10 +132,14 @@ exports[`LayerToolbar Should render edit button 1`] = ` </div> </div> <div - class="menuButton" + class="trailingActions" > - <div> - LayerToolbarMoreMenu + <div + class="menuButton" + > + <div> + LayerToolbarMoreMenu + </div> </div> </div> </div> @@ -101,10 +182,14 @@ exports[`LayerToolbar Should render only a visibility toggle and opacity slider </div> </div> <div - class="menuButton" + class="trailingActions" > - <div> - LayerToolbarMoreMenu + <div + class="menuButton" + > + <div> + LayerToolbarMoreMenu + </div> </div> </div> </div> @@ -148,10 +233,14 @@ exports[`LayerToolbar Should show SvgViewOff24 when not visible 1`] = ` </div> </div> <div - class="menuButton" + class="trailingActions" > - <div> - LayerToolbarMoreMenu + <div + class="menuButton" + > + <div> + LayerToolbarMoreMenu + </div> </div> </div> </div> diff --git a/src/components/layers/toolbar/styles/LayerToolbar.module.css b/src/components/layers/toolbar/styles/LayerToolbar.module.css index f875e57066..73a3f138c6 100644 --- a/src/components/layers/toolbar/styles/LayerToolbar.module.css +++ b/src/components/layers/toolbar/styles/LayerToolbar.module.css @@ -16,6 +16,12 @@ margin-top: 1px; } -.menuButton { +.trailingActions { + display: flex; + align-items: center; margin-left: auto; } + +.clearDataFiltersButton { + margin-right: var(--spacers-dp8); +} diff --git a/src/util/__tests__/combinedJoinMatch.spec.js b/src/util/__tests__/combinedJoinMatch.spec.js index 2c17275c89..e393e48eaf 100644 --- a/src/util/__tests__/combinedJoinMatch.spec.js +++ b/src/util/__tests__/combinedJoinMatch.spec.js @@ -166,3 +166,53 @@ describe('getUnmatchedFeatureCount', () => { ) }) }) + +describe('layer.dataFilters applied before hasCombinedRollup/getUnmatchedFeatureCount', () => { + const referenceLayer = { + data: [referenceFeature('ref1', '/country1/ref1')], + } + + test('hasCombinedRollup no longer sees a rollup once dataFilters excludes the second feature', () => { + const layer = { + data: [ + { + properties: { + orgUnitPath: '/country1/ref1/child1', + status: 'open', + }, + }, + { + properties: { + orgUnitPath: '/country1/ref1/child2', + status: 'closed', + }, + }, + ], + dataFilters: { status: 'open' }, + } + expect(hasCombinedRollup(layer, referenceLayer, 'orgUnit')).toBe(false) + }) + + test('getUnmatchedFeatureCount excludes a feature already removed by dataFilters, rather than counting it as unmatched', () => { + const layer = { + data: [ + { + properties: { + orgUnitPath: '/country1/ref1/child1', + status: 'open', + }, + }, + { + properties: { + orgUnitPath: '/country2/other', + status: 'closed', + }, + }, + ], + dataFilters: { status: 'open' }, + } + expect(getUnmatchedFeatureCount(layer, referenceLayer, 'orgUnit')).toBe( + 0 + ) + }) +}) diff --git a/src/util/combinedJoinMatch.js b/src/util/combinedJoinMatch.js index 41af04bcef..312d2a8a4d 100644 --- a/src/util/combinedJoinMatch.js +++ b/src/util/combinedJoinMatch.js @@ -1,4 +1,5 @@ import { ORG_UNIT_PATH_DATA_KEY } from '../constants/dataTable.js' +import { filterData } from './filter.js' import { matchFeaturesToReferenceOrgUnits } from './spatialJoin.js' export const getJoinableFeatures = (layer) => @@ -6,6 +7,9 @@ export const getJoinableFeatures = (layer) => (d) => !d.properties?.hasAdditionalGeometry ) +export const getFilteredJoinableFeatures = (layer) => + filterData(getJoinableFeatures(layer), layer?.dataFilters) + export const getProps = (feature) => feature.properties || feature export const matchOrgUnitReference = ( @@ -75,7 +79,7 @@ export const hasCombinedRollup = (layer, referenceLayer, joinType) => { return false } const byReferenceId = getByReferenceId( - getJoinableFeatures(layer), + getFilteredJoinableFeatures(layer), referenceOrgUnits, joinType ) @@ -86,7 +90,7 @@ export const hasCombinedRollup = (layer, referenceLayer, joinType) => { export const getUnmatchedFeatureCount = (layer, referenceLayer, joinType) => { const referenceOrgUnits = getJoinableFeatures(referenceLayer) - const features = getJoinableFeatures(layer) + const features = getFilteredJoinableFeatures(layer) if (!referenceOrgUnits.length || !features.length) { return 0 } From c00205399699ac7190fc6c81db81c8b50d635eae Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 4 Aug 2026 15:46:08 +0200 Subject: [PATCH 197/205] feat: add collapsible rows and consistent styling to the combine-layers popover --- i18n/en.pot | 34 +- .../datatable/CombinedDataTable.jsx | 149 +++--- .../__tests__/CombinedDataTable.spec.jsx | 20 +- .../__tests__/JoinLayersControl.spec.jsx | 198 +++++++- .../__tests__/useCombinedTableData.spec.js | 7 + .../datatable/controls/JoinLayersControl.jsx | 426 +++--------------- .../datatable/controls/LayerRow.jsx | 338 ++++++++++++++ .../styles/ColumnPickerControl.module.css | 5 +- .../styles/JoinLayersControl.module.css | 60 ++- .../controls/styles/PopoverPanel.module.css | 6 + .../datatable/useCombinedTableData.js | 67 ++- src/components/edit/LayerEdit.jsx | 10 +- .../edit/__tests__/LayerEdit.spec.jsx | 18 + src/util/__tests__/dataTable.spec.js | 23 +- src/util/__tests__/styleByDataItem.spec.js | 4 + src/util/dataTable.js | 5 +- src/util/styleByDataItem.js | 9 +- 17 files changed, 880 insertions(+), 499 deletions(-) create mode 100644 src/components/datatable/controls/LayerRow.jsx create mode 100644 src/components/datatable/controls/styles/PopoverPanel.module.css diff --git a/i18n/en.pot b/i18n/en.pot index f3d55b655c..78cf37d316 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-08-04T09:12:24.029Z\n" -"PO-Revision-Date: 2026-08-04T09:12:24.029Z\n" +"POT-Creation-Date: 2026-08-04T11:53:43.119Z\n" +"PO-Revision-Date: 2026-08-04T11:53:43.120Z\n" msgid "2020" msgstr "2020" @@ -165,8 +165,8 @@ msgstr "" msgid "No matching rows" msgstr "No matching rows" -msgid "Spatial join over large datasets may be slow (over {{threshold}} features)" -msgstr "Spatial join over large datasets may be slow (over {{threshold}} features)" +msgid "Location join over large datasets may be slow (over {{threshold}} features)" +msgstr "Location join over large datasets may be slow (over {{threshold}} features)" msgid "Drill up one level" msgstr "Drill up one level" @@ -344,6 +344,9 @@ msgstr "Search all columns" msgid "Highlight color" msgstr "Highlight color" +msgid "Choose layers to combine" +msgstr "Choose layers to combine" + msgid "Facilities count" msgstr "Facilities count" @@ -359,8 +362,11 @@ msgstr "Tracked entities count" msgid "Count" msgstr "Count" -msgid "Choose layers to combine" -msgstr "Choose layers to combine" +msgid "Collapse {{layer}}" +msgstr "Collapse {{layer}}" + +msgid "Expand {{layer}}" +msgstr "Expand {{layer}}" msgid "" "This layer has a filter active from its own table - Combined only reflects " @@ -372,14 +378,17 @@ msgstr "" msgid "Clear filters applied to {{layer}}" msgstr "Clear filters applied to {{layer}}" +msgid "Join by" +msgstr "Join by" + msgid "Join type for {{layer}}" msgstr "Join type for {{layer}}" msgid "Org unit" msgstr "Org unit" -msgid "Spatial" -msgstr "Spatial" +msgid "Location" +msgstr "Location" msgid "" "{{count}} feature(s) from {{layer}} could not be matched to a reference org " @@ -473,6 +482,9 @@ msgstr "Org unit id" msgid "Org unit level" msgstr "Org unit level" +msgid "Org unit hierarchy" +msgstr "Org unit hierarchy" + msgid "No valid data was found for the current layer configuration." msgstr "No valid data was found for the current layer configuration." @@ -588,6 +600,9 @@ msgstr "Edit {{name}} layer" msgid "Add new {{name}} layer" msgstr "Add new {{name}} layer" +msgid "Update reference" +msgstr "Update reference" + msgid "Update layer" msgstr "Update layer" @@ -2216,9 +2231,6 @@ msgstr "Range" msgid "Org unit boundary" msgstr "Org unit boundary" -msgid "Org unit hierarchy" -msgstr "Org unit hierarchy" - msgid "Created" msgstr "Created" diff --git a/src/components/datatable/CombinedDataTable.jsx b/src/components/datatable/CombinedDataTable.jsx index 9505a41b64..57d5e034e2 100644 --- a/src/components/datatable/CombinedDataTable.jsx +++ b/src/components/datatable/CombinedDataTable.jsx @@ -103,23 +103,29 @@ const CombinedDataTable = ({ const [selectedIds, setSelectedIds] = useState([]) const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]) - const { headers, rows, rowFeatureIds, columnOptions, spatialWarning } = - useCombinedTableData({ - layers, - referenceLayer, - joinConfig, - sortField, - sortDirection, - filters, - globalSearch, - aggregations, - showOnlyFeaturesInView, - mapBounds, - selectionFilter, - selectedIdSet, - keyAnalysisDigitGroupSeparator, - externalPeriod, - }) + const { + headers, + rows, + rowFeatureIds, + columnOptions, + spatialWarning, + orgUnitIdToName, + } = useCombinedTableData({ + layers, + referenceLayer, + joinConfig, + sortField, + sortDirection, + filters, + globalSearch, + aggregations, + showOnlyFeaturesInView, + mapBounds, + selectionFilter, + selectedIdSet, + keyAnalysisDigitGroupSeparator, + externalPeriod, + }) useEffect(() => { onHeadersChange?.(headers, COMBINED_HEADERS_KEY) @@ -432,55 +438,62 @@ const CombinedDataTable = ({ /> } /> - {visibleHeaders.map(({ name, dataKey, type }, index) => { - const { fixed, left, isLastPinned } = getPinnedCellProps( - dataKey, - index, - { pinnedLeftOffsets, pinnedColumnCount, columnWidths } - ) - return ( - <SortableColumnHeader - key={dataKey} - name={name} - dataKey={dataKey} - sortField={sortField} - sortDirection={sortDirection} - onSort={sortData} - dataTestPrefix="combined-table-column-sort-button" - className={cx(dataTableStyles.columnHeader, { - [dataTableStyles.pinnedColumnShadow]: - isLastPinned, - })} - fixed={fixed} - left={left} - onFilterIconClick={ - isFilterable(dataKey, type) && - Function.prototype - } - showFilter={isFilterable(dataKey, type)} - filter={ - isFilterable(dataKey, type) && ( - <FilterInput - type={type} - dataKey={dataKey} - name={name} - options={columnOptions[dataKey]} - filterValue={filters?.[dataKey]} - onChange={(value) => - onFilterChange(dataKey, value) - } - onClear={() => onFilterClear(dataKey)} - /> - ) - } - width={ - columnWidths.length > 0 - ? `${columnWidths[index]}px` - : 'auto' - } - /> - ) - })} + {visibleHeaders.map( + ({ name, dataKey, type, renderer }, index) => { + const { fixed, left, isLastPinned } = + getPinnedCellProps(dataKey, index, { + pinnedLeftOffsets, + pinnedColumnCount, + columnWidths, + }) + return ( + <SortableColumnHeader + key={dataKey} + name={name} + dataKey={dataKey} + sortField={sortField} + sortDirection={sortDirection} + onSort={sortData} + dataTestPrefix="combined-table-column-sort-button" + className={cx(dataTableStyles.columnHeader, { + [dataTableStyles.pinnedColumnShadow]: + isLastPinned, + })} + fixed={fixed} + left={left} + onFilterIconClick={ + isFilterable(dataKey, type) && + Function.prototype + } + showFilter={isFilterable(dataKey, type)} + filter={ + isFilterable(dataKey, type) && ( + <FilterInput + type={type} + dataKey={dataKey} + name={name} + options={columnOptions[dataKey]} + renderer={renderer} + orgUnitIdToName={orgUnitIdToName} + filterValue={filters?.[dataKey]} + onChange={(value) => + onFilterChange(dataKey, value) + } + onClear={() => + onFilterClear(dataKey) + } + /> + ) + } + width={ + columnWidths.length > 0 + ? `${columnWidths[index]}px` + : 'auto' + } + /> + ) + } + )} </DataTableRow> ), [ @@ -503,6 +516,7 @@ const CombinedDataTable = ({ allRowIds, selectionFilter, dispatch, + orgUnitIdToName, ] ) @@ -511,7 +525,7 @@ const CombinedDataTable = ({ {spatialWarning && ( <div className={styles.spatialWarning}> {i18n.t( - 'Spatial join over large datasets may be slow (over {{threshold}} features)', + 'Location join over large datasets may be slow (over {{threshold}} features)', { threshold: LARGE_FEATURE_THRESHOLD_LABEL } )} </div> @@ -588,6 +602,7 @@ const CombinedDataTable = ({ dataKey )} type={typeByDataKey.get(dataKey)} + orgUnitIdToName={orgUnitIdToName} keyAnalysisDigitGroupSeparator={ keyAnalysisDigitGroupSeparator } diff --git a/src/components/datatable/__tests__/CombinedDataTable.spec.jsx b/src/components/datatable/__tests__/CombinedDataTable.spec.jsx index 868c01549d..19747bcfed 100644 --- a/src/components/datatable/__tests__/CombinedDataTable.spec.jsx +++ b/src/components/datatable/__tests__/CombinedDataTable.spec.jsx @@ -97,6 +97,24 @@ describe('CombinedDataTable', () => { expect(screen.getByText('Low')).toBeInTheDocument() }) + test('exposes an Org unit hierarchy column, rendering the org unit path as a breadcrumb', () => { + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [referenceFeature('ou1', 'Ou One', '/country1/ou1')], + } + + renderCombinedDataTable({ + referenceLayer, + columnConfig: { + visibleKeys: ['id', 'name', 'orgUnitPath'], + }, + }) + + expect(screen.getByText('Org unit hierarchy')).toBeInTheDocument() + // Ancestor names resolve async; falls back to the raw ids until then + expect(screen.getByText('country1 / ou1')).toBeInTheDocument() + }) + test('formats numeric values with the system digit group separator, matching DataTable', () => { const referenceLayer = { ...EMPTY_REFERENCE_LAYER, @@ -220,7 +238,7 @@ describe('CombinedDataTable', () => { }) expect( - screen.getByText(/Spatial join over large datasets may be slow/) + screen.getByText(/Location join over large datasets may be slow/) ).toBeInTheDocument() }) diff --git a/src/components/datatable/__tests__/JoinLayersControl.spec.jsx b/src/components/datatable/__tests__/JoinLayersControl.spec.jsx index fe22b16cc2..388dc4eb25 100644 --- a/src/components/datatable/__tests__/JoinLayersControl.spec.jsx +++ b/src/components/datatable/__tests__/JoinLayersControl.spec.jsx @@ -1,9 +1,11 @@ import { render, fireEvent, screen, within } from '@testing-library/react' +import PropTypes from 'prop-types' import React from 'react' import { Provider } from 'react-redux' import configureMockStore from 'redux-mock-store' import { EARTH_ENGINE_LAYER, + EVENT_LAYER, FACILITY_LAYER, GEOJSON_URL_LAYER, THEMATIC_LAYER, @@ -54,6 +56,42 @@ const renderControl = (props) => { const openPicker = () => fireEvent.click(screen.getByTestId('data-table-join-layers-button')) +const expandLayer = (layerId) => + fireEvent.click( + screen.getByTestId(`data-table-join-layer-toggle-${layerId}`) + ) + +const StatefulJoinLayersControl = ({ + layersConfig: initialConfig, + ...props +}) => { + const [layersConfig, setLayersConfig] = React.useState(initialConfig ?? {}) + return ( + <JoinLayersControl + eligibleLayers={eligibleLayers} + {...props} + layersConfig={layersConfig} + onChange={setLayersConfig} + /> + ) +} + +StatefulJoinLayersControl.propTypes = { + layersConfig: PropTypes.object, +} + +const renderStatefulControl = (props) => { + const store = mockStore({}) + return { + store, + ...render( + <Provider store={store}> + <StatefulJoinLayersControl {...props} /> + </Provider> + ), + } +} + describe('JoinLayersControl trigger', () => { test('is disabled when there are no eligible layers', () => { renderControl({ eligibleLayers: [] }) @@ -150,7 +188,7 @@ describe('JoinLayersControl popover — checkbox list', () => { }) }) - test('checking a layer with no org-unit identity of its own defaults to Spatial join, not Org unit', () => { + test('checking a layer with no org-unit identity of its own defaults to Location join, not Org unit', () => { const onChange = jest.fn() renderControl({ eligibleLayers: [ @@ -193,6 +231,102 @@ describe('JoinLayersControl popover — checkbox list', () => { }) }) +describe('JoinLayersControl popover — collapsible layer settings', () => { + test('an unjoined layer has no expand/collapse toggle', () => { + renderControl() + openPicker() + + expect( + screen.queryByTestId('data-table-join-layer-toggle-layer1') + ).not.toBeInTheDocument() + }) + + test('a joined layer is collapsed by default when the popover is opened, hiding its settings', () => { + renderControl({ + layersConfig: { + layer1: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + }) + openPicker() + + expect( + screen.getByTestId('data-table-join-layer-toggle-layer1') + ).toBeInTheDocument() + expect( + screen.queryByLabelText('Join type for Layer 1') + ).not.toBeInTheDocument() + }) + + test('checking a previously unjoined layer expands its settings automatically', () => { + renderStatefulControl({ + layersConfig: { + layer1: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + }) + openPicker() + + fireEvent.click(screen.getByRole('checkbox', { name: 'Layer 2' })) + + expect( + screen.getByLabelText('Join type for Layer 2') + ).toBeInTheDocument() + }) + + test('expanding a collapsed layer shows its settings', () => { + renderControl({ + layersConfig: { + layer1: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + }) + openPicker() + + expandLayer('layer1') + + expect( + screen.getByLabelText('Join type for Layer 1') + ).toBeInTheDocument() + }) + + test('collapsing an expanded layer hides its settings again, without changing layersConfig', () => { + const onChange = jest.fn() + renderControl({ + layersConfig: { + layer1: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + onChange, + }) + openPicker() + + expandLayer('layer1') + expandLayer('layer1') + + expect( + screen.queryByLabelText('Join type for Layer 1') + ).not.toBeInTheDocument() + expect(screen.getByRole('checkbox', { name: 'Layer 1' })).toBeChecked() + expect(onChange).not.toHaveBeenCalled() + }) + + test('expanding one layer does not affect another joined layer', () => { + renderControl({ + layersConfig: { + layer1: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + layer2: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + }) + openPicker() + + expandLayer('layer1') + + expect( + screen.getByLabelText('Join type for Layer 1') + ).toBeInTheDocument() + expect( + screen.queryByLabelText('Join type for Layer 2') + ).not.toBeInTheDocument() + }) +}) + describe('JoinLayersControl popover — per-layer type/aggregation settings', () => { test('shows the join type and aggregation selects only for joined layers', () => { renderControl({ @@ -201,6 +335,7 @@ describe('JoinLayersControl popover — per-layer type/aggregation settings', () }, }) openPicker() + expandLayer('layer1') expect( screen.getByLabelText('Join type for Layer 1') @@ -213,7 +348,7 @@ describe('JoinLayersControl popover — per-layer type/aggregation settings', () ).not.toBeInTheDocument() }) - test('does not offer the Spatial join option for a layer with no geometry sample available', () => { + test('does not offer the Location join option for a layer with no geometry sample available', () => { renderControl({ layersConfig: { layer1: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, @@ -221,20 +356,22 @@ describe('JoinLayersControl popover — per-layer type/aggregation settings', () }, }) openPicker() + expandLayer('layer1') + expandLayer('layer2') expect( within(screen.getByLabelText('Join type for Layer 1')).queryByText( - 'Spatial' + 'Location' ) ).not.toBeInTheDocument() expect( within(screen.getByLabelText('Join type for Layer 2')).getByText( - 'Spatial' + 'Location' ) ).toBeInTheDocument() }) - test('offers Spatial for polygon geometry regardless of layer type, matched via centroid - including layers with no org-unit identity of their own', () => { + test('offers Location for polygon geometry regardless of layer type, matched via centroid - including layers with no org-unit identity of their own', () => { renderControl({ eligibleLayers: [ { @@ -250,10 +387,11 @@ describe('JoinLayersControl popover — per-layer type/aggregation settings', () }, }) openPicker() + expandLayer('geo') expect( within(screen.getByLabelText('Join type for Zones')).getByText( - 'Spatial' + 'Location' ) ).toBeInTheDocument() }) @@ -267,6 +405,7 @@ describe('JoinLayersControl popover — per-layer type/aggregation settings', () onChange, }) openPicker() + expandLayer('layer2') fireEvent.change(screen.getByLabelText('Join type for Layer 2'), { target: { value: 'spatial' }, @@ -286,6 +425,7 @@ describe('JoinLayersControl popover — per-layer type/aggregation settings', () onChange, }) openPicker() + expandLayer('layer1') fireEvent.change( screen.getByLabelText('Aggregation type for Layer 1'), @@ -347,6 +487,7 @@ describe('JoinLayersControl popover — per-layer type/aggregation settings', () onChange, }) openPicker() + expandLayer('ee') expect( screen.getByLabelText('Aggregation type for Mean Ndvi (NDVI)') @@ -391,6 +532,7 @@ describe('JoinLayersControl popover — per-layer type/aggregation settings', () onChange, }) openPicker() + expandLayer('timeline') expect( screen.getAllByLabelText('Aggregation type for Timeline Layer') @@ -460,10 +602,44 @@ describe('JoinLayersControl popover — aggregation rollup warning', () => { }, }) openPicker() + expandLayer('layer1') expect(getWarning()).toBeInTheDocument() }) + test('does not show a warning for an Event layer, even when it rolls up and the aggregation is non-composable (AVERAGE) - event values are raw individual records, not an average-of-averages approximation', () => { + const rollupEventLayer = { + id: 'layer1', + name: 'Layer 1', + combinedLayerKey: 'layer1', + layer: EVENT_LAYER, + styleDataItem: { id: 'de1', valueType: 'NUMBER' }, + legend: { items: [{ name: 'Low' }, { name: 'High' }] }, + data: [ + { properties: { orgUnitPath: '/country1/ref1/child1' } }, + { properties: { orgUnitPath: '/country1/ref1/child2' } }, + ], + } + renderControl({ + eligibleLayers: [rollupEventLayer], + referenceLayer, + layersConfig: { + layer1: { + type: 'orgUnit', + aggregation: { value: 'AVERAGE' }, + }, + }, + }) + openPicker() + expandLayer('layer1') + + expect( + screen.queryByTestId( + 'data-table-join-aggregation-warning-layer1-value' + ) + ).not.toBeInTheDocument() + }) + test('does not show a warning when the layer rolls up but the aggregation is composable (SUM)', () => { renderControl({ eligibleLayers: [rollupLayer], @@ -473,6 +649,7 @@ describe('JoinLayersControl popover — aggregation rollup warning', () => { }, }) openPicker() + expandLayer('layer1') expect(getWarning()).not.toBeInTheDocument() }) @@ -489,6 +666,7 @@ describe('JoinLayersControl popover — aggregation rollup warning', () => { }, }) openPicker() + expandLayer('layer1') expect(getWarning()).not.toBeInTheDocument() }) @@ -505,6 +683,7 @@ describe('JoinLayersControl popover — aggregation rollup warning', () => { }, }) openPicker() + expandLayer('layer1') expect(getWarning()).not.toBeInTheDocument() }) @@ -547,6 +726,7 @@ describe('JoinLayersControl popover — unmatched features warning', () => { }, }) openPicker() + expandLayer('layer1') expect(getWarning()).toBeInTheDocument() }) @@ -568,6 +748,7 @@ describe('JoinLayersControl popover — unmatched features warning', () => { }, }) openPicker() + expandLayer('layer1') expect(getWarning()).not.toBeInTheDocument() }) @@ -589,6 +770,7 @@ describe('JoinLayersControl popover — unmatched features warning', () => { }, }) openPicker() + expandLayer('layer1') expect(getWarning()).not.toBeInTheDocument() }) @@ -631,6 +813,7 @@ describe('JoinLayersControl popover — count/category value columns', () => { }, }) openPicker() + expandLayer('facility1') expect(screen.getByText('Facilities count')).toBeInTheDocument() expect( @@ -651,6 +834,7 @@ describe('JoinLayersControl popover — count/category value columns', () => { onChange, }) openPicker() + expandLayer('facility1') expect( screen.queryByLabelText( @@ -690,6 +874,7 @@ describe('JoinLayersControl popover — count/category value columns', () => { }, }) openPicker() + expandLayer('facility1') expect(screen.getByText('Categories')).toBeInTheDocument() }) @@ -701,6 +886,7 @@ describe('JoinLayersControl popover — count/category value columns', () => { }, }) openPicker() + expandLayer('layer1') const select = screen.getByLabelText('Aggregation type for Layer 1') expect(within(select).getByText('Average')).toBeInTheDocument() diff --git a/src/components/datatable/__tests__/useCombinedTableData.spec.js b/src/components/datatable/__tests__/useCombinedTableData.spec.js index 57d8e91c68..ea9b22da2b 100644 --- a/src/components/datatable/__tests__/useCombinedTableData.spec.js +++ b/src/components/datatable/__tests__/useCombinedTableData.spec.js @@ -60,6 +60,7 @@ describe('useCombinedTableData - org unit join', () => { 'id', 'name', 'level', + 'orgUnitPath', 'layerA_rawValue', 'layerA_legend', ]) @@ -763,6 +764,7 @@ describe('useCombinedTableData - empty input', () => { rowFeatureIds: new Map(), columnOptions: {}, spatialWarning: false, + orgUnitIdToName: new Map(), }) }) @@ -778,6 +780,7 @@ describe('useCombinedTableData - empty input', () => { 'id', 'name', 'level', + 'orgUnitPath', ]) }) }) @@ -819,6 +822,7 @@ describe('useCombinedTableData - Earth Engine value columns', () => { 'id', 'name', 'level', + 'orgUnitPath', 'layerA_mean', 'layerA_max', ]) @@ -872,6 +876,7 @@ describe('useCombinedTableData - Earth Engine value columns', () => { 'id', 'name', 'level', + 'orgUnitPath', 'layerA_1', 'layerA_2', ]) @@ -927,6 +932,7 @@ describe('useCombinedTableData - thematic timeline/split-by-period value columns 'id', 'name', 'level', + 'orgUnitPath', 'layerA_rawValue', 'layerA_period_p1_rawValue', 'layerA_period_p2_rawValue', @@ -979,6 +985,7 @@ describe('useCombinedTableData - thematic timeline/split-by-period value columns 'id', 'name', 'level', + 'orgUnitPath', 'layerA_period_p1_rawValue', 'layerA_period_p2_rawValue', ]) diff --git a/src/components/datatable/controls/JoinLayersControl.jsx b/src/components/datatable/controls/JoinLayersControl.jsx index a8118d2776..503930f7de 100644 --- a/src/components/datatable/controls/JoinLayersControl.jsx +++ b/src/components/datatable/controls/JoinLayersControl.jsx @@ -1,53 +1,26 @@ import i18n from '@dhis2/d2-i18n' -import { IconWarningFilled16, Tooltip } from '@dhis2/ui' import PropTypes from 'prop-types' import React, { useMemo, useRef, useState } from 'react' import { useDispatch } from 'react-redux' import { clearDataFilters } from '../../../actions/dataFilters.js' -import { - getCategoryValueDisplayTypes, - getCombinedAggregationTypes, -} from '../../../constants/aggregationTypes.js' import { DATA_KEY_KIND_CATEGORY, - DATA_KEY_KIND_COUNT, ORG_UNIT_PATH_DATA_KEY, } from '../../../constants/dataTable.js' -import { - FACILITY_LAYER, - ORG_UNIT_LAYER, - EVENT_LAYER, - TRACKED_ENTITY_LAYER, -} from '../../../constants/layers.js' -import { NON_COMPOSABLE_AGGREGATION_TYPES } from '../../../util/aggregation.js' import { getUnmatchedFeatureCount, hasCombinedRollup, } from '../../../util/combinedJoinMatch.js' import { - CATEGORY_DISPLAY_TYPE_KEY, getCombinedValueDataKeys, getDefaultCombinedAggregation, } from '../../../util/dataTable.js' -import { - GEO_TYPE_POINT, - GEO_TYPE_POLYGON, - GEO_TYPE_MULTIPOLYGON, -} from '../../../util/geojson.js' -import Checkbox from '../../core/Checkbox.jsx' import { IconLayersStack16 } from '../../core/icons.jsx' -import { FilterActiveIcon } from '../../core/index.js' import { FilterDropdownPopover } from '../FilterDropdownPopover.jsx' +import LayerRow from './LayerRow.jsx' import styles from './styles/JoinLayersControl.module.css' import ToolbarIconButton from './ToolbarIconButton.jsx' -const isSpatialEligible = (layer) => { - const geometryType = layer.data?.[0]?.geometry?.type - return [GEO_TYPE_POINT, GEO_TYPE_POLYGON, GEO_TYPE_MULTIPOLYGON].includes( - geometryType - ) -} - const hasOrgUnitIdentity = (layer) => { const feature = layer.data?.[0] return !!(feature?.properties ?? feature)?.[ORG_UNIT_PATH_DATA_KEY] @@ -58,16 +31,6 @@ const getDefaultSettings = (layer) => ({ aggregation: getDefaultCombinedAggregation(layer), }) -const COUNT_LABEL_BY_LAYER_TYPE = { - [FACILITY_LAYER]: () => i18n.t('Facilities count'), - [ORG_UNIT_LAYER]: () => i18n.t('Org units count'), - [EVENT_LAYER]: () => i18n.t('Events count'), - [TRACKED_ENTITY_LAYER]: () => i18n.t('Tracked entities count'), -} - -const getCountLabel = (layer) => - COUNT_LABEL_BY_LAYER_TYPE[layer.layer]?.() ?? i18n.t('Count') - const JoinLayersControl = ({ eligibleLayers, layersConfig, @@ -76,9 +39,14 @@ const JoinLayersControl = ({ }) => { const anchorRef = useRef(null) const [isOpen, setIsOpen] = useState(false) - const aggregationTypes = getCombinedAggregationTypes() + const [expandedKeys, setExpandedKeys] = useState(() => new Set()) const dispatch = useDispatch() + const openPopover = () => { + setExpandedKeys(new Set()) + setIsOpen(true) + } + const joinQualityByLayerKey = useMemo(() => { const result = {} eligibleLayers.forEach((layer) => { @@ -108,10 +76,22 @@ const JoinLayersControl = ({ delete next[layer.combinedLayerKey] } else { next[layer.combinedLayerKey] = getDefaultSettings(layer) + setExpandedKeys((prev) => new Set(prev).add(layer.combinedLayerKey)) } onChange(next) } + const onToggleExpand = (layerKey) => + setExpandedKeys((prev) => { + const next = new Set(prev) + if (next.has(layerKey)) { + next.delete(layerKey) + } else { + next.add(layerKey) + } + return next + }) + const onTypeChange = (layerKey, type) => onChange({ ...layersConfig, @@ -138,7 +118,7 @@ const JoinLayersControl = ({ ariaLabel={i18n.t('Choose layers to combine')} dataTest="data-table-join-layers-button" disabled={!eligibleLayers.length} - onClick={() => setIsOpen((o) => !o)} + onClick={() => (isOpen ? setIsOpen(false) : openPopover())} > <IconLayersStack16 /> </ToolbarIconButton> @@ -186,340 +166,42 @@ const JoinLayersControl = ({ return true }) return ( - <div + <LayerRow key={layer.id} - className={styles.layerRow} - > - <div className={styles.layerRowHeader}> - <Checkbox - label={ - <span - className={ - styles.layerName - } - > - {layer.name} - </span> - } - checked={!!settings} - onChange={() => onToggle(layer)} - className={styles.layerCheckbox} - dataTest={`data-table-join-layer-${layer.id}`} - /> - {hasDataFilters && ( - <> - <Tooltip - content={i18n.t( - 'This layer has a filter active from its own table - Combined only reflects the filtered records.' - )} - > - <span - className={ - styles.aggregationWarning - } - data-test={`data-table-join-datafilters-warning-${layer.id}`} - > - <IconWarningFilled16 /> - </span> - </Tooltip> - <Tooltip - content={i18n.t( - 'Clear filters applied to {{layer}}', - { - layer: layer.name, - } - )} - > - <button - type="button" - className={ - styles.clearDataFiltersButton - } - onClick={() => - dispatch( - clearDataFilters( - layer.id - ) - ) - } - aria-label={i18n.t( - 'Clear filters applied to {{layer}}', - { - layer: layer.name, - } - )} - data-test={`data-table-join-clear-datafilters-${layer.id}`} - > - <FilterActiveIcon /> - </button> - </Tooltip> - </> - )} - </div> - {settings && ( - <div - className={styles.layerSettings} - > - <div - className={ - styles.aggregationRow - } - > - <select - aria-label={i18n.t( - 'Join type for {{layer}}', - { - layer: layer.name, - } - )} - value={settings.type} - onChange={(e) => - onTypeChange( - layer.combinedLayerKey, - e.target.value - ) - } - > - <option value="orgUnit"> - {i18n.t('Org unit')} - </option> - {isSpatialEligible( - layer - ) && ( - <option value="spatial"> - {i18n.t( - 'Spatial' - )} - </option> - )} - </select> - {unmatchedCount > 0 && ( - <Tooltip - content={i18n.t( - '{{count}} feature(s) from {{layer}} could not be matched to a reference org unit (wrong level, no matching parent, or outside every boundary) and will be excluded from the Combined table.', - { - count: unmatchedCount, - layer: layer.name, - } - )} - > - <span - className={ - styles.aggregationWarning - } - data-test={`data-table-join-unmatched-warning-${layer.id}`} - > - <IconWarningFilled16 /> - </span> - </Tooltip> - )} - </div> - {otherDataKeys.map( - ({ - dataKey, - name, - kind, - settingsKey, - }) => { - if ( - kind === - DATA_KEY_KIND_COUNT - ) { - return ( - <div - key={ - dataKey - } - className={ - styles.aggregationRow - } - > - <span - className={ - styles.aggregationRowLabel - } - > - {getCountLabel( - layer - )} - </span> - </div> - ) - } - - const aggregationKey = - settingsKey ?? - dataKey - const effectiveType = - settings - .aggregation?.[ - aggregationKey - ] ?? - defaultAggregation[ - dataKey - ] - const showWarning = - hasRollup && - NON_COMPOSABLE_AGGREGATION_TYPES.has( - effectiveType - ) - - return ( - <div - key={dataKey} - className={ - styles.aggregationRow - } - > - {name && ( - <span - className={ - styles.aggregationRowLabel - } - > - {name} - </span> - )} - <select - aria-label={ - name - ? i18n.t( - 'Aggregation type for {{name}} ({{layer}})', - { - name, - layer: layer.name, - } - ) - : i18n.t( - 'Aggregation type for {{layer}}', - { - layer: layer.name, - } - ) - } - value={ - effectiveType - } - onChange={( - e - ) => - onAggregationChange( - layer.combinedLayerKey, - aggregationKey, - e - .target - .value - ) - } - > - {aggregationTypes.map( - ( - type - ) => ( - <option - key={ - type.id - } - value={ - type.id - } - > - { - type.name - } - </option> - ) - )} - </select> - {showWarning && ( - <Tooltip - content={i18n.t( - 'Several {{layer}} features roll up into each reference org unit here - {{type}} is an approximation of the values you can see joined in, not a recomputation over the combined area.', - { - layer: layer.name, - type: effectiveType, - } - )} - > - <span - className={ - styles.aggregationWarning - } - data-test={`data-table-join-aggregation-warning-${layer.id}-${dataKey}`} - > - <IconWarningFilled16 /> - </span> - </Tooltip> - )} - </div> - ) - } - )} - {categoryDataKeys.length > - 0 && ( - <div - className={ - styles.aggregationRow - } - > - <span - className={ - styles.aggregationRowLabel - } - > - {layer.legend - ?.unit ?? - i18n.t( - 'Categories' - )} - </span> - <select - aria-label={i18n.t( - 'Category display for {{layer}}', - { - layer: layer.name, - } - )} - value={ - settings - .aggregation?.[ - CATEGORY_DISPLAY_TYPE_KEY - ] ?? - defaultAggregation[ - CATEGORY_DISPLAY_TYPE_KEY - ] - } - onChange={(e) => - onAggregationChange( - layer.combinedLayerKey, - CATEGORY_DISPLAY_TYPE_KEY, - e.target - .value - ) - } - > - {getCategoryValueDisplayTypes().map( - (type) => ( - <option - key={ - type.id - } - value={ - type.id - } - > - { - type.name - } - </option> - ) - )} - </select> - </div> - )} - </div> + layer={layer} + isExpanded={expandedKeys.has( + layer.combinedLayerKey )} - </div> + onToggleExpand={() => + onToggleExpand( + layer.combinedLayerKey + ) + } + onToggleJoined={() => onToggle(layer)} + hasDataFilters={hasDataFilters} + onClearDataFilters={() => + dispatch(clearDataFilters(layer.id)) + } + settings={settings} + defaultAggregation={defaultAggregation} + hasRollup={hasRollup} + unmatchedCount={unmatchedCount} + categoryDataKeys={categoryDataKeys} + otherDataKeys={otherDataKeys} + onTypeChange={(type) => + onTypeChange( + layer.combinedLayerKey, + type + ) + } + onAggregationChange={(dataKey, type) => + onAggregationChange( + layer.combinedLayerKey, + dataKey, + type + ) + } + /> ) })} </div> diff --git a/src/components/datatable/controls/LayerRow.jsx b/src/components/datatable/controls/LayerRow.jsx new file mode 100644 index 0000000000..448b0e343b --- /dev/null +++ b/src/components/datatable/controls/LayerRow.jsx @@ -0,0 +1,338 @@ +import i18n from '@dhis2/d2-i18n' +import { + IconChevronDown16, + IconChevronRight16, + IconWarningFilled16, + Tooltip, +} from '@dhis2/ui' +import PropTypes from 'prop-types' +import React from 'react' +import { + getCategoryValueDisplayTypes, + getCombinedAggregationTypes, +} from '../../../constants/aggregationTypes.js' +import { DATA_KEY_KIND_COUNT } from '../../../constants/dataTable.js' +import { + FACILITY_LAYER, + ORG_UNIT_LAYER, + EVENT_LAYER, + TRACKED_ENTITY_LAYER, +} from '../../../constants/layers.js' +import { NON_COMPOSABLE_AGGREGATION_TYPES } from '../../../util/aggregation.js' +import { CATEGORY_DISPLAY_TYPE_KEY } from '../../../util/dataTable.js' +import { + GEO_TYPE_POINT, + GEO_TYPE_POLYGON, + GEO_TYPE_MULTIPOLYGON, +} from '../../../util/geojson.js' +import Checkbox from '../../core/Checkbox.jsx' +import { FilterActiveIcon } from '../../core/index.js' +import styles from './styles/JoinLayersControl.module.css' + +const isSpatialEligible = (layer) => { + const geometryType = layer.data?.[0]?.geometry?.type + return [GEO_TYPE_POINT, GEO_TYPE_POLYGON, GEO_TYPE_MULTIPOLYGON].includes( + geometryType + ) +} + +const COUNT_LABEL_BY_LAYER_TYPE = { + [FACILITY_LAYER]: () => i18n.t('Facilities count'), + [ORG_UNIT_LAYER]: () => i18n.t('Org units count'), + [EVENT_LAYER]: () => i18n.t('Events count'), + [TRACKED_ENTITY_LAYER]: () => i18n.t('Tracked entities count'), +} + +const getCountLabel = (layer) => + COUNT_LABEL_BY_LAYER_TYPE[layer.layer]?.() ?? i18n.t('Count') + +const LayerRow = ({ + layer, + isExpanded, + onToggleExpand, + onToggleJoined, + hasDataFilters, + onClearDataFilters, + settings, + defaultAggregation, + hasRollup, + unmatchedCount, + categoryDataKeys, + otherDataKeys, + onTypeChange, + onAggregationChange, +}) => { + const aggregationTypes = getCombinedAggregationTypes() + const isJoined = !!settings + + return ( + <div className={styles.layerRow}> + <div className={styles.layerRowHeader}> + {isJoined ? ( + <button + type="button" + className={styles.expandButton} + onClick={onToggleExpand} + aria-label={ + isExpanded + ? i18n.t('Collapse {{layer}}', { + layer: layer.name, + }) + : i18n.t('Expand {{layer}}', { + layer: layer.name, + }) + } + data-test={`data-table-join-layer-toggle-${layer.id}`} + > + {isExpanded ? ( + <IconChevronDown16 /> + ) : ( + <IconChevronRight16 /> + )} + </button> + ) : ( + <span className={styles.expandButtonPlaceholder} /> + )} + <Checkbox + label={ + <span className={styles.layerName}>{layer.name}</span> + } + checked={isJoined} + onChange={onToggleJoined} + className={styles.layerCheckbox} + dataTest={`data-table-join-layer-${layer.id}`} + /> + {hasDataFilters && ( + <> + <Tooltip + content={i18n.t( + 'This layer has a filter active from its own table - Combined only reflects the filtered records.' + )} + > + <span + className={styles.aggregationWarning} + data-test={`data-table-join-datafilters-warning-${layer.id}`} + > + <IconWarningFilled16 /> + </span> + </Tooltip> + <Tooltip + content={i18n.t( + 'Clear filters applied to {{layer}}', + { layer: layer.name } + )} + > + <button + type="button" + className={styles.clearDataFiltersButton} + onClick={onClearDataFilters} + aria-label={i18n.t( + 'Clear filters applied to {{layer}}', + { layer: layer.name } + )} + data-test={`data-table-join-clear-datafilters-${layer.id}`} + > + <FilterActiveIcon /> + </button> + </Tooltip> + </> + )} + </div> + {isJoined && isExpanded && ( + <div className={styles.layerSettings}> + <div className={styles.aggregationRow}> + <span className={styles.aggregationRowLabel}> + {i18n.t('Join by')} + </span> + <select + aria-label={i18n.t('Join type for {{layer}}', { + layer: layer.name, + })} + value={settings.type} + onChange={(e) => onTypeChange(e.target.value)} + > + <option value="orgUnit"> + {i18n.t('Org unit')} + </option> + {isSpatialEligible(layer) && ( + <option value="spatial"> + {i18n.t('Location')} + </option> + )} + </select> + {unmatchedCount > 0 && ( + <Tooltip + content={i18n.t( + '{{count}} feature(s) from {{layer}} could not be matched to a reference org unit (wrong level, no matching parent, or outside every boundary) and will be excluded from the Combined table.', + { count: unmatchedCount, layer: layer.name } + )} + > + <span + className={styles.aggregationWarning} + data-test={`data-table-join-unmatched-warning-${layer.id}`} + > + <IconWarningFilled16 /> + </span> + </Tooltip> + )} + </div> + {otherDataKeys.map( + ({ dataKey, name, kind, settingsKey }) => { + if (kind === DATA_KEY_KIND_COUNT) { + return ( + <div + key={dataKey} + className={styles.aggregationRow} + > + <span + className={styles.staticValueLabel} + > + {getCountLabel(layer)} + </span> + </div> + ) + } + + const aggregationKey = settingsKey ?? dataKey + const effectiveType = + settings.aggregation?.[aggregationKey] ?? + defaultAggregation[dataKey] + const showWarning = + hasRollup && + layer.layer !== EVENT_LAYER && + NON_COMPOSABLE_AGGREGATION_TYPES.has( + effectiveType + ) + + return ( + <div + key={dataKey} + className={styles.aggregationRow} + > + <span + className={styles.aggregationRowLabel} + > + {name ?? i18n.t('Value')} + </span> + <select + aria-label={ + name + ? i18n.t( + 'Aggregation type for {{name}} ({{layer}})', + { + name, + layer: layer.name, + } + ) + : i18n.t( + 'Aggregation type for {{layer}}', + { layer: layer.name } + ) + } + value={effectiveType} + onChange={(e) => + onAggregationChange( + aggregationKey, + e.target.value + ) + } + > + {aggregationTypes.map((type) => ( + <option + key={type.id} + value={type.id} + > + {type.name} + </option> + ))} + </select> + {showWarning && ( + <Tooltip + content={i18n.t( + 'Several {{layer}} features roll up into each reference org unit here - {{type}} is an approximation of the values you can see joined in, not a recomputation over the combined area.', + { + layer: layer.name, + type: effectiveType, + } + )} + > + <span + className={ + styles.aggregationWarning + } + data-test={`data-table-join-aggregation-warning-${layer.id}-${dataKey}`} + > + <IconWarningFilled16 /> + </span> + </Tooltip> + )} + </div> + ) + } + )} + {categoryDataKeys.length > 0 && ( + <div className={styles.aggregationRow}> + <span className={styles.aggregationRowLabel}> + {layer.legend?.unit ?? i18n.t('Categories')} + </span> + <select + aria-label={i18n.t( + 'Category display for {{layer}}', + { layer: layer.name } + )} + value={ + settings.aggregation?.[ + CATEGORY_DISPLAY_TYPE_KEY + ] ?? + defaultAggregation[ + CATEGORY_DISPLAY_TYPE_KEY + ] + } + onChange={(e) => + onAggregationChange( + CATEGORY_DISPLAY_TYPE_KEY, + e.target.value + ) + } + > + {getCategoryValueDisplayTypes().map((type) => ( + <option key={type.id} value={type.id}> + {type.name} + </option> + ))} + </select> + </div> + )} + </div> + )} + </div> + ) +} + +LayerRow.propTypes = { + categoryDataKeys: PropTypes.array.isRequired, + hasDataFilters: PropTypes.bool.isRequired, + hasRollup: PropTypes.bool.isRequired, + isExpanded: PropTypes.bool.isRequired, + layer: PropTypes.shape({ + data: PropTypes.array, + id: PropTypes.string, + layer: PropTypes.string, + legend: PropTypes.object, + name: PropTypes.string, + }).isRequired, + otherDataKeys: PropTypes.array.isRequired, + unmatchedCount: PropTypes.number.isRequired, + onAggregationChange: PropTypes.func.isRequired, + onClearDataFilters: PropTypes.func.isRequired, + onToggleExpand: PropTypes.func.isRequired, + onToggleJoined: PropTypes.func.isRequired, + onTypeChange: PropTypes.func.isRequired, + defaultAggregation: PropTypes.object, + settings: PropTypes.shape({ + aggregation: PropTypes.object, + type: PropTypes.string, + }), +} + +export default LayerRow diff --git a/src/components/datatable/controls/styles/ColumnPickerControl.module.css b/src/components/datatable/controls/styles/ColumnPickerControl.module.css index fa974e6fd3..3732301418 100644 --- a/src/components/datatable/controls/styles/ColumnPickerControl.module.css +++ b/src/components/datatable/controls/styles/ColumnPickerControl.module.css @@ -1,9 +1,6 @@ .columnPickerPopover { - padding: var(--spacers-dp8); + composes: popoverPanel from './PopoverPanel.module.css'; min-width: 190px; - background-color: var(--colors-white); - border-radius: 4px; - box-shadow: var(--elevations-popover); } .searchInput { diff --git a/src/components/datatable/controls/styles/JoinLayersControl.module.css b/src/components/datatable/controls/styles/JoinLayersControl.module.css index a61faf8731..8ef148a8d0 100644 --- a/src/components/datatable/controls/styles/JoinLayersControl.module.css +++ b/src/components/datatable/controls/styles/JoinLayersControl.module.css @@ -1,9 +1,6 @@ .joinLayersPopover { - padding: var(--spacers-dp8); - min-width: 220px; - background-color: var(--colors-white); - border-radius: 4px; - box-shadow: var(--elevations-popover); + composes: popoverPanel from './PopoverPanel.module.css'; + min-width: 264px; } .layerList { @@ -14,16 +11,43 @@ } .layerRow { - padding: var(--spacers-dp2) var(--spacers-dp4); + padding: var(--spacers-dp4); border-radius: 3px; } +.layerRow:not(:last-child) { + border-bottom: 1px solid var(--colors-grey300); +} + .layerRowHeader { display: flex; align-items: center; gap: var(--spacers-dp4); } +.expandButton { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + padding: 0; + border: none; + background: none; + cursor: pointer; + color: var(--colors-grey700); +} + +.expandButton:hover { + color: var(--colors-grey900); +} + +.expandButtonPlaceholder { + width: 16px; + flex-shrink: 0; +} + .clearDataFiltersButton { display: flex; flex-shrink: 0; @@ -64,16 +88,8 @@ display: flex; flex-direction: column; gap: var(--spacers-dp4); - padding: var(--spacers-dp4) 0 var(--spacers-dp4) var(--spacers-dp20); -} - -.layerSettings > select { - height: 24px; - padding: 0 var(--spacers-dp4); - font-size: 12px; - border: 1px solid var(--colors-grey500); - border-radius: 3px; - background-color: var(--colors-white); + /* Aligns with the layer-name text, past the chevron + checkbox column */ + padding: var(--spacers-dp4) 0 var(--spacers-dp4) 44px; } .aggregationRow { @@ -83,8 +99,7 @@ } .aggregationRowLabel { - flex: 0 0 auto; - max-width: 80px; + flex: 0 0 80px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; @@ -109,3 +124,12 @@ align-items: center; color: var(--colors-yellow600); } + +.staticValueLabel { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: 11px; + font-style: italic; + color: var(--colors-grey600); +} diff --git a/src/components/datatable/controls/styles/PopoverPanel.module.css b/src/components/datatable/controls/styles/PopoverPanel.module.css new file mode 100644 index 0000000000..6751005ee0 --- /dev/null +++ b/src/components/datatable/controls/styles/PopoverPanel.module.css @@ -0,0 +1,6 @@ +.popoverPanel { + padding: var(--spacers-dp8); + background-color: var(--colors-white); + border-radius: 4px; + box-shadow: var(--elevations-popover); +} diff --git a/src/components/datatable/useCombinedTableData.js b/src/components/datatable/useCombinedTableData.js index 07bbc06fe6..ade47a68ba 100644 --- a/src/components/datatable/useCombinedTableData.js +++ b/src/components/datatable/useCombinedTableData.js @@ -4,8 +4,11 @@ import { DATA_KEY_KIND_CATEGORY, DATA_KEY_KIND_COUNT, ORG_UNIT_LEVEL_DATA_KEY, + ORG_UNIT_PATH_DATA_KEY, + RENDERER_ORG_UNIT, SORT_ASCENDING, TYPE_NUMBER, + TYPE_ORG_UNIT, TYPE_STRING, } from '../../constants/dataTable.js' import { EARTH_ENGINE_LAYER } from '../../constants/layers.js' @@ -13,6 +16,7 @@ import { SELECTION_FILTER_SELECTED, SELECTION_FILTER_NOT_SELECTED, } from '../../constants/selection.js' +import useOrgUnitAncestorNames from '../../hooks/useOrgUnitAncestorNames.js' import { applyAggregation } from '../../util/aggregation.js' import { getByReferenceId, @@ -41,6 +45,15 @@ const LEGEND_KEY = 'legend' const LARGE_FEATURE_THRESHOLD = 10000 const EMPTY_AGGREGATIONS = {} +// Number(null) is 0, not NaN - must exclude nullish before coercing. +const toFiniteNumber = (value) => { + if (value == null) { + return undefined + } + const num = Number(value) + return Number.isFinite(num) ? num : undefined +} + const mergeAggregations = (layer, aggregationsForLayer) => { if (layer.layer !== EARTH_ENGINE_LAYER || !aggregationsForLayer) { return layer @@ -70,6 +83,8 @@ const finalizeRows = ( selectionFilter, selectedIdSet, keyAnalysisDigitGroupSeparator, + orgUnitRenderer, + orgUnitIdToName, } ) => { let data = filterData(flatRows, filters) @@ -94,7 +109,12 @@ const finalizeRows = ( } data = [...data].sort((a, b) => - compareRows(a, b, { sortField, sortDirection }) + compareRows(a, b, { + sortField, + sortDirection, + orgUnitRenderer, + idToName: orgUnitIdToName, + }) ) return data.map((row) => buildRowCells(row, headers)) @@ -134,16 +154,15 @@ const applyLayerMatchToRow = ({ row, featureIds, refProps }, layerMatch) => { const effectiveType = settings.aggregation?.[settingsKey ?? dataKey] ?? getDefaultCombinedAggregation(layer)[dataKey] - const values = + const values = ( periodId != null - ? matches - .map( - (p) => layer.valuesByPeriod?.[periodId]?.[p.id]?.value - ) - .filter((v) => Number.isFinite(v)) - : matches - .map((p) => p[dataKey]) - .filter((v) => Number.isFinite(v)) + ? matches.map( + (p) => layer.valuesByPeriod?.[periodId]?.[p.id]?.value + ) + : matches.map((p) => p[dataKey]) + ) + .map(toFiniteNumber) + .filter((v) => v !== undefined) row[rowKey] = applyAggregation(effectiveType, values) }) @@ -257,6 +276,7 @@ const getLegendHeader = (layer, { periodName, isCurrentPeriod }) => { const EMPTY_COLUMN_OPTIONS = {} const EMPTY_HEADERS = [] +const EMPTY_ORG_UNIT_ID_TO_NAME = new Map() const EMPTY_RESULT = { headers: EMPTY_HEADERS, @@ -264,6 +284,7 @@ const EMPTY_RESULT = { rowFeatureIds: new Map(), columnOptions: EMPTY_COLUMN_OPTIONS, spatialWarning: false, + orgUnitIdToName: EMPTY_ORG_UNIT_ID_TO_NAME, } export const useCombinedTableData = ({ @@ -297,6 +318,16 @@ export const useCombinedTableData = ({ [referenceOrgUnits, showOnlyFeaturesInView, mapBounds] ) + const orgUnitPathValues = useMemo( + () => + visibleReferenceOrgUnits.map( + (f) => getProps(f)[ORG_UNIT_PATH_DATA_KEY] + ), + [visibleReferenceOrgUnits] + ) + const { idToName: orgUnitIdToName } = + useOrgUnitAncestorNames(orgUnitPathValues) + const layerMatches = useMemo( () => layers.map((layer) => { @@ -351,6 +382,12 @@ export const useCombinedTableData = ({ type: TYPE_NUMBER, defaultHidden: true, }, + { + name: i18n.t('Org unit hierarchy'), + dataKey: ORG_UNIT_PATH_DATA_KEY, + type: TYPE_ORG_UNIT, + renderer: RENDERER_ORG_UNIT, + }, ...layerMatches.flatMap( ({ layer, settings, valueDataKeys, legendConfig }) => [ ...valueDataKeys.map((valueDataKey) => @@ -386,6 +423,8 @@ export const useCombinedTableData = ({ id: refProps.id, name: refProps.name ?? null, level: refProps[ORG_UNIT_LEVEL_DATA_KEY] ?? null, + [ORG_UNIT_PATH_DATA_KEY]: + refProps[ORG_UNIT_PATH_DATA_KEY] ?? null, index, } @@ -403,6 +442,10 @@ export const useCombinedTableData = ({ } ) + const sortFieldRenderer = headers.find( + (h) => h.dataKey === sortField + )?.renderer + const rows = finalizeRows(flatRows, headers, { filters, globalSearch, @@ -411,6 +454,8 @@ export const useCombinedTableData = ({ selectionFilter, selectedIdSet, keyAnalysisDigitGroupSeparator, + orgUnitRenderer: sortFieldRenderer, + orgUnitIdToName, }) const columnOptions = sortColumnOptions(getColumnDistinctValues(headers, flatRows), { @@ -424,6 +469,7 @@ export const useCombinedTableData = ({ rowFeatureIds, columnOptions, spatialWarning, + orgUnitIdToName, } }, [ referenceOrgUnits, @@ -438,5 +484,6 @@ export const useCombinedTableData = ({ selectionFilter, selectedIdSet, keyAnalysisDigitGroupSeparator, + orgUnitIdToName, ]) } diff --git a/src/components/edit/LayerEdit.jsx b/src/components/edit/LayerEdit.jsx index cf18fd8530..e40697433c 100644 --- a/src/components/edit/LayerEdit.jsx +++ b/src/components/edit/LayerEdit.jsx @@ -135,11 +135,11 @@ const LayerEdit = ({ layer, addLayer, updateLayer, cancelLayer }) => { onClick={onValidateLayer} dataTest="layeredit-addbtn" > - {i18n.t( - layer.id - ? i18n.t('Update layer') - : i18n.t('Add layer') - )} + {isReferenceLayer + ? i18n.t('Update reference') + : layer.id + ? i18n.t('Update layer') + : i18n.t('Add layer')} </Button> </ButtonStrip> </ModalActions> diff --git a/src/components/edit/__tests__/LayerEdit.spec.jsx b/src/components/edit/__tests__/LayerEdit.spec.jsx index c3db00e65f..352ca883a6 100644 --- a/src/components/edit/__tests__/LayerEdit.spec.jsx +++ b/src/components/edit/__tests__/LayerEdit.spec.jsx @@ -82,4 +82,22 @@ describe('LayerEdit — reference org unit layer', () => { ) expect(screen.getByText('Add new org unit layer')).toBeInTheDocument() }) + + test('shows a state-agnostic "Update reference" submit button, with no id (new)', () => { + renderLayerEdit({ layer: 'combinedTableRef', rows: [] }) + expect(screen.getByText('Update reference')).toBeInTheDocument() + }) + + test('shows the same "Update reference" submit button once it has an id (already saved/editing)', () => { + renderLayerEdit({ id: 'ref1', layer: 'combinedTableRef', rows: [] }) + expect(screen.getByText('Update reference')).toBeInTheDocument() + }) + + test('a real org unit layer still shows "Add layer"/"Update layer" depending on id', () => { + renderLayerEdit({ layer: 'orgUnit', rows: [] }) + expect(screen.getByText('Add layer')).toBeInTheDocument() + + renderLayerEdit({ id: 'ou1', layer: 'orgUnit', rows: [] }) + expect(screen.getAllByText('Update layer')[0]).toBeInTheDocument() + }) }) diff --git a/src/util/__tests__/dataTable.spec.js b/src/util/__tests__/dataTable.spec.js index a58aa34964..70f3707f01 100644 --- a/src/util/__tests__/dataTable.spec.js +++ b/src/util/__tests__/dataTable.spec.js @@ -475,6 +475,25 @@ describe('getCombinedValueDataKeys - Event layers', () => { ).toEqual([{ dataKey: 'value', name: null, kind: DATA_KEY_KIND_VALUE }]) }) + test('styleDataItem on a numeric value type: uses the data item name, matching the categorical/boolean cases', () => { + expect( + getCombinedValueDataKeys({ + layer: EVENT_LAYER, + styleDataItem: { + id: 'de1', + name: 'Weight (kg)', + valueType: 'NUMBER', + }, + }) + ).toEqual([ + { + dataKey: 'value', + name: 'Weight (kg)', + kind: DATA_KEY_KIND_VALUE, + }, + ]) + }) + test('styleDataItem.optionSet with 3 options: 3 category entries keyed by colorGroup', () => { expect( getCombinedValueDataKeys({ @@ -733,14 +752,14 @@ describe('getDefaultCombinedAggregation', () => { ).toEqual({ categoryDisplayType: 'COUNT' }) }) - test('Event: a numeric styleDataItem defaults to SUM - there is no per-data-item aggregationType metadata for event data elements the way there is for Thematic', () => { + test('Event: a numeric styleDataItem defaults to AVERAGE - event values are raw individual records, not pre-aggregated org-unit values', () => { expect( getDefaultCombinedAggregation({ layer: EVENT_LAYER, styleDataItem: { id: 'de1', valueType: 'NUMBER' }, legend: { items: [{ name: 'Low' }, { name: 'High' }] }, }) - ).toEqual({ value: 'SUM' }) + ).toEqual({ value: 'AVERAGE' }) }) test('Event: category dataKeys default to a single shared categoryDisplayType of COUNT', () => { diff --git a/src/util/__tests__/styleByDataItem.spec.js b/src/util/__tests__/styleByDataItem.spec.js index 5baf91e5c1..133fd69947 100644 --- a/src/util/__tests__/styleByDataItem.spec.js +++ b/src/util/__tests__/styleByDataItem.spec.js @@ -119,6 +119,7 @@ describe('styleByDataItem', () => { }), ]) ) + expect(result.styleDataItem.name).toEqual(STYLE_DATA_ITEM_NAME) }) it('should include no-data events when noDataLegend is configured (default)', async () => { @@ -231,6 +232,7 @@ describe('styleByDataItem', () => { ]) ) expect(result.legend.unit).toEqual(LEGEND_SET_NAME) + expect(result.styleDataItem.name).toEqual(STYLE_DATA_ITEM_NAME) }) it('should include outside and no-data features when unclassifiedLegend and noDataLegend are configured (predefined)', async () => { @@ -353,6 +355,7 @@ describe('styleByDataItem', () => { ]) ) expect(result.legend.unit).toEqual(STYLE_DATA_ITEM_NAME) + expect(result.styleDataItem.name).toEqual(STYLE_DATA_ITEM_NAME) }) it('should include no-data features when noDataLegend is configured (auto)', async () => { @@ -446,6 +449,7 @@ describe('styleByDataItem', () => { ]) ) expect(result.legend.unit).toEqual(STYLE_DATA_ITEM_NAME) + expect(result.styleDataItem.name).toEqual(STYLE_DATA_ITEM_NAME) }) it('should include unclassified and no-data events when configured (boolean)', async () => { diff --git a/src/util/dataTable.js b/src/util/dataTable.js index 141db457f0..b7aa7cea8b 100644 --- a/src/util/dataTable.js +++ b/src/util/dataTable.js @@ -60,6 +60,9 @@ const getValueAggregationType = (layer) => { layer.aggregationType ] } + if (layer.layer === EVENT_LAYER) { + return 'AVERAGE' + } const dataItem = getDataItemFromColumns(layer.columns) return getDefaultCombinedAggregationType( dataItem?.aggregationType, @@ -140,7 +143,7 @@ const getEventValueDataKeys = (layer) => { return [ { dataKey: EVENT_STYLE_VALUE_KEY, - name: null, + name: layer.styleDataItem.name ?? null, kind: DATA_KEY_KIND_VALUE, }, ] diff --git a/src/util/styleByDataItem.js b/src/util/styleByDataItem.js index 0d5913f2b3..bf6c04d3ba 100644 --- a/src/util/styleByDataItem.js +++ b/src/util/styleByDataItem.js @@ -93,6 +93,7 @@ const styleByDefault = async (config, engine) => { const { id } = styleDataItem legend.unit = await getLegendUnit(engine, styleDataItem) + config.styleDataItem = { ...styleDataItem, name: legend.unit } const eventItem = { name: i18n.t('Event'), @@ -133,6 +134,7 @@ const styleByBoolean = async (config, engine) => { const { id, values } = styleDataItem legend.unit = await getLegendUnit(engine, styleDataItem) + config.styleDataItem = { ...styleDataItem, name: legend.unit } const yesItem = { name: i18n.t('Yes'), color: values.true } const noItem = values.false @@ -204,6 +206,9 @@ const styleByNumeric = async (config, engine) => { } = config let valueFormat + const itemName = await getLegendUnit(engine, styleDataItem) + config.styleDataItem = { ...styleDataItem, name: itemName } + // If legend set if (method === CLASSIFICATION_PREDEFINED) { // Load legend set from server @@ -230,8 +235,8 @@ const styleByNumeric = async (config, engine) => { } sortedValues.sort((a, b) => a - b) - // Use data item name as legend unit (load from server if needed) - legend.unit = await getLegendUnit(engine, styleDataItem) + // Use data item name as legend unit + legend.unit = itemName // Generate legend items based on layer config const classification = getAutomaticLegendItems({ From 5d2e6913e43ba3d1b121e98eca92e6e8d0f4e312 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 4 Aug 2026 16:09:47 +0200 Subject: [PATCH 198/205] fix: pin data table row highlight and remove drill up/down --- i18n/en.pot | 16 +-- .../datatable/CombinedDataTable.jsx | 50 ++++++---- .../datatable/CombinedTableContextMenu.jsx | 68 +------------ src/components/datatable/DataTable.jsx | 27 ++--- src/components/datatable/TableContextMenu.jsx | 8 +- .../CombinedTableContextMenu.spec.jsx | 77 +-------------- .../__tests__/TableContextMenu.spec.jsx | 5 +- .../useRowContextMenuHighlight.spec.js | 98 +++++++++++++++++++ .../datatable/useRowContextMenuHighlight.js | 38 +++++++ 9 files changed, 203 insertions(+), 184 deletions(-) create mode 100644 src/components/datatable/__tests__/useRowContextMenuHighlight.spec.js create mode 100644 src/components/datatable/useRowContextMenuHighlight.js diff --git a/i18n/en.pot b/i18n/en.pot index 78cf37d316..fc6a371654 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-08-04T11:53:43.119Z\n" -"PO-Revision-Date: 2026-08-04T11:53:43.120Z\n" +"POT-Creation-Date: 2026-08-04T14:05:40.790Z\n" +"PO-Revision-Date: 2026-08-04T14:05:40.791Z\n" msgid "2020" msgstr "2020" @@ -168,12 +168,6 @@ msgstr "No matching rows" msgid "Location join over large datasets may be slow (over {{threshold}} features)" msgstr "Location join over large datasets may be slow (over {{threshold}} features)" -msgid "Drill up one level" -msgstr "Drill up one level" - -msgid "Drill down one level" -msgstr "Drill down one level" - msgid "Zoom to feature" msgstr "Zoom to feature" @@ -287,6 +281,12 @@ msgstr[1] "{{count}} selected" msgid "Sort by {{column}}" msgstr "Sort by {{column}}" +msgid "Drill up one level" +msgstr "Drill up one level" + +msgid "Drill down one level" +msgstr "Drill down one level" + msgid "View profile" msgstr "View profile" diff --git a/src/components/datatable/CombinedDataTable.jsx b/src/components/datatable/CombinedDataTable.jsx index 57d5e034e2..6be7bed369 100644 --- a/src/components/datatable/CombinedDataTable.jsx +++ b/src/components/datatable/CombinedDataTable.jsx @@ -20,7 +20,6 @@ import { getRowId, getUnionBounds, mergeCrossLayerIds, - shouldClearFeatureHighlight, } from '../../util/dataTable.js' import { getPinnedCellProps, @@ -44,6 +43,7 @@ import TableComponents from './TableVirtuosoComponents.jsx' import { useColumnWidths } from './useColumnWidths.js' import { useCombinedTableData } from './useCombinedTableData.js' import { useRowClickSelection } from './useRowClickSelection.js' +import { useRowContextMenuHighlight } from './useRowContextMenuHighlight.js' import { useRowSelection } from './useRowSelection.js' import { useSortState } from './useSortState.js' @@ -289,15 +289,16 @@ const CombinedDataTable = ({ [dispatch, rowFeatureIds] ) - const clearFeatureHighlight = useCallback( - (event) => { - if (shouldClearFeatureHighlight(event)) { - setHoveredRowId(null) - dispatch(highlightFeature(null)) - } - }, - [dispatch] - ) + const onClearHighlight = useCallback(() => { + setHoveredRowId(null) + dispatch(highlightFeature(null)) + }, [dispatch]) + + const { onContextMenuOpen, guardedClear, onMenuClose } = + useRowContextMenuHighlight({ + onPin: setFeatureHighlight, + onClear: onClearHighlight, + }) const onRowDoubleClick = useCallback( (row) => { @@ -371,19 +372,23 @@ const CombinedDataTable = ({ const [tableContextMenu, setTableContextMenu] = useState(null) - const onRowContextMenu = useCallback((e, row) => { - e.preventDefault() - const rowId = getRowId(row) - if (!rowId) { - return - } - setTableContextMenu({ x: e.clientX, y: e.clientY, rowId }) - }, []) + const onRowContextMenu = useCallback( + (e, row) => { + e.preventDefault() + const rowId = getRowId(row) + if (!rowId) { + return + } + onContextMenuOpen(row) + setTableContextMenu({ x: e.clientX, y: e.clientY, rowId }) + }, + [onContextMenuOpen] + ) const tableContext = useMemo( () => ({ onMouseEnter: setFeatureHighlight, - onMouseLeave: clearFeatureHighlight, + onMouseLeave: guardedClear, onRowClick, onContextMenu: onRowContextMenu, onRowDoubleClick, @@ -391,7 +396,7 @@ const CombinedDataTable = ({ }), [ setFeatureHighlight, - clearFeatureHighlight, + guardedClear, onRowClick, onRowContextMenu, onRowDoubleClick, @@ -621,7 +626,10 @@ const CombinedDataTable = ({ rowFeatureIds={rowFeatureIds} selectedIds={selectedIds} filteredIds={hasActiveFilters ? allRowIds : null} - onClose={() => setTableContextMenu(null)} + onClose={(highlightChanged) => { + setTableContextMenu(null) + onMenuClose(highlightChanged) + }} /> </div> ) diff --git a/src/components/datatable/CombinedTableContextMenu.jsx b/src/components/datatable/CombinedTableContextMenu.jsx index a91063690b..2a09e97191 100644 --- a/src/components/datatable/CombinedTableContextMenu.jsx +++ b/src/components/datatable/CombinedTableContextMenu.jsx @@ -1,22 +1,10 @@ import i18n from '@dhis2/d2-i18n' -import { - Popover, - Menu, - MenuItem, - IconArrowDown16, - IconArrowUp16, -} from '@dhis2/ui' +import { Popover, Menu, MenuItem } from '@dhis2/ui' import PropTypes from 'prop-types' import React, { useRef } from 'react' import { useDispatch } from 'react-redux' import { highlightFeature } from '../../actions/feature.js' -import { updateLayer } from '../../actions/layers.js' -import { - buildFeatureIndex, - getUnionBounds, - mergeCrossLayerIds, -} from '../../util/dataTable.js' -import { drillUpDown } from '../../util/map.js' +import { getUnionBounds, mergeCrossLayerIds } from '../../util/dataTable.js' import { IconZoomIn16 } from '../core/icons.jsx' const CombinedTableContextMenu = ({ @@ -39,10 +27,6 @@ const CombinedTableContextMenu = ({ const entry = rowFeatureIds.get(rowId) ?? {} const allLayers = [referenceLayer, ...layers] - const referenceFeatureProps = buildFeatureIndex(referenceLayer.data).get( - rowId - )?.properties - const zoomTo = (idsByLayerId) => { dispatch( highlightFeature({ @@ -53,7 +37,7 @@ const CombinedTableContextMenu = ({ crossLayerIds: idsByLayerId, }) ) - onClose() + onClose(true) } return ( @@ -76,52 +60,6 @@ const CombinedTableContextMenu = ({ onClickOutside={onClose} > <Menu dense dataTest="combined-table-context-menu"> - {referenceFeatureProps && ( - <MenuItem - dataTest="combined-table-context-menu-drill-up" - label={i18n.t('Drill up one level')} - icon={<IconArrowUp16 />} - disabled={!referenceFeatureProps.hasCoordinatesUp} - onClick={() => { - dispatch( - updateLayer( - drillUpDown( - referenceLayer, - referenceFeatureProps.grandParentId, - referenceFeatureProps.grandParentParentGraph, - Number.parseInt( - referenceFeatureProps.level - ) - 1 - ) - ) - ) - onClose() - }} - /> - )} - {referenceFeatureProps && ( - <MenuItem - dataTest="combined-table-context-menu-drill-down" - label={i18n.t('Drill down one level')} - icon={<IconArrowDown16 />} - disabled={!referenceFeatureProps.hasCoordinatesDown} - onClick={() => { - dispatch( - updateLayer( - drillUpDown( - referenceLayer, - referenceFeatureProps.id, - referenceFeatureProps.parentGraph, - Number.parseInt( - referenceFeatureProps.level - ) + 1 - ) - ) - ) - onClose() - }} - /> - )} <MenuItem dataTest="combined-table-context-menu-zoom-to-feature" label={i18n.t('Zoom to feature')} diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index d3f7ccf15e..0a2485f20d 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -35,7 +35,6 @@ import { getRowId, hasActiveDataTableFilters, isFilterable, - shouldClearFeatureHighlight, } from '../../util/dataTable.js' import { getPinnedCellProps, @@ -57,6 +56,7 @@ import TableContextMenu from './TableContextMenu.jsx' import TableComponents from './TableVirtuosoComponents.jsx' import { useColumnWidths } from './useColumnWidths.js' import { useRowClickSelection } from './useRowClickSelection.js' +import { useRowContextMenuHighlight } from './useRowContextMenuHighlight.js' import { useRowSelection } from './useRowSelection.js' import { useSortState } from './useSortState.js' import { useTableData } from './useTableData.js' @@ -117,14 +117,15 @@ const Table = ({ }, [dispatch, layer.id] ) - const clearFeatureHighlight = useCallback( - (event) => { - if (shouldClearFeatureHighlight(event)) { - dispatch(highlightFeature(null)) - } - }, + const onClearHighlight = useCallback( + () => dispatch(highlightFeature(null)), [dispatch] ) + const { onContextMenuOpen, guardedClear, onMenuClose } = + useRowContextMenuHighlight({ + onPin: setFeatureHighlight, + onClear: onClearHighlight, + }) const featureById = useMemo( () => buildFeatureIndex(layer.data), @@ -136,6 +137,7 @@ const Table = ({ const onRowContextMenu = useCallback( (e, row) => { e.preventDefault() + onContextMenuOpen(row) const id = getRowId(row) const feature = featureById.get(id) setTableContextMenu({ @@ -144,7 +146,7 @@ const Table = ({ featureProps: feature?.properties ?? { id }, }) }, - [featureById] + [featureById, onContextMenuOpen] ) const selectedIds = useMemo( @@ -275,7 +277,7 @@ const Table = ({ const tableContext = useMemo( () => ({ onMouseEnter: setFeatureHighlight, - onMouseLeave: clearFeatureHighlight, + onMouseLeave: guardedClear, onContextMenu: onRowContextMenu, onRowClick, onRowDoubleClick, @@ -288,7 +290,7 @@ const Table = ({ }), [ setFeatureHighlight, - clearFeatureHighlight, + guardedClear, onRowContextMenu, onRowClick, onRowDoubleClick, @@ -625,7 +627,10 @@ const Table = ({ layer={layer} selectedIds={selectedIds} filteredIds={hasActiveFilters ? allRowIds : null} - onClose={() => setTableContextMenu(null)} + onClose={(highlightChanged) => { + setTableContextMenu(null) + onMenuClose(highlightChanged) + }} /> </> ) diff --git a/src/components/datatable/TableContextMenu.jsx b/src/components/datatable/TableContextMenu.jsx index 24d0974dd3..f08a383553 100644 --- a/src/components/datatable/TableContextMenu.jsx +++ b/src/components/datatable/TableContextMenu.jsx @@ -164,7 +164,7 @@ const TableContextMenu = ({ zoom: true, }) ) - onClose() + onClose(true) }} /> )} @@ -180,7 +180,7 @@ const TableContextMenu = ({ zoom: true, }) ) - onClose() + onClose(true) }} /> <MenuItem @@ -197,7 +197,7 @@ const TableContextMenu = ({ zoom: true, }) ) - onClose() + onClose(true) }} /> <MenuItem @@ -214,7 +214,7 @@ const TableContextMenu = ({ zoom: true, }) ) - onClose() + onClose(true) }} /> </Menu> diff --git a/src/components/datatable/__tests__/CombinedTableContextMenu.spec.jsx b/src/components/datatable/__tests__/CombinedTableContextMenu.spec.jsx index b755d5987d..7e9f6d0515 100644 --- a/src/components/datatable/__tests__/CombinedTableContextMenu.spec.jsx +++ b/src/components/datatable/__tests__/CombinedTableContextMenu.spec.jsx @@ -2,10 +2,7 @@ import { render, fireEvent, screen } from '@testing-library/react' import React from 'react' import { Provider } from 'react-redux' import configureMockStore from 'redux-mock-store' -import { - FEATURE_HIGHLIGHT, - LAYER_UPDATE, -} from '../../../constants/actionTypes.js' +import { FEATURE_HIGHLIGHT } from '../../../constants/actionTypes.js' import { THEMATIC_LAYER } from '../../../constants/layers.js' import CombinedTableContextMenu from '../CombinedTableContextMenu.jsx' @@ -20,16 +17,7 @@ const point = (id, coordinates, properties = {}) => ({ const referenceLayer = { id: 'ref1', layer: 'combinedTableRef', - data: [ - point('ou1', [0, 0], { - level: '3', - hasCoordinatesUp: true, - hasCoordinatesDown: false, - grandParentId: 'gp1', - grandParentParentGraph: '/country1', - parentGraph: '/country1/region1', - }), - ], + data: [point('ou1', [0, 0])], } const layers = [ @@ -65,65 +53,6 @@ const renderMenu = (props) => { return { ...result, store } } -describe('CombinedTableContextMenu — drill up/down', () => { - test('is enabled/disabled per the reference layer own hasCoordinatesUp/hasCoordinatesDown', () => { - renderMenu() - expect( - getLink('combined-table-context-menu-drill-up') - ).not.toHaveAttribute('aria-disabled', 'true') - expect( - getLink('combined-table-context-menu-drill-down') - ).toHaveAttribute('aria-disabled', 'true') - }) - - test('drilling up dispatches updateLayer for the reference layer, using its own feature props', () => { - const onClose = jest.fn() - const { store } = renderMenu({ onClose }) - fireEvent.click(getLink('combined-table-context-menu-drill-up')) - - const layerUpdates = store - .getActions() - .filter((a) => a.type === LAYER_UPDATE) - expect(layerUpdates).toHaveLength(1) - expect(layerUpdates[0].payload.id).toBe('ref1') - expect(layerUpdates[0].payload.rows[0].items).toEqual([ - { id: 'gp1', path: '/country1/gp1' }, - { id: 'LEVEL-2' }, - ]) - expect(onClose).toHaveBeenCalled() - }) - - test('drilling down dispatches updateLayer for the reference layer, using its own feature props', () => { - const onClose = jest.fn() - const { store } = renderMenu({ - onClose, - referenceLayer: { - ...referenceLayer, - data: [ - point('ou1', [0, 0], { - level: '3', - hasCoordinatesUp: false, - hasCoordinatesDown: true, - parentGraph: '/country1/region1', - }), - ], - }, - }) - fireEvent.click(getLink('combined-table-context-menu-drill-down')) - - const layerUpdates = store - .getActions() - .filter((a) => a.type === LAYER_UPDATE) - expect(layerUpdates).toHaveLength(1) - expect(layerUpdates[0].payload.id).toBe('ref1') - expect(layerUpdates[0].payload.rows[0].items).toEqual([ - { id: 'ou1', path: '/country1/region1/ou1' }, - { id: 'LEVEL-4' }, - ]) - expect(onClose).toHaveBeenCalled() - }) -}) - describe('CombinedTableContextMenu — zoom actions', () => { test('zoom to feature dispatches a crossLayerIds highlight with the union bounds across the reference and participating layers', () => { const onClose = jest.fn() @@ -142,7 +71,7 @@ describe('CombinedTableContextMenu — zoom actions', () => { crossLayerIds: { ref1: ['ou1'], layerA: ['a1'] }, }, }) - expect(onClose).toHaveBeenCalled() + expect(onClose).toHaveBeenCalledWith(true) }) test('zoom to selected features is disabled when nothing is selected', () => { diff --git a/src/components/datatable/__tests__/TableContextMenu.spec.jsx b/src/components/datatable/__tests__/TableContextMenu.spec.jsx index 0384092cdc..d0ba24e7ca 100644 --- a/src/components/datatable/__tests__/TableContextMenu.spec.jsx +++ b/src/components/datatable/__tests__/TableContextMenu.spec.jsx @@ -55,8 +55,10 @@ describe('TableContextMenu — view profile menu item', () => { }) test('dispatches setOrgUnitProfile with the row id for a layer type that supports it', () => { + const onClose = jest.fn() const { store } = renderMenu({ contextMenu: { x: 10, y: 10, featureProps: { id: 'ou1' } }, + onClose, }) fireEvent.click( screen @@ -67,6 +69,7 @@ describe('TableContextMenu — view profile menu item', () => { type: ORGANISATION_UNIT_PROFILE_SET, payload: 'ou1', }) + expect(onClose).toHaveBeenCalledWith() }) }) @@ -97,6 +100,6 @@ describe('TableContextMenu — zoom to filtered features', () => { zoom: true, }, }) - expect(onClose).toHaveBeenCalled() + expect(onClose).toHaveBeenCalledWith(true) }) }) diff --git a/src/components/datatable/__tests__/useRowContextMenuHighlight.spec.js b/src/components/datatable/__tests__/useRowContextMenuHighlight.spec.js new file mode 100644 index 0000000000..2b0af0a8ea --- /dev/null +++ b/src/components/datatable/__tests__/useRowContextMenuHighlight.spec.js @@ -0,0 +1,98 @@ +import { renderHook } from '@testing-library/react' +import { useRowContextMenuHighlight } from '../useRowContextMenuHighlight.js' + +const leaveEvent = (relatedTagName) => ({ + relatedTarget: relatedTagName ? { tagName: relatedTagName } : null, +}) + +describe('useRowContextMenuHighlight', () => { + test('opening the context menu pins the row via onPin', () => { + const onPin = jest.fn() + const onClear = jest.fn() + const { result } = renderHook(() => + useRowContextMenuHighlight({ onPin, onClear }) + ) + const row = { id: 'row1' } + + result.current.onContextMenuOpen(row) + + expect(onPin).toHaveBeenCalledWith(row) + }) + + test('a mouseleave while the menu is open is ignored, even when it would normally clear the highlight', () => { + const onPin = jest.fn() + const onClear = jest.fn() + const { result } = renderHook(() => + useRowContextMenuHighlight({ onPin, onClear }) + ) + + result.current.onContextMenuOpen({ id: 'row1' }) + result.current.guardedClear(leaveEvent('DIV')) + + expect(onClear).not.toHaveBeenCalled() + }) + + test('mouseleave clears the highlight normally when no menu is open', () => { + const onPin = jest.fn() + const onClear = jest.fn() + const { result } = renderHook(() => + useRowContextMenuHighlight({ onPin, onClear }) + ) + + result.current.guardedClear(leaveEvent('DIV')) + + expect(onClear).toHaveBeenCalledTimes(1) + }) + + test('mouseleave between cells of the same row (relatedTarget is a TD) still never clears, menu or no menu', () => { + const onPin = jest.fn() + const onClear = jest.fn() + const { result } = renderHook(() => + useRowContextMenuHighlight({ onPin, onClear }) + ) + + result.current.guardedClear(leaveEvent('TD')) + + expect(onClear).not.toHaveBeenCalled() + }) + + test('closing the menu without a superseding highlight clears it', () => { + const onPin = jest.fn() + const onClear = jest.fn() + const { result } = renderHook(() => + useRowContextMenuHighlight({ onPin, onClear }) + ) + + result.current.onContextMenuOpen({ id: 'row1' }) + result.current.onMenuClose(false) + + expect(onClear).toHaveBeenCalledTimes(1) + }) + + test('closing the menu after a "Zoom to ..." action (highlightChanged=true) preserves the new highlight', () => { + const onPin = jest.fn() + const onClear = jest.fn() + const { result } = renderHook(() => + useRowContextMenuHighlight({ onPin, onClear }) + ) + + result.current.onContextMenuOpen({ id: 'row1' }) + result.current.onMenuClose(true) + + expect(onClear).not.toHaveBeenCalled() + }) + + test('after the menu closes, mouseleave clearing resumes normally', () => { + const onPin = jest.fn() + const onClear = jest.fn() + const { result } = renderHook(() => + useRowContextMenuHighlight({ onPin, onClear }) + ) + + result.current.onContextMenuOpen({ id: 'row1' }) + result.current.onMenuClose(true) + result.current.guardedClear(leaveEvent('DIV')) + + expect(onClear).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/components/datatable/useRowContextMenuHighlight.js b/src/components/datatable/useRowContextMenuHighlight.js new file mode 100644 index 0000000000..77a307ac05 --- /dev/null +++ b/src/components/datatable/useRowContextMenuHighlight.js @@ -0,0 +1,38 @@ +import { useCallback, useRef } from 'react' +import { shouldClearFeatureHighlight } from '../../util/dataTable.js' + +export const useRowContextMenuHighlight = ({ onPin, onClear }) => { + const menuOpenRef = useRef(false) + + const onContextMenuOpen = useCallback( + (row) => { + menuOpenRef.current = true + onPin(row) + }, + [onPin] + ) + + const guardedClear = useCallback( + (event) => { + if (menuOpenRef.current) { + return + } + if (shouldClearFeatureHighlight(event)) { + onClear() + } + }, + [onClear] + ) + + const onMenuClose = useCallback( + (highlightChanged) => { + menuOpenRef.current = false + if (!highlightChanged) { + onClear() + } + }, + [onClear] + ) + + return { onContextMenuOpen, guardedClear, onMenuClose } +} From baad9abfa111832b8a90e35ed3e66dba23e1cc1f Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 4 Aug 2026 16:48:22 +0200 Subject: [PATCH 199/205] fix: add warning when event layer is server clustered --- i18n/en.pot | 14 +++- src/components/datatable/BottomPanel.jsx | 26 +++++++ .../datatable/__tests__/BottomPanel.spec.jsx | 75 ++++++++++++++++++ .../__tests__/JoinLayersControl.spec.jsx | 71 +++++++++++++++++ .../datatable/__tests__/LayerRow.spec.jsx | 78 +++++++++++++++++++ .../datatable/controls/JoinLayersControl.jsx | 10 +++ .../datatable/controls/LayerRow.jsx | 36 +++++++++ 7 files changed, 308 insertions(+), 2 deletions(-) create mode 100644 src/components/datatable/__tests__/LayerRow.spec.jsx diff --git a/i18n/en.pot b/i18n/en.pot index fc6a371654..a98250b2d8 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-08-04T14:05:40.790Z\n" -"PO-Revision-Date: 2026-08-04T14:05:40.791Z\n" +"POT-Creation-Date: 2026-08-04T14:25:52.537Z\n" +"PO-Revision-Date: 2026-08-04T14:25:52.537Z\n" msgid "2020" msgstr "2020" @@ -378,6 +378,16 @@ msgstr "" msgid "Clear filters applied to {{layer}}" msgstr "Clear filters applied to {{layer}}" +msgid "" +"This layer is clustered on the server - its data isn't available to join " +"into the Combined table." +msgstr "" +"This layer is clustered on the server - its data isn't available to join " +"into the Combined table." + +msgid "Switch to client clustering" +msgstr "Switch to client clustering" + msgid "Join by" msgstr "Join by" diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index 0c96f9e7ed..ad93920e68 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -98,6 +98,13 @@ const BottomPanel = () => { ), [combinedLayers] ) + const serverClusteredCombinedLayers = useMemo( + () => + combinedLayers.filter( + (l) => l.serverCluster && !l.forceClientCluster + ), + [combinedLayers] + ) const activeLayer = mapViews.find((l) => l.id === activeLayerId) const dataFilters = activeLayer?.dataFilters ?? EMPTY_FILTERS @@ -367,6 +374,25 @@ const BottomPanel = () => { </span> </Tooltip> )} + {serverClusteredCombinedLayers.length > 0 && ( + <Tooltip + content={i18n.t( + "Values from {{layers}} aren't available to join into the Combined table while clustered on the server.", + { + layers: serverClusteredCombinedLayers + .map((l) => l.name) + .join(', '), + } + )} + > + <span + className={styles.filteredLayersWarning} + data-test="data-table-combined-servercluster-warning" + > + <IconWarningFilled16 /> + </span> + </Tooltip> + )} <ReferenceOrgUnitControl /> <span className={styles.divider} /> </> diff --git a/src/components/datatable/__tests__/BottomPanel.spec.jsx b/src/components/datatable/__tests__/BottomPanel.spec.jsx index d1db58ef05..72d8f8e458 100644 --- a/src/components/datatable/__tests__/BottomPanel.spec.jsx +++ b/src/components/datatable/__tests__/BottomPanel.spec.jsx @@ -521,6 +521,81 @@ describe('BottomPanel Combined join controls', () => { ).toBeInTheDocument() }) + test('shows no ambient server-cluster warning when no joined layer is server clustered', () => { + renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1', 'layer2'], + combinedView: true, + }, + mapViews: [ + ...twoEligibleLayers, + { + ...referenceLayer(), + combinedJoinConfig: { + layer1: { type: 'orgUnit', aggregation: {} }, + }, + }, + ], + }) + + expect( + screen.queryByTestId('data-table-combined-servercluster-warning') + ).not.toBeInTheDocument() + }) + + test('shows an ambient server-cluster warning naming a joined layer that is server clustered', () => { + renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1', 'layer2'], + combinedView: true, + }, + mapViews: [ + { ...twoEligibleLayers[0], serverCluster: true }, + twoEligibleLayers[1], + { + ...referenceLayer(), + combinedJoinConfig: { + layer1: { type: 'orgUnit', aggregation: {} }, + }, + }, + ], + }) + + expect( + screen.getByTestId('data-table-combined-servercluster-warning') + ).toBeInTheDocument() + }) + + test('does not show the ambient server-cluster warning once forceClientCluster is set', () => { + renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1', 'layer2'], + combinedView: true, + }, + mapViews: [ + { + ...twoEligibleLayers[0], + serverCluster: true, + forceClientCluster: true, + }, + twoEligibleLayers[1], + { + ...referenceLayer(), + combinedJoinConfig: { + layer1: { type: 'orgUnit', aggregation: {} }, + }, + }, + ], + }) + + expect( + screen.queryByTestId('data-table-combined-servercluster-warning') + ).not.toBeInTheDocument() + }) + test('toggling an already-joined layer off dispatches DATA_TABLE_JOIN_CONFIG_SET with that layer removed', () => { const { store } = renderBottomPanel({ dataTable: { diff --git a/src/components/datatable/__tests__/JoinLayersControl.spec.jsx b/src/components/datatable/__tests__/JoinLayersControl.spec.jsx index 388dc4eb25..b9f7ecb664 100644 --- a/src/components/datatable/__tests__/JoinLayersControl.spec.jsx +++ b/src/components/datatable/__tests__/JoinLayersControl.spec.jsx @@ -947,3 +947,74 @@ describe('JoinLayersControl dataFilters warning', () => { }) }) }) + +describe('JoinLayersControl server-cluster warning', () => { + test('shows no warning or switch button for a layer that is not server clustered', () => { + renderControl() + openPicker() + + expect( + screen.queryByTestId('data-table-join-servercluster-warning-layer1') + ).not.toBeInTheDocument() + expect( + screen.queryByTestId('data-table-join-servercluster-switch-layer1') + ).not.toBeInTheDocument() + }) + + test('shows the warning and switch button for a server-clustered layer, even when not joined', () => { + renderControl({ + eligibleLayers: [ + { ...eligibleLayers[0], serverCluster: true }, + eligibleLayers[1], + ], + }) + openPicker() + + expect( + screen.getByTestId('data-table-join-servercluster-warning-layer1') + ).toBeInTheDocument() + expect( + screen.getByTestId('data-table-join-servercluster-switch-layer1') + ).toBeInTheDocument() + expect( + screen.queryByTestId('data-table-join-servercluster-warning-layer2') + ).not.toBeInTheDocument() + }) + + test('does not show the warning once forceClientCluster is set, even if serverCluster is still true', () => { + renderControl({ + eligibleLayers: [ + { + ...eligibleLayers[0], + serverCluster: true, + forceClientCluster: true, + }, + eligibleLayers[1], + ], + }) + openPicker() + + expect( + screen.queryByTestId('data-table-join-servercluster-warning-layer1') + ).not.toBeInTheDocument() + }) + + test('clicking the switch button dispatches setForceClientCluster for that layer', () => { + const { store } = renderControl({ + eligibleLayers: [ + { ...eligibleLayers[0], serverCluster: true }, + eligibleLayers[1], + ], + }) + openPicker() + + fireEvent.click( + screen.getByTestId('data-table-join-servercluster-switch-layer1') + ) + + expect(store.getActions()).toContainEqual({ + type: 'LAYER_FORCE_CLIENT_CLUSTER_SET', + id: 'layer1', + }) + }) +}) diff --git a/src/components/datatable/__tests__/LayerRow.spec.jsx b/src/components/datatable/__tests__/LayerRow.spec.jsx new file mode 100644 index 0000000000..b13a65e3a1 --- /dev/null +++ b/src/components/datatable/__tests__/LayerRow.spec.jsx @@ -0,0 +1,78 @@ +import { render, fireEvent, screen } from '@testing-library/react' +import React from 'react' +import { THEMATIC_LAYER } from '../../../constants/layers.js' +import LayerRow from '../controls/LayerRow.jsx' + +const layer = { id: 'layer1', name: 'Layer 1', layer: THEMATIC_LAYER, data: [] } + +const renderRow = (props) => + render( + <LayerRow + layer={layer} + isExpanded={false} + onToggleExpand={jest.fn()} + onToggleJoined={jest.fn()} + hasDataFilters={false} + onClearDataFilters={jest.fn()} + isServerClustered={false} + onForceClientCluster={jest.fn()} + settings={undefined} + hasRollup={false} + unmatchedCount={0} + categoryDataKeys={[]} + otherDataKeys={[]} + onTypeChange={jest.fn()} + onAggregationChange={jest.fn()} + {...props} + /> + ) + +describe('LayerRow — server-cluster warning', () => { + test('shows no warning or switch button when the layer is not server clustered', () => { + renderRow() + + expect( + screen.queryByTestId('data-table-join-servercluster-warning-layer1') + ).not.toBeInTheDocument() + expect( + screen.queryByTestId('data-table-join-servercluster-switch-layer1') + ).not.toBeInTheDocument() + }) + + test('shows the warning and switch button when the layer is server clustered', () => { + renderRow({ isServerClustered: true }) + + expect( + screen.getByTestId('data-table-join-servercluster-warning-layer1') + ).toBeInTheDocument() + expect( + screen.getByTestId('data-table-join-servercluster-switch-layer1') + ).toBeInTheDocument() + }) + + test('clicking the switch button calls onForceClientCluster', () => { + const onForceClientCluster = jest.fn() + renderRow({ isServerClustered: true, onForceClientCluster }) + + fireEvent.click( + screen.getByTestId('data-table-join-servercluster-switch-layer1') + ) + + expect(onForceClientCluster).toHaveBeenCalledTimes(1) + }) + + test('the warning and the data-filters warning can appear together', () => { + renderRow({ + isServerClustered: true, + hasDataFilters: true, + onClearDataFilters: jest.fn(), + }) + + expect( + screen.getByTestId('data-table-join-servercluster-warning-layer1') + ).toBeInTheDocument() + expect( + screen.getByTestId('data-table-join-datafilters-warning-layer1') + ).toBeInTheDocument() + }) +}) diff --git a/src/components/datatable/controls/JoinLayersControl.jsx b/src/components/datatable/controls/JoinLayersControl.jsx index 503930f7de..fea364f668 100644 --- a/src/components/datatable/controls/JoinLayersControl.jsx +++ b/src/components/datatable/controls/JoinLayersControl.jsx @@ -3,6 +3,7 @@ import PropTypes from 'prop-types' import React, { useMemo, useRef, useState } from 'react' import { useDispatch } from 'react-redux' import { clearDataFilters } from '../../../actions/dataFilters.js' +import { setForceClientCluster } from '../../../actions/layers.js' import { DATA_KEY_KIND_CATEGORY, ORG_UNIT_PATH_DATA_KEY, @@ -136,6 +137,9 @@ const JoinLayersControl = ({ const hasDataFilters = Object.keys(layer.dataFilters ?? {}) .length > 0 + const isServerClustered = + layer.serverCluster && + !layer.forceClientCluster const defaultAggregation = getDefaultCombinedAggregation(layer) const { @@ -182,6 +186,12 @@ const JoinLayersControl = ({ onClearDataFilters={() => dispatch(clearDataFilters(layer.id)) } + isServerClustered={isServerClustered} + onForceClientCluster={() => + dispatch( + setForceClientCluster(layer.id) + ) + } settings={settings} defaultAggregation={defaultAggregation} hasRollup={hasRollup} diff --git a/src/components/datatable/controls/LayerRow.jsx b/src/components/datatable/controls/LayerRow.jsx index 448b0e343b..85a4d40824 100644 --- a/src/components/datatable/controls/LayerRow.jsx +++ b/src/components/datatable/controls/LayerRow.jsx @@ -2,6 +2,7 @@ import i18n from '@dhis2/d2-i18n' import { IconChevronDown16, IconChevronRight16, + IconReorder16, IconWarningFilled16, Tooltip, } from '@dhis2/ui' @@ -53,6 +54,8 @@ const LayerRow = ({ onToggleJoined, hasDataFilters, onClearDataFilters, + isServerClustered, + onForceClientCluster, settings, defaultAggregation, hasRollup, @@ -137,6 +140,37 @@ const LayerRow = ({ </Tooltip> </> )} + {isServerClustered && ( + <> + <Tooltip + content={i18n.t( + "This layer is clustered on the server - its data isn't available to join into the Combined table." + )} + > + <span + className={styles.aggregationWarning} + data-test={`data-table-join-servercluster-warning-${layer.id}`} + > + <IconWarningFilled16 /> + </span> + </Tooltip> + <Tooltip + content={i18n.t('Switch to client clustering')} + > + <button + type="button" + className={styles.clearDataFiltersButton} + onClick={onForceClientCluster} + aria-label={i18n.t( + 'Switch to client clustering' + )} + data-test={`data-table-join-servercluster-switch-${layer.id}`} + > + <IconReorder16 /> + </button> + </Tooltip> + </> + )} </div> {isJoined && isExpanded && ( <div className={styles.layerSettings}> @@ -314,6 +348,7 @@ LayerRow.propTypes = { hasDataFilters: PropTypes.bool.isRequired, hasRollup: PropTypes.bool.isRequired, isExpanded: PropTypes.bool.isRequired, + isServerClustered: PropTypes.bool.isRequired, layer: PropTypes.shape({ data: PropTypes.array, id: PropTypes.string, @@ -325,6 +360,7 @@ LayerRow.propTypes = { unmatchedCount: PropTypes.number.isRequired, onAggregationChange: PropTypes.func.isRequired, onClearDataFilters: PropTypes.func.isRequired, + onForceClientCluster: PropTypes.func.isRequired, onToggleExpand: PropTypes.func.isRequired, onToggleJoined: PropTypes.func.isRequired, onTypeChange: PropTypes.func.isRequired, From a6ab462698426d5fd93ecbed94fb0ad4c3dbf388 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Tue, 4 Aug 2026 17:10:20 +0200 Subject: [PATCH 200/205] fix: allow org-unit join to use reference org units with no geometry --- i18n/en.pot | 11 +- src/loaders/__tests__/orgUnitLoader.spec.js | 138 +++++++++++++++++++- src/loaders/orgUnitLoader.js | 12 +- 3 files changed, 156 insertions(+), 5 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index a98250b2d8..425bcd5ca8 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-08-04T14:25:52.537Z\n" -"PO-Revision-Date: 2026-08-04T14:25:52.537Z\n" +"POT-Creation-Date: 2026-08-04T15:05:43.776Z\n" +"PO-Revision-Date: 2026-08-04T15:05:43.776Z\n" msgid "2020" msgstr "2020" @@ -162,6 +162,13 @@ msgstr "" "Values from {{layers}} only reflect the filter(s) applied in their own " "table." +msgid "" +"Values from {{layers}} aren't available to join into the Combined table " +"while clustered on the server." +msgstr "" +"Values from {{layers}} aren't available to join into the Combined table " +"while clustered on the server." + msgid "No matching rows" msgstr "No matching rows" diff --git a/src/loaders/__tests__/orgUnitLoader.spec.js b/src/loaders/__tests__/orgUnitLoader.spec.js index a2082f58be..8cca296a83 100644 --- a/src/loaders/__tests__/orgUnitLoader.spec.js +++ b/src/loaders/__tests__/orgUnitLoader.spec.js @@ -1,9 +1,11 @@ +import { WARNING_NO_OU_COORD } from '../../constants/alerts.js' import { FIRST_DATA_ELEMENT_QUERY, + GEOFEATURES_QUERY, ORG_UNITS_COUNT_QUERY, ORG_UNITS_PATHS_QUERY, } from '../../util/requests.js' -import { applyMissingCoordsCount } from '../orgUnitLoader.js' +import orgUnitLoader, { applyMissingCoordsCount } from '../orgUnitLoader.js' const makeEngine = ({ missingOuIds = [], @@ -123,3 +125,137 @@ describe('applyMissingCoordsCount', () => { ]) }) }) + +// A minimal engine covering every query the full orgUnitLoader issues - +// GEOFEATURES_QUERY, the org unit levels lookup, and (when +// countFeaturesWithoutCoordinates is in effect) the missing-org-units +// count/path queries already exercised above via applyMissingCoordsCount. +const makeFullLoaderEngine = ({ + missingOuIds = [], + ouNamesById = {}, + orgUnitPathsById = {}, +} = {}) => ({ + query: jest.fn((query) => { + if (query === GEOFEATURES_QUERY) { + return Promise.resolve({ geoFeatures: [] }) + } + if (query?.orgUnitLevels) { + return Promise.resolve({ + orgUnitLevels: { organisationUnitLevels: [] }, + }) + } + if (query === FIRST_DATA_ELEMENT_QUERY) { + return Promise.resolve({ + dataElements: { dataElements: [{ id: 'de1' }] }, + }) + } + if (query === ORG_UNITS_COUNT_QUERY) { + return Promise.resolve({ + orgUnitsCount: { + metaData: { + dimensions: { ou: missingOuIds }, + items: Object.fromEntries( + missingOuIds.map((id) => [ + id, + { name: ouNamesById[id] ?? id }, + ]) + ), + }, + }, + }) + } + if (query === ORG_UNITS_PATHS_QUERY) { + return Promise.resolve({ + organisationUnits: { + organisationUnits: Object.entries(orgUnitPathsById).map( + ([id, path]) => ({ id, path }) + ), + }, + }) + } + throw new Error('Unexpected query') + }), +}) + +const referenceLayerConfig = () => ({ + id: 'ref1', + layer: 'combinedTableRef', + rows: [{ dimension: 'ou', items: [{ id: 'ou1' }] }], +}) + +const orgUnitLayerConfig = () => ({ + id: 'orgunit1', + layer: 'orgUnit', + rows: [{ dimension: 'ou', items: [{ id: 'ou1' }] }], +}) + +const loadArgs = (config, engine) => ({ + config, + engine, + keyAnalysisDisplayProperty: 'name', + userId: 'user1', + baseUrl: '', +}) + +describe('orgUnitLoader - reference layer org units without coordinates', () => { + test('always loads org units without coordinates for the reference layer, even though it is never explicitly configured to', async () => { + const engine = makeFullLoaderEngine({ + missingOuIds: ['ou1'], + ouNamesById: { ou1: 'Country 1' }, + orgUnitPathsById: { ou1: '/ou1' }, + }) + + const result = await orgUnitLoader( + loadArgs(referenceLayerConfig(), engine) + ) + + expect(result.countFeaturesWithoutCoordinates).toBe(true) + expect(result.dataWithoutCoords).toEqual([ + { + id: 'ou1', + properties: { + id: 'ou1', + name: 'Country 1', + orgUnitId: 'ou1', + orgUnitPath: '/ou1', + orgUnitOwn: '/ou1', + level: 1, + }, + }, + ]) + }) + + test('does not push the generic "no coordinates" warning for the reference layer, even when every org unit lacks geometry', async () => { + const engine = makeFullLoaderEngine({ + missingOuIds: ['ou1'], + ouNamesById: { ou1: 'Country 1' }, + orgUnitPathsById: { ou1: '/ou1' }, + }) + + const result = await orgUnitLoader( + loadArgs(referenceLayerConfig(), engine) + ) + + expect(result.alerts).not.toContainEqual( + expect.objectContaining({ code: WARNING_NO_OU_COORD }) + ) + }) + + test('a regular org unit layer is unaffected - still needs the checkbox-derived flag to load org units without coordinates, and still warns when none have coordinates', async () => { + const engine = makeFullLoaderEngine({ + missingOuIds: ['ou1'], + ouNamesById: { ou1: 'Country 1' }, + orgUnitPathsById: { ou1: '/ou1' }, + }) + + const result = await orgUnitLoader( + loadArgs(orgUnitLayerConfig(), engine) + ) + + expect(result.countFeaturesWithoutCoordinates).toBeUndefined() + expect(result.dataWithoutCoords).toBeUndefined() + expect(result.alerts).toContainEqual( + expect.objectContaining({ code: WARNING_NO_OU_COORD }) + ) + }) +}) diff --git a/src/loaders/orgUnitLoader.js b/src/loaders/orgUnitLoader.js index 5693b38e94..f8e97234e6 100644 --- a/src/loaders/orgUnitLoader.js +++ b/src/loaders/orgUnitLoader.js @@ -6,6 +6,7 @@ import { ERROR_CRITICAL, CUSTOM_ALERT, } from '../constants/alerts.js' +import { COMBINED_TABLE_REF_LAYER } from '../constants/layers.js' import { getOrgUnitsFromRows } from '../util/analytics.js' import { parseJsonConfig } from '../util/config.js' import { toGeoJson } from '../util/map.js' @@ -79,7 +80,10 @@ const orgUnitLoader = async ({ combinedColumnConfig, combinedLayerKey, } = parseJsonConfig(config.config) - if (countFeaturesWithoutCoordinates) { + if ( + countFeaturesWithoutCoordinates || + config.layer === COMBINED_TABLE_REF_LAYER + ) { config.countFeaturesWithoutCoordinates = true } if (unclassifiedLegend) { @@ -122,7 +126,11 @@ const orgUnitLoader = async ({ ) const mainFeatures = data?.geoFeatures ? toGeoJson(data.geoFeatures) : [] - if (!mainFeatures.length && !alerts.length) { + if ( + !mainFeatures.length && + !alerts.length && + config.layer !== COMBINED_TABLE_REF_LAYER + ) { alerts.push({ code: WARNING_NO_OU_COORD, message: i18n.t('Org unit layer'), From d510af111ac0b8eae5212b766f330abaee741024 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Wed, 5 Aug 2026 21:00:41 +0200 Subject: [PATCH 201/205] fix: performance optimisation --- src/components/datatable/DataTable.jsx | 9 + src/components/datatable/FilterInput.jsx | 23 +- .../datatable/__tests__/FilterInput.spec.jsx | 42 ++++ src/components/datatable/useTableData.js | 11 +- .../__tests__/useOrgUnitAncestorNames.spec.js | 65 +++++- src/hooks/useOrgUnitAncestorNames.js | 54 ++++- src/loaders/__tests__/eventLoader.spec.js | 214 ++++++++++++++++++ .../__tests__/trackedEntityLoader.spec.js | 25 ++ src/loaders/eventLoader.js | 153 +++++++++++-- src/loaders/trackedEntityLoader.js | 11 + src/util/__tests__/orgUnits.spec.js | 66 ++++++ src/util/orgUnits.js | 13 ++ 12 files changed, 655 insertions(+), 31 deletions(-) diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index 0a2485f20d..d8b0d10602 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -435,6 +435,14 @@ const Table = ({ name={name} options={columnOptions[dataKey]} optionSetId={optionSet?.id} + resolvedOptionNames={ + optionSet?.id + ? layer + .optionSetOptionsByCode?.[ + optionSet.id + ] + : undefined + } renderer={renderer} orgUnitIdToName={orgUnitIdToName} filterValue={ @@ -484,6 +492,7 @@ const Table = ({ visibleHeaders, pinnedLeftOffsets, layer.dataFilters, + layer.optionSetOptionsByCode, pinnedColumnCount, columnWidths, columnOptions, diff --git a/src/components/datatable/FilterInput.jsx b/src/components/datatable/FilterInput.jsx index 187683a35a..f7fa428442 100644 --- a/src/components/datatable/FilterInput.jsx +++ b/src/components/datatable/FilterInput.jsx @@ -543,18 +543,27 @@ PlainSearchableFilter.propTypes = { type: PropTypes.string, } -const OptionSetSearchableFilter = ({ optionSetId, ...props }) => { - const { optionSet } = useOptionSet(optionSetId) +const OptionSetSearchableFilter = ({ + optionSetId, + resolvedOptionNames, + ...props +}) => { + const { optionSet } = useOptionSet( + resolvedOptionNames ? undefined : optionSetId + ) const optionByCode = useMemo(() => { + if (resolvedOptionNames) { + return new Map(Object.entries(resolvedOptionNames)) + } const map = new Map() - optionSet?.options.forEach((o) => map.set(o.code, o)) + optionSet?.options.forEach((o) => map.set(o.code, o.name)) return map - }, [optionSet]) + }, [resolvedOptionNames, optionSet]) const resolveLabel = useCallback( (value) => value === SENTINEL_NO_VALUE ? i18n.t('No value') - : optionByCode.get(value)?.name ?? value, + : optionByCode.get(value) ?? value, [optionByCode] ) return ( @@ -568,6 +577,7 @@ const OptionSetSearchableFilter = ({ optionSetId, ...props }) => { OptionSetSearchableFilter.propTypes = { optionSetId: PropTypes.string.isRequired, + resolvedOptionNames: PropTypes.object, } const FilterInput = React.memo(function FilterInput({ @@ -577,6 +587,7 @@ const FilterInput = React.memo(function FilterInput({ name, options, optionSetId, + resolvedOptionNames, renderer, orgUnitIdToName, filterValue, @@ -620,6 +631,7 @@ const FilterInput = React.memo(function FilterInput({ filterValue={filterValue} options={options ?? []} optionSetId={optionSetId} + resolvedOptionNames={resolvedOptionNames} type={type} renderer={renderer} onChange={onChange} @@ -657,6 +669,7 @@ FilterInput.propTypes = { options: PropTypes.arrayOf(PropTypes.shape({ value: PropTypes.string })), orgUnitIdToName: PropTypes.instanceOf(Map), renderer: PropTypes.string, + resolvedOptionNames: PropTypes.object, onChange: PropTypes.func, onClear: PropTypes.func, } diff --git a/src/components/datatable/__tests__/FilterInput.spec.jsx b/src/components/datatable/__tests__/FilterInput.spec.jsx index e5bf9bce80..184c9eff71 100644 --- a/src/components/datatable/__tests__/FilterInput.spec.jsx +++ b/src/components/datatable/__tests__/FilterInput.spec.jsx @@ -364,6 +364,48 @@ describe('FilterInput multi-select path (optionSetId)', () => { }) }) +describe('FilterInput multi-select path (resolvedOptionNames pre-resolved)', () => { + const options = [{ value: 'CONFIRMED' }, { value: 'PROBABLE' }] + + beforeEach(() => { + useOptionSet.mockReturnValue({ optionSet: null }) + }) + + test('uses resolvedOptionNames directly without calling useOptionSet', () => { + renderFilterInput({ + dataKey: 'caseType', + name: 'Case classification', + options, + optionSetId: 'optionSet1', + resolvedOptionNames: { + CONFIRMED: 'Confirmed case', + PROBABLE: 'Probable case', + }, + }) + openPopover('Case classification') + expect(screen.getByLabelText('Confirmed case')).toBeInTheDocument() + expect(screen.getByLabelText('Probable case')).toBeInTheDocument() + expect(useOptionSet).toHaveBeenCalledWith(undefined) + }) + + test('falls back to useOptionSet when resolvedOptionNames is not provided', () => { + useOptionSet.mockReturnValue({ + optionSet: { + options: [{ code: 'CONFIRMED', name: 'Confirmed case' }], + }, + }) + renderFilterInput({ + dataKey: 'caseType', + name: 'Case classification', + options, + optionSetId: 'optionSet1', + }) + openPopover('Case classification') + expect(screen.getByLabelText('Confirmed case')).toBeInTheDocument() + expect(useOptionSet).toHaveBeenCalledWith('optionSet1') + }) +}) + describe('FilterInput searchable popover — search', () => { const options = [{ value: 'High' }, { value: 'Low' }] diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index dbd8373681..cada838ada 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -19,6 +19,7 @@ import { } from '../../constants/selection.js' import useOrgUnitAncestorNames from '../../hooks/useOrgUnitAncestorNames.js' import { filterByGlobalSearch, filterData } from '../../util/filter.js' +import { buildKnownOrgUnitNames } from '../../util/orgUnits.js' import { buildRowCells, getColumnDistinctValues, @@ -240,8 +241,14 @@ export const useTableData = ({ ), [headers, columnOptions] ) - const { idToName: orgUnitIdToName } = - useOrgUnitAncestorNames(orgUnitPathValues) + const knownOrgUnitNames = useMemo( + () => buildKnownOrgUnitNames(dataWithAggregations), + [dataWithAggregations] + ) + const { idToName: orgUnitIdToName } = useOrgUnitAncestorNames( + orgUnitPathValues, + knownOrgUnitNames + ) const rows = useMemo(() => { if (errorCode.current) { diff --git a/src/hooks/__tests__/useOrgUnitAncestorNames.spec.js b/src/hooks/__tests__/useOrgUnitAncestorNames.spec.js index 04bbdc1f94..66d542c49f 100644 --- a/src/hooks/__tests__/useOrgUnitAncestorNames.spec.js +++ b/src/hooks/__tests__/useOrgUnitAncestorNames.spec.js @@ -1,6 +1,8 @@ import { renderHook, waitFor } from '@testing-library/react' import { fetchOrgUnitPathDetails } from '../../util/orgUnits.js' -import useOrgUnitAncestorNames from '../useOrgUnitAncestorNames.js' +import useOrgUnitAncestorNames, { + __resetOrgUnitNameSessionCacheForTests, +} from '../useOrgUnitAncestorNames.js' jest.mock('@dhis2/app-runtime', () => ({ useDataEngine: () => mockEngine, @@ -17,6 +19,7 @@ jest.mock('../../util/orgUnits.js', () => ({ beforeEach(() => { fetchOrgUnitPathDetails.mockReset() + __resetOrgUnitNameSessionCacheForTests() }) describe('useOrgUnitAncestorNames', () => { @@ -63,4 +66,64 @@ describe('useOrgUnitAncestorNames', () => { }) expect(result.current.idToName.get('country1')).toBe('Sierra Leone') }) + + it('does not fetch ids that are present in the seed map, and returns them merged into idToName immediately', async () => { + fetchOrgUnitPathDetails.mockResolvedValue({ + region1: { name: 'Region 1', level: 2 }, + facility1: { name: 'Facility 1', level: 3 }, + }) + const knownIdToName = new Map([['country1', 'Sierra Leone']]) + const { result } = renderHook(() => + useOrgUnitAncestorNames( + ['/country1/region1/facility1'], + knownIdToName + ) + ) + + expect(fetchOrgUnitPathDetails).toHaveBeenCalledWith( + {}, + expect.arrayContaining(['region1', 'facility1']), + 'displayShortName' + ) + const [, fetchedIds] = fetchOrgUnitPathDetails.mock.calls[0] + expect(fetchedIds).not.toContain('country1') + + await waitFor(() => { + expect(result.current.loading).toBe(false) + }) + expect(result.current.idToName.get('country1')).toBe('Sierra Leone') + expect(result.current.idToName.get('region1')).toBe('Region 1') + }) + + it('skips the fetch entirely when every id is already known', () => { + const knownIdToName = new Map([['country1', 'Sierra Leone']]) + const { result } = renderHook(() => + useOrgUnitAncestorNames(['/country1'], knownIdToName) + ) + + expect(fetchOrgUnitPathDetails).not.toHaveBeenCalled() + expect(result.current.loading).toBe(false) + expect(result.current.idToName.get('country1')).toBe('Sierra Leone') + }) + + it('reuses a name across separate hook mounts, from the session cache', async () => { + fetchOrgUnitPathDetails.mockResolvedValue({ + country1: { name: 'Sierra Leone', level: 1 }, + }) + + const first = renderHook(() => useOrgUnitAncestorNames(['/country1'])) + await waitFor(() => { + expect(first.result.current.loading).toBe(false) + }) + first.unmount() + + const second = renderHook(() => useOrgUnitAncestorNames(['/country1'])) + await waitFor(() => { + expect(second.result.current.idToName.get('country1')).toBe( + 'Sierra Leone' + ) + }) + + expect(fetchOrgUnitPathDetails).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/hooks/useOrgUnitAncestorNames.js b/src/hooks/useOrgUnitAncestorNames.js index 87761d78b2..cad33a0f16 100644 --- a/src/hooks/useOrgUnitAncestorNames.js +++ b/src/hooks/useOrgUnitAncestorNames.js @@ -3,7 +3,17 @@ import { useEffect, useMemo, useState } from 'react' import { useCachedData } from '../components/cachedDataProvider/CachedDataProvider.jsx' import { fetchOrgUnitPathDetails } from '../util/orgUnits.js' -const useOrgUnitAncestorNames = (distinctPathValues) => { +const EMPTY_MAP = new Map() + +const orgUnitNameSessionCache = new Map() + +export const __resetOrgUnitNameSessionCacheForTests = () => + orgUnitNameSessionCache.clear() + +const useOrgUnitAncestorNames = ( + distinctPathValues, + knownIdToName = EMPTY_MAP +) => { const engine = useDataEngine() const { nameProperty } = useCachedData() const ids = useMemo( @@ -25,24 +35,48 @@ const useOrgUnitAncestorNames = (distinctPathValues) => { if (!ids.length) { return } + + const buildMerged = () => { + const merged = new Map(knownIdToName) + ids.forEach((id) => { + if (!merged.has(id) && orgUnitNameSessionCache.has(id)) { + merged.set(id, orgUnitNameSessionCache.get(id)) + } + }) + return merged + } + + const idsToFetch = ids.filter( + (id) => !knownIdToName.has(id) && !orgUnitNameSessionCache.has(id) + ) + + if (!idsToFetch.length) { + setIdToName(buildMerged()) + setLoading(false) + return + } + let cancelled = false setLoading(true) - fetchOrgUnitPathDetails(engine, ids, nameProperty).then((details) => { - if (cancelled) { - return + fetchOrgUnitPathDetails(engine, idsToFetch, nameProperty).then( + (details) => { + if (cancelled) { + return + } + Object.entries(details).forEach(([id, d]) => { + orgUnitNameSessionCache.set(id, d.name) + }) + setIdToName(buildMerged()) + setLoading(false) } - setIdToName( - new Map(Object.entries(details).map(([id, d]) => [id, d.name])) - ) - setLoading(false) - }) + ) return () => { cancelled = true } // idsKey is the stable, content-based dependency // `ids` is a new array identity every render // eslint-disable-next-line react-hooks/exhaustive-deps - }, [engine, idsKey, nameProperty]) + }, [engine, idsKey, nameProperty, knownIdToName]) return { idToName, loading } } diff --git a/src/loaders/__tests__/eventLoader.spec.js b/src/loaders/__tests__/eventLoader.spec.js index 976864bef1..e01b8d5e09 100644 --- a/src/loaders/__tests__/eventLoader.spec.js +++ b/src/loaders/__tests__/eventLoader.spec.js @@ -981,4 +981,218 @@ describe('eventLoader - isExtended vs serverCluster', () => { // actually loaded (capped), not the raw analytics total. expect(result.legend.items[0].count).toBe(0) }) + + test('attaches optionSetOptionsByCode from the analytics response metadata, matched by code (not by the metaData.items key)', async () => { + const args = makeArgs({ ...baseConfig(), eventClustering: false }) + args.analyticsEngine.events.getQuery = jest.fn().mockResolvedValue({ + headers: [ + { name: 'psi', valueType: 'TEXT' }, + { name: 'de1', valueType: 'TEXT', optionSet: { id: 'os1' } }, + ], + metaData: { + items: { + optUid1: { name: 'Male', code: 'M' }, + optUid2: { name: 'Female', code: 'F' }, + prog1: { name: 'Program 1' }, // no `code` - must be ignored + }, + pager: { total: 0 }, + }, + rows: [], + }) + + const result = await eventLoader(args) + + expect(result.optionSetOptionsByCode).toEqual({ + os1: { M: 'Male', F: 'Female' }, + }) + }) + + test('does not set optionSetOptionsByCode when no header has an optionSet', async () => { + const result = await eventLoader( + makeArgs({ ...baseConfig(), eventClustering: false }) + ) + + expect(result.optionSetOptionsByCode).toBeUndefined() + }) +}) + +describe('eventLoader - extended column top-up', () => { + const NEW_DE_UID = 'dataElemUID' // 11-char valid UID + + const loadedConfig = (overrides = {}) => ({ + program: { id: 'prog1' }, + programStage: { id: 'stage1', name: 'Stage 1' }, + columns: [{ dimension: 'de1' }], + filters: [], + rows: [], + eventClustering: false, + startDate: '2024-01-01', + endDate: '2024-01-31', + isLoaded: true, + isExtended: false, + serverCluster: false, + headers: [ + { name: 'psi', valueType: 'TEXT' }, + { name: 'de1', valueType: 'NUMBER' }, + ], + data: [ + { + type: 'Feature', + id: 'event1', + geometry: { type: 'Point', coordinates: [0, 0] }, + properties: { id: 'event1', de1: 5 }, + }, + ], + ...overrides, + }) + + const makeArgs = (config, { engineQueryImpl, getQueryImpl } = {}) => ({ + config, + engine: { + query: jest.fn().mockImplementation( + engineQueryImpl ?? + (() => + Promise.resolve({ + programStage: { + programStageDataElements: [ + { + displayInReports: true, + dataElement: { + id: NEW_DE_UID, + name: 'New DE', + valueType: 'NUMBER', + }, + }, + ], + }, + })) + ), + }, + keyAnalysisDisplayProperty: 'name', + keyAnalysisDigitGroupSeparator: 'NONE', + analyticsEngine: { + request: FakeAnalyticsRequest, + events: { + getCount: jest + .fn() + .mockResolvedValue({ count: 0, extent: null }), + getQuery: + getQueryImpl ?? + jest.fn().mockResolvedValue({ + headers: [ + { name: 'psi', valueType: 'TEXT' }, + { name: NEW_DE_UID, valueType: 'NUMBER' }, + ], + metaData: { items: {}, pager: { total: 1 } }, + rows: [['event1', '10']], + }), + }, + }, + periodTypeData: undefined, + loadExtended: true, + spatialSupport: true, + }) + + test('fetches only the metadata + delta query, merges the new column by id, and never re-runs clustering count', async () => { + const args = makeArgs(loadedConfig()) + + const result = await eventLoader(args) + + expect(args.engine.query).toHaveBeenCalledTimes(1) + expect(args.analyticsEngine.events.getQuery).toHaveBeenCalledTimes(1) + expect(args.analyticsEngine.events.getCount).not.toHaveBeenCalled() + + expect(result.isExtended).toBe(true) + expect(result.headers.map((h) => h.name)).toEqual([ + 'psi', + 'de1', + NEW_DE_UID, + ]) + expect(result.data[0].properties.de1).toBe(5) // pre-existing, untouched + expect(result.data[0].properties[NEW_DE_UID]).toBe(10) // new, parsed to a number + }) + + test('does nothing further when every "display in reports" column is already present', async () => { + const args = makeArgs(loadedConfig(), { + engineQueryImpl: () => + Promise.resolve({ + programStage: { + programStageDataElements: [ + { + displayInReports: true, + dataElement: { + id: 'de1', + name: 'DE 1', + valueType: 'NUMBER', + }, + }, + ], + }, + }), + }) + + const result = await eventLoader(args) + + expect(args.analyticsEngine.events.getQuery).not.toHaveBeenCalled() + expect(result.isExtended).toBe(true) + expect(result.headers).toEqual(loadedConfig().headers) + }) + + test('falls back to a full reload when there is no prior client dataset (e.g. was previously server-clustered)', async () => { + const args = makeArgs( + loadedConfig({ headers: undefined, data: undefined }) + ) + + await eventLoader(args) + + expect(args.analyticsEngine.events.getCount).not.toHaveBeenCalled() // eventClustering is false here + expect(args.analyticsEngine.events.getQuery).toHaveBeenCalledTimes(1) + }) + + test('runs a full load, not the top-up path, on a genuine first load even if the table is already open', async () => { + const args = makeArgs( + loadedConfig({ + isLoaded: undefined, + headers: undefined, + data: undefined, + }) + ) + + await eventLoader(args) + + expect(args.analyticsEngine.events.getQuery).toHaveBeenCalledTimes(1) + }) + + test('preserves an existing combinedLayerKey across two full-path loader runs instead of regenerating it', async () => { + const overThreshold = EVENT_SERVER_CLUSTER_COUNT + 1 + const firstArgs = makeArgs( + loadedConfig({ + isLoaded: undefined, + isExtended: undefined, + serverCluster: undefined, + headers: undefined, + data: undefined, + eventClustering: true, + }) + ) + firstArgs.analyticsEngine.events.getCount = jest + .fn() + .mockResolvedValue({ count: overThreshold, extent: null }) + + const result1 = await eventLoader(firstArgs) + expect(result1.serverCluster).toBe(true) + expect(typeof result1.combinedLayerKey).toBe('string') + + const secondArgs = makeArgs({ + ...result1, + forceClientCluster: true, + }) + secondArgs.analyticsEngine.events.getCount = jest + .fn() + .mockResolvedValue({ count: overThreshold, extent: null }) + + const result2 = await eventLoader(secondArgs) + expect(result2.serverCluster).toBe(false) + expect(result2.combinedLayerKey).toBe(result1.combinedLayerKey) + }) }) diff --git a/src/loaders/__tests__/trackedEntityLoader.spec.js b/src/loaders/__tests__/trackedEntityLoader.spec.js index 6876c625aa..8e13bd7199 100644 --- a/src/loaders/__tests__/trackedEntityLoader.spec.js +++ b/src/loaders/__tests__/trackedEntityLoader.spec.js @@ -3,6 +3,7 @@ import { getAttributeProperties, applyParsedConfig, toGeoJson, + toOptionSetOptionsByCode, } from '../trackedEntityLoader.js' jest.mock('../../components/map/MapApi.js', () => ({ @@ -291,3 +292,27 @@ describe('toGeoJson', () => { expect(result[0].properties.genderUid).toBe('Male') }) }) + +describe('toOptionSetOptionsByCode', () => { + it('converts a Map<optionSetId, Map<code, name>> to a plain nested object', () => { + const optionNamesByOptionSet = new Map([ + [ + 'os1', + new Map([ + ['M', 'Male'], + ['F', 'Female'], + ]), + ], + ['os2', new Map([['Y', 'Yes']])], + ]) + + expect(toOptionSetOptionsByCode(optionNamesByOptionSet)).toEqual({ + os1: { M: 'Male', F: 'Female' }, + os2: { Y: 'Yes' }, + }) + }) + + it('returns an empty object for an empty map', () => { + expect(toOptionSetOptionsByCode(new Map())).toEqual({}) + }) +}) diff --git a/src/loaders/eventLoader.js b/src/loaders/eventLoader.js index 44ef4a31a7..bcb37e402e 100644 --- a/src/loaders/eventLoader.js +++ b/src/loaders/eventLoader.js @@ -24,7 +24,11 @@ import { import { cssColor, getContrastColor } from '../util/colors.js' import { parseJsonConfig } from '../util/config.js' import { loadEventCoordinateFieldName } from '../util/coordinatesName.js' -import { getAnalyticsRequest, loadData } from '../util/event.js' +import { + getAnalyticsRequest, + getEventColumns, + loadData, +} from '../util/event.js' import { getBounds, getContainingOrgUnit, @@ -128,18 +132,35 @@ const eventLoader = async ({ ? 'displayName' : 'displayShortName' + // A layer already loaded once only needs the incremental columns + const isTopUpLoad = + loadExtended && + config.isLoaded && + !config.isExtended && + !config.serverCluster && + Array.isArray(config.headers) + try { - await loadEventLayer({ - config, - engine, - displayNameProp, - keyAnalysisDisplayProperty, - userOrgUnitIdsByKeyword, - analyticsEngine, - periodTypeData, - loadExtended, - spatialSupport, - }) + if (isTopUpLoad) { + await loadExtendedEventColumns({ + config, + engine, + displayNameProp, + analyticsEngine, + }) + } else { + await loadEventLayer({ + config, + engine, + displayNameProp, + keyAnalysisDisplayProperty, + userOrgUnitIdsByKeyword, + analyticsEngine, + periodTypeData, + loadExtended, + spatialSupport, + }) + } } catch (e) { if ( e.details?.httpStatusCode === 403 || @@ -162,6 +183,93 @@ const eventLoader = async ({ return config } +// Merges only the new "display in reports" columns +const loadExtendedEventColumns = async ({ + config, + engine, + displayNameProp, + analyticsEngine, +}) => { + const { programStage } = config + + const displayColumns = await getEventColumns( + { programStage }, + { engine, nameProperty: displayNameProp } + ) + + const newColumns = displayColumns.filter( + (col) => !config.headers.some((header) => header.name === col.dimension) + ) + + if (!newColumns.length) { + config.isExtended = true + return + } + + const deltaRequest = await getAnalyticsRequest( + { + ...config, + columns: newColumns, + styleDataItem: undefined, + labelDataItem: undefined, + isExtended: false, // the delta is resolved here - don't recurse + }, + { analyticsEngine, nameProperty: displayNameProp, engine } + ) + + const { + data: deltaData, + dataWithoutCoords: deltaDataWithoutCoords, + response, + } = await loadData({ + request: deltaRequest, + config: { ...config, outputIdScheme: 'ID' }, + analyticsEngine, + }) + + const deltaById = new Map( + [...deltaData, ...(deltaDataWithoutCoords ?? [])].map((f) => [ + f.id, + f.properties, + ]) + ) + const mergeDelta = (features) => + features.map((f) => ({ + ...f, + properties: { ...f.properties, ...(deltaById.get(f.id) ?? {}) }, + })) + + config.data = mergeDelta(config.data) + if (config.dataWithoutCoords?.length) { + config.dataWithoutCoords = mergeDelta(config.dataWithoutCoords) + } + + const newHeaders = response.headers.filter((header) => + newColumns.some((col) => col.dimension === header.name) + ) + config.headers = [...config.headers, ...newHeaders] + + const numericNewHeaders = newHeaders.filter( + (header) => + isValidUid(header.name) && + numberValueTypes.includes(header.valueType) && + !header.optionSet + ) + if (numericNewHeaders.length) { + config.data = config.data.map((d) => { + const newD = { ...d } + numericNewHeaders.forEach((header) => { + newD.properties[header.name] = parseWithSeparator( + d.properties[header.name] + ) + }) + return newD + }) + } + + config.isExtended = true +} + const loadEventLayer = async ({ config, engine, @@ -224,7 +332,8 @@ const loadEventLayer = async ({ if (dataTableColumnConfig) { config.dataTableColumnConfig = dataTableColumnConfig } - config.combinedLayerKey = combinedLayerKey ?? generateUid() + config.combinedLayerKey = + combinedLayerKey ?? config.combinedLayerKey ?? generateUid() if (config.noDataColor) { config.noDataLegend = { ...noDataLegendFromConfig, @@ -399,6 +508,24 @@ const loadEventLayer = async ({ config.headers = response.headers + const optionSetIds = [ + ...new Set( + config.headers + .map((header) => header.optionSet?.id) + .filter(Boolean) + ), + ] + if (optionSetIds.length) { + const optionCodeToName = Object.fromEntries( + Object.values(response.metaData.items) + .filter((item) => item.code) + .map((item) => [item.code, item.name]) + ) + config.optionSetOptionsByCode = Object.fromEntries( + optionSetIds.map((id) => [id, optionCodeToName]) + ) + } + const numericDataItemHeaders = config.headers.filter( (header) => isValidUid(header.name) && diff --git a/src/loaders/trackedEntityLoader.js b/src/loaders/trackedEntityLoader.js index 6bf875be7c..5c9f201422 100644 --- a/src/loaders/trackedEntityLoader.js +++ b/src/loaders/trackedEntityLoader.js @@ -215,6 +215,14 @@ const fetchOptionSetIdByAttribute = async ( ) } +export const toOptionSetOptionsByCode = (optionNamesByOptionSet) => + Object.fromEntries( + [...optionNamesByOptionSet].map(([id, codeMap]) => [ + id, + Object.fromEntries(codeMap), + ]) + ) + const fetchOptionNamesByOptionSet = async (engine, optionSetIds) => { const entries = await Promise.all( optionSetIds.map(async (id) => { @@ -508,6 +516,9 @@ const trackedEntityLoader = async ({ name, data, headers, + optionSetOptionsByCode: toOptionSetOptionsByCode( + optionNamesByOptionSet + ), keyAnalysisDigitGroupSeparator, relationships, secondaryData, diff --git a/src/util/__tests__/orgUnits.spec.js b/src/util/__tests__/orgUnits.spec.js index e916a0c0f4..3673d49297 100644 --- a/src/util/__tests__/orgUnits.spec.js +++ b/src/util/__tests__/orgUnits.spec.js @@ -14,6 +14,7 @@ import { fetchOrgUnitPaths, fetchOrgUnitPathDetails, attachOrgUnitPaths, + buildKnownOrgUnitNames, } from '../orgUnits.js' describe('getUserOrgUnitIdsByKeyword', () => { @@ -193,6 +194,71 @@ describe('attachOrgUnitPaths', () => { }) }) +describe('buildKnownOrgUnitNames', () => { + it("seeds a row's own id/name pair", () => { + const rows = [{ id: 'ou1', name: 'Facility 1' }] + expect(buildKnownOrgUnitNames(rows)).toEqual( + new Map([['ou1', 'Facility 1']]) + ) + }) + + it("seeds a row's parentId/parentName pair alongside its own", () => { + const rows = [ + { + id: 'ou1', + name: 'Facility 1', + parentId: 'region1', + parentName: 'Region 1', + }, + ] + expect(buildKnownOrgUnitNames(rows)).toEqual( + new Map([ + ['ou1', 'Facility 1'], + ['region1', 'Region 1'], + ]) + ) + }) + + it('ignores an incomplete id/name or parentId/parentName pair', () => { + const rows = [ + { id: 'ou1', name: null }, + { id: null, name: 'Orphan name' }, + { id: 'ou2', name: 'Facility 2', parentId: 'region1' }, + ] + expect(buildKnownOrgUnitNames(rows)).toEqual( + new Map([['ou2', 'Facility 2']]) + ) + }) + + it('dedupes the same id across multiple rows', () => { + const rows = [ + { + id: 'ou1', + name: 'Facility 1', + parentId: 'region1', + parentName: 'Region 1', + }, + { id: 'region1', name: 'Region 1' }, + ] + expect(buildKnownOrgUnitNames(rows)).toEqual( + new Map([ + ['ou1', 'Facility 1'], + ['region1', 'Region 1'], + ]) + ) + }) + + it('contributes nothing for an Event/Tracked-Entity-shaped row (id is the event/TEI id, no name for the referenced org unit)', () => { + const rows = [{ id: 'event1', orgUnit: 'ou1' }] + expect(buildKnownOrgUnitNames(rows)).toEqual(new Map()) + }) + + it('returns an empty map for no rows', () => { + expect(buildKnownOrgUnitNames([])).toEqual(new Map()) + expect(buildKnownOrgUnitNames()).toEqual(new Map()) + }) +}) + describe('getStyledOrgUnits', () => { it('should return styled features and legend for facility layer', () => { const features = [ diff --git a/src/util/orgUnits.js b/src/util/orgUnits.js index 7da675f959..421b3e261c 100644 --- a/src/util/orgUnits.js +++ b/src/util/orgUnits.js @@ -347,6 +347,19 @@ export const fetchOrgUnitPaths = async (engine, ids) => { return results.flatMap((r) => r.organisationUnits.organisationUnits ?? []) } +export const buildKnownOrgUnitNames = (rows = []) => { + const map = new Map() + rows.forEach((row) => { + if (row?.id != null && row?.name != null) { + map.set(row.id, row.name) + } + if (row?.parentId != null && row?.parentName != null) { + map.set(row.parentId, row.parentName) + } + }) + return map +} + export const fetchOrgUnitPathDetails = async (engine, ids, nameProperty) => { const results = await fetchInBatches(engine, ids, { query: ORG_UNIT_PATH_DETAILS_QUERY, From f37cb9efc0a7df69b79d8d302d588a841e7164af Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Wed, 5 Aug 2026 22:22:22 +0200 Subject: [PATCH 202/205] chore: fix tests --- cypress/integration/dataTable.cy.js | 54 +++++++------- src/components/edit/LayerEdit.jsx | 13 ++-- .../layers/overlays/OverlayCard.jsx | 70 +++++++++---------- src/loaders/eventLoader.js | 2 +- 4 files changed, 70 insertions(+), 69 deletions(-) diff --git a/cypress/integration/dataTable.cy.js b/cypress/integration/dataTable.cy.js index cfc3698411..ee4468fac0 100644 --- a/cypress/integration/dataTable.cy.js +++ b/cypress/integration/dataTable.cy.js @@ -83,7 +83,7 @@ describe('data table', () => { // Check number of columns cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-datatablecellhead') - .should('have.length', 10) + .should('have.length', 7) // Filter by Org unit cy.getByDataTest('data-table-column-filter-search-Org unit') @@ -97,8 +97,8 @@ describe('data table', () => { .should('have.length', 7) // Confirm that the sort order is initially ascending by Name - checkTableCell({ row: 0, column: 2, expectedContent: 'Bargbe' }) - checkTableCell({ row: 6, column: 2, expectedContent: 'Upper Bambara' }) + checkTableCell({ row: 0, column: 1, expectedContent: 'Bargbe' }) + checkTableCell({ row: 6, column: 1, expectedContent: 'Upper Bambara' }) // Sort by name (descending) cy.getByDataTest('data-table-column-sort-button-Org unit').click() @@ -110,8 +110,8 @@ describe('data table', () => { cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') // Confirm that the rows are sorted by Name descending - checkTableCell({ row: 0, column: 2, expectedContent: 'Upper Bambara' }) - checkTableCell({ row: 6, column: 2, expectedContent: 'Bargbe' }) + checkTableCell({ row: 0, column: 1, expectedContent: 'Upper Bambara' }) + checkTableCell({ row: 6, column: 1, expectedContent: 'Bargbe' }) // Filter by Value (numeric) cy.getByDataTest('data-table-column-filter-search-Value') @@ -131,8 +131,8 @@ describe('data table', () => { cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') // Check that the rows are sorted by Value ascending - checkTableCell({ row: 0, column: 5, expectedContent: '35' }) - checkTableCell({ row: 4, column: 5, expectedContent: '76' }) + checkTableCell({ row: 0, column: 3, expectedContent: '35' }) + checkTableCell({ row: 4, column: 3, expectedContent: '76' }) // Right-click a row and select "View profile" cy.getByDataTest('bottom-panel') @@ -196,7 +196,7 @@ describe('data table', () => { // Check number of columns cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-datatablecellhead') - .should('have.length', 13) + .should('have.length', 9) cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-datatablecellhead') @@ -210,8 +210,8 @@ describe('data table', () => { .type(`${ouName}{enter}`) // Check that all the rows have Org unit Moyowa - checkTableCell({ row: 0, column: 3, expectedContent: ouName }) - checkTableCell({ row: 2, column: 3, expectedContent: ouName }) + checkTableCell({ row: 0, column: 1, expectedContent: ouName }) + checkTableCell({ row: 2, column: 1, expectedContent: ouName }) cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-tablebody') @@ -256,8 +256,8 @@ describe('data table', () => { // Confirm that the rows are sorted by Age in years ascending // (the first click on a new column always sorts ascending) - checkTableCell({ row: 0, column: 10, expectedContent: '6' }) - checkTableCell({ row: 1, column: 10, expectedContent: '32' }) + checkTableCell({ row: 0, column: 7, expectedContent: '6' }) + checkTableCell({ row: 1, column: 7, expectedContent: '32' }) // Right-click a row: Event layers have no profile to view cy.getByDataTest('bottom-panel') @@ -318,7 +318,7 @@ describe('data table', () => { cy.getByDataTest('layers-toggle-button').click() // Confirm that the sort order is initially ascending by Name - checkTableCell({ row: 0, column: 2, expectedContent: 'Bendu CHC' }) + checkTableCell({ row: 0, column: 1, expectedContent: 'Bendu CHC' }) // First click on a new column always sorts ascending cy.getByDataTest('data-table-column-sort-button-Value').click() @@ -327,15 +327,15 @@ describe('data table', () => { cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') // Check that first row has Tihun CHC with value 28.63 - checkTableCell({ row: 0, column: 2, expectedContent: 'Tihun CHC' }) - checkTableCell({ row: 0, column: 5, expectedContent: '28.63' }) + checkTableCell({ row: 0, column: 1, expectedContent: 'Tihun CHC' }) + checkTableCell({ row: 0, column: 3, expectedContent: '28.63' }) // Check that row 5 has Gbamgbama CHC with value 117.98 - checkTableCell({ row: 5, column: 2, expectedContent: 'Gbamgbama CHC' }) - checkTableCell({ row: 5, column: 5, expectedContent: '117.98' }) + checkTableCell({ row: 5, column: 1, expectedContent: 'Gbamgbama CHC' }) + checkTableCell({ row: 5, column: 3, expectedContent: '117.98' }) // Check that row 6 has no value (undefined) - checkTableCell({ row: 6, column: 5, expectedContent: '' }) + checkTableCell({ row: 6, column: 3, expectedContent: '' }) // Sort descending by Value cy.getByDataTest('data-table-column-sort-button-Value').click() @@ -343,13 +343,13 @@ describe('data table', () => { // Reset scroll position after sorting - see comment above cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') - checkTableCell({ row: 0, column: 2, expectedContent: 'Gbamgbama CHC' }) - checkTableCell({ row: 0, column: 5, expectedContent: '117.98' }) + checkTableCell({ row: 0, column: 1, expectedContent: 'Gbamgbama CHC' }) + checkTableCell({ row: 0, column: 3, expectedContent: '117.98' }) - checkTableCell({ row: 5, column: 2, expectedContent: 'Tihun CHC' }) - checkTableCell({ row: 5, column: 5, expectedContent: '28.63' }) + checkTableCell({ row: 5, column: 1, expectedContent: 'Tihun CHC' }) + checkTableCell({ row: 5, column: 3, expectedContent: '28.63' }) - checkTableCell({ row: 6, column: 5, expectedContent: '' }) + checkTableCell({ row: 6, column: 3, expectedContent: '' }) // Third click on the same column cycles back to natural (unsorted) // order - there's no dedicated Index column/button any more @@ -359,7 +359,7 @@ describe('data table', () => { cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') // Check that row 0 range value is empty - checkTableCell({ row: 0, column: 7, expectedContent: '' }) + checkTableCell({ row: 0, column: 5, expectedContent: '' }) // Sort by range, which is a string cy.getByDataTest('data-table-column-sort-button-Range').click() @@ -368,12 +368,12 @@ describe('data table', () => { cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') // Check that row 0 range value has value '0-40' - checkTableCell({ row: 0, column: 7, expectedContent: '0 – 40' }) + checkTableCell({ row: 0, column: 5, expectedContent: '0 – 40' }) // Check that row 5 range value has value '90 - 120' - checkTableCell({ row: 5, column: 7, expectedContent: '90 – 120' }) + checkTableCell({ row: 5, column: 5, expectedContent: '90 – 120' }) // Check that row 6 range value is empty - checkTableCell({ row: 6, column: 7, expectedContent: '' }) + checkTableCell({ row: 6, column: 5, expectedContent: '' }) }) }) diff --git a/src/components/edit/LayerEdit.jsx b/src/components/edit/LayerEdit.jsx index e40697433c..8ecfd76415 100644 --- a/src/components/edit/LayerEdit.jsx +++ b/src/components/edit/LayerEdit.jsx @@ -108,6 +108,13 @@ const LayerEdit = ({ layer, addLayer, updateLayer, cancelLayer }) => { ? i18n.t('Configure reference org units') : editOrAddTitle + const addOrUpdateLabel = layer.id + ? i18n.t('Update layer') + : i18n.t('Add layer') + const submitButtonLabel = isReferenceLayer + ? i18n.t('Update reference') + : addOrUpdateLabel + return ( <Modal position="top" dataTest="layeredit" fluid onClose={cancelLayer}> <ModalTitle>{title}</ModalTitle> @@ -135,11 +142,7 @@ const LayerEdit = ({ layer, addLayer, updateLayer, cancelLayer }) => { onClick={onValidateLayer} dataTest="layeredit-addbtn" > - {isReferenceLayer - ? i18n.t('Update reference') - : layer.id - ? i18n.t('Update layer') - : i18n.t('Add layer')} + {submitButtonLabel} </Button> </ButtonStrip> </ModalActions> diff --git a/src/components/layers/overlays/OverlayCard.jsx b/src/components/layers/overlays/OverlayCard.jsx index ff3844a175..65d66a659d 100644 --- a/src/components/layers/overlays/OverlayCard.jsx +++ b/src/components/layers/overlays/OverlayCard.jsx @@ -37,6 +37,38 @@ import DataDownloadDialog from '../download/DataDownloadDialog.jsx' import LayerCard from '../LayerCard.jsx' import styles from './styles/OverlayCard.module.css' +const getCardContent = ({ loadError, legend }) => { + if (loadError) { + return ( + <div data-test="load-error-noticebox" className={styles.loadError}> + <LegendAlert + alert={{ code: ERROR_CRITICAL, message: loadError }} + /> + </div> + ) + } + return ( + legend && ( + <div className={styles.legend}> + <Legend {...legend} /> + </div> + ) + ) +} + +const getOpenAsHandler = (layer, baseUrl, setCurrentAO) => async (type) => { + const currentAO = getAnalyticalObjectFromThematicLayer(layer) + + // Store AO in user data store + await setCurrentAO(currentAO) + + // Open it in another app + window.open( + `${baseUrl}/${APP_URLS[type]}/#/currentAnalyticalObject`, + '_blank' + ) +} + const OverlayCard = ({ layer, editLayer, @@ -72,28 +104,6 @@ const OverlayCard = ({ const canOpenAs = OPEN_AS_LAYER_TYPES.includes(layerType) const hasDataFilters = Object.keys(dataFilters ?? {}).length > 0 - const getCardContent = () => { - if (loadError) { - return ( - <div - data-test="load-error-noticebox" - className={styles.loadError} - > - <LegendAlert - alert={{ code: ERROR_CRITICAL, message: loadError }} - /> - </div> - ) - } - return ( - legend && ( - <div className={styles.legend}> - <Legend {...legend} /> - </div> - ) - ) - } - return ( <> <LayerCard @@ -132,24 +142,12 @@ const OverlayCard = ({ } openAs={ canOpenAs - ? async (type) => { - const currentAO = - getAnalyticalObjectFromThematicLayer(layer) - - // Store AO in user data store - await set(currentAO) - - // Open it in another app - window.open( - `${baseUrl}/${APP_URLS[type]}/#/currentAnalyticalObject`, - '_blank' - ) - } + ? getOpenAsHandler(layer, baseUrl, set) : undefined } hasError={!!loadError} > - {getCardContent()} + {getCardContent({ loadError, legend })} </LayerCard> {showDataDownloadDialog && ( <DataDownloadDialog diff --git a/src/loaders/eventLoader.js b/src/loaders/eventLoader.js index bcb37e402e..4c8110b062 100644 --- a/src/loaders/eventLoader.js +++ b/src/loaders/eventLoader.js @@ -236,7 +236,7 @@ const loadExtendedEventColumns = async ({ const mergeDelta = (features) => features.map((f) => ({ ...f, - properties: { ...f.properties, ...(deltaById.get(f.id) ?? {}) }, + properties: { ...f.properties, ...deltaById.get(f.id) }, })) config.data = mergeDelta(config.data) From 88751212de8bf998d7d0f3a1821c36786aeb128a Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Thu, 6 Aug 2026 14:29:16 +0200 Subject: [PATCH 203/205] chore: fix sonarqube issue --- i18n/en.pot | 10 ++--- .../layers/overlays/OverlayCard.jsx | 45 ++++++++++--------- 2 files changed, 29 insertions(+), 26 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 425bcd5ca8..867f70acf2 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-08-04T15:05:43.776Z\n" -"PO-Revision-Date: 2026-08-04T15:05:43.776Z\n" +"POT-Creation-Date: 2026-08-05T20:22:57.021Z\n" +"PO-Revision-Date: 2026-08-05T20:22:57.022Z\n" msgid "2020" msgstr "2020" @@ -617,15 +617,15 @@ msgstr "Edit {{name}} layer" msgid "Add new {{name}} layer" msgstr "Add new {{name}} layer" -msgid "Update reference" -msgstr "Update reference" - msgid "Update layer" msgstr "Update layer" msgid "Add layer" msgstr "Add layer" +msgid "Update reference" +msgstr "Update reference" + msgid "Area statistics (popups and data table)" msgstr "Area statistics (popups and data table)" diff --git a/src/components/layers/overlays/OverlayCard.jsx b/src/components/layers/overlays/OverlayCard.jsx index 65d66a659d..d472337760 100644 --- a/src/components/layers/overlays/OverlayCard.jsx +++ b/src/components/layers/overlays/OverlayCard.jsx @@ -69,6 +69,14 @@ const getOpenAsHandler = (layer, baseUrl, setCurrentAO) => async (type) => { ) } +const getTitle = (isLoaded, name) => + isLoaded ? name : i18n.t('Loading layer') + '...' + +const getSubtitle = (isLoaded, legend) => + isLoaded && legend?.period ? legend.period : null + +const ifAllowed = (allowed, handler) => (allowed ? handler : undefined) + const OverlayCard = ({ layer, editLayer, @@ -108,22 +116,20 @@ const OverlayCard = ({ <> <LayerCard layer={layer} - title={isLoaded ? name : i18n.t('Loading layer') + '...'} - subtitle={ - isLoaded && legend && legend.period ? legend.period : null - } + title={getTitle(isLoaded, name)} + subtitle={getSubtitle(isLoaded, legend)} opacity={opacity} isOverlay={true} isExpanded={isExpanded} isVisible={isVisible} toggleExpand={() => toggleLayerExpand(id)} - onEdit={canEdit ? () => editLayer(layer) : undefined} - toggleDataTable={ - canToggleDataTable ? () => toggleDataTable(id) : undefined - } - onClearDataFilters={ - hasDataFilters ? () => clearDataFilters(id) : undefined - } + onEdit={ifAllowed(canEdit, () => editLayer(layer))} + toggleDataTable={ifAllowed(canToggleDataTable, () => + toggleDataTable(id) + )} + onClearDataFilters={ifAllowed(hasDataFilters, () => + clearDataFilters(id) + )} toggleLayerVisibility={() => toggleLayerVisibility(id)} onOpacityChange={(newOpacity) => changeLayerOpacity(id, newOpacity) @@ -135,16 +141,13 @@ const OverlayCard = ({ msg: i18n.t('{{- name}} deleted.', { name }), }) }} - downloadData={ - canDownload - ? () => setShowDataDownloadDialog(true) - : undefined - } - openAs={ - canOpenAs - ? getOpenAsHandler(layer, baseUrl, set) - : undefined - } + downloadData={ifAllowed(canDownload, () => + setShowDataDownloadDialog(true) + )} + openAs={ifAllowed( + canOpenAs, + getOpenAsHandler(layer, baseUrl, set) + )} hasError={!!loadError} > {getCardContent({ loadError, legend })} From f49e3c7dcc593b0226283523f84c018a78b796a3 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Mon, 24 Aug 2026 09:11:11 +0200 Subject: [PATCH 204/205] fix: make org-unit and date hierarchy filters work in the combined table --- src/components/datatable/DataTable.jsx | 1 - .../datatable/DateGroupFilterInput.jsx | 16 +- src/components/datatable/FilterInput.jsx | 14 +- .../datatable/OrgUnitGroupFilterInput.jsx | 32 ++-- .../__tests__/DateGroupFilterInput.spec.jsx | 157 ++++++------------ .../datatable/__tests__/FilterInput.spec.jsx | 1 - .../OrgUnitGroupFilterInput.spec.jsx | 126 +++++--------- .../datatable/useGroupFilterInput.js | 24 +-- 8 files changed, 134 insertions(+), 237 deletions(-) diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index d8b0d10602..04168f8a03 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -429,7 +429,6 @@ const Table = ({ filter={ isFilterable(dataKey, type) && ( <FilterInput - layerId={activeLayerId} type={type} dataKey={dataKey} name={name} diff --git a/src/components/datatable/DateGroupFilterInput.jsx b/src/components/datatable/DateGroupFilterInput.jsx index 3c7131c764..dda9c29aba 100644 --- a/src/components/datatable/DateGroupFilterInput.jsx +++ b/src/components/datatable/DateGroupFilterInput.jsx @@ -1,7 +1,6 @@ import i18n from '@dhis2/d2-i18n' import PropTypes from 'prop-types' import React, { useCallback } from 'react' -import { setDataFilter } from '../../actions/dataFilters.js' import { DATE_GROUPS_GRANULARITY } from '../../constants/dataTable.js' import { buildDateGroupTree, @@ -29,13 +28,12 @@ const parseFilterValue = (filterValue) => ({ const sanitizeInput = (value) => value.replace(DATE_INPUT_DISALLOWED, '') -const commitSearch = (text, { dispatch, layerId, dataKey }) => - dispatch(setDataFilter(layerId, dataKey, text)) +const commitSearch = (text, { onChange }) => onChange(text) const DateGroupFilterInput = ({ - dataKey, name, - layerId, + onChange, + onClear, filterValue, options, type, @@ -46,8 +44,8 @@ const DateGroupFilterInput = ({ ) const groupFilter = useGroupFilterInput({ - dataKey, - layerId, + onChange, + onClear, filterValue, options, granularity: DATE_GROUPS_GRANULARITY, @@ -70,17 +68,17 @@ const DateGroupFilterInput = ({ } DateGroupFilterInput.propTypes = { - dataKey: PropTypes.string.isRequired, name: PropTypes.string.isRequired, options: PropTypes.arrayOf(PropTypes.shape({ value: PropTypes.string })) .isRequired, type: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + onClear: PropTypes.func.isRequired, filterValue: PropTypes.oneOfType([ PropTypes.string, PropTypes.arrayOf(PropTypes.string), PropTypes.object, ]), - layerId: PropTypes.string, } export default DateGroupFilterInput diff --git a/src/components/datatable/FilterInput.jsx b/src/components/datatable/FilterInput.jsx index f7fa428442..a05337e8e3 100644 --- a/src/components/datatable/FilterInput.jsx +++ b/src/components/datatable/FilterInput.jsx @@ -581,7 +581,6 @@ OptionSetSearchableFilter.propTypes = { } const FilterInput = React.memo(function FilterInput({ - layerId, type, dataKey, name, @@ -600,9 +599,9 @@ const FilterInput = React.memo(function FilterInput({ if (isDateType) { return ( <DateGroupFilterInput - dataKey={dataKey} name={name} - layerId={layerId} + onChange={onChange} + onClear={onClear} filterValue={filterValue} options={options ?? []} type={type} @@ -613,9 +612,9 @@ const FilterInput = React.memo(function FilterInput({ if (type === TYPE_ORG_UNIT) { return ( <OrgUnitGroupFilterInput - dataKey={dataKey} name={name} - layerId={layerId} + onChange={onChange} + onClear={onClear} filterValue={filterValue} options={options ?? []} idToName={orgUnitIdToName} @@ -659,19 +658,18 @@ FilterInput.propTypes = { dataKey: PropTypes.string.isRequired, name: PropTypes.string.isRequired, type: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + onClear: PropTypes.func.isRequired, filterValue: PropTypes.oneOfType([ PropTypes.string, PropTypes.arrayOf(PropTypes.string), PropTypes.object, ]), - layerId: PropTypes.string, optionSetId: PropTypes.string, options: PropTypes.arrayOf(PropTypes.shape({ value: PropTypes.string })), orgUnitIdToName: PropTypes.instanceOf(Map), renderer: PropTypes.string, resolvedOptionNames: PropTypes.object, - onChange: PropTypes.func, - onClear: PropTypes.func, } export default FilterInput diff --git a/src/components/datatable/OrgUnitGroupFilterInput.jsx b/src/components/datatable/OrgUnitGroupFilterInput.jsx index 91acceb5b5..b658831854 100644 --- a/src/components/datatable/OrgUnitGroupFilterInput.jsx +++ b/src/components/datatable/OrgUnitGroupFilterInput.jsx @@ -1,7 +1,6 @@ import i18n from '@dhis2/d2-i18n' import PropTypes from 'prop-types' import React, { useCallback } from 'react' -import { setDataFilter } from '../../actions/dataFilters.js' import { ORG_UNIT_GROUPS_GRANULARITY } from '../../constants/dataTable.js' import { isOrgUnitGroupFilter } from '../../util/filter.js' import { @@ -36,9 +35,9 @@ const parseFilterValue = (filterValue) => ({ }) const OrgUnitGroupFilterInput = ({ - dataKey, name, - layerId, + onChange, + onClear, filterValue, options, idToName, @@ -50,10 +49,7 @@ const OrgUnitGroupFilterInput = ({ ) const commitSearch = useCallback( - ( - text, - { tree, dispatch, layerId: layerIdArg, dataKey: dataKeyArg } - ) => { + (text, { tree, onChange: onChangeArg }) => { const matches = getOrgUnitSearchMatches( tree, text.toLowerCase(), @@ -66,21 +62,19 @@ const OrgUnitGroupFilterInput = ({ .map((key) => nodeByKey.get(key)) .filter(Boolean) .map((node) => node.prefix) - dispatch( - setDataFilter(layerIdArg, dataKeyArg, { - granularity: ORG_UNIT_GROUPS_GRANULARITY, - prefixes: matchedPrefixes, - searchDerived: true, - searchText: text, - }) - ) + onChangeArg({ + granularity: ORG_UNIT_GROUPS_GRANULARITY, + prefixes: matchedPrefixes, + searchDerived: true, + searchText: text, + }) }, [idToName] ) const groupFilter = useGroupFilterInput({ - dataKey, - layerId, + onChange, + onClear, filterValue, options, granularity: ORG_UNIT_GROUPS_GRANULARITY, @@ -102,17 +96,17 @@ const OrgUnitGroupFilterInput = ({ } OrgUnitGroupFilterInput.propTypes = { - dataKey: PropTypes.string.isRequired, idToName: PropTypes.instanceOf(Map).isRequired, name: PropTypes.string.isRequired, options: PropTypes.arrayOf(PropTypes.shape({ value: PropTypes.string })) .isRequired, + onChange: PropTypes.func.isRequired, + onClear: PropTypes.func.isRequired, filterValue: PropTypes.oneOfType([ PropTypes.string, PropTypes.arrayOf(PropTypes.string), PropTypes.object, ]), - layerId: PropTypes.string, } export default OrgUnitGroupFilterInput diff --git a/src/components/datatable/__tests__/DateGroupFilterInput.spec.jsx b/src/components/datatable/__tests__/DateGroupFilterInput.spec.jsx index f3b50feaff..643e89ffb6 100644 --- a/src/components/datatable/__tests__/DateGroupFilterInput.spec.jsx +++ b/src/components/datatable/__tests__/DateGroupFilterInput.spec.jsx @@ -1,12 +1,6 @@ import { render, fireEvent, screen } from '@testing-library/react' import React from 'react' -import { Provider } from 'react-redux' import { VirtuosoMockContext } from 'react-virtuoso' -import configureMockStore from 'redux-mock-store' -import { - DATA_FILTER_SET, - DATA_FILTER_CLEAR, -} from '../../../constants/actionTypes.js' import { SENTINEL_ANY_VALUE, SENTINEL_NO_VALUE, @@ -17,8 +11,6 @@ import { } from '../../../constants/dataTable.js' import DateGroupFilterInput from '../DateGroupFilterInput.jsx' -const mockStore = configureMockStore() - const DATETIME_VALUES = [ { value: '2023-05-15 09:00:00.0' }, { value: '2023-05-15 14:00:00.0' }, @@ -26,24 +18,23 @@ const DATETIME_VALUES = [ ] const renderDateGroupFilter = (props) => { - const store = mockStore({}) + const onChange = jest.fn() + const onClear = jest.fn() const result = render( - <Provider store={store}> - <VirtuosoMockContext.Provider - value={{ viewportHeight: 300, itemHeight: 28 }} - > - <DateGroupFilterInput - dataKey="eventdate" - name="Event date" - layerId="layer1" - type={TYPE_DATETIME} - options={DATETIME_VALUES} - {...props} - /> - </VirtuosoMockContext.Provider> - </Provider> + <VirtuosoMockContext.Provider + value={{ viewportHeight: 300, itemHeight: 28 }} + > + <DateGroupFilterInput + name="Event date" + onChange={onChange} + onClear={onClear} + type={TYPE_DATETIME} + options={DATETIME_VALUES} + {...props} + /> + </VirtuosoMockContext.Provider> ) - return { ...result, store } + return { ...result, onChange, onClear } } const getInput = () => @@ -92,24 +83,19 @@ describe('DateGroupFilterInput - default (collapsed) tree', () => { }) }) -describe('DateGroupFilterInput - selection dispatches', () => { - test('checking a year dispatches the full date-group filter shape', () => { - const { store } = renderDateGroupFilter() +describe('DateGroupFilterInput - selection calls onChange/onClear', () => { + test('checking a year calls onChange with the full date-group filter shape', () => { + const { onChange } = renderDateGroupFilter() openPopover() fireEvent.click(screen.getByLabelText('2023')) - expect(store.getActions()).toContainEqual({ - type: DATA_FILTER_SET, - layerId: 'layer1', - fieldId: 'eventdate', - filter: { - granularity: DATE_GROUPS_GRANULARITY, - prefixes: ['2023'], - }, + expect(onChange).toHaveBeenCalledWith({ + granularity: DATE_GROUPS_GRANULARITY, + prefixes: ['2023'], }) }) - test('unchecking the only selected prefix dispatches DATA_FILTER_CLEAR', () => { - const { store } = renderDateGroupFilter({ + test('unchecking the only selected prefix calls onClear', () => { + const { onClear } = renderDateGroupFilter({ filterValue: { granularity: DATE_GROUPS_GRANULARITY, prefixes: ['2023'], @@ -117,15 +103,11 @@ describe('DateGroupFilterInput - selection dispatches', () => { }) openPopover() fireEvent.click(screen.getByLabelText('2023')) - expect(store.getActions()).toContainEqual({ - type: DATA_FILTER_CLEAR, - layerId: 'layer1', - fieldId: 'eventdate', - }) + expect(onClear).toHaveBeenCalled() }) test('checking a month drops the now-redundant year-level ancestor selection scenario in reverse: checking a day under an unrelated selected month keeps both', () => { - const { store } = renderDateGroupFilter({ + const { onChange } = renderDateGroupFilter({ filterValue: { granularity: DATE_GROUPS_GRANULARITY, prefixes: ['2024'], @@ -134,14 +116,9 @@ describe('DateGroupFilterInput - selection dispatches', () => { openPopover() fireEvent.click(screen.getByLabelText('Expand 2023')) fireEvent.click(screen.getByLabelText('May')) - expect(store.getActions()).toContainEqual({ - type: DATA_FILTER_SET, - layerId: 'layer1', - fieldId: 'eventdate', - filter: { - granularity: DATE_GROUPS_GRANULARITY, - prefixes: ['2024', '2023-05'], - }, + expect(onChange).toHaveBeenCalledWith({ + granularity: DATE_GROUPS_GRANULARITY, + prefixes: ['2024', '2023-05'], }) }) }) @@ -193,8 +170,8 @@ describe('DateGroupFilterInput - "Any value" / "No value"', () => { expect(screen.queryByLabelText('No value')).not.toBeInTheDocument() }) - test('checking "Any value" dispatches the sentinel and clears prior selections', () => { - const { store } = renderDateGroupFilter({ + test('checking "Any value" calls onChange with the sentinel and clears prior selections', () => { + const { onChange } = renderDateGroupFilter({ options: [...DATETIME_VALUES, { value: SENTINEL_NO_VALUE }], filterValue: { granularity: DATE_GROUPS_GRANULARITY, @@ -203,19 +180,14 @@ describe('DateGroupFilterInput - "Any value" / "No value"', () => { }) openPopover() fireEvent.click(screen.getByLabelText('Any value')) - expect(store.getActions()).toContainEqual({ - type: DATA_FILTER_SET, - layerId: 'layer1', - fieldId: 'eventdate', - filter: { - granularity: DATE_GROUPS_GRANULARITY, - prefixes: [SENTINEL_ANY_VALUE], - }, + expect(onChange).toHaveBeenCalledWith({ + granularity: DATE_GROUPS_GRANULARITY, + prefixes: [SENTINEL_ANY_VALUE], }) }) test('checking "No value" preserves an existing tree selection alongside it', () => { - const { store } = renderDateGroupFilter({ + const { onChange } = renderDateGroupFilter({ options: [...DATETIME_VALUES, { value: SENTINEL_NO_VALUE }], filterValue: { granularity: DATE_GROUPS_GRANULARITY, @@ -224,19 +196,14 @@ describe('DateGroupFilterInput - "Any value" / "No value"', () => { }) openPopover() fireEvent.click(screen.getByLabelText('No value')) - expect(store.getActions()).toContainEqual({ - type: DATA_FILTER_SET, - layerId: 'layer1', - fieldId: 'eventdate', - filter: { - granularity: DATE_GROUPS_GRANULARITY, - prefixes: ['2023', SENTINEL_NO_VALUE], - }, + expect(onChange).toHaveBeenCalledWith({ + granularity: DATE_GROUPS_GRANULARITY, + prefixes: ['2023', SENTINEL_NO_VALUE], }) }) test('clicking a tree node while "Any value" is active is a no-op (v1 scope boundary)', () => { - const { store } = renderDateGroupFilter({ + const { onChange, onClear } = renderDateGroupFilter({ filterValue: { granularity: DATE_GROUPS_GRANULARITY, prefixes: [SENTINEL_ANY_VALUE], @@ -244,13 +211,14 @@ describe('DateGroupFilterInput - "Any value" / "No value"', () => { }) openPopover() fireEvent.click(screen.getByLabelText('2023')) - expect(store.getActions()).toEqual([]) + expect(onChange).not.toHaveBeenCalled() + expect(onClear).not.toHaveBeenCalled() }) }) describe('DateGroupFilterInput - clearing via the input’s clear ("x") button', () => { - test('clearing the closed trigger (showing "N selected") clears the whole filter', () => { - const { store } = renderDateGroupFilter({ + test('clearing the closed trigger (showing "N selected") calls onClear', () => { + const { onClear } = renderDateGroupFilter({ filterValue: { granularity: DATE_GROUPS_GRANULARITY, prefixes: ['2023'], @@ -258,15 +226,11 @@ describe('DateGroupFilterInput - clearing via the input’s clear ("x") button', }) expect(getInput()).toHaveValue('1 selected') fireEvent.change(getInput(), { target: { value: '' } }) - expect(store.getActions()).toContainEqual({ - type: DATA_FILTER_CLEAR, - layerId: 'layer1', - fieldId: 'eventdate', - }) + expect(onClear).toHaveBeenCalled() }) - test('clearing a typed search narrow while a selection is active also clears the selection (mirrors the flat filter variant)', () => { - const { store } = renderDateGroupFilter({ + test('clearing a typed search narrow while a selection is active also calls onClear (mirrors the flat filter variant)', () => { + const { onClear } = renderDateGroupFilter({ filterValue: { granularity: DATE_GROUPS_GRANULARITY, prefixes: ['2023'], @@ -277,18 +241,15 @@ describe('DateGroupFilterInput - clearing via the input’s clear ("x") button', expect(screen.queryByLabelText('2024')).not.toBeInTheDocument() fireEvent.change(getInput(), { target: { value: '' } }) - expect(store.getActions()).toContainEqual({ - type: DATA_FILTER_CLEAR, - layerId: 'layer1', - fieldId: 'eventdate', - }) + expect(onClear).toHaveBeenCalled() }) - test('clearing empty search text with no active filter dispatches nothing', () => { - const { store } = renderDateGroupFilter() + test('clearing empty search text with no active filter calls neither onChange nor onClear', () => { + const { onChange, onClear } = renderDateGroupFilter() openPopover() fireEvent.change(getInput(), { target: { value: '' } }) - expect(store.getActions()).toEqual([]) + expect(onChange).not.toHaveBeenCalled() + expect(onClear).not.toHaveBeenCalled() }) }) @@ -312,34 +273,24 @@ describe('DateGroupFilterInput - search', () => { }) test('typing text with no exact tree match shows a live-applying "Contains" custom filter row', () => { - const { store } = renderDateGroupFilter() + const { onChange } = renderDateGroupFilter() openPopover() fireEvent.change(getInput(), { target: { value: '2023-05-15 09:0' } }) expect( screen.getByTestId('data-table-column-filter-custom-Event date') ).toBeInTheDocument() - expect(store.getActions()).toContainEqual({ - type: DATA_FILTER_SET, - layerId: 'layer1', - fieldId: 'eventdate', - filter: '2023-05-15 09:0', - }) + expect(onChange).toHaveBeenCalledWith('2023-05-15 09:0') }) test('stays shown and keeps live-applying even when the typed text exactly matches a tree node prefix (e.g. a full year)', () => { - const { store } = renderDateGroupFilter() + const { onChange } = renderDateGroupFilter() openPopover() fireEvent.change(getInput(), { target: { value: '202' } }) fireEvent.change(getInput(), { target: { value: '2023' } }) expect( screen.getByTestId('data-table-column-filter-custom-Event date') ).toBeInTheDocument() - expect(store.getActions()).toContainEqual({ - type: DATA_FILTER_SET, - layerId: 'layer1', - fieldId: 'eventdate', - filter: '2023', - }) + expect(onChange).toHaveBeenCalledWith('2023') }) }) diff --git a/src/components/datatable/__tests__/FilterInput.spec.jsx b/src/components/datatable/__tests__/FilterInput.spec.jsx index 184c9eff71..0543065656 100644 --- a/src/components/datatable/__tests__/FilterInput.spec.jsx +++ b/src/components/datatable/__tests__/FilterInput.spec.jsx @@ -44,7 +44,6 @@ const renderFilterInput = (props, dataFilters) => { value={{ viewportHeight: 300, itemHeight: 28 }} > <FilterInput - layerId="layer1" dataKey="name" name="Name" type="string" diff --git a/src/components/datatable/__tests__/OrgUnitGroupFilterInput.spec.jsx b/src/components/datatable/__tests__/OrgUnitGroupFilterInput.spec.jsx index 2fad2e8a7b..eaa013406c 100644 --- a/src/components/datatable/__tests__/OrgUnitGroupFilterInput.spec.jsx +++ b/src/components/datatable/__tests__/OrgUnitGroupFilterInput.spec.jsx @@ -1,12 +1,6 @@ import { render, fireEvent, screen } from '@testing-library/react' import React from 'react' -import { Provider } from 'react-redux' import { VirtuosoMockContext } from 'react-virtuoso' -import configureMockStore from 'redux-mock-store' -import { - DATA_FILTER_SET, - DATA_FILTER_CLEAR, -} from '../../../constants/actionTypes.js' import { SENTINEL_ANY_VALUE, SENTINEL_NO_VALUE, @@ -14,8 +8,6 @@ import { } from '../../../constants/dataTable.js' import OrgUnitGroupFilterInput from '../OrgUnitGroupFilterInput.jsx' -const mockStore = configureMockStore() - const ORG_UNIT_VALUES = [ { value: '/country1/region1/facility1' }, { value: '/country1/region2/facility2' }, @@ -23,24 +15,23 @@ const ORG_UNIT_VALUES = [ ] const renderOrgUnitGroupFilter = (props) => { - const store = mockStore({}) + const onChange = jest.fn() + const onClear = jest.fn() const result = render( - <Provider store={store}> - <VirtuosoMockContext.Provider - value={{ viewportHeight: 300, itemHeight: 28 }} - > - <OrgUnitGroupFilterInput - dataKey="orgUnitPath" - name="Org unit" - layerId="layer1" - options={ORG_UNIT_VALUES} - idToName={new Map()} - {...props} - /> - </VirtuosoMockContext.Provider> - </Provider> + <VirtuosoMockContext.Provider + value={{ viewportHeight: 300, itemHeight: 28 }} + > + <OrgUnitGroupFilterInput + name="Org unit" + onChange={onChange} + onClear={onClear} + options={ORG_UNIT_VALUES} + idToName={new Map()} + {...props} + /> + </VirtuosoMockContext.Provider> ) - return { ...result, store } + return { ...result, onChange, onClear } } const getInput = () => @@ -104,24 +95,19 @@ describe('OrgUnitGroupFilterInput - label resolution', () => { }) }) -describe('OrgUnitGroupFilterInput - selection dispatches', () => { - test('checking a root node dispatches the full org-unit-group filter shape', () => { - const { store } = renderOrgUnitGroupFilter() +describe('OrgUnitGroupFilterInput - selection calls onChange/onClear', () => { + test('checking a root node calls onChange with the full org-unit-group filter shape', () => { + const { onChange } = renderOrgUnitGroupFilter() openPopover() fireEvent.click(screen.getByLabelText('country1')) - expect(store.getActions()).toContainEqual({ - type: DATA_FILTER_SET, - layerId: 'layer1', - fieldId: 'orgUnitPath', - filter: { - granularity: ORG_UNIT_GROUPS_GRANULARITY, - prefixes: ['/country1'], - }, + expect(onChange).toHaveBeenCalledWith({ + granularity: ORG_UNIT_GROUPS_GRANULARITY, + prefixes: ['/country1'], }) }) - test('unchecking the only selected prefix dispatches DATA_FILTER_CLEAR', () => { - const { store } = renderOrgUnitGroupFilter({ + test('unchecking the only selected prefix calls onClear', () => { + const { onClear } = renderOrgUnitGroupFilter({ filterValue: { granularity: ORG_UNIT_GROUPS_GRANULARITY, prefixes: ['/country1'], @@ -129,11 +115,7 @@ describe('OrgUnitGroupFilterInput - selection dispatches', () => { }) openPopover() fireEvent.click(screen.getByLabelText('country1')) - expect(store.getActions()).toContainEqual({ - type: DATA_FILTER_CLEAR, - layerId: 'layer1', - fieldId: 'orgUnitPath', - }) + expect(onClear).toHaveBeenCalled() }) }) @@ -184,8 +166,8 @@ describe('OrgUnitGroupFilterInput - "Any value" / "No value"', () => { expect(screen.queryByLabelText('No value')).not.toBeInTheDocument() }) - test('checking "Any value" dispatches the sentinel and clears prior selections', () => { - const { store } = renderOrgUnitGroupFilter({ + test('checking "Any value" calls onChange with the sentinel and clears prior selections', () => { + const { onChange } = renderOrgUnitGroupFilter({ options: [...ORG_UNIT_VALUES, { value: SENTINEL_NO_VALUE }], filterValue: { granularity: ORG_UNIT_GROUPS_GRANULARITY, @@ -194,19 +176,14 @@ describe('OrgUnitGroupFilterInput - "Any value" / "No value"', () => { }) openPopover() fireEvent.click(screen.getByLabelText('Any value')) - expect(store.getActions()).toContainEqual({ - type: DATA_FILTER_SET, - layerId: 'layer1', - fieldId: 'orgUnitPath', - filter: { - granularity: ORG_UNIT_GROUPS_GRANULARITY, - prefixes: [SENTINEL_ANY_VALUE], - }, + expect(onChange).toHaveBeenCalledWith({ + granularity: ORG_UNIT_GROUPS_GRANULARITY, + prefixes: [SENTINEL_ANY_VALUE], }) }) test('clicking a tree node while "Any value" is active is a no-op', () => { - const { store } = renderOrgUnitGroupFilter({ + const { onChange, onClear } = renderOrgUnitGroupFilter({ filterValue: { granularity: ORG_UNIT_GROUPS_GRANULARITY, prefixes: [SENTINEL_ANY_VALUE], @@ -214,7 +191,8 @@ describe('OrgUnitGroupFilterInput - "Any value" / "No value"', () => { }) openPopover() fireEvent.click(screen.getByLabelText('country1')) - expect(store.getActions()).toEqual([]) + expect(onChange).not.toHaveBeenCalled() + expect(onClear).not.toHaveBeenCalled() }) }) @@ -247,44 +225,32 @@ describe('OrgUnitGroupFilterInput - search', () => { }) test('typing text with no tree match shows the custom filter row and applies a filter matching nothing, rather than clearing back to unfiltered', () => { - const { store } = renderOrgUnitGroupFilter() + const { onChange, onClear } = renderOrgUnitGroupFilter() openPopover() fireEvent.change(getInput(), { target: { value: 'Nairobi' } }) expect( screen.getByTestId('data-table-column-filter-custom-Org unit') ).toBeInTheDocument() - expect(store.getActions()).toContainEqual({ - type: DATA_FILTER_SET, - layerId: 'layer1', - fieldId: 'orgUnitPath', - filter: { - granularity: ORG_UNIT_GROUPS_GRANULARITY, - prefixes: [], - searchDerived: true, - searchText: 'Nairobi', - }, + expect(onChange).toHaveBeenCalledWith({ + granularity: ORG_UNIT_GROUPS_GRANULARITY, + prefixes: [], + searchDerived: true, + searchText: 'Nairobi', }) - expect(store.getActions()).not.toContainEqual( - expect.objectContaining({ type: DATA_FILTER_CLEAR }) - ) + expect(onClear).not.toHaveBeenCalled() }) - test('committing a name-matched custom filter dispatches the matched nodes’ prefixes, not a raw substring match against the id path', () => { - const { store } = renderOrgUnitGroupFilter({ + test('committing a name-matched custom filter calls onChange with the matched nodes’ prefixes, not a raw substring match against the id path', () => { + const { onChange } = renderOrgUnitGroupFilter({ idToName: new Map([['country1', 'Sierra Leone']]), }) openPopover() fireEvent.change(getInput(), { target: { value: 'Sierra' } }) - expect(store.getActions()).toContainEqual({ - type: DATA_FILTER_SET, - layerId: 'layer1', - fieldId: 'orgUnitPath', - filter: { - granularity: ORG_UNIT_GROUPS_GRANULARITY, - prefixes: ['/country1'], - searchDerived: true, - searchText: 'Sierra', - }, + expect(onChange).toHaveBeenCalledWith({ + granularity: ORG_UNIT_GROUPS_GRANULARITY, + prefixes: ['/country1'], + searchDerived: true, + searchText: 'Sierra', }) }) diff --git a/src/components/datatable/useGroupFilterInput.js b/src/components/datatable/useGroupFilterInput.js index daf63e74a5..87c4d62d17 100644 --- a/src/components/datatable/useGroupFilterInput.js +++ b/src/components/datatable/useGroupFilterInput.js @@ -1,6 +1,4 @@ import { useCallback, useMemo, useRef, useState } from 'react' -import { useDispatch } from 'react-redux' -import { setDataFilter, clearDataFilter } from '../../actions/dataFilters.js' import { SENTINEL_ANY_VALUE, SENTINEL_NO_VALUE, @@ -22,8 +20,8 @@ import { getDropdownPlacement } from './FilterDropdownPopover.jsx' const identity = (value) => value const useGroupFilterInput = ({ - dataKey, - layerId, + onChange, + onClear, filterValue, options, granularity, @@ -33,7 +31,6 @@ const useGroupFilterInput = ({ commitSearch, sanitizeInput = identity, }) => { - const dispatch = useDispatch() const anchorRef = useRef(null) const listRef = useRef(null) const [isOpen, setIsOpen] = useState(false) @@ -63,14 +60,9 @@ const useGroupFilterInput = ({ const applyValues = useCallback( (nextPrefixes) => nextPrefixes.length - ? dispatch( - setDataFilter(layerId, dataKey, { - granularity, - prefixes: nextPrefixes, - }) - ) - : dispatch(clearDataFilter(layerId, dataKey)), - [dispatch, layerId, dataKey, granularity] + ? onChange({ granularity, prefixes: nextPrefixes }) + : onClear(), + [onChange, onClear, granularity] ) const hasNotSetOption = options.some( @@ -152,10 +144,10 @@ const useGroupFilterInput = ({ const applyCustomFilter = (text) => { if (!text) { - dispatch(clearDataFilter(layerId, dataKey)) + onClear() return } - commitSearch(text, { tree, dispatch, layerId, dataKey }) + commitSearch(text, { tree, onChange }) } const onSearchChange = ({ value }) => { @@ -166,7 +158,7 @@ const useGroupFilterInput = ({ const trimmed = sanitized.trim() if (trimmed === '') { if (hasActiveFilter) { - dispatch(clearDataFilter(layerId, dataKey)) + onClear() } return } From 6f9b647d2f77a8f6c763334c4f9a82ce0f3166e2 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault <bruno@dhis2.org> Date: Mon, 24 Aug 2026 12:44:23 +0200 Subject: [PATCH 205/205] fix: preserve data table tabs and selection across close/reopen --- src/actions/dataTable.js | 9 ++ src/components/datatable/BottomPanel.jsx | 14 +- .../datatable/CombinedDataTable.jsx | 19 +-- src/components/datatable/DataTableButton.jsx | 5 + .../datatable/__tests__/BottomPanel.spec.jsx | 28 +++- .../__tests__/CombinedDataTable.spec.jsx | 52 ++++--- .../__tests__/DataTableButton.spec.jsx | 38 ++++- src/constants/actionTypes.js | 2 + src/reducers/__tests__/dataTable.spec.js | 136 +++++++++++++++++- src/reducers/dataTable.js | 39 ++++- src/util/__tests__/dataTable.spec.js | 38 ++++- src/util/dataTable.js | 4 +- 12 files changed, 317 insertions(+), 67 deletions(-) diff --git a/src/actions/dataTable.js b/src/actions/dataTable.js index 84ad32b640..c7ff73b4d2 100644 --- a/src/actions/dataTable.js +++ b/src/actions/dataTable.js @@ -4,11 +4,20 @@ export const closeDataTable = () => ({ type: types.DATA_TABLE_CLOSE, }) +export const openDataTable = () => ({ + type: types.DATA_TABLE_OPEN, +}) + export const toggleDataTable = (id) => ({ type: types.DATA_TABLE_TOGGLE, id, }) +export const setActiveDataTableLayer = (id) => ({ + type: types.DATA_TABLE_ACTIVE_LAYER_SET, + id, +}) + export const resizeDataTable = (height) => ({ type: types.DATA_TABLE_RESIZE, height, diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index ad93920e68..890e2b11f2 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -21,6 +21,7 @@ import { setJoinConfig, setCombinedColumnConfig, setDataTableColumnConfig, + setActiveDataTableLayer, } from '../../actions/dataTable.js' import { COMBINED_HEADERS_KEY } from '../../constants/dataTable.js' import useKeyDown from '../../hooks/useKeyDown.js' @@ -61,12 +62,15 @@ const BottomPanel = () => { systemSettings: { keyAnalysisDigitGroupSeparator }, } = useCachedData() const dataTableHeight = useSelector((state) => state.ui.dataTableHeight) - const { openIds, combinedView } = useSelector((state) => state.dataTable) + const { + openIds, + combinedView, + activeLayerId: storedActiveLayerId, + } = useSelector((state) => state.dataTable) const mapViews = useSelector((state) => state.map.mapViews) - const [manualActiveLayerId, setManualActiveLayerId] = useState(null) const activeLayerId = - manualActiveLayerId && openIds.includes(manualActiveLayerId) - ? manualActiveLayerId + storedActiveLayerId && openIds.includes(storedActiveLayerId) + ? storedActiveLayerId : openIds[openIds.length - 1] ?? null const eligibleLayers = useMemo(() => { @@ -309,7 +313,7 @@ const BottomPanel = () => { activeLayerId={activeLayerId} combinedView={combinedView} onSelectLayer={(id) => { - setManualActiveLayerId(id) + dispatch(setActiveDataTableLayer(id)) if (combinedView) { dispatch(toggleCombinedView()) } diff --git a/src/components/datatable/CombinedDataTable.jsx b/src/components/datatable/CombinedDataTable.jsx index 6be7bed369..5c4bcde8ef 100644 --- a/src/components/datatable/CombinedDataTable.jsx +++ b/src/components/datatable/CombinedDataTable.jsx @@ -100,7 +100,12 @@ const CombinedDataTable = ({ const { sortField, sortDirection, sortData } = useSortState('name') - const [selectedIds, setSelectedIds] = useState([]) + const initialCrossLayerIds = useSelector( + (state) => state.selection.crossLayerIds + ) + const [selectedIds, setSelectedIds] = useState( + () => initialCrossLayerIds?.[referenceLayer.id] ?? [] + ) const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]) const { @@ -193,21 +198,9 @@ const CombinedDataTable = ({ const [hoveredRowId, setHoveredRowId] = useState(null) - const hasAppliedSelectionRef = useRef(false) - - useEffect( - () => () => { - if (hasAppliedSelectionRef.current) { - dispatch(setCrossLayerSelection({})) - } - }, - [dispatch] - ) - const applySelection = useCallback( (nextIds) => { setSelectedIds(nextIds) - hasAppliedSelectionRef.current = true dispatch( setCrossLayerSelection( mergeCrossLayerIds(nextIds, rowFeatureIds) diff --git a/src/components/datatable/DataTableButton.jsx b/src/components/datatable/DataTableButton.jsx index 6ffc04c853..1750cba278 100644 --- a/src/components/datatable/DataTableButton.jsx +++ b/src/components/datatable/DataTableButton.jsx @@ -3,6 +3,7 @@ import React from 'react' import { useDispatch, useSelector } from 'react-redux' import { closeDataTable, + openDataTable, toggleDataTable, toggleCombinedView, } from '../../actions/dataTable.js' @@ -28,6 +29,10 @@ const DataTableButton = () => { dispatch(closeDataTable()) return } + if (dataTable.openIds.length > 0 || dataTable.combinedView) { + dispatch(openDataTable()) + return + } if (combinedEnabled) { dispatch(toggleCombinedView()) } else if (eligibleLayers.length >= 1) { diff --git a/src/components/datatable/__tests__/BottomPanel.spec.jsx b/src/components/datatable/__tests__/BottomPanel.spec.jsx index 72d8f8e458..81b85af5bf 100644 --- a/src/components/datatable/__tests__/BottomPanel.spec.jsx +++ b/src/components/datatable/__tests__/BottomPanel.spec.jsx @@ -228,8 +228,8 @@ describe('BottomPanel layer selector', () => { expect(screen.getByText('Layer 2')).toBeInTheDocument() }) - test('selecting a different, already-open layer switches the active layer shown in the table', () => { - renderBottomPanel({ + test('selecting a different, already-open layer dispatches DATA_TABLE_ACTIVE_LAYER_SET', () => { + const { store } = renderBottomPanel({ dataTable: { ...DEFAULT_DATA_TABLE_STATE, openIds: ['layer1', 'layer2'], @@ -237,10 +237,23 @@ describe('BottomPanel layer selector', () => { mapViews: twoEligibleLayers, }) - expect(screen.getByTestId('datatable-mock')).toHaveTextContent('layer2') - fireEvent.change(getLayerSelector(), { target: { value: 'layer1' } }) + expect(store.getActions()).toEqual([ + { type: 'DATA_TABLE_ACTIVE_LAYER_SET', id: 'layer1' }, + ]) + }) + + test('shows the persisted activeLayerId as the active tab on mount, not just the last-opened one', () => { + renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1', 'layer2'], + activeLayerId: 'layer1', + }, + mapViews: twoEligibleLayers, + }) + expect(screen.getByTestId('datatable-mock')).toHaveTextContent('layer1') }) @@ -253,11 +266,12 @@ describe('BottomPanel layer selector', () => { fireEvent.change(getLayerSelector(), { target: { value: 'layer2' } }) expect(store.getActions()).toEqual([ + { type: 'DATA_TABLE_ACTIVE_LAYER_SET', id: 'layer2' }, { type: 'DATA_TABLE_TOGGLE', id: 'layer2' }, ]) }) - test('does not re-dispatch toggleDataTable when selecting an already-open layer', () => { + test('sets the active layer but does not re-dispatch toggleDataTable when selecting an already-open layer', () => { const { store } = renderBottomPanel({ dataTable: { ...DEFAULT_DATA_TABLE_STATE, @@ -268,7 +282,9 @@ describe('BottomPanel layer selector', () => { fireEvent.change(getLayerSelector(), { target: { value: 'layer1' } }) - expect(store.getActions()).toEqual([]) + expect(store.getActions()).toEqual([ + { type: 'DATA_TABLE_ACTIVE_LAYER_SET', id: 'layer1' }, + ]) }) test('the active layer is correct on the very first render, with no transient null in between', () => { diff --git a/src/components/datatable/__tests__/CombinedDataTable.spec.jsx b/src/components/datatable/__tests__/CombinedDataTable.spec.jsx index 19747bcfed..1f331df231 100644 --- a/src/components/datatable/__tests__/CombinedDataTable.spec.jsx +++ b/src/components/datatable/__tests__/CombinedDataTable.spec.jsx @@ -31,8 +31,8 @@ const EMPTY_REFERENCE_LAYER = { data: [], } -const renderCombinedDataTable = (props) => { - const store = mockStore({ ui: {} }) +const renderCombinedDataTable = (props, selection = {}) => { + const store = mockStore({ ui: {}, selection }) const result = render( <Provider store={store}> <VirtuosoMockContext.Provider @@ -456,7 +456,7 @@ describe('CombinedDataTable', () => { }) }) - test('does not clear selection on unmount when nothing was ever selected here', () => { + test('does not clear the cross-layer selection on unmount, regardless of prior selection', () => { const referenceLayer = { ...EMPTY_REFERENCE_LAYER, data: [referenceFeature('ou1', 'Ou One', '/country1/ou1')], @@ -483,14 +483,21 @@ describe('CombinedDataTable', () => { }, }) + fireEvent.click(screen.getAllByRole('checkbox')[1]) + const actionsBeforeUnmount = store.getActions().length unmount() - expect(store.getActions()).not.toContainEqual( + // Unmount still dispatches an unrelated setCombinedVisibleIds(null) + // cleanup, but must not dispatch another SELECTION_SET_CROSS_LAYER. + const actionsAfterUnmount = store + .getActions() + .slice(actionsBeforeUnmount) + expect(actionsAfterUnmount).not.toContainEqual( expect.objectContaining({ type: 'SELECTION_SET_CROSS_LAYER' }) ) }) - test('clears the cross-layer selection on unmount after selecting a row', () => { + test('restores a previously-selected row as checked on mount, from the persisted cross-layer selection', () => { const referenceLayer = { ...EMPTY_REFERENCE_LAYER, data: [referenceFeature('ou1', 'Ou One', '/country1/ou1')], @@ -504,26 +511,23 @@ describe('CombinedDataTable', () => { }, ] - const { store, unmount } = renderCombinedDataTable({ - referenceLayer, - layers, - joinConfig: { - layers: { - layerA: { - type: 'orgUnit', - aggregation: { rawValue: 'SUM' }, + renderCombinedDataTable( + { + referenceLayer, + layers, + joinConfig: { + layers: { + layerA: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, }, }, }, - }) - - fireEvent.click(screen.getAllByRole('checkbox')[1]) - unmount() + { crossLayerIds: { ref1: ['ou1'], layerA: ['evt1'] } } + ) - expect(store.getActions()).toContainEqual({ - type: 'SELECTION_SET_CROSS_LAYER', - crossLayerIds: {}, - }) + expect(screen.getAllByRole('checkbox')[1]).toBeChecked() }) test('reports computed headers up via onHeadersChange, keyed by the combined sentinel', () => { @@ -728,6 +732,7 @@ describe('CombinedDataTable', () => { const store = mockStore({ ui: {}, + selection: {}, feature: { id: 'evtA1', layerId: 'layerA', origin: 'map' }, }) render( @@ -778,6 +783,7 @@ describe('CombinedDataTable', () => { const store = mockStore({ ui: {}, + selection: {}, feature: { id: 'evtA1', layerId: 'layerA', origin: 'table' }, }) render( @@ -834,6 +840,7 @@ describe('CombinedDataTable', () => { multiSelect: false, }, }, + selection: {}, }) render( <Provider store={store}> @@ -890,6 +897,7 @@ describe('CombinedDataTable', () => { multiSelect: true, }, }, + selection: {}, }) render( <Provider store={store}> @@ -933,6 +941,7 @@ describe('CombinedDataTable', () => { multiSelect: true, }, }, + selection: {}, }) render( <Provider store={store}> @@ -1043,6 +1052,7 @@ describe('CombinedDataTable', () => { test('does not narrow map visibility from the selection filter alone', () => { const store = mockStore({ ui: { selectionFilter: ['selected'] }, + selection: {}, }) render( <Provider store={store}> diff --git a/src/components/datatable/__tests__/DataTableButton.spec.jsx b/src/components/datatable/__tests__/DataTableButton.spec.jsx index a7d082c6f3..c311cce093 100644 --- a/src/components/datatable/__tests__/DataTableButton.spec.jsx +++ b/src/components/datatable/__tests__/DataTableButton.spec.jsx @@ -83,7 +83,11 @@ describe('DataTableButton', () => { test('closes the panel when a single-layer table is already open', () => { const { store } = renderButton({ - dataTable: { openIds: ['a'], combinedView: false }, + dataTable: { + openIds: ['a'], + combinedView: false, + isPanelVisible: true, + }, mapViews: [layer('a'), layer('b')], }) fireEvent.click(screen.getByText('Data table')) @@ -92,10 +96,40 @@ describe('DataTableButton', () => { test('closes the panel when Combined is already open', () => { const { store } = renderButton({ - dataTable: { openIds: [], combinedView: true }, + dataTable: { + openIds: [], + combinedView: true, + isPanelVisible: true, + }, mapViews: [layer('a'), layer('b')], }) fireEvent.click(screen.getByText('Data table')) expect(store.getActions()).toEqual([{ type: 'DATA_TABLE_CLOSE' }]) }) + + test('reopens (without changing what is open) when a single-layer table was open but the panel is hidden', () => { + const { store } = renderButton({ + dataTable: { + openIds: ['a'], + combinedView: false, + isPanelVisible: false, + }, + mapViews: [layer('a'), layer('b')], + }) + fireEvent.click(screen.getByText('Data table')) + expect(store.getActions()).toEqual([{ type: 'DATA_TABLE_OPEN' }]) + }) + + test('reopens (without changing what is open) when Combined was open but the panel is hidden', () => { + const { store } = renderButton({ + dataTable: { + openIds: [], + combinedView: true, + isPanelVisible: false, + }, + mapViews: [layer('a'), layer('b')], + }) + fireEvent.click(screen.getByText('Data table')) + expect(store.getActions()).toEqual([{ type: 'DATA_TABLE_OPEN' }]) + }) }) diff --git a/src/constants/actionTypes.js b/src/constants/actionTypes.js index d2efacbb87..0dc047615a 100644 --- a/src/constants/actionTypes.js +++ b/src/constants/actionTypes.js @@ -39,7 +39,9 @@ export const LAYER_FORCE_CLIENT_CLUSTER_SET = 'LAYER_FORCE_CLIENT_CLUSTER_SET' /* DATA TABLE */ export const DATA_TABLE_CLOSE = 'DATA_TABLE_CLOSE' +export const DATA_TABLE_OPEN = 'DATA_TABLE_OPEN' export const DATA_TABLE_TOGGLE = 'DATA_TABLE_TOGGLE' +export const DATA_TABLE_ACTIVE_LAYER_SET = 'DATA_TABLE_ACTIVE_LAYER_SET' export const DATA_TABLE_RESIZE = 'DATA_TABLE_RESIZE' export const MAP_BOUNDS_CHANGED = 'MAP_BOUNDS_CHANGED' export const TOGGLE_SHOW_ONLY_IN_VIEW = 'TOGGLE_SHOW_ONLY_IN_VIEW' diff --git a/src/reducers/__tests__/dataTable.spec.js b/src/reducers/__tests__/dataTable.spec.js index bb8bdec734..4c61b878b3 100644 --- a/src/reducers/__tests__/dataTable.spec.js +++ b/src/reducers/__tests__/dataTable.spec.js @@ -4,6 +4,8 @@ import dataTable from '../dataTable.js' const initialState = { openIds: [], combinedView: false, + isPanelVisible: false, + activeLayerId: null, } describe('dataTable reducer', () => { @@ -14,15 +16,60 @@ describe('dataTable reducer', () => { it.each([ types.MAP_NEW, types.MAP_SET, - types.DATA_TABLE_CLOSE, types.DOWNLOAD_MODE_CLOSE, types.DOWNLOAD_MODE_OPEN, ])('resets fully to the initial state on %s', (type) => { - const state = { openIds: ['layer1', 'layer2'], combinedView: true } + const state = { + openIds: ['layer1', 'layer2'], + combinedView: true, + isPanelVisible: true, + activeLayerId: 'layer1', + } expect(dataTable(state, { type })).toEqual(initialState) }) + describe('DATA_TABLE_CLOSE', () => { + it('hides the panel without touching openIds, combinedView, or activeLayerId', () => { + const state = { + openIds: ['layer1', 'layer2'], + combinedView: true, + isPanelVisible: true, + activeLayerId: 'layer1', + } + + const nextState = dataTable(state, { type: types.DATA_TABLE_CLOSE }) + + expect(nextState).toEqual({ ...state, isPanelVisible: false }) + }) + }) + + describe('DATA_TABLE_OPEN', () => { + it('shows the panel without touching openIds, combinedView, or activeLayerId', () => { + const state = { + openIds: ['layer1'], + combinedView: false, + isPanelVisible: false, + activeLayerId: 'layer1', + } + + const nextState = dataTable(state, { type: types.DATA_TABLE_OPEN }) + + expect(nextState).toEqual({ ...state, isPanelVisible: true }) + }) + }) + + describe('DATA_TABLE_ACTIVE_LAYER_SET', () => { + it('sets activeLayerId', () => { + const state = dataTable(initialState, { + type: types.DATA_TABLE_ACTIVE_LAYER_SET, + id: 'layer1', + }) + + expect(state.activeLayerId).toBe('layer1') + }) + }) + describe('DATA_TABLE_TOGGLE', () => { it('opens a layer tab that was not open', () => { const state = dataTable(initialState, { @@ -52,7 +99,11 @@ describe('dataTable reducer', () => { }) it('leaves combinedView untouched even when closing the last open tab', () => { - const prevState = { openIds: ['layer1'], combinedView: true } + const prevState = { + ...initialState, + openIds: ['layer1'], + combinedView: true, + } const state = dataTable(prevState, { type: types.DATA_TABLE_TOGGLE, @@ -62,6 +113,30 @@ describe('dataTable reducer', () => { expect(state.openIds).toEqual([]) expect(state.combinedView).toBe(true) }) + + it('makes the panel visible when opening a tab, even from a hidden state', () => { + const state = dataTable( + { ...initialState, isPanelVisible: false }, + { type: types.DATA_TABLE_TOGGLE, id: 'layer1' } + ) + + expect(state.isPanelVisible).toBe(true) + }) + + it('does not forcibly clear panel visibility when closing a tab', () => { + const prevState = { + ...initialState, + openIds: ['layer1'], + isPanelVisible: true, + } + + const state = dataTable(prevState, { + type: types.DATA_TABLE_TOGGLE, + id: 'layer1', + }) + + expect(state.isPanelVisible).toBe(true) + }) }) describe('LAYER_REMOVE', () => { @@ -75,7 +150,7 @@ describe('dataTable reducer', () => { }) it('leaves combinedView untouched', () => { - const prevState = { openIds: [], combinedView: true } + const prevState = { ...initialState, combinedView: true } const state = dataTable(prevState, { type: types.LAYER_REMOVE, @@ -85,6 +160,36 @@ describe('dataTable reducer', () => { expect(state.combinedView).toBe(true) }) + + it('clears activeLayerId when the removed layer was the active one', () => { + const prevState = { + ...initialState, + openIds: ['layer1'], + activeLayerId: 'layer1', + } + + const state = dataTable(prevState, { + type: types.LAYER_REMOVE, + id: 'layer1', + }) + + expect(state.activeLayerId).toBeNull() + }) + + it('leaves activeLayerId untouched when a different layer is removed', () => { + const prevState = { + ...initialState, + openIds: ['layer1', 'layer2'], + activeLayerId: 'layer1', + } + + const state = dataTable(prevState, { + type: types.LAYER_REMOVE, + id: 'layer2', + }) + + expect(state.activeLayerId).toBe('layer1') + }) }) describe('DATA_TABLE_COMBINED_VIEW_TOGGLE', () => { @@ -104,6 +209,29 @@ describe('dataTable reducer', () => { expect(state.combinedView).toBe(false) }) + + it('makes the panel visible when turning combinedView on, even from a hidden state', () => { + const state = dataTable( + { ...initialState, isPanelVisible: false }, + { type: types.DATA_TABLE_COMBINED_VIEW_TOGGLE } + ) + + expect(state.isPanelVisible).toBe(true) + }) + + it('does not forcibly clear panel visibility when turning combinedView off', () => { + const prevState = { + ...initialState, + combinedView: true, + isPanelVisible: true, + } + + const state = dataTable(prevState, { + type: types.DATA_TABLE_COMBINED_VIEW_TOGGLE, + }) + + expect(state.isPanelVisible).toBe(true) + }) }) it('returns the current state for unknown actions', () => { diff --git a/src/reducers/dataTable.js b/src/reducers/dataTable.js index 3f7df533b4..fb1450e5dc 100644 --- a/src/reducers/dataTable.js +++ b/src/reducers/dataTable.js @@ -3,32 +3,57 @@ import * as types from '../constants/actionTypes.js' const initialState = { openIds: [], combinedView: false, + isPanelVisible: false, + activeLayerId: null, } const dataTable = (state = initialState, action) => { switch (action.type) { - case types.DATA_TABLE_CLOSE: case types.DOWNLOAD_MODE_CLOSE: case types.DOWNLOAD_MODE_OPEN: case types.MAP_NEW: case types.MAP_SET: return initialState + case types.DATA_TABLE_CLOSE: + return { ...state, isPanelVisible: false } + + case types.DATA_TABLE_OPEN: + return { ...state, isPanelVisible: true } + + case types.DATA_TABLE_ACTIVE_LAYER_SET: + return { ...state, activeLayerId: action.id } + case types.DATA_TABLE_TOGGLE: { - const openIds = state.openIds.includes(action.id) - ? state.openIds.filter((id) => id !== action.id) - : [...state.openIds, action.id] - return { ...state, openIds } + const isOpening = !state.openIds.includes(action.id) + const openIds = isOpening + ? [...state.openIds, action.id] + : state.openIds.filter((id) => id !== action.id) + return { + ...state, + openIds, + isPanelVisible: isOpening ? true : state.isPanelVisible, + } } case types.LAYER_REMOVE: return { ...state, openIds: state.openIds.filter((id) => id !== action.id), + activeLayerId: + state.activeLayerId === action.id + ? null + : state.activeLayerId, } - case types.DATA_TABLE_COMBINED_VIEW_TOGGLE: - return { ...state, combinedView: !state.combinedView } + case types.DATA_TABLE_COMBINED_VIEW_TOGGLE: { + const combinedView = !state.combinedView + return { + ...state, + combinedView, + isPanelVisible: combinedView ? true : state.isPanelVisible, + } + } default: return state diff --git a/src/util/__tests__/dataTable.spec.js b/src/util/__tests__/dataTable.spec.js index 70f3707f01..9c3c29f1e8 100644 --- a/src/util/__tests__/dataTable.spec.js +++ b/src/util/__tests__/dataTable.spec.js @@ -1113,20 +1113,44 @@ describe('getEligibleDataTableLayers', () => { }) describe('isDataTableOpen', () => { - test('is open when at least one tab is open', () => { + test('is open when at least one tab is open and the panel is visible', () => { expect( - isDataTableOpen({ openIds: ['layer1'], combinedView: false }) + isDataTableOpen({ + openIds: ['layer1'], + combinedView: false, + isPanelVisible: true, + }) ).toBe(true) }) - test('is open when Combined is active, even with no open tabs', () => { - expect(isDataTableOpen({ openIds: [], combinedView: true })).toBe(true) + test('is open when Combined is active and the panel is visible, even with no open tabs', () => { + expect( + isDataTableOpen({ + openIds: [], + combinedView: true, + isPanelVisible: true, + }) + ).toBe(true) }) test('is closed when there are no open tabs and Combined is not active', () => { - expect(isDataTableOpen({ openIds: [], combinedView: false })).toBe( - false - ) + expect( + isDataTableOpen({ + openIds: [], + combinedView: false, + isPanelVisible: true, + }) + ).toBe(false) + }) + + test('is closed when the panel is hidden, even with open tabs or Combined active', () => { + expect( + isDataTableOpen({ + openIds: ['layer1'], + combinedView: true, + isPanelVisible: false, + }) + ).toBe(false) }) }) diff --git a/src/util/dataTable.js b/src/util/dataTable.js index b7aa7cea8b..f17b86aab8 100644 --- a/src/util/dataTable.js +++ b/src/util/dataTable.js @@ -408,8 +408,8 @@ export const hasActiveDataTableFilters = ({ selectionFilter?.length > 0 || !!showOnlyFeaturesInView -export const isDataTableOpen = ({ openIds, combinedView }) => - openIds.length > 0 || combinedView +export const isDataTableOpen = ({ openIds, combinedView, isPanelVisible }) => + isPanelVisible && (openIds.length > 0 || combinedView) export const getEligibleDataTableLayers = (mapViews) => mapViews.filter(