Skip to content

feat: implement <AddUsersMenu /> generic component#23557

Closed
jakehwll wants to merge 16 commits into
mainfrom
jakehwll/DEVEX-112-add-users-popover
Closed

feat: implement <AddUsersMenu /> generic component#23557
jakehwll wants to merge 16 commits into
mainfrom
jakehwll/DEVEX-112-add-users-popover

Conversation

@jakehwll

@jakehwll jakehwll commented Mar 25, 2026

Copy link
Copy Markdown
Contributor

Share a reusable multi-select “Add users” popover across Organization + Group member pages, batching adds to keep the UI stable (single toast.promise, invalidate once). Also align member search to the backend q filtering and update stories/props for the new addMembers flow.

  • Add site/src/components/AddUsersMenu/AddUsersMenu.tsx
  • Batch mutations with Promise.all and single success/error toast
  • Invalidates only the relevant group-member query on success
Old New
image image
image image

@github-actions github-actions Bot added the community Pull Requests and issues created by the community. label Mar 25, 2026
@matifali matifali removed the community Pull Requests and issues created by the community. label Mar 26, 2026
@blinkagent

blinkagent Bot commented Mar 27, 2026

Copy link
Copy Markdown
Contributor

Heads up: this PR introduces several baseUrl-style imports (e.g. import { getErrorMessage } from "api/errors", import { users } from "api/queries/users", import type { User } from "api/typesGenerated", import { useDebouncedValue } from "hooks/debounce", import { prepareQuery } from "utils/filters", import type { ... } from "api/typesGenerated" in organizations.ts, etc.). The baseUrl resolution in tsconfig.json is deprecated and will be removed soon — these imports will stop working.

Please pull a recent version of main into your branch and make sure all new TypeScript imports use the new #/ import style (e.g. import { getErrorMessage } from "#/api/errors").

