diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-28 00:03:26 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-28 00:03:26 -0500 |
| commit | 1288c2af084fb9ce09bd8807a30291821b39f7b3 (patch) | |
| tree | fd9b4bdb0b25ac474cc6fc3dd72be9be485b840c | |
| parent | 3c41d3b6fcfb3bfa3cff627535685b9ecfe82a00 (diff) | |
| download | pearl-1288c2af084fb9ce09bd8807a30291821b39f7b3.tar.gz pearl-1288c2af084fb9ce09bd8807a30291821b39f7b3.zip | |
feat(filter): show "[ None. ]" as the topmost option in builder prompts
The builder's state / project / team / labels / assignee prompts each relied on the "empty input means no constraint" convention, with a parenthetical hint in the prompt label ("Team (empty for any):"). The convention is invisible, has to be remembered, and reads as a non-choice rather than a choice. Now every prompt carries `pearl--filter-none` ("[ None. ]") as the first candidate and uses `require-match` so the user picks from the list. Selecting the sentinel is the same logical opt-out as empty input was, but the choice is on screen rather than in the user's head.
Two small helpers (`pearl--with-none`, `pearl--filter-none-value-p`) and one defconst keep the sentinel string in one place. The prompt labels lost their parenthetical hints since the affordance is now in the candidate list. Five unit tests cover the sentinel predicate and the list helper across the normal / boundary / "real value" cases.
The pattern generalizes well past the builder: any time pearl prompts the user to pick between a value and "no value," the no-value option belongs in the list, not hidden behind empty input.
| -rw-r--r-- | pearl.el | 76 | ||||
| -rw-r--r-- | tests/test-pearl-adhoc.el | 29 |
2 files changed, 83 insertions, 22 deletions
@@ -3340,42 +3340,74 @@ with just what the user set." (when project (list :project project)) (when labels (list :labels labels)))) +(defconst pearl--filter-none "[ None. ]" + "Sentinel candidate shown at the top of `pearl--read-filter-interactively' +prompts. Picking it drops the dimension from the filter, the same as +opting out. Always rendered first so \"no constraint\" is a visible +choice rather than a hidden empty-input idiom.") + +(defun pearl--filter-none-value-p (value) + "Return non-nil when VALUE means \"no constraint\" for a filter dimension. +A nil, an empty string, or `pearl--filter-none' all mean the user opted +out of constraining this dimension." + (or (null value) + (and (stringp value) + (or (string-empty-p value) + (string= value pearl--filter-none))))) + +(defun pearl--with-none (candidates) + "Return CANDIDATES with `pearl--filter-none' prepended as the first option." + (cons pearl--filter-none candidates)) + (defun pearl--read-filter-interactively () "Build a filter plist by completing over the chosen team's fetched dimensions. Picks a team first (scoping the rest), then offers open-only, state, project, -labels, and assignee. The assignee prompt offers `me' (the viewer), -`member' (a specific teammate, resolved to an id), or `any' (no scoping). -Empty answers drop the dimension." +labels, and assignee. Each prompt carries `pearl--filter-none' as the topmost +choice; selecting it drops that dimension. The assignee prompt offers `me' +(the viewer), `member' (a specific teammate, resolved to an id), or +`pearl--filter-none' (no scoping)." (let* ((teams (pearl--all-teams)) - (team-name (completing-read "Team (empty for any): " - (mapcar (lambda (tm) (cdr (assoc 'name tm))) teams) - nil nil)) - (team-id (and (not (string-empty-p team-name)) + (team-name (completing-read + "Team: " + (pearl--with-none + (mapcar (lambda (tm) (cdr (assoc 'name tm))) teams)) + nil t)) + (team-id (and (not (pearl--filter-none-value-p team-name)) (pearl--get-team-id-by-name team-name))) (open (y-or-n-p "Open issues only? ")) (state (and team-id (let ((s (completing-read - "State (empty for any): " - (mapcar (lambda (st) (cdr (assoc 'name st))) - (pearl--team-states team-id)) - nil nil))) - (unless (string-empty-p s) s)))) + "State: " + (pearl--with-none + (mapcar (lambda (st) (cdr (assoc 'name st))) + (pearl--team-states team-id))) + nil t))) + (unless (pearl--filter-none-value-p s) s)))) (project (and team-id (let ((p (completing-read - "Project (empty for any): " - (pearl--team-collection-names 'projects team-id) - nil nil))) + "Project: " + (pearl--with-none + (pearl--team-collection-names 'projects team-id)) + nil t))) ;; The :project filter compiles to project.id.eq, so ;; resolve the picked name to its id before passing. - (unless (string-empty-p p) + (unless (pearl--filter-none-value-p p) (pearl--resolve-team-id 'projects p team-id))))) (labels (and team-id - (completing-read-multiple - "Labels (comma-separated, empty for none): " - (pearl--team-collection-names 'labels team-id)))) + ;; `[ None. ]' alongside real labels is redundant, so + ;; filter the sentinel out of the result. A result of + ;; only-sentinel (or empty) means "no labels." + (cl-remove pearl--filter-none + (completing-read-multiple + "Labels (comma-separated): " + (pearl--with-none + (pearl--team-collection-names 'labels team-id)) + nil t) + :test #'string=))) (assignee (let ((choice (completing-read - "Assignee (me / member / any): " - '("me" "member" "any") nil t "any"))) + "Assignee: " + (list pearl--filter-none "me" "member") + nil t))) (pcase choice ("me" :me) ("member" @@ -3390,7 +3422,7 @@ Empty answers drop the dimension." ;; The :team filter compiles to team.key.eq, so look the picked name up ;; in the teams alist and pass its key (not the display name). (pearl--assemble-filter - (and (not (string-empty-p team-name)) + (and (not (pearl--filter-none-value-p team-name)) (cdr (assoc 'key (cl-find team-name teams :key (lambda (tm) (cdr (assoc 'name tm))) diff --git a/tests/test-pearl-adhoc.el b/tests/test-pearl-adhoc.el index 23aad79..d8c7231 100644 --- a/tests/test-pearl-adhoc.el +++ b/tests/test-pearl-adhoc.el @@ -28,6 +28,35 @@ (require 'test-bootstrap (expand-file-name "test-bootstrap.el")) (require 'cl-lib) +;;; --filter-none sentinel helpers + +(ert-deftest test-pearl-filter-none-value-p-recognizes-sentinel () + "The sentinel string reads as a `no constraint' value." + (should (pearl--filter-none-value-p pearl--filter-none))) + +(ert-deftest test-pearl-filter-none-value-p-recognizes-empty-and-nil () + "Empty input and nil also read as `no constraint' (for back-compat)." + (should (pearl--filter-none-value-p "")) + (should (pearl--filter-none-value-p nil))) + +(ert-deftest test-pearl-filter-none-value-p-rejects-real-values () + "An actual picked value does not read as `no constraint'." + (should-not (pearl--filter-none-value-p "In Progress")) + (should-not (pearl--filter-none-value-p "Platform")) + (should-not (pearl--filter-none-value-p "bug"))) + +(ert-deftest test-pearl-with-none-prepends-sentinel () + "`pearl--with-none' puts the sentinel first so it sits at the top of the +completing-read candidate list." + (let ((wrapped (pearl--with-none '("alpha" "beta")))) + (should (string= pearl--filter-none (car wrapped))) + (should (equal '("alpha" "beta") (cdr wrapped))))) + +(ert-deftest test-pearl-with-none-empty-list-yields-just-the-sentinel () + "An empty candidate list still yields a one-element list with the sentinel." + (let ((wrapped (pearl--with-none '()))) + (should (equal (list pearl--filter-none) wrapped)))) + ;;; --assemble-filter (ert-deftest test-pearl-assemble-filter-only-set-keys () |
