aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--pearl.el76
-rw-r--r--tests/test-pearl-adhoc.el29
2 files changed, 83 insertions, 22 deletions
diff --git a/pearl.el b/pearl.el
index 27d56ac..cb1351b 100644
--- a/pearl.el
+++ b/pearl.el
@@ -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 ()