Skip to content

fix(tabs): consolidate isDirty marker and decouple URL sync from Tabs#7387

Draft
talissoncosta wants to merge 4 commits intorefactor/tabs-cleanupfrom
refactor/tabs-isdirty
Draft

fix(tabs): consolidate isDirty marker and decouple URL sync from Tabs#7387
talissoncosta wants to merge 4 commits intorefactor/tabs-cleanupfrom
refactor/tabs-isdirty

Conversation

@talissoncosta
Copy link
Copy Markdown
Contributor

Thanks for submitting a PR! Please check the boxes below:

  • I have read the Contributing Guide.
  • I have added information to `docs/` if required so people know about the feature.
  • I have filled in the "Changes" section below.
  • I have filled in the "How did you test this code" section below.

Changes

Closes #6935. Contributes to #6606 (Design System Audit). Stacked on top of #7386.

This PR consolidates the unsaved-changes badge into Tabs, then takes the rest of the API down to its essentials by moving URL/router concerns out into a hook. `Tabs` is now a pure presentational component (`value` + `onChange`), and consumers compose state in via `useState` or the new `useTabUrlSync` hook.

Closes #6935 — `isDirty` on `TabItem`

The unsaved-changes badge was reimplemented at every call site (`

*
` etc.), and one of those reimplementations caused #6935: the badge overflowed below the active-tab underline and stayed visible on inactive tabs.

  • Add `isDirty?: boolean` to `TabItem`. The `TabButton` wraps label + badge in an inline-flex `.btn-tab__label` container so they always stay on the same line and align vertically (port of the wrapper from fix(tabs): alignment issues with 'unsaved changes' badge in Tabs component #7038 — credit @Zaimwa9 for the original investigation).
  • Migrate the seven badged sites in `CreateGroup`, `CreateSegment` (×2), `CreateFeature` (×3), and `create-experiment` to `isDirty`, dropping the local ``/`
    `/`unread` JSX in favour of plain string `tabLabel`s.
  • New `WithDirtyMarker` story for Chromatic.

Decouple URL/router from Tabs

  • New hook `common/hooks/useTabUrlSync.ts`. Reads/writes a URL search param, slugifies labels, falls back to `window.history.replaceState` when called outside a Router (modal portals via `openModal`).
  • Migrate 16 function-component consumers from `urlParam` / `uncontrolled` to controlled ``:
    • URL-bound (10): AdminDashboardPage, ImportPage, UsersAndPermissionsPage, ChangeRequestsPage, FeatureHistoryDetailPage, EnvironmentSettingsPage, OrganisationSettingsPage, ProjectSettingsPage, CreateSegment, CreateFeature.
    • `useState` (6): AccountSettingsPage, CreateGroup, CreateSAML, EnvironmentDocumentCodeHelp, DiffSegmentOverrides.
  • Convert ComparePage class → function component using the hook.
  • Extract `OrganisationUsersTable`'s two `openModal` JSX trees into proper FCs (`EditUserPermissionsModal`, `InspectUserPermissionsModal`) so the Tabs inside can use `useState`.
  • `PermissionsTabs` and `InspectPermissions` drop their own pass-through `uncontrolled` prop; `value` and `onChange` are now required, and consumers wire up local state.
  • Drop `uncontrolled` from `Tabs` entirely. Tabs is purely controlled now. `urlParam`, `history`, `tabLabelString`, and `isRoles` remain on the type but are marked `@deprecated` with JSDoc pointing at the hook.
  • New `WithUrlSync`, `Controlled`, and `HideNavOnSingleTab` stories.

Left for follow-up

  • `create-experiment/index.js` is a class component, gated by the `experimental_flags` Flagsmith flag. Stays on the legacy `urlParam` path. To be migrated when that flag promotes or the modal is converted to a function component.
  • Once that lands, the `@deprecated` props (`urlParam`, `history`, `tabLabelString`, `isRoles`) can be physically removed from Tabs.

Supersedes #7038

#7038 was the surgical fix for #6935 by @Zaimwa9. Most of its diff is now redundant after this stack (the `` wrappers are gone, badge wrapper lives in TabButton). Plan to close #7038 with a comment pointing here once this lands.

How did you test this code?

Manually verified:

  • Badge behaviour: opened Edit Feature modal → toggled values on Value / Segment Overrides / Settings tabs → `*` aligns inline, doesn't overflow underline, doesn't persist on inactive tabs.
  • URL sync: navigated each migrated page (Admin Dashboard, Environment Settings, Project Settings, Org Settings, Change Requests, Users & Permissions, Feature History, Import) → tab switching updates the URL param, deep-linking with `?tab=foo` opens the right tab, browser back/forward works.
  • Modals: `openModal` flow for `OrganisationUsersTable`'s Edit Permissions / Inspect Permissions still works (Tabs render and switch).
  • Stories: ran `npm run storybook` → `Default`, `PillTheme`, `WithDirtyMarker`, `HideNavOnSingleTab`, `WithUrlSync`, and `Controlled` all render correctly.
  • Typecheck error count went down by ~13 over baseline (only known new errors are pre-existing tech debt in adjacent files).
  • Lint passes on all modified files (lint-staged hook clean).

talissoncosta and others added 4 commits April 30, 2026 14:38
Closes #6935.

The unsaved-changes badge was reimplemented at every call site as a JSX
tabLabel containing a `<div className='unread'>*</div>` next to the text.
Two problems with that:
- Each site had its own slightly different wrapper (Row, div, varying
  margin/padding classes), and one of them caused #6935: the badge
  overflowed below the active-tab underline and stayed visible on
  inactive tabs.
- The badge belongs to the tab component, not the consumer.

This commit:
- Adds `isDirty?: boolean` to `TabItem`. TabButton renders the badge
  inside an inline-flex `.btn-tab__label` wrapper so label and badge
  stay on the same line and align vertically (port of the wrapper from
  #7038 by @Zaimwa9 — credit for the fix idea).
- Migrates the seven badged call sites in CreateGroup, CreateSegment
  (×2), CreateFeature (×3), and create-experiment to use `isDirty`.
  This drops the local `<Row>`/`<div>`/`unread` JSX in favour of plain
  string `tabLabel`s, removing the redundant `tabLabelString` aliases
  alongside.
- Adds a `WithDirtyMarker` story to `Tabs.stories.tsx` for Chromatic
  coverage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Decouples Tabs from react-router. Tabs becomes a controlled component
(`value` + `onChange`); URL persistence is opt-in per consumer via
`useTabUrlSync(paramName, labels)`.

- Add `common/hooks/useTabUrlSync.ts`. Reads/writes a URL search param,
  slugifies labels, falls back to `window.history.replaceState` when
  rendered outside Router context (modal portals).
- Migrate 16 function-component consumers from `urlParam` /
  `uncontrolled` to controlled Tabs:
  - URL-bound (10): AdminDashboardPage, ImportPage, UsersAndPermissionsPage,
    ChangeRequestsPage, FeatureHistoryDetailPage, EnvironmentSettingsPage,
    OrganisationSettingsPage, ProjectSettingsPage, CreateSegment, CreateFeature.
  - useState (6): AccountSettingsPage, CreateGroup, CreateSAML,
    EnvironmentDocumentCodeHelp, DiffSegmentOverrides.
- Convert ComparePage class component to a function component using the
  hook. (`create-experiment/index.js` is left on the legacy `urlParam`
  path — gated by the `experimental_flags` Flagsmith feature flag,
  follow-up when that's promoted or the modal converts.)
- Mark `urlParam`, `history`, `uncontrolled`, `tabLabelString`, and
  `isRoles` as `@deprecated` on the Tabs/TabItem types. Implementation
  stays for the legacy class consumer plus the two `OrganisationUsersTable`
  Tabs that render inline inside `openModal` (no component context for
  hooks until extracted).
- Add `WithUrlSync`, `Controlled`, and `HideNavOnSingleTab` stories so
  the new pattern is the documented one.
- Incidentally rewrite a pre-existing nested ternary in
  UsersAndPermissionsPage as an IIFE to satisfy the pre-commit hook
  (the file's now in scope for lint-staged).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The two `openModal(...)` calls in `OrganisationUsersTable` rendered Tabs
inline as JSX, with no surrounding component context — meaning they
couldn't use hooks. Extract each tree into a proper function component
so the outer Tabs can become controlled (`useState`) instead of relying
on the deprecated `uncontrolled` prop.

- Add `EditUserPermissionsModal` (Permissions/Groups tabs).
- Add `InspectUserPermissionsModal` (Permissions tab only).
- `OrganisationUsersTable.tsx` now passes the FCs to `openModal`.

Note: the inner `PermissionsTabs` and `InspectPermissions` still pass
`uncontrolled` through to their own internal Tabs; that's a separate
migration on those wrapper components.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ermissions controlled

- Drop `uncontrolled` from `Tabs.tsx`. Pure controlled API now: `value` +
  `onChange`. The internal `useState` fallback is removed; `urlParam`
  remains the only legacy escape hatch (still used by
  `create-experiment/index.js`).
- `PermissionsTabs` and `InspectPermissions` no longer accept
  `uncontrolled`; `value` and `onChange` are required. Updates all three
  consumers (`CreateGroup`, `EditUserPermissionsModal`,
  `InspectUserPermissionsModal`) to wire up local `useState`.
- Migrate Tabs stories to the controlled pattern with `useState`. New
  consumers should not see `uncontrolled` in any documented example.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 30, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
flagsmith-frontend-preview Ready Ready Preview, Comment Apr 30, 2026 9:49pm
flagsmith-frontend-staging Ready Ready Preview, Comment Apr 30, 2026 9:49pm
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Ignored Ignored Apr 30, 2026 9:49pm

Request Review

@github-actions github-actions Bot added front-end Issue related to the React Front End Dashboard fix labels Apr 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fix front-end Issue related to the React Front End Dashboard

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant