Skip to content

feat(site): add organizationId prop to OrganizationAutocomplete#23906

Closed
johnstcn wants to merge 6 commits into
mainfrom
cian/org-autocomplete-value-prop
Closed

feat(site): add organizationId prop to OrganizationAutocomplete#23906
johnstcn wants to merge 6 commits into
mainfrom
cian/org-autocomplete-value-prop

Conversation

@johnstcn

@johnstcn johnstcn commented Apr 1, 2026

Copy link
Copy Markdown
Member

Enabling change for #23827 (organization-scoped chats), which reuses OrganizationAutocomplete in the chat creation form. This PR adds the controlled pre-selection capability so callers can pre-fill the org picker instead of requiring the user to select each time.

  • Replace useEffect prop→state sync with render-time derivation
  • Guard auto-select effect to only fire in uncontrolled mode
  • Guard setSelected in onSelect to prevent shadow state in controlled mode
  • Use ID comparison in auto-select to survive react-query refetches
  • Handle empty string organizationId as "not provided" (falsy check)
  • Add five Storybook stories covering controlled pre-selection, unmatched ID, user interaction, and single-org suppression

Follow-up for full controlled refactor: coder/internal#1440

Implementation plan & decision log

Decisions:

Deep review findings addressed:

  • P1: Selection-revert loop (selected?.id in deps) → eliminated by removing the useEffect
  • P1: Missing onChange in controlled sync → eliminated by removing the useEffect
  • P1: Unnecessary useEffect anti-pattern → replaced with render-time derivation
  • P2: Empty string trap ("" === undefined is true) → falsy guard catches it
  • P2: PreselectedOrgNotFound vacuously true → fixed to prove data loaded first
  • P3: Shadow setSelected in controlled mode → guarded behind if (!organizationId)
  • P3: No interaction story → PreselectedOrgUserSelects added with display-revert assertion
  • P3: Auth edge case with check + organizationId → JSDoc warning added
  • Pre-existing: Reference equality in auto-select → switched to ID comparison

🤖 Written by a Coder Agent. Will be reviewed by a human.

The component was fully uncontrolled — it managed its own selected
state internally. Callers that pre-selected an org (like AgentCreateForm
for single-org users) had no way to display that selection in the picker,
resulting in a visual desync where the button shows 'Select an
organization…' while the form already knows the answer.

Adding organizationId as an optional controlled prop lets the parent
set which org is visually selected. When provided, the component syncs
from options on load. The existing auto-select-on-single-option
behavior is preserved when the prop is omitted.
Derive the displayed selection during render instead of syncing via
useEffect. This eliminates the selection-revert loop, the missing
onChange notification, and the unnecessary extra render cycle.

- Replace first useEffect with render-time derivation
- Guard auto-select effect to only run in uncontrolled mode
- Handle empty string organizationId as 'not provided'
- Add three new Storybook stories for controlled pre-selection
johnstcn added 3 commits April 1, 2026 16:34
- Guard setSelected in onSelect to prevent shadow state in controlled mode
- Fix PreselectedOrgNotFound test to prove data loaded before asserting
- Add PreselectedOrgUserSelects story for controlled-mode interaction
- Extract OnChangeFn type alias to deduplicate spy casts
- Document placeholder flash edge case when check + organizationId combine
…ge case

- Assert button reverts to controlled value after user selection in
  PreselectedOrgUserSelects story
- Warn in JSDoc that organizationId must reference an authorized org
  when combined with check prop
@johnstcn johnstcn marked this pull request as ready for review April 1, 2026 16:49
Copilot AI review requested due to automatic review settings April 1, 2026 16:49

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a controlled pre-selection capability to OrganizationAutocomplete so callers can provide an organizationId and have the displayed selection derived at render time, avoiding prior prop→state synchronization pitfalls.

Changes:

  • Introduce an organizationId?: string prop and derive the displayed selection from it during render.
  • Guard the “auto-select single org” effect so it only runs in uncontrolled mode.
  • Add Storybook coverage for controlled preselection, unmatched ID fallback, and single-org suppression in controlled mode.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.tsx Adds organizationId prop, render-time selection derivation, and adjusts auto-select/onSelect behavior for controlled vs uncontrolled usage.
site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.stories.tsx Adds Storybook stories validating controlled preselection behavior and ensuring no unintended onChange calls.
Comments suppressed due to low confidence (1)

site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.tsx:104

  • The auto-select effect uses org === selected (reference equality) to decide whether to fire. If the organizations query refetches and returns a new object instance for the same org (common with react-query), this will re-trigger setSelected + onChange(org) even though the selected org ID hasn’t changed. Prefer comparing stable identity (e.g., selected?.id === org.id) so onChange isn’t fired repeatedly on background refetches/window-focus refetches when there is only one org.
		const org = options[0];
		if (options.length !== 1 || org === selected) {
			return;
		}

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

…fetches

