MotirBuilding in public
MOTIR · moooon
onMotir
You’re viewing a public project. Anyone can view it — no account needed. Sign in to submit, upvote, or comment on requests.View-only — you can’t edit work items
MOTIR-334

6.2 Saved filters

Done
Description

Persist the 6.1 filter builder's state as named saved filters: a project-scoped saved_filter entity owned by its creator, carrying the SAME versioned FilterAST envelope the ?filter=v1: URL codec serializes (one codec, two carriers — a saved filter is the URL param given a name, an owner, and permissions). Applied from a filter dropdown beside the builder on /issues (starred first, then mine / project-shared / built-in defaults, searchable), managed in a filters directory, starred per-user, optionally emailed on a schedule (subscriptions), and exposed to Story 6.3 dashboards/reports through a documented data-source contract.

Where it sits relative to the mirror (verified, deviations recorded). Jira Cloud: save a search as a filter; the owner of a modified filter gets Save + Save as, a non-owner Save as only — we mirror that dirty-state rule exactly. Jira filters are user-owned SITE-GLOBAL objects with six share scopes and separate Viewers/Editors grant lists; ours are project-contained (the AST's referents — statuses, custom fields, labels, components, sprints — are project data, and the whole shipped search substrate is project-scoped; the 6.1 recorded deviation) with two visibilitiesprivate (owner + project/workspace admins) and project (everyone who can browse the project, riding the 6.4 gate) — and editing by owner + project admin (the per-filter editor-grant list and cross-project filters are documented extensions). Sharing (visibility project) requires role ≥ member — a viewer is a read-only persona and publishing into the project's shared namespace is a write; viewers still create/star PRIVATE filters freely (filters are a read-layer construct). Project admins (+ workspace owner/admin) can edit, delete, and change the owner of any project-shared filter — the Jira-admin powers, project-sized.

Built-in defaults (the system filters). The expressible subset of Jira's nine, as non-persisted AST constants (NOT rows): My open issues, Reported by me, All issues, Open issues, Done issues, Created / Updated / Resolved recently. Non-editable, non-deletable (the mirror rule); applying one loads its rows into the builder like any saved filter ("Viewed recently" omitted — no view-history substrate; extension).

Durability (the load-bearing piece). The stored payload is the versioned envelope the 6.1.1 codec defines — decode + registry-validate on EVERY resolve, never trust-and-compile: a stale referent inside a saved AST (deleted option / label / field) degrades to the 6.1.2 unknown-value condition (matches nothing + per-row notice — the rule 6.1 recorded for exactly this story); a malformed or future-versioned envelope yields the typed recoverable state, never a crash. Name uniqueness is per-project case-insensitive (our call, not mirror-verified — two "Sprint blockers" in one shared dropdown is ambiguity nobody wants).

Subscriptions. Per-user, per-filter email on a preset schedule (daily / weekdays / weekly at an hour — Jira's preset tier; advanced cron is the extension), running on the Story 1.6 jobs substrate (the dailyHealthCheck cron precedent) and the lib/emailTemplates/ layer: first 50 results (our page unit; Jira caps at 200) + total count + a deep link to the applied filter. Deleting a filter deletes its subscriptions — after a Cloud-style warning that names the dependents (subscription count now; 6.3 widget usages once they exist).

The 6.3 data-source contract (what "reuse for dashboards and reports" means). A documented service read — resolve a saved filter id to its compiled WHERE fragment + metadata (name, owner, visibility) — plus the usage seam: consumers reference filters by FK, the delete warning enumerates them, and a widget whose filter was deleted renders a designed "filter missing" state (the verified Cloud gadget behaviour), never a crash. Boards stay status-mapped — Motir mirrors the team-managed family (3.1/3.6 tested decision, the same family 6.4 chose), so filter-sourced boards (a company-managed shape) are the documented extension; the stub's "boards" consumer resolves to this contract.

Bounded + complete (finding #57). Directory and dropdown reads are paginated/bounded and searchable server-side (a project with 500 filters must not ship them all to a dropdown); star counts aggregate in the query, not in JS over all rows; empty / loading / error / no-access states designed + asserted; every list keyed for the project so one team's filters never leak into another's.

Out of scope (documented extension slots, each justified): cross-project / workspace-global filters (follows the 6.1 scope deviation); per-filter Viewers/Editors grant lists + group scopes (no groups in Motir; 6.4 roles draw the boundary); filter-sourced boards (company-managed shape; ours are team-managed-style status boards); advanced cron subscriptions; "Viewed recently" (no view-history substrate); public/anonymous sharing (no anonymous surface exists — rung 2).

Verification

  • Pull the Story branch, pnpm install, pnpm prisma migrate dev (the saved_filter / star / subscription migrations apply cleanly; re-run reports "No difference detected"), pnpm db:seed, pnpm dev.
  • pnpm test:coverage — Vitest (real Postgres) over the service/permission matrix, the persist→resolve round-trip, and the subscription scheduler ≥90% per-file branch/fn/line.
  • Save flow: sign in as zhuyue@motir.co / !QAZ1qaz → /issues → build a filter (Status is any of (To do) AND Priority is none of (Lowest)) → Save as → name it "Sprint blockers", visibility Project → the toolbar shows the applied filter's name; edit a row → the dirty state appears with Save + Save as (you own it); a second account (bophilips@motir.co) applying the same filter and editing a row sees Save as only (the mirror's non-owner rule).
  • Apply + star: the dropdown lists starred first, then My filters / Project filters / Defaults; starring "Sprint blockers" floats it to the top; applying any entry loads its rows into the builder and writes the ?filter=v1: URL (shareable; reload-safe); the built-in defaults (My open issues, Reported by me, …) apply but expose no edit/delete.
  • Visibility + permissions: a private filter is invisible to bophilips@motir.co (dropdown + directory + direct id); flipping it to Project makes it appear; a project viewer can create/star private filters but gets no Project-visibility option; a project admin can rename/delete/change-owner on a member's shared filter.
  • Directory: the filters page lists name / owner / visibility / stars / actions, server-searched and paginated; rename + visibility change + delete work per the matrix; empty and no-access states render designed.
  • Stale referent: save a filter on a label, delete the label, re-apply the saved filter → the condition row shows the unknown-value notice and matches nothing (no crash); a hand-corrupted stored envelope yields the typed recoverable state.
  • Subscription: subscribe daily-at-09:00 to "Sprint blockers" → trigger the job (dev Inngest) → the email renders ≤50 results + total count + a deep link that opens the applied filter; deleting the filter first warns "1 subscription will be removed", then cascades.
  • pnpm test:e2e --grep saved-filters — Playwright over the real stack: save → dirty → save-as → share → second-user apply/star → delete-with-warning journey.
  • a11y check: dropdown, directory, share control, and dialogs pass the strict axe sweep; fully keyboard-operable; colour via --el-*, shape via element tokens.