@jakehwll jakehwll marked this pull request as ready for review April 1, 2026 07:22
@jakehwll jakehwll requested a review from jeremyruppel April 1, 2026 07:22

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6ec30aa20d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread site/src/modules/users/AddUsersPopover.tsx Outdated
Comment on lines +88 to +92
await addAllPromise;
}}
onSuccess={async () => {
// Only invalidate the group-members list we are updating.
await queryClient.invalidateQueries({

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Refresh group member data when batched adds partially fail

The cache refresh is gated behind full Promise.all success, so if one mutateAsync fails but earlier adds succeeded, onSuccess never runs and no member-list invalidation happens. Because addMember() no longer has an onSuccess invalidation in site/src/api/queries/groups.ts, the UI can remain stale after partial success and users can immediately retry against already-added members.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

A partial add is still a failure to add if only some of the data got added. NBD imo.

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.

I think we should handle it personally, it leaves the page in a broken state that only a reload can fix which seems like a bad experience for the user.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 7056295 — switched from Promise.all to Promise.allSettled. Now when at least one add succeeds, the member list is always invalidated/refetched. Partial failures show a descriptive toast and keep the popover open for retry.

🤖 Generated by Coder Agents

@jakehwll

jakehwll commented Apr 1, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0e69ccf75d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +50 to +53
const useFilterResult = useFilter({
searchParams,
onSearchParamsChange: setSearchParams,
onUpdate: membersQuery.goToFirstPage,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Use one searchParams source for filter and pagination

OrganizationMembersPage now reads search params from two different useSearchParams() calls, then wires usePaginatedQuery to one object and useFilter to the other. useFilter.update() writes its params and then calls membersQuery.goToFirstPage, which writes again using the other snapshot; this can overwrite the just-applied filter with stale values (or drop page/filter changes). Reuse a single [searchParams, setSearchParams] pair for both hooks so URL updates are composed consistently.

Useful? React with 👍 / 👎.

Comment on lines +99 to +100
const addAllPromise = Promise.all(addPromises);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Invalidate members after partial add failures

The new multi-add flow uses Promise.all(...) for per-user mutations; if any request fails, await addAllPromise throws and AddUsersPopover will skip onSuccess, so the members query is never invalidated. Because some requests may already have succeeded before the rejection, the server state can change while the table stays stale, and retries can hit duplicate-member errors. Handle partial success (for example with Promise.allSettled) and always invalidate when at least one add succeeds.

Useful? React with 👍 / 👎.

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.

this is a good catch. it also speaks to @code-asher's comment here– there's probably a better way to handle partial failures

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 7056295 — both GroupMembersPage and OrganizationMembersPage now use Promise.allSettled with proper partial failure handling.

🤖 Generated by Coder Agents

@jakehwll jakehwll requested review from code-asher and jaaydenh April 7, 2026 15:11
@code-asher

Copy link
Copy Markdown
Member

Will go over this tomorrow!

autoFocus
/>
<div
className="max-h-64 overflow-y-auto rounded-md border border-solid"

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.

Does the box inside the box look kinda weird or is it just me? What if removed the inner box(es) and we just had a divider? Something like:

Image

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.

no, totes agree. good call pinpointing the exact line too

Suggested change
className="max-h-64 overflow-y-auto rounded-md border border-solid"
className="max-h-64 overflow-y-auto"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 7056295 — removed rounded-md border border-solid from the inner div, keeping only max-h-64 overflow-y-auto.

🤖 Generated by Coder Agents

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied in 7056295 — matches your suggestion exactly.

🤖 Generated by Coder Agents

};

export const addMembers = (queryClient: QueryClient, organization: string) => {
export const addMember = () => {

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.

Why the switch from the bulk add endpoint to the single add endpoint?

idk if this is related, but when I try to add two members, one gets added and the other gets an error:

execute transaction: get group by ID: fetch object: sql: no rows in result set

But sometimes it does work. 🤔 Organization members always seems to work, I only get this on the group for some reason.

Maybe a backend race with trying to add individual members in parallel?

idk if it would be worth trying to fix that though when we could just add them all at once.

Another tricky thing about the single add endpoint is that if some succeed and some do not, we should probably update the list with the ones that did succeed. Otherwise it looks like nothing got added, and also if they try again it will error saying some have already been added. At that point I think the only way the user can fix the page is by reloading it.

Also does each user add query invalidate the group/org? When adding two members to an org for example I see three parallel requests for the members list which seems unfortunate. Ideally we could just do one update after all members were added (or were failed to be added). But maybe it is easier to use the bulk endpoint (idk if org members has one though).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The race condition with parallel single-add mutations is now mitigated by switching to Promise.allSettled (7056295) — partial successes are reflected in the UI, and the member list always refreshes when at least one add succeeds.

Switching back to the bulk endpoint would avoid the parallel mutation issue entirely — leaving that decision to @jakehwll.

🤖 Generated by Coder Agents

const checkboxId = `add-member-${user.id}`;
return (
<li key={user.id} className="pl-0">
<label

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.

This could just be me, sometimes I drag slightly on accident when I click and it keeps highlighting the whole page, and more strangely it inconsistently will not select the user when this happening. For whatever reason the old dialog does not do this to me.

Buut maybe this is just me clicking weirdly?? It keeps happening to me though so thought I would mention it.

2026-04-08_14-35-24.mp4

import { EmptyState } from "#/components/EmptyState/EmptyState";
import { UsersFilter } from "#/components/Filter/UsersFilter";
import { LastSeen } from "#/components/LastSeen/LastSeen";
import { MultiMemberSelect } from "#/components/MultiUserSelect/MultiUserSelect";

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.

I think we can delete this component (and MultiUserSelect) since it is not used anywhere else.

userIds: users.map((u) => u.user_id),
<AddUsersPopover
isLoading={addMemberMutation.isPending}
existingUserIds={new Set(members.map((m) => m.id))}

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.

This will not work because the members are paginated, so this only excludes the members on the current page. Exclusion would have to be implemented in the db query.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added a comment in 7056295 acknowledging this limitation. The exclusion is best-effort since it only covers the current page. The server rejects duplicates anyway, so this serves as a UX optimization rather than a guarantee.

A proper fix would require a server-side exclusion parameter on the search query. Leaving as-is for now with the documented caveat.

🤖 Generated by Coder Agents

});

const membersQuery = (data: unknown) => ({
key: organizationMembersKey(MockDefaultOrganization.id, {

@code-asher code-asher Apr 8, 2026

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.

Is this change from org members to users query correct? Group should only be querying org members I thought. For example when I check the "No Members" story, I do not see the expected error, instead it fails with 404. ("No Members" and "Filters By Members" are also broken.)

Old:

Image

New:

Image

const { entitlements } = useDashboard();
const searchParamsResult = useSearchParams();
const showAISeatColumn = shouldShowAISeatColumn(entitlements);
const [searchParams, setSearchParams] = useSearchParams();

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.

Duplicate of useSearchParams above, but we should delete the other one and use this deconstructed one!

<AddUsersPopover
isLoading={isAddingMember}
onSubmit={addMembers}
existingUserIds={new Set(members?.map((m) => m.user_id) ?? [])}

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.

Same problem here with members being paginated.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Same comment added in 7056295 — documented as best-effort UX optimization.

🤖 Generated by Coder Agents

const addableUsersQuery = useQuery({
...users({
q: prepareQuery(debouncedSearch),
limit: 50,

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.

just tagging the 50 here as well, idk if it really matters if we use 50 or 25 but probably makes sense to use the same number everywhere

paginatedOrganizationMembers(organizationName, searchParamsResult[0]),
);

const useFilterResult = useFilter({

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.

I have not looked into it yet but for some reason when I filter org members nothing changes 🤔

Oh wait might be due to the duplicate useSearchParams

- Fix duplicate useSearchParams in OrganizationMembersPage causing
  broken member filtering
- Normalize add-user search limit from 50 to 25 to match existing
  components (UserAutocomplete, MultiMemberSelect)
- Fix GroupPage stories to use organizationMembersKey instead of
  usersKey, and correct mock data shape
- Remove dead MultiUserSelect component and stories (no imports)
) => {
const base = [...getRootGroupQueryKey(organization, groupName), "members"];
const base = groupMembersByOrganizationQueryKey(organization, groupName);
return req ? [...base, req] : base;

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.

is it ok that there's a query key collision here?

};

export const addMembers = (queryClient: QueryClient, organization: string) => {
export const addMember = () => {

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.

could we specify the UseMutationOptions return type here?

Comment on lines +18 to +21
export type AddableUser = Pick<
User,
"id" | "username" | "name" | "email" | "avatar_url"
>;

@jeremyruppel jeremyruppel Apr 9, 2026

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.

the only difference between this and MinimalUser is the email field. thoughts on using that instead?

Comment on lines +179 to +193
<Button
type="button"
variant="outline"
onClick={() => {
handleOpenChange(false);
}}
>
Cancel
</Button>
<Button
type="button"
disabled={selectedCount === 0 || isLoading}
onClick={() => {
void handleAddUsers();
}}

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.

my preference would be to remove void here but at the very least we should add it to the other onClick and be consistent

- Use Promise.allSettled instead of Promise.all for batch member adds
  so partial failures don't leave the page in a broken state. When at
  least one add succeeds, always invalidate/refetch the member list.
- Remove inner border on AddUsersPopover user list (box-in-box visual)
- Add comments documenting pagination limitation for existingUserIds

🤖 Generated by Coder Agents
@jakehwll

Copy link
Copy Markdown
Contributor Author

Superseded by #24287 🙂

@jakehwll jakehwll closed this Apr 16, 2026
@github-actions github-actions Bot locked and limited conversation to collaborators Apr 16, 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.

4 participants