Reference equality (org === selected) breaks on background refetch
since react-query returns new object instances. Compare by ID instead
to prevent spurious onChange calls for single-org users.

@mafredri mafredri left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid work. The render-time derivation replacing useEffect prop-to-state sync is the correct React pattern, and the controlled/uncontrolled guards are clean. The ID-equality fix in auto-select (org.id === selected?.id) prevents duplicate onChange fires on react-query refetches. Existing callers are unaffected. Stories are genuine: PreselectedOrgUserSelects is the standout, proving display-revert and controlled behavior.

Severity summary: 0 P0, 0 P1, 1 P2, 3 P3, 5 notes.

The top finding (P2) is a convergent result from 5 of 6 reviewers: when check filters out the controlled org, parent and component silently disagree. No story pins this documented behavior. Meruem's sharpest observation: the deferred fix (coder/internal#1440, full-object refactor) changes the input type but does not add a notification mechanism, so the deferral doesn't fix this specific problem.

P3 (file-level, OrganizationAutocomplete.stories.tsx) Empty string organizationId is claimed as handled (PR changelog: "Handle empty string organizationId as 'not provided' (falsy check)") but has no test. The code relies on JavaScript truthiness at three sites (lines 90, 98, 157 of the component). This works today, but it's one organizationId !== undefined refactor away from breaking all three guards simultaneously. A one-story test locks it down: pass organizationId: "" with a single-org query mock, assert auto-select fires onChange (proving uncontrolled fallthrough). (Bisky)

Two additional inline findings on comment accuracy and convention. Plus notes on displayedSelection naming, options reference instability in useEffect deps, and PreselectedOrgNotFound using action() instead of fn() (cannot spy on onChange).

Mafuuu on the PreselectedOrgNotFound story: "Falls through to the meta default action('Selected organization'), which cannot be spied on. This means the story cannot assert that onChange was NOT called."

🤖 This review was automatically generated with Coder Agents.

// until permissions load, causing a brief placeholder flash.
// The follow-up refactor (passing the full Organization object
// instead of just an ID) will eliminate this.
const displayedSelection = organizationId

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 When check filters out the controlled org, parent and component silently disagree about the selected organization.

Trace: parent passes organizationId = "org-2-id" with a check prop. Permissions load. Org-2 fails the check and is excluded from options (line 80). displayedSelection becomes null here. Button shows placeholder. onChange(null) never fires. The parent continues to believe org-2 is selected.

If the consuming form (#23827) submits with the uncorrected organizationId, it sends an ID the user isn't authorized for. Backend rejects it. The user never explicitly chose anything, so they have no idea what went wrong.

The JSDoc (lines 35-39) documents this and defers to coder/internal#1440 ("full-object refactor"). That refactor changes the input type but doesn't add a notification mechanism. If the refactored component receives a full Organization object that fails the check, it still needs to tell the parent. The deferral points at a fix that does not fix this specific problem.

No story covers check + organizationId together. The PreselectedOrgNotFound story tests a nonexistent ID (different failure mode). The check interaction is structurally distinct because it depends on an async permission query filtering options after they load.

Suggestion: add a story with organizationId pointing to an org that exists in the query data but is filtered out by check. Assert placeholder text after both queries resolve. Consider whether onChange(null) should fire once options settle. (Meruem P2, Nami P2, Hisoka P2, Mafuuu P3, Bisky P3)

🤖

required?: boolean;
check?: AuthorizationCheck;
/**
* Pre-selects an organization by ID. When provided, the

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 JSDoc opens with "Pre-selects" but the prop does continuous control, not one-time initialization.

A caller reading "Pre-selects an organization by ID" will think this is a defaultValue that can be overridden by user interaction. The PreselectedOrgUserSelects story proves otherwise: the display reverts to the prop value after user selection. "Controls the selected organization by ID" matches the actual semantics. (Gon)

🤖

* selections via onChange.
*
* When combined with `check`, the ID must reference an org
* the user is authorized for — if the org fails the check,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Em-dash character (U+2014) in new comments. Also on line 95. Project convention prohibits em-dashes. Replace with a comma, period, or restructure the sentence.

🤖

// until permissions load, causing a brief placeholder flash.
// The follow-up refactor (passing the full Organization object
// instead of just an ID) will eliminate this.
const displayedSelection = organizationId

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note displayedSelection is a name invented for this component with no precedent in the codebase. The sibling Autocomplete.tsx uses displayValue for the same concept. Other components use value. Consider selection for consistency: it contrasts with the internal state selected without inventing vocabulary. (Gon)

PS. The comment on line 154 references displayedSelection by name, so if renamed, update the comment too.

🤖

@johnstcn

Copy link
Copy Markdown
Member Author

I decided that it wasn't worth working around the hybrid state issues and elected instead to proceed with migration to a fully-controlled component instead (#24211).

@johnstcn johnstcn closed this Apr 10, 2026
@github-actions github-actions Bot locked and limited conversation to collaborators Apr 10, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants