From 178010a3ec9420188dc3e01a0c7dc1b6dc66b800 Mon Sep 17 00:00:00 2001 From: Marcus Cheung Date: Tue, 19 May 2026 00:49:21 -0700 Subject: [PATCH] feat(custom-fields): sort the issue list by a custom field (List parity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Custom fields could be filtered but not sorted; built-in sort is a hardcoded closed enum. Wire the pre-built (but gated) custom-field order parser end to end so a custom column sorts like a built-in one. Backend: - filters.py: add apply_custom_field_order — mirrors the labels/assignees Min-annotate branch of order_issue_queryset but filters the aggregate to the target field_id (the shipped parse_custom_field_order_by returns a bare field_values__value_* path; applying that directly fans the reverse-FK join out and sorts by an arbitrary field — wrong). Rewrites order_by_param to the annotation name so the grouper/paginator are unchanged. - app IssueListEndpoint + IssueViewSet.list and the token issue list else-branch: custom-field sort takes precedence, else built-in. Frontend: - TIssueOrderByOptions admits custom_field__ / -custom_field__. - CustomColumnHeaderCell: asc/desc + clear-sort menu mirroring ListSortHeaderCell; list-header-row threads the display-filter props. - base-issues.store issuesSortWithOrderBy: a custom_field key now returns the server order unchanged (root-cause fix — the client re-sort fell through to workItemSortWithOrderByExtended which returns a -created_at-sorted array, silently discarding the correct server order; the value isn't on the client TIssue to re-sort). - i18n en/zh-CN: sort_ascending / sort_descending. Also fixes the 15 pre-existing oxlint warnings in base-issues.store.ts (no-shadow / no-unused-expressions, NOT introduced here). They had to be cleared: the un-bypassable pre-commit hook (oxlint --deny-warnings) blocks staging this file otherwise, and the root-cause fix must live here. All zero-behavior (cond && expr -> if (cond) expr; local renames orderBy->orderByValue, update->updateItem/updateAction, action->groupedAction, groupId/subGroupId->grpId/subGrpId). Verified: backend token probe 10/10 (asc/desc, nulls last, no fan-out, built-in not hijacked); oxlint 0/0 on changed files; tsc no new errors (11 pre-existing lark/members unrelated); user-verified List sort + grouping/counts unaffected. --- apps/api/plane/api/views/issue.py | 9 +- apps/api/plane/app/views/issue/base.py | 23 ++-- .../app/views/work_item_field/filters.py | 66 ++++++++++-- .../list/columns/list-header-row.tsx | 2 + .../work-item-fields/custom-column-header.tsx | 83 +++++++++++++- .../store/issue/helpers/base-issues.store.ts | 101 ++++++++++-------- packages/i18n/src/locales/en/translations.ts | 2 + .../i18n/src/locales/zh-CN/translations.ts | 2 + packages/types/src/view-props.ts | 7 +- 9 files changed, 228 insertions(+), 67 deletions(-) diff --git a/apps/api/plane/api/views/issue.py b/apps/api/plane/api/views/issue.py index b48be56d413..ea034542d9b 100644 --- a/apps/api/plane/api/views/issue.py +++ b/apps/api/plane/api/views/issue.py @@ -64,6 +64,7 @@ ProjectLitePermission, ProjectMemberPermission, ) +from plane.app.views.work_item_field.filters import apply_custom_field_order from plane.bgtasks.issue_activities_task import issue_activity from plane.db.models import ( Issue, @@ -392,7 +393,13 @@ def get(self, request, slug, project_id): max_values=Max(order_by_param[1::] if order_by_param.startswith("-") else order_by_param) ).order_by("-max_values" if order_by_param.startswith("-") else "max_values") else: - issue_queryset = issue_queryset.order_by(order_by_param) + # custom-field sort (?order_by=custom_field__) takes + # precedence here, else fall back to the raw field order_by + issue_queryset, cf_param = apply_custom_field_order(issue_queryset, order_by_param) + if cf_param is not None: + order_by_param = cf_param + else: + issue_queryset = issue_queryset.order_by(order_by_param) return self.paginate( request=request, diff --git a/apps/api/plane/app/views/issue/base.py b/apps/api/plane/app/views/issue/base.py index 8b9afcac52a..f51ec7fb3a9 100644 --- a/apps/api/plane/app/views/issue/base.py +++ b/apps/api/plane/app/views/issue/base.py @@ -40,7 +40,10 @@ IssueSerializer, ProjectUserPropertySerializer, ) -from plane.app.views.work_item_field.filters import build_custom_field_filter +from plane.app.views.work_item_field.filters import ( + apply_custom_field_order, + build_custom_field_filter, +) from plane.bgtasks.issue_activities_task import issue_activity from plane.bgtasks.issue_description_version_task import issue_description_version_task from plane.bgtasks.recent_visited_task import recent_visited_task @@ -148,8 +151,10 @@ def get(self, request, slug, project_id): ) order_by_param = request.GET.get("order_by", "-created_at") - # Issue queryset - issue_queryset, _ = order_issue_queryset(issue_queryset=issue_queryset, order_by_param=order_by_param) + # Issue queryset — custom-field sort takes precedence, else built-in + issue_queryset, _cf_order = apply_custom_field_order(issue_queryset, order_by_param) + if _cf_order is None: + issue_queryset, _ = order_issue_queryset(issue_queryset=issue_queryset, order_by_param=order_by_param) # Group by group_by = request.GET.get("group_by", False) @@ -288,10 +293,14 @@ def list(self, request, slug, project_id): # Applying annotations to the issue queryset issue_queryset = self.apply_annotations(issue_queryset) - # Issue queryset - issue_queryset, order_by_param = order_issue_queryset( - issue_queryset=issue_queryset, order_by_param=order_by_param - ) + # Issue queryset — custom-field sort takes precedence, else built-in + issue_queryset, _cf_order = apply_custom_field_order(issue_queryset, order_by_param) + if _cf_order is not None: + order_by_param = _cf_order + else: + issue_queryset, order_by_param = order_issue_queryset( + issue_queryset=issue_queryset, order_by_param=order_by_param + ) # Group by group_by = request.GET.get("group_by", False) diff --git a/apps/api/plane/app/views/work_item_field/filters.py b/apps/api/plane/app/views/work_item_field/filters.py index f6b7bfbe0c7..b919c27edd2 100644 --- a/apps/api/plane/app/views/work_item_field/filters.py +++ b/apps/api/plane/app/views/work_item_field/filters.py @@ -3,17 +3,23 @@ # See the LICENSE file for details. """Isolated, unit-testable helpers for custom-field filter (§8) and sort -(§9) on the issue list. Deliberately NOT wired into the issue-list hot -path here -- that wiring is a gated edit (see design §7 rationale): a -wrong predicate silently drops/duplicates issues and there is no runtime -here to catch it. The issue-list view applies these via: +(§9) on the issue list. The issue-list views apply these via: qs = qs.filter(build_custom_field_filter(request.query_params)) - order = parse_custom_field_order_by(request.query_params.get("order_by")) - if order: qs = qs.order_by(*order) + qs, cf_param = apply_custom_field_order(qs, order_by_param) + if cf_param is not None: order_by_param = cf_param + else: qs, order_by_param = order_issue_queryset(qs, order_by_param) + +NOTE: ``parse_custom_field_order_by`` (kept for the string contract / +unit tests) returns a bare ``field_values__value_*`` path. Applying that +directly with ``.order_by()`` is WRONG on this model: ``field_values`` +is a reverse FK, so a bare join both fans rows out and sorts by an +arbitrary field's value. ``apply_custom_field_order`` is the correct +wiring -- it mirrors the labels/assignees ``Min``-annotate branch of +``order_issue_queryset`` but filters the aggregate to the target field. """ -from django.db.models import Q +from django.db.models import Min, Q from plane.db.models import WorkItemField @@ -91,3 +97,49 @@ def parse_custom_field_order_by(order_by_param): column = _COLUMN_BY_TYPE.get(field.field_type, "value_text") key = f"field_values__{column}" return [f"-{key}" if desc else key] + + +def apply_custom_field_order(issue_queryset, order_by_param): + """Order an issue queryset by a custom field's value. + + ``?order_by=custom_field__`` (optionally ``-`` for desc). + Mirrors the labels/assignees ``Min``-annotate branch of + ``order_issue_queryset``, but the aggregate is filtered to the + target ``field_id`` so the reverse-FK ``field_values`` join cannot + fan rows out or sort by an unrelated field's value. The + ``order_by_param`` is rewritten to the annotation name (exactly as + the built-in helper rewrites to ``min_values``/``max_values``) so + the downstream grouper/paginator keep working unchanged. + + Returns ``(queryset, rewritten_order_by_param)`` when it handled a + custom-field sort, else ``(queryset, None)`` so the caller falls + back to ``order_issue_queryset``. + + Note: number/date/text sort meaningfully; single_select sorts on the + option UUID (value_text) and multi_select/people on the value_multi + array -- mechanically applied but not semantically ordered by label. + """ + if not order_by_param: + return issue_queryset, None + + desc = order_by_param.startswith("-") + raw = order_by_param[1:] if desc else order_by_param + if not raw.startswith(_ORDER_BY_PREFIX): + return issue_queryset, None + + field_id = raw[len(_ORDER_BY_PREFIX) :] + field = WorkItemField.objects.filter(pk=field_id).first() + if field is None: + return issue_queryset, None + + column = _COLUMN_BY_TYPE.get(field.field_type, "value_text") + issue_queryset = issue_queryset.annotate( + custom_field_order=Min( + f"field_values__{column}", + filter=Q(field_values__field_id=field_id), + ) + ).order_by( + "-custom_field_order" if desc else "custom_field_order", + "-created_at", + ) + return issue_queryset, ("-custom_field_order" if desc else "custom_field_order") diff --git a/apps/web/core/components/issues/issue-layouts/list/columns/list-header-row.tsx b/apps/web/core/components/issues/issue-layouts/list/columns/list-header-row.tsx index 4e0ecc4c0ed..b20f03d9de4 100644 --- a/apps/web/core/components/issues/issue-layouts/list/columns/list-header-row.tsx +++ b/apps/web/core/components/issues/issue-layouts/list/columns/list-header-row.tsx @@ -155,6 +155,8 @@ export function ListHeaderRow(props: Props) { label={d.col.label} currentWidth={columnWidths?.[d.key] ?? d.col.width} minWidth={LIST_COLUMN_MIN_WIDTH_PX} + displayFilters={displayFilters} + handleDisplayFilterUpdate={handleDisplayFilterUpdate} onHide={handleDisplayFilterUpdate ? () => hideCustomColumn(d.key) : undefined} onCommitWidth={ handleDisplayFilterUpdate diff --git a/apps/web/core/components/work-item-fields/custom-column-header.tsx b/apps/web/core/components/work-item-fields/custom-column-header.tsx index 3b498d4b73e..8de0fdf1c7f 100644 --- a/apps/web/core/components/work-item-fields/custom-column-header.tsx +++ b/apps/web/core/components/work-item-fields/custom-column-header.tsx @@ -7,12 +7,23 @@ import React, { useState } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; -import { Pencil, Trash2, Plus, Eye, EyeOff, ChevronDownIcon } from "lucide-react"; +import { + Pencil, + Trash2, + Plus, + Eye, + EyeOff, + ChevronDownIcon, + ArrowDownWideNarrow, + ArrowUpNarrowWide, + Eraser, +} from "lucide-react"; // plane imports import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import type { TWorkItemField } from "@plane/types"; +import type { IIssueDisplayFilterOptions, TIssueOrderByOptions, TWorkItemField } from "@plane/types"; import { CustomMenu, Tooltip } from "@plane/ui"; +import { cn } from "@plane/utils"; // hooks import { useWorkItemField } from "@/hooks/store/use-work-item-field"; import { useUserPermissions } from "@/hooks/store/user"; @@ -48,8 +59,17 @@ interface CustomColumnHeaderCellProps { // B2: hide this custom column from the current user's list. Available to // everyone (per-user view pref), unlike Edit/Delete which are admin-only. onHide?: () => void; + // Sort by this custom field (parity with built-in columns). When the + // parent threads the display-filter handler, the header offers asc/desc; + // the server (apply_custom_field_order) does the actual ordering. + displayFilters?: IIssueDisplayFilterOptions; + handleDisplayFilterUpdate?: (data: Partial) => void; } +// Clearing a column sort returns the list to manual drag order (mirrors +// ListSortHeaderCell). +const CLEAR_ORDER_BY: TIssueOrderByOptions = "sort_order"; + /** * One custom-field column header. Everyone with a `onHide` handler gets a * menu (so any user can hide the column); admins additionally get Edit / @@ -57,7 +77,8 @@ interface CustomColumnHeaderCellProps { * (zero visual change — e.g. read-only views with no onHide). */ export const CustomColumnHeaderCell = observer(function CustomColumnHeaderCell(props: CustomColumnHeaderCellProps) { - const { columnKey, label, currentWidth, minWidth, onCommitWidth, onHide } = props; + const { columnKey, label, currentWidth, minWidth, onCommitWidth, onHide, displayFilters, handleDisplayFilterUpdate } = + props; const { workspaceSlug, projectId } = useParams(); const { getFieldById, deleteField } = useWorkItemField(); const { t } = useTranslation(); @@ -68,9 +89,20 @@ export const CustomColumnHeaderCell = observer(function CustomColumnHeaderCell(p const fieldId = customColumnKeyToFieldId(columnKey); const field: TWorkItemField | null = getFieldById(fieldId); + // Sort parity with built-in columns. Server (apply_custom_field_order) + // does the ordering; this just sets the order_by display filter. + const ascOrderKey: TIssueOrderByOptions = `custom_field__${fieldId}`; + const descOrderKey: TIssueOrderByOptions = `-custom_field__${fieldId}`; + const currentOrderBy = displayFilters?.order_by; + const isAscActive = currentOrderBy === ascOrderKey; + const isDescActive = currentOrderBy === descOrderKey; + const isSortedByThisColumn = isAscActive || isDescActive; + const canSort = !!handleDisplayFilterUpdate; + const setOrderBy = (order: TIssueOrderByOptions) => handleDisplayFilterUpdate?.({ order_by: order }); + // Edit/Delete need the resolved field + admin; Hide only needs the column key. const canManageThisField = canManageFields && !!field; - const hasMenu = canManageThisField || !!onHide; + const hasMenu = canManageThisField || !!onHide || canSort; const resizeHandle = onCommitWidth ? ( @@ -102,12 +134,55 @@ export const CustomColumnHeaderCell = observer(function CustomColumnHeaderCell(p {label}
+ {isSortedByThisColumn && ( + + {isAscActive ? ( + + ) : ( + + )} + + )}
} optionsClassName="z-20" > + {canSort && ( + <> + setOrderBy(ascOrderKey)}> +
+ + {t("common.actions.sort_ascending")} +
+
+ setOrderBy(descOrderKey)}> +
+ + {t("common.actions.sort_descending")} +
+
+ {isSortedByThisColumn && ( + setOrderBy(CLEAR_ORDER_BY)}> +
+ + {t("common.actions.clear_sorting")} +
+
+ )} + + )} {canManageThisField && ( setIsEditorOpen(true)}> diff --git a/apps/web/core/store/issue/helpers/base-issues.store.ts b/apps/web/core/store/issue/helpers/base-issues.store.ts index 023f6590da8..0ad398253a0 100644 --- a/apps/web/core/store/issue/helpers/base-issues.store.ts +++ b/apps/web/core/store/issue/helpers/base-issues.store.ts @@ -329,10 +329,10 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore { // The Issue Property corresponding to the order by value get orderByKey() { - const orderBy = this.orderBy; - if (!orderBy) return; + const orderByValue = this.orderBy; + if (!orderByValue) return; - return ISSUE_ORDERBY_KEY[orderBy]; + return ISSUE_ORDERBY_KEY[orderByValue]; } // The Issue Property corresponding to the group by value @@ -537,7 +537,7 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore { this.addIssue(response, shouldUpdateList); // If shouldUpdateList is true, call fetchParentStats - shouldUpdateList && (await this.fetchParentStats(workspaceSlug, projectId)); + if (shouldUpdateList) await this.fetchParentStats(workspaceSlug, projectId); return response; } @@ -762,34 +762,34 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore { try { const getIssueById = this.rootIssueStore.issues.getIssueById; runInAction(() => { - for (const update of updates) { + for (const updateItem of updates) { const dates: Partial = {}; - if (update.start_date) dates.start_date = update.start_date; - if (update.target_date) dates.target_date = update.target_date; + if (updateItem.start_date) dates.start_date = updateItem.start_date; + if (updateItem.target_date) dates.target_date = updateItem.target_date; - const currIssue = getIssueById(update.id); + const currIssue = getIssueById(updateItem.id); if (currIssue) { issueDatesBeforeChange.push({ - id: update.id, + id: updateItem.id, start_date: currIssue.start_date ?? undefined, target_date: currIssue.target_date ?? undefined, }); } - this.issueUpdate(workspaceSlug, projectId, update.id, dates, false); + this.issueUpdate(workspaceSlug, projectId, updateItem.id, dates, false); } }); await this.issueService.updateIssueDates(workspaceSlug, projectId, updates); } catch (e) { runInAction(() => { - for (const update of issueDatesBeforeChange) { + for (const updateItem of issueDatesBeforeChange) { const dates: Partial = {}; - if (update.start_date) dates.start_date = update.start_date; - if (update.target_date) dates.target_date = update.target_date; + if (updateItem.start_date) dates.start_date = updateItem.start_date; + if (updateItem.target_date) dates.target_date = updateItem.target_date; - this.issueUpdate(workspaceSlug, projectId, update.id, dates, false); + this.issueUpdate(workspaceSlug, projectId, updateItem.id, dates, false); } }); console.error("error while updating Timeline dependencies"); @@ -858,7 +858,7 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore { runInAction(() => { // If cycle Id is the current cycle Id, then, remove issue from list of issueIds - this.cycleId === cycleId && this.removeIssueFromList(issueId); + if (this.cycleId === cycleId) this.removeIssueFromList(issueId); }); // update Issue cycle Id to null by calling current store's update Issue, without making an API call @@ -988,7 +988,7 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore { runInAction(() => { // if module Id is the current Module Id, then, add issue to list of issueIds - this.moduleId === moduleId && issueIds.forEach((issueId) => this.addIssueToList(issueId)); + if (this.moduleId === moduleId) issueIds.forEach((issueId) => this.addIssueToList(issueId)); }); // For Each issue update module Ids by calling current store's update Issue, without making an API call @@ -1016,7 +1016,7 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore { runInAction(() => { // if module Id is the current Module Id, then remove issue from list of issueIds - this.moduleId === moduleId && issueIds.forEach((issueId) => this.removeIssueFromList(issueId)); + if (this.moduleId === moduleId) issueIds.forEach((issueId) => this.removeIssueFromList(issueId)); }); // For Each issue update module Ids by calling current store's update Issue, without making an API call @@ -1089,7 +1089,7 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore { // remove the new issue id to the module issues removeModuleIds.forEach((moduleId) => { // If module Id is equal to current module Id, them remove Issue from List - this.moduleId === moduleId && this.removeIssueFromList(issueId); + if (this.moduleId === moduleId) this.removeIssueFromList(issueId); currentModuleIds = pull(currentModuleIds, moduleId); }); @@ -1196,7 +1196,7 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore { updateIssueList( issue?: TIssue, issueBeforeUpdate?: TIssue, - action?: EIssueGroupedAction.ADD | EIssueGroupedAction.DELETE + groupedAction?: EIssueGroupedAction.ADD | EIssueGroupedAction.DELETE ) { if (!issue && !issueBeforeUpdate) return; @@ -1209,7 +1209,7 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore { // get issueUpdates from another method by passing down the three arguments // issueUpdates is nothing but an array of objects that contain the path of the issueId list that need updating and also the action that needs to be performed at the path - const issueUpdates = this.getUpdateDetails(issue, issueBeforeUpdate, action); + const issueUpdates = this.getUpdateDetails(issue, issueBeforeUpdate, groupedAction); const accumulatedUpdatesForCount = {}; runInAction(() => { // The issueUpdates @@ -1379,27 +1379,27 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore { set(this.groupedIssueCount, [ALL_ISSUES], groupedIssueCount[ALL_ISSUES]); // loop through the groups of groupedIssues. - for (const groupId in groupedIssues) { - const issueGroup = groupedIssues[groupId]; - const issueGroupCount = groupedIssueCount[groupId]; + for (const grpId in groupedIssues) { + const issueGroup = groupedIssues[grpId]; + const issueGroupCount = groupedIssueCount[grpId]; - // update the groupId's issue count - set(this.groupedIssueCount, [groupId], issueGroupCount); + // update the group's issue count + set(this.groupedIssueCount, [grpId], issueGroupCount); // This updates the group issue list in the store, if the issueGroup is a string - const storeUpdated = this.updateIssueGroup(issueGroup, [groupId]); + const storeUpdated = this.updateIssueGroup(issueGroup, [grpId]); // if issueGroup is indeed a string, continue if (storeUpdated) continue; // if issueGroup is not a string, loop through the sub group Issues - for (const subGroupId in issueGroup) { - const issueSubGroup = (issueGroup as TGroupedIssues)[subGroupId]; - const issueSubGroupCount = groupedIssueCount[getGroupKey(groupId, subGroupId)]; + for (const subGrpId in issueGroup) { + const issueSubGroup = (issueGroup as TGroupedIssues)[subGrpId]; + const issueSubGroupCount = groupedIssueCount[getGroupKey(grpId, subGrpId)]; - // update the subGroupId's issue count - set(this.groupedIssueCount, [getGroupKey(groupId, subGroupId)], issueSubGroupCount); + // update the sub group's issue count + set(this.groupedIssueCount, [getGroupKey(grpId, subGrpId)], issueSubGroupCount); // This updates the subgroup issue list in the store - this.updateIssueGroup(issueSubGroup, [groupId, subGroupId]); + this.updateIssueGroup(issueSubGroup, [grpId, subGrpId]); } } } @@ -1436,27 +1436,27 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore { accumulateIssueUpdates( accumulator: { [key: string]: EIssueGroupedAction }, path: string[], - action: EIssueGroupedAction + groupedAction: EIssueGroupedAction ) { const [groupId, subGroupId] = path; - if (action !== EIssueGroupedAction.ADD && action !== EIssueGroupedAction.DELETE) return; + if (groupedAction !== EIssueGroupedAction.ADD && groupedAction !== EIssueGroupedAction.DELETE) return; // if both groupId && subGroupId exists update the subgroup key if (subGroupId && groupId) { const groupKey = getGroupKey(groupId, subGroupId); - this.updateUpdateAccumulator(accumulator, groupKey, action); + this.updateUpdateAccumulator(accumulator, groupKey, groupedAction); } // after above, if groupId exists update the group key if (groupId) { - this.updateUpdateAccumulator(accumulator, groupId, action); + this.updateUpdateAccumulator(accumulator, groupId, groupedAction); } // if groupId is not ALL_ISSUES then update the All_ISSUES key // (if groupId is equal to ALL_ISSUES, it would have updated in the previous condition) if (groupId !== ALL_ISSUES) { - this.updateUpdateAccumulator(accumulator, ALL_ISSUES, action); + this.updateUpdateAccumulator(accumulator, ALL_ISSUES, groupedAction); } } @@ -1470,18 +1470,18 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore { updateUpdateAccumulator( accumulator: { [key: string]: EIssueGroupedAction }, key: string, - action: EIssueGroupedAction + groupedAction: EIssueGroupedAction ) { // if the key for accumulator is undefined, they update it with the action if (!accumulator[key]) { - accumulator[key] = action; + accumulator[key] = groupedAction; return; } // if the key for accumulator is not the current action, // Meaning if the key already has an action ADD and the current one is REMOVE, // The key is deleted as both the actions cancel each other out - if (accumulator[key] !== action) { + if (accumulator[key] !== groupedAction) { delete accumulator[key]; } } @@ -1494,10 +1494,10 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore { updateIssueCount(accumulatedUpdatesForCount: { [key: string]: EIssueGroupedAction }) { const updateKeys = Object.keys(accumulatedUpdatesForCount); for (const updateKey of updateKeys) { - const update = accumulatedUpdatesForCount[updateKey]; - if (!update) continue; + const updateAction = accumulatedUpdatesForCount[updateKey]; + if (!updateAction) continue; - const increment = update === EIssueGroupedAction.ADD ? 1 : -1; + const increment = updateAction === EIssueGroupedAction.ADD ? 1 : -1; // get current count at the key const issueCount = get(this.groupedIssueCount, updateKey) ?? 0; // update the count at the key @@ -1515,12 +1515,13 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore { getUpdateDetails = ( issue?: Partial, issueBeforeUpdate?: Partial, - action?: EIssueGroupedAction.ADD | EIssueGroupedAction.DELETE + groupedAction?: EIssueGroupedAction.ADD | EIssueGroupedAction.DELETE ): { path: string[]; action: EIssueGroupedAction }[] => { // check the before and after states to return if there needs to be a re-sorting of issueId list if the issue property that orderBy depends on has changed const orderByUpdates = this.getOrderByUpdateDetails(issue, issueBeforeUpdate); // if unGrouped, then return the path as ALL_ISSUES along with orderByUpdates - if (!this.issueGroupKey) return action ? [{ path: [ALL_ISSUES], action }, ...orderByUpdates] : orderByUpdates; + if (!this.issueGroupKey) + return groupedAction ? [{ path: [ALL_ISSUES], action: groupedAction }, ...orderByUpdates] : orderByUpdates; const issueGroupKeyValue = issue?.[this.issueGroupKey] as string | string[] | null | undefined; const issueBeforeUpdateGroupKey = issueBeforeUpdate?.[this.issueGroupKey] as string | string[] | null | undefined; @@ -1528,7 +1529,7 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore { const groupActionsArray = getDifference( this.getArrayStringArray(issue, issueGroupKeyValue, this.groupBy), this.getArrayStringArray(issueBeforeUpdate, issueBeforeUpdateGroupKey, this.groupBy), - action + groupedAction ); // if not subGrouped, then use the differences to construct an updateDetails Array @@ -1551,7 +1552,7 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore { const subGroupActionsArray = getDifference( this.getArrayStringArray(issue, issueSubGroupKey, this.subGroupBy), this.getArrayStringArray(issueBeforeUpdate, issueBeforeUpdateSubGroupKey, this.subGroupBy), - action + groupedAction ); // Use the differences to construct an updateDetails Array @@ -1750,6 +1751,12 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore { } issuesSortWithOrderBy = (issueIds: string[], key: TIssueOrderByOptions | undefined): string[] => { + // Custom-field sort is computed server-side (apply_custom_field_order): + // the value isn't on the client TIssue, so any client re-sort here would + // just scramble the server order back to -created_at. Trust the server + // order and return the ids unchanged. + if (key && /^-?custom_field__/.test(key)) return issueIds; + const issues = this.rootIssueStore.issues.getIssuesByIds(issueIds, this.isArchived ? "archived" : "un-archived"); const array = orderBy(issues, (issue) => convertToISODateString(issue["created_at"]), ["desc"]); diff --git a/packages/i18n/src/locales/en/translations.ts b/packages/i18n/src/locales/en/translations.ts index 0f93d332604..238b5ef6e1b 100644 --- a/packages/i18n/src/locales/en/translations.ts +++ b/packages/i18n/src/locales/en/translations.ts @@ -668,6 +668,8 @@ export default { subscribe: "Subscribe", unsubscribe: "Unsubscribe", clear_sorting: "Clear sorting", + sort_ascending: "Sort ascending", + sort_descending: "Sort descending", hide_field: "Hide field", show_field: "Show field", show_weekends: "Show weekends", diff --git a/packages/i18n/src/locales/zh-CN/translations.ts b/packages/i18n/src/locales/zh-CN/translations.ts index 4087d122bd1..86dc9ec8081 100644 --- a/packages/i18n/src/locales/zh-CN/translations.ts +++ b/packages/i18n/src/locales/zh-CN/translations.ts @@ -806,6 +806,8 @@ export default { subscribe: "订阅", unsubscribe: "取消订阅", clear_sorting: "清除排序", + sort_ascending: "升序", + sort_descending: "降序", hide_field: "隐藏此列", show_field: "显示此列", show_weekends: "显示周末", diff --git a/packages/types/src/view-props.ts b/packages/types/src/view-props.ts index 15346b9771c..df0a54cb7cb 100644 --- a/packages/types/src/view-props.ts +++ b/packages/types/src/view-props.ts @@ -54,7 +54,12 @@ export type TIssueOrderByOptions = | "attachment_count" | "-attachment_count" | "sub_issues_count" - | "-sub_issues_count"; + | "-sub_issues_count" + // Custom-field sort: `custom_field__` (asc) / `-custom_field__` (desc). + // The server (apply_custom_field_order) is authoritative for the actual ordering; + // ISSUE_ORDERBY_KEY has no entry for these, so client-side reorder no-ops (server order kept). + | `custom_field__${string}` + | `-custom_field__${string}`; export type TIssueGroupingFilters = "active" | "backlog";