Skip to content

feat: project-scoped custom fields (Asana parity, 6 types) β€” full stack#1

Merged
JOBYINC merged 26 commits into
feature/lark-oauth-providerfrom
feature/custom-fields
May 17, 2026
Merged

feat: project-scoped custom fields (Asana parity, 6 types) β€” full stack#1
JOBYINC merged 26 commits into
feature/lark-oauth-providerfrom
feature/custom-fields

Conversation

@JOBYINC
Copy link
Copy Markdown
Owner

@JOBYINC JOBYINC commented May 15, 2026

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 clickable
column-sort, PR3 inline quick-add, sticky header, Phase-2). The only merge
conflict β€” list-header-row.tsx β€” is resolved (PR2's ListSortHeaderCell
kept for built-ins; custom-field columns appended as plain headers;
getListGridTemplateWithCustom grid).

Backend

  • 3 models (WorkItemField / Option / Value) + migration 0123,
    dual-pattern soft-delete uniqueness (matches upstream Module/State).
  • Schema + value CRUD API; bulk issue-values endpoint (no ?expand hot-path).
  • Β§8 filter wired into both issue-list endpoints (inert Q() when absent β†’
    zero behaviour change for existing requests).
  • Β§9 sort parser ready (sort UI gated on PR2's menu β€” external dep).

Frontend

  • Project Settings β†’ Fields panel: add / rename / delete-archive /
    manage select options + colors. Route registered in app/routes/core.ts.
  • List view: one column per active field, aligned with PR2 sort headers
    (runtime registry, MobX-reactive, purely additive to list-columns.ts).
  • Peek panel: custom-fields section (inline value editing).
  • Cells: single_select β†’ native CustomSelect, date β†’ DateDropdown,
    text/number β†’ inline inputs; stopPropagation guard so editing is
    inline (no longer opens the issue peek).

Verification done

  • pnpm --filter web check:types: 0 new errors (only the 11 pre-existing
    unrelated Lark-integration errors; none in any custom-fields/merged file).
  • Backend: 20 unit tests (filter/sort helpers) + live-DB integration
    test
    (real Postgres via pgserver: correct rows, field-scoped, no
    duplicate Issue rows without .distinct()).
  • Migration 0123 applied on real Postgres (full 0001β†’0123 chain),
    both halves of the dual-pattern uniqueness confirmed in
    pg_constraint + pg_indexes.
  • oxlint --deny-warnings + oxfmt + ruff clean on all touched files.
  • Manually bootstrapped the full stack locally (api+web) and exercised
    login, settings panel, list columns, inline cell editing.

Test plan / TODO

  • CI: web typecheck/lint/build + API pytest + migration check green
  • Reviewer: confirm migration 0123 vs feature/asana-sections 0124
    numbering β€” resolve per design Β§4 (whoever merges 2nd does
    makemigrations --merge); do not renumber an applied migration
  • Dev-server visual pass: PR2 sort header + custom columns render
    aligned; single_select/date popovers; peek section
  • Backend smoke on staging Postgres (production-shaped data)

Out of scope (follow-up, UI/UX)

  • Asana in-context entry points: peek "+ Add custom field", list column-
    header "+ add / edit-remove" menu (PR2-zone, gated)
  • multi_select / people full inline multi-picker (today: read-only chips)
  • Sort UI for custom fields (blocked by PR2's 24-option menu, design Β§10)

Marcus Cheung 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.
@JOBYINC JOBYINC merged commit d8b6b55 into feature/lark-oauth-provider May 17, 2026
10 of 13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant