diff options
| -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 () |
