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