diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-03 06:52:14 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-03 06:52:14 -0500 |
| commit | 483660f9477cc9504a35a3a29ee1fbca52cccdb1 (patch) | |
| tree | 35177a9c002d37d5773289a6a04b4e05dbe3aa09 | |
| parent | 8acc99d88527a9dfecf7ebceb858f344819d10c3 (diff) | |
| download | pearl-483660f9477cc9504a35a3a29ee1fbca52cccdb1.tar.gz pearl-483660f9477cc9504a35a3a29ee1fbca52cccdb1.zip | |
feat(views): run a Linear view in its configured sort order
A Linear view's issue query passed no orderBy, so issues came back in Linear's API default, createdAt descending. That's neither the order set on the view in Linear nor the updatedAt-desc the rest of pearl uses, and the two unrelated defaults colliding is what made a view's order look random.
Linear exposes a view's configured sort in viewPreferencesValues (viewOrdering plus viewOrderingDirection), and the issues connection accepts an IssueSortInput sort arg that applies it server-side. I read the view's ordering when running it, translate it to that sort arg, and pass it through, so a view arrives in the order you gave it in Linear. The sort is stamped on the recorded source so a refresh keeps the same order. An ordering pearl can't map, or a view with no preference, falls back to updatedAt-desc rather than the createdAt default, so it lines up with every other pearl fetch.
This is the sort half of matching the Linear UI. Grouping (issueGrouping into org sections) is the larger remaining half and is tracked separately.
| -rw-r--r-- | pearl.el | 66 | ||||
| -rw-r--r-- | tests/test-pearl-views.el | 86 |
2 files changed, 138 insertions, 14 deletions
@@ -1581,13 +1581,41 @@ gone\" apart from \"the API call failed.\"" (funcall callback (or issue :missing))))) (lambda (_error _response _data) (funcall callback :error)))) +(defconst pearl--default-issue-sort + (vector '(("updatedAt" ("order" . "Descending")))) + "Fallback issue sort for a Custom View whose configured ordering pearl can't +reproduce: most-recently-updated first, matching the default `pearl-list-issues' +uses. Keeps a view from falling through to Linear's createdAt-desc API default, +which reads as random next to the rest of pearl.") + +(defconst pearl--view-sortable-orderings + '("priority" "estimate" "title" "label" "slaStatus" "createdAt" "updatedAt" + "completedAt" "dueDate" "accumulatedStateUpdatedAt" "cycle" "milestone" + "assignee" "project" "team" "manual" "workflowState") + "Linear `viewOrdering' values that also exist as `IssueSortInput' keys, so +pearl can reproduce the view's sort server-side. An ordering outside this set +falls back to `pearl--default-issue-sort'.") + +(defun pearl--view-sort-arg (ordering direction) + "Translate a Custom View's ORDERING and DIRECTION into an `IssueSortInput' list. +ORDERING is Linear's `viewOrdering' string; DIRECTION is `viewOrderingDirection' +(\"asc\", \"desc\", or nil). Returns a one-element vector usable as the `sort' +query variable, or nil when ORDERING isn't one pearl can reproduce -- the caller +then falls back to `pearl--default-issue-sort'. A nil DIRECTION defaults to +Descending, the field's natural order and what Linear's UI shows when no +direction is set." + (when (member ordering pearl--view-sortable-orderings) + (let ((order (if (equal direction "asc") "Ascending" "Descending"))) + (vector (list (cons ordering (list (cons "order" order)))))))) + (defun pearl--view-issues-query () "GraphQL query running a Custom View's own filter server-side, by view id. Pulls each issue's recent comments (see `pearl--list-comments-fragment') so a -view-populated list renders them." - (format "query ViewIssues($id: String!, $first: Int!, $after: String) { +view-populated list renders them. The `sort' variable orders the issues; see +`pearl--view-sort-arg' and `pearl--default-issue-sort'." + (format "query ViewIssues($id: String!, $first: Int!, $after: String, $sort: [IssueSortInput!]) { customView(id: $id) { - issues(first: $first, after: $after) { + issues(first: $first, after: $after, sort: $sort) { nodes { id identifier title description priority url updatedAt state { id name type } @@ -1603,16 +1631,21 @@ view-populated list renders them." } }" (pearl--list-comments-fragment))) -(defun pearl--query-view-async (view-id callback) +(defun pearl--query-view-async (view-id callback &optional sort) "Run the Custom View VIEW-ID server-side, calling CALLBACK with a query-result. The view applies its own stored filter on Linear's side; issues come back as -raw nodes (normalized at the render boundary), paged like the general fetch." - (let ((page-fn +raw nodes (normalized at the render boundary), paged like the general fetch. +SORT is an `IssueSortInput' list (the `sort' variable); when nil the view runs +under `pearl--default-issue-sort' so its order matches the rest of pearl rather +than Linear's createdAt-desc API default." + (let* ((sort (or sort pearl--default-issue-sort)) + (page-fn (lambda (after page-cb) (pearl--graphql-request-async (pearl--view-issues-query) `(("id" . ,view-id) ("first" . 100) + ("sort" . ,sort) ,@(when after (list (cons "after" after)))) (lambda (response) (if (or (null response) @@ -1647,14 +1680,17 @@ Selects no `url' -- Linear's `CustomView' type has none, and requesting it 400s the whole query (see `pearl--view-url' for how the link is rebuilt)." "query CustomViews($first: Int!, $after: String) { customViews(first: $first, after: $after) { - nodes { id name description shared team { id } } + nodes { id name description shared team { id } + viewPreferencesValues { viewOrdering viewOrderingDirection } } pageInfo { hasNextPage endCursor } } }") (defun pearl--custom-views (&optional force) "Return the workspace's Custom Views, fetching once and caching. -Each node carries id/name/description/shared/team. A non-nil FORCE refetches." +Each node carries id/name/description/shared/team plus viewPreferencesValues +(the configured ordering, read by `pearl--view-sort-arg'). A non-nil FORCE +refetches." (or (and (not force) pearl--cache-views) (let* ((response (pearl--graphql-request (pearl--custom-views-query) '(("first" . 100)))) @@ -6246,7 +6282,8 @@ commands. Errors if no source is recorded." merge)) ('view (pearl--progress "Refreshing %s..." (pearl--source-name source)) - (pearl--query-view-async (plist-get source :id) merge)) + (pearl--query-view-async (plist-get source :id) merge + (plist-get source :sort))) (_ (user-error "Unknown Linear source type: %s" (plist-get source :type))))))) ;;;###autoload @@ -6265,12 +6302,17 @@ dirty-buffer guard) and records the view as the active source." (view-id (and view (cdr (assoc 'id view))))) (unless view-id (user-error "No Custom View named %s" view-name)) - (let ((source (list :type 'view :name view-name :id view-id - :url (pearl--view-url view-id)))) + (let* ((prefs (cdr (assoc 'viewPreferencesValues view))) + (sort (pearl--view-sort-arg (cdr (assoc 'viewOrdering prefs)) + (cdr (assoc 'viewOrderingDirection prefs)))) + (source (list :type 'view :name view-name :id view-id + :url (pearl--view-url view-id) + :sort sort))) (pearl--progress "Running view %s..." view-name) (pearl--query-view-async view-id - (lambda (result) (pearl--render-query-result result source)))))) + (lambda (result) (pearl--render-query-result result source)) + sort)))) ;;;###autoload (defun pearl-open-current-view-in-linear () diff --git a/tests/test-pearl-views.el b/tests/test-pearl-views.el index e22a6d3..7a211df 100644 --- a/tests/test-pearl-views.el +++ b/tests/test-pearl-views.el @@ -101,6 +101,51 @@ and leaves the run-view picker empty (the 2026-06-02 regression)." (should-not (pearl--view-url nil)) (should-not (pearl--view-url "")))) +;;; view sort — match the Linear view's configured ordering + +(ert-deftest test-pearl-view-sort-arg-maps-priority-desc () + "A priority ordering with desc direction maps to an IssueSortInput list." + (should (equal (vector '(("priority" ("order" . "Descending")))) + (pearl--view-sort-arg "priority" "desc")))) + +(ert-deftest test-pearl-view-sort-arg-honors-asc-direction () + "An asc direction maps to Ascending." + (should (equal (vector '(("accumulatedStateUpdatedAt" ("order" . "Ascending")))) + (pearl--view-sort-arg "accumulatedStateUpdatedAt" "asc")))) + +(ert-deftest test-pearl-view-sort-arg-defaults-nil-direction-to-descending () + "A nil direction defaults to Descending — the field's natural order, and what +Linear's UI shows for a priority view with no explicit direction." + (should (equal (vector '(("priority" ("order" . "Descending")))) + (pearl--view-sort-arg "priority" nil)))) + +(ert-deftest test-pearl-view-sort-arg-unsupported-ordering-is-nil () + "An ordering with no IssueSortInput key yields nil, so the caller falls back +to the default sort rather than sending an invalid sort key." + (should-not (pearl--view-sort-arg "someExoticOrdering" "asc"))) + +(ert-deftest test-pearl-view-issues-query-accepts-sort () + "The view issues query declares the sort variable and forwards it to the +issues connection, so a view runs under a chosen order." + (let ((q (pearl--view-issues-query))) + (should (string-match-p "\\$sort: \\[IssueSortInput!\\]" q)) + (should (string-match-p "sort: \\$sort" q)))) + +(ert-deftest test-pearl-query-view-async-defaults-sort-to-updated-desc () + "With no explicit sort, a view runs under updatedAt-desc rather than Linear's +createdAt-desc API default, so its order matches the rest of pearl." + (let ((sent 'unset)) + (cl-letf (((symbol-function 'pearl--graphql-request-async) + (lambda (_q vars success &optional _err) + (setq sent (cdr (assoc "sort" vars))) + (funcall success + '((data (customView + (issues (nodes . []) + (pageInfo (hasNextPage . :json-false) + (endCursor)))))))))) + (pearl--query-view-async "v1" #'ignore) + (should (equal pearl--default-issue-sort sent))))) + ;;; run-view (ert-deftest test-pearl-run-view-renders-with-view-source () @@ -113,7 +158,7 @@ and leaves the run-view picker empty (the 2026-06-02 regression)." '(((id . "v1") (name . "My View"))))) ((symbol-function 'pearl--org-url-key) (lambda (&optional _) "deepsat")) ((symbol-function 'pearl--query-view-async) - (lambda (id cb) (setq ran-id id) + (lambda (id cb &optional _sort) (setq ran-id id) (funcall cb (pearl--make-query-result 'ok :issues nil)))) ((symbol-function 'pearl--render-query-result) (lambda (_result source) (setq rendered-source source)))) @@ -124,6 +169,26 @@ and leaves the run-view picker empty (the 2026-06-02 regression)." (should (string= "https://linear.app/deepsat/view/v1" (plist-get rendered-source :url)))))) +(ert-deftest test-pearl-run-view-applies-and-stamps-view-sort () + "Running a view resolves its configured ordering, passes it to the query, and +records it on the source so a later refresh reuses the same order." + (let ((passed-sort 'unset) (rendered-source nil)) + (cl-letf (((symbol-function 'pearl--custom-views) + (lambda (&optional _force) + '(((id . "v1") (name . "My View") + (viewPreferencesValues (viewOrdering . "priority") + (viewOrderingDirection . "desc")))))) + ((symbol-function 'pearl--org-url-key) (lambda (&optional _) "deepsat")) + ((symbol-function 'pearl--query-view-async) + (lambda (_id cb &optional sort) (setq passed-sort sort) + (funcall cb (pearl--make-query-result 'ok :issues nil)))) + ((symbol-function 'pearl--render-query-result) + (lambda (_result source) (setq rendered-source source)))) + (pearl-run-linear-view "My View") + (should (equal (vector '(("priority" ("order" . "Descending")))) passed-sort)) + (should (equal (vector '(("priority" ("order" . "Descending")))) + (plist-get rendered-source :sort)))))) + ;;; refresh-current-view, view branch (ert-deftest test-pearl-refresh-current-view-runs-view-source () @@ -135,13 +200,30 @@ and leaves the run-view picker empty (the 2026-06-02 regression)." (prin1-to-string source))) (org-mode) (cl-letf (((symbol-function 'pearl--query-view-async) - (lambda (id cb) (setq view-ran id) + (lambda (id cb &optional _sort) (setq view-ran id) (funcall cb (pearl--make-query-result 'ok :issues nil)))) ((symbol-function 'pearl--merge-query-result) (lambda (&rest _) nil))) (pearl-refresh-current-view) (should (string= "v1" view-ran)))))) +(ert-deftest test-pearl-refresh-current-view-passes-stored-sort () + "Refreshing a view source forwards the :sort recorded on it, so the refreshed +list keeps the order the view was run under." + (let ((passed-sort 'unset) + (source '(:type view :name "My View" :id "v1" + :sort [(("priority" ("order" . "Descending")))]))) + (with-temp-buffer + (insert (format "#+LINEAR-SOURCE: %s\n\n" (prin1-to-string source))) + (org-mode) + (cl-letf (((symbol-function 'pearl--query-view-async) + (lambda (_id cb &optional sort) (setq passed-sort sort) + (funcall cb (pearl--make-query-result 'ok :issues nil)))) + ((symbol-function 'pearl--merge-query-result) + (lambda (&rest _) nil))) + (pearl-refresh-current-view) + (should (equal [(("priority" ("order" . "Descending")))] passed-sort)))))) + ;;; open-current-view-in-linear (ert-deftest test-pearl-open-current-view-visits-url () |
