@vtex/faststore-plugin-buyer-portal 2.0.15 → 2.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (20) hide show
  1. package/CHANGELOG.md +13 -1
  2. package/package.json +1 -1
  3. package/specs/custom-roles-selector.md +337 -0
  4. package/src/features/roles/types/RolesData.ts +1 -0
  5. package/src/features/shared/components/AutocompleteDropdown/AutocompleteDropdown.tsx +6 -21
  6. package/src/features/shared/components/AutocompleteDropdown/useDropdownFlipPosition.ts +32 -0
  7. package/src/features/shared/components/index.ts +1 -0
  8. package/src/features/shared/utils/constants.ts +1 -1
  9. package/src/features/users/components/CreateUserDrawer/CreateUserDrawer.tsx +7 -31
  10. package/src/features/users/components/CreateUserDrawer/create-user-drawer.scss +1 -22
  11. package/src/features/users/components/RolesMultiSelect/RolesMultiSelect.tsx +312 -0
  12. package/src/features/users/components/RolesMultiSelect/__tests__/rolesMultiSelect.utils.test.ts +113 -0
  13. package/src/features/users/components/RolesMultiSelect/roles-multi-select.scss +150 -0
  14. package/src/features/users/components/RolesMultiSelect/rolesMultiSelect.utils.ts +48 -0
  15. package/src/features/users/components/UpdateUserDrawer/UpdateUserDrawer.tsx +56 -45
  16. package/src/features/users/components/UpdateUserDrawer/update-user-drawer.scss +1 -21
  17. package/src/features/users/components/UpdateUserDrawerWithUsername/update-user-drawer-with-username.scss +1 -21
  18. package/src/features/users/components/UserFormFields/UserFormFields.tsx +9 -47
  19. package/src/features/users/utils/__tests__/mapRoleIdsRoleNames.test.ts +49 -0
  20. package/src/features/users/utils/mapRoleIdsRoleNames.ts +17 -0
package/CHANGELOG.md CHANGED
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.0.16] - 2026-07-28
11
+
12
+ ### Added
13
+
14
+ - Custom roles now appear in the role selector alongside native roles in every create/edit user flow, with no client-side whitelist filtering the BFF's role list (`RoleData.isCustom?: boolean`)
15
+ - New `RolesMultiSelect` component: a searchable multi-select combobox (removable chips, inline search, keyboard navigation, minimum-one-role guard, above/below flip positioning) replacing the static role checkbox list in `UserFormFields`, `CreateUserDrawer`, and `UpdateUserDrawer`; custom roles are marked with a "Custom" tag in the dropdown
16
+
17
+ ### Fixed
18
+
19
+ - Role checkbox/list ids were generated with `.replace(" ", "-")`, which only replaced the first space and could produce malformed or colliding ids for role names with multiple spaces or special characters (e.g. merchant-defined custom roles); id generation now uses a global regex
20
+
10
21
  ## [2.0.15] - 2026-07-27
11
22
 
12
23
  ### Fixed
@@ -792,7 +803,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
792
803
  - Add CHANGELOG file
793
804
  - Add README file
794
805
 
795
- [unreleased]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v2.0.15...HEAD
806
+ [unreleased]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v2.0.16...HEAD
796
807
  [1.3.55]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v1.3.54...v1.3.55
797
808
  [1.3.54]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v1.3.53...v1.3.54
798
809
  [1.3.53]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v1.3.52...v1.3.53
@@ -890,6 +901,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
890
901
  [2.0.10]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v2.0.9...v2.0.10
891
902
  [2.0.9]: https://github.com/vtex/faststore-plugin-buyer-portal/releases/tag/2.0.9
892
903
 
904
+ [2.0.16]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v2.0.15...v2.0.16
893
905
  [2.0.15]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v2.0.14...v2.0.15
894
906
  [2.0.14]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v2.0.13...v2.0.14
895
907
  [2.0.13]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v2.0.12...v2.0.13
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vtex/faststore-plugin-buyer-portal",
3
- "version": "2.0.15",
3
+ "version": "2.0.16",
4
4
  "description": "A plugin for faststore with buyer portal",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -0,0 +1,337 @@
