Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion apps/api/plane/api/views/issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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__<id>) 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,
Expand Down
23 changes: 16 additions & 7 deletions apps/api/plane/app/views/issue/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
66 changes: 59 additions & 7 deletions apps/api/plane/app/views/work_item_field/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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__<field_id>`` (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")
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
83 changes: 79 additions & 4 deletions apps/web/core/components/work-item-fields/custom-column-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -48,16 +59,26 @@ 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<IIssueDisplayFilterOptions>) => 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 /
* Delete. With no menu actions at all, it falls back to the plain label
* (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();
Expand All @@ -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 ? (
<ColumnResizeHandle currentWidth={currentWidth} minWidth={minWidth} onCommit={onCommitWidth} />
Expand Down Expand Up @@ -102,12 +134,55 @@ export const CustomColumnHeaderCell = observer(function CustomColumnHeaderCell(p
<span className="truncate">{label}</span>
</div>
<div className="ml-1 flex shrink-0 items-center">
{isSortedByThisColumn && (
<span className="flex h-3.5 w-3.5 items-center justify-center">
{isAscActive ? (
<ArrowDownWideNarrow className="h-3 w-3" />
) : (
<ArrowUpNarrowWide className="h-3 w-3" />
)}
</span>
)}
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
</div>
</div>
}
optionsClassName="z-20"
>
{canSort && (
<>
<CustomMenu.MenuItem onClick={() => setOrderBy(ascOrderKey)}>
<div
className={cn("flex items-center gap-2 px-1", {
"text-primary": isAscActive,
"text-secondary hover:text-primary": !isAscActive,
})}
>
<ArrowDownWideNarrow className="h-3 w-3 stroke-[1.5]" />
<span>{t("common.actions.sort_ascending")}</span>
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => setOrderBy(descOrderKey)}>
<div
className={cn("flex items-center gap-2 px-1", {
"text-primary": isDescActive,
"text-secondary hover:text-primary": !isDescActive,
})}
>
<ArrowUpNarrowWide className="h-3 w-3 stroke-[1.5]" />
<span>{t("common.actions.sort_descending")}</span>
</div>
</CustomMenu.MenuItem>
{isSortedByThisColumn && (
<CustomMenu.MenuItem className="mt-0.5" onClick={() => setOrderBy(CLEAR_ORDER_BY)}>
<div className="flex items-center gap-2 px-1">
<Eraser className="h-3 w-3" />
<span>{t("common.actions.clear_sorting")}</span>
</div>
</CustomMenu.MenuItem>
)}
</>
)}
{canManageThisField && (
<CustomMenu.MenuItem onClick={() => setIsEditorOpen(true)}>
<span className="flex items-center gap-2">
Expand Down
Loading
Loading