Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
e2a48b7
spike: mount modals in-tree via portals instead of separate roots
talissoncosta Jul 16, 2026
9757d36
docs: add industry-practice section to modal spike findings
talissoncosta Jul 16, 2026
b05e36d
feat(spike): add DS Dialog component on native <dialog>
talissoncosta Jul 16, 2026
acafdb8
feat(spike): render the imperative modal stack with the DS Dialog
talissoncosta Jul 16, 2026
b4974cd
refactor(spike): migrate remaining reactstrap modals to DS Dialog
talissoncosta Jul 16, 2026
90c492b
docs: update modal spike with completed migration + QA checklist
talissoncosta Jul 16, 2026
988e46a
feat(spike): split Drawer out of Dialog
talissoncosta Jul 16, 2026
6b796bc
refactor(spike): drop the ModalDefault shim, repoint helpers to the c…
talissoncosta Jul 16, 2026
5794098
docs: reflect Dialog+Drawer split and reactstrap removal in modal spike
talissoncosta Jul 16, 2026
deafcd0
feat(spike): animate Dialog (fade-up) and Drawer (slide-in)
talissoncosta Jul 16, 2026
83eda91
feat(spike): a11y labelling + reusable ConfirmDialog
talissoncosta Jul 16, 2026
710d51d
test(spike): unit-test the modal controller
talissoncosta Jul 16, 2026
6d37fa7
refactor(spike): migrate modal CSS to Dialog/Drawer + semantic tokens
talissoncosta Jul 16, 2026
007d914
docs: mark modal CSS migration done in spike (runtime QA remains)
talissoncosta Jul 16, 2026
356123b
fix(spike): make closeModal/closeModal2 stable globals, not per-modal
talissoncosta Jul 16, 2026
c7dba7b
feat(spike): add explicit openDrawer API
talissoncosta Jul 16, 2026
26ed2c6
refactor(spike): migrate first batch of drawers to openDrawer
talissoncosta Jul 16, 2026
55d8de3
refactor(spike): migrate identities/roles/audit-webhook drawers to op…
talissoncosta Jul 16, 2026
b8b5db6
refactor(spike): migrate saml/breadcrumb/org-users/api-keys/integrati…
talissoncosta Jul 16, 2026
cf78e9a
refactor(spike): migrate EditPermissions drawers to openDrawer
talissoncosta Jul 16, 2026
1cb9052
refactor(spike): migrate users/identity/override/flag-env drawers to …
talissoncosta Jul 16, 2026
774f1d2
refactor(spike): finish openDrawer sweep; remove side-modal entirely
talissoncosta Jul 16, 2026
056aa56
chore(spike): remove reactstrap
talissoncosta Jul 16, 2026
75d958c
refactor(spike): convert create-project to declarative Drawer (worked…
talissoncosta Jul 16, 2026
dd722fd
docs: add declarative-migration guide to modal spike
talissoncosta Jul 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ module.exports = {
'moment': true,
'oneOfType': true,
'openConfirm': true,
'openDrawer': true,
'openModal': true,
'openModal2': true,
'pact': true,
Expand Down
181 changes: 181 additions & 0 deletions frontend/MODAL_SPIKE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
# Spike: modal mounting via in-tree portals

## Question

Our modals mount as **separate React roots** — `Modal.tsx` does `createRoot` on the
`#modal`/`#modal2`/`#confirm` divs and wraps each in its own `<Provider store={getStore()}>`.
They're detached trees outside `#app`, which forces per-root store wiring and means CSS
(like the shared `.hljs` theme) has to know about four roots.

Can we move modals into the app's React tree **without touching the ~177 call sites**
(`openModal` ×118, `openConfirm` ×44, `openModal2` ×15)?

## Answer: yes, and it's contained

The migration lives entirely in `web/components/modals/base/` plus one mount point:

- **`modalController.ts`** — module-level modal state + subscribe. `openModal`/`openModal2`/
`openConfirm` keep their exact signatures and still populate the `window.*`/global bindings,
so call sites and `main.js` are untouched. Default modals are held as a **stack**, so
"modal on modal" (old `openModal2`) is just depth 2 of one list, not a bespoke root.
- **`ModalManager.tsx`** — subscribes via `useSyncExternalStore` and renders the active modals.
Mounted **once** in `App.js` inside the existing `<Provider>`.
- **reactstrap's `container` prop** (default `'body'`) is pointed at `#app`, so the modal DOM
lands **inside the app root** while React context flows down the tree.
- `Modal.tsx` now just re-exports from the controller.

Net deletion: the per-root `createRoot`, the `withModal` HOC, and the `<Provider>` re-wrap.

## Proven / not yet

- **Proven:** typechecks clean; API and globals preserved; stack supports arbitrary depth;
each level keeps its `closeModal`/`closeModal2` global by position.
- **Needs a dev-server smoke test:** open/close transitions, the value-editor modal
inheriting store/theme, DOM landing in `#app`, stacked-modal z-index, and the
`interceptClose` (unsaved-changes) guard.

## Payoffs

- Modals inherit store/theme/router context — no more `<Provider store={getStore()}>` per root.
- Modal DOM lives under `#app`, so the `:where(#app, #modal, …)` scoping added for the shared
`.hljs` theme collapses to `#app` alone. The extra template roots become removable.
- `modal2` stops being a special case.

## Risks / open questions

- reactstrap 9 `container='app'` behaviour under stacking (backdrop, scroll-lock on `<body>`).
- Esc-close (#4234) — reactstrap's `toggle` should now behave consistently; worth checking if
this fixes it.
- `openModal` resets the stack (dismisses a stacked `modal2`); the old code left `#modal2`
independent. Believed harmless, needs confirming against call sites.
- Fully declarative slots (`<Dialog open>`) remain the long-term target; they'd touch call
sites and are out of scope for this contained step.

## How the industry does this

A survey of modern React practice and established design systems (MUI, Radix/shadcn, Ant,
Chakra v3, React Aria, Polaris, Carbon, Atlassian) puts this approach in the mainstream:

- **Portal into a configurable container is the norm.** Radix (`Portal container`), MUI
(`container`), Ant (`getContainer`), Chakra v3 all expose a per-instance target that defaults
to `<body>`. reactstrap's `container` is the same lever; pointing it at `#app` is a standard move.
- **Our controller is essentially `@ebay/nice-modal-react`.** That library is the reference
pattern for an imperative "open from anywhere" API over an in-tree provider (promise-based,
addressable by id, ~2KB). If we would rather not maintain our own controller, adopting it is a
credible off-the-shelf swap. Trade-off: its `show(id, props)` API differs from our `openModal`,
so it is not a drop-in for the untouched-call-sites goal.
- **Imperative APIs are the minority.** Most systems are declarative-only (`<Dialog open>`);
the fully declarative slot approach (`<Dialog open>` rendered in place) is the long-term target
but touches every call site — out of scope here.
- **If reactstrap is ever replaced, use a headless primitive** (Radix / React Aria) rather than
hand-rolling focus-trap, scroll-lock, and ARIA.
- **The 2026+ frontier is native `<dialog>` + top layer** (~96% support; Atlassian is migrating
behind a flag). It makes stacking and z-index free, which would retire our manual modal stack.

Sources: [react.dev createPortal](https://react.dev/reference/react-dom/createPortal),
[nice-modal-react](https://github.com/eBay/nice-modal-react),
[Radix Dialog](https://www.radix-ui.com/primitives/docs/components/dialog),
[caniuse dialog](https://caniuse.com/dialog).

## DS Dialog + Drawer (native `<dialog>`)

Two distinct patterns, two components sharing one native-`<dialog>` base
(`useNativeDialog` + shared `Dialog.Header/Body/Footer` slots):

- **`base/Dialog/`** — centred modal, sizes `sm|md|lg|full`.
- **`base/Drawer/`** — right-anchored drawer (replaces the legacy `side-modal`), `width`
`default|narrow`.

Both own folders + barrels + co-located SCSS (matching `base/CenteredModal`). Storybook:
`documentation/components/Dialog.stories.tsx`, `Drawer.stories.tsx`.

- **Native `<dialog>` + `showModal()`** — top layer (no z-index, no portal target), built-in
focus trap and Esc, `::backdrop`.
- **Compound API** — `Dialog` + `Dialog.Header` / `Dialog.Body` / `Dialog.Footer`, the shape the
industry standardises on (Radix, MUI Base, Chakra).
- **Tokenised chrome** — `--color-surface-*` / `--color-border-*` / radius, so it themes
light/dark with no bootstrap dependency.
- **Declarative** — the parent owns `open`; `onClose` fires on Esc, backdrop click, and the close
button. New modal code can use `Dialog` or `Drawer` directly today.

### Sequence

1. **DS `Dialog` component** — done. Usable for new declarative modals.
2. **Point the imperative manager at `Dialog`** — done. `ModalManager` renders `Dialog`
(default stack + inline confirm) instead of reactstrap. `interceptClose` and `setModalTitle`
moved to the controller (the manager honours them); `ModalDefault` is a re-export shim;
`ModalConfirm` removed. `openModal`/`openModal2`/`openConfirm` signatures unchanged.
3. **Other reactstrap consumers migrated + `side` split into `Drawer`** — done.
`IntegrationSelect`, `useFormNotSavedModal`, `CenteredModal` use `Dialog`; the manager routes
legacy `side-modal` to `Drawer`. `ModalDefault`/`ModalConfirm` are deleted and the guard/title
helpers moved to the controller. The reactstrap `<Modal>` portal is gone from the modal path
(reactstrap's `ModalBody`/`ModalFooter` helper divs still appear in modal *content* — dropping
the dep entirely is a separate follow-up).
4. **Variant CSS migrated** — done (but unverified). `_modals.scss` retargeted from bootstrap's
`.modal-dialog`/`.modal-content`/`.modal-body` to the new `.dialog__panel`/`.dialog__body`;
the legacy `openModal` className (`side-modal`, `create-feature-modal`, `p-0`, `modal-full-screen`)
passes through to the dialog element, so those hooks still land. Dead reactstrap-portal rules
removed, colours moved to `--color-*` tokens (the `.dark` overrides drop out). Content JSX is
unchanged — standard `.modal-footer` divs keep bootstrap's base styling inside `Dialog.Body`.
Animations (fade-up / drawer slide) done via `@starting-style`.

The only thing left is **runtime QA** — this CSS was written without a browser, so the
`create-feature` drawer (absolute tab-height calcs) and general visual parity need checking
against a running app.

### QA checklist (needs a dev server)

- [ ] Plain modals open/close: Esc, backdrop click, close button, and programmatic `closeModal()`.
- [ ] `openConfirm` yes/no + destructive styling.
- [ ] Stacked `openModal2` on top of a modal (z-index via the top layer).
- [ ] Unsaved-changes guard (`interceptClose`) on create/edit modals.
- [ ] Dynamic title (`setModalTitle`) in create-feature / create-experiment.
- [ ] **side-modal / create-feature drawer** — layout, tabs, height calcs (the hotspot).
- [ ] Padding parity vs the old `$modal-*-padding` tokens; dark mode.
- [ ] `.modal-open` body effects: scroll lock, support-chat hidden.
- [ ] E2E modal-heavy flows + Chromatic.

## Eliminating the imperative API (declarative components)

The imperative `openModal`/`openModal2`/`openDrawer`/`openConfirm` globals still exist as a
compatibility facade over the DS components. They are not the legacy *implementation* (that is
gone) — they are the public API the ~177 call sites use. The modern end-state is to drop them and
use the components directly. Several places already do (`IntegrationSelect`, `CenteredModal`,
`useFormNotSavedModal`, every Storybook story), and `ProjectManageWidget` is a worked example of
converting an imperative site:

```tsx
// before — imperative global
const create = () => openDrawer('Create Project', <CreateProjectModal history={h} />, 'p-0')

// after — declarative component (ProjectManageWidget + CreateProject)
const [open, setOpen] = useState(false)
// trigger: onClick={() => setOpen(true)}
{open && (
<Drawer open onClose={() => setOpen(false)} className='p-0'>
<Drawer.Header>Create Project</Drawer.Header>
<Drawer.Body>
<CreateProjectModal history={h} onClose={() => setOpen(false)} />
</Drawer.Body>
</Drawer>
)}
```

Per conversion: the trigger owns `open` state and renders the component; the content component
takes an `onClose` prop instead of calling the global `closeModal()`. `{open && ...}` matches the
old `unmountOnClose` (content mounts on open).

Path to remove the imperative API entirely:
1. New code: declarative only (`<Dialog>`/`<Drawer>`); lint-ban new `openModal*`.
2. Migrate call sites as they are touched (trigger → local state; content `closeModal()` → `onClose`).
3. When the last global call is gone, delete `openModal`/`openModal2`/`openDrawer`/`openConfirm`,
the controller's `legacyGlobal` wiring, the `global.d.ts` entries, and the eslint globals.

This is the incremental long tail, not a mechanical sweep (each content component's close changes).
Its payoff is idiomatic React + type-safety, not correctness — so migrate-as-you-touch, not big-bang.

## Effort

Small-to-medium: the base is done here. Remaining is runtime QA across the modal surface
(~35 modal components), removing the dead template roots, simplifying the `.hljs` scope, and
the CSS variant re-mapping above (which dominates).
54 changes: 54 additions & 0 deletions frontend/documentation/components/ConfirmDialog.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { Meta, StoryObj } from 'storybook'

import { ConfirmDialog } from 'components/base/Dialog'

const meta: Meta<typeof ConfirmDialog> = {
component: ConfirmDialog,
parameters: {
chromatic: { delay: 300 },
docs: {
description: {
component:
'Small Dialog preset for yes/no confirmations. Backs the imperative ' +
'`openConfirm`, and is usable declaratively for new code.',
},
},
layout: 'fullscreen',
},
}

export default meta

type Story = StoryObj<typeof ConfirmDialog>

const noop = () => undefined

export const Default: Story = {
render: () => (
<ConfirmDialog
open
title='Leave without saving?'
onYes={noop}
onNo={noop}
yesText='Discard'
>
You have unsaved changes. Are you sure you want to leave?
</ConfirmDialog>
),
}

export const Destructive: Story = {
render: () => (
<ConfirmDialog
open
destructive
title='Delete segment'
yesText='Delete'
onYes={noop}
onNo={noop}
>
This can&apos;t be undone. The segment will be removed from every
environment.
</ConfirmDialog>
),
}
68 changes: 68 additions & 0 deletions frontend/documentation/components/Dialog.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { Meta, StoryObj } from 'storybook'

import Dialog from 'components/base/Dialog'
import Button from 'components/base/forms/Button'

const meta: Meta<typeof Dialog> = {
component: Dialog,
parameters: {
chromatic: { delay: 300 },
docs: {
description: {
component:
'DS dialog built on the native `<dialog>` element (`showModal()` top layer, ' +
'built-in focus trap and Esc). Compound API: `Dialog.Header` / `Dialog.Body` / ' +
'`Dialog.Footer`. Chrome is tokenised, so it themes light/dark with no bootstrap. ' +
'The parent owns `open`; `onClose` fires on Esc, backdrop click, and the close button.',
},
},
layout: 'fullscreen',
},
}

export default meta

type Story = StoryObj<typeof Dialog>

const body = (
<p className='mb-0'>
This dialog renders in the browser top layer. Focus is trapped, Escape and a
backdrop click both dismiss it, and the surface follows the active theme.
</p>
)

const noop = () => undefined

export const Default: Story = {
render: () => (
<Dialog open size='md' onClose={noop}>
<Dialog.Header>Rename flag</Dialog.Header>
<Dialog.Body>{body}</Dialog.Body>
</Dialog>
),
}

export const WithFooter: Story = {
render: () => (
<Dialog open size='sm' onClose={noop}>
<Dialog.Header>Delete segment</Dialog.Header>
<Dialog.Body>
This can&apos;t be undone. The segment will be removed from every
environment.
</Dialog.Body>
<Dialog.Footer>
<Button theme='secondary'>Cancel</Button>
<Button theme='danger'>Delete</Button>
</Dialog.Footer>
</Dialog>
),
}

export const Large: Story = {
render: () => (
<Dialog open size='lg' onClose={noop}>
<Dialog.Header>Edit feature</Dialog.Header>
<Dialog.Body>{body}</Dialog.Body>
</Dialog>
),
}
65 changes: 65 additions & 0 deletions frontend/documentation/components/Drawer.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { Meta, StoryObj } from 'storybook'

import Drawer from 'components/base/Drawer'
import Button from 'components/base/forms/Button'

const meta: Meta<typeof Drawer> = {
component: Drawer,
parameters: {
chromatic: { delay: 300 },
docs: {
description: {
component:
'Right-anchored drawer on the native `<dialog>` element (shares the DS ' +
'Dialog chrome and slots). Full height, slides from the right, `width` ' +
'`default` (800px) or `narrow` (640px). Use it for larger flows; use Dialog ' +
'for centred, focused tasks.',
},
},
layout: 'fullscreen',
},
}

export default meta

type Story = StoryObj<typeof Drawer>

const noop = () => undefined

const body = (
<p className='mb-0'>
A drawer is for larger, multi-step flows anchored to the edge of the screen,
as opposed to a centred modal for a single focused task.
</p>
)

export const Default: Story = {
render: () => (
<Drawer open onClose={noop}>
<Drawer.Header>Create feature</Drawer.Header>
<Drawer.Body>{body}</Drawer.Body>
</Drawer>
),
}

export const Narrow: Story = {
render: () => (
<Drawer open width='narrow' onClose={noop}>
<Drawer.Header>Filters</Drawer.Header>
<Drawer.Body>{body}</Drawer.Body>
</Drawer>
),
}

export const WithFooter: Story = {
render: () => (
<Drawer open onClose={noop}>
<Drawer.Header>Edit segment</Drawer.Header>
<Drawer.Body>{body}</Drawer.Body>
<Drawer.Footer>
<Button theme='secondary'>Cancel</Button>
<Button>Save</Button>
</Drawer.Footer>
</Drawer>
),
}
Loading
Loading