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 new file mode 100755 index 0000000000..554ba16d0d --- /dev/null +++ b/.claude/hooks/post-edit.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -uo pipefail + +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 + *.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 new file mode 100644 index 0000000000..b737843e29 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,27 @@ +{ + "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": { + "chrome-devtools-mcp@claude-plugins-official": true, + "context7@claude-plugins-official": true + }, + "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 new file mode 100644 index 0000000000..da3aff47cf --- /dev/null +++ b/.mcp.json @@ -0,0 +1,17 @@ +{ + "mcpServers": { + "chrome-devtools": { + "command": "npx", + "args": [ + "chrome-devtools-mcp@latest", + "--chromeArg=--use-angle=swiftshader-webgl", + "--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/). diff --git a/cypress/elements/map_context_menu.js b/cypress/elements/map_context_menu.js index 0da64b2203..fb760ad292 100644 --- a/cypress/elements/map_context_menu.js +++ b/cypress/elements/map_context_menu.js @@ -4,9 +4,20 @@ 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 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 = [DRILL_UP, DRILL_DOWN, VIEW_PROFILE, SHOW_LONG_LAT] +const ALL_OPTIONS = [ + DRILL_UP, + DRILL_DOWN, + VIEW_PROFILE, + ZOOM_TO_FEATURE, + ZOOM_TO_LAYER, + ZOOM_TO_SELECTED, + SHOW_LONG_LAT, +] export const expectContextMenuOptions = (availableOptions) => { getMaps() diff --git a/cypress/integration/dataTable.cy.js b/cypress/integration/dataTable.cy.js index 8e2ae9afc3..ee4468fac0 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) => { @@ -77,76 +77,92 @@ describe('data table', () => { assertMapPosition(expectedBottoms2, expectedHeights2) }) - // check number of columns + // 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') - .should('have.length', 10) + .should('have.length', 7) - // Filter by name - cy.getByDataTest('data-table-column-filter-input-Name') + // Filter by Org unit + cy.getByDataTest('data-table-column-filter-search-Org unit') .find('input') - .type('bar') + .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: 1, expectedContent: 'Bargbe' }) checkTableCell({ row: 6, column: 1, expectedContent: 'Upper Bambara' }) - // Sort by name - cy.get('button[title="Sort by Name"]').click() + // 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 + // (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 + // Confirm that the rows are sorted by Name descending 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-input-Value') + // Filter by Value (numeric) + 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 + // Check that the (combined) filter returned the correct number of rows cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-tablebody') .findByDataTest('dhis2-uicore-datatablerow') .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 + // 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: 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() - // check that the org unit profile drawer is opened + 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') - // close the datatable + cy.getByDataTest('layers-toggle-button').click() + + // 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) }) - it('opens the data table for an Event layer', () => { + it('opens data table for an Event layer', () => { cy.visit('/') const EvenLayer = new EventLayer() @@ -174,23 +190,26 @@ describe('data table', () => { cy.getByDataTest('bottom-panel').should('be.visible') - // check number of columns + // 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') - .should('have.length', 10) + .should('have.length', 9) cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-datatablecellhead') .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-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 + // Check that all the rows have Org unit Moyowa checkTableCell({ row: 0, column: 1, expectedContent: ouName }) checkTableCell({ row: 2, column: 1, expectedContent: ouName }) @@ -199,51 +218,59 @@ describe('data table', () => { .findByDataTest('dhis2-uicore-datatablerow') .should('have.length', 3) - // filter by Mode of Discharge - cy.getByDataTest('data-table-column-filter-input-Mode of Discharge') + // Filter by 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.get('.backdrop').last().click() + + cy.getByDataTest('data-table-column-filter-search-Mode of Discharge') + .find('.clear-button') + .click() cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-tablebody') .findByDataTest('dhis2-uicore-datatablerow') .should('have.length', 3) - // filter by Age in years (numeric) - cy.getByDataTest('data-table-column-filter-input-Age in years') + // Filter by Age in years (numeric) + 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 + // 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', 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' }) + // 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' }) - // 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() - // check that the org unit profile drawer is NOT opened + 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') }) @@ -287,44 +314,58 @@ 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: 1, expectedContent: 'Bendu CHC' }) - cy.get('button[title="Sort by 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' }) + // First click on a new column always sorts ascending + cy.getByDataTest('data-table-column-sort-button-Value').click() - // 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 6 has no value (undefined) - checkTableCell({ row: 6, column: 3, expectedContent: '' }) - - // Sort ascending by Value - cy.get('button[title="Sort by 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: 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: 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 by index and scroll to the top - cy.get('button[title="Sort by Index"]').click() + // 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: 0, expectedContent: '28' }) + checkTableCell({ row: 0, column: 1, expectedContent: 'Gbamgbama CHC' }) + checkTableCell({ row: 0, 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: '' }) + + // 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') // Check that row 0 range value is empty 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() + + // 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: 5, expectedContent: '0 – 40' }) 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/cypress/integration/layers/geojsonlayer.cy.js b/cypress/integration/layers/geojsonlayer.cy.js index 3cacd25903..507d0c5190 100644 --- a/cypress/integration/layers/geojsonlayer.cy.js +++ b/cypress/integration/layers/geojsonlayer.cy.js @@ -76,13 +76,15 @@ 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..de4e1ef8b3 100644 --- a/cypress/integration/layers/thematiclayer.cy.js +++ b/cypress/integration/layers/thematiclayer.cy.js @@ -3,6 +3,9 @@ import { DRILL_UP, DRILL_DOWN, VIEW_PROFILE, + ZOOM_TO_FEATURE, + ZOOM_TO_LAYER, + ZOOM_TO_SELECTED, SHOW_LONG_LAT, expectContextMenuOptions, } from '../../elements/map_context_menu.js' @@ -539,6 +542,9 @@ context('Thematic Layers', () => { { name: DRILL_UP, disabled: true }, { name: DRILL_DOWN }, { name: VIEW_PROFILE }, + { name: ZOOM_TO_FEATURE }, + { name: ZOOM_TO_LAYER }, + { name: ZOOM_TO_SELECTED, disabled: true }, { name: SHOW_LONG_LAT }, ]) }) @@ -723,6 +729,9 @@ context('Thematic Layers', () => { { name: DRILL_UP, disabled: true }, { name: DRILL_DOWN }, { name: VIEW_PROFILE }, + { name: ZOOM_TO_FEATURE }, + { name: ZOOM_TO_LAYER }, + { name: ZOOM_TO_SELECTED, disabled: true }, ]) }) diff --git a/i18n/en.pot b/i18n/en.pot index 8e5f58ce96..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-07-10T08:27:09.245Z\n" -"PO-Revision-Date: 2026-07-10T08:27:09.245Z\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" @@ -155,63 +155,371 @@ msgstr "Operator" msgid "Date" msgstr "Date" -msgid "No results found" -msgstr "No results found" +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 "Sort by {{column}}" -msgstr "Sort by {{column}}" +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" + +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 "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" + +msgid "Data table" +msgstr "Data table" + +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 "Contains" +msgstr "Contains" 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 "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 "Search or type > 5, < 8…" +msgstr "Search or type > 5, < 8…" + msgid "Search" msgstr "Search" -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 "Reverse selection" +msgstr "Reverse selection" -msgid "No valid data was found for the current layer configuration." -msgstr "No valid data was found for the current layer configuration." +msgid "Any value" +msgstr "Any value" -msgid "" -"Data table is not supported when there is more than one geometry type in " -"the dataset." -msgstr "" -"Data table is not supported when there is more than one geometry type in " -"the dataset." +msgid "Too many values to list - type to filter this column" +msgstr "Too many values to list - type to filter this column" -msgid "No valid data fields were found for this layer." -msgstr "No valid data fields were found for this layer." +msgid "No matches" +msgstr "No matches" -msgid "Index" -msgstr "Index" +msgid "No value" +msgstr "No value" -msgid "Id" -msgstr "Id" +msgid "Collapse {{label}}" +msgstr "Collapse {{label}}" -msgid "Level" -msgstr "Level" +msgid "Expand {{label}}" +msgstr "Expand {{label}}" -msgid "Parent" -msgstr "Parent" +msgid "Select a country, region, district or facility" +msgstr "Select a country, region, district or facility" -msgid "Type" -msgstr "Type" +msgid "to match the rows under it, or type to search" +msgstr "to match the rows under it, or type to search" -msgid "Legend" -msgstr "Legend" +msgid "Select matches" +msgstr "Select matches" -msgid "Range" -msgstr "Range" +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" + +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 "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 layer" +msgstr "Zoom to layer" + +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" + +msgid "Clear filters" +msgstr "Clear filters" + +msgid "No results found" +msgstr "No results found" + +msgid "Close" +msgstr "Close" + +msgid "Restore" +msgstr "Restore" + +msgid "Collapse" +msgstr "Collapse" + +msgid "Configure columns" +msgstr "Configure columns" + +msgid "Select all columns" +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" + +msgid "Search all columns" +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" + +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 "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 " +"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 "" +"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" + +msgid "Join type for {{layer}}" +msgstr "Join type for {{layer}}" msgid "Org unit" msgstr "Org unit" -msgid "Org unit boundary" -msgstr "Org unit boundary" +msgid "Location" +msgstr "Location" + +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 "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" + +msgid "Combined" +msgstr "Combined" + +msgid "Configure reference org units" +msgstr "Configure reference org units" + +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 "Count ({{layer}})" +msgstr "Count ({{layer}})" + +msgid "{{name}} ({{unit}}) ({{layer}})" +msgstr "{{name}} ({{unit}}) ({{layer}})" + +msgid "%" +msgstr "%" + +msgid "count" +msgstr "count" -msgid "Event time" -msgstr "Event time" +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 "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." + +msgid "" +"Data table is not supported when there is more than one geometry type in " +"the dataset." +msgstr "" +"Data table is not supported when there is more than one geometry type in " +"the dataset." + +msgid "No valid data fields were found for this layer." +msgstr "No valid data fields were found for this layer." + +msgid "Loading Earth Engine data…" +msgstr "Loading Earth Engine data…" + +msgid "Loading additional events…" +msgstr "Loading additional events…" msgid "Items" msgstr "Items" @@ -261,9 +569,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" @@ -297,6 +602,9 @@ msgstr "thematic" msgid "org unit" msgstr "org unit" +msgid "reference org units" +msgstr "reference org units" + msgid "Earth Engine" msgstr "Earth Engine" @@ -315,6 +623,9 @@ 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)" @@ -622,9 +933,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" @@ -677,6 +985,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" @@ -695,9 +1006,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" @@ -771,18 +1079,12 @@ 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" -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" @@ -811,12 +1113,18 @@ msgstr "Could not retrieve event data" msgid "Organisation unit" msgstr "Organisation unit" +msgid "Event date" +msgstr "Event date" + msgid "Groups" msgstr "Groups" msgid "Parent unit" msgstr "Parent unit" +msgid "Level" +msgstr "Level" + msgid "Not set" msgstr "Not set" @@ -836,9 +1144,6 @@ msgstr "" msgid "no value" msgstr "no value" -msgid "All" -msgstr "All" - msgid "Loading data" msgstr "Loading data" @@ -945,6 +1250,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" @@ -1064,6 +1372,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" @@ -1085,9 +1396,6 @@ msgstr "Tracked Entity Type" msgid "By data element" msgstr "By data element" -msgid "Count" -msgstr "Count" - msgid "Average" msgstr "Average" @@ -1696,6 +2004,9 @@ msgstr "16-day" msgid "Since February 2000" msgstr "Since February 2000" +msgid "Index" +msgstr "Index" + msgid "NDVI" msgstr "NDVI" @@ -1925,6 +2236,36 @@ msgstr "Facility" msgid "GroupSet used for styling was not found" msgstr "GroupSet used for styling was not found" +msgid "Id" +msgstr "Id" + +msgid "Geometry type" +msgstr "Geometry type" + +msgid "Range" +msgstr "Range" + +msgid "Org unit boundary" +msgstr "Org unit boundary" + +msgid "Created" +msgstr "Created" + +msgid "Group" +msgstr "Group" + +msgid "Icon" +msgstr "Icon" + +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/package.json b/package.json index b0339d1e88..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": "^4.3.1", + "@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/__tests__/dataTable.spec.js b/src/actions/__tests__/dataTable.spec.js index bc50b2763d..130fbd7b87 100644 --- a/src/actions/__tests__/dataTable.spec.js +++ b/src/actions/__tests__/dataTable.spec.js @@ -3,6 +3,10 @@ import { closeDataTable, toggleDataTable, resizeDataTable, + setActiveTimelinePeriod, + toggleCombinedView, + setJoinConfig, + setCombinedColumnConfig, } from '../dataTable.js' describe('closeDataTable', () => { @@ -30,3 +34,45 @@ 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, + }) + }) +}) + +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 layers = { + layer1: { type: 'orgUnit', aggregation: {} }, + } + 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/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/actions/dataTable.js b/src/actions/dataTable.js index 392ebadf48..c7ff73b4d2 100644 --- a/src/actions/dataTable.js +++ b/src/actions/dataTable.js @@ -4,12 +4,72 @@ 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, }) + +export const setMapBounds = (bounds) => ({ + type: types.MAP_BOUNDS_CHANGED, + bounds, +}) + +export const toggleShowOnlyFeaturesInView = () => ({ + type: types.TOGGLE_SHOW_ONLY_IN_VIEW, +}) + +export const setSelectionFilter = (value) => ({ + type: types.SELECTION_FILTER_SET, + value, +}) + +export const setHighlightColor = (color) => ({ + type: types.HIGHLIGHT_COLOR_SET, + color, +}) + +export const setDataTableColumnConfig = (layerId, config) => ({ + type: types.DATA_TABLE_COLUMN_CONFIG_SET, + layerId, + config, +}) + +export const setActiveTimelinePeriod = (period) => ({ + type: types.ACTIVE_TIMELINE_PERIOD_SET, + period, +}) + +export const toggleCombinedView = () => ({ + type: types.DATA_TABLE_COMBINED_VIEW_TOGGLE, +}) + +export const setJoinConfig = (layerId, layers) => ({ + type: types.DATA_TABLE_JOIN_CONFIG_SET, + layerId, + layers, +}) + +export const setCombinedColumnConfig = (layerId, config) => ({ + type: types.DATA_TABLE_COMBINED_COLUMN_CONFIG_SET, + layerId, + config, +}) + +export const setCombinedVisibleIds = (idsByLayer) => ({ + type: types.COMBINED_VISIBLE_IDS_SET, + idsByLayer, +}) 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/layers.js b/src/actions/layers.js index 1af92b1d58..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 @@ -66,3 +67,9 @@ export const setLayerLoading = (id) => ({ type: types.LAYER_LOADING_SET, id, }) + +// 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/actions/selection.js b/src/actions/selection.js new file mode 100644 index 0000000000..603e9a0e9e --- /dev/null +++ b/src/actions/selection.js @@ -0,0 +1,28 @@ +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, +}) + +export const setCrossLayerSelection = (crossLayerIds) => ({ + type: types.SELECTION_SET_CROSS_LAYER, + crossLayerIds, +}) diff --git a/src/components/app/App.jsx b/src/components/app/App.jsx index 91a8f4ab13..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,7 +36,9 @@ const App = () => { const [interpretationsRenderCount, setInterpretationsRenderCount] = useState(1) - const dataTableOpen = useSelector((state) => !!state.dataTable) + const dataTableOpen = useSelector((state) => + isDataTableOpen(state.dataTable) + ) const downloadModeOpen = useSelector((state) => !!state.ui.downloadMode) const detailsPanelOpen = useSelector( (state) => state.ui.rightPanelOpen && !state.orgUnitProfile 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/app/FileMenu.jsx b/src/components/app/FileMenu.jsx index cfd1b6c188..4ec06b4464 100644 --- a/src/components/app/FileMenu.jsx +++ b/src/components/app/FileMenu.jsx @@ -70,7 +70,8 @@ const FileMenu = ({ onFileMenuAction }) => { 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( @@ -145,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, diff --git a/src/components/core/Checkbox.jsx b/src/components/core/Checkbox.jsx index 18a7798a40..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,7 +45,8 @@ Checkbox.propTypes = { dataTest: PropTypes.string, dense: PropTypes.bool, disabled: PropTypes.bool, - label: PropTypes.string, + indeterminate: PropTypes.bool, + label: PropTypes.node, style: PropTypes.object, tooltip: PropTypes.string, } 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/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/IconButton.jsx b/src/components/core/IconButton.jsx index c7b4e49b89..9ccb118bff 100644 --- a/src/components/core/IconButton.jsx +++ b/src/components/core/IconButton.jsx @@ -1,36 +1,45 @@ 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} + type="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> + ) + } +) + +IconButton.displayName = 'IconButton' IconButton.propTypes = { ariaLabel: PropTypes.string, 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 420a19d24a..d7eb5b53ec 100644 --- a/src/components/core/icons.jsx +++ b/src/components/core/icons.jsx @@ -1,5 +1,85 @@ +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-teal600)' + : 'var(--colors-grey500)' + } + points="4 9 12 9 8 14" + /> + <polygon + fill={ + direction === 'asc' + ? 'var(--colors-teal600)' + : '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 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/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/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/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/core/styles/FilterActiveIcon.module.css b/src/components/core/styles/FilterActiveIcon.module.css new file mode 100644 index 0000000000..826a5e5599 --- /dev/null +++ b/src/components/core/styles/FilterActiveIcon.module.css @@ -0,0 +1,40 @@ +.icon { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; +} + +.badge { + position: absolute; + bottom: 0; + right: 0; + width: 8px; + height: 8px; + background: var(--colors-grey100); +} + +:global(button):hover .badge { + background: var(--colors-grey300); +} + +.badge::before, +.badge::after { + content: ''; + position: absolute; + width: 5px; + height: 1px; + background: currentColor; + top: 50%; + left: 50%; +} + +.badge::before { + transform: translate(-50%, -50%) rotate(45deg); +} + +.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 bbdabed720..890e2b11f2 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -1,47 +1,271 @@ -import { IconCross16 } from '@dhis2/ui' +import i18n from '@dhis2/d2-i18n' +import { IconWarningFilled16, Tooltip } from '@dhis2/ui' import React, { useRef, useCallback, + useMemo, useState, useEffect, useLayoutEffect, } from 'react' import { useSelector, useDispatch } from 'react-redux' -import { closeDataTable, resizeDataTable } from '../../actions/dataTable.js' +import { clearDataFilters } from '../../actions/dataFilters.js' +import { + closeDataTable, + resizeDataTable, + toggleShowOnlyFeaturesInView, + setSelectionFilter, + setHighlightColor, + toggleDataTable, + toggleCombinedView, + setJoinConfig, + setCombinedColumnConfig, + setDataTableColumnConfig, + setActiveDataTableLayer, +} 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 { 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' +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 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' import DataTable from './DataTable.jsx' import ErrorBoundary from './ErrorBoundary.jsx' -import ResizeHandle from './ResizeHandle.jsx' import styles from './styles/BottomPanel.module.css' -// Container for DataTable +const MIN_HEIGHT = 50 +const EMPTY_FILTERS = {} +const EMPTY_JOIN_LAYERS = {} + const BottomPanel = () => { + const { + systemSettings: { keyAnalysisDigitGroupSeparator }, + } = useCachedData() const dataTableHeight = useSelector((state) => state.ui.dataTableHeight) + const { + openIds, + combinedView, + activeLayerId: storedActiveLayerId, + } = useSelector((state) => state.dataTable) + const mapViews = useSelector((state) => state.map.mapViews) + const activeLayerId = + storedActiveLayerId && openIds.includes(storedActiveLayerId) + ? storedActiveLayerId + : openIds[openIds.length - 1] ?? null + + 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 + + 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]), + [mapViews, joinLayersConfig] + ) + const filteredCombinedLayers = useMemo( + () => + combinedLayers.filter( + (l) => Object.keys(l.dataFilters ?? {}).length > 0 + ), + [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 + const showOnlyFeaturesInView = useSelector( + (state) => state.ui.showOnlyFeaturesInView + ) + const selectionFilter = useSelector((state) => state.ui.selectionFilter) + const highlightColor = useSelector((state) => state.ui.highlightColor) const dispatch = useDispatch() 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) + const [isCollapsed, setIsCollapsed] = useState(false) + const [globalSearch, setGlobalSearch] = useState('') + const [headersByLayer, setHeadersByLayer] = useState(null) + const [combinedFilters, setCombinedFilters] = useState(EMPTY_FILTERS) + + const hasActiveFilters = combinedView + ? Object.keys(combinedFilters).length > 0 || + !!globalSearch.trim() || + showOnlyFeaturesInView || + !!selectionFilter?.length + : hasActiveDataTableFilters({ + dataFilters, + globalSearch, + selectionFilter, + showOnlyFeaturesInView, + }) - const maxHeight = - height - getCssVar('--header-height') - getCssVar('--toolbar-height') - const tableHeight = - dataTableHeight < maxHeight ? dataTableHeight : maxHeight - const onResize = useCallback((h) => { + 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), + [] + ) + + const onControlsDoubleClick = useCallback( + (e) => { + if ( + e.target.closest('button, input, label, select') || + !e.currentTarget.contains(e.target) + ) { + return + } + toggleCollapsed() + }, + [toggleCollapsed] + ) + + const onResizeStart = useCallback(() => { + isDraggingRef.current = true + preDragCollapsedRef.current = isCollapsed + }, [isCollapsed]) + + 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) => { + isDraggingRef.current = false + if (h <= MIN_HEIGHT) { + setIsCollapsed(true) + } else { + setIsCollapsed(false) + dispatch(resizeDataTable(h)) + } + }, + [dispatch] + ) + + const onResizeCancel = useCallback(() => { + isDraggingRef.current = false + setIsCollapsed(preDragCollapsedRef.current) document.documentElement.style.setProperty( '--data-table-height', - `${h}px` + `${displayHeight}px` ) + }, [displayHeight]) + + const onCountChange = useCallback((total, filtered) => { + setTotalCount(total) + setFilteredCount(filtered) }, []) + const onHeadersChange = useCallback((headers, layerId) => { + setHeadersByLayer({ layerId, headers }) + }, []) + + const activeHeadersKey = combinedView ? COMBINED_HEADERS_KEY : activeLayerId + const allHeaders = + headersByLayer?.layerId === activeHeadersKey + ? headersByLayer.headers + : null + + const onClearFilters = useCallback(() => { + if (combinedView) { + setCombinedFilters(EMPTY_FILTERS) + } else { + dispatch(clearDataFilters(activeLayerId)) + } + if (showOnlyFeaturesInView) { + dispatch(toggleShowOnlyFeaturesInView()) + } + if (selectionFilter?.length) { + dispatch(setSelectionFilter([])) + } + setGlobalSearch('') + }, [ + dispatch, + activeLayerId, + showOnlyFeaturesInView, + selectionFilter, + combinedView, + ]) + + const onToggleShowOnlyFeaturesInView = useCallback(() => { + dispatch(toggleShowOnlyFeaturesInView()) + }, [dispatch]) + + const onCloseDataTable = useCallback(() => { + dispatch(closeDataTable()) + }, [dispatch]) + + const onHighlightColorChange = useCallback( + (color) => dispatch(setHighlightColor(color)), + [dispatch] + ) + useLayoutEffect(() => { + if (isDraggingRef.current) { + return + } document.documentElement.style.setProperty( '--data-table-height', - `${tableHeight}px` + `${displayHeight}px` ) - }, [tableHeight]) + }, [displayHeight]) useLayoutEffect( () => () => @@ -53,9 +277,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) @@ -63,7 +291,7 @@ const BottomPanel = () => { return () => observer.disconnect() }, []) - useKeyDown('Escape', () => dispatch(closeDataTable()), true) + useKeyDown('Escape', onCloseDataTable, true) return ( <div @@ -71,22 +299,180 @@ const BottomPanel = () => { className={styles.bottomPanel} data-test="bottom-panel" > - <div className={styles.dataTableControls}> - <ResizeHandle + <div + className={styles.dataTableControls} + onDoubleClick={onControlsDoubleClick} + > + <CollapseControl + isCollapsed={isCollapsed} + onClick={toggleCollapsed} + /> + <span className={styles.divider} /> + <LayerSelectorControl + layers={eligibleLayers} + activeLayerId={activeLayerId} + combinedView={combinedView} + onSelectLayer={(id) => { + dispatch(setActiveDataTableLayer(id)) + if (combinedView) { + dispatch(toggleCombinedView()) + } + if (!openIds.includes(id)) { + dispatch(toggleDataTable(id)) + } + }} + onSelectCombined={() => { + if (!combinedView) { + dispatch(toggleCombinedView()) + } + if (!combinedEnabled) { + openReferenceLayerEditor() + } + }} + /> + <span className={styles.divider} /> + <HighlightColorControl + color={highlightColor} + onChange={onHighlightColorChange} + /> + {combinedView ? ( + <> + <ColumnPickerControl + allHeaders={allHeaders} + columnConfig={combinedColumnConfig} + onChange={(config) => + dispatch( + setCombinedColumnConfig( + referenceLayer.id, + config + ) + ) + } + /> + <JoinLayersControl + eligibleLayers={eligibleLayers} + layersConfig={joinLayersConfig} + referenceLayer={referenceLayer} + onChange={(layers) => + dispatch( + setJoinConfig(referenceLayer.id, layers) + ) + } + /> + {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> + )} + {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} /> + </> + ) : ( + <> + <ColumnPickerControl + allHeaders={allHeaders} + columnConfig={activeLayer?.dataTableColumnConfig} + onChange={(config) => + dispatch( + setDataTableColumnConfig( + activeLayerId, + config + ) + ) + } + /> + <span className={styles.divider} /> + </> + )} + <ResizeHandleControl maxHeight={maxHeight} + minHeight={MIN_HEIGHT} + onResizeStart={onResizeStart} onResize={onResize} - onResizeEnd={(height) => dispatch(resizeDataTable(height))} + onResizeEnd={onResizeEnd} + onResizeCancel={onResizeCancel} + /> + <RowCountControl + totalCount={totalCount} + filteredCount={filteredCount} + keyAnalysisDigitGroupSeparator={ + keyAnalysisDigitGroupSeparator + } + /> + <span className={styles.divider} /> + <ClearFiltersControl + disabled={!hasActiveFilters} + onClick={onClearFilters} + /> + <GlobalSearchControl + value={globalSearch} + onChange={setGlobalSearch} + /> + <ShowInViewControl + active={showOnlyFeaturesInView} + onClick={onToggleShowOnlyFeaturesInView} /> - <button - className={styles.closeIcon} - onClick={() => dispatch(closeDataTable())} - > - <IconCross16 /> - </button> + <span className={styles.divider} /> + <CloseControl onClick={onCloseDataTable} /> </div> <div className={styles.tableContainer}> <ErrorBoundary> - <DataTable availableWidth={panelWidth} /> + {combinedView ? ( + <CombinedDataTable + availableWidth={panelWidth} + layers={combinedLayers} + referenceLayer={referenceLayer} + joinConfig={combinedJoinConfig} + filters={combinedFilters} + onFiltersChange={setCombinedFilters} + globalSearch={globalSearch} + onCountChange={onCountChange} + onHeadersChange={onHeadersChange} + columnConfig={combinedColumnConfig} + /> + ) : ( + <DataTable + activeLayerId={activeLayerId} + availableWidth={panelWidth} + onCountChange={onCountChange} + onHeadersChange={onHeadersChange} + globalSearch={globalSearch} + onClearFilters={onClearFilters} + /> + )} </ErrorBoundary> </div> </div> diff --git a/src/components/datatable/CellValue.jsx b/src/components/datatable/CellValue.jsx new file mode 100644 index 0000000000..90091575ed --- /dev/null +++ b/src/components/datatable/CellValue.jsx @@ -0,0 +1,73 @@ +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 { formatCellText } from '../../util/cellValue.js' +import styles from './styles/DataTable.module.css' + +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 { isIconCell } = getCellRendererFlags(renderer, type) + + if (isIconCell) { + return ( + <img + className={styles.iconCell} + src={value} + alt="" + onError={(e) => { + e.target.style.visibility = 'hidden' + }} + /> + ) + } + + return formatCellText(value, { + renderer, + type, + orgUnitIdToName, + 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 new file mode 100644 index 0000000000..5c4bcde8ef --- /dev/null +++ b/src/components/datatable/CombinedDataTable.jsx @@ -0,0 +1,650 @@ +import i18n from '@dhis2/d2-i18n' +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, 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 { + COMBINED_HEADERS_KEY, + ORG_UNIT_ID_DATA_KEY, +} from '../../constants/dataTable.js' +import { + isFilterable, + getRowId, + getUnionBounds, + mergeCrossLayerIds, +} 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 CombinedTableContextMenu from './CombinedTableContextMenu.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/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 { useRowContextMenuHighlight } from './useRowContextMenuHighlight.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 EmptyPlaceholder = () => ( + <tbody> + <tr> + <td colSpan={99999}> + <div className={styles.noResults}> + {i18n.t('No matching rows')} + </div> + </td> + </tr> + </tbody> +) + +const CombinedTableComponents = { + ...TableComponents, + EmptyPlaceholder, +} + +const CombinedDataTable = ({ + availableWidth, + layers, + referenceLayer, + joinConfig, + filters, + onFiltersChange, + globalSearch, + onCountChange, + onHeadersChange, + columnConfig, +}) => { + const dispatch = useDispatch() + const { + systemSettings: { keyAnalysisDigitGroupSeparator }, + } = useCachedData() + const aggregations = useSelector((state) => state.aggregations) + const showOnlyFeaturesInView = useSelector( + (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( + (state) => state.ui.lastClickedFeature + ) + + const { sortField, sortDirection, sortData } = useSortState('name') + + const initialCrossLayerIds = useSelector( + (state) => state.selection.crossLayerIds + ) + const [selectedIds, setSelectedIds] = useState( + () => initialCrossLayerIds?.[referenceLayer.id] ?? [] + ) + const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]) + + 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) + }, [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] + ) + + const visibleHeaders = useMemo( + () => getVisibleHeaders(headers, columnConfig) ?? [], + [headers, columnConfig] + ) + + const rendererByDataKey = useMemo( + () => new Map(visibleHeaders.map((h) => [h.dataKey, h.renderer])), + [visibleHeaders] + ) + const typeByDataKey = useMemo( + () => 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]) + + const [hoveredRowId, setHoveredRowId] = useState(null) + + const applySelection = useCallback( + (nextIds) => { + setSelectedIds(nextIds) + dispatch( + setCrossLayerSelection( + mergeCrossLayerIds(nextIds, rowFeatureIds) + ) + ) + }, + [dispatch, rowFeatureIds] + ) + + 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 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) + 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 onClearHighlight = useCallback(() => { + setHoveredRowId(null) + dispatch(highlightFeature(null)) + }, [dispatch]) + + const { onContextMenuOpen, guardedClear, onMenuClose } = + useRowContextMenuHighlight({ + onPin: setFeatureHighlight, + onClear: onClearHighlight, + }) + + 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 } = + useRowSelection({ + selectedIds, + selectedIdSet, + allRowIds, + onChange: applySelection, + }) + + const hasActiveFilters = + Object.keys(filters ?? EMPTY_FILTERS).length > 0 || + !!globalSearch?.trim() || + !!selectionFilter?.length + + const [tableContextMenu, setTableContextMenu] = useState(null) + + 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: guardedClear, + onRowClick, + onContextMenu: onRowContextMenu, + onRowDoubleClick, + layout: 'auto', + }), + [ + setFeatureHighlight, + guardedClear, + onRowClick, + onRowContextMenu, + onRowDoubleClick, + ] + ) + + const onFilterChange = useCallback( + (dataKey, value) => { + onFiltersChange?.({ + ...(filters ?? EMPTY_FILTERS), + [dataKey]: value, + }) + }, + [filters, onFiltersChange] + ) + + const onFilterClear = useCallback( + (dataKey) => { + const next = { ...(filters ?? EMPTY_FILTERS) } + delete next[dataKey] + onFiltersChange?.(next) + }, + [filters, onFiltersChange] + ) + + const fixedHeaderContent = useCallback( + () => ( + <DataTableRow ref={headerRowRef}> + <SelectionCheckboxHeaderCell + fixed={isCheckboxColumnPinned} + left={isCheckboxColumnPinned ? '0px' : undefined} + isAllSelected={isAllSelected} + 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, 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> + ), + [ + headerRowRef, + isCheckboxColumnPinned, + visibleHeaders, + pinnedLeftOffsets, + pinnedColumnCount, + columnWidths, + filters, + columnOptions, + onFilterChange, + onFilterClear, + sortData, + sortField, + sortDirection, + isAllSelected, + onToggleSelectAll, + onReverseSelection, + allRowIds, + selectionFilter, + dispatch, + orgUnitIdToName, + ] + ) + + return ( + <div className={styles.container} style={{ width: availableWidth }}> + {spatialWarning && ( + <div className={styles.spatialWarning}> + {i18n.t( + 'Location join over large datasets may be slow (over {{threshold}} features)', + { threshold: LARGE_FEATURE_THRESHOLD_LABEL } + )} + </div> + )} + <TableVirtuoso + ref={virtuosoRef} + context={tableContext} + components={CombinedTableComponents} + style={TABLE_STYLE} + data={rows} + fixedHeaderContent={fixedHeaderContent} + itemContent={(_, row) => { + const rowId = getRowId(row) + const isSelected = !!rowId && selectedIdSet.has(rowId) + const isHovered = + !!rowId && + (rowId === hoveredRowId || rowId === mapHoveredRowId) + 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)} + /> + {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, + } + )} + > + <CellValue + value={value} + renderer={rendererByDataKey.get( + dataKey + )} + type={typeByDataKey.get(dataKey)} + orgUnitIdToName={orgUnitIdToName} + keyAnalysisDigitGroupSeparator={ + keyAnalysisDigitGroupSeparator + } + /> + </DataTableCell> + ) + })} + </> + ) + }} + /> + <CombinedTableContextMenu + contextMenu={tableContextMenu} + layers={layers} + referenceLayer={referenceLayer} + rowFeatureIds={rowFeatureIds} + selectedIds={selectedIds} + filteredIds={hasActiveFilters ? allRowIds : null} + onClose={(highlightChanged) => { + setTableContextMenu(null) + onMenuClose(highlightChanged) + }} + /> + </div> + ) +} + +CombinedDataTable.propTypes = { + joinConfig: PropTypes.shape({ + layers: PropTypes.object, + }).isRequired, + layers: PropTypes.array.isRequired, + referenceLayer: PropTypes.object.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/CombinedTableContextMenu.jsx b/src/components/datatable/CombinedTableContextMenu.jsx new file mode 100644 index 0000000000..2a09e97191 --- /dev/null +++ b/src/components/datatable/CombinedTableContextMenu.jsx @@ -0,0 +1,112 @@ +import i18n from '@dhis2/d2-i18n' +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 { getUnionBounds, mergeCrossLayerIds } from '../../util/dataTable.js' +import { IconZoomIn16 } from '../core/icons.jsx' + +const CombinedTableContextMenu = ({ + contextMenu, + referenceLayer, + layers, + 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 allLayers = [referenceLayer, ...layers] + + const zoomTo = (idsByLayerId) => { + dispatch( + highlightFeature({ + layerId: null, + origin: 'table', + zoom: true, + bounds: getUnionBounds(allLayers, idsByLayerId), + crossLayerIds: idsByLayerId, + }) + ) + onClose(true) + } + + 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"> + <MenuItem + dataTest="combined-table-context-menu-zoom-to-feature" + label={i18n.t('Zoom to feature')} + icon={<IconZoomIn16 />} + disabled={!getUnionBounds(allLayers, 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 = { + layers: PropTypes.array.isRequired, + referenceLayer: PropTypes.object.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/DataTable.jsx b/src/components/datatable/DataTable.jsx index 3d92479c39..04168f8a03 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -1,171 +1,107 @@ import i18n from '@dhis2/d2-i18n' import { - DataTable, DataTableRow, DataTableCell, - DataTableColumnHeader, - DataTableHead, - DataTableBody, ComponentCover, CenteredContent, CircularLoader, } 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 { highlightFeature, setFeatureProfile } from '../../actions/feature.js' -import { setOrgUnitProfile } from '../../actions/orgUnits.js' -import { EVENT_LAYER, GEOJSON_URL_LAYER } from '../../constants/layers.js' +import { setDataFilter, clearDataFilter } from '../../actions/dataFilters.js' +import { + setCombinedVisibleIds, + 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, + ORG_UNIT_ID_DATA_KEY, +} from '../../constants/dataTable.js' import { isDarkColor } from '../../util/colors.js' -import { formatWithSeparator } from '../../util/numbers.js' +import { + buildFeatureIndex, + getLayerSelectedIds, + getRowId, + hasActiveDataTableFilters, + isFilterable, +} from '../../util/dataTable.js' +import { + getPinnedCellProps, + getPinnedCount, + getPinnedLeftOffsets, + getVisibleHeaders, +} from '../../util/tableColumns.js' import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' +import CellValue, { getCellRendererFlags } from './CellValue.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 { 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' -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 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 - onClick={() => context.onClick(item)} - onMouseEnter={() => context.onMouseEnter(item)} - onMouseLeave={context.onMouseLeave} - {...props} - /> -) - -DataTableRowWithVirtuosoContext.propTypes = { - context: PropTypes.shape({ - onClick: PropTypes.func, - onMouseEnter: PropTypes.func, - onMouseLeave: 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 TABLE_STYLE = { height: '100%', width: '100%' } +const VIEWPORT_OVERSCAN = { top: 400, bottom: 400 } -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> - ), -} - -const Table = ({ availableWidth }) => { +const Table = ({ + activeLayerId, + availableWidth, + onCountChange, + onHeadersChange, + globalSearch, + onClearFilters, +}) => { const { systemSettings: { keyAnalysisDigitGroupSeparator }, } = useCachedData() - const headerRowRef = useRef(null) - const [columnWidths, setColumnWidths] = useState([]) - const minColumnWidthsRef = useRef([]) + 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) - const [{ sortField, sortDirection }, setSorting] = useReducer( - (sorting, newSorting) => ({ ...sorting, ...newSorting }), - { - sortField: 'name', - sortDirection: ASCENDING, - } + const selection = useSelector((state) => state.selection) + const showOnlyFeaturesInView = useSelector( + (state) => state.ui.showOnlyFeaturesInView ) + const mapBounds = useSelector((state) => state.ui.mapBounds) + const selectionFilter = useSelector((state) => state.ui.selectionFilter) + const { sortField, sortDirection, sortData } = useSortState('name') const layer = mapViews.find((l) => l.id === activeLayerId) - const sortData = useCallback( - ({ name }) => { - setSorting({ - sortField: name, - sortDirection: - 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 - id && dispatch(setOrgUnitProfile(id)) - } - }, - [dispatch, layer] - ) + // 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 const setFeatureHighlight = useCallback( (row) => { - const id = - row.find((r) => r.dataKey === 'id')?.value || row[0].itemId + const id = getRowId(row) + const currentFeature = featureRef.current - if (!id || !feature || id !== feature.id) { + if (!id || !currentFeature || id !== currentFeature.id) { dispatch( highlightFeature( id @@ -179,123 +115,355 @@ const Table = ({ availableWidth }) => { ) } }, - [feature, dispatch, layer.id] + [dispatch, layer.id] + ) + const onClearHighlight = useCallback( + () => dispatch(highlightFeature(null)), + [dispatch] ) - const clearFeatureHighlight = useCallback( - (event) => { - if (shouldClearFeatureHighlight(event)) { - dispatch(highlightFeature(null)) + const { onContextMenuOpen, guardedClear, onMenuClose } = + useRowContextMenuHighlight({ + onPin: setFeatureHighlight, + onClear: onClearHighlight, + }) + + const featureById = useMemo( + () => buildFeatureIndex(layer.data), + [layer.data] + ) + + const [tableContextMenu, setTableContextMenu] = useState(null) + + const onRowContextMenu = useCallback( + (e, row) => { + e.preventDefault() + onContextMenuOpen(row) + const id = getRowId(row) + const feature = featureById.get(id) + setTableContextMenu({ + x: e.clientX, + y: e.clientY, + featureProps: feature?.properties ?? { id }, + }) + }, + [featureById, onContextMenuOpen] + ) + + const selectedIds = useMemo( + () => getLayerSelectedIds(selection, layer.id), + [selection, layer.id] + ) + const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]) + + const { + headers, + rows, + isLoading, + loadingReason, + error, + totalCount, + filteredCount, + columnOptions, + orgUnitIdToName, + } = useTableData({ + layer, + sortField, + sortDirection, + showOnlyFeaturesInView, + mapBounds, + selectionFilter, + selectedIdSet, + globalSearch, + keyAnalysisDigitGroupSeparator, + }) + + useEffect(() => { + onHeadersChange?.(headers, layer.id) + }, [onHeadersChange, headers, layer.id]) + + const columnConfig = layer.dataTableColumnConfig + const pinnedKeys = useMemo( + () => columnConfig?.pinnedKeys ?? [], + [columnConfig] + ) + + const visibleHeaders = useMemo( + () => getVisibleHeaders(headers, columnConfig) ?? [], + [headers, columnConfig] + ) + + const rendererByDataKey = useMemo( + () => new Map(visibleHeaders.map((h) => [h.dataKey, h.renderer])), + [visibleHeaders] + ) + + const typeByDataKey = useMemo( + () => new Map(visibleHeaders.map((h) => [h.dataKey, h.type])), + [visibleHeaders] + ) + + const { headerRowRef, columnWidths } = useColumnWidths({ + availableWidth, + headers: visibleHeaders, + error, + }) + + 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?.(totalCount, filteredCount) + }, [onCountChange, totalCount, filteredCount]) + + 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) => { + const id = getRowId(row) + + if (!id) { + return } + + dispatch( + highlightFeature({ + id, + layerId: layer.id, + origin: 'table', + zoom: true, + }) + ) }, - [dispatch] + [dispatch, layer.id] + ) + + const hasActiveFilters = hasActiveDataTableFilters({ + dataFilters: layer.dataFilters, + globalSearch, + selectionFilter, + showOnlyFeaturesInView, + }) + + const showServerClusterAction = + layer.serverCluster && !layer.forceClientCluster + + const onForceClientCluster = useCallback( + () => dispatch(setForceClientCluster(layer.id)), + [dispatch, layer.id] ) const tableContext = useMemo( () => ({ - onClick: showDetailView, onMouseEnter: setFeatureHighlight, - onMouseLeave: clearFeatureHighlight, + onMouseLeave: guardedClear, + onContextMenu: onRowContextMenu, + onRowClick, + onRowDoubleClick, layout: columnWidths.length > 0 ? 'fixed' : 'auto', + totalCount, + hasActiveFilters, + onClearFilters, + showServerClusterAction, + onForceClientCluster, }), [ - showDetailView, setFeatureHighlight, - clearFeatureHighlight, + guardedClear, + onRowContextMenu, + onRowClick, + onRowDoubleClick, columnWidths, + totalCount, + hasActiveFilters, + onClearFilters, + showServerClusterAction, + onForceClientCluster, ] ) - const { headers, rows, isLoading, error } = useTableData({ - layer, - sortField, - sortDirection, - }) - + const lastClickedFeature = useSelector( + (state) => state.ui.lastClickedFeature + ) + const rowsRef = useRef(rows) + rowsRef.current = rows 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 measuredColumnWidths = [] - - for (const cell of headerRowRef.current.cells) { - const rect = cell.getBoundingClientRect() - measuredColumnWidths.push(Math.floor(rect.width)) - } - - minColumnWidthsRef.current = measuredColumnWidths - setColumnWidths(measuredColumnWidths) + 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', }) } - }, [columnWidths]) + }, [lastClickedFeature, layer.id]) - useEffect(() => { - // Reset to auto layout for re-measurement when headers change - if (!error) { - minColumnWidthsRef.current = [] - setColumnWidths([]) - } - }, [headers, error]) + const allRowIds = useMemo( + () => rows?.map(getRowId).filter(Boolean) ?? [], + [rows] + ) + const hasGlobalSearch = !!globalSearch?.trim() 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]) + dispatch( + setCombinedVisibleIds( + hasGlobalSearch ? { [layer.id]: allRowIds } : null + ) + ) + }, [dispatch, hasGlobalSearch, allRowIds, layer.id]) - if (error) { - return <p className={styles.noSupport}>{error}</p> - } + useEffect( + () => () => { + dispatch(setCombinedVisibleIds(null)) + }, + [dispatch] + ) - return ( - <> - <TableVirtuoso - context={tableContext} - components={TableComponents} - style={{ - height: '100%', - width: '100%', - }} - data={rows} - fixedHeaderContent={() => ( - <DataTableRow ref={headerRowRef}> - {headers.map(({ name, dataKey, type }, index) => ( - <DataTableColumnHeader - className={styles.columnHeader} + 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, + onChange: onSelectionChange, + }) + + const computeItemKey = useCallback( + (index, row) => getRowId(row) ?? index, + [] + ) + + const fixedHeaderContent = useCallback( + () => ( + <DataTableRow ref={headerRowRef}> + <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={ + <SelectionFilterButton + value={selectionFilter ?? []} + onChange={(next) => + dispatch(setSelectionFilter(next)) + } + /> + } + /> + {visibleHeaders.map( + ({ name, dataKey, type, optionSet, renderer }, index) => { + const { fixed, left, isLastPinned } = + getPinnedCellProps(dataKey, index, { + pinnedLeftOffsets, + pinnedColumnCount, + columnWidths, + }) + return ( + <SortableColumnHeader key={`${dataKey}-${index}`} - onSortIconClick={sortData} - sortDirection={ - dataKey === sortField - ? sortDirection - : 'default' - } - sortIconTitle={i18n.t('Sort by {{column}}', { - column: name, + name={name} + dataKey={dataKey} + sortField={sortField} + sortDirection={sortDirection} + onSort={sortData} + dataTestPrefix="data-table-column-sort-button" + className={cx(styles.columnHeader, { + [styles.pinnedColumnShadow]: isLastPinned, })} - onFilterIconClick={type && Function.prototype} - showFilter={!!type && dataKey !== 'index'} - name={dataKey} + fixed={fixed} + left={left} + onFilterIconClick={ + isFilterable(dataKey, type) && + Function.prototype + } + showFilter={isFilterable(dataKey, type)} filter={ - type && ( + isFilterable(dataKey, type) && ( <FilterInput type={type} dataKey={dataKey} name={name} + options={columnOptions[dataKey]} + optionSetId={optionSet?.id} + resolvedOptionNames={ + optionSet?.id + ? layer + .optionSetOptionsByCode?.[ + optionSet.id + ] + : undefined + } + renderer={renderer} + orgUnitIdToName={orgUnitIdToName} + filterValue={ + layer.dataFilters?.[dataKey] + } + onChange={(value) => + dispatch( + setDataFilter( + activeLayerId, + dataKey, + value + ) + ) + } + onClear={() => + dispatch( + clearDataFilter( + activeLayerId, + dataKey + ) + ) + } /> ) } @@ -304,46 +472,185 @@ const Table = ({ availableWidth }) => { ? `${columnWidths[index]}px` : 'auto' } - > - {name} - </DataTableColumnHeader> - ))} - </DataTableRow> + /> + ) + } )} - itemContent={(_, row) => - row.map(({ dataKey, value, align }) => ( - <DataTableCell - key={`dtcell-${dataKey}`} - className={cx(styles.dataCell, { - [styles.lightText]: - dataKey === 'color' && isDarkColor(value), + </DataTableRow> + ), + [ + activeLayerId, + isCheckboxColumnPinned, + selectionFilter, + dispatch, + allRowIds, + onReverseSelection, + sortData, + sortField, + sortDirection, + visibleHeaders, + pinnedLeftOffsets, + layer.dataFilters, + layer.optionSetOptionsByCode, + pinnedColumnCount, + columnWidths, + columnOptions, + isAllSelected, + onToggleSelectAll, + headerRowRef, + orgUnitIdToName, + ] + ) + + if (error) { + return ( + <p className={styles.noSupport}> + {error} + <button + type="button" + className={styles.editLayerLink} + onClick={() => dispatch(editLayer(layer))} + > + {i18n.t('Edit layer')} + </button> + </p> + ) + } + + return ( + <> + <TableVirtuoso + ref={virtuosoRef} + context={tableContext} + components={TableComponents} + style={TABLE_STYLE} + data={rows} + computeItemKey={computeItemKey} + increaseViewportBy={VIEWPORT_OVERSCAN} + fixedHeaderContent={fixedHeaderContent} + itemContent={(_, row) => { + const rowId = getRowId(row) + const isSelected = !!rowId && selectedIdSet.has(rowId) + const isHovered = + !!rowId && + feature?.id === rowId && + feature?.layerId === layer.id + + 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)} + /> + {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, + }) + const renderer = rendererByDataKey.get(dataKey) + const type = typeByDataKey.get(dataKey) + const { isColorCell } = getCellRendererFlags( + renderer, + type + ) + return ( + <DataTableCell + key={`dtcell-${dataKey}`} + staticStyle + fixed={fixed} + left={left} + width={width} + className={cx(styles.dataCell, { + [styles.lightText]: + isColorCell && + isDarkColor(value), + [styles.monoCell]: + dataKey === 'id' || + dataKey === + ORG_UNIT_ID_DATA_KEY || + isColorCell, + [styles.selected]: + isSelected && !isColorCell, + [styles.hovered]: + isHovered && !isColorCell, + [styles.pinnedColumnShadow]: + isLastPinned, + })} + backgroundColor={ + isColorCell ? value : null + } + align={align} + > + <CellValue + value={value} + renderer={renderer} + type={type} + orgUnitIdToName={orgUnitIdToName} + keyAnalysisDigitGroupSeparator={ + keyAnalysisDigitGroupSeparator + } + /> + </DataTableCell> + ) })} - backgroundColor={dataKey === 'color' ? value : null} - align={align} - > - {dataKey === 'color' - ? value?.toLowerCase() - : formatWithSeparator( - value, - keyAnalysisDigitGroupSeparator - )} - </DataTableCell> - )) - } + </> + ) + }} /> - {isLoading && ( - <ComponentCover> + {(isLoading || layer?.isLoaded === false || layer?.isLoading) && ( + <ComponentCover translucent> <CenteredContent> - <CircularLoader /> + <div className={styles.loadingContent}> + <CircularLoader invert /> + {loadingReason && ( + <span className={styles.loadingReason}> + {loadingReason} + </span> + )} + </div> </CenteredContent> </ComponentCover> )} + <TableContextMenu + contextMenu={tableContextMenu} + layer={layer} + selectedIds={selectedIds} + filteredIds={hasActiveFilters ? allRowIds : null} + onClose={(highlightChanged) => { + setTableContextMenu(null) + onMenuClose(highlightChanged) + }} + /> </> ) } Table.propTypes = { + activeLayerId: PropTypes.string, availableWidth: PropTypes.number, + globalSearch: PropTypes.string, + onClearFilters: PropTypes.func, + onCountChange: PropTypes.func, + onHeadersChange: PropTypes.func, } export default Table diff --git a/src/components/datatable/DataTableButton.jsx b/src/components/datatable/DataTableButton.jsx new file mode 100644 index 0000000000..1750cba278 --- /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 { + closeDataTable, + openDataTable, + 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 = () => { + const dispatch = useDispatch() + 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 = () => { + if (isDataTableOpen(dataTable)) { + dispatch(closeDataTable()) + return + } + if (dataTable.openIds.length > 0 || dataTable.combinedView) { + dispatch(openDataTable()) + return + } + if (combinedEnabled) { + 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/DateGroupFilterInput.jsx b/src/components/datatable/DateGroupFilterInput.jsx new file mode 100644 index 0000000000..dda9c29aba --- /dev/null +++ b/src/components/datatable/DateGroupFilterInput.jsx @@ -0,0 +1,84 @@ +import i18n from '@dhis2/d2-i18n' +import PropTypes from 'prop-types' +import React, { useCallback } from 'react' +import { DATE_GROUPS_GRANULARITY } from '../../constants/dataTable.js' +import { + buildDateGroupTree, + formatNodeLabel, + getSearchMatches, +} from '../../util/dateGroups.js' +import { isDateGroupFilter } from '../../util/filter.js' +import GroupFilterPopover from './GroupFilterPopover.jsx' +import useGroupFilterInput from './useGroupFilterInput.js' + +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 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, { onChange }) => onChange(text) + +const DateGroupFilterInput = ({ + name, + onChange, + onClear, + filterValue, + options, + type, +}) => { + const buildTree = useCallback( + (realValues) => buildDateGroupTree(realValues, type), + [type] + ) + + const groupFilter = useGroupFilterInput({ + onChange, + onClear, + 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)} + /> + ) +} + +DateGroupFilterInput.propTypes = { + 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, + ]), +} + +export default DateGroupFilterInput 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/FilterDropdownPopover.jsx b/src/components/datatable/FilterDropdownPopover.jsx new file mode 100644 index 0000000000..676b95cddc --- /dev/null +++ b/src/components/datatable/FilterDropdownPopover.jsx @@ -0,0 +1,55 @@ +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 + +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, +}) => { + 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, + 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/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 16bab4a9c8..a05337e8e3 100644 --- a/src/components/datatable/FilterInput.jsx +++ b/src/components/datatable/FilterInput.jsx @@ -1,47 +1,675 @@ import i18n from '@dhis2/d2-i18n' -import { Input } from '@dhis2/ui' +import { Input, IconFilter16, IconSync16 } from '@dhis2/ui' +import cx from 'classnames' import PropTypes from 'prop-types' -import React from 'react' -import { useDispatch, useSelector } from 'react-redux' -import { setDataFilter, clearDataFilter } from '../../actions/dataFilters.js' +import React, { useCallback, useMemo, useRef, useState } from 'react' +import { Virtuoso } from 'react-virtuoso' +import { + SENTINEL_ANY_VALUE, + SENTINEL_NO_VALUE, + RENDERER_COLOR, + RENDERER_ICON, + RENDERER_ORG_UNIT, + RENDERER_ORG_UNIT_NAME, + RENDERER_BOOLEAN, + 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 { + getCyclicIndex, + getDisplayValue, + getFilteredOptions, + getPopoverWidth, + getSelectedAndAppliedString, + measureMaxTextWidth, + toHighlightedIndex, + toOptionIndex, + OPTION_ROW_HEIGHT, + MAX_LIST_HEIGHT, +} from '../../util/filterInput.js' +import { + getInvertibleValues, + reverseSelection, + toggleAnyValue, + toggleRealValue, +} from '../../util/filterSelection.js' +import { formatBoolean } from '../../util/helpers.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' +import { + FilterDropdownPopover, + getDropdownPlacement, +} from './FilterDropdownPopover.jsx' +import FilterHelpTooltip from './FilterHelpTooltip.jsx' +import OrgUnitGroupFilterInput from './OrgUnitGroupFilterInput.jsx' +import styles from './styles/FilterInput.module.css' -const FilterInput = ({ type, dataKey, name }) => { - const dispatch = useDispatch() - const dataTable = useSelector((state) => state.dataTable) - const map = useSelector((state) => state.map) +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> + <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> +) +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> +) +const NUMERIC_INPUT_DISALLOWED = /[^0-9.\-<>=,&\s]/g - const overlay = - dataTable && map.mapViews.filter((layer) => layer.id === dataTable)[0] +const SearchableFilterPopover = React.memo(function SearchableFilterPopover({ + dataKey, + name, + filterValue, + options, + resolveLabel, + type, + renderer, + allowCustomFilter = true, + onChange, + onClear, +}) { + const anchorRef = useRef(null) + const listRef = useRef(null) + const [isOpen, setIsOpen] = useState(false) + const [searchText, setSearchText] = useState('') + const [highlightedIndex, setHighlightedIndex] = useState(-1) - let layerId - let filters - if (overlay) { - layerId = overlay.id - filters = overlay.dataFilters || {} + const { selected, appliedString } = getSelectedAndAppliedString(filterValue) + + const openPopover = () => { + setSearchText(appliedString) + setHighlightedIndex(-1) + setIsOpen(true) } - const filterValue = filters[dataKey] || '' + const closePopover = () => setIsOpen(false) - const onChange = ({ value }) => - value !== '' - ? dispatch(setDataFilter(layerId, dataKey, value)) - : dispatch(clearDataFilter(layerId, dataKey, value)) + const anchorRect = anchorRef.current?.getBoundingClientRect() + const { dropdownPlacement, dropdownSide, tooltipPlacement } = + getDropdownPlacement(anchorRect) - return ( + const applyValues = (next) => (next.length ? onChange(next) : onClear()) + + const toggleValue = (value) => { + const next = selected.includes(value) + ? selected.filter((v) => v !== value) + : [...selected, value] + applyValues(next) + } + + const isOrgUnitRenderer = + renderer === RENDERER_ORG_UNIT || renderer === RENDERER_ORG_UNIT_NAME + + const applyCustomFilter = (text) => { + if (!text) { + onClear() + return + } + if (isOrgUnitRenderer) { + const lower = text.toLowerCase() + const values = realValues.filter((value) => + resolveLabel(value).toLowerCase().includes(lower) + ) + onChange({ + values, + searchDerived: true, + searchText: text, + }) + return + } + onChange(text) + } + + const isIconColumn = renderer === RENDERER_ICON + + 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 = useMemo( + () => options.some(({ value }) => value === SENTINEL_NO_VALUE), + [options] + ) + const realOptions = useMemo( + () => options.filter(({ value }) => value !== SENTINEL_NO_VALUE), + [options] + ) + const realValues = useMemo( + () => realOptions.map((o) => o.value), + [realOptions] + ) + 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 getPopoverWidth(maxLabelWidth) + }, [realOptions, hasNotSetOption, resolveLabel]) + + const onToggleAnyValue = () => applyValues(toggleAnyValue(selected)) + + const invertibleValues = useMemo( + () => getInvertibleValues(hasNotSetOption, realValues), + [hasNotSetOption, realValues] + ) + + const onToggleRealValue = (value) => + applyValues(toggleRealValue(selected, value, realValues)) + + const onReverseSelection = () => + applyValues(reverseSelection(selected, realValues, hasNotSetOption)) + + const trimmedSearch = searchText.trim() + const normalizedSearch = trimmedSearch.toLowerCase() + const filteredOptions = useMemo( + () => + getFilteredOptions({ + realOptions, + trimmedSearch, + normalizedSearch, + type, + resolveLabel, + }), + [realOptions, trimmedSearch, normalizedSearch, type, resolveLabel] + ) + const showCustomFilterRow = allowCustomFilter && normalizedSearch !== '' + const totalCount = filteredOptions.length + (showCustomFilterRow ? 1 : 0) + + const customFilterTag = + type === TYPE_NUMBER ? i18n.t('Use filter') : i18n.t('Contains') + + const hasActiveFilter = selected.length > 0 || appliedString !== '' + + const onSearchChange = ({ value }) => { + const sanitized = + type === TYPE_NUMBER + ? value.replace(NUMERIC_INPUT_DISALLOWED, '') + : value + setSearchText(sanitized) + setHighlightedIndex(-1) + + const trimmed = sanitized.trim() + if (trimmed === '') { + if (hasActiveFilter) { + onClear() + } + return + } + + if (!allowCustomFilter) { + return + } + + applyCustomFilter(trimmed) + } + + const scrollHighlightedIntoView = (index) => { + const optionIndex = toOptionIndex(index, showCustomFilterRow) + if (optionIndex >= 0 && optionIndex < filteredOptions.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 < filteredOptions.length) { + toggleValue(filteredOptions[optionIndex].value) + } + } + + const onSearchKeyDown = (_, event) => { + 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 'Enter': + event.preventDefault() + onEnterKey() + closePopover() + break + default: + break + } + } + + const displayValue = getDisplayValue({ + isOpen, + searchText, + selected, + appliedString, + }) + + const mainInput = ( <Input - dataTest={`data-table-column-filter-input-${name}`} dense - placeholder={type === 'number' ? '2,>3&<8' : i18n.t('Search')} - value={filterValue} - onChange={onChange} + clearable + dataTest={`data-table-column-filter-search-${name}`} + placeholder={ + type === TYPE_NUMBER + ? i18n.t('Search or type > 5, < 8…') + : i18n.t('Search') + } + value={displayValue} + onFocus={() => { + if (!isOpen) { + openPopover() + } + }} + onChange={onSearchChange} + onKeyDown={onSearchKeyDown} + /> + ) + + return ( + <div className={styles.filterTrigger} ref={anchorRef}> + <FilterHelpTooltip + content={ + type === TYPE_NUMBER + ? NUMERIC_FILTER_HELP + : TEXT_FILTER_HELP + } + placement={tooltipPlacement} + estimatedHeight={ + type === TYPE_NUMBER + ? NUMERIC_HELP_HEIGHT + : TEXT_HELP_HEIGHT + } + dataTest="data-table-filter-help" + > + {mainInput} + </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: `${popoverWidth}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}> + {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 + 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={resolveLabel(SENTINEL_NO_VALUE)} + checked={selected.includes( + SENTINEL_NO_VALUE + )} + onChange={() => + toggleValue(SENTINEL_NO_VALUE) + } + className={cx( + styles.specialOption, + styles.denseCheckbox + )} + 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 + ), + }} + increaseViewportBy={{ + top: 0, + bottom: OPTION_ROW_HEIGHT * 2, + }} + data={filteredOptions} + fixedItemHeight={OPTION_ROW_HEIGHT} + computeItemKey={(_, option) => option.value} + itemContent={(index, option) => ( + <Checkbox + label={renderOptionLabel( + option.value + )} + checked={ + anyValueActive || + selected.includes(option.value) + } + onChange={() => + onToggleRealValue(option.value) + } + className={cx( + styles.denseCheckbox, + (dataKey === 'id' || + dataKey === + ORG_UNIT_ID_DATA_KEY || + renderer === + RENDERER_COLOR) && + styles.monoOption, + highlightedIndex === + toHighlightedIndex( + index, + showCustomFilterRow + ) && styles.highlighted + )} + /> + )} + /> + )} + </div> + </div> + </FilterDropdownPopover> + )} + </div> + ) +}) + +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, + onChange: PropTypes.func.isRequired, + onClear: PropTypes.func.isRequired, + allowCustomFilter: PropTypes.bool, + filterValue: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.arrayOf(PropTypes.string), + ]), + renderer: PropTypes.string, +} + +const PlainSearchableFilter = (props) => { + const { type, renderer, orgUnitIdToName } = props + const { + systemSettings: { keyAnalysisDigitGroupSeparator }, + } = useCachedData() + + const resolveLabel = useCallback( + (value) => { + 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) + } + if (renderer === RENDERER_BOOLEAN) { + return formatBoolean(value) + } + return type === TYPE_NUMBER + ? formatWithSeparator( + Number(value), + keyAnalysisDigitGroupSeparator + ) + : value + }, + [type, renderer, orgUnitIdToName, keyAnalysisDigitGroupSeparator] + ) + + return <SearchableFilterPopover {...props} resolveLabel={resolveLabel} /> +} + +PlainSearchableFilter.propTypes = { + orgUnitIdToName: PropTypes.instanceOf(Map), + renderer: PropTypes.string, + type: PropTypes.string, +} + +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.name)) + return map + }, [resolvedOptionNames, optionSet]) + const resolveLabel = useCallback( + (value) => + value === SENTINEL_NO_VALUE + ? i18n.t('No value') + : optionByCode.get(value) ?? value, + [optionByCode] + ) + return ( + <SearchableFilterPopover + {...props} + resolveLabel={resolveLabel} + allowCustomFilter={false} /> ) } +OptionSetSearchableFilter.propTypes = { + optionSetId: PropTypes.string.isRequired, + resolvedOptionNames: PropTypes.object, +} + +const FilterInput = React.memo(function FilterInput({ + type, + dataKey, + name, + options, + optionSetId, + resolvedOptionNames, + renderer, + orgUnitIdToName, + filterValue, + onChange, + onClear, +}) { + const isDateType = + type === TYPE_DATE || type === TYPE_DATETIME || type === TYPE_TIME + + if (isDateType) { + return ( + <DateGroupFilterInput + name={name} + onChange={onChange} + onClear={onClear} + filterValue={filterValue} + options={options ?? []} + type={type} + /> + ) + } + + if (type === TYPE_ORG_UNIT) { + return ( + <OrgUnitGroupFilterInput + name={name} + onChange={onChange} + onClear={onClear} + filterValue={filterValue} + options={options ?? []} + idToName={orgUnitIdToName} + /> + ) + } + + if (optionSetId) { + return ( + <OptionSetSearchableFilter + dataKey={dataKey} + name={name} + filterValue={filterValue} + options={options ?? []} + optionSetId={optionSetId} + resolvedOptionNames={resolvedOptionNames} + type={type} + renderer={renderer} + onChange={onChange} + onClear={onClear} + /> + ) + } + + return ( + <PlainSearchableFilter + dataKey={dataKey} + name={name} + filterValue={filterValue} + options={options ?? []} + type={type} + renderer={renderer} + orgUnitIdToName={orgUnitIdToName} + onChange={onChange} + onClear={onClear} + /> + ) +}) + 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, + ]), + optionSetId: PropTypes.string, + options: PropTypes.arrayOf(PropTypes.shape({ value: PropTypes.string })), + orgUnitIdToName: PropTypes.instanceOf(Map), + renderer: PropTypes.string, + resolvedOptionNames: PropTypes.object, } export default FilterInput 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 new file mode 100644 index 0000000000..b658831854 --- /dev/null +++ b/src/components/datatable/OrgUnitGroupFilterInput.jsx @@ -0,0 +1,112 @@ +import i18n from '@dhis2/d2-i18n' +import PropTypes from 'prop-types' +import React, { useCallback } from 'react' +import { ORG_UNIT_GROUPS_GRANULARITY } from '../../constants/dataTable.js' +import { isOrgUnitGroupFilter } from '../../util/filter.js' +import { + buildOrgUnitGroupTree, + formatOrgUnitNodeLabel, + getOrgUnitSearchMatches, +} from '../../util/orgUnitGroups.js' +import { flattenAllNodes } from '../../util/prefixTree.js' +import GroupFilterPopover from './GroupFilterPopover.jsx' +import useGroupFilterInput from './useGroupFilterInput.js' + +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 getAppliedString = (filterValue) => { + if (isOrgUnitGroupFilter(filterValue)) { + return filterValue.searchDerived ? filterValue.searchText : '' + } + return typeof filterValue === 'string' ? filterValue : '' +} + +const parseFilterValue = (filterValue) => ({ + selectedPrefixes: + isOrgUnitGroupFilter(filterValue) && !filterValue.searchDerived + ? filterValue.prefixes + : [], + appliedString: getAppliedString(filterValue), +}) + +const OrgUnitGroupFilterInput = ({ + name, + onChange, + onClear, + filterValue, + options, + idToName, +}) => { + const getMatches = useCallback( + (tree, normalizedSearch) => + getOrgUnitSearchMatches(tree, normalizedSearch, idToName), + [idToName] + ) + + const commitSearch = useCallback( + (text, { tree, onChange: onChangeArg }) => { + 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) + onChangeArg({ + granularity: ORG_UNIT_GROUPS_GRANULARITY, + prefixes: matchedPrefixes, + searchDerived: true, + searchText: text, + }) + }, + [idToName] + ) + + const groupFilter = useGroupFilterInput({ + onChange, + onClear, + 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)} + /> + ) +} + +OrgUnitGroupFilterInput.propTypes = { + 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, + ]), +} + +export default OrgUnitGroupFilterInput diff --git a/src/components/datatable/ResizeHandle.jsx b/src/components/datatable/ResizeHandle.jsx deleted file mode 100644 index f591aac527..0000000000 --- a/src/components/datatable/ResizeHandle.jsx +++ /dev/null @@ -1,85 +0,0 @@ -import PropTypes from 'prop-types' -import React 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' - -const ResizeHandle = ({ - onResize, - onResizeEnd, - 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) - } - - evt.dataTransfer.setData('text/plain', 'node') // Required to initialize dragging in Firefox - - // https://stackoverflow.com/questions/23992091/drag-and-drop-directive-no-e-clientx-or-e-clienty-on-drag-event-in-firefox - document.ondragover = onDrag - } - - const onDrag = (evt) => { - const height = getHeight(evt || window.event) - - if (height && onResize) { - onResize(height) - dragHeight = height - } - } - - const onDragEnd = (evt) => { - const height = getHeight(evt) - - if (height && onResizeEnd) { - onResizeEnd(height) - } - - document.ondragover = null - } - - const getHeight = (evt) => { - if (evt.pageY) { - const height = window.innerHeight - evt.pageY - dragHeight = - height < minHeight - ? minHeight - : height > maxHeight - ? maxHeight - : height - } - - return dragHeight - } - - return ( - <div - className={styles.resizeHandle} - draggable={true} - onDragStart={(evt) => onDragStart(evt)} - onDragEnd={(evt) => onDragEnd(evt)} - > - <IconDrag /> - </div> - ) -} - -ResizeHandle.propTypes = { - maxHeight: PropTypes.number.isRequired, - minHeight: PropTypes.number, - onResize: PropTypes.func, - onResizeEnd: PropTypes.func, -} - -export default ResizeHandle diff --git a/src/components/datatable/SelectionCheckboxColumn.jsx b/src/components/datatable/SelectionCheckboxColumn.jsx new file mode 100644 index 0000000000..22a9171d12 --- /dev/null +++ b/src/components/datatable/SelectionCheckboxColumn.jsx @@ -0,0 +1,128 @@ +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' + +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/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/SortableColumnHeader.jsx b/src/components/datatable/SortableColumnHeader.jsx new file mode 100644 index 0000000000..9f2b4e3573 --- /dev/null +++ b/src/components/datatable/SortableColumnHeader.jsx @@ -0,0 +1,48 @@ +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' + +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/TableContextMenu.jsx b/src/components/datatable/TableContextMenu.jsx new file mode 100644 index 0000000000..f08a383553 --- /dev/null +++ b/src/components/datatable/TableContextMenu.jsx @@ -0,0 +1,238 @@ +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, + TRACKED_ENTITY_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, + selectedIds, + filteredIds, + 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 && + layerType !== TRACKED_ENTITY_LAYER + + const canViewProfile = + id && layerType !== EVENT_LAYER && layerType !== TRACKED_ENTITY_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 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} + onClick={() => { + dispatch( + updateLayer( + drillUpDown( + layer, + grandParentId, + grandParentParentGraph, + Number.parseInt(level) - 1 + ) + ) + ) + onClose() + }} + /> + )} + {canDrill && ( + <MenuItem + dataTest="data-table-context-menu-drill-down" + label={i18n.t('Drill down one level')} + icon={<IconArrowDown16 />} + disabled={!hasCoordinatesDown} + onClick={() => { + dispatch( + updateLayer( + drillUpDown( + layer, + id, + parentGraph, + Number.parseInt(level) + 1 + ) + ) + ) + onClose() + }} + /> + )} + {canViewProfile && ( + <MenuItem + dataTest="data-table-context-menu-view-profile" + 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 + dataTest="data-table-context-menu-zoom-to-feature" + label={i18n.t('Zoom to feature')} + icon={<IconZoomIn16 />} + onClick={() => { + dispatch( + highlightFeature({ + id, + layerId: layer.id, + origin: 'table', + zoom: true, + }) + ) + onClose(true) + }} + /> + )} + <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(true) + }} + /> + <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(true) + }} + /> + <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(true) + }} + /> + </Menu> + </Popover> + </> + ) +} + +TableContextMenu.propTypes = { + layer: PropTypes.object.isRequired, + onClose: PropTypes.func.isRequired, + contextMenu: PropTypes.shape({ + featureProps: PropTypes.object, + x: PropTypes.number, + y: PropTypes.number, + }), + filteredIds: PropTypes.array, + selectedIds: PropTypes.array, +} + +export default TableContextMenu diff --git a/src/components/datatable/TableVirtuosoComponents.jsx b/src/components/datatable/TableVirtuosoComponents.jsx new file mode 100644 index 0000000000..0acdaa1e88 --- /dev/null +++ b/src/components/datatable/TableVirtuosoComponents.jsx @@ -0,0 +1,126 @@ +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 = 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 = { + 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 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}> + {getEmptyPlaceholderContent(context)} + </div> + </td> + </tr> + </tbody> +) + +EmptyPlaceholder.propTypes = { + context: PropTypes.shape({ + hasActiveFilters: PropTypes.bool, + showServerClusterAction: PropTypes.bool, + totalCount: PropTypes.number, + onClearFilters: PropTypes.func, + onForceClientCluster: 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__/BottomPanel.spec.jsx b/src/components/datatable/__tests__/BottomPanel.spec.jsx new file mode 100644 index 0000000000..81b85af5bf --- /dev/null +++ b/src/components/datatable/__tests__/BottomPanel.spec.jsx @@ -0,0 +1,682 @@ +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' + +jest.mock('../../cachedDataProvider/CachedDataProvider.jsx', () => ({ + useCachedData: () => ({ + systemSettings: { keyAnalysisDigitGroupSeparator: 'COMMA' }, + }), +})) + +jest.mock('../DataTable.jsx', () => { + // eslint-disable-next-line react/prop-types + const DataTableMock = ({ activeLayerId }) => ( + <div data-test="datatable-mock">{activeLayerId}</div> + ) + DataTableMock.displayName = 'DataTableMock' + 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 +beforeAll(() => { + Element.prototype.setPointerCapture = jest.fn() + Element.prototype.releasePointerCapture = jest.fn() + global.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } +}) + +const DATA_TABLE_HEIGHT = 300 + +const DEFAULT_DATA_TABLE_STATE = { + openIds: ['layer1'], + combinedView: false, +} + +const DEFAULT_MAP_VIEWS = [ + { + id: 'layer1', + name: 'Layer 1', + layer: THEMATIC_LAYER, + isLoaded: true, + 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, + ui = {}, +} = {}) => { + const store = mockStore({ + ui: { + dataTableHeight: DATA_TABLE_HEIGHT, + showOnlyFeaturesInView: false, + selectionFilter: [], + highlightColor: null, + ...ui, + }, + dataTable, + map: { mapViews }, + }) + const { container } = render( + <Provider store={store}> + <WindowDimensionsProvider> + <BottomPanel /> + </WindowDimensionsProvider> + </Provider> + ) + return { + handle: container.querySelector('.resizeHandle'), + container, + store, + } +} + +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`) + }) +}) + +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`) + }) + + 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 = [ + { + id: 'layer1', + name: 'Layer 1', + combinedLayerKey: 'layer1', + layer: THEMATIC_LAYER, + isLoaded: true, + data: [{ properties: { orgUnitPath: '/country1/ou1' } }], + }, + { + id: 'layer2', + name: 'Layer 2', + combinedLayerKey: 'layer2', + layer: THEMATIC_LAYER, + isLoaded: true, + data: [{ properties: { orgUnitPath: '/country1/ou2' } }], + }, +] + +const getLayerSelector = () => screen.getByTestId('data-table-layer-selector') + +describe('BottomPanel layer selector', () => { + test('lists every eligible layer, whether or not its table is open, plus a Combined option', () => { + renderBottomPanel({ + dataTable: DEFAULT_DATA_TABLE_STATE, + mapViews: twoEligibleLayers, + }) + + expect(screen.getByText('Layer 1')).toBeInTheDocument() + expect(screen.getByText('Layer 2')).toBeInTheDocument() + 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 dispatches DATA_TABLE_ACTIVE_LAYER_SET', () => { + const { store } = renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1', 'layer2'], + }, + mapViews: twoEligibleLayers, + }) + + 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') + }) + + 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, + }) + + 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('sets the active layer but does not re-dispatch toggleDataTable when selecting an already-open layer', () => { + const { store } = renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1', 'layer2'], + }, + mapViews: twoEligibleLayers, + }) + + fireEvent.change(getLayerSelector(), { target: { value: 'layer1' } }) + + 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', () => { + 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('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, referenceLayer()], + }) + + fireEvent.change(getLayerSelector(), { + target: { value: SENTINEL_COMBINED_VALUE }, + }) + + expect(store.getActions()).toEqual([ + { type: 'DATA_TABLE_COMBINED_VIEW_TOGGLE' }, + ]) + }) + + 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'], + }, + mapViews: twoEligibleLayers, + }) + + fireEvent.change(getLayerSelector(), { + target: { value: SENTINEL_COMBINED_VALUE }, + }) + + expect(store.getActions()).toEqual([ + { type: 'DATA_TABLE_COMBINED_VIEW_TOGGLE' }, + { + type: 'LAYER_EDIT', + payload: { + layer: 'combinedTableRef', + isVisible: false, + rows: [], + }, + }, + ]) + }) + + 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'], + }, + mapViews: [...twoEligibleLayers, emptyReference], + }) + + fireEvent.change(getLayerSelector(), { + target: { value: SENTINEL_COMBINED_VALUE }, + }) + + expect(store.getActions()).toEqual([ + { type: 'DATA_TABLE_COMBINED_VIEW_TOGGLE' }, + { type: 'LAYER_EDIT', payload: emptyReference }, + ]) + }) +}) + +describe('BottomPanel Combined join controls', () => { + const combinedMapViews = [...twoEligibleLayers, referenceLayer()] + + test('shows the reference org unit control and join layers control while Combined is active', () => { + renderBottomPanel({ + dataTable: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1', 'layer2'], + combinedView: true, + }, + mapViews: combinedMapViews, + }) + + expect( + screen.getByLabelText('Configure reference org units') + ).toBeInTheDocument() + 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( + 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)', () => { + 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: ['layer1', 'layer2'], + combinedView: true, + }, + mapViews: combinedMapViews, + }) + + 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', + layerId: 'ref1', + layers: { + layer1: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, + }, + ]) + }) + + 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('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: { + ...DEFAULT_DATA_TABLE_STATE, + openIds: ['layer1', 'layer2'], + combinedView: true, + }, + mapViews: [ + ...twoEligibleLayers, + { + ...referenceLayer(), + combinedJoinConfig: { + layer1: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + layer2: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, + }, + ], + }) + + 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', + layerId: 'ref1', + layers: { + layer2: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, + }, + ]) + }) +}) + +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__/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__/ColumnPickerControl.spec.jsx b/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx new file mode 100644 index 0000000000..c79452a634 --- /dev/null +++ b/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx @@ -0,0 +1,435 @@ +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' + +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}> + <ColumnPickerControl + allHeaders={headers} + onChange={(config) => + store.dispatch(setDataTableColumnConfig('layer1', config)) + } + {...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() + }) + + 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', () => { + 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) + // 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', () => { + 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(() => { + 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'], + }, + }) + }) +}) + +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, + { + name: 'Value (Jan 2023)', + dataKey: 'period_202301_rawValue', + defaultHidden: true, + }, + ] + + test('appears in the main list, unchecked, when there is no saved config yet', () => { + renderColumnPicker({ allHeaders: headersWithHiddenColumn }) + openPicker() + expect(screen.getByLabelText('Value (Jan 2023)')).not.toBeChecked() + }) + + test('checking it dispatches visibleKeys with its dataKey added, alongside the other default-visible columns', () => { + const { store } = renderColumnPicker({ + allHeaders: headersWithHiddenColumn, + }) + openPicker() + fireEvent.click(screen.getByLabelText('Value (Jan 2023)')) + expect(store.getActions()).toContainEqual({ + type: DATA_TABLE_COLUMN_CONFIG_SET, + layerId: 'layer1', + config: { + visibleKeys: [ + 'name', + 'rawValue', + 'legend', + 'period_202301_rawValue', + ], + pinnedKeys: [], + orderedKeys: [ + 'name', + 'rawValue', + 'legend', + 'period_202301_rawValue', + ], + }, + }) + }) +}) diff --git a/src/components/datatable/__tests__/CombinedDataTable.spec.jsx b/src/components/datatable/__tests__/CombinedDataTable.spec.jsx new file mode 100644 index 0000000000..1f331df231 --- /dev/null +++ b/src/components/datatable/__tests__/CombinedDataTable.spec.jsx @@ -0,0 +1,1095 @@ +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 { COMBINED_HEADERS_KEY } from '../../../constants/dataTable.js' +import CombinedDataTable from '../CombinedDataTable.jsx' + +jest.mock('../../cachedDataProvider/CachedDataProvider.jsx', () => ({ + useCachedData: () => ({ + systemSettings: { keyAnalysisDigitGroupSeparator: 'COMMA' }, + }), +})) + +const mockStore = configureMockStore() + +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', + data: [], +} + +const renderCombinedDataTable = (props, selection = {}) => { + const store = mockStore({ ui: {}, selection }) + const result = render( + <Provider store={store}> + <VirtuosoMockContext.Provider + value={{ viewportHeight: 300, itemHeight: 28 }} + > + <CombinedDataTable + availableWidth={800} + layers={[]} + referenceLayer={EMPTY_REFERENCE_LAYER} + joinConfig={{ layers: {} }} + {...props} + /> + </VirtuosoMockContext.Provider> + </Provider> + ) + return { ...result, store } +} + +describe('CombinedDataTable', () => { + test('renders a column header per computed header and a cell per row', () => { + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [referenceFeature('ou1', 'Ou One', '/country1/ou1')], + } + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + data: [ + feature({ + orgUnitPath: '/country1/ou1', + rawValue: 10, + legend: 'Low', + }), + ], + }, + ] + + renderCombinedDataTable({ + referenceLayer, + layers, + joinConfig: { + layers: { + layerA: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, + }, + columnConfig: { + visibleKeys: ['id', 'name', 'layerA_rawValue', 'layerA_legend'], + }, + }) + + 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() + expect(screen.getByText('10')).toBeInTheDocument() + 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, + data: [referenceFeature('ou1', 'Ou One', '/country1/ou1')], + } + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + data: [ + feature({ + orgUnitPath: '/country1/ou1', + rawValue: 1234567, + }), + ], + }, + ] + + renderCombinedDataTable({ + referenceLayer, + layers, + joinConfig: { + layers: { + layerA: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, + }, + }) + + expect(screen.getByText('1,234,567')).toBeInTheDocument() + }) + + 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', + combinedLayerKey: 'layerA', + data: [feature({ orgUnitPath: '/country1/ou1' })], + }, + ] + + renderCombinedDataTable({ + referenceLayer, + layers, + joinConfig: { + layers: { + layerA: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, + }, + }) + + expect(screen.getAllByText('—').length).toBeGreaterThan(0) + }) + + test('shows the empty-results placeholder when there are no rows', () => { + renderCombinedDataTable() + + expect(screen.getByText('No matching rows')).toBeInTheDocument() + }) + + test('shows the spatial warning banner when a spatial join exceeds the large-feature threshold', () => { + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [ + { + type: 'Feature', + properties: { + id: 'poly1', + name: 'Region', + orgUnitPath: '/country1/poly1', + level: 2, + }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + [0, 0], + ], + ], + }, + }, + ], + } + const pointLayer = { + id: 'points', + name: 'Points', + combinedLayerKey: 'points', + data: Array.from({ length: 10001 }, (_, i) => ({ + type: 'Feature', + properties: { id: `p${i}` }, + geometry: { type: 'Point', coordinates: [1, 1] }, + })), + } + + renderCombinedDataTable({ + referenceLayer, + layers: [pointLayer], + joinConfig: { + layers: { + points: { + type: 'spatial', + aggregation: { rawValue: 'SUM' }, + }, + }, + }, + }) + + expect( + screen.getByText(/Location join over large datasets may be slow/) + ).toBeInTheDocument() + }) + + test('calls onCountChange with the row count', () => { + const onCountChange = jest.fn() + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [ + referenceFeature('ou1', 'Ou One', '/country1/ou1'), + referenceFeature('ou2', 'Ou Two', '/country1/ou2'), + ], + } + + renderCombinedDataTable({ + referenceLayer, + onCountChange, + }) + + expect(onCountChange).toHaveBeenCalledWith(2, 2) + }) + + 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', + combinedLayerKey: 'layerA', + data: [ + feature({ orgUnitPath: '/country1/ou1', rawValue: 20 }), + feature({ orgUnitPath: '/country1/ou2', rawValue: 10 }), + ], + }, + ] + + renderCombinedDataTable({ + referenceLayer, + layers, + joinConfig: { + layers: { + layerA: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, + }, + }) + + const rowsBefore = screen.getAllByRole('row').slice(1) + expect(rowsBefore[0]).toHaveTextContent('Ou One') + + fireEvent.click( + screen.getByTestId( + 'combined-table-column-sort-button-Value (Layer A)' + ) + ) + + const rowsAfter = screen.getAllByRole('row').slice(1) + expect(rowsAfter[0]).toHaveTextContent('Ou Two') + }) + + test('applies a per-column filter via onFiltersChange', () => { + const onFiltersChange = jest.fn() + const referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [ + referenceFeature('ou1', 'Ou One', '/country1/ou1'), + referenceFeature('ou2', 'Ou Two', '/country1/ou2'), + ], + } + + renderCombinedDataTable({ + referenceLayer, + filters: {}, + onFiltersChange, + columnConfig: { visibleKeys: ['id', 'name'] }, + }) + + const input = screen + .getByTestId('data-table-column-filter-search-Org unit id') + .querySelector('input') + fireEvent.focus(input) + fireEvent.change(input, { target: { value: 'ou1' } }) + + expect(onFiltersChange).toHaveBeenCalledWith({ id: 'ou1' }) + }) + + 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', + combinedLayerKey: 'layerA', + data: [ + feature({ + id: 'evtA1', + orgUnitPath: '/country1/ou1', + rawValue: 20, + }), + ], + }, + { + id: 'layerB', + name: 'Layer B', + combinedLayerKey: 'layerB', + data: [ + feature({ + id: 'evtB1', + orgUnitPath: '/country1/ou1', + rawValue: 5, + }), + ], + }, + ] + + const { store } = renderCombinedDataTable({ + referenceLayer, + layers, + joinConfig: { + layers: { + layerA: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + layerB: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, + }, + }) + + const dataRow = screen.getAllByRole('row')[1] + fireEvent.mouseEnter(dataRow) + + expect(store.getActions()).toContainEqual({ + type: 'FEATURE_HIGHLIGHT', + payload: { + layerId: null, + origin: 'table', + crossLayerIds: { + ref1: ['ou1'], + 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 referenceLayer = { + ...EMPTY_REFERENCE_LAYER, + data: [ + referenceFeature('ou1', 'Ou One', '/country1/ou1'), + referenceFeature('ou2', 'Ou Two', '/country1/ou2'), + ], + } + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + data: [ + feature({ + id: 'evt1', + orgUnitPath: '/country1/ou1', + rawValue: 20, + }), + feature({ + id: 'evt2', + orgUnitPath: '/country1/ou2', + rawValue: 10, + }), + ], + }, + ] + + const { store } = renderCombinedDataTable({ + referenceLayer, + layers, + joinConfig: { + layers: { + layerA: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, + }, + }) + + 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: { ref1: ['ou1'], layerA: ['evt1'] }, + }) + }) + + 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')], + } + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + data: [feature({ id: 'evt1', orgUnitPath: '/country1/ou1' })], + }, + ] + + const { store, unmount } = renderCombinedDataTable({ + referenceLayer, + layers, + joinConfig: { + layers: { + layerA: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, + }, + }) + + fireEvent.click(screen.getAllByRole('checkbox')[1]) + const actionsBeforeUnmount = store.getActions().length + unmount() + + // 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('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')], + } + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + data: [feature({ id: 'evt1', orgUnitPath: '/country1/ou1' })], + }, + ] + + renderCombinedDataTable( + { + referenceLayer, + layers, + joinConfig: { + layers: { + layerA: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, + }, + }, + { crossLayerIds: { ref1: ['ou1'], layerA: ['evt1'] } } + ) + + expect(screen.getAllByRole('checkbox')[1]).toBeChecked() + }) + + 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', + combinedLayerKey: 'layerA', + data: [feature({ orgUnitPath: '/country1/ou1', rawValue: 1 })], + }, + ] + + renderCombinedDataTable({ + referenceLayer, + layers, + joinConfig: { + layers: { + layerA: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, + }, + onHeadersChange, + }) + + expect(onHeadersChange).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ dataKey: 'id' }), + ]), + COMBINED_HEADERS_KEY + ) + }) + + 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', + combinedLayerKey: 'layerA', + data: [feature({ orgUnitPath: '/country1/ou1', rawValue: 20 })], + }, + ] + + renderCombinedDataTable({ + referenceLayer, + layers, + joinConfig: { + layers: { + layerA: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, + }, + columnConfig: { visibleKeys: ['id', 'name'] }, + }) + + 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, + data: [referenceFeature('ou1', 'Ou One', '/country1/ou1')], + } + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + data: [feature({ orgUnitPath: '/country1/ou1', rawValue: 20 })], + }, + ] + + renderCombinedDataTable({ + referenceLayer, + layers, + joinConfig: { + layers: { + layerA: { + type: 'orgUnit', + aggregation: { rawValue: 'SUM' }, + }, + }, + }, + columnConfig: { + pinnedKeys: ['level'], + visibleKeys: [ + 'level', + 'name', + 'layerA_rawValue', + 'layerA_legend', + ], + }, + }) + + const headerNames = screen + .getAllByRole('columnheader') + .map((el) => el.textContent) + .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('Org unit 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', + combinedLayerKey: 'layerA', + data: [ + feature({ + id: 'evtA1', + orgUnitPath: '/country1/ou1', + rawValue: 20, + }), + ], + }, + ] + + const store = mockStore({ + ui: {}, + selection: {}, + 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', + combinedLayerKey: 'layerA', + data: [ + feature({ + id: 'evtA1', + orgUnitPath: '/country1/ou1', + rawValue: 20, + }), + ], + }, + ] + + const store = mockStore({ + ui: {}, + selection: {}, + 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', + combinedLayerKey: 'layerA', + data: [ + feature({ + id: 'evtA1', + orgUnitPath: '/country1/ou1', + rawValue: 20, + }), + ], + }, + ] + + const store = mockStore({ + ui: { + lastClickedFeature: { + id: 'evtA1', + layerId: 'layerA', + multiSelect: false, + }, + }, + selection: {}, + }) + 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', + combinedLayerKey: 'layerA', + data: [ + feature({ + id: 'evtA1', + orgUnitPath: '/country1/ou1', + rawValue: 20, + }), + ], + }, + ] + + const store = mockStore({ + ui: { + lastClickedFeature: { + id: 'evtA1', + layerId: 'layerA', + multiSelect: true, + }, + }, + selection: {}, + }) + 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, + }, + }, + selection: {}, + }) + 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', + combinedLayerKey: 'layerA', + 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'] }, + selection: {}, + }) + 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__/CombinedTableContextMenu.spec.jsx b/src/components/datatable/__tests__/CombinedTableContextMenu.spec.jsx new file mode 100644 index 0000000000..7e9f6d0515 --- /dev/null +++ b/src/components/datatable/__tests__/CombinedTableContextMenu.spec.jsx @@ -0,0 +1,103 @@ +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 { 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 referenceLayer = { + id: 'ref1', + layer: 'combinedTableRef', + data: [point('ou1', [0, 0])], +} + +const layers = [ + { + id: 'layerA', + layer: THEMATIC_LAYER, + data: [point('a1', [5, 5])], + }, +] + +// 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 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} + referenceLayer={referenceLayer} + layers={layers} + rowFeatureIds={rowFeatureIds} + onClose={jest.fn()} + {...props} + /> + </Provider> + ) + return { ...result, store } +} + +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() + 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: { ref1: ['ou1'], layerA: ['a1'] }, + }, + }) + expect(onClose).toHaveBeenCalledWith(true) + }) + + 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: { ref1: ['ou1'], layerA: ['a1'] }, + }), + }) + ) + }) + + 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/datatable/__tests__/DataTable.spec.jsx b/src/components/datatable/__tests__/DataTable.spec.jsx index 5d18019348..6203a28cfa 100644 --- a/src/components/datatable/__tests__/DataTable.spec.jsx +++ b/src/components/datatable/__tests__/DataTable.spec.jsx @@ -1,27 +1,31 @@ -import { shouldClearFeatureHighlight } 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('getReversedSelection', () => { + test('selects every visible row when nothing is currently selected', () => { + expect(getReversedSelection([], ['a', 'b', 'c'])).toEqual([ + 'a', + 'b', + 'c', + ]) + }) -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) + test('deselects every visible row when all are currently selected', () => { + expect(getReversedSelection(['a', 'b', 'c'], ['a', 'b', 'c'])).toEqual( + [] + ) }) - test('does not clear when hovering to an adjacent row cell (TD)', () => { - expect( - shouldClearFeatureHighlight({ relatedTarget: { tagName: 'TD' } }) - ).toBe(false) + test('flips only the visible rows, keeping the rest of the selection as-is', () => { + expect(getReversedSelection(['a'], ['a', 'b', 'c'])).toEqual(['b', 'c']) }) - test('clears when leaving to a non-TD element', () => { - expect( - shouldClearFeatureHighlight({ relatedTarget: { tagName: 'DIV' } }) - ).toBe(true) + 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__/DataTableButton.spec.jsx b/src/components/datatable/__tests__/DataTableButton.spec.jsx new file mode 100644 index 0000000000..c311cce093 --- /dev/null +++ b/src/components/datatable/__tests__/DataTableButton.spec.jsx @@ -0,0 +1,135 @@ +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, + isLoaded: true, + data: [{}], + ...overrides, +}) + +const referenceLayer = ( + rows = [{ dimension: 'ou', items: [{ id: 'country1' }] }] +) => ({ + id: 'ref1', + layer: 'combinedTableRef', + rows, +}) + +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 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_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' }, + ]) + }) + + test('closes the panel when a single-layer table is already open', () => { + const { store } = renderButton({ + dataTable: { + openIds: ['a'], + combinedView: false, + isPanelVisible: true, + }, + mapViews: [layer('a'), layer('b')], + }) + fireEvent.click(screen.getByText('Data table')) + expect(store.getActions()).toEqual([{ type: 'DATA_TABLE_CLOSE' }]) + }) + + test('closes the panel when Combined is already open', () => { + const { store } = renderButton({ + 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/components/datatable/__tests__/DateGroupFilterInput.spec.jsx b/src/components/datatable/__tests__/DateGroupFilterInput.spec.jsx new file mode 100644 index 0000000000..643e89ffb6 --- /dev/null +++ b/src/components/datatable/__tests__/DateGroupFilterInput.spec.jsx @@ -0,0 +1,329 @@ +import { render, fireEvent, screen } from '@testing-library/react' +import React from 'react' +import { VirtuosoMockContext } from 'react-virtuoso' +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 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 onChange = jest.fn() + const onClear = jest.fn() + const result = render( + <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, onChange, onClear } +} + +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 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(onChange).toHaveBeenCalledWith({ + granularity: DATE_GROUPS_GRANULARITY, + prefixes: ['2023'], + }) + }) + + test('unchecking the only selected prefix calls onClear', () => { + const { onClear } = renderDateGroupFilter({ + filterValue: { + granularity: DATE_GROUPS_GRANULARITY, + prefixes: ['2023'], + }, + }) + openPopover() + fireEvent.click(screen.getByLabelText('2023')) + 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 { onChange } = renderDateGroupFilter({ + filterValue: { + granularity: DATE_GROUPS_GRANULARITY, + prefixes: ['2024'], + }, + }) + openPopover() + fireEvent.click(screen.getByLabelText('Expand 2023')) + fireEvent.click(screen.getByLabelText('May')) + expect(onChange).toHaveBeenCalledWith({ + 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" calls onChange with the sentinel and clears prior selections', () => { + const { onChange } = renderDateGroupFilter({ + options: [...DATETIME_VALUES, { value: SENTINEL_NO_VALUE }], + filterValue: { + granularity: DATE_GROUPS_GRANULARITY, + prefixes: ['2023'], + }, + }) + openPopover() + fireEvent.click(screen.getByLabelText('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 { onChange } = renderDateGroupFilter({ + options: [...DATETIME_VALUES, { value: SENTINEL_NO_VALUE }], + filterValue: { + granularity: DATE_GROUPS_GRANULARITY, + prefixes: ['2023'], + }, + }) + openPopover() + fireEvent.click(screen.getByLabelText('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 { onChange, onClear } = renderDateGroupFilter({ + filterValue: { + granularity: DATE_GROUPS_GRANULARITY, + prefixes: [SENTINEL_ANY_VALUE], + }, + }) + openPopover() + fireEvent.click(screen.getByLabelText('2023')) + 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") calls onClear', () => { + const { onClear } = renderDateGroupFilter({ + filterValue: { + granularity: DATE_GROUPS_GRANULARITY, + prefixes: ['2023'], + }, + }) + expect(getInput()).toHaveValue('1 selected') + fireEvent.change(getInput(), { target: { value: '' } }) + expect(onClear).toHaveBeenCalled() + }) + + 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'], + }, + }) + openPopover() + fireEvent.change(getInput(), { target: { value: '2023-05' } }) + expect(screen.queryByLabelText('2024')).not.toBeInTheDocument() + + fireEvent.change(getInput(), { target: { value: '' } }) + expect(onClear).toHaveBeenCalled() + }) + + 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(onChange).not.toHaveBeenCalled() + expect(onClear).not.toHaveBeenCalled() + }) +}) + +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 { 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(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 { 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(onChange).toHaveBeenCalledWith('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/components/datatable/__tests__/FilterInput.spec.jsx b/src/components/datatable/__tests__/FilterInput.spec.jsx new file mode 100644 index 0000000000..0543065656 --- /dev/null +++ b/src/components/datatable/__tests__/FilterInput.spec.jsx @@ -0,0 +1,1069 @@ +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 { setDataFilter, clearDataFilter } from '../../../actions/dataFilters.js' +import { + DATA_FILTER_SET, + DATA_FILTER_CLEAR, +} from '../../../constants/actionTypes.js' +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' + +jest.mock('../../../hooks/useOptionSet.js', () => ({ + __esModule: true, + default: jest.fn(), +})) + +jest.mock('../../cachedDataProvider/CachedDataProvider.jsx', () => ({ + useCachedData: () => ({ + systemSettings: { keyAnalysisDigitGroupSeparator: 'COMMA' }, + }), +})) + +const mockStore = configureMockStore() + +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}> + <VirtuosoMockContext.Provider + value={{ viewportHeight: 300, itemHeight: 28 }} + > + <FilterInput + 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> + </Provider> + ) + return { ...result, store } +} + +// The trigger and the dropdown's search field are the same <Input> +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.getByText( + 'Too many values to list - type to filter this column' + ) + ).toBeInTheDocument() + }) + + test('hides that hint once the user starts typing', () => { + renderFilterInput({}) + openPopover('Name') + fireEvent.change(getInput('Name'), { + target: { value: 'hospital' }, + }) + expect( + screen.queryByText( + 'Too many values to list - type to filter this column' + ) + ).not.toBeInTheDocument() + }) + + 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-filter-help-reference') + ).toBeInTheDocument() + }) + + test('also wraps the search input in a help tooltip for string columns', () => { + renderFilterInput({ type: 'string' }) + expect( + screen.getByTestId('data-table-filter-help-reference') + ).toBeInTheDocument() + }) +}) + +describe('FilterInput multi-select path (no optionSetId)', () => { + const options = [{ value: 'High' }, { value: 'Low' }] + + test('shows an empty input when nothing is selected', () => { + renderFilterInput({ dataKey: 'legend', name: 'Legend', options }) + expect(getInput('Legend')).toHaveValue('') + }) + + test('shows the selected count on the closed trigger', () => { + renderFilterInput( + { dataKey: 'legend', name: 'Legend', options }, + { legend: ['High'] } + ) + 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 }) + 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() + }) + + test('renders Icon column options as a thumbnail plus filename, not the raw URL', () => { + renderFilterInput({ + dataKey: 'iconUrl', + name: 'Icon', + renderer: 'rendericon', + 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', + 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() + }) + + 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 + fireEvent.click(yes) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'followUp', + filter: ['1'], + }) + }) +}) + +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', + }) + openPopover('Case classification') + 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', + }) + 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 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' }] + + 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('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.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', () => { + 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 — org-unit-flavored plain-text column', () => { + const options = [{ value: 'facility1' }, { value: 'facility2' }] + const orgUnitIdToName = new Map([ + ['facility1', 'Moyowa CHC'], + ['facility2', 'Tihun CHC'], + ]) + + 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' }] + + 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' }] + + test('clearing an active array (checkbox) filter closes it out', () => { + const { store } = renderFilterInput( + { dataKey: 'legend', name: 'Legend', options }, + { legend: ['High'] } + ) + 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 SENTINEL_ANY_VALUE', () => { + 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: [SENTINEL_ANY_VALUE], + }) + }) + + 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: [SENTINEL_ANY_VALUE] } + ) + 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: [SENTINEL_ANY_VALUE], + }) + }) + + 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: [SENTINEL_ANY_VALUE, ''], + }) + }) + + 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', SENTINEL_ANY_VALUE] } + ) + 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: [SENTINEL_ANY_VALUE] } + ) + 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') + 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: [SENTINEL_ANY_VALUE] } + ) + 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: [SENTINEL_ANY_VALUE] } + ) + 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: [SENTINEL_ANY_VALUE] } + ) + 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: [SENTINEL_ANY_VALUE, ''] } + ) + 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: [SENTINEL_ANY_VALUE], + }) + }) + + 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: [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: [SENTINEL_ANY_VALUE] } + ) + 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: [SENTINEL_ANY_VALUE, ''] } + ) + 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__/JoinLayersControl.spec.jsx b/src/components/datatable/__tests__/JoinLayersControl.spec.jsx new file mode 100644 index 0000000000..b9f7ecb664 --- /dev/null +++ b/src/components/datatable/__tests__/JoinLayersControl.spec.jsx @@ -0,0 +1,1020 @@ +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, +} from '../../../constants/layers.js' +import JoinLayersControl from '../controls/JoinLayersControl.jsx' + +const mockStore = configureMockStore() + +const eligibleLayers = [ + { + id: 'layer1', + name: 'Layer 1', + combinedLayerKey: 'layer1', + layer: THEMATIC_LAYER, + data: [], + }, + { + id: 'layer2', + name: 'Layer 2', + combinedLayerKey: 'layer2', + layer: THEMATIC_LAYER, + data: [ + { + properties: { orgUnitPath: '/country1/ou1' }, + geometry: { type: 'Point' }, + }, + ], + }, +] + +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')) + +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: [] }) + 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 — checkbox list', () => { + 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 joined layers as checked', () => { + renderControl({ + layersConfig: { + layer2: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + }) + openPicker() + + expect( + 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("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 Location join, not Org unit', () => { + const onChange = jest.fn() + renderControl({ + eligibleLayers: [ + { + id: 'geo', + name: 'Zones', + combinedLayerKey: 'geo', + 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({ + 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 — 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({ + layersConfig: { + layer1: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + }) + openPicker() + expandLayer('layer1') + + expect( + screen.getByLabelText('Join type for Layer 1') + ).toBeInTheDocument() + expect( + screen.getByLabelText('Aggregation type for Layer 1') + ).toBeInTheDocument() + expect( + screen.queryByLabelText('Join type for Layer 2') + ).not.toBeInTheDocument() + }) + + test('does not offer the Location 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() + expandLayer('layer1') + expandLayer('layer2') + + expect( + within(screen.getByLabelText('Join type for Layer 1')).queryByText( + 'Location' + ) + ).not.toBeInTheDocument() + expect( + within(screen.getByLabelText('Join type for Layer 2')).getByText( + 'Location' + ) + ).toBeInTheDocument() + }) + + 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: [ + { + id: 'geo', + name: 'Zones', + combinedLayerKey: 'geo', + layer: GEOJSON_URL_LAYER, + data: [{ geometry: { type: 'Polygon' } }], + }, + ], + layersConfig: { + geo: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + }) + openPicker() + expandLayer('geo') + + expect( + within(screen.getByLabelText('Join type for Zones')).getByText( + 'Location' + ) + ).toBeInTheDocument() + }) + + test('changing the join type dispatches onChange with the updated type', () => { + const onChange = jest.fn() + renderControl({ + layersConfig: { + layer2: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + onChange, + }) + openPicker() + expandLayer('layer2') + + fireEvent.change(screen.getByLabelText('Join type for Layer 2'), { + target: { value: 'spatial' }, + }) + + expect(onChange).toHaveBeenCalledWith({ + layer2: { type: 'spatial', aggregation: { rawValue: 'SUM' } }, + }) + }) + + test('changing the aggregation type dispatches onChange with the updated aggregation for that column', () => { + const onChange = jest.fn() + renderControl({ + layersConfig: { + layer1: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + onChange, + }) + openPicker() + expandLayer('layer1') + + fireEvent.change( + screen.getByLabelText('Aggregation type for Layer 1'), + { target: { value: 'AVERAGE' } } + ) + + expect(onChange).toHaveBeenCalledWith({ + layer1: { type: 'orgUnit', aggregation: { rawValue: 'AVERAGE' } }, + }) + }) + + 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', + name: 'NDVI', + combinedLayerKey: 'ee', + 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: 'AVERAGE', max: 'AVERAGE' }, + }, + }) + }) + + test('changing one Earth Engine stat column aggregation leaves the other stat column untouched', () => { + const onChange = jest.fn() + const eeLayer = { + id: 'ee', + name: 'NDVI', + combinedLayerKey: 'ee', + 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() + expandLayer('ee') + + 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' }, + }, + }) + }) + + 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() + expandLayer('timeline') + + 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', () => { + 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() + 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], + referenceLayer, + layersConfig: { + layer1: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + }) + openPicker() + expandLayer('layer1') + + 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() + expandLayer('layer1') + + 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() + expandLayer('layer1') + + 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() + expandLayer('layer1') + + 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() + expandLayer('layer1') + + 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() + expandLayer('layer1') + + 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: { + unit: 'Facility Type', + items: [ + { id: 'group1', name: 'Hospital' }, + { id: 'group2', name: 'Clinic' }, + ], + }, + data: [{ properties: { orgUnitPath: '/country1/ou1' } }], + } + + test('a count-only layer shows a static, layer-type-specific count label, not an aggregation-type select', () => { + renderControl({ + eligibleLayers: [countOnlyFacility], + layersConfig: { + facility1: { + type: 'orgUnit', + aggregation: { count: 'COUNT' }, + }, + }, + }) + openPicker() + expandLayer('facility1') + + expect(screen.getByText('Facilities count')).toBeInTheDocument() + expect( + screen.queryByLabelText('Aggregation type for Facilities') + ).not.toBeInTheDocument() + }) + + 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: { categoryDisplayType: 'COUNT' }, + }, + }, + onChange, + }) + openPicker() + expandLayer('facility1') + + 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(categorySelect).getByText('Percentage') + ).toBeInTheDocument() + expect(within(categorySelect).getByText('Count')).toBeInTheDocument() + + fireEvent.change(categorySelect, { target: { value: 'PERCENTAGE' } }) + + expect(onChange).toHaveBeenCalledWith({ + facility1: { + type: 'orgUnit', + 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() + expandLayer('facility1') + + expect(screen.getByText('Categories')).toBeInTheDocument() + }) + + 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() + expandLayer('layer1') + + 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() + }) +}) + +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', + }) + }) +}) + +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/__tests__/LayerSelectorControl.spec.jsx b/src/components/datatable/__tests__/LayerSelectorControl.spec.jsx new file mode 100644 index 0000000000..ed930be79d --- /dev/null +++ b/src/components/datatable/__tests__/LayerSelectorControl.spec.jsx @@ -0,0 +1,63 @@ +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 = [ + { id: 'layer1', name: 'Layer 1' }, + { id: 'layer2', name: 'Layer 2' }, +] + +const renderControl = (props) => + render( + <LayerSelectorControl + layers={layers} + activeLayerId="layer1" + combinedView={false} + onSelectLayer={jest.fn()} + onSelectCombined={jest.fn()} + {...props} + /> + ) + +const getSelect = () => screen.getByTestId('data-table-layer-selector') + +describe('LayerSelectorControl', () => { + test('lists every eligible 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 never disabled - selecting it before a reference is configured is the caller's job to handle", () => { + renderControl() + 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(SENTINEL_COMBINED_VALUE) + }) + + 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: SENTINEL_COMBINED_VALUE }, + }) + expect(onSelectCombined).toHaveBeenCalled() + }) +}) diff --git a/src/components/datatable/__tests__/OrgUnitGroupFilterInput.spec.jsx b/src/components/datatable/__tests__/OrgUnitGroupFilterInput.spec.jsx new file mode 100644 index 0000000000..eaa013406c --- /dev/null +++ b/src/components/datatable/__tests__/OrgUnitGroupFilterInput.spec.jsx @@ -0,0 +1,282 @@ +import { render, fireEvent, screen } from '@testing-library/react' +import React from 'react' +import { VirtuosoMockContext } from 'react-virtuoso' +import { + SENTINEL_ANY_VALUE, + SENTINEL_NO_VALUE, + ORG_UNIT_GROUPS_GRANULARITY, +} from '../../../constants/dataTable.js' +import OrgUnitGroupFilterInput from '../OrgUnitGroupFilterInput.jsx' + +const ORG_UNIT_VALUES = [ + { value: '/country1/region1/facility1' }, + { value: '/country1/region2/facility2' }, + { value: '/country2/facility3' }, +] + +const renderOrgUnitGroupFilter = (props) => { + const onChange = jest.fn() + const onClear = jest.fn() + const result = render( + <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, onChange, onClear } +} + +const getInput = () => + screen + .getByTestId('data-table-column-filter-search-Org unit') + .querySelector('input') + +const openPopover = () => fireEvent.focus(getInput()) + +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', () => { + renderOrgUnitGroupFilter({ + idToName: new Map([['country1', 'Sierra Leone']]), + }) + openPopover() + expect(screen.getByLabelText('Sierra Leone')).toBeInTheDocument() + expect(screen.queryByLabelText('country1')).not.toBeInTheDocument() + }) +}) + +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(onChange).toHaveBeenCalledWith({ + granularity: ORG_UNIT_GROUPS_GRANULARITY, + prefixes: ['/country1'], + }) + }) + + test('unchecking the only selected prefix calls onClear', () => { + const { onClear } = renderOrgUnitGroupFilter({ + filterValue: { + granularity: ORG_UNIT_GROUPS_GRANULARITY, + prefixes: ['/country1'], + }, + }) + openPopover() + fireEvent.click(screen.getByLabelText('country1')) + expect(onClear).toHaveBeenCalled() + }) +}) + +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" 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, + prefixes: ['/country1'], + }, + }) + openPopover() + fireEvent.click(screen.getByLabelText('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 { onChange, onClear } = renderOrgUnitGroupFilter({ + filterValue: { + granularity: ORG_UNIT_GROUPS_GRANULARITY, + prefixes: [SENTINEL_ANY_VALUE], + }, + }) + openPopover() + fireEvent.click(screen.getByLabelText('country1')) + expect(onChange).not.toHaveBeenCalled() + expect(onClear).not.toHaveBeenCalled() + }) +}) + +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', () => { + renderOrgUnitGroupFilter({ + idToName: new Map([['country1', 'Sierra Leone']]), + }) + 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 and applies a filter matching nothing, rather than clearing back to unfiltered', () => { + const { onChange, onClear } = renderOrgUnitGroupFilter() + openPopover() + fireEvent.change(getInput(), { target: { value: 'Nairobi' } }) + expect( + screen.getByTestId('data-table-column-filter-custom-Org unit') + ).toBeInTheDocument() + expect(onChange).toHaveBeenCalledWith({ + granularity: ORG_UNIT_GROUPS_GRANULARITY, + prefixes: [], + searchDerived: true, + searchText: 'Nairobi', + }) + expect(onClear).not.toHaveBeenCalled() + }) + + 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(onChange).toHaveBeenCalledWith({ + 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', () => { + renderOrgUnitGroupFilter({ + idToName: new Map([['country1', 'Sierra Leone']]), + 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__/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/__tests__/TableContextMenu.spec.jsx b/src/components/datatable/__tests__/TableContextMenu.spec.jsx new file mode 100644 index 0000000000..d0ba24e7ca --- /dev/null +++ b/src/components/datatable/__tests__/TableContextMenu.spec.jsx @@ -0,0 +1,105 @@ +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, + 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', () => ({ + useCachedData: () => ({ + systemSettings: { keyAnalysisDigitGroupSeparator: ',' }, + }), +})) + +const mockStore = configureMockStore() + +const layer = { id: 'layer1', layer: FACILITY_LAYER, name: 'Test layer' } +const contextMenu = { x: 10, y: 10, featureProps: {} } + +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 — 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 onClose = jest.fn() + const { store } = renderMenu({ + contextMenu: { x: 10, y: 10, featureProps: { id: 'ou1' } }, + onClose, + }) + fireEvent.click( + screen + .getByTestId('data-table-context-menu-view-profile') + .querySelector('a') + ) + expect(store.getActions()).toContainEqual({ + type: ORGANISATION_UNIT_PROFILE_SET, + payload: 'ou1', + }) + expect(onClose).toHaveBeenCalledWith() + }) +}) + +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).toHaveBeenCalledWith(true) + }) +}) 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() + }) +}) diff --git a/src/components/datatable/__tests__/useColumnWidths.spec.jsx b/src/components/datatable/__tests__/useColumnWidths.spec.jsx new file mode 100644 index 0000000000..66978648c6 --- /dev/null +++ b/src/components/datatable/__tests__/useColumnWidths.spec.jsx @@ -0,0 +1,113 @@ +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 + 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__/useCombinedTableData.spec.js b/src/components/datatable/__tests__/useCombinedTableData.spec.js new file mode 100644 index 0000000000..ea9b22da2b --- /dev/null +++ b/src/components/datatable/__tests__/useCombinedTableData.spec.js @@ -0,0 +1,1356 @@ +import { renderHook } from '@testing-library/react' +import { + EARTH_ENGINE_LAYER, + EVENT_LAYER, + FACILITY_LAYER, + THEMATIC_LAYER, +} from '../../../constants/layers.js' +import { useCombinedTableData } from '../useCombinedTableData.js' + +const feature = (props) => ({ properties: props }) + +const findCell = (row, dataKey) => row.find((c) => c.dataKey === dataKey) + +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', + name: 'Layer A', + combinedLayerKey: 'layerA', + data: [ + feature({ + id: 'ou1', + orgUnitPath: '/country1/ou1', + rawValue: 10, + legend: 'Low', + }), + ], + }, + ] + const joinConfig = { + layers: { + layerA: { type: 'orgUnit', aggregation: { rawValue: 'SUM' } }, + }, + } + + const { result } = renderHook(() => + useCombinedTableData({ layers, referenceLayer, joinConfig }) + ) + + expect(result.current.headers.map((h) => h.dataKey)).toEqual([ + 'id', + 'name', + 'level', + 'orgUnitPath', + '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( + (r) => findCell(r, 'id').value === 'ou1' + ) + expect(findCell(row1, 'name').value).toBe('Ou One') + expect(findCell(row1, 'layerA_rawValue').value).toBe(10) + expect(findCell(row1, 'layerA_legend').value).toBe('Low') + + const row2 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou2' + ) + expect(findCell(row2, 'layerA_rawValue').value).toBe(null) + }) + + test('takes the row name/level directly from the reference layer, not from the participating layer', () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + data: [ + feature({ + id: 'ou1', + name: 'Some other name', + orgUnitPath: '/country1/ou1', + level: 99, + rawValue: 10, + }), + ], + }, + ] + const joinConfig = { + layers: { layerA: { type: 'orgUnit', aggregation: {} } }, + } + + const { result } = renderHook(() => + useCombinedTableData({ layers, referenceLayer, joinConfig }) + ) + + const row1 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' + ) + expect(findCell(row1, 'name').value).toBe('Ou One') + expect(findCell(row1, 'level').value).toBe(2) + }) + + test('aggregates several descendant features under the reference org unit using the chosen aggregation type', () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + 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(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 = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + data: [ + feature({ + id: 'country1', + orgUnitPath: '/country1', + rawValue: 10, + }), + ], + }, + ] + const joinConfig = { + layers: { layerA: { type: 'orgUnit', aggregation: {} } }, + } + + const { result } = renderHook(() => + useCombinedTableData({ layers, referenceLayer, joinConfig }) + ) + + result.current.rows.forEach((row) => { + expect(findCell(row, 'layerA_rawValue').value).toBe(null) + }) + }) + + test('resolves legend to the shared value when every match agrees, otherwise blank', () => { + const agreeingLayer = { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + 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', + combinedLayerKey: 'layerB', + data: [ + feature({ + id: 'evt3', + orgUnitPath: '/country1/ou2/f1', + legend: 'Low', + }), + feature({ + id: 'evt4', + orgUnitPath: '/country1/ou2/f2', + legend: 'High', + }), + ], + } + const joinConfig = { + layers: { + layerA: { type: 'orgUnit', aggregation: {} }, + layerB: { type: 'orgUnit', aggregation: {} }, + }, + } + + const { result } = renderHook(() => + useCombinedTableData({ + layers: [agreeingLayer, disagreeingLayer], + referenceLayer, + joinConfig, + }) + ) + + 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) + }) + + test('includes rows from layer.dataWithoutCoords, matching the single-layer table', () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + data: [], + dataWithoutCoords: [ + feature({ + id: 'ou1', + orgUnitPath: '/country1/ou1', + rawValue: 10, + }), + ], + }, + ] + const joinConfig = { + layers: { layerA: { type: 'orgUnit', aggregation: {} } }, + } + + 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(10) + }) + + test('excludes features with hasAdditionalGeometry set', () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + data: [ + feature({ + id: 'extra', + orgUnitPath: '/country1/ou1', + rawValue: 999, + hasAdditionalGeometry: true, + }), + ], + }, + ] + const joinConfig = { + layers: { layerA: { type: 'orgUnit', aggregation: {} } }, + } + + 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(null) + }) + + test('rowFeatureIds always includes the reference layer itself, plus every matching participating feature', () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + data: [ + feature({ + id: 'evt1', + orgUnitPath: '/country1/ou1/f1', + rawValue: 10, + }), + feature({ + id: 'evt2', + orgUnitPath: '/country1/ou1/f2', + rawValue: 20, + }), + ], + }, + ] + const joinConfig = { + layers: { layerA: { type: 'orgUnit', aggregation: {} } }, + } + + const { result } = renderHook(() => + useCombinedTableData({ layers, referenceLayer, joinConfig }) + ) + + 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 referenceOrgUnitsAsPolygons = { + id: 'ref1', + data: [ + { + type: 'Feature', + properties: { id: 'ou1', name: 'Ou One', level: 2 }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + [0, 0], + ], + ], + }, + }, + ], + } + + test('joins a point feature to the reference org unit whose polygon contains it', () => { + const layers = [ + { + id: 'points', + name: 'Points', + combinedLayerKey: 'points', + layer: 'geoJsonUrl', + data: [ + { + type: 'Feature', + properties: { id: 'p1', rawValue: 42, legend: 'High' }, + geometry: { type: 'Point', coordinates: [1, 1] }, + }, + ], + }, + ] + const joinConfig = { + layers: { + points: { + type: 'spatial', + aggregation: { rawValue: 'SUM' }, + }, + }, + } + + 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(42) + expect(result.current.rowFeatureIds.get('ou1')).toEqual({ + ref1: ['ou1'], + points: ['p1'], + }) + }) + + test('matches an event feature via its centroid when its geometry is a polygon', () => { + const layers = [ + { + id: 'events', + name: 'Events', + combinedLayerKey: 'events', + layer: EVENT_LAYER, + data: [ + { + type: 'Feature', + properties: { id: 'e1' }, + 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 = { + layers: { + events: { type: 'spatial', aggregation: {} }, + }, + } + + const { result } = renderHook(() => + useCombinedTableData({ + layers, + referenceLayer: referenceOrgUnitsAsPolygons, + joinConfig, + }) + ) + + const row1 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' + ) + // 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', () => { + const layers = [ + { + id: 'zones', + name: 'Zones', + combinedLayerKey: '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 = { + layers: { + zones: { type: 'spatial', aggregation: { rawValue: 'SUM' } }, + }, + } + + const { result } = renderHook(() => + useCombinedTableData({ + layers, + referenceLayer: referenceOrgUnitsAsPolygons, + joinConfig, + }) + ) + + const row1 = result.current.rows.find( + (r) => findCell(r, 'id').value === 'ou1' + ) + expect(findCell(row1, 'zones_rawValue').value).toBe(3) + }) + + test('averages several points falling inside the same reference polygon', () => { + const layers = [ + { + id: 'points', + name: 'Points', + combinedLayerKey: 'points', + layer: 'geoJsonUrl', + 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', + combinedLayerKey: 'points', + layer: 'event', + data: Array.from({ length: 10001 }, (_, i) => ({ + type: 'Feature', + properties: { id: `p${i}` }, + geometry: { type: 'Point', coordinates: [1, 1] }, + })), + }, + ] + const joinConfig = { + layers: { points: { type: 'spatial', aggregation: {} } }, + } + + const { result } = renderHook(() => + useCombinedTableData({ + layers, + referenceLayer: referenceOrgUnitsAsPolygons, + joinConfig, + }) + ) + + expect(result.current.spatialWarning).toBe(true) + }) +}) + +describe('useCombinedTableData - sorting and filtering', () => { + const layers = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + data: [ + feature({ + id: 'ou1', + orgUnitPath: '/country1/ou1', + rawValue: 30, + }), + feature({ + id: 'ou2', + orgUnitPath: '/country1/ou2', + rawValue: 10, + }), + ], + }, + ] + const joinConfig = { + 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', + }) + ) + + expect(result.current.rows.map((r) => findCell(r, 'id').value)).toEqual( + ['ou2', 'ou1'] + ) + }) + + test('sorts rows by a numeric column descending', () => { + const { result } = renderHook(() => + useCombinedTableData({ + layers, + referenceLayer, + joinConfig, + sortField: 'layerA_rawValue', + sortDirection: 'desc', + }) + ) + + expect(result.current.rows.map((r) => findCell(r, 'id').value)).toEqual( + ['ou1', 'ou2'] + ) + }) + + test('applies a per-column filter', () => { + const { result } = renderHook(() => + useCombinedTableData({ + layers, + referenceLayer, + joinConfig, + filters: { layerA_rawValue: '>15' }, + }) + ) + + expect(result.current.rows.map((r) => findCell(r, 'id').value)).toEqual( + ['ou1'] + ) + }) + + test('applies global search across string columns', () => { + const { result } = renderHook(() => + useCombinedTableData({ + layers, + referenceLayer, + joinConfig, + globalSearch: 'Ou Two', + }) + ) + + expect(result.current.rows.map((r) => findCell(r, 'id').value)).toEqual( + ['ou2'] + ) + }) + + test('exposes distinct column values for the filter popover, sorted ascending by default', () => { + const { result } = renderHook(() => + useCombinedTableData({ layers, referenceLayer, joinConfig }) + ) + + expect(result.current.columnOptions.layerA_rawValue).toEqual([ + { value: '10' }, + { 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', () => { + test('returns an empty result when the reference layer has no org units', () => { + const joinConfig = { layers: {} } + + const { result } = renderHook(() => + useCombinedTableData({ + layers: [], + referenceLayer: { id: 'ref1', data: [] }, + joinConfig, + }) + ) + + expect(result.current).toEqual({ + headers: [], + rows: [], + rowFeatureIds: new Map(), + columnOptions: {}, + spatialWarning: false, + orgUnitIdToName: new Map(), + }) + }) + + 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', + 'orgUnitPath', + ]) + }) +}) + +describe('useCombinedTableData - Earth Engine value columns', () => { + // Earth Engine layers never carry their value(s) directly on feature + // 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 = [ + { + id: 'layerA', + name: 'Layer A', + combinedLayerKey: 'layerA', + 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', + 'orgUnitPath', + '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', + combinedLayerKey: 'layerA', + 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', + 'orgUnitPath', + '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) + }) +}) + +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', + 'orgUnitPath', + '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', + 'orgUnitPath', + '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', + 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'] + ) + }) +}) + +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 the shared categoryDisplayType setting switches every category column to a percentage together', () => { + 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: { categoryDisplayType: 'PERCENTAGE' }, + }, + }, + }, + }) + ) + const percentRow1 = percentResult.current.rows.find( + (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', () => { + 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("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', + 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: { categoryDisplayType: '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/__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__/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/__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/__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/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index aa8d588656..0a87d6eb13 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -2,14 +2,31 @@ import { renderHook } from '@testing-library/react' import React from 'react' import { Provider } from 'react-redux' import configureMockStore from 'redux-mock-store' +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 = { @@ -44,24 +61,130 @@ describe('useTableData headers', () => { ) const { headers, rows, isLoading } = result.current - expect(headers).toHaveLength(4) + 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' }, + { 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(4) + expect(rows[0]).toHaveLength(5) expect(rows[0]).toMatchObject([ - { value: 0, dataKey: 'index' }, - { 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) }) + 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: {}, @@ -96,23 +219,25 @@ 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' }, - { 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(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: undefined, dataKey: 'orgUnitOwn' }, { value: 3, dataKey: 'level' }, - { value: 'Bo', dataKey: 'parentName' }, + { value: undefined, dataKey: 'orgUnitPath' }, { value: 'MultiPolygon', dataKey: 'type' }, ]) expect(isLoading).toBe(false) @@ -156,41 +281,242 @@ 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: '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: '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: 'Color', dataKey: 'color', type: 'string', renderer: 'rendercolor', }, + { name: 'Geometry type', dataKey: 'type', type: 'string' }, ]) 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: undefined, dataKey: 'orgUnitOwn' }, + { value: 4, dataKey: 'level' }, + { value: undefined, dataKey: 'orgUnitPath' }, { 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: '#FFFFB2', dataKey: 'color' }, + { value: 'Point', dataKey: 'type' }, ]) expect(isLoading).toBe(false) }) + test('gets current-period Value/Legend/Range/Color for a timeline thematic layer', () => { + // 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', + 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: '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: '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([ + expect.objectContaining({ value: 200, dataKey: 'rawValue' }), + expect.objectContaining({ value: 'High', dataKey: 'legend' }), + expect.objectContaining({ + value: '150 – 250', + dataKey: 'range', + }), + ]) + ) + }) + + test('adds a defaultHidden raw-value-only column for every period, including the current one, for a timeline thematic layer', () => { + const store = { + aggregations: {}, + ui: { + activeTimelinePeriod: { id: '202302', name: 'February 2023' }, + }, + } + const layer = { + layer: 'thematic', + renderingStrategy: 'TIMELINE', + periods: [ + { id: '202301', name: 'January 2023' }, + { id: '202302', name: 'February 2023' }, + ], + 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', + 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', () => { + const store = { aggregations: {} } + const layer = { + layer: 'thematic', + renderingStrategy: 'SPLIT_BY_PERIOD', + periods: [{ id: '202301', name: 'January 2023' }], + valuesByPeriod: { + 202301: { 'ou-1': { value: 100 } }, + }, + 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: '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', + defaultHidden: true, + }, + ]) + 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: {}, @@ -258,28 +584,42 @@ describe('useTableData headers', () => { } ) const { headers, rows, isLoading } = result.current - expect(headers).toHaveLength(8) + expect(headers).toHaveLength(10) expect(headers).toMatchObject([ - { name: 'Index', dataKey: 'index', type: 'number' }, - { 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 time', + name: 'Event date', dataKey: 'eventdate', type: 'date', - renderer: 'formatTime...', + renderer: 'renderdate', + }, + { + name: 'Last updated', + dataKey: 'lastupdated', + type: 'datetime', + renderer: 'renderdate', }, - { name: 'Last updated on', dataKey: 'lastupdated', type: 'string' }, { 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(8) + expect(rows[0]).toHaveLength(10) expect(rows[0]).toMatchObject([ - { value: 0, dataKey: 'index' }, - { 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' }, @@ -289,31 +629,13 @@ describe('useTableData headers', () => { expect(isLoading).toBe(false) }) - test('treats NUMBER header with optionSet as string type', () => { + 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, - isExtended: true, - headers: [ - { - name: 'AbCdEfGhIjK', - column: 'Severity', - valueType: 'NUMBER', - optionSet: { id: 'xyz123' }, - }, - ], - data: [ - { - properties: { - id: 'evt1', - type: 'Point', - ouname: 'Test OU', - eventdate: '2023-01-01', - AbCdEfGhIjK: 'high', - }, - }, - ], + serverCluster: true, + isExtended: false, } const { result } = renderHook( @@ -330,35 +652,18 @@ describe('useTableData headers', () => { } ) - const { headers } = result.current - const severityHeader = headers.find((h) => h.dataKey === 'AbCdEfGhIjK') - expect(severityHeader.type).toBe('string') + expect(result.current.isLoading).toBe(false) + expect(result.current.loadingReason).toBeNull() }) - test('treats NUMBER header without optionSet as number type', () => { + test('shows "Loading additional events…" while forceClientCluster reload is in flight', () => { const store = { aggregations: {} } const layer = { layer: 'event', dataFilters: null, - isExtended: true, - headers: [ - { - name: 'AbCdEfGhIjK', - column: 'Score', - valueType: 'NUMBER', - }, - ], - data: [ - { - properties: { - id: 'evt2', - type: 'Point', - ouname: 'Test OU', - eventdate: '2023-01-01', - AbCdEfGhIjK: 42, - }, - }, - ], + serverCluster: true, + forceClientCluster: true, + isExtended: false, } const { result } = renderHook( @@ -375,54 +680,41 @@ describe('useTableData headers', () => { } ) - const { headers } = result.current - const scoreHeader = headers.find((h) => h.dataKey === 'AbCdEfGhIjK') - expect(scoreHeader.type).toBe('number') + expect(result.current.isLoading).toBe(true) + expect(result.current.loadingReason).toBe('Loading additional events…') }) - test('gets headers and rows for EE population layer', () => { + test('gets headers and rows for tracked entity layer', () => { const store = { - aggregations: { - eelayerid: { - boOU: { - mean: 47.34593724212383, - sum: 851090.567864418, - }, - bombaliOU: { - mean: 27.347820392739166, - sum: 585872.3562736511, - }, - }, - }, + aggregations: {}, } - const layer = { - layer: 'earthEngine', - aggregationType: ['sum', 'mean'], - legend: { - title: 'Population', - }, - id: 'eelayerid', + layer: 'trackedEntity', dataFilters: null, - data: [ + headers: [ { - id: 'boOU', - properties: { - type: 'Polygon', - id: 'boOu', - name: 'Bo', - }, + name: 'First name', + dataKey: 'w75KJ2mc4zz', + valueType: 'TEXT', }, { - id: 'bombaliOU', + name: 'Age', + dataKey: 'zDhUuAYrxNC', + valueType: 'NUMBER', + }, + ], + data: [ + { properties: { - type: 'Polygon', - id: 'bombaliOU', - name: 'Bombali', + id: 'PsgJS8BUxZd', + w75KJ2mc4zz: 'Gabrielle', + zDhUuAYrxNC: 28, + color: '#e57200', }, }, ], } + const { result } = renderHook( () => useTableData({ @@ -436,280 +728,1351 @@ describe('useTableData headers', () => { ), } ) - const { headers, rows, isLoading } = result.current - expect(headers).toHaveLength(6) + const { headers, rows, isLoading } = result.current + expect(headers).toHaveLength(11) 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' }, - { - name: 'Sum Population', - dataKey: 'sum', - // roundFn: Function.prototype, - type: 'number', - }, + { 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: 'Mean Population', - dataKey: 'mean', - // roundFn: Function.prototype, - type: 'number', + name: 'Org unit hierarchy', + 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(headers[4].roundFn).toBeInstanceOf(Function) - expect(headers[5].roundFn).toBeInstanceOf(Function) - expect(rows).toHaveLength(2) - expect(rows[0]).toHaveLength(6) + expect(rows).toHaveLength(1) + expect(rows[0]).toHaveLength(11) expect(rows[0]).toMatchObject([ - { value: 0, dataKey: 'index' }, - { value: 'Bo', dataKey: 'name' }, - { value: 'boOu', dataKey: 'id' }, - { value: 'Polygon', dataKey: 'type' }, - { value: 851091, dataKey: 'sum' }, - { value: 47.35, dataKey: 'mean' }, + { 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' }, + { value: undefined, dataKey: 'type' }, ]) expect(isLoading).toBe(false) }) - test('gets headers and rows for EE population age groups layer', () => { - const store = { - aggregations: { - eelayerid: { - badijaOU: { - M_0_mean: 0.4416957503717281, - M_0_sum: 279.5934099853039, - M_1_mean: 1.667343524395007, - M_1_sum: 1055.4284509420395, - M_5_mean: 1.8668244672235907, - M_5_sum: 1181.699887752533, - mean: 3.975863741990326, - sum: 2516.7217486798763, + test('treats NUMBER header with optionSet as string type', () => { + const store = { aggregations: {} } + const layer = { + layer: 'event', + dataFilters: null, + isExtended: true, + headers: [ + { + name: 'AbCdEfGhIjK', + column: 'Severity', + valueType: 'NUMBER', + optionSet: { id: 'xyz123' }, + }, + ], + data: [ + { + properties: { + id: 'evt1', + type: 'Point', + ouname: 'Test OU', + eventdate: '2023-01-01', + AbCdEfGhIjK: 'high', }, - baomaOU: { - M_0_mean: 0.6669754306043404, - M_0_sum: 1012.4687036573887, - M_1_mean: 2.517744771694477, - M_1_sum: 3821.9365634322166, - M_5_mean: 2.818359887764859, - M_5_sum: 4278.270309627056, - mean: 6.003080090063677, - sum: 9112.675576716661, + }, + ], + } + + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + <Provider store={mockStore(store)}>{children}</Provider> + ), + } + ) + + const { headers } = result.current + const severityHeader = headers.find((h) => h.dataKey === 'AbCdEfGhIjK') + expect(severityHeader.type).toBe('string') + }) + + test('treats NUMBER header without optionSet as number type', () => { + const store = { aggregations: {} } + const layer = { + layer: 'event', + dataFilters: null, + isExtended: true, + headers: [ + { + name: 'AbCdEfGhIjK', + column: 'Score', + valueType: 'NUMBER', + }, + ], + data: [ + { + properties: { + id: 'evt2', + type: 'Point', + ouname: 'Test OU', + eventdate: '2023-01-01', + AbCdEfGhIjK: 42, }, }, - }, + ], } + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + <Provider store={mockStore(store)}>{children}</Provider> + ), + } + ) + + const { headers } = result.current + const scoreHeader = headers.find((h) => h.dataKey === 'AbCdEfGhIjK') + 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: 'earthEngine', - name: 'Population age groups', - aggregationType: ['sum', 'mean'], - id: 'eelayerid', + layer: 'event', + dataFilters: null, + isExtended: true, + styleDataItem: { id: 'AbCdEfGhIjK' }, legend: { - title: 'Population age groups', - groups: [ - { - id: 'M_0', - name: 'Male 0 - 1 years', - }, - { - id: 'M_1', - name: 'Male 1 - 4 years', - }, - { - id: 'M_5', - name: 'Male 5 - 9 years', - }, - ], items: [ { - color: '#fee5d9', - from: 0, - to: 10, - name: '0 - 10', - }, - { - color: '#fcbba1', - from: 10, - to: 20, - name: '10 - 20', - }, - { - color: '#fc9272', - from: 20, - to: 30, - name: '20 - 30', + name: 'Low', + color: '#aaaaaa', + startValue: 0, + endValue: 50, + colorGroup: 0, }, { - color: '#fb6a4a', - from: 30, - to: 40, - name: '30 - 40', + name: 'High', + color: '#bbbbbb', + startValue: 50, + endValue: 100, + colorGroup: 1, }, - { - color: '#de2d26', - from: 40, - to: 50, - name: '40 - 50', + ], + }, + 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: [ { - color: '#a50f15', - from: 50, - name: '> 50', + 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: { + eelayerid: { + boOU: { + mean: 47.34593724212383, + sum: 851090.567864418, + }, + bombaliOU: { + mean: 27.347820392739166, + sum: 585872.3562736511, + }, + }, + }, + } + + const layer = { + layer: 'earthEngine', + aggregationType: ['sum', 'mean'], + legend: { + title: 'Population', + }, + id: 'eelayerid', + dataFilters: null, + data: [ + { + id: 'boOU', + properties: { + type: 'Polygon', + id: 'boOu', + name: 'Bo', + }, + }, + { + id: 'bombaliOU', + properties: { + type: 'Polygon', + id: 'bombaliOU', + name: 'Bombali', + }, + }, + ], + } + 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(7) + expect(headers).toMatchObject([ + { 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', + // roundFn: Function.prototype, + type: 'number', + }, + { + name: 'Mean Population', + dataKey: 'mean', + // roundFn: Function.prototype, + type: 'number', + }, + { name: 'Geometry type', dataKey: 'type', type: 'string' }, + ]) + expect(headers[4].roundFn).toBeInstanceOf(Function) + expect(headers[5].roundFn).toBeInstanceOf(Function) + expect(rows).toHaveLength(2) + expect(rows[0]).toHaveLength(7) + expect(rows[0]).toMatchObject([ + { value: 'boOu', dataKey: 'id' }, + { 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) + }) + + test('gets headers and rows for EE population age groups layer', () => { + const store = { + aggregations: { + eelayerid: { + badijaOU: { + M_0_mean: 0.4416957503717281, + M_0_sum: 279.5934099853039, + M_1_mean: 1.667343524395007, + M_1_sum: 1055.4284509420395, + M_5_mean: 1.8668244672235907, + M_5_sum: 1181.699887752533, + mean: 3.975863741990326, + sum: 2516.7217486798763, + }, + baomaOU: { + M_0_mean: 0.6669754306043404, + M_0_sum: 1012.4687036573887, + M_1_mean: 2.517744771694477, + M_1_sum: 3821.9365634322166, + M_5_mean: 2.818359887764859, + M_5_sum: 4278.270309627056, + mean: 6.003080090063677, + sum: 9112.675576716661, + }, + }, + }, + } + + const layer = { + layer: 'earthEngine', + name: 'Population age groups', + aggregationType: ['sum', 'mean'], + id: 'eelayerid', + legend: { + title: 'Population age groups', + groups: [ + { + id: 'M_0', + name: 'Male 0 - 1 years', + }, + { + id: 'M_1', + name: 'Male 1 - 4 years', + }, + { + id: 'M_5', + name: 'Male 5 - 9 years', + }, + ], + items: [ + { + color: '#fee5d9', + from: 0, + to: 10, + name: '0 - 10', + }, + { + color: '#fcbba1', + from: 10, + to: 20, + name: '10 - 20', + }, + { + color: '#fc9272', + from: 20, + to: 30, + name: '20 - 30', + }, + { + color: '#fb6a4a', + from: 30, + to: 40, + name: '30 - 40', + }, + { + color: '#de2d26', + from: 40, + to: 50, + name: '40 - 50', + }, + { + color: '#a50f15', + from: 50, + name: '> 50', + }, + ], + }, + data: [ + { + id: 'badijaOU', + properties: { + type: 'Polygon', + id: 'boOU', + name: 'Badija', + }, + }, + { + type: 'Feature', + id: 'baomaOU', + properties: { + type: 'Polygon', + id: 'baomaOU', + name: 'Baoma', + }, + }, + ], + } + + 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(7) + expect(headers).toMatchObject([ + { 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', + // roundFn: Function.prototype, + type: 'number', + }, + { + name: 'Mean Population Age Groups', + dataKey: 'mean', + // roundFn: Function.prototype, + type: 'number', + }, + { name: 'Geometry type', dataKey: 'type', type: 'string' }, + ]) + expect(headers[4].roundFn).toBeInstanceOf(Function) + expect(headers[5].roundFn).toBeInstanceOf(Function) + expect(rows).toHaveLength(2) + expect(rows[0]).toHaveLength(7) + expect(rows[0]).toMatchObject([ + { value: 'boOU', dataKey: 'id' }, + { 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) + }) + + 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', () => { + const mockLayer = { + id: 'test-layer', + layer: 'thematic', + dataFilters: null, + data: [ + { id: '1', properties: { name: 'Item A', rawValue: 10 } }, + { id: '2', properties: { name: 'Item B', rawValue: 5 } }, + { id: '3', properties: { name: 'Item C', rawValue: undefined } }, + { id: '4', properties: { name: 'Item D', rawValue: 15 } }, + { id: '5', properties: { name: 'Item E', rawValue: undefined } }, + ], + } + + test('sorts numeric values in ascending order with undefined/null at end', () => { + const store = { + aggregations: {}, + } + const { result } = renderHook( + () => + useTableData({ + layer: mockLayer, + sortField: 'rawValue', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + <Provider store={mockStore(store)}>{children}</Provider> + ), + } + ) + + const valueColumn = result.current.rows.map( + (row) => row.find((c) => c.dataKey === 'rawValue')?.value + ) + expect(valueColumn).toEqual([5, 10, 15, null, null]) + }) + + test('sorts numeric values in descending order with undefined/null at end', () => { + const store = { + aggregations: {}, + } + const { result } = renderHook( + () => + useTableData({ + layer: mockLayer, + sortField: 'rawValue', + sortDirection: 'desc', + }), + { + wrapper: ({ children }) => ( + <Provider store={mockStore(store)}>{children}</Provider> + ), + } + ) + + const valueColumn = result.current.rows.map( + (row) => row.find((c) => c.dataKey === 'rawValue')?.value + ) + expect(valueColumn).toEqual([15, 10, 5, null, null]) + }) + + test('sorts string values in ascending order with undefined at end', () => { + const layerWithStringData = { + id: 'test-layer', + layer: 'thematic', + dataFilters: null, + data: [ + { 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 } }, + ], + } + + const store = { + aggregations: {}, + } + const { result } = renderHook( + () => + useTableData({ + layer: layerWithStringData, + sortField: 'orgUnitOwn', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + <Provider store={mockStore(store)}>{children}</Provider> + ), + } + ) + + const nameColumn = result.current.rows.map( + (row) => row.find((c) => c.dataKey === 'orgUnitOwn')?.value + ) + expect(nameColumn).toEqual(['Apple', 'Banana', 'Zebra', undefined]) + }) + + test('sorts string values in descending order with undefined at end', () => { + const layerWithStringData = { + id: 'test-layer', + layer: 'thematic', + dataFilters: null, + data: [ + { 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 } }, + ], + } + + const store = { + aggregations: {}, + } + const { result } = renderHook( + () => + useTableData({ + layer: layerWithStringData, + sortField: 'orgUnitOwn', + sortDirection: 'desc', + }), + { + wrapper: ({ children }) => ( + <Provider store={mockStore(store)}>{children}</Provider> + ), + } + ) + + const nameColumn = result.current.rows.map( + (row) => row.find((c) => c.dataKey === 'orgUnitOwn')?.value + ) + expect(nameColumn).toEqual(['Zebra', 'Banana', 'Apple', undefined]) + }) + + test('handles multiple undefined values correctly', () => { + const layerWithManyUndefined = { + id: 'test-layer', + layer: 'thematic', + dataFilters: null, + data: [ + { id: '1', properties: { name: 'Item A', rawValue: 10 } }, + { + id: '2', + properties: { name: 'Item B', rawValue: undefined }, + }, + { + id: '3', + properties: { name: 'Item C', rawValue: undefined }, + }, + { id: '4', properties: { name: 'Item D', rawValue: 5 } }, + ], + } + + const store = { + aggregations: {}, + } + const { result } = renderHook( + () => + useTableData({ + layer: layerWithManyUndefined, + sortField: 'rawValue', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + <Provider store={mockStore(store)}>{children}</Provider> + ), + } + ) + + const valueColumn = result.current.rows.map( + (row) => row.find((c) => c.dataKey === 'rawValue')?.value + ) + expect(valueColumn).toEqual([5, 10, null, null]) + }) + + test('handles all undefined values', () => { + const layerWithAllUndefined = { + id: 'test-layer', + layer: 'thematic', + dataFilters: null, + data: [ + { + id: '1', + properties: { name: 'Item A', rawValue: undefined }, + }, + { + id: '2', + properties: { name: 'Item B', rawValue: undefined }, + }, + { + id: '3', + properties: { name: 'Item C', rawValue: undefined }, + }, + ], + } + + const store = { + aggregations: {}, + } + const { result } = renderHook( + () => + useTableData({ + layer: layerWithAllUndefined, + sortField: 'rawValue', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + <Provider store={mockStore(store)}>{children}</Provider> + ), + } + ) + + const valueColumn = result.current.rows.map( + (row) => row.find((c) => c.dataKey === 'rawValue')?.value + ) + expect(valueColumn).toEqual([null, null, null]) + }) + + test('falls back to natural (index) order when sortField is null', () => { + const layerInInputOrder = { + id: 'test-layer', + layer: 'thematic', + dataFilters: null, + data: [ + { + 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: {} } + 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 === 'orgUnitOwn')?.value + ) + expect(names).toEqual(['Item C', 'Item A', 'Item B']) + }) + + describe('sorting by selection state', () => { + 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: SENTINEL_SELECTED_ROW, + 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', () => { + 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 === 'id').value).toBe( + 'inview' + ) + }) + + 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 === 'id').value).toBe( + 'inview' + ) + }) +}) + +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 === 'id').value).toBe('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 === 'id').value).toBe('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 columnOptions', () => { + const store = { aggregations: {} } + + const renderTableData = (layer) => + renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + <Provider store={mockStore(store)}>{children}</Provider> + ), + } + ).result + + test('gives options to every column type once distinct values are within the cap', () => { + const layer = { + layer: 'thematic', + dataFilters: null, + data: [ + { + properties: { + id: 'ou1', + orgUnitOwn: 'Org unit 1', + rawValue: 10, + legend: 'High', + range: '5 - 15', + level: 1, + type: 'Point', + color: '#ff0000', + }, + }, + { + properties: { + id: 'ou2', + orgUnitOwn: 'Org unit 2', + rawValue: 20, + legend: 'Low', + range: '15 - 25', + level: 1, + 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.orgUnitOwn).toEqual([ + { value: 'Org unit 1' }, + { value: 'Org unit 2' }, + ]) + expect(current.columnOptions.id).toEqual([ + { value: 'ou1' }, + { value: 'ou2' }, + ]) + expect(current.columnOptions.rawValue).toEqual([ + { value: '10' }, + { value: '20' }, + ]) + expect(current.columnOptions.level).toEqual([{ value: '1' }]) + }) + + test('gives options to a column even with many distinct values - no cap', () => { + 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).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('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, data: [ { - id: 'badijaOU', properties: { - type: 'Polygon', - id: 'boOU', - name: 'Badija', + id: 'ou1', + orgUnitOwn: 'Country', + level: 1, + type: 'Point', }, }, { - type: 'Feature', - id: 'baomaOU', properties: { - type: 'Polygon', - id: 'baomaOU', - name: 'Baoma', + id: 'ou2', + orgUnitOwn: '', + level: 1, + type: 'Point', + }, + }, + { + properties: { + id: 'ou3', + // orgUnitOwn omitted entirely (undefined) + level: 1, + type: 'Point', }, }, ], } - const { result } = renderHook( - () => - useTableData({ - layer, - sortField: 'name', - sortDirection: 'asc', - }), - { - wrapper: ({ children }) => ( - <Provider store={mockStore(store)}>{children}</Provider> - ), - } - ) - const { headers, rows, isLoading } = result.current + const { current } = renderTableData(layer) - expect(headers).toHaveLength(6) - 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' }, - { - name: 'Sum Population Age Groups', - dataKey: 'sum', - // roundFn: Function.prototype, - type: 'number', - }, - { - name: 'Mean Population Age Groups', - dataKey: 'mean', - // roundFn: Function.prototype, - type: 'number', - }, - ]) - expect(headers[4].roundFn).toBeInstanceOf(Function) - expect(headers[5].roundFn).toBeInstanceOf(Function) - expect(rows).toHaveLength(2) - expect(rows[0]).toHaveLength(6) - expect(rows[0]).toMatchObject([ - { value: 0, dataKey: 'index' }, - { value: 'Badija', dataKey: 'name' }, - { value: 'boOU', dataKey: 'id' }, - { value: 'Polygon', dataKey: 'type' }, - { value: 2517, dataKey: 'sum' }, - { value: 3.976, dataKey: 'mean' }, + expect(current.columnOptions.orgUnitOwn).toEqual([ + { value: SENTINEL_NO_VALUE }, + { value: 'Country' }, ]) - expect(isLoading).toBe(false) }) -}) - -describe('useTableData sorting', () => { - const mockLayer = { - id: 'test-layer', - layer: 'thematic', - dataFilters: null, - data: [ - { id: '1', properties: { name: 'Item A', rawValue: 10 } }, - { id: '2', properties: { name: 'Item B', rawValue: 5 } }, - { id: '3', properties: { name: 'Item C', rawValue: undefined } }, - { id: '4', properties: { name: 'Item D', rawValue: 15 } }, - { id: '5', properties: { name: 'Item E', rawValue: undefined } }, - ], - } - test('sorts numeric values in ascending order with undefined/null at end', () => { - const store = { - aggregations: {}, + 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 { result } = renderHook( - () => - useTableData({ - layer: mockLayer, - sortField: 'rawValue', - sortDirection: 'asc', - }), - { - wrapper: ({ children }) => ( - <Provider store={mockStore(store)}>{children}</Provider> - ), - } - ) - const valueColumn = result.current.rows.map((row) => row[3]?.value) // Value column - expect(valueColumn).toEqual([5, 10, 15, null, null]) - }) - - test('sorts numeric values in descending order with undefined/null at end', () => { - const store = { - aggregations: {}, - } - const { result } = renderHook( - () => - useTableData({ - layer: mockLayer, - sortField: 'rawValue', - sortDirection: 'desc', - }), - { - wrapper: ({ children }) => ( - <Provider store={mockStore(store)}>{children}</Provider> - ), - } - ) + const { current } = renderTableData(layer) - const valueColumn = result.current.rows.map((row) => row[3]?.value) // Value column - expect(valueColumn).toEqual([15, 10, 5, null, null]) + const header = current.headers.find((h) => h.dataKey === 'AbCdEfGhIjK') + expect(header.optionSet).toEqual({ id: 'xyz123' }) + expect(current.columnOptions.AbCdEfGhIjK).toEqual([ + { value: 'CONFIRMED' }, + ]) }) - test('sorts string values in ascending order with undefined at end', () => { - const layerWithStringData = { - id: 'test-layer', + test('matches the currently sorted column direction, leaving other columns ascending', () => { + const layer = { 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 } }, + { + properties: { + id: 'ou1', + orgUnitOwn: 'Org unit 1', + rawValue: 10, + legend: 'High', + level: 1, + type: 'Point', + }, + }, + { + properties: { + id: 'ou2', + orgUnitOwn: 'Org unit 2', + rawValue: 20, + legend: 'Low', + level: 1, + type: 'Point', + }, + }, ], } - const store = { - aggregations: {}, - } const { result } = renderHook( () => useTableData({ - layer: layerWithStringData, - sortField: 'name', - sortDirection: 'asc', + layer, + sortField: 'orgUnitOwn', + sortDirection: 'desc', }), { wrapper: ({ children }) => ( @@ -718,72 +2081,114 @@ describe('useTableData sorting', () => { } ) - const nameColumn = result.current.rows.map((row) => row[1]?.value) // Name column - expect(nameColumn).toEqual(['Apple', 'Banana', 'Zebra', undefined]) + // Sorted column (orgUnitOwn, desc) is reversed to match... + expect(result.current.columnOptions.orgUnitOwn).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' }, + ]) }) +}) - test('sorts string values in descending order with undefined at end', () => { - const layerWithStringData = { - id: 'test-layer', - 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 } }, - ], - } +describe('useTableData globalSearch', () => { + const store = { aggregations: {} } - const store = { - aggregations: {}, - } - const { result } = renderHook( + const layer = { + layer: 'orgUnit', + dataFilters: null, + data: [ + { + properties: { + id: 'facility-a', + orgUnitPath: '/country1/facility-a', + orgUnitOwn: '/country1/facility-a', + }, + }, + { + properties: { + id: 'facility-b', + orgUnitPath: '/country1/facility-b', + orgUnitOwn: '/country1/facility-b', + }, + }, + ], + } + + const renderTableData = (globalSearch) => + renderHook( () => useTableData({ - layer: layerWithStringData, - sortField: 'name', - sortDirection: 'desc', + layer, + sortField: 'id', + sortDirection: 'asc', + globalSearch, }), { wrapper: ({ children }) => ( <Provider store={mockStore(store)}>{children}</Provider> ), } - ) + ).result - const nameColumn = result.current.rows.map((row) => row[1]?.value) // Name column - expect(nameColumn).toEqual(['Zebra', 'Banana', 'Apple', undefined]) + test('includes all rows when the search string is empty', () => { + const { current } = renderTableData('') + expect(current.rows).toHaveLength(2) }) - test('handles multiple undefined values correctly', () => { - const layerWithManyUndefined = { - id: 'test-layer', - layer: 'thematic', + 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 === '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, - data: [ - { id: '1', properties: { name: 'Item A', rawValue: 10 } }, + isExtended: true, + headers: [ { - id: '2', - properties: { name: 'Item B', rawValue: undefined }, + name: 'c3d4e5f6a7b', + column: 'Referred by facility', + valueType: 'ORGANISATION_UNIT', }, + ], + data: [ { - id: '3', - properties: { name: 'Item C', rawValue: undefined }, + properties: { + id: 'evt1', + type: 'Point', + eventdate: '2023-01-01', + c3d4e5f6a7b: 'Referral Hospital', + }, }, - { id: '4', properties: { name: 'Item D', rawValue: 5 } }, ], } - - const store = { - aggregations: {}, - } const { result } = renderHook( () => useTableData({ - layer: layerWithManyUndefined, - sortField: 'rawValue', + layer: eventLayer, + sortField: 'id', sortDirection: 'asc', + globalSearch: 'referral', }), { wrapper: ({ children }) => ( @@ -791,50 +2196,63 @@ describe('useTableData sorting', () => { ), } ) - - const valueColumn = result.current.rows.map((row) => row[3]?.value) // Value column - expect(valueColumn).toEqual([5, 10, null, null]) + expect(result.current.rows).toHaveLength(1) + expect( + result.current.rows[0].find((c) => c.dataKey === 'id').value + ).toBe('evt1') }) - test('handles all undefined values', () => { - const layerWithAllUndefined = { - id: 'test-layer', - layer: 'thematic', + 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, + }) + const teiLayer = { + layer: 'trackedEntity', dataFilters: null, - data: [ - { - id: '1', - properties: { name: 'Item A', rawValue: undefined }, - }, + headers: [ { - id: '2', - properties: { name: 'Item B', rawValue: undefined }, + name: 'Referred by facility', + dataKey: 'c3d4e5f6a7b', + valueType: 'ORGANISATION_UNIT', }, + ], + data: [ { - id: '3', - properties: { name: 'Item C', rawValue: undefined }, + 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 - const store = { - aggregations: {}, - } - const { result } = renderHook( - () => - useTableData({ - layer: layerWithAllUndefined, - sortField: 'rawValue', - sortDirection: 'asc', - }), - { - wrapper: ({ children }) => ( - <Provider store={mockStore(store)}>{children}</Provider> - ), - } + const { current } = renderTeiTableData('referral') + expect(current.rows).toHaveLength(1) + expect(current.rows[0].find((c) => c.dataKey === 'id').value).toBe( + 'tei1' ) - const valueColumn = result.current.rows.map((row) => row[3]?.value) // Value column - expect(valueColumn).toEqual([null, null, null]) + expect(renderTeiTableData('addis ababa').current.rows).toHaveLength(0) + }) + + test('shows no rows when nothing matches', () => { + const { current } = renderTableData('addis ababa') + expect(current.rows).toHaveLength(0) }) }) diff --git a/src/components/datatable/controls/ClearFiltersControl.jsx b/src/components/datatable/controls/ClearFiltersControl.jsx new file mode 100644 index 0000000000..31d42db860 --- /dev/null +++ b/src/components/datatable/controls/ClearFiltersControl.jsx @@ -0,0 +1,24 @@ +import i18n from '@dhis2/d2-i18n' +import PropTypes from 'prop-types' +import React from 'react' +import { FilterActiveIcon } from '../../core/index.js' +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} + > + <FilterActiveIcon /> + </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/controls/ColumnPickerControl.jsx b/src/components/datatable/controls/ColumnPickerControl.jsx new file mode 100644 index 0000000000..34c4d1657a --- /dev/null +++ b/src/components/datatable/controls/ColumnPickerControl.jsx @@ -0,0 +1,323 @@ +import i18n from '@dhis2/d2-i18n' +import { IconLayoutColumns16, IconSync16, IconUndo16, Tooltip } 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, + verticalListSortingStrategy, +} from '@dnd-kit/sortable' +import cx from 'classnames' +import PropTypes from 'prop-types' +import React, { + useCallback, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react' +import { createPortal } from 'react-dom' +import { + filterHeadersByName, + getDefaultVisibleKeys, + getOrderedHeaders, + getPinnedCount, + isPinnedGroupEnd, + reorderHeaderKeys, + 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' + +const DRAG_OVERLAY_Z_INDEX = 2100 +const EMPTY_HEADERS = [] +const EMPTY_KEYS = [] + +const ColumnPickerControl = React.memo(function ColumnPickerControl({ + allHeaders, + columnConfig, + onChange, +}) { + const anchorRef = useRef(null) + const [isOpen, setIsOpen] = useState(false) + const [activeId, setActiveId] = useState(null) + const [search, setSearch] = useState('') + const [hasScroll, setHasScroll] = useState(false) + const [popoverWidth, setPopoverWidth] = useState(null) + + useLayoutEffect(() => { + if (isOpen) { + setSearch('') + } + }, [isOpen]) + + const columnListRef = useCallback((el) => { + if (el) { + setHasScroll(el.scrollHeight > el.clientHeight) + } + }, []) + + const popoverRef = useCallback((el) => { + if (el) { + setPopoverWidth(el.offsetWidth) + } + }, []) + + const headers = allHeaders ?? EMPTY_HEADERS + + const visibleKeys = + columnConfig?.visibleKeys ?? getDefaultVisibleKeys(headers) + const pinnedKeys = columnConfig?.pinnedKeys ?? EMPTY_KEYS + const orderedKeys = + columnConfig?.orderedKeys ?? headers.map((h) => h.dataKey) + + const orderedHeaders = useMemo( + () => + isOpen + ? getOrderedHeaders(headers, { orderedKeys, pinnedKeys }) + : EMPTY_HEADERS, + [isOpen, headers, orderedKeys, pinnedKeys] + ) + + const pinnedCount = useMemo( + () => (isOpen ? getPinnedCount(orderedHeaders, pinnedKeys) : 0), + [isOpen, orderedHeaders, pinnedKeys] + ) + + const updateConfig = (partial) => + onChange({ + visibleKeys, + pinnedKeys, + orderedKeys, + ...partial, + }) + + const onToggleVisible = (dataKey, checked) => + updateConfig({ + visibleKeys: toggleVisibleKey(visibleKeys, dataKey, checked), + }) + + const onTogglePinned = (dataKey) => + updateConfig({ pinnedKeys: togglePinnedKey(pinnedKeys, dataKey) }) + + const isAllVisible = visibleKeys.length === headers.length + const onToggleSelectAll = () => + updateConfig({ + visibleKeys: isAllVisible ? [] : headers.map((h) => h.dataKey), + }) + + const onReverseSelection = () => + updateConfig({ + visibleKeys: reverseVisibleKeys(headers, visibleKeys), + }) + + const onResetToDefaults = () => onChange(undefined) + + const filteredHeaders = useMemo( + () => + isOpen + ? filterHeadersByName(orderedHeaders, search) + : EMPTY_HEADERS, + [isOpen, orderedHeaders, search] + ) + + const sensors = useSensors( + useSensor(MouseSensor, { + 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 nextOrder = reorderHeaderKeys( + orderedHeaders, + active.id, + over.id + ) + if (nextOrder) { + updateConfig({ orderedKeys: nextOrder }) + } + } + } + + const activeHeader = orderedHeaders.find((h) => h.dataKey === activeId) + + return ( + <> + <ToolbarIconButton + ref={anchorRef} + tooltip={i18n.t('Configure columns')} + ariaLabel={i18n.t('Configure columns')} + dataTest="data-table-column-picker-button" + disabled={!headers.length} + onClick={() => setIsOpen((o) => !o)} + > + <IconLayoutColumns16 /> + </ToolbarIconButton> + {isOpen && ( + <FilterDropdownPopover + reference={anchorRef} + placement="top-start" + onClickOutside={() => setIsOpen(false)} + > + <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} + modifiers={[restrictToVerticalAxis]} + onDragStart={({ active }) => setActiveId(active.id)} + onDragEnd={onDragEnd} + onDragCancel={() => setActiveId(null)} + > + <SortableContext + items={orderedHeaders.map((h) => h.dataKey)} + strategy={verticalListSortingStrategy} + > + <div + ref={columnListRef} + className={styles.columnList} + > + {filteredHeaders.map((header) => ( + <ColumnRow + key={header.dataKey} + header={header} + isVisible={visibleKeys.includes( + header.dataKey + )} + isPinned={pinnedKeys.includes( + header.dataKey + )} + isPinnedGroupEnd={isPinnedGroupEnd( + header, + pinnedCount, + orderedHeaders + )} + isDragActive={activeId != null} + onToggleVisible={onToggleVisible} + onTogglePinned={onTogglePinned} + /> + ))} + </div> + </SortableContext> + {createPortal( + <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> + )} + </> + ) +}) + +ColumnPickerControl.propTypes = { + onChange: PropTypes.func.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 ColumnPickerControl diff --git a/src/components/datatable/controls/ColumnRow.jsx b/src/components/datatable/controls/ColumnRow.jsx new file mode 100644 index 0000000000..d944040f05 --- /dev/null +++ b/src/components/datatable/controls/ColumnRow.jsx @@ -0,0 +1,161 @@ +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.configName ?? 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, + configName: PropTypes.string, + }).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, + configName: PropTypes.string, + }).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/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/controls/JoinLayersControl.jsx b/src/components/datatable/controls/JoinLayersControl.jsx new file mode 100644 index 0000000000..fea364f668 --- /dev/null +++ b/src/components/datatable/controls/JoinLayersControl.jsx @@ -0,0 +1,240 @@ +import i18n from '@dhis2/d2-i18n' +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, +} from '../../../constants/dataTable.js' +import { + getUnmatchedFeatureCount, + hasCombinedRollup, +} from '../../../util/combinedJoinMatch.js' +import { + getCombinedValueDataKeys, + getDefaultCombinedAggregation, +} from '../../../util/dataTable.js' +import { IconLayersStack16 } from '../../core/icons.jsx' +import { FilterDropdownPopover } from '../FilterDropdownPopover.jsx' +import LayerRow from './LayerRow.jsx' +import styles from './styles/JoinLayersControl.module.css' +import ToolbarIconButton from './ToolbarIconButton.jsx' + +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: getDefaultCombinedAggregation(layer), +}) + +const JoinLayersControl = ({ + eligibleLayers, + layersConfig, + onChange, + referenceLayer, +}) => { + const anchorRef = useRef(null) + const [isOpen, setIsOpen] = useState(false) + const [expandedKeys, setExpandedKeys] = useState(() => new Set()) + const dispatch = useDispatch() + + const openPopover = () => { + setExpandedKeys(new Set()) + setIsOpen(true) + } + + 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]) { + 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, + [layerKey]: { ...layersConfig[layerKey], type }, + }) + + const onAggregationChange = (layerKey, dataKey, aggregationType) => + onChange({ + ...layersConfig, + [layerKey]: { + ...layersConfig[layerKey], + aggregation: { + ...layersConfig[layerKey].aggregation, + [dataKey]: aggregationType, + }, + }, + }) + + 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={() => (isOpen ? setIsOpen(false) : openPopover())} + > + <IconLayersStack16 /> + </ToolbarIconButton> + {isOpen && ( + <FilterDropdownPopover + reference={anchorRef} + placement="top-start" + onClickOutside={() => setIsOpen(false)} + > + <div className={styles.joinLayersPopover}> + <div className={styles.layerList}> + {eligibleLayers.map((layer) => { + const settings = + layersConfig[layer.combinedLayerKey] + const hasDataFilters = + Object.keys(layer.dataFilters ?? {}) + .length > 0 + const isServerClustered = + layer.serverCluster && + !layer.forceClientCluster + const defaultAggregation = + getDefaultCombinedAggregation(layer) + const { + hasRollup = false, + unmatchedCount = 0, + } = + joinQualityByLayerKey[ + layer.combinedLayerKey + ] ?? {} + const valueDataKeys = + getCombinedValueDataKeys(layer) + const categoryDataKeys = 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 ( + <LayerRow + key={layer.id} + layer={layer} + isExpanded={expandedKeys.has( + layer.combinedLayerKey + )} + onToggleExpand={() => + onToggleExpand( + layer.combinedLayerKey + ) + } + onToggleJoined={() => onToggle(layer)} + hasDataFilters={hasDataFilters} + onClearDataFilters={() => + dispatch(clearDataFilters(layer.id)) + } + isServerClustered={isServerClustered} + onForceClientCluster={() => + dispatch( + setForceClientCluster(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> + </div> + </FilterDropdownPopover> + )} + </> + ) +} + +JoinLayersControl.propTypes = { + eligibleLayers: PropTypes.arrayOf( + PropTypes.shape({ + combinedLayerKey: PropTypes.string, + data: PropTypes.array, + id: PropTypes.string, + layer: PropTypes.string, + name: PropTypes.string, + }) + ).isRequired, + layersConfig: PropTypes.object.isRequired, + onChange: PropTypes.func.isRequired, + referenceLayer: PropTypes.object, +} + +export default JoinLayersControl diff --git a/src/components/datatable/controls/LayerRow.jsx b/src/components/datatable/controls/LayerRow.jsx new file mode 100644 index 0000000000..85a4d40824 --- /dev/null +++ b/src/components/datatable/controls/LayerRow.jsx @@ -0,0 +1,374 @@ +import i18n from '@dhis2/d2-i18n' +import { + IconChevronDown16, + IconChevronRight16, + IconReorder16, + 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, + isServerClustered, + onForceClientCluster, + 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> + </> + )} + {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}> + <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, + isServerClustered: 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, + onForceClientCluster: 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/LayerSelectorControl.jsx b/src/components/datatable/controls/LayerSelectorControl.jsx new file mode 100644 index 0000000000..c112d21875 --- /dev/null +++ b/src/components/datatable/controls/LayerSelectorControl.jsx @@ -0,0 +1,49 @@ +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 LayerSelectorControl = ({ + layers, + activeLayerId, + combinedView, + onSelectLayer, + onSelectCombined, +}) => ( + <select + className={styles.layerSelect} + aria-label={i18n.t('Choose a data table to view')} + data-test="data-table-layer-selector" + value={combinedView ? SENTINEL_COMBINED_VALUE : activeLayerId ?? ''} + onChange={(e) => { + if (e.target.value === SENTINEL_COMBINED_VALUE) { + onSelectCombined() + } else { + onSelectLayer(e.target.value) + } + }} + > + {layers.map((layer) => ( + <option key={layer.id} value={layer.id}> + {layer.name} + </option> + ))} + <option value={SENTINEL_COMBINED_VALUE}>{i18n.t('Combined')}</option> + </select> +) + +LayerSelectorControl.propTypes = { + combinedView: PropTypes.bool.isRequired, + layers: 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/ReferenceOrgUnitControl.jsx b/src/components/datatable/controls/ReferenceOrgUnitControl.jsx new file mode 100644 index 0000000000..c9e96d02b4 --- /dev/null +++ b/src/components/datatable/controls/ReferenceOrgUnitControl.jsx @@ -0,0 +1,51 @@ +import i18n from '@dhis2/d2-i18n' +import { IconDimensionOrgUnit16 } from '@dhis2/ui' +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) => + mapViews.find((l) => l.layer === COMBINED_TABLE_REF_LAYER) + +export const useReferenceLayer = () => { + const dispatch = useDispatch() + const store = useStore() + const referenceLayer = useSelector((state) => + findReferenceLayer(state.map.mapViews) + ) + + const openReferenceLayerEditor = () => { + const mapViews = store.getState().map.mapViews + dispatch( + editLayer( + findReferenceLayer(mapViews) ?? { + layer: COMBINED_TABLE_REF_LAYER, + isVisible: false, + rows: getDefaultReferenceRows(mapViews), + } + ) + ) + } + + 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={openReferenceLayerEditor} + > + <IconDimensionOrgUnit16 /> + </ToolbarIconButton> + ) +} + +export default ReferenceOrgUnitControl diff --git a/src/components/datatable/controls/ResizeHandleControl.jsx b/src/components/datatable/controls/ResizeHandleControl.jsx new file mode 100644 index 0000000000..341d5e24de --- /dev/null +++ b/src/components/datatable/controls/ResizeHandleControl.jsx @@ -0,0 +1,94 @@ +import PropTypes from 'prop-types' +import React, { useEffect, useRef } from 'react' +import { IconDrag } from '../../core/icons.jsx' +import styles from './styles/ResizeHandleControl.module.css' + +const ResizeHandleControl = ({ + onResize, + onResizeStart, + onResizeEnd, + onResizeCancel, + minHeight = 50, + maxHeight = 500, +}) => { + const isDraggingRef = useRef(false) + + const getHeight = (clientY) => { + const height = window.innerHeight - clientY + return Math.min(Math.max(height, minHeight), maxHeight) + } + + const onPointerDown = (evt) => { + evt.preventDefault() // avoid text selection while dragging + evt.currentTarget.setPointerCapture(evt.pointerId) + isDraggingRef.current = true + + onResizeStart?.() + evt.currentTarget.style.cursor = 'grabbing' + document.body.style.cursor = 'grabbing' + } + + const onPointerMove = (evt) => { + if (isDraggingRef.current) { + onResize?.(getHeight(evt.clientY)) + } + } + + 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( + () => () => { + if (isDraggingRef.current) { + document.body.style.removeProperty('cursor') + } + }, + [] + ) + + return ( + <div + className={styles.resizeHandle} + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + onPointerCancel={onPointerCancel} + > + <span className={styles.gripBox}> + <IconDrag /> + </span> + </div> + ) +} + +ResizeHandleControl.propTypes = { + maxHeight: PropTypes.number.isRequired, + minHeight: PropTypes.number, + onResize: PropTypes.func, + onResizeCancel: PropTypes.func, + onResizeEnd: PropTypes.func, + onResizeStart: PropTypes.func, +} + +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..2284b416d5 --- /dev/null +++ b/src/components/datatable/controls/RowCountControl.jsx @@ -0,0 +1,39 @@ +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, + 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, total }) + : i18n.t('{{total}} rows', { total }) + + return <span className={styles.rowCount}>{label}</span> +} + +RowCountControl.propTypes = { + filteredCount: PropTypes.number, + keyAnalysisDigitGroupSeparator: PropTypes.string, + 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..0922cca3d0 --- /dev/null +++ b/src/components/datatable/controls/ShowInViewControl.jsx @@ -0,0 +1,24 @@ +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')} + ariaLabel={i18n.t('Show only features in current map view')} + dataTest="data-table-show-in-view-button" + 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/__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/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() + }) +}) 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() + }) +}) diff --git a/src/components/datatable/controls/styles/ColumnPickerControl.module.css b/src/components/datatable/controls/styles/ColumnPickerControl.module.css new file mode 100644 index 0000000000..3732301418 --- /dev/null +++ b/src/components/datatable/controls/styles/ColumnPickerControl.module.css @@ -0,0 +1,136 @@ +.columnPickerPopover { + composes: popoverPanel from './PopoverPanel.module.css'; + min-width: 190px; +} + +.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); +} + +.bulkActionsRow input[type='checkbox'] { + width: 14px; + height: 14px; + position: relative; + top: 2px; + left: 2px; + accent-color: var(--colors-teal600); +} + +.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; + max-height: 260px; + overflow-y: auto; +} + +.columnRow { + display: flex; + align-items: center; + gap: var(--spacers-dp4); + padding: var(--spacers-dp2) 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; + 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; +} + +.rowIconButton { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 20px; + height: 20px; + padding: 0; + border: none; + border-radius: 3px; + background: transparent; +} + +.rowIconButton:hover { + background: var(--colors-grey100); + color: var(--colors-grey800); +} + +.dragHandle { + -webkit-user-drag: none; + user-select: none; + color: var(--colors-grey600); + cursor: grab; + touch-action: none; +} + +.pinButton { + color: var(--colors-grey700); + cursor: pointer; +} + +.pinButtonActive { + color: var(--colors-teal600); +} + +.pinButtonActive:hover { + color: var(--colors-teal700); +} + +.dragOverlay { + background-color: var(--colors-white); + box-shadow: var(--elevations-popover); +} 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..7b8c95bd96 --- /dev/null +++ b/src/components/datatable/controls/styles/HighlightColorControl.module.css @@ -0,0 +1,28 @@ +.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. */ +.colorPicker label { + box-sizing: border-box; + overflow: hidden; + min-width: 16px !important; + min-height: 16px !important; +} 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..8ef148a8d0 --- /dev/null +++ b/src/components/datatable/controls/styles/JoinLayersControl.module.css @@ -0,0 +1,135 @@ +.joinLayersPopover { + composes: popoverPanel from './PopoverPanel.module.css'; + min-width: 264px; +} + +.layerList { + display: flex; + flex-direction: column; + max-height: 320px; + overflow-y: auto; +} + +.layerRow { + 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; + 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); +} + +.layerCheckbox { + margin: 0; +} + +.layerCheckbox :global(label) { + min-width: 0; +} + +.layerName { + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: 12px; +} + +.layerSettings { + display: flex; + flex-direction: column; + gap: var(--spacers-dp4); + /* Aligns with the layer-name text, past the chevron + checkbox column */ + padding: var(--spacers-dp4) 0 var(--spacers-dp4) 44px; +} + +.aggregationRow { + display: flex; + align-items: center; + gap: var(--spacers-dp4); +} + +.aggregationRowLabel { + flex: 0 0 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; + padding: 0 var(--spacers-dp4); + font-size: 12px; + border: 1px solid var(--colors-grey500); + border-radius: 3px; + background-color: var(--colors-white); +} + +.aggregationWarning { + display: flex; + flex-shrink: 0; + 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/controls/styles/ResizeHandleControl.module.css b/src/components/datatable/controls/styles/ResizeHandleControl.module.css new file mode 100644 index 0000000000..bec35a033f --- /dev/null +++ b/src/components/datatable/controls/styles/ResizeHandleControl.module.css @@ -0,0 +1,26 @@ +.resizeHandle { + display: flex; + justify-content: center; + align-items: center; + flex: 1 1 auto; + min-width: 24px; + height: 100%; + z-index: 1500; + cursor: grab; +} + +.gripBox { + width: 99%; + height: 24px; + border-radius: 3px; + display: flex; + align-items: center; + justify-content: center; + color: var(--colors-grey600); +} + +.resizeHandle:hover .gripBox, +.resizeHandle:active .gripBox { + background-color: var(--colors-grey200); + color: var(--colors-grey900); +} 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..a47826bc45 --- /dev/null +++ b/src/components/datatable/controls/styles/ToolbarIconButton.module.css @@ -0,0 +1,50 @@ +/* !important beats core/IconButton's own 28px/grey700/grey200-hover defaults. */ +.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; +} + +.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 68ce43eab9..1fa9153747 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; @@ -5,7 +9,7 @@ width: 100%; height: var(--data-table-height); z-index: 1040; - background: #fff; + background: var(--colors-white); display: flex; flex-direction: column; } @@ -13,37 +17,62 @@ .tableContainer { flex: 1; min-height: 0; + overflow: hidden; position: relative; } .dataTableControls { width: 100%; - height: 20px; + height: var(--data-table-controls-height); 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); } -.closeIcon { - position: absolute; - top: 0; - right: 2px; - z-index: 100; - cursor: pointer; - color: var(--colors-grey800); - background-color: var(--colors-grey100); - width: 20px; +.divider { + width: 1px; height: 20px; - border: none; + background-color: var(--colors-grey300); + flex-shrink: 0; } -.closeIcon:hover { - color: var(--colors-grey900); - background-color: var(--colors-grey300); +.filteredLayersWarning { + display: flex; + flex-shrink: 0; + align-items: center; + color: var(--colors-yellow600); } -.closeIcon svg { - position: absolute; - top: 50%; - left: 50%; - margin: -8px 0 0 -8px; +.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); +} + +.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; +} + +.layerSelect:focus { + outline: none; + border-color: var(--colors-blue600); + box-shadow: inset 0 0 0 2px var(--colors-blue600); } diff --git a/src/components/datatable/styles/CombinedDataTable.module.css b/src/components/datatable/styles/CombinedDataTable.module.css new file mode 100644 index 0000000000..7c5a5dabac --- /dev/null +++ b/src/components/datatable/styles/CombinedDataTable.module.css @@ -0,0 +1,24 @@ +.container { + height: 100%; + display: flex; + flex-direction: column; +} + +.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); +} diff --git a/src/components/datatable/styles/DataTable.module.css b/src/components/datatable/styles/DataTable.module.css index c82cf02395..8bd794ee99 100644 --- a/src/components/datatable/styles/DataTable.module.css +++ b/src/components/datatable/styles/DataTable.module.css @@ -1,59 +1,192 @@ -.dataTable { - height: 1px; -} - -.dataTable > :global(thead) { - user-select: none; -} - -td.dataCell { +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 { +td.dataCell:hover, +th.dataCell:hover { cursor: default; } -td.lightText { +td.lightText, +th.lightText { color: var(--colors-white); } -.columnHeader > :global(span.container) { +td.monoCell, +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; + 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); +} + +.checkboxHeaderContent { + display: flex; + align-items: center; + justify-content: center; + gap: 2px; +} + +td.selected, +th.selected { + background-color: var(--colors-blue050); +} + +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; } +.headerContent { + display: flex; + align-items: flex-start; + min-width: 0; + gap: 2px; +} + +.headerTitle { + padding-top: var(--spacers-dp4); +} + +.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; +} + +.reverseButton svg { + flex-shrink: 0; +} + +.reverseButton:hover:not(:disabled) { + background: var(--colors-grey400); +} + +.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:not(:disabled), +.sortButton:focus-visible:not(:disabled) { + background: var(--colors-grey400); +} + +.sortButton:focus { + outline: none; +} + +.sortButton:disabled { + opacity: 0.4; + cursor: not-allowed; +} + .columnHeader :global(input.dense) { padding: 4px 6px; + font-size: 11px; } .columnHeader :global(input::placeholder) { color: var(--colors-grey400); } -/* Hide the filter icon */ .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 { +.loadingContent { display: flex; - color: var(--colors-grey600); + flex-direction: column; align-items: center; - justify-content: center; - font-style: italic; - min-height: 40px; + gap: var(--spacers-dp8); +} + +.loadingReason { + font-size: 12px; + color: var(--colors-white); } .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/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/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/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 new file mode 100644 index 0000000000..2541b751cf --- /dev/null +++ b/src/components/datatable/styles/FilterInput.module.css @@ -0,0 +1,204 @@ +.filterTrigger { + position: relative; + display: flex; + align-items: center; + width: 100%; +} + +.filterTrigger :global(input.dense) { + padding: 4px 6px; + font-size: 11px; +} + +.filterTrigger :global(input::placeholder) { + color: var(--colors-grey400); +} + +.dropdownPopper { + background-color: var(--colors-white); + border-radius: 4px; + border-top-left-radius: 0; + border-top-right-radius: 0; + box-shadow: var(--elevations-popover); +} + +.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; + padding: var(--spacers-dp8); + min-width: 140px; + box-sizing: border-box; +} + +.reversedOrder .multiSelectPopover { + order: 0; +} + +.reversedOrder .pinnedOptions { + order: 1; + border-bottom: none; + border-top: 1px solid var(--colors-grey300); +} + +.reversedOrder .customFilterRow { + order: 2; +} + +.pinnedOptions { + position: relative; + width: 100%; + min-width: 0; + box-sizing: border-box; + border-bottom: 1px solid var(--colors-grey300); +} + +/* !important beats @dhis2/ui's own Checkbox label font-size. */ +.pinnedOptions :global(label) { + font-size: 11px !important; +} + +.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; +} + +.reverseSelectionButton:hover:not(:disabled) { + background: var(--colors-grey100); +} + +.reverseSelectionButton:disabled { + color: var(--colors-grey400); + cursor: not-allowed; +} + +.multiSelectPopover { + max-height: 260px; + overflow-y: auto; +} + +/* Same override as .pinnedOptions above. */ +.multiSelectPopover :global(label) { + font-size: 11px !important; +} + +.monoOption :global(label) { + 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; +} + +/* !important beats @dhis2/ui's own Checkbox label color. */ +.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); +} + +.customFilterRow { + display: flex; + align-items: center; + 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; +} + +.customFilterRow svg { + flex-shrink: 0; +} + +.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; + overflow-wrap: anywhere; +} + +.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/styles/ResizeHandle.module.css b/src/components/datatable/styles/ResizeHandle.module.css deleted file mode 100644 index 27c0465abe..0000000000 --- a/src/components/datatable/styles/ResizeHandle.module.css +++ /dev/null @@ -1,19 +0,0 @@ -.resizeHandle { - display: flex; - justify-content: center; - align-items: center; - width: 100%; - height: 100%; - z-index: 1500; - cursor: grab; -} - -.resizeHandle:hover, -.resizeHandle:active { - background-color: var(--colors-grey300); - color: var(--colors-grey900); -} - -.resizeHandle:active { - cursor: grabbing; -} 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/components/datatable/useColumnWidths.js b/src/components/datatable/useColumnWidths.js new file mode 100644 index 0000000000..cc87a4259e --- /dev/null +++ b/src/components/datatable/useColumnWidths.js @@ -0,0 +1,70 @@ +import { useEffect, useRef, useState } from 'react' + +const MIN_COLUMN_WIDTH = 100 + +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.max(MIN_COLUMN_WIDTH, 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/useCombinedTableData.js b/src/components/datatable/useCombinedTableData.js new file mode 100644 index 0000000000..ade47a68ba --- /dev/null +++ b/src/components/datatable/useCombinedTableData.js @@ -0,0 +1,489 @@ +import i18n from '@dhis2/d2-i18n' +import { useMemo } from 'react' +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' +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, + getFilteredJoinableFeatures, + getJoinableFeatures, + getProps, +} from '../../util/combinedJoinMatch.js' +import { + CATEGORY_DISPLAY_TYPE_KEY, + getCombinedLegendConfig, + 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, + sortColumnOptions, +} from '../../util/tableColumns.js' +import { compareRows } from '../../util/tableSort.js' + +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 + } + 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), + } +} + +const finalizeRows = ( + flatRows, + headers, + { + filters, + globalSearch, + sortField, + sortDirection, + selectionFilter, + selectedIdSet, + keyAnalysisDigitGroupSeparator, + orgUnitRenderer, + orgUnitIdToName, + } +) => { + let data = filterData(flatRows, filters) + + if (globalSearch?.trim()) { + data = filterByGlobalSearch(data, globalSearch, { + headers, + keyAnalysisDigitGroupSeparator, + }) + } + + 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, + orgUnitRenderer, + idToName: orgUnitIdToName, + }) + ) + + return data.map((row) => buildRowCells(row, headers)) +} + +const applyLayerMatchToRow = ({ row, featureIds, refProps }, layerMatch) => { + const { layer, settings, byReferenceId, valueDataKeys, legendConfig } = + layerMatch + const matches = byReferenceId.get(refProps.id) ?? [] + + valueDataKeys.forEach(({ dataKey, kind, periodId, settingsKey }) => { + const rowKey = `${layer.combinedLayerKey}_${dataKey}` + + if (kind === DATA_KEY_KIND_COUNT) { + row[rowKey] = matches.length || null + return + } + + if (kind === DATA_KEY_KIND_CATEGORY) { + if (!matches.length) { + 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] = + displayType === 'PERCENTAGE' + ? (inCategory / matches.length) * 100 + : inCategory + return + } + + const effectiveType = + settings.aggregation?.[settingsKey ?? dataKey] ?? + getDefaultCombinedAggregation(layer)[dataKey] + const values = ( + periodId != null + ? 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) + }) + + if (legendConfig) { + const legends = matches + .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]) + ? legends[0] + : null + } + + const ids = matches.map((p) => p.id).filter((id) => id != null) + if (ids.length) { + featureIds[layer.id] = ids + } +} + +const getValueDataKeyHeader = ( + layer, + settings, + { dataKey, name, kind, defaultHidden, periodName, isCurrentPeriod } +) => { + 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 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, + unit: isPercentage ? i18n.t('%') : i18n.t('count'), + layer: layer.name, + }), + dataKey: rowKey, + type: TYPE_NUMBER, + defaultHidden, + ...(isPercentage ? { roundFn: getRoundToPrecisionFn(1) } : {}), + } + } + + 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 }) + : i18n.t('Value ({{layer}})', { layer: layer.name }), + dataKey: rowKey, + type: TYPE_NUMBER, + defaultHidden, + } +} + +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 = [] +const EMPTY_ORG_UNIT_ID_TO_NAME = new Map() + +const EMPTY_RESULT = { + headers: EMPTY_HEADERS, + rows: [], + rowFeatureIds: new Map(), + columnOptions: EMPTY_COLUMN_OPTIONS, + spatialWarning: false, + orgUnitIdToName: EMPTY_ORG_UNIT_ID_TO_NAME, +} + +export const useCombinedTableData = ({ + layers, + referenceLayer, + joinConfig, + sortField = null, + sortDirection = SORT_ASCENDING, + filters, + globalSearch, + aggregations: allAggregations = EMPTY_AGGREGATIONS, + showOnlyFeaturesInView = false, + mapBounds, + selectionFilter, + selectedIdSet, + keyAnalysisDigitGroupSeparator, + externalPeriod, +}) => { + const referenceOrgUnits = useMemo( + () => getJoinableFeatures(referenceLayer), + [referenceLayer] + ) + + const visibleReferenceOrgUnits = useMemo( + () => + showOnlyFeaturesInView && mapBounds + ? referenceOrgUnits.filter((f) => + isFeatureInBounds(f, mapBounds) + ) + : referenceOrgUnits, + [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) => { + const settings = joinConfig.layers[layer.combinedLayerKey] ?? { + type: 'orgUnit', + aggregation: {}, + } + const mergedLayer = mergeAggregations( + layer, + allAggregations[layer.id] ?? EMPTY_AGGREGATIONS + ) + const features = getFilteredJoinableFeatures(mergedLayer) + const valueDataKeys = getCombinedValueDataKeys( + layer, + externalPeriod + ) + const legendConfig = getCombinedLegendConfig( + layer, + externalPeriod + ) + const byReferenceId = getByReferenceId( + features, + referenceOrgUnits, + settings.type + ) + return { + layer, + settings, + byReferenceId, + valueDataKeys, + legendConfig, + } + }), + [layers, joinConfig, referenceOrgUnits, allAggregations, externalPeriod] + ) + + const headers = useMemo(() => { + if (!referenceOrgUnits.length) { + return EMPTY_HEADERS + } + return [ + { + 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, + }, + { + 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) => + getValueDataKeyHeader(layer, settings, valueDataKey) + ), + ...(legendConfig + ? [getLegendHeader(layer, legendConfig)] + : []), + ] + ), + ] + }, [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() + + 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, + [ORG_UNIT_PATH_DATA_KEY]: + refProps[ORG_UNIT_PATH_DATA_KEY] ?? null, + index, + } + + const featureIds = { [referenceLayer.id]: [refProps.id] } + + layerMatches.forEach((layerMatch) => + applyLayerMatchToRow( + { row, featureIds, refProps }, + layerMatch + ) + ) + + rowFeatureIds.set(refProps.id, featureIds) + return row + } + ) + + const sortFieldRenderer = headers.find( + (h) => h.dataKey === sortField + )?.renderer + + const rows = finalizeRows(flatRows, headers, { + filters, + globalSearch, + sortField, + sortDirection, + selectionFilter, + selectedIdSet, + keyAnalysisDigitGroupSeparator, + orgUnitRenderer: sortFieldRenderer, + orgUnitIdToName, + }) + const columnOptions = + sortColumnOptions(getColumnDistinctValues(headers, flatRows), { + sortField, + sortDirection, + }) ?? EMPTY_COLUMN_OPTIONS + + return { + headers, + rows, + rowFeatureIds, + columnOptions, + spatialWarning, + orgUnitIdToName, + } + }, [ + referenceOrgUnits, + visibleReferenceOrgUnits, + referenceLayer, + layerMatches, + headers, + filters, + globalSearch, + sortField, + sortDirection, + selectionFilter, + selectedIdSet, + keyAnalysisDigitGroupSeparator, + orgUnitIdToName, + ]) +} diff --git a/src/components/datatable/useGroupFilterInput.js b/src/components/datatable/useGroupFilterInput.js new file mode 100644 index 0000000000..87c4d62d17 --- /dev/null +++ b/src/components/datatable/useGroupFilterInput.js @@ -0,0 +1,277 @@ +import { useCallback, useMemo, useRef, useState } from 'react' +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 = ({ + onChange, + onClear, + filterValue, + options, + granularity, + buildTree, + getMatches, + parseFilterValue, + commitSearch, + sanitizeInput = identity, +}) => { + 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 + ? onChange({ granularity, prefixes: nextPrefixes }) + : onClear(), + [onChange, onClear, 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) { + onClear() + return + } + commitSearch(text, { tree, onChange }) + } + + const onSearchChange = ({ value }) => { + const sanitized = sanitizeInput(value) + setSearchText(sanitized) + setHighlightedIndex(-1) + + const trimmed = sanitized.trim() + if (trimmed === '') { + if (hasActiveFilter) { + onClear() + } + 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 + 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 diff --git a/src/components/datatable/useRowClickSelection.js b/src/components/datatable/useRowClickSelection.js new file mode 100644 index 0000000000..713c7908cb --- /dev/null +++ b/src/components/datatable/useRowClickSelection.js @@ -0,0 +1,36 @@ +import { useCallback, useRef } from 'react' +import { getRowClickAction, getRowId } from '../../util/dataTable.js' + +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/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 } +} diff --git a/src/components/datatable/useRowSelection.js b/src/components/datatable/useRowSelection.js new file mode 100644 index 0000000000..18380b7d49 --- /dev/null +++ b/src/components/datatable/useRowSelection.js @@ -0,0 +1,43 @@ +import { useCallback, useMemo } from 'react' + +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, + onChange, +}) => { + 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])] + + onChange(nextIds) + }, [isAllSelected, allRowIds, allRowIdSet, selectedIds, onChange]) + + const onReverseSelection = useCallback(() => { + onChange(getReversedSelection(selectedIds, allRowIds)) + }, [selectedIds, allRowIds, onChange]) + + return { + isAllSelected, + onToggleSelectAll, + onReverseSelection, + } +} diff --git a/src/components/datatable/useSortState.js b/src/components/datatable/useSortState.js new file mode 100644 index 0000000000..2a77e15cc8 --- /dev/null +++ b/src/components/datatable/useSortState.js @@ -0,0 +1,25 @@ +import { useCallback, useReducer } from 'react' +import { SORT_ASCENDING } from '../../constants/dataTable.js' +import { getNextSorting } from '../../util/dataTable.js' + +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 }, + { defaultSortField: initialSortField } + ) + ) + }, + [sortField, sortDirection, initialSortField] + ) + + return { sortField, sortDirection, sortData } +} diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index ccbc96ab8f..cada838ada 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -1,53 +1,41 @@ import i18n from '@dhis2/d2-i18n' -import { useMemo, useRef } from 'react' +import { useDeferredValue, useMemo, useRef } from 'react' import { useSelector } from 'react-redux' +import { + SENTINEL_SELECTED_ROW, + RENDERER_ORG_UNIT, + RENDERER_ORG_UNIT_NAME, +} from '../../constants/dataTable.js' import { EVENT_LAYER, THEMATIC_LAYER, - ORG_UNIT_LAYER, EARTH_ENGINE_LAYER, - FACILITY_LAYER, - GEOJSON_URL_LAYER, + RENDERING_STRATEGY_SINGLE, + RENDERING_STRATEGY_TIMELINE, } from '../../constants/layers.js' -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 { parseRange } from '../../util/legend.js' -import { getRoundToPrecisionFn, getPrecision } from '../../util/numbers.js' -import { isValidUid } from '../../util/uid.js' - -const ASCENDING = 'asc' - -const TYPE_NUMBER = 'number' -const TYPE_STRING = 'string' -const TYPE_DATE = 'date' - -const INDEX = 'index' -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 OUNAME = 'ouname' -const OUBOUNDARY = 'ouBoundary' -const EVENTDATE = 'eventdate' +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 { buildKnownOrgUnitNames } from '../../util/orgUnits.js' +import { + buildRowCells, + getColumnDistinctValues, + sortColumnOptions, +} from '../../util/tableColumns.js' +import { + ERROR_NON_HOMOGENOUS_FEATURES, + getHeadersForLayer, +} from '../../util/tableHeaders.js' +import { ERROR_NO_VALID_DATA, buildTableData } from '../../util/tableRows.js' +import { compareRows } from '../../util/tableSort.js' -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) { - 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.' @@ -63,143 +51,26 @@ 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 }, - [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', - }, -}) - -const getThematicHeaders = () => - [ - INDEX, - 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( - (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, - })) - - customFields.push(defaultFieldsMap()[TYPE]) - - if (styleDataItem) { - customFields.push(defaultFieldsMap()[COLOR]) - } - - return fields.concat(customFields) -} - -const getOrgUnitHeaders = () => - [INDEX, NAME, ID, LEVEL, PARENT_NAME, TYPE].map( - (field) => defaultFieldsMap()[field] - ) - -const getFacilityHeaders = () => - [INDEX, NAME, ID, TYPE].map((field) => defaultFieldsMap()[field]) - -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 [INDEX, NAME, ID, TYPE] - .map((field) => defaultFieldsMap()[field]) - .concat(customFields) -} - -const getGeoJsonUrlHeaders = (firstDataItem) => - getGeojsonDisplayData(firstDataItem) - const EMPTY_AGGREGATIONS = {} const EMPTY_LAYER = {} - -export const useTableData = ({ layer, sortField, sortDirection }) => { +const EMPTY_COLUMN_OPTIONS = {} + +export const useTableData = ({ + layer, + sortField, + sortDirection, + showOnlyFeaturesInView, + mapBounds, + selectionFilter, + selectedIdSet, + globalSearch, + keyAnalysisDigitGroupSeparator, +}) => { const allAggregations = useSelector((state) => state.aggregations) const aggregations = allAggregations[layer.id] || EMPTY_AGGREGATIONS + const externalPeriod = useSelector( + (state) => state.ui?.activeTimelinePeriod + ) const errorCode = useRef(null) @@ -209,90 +80,111 @@ export const useTableData = ({ layer, sortField, sortDirection }) => { legend, styleDataItem, countEventsOutsideOrgUnits, + bands, + band, data, dataWithoutCoords, dataFilters, headers: layerHeaders, serverCluster, + renderingStrategy, + valuesByPeriod, + periods, + legendDecimalPlaces, } = layer || EMPTY_LAYER - const dataWithAggregations = useMemo(() => { - errorCode.current = null - if (serverCluster) { - errorCode.current = ERROR_SERVER_CLUSTER - return null - } + const isMultiPeriodThematic = + layerType === THEMATIC_LAYER && + renderingStrategy && + renderingStrategy !== RENDERING_STRATEGY_SINGLE + const isTimelineThematic = + isMultiPeriodThematic && + renderingStrategy === RENDERING_STRATEGY_TIMELINE + 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 allData = dataWithoutCoords?.length - ? [...(data || []), ...dataWithoutCoords] - : data - - if (!allData?.length) { - errorCode.current = ERROR_NO_VALID_DATA - return null - } - - if (layerType === GEOJSON_URL_LAYER) { - return allData.map((d) => ({ - ...d.properties, - })) - } + const dataWithAggregations = useMemo(() => { + const { data: rows, errorCode: rowsErrorCode } = buildTableData( + layerType, + { + data, + dataWithoutCoords, + serverCluster, + showOnlyFeaturesInView, + mapBounds, + aggregations, + isStyledEvent, + isMultiPeriodThematic, + isTimelineThematic, + legend, + valuesByPeriod, + externalPeriod, + periods, + keyAnalysisDigitGroupSeparator, + legendDecimalPlaces, + } + ) - return allData - .filter((d) => !d.properties.hasAdditionalGeometry) - .map((d, index) => ({ - ...(d.properties || d), - ...aggregations[d.id], - index, - })) - }, [data, dataWithoutCoords, aggregations, serverCluster, layerType]) + errorCode.current = rowsErrorCode ?? null + return rowsErrorCode ? null : rows + // *Dependency vars proxy their raw counterparts (see above) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + data, + dataWithoutCoords, + aggregations, + serverCluster, + layerType, + showOnlyFeaturesInView, + boundsDependency, + isMultiPeriodThematic, + isTimelineThematic, + valuesByPeriodDependency, + externalPeriodDependency, + periodsDependency, + isStyledEvent, + legend, + keyAnalysisDigitGroupSeparator, + legendDecimalPlaces, + ]) const headers = useMemo(() => { if (errorCode.current) { return null } - let headers = null - switch (layerType) { - case THEMATIC_LAYER: - headers = getThematicHeaders() - break - case EVENT_LAYER: - headers = getEventHeaders({ - layerHeaders, - styleDataItem, - countEventsOutsideOrgUnits, - }) - break - case ORG_UNIT_LAYER: - headers = getOrgUnitHeaders() - break - case EARTH_ENGINE_LAYER: - headers = getEarthEngineHeaders({ - aggregationType, - legend, - data: dataWithAggregations, - }) - break - case FACILITY_LAYER: - headers = getFacilityHeaders() - 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, + bands, + band, + data: dataWithAggregations, + rawData: data, } - default: - break + ) + + if (headersErrorCode) { + errorCode.current = headersErrorCode + return null } if (!headers?.length) { @@ -300,17 +192,64 @@ export const useTableData = ({ layer, sortField, sortDirection }) => { return null } return headers + // *Dependency vars proxy their raw counterparts (see above) + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ layerType, aggregationType, legend, styleDataItem, countEventsOutsideOrgUnits, + bands, + band, dataWithAggregations, data, layerHeaders, + isMultiPeriodThematic, + isTimelineThematic, + externalPeriodDependency, + periodsDependency, ]) + // Expensive: scans every row once per column + const deferredDataForOptions = useDeferredValue(dataWithAggregations) + const columnDistinctValues = useMemo( + () => getColumnDistinctValues(headers, deferredDataForOptions), + [headers, deferredDataForOptions] + ) + + // Cheap: just re-orders each column's already-known distinct-value list + const columnOptions = useMemo( + () => + sortColumnOptions(columnDistinctValues, { + sortField, + sortDirection, + }) ?? EMPTY_COLUMN_OPTIONS, + [columnDistinctValues, sortField, sortDirection] + ) + + 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 knownOrgUnitNames = useMemo( + () => buildKnownOrgUnitNames(dataWithAggregations), + [dataWithAggregations] + ) + const { idToName: orgUnitIdToName } = useOrgUnitAncestorNames( + orgUnitPathValues, + knownOrgUnitNames + ) + const rows = useMemo(() => { if (errorCode.current) { return null @@ -321,74 +260,89 @@ export const useTableData = ({ layer, sortField, sortDirection }) => { return null } - const filteredData = filterData(dataWithAggregations, dataFilters) + let filteredData = filterData(dataWithAggregations, dataFilters) - //sort - filteredData.sort((a, b) => { - 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 (globalSearch?.trim()) { + filteredData = filterByGlobalSearch(filteredData, globalSearch, { + headers, + orgUnitIdToName, + keyAnalysisDigitGroupSeparator, + }) + } - 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 + 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 + ) } + } - // TODO: Make sure sorting works across different locales - return sortDirection === ASCENDING - ? aVal.localeCompare(bVal) - : bVal.localeCompare(aVal) - }) - - 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, - } + // Sort + const sortFieldRenderer = headers.find( + (h) => h.dataKey === sortField + )?.renderer + filteredData.sort((a, b) => + compareRows(a, b, { + sortField, + sortDirection, + selectedIdSet, + orgUnitRenderer: sortFieldRenderer, + idToName: orgUnitIdToName, }) ) - }, [headers, dataWithAggregations, dataFilters, sortField, sortDirection]) + + return filteredData.map((item) => buildRowCells(item, headers)) + // *Dependency vars proxy their raw counterparts (see above) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + headers, + dataWithAggregations, + dataFilters, + globalSearch, + sortField, + sortDirection, + selectionFilter, + selectedIdSetDependency, + orgUnitIdToName, + ]) // 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 || layer.forceClientCluster) + const isLoading = isLoadingAggregations || isExtendingEvents + 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 return { headers, rows, isLoading, + loadingReason, error: getErrorCodeText(errorCode.current), + totalCount, + filteredCount, + orgUnitIdToName, + columnOptions, } } diff --git a/src/components/edit/LayerEdit.jsx b/src/components/edit/LayerEdit.jsx index 046b1b81e0..8ecfd76415 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' @@ -30,6 +33,7 @@ const layerDialogs = { facility: FacilityDialog, thematic: ThematicDialog, orgUnit: OrgUnitDialog, + combinedTableRef: OrgUnitDialog, earthEngine: EarthEngineDialog, geoJsonUrl: GeoJsonDialog, } @@ -40,6 +44,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'), }) @@ -93,9 +98,22 @@ 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 + 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 + + 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}> @@ -110,6 +128,7 @@ const LayerEdit = ({ layer, addLayer, updateLayer, cancelLayer }) => { orgUnits={orgUnits} validateLayer={isValidLayer} onLayerValidation={onLayerValidation} + hideStyleTab={isReferenceLayer} /> </div> </ModalContent> @@ -123,11 +142,7 @@ const LayerEdit = ({ layer, addLayer, updateLayer, cancelLayer }) => { onClick={onValidateLayer} dataTest="layeredit-addbtn" > - {i18n.t( - layer.id - ? i18n.t('Update layer') - : i18n.t('Add layer') - )} + {submitButtonLabel} </Button> </ButtonStrip> </ModalActions> diff --git a/src/components/edit/__tests__/LayerEdit.spec.jsx b/src/components/edit/__tests__/LayerEdit.spec.jsx new file mode 100644 index 0000000000..352ca883a6 --- /dev/null +++ b/src/components/edit/__tests__/LayerEdit.spec.jsx @@ -0,0 +1,103 @@ +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: () => ({}), +})) + +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() + }) + + 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/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() + }) +}) 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/components/layers/LayersPanel.jsx b/src/components/layers/LayersPanel.jsx index 9ebbeb01d0..81e0d319f4 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,20 @@ SortableLayer.propTypes = { layer: PropTypes.object.isRequired, } +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()) + const reversedMapViews = useSelector((state) => + [...state.map.mapViews].reverse() + ) + const layers = reversedMapViews.filter( + (l) => l.layer !== COMBINED_TABLE_REF_LAYER + ) const dispatch = useDispatch() @@ -101,8 +112,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..0e7623bdfe --- /dev/null +++ b/src/components/layers/__tests__/LayersPanel.spec.jsx @@ -0,0 +1,86 @@ +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', () => { + 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/layers/overlays/OverlayCard.jsx b/src/components/layers/overlays/OverlayCard.jsx index a490f28cda..d472337760 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, @@ -36,6 +37,46 @@ 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 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, @@ -45,6 +86,7 @@ const OverlayCard = ({ toggleLayerExpand, toggleLayerVisibility, toggleDataTable, + clearDataFilters, }) => { const [showDataDownloadDialog, setShowDataDownloadDialog] = useState(false) const { baseUrl } = useConfig() @@ -61,88 +103,54 @@ 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 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> - ) - ) - } + const hasDataFilters = Object.keys(dataFilters ?? {}).length > 0 return ( <> <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 - } + onEdit={ifAllowed(canEdit, () => editLayer(layer))} + toggleDataTable={ifAllowed(canToggleDataTable, () => + toggleDataTable(id) + )} + onClearDataFilters={ifAllowed(hasDataFilters, () => + clearDataFilters(id) + )} toggleLayerVisibility={() => toggleLayerVisibility(id)} onOpacityChange={(newOpacity) => changeLayerOpacity(id, newOpacity) } onDuplicate={() => duplicateLayer(id)} onRemove={() => { - removeLayer(id) + removeLayer(id, layer.combinedLayerKey) layerRemovedAlert.show({ msg: i18n.t('{{- name}} deleted.', { name }), }) }} - downloadData={ - canDownload - ? () => setShowDataDownloadDialog(true) - : undefined - } - 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' - ) - } - : undefined - } + downloadData={ifAllowed(canDownload, () => + setShowDataDownloadDialog(true) + )} + openAs={ifAllowed( + canOpenAs, + getOpenAsHandler(layer, baseUrl, set) + )} hasError={!!loadError} > - {getCardContent()} + {getCardContent({ loadError, legend })} </LayerCard> {showDataDownloadDialog && ( <DataDownloadDialog @@ -156,6 +164,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 +179,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 4da6e6ae81..781490abf6 100644 --- a/src/components/layers/overlays/__tests__/OverlayCard.spec.jsx +++ b/src/components/layers/overlays/__tests__/OverlayCard.spec.jsx @@ -28,9 +28,13 @@ jest.mock('@dhis2/app-service-alerts', () => ({ const mockStore = configureMockStore() describe('OverlayCard', () => { - const renderCard = (name) => - render( - <Provider store={mockStore({ dataTable: null, aggregations: {} })}> + const renderCard = (name, layerOverrides = {}) => { + const store = mockStore({ + dataTable: { openIds: [] }, + aggregations: {}, + }) + const rendered = render( + <Provider store={store}> <OverlayCard layer={{ id: 'layer1', @@ -40,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"') @@ -58,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/LayerToolbarMoreMenu.jsx b/src/components/layers/toolbar/LayerToolbarMoreMenu.jsx index 0e0ad2dd67..b774fed51f 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' @@ -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,15 +165,19 @@ const DEFAULT_EMPTY_LAYER = {} export default connect( ( - { dataTable: dataTableOpen, aggregations }, + { dataTable: { openIds }, aggregations }, { 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] - return { dataTableOpen, hasOrgUnitData, isLoading } + return { openIds, hasOrgUnitData, isLoading } } )(LayerToolbarMoreMenu) 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__/LayerToolbarMoreMenu.spec.jsx b/src/components/layers/toolbar/__tests__/LayerToolbarMoreMenu.spec.jsx index 314b731eaa..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,111 @@ 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: {}, + } + + 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('also enables Download data for a server-clustered event layer with no data yet', async () => { + const store = { + dataTable: { openIds: [] }, + 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 = { + dataTable: { openIds: [] }, aggregations: {}, } @@ -188,6 +293,7 @@ describe('LayerToolbarMoreMenu', () => { test('renders four MenuItems WITH divider if passed toggleDataTable, downloadData, onEdit, and onRemove', async () => { const store = { + dataTable: { openIds: [] }, aggregations: {}, } @@ -224,6 +330,7 @@ describe('LayerToolbarMoreMenu', () => { test('renders Duplicate layer item between Edit layer and Remove layer', async () => { const store = { + dataTable: { openIds: [] }, aggregations: {}, } @@ -261,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/layers/toolbar/__tests__/__snapshots__/LayerToolbar.spec.jsx.snap b/src/components/layers/toolbar/__tests__/__snapshots__/LayerToolbar.spec.jsx.snap index 37ad5d4ab0..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 @@ -9,6 +86,7 @@ exports[`LayerToolbar Should render edit button 1`] = ` <button class="iconButton editButton" data-test="layer-edit-button" + type="button" > <div> <svg @@ -27,6 +105,7 @@ exports[`LayerToolbar Should render edit button 1`] = ` <button class="iconButton visible" data-test="visibilitybutton" + type="button" > <div> <div> @@ -53,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> @@ -72,6 +155,7 @@ exports[`LayerToolbar Should render only a visibility toggle and opacity slider <button class="iconButton visible" data-test="visibilitybutton" + type="button" > <div> <div> @@ -98,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> @@ -117,6 +205,7 @@ exports[`LayerToolbar Should show SvgViewOff24 when not visible 1`] = ` <button class="iconButton notvisible" data-test="visibilitybutton" + type="button" > <div> <div> @@ -144,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/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/components/map/ContextMenu.jsx b/src/components/map/ContextMenu.jsx index f938129dd3..3c6f923244 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,23 @@ import { import { setOrgUnitProfile } from '../../actions/orgUnits.js' 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' 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, @@ -35,12 +45,15 @@ const ContextMenu = (props) => { layerConfig, coordinates, earthEngineLayers, + selectedIds, position, offset, closeContextMenu, + highlightFeature, openCoordinatePopup, showEarthEngineValue, setOrgUnitProfile, + setFeatureProfile, updateLayer, } = props @@ -51,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] @@ -81,7 +97,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 +115,29 @@ 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 + 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: } @@ -109,35 +158,67 @@ const ContextMenu = (props) => { > <div className={styles.menu}> <Menu dense dataTest="context-menu"> - {layerType !== FACILITY_LAYER && feature && ( + {supportsProfileAndDrill && + 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')} + /> + )} + + {supportsProfileAndDrill && + 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')} + /> + )} + + {supportsProfileAndDrill && feature && ( <MenuItem - dataTest="context-menu-drill-up" - label={i18n.t('Drill up one level')} - icon={<IconArrowUp16 />} - disabled={!attr.hasCoordinatesUp} - onClick={() => onClick('drill_up')} + dataTest="context-menu-view-profile" + label={i18n.t('View profile')} + icon={<IconInfo16 />} + onClick={() => onClick('show_info')} /> )} - {layerType !== FACILITY_LAYER && feature && ( + {feature && ( <MenuItem - dataTest="context-menu-drill-down" - label={i18n.t('Drill down one level')} - icon={<IconArrowDown16 />} - disabled={!attr.hasCoordinatesDown} - onClick={() => onClick('drill_down')} + dataTest="context-menu-zoom-to-feature" + label={i18n.t('Zoom to feature')} + icon={<IconZoomIn16 />} + onClick={() => onClick('zoom_to_feature')} /> )} {feature && ( <MenuItem - dataTest="context-menu-view-profile" - label={i18n.t('View profile')} - icon={<IconInfo16 />} - onClick={() => onClick('show_info')} + 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" @@ -169,7 +250,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, @@ -181,19 +264,26 @@ 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, + highlightFeature, openCoordinatePopup, showEarthEngineValue, + setFeatureProfile, setOrgUnitProfile, updateLayer, } diff --git a/src/components/map/Map.jsx b/src/components/map/Map.jsx index bc3571c6ed..31c754d6c9 100644 --- a/src/components/map/Map.jsx +++ b/src/components/map/Map.jsx @@ -2,7 +2,12 @@ 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, + fitCrossLayerZoomBounds, +} from '../../util/map.js' import { sortPeriodsByLevelAndStartDate, addPeriodsDetails, @@ -40,11 +45,15 @@ class Map extends Component { openContextMenu: PropTypes.func.isRequired, basemap: PropTypes.object, bounds: PropTypes.array, + clickFeature: PropTypes.func, closeCoordinatePopup: PropTypes.func, + combinedVisibleIds: PropTypes.object, 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 +62,13 @@ class Map extends Component { longitude: PropTypes.number, nameProperty: PropTypes.string, resizeCount: PropTypes.number, + selection: PropTypes.object, + selectionFilter: PropTypes.array, + setActiveTimelinePeriod: PropTypes.func, setAggregations: PropTypes.func, setFeatureProfile: PropTypes.func, setMapObject: PropTypes.func, + toggleFeatureSelection: PropTypes.func, zoom: PropTypes.number, } @@ -152,11 +165,17 @@ class Map extends Component { onFullscreenChange(this.map, isFullscreen) } + this.handleCrossLayerZoom(prevProps) + const overlays = this.getLoadedLayers(layers) const timelineOverlay = this.getTimelineOverlay(overlays) this.initializeTimelinePeriod(timelineOverlay) } + handleCrossLayerZoom(prevProps) { + fitCrossLayerZoomBounds(this.map, this.props.feature, prevProps.feature) + } + // Remove map componentWillUnmount() { if (this._onWindowResize) { @@ -176,9 +195,17 @@ class Map extends Component { nameProperty, layers, feature, + selection, + highlightFeature, + highlightColor, + selectionFilter, + combinedVisibleIds, + clickFeature, + toggleFeatureSelection, coordinatePopup: coordinates, closeCoordinatePopup, openContextMenu, + setActiveTimelinePeriod, setAggregations, setFeatureProfile, resizeCount, @@ -203,25 +230,35 @@ class Map extends Component { periodId={period.id} period={period} periods={timelineOverlay?.periods} - onChange={(period) => + onChange={(period) => { this.setState({ period }) - } + setActiveTimelinePeriod?.(period) + }} resizeCount={resizeCount} /> </Fragment> )} {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 key={config.id} index={layers.length - index} feature={highlight} + selection={selection} + highlightFeature={highlightFeature} + highlightColor={highlightColor} + selectionFilter={selectionFilter} + combinedVisibleIds={combinedVisibleIds} + clickFeature={clickFeature} + toggleFeatureSelection={ + toggleFeatureSelection + } openContextMenu={openContextMenu} setAggregations={setAggregations} setFeatureProfile={setFeatureProfile} @@ -302,6 +339,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 3ae0ee48ec..dbff78fcda 100644 --- a/src/components/map/MapContainer.jsx +++ b/src/components/map/MapContainer.jsx @@ -1,10 +1,18 @@ 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 { setActiveTimelinePeriod } from '../../actions/dataTable.js' +import { + highlightFeature, + setFeatureProfile, + clickFeature, +} 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' import MapName from './MapName.jsx' import MapView from './MapView.jsx' @@ -17,12 +25,30 @@ 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 combinedView = useSelector((state) => state.dataTable.combinedView) + const { + layersSorting, + highlightColor, + selectionFilter, + combinedVisibleIds, + } = useSelector((state) => state.ui) const basemapConfig = useBasemapConfig(basemap) const dispatch = useDispatch() - const loadedMapViews = mapViews.filter((layer) => layer.isLoaded) - const isLoading = loadedMapViews.length !== mapViews.length + const dispatchHighlightFeature = useCallback( + (payload) => dispatch(highlightFeature(payload)), + [dispatch] + ) + const debouncedHighlightFeature = useDebouncedHighlightFeature( + dispatchHighlightFeature + ) + + 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 ( <> @@ -33,12 +59,26 @@ const MapContainer = ({ resizeCount, setMap }) => { layers={loadedMapViews} bounds={bounds} feature={feature} + selection={selection} + highlightColor={highlightColor} + selectionFilter={selectionFilter} + combinedVisibleIds={combinedVisibleIds} + highlightFeature={debouncedHighlightFeature} + clickFeature={(payload) => dispatch(clickFeature(payload))} + toggleFeatureSelection={(id, layerId) => { + if (!combinedView) { + dispatch(toggleFeatureSelection(id, layerId)) + } + }} openContextMenu={(config) => dispatch(openContextMenu(config))} coordinatePopup={coordinatePopup} interpretationModalOpen={interpretationModalOpen} 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/components/map/MapPosition.jsx b/src/components/map/MapPosition.jsx index b66119cafa..17e4cc0163 100644 --- a/src/components/map/MapPosition.jsx +++ b/src/components/map/MapPosition.jsx @@ -1,6 +1,8 @@ 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 { isDataTableOpen } from '../../util/dataTable.js' import { getSplitViewLayer } from '../../util/helpers.js' import DownloadMapInfo from '../download/DownloadMapInfo.jsx' import NorthArrow from '../download/NorthArrow.jsx' @@ -13,6 +15,7 @@ const MapPosition = () => { const [map, setMap] = useState() const [resizeCount, setResizeCount] = useState(0) const outerRef = useRef(null) + const dispatch = useDispatch() const { showName, showDescription, @@ -24,7 +27,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) => + isDataTableOpen(state.dataTable) + ) const downloadMapInfoOpen = downloadMode && @@ -83,6 +88,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..22ce5eb8dd 100644 --- a/src/components/map/MapView.jsx +++ b/src/components/map/MapView.jsx @@ -17,6 +17,13 @@ const MapView = (props) => { layers, controls, feature, + selection, + highlightFeature, + highlightColor, + selectionFilter, + combinedVisibleIds, + clickFeature, + toggleFeatureSelection, bounds, coordinatePopup, interpretationModalOpen, @@ -57,6 +64,13 @@ const MapView = (props) => { layers={splitViewLayers.reverse()} controls={mapControls} feature={feature} + selection={selection} + highlightFeature={highlightFeature} + highlightColor={highlightColor} + selectionFilter={selectionFilter} + combinedVisibleIds={combinedVisibleIds} + clickFeature={clickFeature} + toggleFeatureSelection={toggleFeatureSelection} interpretationModalOpen={interpretationModalOpen} openContextMenu={openContextMenu} resizeCount={resizeCount} @@ -72,6 +86,13 @@ const MapView = (props) => { bounds={bounds} controls={mapControls} feature={feature} + selection={selection} + highlightFeature={highlightFeature} + highlightColor={highlightColor} + selectionFilter={selectionFilter} + combinedVisibleIds={combinedVisibleIds} + clickFeature={clickFeature} + toggleFeatureSelection={toggleFeatureSelection} coordinatePopup={coordinatePopup} openContextMenu={openContextMenu} resizeCount={resizeCount} @@ -92,9 +113,13 @@ const MapView = (props) => { MapView.propTypes = { basemap: PropTypes.object, bounds: PropTypes.array, + clickFeature: PropTypes.func, + combinedVisibleIds: PropTypes.object, 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 +127,10 @@ MapView.propTypes = { layersSorting: PropTypes.bool, openContextMenu: PropTypes.func, resizeCount: PropTypes.number, + selection: PropTypes.object, + selectionFilter: PropTypes.array, setMapObject: PropTypes.func, + toggleFeatureSelection: PropTypes.func, } export default MapView diff --git a/src/components/map/SplitView.jsx b/src/components/map/SplitView.jsx index c3268e96a5..75c5e0f2a8 100644 --- a/src/components/map/SplitView.jsx +++ b/src/components/map/SplitView.jsx @@ -13,6 +13,13 @@ const SplitView = ({ basemap, layers, feature, + selection, + highlightFeature, + highlightColor, + selectionFilter, + combinedVisibleIds, + clickFeature, + toggleFeatureSelection, controls, openContextMenu = Function.prototype, isFullscreen, @@ -92,6 +99,13 @@ const SplitView = ({ index={layers.length - index} externalPeriod={period} feature={feature} + selection={selection} + highlightFeature={highlightFeature} + highlightColor={highlightColor} + selectionFilter={selectionFilter} + combinedVisibleIds={combinedVisibleIds} + clickFeature={clickFeature} + toggleFeatureSelection={toggleFeatureSelection} openContextMenu={openContextMenu} /> ) @@ -113,14 +127,21 @@ SplitView.propTypes = { ).isRequired, openContextMenu: PropTypes.func.isRequired, basemap: PropTypes.object, + clickFeature: PropTypes.func, + combinedVisibleIds: PropTypes.object, 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, + selectionFilter: PropTypes.array, setMapObject: PropTypes.func, + 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/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/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 b837f48a02..864de180a2 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,15 @@ class GeoJsonLayer extends Layer { onClick: isPlugin ? Function.prototype : this.onFeatureClick.bind(this), + 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) @@ -55,27 +63,25 @@ class GeoJsonLayer extends Layer { } onFeatureClick(evt) { - const { keyAnalysisDigitGroupSeparator } = this.props + 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 ) - 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 c8bff13635..e09c53b298 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' @@ -6,6 +7,21 @@ import { PADDING_DEFAULT, DURATION_DEFAULT, } from '../../../constants/layers.js' +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]) + +export const visibleIdsEqual = (a, b) => { + if (a === null || b === null) { + return a === b + } + return idsEqual(a, b) +} class Layer extends PureComponent { static contextTypes = { @@ -15,16 +31,23 @@ 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, 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, + selectionFilter: PropTypes.array, + toggleFeatureSelection: PropTypes.func, } static defaultProps = { @@ -41,50 +64,121 @@ class Layer extends PureComponent { } componentDidUpdate(prevProps, prevState = {}) { - const { - id, - data, - index, - opacity, - isVisible, - editCounter, - dataFilters, - feature, - } = 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.highlightFeature(feature) + handleFeatureChange(prevProps) { + const { feature } = this.props + if (feature === prevProps.feature) { + return + } + + this.handleFeatureUpdate(feature) + + if ( + !idsEqual( + this.getHoverIds(prevProps.feature), + this.getHoverIds(feature) + ) + ) { + this.highlightFeature() + } + } + + handleSelectionChange(prevProps) { + const { selection } = this.props + if ( + selection !== prevProps.selection && + !idsEqual( + this.getSelectedIds(prevProps.selection), + this.getSelectedIds(selection) + ) + ) { + this.selectFeatures() + } + } + + handleHighlightColorChange(prevProps) { + if (this.props.highlightColor === prevProps.highlightColor) { + return + } + + if (this.getHoverIds().length) { + this.highlightFeature() + } + if (this.getSelectedIds().length) { + this.selectFeatures() + } + } + + handleVisibleIdsChange(prevProps) { + const { selection, selectionFilter, combinedVisibleIds } = this.props + if ( + !visibleIdsEqual( + this.getVisibleIds( + prevProps.selection, + prevProps.selectionFilter, + prevProps.combinedVisibleIds + ), + this.getVisibleIds( + selection, + selectionFilter, + combinedVisibleIds + ) + ) + ) { + this.updateVisibleIds() } } @@ -114,6 +208,9 @@ class Layer extends PureComponent { await this.createLayer(true) this.setLayerOrder() this.setLayerVisibility() + this.highlightFeature() + this.selectFeatures() + this.updateVisibleIds() } // Override in subclass if needed @@ -182,12 +279,163 @@ class Layer extends PureComponent { } } - highlightFeature(feature) { - if (this.layer.highlight) { - this.layer.highlight(feature ? feature.id : null) + handleFeatureUpdate(feature) { + if (feature?.zoom && feature?.layerId === this.props.id) { + if (feature.ids?.length) { + this.panToFeature(feature.ids) + } else if (feature.id != null) { + this.panToFeature(feature.id) + } else { + this.fitBounds() + } } } + 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 getLayerSelectedIds(selection, this.props.id) + } + + highlightFeature() { + this.layer?.highlight?.(this.getHoverIds(), this.props.highlightColor) + } + + selectFeatures() { + this.layer?.select?.(this.getSelectedIds(), this.props.highlightColor) + } + + // 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 + + if (!selectionFilter?.length || !isReferenced) { + 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 + } + + 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)) + } + + 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()) + } + + onFeatureLeftClick(evt) { + const id = evt.feature?.properties?.id + + if (!id) { + return + } + + const multiSelect = this.isMultiSelectClick(evt) + this.props.clickFeature?.({ id, layerId: this.props.id, multiSelect }) + + if (multiSelect) { + this.props.toggleFeatureSelection?.(id, this.props.id) + } + } + + 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', + }) + } + } + + onFeatureMouseLeave() { + this.props.highlightFeature?.(null) + } + + panToFeature(featureIds) { + if (!this.layer?.getFeaturesById) { + return + } + const ids = Array.isArray(featureIds) ? featureIds : [featureIds] + const features = ids + .flatMap((id) => this.layer.getFeaturesById(id) ?? []) + .filter((f) => f.geometry) + if (!features.length) { + return + } + + const [minLng, minLat, maxLng, maxLat] = bbox({ + type: 'FeatureCollection', + features, + }) + + if (!Number.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 } @@ -201,6 +449,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 adf4bfbfe9..ff968b2e18 100644 --- a/src/components/map/layers/ThematicLayer.jsx +++ b/src/components/map/layers/ThematicLayer.jsx @@ -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) { @@ -191,33 +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() - 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() @@ -230,6 +236,9 @@ class ThematicLayer extends Layer { ) { try { this.layer.setData(filteredData) + this.highlightFeature() + this.selectFeatures() + this.updateVisibleIds() } catch (e) { console.warn('Failed to set layer data incrementally:', e) // fallback to full update on error @@ -239,34 +248,39 @@ 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?.[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) { - 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/__tests__/Layer.spec.js b/src/components/map/layers/__tests__/Layer.spec.js new file mode 100644 index 0000000000..d953b94bd6 --- /dev/null +++ b/src/components/map/layers/__tests__/Layer.spec.js @@ -0,0 +1,352 @@ +import Layer, { visibleIdsEqual } from '../Layer.js' + +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) + }) + + 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) + }) + + 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('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 }) + 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']) + }) +}) + +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/components/map/layers/earthEngine/EarthEngineLayer.jsx b/src/components/map/layers/earthEngine/EarthEngineLayer.jsx index 8a97a6f230..35ddf16b41 100644 --- a/src/components/map/layers/earthEngine/EarthEngineLayer.jsx +++ b/src/components/map/layers/earthEngine/EarthEngineLayer.jsx @@ -45,6 +45,9 @@ export default class EarthEngineLayer extends Layer { await this.removeLayer() await this.createLayer(true) this.setLayerOrder() + this.highlightFeature() + this.selectFeatures() + this.updateVisibleIds() } } @@ -115,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), } @@ -252,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/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/components/plugin/Map.jsx b/src/components/plugin/Map.jsx index 32471e8a88..9c69423cb8 100644 --- a/src/components/plugin/Map.jsx +++ b/src/components/plugin/Map.jsx @@ -10,8 +10,11 @@ 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' import MapView from '../map/MapView.jsx' @@ -36,25 +39,37 @@ const getFullscreenDoc = () => { const Map = forwardRef((props, ref) => { const { basemap, mapViews, controls, getResizeFunction } = props + 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]) + }, [renderableMapViews]) - const [mapIsLoaded, setMapIsLoaded] = useState(mapViews.length === 0) + const [mapIsLoaded, setMapIsLoaded] = useState( + renderableMapViews.length === 0 + ) const [contextMenu, setContextMenu] = useState() const [visibilityOverrides, setVisibilityOverrides] = useState({}) const [resizeCount, setResizeCount] = useState(0) const [isFullscreen, setIsFullscreen] = useState( () => !!getFullscreenDoc().fullscreenElement ) + const [hoveredFeature, setHoveredFeature] = useState(null) + const highlightFeature = useDebouncedHighlightFeature(setHoveredFeature) const onResize = () => setResizeCount((state) => state + 1) @@ -180,6 +195,8 @@ const Map = forwardRef((props, ref) => { bounds={defaultBounds} openContextMenu={setContextMenu} resizeCount={resizeCount} + feature={hoveredFeature} + highlightFeature={highlightFeature} /> {mapViews.length > 0 && ( <Legend 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, } } 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/actionTypes.js b/src/constants/actionTypes.js index 04bc55e885..0dc047615a 100644 --- a/src/constants/actionTypes.js +++ b/src/constants/actionTypes.js @@ -35,15 +35,31 @@ 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' +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' +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' +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 */ 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' @@ -181,6 +197,13 @@ 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_SET_CROSS_LAYER = 'SELECTION_SET_CROSS_LAYER' +export const SELECTION_CLEAR = 'SELECTION_CLEAR' + /* AGGREGATIONS */ export const AGGREGATIONS_SET = 'AGGREGATIONS_SET' diff --git a/src/constants/aggregationTypes.js b/src/constants/aggregationTypes.js index 2c942a9cd9..939a64746f 100644 --- a/src/constants/aggregationTypes.js +++ b/src/constants/aggregationTypes.js @@ -12,6 +12,16 @@ export const getThematicAggregationTypes = () => [ { id: 'MAX', name: i18n.t('Max') }, ] +// Combined data table join +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') }, 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/constants/dataTable.js b/src/constants/dataTable.js new file mode 100644 index 0000000000..f5457f0b0d --- /dev/null +++ b/src/constants/dataTable.js @@ -0,0 +1,35 @@ +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' + +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 RENDERER_BOOLEAN = 'renderboolean' + +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' + +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' + +export const COMBINED_HEADERS_KEY = '__combined__' + +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/constants/layers.js b/src/constants/layers.js index bb31be7119..d397e900f6 100644 --- a/src/constants/layers.js +++ b/src/constants/layers.js @@ -17,6 +17,7 @@ export const TRACKED_ENTITY_LAYER = 'trackedEntity' export const GEOJSON_LAYER = 'geoJson' export const GROUP_LAYER = 'group' export const GEOJSON_URL_LAYER = 'geoJsonUrl' +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: [ @@ -51,6 +52,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/constants/selection.js b/src/constants/selection.js new file mode 100644 index 0000000000..68174c2b82 --- /dev/null +++ b/src/constants/selection.js @@ -0,0 +1,2 @@ +export const SELECTION_FILTER_SELECTED = 'selected' +export const SELECTION_FILTER_NOT_SELECTED = 'not-selected' 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/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/__tests__/useLayersLoader.spec.js b/src/hooks/__tests__/useLayersLoader.spec.js new file mode 100644 index 0000000000..d8de99c618 --- /dev/null +++ b/src/hooks/__tests__/useLayersLoader.spec.js @@ -0,0 +1,185 @@ +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 + +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: { openIds: ['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: { openIds: ['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: { openIds: ['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: { 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', () => { + test('passes spatialSupport from useCachedData into the event loader call', () => { + mockCachedData.spatialSupport = true + + renderWithStore({ + map: { + mapViews: [{ ...baseLayer, isLoaded: false }], + }, + dataTable: { openIds: [] }, + }) + + expect(eventLoader).toHaveBeenCalledWith( + expect.objectContaining({ spatialSupport: true }) + ) + }) + + test('passes spatialSupport: false through unchanged', () => { + mockCachedData.spatialSupport = false + + renderWithStore({ + map: { + mapViews: [{ ...baseLayer, isLoaded: false }], + }, + dataTable: { openIds: [] }, + }) + + expect(eventLoader).toHaveBeenCalledWith( + expect.objectContaining({ spatialSupport: false }) + ) + }) +}) diff --git a/src/hooks/__tests__/useOrgUnitAncestorNames.spec.js b/src/hooks/__tests__/useOrgUnitAncestorNames.spec.js new file mode 100644 index 0000000000..66d542c49f --- /dev/null +++ b/src/hooks/__tests__/useOrgUnitAncestorNames.spec.js @@ -0,0 +1,129 @@ +import { renderHook, waitFor } from '@testing-library/react' +import { fetchOrgUnitPathDetails } from '../../util/orgUnits.js' +import useOrgUnitAncestorNames, { + __resetOrgUnitNameSessionCacheForTests, +} from '../useOrgUnitAncestorNames.js' + +jest.mock('@dhis2/app-runtime', () => ({ + useDataEngine: () => mockEngine, +})) +const mockEngine = {} + +jest.mock('../../components/cachedDataProvider/CachedDataProvider.jsx', () => ({ + useCachedData: () => ({ nameProperty: 'displayShortName' }), +})) + +jest.mock('../../util/orgUnits.js', () => ({ + fetchOrgUnitPathDetails: jest.fn(), +})) + +beforeEach(() => { + fetchOrgUnitPathDetails.mockReset() + __resetOrgUnitNameSessionCacheForTests() +}) + +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', + ]), + 'displayShortName' + ) + }) + + 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') + }) + + 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/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/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/hooks/useLayersLoader.js b/src/hooks/useLayersLoader.js index 194765c4cf..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, @@ -34,10 +35,11 @@ export const useLayersLoader = () => { const { systemSettings: { keyAnalysisDigitGroupSeparator }, currentUser, + spatialSupport, } = 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}`, @@ -64,7 +66,8 @@ 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) { showAlerts(result.alerts) @@ -85,9 +88,9 @@ 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.serverCluster || layer.forceClientCluster) ) { return true } @@ -126,7 +129,8 @@ export const useLayersLoader = () => { showAlerts, showLoaderAlert, baseUrl, - dataTable, + openIds, serverVersion, + spatialSupport, ]) } diff --git a/src/hooks/useOrgUnitAncestorNames.js b/src/hooks/useOrgUnitAncestorNames.js new file mode 100644 index 0000000000..cad33a0f16 --- /dev/null +++ b/src/hooks/useOrgUnitAncestorNames.js @@ -0,0 +1,84 @@ +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 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( + () => [ + ...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 + } + + 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, idsToFetch, nameProperty).then( + (details) => { + if (cancelled) { + return + } + Object.entries(details).forEach(([id, d]) => { + orgUnitNameSessionCache.set(id, d.name) + }) + setIdToName(buildMerged()) + 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, knownIdToName]) + + return { idToName, loading } +} + +export default useOrgUnitAncestorNames diff --git a/src/loaders/__tests__/eventLoader.spec.js b/src/loaders/__tests__/eventLoader.spec.js index ab9af8ba2d..e01b8d5e09 100644 --- a/src/loaders/__tests__/eventLoader.spec.js +++ b/src/loaders/__tests__/eventLoader.spec.js @@ -4,12 +4,17 @@ 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 eventLoader, { + attachOrgUnitPaths, + excludeEventsOutsideOrgUnits, + shouldUseServerCluster, +} from '../eventLoader.js' // [0,0]-[10,10] const SQUARE_A = [ @@ -729,3 +734,465 @@ describe('excludeEventsOutsideOrgUnits', () => { expect(config.legend.orgUnitsWithoutBoundaryCount).toBeUndefined() }) }) + +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() + }) + + 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', () => { + 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) + }) +}) + +// A minimal chainable stand-in for the real analytics request builder +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() + // 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 () => { + const result = await eventLoader( + makeArgs({ ...baseConfig(), forceClientCluster: true }) + ) + + 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) + }) + + 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__/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__/geoJsonUrlLoader.spec.js b/src/loaders/__tests__/geoJsonUrlLoader.spec.js new file mode 100644 index 0000000000..8eac7f2bb2 --- /dev/null +++ b/src/loaders/__tests__/geoJsonUrlLoader.spec.js @@ -0,0 +1,67 @@ +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', () => { + 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__/orgUnitLoader.spec.js b/src/loaders/__tests__/orgUnitLoader.spec.js new file mode 100644 index 0000000000..8cca296a83 --- /dev/null +++ b/src/loaders/__tests__/orgUnitLoader.spec.js @@ -0,0 +1,261 @@ +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 orgUnitLoader, { 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', + }), + ]) + }) +}) + +// 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/__tests__/trackedEntityLoader.spec.js b/src/loaders/__tests__/trackedEntityLoader.spec.js index 634049846e..8e13bd7199 100644 --- a/src/loaders/__tests__/trackedEntityLoader.spec.js +++ b/src/loaders/__tests__/trackedEntityLoader.spec.js @@ -1,10 +1,163 @@ -import { parseJsonConfig } from '../trackedEntityLoader.js' +import { + getAttributeHeaders, + getAttributeProperties, + applyParsedConfig, + toGeoJson, + toOptionSetOptionsByCode, +} from '../trackedEntityLoader.js' jest.mock('../../components/map/MapApi.js', () => ({ loadEarthEngineWorker: jest.fn(), })) -describe('parseJsonConfig', () => { +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({}) + }) + + 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() + }) + + 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', () => { + 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', + 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', () => { it('extracts periodType when relationships is null', () => { const config = { config: JSON.stringify({ @@ -12,7 +165,7 @@ describe('parseJsonConfig', () => { periodType: 'program', }), } - parseJsonConfig(config) + applyParsedConfig(config) expect(config.periodType).toBe('program') expect(config.relationshipType).toBeUndefined() expect(config.config).toBeUndefined() @@ -31,7 +184,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') @@ -41,16 +194,125 @@ describe('parseJsonConfig', () => { 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' } - parseJsonConfig(config) - expect(config).toEqual({ layer: 'trackedEntity' }) + applyParsedConfig(config) + expect(config).toEqual({ + layer: 'trackedEntity', + combinedLayerKey: expect.any(String), + }) }) 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() }) }) + +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', + 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') + }) + + 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') + }) +}) + +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/earthEngineLoader.js b/src/loaders/earthEngineLoader.js index 19ee620108..ce2cb01722 100644 --- a/src/loaders/earthEngineLoader.js +++ b/src/loaders/earthEngineLoader.js @@ -20,9 +20,12 @@ import { getRoundToPrecisionFn, formatWithSeparator } from '../util/numbers.js' import { getCoordinateField, addAssociatedGeometries, + attachOrgUnitPaths, + getMissingOrgUnitId, getOrgUnitsWithoutCoordsCount, } from '../util/orgUnits.js' import { GEOFEATURES_QUERY } from '../util/requests.js' +import { generateUid } from '../util/uid.js' const earthEngineLoader = async ({ config, @@ -139,7 +142,11 @@ const earthEngineLoader = async ({ } else { orgUnitsWithoutCoordsCount = result.count if (result.count > 0) { - config.dataWithoutCoords = result.missingOrgUnits + config.dataWithoutCoords = await attachOrgUnitPaths( + result.missingOrgUnits, + engine, + getMissingOrgUnitId + ) } } } @@ -194,6 +201,9 @@ const earthEngineLoader = async ({ ...config, ...layerConfig, } + if (!layer.combinedLayerKey) { + layer.combinedLayerKey = generateUid() + } const { unit, diff --git a/src/loaders/eventLoader.js b/src/loaders/eventLoader.js index da3b847c16..4c8110b062 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, @@ -32,6 +36,7 @@ import { } from '../util/geojson.js' import { formatWithSeparator, parseWithSeparator } from '../util/numbers.js' import { + attachOrgUnitPaths as attachOrgUnitPathsUtil, fetchAssociatedGeometries, fetchOrgUnitPaths, getPolygonItems, @@ -40,12 +45,29 @@ 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) => feature.properties?.ou ?? feature.properties?.['Organisation unit'] +export const attachOrgUnitPaths = async ({ config, engine }) => { + if (config.data?.length) { + config.data = await attachOrgUnitPathsUtil( + config.data, + engine, + getEventOuId + ) + } + if (config.dataWithoutCoords?.length) { + config.dataWithoutCoords = await attachOrgUnitPathsUtil( + config.dataWithoutCoords, + engine, + getEventOuId + ) + } +} + // Expands USER_ORGUNIT/_CHILDREN/_GRANDCHILDREN into ids; [id] if literal. const expandOrgUnitKeyword = (id, userOrgUnitIdsByKeyword) => { if (id in userOrgUnitIdsByKeyword) { @@ -58,12 +80,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 +121,7 @@ const eventLoader = async ({ analyticsEngine, periodTypeData, loadExtended, + spatialSupport, }) => { const config = { ...layerConfig, @@ -107,17 +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, - }) + 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 || @@ -140,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, @@ -149,6 +279,7 @@ const loadEventLayer = async ({ analyticsEngine, periodTypeData, loadExtended, + spatialSupport, }) => { // Config normalization // ----- @@ -161,6 +292,8 @@ const loadEventLayer = async ({ unclassifiedLegend: unclassifiedLegendFromConfig, noDataLegend: noDataLegendFromConfig, labelDataItem, + dataTableColumnConfig, + combinedLayerKey, } = parseJsonConfig(config.config) if (countFeaturesWithoutCoordinates) { config.countFeaturesWithoutCoordinates = true @@ -196,6 +329,11 @@ const loadEventLayer = async ({ if (noDataLegendFromConfig) { config.noDataLegend = noDataLegendFromConfig } + if (dataTableColumnConfig) { + config.dataTableColumnConfig = dataTableColumnConfig + } + config.combinedLayerKey = + combinedLayerKey ?? config.combinedLayerKey ?? generateUid() if (config.noDataColor) { config.noDataLegend = { ...noDataLegendFromConfig, @@ -235,13 +373,15 @@ const loadEventLayer = async ({ const dataFilters = getFiltersFromColumns(columns) - config.isExtended = loadExtended - - const analyticsRequest = await getAnalyticsRequest(config, { - analyticsEngine, - nameProperty: displayNameProp, - engine, - }) + // Request setup only - config.isExtended is set further down + const analyticsRequest = await getAnalyticsRequest( + { ...config, isExtended: loadExtended }, + { + analyticsEngine, + nameProperty: displayNameProp, + engine, + } + ) const alerts = [] // Legend skeleton @@ -276,14 +416,22 @@ 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 = config.forceClientCluster + ? false + : shouldUseServerCluster({ + count: response.count, + countFeaturesWithoutCoordinates: + config.countFeaturesWithoutCoordinates, + countEventsOutsideOrgUnits: config.countEventsOutsideOrgUnits, + spatialSupport, + }) 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 // ----- @@ -315,6 +463,8 @@ const loadEventLayer = async ({ }) } + await attachOrgUnitPaths({ config, engine }) + if (styleDataItem) { await styleByDataItem(config, engine) } @@ -358,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) && @@ -421,15 +589,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: - serverCount || - (Array.isArray(config?.data) ? config.data.length : 0), + count, }, ] } 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/facilityLoader.js b/src/loaders/facilityLoader.js index db0be8196b..f1a7967358 100644 --- a/src/loaders/facilityLoader.js +++ b/src/loaders/facilityLoader.js @@ -12,14 +12,17 @@ import { getPolygonItems, getStyledOrgUnits, getCoordinateField, + attachOrgUnitPaths, + getMissingOrgUnitId, getOrgUnitsWithoutCoordsCount, addGroupCountsToLegend, loadGroupSetData, fetchAssociatedGeometries, } from '../util/orgUnits.js' import { GEOFEATURES_QUERY } from '../util/requests.js' +import { generateUid } from '../util/uid.js' -const applyMissingCoordsCount = async ( +export const applyMissingCoordsCount = async ( config, { engine, orgUnitIds, userId, features, legend, alerts } ) => { @@ -42,7 +45,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 + ) } } @@ -65,14 +72,22 @@ const facilityLoader = async ({ // Config parsing // ----- - const { countFeaturesWithoutCoordinates, unclassifiedLegend } = - parseJsonConfig(config.config) + const { + countFeaturesWithoutCoordinates, + unclassifiedLegend, + dataTableColumnConfig, + combinedLayerKey, + } = parseJsonConfig(config.config) if (countFeaturesWithoutCoordinates) { config.countFeaturesWithoutCoordinates = true } if (unclassifiedLegend) { config.unclassifiedLegend = unclassifiedLegend } + 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 2d96c21922..756a840c04 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, @@ -6,6 +7,19 @@ 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). +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)) { @@ -59,16 +73,33 @@ const geoJsonUrlLoader = async ({ let newConfig let featureStyle - // keep featureStyle property outside of config while in app + let dataTableColumnConfig + let combinedLayerKey + 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 + 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 @@ -88,9 +119,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 @@ -118,7 +149,10 @@ const geoJsonUrlLoader = async ({ } } legend.items.push(legendItem) + legendItemsByType[type] = legendItem }) + + data = stampFeatureColors(featureCollection, legendItemsByType) } return { @@ -129,10 +163,13 @@ const geoJsonUrlLoader = async ({ keyAnalysisDigitGroupSeparator, config: newConfig, featureStyle, + dataTableColumnConfig, + combinedLayerKey, isLoaded: true, isLoading: false, isExpanded: true, loadError, + ...(alerts.length ? { alerts } : {}), } } diff --git a/src/loaders/orgUnitLoader.js b/src/loaders/orgUnitLoader.js index 64f2711f35..f8e97234e6 100644 --- a/src/loaders/orgUnitLoader.js +++ b/src/loaders/orgUnitLoader.js @@ -6,23 +6,26 @@ 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' import { addAssociatedGeometries, + attachOrgUnitPaths, getStyledOrgUnits, getCoordinateField, + getMissingOrgUnitId, getOrgUnitsWithoutCoordsCount, - fetchOrgUnitDetails, addGroupCountsToLegend, addLevelCountsToLegend, loadGroupSetData, fetchAssociatedGeometries, } from '../util/orgUnits.js' import { GEOFEATURES_QUERY } from '../util/requests.js' +import { generateUid } from '../util/uid.js' -const applyMissingCoordsCount = async ( +export const applyMissingCoordsCount = async ( config, { engine, orgUnitIds, userId, features, legend, alerts } ) => { @@ -42,18 +45,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, - }, - })) } } @@ -76,14 +72,33 @@ const orgUnitLoader = async ({ // Config parsing // ----- - const { countFeaturesWithoutCoordinates, unclassifiedLegend } = - parseJsonConfig(config.config) - if (countFeaturesWithoutCoordinates) { + const { + countFeaturesWithoutCoordinates, + unclassifiedLegend, + dataTableColumnConfig, + combinedJoinConfig, + combinedColumnConfig, + combinedLayerKey, + } = parseJsonConfig(config.config) + if ( + countFeaturesWithoutCoordinates || + config.layer === COMBINED_TABLE_REF_LAYER + ) { config.countFeaturesWithoutCoordinates = true } if (unclassifiedLegend) { config.unclassifiedLegend = unclassifiedLegend } + if (dataTableColumnConfig) { + config.dataTableColumnConfig = dataTableColumnConfig + } + if (combinedJoinConfig) { + config.combinedJoinConfig = combinedJoinConfig + } + if (combinedColumnConfig) { + config.combinedColumnConfig = combinedColumnConfig + } + config.combinedLayerKey = combinedLayerKey ?? generateUid() delete config.config // Data loading @@ -111,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'), diff --git a/src/loaders/thematicLoader.js b/src/loaders/thematicLoader.js index 4e4998c125..d5a452fd58 100644 --- a/src/loaders/thematicLoader.js +++ b/src/loaders/thematicLoader.js @@ -48,11 +48,13 @@ 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' +import { generateUid } from '../util/uid.js' const thematicLoader = async ({ config, @@ -85,6 +87,8 @@ const thematicLoader = async ({ legendIsolated, unclassifiedLegend: unclassifiedLegendFromConfig, noDataLegend: noDataLegendFromConfig, + dataTableColumnConfig, + combinedLayerKey, } = parseJsonConfig(config.config) if (countFeaturesWithoutCoordinates) { config.countFeaturesWithoutCoordinates = true @@ -101,6 +105,10 @@ const thematicLoader = async ({ if (noDataLegendFromConfig) { config.noDataLegend = noDataLegendFromConfig } + if (dataTableColumnConfig) { + config.dataTableColumnConfig = dataTableColumnConfig + } + config.combinedLayerKey = combinedLayerKey ?? generateUid() if (config.noDataColor) { config.noDataLegend = { ...noDataLegendFromConfig, @@ -175,6 +183,7 @@ const thematicLoader = async ({ legend: null, isLoaded: true, isLoading: false, + isExpanded: true, loadError, } } @@ -195,26 +204,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 - ), - }, - })) } } } @@ -540,6 +550,10 @@ const thematicLoader = async ({ isNoData, isUnclassified, }), + ...getFeatureLegend(legendItem, { + isNoData, + isUnclassified, + }), ...getFeatureRadius( legendItem, { isNoData, isUnclassified }, diff --git a/src/loaders/trackedEntityLoader.js b/src/loaders/trackedEntityLoader.js index 90ffe7b192..5c9f201422 100644 --- a/src/loaders/trackedEntityLoader.js +++ b/src/loaders/trackedEntityLoader.js @@ -8,7 +8,9 @@ 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 { GEO_TYPE_POINT, GEO_TYPE_POLYGON, @@ -16,10 +18,25 @@ import { GEO_TYPE_LINE, GEO_TYPE_FEATURE, } 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'] +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)', + 'geometry', + 'attributes', + 'orgUnit', + 'createdAt', + 'updatedAt', +] // Valid geometry types for TEIs const teiGeometryTypes = new Set([ @@ -100,37 +117,154 @@ const TRACKED_ENTITY_TYPES_QUERY = { }, } -const toGeoJson = (instances) => - instances.map(({ id, geometry }) => ({ - type: GEO_TYPE_FEATURE, - geometry, - properties: { - id, - }, - })) +export const getAttributeProperties = ( + attributes, + optionSetIdByAttribute, + optionNamesByOptionSet +) => + Object.fromEntries( + (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, 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, + }) + } + }) + }) + return [...headersByAttribute.values()] +} + +// 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, + { 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 + ), + }, + }) + ) + +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 + ) -export const parseJsonConfig = (config) => { - if (!config.config || typeof config.config !== 'string') { - return + 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) + ), + ] } - try { - const { relationships, periodType } = JSON.parse(config.config) + return new Map( + attributes + .filter((a) => a.optionSet?.id) + .map((a) => [a.id, a.optionSet.id]) + ) +} - if (relationships) { - config.relationshipType = relationships.type - config.relatedPointColor = relationships.pointColor - config.relatedPointRadius = relationships.pointRadius - config.relationshipLineColor = relationships.lineColor - config.relationshipOutsideProgram = - relationships.relationshipOutsideProgram - } +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) => { + 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, + combinedLayerKey, + } = 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 + } + + config.periodType = periodType - config.periodType = periodType - } catch (e) { - // Malformed config JSON + if (dataTableColumnConfig) { + config.dataTableColumnConfig = dataTableColumnConfig } + config.combinedLayerKey = combinedLayerKey ?? generateUid() + delete config.config } @@ -144,6 +278,7 @@ const fetchRelationshipData = async ({ relatedPointColor, relatedPointRadius, relationshipLineColor, + pointColor, legend, }) => { const { relationshipType } = await engine.query( @@ -192,7 +327,7 @@ const fetchRelationshipData = async ({ }) return { - data: toGeoJson(dataWithRels.primary), + data: toGeoJson(dataWithRels.primary, pointColor), relationships: dataWithRels.relationships, secondaryData: toGeoJson(dataWithRels.secondary), } @@ -238,7 +373,7 @@ const trackedEntityLoader = async ({ keyAnalysisDigitGroupSeparator, serverVersion, }) => { - parseJsonConfig(config) + applyParsedConfig(config) const { trackedEntityType, @@ -260,6 +395,7 @@ const trackedEntityLoader = async ({ } = config const name = program ? program.name : i18n.t('Tracked entity') + const pointColor = eventPointColor || TEI_COLOR const legend = { title: name, @@ -272,7 +408,7 @@ const trackedEntityLoader = async ({ name: trackedEntityType.name + (areaRadius ? ` + ${areaRadius} ${'m'} ${'buffer'}` : ''), - color: eventPointColor || TEI_COLOR, + color: pointColor, radius: eventPointRadius || TEI_RADIUS, }, ], @@ -320,6 +456,19 @@ const trackedEntityLoader = async ({ instance.geometry?.coordinates ) + 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 if (!instances.length) { @@ -342,12 +491,22 @@ const trackedEntityLoader = async ({ relatedPointColor, relatedPointRadius, relationshipLineColor, + pointColor, legend, })) } else { - data = toGeoJson(instances) + data = toGeoJson(instances, pointColor, { + optionSetIdByAttribute, + optionNamesByOptionSet, + }) } + data = await attachOrgUnitPaths( + data, + engine, + (feature) => feature.properties.orgUnit + ) + if (explanation) { legend.explanation = [explanation] } @@ -356,6 +515,10 @@ const trackedEntityLoader = async ({ ...config, name, data, + headers, + optionSetOptionsByCode: toOptionSetOptionsByCode( + optionNamesByOptionSet + ), keyAnalysisDigitGroupSeparator, relationships, secondaryData, diff --git a/src/reducers/__tests__/dataTable.spec.js b/src/reducers/__tests__/dataTable.spec.js index 2dcf5180b8..4c61b878b3 100644 --- a/src/reducers/__tests__/dataTable.spec.js +++ b/src/reducers/__tests__/dataTable.spec.js @@ -1,52 +1,242 @@ import * as types from '../../constants/actionTypes.js' import dataTable from '../dataTable.js' +const initialState = { + openIds: [], + combinedView: false, + isPanelVisible: false, + activeLayerId: 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 fully to the initial state on %s', (type) => { + const state = { + openIds: ['layer1', 'layer2'], + combinedView: true, + isPanelVisible: true, + activeLayerId: 'layer1', + } + + expect(dataTable(state, { type })).toEqual(initialState) }) - it('closes the data table when toggling the currently open layer', () => { - expect( - dataTable('layer1', { - type: types.DATA_TABLE_TOGGLE, + 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', }) - ).toBe(null) + + expect(state.activeLayerId).toBe('layer1') + }) }) - it('opens the data table when toggling a different 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: 'layer2', + id: '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 untouched even when closing the last open tab', () => { + const prevState = { + ...initialState, + openIds: ['layer1'], + combinedView: true, + } + + const state = dataTable(prevState, { + type: types.DATA_TABLE_TOGGLE, + id: 'layer1', + }) + + 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', }) - ).toBe('layer2') + + expect(state.isPanelVisible).toBe(true) + }) }) - 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('leaves combinedView untouched', () => { + const prevState = { ...initialState, combinedView: true } + + const state = dataTable(prevState, { + type: types.LAYER_REMOVE, + id: 'layer1', + combinedLayerKey: 'layer1Key', + }) + + 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') + }) }) - 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_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('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', () => { - expect(dataTable('layer1', { type: 'UNKNOWN' })).toBe('layer1') + const state = { ...initialState, openIds: ['layer1'] } + + expect(dataTable(state, { type: 'UNKNOWN' })).toBe(state) }) }) diff --git a/src/reducers/__tests__/map.spec.js b/src/reducers/__tests__/map.spec.js index 1c70810eab..2c17122159 100644 --- a/src/reducers/__tests__/map.spec.js +++ b/src/reducers/__tests__/map.spec.js @@ -1,4 +1,10 @@ import * as types from '../../constants/actionTypes.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' @@ -257,6 +263,89 @@ 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) + }) + + 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', () => { it('removes a layer by id', () => { const state = { @@ -269,6 +358,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' }] } @@ -334,6 +472,87 @@ 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'], + }) + }) + + 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', () => { @@ -406,6 +625,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 } @@ -471,6 +708,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/__tests__/selection.spec.js b/src/reducers/__tests__/selection.spec.js new file mode 100644 index 0000000000..ff69b2a3aa --- /dev/null +++ b/src/reducers/__tests__/selection.spec.js @@ -0,0 +1,241 @@ +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])( + '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).toBe(prevState) + }) + + 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).toBe(prevState) + }) + + 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('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'] }, + { 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('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'] } + + 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..b0025f4354 --- /dev/null +++ b/src/reducers/__tests__/ui.spec.js @@ -0,0 +1,99 @@ +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 — selectionFilter', () => { + it('defaults to an empty array', () => { + expect(ui(undefined, {}).selectionFilter).toEqual([]) + }) + + it('sets an explicit value on SELECTION_FILTER_SET', () => { + const state = ui(undefined, { + type: types.SELECTION_FILTER_SET, + value: ['selected'], + }) + + expect(state.selectionFilter).toEqual(['selected']) + }) + + it.each([ + types.MAP_NEW, + types.MAP_SET, + types.DATA_TABLE_CLOSE, + types.DATA_TABLE_TOGGLE, + ])('resets to an empty array on %s', (type) => { + const prevState = { + ...ui(undefined, {}), + selectionFilter: ['selected'], + } + const state = ui(prevState, { type }) + + expect(state.selectionFilter).toEqual([]) + }) +}) + +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) + }) +}) + +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/dataTable.js b/src/reducers/dataTable.js index bfd2bf6a0c..fb1450e5dc 100644 --- a/src/reducers/dataTable.js +++ b/src/reducers/dataTable.js @@ -1,19 +1,59 @@ import * as types from '../constants/actionTypes.js' -const dataTable = (state = null, action) => { +const initialState = { + openIds: [], + combinedView: false, + isPanelVisible: false, + activeLayerId: null, +} + +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 + case types.MAP_NEW: + case types.MAP_SET: + return initialState + + case types.DATA_TABLE_CLOSE: + return { ...state, isPanelVisible: false } - case types.DATA_TABLE_TOGGLE: - return state === action.id ? null : action.id + 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 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 === action.id ? null : state + 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: { + const combinedView = !state.combinedView + return { + ...state, + combinedView, + isPanelVisible: combinedView ? true : state.isPanelVisible, + } + } default: return state 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/map.js b/src/reducers/map.js index 7794693f07..a5a194beb5 100644 --- a/src/reducers/map.js +++ b/src/reducers/map.js @@ -1,5 +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 = { @@ -90,6 +92,18 @@ const layer = (state, action) => { return { ...action.payload, + dataTableColumnConfig: + state.dataTableColumnConfig ?? + action.payload.dataTableColumnConfig, + 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: @@ -122,6 +136,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 @@ -160,6 +184,58 @@ 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.DATA_TABLE_COLUMN_CONFIG_SET: + if (state.id !== action.layerId) { + return state + } + + return { + ...state, + 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, @@ -244,6 +320,8 @@ const map = (state = defaultState, action) => { ...action.payload, id: generateUid(), isVisible: action.payload.isVisible ?? true, + combinedLayerKey: + action.payload.combinedLayerKey ?? generateUid(), }, ], } @@ -251,9 +329,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: getDefaultReferenceRows(state.mapViews), + }, + ], } case types.LAYER_DUPLICATE: { @@ -266,6 +367,7 @@ const map = (state = defaultState, action) => { const duplicate = { ...state.mapViews[sourceIndex], id: generateUid(), + combinedLayerKey: generateUid(), } delete duplicate.isLoading delete duplicate.coordinate @@ -297,8 +399,13 @@ 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: + 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/reducers/selection.js b/src/reducers/selection.js new file mode 100644 index 0000000000..6b79378580 --- /dev/null +++ b/src/reducers/selection.js @@ -0,0 +1,83 @@ +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 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 setCrossLayerSelection = (action) => + Object.keys(action.crossLayerIds).length + ? { layerId: null, ids: [], crossLayerIds: action.crossLayerIds } + : defaultState + +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: + return addSelectionRange(state, action) + + case types.SELECTION_SET_CROSS_LAYER: + return setCrossLayerSelection(action) + + case types.SELECTION_CLEAR: + case types.MAP_NEW: + case types.MAP_SET: + return defaultState + + case types.LAYER_REMOVE: + return removeLayerFromSelection(state, action) + + default: + return state + } +} + +export default selection diff --git a/src/reducers/ui.js b/src/reducers/ui.js index 42a29e3e91..f28dd7f808 100644 --- a/src/reducers/ui.js +++ b/src/reducers/ui.js @@ -9,6 +9,13 @@ const defaultState = { mapContextMenu: true, downloadMode: false, layersSorting: false, + mapBounds: null, + showOnlyFeaturesInView: false, + selectionFilter: [], + highlightColor: null, + lastClickedFeature: null, + activeTimelinePeriod: null, + combinedVisibleIds: null, } const ui = (state = defaultState, action) => { @@ -36,11 +43,27 @@ 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, + selectionFilter: [], + lastClickedFeature: null, + combinedVisibleIds: null, + } + + case types.DATA_TABLE_CLOSE: + case types.DATA_TABLE_TOGGLE: + return { + ...state, + selectionFilter: [], + combinedVisibleIds: null, } case types.DOWNLOAD_MODE_OPEN: @@ -72,6 +95,48 @@ 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.SELECTION_FILTER_SET: + return { + ...state, + selectionFilter: action.value, + } + + case types.HIGHLIGHT_COLOR_SET: + return { + ...state, + highlightColor: action.color, + } + + case types.MAP_FEATURE_CLICKED: + return { + ...state, + lastClickedFeature: action.payload, + } + + case types.ACTIVE_TIMELINE_PERIOD_SET: + return { + ...state, + activeTimelinePeriod: action.period, + } + + case types.COMBINED_VISIBLE_IDS_SET: + return { + ...state, + combinedVisibleIds: action.idsByLayer, + } + default: return state } diff --git a/src/util/__tests__/aggregation.spec.js b/src/util/__tests__/aggregation.spec.js new file mode 100644 index 0000000000..6c99375624 --- /dev/null +++ b/src/util/__tests__/aggregation.spec.js @@ -0,0 +1,125 @@ +import { + applyAggregation, + getDefaultCombinedAggregationType, + getDefaultCombinedAggregationTypeFromEarthEngineStat, +} 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() + }) +}) + +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__/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/__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__/combinedJoinMatch.spec.js b/src/util/__tests__/combinedJoinMatch.spec.js new file mode 100644 index 0000000000..e393e48eaf --- /dev/null +++ b/src/util/__tests__/combinedJoinMatch.spec.js @@ -0,0 +1,218 @@ +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 + ) + }) +}) + +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/__tests__/dataTable.spec.js b/src/util/__tests__/dataTable.spec.js new file mode 100644 index 0000000000..9c3c29f1e8 --- /dev/null +++ b/src/util/__tests__/dataTable.spec.js @@ -0,0 +1,1322 @@ +import { + DATA_KEY_KIND_CATEGORY, + DATA_KEY_KIND_COUNT, + DATA_KEY_KIND_VALUE, +} from '../../constants/dataTable.js' +import { + EARTH_ENGINE_LAYER, + THEMATIC_LAYER, + ORG_UNIT_LAYER, + FACILITY_LAYER, + EVENT_LAYER, + TRACKED_ENTITY_LAYER, + EXTERNAL_LAYER, +} from '../../constants/layers.js' +import { + buildFeatureIndex, + getCombinedLegendConfig, + getCombinedValueDataKeys, + getDefaultCombinedAggregation, + getDefaultReferenceRows, + getEligibleDataTableLayers, + getFeatureCategoryKey, + getLayerSelectedIds, + getNextSorting, + getPanelHeights, + getRowClickAction, + getRowId, + getUnionBounds, + hasActiveDataTableFilters, + isDataTableOpen, + isFilterable, + mergeCrossLayerIds, + 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([ + { dataKey: 'rawValue', name: null, kind: DATA_KEY_KIND_VALUE }, + ]) + }) + + 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({ + layer: EARTH_ENGINE_LAYER, + aggregationType: ['mean', 'max'], + legend: { title: 'NDVI' }, + }) + ).toEqual([ + { dataKey: 'mean', name: 'Mean Ndvi', kind: DATA_KEY_KIND_VALUE }, + { dataKey: 'max', name: 'Max Ndvi', kind: DATA_KEY_KIND_VALUE }, + ]) + }) + + 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', kind: DATA_KEY_KIND_VALUE }, + { dataKey: '2', name: 'Water', kind: DATA_KEY_KIND_VALUE }, + ]) + }) + + test('returns no columns for an Earth Engine layer with neither shape configured yet', () => { + expect( + getCombinedValueDataKeys({ + layer: EARTH_ENGINE_LAYER, + aggregationType: null, + legend: {}, + }) + ).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', + kind: DATA_KEY_KIND_VALUE, + }, + { + dataKey: 'mean', + name: 'Mean Population', + kind: DATA_KEY_KIND_VALUE, + }, + ]) + }) + + 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', + 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, + }, + ]) + }) + + 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', + 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, + }, + ]) + }) + + 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', kind: DATA_KEY_KIND_VALUE }, + { 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('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, + 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('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 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({ + 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('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({ + 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' }) + }) + + 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', + }) + }) + + test('Facility/OrgUnit: count-only defaults to COUNT, category defaults to a single shared categoryDisplayType of 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({ categoryDisplayType: 'COUNT' }) + }) + + 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: 'AVERAGE' }) + }) + + test('Event: category dataKeys default to a single shared categoryDisplayType of 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({ categoryDisplayType: 'COUNT' }) + }) +}) + +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) + }) + + 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 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: 'name', sortDirection: 'asc' }) + }) + + test('clicking a different column restarts the cycle at ascending', () => { + expect( + 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', () => { + 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('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('getEligibleDataTableLayers', () => { + test('includes data-table-capable layer types that have finished loading', () => { + const mapViews = [ + { 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', + 'b', + ]) + }) + + test('excludes layer types with no data table support', () => { + const mapViews = [ + { id: 'a', layer: EXTERNAL_LAYER, isLoaded: true, data: [{}] }, + ] + expect(getEligibleDataTableLayers(mapViews)).toEqual([]) + }) + + 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', () => { + test('is open when at least one tab is open and the panel is visible', () => { + expect( + isDataTableOpen({ + openIds: ['layer1'], + combinedView: false, + isPanelVisible: 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, + 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) + }) +}) + +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'] }], + ['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({ + 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__/dateGroups.spec.js b/src/util/__tests__/dateGroups.spec.js new file mode 100644 index 0000000000..7f09529e41 --- /dev/null +++ b/src/util/__tests__/dateGroups.spec.js @@ -0,0 +1,198 @@ +import { + TYPE_DATE, + TYPE_DATETIME, + TYPE_TIME, +} from '../../constants/dataTable.js' +import { + parseDateGroupKey, + buildDateGroupTree, + formatNodeLabel, +} 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("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', () => { + 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('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' + ) + }) +}) 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/__tests__/favorites.spec.js b/src/util/__tests__/favorites.spec.js index 5db9075a14..219a995e1b 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: { @@ -921,4 +941,148 @@ 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 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('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 = { + 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') + }) }) diff --git a/src/util/__tests__/filter.spec.js b/src/util/__tests__/filter.spec.js index c23cda725b..603821b459 100644 --- a/src/util/__tests__/filter.spec.js +++ b/src/util/__tests__/filter.spec.js @@ -1,4 +1,17 @@ -import { filterData } from '../filter.js' +import { + SENTINEL_ANY_VALUE, + 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' describe('filterData', () => { it('should return the original data if no filters are provided', () => { @@ -68,4 +81,348 @@ 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' }]) + }) + + 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: [SENTINEL_ANY_VALUE] } + expect(filterData(data, filters)).toEqual([{ a: 'High' }, { a: 'Low' }]) + }) + + it('combining SENTINEL_ANY_VALUE with the blank sentinel ("") matches every row', () => { + const data = [{ a: 'High' }, { a: '' }, { a: null }] + 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('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 }]) + }) + + 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)', () => { + 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', () => { + const data = [ + { name: 'Kampala Hospital', type: 'Hospital' }, + { name: 'Entebbe Clinic', type: 'Clinic' }, + { name: 'Jinja Hospital', type: 'Hospital' }, + ] + 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, '', { headers })).toEqual(data) + expect(filterByGlobalSearch(data, ' ', { headers })).toEqual(data) + }) + + 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', { headers })).toEqual([ + { name: 'Kampala Hospital', type: 'Hospital' }, + ]) + }) + + it('matches rows where any field contains the search string', () => { + 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', { headers })).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'], + ]) + const orgUnitHeaders = [ + { + dataKey: 'orgUnitPath', + type: TYPE_ORG_UNIT, + renderer: RENDERER_ORG_UNIT, + }, + ] + expect( + filterByGlobalSearch(orgUnitData, 'bo hospital', { + 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/__tests__/filterInput.spec.js b/src/util/__tests__/filterInput.spec.js new file mode 100644 index 0000000000..62440eb572 --- /dev/null +++ b/src/util/__tests__/filterInput.spec.js @@ -0,0 +1,175 @@ +import { + getCyclicIndex, + getDisplayValue, + getFilteredOptions, + getPopoverWidth, + getSelectedAndAppliedString, + measureMaxTextWidth, + toHighlightedIndex, + toOptionIndex, +} 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) + }) +}) + +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__/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__/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/__tests__/map.spec.js b/src/util/__tests__/map.spec.js index 286e0f2648..8731fee175 100644 --- a/src/util/__tests__/map.spec.js +++ b/src/util/__tests__/map.spec.js @@ -1,4 +1,10 @@ -import { onFullscreenChange, resizeAndFitBounds } from '../map.js' +import { + fitCrossLayerZoomBounds, + getLayerFeatureHighlight, + onFullscreenChange, + resizeAndFitBounds, + toGeoJson, +} from '../map.js' const bounds = [ [0, 0], @@ -11,6 +17,141 @@ const createMockMap = (layersBounds = bounds) => ({ fitBounds: jest.fn(), toggleMultiTouch: jest.fn(), toggleScrollZoom: jest.fn(), + getMapGL: jest.fn(() => ({ getBearing: jest.fn(() => 0) })), +}) + +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('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('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', () => { 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 3a7f54155b..3673d49297 100644 --- a/src/util/__tests__/orgUnits.spec.js +++ b/src/util/__tests__/orgUnits.spec.js @@ -11,8 +11,10 @@ import { fetchAndParseGroupSet, loadGroupSetData, getUserOrgUnitIdsByKeyword, - fetchOrgUnitDetails, fetchOrgUnitPaths, + fetchOrgUnitPathDetails, + attachOrgUnitPaths, + buildKnownOrgUnitNames, } from '../orgUnits.js' describe('getUserOrgUnitIdsByKeyword', () => { @@ -61,15 +63,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')), @@ -109,6 +103,160 @@ 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 }, + }) + }) + + 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', () => { + 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('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', () => { @@ -358,6 +506,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/__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__/spatialJoin.spec.js b/src/util/__tests__/spatialJoin.spec.js new file mode 100644 index 0000000000..470c2edc2d --- /dev/null +++ b/src/util/__tests__/spatialJoin.spec.js @@ -0,0 +1,114 @@ +import { matchFeaturesToReferenceOrgUnits } 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('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])] + + expect( + matchFeaturesToReferenceOrgUnits(features, referenceOrgUnits) + ).toEqual([ + { featureProps: { id: 'p1', name: 'Point p1' }, referenceId: 'a' }, + ]) + }) + + 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])] + + expect( + matchFeaturesToReferenceOrgUnits(features, referenceOrgUnits) + ).toEqual([ + { featureProps: { id: 'p1', name: 'Point p1' }, referenceId: 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('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]), + ] + + expect( + matchFeaturesToReferenceOrgUnits(features, referenceOrgUnits)[0] + .referenceId + ).toBe('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([ + { + featureProps: { id: 'poly1', name: 'Polygon poly1' }, + referenceId: null, + }, + ]) + }) + + 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( + matchFeaturesToReferenceOrgUnits(features, referenceOrgUnits, { + useCentroid: true, + }) + ).toEqual([ + { + featureProps: { id: 'poly1', name: 'Polygon poly1' }, + referenceId: 'a', + }, + ]) + }) + + test('returns an empty array when there are no features', () => { + expect( + matchFeaturesToReferenceOrgUnits([], [square('a', [0, 0, 2, 2])]) + ).toEqual([]) + }) +}) 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/__tests__/tableColumns.spec.js b/src/util/__tests__/tableColumns.spec.js new file mode 100644 index 0000000000..fb3139011a --- /dev/null +++ b/src/util/__tests__/tableColumns.spec.js @@ -0,0 +1,544 @@ +import { SENTINEL_NO_VALUE, TYPE_NUMBER } from '../../constants/dataTable.js' +import { + buildRowCells, + filterHeadersByName, + getColumnDistinctValues, + getDefaultVisibleKeys, + getOrderedHeaders, + getPinnedCellProps, + getPinnedCount, + getPinnedLeftOffsets, + getVisibleHeaders, + isPinnedGroupEnd, + reorderHeaderKeys, + reverseVisibleKeys, + sortColumnOptions, + togglePinnedKey, + toggleVisibleKey, +} from '../tableColumns.js' + +const headers = [ + { name: 'Name', dataKey: 'name' }, + { name: 'Id', dataKey: 'id' }, + { name: 'Value', dataKey: 'rawValue' }, + { 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) + }) + + 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', + ]) + }) + + 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', () => { + 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', () => { + // 'name' sits between the two pinned columns here and must not + // inflate 'id's offset. + const offsets = getPinnedLeftOffsets( + visibleHeaders, + ['rawValue', 'id'], + columnWidths + ) + 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']) + }) +}) + +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, + }) + }) +}) + +describe('getDefaultVisibleKeys', () => { + it('includes every header dataKey when none are marked defaultHidden', () => { + expect(getDefaultVisibleKeys(headers)).toEqual([ + 'name', + 'id', + 'rawValue', + 'legend', + ]) + }) + + 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', + ]) + }) +}) + +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('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' }, + { 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) + }) + + 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', () => { + 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..e631e642a6 --- /dev/null +++ b/src/util/__tests__/tableHeaders.spec.js @@ -0,0 +1,543 @@ +import { + RENDERER_DATE, + RENDERER_ORG_UNIT, + RENDERER_BOOLEAN, +} 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 { + ERROR_NON_HOMOGENOUS_FEATURES, + getHeadersForLayer, + TYPE_NUMBER, + TYPE_STRING, + TYPE_DATE, + TYPE_DATETIME, + TYPE_TIME, +} from '../tableHeaders.js' + +jest.mock('../../components/map/MapApi.js', () => ({ + loadEarthEngineWorker: jest.fn(), +})) + +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', () => { + const result = getHeadersForLayer(THEMATIC_LAYER, { + isMultiPeriodThematic: false, + }) + expect(dataKeys(result)).toEqual([ + 'id', + 'orgUnitOwn', + 'level', + 'orgUnitPath', + 'rawValue', + 'legend', + 'range', + 'color', + 'type', + ]) + }) + + 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([ + 'id', + 'orgUnitOwn', + 'orgUnitPath', + 'level', + 'type', + 'period_p1_rawValue', + 'period_p2_rawValue', + ]) + ) + }) + + 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' }, + ] + const externalPeriod = { id: 'p1', name: 'Jan' } + const result = getHeadersForLayer(THEMATIC_LAYER, { + isMultiPeriodThematic: true, + isTimelineThematic: true, + periods, + externalPeriod, + }) + 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') + expect(valueHeader.configName).toBe('Value (Current period)') + }) +}) + +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([ + 'id', + 'orgUnitId', + 'orgUnitOwn', + 'eventdate', + 'orgUnitPath', + 'w75KJ2mc4zz', + ]) + ) + expect(dataKeys(result)).not.toContain('not-a-uid') + const ageHeader = result.headers.find( + (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' }, + }, + { + name: 'c3d4e5f6a7b', + column: 'Referred by facility', + valueType: 'ORGANISATION_UNIT', + }, + { + name: 'd4e5f6a7b8c', + column: 'Follow-up', + valueType: 'BOOLEAN', + }, + ] + 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(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('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', () => { + 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('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, { + 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([ + 'id', + 'orgUnitOwn', + 'level', + 'type', + 'orgUnitPath', + '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([ + 'id', + 'orgUnitOwn', + 'level', + 'orgUnitPath', + 'group', + 'type', + ]) + }) +}) + +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', + 'orgUnitId', + 'orgUnitOwn', + 'level', + 'orgUnitPath', + 'createdAt', + 'updatedAt', + 'w75KJ2mc4zz', + 'color', + 'type', + ]) + const nameHeader = result.headers.find( + (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/BOOLEAN 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' }, + { + name: 'Referred by facility', + dataKey: 'c3d4e5f6a7b', + valueType: 'ORGANISATION_UNIT', + }, + { + name: 'Follow-up', + dataKey: 'd4e5f6a7b8c', + valueType: 'BOOLEAN', + }, + ] + 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(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) + }) +}) + +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([ + 'id', + 'orgUnitOwn', + 'orgUnitPath', + '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) + }) + + // 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', () => { + 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 }) + }) + + 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', () => { + 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..829517c9ed --- /dev/null +++ b/src/util/__tests__/tableRows.spec.js @@ -0,0 +1,226 @@ +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]) => ({ + id, + geometry: { type: 'Point', coordinates }, + properties: { id, ...extraProperties }, +}) + +describe('buildTableData - error paths', () => { + test('server-clustered layers return empty data instead of an error', () => { + expect(buildTableData(THEMATIC_LAYER, { serverCluster: true })).toEqual( + { data: [] } + ) + }) + + 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, 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', () => { + 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 - 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 })] + 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 fixed column per period, including the external one', () => { + 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_p1_rawValue: 1, + period_p2_rawValue: 2, + }) + }) + + 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/__tests__/tableSort.spec.js b/src/util/__tests__/tableSort.spec.js new file mode 100644 index 0000000000..746b4abd4f --- /dev/null +++ b/src/util/__tests__/tableSort.spec.js @@ -0,0 +1,258 @@ +import { + SENTINEL_NO_VALUE, + SENTINEL_SELECTED_ROW, + RENDERER_ORG_UNIT, + RENDERER_ORG_UNIT_NAME, +} 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('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', { + sortField: 'range', + sortDirection: 'asc', + }) + ).toBeGreaterThan(0) + }) + + describe('org-unit-renderer columns - sorts by the resolved display name, not the raw stored path/id', () => { + 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', () => { + 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', () => { + 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/aggregation.js b/src/util/aggregation.js new file mode 100644 index 0000000000..1bbfa16aa6 --- /dev/null +++ b/src/util/aggregation.js @@ -0,0 +1,65 @@ +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 +} + +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/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, 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/combinedJoinMatch.js b/src/util/combinedJoinMatch.js new file mode 100644 index 0000000000..312d2a8a4d --- /dev/null +++ b/src/util/combinedJoinMatch.js @@ -0,0 +1,107 @@ +import { ORG_UNIT_PATH_DATA_KEY } from '../constants/dataTable.js' +import { filterData } from './filter.js' +import { matchFeaturesToReferenceOrgUnits } from './spatialJoin.js' + +export const getJoinableFeatures = (layer) => + [...(layer?.data ?? []), ...(layer?.dataWithoutCoords ?? [])].filter( + (d) => !d.properties?.hasAdditionalGeometry + ) + +export const getFilteredJoinableFeatures = (layer) => + filterData(getJoinableFeatures(layer), layer?.dataFilters) + +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( + getFilteredJoinableFeatures(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 = getFilteredJoinableFeatures(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 new file mode 100644 index 0000000000..f17b86aab8 --- /dev/null +++ b/src/util/dataTable.js @@ -0,0 +1,492 @@ +import { bbox } from '@turf/bbox' +import { + DATA_KEY_KIND_CATEGORY, + DATA_KEY_KIND_COUNT, + DATA_KEY_KIND_VALUE, + 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, + RENDERING_STRATEGY_SINGLE, + RENDERING_STRATEGY_TIMELINE, +} from '../constants/layers.js' +import { numberValueTypes } from '../constants/valueTypes.js' +import { + getDefaultCombinedAggregationType, + getDefaultCombinedAggregationTypeFromEarthEngineStat, +} from './aggregation.js' +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' +export const EVENT_STYLE_VALUE_KEY = 'value' +export const CATEGORY_DISPLAY_TYPE_KEY = 'categoryDisplayType' + +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() + ) + +const getValueAggregationType = (layer) => { + if (Array.isArray(layer.aggregationType)) { + return getDefaultCombinedAggregationTypeFromEarthEngineStat( + layer.aggregationType[0] + ) + } + if (CLASSIFIED_EARTH_ENGINE_AGGREGATION_TYPES.has(layer.aggregationType)) { + return CLASSIFIED_EARTH_ENGINE_DEFAULT_AGGREGATION_TYPE[ + layer.aggregationType + ] + } + if (layer.layer === EVENT_LAYER) { + return 'AVERAGE' + } + const dataItem = getDataItemFromColumns(layer.columns) + return getDefaultCombinedAggregationType( + dataItem?.aggregationType, + dataItem?.dimensionItemType + ) +} + +export const getDefaultCombinedAggregation = (layer) => { + const valueType = getValueAggregationType(layer) + 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) => { + 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, + 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, + })) + ) +} + +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, + })) +} + +const getEventValueDataKeys = (layer) => { + if ( + layer.styleDataItem && + numberValueTypes.includes(layer.styleDataItem.valueType) + ) { + return [ + { + dataKey: EVENT_STYLE_VALUE_KEY, + name: layer.styleDataItem.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 }, + ] +} + +export const getFeatureCategoryKey = (layer, props) => { + if (layer.layer === FACILITY_LAYER || layer.layer === ORG_UNIT_LAYER) { + return ( + props.dimensions?.[layer.organisationUnitGroupSet?.id] ?? + UNCLASSIFIED_CATEGORY_KEY + ) + } + if (layer.layer === EVENT_LAYER) { + return String(props.colorGroup) + } + return UNCLASSIFIED_CATEGORY_KEY +} + +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) + } + if (layer.layer === EVENT_LAYER) { + return getEventValueDataKeys(layer) + } + if (layer.layer === TRACKED_ENTITY_LAYER) { + return [ + { + dataKey: COMBINED_COUNT_KEY, + name: null, + kind: DATA_KEY_KIND_COUNT, + }, + ] + } + if (layer.layer === THEMATIC_LAYER) { + return getThematicCombinedValueDataKeys(layer, externalPeriod) + } + if (layer.layer !== EARTH_ENGINE_LAYER) { + return [ + { + dataKey: COMBINED_VALUE_KEY, + name: null, + kind: DATA_KEY_KIND_VALUE, + }, + ] + } + if ( + CLASSIFIED_EARTH_ENGINE_AGGREGATION_TYPES.has(layer.aggregationType) && + layer.legend?.items + ) { + return layer.legend.items.map(({ value, name }) => ({ + dataKey: String(value), + name, + kind: DATA_KEY_KIND_VALUE, + })) + } + if (Array.isArray(layer.aggregationType) && layer.aggregationType.length) { + return layer.aggregationType + .map((type) => ({ + dataKey: type, + name: toTitleCase( + `${type} ${layer.legend?.title ?? ''}`.trim() + ), + kind: DATA_KEY_KIND_VALUE, + })) + .concat(getEarthEngineBandValueDataKeys(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, + candidates[0] + ).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) => + event.relatedTarget?.tagName !== 'TD' + +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: defaultSortField, sortDirection: defaultSortDirection } +} + +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 +} + +export const hasActiveDataTableFilters = ({ + dataFilters, + globalSearch, + selectionFilter, + showOnlyFeaturesInView, +}) => + Object.keys(dataFilters ?? {}).length > 0 || + !!globalSearch?.trim() || + selectionFilter?.length > 0 || + !!showOnlyFeaturesInView + +export const isDataTableOpen = ({ openIds, combinedView, isPanelVisible }) => + isPanelVisible && (openIds.length > 0 || combinedView) + +export const getEligibleDataTableLayers = (mapViews) => + mapViews.filter( + (l) => DATA_TABLE_LAYER_TYPES.includes(l.layer) && l.isLoaded + ) + +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) => { + const id = f.properties?.id ?? f.id + if (id != null) { + index.set(id, f) + } + }) + return index +} + +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 +} + +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, + 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/dateGroups.js b/src/util/dateGroups.js new file mode 100644 index 0000000000..93b7fa0c81 --- /dev/null +++ b/src/util/dateGroups.js @@ -0,0 +1,158 @@ +import { TYPE_DATETIME, TYPE_TIME } from '../constants/dataTable.js' +import { formatDate, formatDatetime } from './helpers.js' +import { dateLocale } from './time.js' + +export { + getNodeCheckState, + flattenVisibleNodes, + getSearchMatches, + nodeMatchesOrHasMatch, + togglePrefix as toggleDateGroupPrefix, +} from './prefixTree.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 = TIME_KEY_PATTERN.exec(str) + return match ? { hour: match[1] } : null + } + + const match = DATE_KEY_PATTERN.exec(str) + 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()).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] +} + +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 + } +} 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 } } diff --git a/src/util/favorites.js b/src/util/favorites.js index 520c919873..decdc49b0a 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, @@ -8,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 = [ @@ -35,8 +37,12 @@ const validLayerProperties = [ 'colorLow', // Deprecated 'colorScale', 'columns', + 'combinedColumnConfig', // used by combinedTableRef layer + 'combinedJoinConfig', // used by combinedTableRef layer + 'combinedLayerKey', // stable cross-save id, set on every layer type 'config', 'created', + 'dataTableColumnConfig', 'datasetId', 'displayName', 'endDate', @@ -118,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 @@ -180,6 +192,18 @@ const buildCommonLayerConfigData = (layer) => { if (layer.labelDataItem) { configData.labelDataItem = layer.labelDataItem } + if (layer.dataTableColumnConfig) { + configData.dataTableColumnConfig = layer.dataTableColumnConfig + } + if (layer.combinedJoinConfig) { + configData.combinedJoinConfig = layer.combinedJoinConfig + } + if (layer.combinedColumnConfig) { + configData.combinedColumnConfig = layer.combinedColumnConfig + } + if (layer.combinedLayerKey) { + configData.combinedLayerKey = layer.combinedLayerKey + } return configData } @@ -194,11 +218,31 @@ const deleteCommonLayerConfigProps = (layer) => { delete layer.countFeaturesWithoutCoordinates delete layer.countEventsOutsideOrgUnits delete layer.labelDataItem + delete layer.dataTableColumnConfig + delete layer.combinedJoinConfig + delete layer.combinedColumnConfig + delete layer.combinedLayerKey } 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, + combinedLayerKey, + } = layer + return omitBy(isNil, { + id, + style, + band, + aggregationType, + period, + dataTableColumnConfig, + combinedLayerKey, + }) } const deleteEarthEngineLayerProps = (layer) => { @@ -211,6 +255,8 @@ const deleteEarthEngineLayerProps = (layer) => { delete layer.periodType delete layer.aggregationType delete layer.band + delete layer.dataTableColumnConfig + delete layer.combinedLayerKey } const buildTrackedEntityLayerConfigData = (layer) => ({ @@ -224,6 +270,8 @@ const buildTrackedEntityLayerConfigData = (layer) => ({ } : null, periodType: layer.periodType, + dataTableColumnConfig: layer.dataTableColumnConfig, + combinedLayerKey: layer.combinedLayerKey, }) const deleteTrackedEntityLayerProps = (layer) => { @@ -233,6 +281,8 @@ const deleteTrackedEntityLayerProps = (layer) => { delete layer.relationshipLineColor 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 @@ -268,14 +318,23 @@ const models2objects = (layer, cleanMapviewConfig) => { layer.config = { ...layer.config, featureStyle: { ...layer.featureStyle }, + ...(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 || layerType === ORG_UNIT_LAYER || - layerType === FACILITY_LAYER + layerType === FACILITY_LAYER || + layerType === COMBINED_TABLE_REF_LAYER ) { if (cleanMapviewConfig) { const configData = buildCommonLayerConfigData(layer) diff --git a/src/util/filter.js b/src/util/filter.js index ae5d722963..2a40d04ffc 100644 --- a/src/util/filter.js +++ b/src/util/filter.js @@ -1,3 +1,47 @@ +import { + SENTINEL_ANY_VALUE, + SENTINEL_NO_VALUE, + DATE_GROUPS_GRANULARITY, + ORG_UNIT_GROUPS_GRANULARITY, + TYPE_NUMBER, +} from '../constants/dataTable.js' +import { formatCellText } from './cellValue.js' + +// 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 === granularity + +export const prefixGroupFilter = (value, { prefixes, searchDerived }) => { + if (!prefixes?.length) { + return !searchDerived + } + 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) + }) +} + +export const isDateGroupFilter = (filter) => + isPrefixGroupFilter(filter, DATE_GROUPS_GRANULARITY) +export const isOrgUnitGroupFilter = (filter) => + isPrefixGroupFilter(filter, ORG_UNIT_GROUPS_GRANULARITY) + +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) { @@ -15,6 +59,27 @@ export const filterData = (data, filters) => { const props = d.properties || d // GeoJSON or plain object const value = props[field] + if (isDateGroupFilter(filter) || isOrgUnitGroupFilter(filter)) { + 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 + return ( + filter.length === 0 || + filter.includes(stringValue) || + (stringValue !== SENTINEL_NO_VALUE && + filter.includes(SENTINEL_ANY_VALUE)) + ) + } + return typeof value === 'number' ? numericFilter(value, filter) : stringFilter(value, filter) @@ -40,6 +105,40 @@ 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, + { headers = [], orgUnitIdToName, keyAnalysisDigitGroupSeparator } = {} +) => { + if (!searchString?.trim() || !headers.length) { + return data + } + const lower = searchString.toLowerCase() + return data.filter((item) => { + const props = item.properties || item + return headers.some((header) => + getSearchableTexts(props[header.dataKey], header, { + orgUnitIdToName, + keyAnalysisDigitGroupSeparator, + }).some((text) => text.toLowerCase().includes(lower)) + ) + }) +} + // Returns true if the filter is true const isTrueFilter = (value, filter) => { if (filter.includes('>=')) { diff --git a/src/util/filterInput.js b/src/util/filterInput.js new file mode 100644 index 0000000000..9a3dc75d01 --- /dev/null +++ b/src/util/filterInput.js @@ -0,0 +1,88 @@ +import i18n from '@dhis2/d2-i18n' +import { TYPE_NUMBER } from '../constants/dataTable.js' +import { isOrgUnitValueFilter, numericFilter } from './filter.js' + +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) => { + if (isOrgUnitValueFilter(filterValue)) { + return { selected: [], appliedString: filterValue.searchText ?? '' } + } + return { + 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 === 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 + ) + +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/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/geojson.js b/src/util/geojson.js index ce6b0d372a..767c442758 100644 --- a/src/util/geojson.js +++ b/src/util/geojson.js @@ -1,6 +1,8 @@ +import turfBbox from '@turf/bbox' 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' @@ -202,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) { @@ -233,6 +255,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 diff --git a/src/util/helpers.js b/src/util/helpers.js index d69c0d2c61..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') } @@ -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/map.js b/src/util/map.js index 70526c6fa8..9b60d2f5f9 100644 --- a/src/util/map.js +++ b/src/util/map.js @@ -1,5 +1,10 @@ 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' +import { PADDING_DEFAULT, DURATION_DEFAULT } from '../constants/layers.js' export const toGeoJson = (organisationUnits) => sortBy('le', organisationUnits) @@ -16,21 +21,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 +53,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, }, } @@ -61,7 +66,24 @@ export const toGeoJson = (organisationUnits) => geometry.coordinates.flat().length ) -//eslint-disable-next-line max-params +export const getLayerFeatureHighlight = (feature, layerId) => + feature && (feature.layerId === layerId || feature.crossLayerIds?.[layerId]) + ? feature + : null + +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, rows: [ diff --git a/src/util/orgUnitGroups.js b/src/util/orgUnitGroups.js new file mode 100644 index 0000000000..3e7e2657b6 --- /dev/null +++ b/src/util/orgUnitGroups.js @@ -0,0 +1,80 @@ +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 +} + +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), + })) + +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('/').findLast(Boolean) + 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?.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 2c6d4dbfcb..421b3e261c 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, @@ -21,7 +27,7 @@ import { GEOFEATURES_QUERY, 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. @@ -159,10 +165,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 +194,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) } @@ -265,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, @@ -325,25 +339,63 @@ const fetchInBatches = async (engine, ids, { query, buildVariables }) => { .map((result) => result.value) } -export const fetchOrgUnitDetails = async (engine, ids) => { +export const fetchOrgUnitPaths = async (engine, ids) => { + const results = await fetchInBatches(engine, ids, { + query: ORG_UNITS_PATHS_QUERY, + buildVariables: (batch) => ({ ids: batch.join(',') }), + }) + 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_DETAILS_QUERY, - buildVariables: (batch) => ({ ids: batch }), + query: ORG_UNIT_PATH_DETAILS_QUERY, + buildVariables: (batch) => ({ ids: batch, nameProperty }), }) return results.reduce((acc, result) => { result.orgUnits.organisationUnits?.forEach((ou) => { - acc[ou.id] = { level: ou.level, parentName: ou.parent?.name } + acc[ou.id] = { name: ou.name, level: ou.level } }) return acc }, {}) } -export const fetchOrgUnitPaths = async (engine, ids) => { - const results = await fetchInBatches(engine, ids, { - query: ORG_UNITS_PATHS_QUERY, - buildVariables: (batch) => ({ ids: batch.join(',') }), +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, + }, + } }) - return results.flatMap((r) => r.organisationUnits.organisationUnits ?? []) } export const addGroupCountsToLegend = (legendItems, features, groupSet) => { 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..19d7518636 100644 --- a/src/util/requests.js +++ b/src/util/requests.js @@ -178,12 +178,12 @@ export const ORG_UNITS_COUNT_QUERY = { }, } -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,level,parent[displayName~rename(name)]', + fields: `id,${nameProperty}~rename(name),level`, paging: false, }), }, diff --git a/src/util/spatialJoin.js b/src/util/spatialJoin.js new file mode 100644 index 0000000000..def0baa60d --- /dev/null +++ b/src/util/spatialJoin.js @@ -0,0 +1,39 @@ +import { booleanPointInPolygon } from '@turf/boolean-point-in-polygon' +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) + +const getTestPoint = (feature, useCentroid) => { + if (feature.geometry?.type === GEO_TYPE_POINT) { + return feature + } + return useCentroid && feature.geometry + ? turfCentroid(feature.geometry) + : null +} + +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 { + featureProps: feature.properties || feature, + referenceId: matched ? (matched.properties || matched).id : null, + } + }) +} 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({ diff --git a/src/util/tableColumns.js b/src/util/tableColumns.js new file mode 100644 index 0000000000..41b7346ca4 --- /dev/null +++ b/src/util/tableColumns.js @@ -0,0 +1,216 @@ +import { arrayMoveImmutable } from 'array-move' +import { + SENTINEL_NO_VALUE, + SORT_ASCENDING, + TYPE_NUMBER, +} from '../constants/dataTable.js' +import { compareColumnOptionValues } from './tableSort.js' + +const CHECKBOX_COLUMN_WIDTH = 76 + +export const getDefaultVisibleKeys = (headers) => + headers.filter((h) => !h.defaultHidden).map((h) => h.dataKey) + +export const getOrderedHeaders = (headers, config) => { + if (!headers) { + return headers + } + + const { orderedKeys, pinnedKeys } = config ?? {} + + 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)) + const rest = result.filter((h) => !pinnedKeys.includes(h.dataKey)) + result = [...pinned, ...rest] + } + + 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 + } + 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) + +// @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, + 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 +} + +// 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 +} + +// Cheap: just re-orders each column's already-known distinct-value list +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] + return { + dataKey, + value: + type === TYPE_NUMBER && Number.isNaN(Number(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.configName ?? 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..4a86b64bec --- /dev/null +++ b/src/util/tableHeaders.js @@ -0,0 +1,500 @@ +import i18n from '@dhis2/d2-i18n' +import { + RENDERER_COLOR, + RENDERER_ICON, + RENDERER_DATE, + RENDERER_ORG_UNIT, + RENDERER_ORG_UNIT_NAME, + RENDERER_BOOLEAN, + 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, + THEMATIC_LAYER, + ORG_UNIT_LAYER, + EARTH_ENGINE_LAYER, + FACILITY_LAYER, + GEOJSON_URL_LAYER, + TRACKED_ENTITY_LAYER, +} from '../constants/layers.js' +import { + numberValueTypes, + dateValueTypes, + datetimeValueTypes, + timeValueTypes, + ouValueTypes, + booleanValueTypes, +} 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, 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 = new Set([TYPE_DATE, TYPE_DATETIME, TYPE_TIME]) + +const getCustomFieldRenderer = (type, valueType) => { + if (DATE_LIKE_TYPES.has(type)) { + return RENDERER_DATE + } + if (ouValueTypes.includes(valueType)) { + return RENDERER_ORG_UNIT + } + if (booleanValueTypes.includes(valueType)) { + return RENDERER_BOOLEAN + } + return undefined +} + +const ID = 'id' +const VALUE = 'rawValue' +const LEGEND = 'legend' +const RANGE = 'range' +const LEVEL = ORG_UNIT_LEVEL_DATA_KEY +const TYPE = 'type' +const COLOR = 'color' +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 + +export const ERROR_NON_HOMOGENOUS_FEATURES = 'NON_HOMOGENOUS_FEATURES' + +const defaultFieldsMap = () => ({ + [ID]: { + name: i18n.t('Id'), + dataKey: ID, + type: TYPE_STRING, + defaultHidden: true, + }, + [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'), + dataKey: ORG_UNIT, + type: TYPE_STRING, + renderer: RENDERER_ORG_UNIT_NAME, + }, + [LEVEL]: { + 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, + }, + [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 }, + [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, + 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, + 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 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, + 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 = () => + getOrgUnitCoreFields(i18n.t('Org unit id')) + .concat(defaultFieldsMap()[VALUE]) + .concat( + getStyleHeaders({ hasLegend: true, hasRange: true, hasColor: true }) + ) + .concat(defaultFieldsMap()[TYPE]) + +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') + })`, + configName: `${header.name} (${i18n.t( + 'Current period' + )})`, + } + : header + ) + : getOrgUnitHeaders() + + ;(periods ?? []).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 = getOrgUnitCoreFields(i18n.t('Event Id'), { + includeOrgUnitId: true, + }) + .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 + const fixedDataKeys = new Set(fields.map((f) => f.dataKey)) + + const customFields = layerHeaders + .filter(({ name }) => isValidUid(name) && !fixedDataKeys.has(name)) + .map(({ name: dataKey, column: name, valueType, optionSet }) => { + const type = getCustomFieldType(valueType, !!optionSet) + return { + name, + dataKey, + type, + renderer: getCustomFieldRenderer(type, valueType), + optionSet: optionSet || null, + } + }) + + customFields.push( + ...getStyleHeaders({ + hasLegend: !!styleDataItem, + hasRange: !!styleDataItem, + hasColor: !!styleDataItem, + }) + ) + + return fields.concat(customFields).concat(defaultFieldsMap()[TYPE]) +} + +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 getFixedFieldsWithOrgUnitStyle = (data) => + getOrgUnitCoreFields(i18n.t('Org unit id')) + .concat(getOrgUnitStyleHeaders(data)) + .concat(defaultFieldsMap()[TYPE]) + +const getOrgUnitHeaders = (data) => getFixedFieldsWithOrgUnitStyle(data) + +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, optionSet }) => { + const type = getCustomFieldType(valueType, !!optionSet) + return { + name, + dataKey, + type, + renderer: getCustomFieldRenderer(type, valueType), + optionSet: optionSet || null, + } + }) + + customFields.push(...getStyleHeaders({ hasColor: true })) + + return fields.concat(customFields).concat(defaultFieldsMap()[TYPE]) +} + +const getFacilityHeaders = (data) => getFixedFieldsWithOrgUnitStyle(data) + +const toTitleCase = (str) => + str.replace( + /\w\S*/g, + (txt) => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase() + ) + +const getFieldRoundFn = (data, dataKey) => { + if (!data?.length) { + return null + } + return getRoundToPrecisionFn(getPrecision(data.map((d) => d[dataKey]))) +} + +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 = [] + + 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, + } + }) + .concat(getBandFields({ bands, band, aggregationType, data })) + } + + return getOrgUnitCoreFields(i18n.t('Org unit id')) + .concat(customFields) + .concat(defaultFieldsMap()[TYPE]) +} + +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, + bands: ctx.bands, + band: ctx.band, + }), + } + 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 nonMultiType = (type) => type.replaceAll('Multi', '') + const isHomogenous = rawData.every( + (feature) => + nonMultiType(feature.geometry.type) === + nonMultiType(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..41e704ca1f --- /dev/null +++ b/src/util/tableRows.js @@ -0,0 +1,110 @@ +import { GEOJSON_URL_LAYER } from '../constants/layers.js' +import { isFeatureInBounds } from './geojson.js' +import { formatRangeWithSeparator } from './numbers.js' + +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 { data: [] } + } + + 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, index) => ({ + ...d.properties, + // Row-order tie-breaker for compareRows when no sortField is set + index, + })), + } + } + + 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) => { + 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 new file mode 100644 index 0000000000..8c11ea26e4 --- /dev/null +++ b/src/util/tableSort.js @@ -0,0 +1,117 @@ +import { + SENTINEL_NO_VALUE, + 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' + +const compareStrings = (a, b) => { + if (a < b) { + return -1 + } + if (a > b) { + return 1 + } + return 0 +} + +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 === TYPE_NUMBER ? Number(a) - Number(b) : compareStrings(a, b) + 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 +} + +const isNoValue = (val) => val === undefined || val === null + +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, orgUnitRenderer, idToName } +) => { + // All missing values should be sorted to the end + if (isNoValue(aVal) && isNoValue(bVal)) { + return 0 + } + if (isNoValue(aVal)) { + return 1 + } + if (isNoValue(bVal)) { + return -1 + } + if (typeof aVal === 'number') { + return sortDirection === SORT_ASCENDING ? aVal - bVal : bVal - aVal + } + 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 + ? aText.localeCompare(bText) + : bText.localeCompare(aText) +} + +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) +} 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 /** diff --git a/yarn.lock b/yarn.lock index a5cfc3738e..b001cbec97 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#3934323c80eb763e843eefa6a1faa031c341d779": 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#3934323c80eb763e843eefa6a1faa031c341d779" dependencies: "@mapbox/sphericalmercator" "^1.2.0" "@turf/area" "^7.3.5"