feat: project-scoped custom fields (Asana parity, 6 types) β full stack#1
Merged
Merged
Conversation
added 26 commits
May 15, 2026 02:02
β¦ions) Planning doc for the next list-view wave, to start AFTER feature/custom-fields lands. Covers: - F1 column drag-reorder, F2 column width resize (share one TViewColumnPrefs persistence + the getListGridTemplate seam) - F3 free-form Sections β incl. the load-bearing-State problem and three strategies (S1 coexist / S2 state-backed / S3 replace); recommends S1 to avoid gutting the Automation Engine - Β§6 conflict matrix answering whether custom-fields collides with shipped PR1-3 / Sections / automation (verdict: sequencing, not conflict β F1/F2 share files with custom-fields so they follow it; Sections is largely independent) Not pushed: the GHCR workflow has no path filter, so a docs-only push would trigger a pointless full 4-image rebuild. Bundle with the next code change.
WorkItemField / WorkItemFieldOption / WorkItemFieldValue with the Plane house-style dual-pattern uniqueness (unique_together incl. deleted_at + partial UniqueConstraint). 6 Asana types, no boolean. Migration 0123. Design doc realigned + Β§10 resequenced. Not yet runtime-tested (no Django env); py_compile clean.
Serializers + viewsets + URLs for field/option schema CRUD and per-issue value upsert/clear/list. Value layer enforces the Β§3 field_type->column mapping with per-type validation (select option ids, people project-member ids). Mirrors automation/label/issue sub-resource precedents; ADMIN writes schema, ADMIN+MEMBER values. py_compile clean; not yet runtime-tested.
@plane/types work-item-field types, WorkItemFieldService, schema-side WorkItemFieldStore (field+option CRUD, optimistic + rollback), RootStore wiring, useWorkItemField hook. Faithful clone of the label vertical slice. Resequenced ahead of UI per design Β§10. Not yet tsc/built-verified.
Project-settings Fields tab: nav entry (PROJECT_SETTINGS + types union + icon), route (page+header), components (field list, inline create/update form with type picker, inline option editor with color, list item with edit/archive), en + zh-CN i18n. Not yet tsc/built-verified.
Dedicated issue-field-values/ bulk endpoint (avoids issue-list hot-path mutation; design Β§5/Β§6 amended). Isolated filters.py: build_custom_field_filter (Q) + parse_custom_field_order_by. Β§7/Β§8/Β§9 contracts corrected to PR1 reality; hot-path wiring documented + gated. py_compile clean; not runtime-tested.
Frontend value layer: TWorkItemFieldValue types, service value methods, store value cache (valuesByIssue + upsert/clear). List-view plumbing: append-only registry in list-columns.ts (zero PR2/PR3 conflict), WorkItemFieldCell per-type renderer, useCustomFieldColumns, CustomFieldColumnsBridge, WorkItemFieldSection (peek). Hot-file wiring deliberately gated (design Β§7) β not built/tsc-verified here.
β¦steps 7-8)
Closes the frontend loop. Custom-field schema/values now render in the
list view (one column per active field, Asana-aligned grid) and the peek
panel right rail, gated to project-scoped views only.
- list-columns.ts: MobX observable.box version counter so the runtime
provider registry is reactive (bridge registers post-mount; without
this columns never appear until an unrelated re-render). Purely
additive β no existing export changed.
- default.tsx: mount CustomFieldColumnsBridge when route has projectId;
getListGridTemplate -> getListGridTemplateWithCustom for --list-cols.
- block.tsx: append custom WorkItemFieldCell cells after built-ins;
slot div always renders so the grid stays aligned pre-schema-load.
(Corrected target: the cell loop lives here, not issue-cells.tsx.)
- list-header-row.tsx: append custom HeaderCells; matching grid template.
- peek-overview/properties.tsx: mount WorkItemFieldSection after the
additional sidebar properties, isReadOnly={disabled}.
Workspace/profile views register no provider -> getCustomListColumns()
== [] -> zero behaviour change there.
Verified: turbo package build + pnpm --filter web check:types -> 0
errors in any wired file (11 remaining are pre-existing, unrelated Lark
drift); oxlint --deny-warnings + oxfmt clean on all 5 files. Pixel
verification still pending a dev server (flagged in design Β§7).
Design doc Β§7/Β§10 amended to record the corrected contract.
20 passing tests for the exact logic the design gated as 'do not wire blind' -- build_custom_field_filter (the wrong-predicate-silently-drops- issues risk) and parse_custom_field_order_by. Env was the blocker, now solved: uv-provisioned Python 3.12 (Django 4.2 compatible; only 3.13/3.14 were present), venv install minus psycopg-c (C build needs pg_config/libpq, absent here; psycopg-binary suffices). build_custom_field_filter is pure (builds a Q, never hits the DB) so it runs fully without Postgres; parse_custom_field_order_by's single ORM hit is mocked via pytest-mock. pytest.ini --nomigrations + no db fixture => no Postgres needed. Coverage: noop on absent/blank field_id; field_id-only; value predicate AND; operator suffixes incl. __gt-vs-__gte disambiguation; __contains -> __icontains; non-value keys ignored; multi-predicate AND; all 6 field types -> correct value_* column; desc prefix; unknown/non-custom params. Residual (honest): the live ORM JOIN multiplicity + migration 0123 apply still want a Postgres run (ArrayField is Postgres-only; SQLite can't). But the single-combined-Q structure is now unit-proven, and the schema's (issue,field,deleted_at) unique constraint bounds the field_values join to <=1 row/issue -> no duplicate Issue rows / no .distinct() needed. That narrows the gate from 'unverified predicate logic' to just the empirical DB confirmation.
β¦123 + filter/sort Migration 0123 + the dual-pattern uniqueness (step-1 risk flag) are no longer 'py_compile only / needs runtime' β full 0001β0123 chain applied on an ephemeral Postgres (uv Py3.12 + pgserver, no Docker), both halves of the uniqueness directly confirmed (pg_constraint + pg_indexes). Β§4/Β§8/Β§9/Β§10 updated; the Β§8 'silent drop/dupe' gating fear is retired (single combined Q + partial unique index β β€1 join row/issue).
β¦tion test The previously-gated hot-path edit, now de-risked. Adds one additive .filter(build_custom_field_filter(query_params)) to both issue-list entry points (IssueListEndpoint.get + IssueViewSet.list) right after the existing legacy-filter .filter(). build_custom_field_filter returns an inert Q() when no field_values__field_id param -> zero behaviour change for every existing request; both querysets already .distinct(). test_filter_integration.py (4 tests, real Postgres via factories + django_db): asserts the live field_values reverse-FK join returns the correct issues, is field-scoped, inert when absent, and -- the exact gated concern -- produces NO duplicate Issue rows *without* .distinct() (proven by the single combined Q + the (issue,field,deleted_at) partial unique index). Ran green on ephemeral pgserver Postgres; CI runs it against its own PG. 20 prior unit tests still pass; ruff + py_compile clean; no import cycle (filters.py imports no views). Sort wiring (parse_custom_field_order_by into order_issue_queryset) deliberately deferred -- design Β§9 gates the sort UI on external PR2; server-wiring it now is speculative + touches a shared order util.
β¦to feature/custom-fields Brings the completed UI base (clickable column-header sort PR2, inline quick-add PR3, sticky header, Phase-2) into custom-fields. Single conflict (list-header-row.tsx) resolved per the verified recipe: PR2's ListSortHeaderCell kept for built-in columns; custom-field columns appended as plain label headers (not sortable β sort UI gated on PR2 menu, design Β§10); getListGridTemplateWithCustom grid; PR2's displayFilters/handleDisplayFilterUpdate/visibilityClassName + sticky Row preserved; dropped the HeaderCell/icon helpers PR2 removed. Backend merged with zero conflicts. NOT pushed β pending local dev-server verification.
β¦404) react-router uses explicit route config (app/routes/core.ts), not file-based β the page.tsx/nav/i18n existed but the route was never registered, so /settings/projects/<id>/fields hit the catch-all (404). Added the entry next to labels/estimates. Route now serves HTTP 200.
β¦ling - Wrap cell in stopPropagation guard (no preventDefault so inputs still focus) β a click no longer bubbles to the row ControlLink and opens the issue peek; fields edit inline. Fixes list + peek at one point. - single_select -> Plane CustomSelect (colored chip + popover, like StateDropdown); date -> native DateDropdown calendar. text/number stay inline inputs (parity with Plane's native text/number props). typecheck: 11 pre-existing Lark only (0 new); oxlint/oxfmt clean.
Root cause (source-confirmed): the issue peek is a focus-locked panel; @plane/ui controls that createPortal their popup to document.body render outside the lock and are unreachable inside peek. Inline-rendered controls work (stay within the peek subtree). - single_select: CustomSelect (always body-portaled) -> inline CustomMenu, mirroring the already-working multi_select branch (single-value). - people: drop the optionsClassName="z-20" override, the sole deviation from the proven-working native peek Assignees <MemberDropdown> call. - Remove now-unused CustomSelect import; correct comments my change stale. Scope: work-item-field-cell.tsx only. No @plane/ui changes. Verified at :3000 β single_select & people open/select/clear in peek; list view and Tags unaffected.
The list row is an <a href> ControlLink; the cell wrapper only stopped
propagation, so clicking a dropdown option also triggered the row
link/peek and the selection was lost ('opens but can't change'). Add
e.preventDefault() on the wrapper click. Safe for text/number inputs β
their focus happens on mousedown, not click. Verified in list & peek.
With many custom-field columns the grid exceeds the viewport. Rows (blocks-list wrapper) and the sticky header were w-full (client width) so scrolling right past the viewport showed neither β work items vanished and the header truncated. Use min-w-full w-max so both span the full --list-cols content width. Shared list layout; verified no regression on normal/grouped/mobile lists. (Frozen first column is a Spreadsheet-layout capability, intentionally not added here.)
Settings page: 'Archive' -> 'Delete' (Trash2 icon, delete_field i18n). Deleting now opens a shared AlertModal confirm (DeleteFieldModal, mirrors StickyDeleteModal) before the destructive call; soft-deleted fields (is_active=false) are filtered out of the settings list (no restore entry, so effectively permanent). Removed the now-dead 'Archived' badge branch and the unused toast import (toast handling moved into the modal). B-2.1 of 2; list-header / peek menus still use the old path (B-2.2).
B-2.2: list column-header menu and peek section menu now use 'Delete' (Trash2) + the shared confirm modal, consistent with the settings page (B-2.1). Fix peek-close regression: DeleteFieldModal rebuilt on ModalCore with its content+buttons wrapped in data-prevent-outside-click (mirrors WorkItemFieldEditorModal). AlertModalCore has no such opt-out, so confirming a delete inside the issue peek tripped use-peek-overview-outside-click and closed the peek. One component, fixes all three call sites; settings/list-header unaffected. Verified.
Empty multi_select showed {field.name}, reading as if auto-filled with
the title. Show neutral 'β' (parity with single_select) and add a 'β'
clear item to the dropdown (single_select already had one). Verified.
The cell's DateDropdown passed optionsClassName="z-20", the same deviation from the proven native peek DateDropdown call that broke the people field β z-20 sinks the portaled calendar behind the peek so it can't open. Native Start/Due-date DateDropdown passes no optionsClassName and works in peek; mirror it. Verified.
The custom-fields section used a proportional w-2/5 label column; native SidebarPropertyListItem uses a fixed w-30 / h-7.5 / text-body-xs-regular label. Mirror it so custom field labels/values line up with State, Assignees, etc. Verified.
single_select & multi_select cell dropdowns gain an admin-only 'Edit options' item (Asana parity) that opens the existing WorkItemFieldEditorModal (peek-safe). 'Auto-fill field value' was intentionally cut. Verified.
β¦-fields) Pre-existing failures inherited from the feature/lark-oauth-provider merge β NOT introduced by this branch's custom-fields commits. Greens the CI gates so PR #2 (custom-fields -> preview) can merge cleanly. - constants: add missing `lark` key to CORE_LOGIN_MEDIUM_LABELS (Record<TCoreLoginMediums> requires it) -> check:types - ruff: drop unused `synthetic_email_used` (lark.py F841); rename ambiguous `l` -> `label_id` (automation_engine_task.py E741) -> Lint API - AGPL SPDX header on lark_i18n.py / lark_jssdk.py -> Copy Right Check - oxfmt: format packages/types/src/instance/auth.ts and packages/i18n/src/store/index.ts -> check:format - i18n store (the reformatted file): default->named import of IntlMessageFormat (same class) and attach `{ cause }` when re-throwing β clears pre-existing oxlint warnings so lint-staged passes cleanly CodeQL (fails in 7s, no analysis log) is a code-scanning config artifact; the real Analyze (js/py) jobs pass β not code-fixable here.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Project-scoped custom fields for work items β Asana parity, 6 field types
(
text/number/date/single_select/multi_select/people,no boolean). Full stack: Django models + REST API, MobX data layer,
project-settings management UI, list-view columns, peek-panel section,
filter, and sort server-parser. Contract:
docs/custom-fields-design.md.This branch also merges
feature/lark-oauth-provider(PR2 clickablecolumn-sort, PR3 inline quick-add, sticky header, Phase-2). The only merge
conflict β
list-header-row.tsxβ is resolved (PR2'sListSortHeaderCellkept for built-ins; custom-field columns appended as plain headers;
getListGridTemplateWithCustomgrid).Backend
WorkItemField/Option/Value) + migration0123,dual-pattern soft-delete uniqueness (matches upstream Module/State).
?expandhot-path).Q()when absent βzero behaviour change for existing requests).
Frontend
manage select options + colors. Route registered in
app/routes/core.ts.(runtime registry, MobX-reactive, purely additive to
list-columns.ts).single_selectβ nativeCustomSelect,dateβDateDropdown,text/numberβ inline inputs; stopPropagation guard so editing isinline (no longer opens the issue peek).
Verification done
pnpm --filter web check:types: 0 new errors (only the 11 pre-existingunrelated Lark-integration errors; none in any custom-fields/merged file).
test (real Postgres via pgserver: correct rows, field-scoped, no
duplicate Issue rows without
.distinct()).0123applied on real Postgres (full 0001β0123 chain),both halves of the dual-pattern uniqueness confirmed in
pg_constraint+pg_indexes.oxlint --deny-warnings+oxfmt+ruffclean on all touched files.login, settings panel, list columns, inline cell editing.
Test plan / TODO
0123vsfeature/asana-sections0124numbering β resolve per design Β§4 (whoever merges 2nd does
makemigrations --merge); do not renumber an applied migrationaligned; single_select/date popovers; peek section
Out of scope (follow-up, UI/UX)
header "+ add / edit-remove" menu (PR2-zone, gated)
multi_select/peoplefull inline multi-picker (today: read-only chips)