1
+ # Custom Roles in the Role Selector (Organization Account)
2
+
3
+ > **Status**: Done
4
+ > **Created**: 2026-07-22
5
+ > **References**: Jira [B2BTEAM-3388](https://vtex-dev.atlassian.net/browse/B2BTEAM-3388) (this ticket) · Epic [B2BTEAM-3385](https://vtex-dev.atlassian.net/browse/B2BTEAM-3385) (BFF `buyer-portal-graphql`) · BFF PR `vtex/buyer-portal-graphql#303` · Design Handoff & Prototype (Google Drive, linked from B2BTEAM-3388)
6
+
7
+ ---
8
+
9
+ ## 1. Business Context
10
+
11
+ ### Problem Statement
12
+
13
+ Merchants can now define **custom roles** in Identity (Storefront Roles) alongside VTEX's native roles. The Organization Account BFF (`buyer-portal-graphql`) already exposes these dynamically through its existing roles endpoints — the frontend in this repo has never hardcoded a role whitelist, so nothing is technically blocking custom roles from appearing. What's missing is:
14
+
15
+ 1. The `RoleData` type doesn't carry the `isCustom` flag the BFF will start returning, so the UI has no way to tell native and custom roles apart.
16
+ 2. The role picker itself is a plain static checkbox list (`@faststore/components` `CheckboxField`), which the design team has explicitly superseded with a searchable multi-select combobox (chips + inline search + dropdown) — the handoff states this component **replaces** the current role list in Organization Account's create/edit user flows.
17
+ 3. A longstanding id-generation bug (`role.roleName.toLowerCase().replace(" ", "-")`, non-global) silently produces malformed DOM ids for any role name with more than one space — a near-certainty for merchant-authored custom role names.
18
+
19
+ Without this work, a Buyer Admin who has configured a custom role in Identity has no way to assign it to a user from the Organization Account portal.
20
+
21
+ ### Goals
22
+
23
+ 1. Let Buyer Admins see and assign **any** role the BFF returns — native or custom — with zero client-side name filtering.
24
+ 2. Ship the multi-select combobox role selector specified in the design handoff (chips, inline search, dropdown listbox), replacing the static checkbox list across every create/edit user entry point in Organization Account.
25
+ 3. Visually distinguish custom roles from native ones in the picker.
26
+ 4. Fix the id/key generation bug so role names with any spacing or special characters render and behave correctly.
27
+ 5. Preserve the existing role-assignment payload contract (`{ userId, roles: [{ roleName, isActive }] }`) — no BFF or contract changes originate from this spec.
28
+
29
+ ### User Stories
30
+
31
+ #### US-1: Assign a custom role when creating a user
32
+
33
+ - **Story**: As a Buyer Admin, I want to select a merchant-defined custom role while creating a user, so that the user gets the exact permission set my organization configured.
34
+ - **Acceptance Criteria**:
35
+ - **Given** the "Create User" drawer is open and the BFF returned at least one role with `isCustom: true`, **when** the role field is opened, **then** the custom role appears in the dropdown alongside native roles, with no code-level filtering by name.
36
+ - **Given** the dropdown is open, **when** the Buyer Admin clicks a custom role's row, **then** it becomes selected, appears as a chip in the field, and remains selected if the dropdown is reopened.
37
+ - **Given** at least one custom role is selected, **when** the form is submitted, **then** the created user's role assignment includes that role's `roleName`.
38
+
39
+ #### US-2: Assign a custom role when editing a user
40
+
41
+ - **Story**: As a Buyer Admin, I want to add a custom role to an existing user, so that I can grant new permissions without recreating the user.
42
+ - **Acceptance Criteria**:
43
+ - **Given** the "Edit User" drawer is open for a user with only native roles, **when** the Buyer Admin selects a custom role and saves, **then** the update payload includes `{ roleName: "<custom role>", isActive: true }`.
44
+ - **Given** the update succeeds, **when** the drawer is reopened (or the page reloads) for that same user, **then** the custom role appears pre-selected (as a chip) reflecting the persisted state.
45
+
46
+ #### US-3: Unassign a custom role
47
+
48
+ - **Story**: As a Buyer Admin, I want to remove a custom role from a user, so that I can revoke permissions that are no longer needed.
49
+ - **Acceptance Criteria**:
50
+ - **Given** a user currently has a custom role assigned, **when** the Buyer Admin removes its chip (or unchecks it in the dropdown) and saves, **then** the update payload includes `{ roleName: "<custom role>", isActive: false }`.
51
+ - **Given** the removal is saved, **when** the drawer is reopened, **then** the custom role no longer appears selected.
52
+ - **Given** a user has only one role selected, **when** the Buyer Admin attempts to remove it (via chip or dropdown row), **then** the removal is blocked — at least one role must always remain selected.
53
+
54
+ #### US-4: Distinguish custom vs. native roles at a glance
55
+
56
+ - **Story**: As a Buyer Admin, I want to tell which roles are merchant-defined vs. native VTEX roles, so that I understand what I'm assigning.
57
+ - **Acceptance Criteria**:
58
+ - **Given** the dropdown is open, **when** a role has `isCustom: true`, **then** its row shows a small "Custom" tag next to the role name.
59
+ - **Given** a role has `isCustom: false` or the field is absent (native roles, or an older BFF response), **when** the row renders, **then** no "Custom" tag is shown.
60
+ - **Given** a custom role is selected, **when** it renders as a chip in the field, **then** the chip shows the role name only (no "Custom" tag on chips).
61
+
62
+ #### US-5: Search/filter a long role list
63
+
64
+ - **Story**: As a Buyer Admin managing an organization with many custom roles, I want to search the role list, so that I can find a specific role quickly instead of scrolling.
65
+ - **Acceptance Criteria**:
66
+ - **Given** the role field is focused, **when** the Buyer Admin types text, **then** the dropdown filters to roles whose name (or description, when present) contains the typed text, case-insensitive, updating on every keystroke.
67
+ - **Given** the typed text matches no role, **when** the dropdown renders, **then** it shows a "No matches" state instead of an empty list.
68
+ - **Given** the Buyer Admin clears the search text, **when** the dropdown re-renders, **then** the full role list is restored.
69
+
70
+ #### US-6: Legacy flow parity
71
+
72
+ - **Story**: As a Buyer Admin on a merchant still using the legacy (non-username) login flow, I want the same custom-role support, so that I'm not blocked from using custom roles just because my organization hasn't migrated to the newer login flow.
73
+ - **Acceptance Criteria**:
74
+ - **Given** `featureFlags.enableAlternativeLogin` is `false`, **when** the legacy `CreateUserDrawer` or `UpdateUserDrawer` is opened, **then** the same multi-select combobox (with custom-role support) is used — not the old inline checkbox list.
75
+ - **Given** the legacy flow, **when** a role name contains multiple spaces or special characters, **then** it renders and is selectable without generating a malformed DOM id.
76
+
77
+ ### Key Scenarios
78
+
79
+ | Scenario | Pre-conditions | Steps | Expected Result |
80
+ |---|---|---|---|
81
+ | Happy path — assign native + custom together | Create User drawer open; BFF returns 2 native + 1 custom role | Select "Buyer" (native), then "Report Viewer" (custom) | Both appear as chips; submit payload includes both roleNames |
82
+ | Happy path — search narrows results | Edit User drawer open; 8 roles available | Type "appro" in the field | Dropdown shows only "Order Approver"-like matches; other rows hidden |
83
+ | Error/guard — cannot remove the last role | User has exactly 1 role selected | Click that role's chip remove button, or click its dropdown row | Chip's remove control is hidden/disabled; dropdown row is visually muted and non-interactive; role stays selected |
84
+ | Edge case — multi-space custom role name | BFF returns a custom role named `"Regional Sales Lead"` (double spaces) | Open dropdown, select the role, inspect generated DOM id/key | Renders correctly, is selectable, and produces a well-formed id (no literal space in the id string) |
85
+ | Edge case — role with no description | BFF returns a custom role with `roleName` and `roleId` only (no description field, as is the current real contract) | Open dropdown | Row renders with name only; no blank/broken second line, no console error |
86
+ | Edge case — dropdown flip near drawer bottom | Field is positioned near the bottom of a scrolled drawer | Open the dropdown | Dropdown flips to open above the field instead of overflowing the drawer |
87
+ | Edge case — legacy flow with `enableAlternativeLogin=false` | Legacy CreateUserDrawer, custom role available | Open role field, select custom role, submit | Same behavior as the new-flow drawers; role assigned correctly |
88
+
89
+ ### Functional Requirements
90
+
91
+ **Data source**
92
+ - The role list is read exclusively from `getRolesAndPermissionsIds` (`GET .../roles/ids`) — no hardcoded role name list or whitelist filters the result at any point.
93
+ - `RoleData` gains an optional `isCustom?: boolean`. Its absence (older BFF response, or a native role that omits it) must be treated as "not custom" — never as an error.
94
+
95
+ **Component anatomy** (per design handoff)
96
+ - A field with a floating "Roles" label; the entire field is clickable; no trailing icons.
97
+ - Selected roles render as removable chips inside the field.
98
+ - An inline search input shares the same field, to the right of the last chip.
99
+ - A dropdown listbox anchored to the field, opened on click/focus/Tab.
100
+
101
+ **Behavior**
102
+ - Opening: clicking anywhere in the field opens the dropdown and focuses the search input; Tab into the field also opens it.
103
+ - Filtering: case-insensitive, matches role name (and description when present); updates live as the user types; keeps selected chips visible while filtering; restores the full list when the search is cleared.
104
+ - Selection: clicking a dropdown row toggles that role; the dropdown stays open after a selection so multiple roles can be picked in one interaction.
105
+ - Minimum-selection rule: at least one role must always stay selected. When exactly one role is selected, that role's chip-remove control and its dropdown row both become non-interactive (visually muted).
106
+ - Removal: via chip remove button, via clicking the already-selected dropdown row, or via Backspace when the search input is empty (removes the most recently selected role; only when more than one role is selected).
107
+ - Keyboard: ArrowDown/ArrowUp move the highlighted row; Enter toggles the highlighted row; Escape closes the dropdown, clears the search text, and returns focus to the field; current selections are preserved on close.
108
+ - Positioning: dropdown opens below the field by default, flips above when there isn't enough room, and stays fully within the drawer's viewport (recalculated on open and on resize/scroll) — not the full browser window, since this field lives inside a side drawer.
109
+ - "Custom" indicator: a small tag next to the role name, in the dropdown row only, shown when `isCustom` is `true`. Not shown on chips.
110
+ - Missing description: if a role has no description, its dropdown row renders with the name only — no blank line, no layout shift, no error.
111
+
112
+ **Assignment payload**
113
+ - Unchanged: `updateRoles` continues to send `{ userId, roles: [{ roleName, isActive }] }`. This spec does not alter the BFF contract.
114
+
115
+ **id/key generation**
116
+ - Any DOM id or React key derived from a role name must use a global replace (e.g. `/[^a-z0-9]+/gi` or at minimum `/ /g`) so role names with multiple spaces or special characters never produce a malformed id or a key collision.
117
+
118
+ **Scope of replacement**
119
+ - The new component replaces the existing checkbox-based role UI in all three integration points in this repo:
120
+ 1. `UserFormFields` (shared by `CreateUserDrawerWithUsername` and `UpdateUserDrawerWithUsername`)
121
+ 2. `CreateUserDrawer` (legacy flow)
122
+ 3. `UpdateUserDrawer` (legacy flow)
123
+
124
+ ### Non-Functional Requirements
125
+
126
+ - **Accessibility**: dropdown exposed as a listbox; options announced with their selection state; chip-remove buttons individually focusable and announced; role descriptions available to assistive technology as supplementary (not primary) content; full keyboard operability (Tab, Escape, ArrowUp/Down, Enter, Backspace).
127
+ - **Resilience**: the component must never crash or blank-render when `isCustom` or a description is absent from the API response — both are optional and must degrade gracefully.
128
+ - **Test coverage**: unit/component tests for the id-generation fix (multi-space and special-character role names), the "Custom" tag rendering (present/absent), and the component's core behavior (selection, minimum-one-role guard, keyboard navigation, search filtering) — using mocked `RoleData`, with no dependency on a live BFF or a real Identity-configured custom role.
129
+ - **No new feature flag**: the change ships unconditionally. The BFF contract change is additive (more list items + an optional field), so there's no unsafe state for merchants without custom roles configured.
130
+ - **No new localization key**: the "Custom" tag and the field's "Roles" label are hardcoded English strings for this iteration; they do not go through the CMS-backed `t()` localization system (see Key Decision 6).
131
+
132
+ ### Out of Scope
133
+
134
+ - Any BFF/backend changes — covered by Epic B2BTEAM-3385 / PR `vtex/buyer-portal-graphql#303`, assumed merged and available.
135
+ - Contract Manager / Admin UI (`vtex/contracts-management` repo, Jira B2BTEAM-3386) — separate repository, separate ticket, different design system (Shoreline).
136
+ - Creating roles or permissions in Identity.
137
+ - Displaying a custom role's resources/permissions in the role-details page (`RolesLayout` / `RoleDetailsLayout`) — unchanged by this spec; a candidate for a future, separate task.
138
+ - Cypress e2e coverage — would require a real custom role seeded via Identity in `b2bfaststoredev.store`, which is outside this repo's control; manual QA against a real merchant remains the validation path for the ticket's "Validação" checklist.
139
+ - The hardcoded `USER_ROLES.SUPER_USER = "Super Buyer Admin"` check in `OrgUnitBreadcrumb.tsx` — a pre-existing, unrelated coupling to a role name (breadcrumb hierarchy visibility, not role selection). Left untouched; noted as a related risk.
140
+ - Adding `isCustom` awareness to the already-existing role-name `<Tag>` pills in `UsersLayout.tsx` / `UserDetailsLayout.tsx` (which show a user's *already-assigned* roles) — those views only have role names today, not `RoleData`; out of scope for this ticket.
141
+ - Adding a real CMS localization entry for the new "Custom"/"Roles" strings.
142
+
143
+ ---
144
+
145
+ ## 2. Arch Decisions
146
+
147
+ ### Proposed Solution
148
+
149
+ Build one new shared component — a role multi-select field — in `src/features/users/components/`, and use it from all three integration points (`UserFormFields`, `CreateUserDrawer`, `UpdateUserDrawer`), replacing their respective `CheckboxField`/inline-checkbox blocks. Rather than building the combobox from scratch, extend this repo's existing `AutocompleteDropdown` (which already implements the open/close, keyboard nav, and above/below flip-positioning logic needed) into a multi-select variant, and reuse `@faststore/components`'s `Tag` (which already supports a removable-chip pattern via `onClose`) for the selected-role chips. The "Custom" indicator reuses this repo's own lightweight `Tag` (`src/features/shared/components/Tag`), already used elsewhere for role-name pills.
150
+
151
+ `RoleData` gains `isCustom?: boolean`. Selection state stays `roleId`-based (`number[]`), matching the existing `UserFormState.roles` shape — no change to how the assignment payload is built downstream (`roleId → roleName` translation already happens before submit).
152
+
153
+ ### Architecture Overview
154
+
155
+ ```mermaid
156
+ flowchart TD
157
+ BFF["BFF: GET .../roles/ids\n{ roleId, roleName, isCustom? }"] --> Hook[useGetRolesOptions]
158
+ Hook --> RoleData["RoleData[] (rolesOptions)"]
159
+
160
+ RoleData --> UFF[UserFormFields]
161
+ RoleData --> CUD[CreateUserDrawer legacy]
162
+ RoleData --> UUD[UpdateUserDrawer legacy]
163
+
164
+ UFF --> RS1[RolesMultiSelect]
165
+ CUD --> RS2[RolesMultiSelect]
166
+ UUD --> RS3[RolesMultiSelect]
167
+
168
+ RS1 --> Chips["Selected chips (Tag + onClose)"]
169
+ RS1 --> Dropdown["Dropdown listbox (checkbox + name + optional description + Custom tag)"]
170
+ Dropdown --> Position[useAutocompletePosition-style flip/positioning]
171
+
172
+ RS1 -->|"onChange(roleId[])"| FormState["UserFormState.roles: number[]"]
173
+ FormState --> Submit["roleId -> roleName translation (existing)"]
174
+ Submit -->|"{ userId, roles: [{ roleName, isActive }] }"| UpdateRoles["POST .../users/roles"]
175
+ ```
176
+
177
+ ### Alternatives Considered
178
+
179
+ | Alternative | Pros | Cons | Verdict |
180
+ |---|---|---|---|
181
+ | Minimal patch: keep the checkbox list, just add an `isCustom` text suffix and fix the id bug | Smallest possible diff; lowest risk | Directly contradicts the design handoff, which explicitly states the new component *replaces* the current role list; ships a UI the design/product side didn't ask for | Rejected |
182
+ | Adopt a third-party combobox library (e.g. `downshift`, `react-select`) | Less code to write and maintain in-house; battle-tested a11y | New dependency with bundle-size cost; doesn't out-of-the-box solve drawer-scoped positioning/flip; this repo already has a working in-house pattern (`AutocompleteDropdown`) that solves the hard parts | Rejected |
183
+ | Extend `AutocompleteDropdown` in place to support multi-select natively (add a `multiple` prop) | Single source of truth for all dropdown-style fields in the repo | `AutocompleteDropdown` is used elsewhere for single-select (e.g. `OrgUnitInputSearch`); overloading it with chip-rendering and a different selection model risks regressing existing single-select consumers | Rejected — build a sibling component that reuses the same positioning/keyboard building blocks instead of overloading the existing one |
184
+ | New shared `RolesMultiSelect` composing existing primitives (`AutocompleteDropdown`'s positioning approach + `Tag`) | Reuses proven positioning/keyboard logic; isolated blast radius; matches design handoff scope exactly | One more component in the tree | **Accepted** |
185
+
186
+ ### Risks & Mitigations
187
+
188
+ | Risk | Impact | Likelihood | Mitigation |
189
+ |---|---|---|---|
190
+ | BFF PR `#303` not yet merged/available when this ships | High — no custom roles to test against in a live environment | Medium | Contract is additive; `isCustom` is optional and code treats its absence as "native." Development/testing can proceed against mocked `RoleData` regardless of BFF deploy timing. |
191
+ | New interactive component (keyboard nav, chip removal, positioning) ships with only unit/component test coverage, no e2e | Medium — real-environment regressions (positioning inside the actual drawer, real screen readers) could slip through | Medium | Thorough component tests per the Non-Functional Requirements; the ticket's "Validação" checklist is run manually against a merchant with a real custom role before considering the ticket done. |
192
+ | Legacy drawer flow (`enableAlternativeLogin=false`) regresses since it's touched alongside the newer flow | Medium — some merchants still run this flow | Low–Medium | Both legacy drawers are explicitly in scope (US-6) and get the same component and the same test coverage as the new flow. |
193
+ | Hardcoded "Custom"/"Roles" strings ship without localization | Low — cosmetic, English-only for non-English storefronts | High (accepted trade-off) | Explicitly scoped out of this iteration (Key Decision 6); flagged here so it isn't mistaken for an oversight. |
194
+ | `USER_ROLES.SUPER_USER` hardcoded role-name check elsewhere in the codebase remains unaddressed | Low–Medium — a merchant renaming/removing that specific native role could silently break unrelated breadcrumb visibility logic | Low | Explicitly out of scope; documented here as a known, pre-existing risk unrelated to this spec's changes. |
195
+
196
+ ### Key Decisions
197
+
198
+ #### Decision 1: No role-level "description" from the BFF — render gracefully without it
199
+
200
+ - **Status**: Accepted
201
+ - **Context**: The design handoff's dropdown row shows an optional description under the role name. The confirmed BFF contract (`getRolesAndPermissionsIds` → `{ roleId, roleName, isCustom? }`) carries no description field, and `getRolesAndPermissions`'s `RolePermission.description` describes a *permission*, not a role.
202
+ - **Decision**: Render dropdown rows with name-only for now; the description slot is supported by the component (single line, truncated) but simply won't render until/unless the BFF adds a role-level description field in a future iteration.
203
+ - **Consequences**: Matches the current real contract exactly; no risk of ever rendering a blank second line or throwing on `undefined.description`. Revisit once/if the BFF adds this field.
204
+
205
+ #### Decision 2: "Custom" indicator only in the dropdown row, not on chips
206
+
207
+ - **Status**: Accepted
208
+ - **Context**: The design prototype's own mock data (`ROLE_OPTIONS`) has no concept of `isCustom` at all — the handoff doesn't specify where this indicator goes.
209
+ - **Decision**: Show a small "Custom" tag next to the role name in the dropdown row when `isCustom` is `true`. Selected chips remain name-only to keep the field visually uncluttered.
210
+ - **Consequences**: Keeps the always-visible chip row simple; the distinction is available at the moment a Buyer Admin is choosing roles, which is when it matters most.
211
+
212
+ #### Decision 3: One shared component replaces all three duplicated integration points
213
+
214
+ - **Status**: Accepted
215
+ - **Context**: Today, role-checkbox rendering is duplicated three ways: `UserFormFields` (shared by the two `*WithUsername` drawers) and separately inlined in each of the two legacy drawers.
216
+ - **Decision**: Build a single new component and use it from all three call sites, rather than adding a fourth slightly-different implementation or patching each of the three independently.
217
+ - **Consequences**: Eliminates ongoing duplication instead of adding to it; a future behavior change (e.g. adding the description field once the BFF supports it) only needs to happen in one place.
218
+
219
+ #### Decision 4: Ship unconditionally — no new feature flag
220
+
221
+ - **Status**: Accepted
222
+ - **Context**: This repo has an established feature-flag pipeline (FeatureHub → BFF → type → normalization) used for flags like `enableAlternativeLogin`.
223
+ - **Decision**: This change ships without a new flag. The BFF contract change is additive (more list items, one optional field), so there's no unsafe intermediate state — merchants without custom roles configured simply never see `isCustom: true` entries.
224
+ - **Consequences**: Faster rollout, less pipeline overhead. If a rollback is ever needed, it's a standard revert rather than a flag flip.
225
+
226
+ #### Decision 5: Unit/component tests only — no new Cypress e2e coverage
227
+
228
+ - **Status**: Accepted
229
+ - **Context**: The ticket's "Validação" checklist (custom role appears in selector, persists after reload, combines with native roles, special-character names work) reads as manual QA steps against a real merchant with a custom role configured via Identity.
230
+ - **Decision**: Add unit/component tests for the component's own logic (selection, keyboard nav, minimum-role guard, id-gen fix, `isCustom` rendering) using mocked data. Do not add Cypress e2e for the custom-role scenarios, since that would require seeding a real custom role in `b2bfaststoredev.store` via Identity — outside this repo's control.
231
+ - **Consequences**: The manual "Validação" checklist remains the acceptance gate for true end-to-end custom-role behavior; automated coverage protects the component's own logic against regressions.
232
+
233
+ #### Decision 6: Hardcode new UI strings; skip the `t()` localization system for this iteration
234
+
235
+ - **Status**: Accepted
236
+ - **Context**: This repo's `t(key, interpolations?)` localization function resolves labels from a remote CMS-backed catalog and has no fallback-string parameter — a missing key renders the raw key string (e.g., literally `"roles.labels.custom"`) rather than degrading gracefully.
237
+ - **Decision**: Hardcode the English strings "Custom" and "Roles" directly in the component rather than routing them through `t()`, since the corresponding CMS keys don't exist yet and there's no safe fallback mechanism to lean on.
238
+ - **Consequences**: These two strings won't be localized until a follow-up adds the CMS keys and switches the calls to `t()`. Documented explicitly so it isn't mistaken for an oversight.
239
+
240
+ #### Decision 7: `USER_ROLES.SUPER_USER` hardcoded check is out of scope
241
+
242
+ - **Status**: Accepted
243
+ - **Context**: `src/features/users/types/UserRoles.ts` hardcodes `SUPER_USER = "Super Buyer Admin"`, checked in `OrgUnitBreadcrumb.tsx` to grant full hierarchy visibility — a real hardcoded-role-name dependency, but unrelated to the role *selector* this spec targets.
244
+ - **Decision**: Leave it untouched. This spec's "no hardcoded role name" requirement applies to the role list/selector data path, not to this separate breadcrumb-visibility feature.
245
+ - **Consequences**: The risk (a merchant renaming/removing that specific role could silently affect breadcrumb visibility) persists and is documented in the Risks table above for future consideration.
246
+
247
+ ### Implementation Plan
248
+
249
+ 1. **Type update** — add `isCustom?: boolean` to `RoleData` (`src/features/roles/types/RolesData.ts`).
250
+ 2. **Build the shared component** — new `RolesMultiSelect`-style component (naming finalized during implementation) composing selected-role chips (`@faststore/components` `Tag` + `onClose`), an inline search input, and a dropdown listbox reusing this repo's existing flip/positioning approach (`AutocompleteDropdown`/`useAutocompletePosition` as the base pattern). Include the "Custom" tag (this repo's `Tag`) in dropdown rows and the corrected global-regex id/key generation.
251
+ 3. **Wire into `UserFormFields`** — replace its `CheckboxField`-based block with the new component (serves both `CreateUserDrawerWithUsername` and `UpdateUserDrawerWithUsername`).
252
+ 4. **Wire into the legacy drawers** — replace the inlined checkbox JSX in `CreateUserDrawer` and `UpdateUserDrawer` with the same new component.
253
+ 5. **Unit/component tests** — id-generation edge cases (multi-space, special characters), `isCustom` tag presence/absence, missing-description graceful rendering, selection/keyboard/minimum-role-guard behavior.
254
+ 6. **Manual QA** — run the ticket's "Validação" checklist against a merchant with a real custom role configured via Identity (native + custom combination, link/unlink + reload, long/special-character custom role name).
255
+
256
+ ---
257
+
258
+ ## 3. Technical Contract
259
+
260
+ ### Data Models
261
+
262
+ ```typescript
263
+ // src/features/roles/types/RolesData.ts
264
+ export interface RoleData {
265
+ roleName: string;
266
+ roleId: number;
267
+ isCustom?: boolean; // NEW — optional; absence means "not custom"
268
+ }
269
+
270
+ // Unchanged
271
+ export interface RolePermission {
272
+ label: string;
273
+ description: string;
274
+ inRole: boolean;
275
+ }
276
+
277
+ export interface UserRoles {
278
+ roleName: string;
279
+ isActive: boolean;
280
+ }
281
+
282
+ export interface UpdateUserRoles {
283
+ userId: string;
284
+ roles: UserRoles[];
285
+ }
286
+
287
+ // Unchanged — src/features/users/components/UserFormFields/UserFormFields.tsx
288
+ export type UserFormState = {
289
+ login: string;
290
+ name: string;
291
+ email: string;
292
+ phone: string;
293
+ transactionalEmail: string;
294
+ useLoginAsTransactional: boolean;
295
+ roles: number[]; // roleId[] — no shape change
296
+ };
297
+ ```
298
+
299
+ ### Interfaces
300
+
301
+ ```typescript
302
+ // New shared component (path/name finalized during implementation,
303
+ // e.g. src/features/users/components/RolesMultiSelect/RolesMultiSelect.tsx)
304
+ interface RolesMultiSelectProps {
305
+ /** Full list of assignable roles for this account, from getRolesAndPermissionsIds. */
306
+ options: RoleData[] | null;
307
+ /** Currently selected role ids. */
308
+ selected: number[];
309
+ /** Called with the full next selection whenever a role is toggled or removed. */
310
+ onChange: (roleIds: number[]) => void;
311
+ /** Shows skeleton placeholders while `options`/`selected` are loading. */
312
+ isLoading?: boolean;
313
+ /** Distinguishes create vs edit for existing data-fs-bp-* test-id conventions. */
314
+ mode: "create" | "edit";
315
+ }
316
+ ```
317
+
318
+ ### Integration Points
319
+
320
+ | Integration point | Current state | Expected change |
321
+ |---|---|---|
322
+ | `UserFormFields` | Renders `rolesOptions.map(...)` as `CheckboxField` rows, non-global id regex | Renders `<RolesMultiSelect options={rolesOptions} selected={form.roles} onChange={...} />` |
323
+ | `CreateUserDrawer` (legacy) | Inlines its own checkbox JSX, non-global id regex | Renders the same `RolesMultiSelect` component |
324
+ | `UpdateUserDrawer` (legacy) | Inlines its own checkbox JSX, non-global id regex | Renders the same `RolesMultiSelect` component |
325
+ | `getRolesAndPermissionsIds` client/service/hook (`RolesClient.ts`, `get-roles-ids.service.ts`, `useGetRolesOptions.ts`) | Returns `RoleData[]` without `isCustom` | No code change required — additive BFF field flows through automatically once the type is updated |
326
+ | `updateRoles` (`useUpdateRoles`, `update-roles.service.ts`) | Sends `{ userId, roles: [{ roleName, isActive }] }` | **Unchanged** — this spec does not touch the assignment payload |
327
+ | `RolesLayout` / `RoleDetailsLayout` | Renders roles map dynamically, correct global-regex slugging already | **Unchanged** by this spec |
328
+
329
+ ### Invariants & Constraints
330
+
331
+ 1. **No client-side role filtering**: the role list rendered in the selector must be exactly what `getRolesAndPermissionsIds` returns — no name-based whitelist, blacklist, or hardcoded set may filter it.
332
+ 2. **`isCustom` absence means native**: any code reading `role.isCustom` must treat `undefined` the same as `false`. Never assume the field is always present.
333
+ 3. **Minimum one role always selected**: `onChange` must never be called with an empty array through the component's own UI affordances (chip remove, dropdown row click, Backspace) — the last remaining role's removal controls are disabled/non-interactive instead.
334
+ 4. **Assignment payload is `roleName`-keyed and unchanged**: `{ userId, roles: [{ roleName, isActive }] }` — this spec introduces no new payload shape; `roleId → roleName` translation happens exactly where it does today, before submit.
335
+ 5. **No crash on missing description**: the component must render correctly whether or not a role object carries a description-like field; a future BFF addition of a description must not require this invariant to change.
336
+ 6. **id/key generation is collision-safe and space-safe**: any derived DOM id or list key must use a global replace over the full role name (not a first-match-only replace), so names with multiple spaces or special characters never collide or contain literal illegal characters.
337
+ 7. **This spec makes no BFF/contract changes**: all endpoint behavior described here (the additive `isCustom` field, more list items) is assumed already delivered by Epic B2BTEAM-3385 / PR `#303`; this repo only consumes it.
@@ -17,4 +17,5 @@ export interface UpdateUserRoles {
17
17
  export interface RoleData {
18
18
  roleName: string;
19
19
  roleId: number;
20
+ isCustom?: boolean;
20
21
  }
@@ -15,6 +15,7 @@ import { Icon } from "../Icon";
15
15
  import { InputText } from "../InputText/InputText";
16
16
 
17
17
  import { AutocompleteDropdownItem } from "./AutocompleteDropdownItem";
18
+ import { useDropdownFlipPosition } from "./useDropdownFlipPosition";
18
19
 
19
20
  export type AutocompleteDropdownContextProps = {
20
21
  focusedItemIndex: number;
@@ -70,7 +71,6 @@ export const AutocompleteDropdown = <T,>({
70
71
  const [isOpened, setIsOpened] = useState(false);
71
72
 
72
73
  const [focusedItemIndex, setFocusedItemIndex] = useState(0);
73
- const [position, setPosition] = useState<"top" | "bottom">("bottom");
74
74
 
75
75
  const autocompleteMenuRef = useRef<HTMLDivElement>(null);
76
76
  const wrapperAutocompleteRef = useRef<HTMLDivElement>(null);
@@ -79,6 +79,11 @@ export const AutocompleteDropdown = <T,>({
79
79
  isOpened,
80
80
  wrapperAutocompleteRef
81
81
  );
82
+ const position = useDropdownFlipPosition(
83
+ isOpened,
84
+ wrapperAutocompleteRef,
85
+ autocompleteMenuRef
86
+ );
82
87
  const handleClick = () => {
83
88
  if (!disabled && !shouldCloseOnSelect) {
84
89
  setIsOpened((old) => !old);
@@ -194,26 +199,6 @@ export const AutocompleteDropdown = <T,>({
194
199
  }
195
200
  }, [value]);
196
201
 
197
- useEffect(() => {
198
- if (
199
- !isOpened ||
200
- !autocompleteMenuRef.current ||
201
- !wrapperAutocompleteRef.current
202
- ) {
203
- return;
204
- }
205
-
206
- const { bottom, top } =
207
- wrapperAutocompleteRef.current.getBoundingClientRect();
208
- const dropdownHeight = autocompleteMenuRef.current.offsetHeight + 4; // --fs-spacing-0: 4px
209
-
210
- const spaceBelow = window.innerHeight - bottom;
211
-
212
- setPosition(
213
- spaceBelow < dropdownHeight && top > dropdownHeight ? "top" : "bottom"
214
- );
215
- }, [isOpened]);
216
-
217
202
  return (
218
203
  <AutocompleteDropdownContext.Provider
219
204
  value={{
@@ -0,0 +1,32 @@
1
+ import { useEffect, useState, type RefObject } from "react";
2
+
3
+ /**
4
+ * Decides whether a dropdown menu should open below ("bottom") or above
5
+ * ("top") its trigger, based on available viewport space. Shared by
6
+ * AutocompleteDropdown and RolesMultiSelect so the flip calculation isn't
7
+ * duplicated between them.
8
+ */
9
+ export const useDropdownFlipPosition = (
10
+ isOpen: boolean,
11
+ triggerRef: RefObject<HTMLElement>,
12
+ menuRef: RefObject<HTMLElement>,
13
+ deps: unknown[] = []
14
+ ): "top" | "bottom" => {
15
+ const [position, setPosition] = useState<"top" | "bottom">("bottom");
16
+
17
+ useEffect(() => {
18
+ if (!isOpen || !menuRef.current || !triggerRef.current) return;
19
+
20
+ const { bottom, top } = triggerRef.current.getBoundingClientRect();
21
+ const dropdownHeight = menuRef.current.offsetHeight + 4; // --fs-spacing-0: 4px
22
+ const spaceBelow = window.innerHeight - bottom;
23
+
24
+ setPosition(
25
+ spaceBelow < dropdownHeight && top > dropdownHeight ? "top" : "bottom"
26
+ );
27
+ // triggerRef/menuRef are stable refs; isOpen plus caller-supplied deps
28
+ // (e.g. filtered item count) are what should trigger a recompute.
29
+ }, [isOpen, ...deps]);
30
+
31
+ return position;
32
+ };
@@ -9,6 +9,7 @@ export {
9
9
  type AutocompleteDropdownProps,
10
10
  } from "./AutocompleteDropdown/AutocompleteDropdown";
11
11
  export { useAutocompletePosition } from "./AutocompleteDropdown/useAutocompletePosition";
12
+ export { useDropdownFlipPosition } from "./AutocompleteDropdown/useDropdownFlipPosition";
12
13
  export { BasicCard, type BasicCardProps } from "./BasicCard/BasicCard";
13
14
  export { BasicDrawer, type BasicDrawerProps } from "./BasicDrawer/BasicDrawer";
14
15
  export {
@@ -22,4 +22,4 @@ export const SCOPE_KEYS = {
22
22
  CREDIT_CARDS: "creditCards",
23
23
  } as const;
24
24
 
25
- export const CURRENT_VERSION = "2.0.15";
25
+ export const CURRENT_VERSION = "2.0.16";
@@ -23,6 +23,7 @@ import {
23
23
  normalizePhoneNumber,
24
24
  } from "../../../shared/utils/phoneNumber";
25
25
  import { useAddUserToOrgUnit } from "../../hooks";
26
+ import { RolesMultiSelect } from "../RolesMultiSelect/RolesMultiSelect";
26
27
 
27
28
  export type CreateUserDrawerProps = Omit<BasicDrawerProps, "children"> & {
28
29
  onCreate?: () => void;
@@ -287,37 +288,12 @@ export const CreateUserDrawer = ({
287
288
  />
288
289
 
289
290
  <div data-fs-bp-create-user-roles>
290
- <span data-fs-bp-create-user-roles-label>
291
- {t("users.forms.roles")}
292
- </span>
293
-
294
- {rolesOptions &&
295
- rolesOptions.map((role) => {
296
- const id = `role-${role.roleName
297
- .toLowerCase()
298
- .replace(" ", "-")}`;
299
- return (
300
- <span
301
- data-fs-bp-create-user-role-wrapper
302
- key={role.roleName}
303
- >
304
- <input
305
- type="checkbox"
306
- key={role.roleName}
307
- value={role.roleName}
308
- id={id}
309
- checked={roles?.includes(role.roleId)}
310
- onChange={(event) => {
311
- const newRoles = event.target.checked
312
- ? [...(roles ?? []), role.roleId]
313
- : roles?.filter((r) => r !== role.roleId) ?? [];
314
- updateField("roles", newRoles);
315
- }}
316
- />
317
- <label htmlFor={id}>{role.roleName}</label>
318
- </span>
319
- );
320
- })}
291
+ <RolesMultiSelect
292
+ mode="create"
293
+ options={rolesOptions}
294
+ selected={roles ?? []}
295
+ onChange={(newRoles) => updateField("roles", newRoles)}
296
+ />
321
297
  </div>
322
298
  </>
323
299
  )}
@@ -3,7 +3,7 @@
3
3
  [data-fs-bp-create-user-drawer] {
4
4
  @import "../../../shared/components/InputText/input-text.scss";
5
5
  @import "../../../shared/components/ErrorMessage/error-message.scss";
6
- @import "@faststore/ui/src/components/molecules/CheckboxField/styles.scss";
6
+ @import "../RolesMultiSelect/roles-multi-select.scss";
7
7
 
8
8
  [data-fs-bp-create-user-drawer-back-icon] {
9
9
  vertical-align: middle;
@@ -13,29 +13,8 @@
13
13
  }
14
14
 
15
15
  [data-fs-bp-create-user-roles] {
16
- display: flex;
17
- flex-direction: column;
18
- gap: var(--fs-spacing-2);
19
16
  margin-bottom: var(--fs-spacing-6);
20
17
  margin-top: var(--fs-spacing-5);
21
-
22
- [data-fs-bp-create-user-roles-label] {
23
- font-weight: var(--fs-text-weight-regular);
24
- font-size: var(--fs-text-size-1);
25
- line-height: var(--fs-text-size-3);
26
- }
27
-
28
- [data-fs-bp-create-user-role-wrapper] {
29
- input {
30
- margin-right: var(--fs-spacing-1);
31
- }
32
-
33
- label {
34
- font-weight: var(--fs-text-weight-regular);
35
- font-size: var(--fs-text-size-1);
36
- line-height: var(--fs-text-size-3);
37
- }
38
- }
39
18
  }
40
19
 
41
20
  [data-fs-bp-roles-error-container] {