From e2a48b79a1d5d0861f4ed25cad5d01da2d27432d Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Thu, 16 Jul 2026 11:53:53 -0300 Subject: [PATCH 01/25] spike: mount modals in-tree via portals instead of separate roots PoC for modernising the modal architecture. Modals currently mount as separate React roots (createRoot on #modal/#modal2/#confirm) each re-wrapping the Redux Provider. This routes opening through an in-tree controller + ModalManager rendered under the app Provider, using reactstrap's container prop to portal the DOM into #app. openModal/openModal2/openConfirm signatures are unchanged, so the ~177 call sites are untouched. Default modals become a stack. See MODAL_SPIKE.md for findings, risks, and effort. Co-Authored-By: Claude Opus 4.8 --- frontend/MODAL_SPIKE.md | 57 ++++++ frontend/web/components/App.js | 2 + frontend/web/components/modals/base/Modal.tsx | 176 +----------------- .../components/modals/base/ModalConfirm.tsx | 4 + .../components/modals/base/ModalDefault.tsx | 12 +- .../components/modals/base/ModalManager.tsx | 97 ++++++++++ .../components/modals/base/modalController.ts | 114 ++++++++++++ 7 files changed, 289 insertions(+), 173 deletions(-) create mode 100644 frontend/MODAL_SPIKE.md create mode 100644 frontend/web/components/modals/base/ModalManager.tsx create mode 100644 frontend/web/components/modals/base/modalController.ts diff --git a/frontend/MODAL_SPIKE.md b/frontend/MODAL_SPIKE.md new file mode 100644 index 000000000000..55d71faf47ec --- /dev/null +++ b/frontend/MODAL_SPIKE.md @@ -0,0 +1,57 @@ +# 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 ``. +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 ``. +- **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 `` 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 `` 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 ``). +- 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 (``) remain the long-term target; they'd touch call + sites and are out of scope for this contained step. + +## 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, and simplifying the `.hljs` scope. diff --git a/frontend/web/components/App.js b/frontend/web/components/App.js index eba77aab7ab1..37eac0918426 100644 --- a/frontend/web/components/App.js +++ b/frontend/web/components/App.js @@ -14,6 +14,7 @@ import AccountSettingsPage from './pages/AccountSettingsPage' import ProjectStore from 'common/stores/project-store' import { Provider } from 'react-redux' import { getStore } from 'common/store' +import ModalManager from './modals/base/ModalManager' import ConfigProvider from 'common/providers/ConfigProvider' import AccountStore from 'common/stores/account-store' import OrganisationLimit from './OrganisationLimit' @@ -267,6 +268,7 @@ const App = class extends Component { } return ( + void - onNo?: () => void - destructive?: boolean - yesText?: string - noText?: string -} export const ModalHeader = _ModalHeader export const ModalFooter = _ModalFooter export const Modal = _Modal export const ModalBody = _ModalBody -const withModal = ( - WrappedComponent: JSXElementConstructor, - { - closePointer = 'closeModal', - shouldInterceptClose = false, - }: { - closePointer?: string - shouldInterceptClose?: boolean - } = {}, -) => { - return (props: ModalProps) => { - // eslint-disable-next-line react-hooks/rules-of-hooks - const [isOpen, setIsOpen] = useState(true) - // eslint-disable-next-line react-hooks/rules-of-hooks - const toggle = useCallback(() => { - if (interceptClose && shouldInterceptClose) { - interceptClose().then((result) => { - if (result) { - setIsOpen(false) - setInterceptClose(null) - } - }) - } else { - setIsOpen(false) - } - }, [setIsOpen]) - // @ts-ignore - global[closePointer] = toggle - - const onClosed = () => { - if (props.onClosed) { - props.onClosed() - } - } - - return ( - - - - ) - } -} - -function getOrCreateRoot( - elementId: string, - rootRef: { current: Root | null }, -): Root { - if (!rootRef.current) { - const el = document.getElementById(elementId) - if (!el) throw new Error(`Element #${elementId} not found`) - rootRef.current = createRoot(el) - } - return rootRef.current -} - -let modalKey = 0 -let pendingModalOnClose: (() => void) | null = null -let pendingModal2OnClose: (() => void) | null = null - -const confirmRootRef: { current: Root | null } = { current: null } -const modalRootRef: { current: Root | null } = { current: null } -const modal2RootRef: { current: Root | null } = { current: null } - -const _Confirm = withModal(Confirm) -const _ModalDefault2 = withModal(ModalDefault, { closePointer: 'closeModal2' }) -const _ModalDefault = withModal(ModalDefault, { shouldInterceptClose: true }) - -export const openConfirm = (global.openConfirm = ({ - body, - destructive, - noText, - onNo, - onYes, - title, - yesText, -}: OpenConfirmParams) => { - const root = getOrCreateRoot('confirm', confirmRootRef) - root.render( - <_Confirm - key={++modalKey} - isOpen - isDanger={destructive} - onNo={onNo} - onYes={onYes} - title={title} - yesText={yesText} - noText={noText} - > - {body} - , - ) -}) - -export const openModal = (global.openModal = ( - title: string, - body: ReactNode, - className?: string, - onClose?: () => void, -) => { - // If replacing a modal before its close transition completes, fire its - // onClosed callback now since the transition will never finish. - if (pendingModalOnClose) { - pendingModalOnClose() - } - pendingModalOnClose = onClose ?? null - const root = getOrCreateRoot('modal', modalRootRef) - root.render( - <_ModalDefault - key={++modalKey} - isOpen - className={className} - onClosed={() => { - pendingModalOnClose = null - onClose?.() - }} - title={title} - > - {body} - , - ) -}) - -//This is used when we show modals on top of modals, the UI pattern should be avoided if possible -export const openModal2 = (global.openModal2 = ( - title: string, - body: ReactNode, - className?: string, - onClose?: () => void, -) => { - if (pendingModal2OnClose) { - pendingModal2OnClose() - } - pendingModal2OnClose = onClose ?? null - const root = getOrCreateRoot('modal2', modal2RootRef) - root.render( - <_ModalDefault2 - key={++modalKey} - isOpen - className={className} - onClosed={() => { - pendingModal2OnClose = null - onClose?.() - }} - title={title} - > - {body} - , - ) -}) +// PoC (spike): modal opening is delegated to the in-tree controller/manager +// instead of the old per-root createRoot plumbing. Signatures are unchanged, +// so call sites and the window.openModal* wiring in main.js keep working. +export { openModal, openModal2, openConfirm } from './modalController' +export type { ConfirmParams } from './modalController' diff --git a/frontend/web/components/modals/base/ModalConfirm.tsx b/frontend/web/components/modals/base/ModalConfirm.tsx index 5c866168e5ef..a59c0012da52 100644 --- a/frontend/web/components/modals/base/ModalConfirm.tsx +++ b/frontend/web/components/modals/base/ModalConfirm.tsx @@ -17,10 +17,13 @@ interface Confirm { disabledYes?: boolean yesText?: string toggle: () => void + container?: string + children?: ReactNode } const Confirm: FC = ({ children, + container = 'app', disabled, disabledYes, isDanger, @@ -45,6 +48,7 @@ const Confirm: FC = ({ return ( void + onDismiss?: () => void + onClosed?: () => void toggle: () => void zIndex?: number children: ReactNode className?: string + // reactstrap portal target. In-tree manager points this at #app so the modal + // DOM lands inside the app root rather than . + container?: string } export let interceptClose: (() => Promise) | null = null @@ -17,18 +21,20 @@ export const setInterceptClose = (promise: (() => Promise) | null) => { interceptClose = promise } -let cb +let cb: ((title: ReactNode) => void) | undefined export const setModalTitle = (title: string) => { cb?.(title) } const ModalDefault: FC = ({ children, className, + container = 'app', isOpen, onClosed, onDismiss, title: _title, toggle, + zIndex, }) => { const [title, setTitle] = useState(_title) cb = setTitle @@ -50,11 +56,13 @@ const ModalDefault: FC = ({ className={ !className?.includes('side-modal') ? 'modal-dialog-centered' : undefined } + container={container} onClosed={onClosed} modalClassName={className} unmountOnClose isOpen={isOpen} toggle={onDismissClick} + zIndex={zIndex} > {title} {children} diff --git a/frontend/web/components/modals/base/ModalManager.tsx b/frontend/web/components/modals/base/ModalManager.tsx new file mode 100644 index 000000000000..24b53d9ee993 --- /dev/null +++ b/frontend/web/components/modals/base/ModalManager.tsx @@ -0,0 +1,97 @@ +import { + FC, + useCallback, + useEffect, + useState, + useSyncExternalStore, +} from 'react' +import ModalDefault from './ModalDefault' +import Confirm from './ModalConfirm' +import { + clearConfirm, + closeModalByKey, + ConfirmEntry, + getModalState, + ModalEntry, + ModalState, + subscribeModals, +} from './modalController' + +// PoC (spike): renders active modals in-tree via reactstrap's `container` +// prop pointed at #app, so the DOM lands inside the app root (not ) and +// context flows down. Mounted once from App.js inside the store . + +const legacyGlobal = global as typeof globalThis & + Record<'closeModal' | 'closeModal2', (() => void) | undefined> + +const ModalSlot: FC<{ entry: ModalEntry; index: number }> = ({ + entry, + index, +}) => { + const [isOpen, setIsOpen] = useState(true) + const toggle = useCallback(() => setIsOpen(false), []) + + // Levels 0 and 1 keep the imperative globals working (closeModal/closeModal2). + useEffect(() => { + const pointer = (['closeModal', 'closeModal2'] as const)[index] + if (!pointer) return + legacyGlobal[pointer] = toggle + return () => { + if (legacyGlobal[pointer] === toggle) legacyGlobal[pointer] = undefined + } + }, [index, toggle]) + + return ( + { + entry.onClose?.() + closeModalByKey(entry.key) + }} + > + {entry.body} + + ) +} + +const ConfirmSlot: FC<{ entry: ConfirmEntry }> = ({ entry }) => { + const [isOpen, setIsOpen] = useState(true) + const toggle = useCallback(() => setIsOpen(false), []) + return ( + + {entry.body} + + ) +} + +const ModalManager: FC = () => { + const state: ModalState = useSyncExternalStore(subscribeModals, getModalState) + return ( + <> + {state.modals.map((entry, index) => ( + + ))} + {state.confirm && ( + + )} + + ) +} + +export default ModalManager diff --git a/frontend/web/components/modals/base/modalController.ts b/frontend/web/components/modals/base/modalController.ts new file mode 100644 index 000000000000..0e3c9663e5c8 --- /dev/null +++ b/frontend/web/components/modals/base/modalController.ts @@ -0,0 +1,114 @@ +import { ReactNode } from 'react' + +// PoC (spike): in-tree modal state. +// +// Replaces the per-root `createRoot` plumbing that lived in Modal.tsx. The +// public API (openModal/openModal2/openConfirm) keeps its exact signatures, so +// the ~177 call sites and the window.* wiring in main.js are untouched. Opening +// a modal now pushes a descriptor here; the in-tree renders it +// under the app's existing , so modals inherit the store/theme/router +// context instead of each root re-creating a . +// +// Default modals are held as a stack, so "modal on modal" (the old openModal2) +// is just depth 2 of the same list rather than a bespoke second root. + +export type ConfirmParams = { + title: ReactNode + body: ReactNode + onYes: () => void + onNo?: () => void + destructive?: boolean + yesText?: string + noText?: string +} + +export type ModalEntry = { + key: number + title: string + body: ReactNode + className?: string + onClose?: () => void +} + +export type ConfirmEntry = { key: number } & ConfirmParams + +export type ModalState = { + modals: ModalEntry[] + confirm: ConfirmEntry | null +} + +let state: ModalState = { confirm: null, modals: [] } +let key = 0 +const listeners = new Set<() => void>() + +const emit = () => { + listeners.forEach((listener) => listener()) +} + +export const subscribeModals = (listener: () => void) => { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +// Returns a stable reference between changes so useSyncExternalStore is happy. +export const getModalState = () => state + +export const openModal = ( + title: string, + body: ReactNode, + className?: string, + onClose?: () => void, +) => { + // Opening the main modal resets the stack; fire onClose for anything dropped. + state.modals.forEach((entry) => entry.onClose?.()) + state = { + ...state, + modals: [{ body, className, key: ++key, onClose, title }], + } + emit() +} + +export const openModal2 = ( + title: string, + body: ReactNode, + className?: string, + onClose?: () => void, +) => { + // Push on top of the current stack (the old "modal on modal"). + state = { + ...state, + modals: [...state.modals, { body, className, key: ++key, onClose, title }], + } + emit() +} + +export const openConfirm = (params: ConfirmParams) => { + state = { ...state, confirm: { key: ++key, ...params } } + emit() +} + +export const closeModalByKey = (modalKey: number) => { + const modals = state.modals.filter((entry) => entry.key !== modalKey) + if (modals.length === state.modals.length) return + state = { ...state, modals } + emit() +} + +export const clearConfirm = () => { + if (!state.confirm) return + state = { ...state, confirm: null } + emit() +} + +// Legacy call sites reach these via window.openModal* (wired in main.js) and +// bare globals set up in project-components.js. Keep them populated. +const legacyGlobal = global as typeof globalThis & { + openModal: typeof openModal + openModal2: typeof openModal2 + openConfirm: typeof openConfirm +} +legacyGlobal.openModal = openModal +legacyGlobal.openModal2 = openModal2 +legacyGlobal.openConfirm = openConfirm From 9757d36b32d69b82e8a2303434b218ecd9b512e3 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Thu, 16 Jul 2026 12:05:02 -0300 Subject: [PATCH 02/25] docs: add industry-practice section to modal spike findings Co-Authored-By: Claude Opus 4.8 --- frontend/MODAL_SPIKE.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/frontend/MODAL_SPIKE.md b/frontend/MODAL_SPIKE.md index 55d71faf47ec..c8a2070471d4 100644 --- a/frontend/MODAL_SPIKE.md +++ b/frontend/MODAL_SPIKE.md @@ -51,6 +51,32 @@ Net deletion: the per-root `createRoot`, the `withModal` HOC, and the ``) 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 ``. 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 (``); + the fully declarative slot approach (`` 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 `` + 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). + ## Effort Small-to-medium: the base is done here. Remaining is runtime QA across the modal surface From b05e36d02df0e13bc6e35569e082386093a616ea Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Thu, 16 Jul 2026 12:21:30 -0300 Subject: [PATCH 03/25] feat(spike): add DS Dialog component on native Reusable, declarative Dialog (base/Dialog) built on the native element: showModal() top layer, built-in focus trap and Esc, compound Header/Body/Footer API, tokenised chrome (no bootstrap), sizes incl. a side drawer. Storybook stories for the variants. New modal code can use it directly. Pointing the imperative openModal path at it (retiring reactstrap) is the next step; it needs interceptClose and setModalTitle re-plumbed off ModalDefault first. See MODAL_SPIKE.md. Co-Authored-By: Claude Opus 4.8 --- frontend/MODAL_SPIKE.md | 29 ++++- .../components/Dialog.stories.tsx | 77 ++++++++++++ .../web/components/base/Dialog/Dialog.scss | 97 +++++++++++++++ .../web/components/base/Dialog/Dialog.tsx | 115 ++++++++++++++++++ frontend/web/components/base/Dialog/index.ts | 2 + 5 files changed, 319 insertions(+), 1 deletion(-) create mode 100644 frontend/documentation/components/Dialog.stories.tsx create mode 100644 frontend/web/components/base/Dialog/Dialog.scss create mode 100644 frontend/web/components/base/Dialog/Dialog.tsx create mode 100644 frontend/web/components/base/Dialog/index.ts diff --git a/frontend/MODAL_SPIKE.md b/frontend/MODAL_SPIKE.md index c8a2070471d4..1571a7bf0d0e 100644 --- a/frontend/MODAL_SPIKE.md +++ b/frontend/MODAL_SPIKE.md @@ -77,7 +77,34 @@ Sources: [react.dev createPortal](https://react.dev/reference/react-dom/createPo [Radix Dialog](https://www.radix-ui.com/primitives/docs/components/dialog), [caniuse dialog](https://caniuse.com/dialog). +## DS Dialog component (native ``) + +Rather than hack native `` into the reactstrap-based `ModalDefault`, this PoC adds a +reusable DS component: `web/components/base/Dialog/` (own folder + barrel + co-located SCSS, +matching `base/CenteredModal`). Storybook: `documentation/components/Dialog.stories.tsx`. + +- **Native `` + `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. `size`: `sm | md | lg | full | side`. +- **Declarative** — the parent owns `open`; `onClose` fires on Esc, backdrop click, and the close + button. New modal code can use it directly today. + +### Sequence + +1. **DS `Dialog` component** (this PoC) — usable now for new declarative modals. +2. **Point the imperative manager at `Dialog`** (retire reactstrap in the `openModal` path). This + is the next step, not done here, because it needs two behaviours re-plumbed off `ModalDefault`: + `interceptClose` (the unsaved-changes guard, ~12 call sites) and `setModalTitle` (dynamic + titles, incl. create-feature). Swapping blind would silently regress both. +3. **Migrate the variant CSS** — `side-modal`, `modal-full-screen`, `#modal2` z-index, and the + `.modal-open` body effects are all keyed to bootstrap's `.modal*` structure and must be + re-expressed against the new dialog. This is the bulk of the remaining effort. + ## 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, and simplifying the `.hljs` scope. +(~35 modal components), removing the dead template roots, simplifying the `.hljs` scope, and +the CSS variant re-mapping above (which dominates). diff --git a/frontend/documentation/components/Dialog.stories.tsx b/frontend/documentation/components/Dialog.stories.tsx new file mode 100644 index 000000000000..41fb29610bfb --- /dev/null +++ b/frontend/documentation/components/Dialog.stories.tsx @@ -0,0 +1,77 @@ +import type { Meta, StoryObj } from 'storybook' + +import Dialog from 'components/base/Dialog' +import Button from 'components/base/forms/Button' + +const meta: Meta = { + component: Dialog, + parameters: { + chromatic: { delay: 300 }, + docs: { + description: { + component: + 'DS dialog built on the native `` 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 + +const body = ( +

+ 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. +

+) + +const noop = () => undefined + +export const Default: Story = { + render: () => ( + + Rename flag + {body} + + ), +} + +export const WithFooter: Story = { + render: () => ( + + Delete segment + + This can't be undone. The segment will be removed from every + environment. + + + + + + + ), +} + +export const Large: Story = { + render: () => ( + + Edit feature + {body} + + ), +} + +export const Side: Story = { + render: () => ( + + Create feature + {body} + + ), +} diff --git a/frontend/web/components/base/Dialog/Dialog.scss b/frontend/web/components/base/Dialog/Dialog.scss new file mode 100644 index 000000000000..d8717933864a --- /dev/null +++ b/frontend/web/components/base/Dialog/Dialog.scss @@ -0,0 +1,97 @@ +// DS Dialog: native + showModal() (top layer, built-in focus/Esc). +// Chrome is tokenised so it themes light/dark with no bootstrap dependency. +dialog.dialog { + padding: 0; + border: 0; + margin: 0; + width: 100%; + height: 100%; + max-width: 100vw; + max-height: 100dvh; + background: transparent; + color: inherit; + + &::backdrop { + background: rgba(0, 0, 0, 0.5); + } + + // The dialog element is the flex centrer, so a click on its free space is a + // backdrop click (see onClick in Dialog.tsx). + &[open] { + display: flex; + align-items: center; + justify-content: center; + padding: 1.75rem; + } + + &__panel { + display: flex; + flex-direction: column; + width: 100%; + max-height: 100%; + overflow: hidden; + background: var(--color-surface-default); + color: var(--color-text-default); + border: 1px solid var(--color-border-default); + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.16); + } + + &--sm &__panel { + max-width: 400px; + } + &--md &__panel { + max-width: 560px; + } + &--lg &__panel { + max-width: 800px; + } + &--full &__panel { + max-width: 75vw; + } + + // Slide-in drawer from the right (replaces the legacy .side-modal). + &--side { + &[open] { + align-items: stretch; + justify-content: flex-end; + padding: 0; + } + .dialog__panel { + width: 800px; + max-width: 100vw; + height: 100%; + max-height: 100dvh; + border: 0; + border-radius: 0; + } + } + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 20px 24px; + border-bottom: 1px solid var(--color-border-default); + } + + &__title { + margin: 0; + font-size: 1rem; + font-weight: 600; + } + + &__body { + padding: 24px; + overflow-y: auto; + } + + &__footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 16px 24px; + border-top: 1px solid var(--color-border-default); + } +} diff --git a/frontend/web/components/base/Dialog/Dialog.tsx b/frontend/web/components/base/Dialog/Dialog.tsx new file mode 100644 index 000000000000..fa54651b0243 --- /dev/null +++ b/frontend/web/components/base/Dialog/Dialog.tsx @@ -0,0 +1,115 @@ +import { + createContext, + FC, + MouseEvent, + ReactNode, + SyntheticEvent, + useContext, + useEffect, + useRef, +} from 'react' +import ModalClose from 'components/modals/base/ModalClose' +import './Dialog.scss' + +export type DialogSize = 'sm' | 'md' | 'lg' | 'full' | 'side' + +export type DialogProps = { + open: boolean + onClose: () => void + size?: DialogSize + className?: string + children: ReactNode +} + +type SlotProps = { + children: ReactNode + className?: string +} + +const DialogContext = createContext<{ onClose: () => void }>({ + onClose: () => undefined, +}) + +// Ref-counted body class so the app's `.modal-open` rules (scroll lock, hiding +// the support chat) still fire and survive stacked dialogs. +let openDialogs = 0 +const useBodyModalOpen = (open: boolean) => { + useEffect(() => { + if (!open) return + openDialogs += 1 + document.body.classList.add('modal-open') + return () => { + openDialogs -= 1 + if (openDialogs <= 0) { + openDialogs = 0 + document.body.classList.remove('modal-open') + } + } + }, [open]) +} + +const DialogRoot: FC = ({ + children, + className, + onClose, + open, + size = 'md', +}) => { + const ref = useRef(null) + useBodyModalOpen(open) + + // Drive the native dialog imperatively. showModal() promotes it to the top + // layer, so there is no z-index handling and no portal target to configure. + useEffect(() => { + const dialog = ref.current + if (!dialog) return + if (open && !dialog.open) dialog.showModal() + if (!open && dialog.open) dialog.close() + }, [open]) + + return ( + + ) => { + // Esc: run our onClose rather than the browser's immediate close. + e.preventDefault() + onClose() + }} + onClick={(e: MouseEvent) => { + // A click landing on the dialog element itself is a backdrop click. + if (e.target === ref.current) onClose() + }} + > +
{children}
+
+
+ ) +} + +const DialogHeader: FC = ({ children, className }) => { + const { onClose } = useContext(DialogContext) + return ( +
+
{children}
+ +
+ ) +} + +const DialogBody: FC = ({ children, className }) => ( +
{children}
+) + +const DialogFooter: FC = ({ children, className }) => ( +
{children}
+) + +const Dialog = Object.assign(DialogRoot, { + Body: DialogBody, + Footer: DialogFooter, + Header: DialogHeader, +}) + +export default Dialog diff --git a/frontend/web/components/base/Dialog/index.ts b/frontend/web/components/base/Dialog/index.ts new file mode 100644 index 000000000000..d7dde8a39a55 --- /dev/null +++ b/frontend/web/components/base/Dialog/index.ts @@ -0,0 +1,2 @@ +export { default } from './Dialog' +export type { DialogProps, DialogSize } from './Dialog' From acafdb85a41cc66fc7340a749f30a5ea0bb2a621 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Thu, 16 Jul 2026 12:31:11 -0300 Subject: [PATCH 04/25] feat(spike): render the imperative modal stack with the DS Dialog ModalManager now renders base/Dialog (native ) instead of reactstrap ModalDefault/ModalConfirm. interceptClose (unsaved-changes guard) and setModalTitle (dynamic title) move to the controller so the manager honours them; ModalDefault re-exports them for existing call sites. Legacy className maps to Dialog size (side-modal -> side, etc.). openModal/openConfirm signatures unchanged. Co-Authored-By: Claude Opus 4.8 --- .../components/modals/base/ModalDefault.tsx | 29 +++-- .../components/modals/base/ModalManager.tsx | 116 ++++++++++++------ .../components/modals/base/modalController.ts | 18 +++ 3 files changed, 111 insertions(+), 52 deletions(-) diff --git a/frontend/web/components/modals/base/ModalDefault.tsx b/frontend/web/components/modals/base/ModalDefault.tsx index 0d9c20e98355..4323be6137ae 100644 --- a/frontend/web/components/modals/base/ModalDefault.tsx +++ b/frontend/web/components/modals/base/ModalDefault.tsx @@ -1,6 +1,17 @@ import { Modal, ModalBody } from 'reactstrap' -import React, { FC, ReactNode, useState } from 'react' +import React, { FC, ReactNode, useEffect, useState } from 'react' import ModalHeader from './ModalHeader' +import { + interceptClose, + registerModalTitleSetter, + setInterceptClose, + setModalTitle, +} from './modalController' + +// interceptClose / setInterceptClose / setModalTitle now live in the controller +// so the in-tree manager can honour them. Re-exported here for existing imports +// (~15 call sites still import them from this path). +export { interceptClose, setInterceptClose, setModalTitle } interface ModalDefault { title: ReactNode @@ -16,15 +27,6 @@ interface ModalDefault { container?: string } -export let interceptClose: (() => Promise) | null = null -export const setInterceptClose = (promise: (() => Promise) | null) => { - interceptClose = promise -} - -let cb: ((title: ReactNode) => void) | undefined -export const setModalTitle = (title: string) => { - cb?.(title) -} const ModalDefault: FC = ({ children, className, @@ -37,14 +39,17 @@ const ModalDefault: FC = ({ zIndex, }) => { const [title, setTitle] = useState(_title) - cb = setTitle + useEffect(() => { + registerModalTitleSetter(setTitle) + return () => registerModalTitleSetter(null) + }, []) const onDismissClick = async () => { if (interceptClose) { const shouldClose = await interceptClose() if (!shouldClose) { return } - interceptClose = null + setInterceptClose(null) } if (onDismiss) { onDismiss() diff --git a/frontend/web/components/modals/base/ModalManager.tsx b/frontend/web/components/modals/base/ModalManager.tsx index 24b53d9ee993..3e901176e0b2 100644 --- a/frontend/web/components/modals/base/ModalManager.tsx +++ b/frontend/web/components/modals/base/ModalManager.tsx @@ -1,82 +1,118 @@ import { FC, + ReactNode, useCallback, useEffect, useState, useSyncExternalStore, } from 'react' -import ModalDefault from './ModalDefault' -import Confirm from './ModalConfirm' +import Dialog, { DialogSize } from 'components/base/Dialog' +import Button from 'components/base/forms/Button' import { clearConfirm, closeModalByKey, ConfirmEntry, getModalState, + interceptClose, ModalEntry, ModalState, + registerModalTitleSetter, + setInterceptClose, subscribeModals, } from './modalController' -// PoC (spike): renders active modals in-tree via reactstrap's `container` -// prop pointed at #app, so the DOM lands inside the app root (not ) and -// context flows down. Mounted once from App.js inside the store . +// PoC (spike): renders the imperative modal stack with the DS Dialog (native +// , top layer). Mounted once from App.js under the store . const legacyGlobal = global as typeof globalThis & Record<'closeModal' | 'closeModal2', (() => void) | undefined> +// Map the legacy openModal className to a Dialog size/variant. +const sizeFor = (className?: string): DialogSize => { + if (!className) return 'md' + if (className.includes('side-modal')) return 'side' + if (className.includes('modal-full-screen')) return 'full' + if (className.includes('modal-lg')) return 'lg' + if (className.includes('modal-sm')) return 'sm' + return 'md' +} + const ModalSlot: FC<{ entry: ModalEntry; index: number }> = ({ entry, index, }) => { - const [isOpen, setIsOpen] = useState(true) - const toggle = useCallback(() => setIsOpen(false), []) + const [title, setTitle] = useState(entry.title) - // Levels 0 and 1 keep the imperative globals working (closeModal/closeModal2). + // The main modal (index 0) owns the dynamic-title setter, matching the old + // ModalDefault behaviour. + useEffect(() => { + if (index !== 0) return undefined + registerModalTitleSetter(setTitle) + return () => registerModalTitleSetter(null) + }, [index]) + + const requestClose = useCallback(async () => { + // Only the main modal runs the unsaved-changes guard. + if (index === 0 && interceptClose) { + const shouldClose = await interceptClose() + if (!shouldClose) return + setInterceptClose(null) + } + entry.onClose?.() + closeModalByKey(entry.key) + }, [entry, index]) + + // Keep the imperative globals working (closeModal()/closeModal2()). useEffect(() => { const pointer = (['closeModal', 'closeModal2'] as const)[index] - if (!pointer) return - legacyGlobal[pointer] = toggle + if (!pointer) return undefined + legacyGlobal[pointer] = requestClose return () => { - if (legacyGlobal[pointer] === toggle) legacyGlobal[pointer] = undefined + if (legacyGlobal[pointer] === requestClose) { + legacyGlobal[pointer] = undefined + } } - }, [index, toggle]) + }, [index, requestClose]) return ( - { - entry.onClose?.() - closeModalByKey(entry.key) - }} + onClose={requestClose} > - {entry.body} - + {title} + {entry.body} + ) } const ConfirmSlot: FC<{ entry: ConfirmEntry }> = ({ entry }) => { - const [isOpen, setIsOpen] = useState(true) - const toggle = useCallback(() => setIsOpen(false), []) + const no = () => { + entry.onNo?.() + clearConfirm() + } + const yes = () => { + entry.onYes?.() + clearConfirm() + } return ( - - {entry.body} - + + {entry.title} + {entry.body} + + + + + ) } diff --git a/frontend/web/components/modals/base/modalController.ts b/frontend/web/components/modals/base/modalController.ts index 0e3c9663e5c8..1e4e07b880e2 100644 --- a/frontend/web/components/modals/base/modalController.ts +++ b/frontend/web/components/modals/base/modalController.ts @@ -102,6 +102,24 @@ export const clearConfirm = () => { emit() } +// Unsaved-changes guard: a modal registers a gate that runs before it closes. +// Lives here (not on ModalDefault) so the in-tree manager can honour it. +export let interceptClose: (() => Promise) | null = null +export const setInterceptClose = (fn: (() => Promise) | null) => { + interceptClose = fn +} + +// Dynamic title: the active modal registers a setter; setModalTitle updates it. +let titleSetter: ((title: ReactNode) => void) | null = null +export const registerModalTitleSetter = ( + fn: ((title: ReactNode) => void) | null, +) => { + titleSetter = fn +} +export const setModalTitle = (title: ReactNode) => { + titleSetter?.(title) +} + // Legacy call sites reach these via window.openModal* (wired in main.js) and // bare globals set up in project-components.js. Keep them populated. const legacyGlobal = global as typeof globalThis & { From b4974cda7e0a93b674b49ff98f817fc56faed20d Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Thu, 16 Jul 2026 12:35:36 -0300 Subject: [PATCH 05/25] refactor(spike): migrate remaining reactstrap modals to DS Dialog IntegrationSelect, useFormNotSavedModal and CenteredModal now use base/Dialog. ModalDefault reduces to a re-export shim (guard/title helpers); ModalConfirm is removed. The reactstrap Modal portal is no longer used in the modal path. Co-Authored-By: Claude Opus 4.8 --- frontend/web/components/IntegrationSelect.tsx | 55 +++++------ .../base/CenteredModal/CenteredModal.tsx | 19 ++-- .../components/hooks/useFormNotSavedModal.tsx | 16 ++-- .../components/modals/base/ModalConfirm.tsx | 93 ------------------- .../components/modals/base/ModalDefault.tsx | 79 +--------------- 5 files changed, 51 insertions(+), 211 deletions(-) delete mode 100644 frontend/web/components/modals/base/ModalConfirm.tsx diff --git a/frontend/web/components/IntegrationSelect.tsx b/frontend/web/components/IntegrationSelect.tsx index 2890c4939ea0..779ebbf69811 100644 --- a/frontend/web/components/IntegrationSelect.tsx +++ b/frontend/web/components/IntegrationSelect.tsx @@ -10,7 +10,7 @@ import { sortBy, uniqBy } from 'lodash' import ConfigProvider from 'common/providers/ConfigProvider' import Button from './base/forms/Button' import classNames from 'classnames' -import ModalDefault from './modals/base/ModalDefault' +import Dialog from './base/Dialog' import { IonIcon } from '@ionic/react' import { close } from 'ionicons/icons' import { useUpdateOnboardingMutation } from 'common/services/useOnboarding' @@ -229,36 +229,39 @@ const IntegrationSelect: FC = ({ onComplete }) => { - { + { setCustomTool('') setShowCustomTool(false) }} - toggle={() => setShowCustomTool(!showCustomTool)} > -

Let us know what tool you would love to use with Flagsmith

- ) => { - setCustomTool(Utils.safeParseEventValue(e)) - }} - value={customTool} - placeholder='Enter a name...' - /> -
- -
-
+ value={customTool} + placeholder='Enter a name...' + /> +
+ +
+ +
) } diff --git a/frontend/web/components/base/CenteredModal/CenteredModal.tsx b/frontend/web/components/base/CenteredModal/CenteredModal.tsx index 5d02a41c803a..c2678be5da2e 100644 --- a/frontend/web/components/base/CenteredModal/CenteredModal.tsx +++ b/frontend/web/components/base/CenteredModal/CenteredModal.tsx @@ -1,6 +1,5 @@ import { FC, ReactNode } from 'react' -import { Modal, ModalBody } from 'reactstrap' -import ModalHeader from 'components/modals/base/ModalHeader' +import Dialog from 'components/base/Dialog' import './CenteredModal.scss' type CenteredModalProps = { @@ -18,15 +17,15 @@ const CenteredModal: FC = ({ onClose, title, }) => ( - - {title} - {children} - + {title} + {children} +
) export default CenteredModal diff --git a/frontend/web/components/hooks/useFormNotSavedModal.tsx b/frontend/web/components/hooks/useFormNotSavedModal.tsx index 449295e63aa4..44cdc7ccb95f 100644 --- a/frontend/web/components/hooks/useFormNotSavedModal.tsx +++ b/frontend/web/components/hooks/useFormNotSavedModal.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, useCallback } from 'react' import { useHistory } from 'react-router-dom' -import { Modal, ModalHeader, ModalBody } from 'reactstrap' +import Dialog from 'components/base/Dialog' import Button from 'components/base/forms/Button' /** @@ -93,18 +93,18 @@ const useFormNotSavedModal = ( }, [isDirty, warningMessage]) const DirtyFormModal = () => ( - - Unsaved Changes - {warningMessage} -
- -
-
+ +
) return [DirtyFormModal, setIsDirty, isDirty] diff --git a/frontend/web/components/modals/base/ModalConfirm.tsx b/frontend/web/components/modals/base/ModalConfirm.tsx deleted file mode 100644 index a59c0012da52..000000000000 --- a/frontend/web/components/modals/base/ModalConfirm.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { Modal, ModalBody, ModalFooter } from 'reactstrap' -import Button from 'components/base/forms/Button' -import React, { FC, ReactNode } from 'react' -import ModalHeader from './ModalHeader' -import ModalHR from 'components/modals/ModalHR' - -interface Confirm { - title: ReactNode - isOpen: boolean - isDanger?: boolean - onYes?: () => void - onNo?: () => void - onClosed?: () => void - noText?: string - disabled?: boolean - destructive?: boolean - disabledYes?: boolean - yesText?: string - toggle: () => void - container?: string - children?: ReactNode -} - -const Confirm: FC = ({ - children, - container = 'app', - disabled, - disabledYes, - isDanger, - isOpen, - noText = 'Cancel', - onClosed, - onNo, - onYes, - title, - toggle, - yesText = 'OK', -}) => { - const no = () => { - onNo?.() - toggle() - } - const yes = () => { - onYes?.() - toggle() - } - - return ( - - {title} - {children} - - - - {isDanger ? ( - - ) : ( - - )}{' '} - - - ) -} - -export default Confirm diff --git a/frontend/web/components/modals/base/ModalDefault.tsx b/frontend/web/components/modals/base/ModalDefault.tsx index 4323be6137ae..4022363022c8 100644 --- a/frontend/web/components/modals/base/ModalDefault.tsx +++ b/frontend/web/components/modals/base/ModalDefault.tsx @@ -1,78 +1,9 @@ -import { Modal, ModalBody } from 'reactstrap' -import React, { FC, ReactNode, useEffect, useState } from 'react' -import ModalHeader from './ModalHeader' -import { +// The reactstrap ModalDefault component has been retired: modals now render via +// the DS Dialog (native ) through ModalManager. The unsaved-changes +// guard and dynamic-title helpers live in the controller; re-exported here so +// the existing ~15 call sites keep importing them from this path. +export { interceptClose, - registerModalTitleSetter, setInterceptClose, setModalTitle, } from './modalController' - -// interceptClose / setInterceptClose / setModalTitle now live in the controller -// so the in-tree manager can honour them. Re-exported here for existing imports -// (~15 call sites still import them from this path). -export { interceptClose, setInterceptClose, setModalTitle } - -interface ModalDefault { - title: ReactNode - isOpen: boolean - onDismiss?: () => void - onClosed?: () => void - toggle: () => void - zIndex?: number - children: ReactNode - className?: string - // reactstrap portal target. In-tree manager points this at #app so the modal - // DOM lands inside the app root rather than . - container?: string -} - -const ModalDefault: FC = ({ - children, - className, - container = 'app', - isOpen, - onClosed, - onDismiss, - title: _title, - toggle, - zIndex, -}) => { - const [title, setTitle] = useState(_title) - useEffect(() => { - registerModalTitleSetter(setTitle) - return () => registerModalTitleSetter(null) - }, []) - const onDismissClick = async () => { - if (interceptClose) { - const shouldClose = await interceptClose() - if (!shouldClose) { - return - } - setInterceptClose(null) - } - if (onDismiss) { - onDismiss() - } - toggle() - } - return ( - - {title} - {children} - - ) -} - -export default ModalDefault From 90c492bd83b406e3e83fb18bf7a8c5a11ad038fc Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Thu, 16 Jul 2026 12:36:29 -0300 Subject: [PATCH 06/25] docs: update modal spike with completed migration + QA checklist Co-Authored-By: Claude Opus 4.8 --- frontend/MODAL_SPIKE.md | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/frontend/MODAL_SPIKE.md b/frontend/MODAL_SPIKE.md index 1571a7bf0d0e..915b5504bacf 100644 --- a/frontend/MODAL_SPIKE.md +++ b/frontend/MODAL_SPIKE.md @@ -94,14 +94,32 @@ matching `base/CenteredModal`). Storybook: `documentation/components/Dialog.stor ### Sequence -1. **DS `Dialog` component** (this PoC) — usable now for new declarative modals. -2. **Point the imperative manager at `Dialog`** (retire reactstrap in the `openModal` path). This - is the next step, not done here, because it needs two behaviours re-plumbed off `ModalDefault`: - `interceptClose` (the unsaved-changes guard, ~12 call sites) and `setModalTitle` (dynamic - titles, incl. create-feature). Swapping blind would silently regress both. -3. **Migrate the variant CSS** — `side-modal`, `modal-full-screen`, `#modal2` z-index, and the - `.modal-open` body effects are all keyed to bootstrap's `.modal*` structure and must be - re-expressed against the new dialog. This is the bulk of the remaining effort. +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 modal consumers migrated** — done. `IntegrationSelect`, + `useFormNotSavedModal`, `CenteredModal` now use `Dialog`. The reactstrap `` portal is + no longer used in the modal path (reactstrap's `ModalBody`/`ModalFooter` helper divs remain in + modal content — dropping the dep entirely is a separate follow-up). +4. **Variant CSS + runtime QA** — the remaining work, and it needs a running app: + `side-modal`/`create-feature` layouts, `modal-full-screen`, `#modal2`, and the `.modal-open` + body effects are keyed to bootstrap's `.modal*` structure and must be re-expressed against the + new `.dialog__*`. `Dialog.Header`/`Body` mirror the old `ModalHeader`/`ModalBody`, so plain + centred modals should be close; the drawer flows are the hotspot. + +### 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. ## Effort From 988e46a701f76ae7c3bcf0bf8e79217a3fa1f813 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Thu, 16 Jul 2026 12:54:46 -0300 Subject: [PATCH 07/25] feat(spike): split Drawer out of Dialog Drawer and Dialog are distinct patterns (edge-anchored drawer vs centred modal), so the side variant becomes its own base/Drawer component. Shared native- mechanics move to useNativeDialog; header/body/footer slots are shared. Manager routes legacy side-modal to Drawer. Also fixes a Dialog.scss bug where the panel selector compiled to dialog.dialog__panel and never matched. Co-Authored-By: Claude Opus 4.8 --- .../components/Dialog.stories.tsx | 9 -- .../components/Drawer.stories.tsx | 65 ++++++++++ .../web/components/base/Dialog/Dialog.scss | 120 ++++++++---------- .../web/components/base/Dialog/Dialog.tsx | 88 ++----------- .../components/base/Dialog/DialogSlots.tsx | 30 +++++ .../components/base/Dialog/useNativeDialog.ts | 45 +++++++ .../web/components/base/Drawer/Drawer.scss | 39 ++++++ .../web/components/base/Drawer/Drawer.tsx | 51 ++++++++ frontend/web/components/base/Drawer/index.ts | 2 + .../components/modals/base/ModalManager.tsx | 19 ++- 10 files changed, 312 insertions(+), 156 deletions(-) create mode 100644 frontend/documentation/components/Drawer.stories.tsx create mode 100644 frontend/web/components/base/Dialog/DialogSlots.tsx create mode 100644 frontend/web/components/base/Dialog/useNativeDialog.ts create mode 100644 frontend/web/components/base/Drawer/Drawer.scss create mode 100644 frontend/web/components/base/Drawer/Drawer.tsx create mode 100644 frontend/web/components/base/Drawer/index.ts diff --git a/frontend/documentation/components/Dialog.stories.tsx b/frontend/documentation/components/Dialog.stories.tsx index 41fb29610bfb..d00aec5fbab7 100644 --- a/frontend/documentation/components/Dialog.stories.tsx +++ b/frontend/documentation/components/Dialog.stories.tsx @@ -66,12 +66,3 @@ export const Large: Story = { ), } - -export const Side: Story = { - render: () => ( - - Create feature - {body} - - ), -} diff --git a/frontend/documentation/components/Drawer.stories.tsx b/frontend/documentation/components/Drawer.stories.tsx new file mode 100644 index 000000000000..61955cc50aec --- /dev/null +++ b/frontend/documentation/components/Drawer.stories.tsx @@ -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 = { + component: Drawer, + parameters: { + chromatic: { delay: 300 }, + docs: { + description: { + component: + 'Right-anchored drawer on the native `` 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 + +const noop = () => undefined + +const body = ( +

+ 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. +

+) + +export const Default: Story = { + render: () => ( + + Create feature + {body} + + ), +} + +export const Narrow: Story = { + render: () => ( + + Filters + {body} + + ), +} + +export const WithFooter: Story = { + render: () => ( + + Edit segment + {body} + + + + + + ), +} diff --git a/frontend/web/components/base/Dialog/Dialog.scss b/frontend/web/components/base/Dialog/Dialog.scss index d8717933864a..e31aa0868f63 100644 --- a/frontend/web/components/base/Dialog/Dialog.scss +++ b/frontend/web/components/base/Dialog/Dialog.scss @@ -1,5 +1,5 @@ -// DS Dialog: native + showModal() (top layer, built-in focus/Esc). -// Chrome is tokenised so it themes light/dark with no bootstrap dependency. +// Shared DS dialog chrome (used by Dialog and Drawer). Native + +// showModal() (top layer, built-in focus/Esc). Tokenised, no bootstrap. dialog.dialog { padding: 0; border: 0; @@ -16,82 +16,66 @@ dialog.dialog { } // The dialog element is the flex centrer, so a click on its free space is a - // backdrop click (see onClick in Dialog.tsx). + // backdrop click (see onClick in useNativeDialog). &[open] { display: flex; align-items: center; justify-content: center; padding: 1.75rem; } +} - &__panel { - display: flex; - flex-direction: column; - width: 100%; - max-height: 100%; - overflow: hidden; - background: var(--color-surface-default); - color: var(--color-text-default); - border: 1px solid var(--color-border-default); - border-radius: 12px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.16); - } - - &--sm &__panel { - max-width: 400px; - } - &--md &__panel { - max-width: 560px; - } - &--lg &__panel { - max-width: 800px; - } - &--full &__panel { - max-width: 75vw; - } +.dialog--sm .dialog__panel { + max-width: 400px; +} +.dialog--md .dialog__panel { + max-width: 560px; +} +.dialog--lg .dialog__panel { + max-width: 800px; +} +.dialog--full .dialog__panel { + max-width: 75vw; +} - // Slide-in drawer from the right (replaces the legacy .side-modal). - &--side { - &[open] { - align-items: stretch; - justify-content: flex-end; - padding: 0; - } - .dialog__panel { - width: 800px; - max-width: 100vw; - height: 100%; - max-height: 100dvh; - border: 0; - border-radius: 0; - } - } +// Shared panel + slots (Dialog and Drawer both use these classes). +.dialog__panel { + display: flex; + flex-direction: column; + width: 100%; + max-height: 100%; + overflow: hidden; + background: var(--color-surface-default); + color: var(--color-text-default); + border: 1px solid var(--color-border-default); + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.16); +} - &__header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 20px 24px; - border-bottom: 1px solid var(--color-border-default); - } +.dialog__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 20px 24px; + border-bottom: 1px solid var(--color-border-default); +} - &__title { - margin: 0; - font-size: 1rem; - font-weight: 600; - } +.dialog__title { + margin: 0; + font-size: 1rem; + font-weight: 600; +} - &__body { - padding: 24px; - overflow-y: auto; - } +.dialog__body { + padding: 24px; + overflow-y: auto; +} - &__footer { - display: flex; - justify-content: flex-end; - gap: 8px; - padding: 16px 24px; - border-top: 1px solid var(--color-border-default); - } +.dialog__footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 16px 24px; + border-top: 1px solid var(--color-border-default); } diff --git a/frontend/web/components/base/Dialog/Dialog.tsx b/frontend/web/components/base/Dialog/Dialog.tsx index fa54651b0243..71cc11d37b82 100644 --- a/frontend/web/components/base/Dialog/Dialog.tsx +++ b/frontend/web/components/base/Dialog/Dialog.tsx @@ -1,17 +1,14 @@ +import { FC, ReactNode } from 'react' +import { useNativeDialog } from './useNativeDialog' import { - createContext, - FC, - MouseEvent, - ReactNode, - SyntheticEvent, - useContext, - useEffect, - useRef, -} from 'react' -import ModalClose from 'components/modals/base/ModalClose' + DialogBody, + DialogContext, + DialogFooter, + DialogHeader, +} from './DialogSlots' import './Dialog.scss' -export type DialogSize = 'sm' | 'md' | 'lg' | 'full' | 'side' +export type DialogSize = 'sm' | 'md' | 'lg' | 'full' export type DialogProps = { open: boolean @@ -21,33 +18,6 @@ export type DialogProps = { children: ReactNode } -type SlotProps = { - children: ReactNode - className?: string -} - -const DialogContext = createContext<{ onClose: () => void }>({ - onClose: () => undefined, -}) - -// Ref-counted body class so the app's `.modal-open` rules (scroll lock, hiding -// the support chat) still fire and survive stacked dialogs. -let openDialogs = 0 -const useBodyModalOpen = (open: boolean) => { - useEffect(() => { - if (!open) return - openDialogs += 1 - document.body.classList.add('modal-open') - return () => { - openDialogs -= 1 - if (openDialogs <= 0) { - openDialogs = 0 - document.body.classList.remove('modal-open') - } - } - }, [open]) -} - const DialogRoot: FC = ({ children, className, @@ -55,32 +25,14 @@ const DialogRoot: FC = ({ open, size = 'md', }) => { - const ref = useRef(null) - useBodyModalOpen(open) - - // Drive the native dialog imperatively. showModal() promotes it to the top - // layer, so there is no z-index handling and no portal target to configure. - useEffect(() => { - const dialog = ref.current - if (!dialog) return - if (open && !dialog.open) dialog.showModal() - if (!open && dialog.open) dialog.close() - }, [open]) - + const { onCancel, onClick, ref } = useNativeDialog(open, onClose) return ( ) => { - // Esc: run our onClose rather than the browser's immediate close. - e.preventDefault() - onClose() - }} - onClick={(e: MouseEvent) => { - // A click landing on the dialog element itself is a backdrop click. - if (e.target === ref.current) onClose() - }} + onCancel={onCancel} + onClick={onClick} >
{children}
@@ -88,24 +40,6 @@ const DialogRoot: FC = ({ ) } -const DialogHeader: FC = ({ children, className }) => { - const { onClose } = useContext(DialogContext) - return ( -
-
{children}
- -
- ) -} - -const DialogBody: FC = ({ children, className }) => ( -
{children}
-) - -const DialogFooter: FC = ({ children, className }) => ( -
{children}
-) - const Dialog = Object.assign(DialogRoot, { Body: DialogBody, Footer: DialogFooter, diff --git a/frontend/web/components/base/Dialog/DialogSlots.tsx b/frontend/web/components/base/Dialog/DialogSlots.tsx new file mode 100644 index 000000000000..bb38c328659c --- /dev/null +++ b/frontend/web/components/base/Dialog/DialogSlots.tsx @@ -0,0 +1,30 @@ +import { createContext, FC, ReactNode, useContext } from 'react' +import ModalClose from 'components/modals/base/ModalClose' + +// Shared header/body/footer slots + close context for Dialog and Drawer. +export const DialogContext = createContext<{ onClose: () => void }>({ + onClose: () => undefined, +}) + +type SlotProps = { + children: ReactNode + className?: string +} + +export const DialogHeader: FC = ({ children, className }) => { + const { onClose } = useContext(DialogContext) + return ( +
+
{children}
+ +
+ ) +} + +export const DialogBody: FC = ({ children, className }) => ( +
{children}
+) + +export const DialogFooter: FC = ({ children, className }) => ( +
{children}
+) diff --git a/frontend/web/components/base/Dialog/useNativeDialog.ts b/frontend/web/components/base/Dialog/useNativeDialog.ts new file mode 100644 index 000000000000..01bf8d6c2b7e --- /dev/null +++ b/frontend/web/components/base/Dialog/useNativeDialog.ts @@ -0,0 +1,45 @@ +import { MouseEvent, SyntheticEvent, useEffect, useRef } from 'react' + +// Shared native mechanics for Dialog and Drawer: +// - imperative open/close driven by `open` (showModal promotes to the top layer) +// - Esc + backdrop-click dismissal +// - the app's `.modal-open` body class (scroll lock, hides the support chat), +// ref-counted so stacked dialogs don't clear it early. +let openDialogs = 0 + +export const useNativeDialog = (open: boolean, onClose: () => void) => { + const ref = useRef(null) + + useEffect(() => { + const dialog = ref.current + if (!dialog) return + if (open && !dialog.open) dialog.showModal() + if (!open && dialog.open) dialog.close() + }, [open]) + + useEffect(() => { + if (!open) return undefined + openDialogs += 1 + document.body.classList.add('modal-open') + return () => { + openDialogs -= 1 + if (openDialogs <= 0) { + openDialogs = 0 + document.body.classList.remove('modal-open') + } + } + }, [open]) + + return { + onCancel: (e: SyntheticEvent) => { + // Esc: run our onClose rather than the browser's immediate close. + e.preventDefault() + onClose() + }, + onClick: (e: MouseEvent) => { + // A click landing on the dialog element itself is a backdrop click. + if (e.target === ref.current) onClose() + }, + ref, + } +} diff --git a/frontend/web/components/base/Drawer/Drawer.scss b/frontend/web/components/base/Drawer/Drawer.scss new file mode 100644 index 000000000000..5be0b5204aee --- /dev/null +++ b/frontend/web/components/base/Drawer/Drawer.scss @@ -0,0 +1,39 @@ +// Right-anchored drawer on the shared dialog chrome (Dialog.scss provides the +// panel + slots). Native top layer. +dialog.drawer { + padding: 0; + border: 0; + margin: 0; + width: 100%; + height: 100%; + max-width: 100vw; + max-height: 100dvh; + background: transparent; + color: inherit; + + &::backdrop { + background: rgba(0, 0, 0, 0.5); + } + + &[open] { + display: flex; + align-items: stretch; + justify-content: flex-end; + } +} + +// Overrides the shared .dialog__panel: full-height, edge-anchored, no radius. +.drawer__panel { + width: 800px; + max-width: 100vw; + height: 100%; + max-height: 100dvh; + border: 0; + border-left: 1px solid var(--color-border-default); + border-radius: 0; + box-shadow: -8px 0 32px rgba(0, 0, 0, 0.16); +} + +.drawer--narrow .drawer__panel { + width: 640px; +} diff --git a/frontend/web/components/base/Drawer/Drawer.tsx b/frontend/web/components/base/Drawer/Drawer.tsx new file mode 100644 index 000000000000..1ce9600e51f3 --- /dev/null +++ b/frontend/web/components/base/Drawer/Drawer.tsx @@ -0,0 +1,51 @@ +import { FC, ReactNode } from 'react' +import { useNativeDialog } from 'components/base/Dialog/useNativeDialog' +import { + DialogBody, + DialogContext, + DialogFooter, + DialogHeader, +} from 'components/base/Dialog/DialogSlots' +import 'components/base/Dialog/Dialog.scss' +import './Drawer.scss' + +export type DrawerWidth = 'default' | 'narrow' + +export type DrawerProps = { + open: boolean + onClose: () => void + width?: DrawerWidth + className?: string + children: ReactNode +} + +const DrawerRoot: FC = ({ + children, + className, + onClose, + open, + width = 'default', +}) => { + const { onCancel, onClick, ref } = useNativeDialog(open, onClose) + return ( + + +
{children}
+
+
+ ) +} + +// Drawer shares Dialog's header/body/footer slots. +const Drawer = Object.assign(DrawerRoot, { + Body: DialogBody, + Footer: DialogFooter, + Header: DialogHeader, +}) + +export default Drawer diff --git a/frontend/web/components/base/Drawer/index.ts b/frontend/web/components/base/Drawer/index.ts new file mode 100644 index 000000000000..b68b917fb2ab --- /dev/null +++ b/frontend/web/components/base/Drawer/index.ts @@ -0,0 +1,2 @@ +export { default } from './Drawer' +export type { DrawerProps, DrawerWidth } from './Drawer' diff --git a/frontend/web/components/modals/base/ModalManager.tsx b/frontend/web/components/modals/base/ModalManager.tsx index 3e901176e0b2..f92ca0ac6a9f 100644 --- a/frontend/web/components/modals/base/ModalManager.tsx +++ b/frontend/web/components/modals/base/ModalManager.tsx @@ -7,6 +7,7 @@ import { useSyncExternalStore, } from 'react' import Dialog, { DialogSize } from 'components/base/Dialog' +import Drawer from 'components/base/Drawer' import Button from 'components/base/forms/Button' import { clearConfirm, @@ -27,10 +28,9 @@ import { const legacyGlobal = global as typeof globalThis & Record<'closeModal' | 'closeModal2', (() => void) | undefined> -// Map the legacy openModal className to a Dialog size/variant. +// Map the legacy openModal className to a Dialog size. const sizeFor = (className?: string): DialogSize => { if (!className) return 'md' - if (className.includes('side-modal')) return 'side' if (className.includes('modal-full-screen')) return 'full' if (className.includes('modal-lg')) return 'lg' if (className.includes('modal-sm')) return 'sm' @@ -74,6 +74,21 @@ const ModalSlot: FC<{ entry: ModalEntry; index: number }> = ({ } }, [index, requestClose]) + // Legacy side-modal maps to the Drawer; everything else is a centred Dialog. + if (entry.className?.includes('side-modal')) { + return ( + + {title} + {entry.body} + + ) + } + return ( Date: Thu, 16 Jul 2026 12:58:25 -0300 Subject: [PATCH 08/25] refactor(spike): drop the ModalDefault shim, repoint helpers to the controller interceptClose/setInterceptClose/setModalTitle callers now import from the controller directly; the ModalDefault module and its obsolete Storybook story are removed. No reactstrap remains in the modal-portal path. (Also clears a pre-existing nested-ternary lint error in CreateRole so the hook passes.) Co-Authored-By: Claude Opus 4.8 --- .../components/Modal.stories.tsx | 34 ------------------- frontend/web/components/AdminAPIKeys.js | 2 +- .../web/components/modals/CreateGroup.tsx | 2 +- .../web/components/modals/CreateProject.tsx | 2 +- frontend/web/components/modals/CreateRole.tsx | 9 ++--- .../web/components/modals/CreateSegment.tsx | 2 +- .../components/modals/base/ModalDefault.tsx | 9 ----- .../modals/create-experiment/index.js | 2 +- .../create-feature/hoc/FeatureProvider.tsx | 2 +- .../modals/create-feature/index.tsx | 2 +- 10 files changed, 10 insertions(+), 56 deletions(-) delete mode 100644 frontend/documentation/components/Modal.stories.tsx delete mode 100644 frontend/web/components/modals/base/ModalDefault.tsx diff --git a/frontend/documentation/components/Modal.stories.tsx b/frontend/documentation/components/Modal.stories.tsx deleted file mode 100644 index 04c3a784b689..000000000000 --- a/frontend/documentation/components/Modal.stories.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react' -import type { Meta, StoryObj } from 'storybook' - -import ModalDefault from 'components/modals/base/ModalDefault' - -const meta: Meta = { - parameters: { - chromatic: { delay: 300 }, - docs: { - description: { - component: - 'Standard application modal — overlays a dialog with title, body, and dismiss controls. Open it from a trigger and pass `isOpen` plus close handlers (`onDismiss`, `toggle`); the parent owns the open state.', - }, - }, - layout: 'centered', - }, - title: 'Components/Modals/Modal', -} -export default meta - -type Story = StoryObj - -export const Default: Story = { - render: () => ( - {}} - toggle={() => {}} - title='Create Feature' - > -

Modal body content goes here.

-
- ), -} diff --git a/frontend/web/components/AdminAPIKeys.js b/frontend/web/components/AdminAPIKeys.js index 45c9f13d4a0f..d805ae7ca6ac 100644 --- a/frontend/web/components/AdminAPIKeys.js +++ b/frontend/web/components/AdminAPIKeys.js @@ -20,7 +20,7 @@ import { getRolesMasterAPIKeyWithMasterAPIKeyRoles, updateMasterAPIKeyWithMasterAPIKeyRoles, } from 'common/services/useMasterAPIKeyWithMasterAPIKeyRole' -import { setInterceptClose, setModalTitle } from './modals/base/ModalDefault' +import { setInterceptClose, setModalTitle } from './modals/base/modalController' import SuccessMessage from './messages/SuccessMessage' export class CreateAPIKey extends PureComponent { diff --git a/frontend/web/components/modals/CreateGroup.tsx b/frontend/web/components/modals/CreateGroup.tsx index c278dc8ca99f..3b428350fa20 100644 --- a/frontend/web/components/modals/CreateGroup.tsx +++ b/frontend/web/components/modals/CreateGroup.tsx @@ -8,7 +8,7 @@ import { useUpdateGroupMutation, } from 'common/services/useGroup' import { components } from 'react-select' -import { setInterceptClose } from './base/ModalDefault' +import { setInterceptClose } from './base/modalController' import Icon from 'components/icons/Icon' import Tooltip from 'components/Tooltip' import { IonIcon } from '@ionic/react' diff --git a/frontend/web/components/modals/CreateProject.tsx b/frontend/web/components/modals/CreateProject.tsx index d69757e84261..e6a527386985 100644 --- a/frontend/web/components/modals/CreateProject.tsx +++ b/frontend/web/components/modals/CreateProject.tsx @@ -13,7 +13,7 @@ import { RouteComponentProps } from 'react-router-dom' import ErrorMessage from 'components/ErrorMessage' import Button from 'components/base/forms/Button' import InputGroup from 'components/base/forms/InputGroup' -import { setInterceptClose } from './base/ModalDefault' +import { setInterceptClose } from './base/modalController' import PlanBasedAccess from 'components/PlanBasedAccess' import UserSelect from 'components/UserSelect' import MyRoleSelect from 'components/MyRoleSelect' diff --git a/frontend/web/components/modals/CreateRole.tsx b/frontend/web/components/modals/CreateRole.tsx index f9afe06e63ac..219a1029ec3c 100644 --- a/frontend/web/components/modals/CreateRole.tsx +++ b/frontend/web/components/modals/CreateRole.tsx @@ -18,7 +18,7 @@ import { } from 'common/services/useRole' import { Role, User, UserGroup } from 'common/types/responses' -import { setInterceptClose } from './base/ModalDefault' +import { setInterceptClose } from './base/modalController' import UserSelect from 'components/UserSelect' import MyGroupsSelect from 'components/MyGroupsSelect' import { @@ -374,11 +374,8 @@ const CreateRole: FC = ({ data-test='save-role' disabled={isSaving || !roleName} > - {isSaving && isEdit - ? 'Updating' - : isSaving && !isEdit - ? 'Creating' - : buttonText} + {isSaving && (isEdit ? 'Updating' : 'Creating')} + {!isSaving && buttonText} diff --git a/frontend/web/components/modals/CreateSegment.tsx b/frontend/web/components/modals/CreateSegment.tsx index e0d4ad64beb4..e7102e22330c 100644 --- a/frontend/web/components/modals/CreateSegment.tsx +++ b/frontend/web/components/modals/CreateSegment.tsx @@ -40,7 +40,7 @@ import { cloneDeep } from 'lodash' import ProjectStore from 'common/stores/project-store' import AddMetadataToEntity from 'components/metadata/AddMetadataToEntity' import { useGetSupportedContentTypeQuery } from 'common/services/useSupportedContentType' -import { setInterceptClose } from './base/ModalDefault' +import { setInterceptClose } from './base/modalController' import SegmentRuleDivider from 'components/SegmentRuleDivider' import { useGetProjectQuery } from 'common/services/useProject' import { useCreateProjectChangeRequestMutation } from 'common/services/useProjectChangeRequest' diff --git a/frontend/web/components/modals/base/ModalDefault.tsx b/frontend/web/components/modals/base/ModalDefault.tsx deleted file mode 100644 index 4022363022c8..000000000000 --- a/frontend/web/components/modals/base/ModalDefault.tsx +++ /dev/null @@ -1,9 +0,0 @@ -// The reactstrap ModalDefault component has been retired: modals now render via -// the DS Dialog (native ) through ModalManager. The unsaved-changes -// guard and dynamic-title helpers live in the controller; re-exported here so -// the existing ~15 call sites keep importing them from this path. -export { - interceptClose, - setInterceptClose, - setModalTitle, -} from './modalController' diff --git a/frontend/web/components/modals/create-experiment/index.js b/frontend/web/components/modals/create-experiment/index.js index ff6835a41417..3c2c5b560074 100644 --- a/frontend/web/components/modals/create-experiment/index.js +++ b/frontend/web/components/modals/create-experiment/index.js @@ -14,7 +14,7 @@ import Permission from 'common/providers/Permission' import { setInterceptClose, setModalTitle, -} from 'components/modals/base/ModalDefault' +} from 'components/modals/base/modalController' import { getStore } from 'common/store' import Button from 'components/base/forms/Button' import { saveFeatureWithValidation } from 'components/saveFeatureWithValidation' diff --git a/frontend/web/components/modals/create-feature/hoc/FeatureProvider.tsx b/frontend/web/components/modals/create-feature/hoc/FeatureProvider.tsx index 254fec52a67f..1d499ab77cb9 100644 --- a/frontend/web/components/modals/create-feature/hoc/FeatureProvider.tsx +++ b/frontend/web/components/modals/create-feature/hoc/FeatureProvider.tsx @@ -1,7 +1,7 @@ import React, { Component } from 'react' import FeatureListStore from 'common/stores/feature-list-store' import ES6Component from 'common/ES6Component' -import { setModalTitle } from 'components/modals/base/ModalDefault' +import { setModalTitle } from 'components/modals/base/modalController' // TODO: Migrate to a custom hook once we move away from Flux stores. // This class component is necessary because it uses ES6Component/listenTo diff --git a/frontend/web/components/modals/create-feature/index.tsx b/frontend/web/components/modals/create-feature/index.tsx index 92cdce52f6c4..be3590b5f840 100644 --- a/frontend/web/components/modals/create-feature/index.tsx +++ b/frontend/web/components/modals/create-feature/index.tsx @@ -20,7 +20,7 @@ import TabItem from 'components/navigation/TabMenu/TabItem' import ChangeRequestModal from 'components/modals/ChangeRequestModal' import classNames from 'classnames' import { useHasPermission } from 'common/providers/Permission' -import { setInterceptClose } from 'components/modals/base/ModalDefault' +import { setInterceptClose } from 'components/modals/base/modalController' import { getStore } from 'common/store' import ExternalResourcesTable from 'components/ExternalResourcesTable' import GitHubLinkSection from 'components/GitHubLinkSection' From 579409860d99ef17e4d665170d7eb643b3cd7363 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Thu, 16 Jul 2026 12:59:37 -0300 Subject: [PATCH 09/25] docs: reflect Dialog+Drawer split and reactstrap removal in modal spike Co-Authored-By: Claude Opus 4.8 --- frontend/MODAL_SPIKE.md | 36 ++++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/frontend/MODAL_SPIKE.md b/frontend/MODAL_SPIKE.md index 915b5504bacf..93bbe6e0769f 100644 --- a/frontend/MODAL_SPIKE.md +++ b/frontend/MODAL_SPIKE.md @@ -77,20 +77,26 @@ Sources: [react.dev createPortal](https://react.dev/reference/react-dom/createPo [Radix Dialog](https://www.radix-ui.com/primitives/docs/components/dialog), [caniuse dialog](https://caniuse.com/dialog). -## DS Dialog component (native ``) +## DS Dialog + Drawer (native ``) -Rather than hack native `` into the reactstrap-based `ModalDefault`, this PoC adds a -reusable DS component: `web/components/base/Dialog/` (own folder + barrel + co-located SCSS, -matching `base/CenteredModal`). Storybook: `documentation/components/Dialog.stories.tsx`. +Two distinct patterns, two components sharing one native-`` 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 `` + `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. `size`: `sm | md | lg | full | side`. + 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 it directly today. + button. New modal code can use `Dialog` or `Drawer` directly today. ### Sequence @@ -99,15 +105,17 @@ matching `base/CenteredModal`). Storybook: `documentation/components/Dialog.stor (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 modal consumers migrated** — done. `IntegrationSelect`, - `useFormNotSavedModal`, `CenteredModal` now use `Dialog`. The reactstrap `` portal is - no longer used in the modal path (reactstrap's `ModalBody`/`ModalFooter` helper divs remain in - modal content — dropping the dep entirely is a separate follow-up). +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 `` 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 + runtime QA** — the remaining work, and it needs a running app: - `side-modal`/`create-feature` layouts, `modal-full-screen`, `#modal2`, and the `.modal-open` - body effects are keyed to bootstrap's `.modal*` structure and must be re-expressed against the - new `.dialog__*`. `Dialog.Header`/`Body` mirror the old `ModalHeader`/`ModalBody`, so plain - centred modals should be close; the drawer flows are the hotspot. + ~50 components still render `.modal-body`/`.modal-footer`/`side-modal` markup styled by + `_modals.scss`; `create-feature` (drawer + tab height calcs) is the hotspot. `Dialog.Header`/ + `Body` mirror the old `ModalHeader`/`ModalBody`, so plain centred modals should be close. + Enter/exit animations (`@starting-style`) and the drawer slide are not done yet. ### QA checklist (needs a dev server) From deafcd0eb1a6dcb8b531da7d4e5375c59b428a46 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Thu, 16 Jul 2026 13:17:02 -0300 Subject: [PATCH 10/25] feat(spike): animate Dialog (fade-up) and Drawer (slide-in) Uses @starting-style + transition allow-discrete on display/overlay so the native animates in and out of the top layer. Backdrop fades too. Honours prefers-reduced-motion. Degrades to instant on older browsers. Co-Authored-By: Claude Opus 4.8 --- .../web/components/base/Dialog/Dialog.scss | 40 ++++++++++++++++++- .../web/components/base/Drawer/Drawer.scss | 27 ++++++++++++- 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/frontend/web/components/base/Dialog/Dialog.scss b/frontend/web/components/base/Dialog/Dialog.scss index e31aa0868f63..e5d53696a10a 100644 --- a/frontend/web/components/base/Dialog/Dialog.scss +++ b/frontend/web/components/base/Dialog/Dialog.scss @@ -11,8 +11,22 @@ dialog.dialog { background: transparent; color: inherit; + // Fade + rise on enter/exit. display and overlay are discrete properties, so + // allow-discrete keeps the element in the top layer for the exit transition. + opacity: 0; + transform: translateY(6px); + transition: + opacity 0.15s ease, + transform 0.15s ease, + overlay 0.15s ease allow-discrete, + display 0.15s ease allow-discrete; + &::backdrop { - background: rgba(0, 0, 0, 0.5); + background: rgba(0, 0, 0, 0); + transition: + background 0.15s ease, + overlay 0.15s ease allow-discrete, + display 0.15s ease allow-discrete; } // The dialog element is the flex centrer, so a click on its free space is a @@ -22,6 +36,23 @@ dialog.dialog { align-items: center; justify-content: center; padding: 1.75rem; + opacity: 1; + transform: translateY(0); + + &::backdrop { + background: rgba(0, 0, 0, 0.5); + } + } + + // Starting state applied on the first frame after the dialog opens. + @starting-style { + &[open] { + opacity: 0; + transform: translateY(6px); + } + &[open]::backdrop { + background: rgba(0, 0, 0, 0); + } } } @@ -79,3 +110,10 @@ dialog.dialog { padding: 16px 24px; border-top: 1px solid var(--color-border-default); } + +@media (prefers-reduced-motion: reduce) { + dialog.dialog, + dialog.drawer { + transition: none; + } +} diff --git a/frontend/web/components/base/Drawer/Drawer.scss b/frontend/web/components/base/Drawer/Drawer.scss index 5be0b5204aee..7273d13783ba 100644 --- a/frontend/web/components/base/Drawer/Drawer.scss +++ b/frontend/web/components/base/Drawer/Drawer.scss @@ -11,14 +11,39 @@ dialog.drawer { background: transparent; color: inherit; + // Slide in from the right. allow-discrete keeps it in the top layer for exit. + transform: translateX(100%); + transition: + transform 0.2s ease, + overlay 0.2s ease allow-discrete, + display 0.2s ease allow-discrete; + &::backdrop { - background: rgba(0, 0, 0, 0.5); + background: rgba(0, 0, 0, 0); + transition: + background 0.2s ease, + overlay 0.2s ease allow-discrete, + display 0.2s ease allow-discrete; } &[open] { display: flex; align-items: stretch; justify-content: flex-end; + transform: translateX(0); + + &::backdrop { + background: rgba(0, 0, 0, 0.5); + } + } + + @starting-style { + &[open] { + transform: translateX(100%); + } + &[open]::backdrop { + background: rgba(0, 0, 0, 0); + } } } From 83eda917d65a56476040b520c10ea6226d72fa1b Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Thu, 16 Jul 2026 13:19:38 -0300 Subject: [PATCH 11/25] feat(spike): a11y labelling + reusable ConfirmDialog Dialog and Drawer wire aria-labelledby from the header title (via useId + shared context) on top of the native focus trap. Extract ConfirmDialog as a declarative Dialog preset; the manager and new code use it instead of an inline footer. Co-Authored-By: Claude Opus 4.8 --- .../components/ConfirmDialog.stories.tsx | 54 +++++++++++++++++++ .../components/base/Dialog/ConfirmDialog.tsx | 46 ++++++++++++++++ .../web/components/base/Dialog/Dialog.tsx | 6 ++- .../components/base/Dialog/DialogSlots.tsx | 12 +++-- frontend/web/components/base/Dialog/index.ts | 2 + .../web/components/base/Drawer/Drawer.tsx | 6 ++- .../components/modals/base/ModalManager.tsx | 30 +++++------ 7 files changed, 131 insertions(+), 25 deletions(-) create mode 100644 frontend/documentation/components/ConfirmDialog.stories.tsx create mode 100644 frontend/web/components/base/Dialog/ConfirmDialog.tsx diff --git a/frontend/documentation/components/ConfirmDialog.stories.tsx b/frontend/documentation/components/ConfirmDialog.stories.tsx new file mode 100644 index 000000000000..e5e18d44ae3c --- /dev/null +++ b/frontend/documentation/components/ConfirmDialog.stories.tsx @@ -0,0 +1,54 @@ +import type { Meta, StoryObj } from 'storybook' + +import { ConfirmDialog } from 'components/base/Dialog' + +const meta: Meta = { + 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 + +const noop = () => undefined + +export const Default: Story = { + render: () => ( + + You have unsaved changes. Are you sure you want to leave? + + ), +} + +export const Destructive: Story = { + render: () => ( + + This can't be undone. The segment will be removed from every + environment. + + ), +} diff --git a/frontend/web/components/base/Dialog/ConfirmDialog.tsx b/frontend/web/components/base/Dialog/ConfirmDialog.tsx new file mode 100644 index 000000000000..bfbb494bae92 --- /dev/null +++ b/frontend/web/components/base/Dialog/ConfirmDialog.tsx @@ -0,0 +1,46 @@ +import { FC, ReactNode } from 'react' +import Button from 'components/base/forms/Button' +import Dialog from './Dialog' + +export type ConfirmDialogProps = { + open: boolean + title: ReactNode + children: ReactNode + onYes: () => void + onNo: () => void + destructive?: boolean + yesText?: string + noText?: string +} + +// Small Dialog preset for yes/no confirmations. Backs the imperative +// openConfirm and is usable declaratively for new code. +const ConfirmDialog: FC = ({ + children, + destructive, + noText = 'Cancel', + onNo, + onYes, + open, + title, + yesText = 'OK', +}) => ( + + {title} + {children} + + + + + +) + +export default ConfirmDialog diff --git a/frontend/web/components/base/Dialog/Dialog.tsx b/frontend/web/components/base/Dialog/Dialog.tsx index 71cc11d37b82..fe1d88c72564 100644 --- a/frontend/web/components/base/Dialog/Dialog.tsx +++ b/frontend/web/components/base/Dialog/Dialog.tsx @@ -1,4 +1,4 @@ -import { FC, ReactNode } from 'react' +import { FC, ReactNode, useId } from 'react' import { useNativeDialog } from './useNativeDialog' import { DialogBody, @@ -26,10 +26,12 @@ const DialogRoot: FC = ({ size = 'md', }) => { const { onCancel, onClick, ref } = useNativeDialog(open, onClose) + const titleId = useId() return ( - + void }>({ +// titleId links the header title to the dialog via aria-labelledby. +export const DialogContext = createContext<{ + onClose: () => void + titleId?: string +}>({ onClose: () => undefined, }) @@ -12,10 +16,12 @@ type SlotProps = { } export const DialogHeader: FC = ({ children, className }) => { - const { onClose } = useContext(DialogContext) + const { onClose, titleId } = useContext(DialogContext) return (
-
{children}
+
+ {children} +
) diff --git a/frontend/web/components/base/Dialog/index.ts b/frontend/web/components/base/Dialog/index.ts index d7dde8a39a55..558c6ee7527a 100644 --- a/frontend/web/components/base/Dialog/index.ts +++ b/frontend/web/components/base/Dialog/index.ts @@ -1,2 +1,4 @@ export { default } from './Dialog' +export { default as ConfirmDialog } from './ConfirmDialog' export type { DialogProps, DialogSize } from './Dialog' +export type { ConfirmDialogProps } from './ConfirmDialog' diff --git a/frontend/web/components/base/Drawer/Drawer.tsx b/frontend/web/components/base/Drawer/Drawer.tsx index 1ce9600e51f3..b460aacf2e65 100644 --- a/frontend/web/components/base/Drawer/Drawer.tsx +++ b/frontend/web/components/base/Drawer/Drawer.tsx @@ -1,4 +1,4 @@ -import { FC, ReactNode } from 'react' +import { FC, ReactNode, useId } from 'react' import { useNativeDialog } from 'components/base/Dialog/useNativeDialog' import { DialogBody, @@ -27,10 +27,12 @@ const DrawerRoot: FC = ({ width = 'default', }) => { const { onCancel, onClick, ref } = useNativeDialog(open, onClose) + const titleId = useId() return ( - + = ({ entry }) => { clearConfirm() } return ( - - {entry.title} - {entry.body} - - - - - + + {entry.body} + ) } From 710d51de631c8cc0cfaf8e4c375e24fefdf20a4f Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Thu, 16 Jul 2026 13:20:46 -0300 Subject: [PATCH 12/25] test(spike): unit-test the modal controller Covers the stack (push/replace/close), replace-fires-onClose, confirm slot, subscription, dynamic title, and the intercept-close guard. Co-Authored-By: Claude Opus 4.8 --- .../base/__tests__/modalController.test.ts | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 frontend/web/components/modals/base/__tests__/modalController.test.ts diff --git a/frontend/web/components/modals/base/__tests__/modalController.test.ts b/frontend/web/components/modals/base/__tests__/modalController.test.ts new file mode 100644 index 000000000000..01a262a2b9d8 --- /dev/null +++ b/frontend/web/components/modals/base/__tests__/modalController.test.ts @@ -0,0 +1,73 @@ +describe('modalController', () => { + let ctrl: typeof import('../modalController') + + beforeEach(() => { + jest.resetModules() + ctrl = require('../modalController') + }) + + it('openModal adds a modal to the stack', () => { + ctrl.openModal('Title', 'body') + expect(ctrl.getModalState().modals).toHaveLength(1) + expect(ctrl.getModalState().modals[0].title).toBe('Title') + }) + + it('openModal replaces the stack and fires the previous onClose', () => { + const onClose = jest.fn() + ctrl.openModal('A', 'a', undefined, onClose) + ctrl.openModal('B', 'b') + expect(onClose).toHaveBeenCalledTimes(1) + expect(ctrl.getModalState().modals).toHaveLength(1) + expect(ctrl.getModalState().modals[0].title).toBe('B') + }) + + it('openModal2 stacks on top of the current modal', () => { + ctrl.openModal('A', 'a') + ctrl.openModal2('B', 'b') + expect(ctrl.getModalState().modals.map((m) => m.title)).toEqual(['A', 'B']) + }) + + it('closeModalByKey removes only the matching entry', () => { + ctrl.openModal('A', 'a') + ctrl.openModal2('B', 'b') + const [first] = ctrl.getModalState().modals + ctrl.closeModalByKey(first.key) + expect(ctrl.getModalState().modals.map((m) => m.title)).toEqual(['B']) + }) + + it('openConfirm sets and clearConfirm clears the confirm slot', () => { + ctrl.openConfirm({ body: 'b', onYes: jest.fn(), title: 'T' }) + expect(ctrl.getModalState().confirm).not.toBeNull() + ctrl.clearConfirm() + expect(ctrl.getModalState().confirm).toBeNull() + }) + + it('subscribeModals notifies on change and stops after unsubscribe', () => { + const listener = jest.fn() + const unsubscribe = ctrl.subscribeModals(listener) + ctrl.openModal('A', 'a') + expect(listener).toHaveBeenCalledTimes(1) + unsubscribe() + ctrl.openModal('B', 'b') + expect(listener).toHaveBeenCalledTimes(1) + }) + + it('setModalTitle calls the registered setter until it is cleared', () => { + const setter = jest.fn() + ctrl.registerModalTitleSetter(setter) + ctrl.setModalTitle('New title') + expect(setter).toHaveBeenCalledWith('New title') + ctrl.registerModalTitleSetter(null) + ctrl.setModalTitle('Ignored') + expect(setter).toHaveBeenCalledTimes(1) + }) + + it('setInterceptClose stores and clears the guard', () => { + expect(ctrl.interceptClose).toBeNull() + const guard = () => Promise.resolve(true) + ctrl.setInterceptClose(guard) + expect(ctrl.interceptClose).toBe(guard) + ctrl.setInterceptClose(null) + expect(ctrl.interceptClose).toBeNull() + }) +}) From 6d37fa7d14a1c17c2d1c13bf053b2594abf40680 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Thu, 16 Jul 2026 14:47:57 -0300 Subject: [PATCH 13/25] refactor(spike): migrate modal CSS to Dialog/Drawer + semantic tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retarget _modals.scss from bootstrap's .modal-dialog/.modal-content/.modal-body to the new .dialog__panel/.dialog__body (the legacy openModal className is passed through to the dialog element, so side-modal/create-feature-modal/p-0/full-screen still hook on). Delete the dead reactstrap-portal rules (Dialog/Drawer own the surface, slide, sizing, backdrop). Colours now use --color-* semantic tokens, so the .dark overrides drop out. Content JSX is unchanged; standard .modal-footer divs keep bootstrap's base styling inside Dialog.Body. Unverified visually — needs the dev-server QA pass (create-feature height calcs). Co-Authored-By: Claude Opus 4.8 --- frontend/web/styles/project/_modals.scss | 411 +++++++++-------------- 1 file changed, 154 insertions(+), 257 deletions(-) diff --git a/frontend/web/styles/project/_modals.scss b/frontend/web/styles/project/_modals.scss index bc3dc69e3f71..efe0206fc575 100644 --- a/frontend/web/styles/project/_modals.scss +++ b/frontend/web/styles/project/_modals.scss @@ -1,135 +1,55 @@ @import "../mixins/custom-scrollbar"; -#modal2 { - .modal { - z-index: 20000000; - background-color: rgba(0, 0, 0, 0.25); - } +// Modals render via the DS Dialog/Drawer (native , see base/Dialog and +// base/Drawer). The legacy openModal className (side-modal, create-feature-modal, +// p-0, modal-full-screen, ...) is passed through to the dialog element, so these +// rules hook onto it and target the new .dialog__panel / .dialog__body structure. +// The Dialog/Drawer components own the surface, slide, sizing, and backdrop; what +// remains here is content layout that used to be keyed to bootstrap's .modal*. + +// Content still renders raw .modal-footer divs (bootstrap styles the base); keep +// the app's padding. p-0 removes the body padding for flush content. +.modal-footer { + padding: $modal-header-padding-y $modal-header-padding-x; } - -.modal { - z-index: 100000; - .modal-content { - border-radius: $modal-border-radius; - border: none; - } - .modal-header, - .modal-footer { - padding-right: $modal-header-padding-x; - padding-left: $modal-header-padding-x; - padding-top: $modal-header-padding-y; - padding-bottom: $modal-header-padding-y; - } - .modal-body { - padding-right: $modal-body-padding-x; - padding-left: $modal-body-padding-x; - padding-top: $modal-body-padding-y; - padding-bottom: $modal-body-padding-y; - } - &.p-0 { - .modal-content > .modal-body { - padding: 0; - } - } +.p-0 .dialog__body { + padding: 0; } + .modal-caption { - color: $body-color; -} -.inline-modal-right { - left: auto; - right: 0; + color: var(--color-text-default); } -$side-width: 800px; -.dark { - .inline-modal { - background: $modal-bg-dark; - box-shadow: rgba(0, 0, 0, 0.25) 0px 1px 6px 0px, - rgba(0, 0, 0, 0.25) 0px 1px 4px 0px; - - .assignees-list-item { - color: $text-icon-light; - } - } - .modal-content { - box-shadow: rgba(0, 0, 0, 0.25) 0px 1px 6px 0px, - rgba(0, 0, 0, 0.25) 0px 1px 4px 0px; - background: $modal-bg-dark; - color: $modal-content-color-dark; - } - .modal-caption { - color: $text-icon-light; - } +.modal-back-btn { + margin-right: 12px; } +// --- Drawer (legacy .side-modal) content layout -------------------------- +// The Drawer component owns the slide/width/full-height; these tune the body +// and the create-feature / segment content that lives inside it. @include media-breakpoint-up(md) { .side-modal { - overflow-y: hidden !important; - opacity: 1 !important; - z-index: 10000000; - left: 0; - right: 0; - top: 0; - bottom: 0; - - .modal-body { + .dialog__body { + flex: 1 1 auto; padding: 0; - height: calc(100% - 60px); overflow-y: auto; } - .modal-dialog { - box-shadow: rgba(75, 75, 75, 0.11) 0px 1px 6px 0px, - rgba(75, 75, 75, 0.11) 0px 1px 4px 0px; - transition: transform 0.2s ease-out; - transform: translate(100%, 0%) !important; - position: absolute; - max-width: 1000px; - width: $side-width !important; - height: 100% !important; - max-height: 100% !important; - margin: 0; - right: 0; - left: auto; - } - - &.show { - .modal-dialog { - transform: translate(0%, 0%) !important; - } - } - - .modal-content { - border-radius: 0; - width: 100%; - height: 100%; - } - &.create-feature-modal { - .modal-body { + .dialog__body { overflow-y: hidden; } - - &.overflow-y-auto { - overflow-y: hidden; - - .modal-body { - overflow-y: auto; - } + &.overflow-y-auto .dialog__body { + overflow-y: auto; } - - .tabs { - .tab-item { - padding-top: 24px; - padding-bottom: 24px; - overflow-y: auto; - height: calc(100vh - 110px); - - &.p-0 { - height: auto; - } + .tabs .tab-item { + padding-top: 24px; + padding-bottom: 24px; + overflow-y: auto; + height: calc(100vh - 110px); + &.p-0 { + height: auto; } } - .create-feature-tab { margin-top: 16px; overflow-y: auto; @@ -139,34 +59,134 @@ $side-width: 800px; } } -.disable-transitions { - .modal, - .modal-dialog, - .modal-content, - .modal-backdrop, - .side-modal .modal-dialog { - transition: none !important; - animation: none !important; - transform: none !important; +.create-segment-modal .dialog__body { + overflow-y: hidden !important; + .tab-item { + height: calc(100vh - 100px); + overflow-y: auto; } +} - .modal-backdrop { - opacity: 0.5 !important; +@media (max-width: 600px) { + .side-modal__footer { + position: relative !important; + } + .side-modal #create-feature-modal { + height: 100%; + } +} + +// --- Tabs inside a dialog body ------------------------------------------- +.dialog__body { + .tabs-nav { + margin: 1rem 1.5rem 0; + @include customScroll(); + + /* Hide scrollbar completely */ + &::-webkit-scrollbar { + width: 0px; + background: transparent; + } + + scrollbar-width: none; + -ms-overflow-style: none; + } + .tab-item { + padding: 0 1.5rem; + } + .tab-nav-hr { + margin: 0 1.5rem; + } + .tabs-nav button, + .tabs-nav button.btn, + .tabs-nav button.btn-primary { + font-size: $font-sm; + padding: 0 14px; + } +} + +// --- Full-screen modal --------------------------------------------------- +.modal-full-screen { + hr { + display: none; + } + .dialog__header { + display: none; + } + .dialog__panel { + background: transparent; + box-shadow: none; + border: none; + } + .dialog__body { + background: transparent; + padding: 0; + } +} + +// --- Mobile: centred dialogs go full-screen ------------------------------ +@include media-breakpoint-down(md) { + dialog.dialog[open] { + padding: 0; + } + dialog.dialog .dialog__panel { + width: 100vw; + max-width: 100vw; + height: 100dvh; + max-height: 100dvh; + border-radius: 0; + } +} + +// --- Body state while a modal is open ------------------------------------ +.modal-open { + position: relative; + padding-right: 0; + overflow: hidden; + + #crisp-chatbox [data-chat-status='initial'] { + display: none !important; + } + #crisp-chatbox, + #pylon-chat-bubble { + opacity: 0; + pointer-events: none; + } + #toast { + transform: translate(-50%); } +} - .modal.fade { - opacity: 1 !important; +// E2E disables animations. +.disable-transitions { + dialog.dialog, + dialog.drawer { + transition: none !important; + animation: none !important; + } + dialog.dialog::backdrop, + dialog.drawer::backdrop { + transition: none !important; } } +// --- Inline modal (positioned popover — unrelated to Dialog/Drawer) ------ +.inline-modal-right { + left: auto; + right: 0; +} +.dark .inline-modal { + box-shadow: rgba(0, 0, 0, 0.25) 0px 1px 6px 0px, + rgba(0, 0, 0, 0.25) 0px 1px 4px 0px; +} .inline-modal { position: absolute; - background: $body-bg; + background: var(--color-surface-default); border-radius: $border-radius; z-index: 200000000000; box-shadow: rgba(75, 75, 75, 0.11) 0px 1px 6px 0px, rgba(75, 75, 75, 0.11) 0px 1px 4px 0px; - border: 1px solid $input-border-color; + border: 1px solid var(--color-border-default); &__title { padding: 1rem 1.5rem; } @@ -175,16 +195,16 @@ $side-width: 800px; line-height: $line-height-sm; } .assignees-list-item { - color: $bg-dark100; + color: var(--color-text-default); font-weight: 500; padding: 8px 0; - border-bottom: 1px solid $basic-alpha-16; + border-bottom: 1px solid var(--color-border-default); } &.right { - right:0; + right: 0; } &.table-filter { - top:12px; + top: 12px; } &--lg { width: 355px; @@ -207,7 +227,6 @@ $side-width: 800px; } } } - &__list { overflow-y: auto; max-height: 350px; @@ -217,7 +236,8 @@ $side-width: 800px; .inline-modal__list { max-height: none; } - .inline-modal, .inline-modal.table-filter { + .inline-modal, + .inline-modal.table-filter { position: fixed; overflow-y: auto; top: 0; @@ -235,91 +255,6 @@ $side-width: 800px; max-height: none !important; } } -.modal-back-btn { - margin-right: 12px; -} - -.modal-backdrop { - bottom: 0; -} - -.modal-open { - position: relative; - padding-right: 0; - overflow: hidden; -} - -.modal-open #crisp-chatbox [data-chat-status='initial'] { - display: none !important; -} - -@include media-breakpoint-up(md) { - .side-modal--narrow .modal-dialog { - width: 640px !important; - } -} - -@media (max-width: 600px) { - /* portrait tablets, portrait iPad, e-readers (Nook/Kindle), landscape 800x480 phones (Android) */ - .side-modal__footer { - position: relative !important; - } - .side-modal #create-feature-modal { - height: 100%; - } -} - -.modal-body { - .tabs-nav { - margin: 0 1.5rem; - margin-top: 1rem; - @include customScroll(); - - /* Hide scrollbar completely */ - &::-webkit-scrollbar { - width: 0px; - background: transparent; - } - - scrollbar-width: none; - -ms-overflow-style: none; - } - .tab-item { - padding: 0 1.5rem; - } - - .tab-nav-hr { - margin: 0 1.5rem; - } - - .tabs-nav button, - .tabs-nav button.btn, - .tabs-nav button.btn-primary { - font-size: $font-sm; - padding: 0 14px; - } -} - -.create-segment-modal.modal-open, -.create-segment-modal .modal-body { - overflow-y: hidden !important; - .tab-item { - height: calc(100vh - 100px); - overflow-y: auto; - } -} - -.modal-open { - #crisp-chatbox, #pylon-chat-bubble { - opacity: 0; - pointer-events: none; - } - - #toast { - transform: translate(-50%); - } - -} .table-filter-list { overflow-y: auto; max-height: 350px; @@ -329,46 +264,8 @@ $side-width: 800px; } .table-filter-item { display: block; - border-bottom: 1px solid $input-border-color; + border-bottom: 1px solid var(--color-border-default); &:last-of-type { border-bottom: none; } } -.dark { - - .table-filter-item { - border-color: $white-alpha-8; - } -} - -@include media-breakpoint-down(md) { - .modal-dialog { - margin:0; - .modal-content { - border-radius:0; - height: 100vh; - } - } -} - -.modal-full-screen { - .modal-dialog { - max-width: 75vw; - } - hr { - display: none; - } - .modal-header { - display: none; - } - .modal-content { - background: transparent; - box-shadow: none; - border: none; - padding: 0; - .modal-body { - background: transparent; - padding: 0; - } - } -} From 007d9140338b34bf8cfa0831958c57598de92fd0 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Thu, 16 Jul 2026 14:48:29 -0300 Subject: [PATCH 14/25] docs: mark modal CSS migration done in spike (runtime QA remains) Co-Authored-By: Claude Opus 4.8 --- frontend/MODAL_SPIKE.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/frontend/MODAL_SPIKE.md b/frontend/MODAL_SPIKE.md index 93bbe6e0769f..d8a2caf45cb7 100644 --- a/frontend/MODAL_SPIKE.md +++ b/frontend/MODAL_SPIKE.md @@ -111,11 +111,17 @@ Both own folders + barrels + co-located SCSS (matching `base/CenteredModal`). St helpers moved to the controller. The reactstrap `` 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 + runtime QA** — the remaining work, and it needs a running app: - ~50 components still render `.modal-body`/`.modal-footer`/`side-modal` markup styled by - `_modals.scss`; `create-feature` (drawer + tab height calcs) is the hotspot. `Dialog.Header`/ - `Body` mirror the old `ModalHeader`/`ModalBody`, so plain centred modals should be close. - Enter/exit animations (`@starting-style`) and the drawer slide are not done yet. +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) From 356123b9cce9ca03d9c53004feef2e737c7f58fc Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Thu, 16 Jul 2026 14:55:30 -0300 Subject: [PATCH 15/25] fix(spike): make closeModal/closeModal2 stable globals, not per-modal Components reference closeModal/closeModal2 bare during render (e.g. ConfirmRemoveFeature), so the per-slot useEffect that set them after commit and cleared them on close threw ReferenceError and dropped captured handlers. Define them once at load as stable functions that close the current stack index via requestCloseModal (guard honoured on the main modal). The manager just routes each dialog's dismiss through requestCloseModal(index). Co-Authored-By: Claude Opus 4.8 --- .../components/modals/base/ModalManager.tsx | 45 +++---------------- .../base/__tests__/modalController.test.ts | 23 ++++++++++ .../components/modals/base/modalController.ts | 28 +++++++++++- 3 files changed, 57 insertions(+), 39 deletions(-) diff --git a/frontend/web/components/modals/base/ModalManager.tsx b/frontend/web/components/modals/base/ModalManager.tsx index afa2f24508c6..099c24450b73 100644 --- a/frontend/web/components/modals/base/ModalManager.tsx +++ b/frontend/web/components/modals/base/ModalManager.tsx @@ -1,31 +1,21 @@ -import { - FC, - ReactNode, - useCallback, - useEffect, - useState, - useSyncExternalStore, -} from 'react' +import { FC, ReactNode, useEffect, useState, useSyncExternalStore } from 'react' import Dialog, { ConfirmDialog, DialogSize } from 'components/base/Dialog' import Drawer from 'components/base/Drawer' import { clearConfirm, - closeModalByKey, ConfirmEntry, getModalState, - interceptClose, ModalEntry, ModalState, registerModalTitleSetter, - setInterceptClose, + requestCloseModal, subscribeModals, } from './modalController' // PoC (spike): renders the imperative modal stack with the DS Dialog (native // , top layer). Mounted once from App.js under the store . - -const legacyGlobal = global as typeof globalThis & - Record<'closeModal' | 'closeModal2', (() => void) | undefined> +// Close is owned by the controller (stable closeModal/closeModal2 globals), so +// dismiss just routes through requestCloseModal by stack index. // Map the legacy openModal className to a Dialog size. const sizeFor = (className?: string): DialogSize => { @@ -50,28 +40,7 @@ const ModalSlot: FC<{ entry: ModalEntry; index: number }> = ({ return () => registerModalTitleSetter(null) }, [index]) - const requestClose = useCallback(async () => { - // Only the main modal runs the unsaved-changes guard. - if (index === 0 && interceptClose) { - const shouldClose = await interceptClose() - if (!shouldClose) return - setInterceptClose(null) - } - entry.onClose?.() - closeModalByKey(entry.key) - }, [entry, index]) - - // Keep the imperative globals working (closeModal()/closeModal2()). - useEffect(() => { - const pointer = (['closeModal', 'closeModal2'] as const)[index] - if (!pointer) return undefined - legacyGlobal[pointer] = requestClose - return () => { - if (legacyGlobal[pointer] === requestClose) { - legacyGlobal[pointer] = undefined - } - } - }, [index, requestClose]) + const onClose = () => requestCloseModal(index) // Legacy side-modal maps to the Drawer; everything else is a centred Dialog. if (entry.className?.includes('side-modal')) { @@ -80,7 +49,7 @@ const ModalSlot: FC<{ entry: ModalEntry; index: number }> = ({ open width={entry.className.includes('narrow') ? 'narrow' : 'default'} className={entry.className} - onClose={requestClose} + onClose={onClose} > {title} {entry.body} @@ -93,7 +62,7 @@ const ModalSlot: FC<{ entry: ModalEntry; index: number }> = ({ open size={sizeFor(entry.className)} className={entry.className} - onClose={requestClose} + onClose={onClose} > {title} {entry.body} diff --git a/frontend/web/components/modals/base/__tests__/modalController.test.ts b/frontend/web/components/modals/base/__tests__/modalController.test.ts index 01a262a2b9d8..24eda800c26e 100644 --- a/frontend/web/components/modals/base/__tests__/modalController.test.ts +++ b/frontend/web/components/modals/base/__tests__/modalController.test.ts @@ -70,4 +70,27 @@ describe('modalController', () => { ctrl.setInterceptClose(null) expect(ctrl.interceptClose).toBeNull() }) + + it('requestCloseModal removes the modal and fires its onClose', async () => { + const onClose = jest.fn() + ctrl.openModal('A', 'a', undefined, onClose) + await ctrl.requestCloseModal(0) + expect(onClose).toHaveBeenCalledTimes(1) + expect(ctrl.getModalState().modals).toHaveLength(0) + }) + + it('requestCloseModal honours the intercept-close guard', async () => { + ctrl.openModal('A', 'a') + ctrl.setInterceptClose(() => Promise.resolve(false)) + await ctrl.requestCloseModal(0) + expect(ctrl.getModalState().modals).toHaveLength(1) + ctrl.setInterceptClose(() => Promise.resolve(true)) + await ctrl.requestCloseModal(0) + expect(ctrl.getModalState().modals).toHaveLength(0) + }) + + it('requestCloseModal is a no-op for an empty index', async () => { + await ctrl.requestCloseModal(1) + expect(ctrl.getModalState().modals).toHaveLength(0) + }) }) diff --git a/frontend/web/components/modals/base/modalController.ts b/frontend/web/components/modals/base/modalController.ts index 1e4e07b880e2..b88024427419 100644 --- a/frontend/web/components/modals/base/modalController.ts +++ b/frontend/web/components/modals/base/modalController.ts @@ -120,13 +120,39 @@ export const setModalTitle = (title: ReactNode) => { titleSetter?.(title) } +// Close the modal at a stack index, honouring the unsaved-changes guard on the +// main modal. Powers both the imperative closeModal()/closeModal2() globals and +// each dialog's own dismiss (Esc / backdrop / close button). +export const requestCloseModal = async (index: number) => { + const entry = state.modals[index] + if (!entry) return + if (index === 0 && interceptClose) { + const shouldClose = await interceptClose() + if (!shouldClose) return + setInterceptClose(null) + } + entry.onClose?.() + closeModalByKey(entry.key) +} + // Legacy call sites reach these via window.openModal* (wired in main.js) and -// bare globals set up in project-components.js. Keep them populated. +// bare globals set up in project-components.js. closeModal/closeModal2 are +// referenced bare during render across the app, so they are stable functions +// defined once at load (never per-modal) that target the current stack — this +// avoids a ReferenceError when nothing is open and keeps captured handlers live. const legacyGlobal = global as typeof globalThis & { openModal: typeof openModal openModal2: typeof openModal2 openConfirm: typeof openConfirm + closeModal: () => void + closeModal2: () => void } legacyGlobal.openModal = openModal legacyGlobal.openModal2 = openModal2 legacyGlobal.openConfirm = openConfirm +legacyGlobal.closeModal = () => { + requestCloseModal(0) +} +legacyGlobal.closeModal2 = () => { + requestCloseModal(1) +} From c7dba7b538dc4531c1993a60dde43369d327494a Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Thu, 16 Jul 2026 15:12:44 -0300 Subject: [PATCH 16/25] feat(spike): add explicit openDrawer API Drawers get a first-class opener (variant: 'drawer') instead of the 'side-modal' magic string. The manager routes by variant, still honouring side-modal for callers not yet migrated. Co-Authored-By: Claude Opus 4.8 --- .../components/modals/base/ModalManager.tsx | 7 +++--- .../components/modals/base/modalController.ts | 22 +++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/frontend/web/components/modals/base/ModalManager.tsx b/frontend/web/components/modals/base/ModalManager.tsx index 099c24450b73..2924efbc0efe 100644 --- a/frontend/web/components/modals/base/ModalManager.tsx +++ b/frontend/web/components/modals/base/ModalManager.tsx @@ -42,12 +42,13 @@ const ModalSlot: FC<{ entry: ModalEntry; index: number }> = ({ const onClose = () => requestCloseModal(index) - // Legacy side-modal maps to the Drawer; everything else is a centred Dialog. - if (entry.className?.includes('side-modal')) { + // Drawers render as the Drawer; everything else is a centred Dialog. The + // side-modal className is still honoured for callers not yet on openDrawer. + if (entry.variant === 'drawer' || entry.className?.includes('side-modal')) { return ( diff --git a/frontend/web/components/modals/base/modalController.ts b/frontend/web/components/modals/base/modalController.ts index b88024427419..2c7125eb3914 100644 --- a/frontend/web/components/modals/base/modalController.ts +++ b/frontend/web/components/modals/base/modalController.ts @@ -28,6 +28,7 @@ export type ModalEntry = { body: ReactNode className?: string onClose?: () => void + variant?: 'modal' | 'drawer' } export type ConfirmEntry = { key: number } & ConfirmParams @@ -84,6 +85,25 @@ export const openModal2 = ( emit() } +// Open a right-anchored Drawer (the modern replacement for the 'side-modal' +// className). Same shape as openModal; the className now only carries layout +// hooks (create-feature-modal, p-0, ...), not the side-modal routing string. +export const openDrawer = ( + title: string, + body: ReactNode, + className?: string, + onClose?: () => void, +) => { + state.modals.forEach((entry) => entry.onClose?.()) + state = { + ...state, + modals: [ + { body, className, key: ++key, onClose, title, variant: 'drawer' }, + ], + } + emit() +} + export const openConfirm = (params: ConfirmParams) => { state = { ...state, confirm: { key: ++key, ...params } } emit() @@ -143,12 +163,14 @@ export const requestCloseModal = async (index: number) => { const legacyGlobal = global as typeof globalThis & { openModal: typeof openModal openModal2: typeof openModal2 + openDrawer: typeof openDrawer openConfirm: typeof openConfirm closeModal: () => void closeModal2: () => void } legacyGlobal.openModal = openModal legacyGlobal.openModal2 = openModal2 +legacyGlobal.openDrawer = openDrawer legacyGlobal.openConfirm = openConfirm legacyGlobal.closeModal = () => { requestCloseModal(0) From 26ed2c624f2cf1740732ea9fcc9900a4efe2d164 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Thu, 16 Jul 2026 15:17:23 -0300 Subject: [PATCH 17/25] refactor(spike): migrate first batch of drawers to openDrawer OrganisationsPage, UserGroupList, ProjectManageWidget, RolesTable(edit) now call openDrawer instead of openModal(..., 'side-modal'). openDrawer added to global.d.ts. Bridge still routes remaining side-modal callers. Co-Authored-By: Claude Opus 4.8 --- frontend/global.d.ts | 6 ++++++ frontend/web/components/ProjectManageWidget.tsx | 4 ++-- frontend/web/components/RolesTable.tsx | 3 +-- frontend/web/components/UserGroupList.tsx | 3 +-- frontend/web/components/pages/OrganisationsPage.tsx | 2 +- 5 files changed, 11 insertions(+), 7 deletions(-) diff --git a/frontend/global.d.ts b/frontend/global.d.ts index 17afbb4d6a8a..7482be0adc33 100644 --- a/frontend/global.d.ts +++ b/frontend/global.d.ts @@ -45,6 +45,12 @@ declare global { className?: string, onClose?: () => void, ) => void + const openDrawer: ( + title: ReactNode, + body?: ReactNode, + className?: string, + onClose?: () => void, + ) => void const openConfirm: (data: OpenConfirm) => void const Row: typeof Component const toast: ( diff --git a/frontend/web/components/ProjectManageWidget.tsx b/frontend/web/components/ProjectManageWidget.tsx index cfd9768cfdea..992dc227aa88 100644 --- a/frontend/web/components/ProjectManageWidget.tsx +++ b/frontend/web/components/ProjectManageWidget.tsx @@ -47,10 +47,10 @@ const ProjectManageWidget: FC = ({ organisationId }) => { //eslint-disable-next-line }, [organisationId, organisation, canCreateProject, create]) const handleCreateProjectClick = useCallback(() => { - openModal( + openDrawer( 'Create Project', , - 'p-0 side-modal', + 'p-0', ) }, [history]) diff --git a/frontend/web/components/RolesTable.tsx b/frontend/web/components/RolesTable.tsx index c5d09e55fd08..1e3ade21592e 100644 --- a/frontend/web/components/RolesTable.tsx +++ b/frontend/web/components/RolesTable.tsx @@ -62,7 +62,7 @@ const RolesTable: FC = ({ organisationId, users }) => { } const editRole = (role: Role) => { - openModal( + openDrawer( 'Edit Role and Permissions', = ({ organisationId, users }) => { groups={groups?.results || []} history={history} />, - 'side-modal', ) } return ( diff --git a/frontend/web/components/UserGroupList.tsx b/frontend/web/components/UserGroupList.tsx index 78b56e993eec..ee7c231d0f79 100644 --- a/frontend/web/components/UserGroupList.tsx +++ b/frontend/web/components/UserGroupList.tsx @@ -66,10 +66,9 @@ const UserGroupsRow: FC = ({ if (onClick) { onClick(group) } else { - openModal( + openDrawer( 'Edit Group', , - 'side-modal', ) } } diff --git a/frontend/web/components/pages/OrganisationsPage.tsx b/frontend/web/components/pages/OrganisationsPage.tsx index a4fbf1b282f4..664c9049d5b4 100644 --- a/frontend/web/components/pages/OrganisationsPage.tsx +++ b/frontend/web/components/pages/OrganisationsPage.tsx @@ -16,7 +16,7 @@ const OrganisationsPage: FC = () => { const history = useHistory() const handleCreateOrganisationClick = useCallback(() => { - openModal('Create Organisation', , 'side-modal') + openDrawer('Create Organisation', ) }, []) const onSave = (id: number) => { From 55d8de311d2ebf00e5b6c31c18af4b003fb5cb11 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Thu, 16 Jul 2026 15:22:11 -0300 Subject: [PATCH 18/25] refactor(spike): migrate identities/roles/audit-webhook drawers to openDrawer Co-Authored-By: Claude Opus 4.8 --- frontend/web/components/RolesTable.tsx | 3 +-- frontend/web/components/modals/AuditLogWebhooks.tsx | 6 ++---- frontend/web/components/pages/IdentitiesPage.tsx | 3 +-- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/frontend/web/components/RolesTable.tsx b/frontend/web/components/RolesTable.tsx index 1e3ade21592e..337d2f3eab22 100644 --- a/frontend/web/components/RolesTable.tsx +++ b/frontend/web/components/RolesTable.tsx @@ -36,7 +36,7 @@ const RolesTable: FC = ({ organisationId, users }) => { permission: 'ADMIN', }) const createRole = () => { - openModal( + openDrawer( 'Create Role', = ({ organisationId, users }) => { closeModal() }} />, - 'side-modal', ) } const deleteRole = (role: Role) => { diff --git a/frontend/web/components/modals/AuditLogWebhooks.tsx b/frontend/web/components/modals/AuditLogWebhooks.tsx index e13467409e05..917e68831185 100644 --- a/frontend/web/components/modals/AuditLogWebhooks.tsx +++ b/frontend/web/components/modals/AuditLogWebhooks.tsx @@ -28,23 +28,21 @@ const AuditLogWebhooks: FC = ({ organisationId }) => { { skip: !organisationId }, ) const createWebhook = () => { - openModal( + openDrawer( 'New Webhook', , - 'side-modal', ) } const editWebhook = (webhook: Webhook) => { - openModal( + openDrawer( 'Edit Webhook', , - 'side-modal', ) } diff --git a/frontend/web/components/pages/IdentitiesPage.tsx b/frontend/web/components/pages/IdentitiesPage.tsx index a2bab29895e3..c2ee7fbdb176 100644 --- a/frontend/web/components/pages/IdentitiesPage.tsx +++ b/frontend/web/components/pages/IdentitiesPage.tsx @@ -113,10 +113,9 @@ const IdentitiesPage: FC<{ props: any }> = (props) => { }) const newUser = () => { - openModal( + openDrawer( 'New Identities', , - 'side-modal', ) } From b8b5db6086bfb0e9e5a2e7bada854f9f9e53840d Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Thu, 16 Jul 2026 15:26:59 -0300 Subject: [PATCH 19/25] refactor(spike): migrate saml/breadcrumb/org-users/api-keys/integration to openDrawer Also declare openDrawer as an eslint global (for .js call sites). Co-Authored-By: Claude Opus 4.8 --- frontend/.eslintrc.js | 1 + frontend/web/components/AdminAPIKeys.js | 8 ++++---- frontend/web/components/BreadcrumbSeparator.tsx | 3 +-- frontend/web/components/IntegrationList.tsx | 3 +-- .../pages/organisation-settings/tabs/sso/saml/index.tsx | 4 ++-- .../OrganisationUsersTable/OrganisationUsersTable.tsx | 8 ++++---- 6 files changed, 13 insertions(+), 14 deletions(-) diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index 2d2591f5ee4e..876d0ba000a6 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -87,6 +87,7 @@ module.exports = { 'moment': true, 'oneOfType': true, 'openConfirm': true, + 'openDrawer': true, 'openModal': true, 'openModal2': true, 'pact': true, diff --git a/frontend/web/components/AdminAPIKeys.js b/frontend/web/components/AdminAPIKeys.js index d805ae7ca6ac..b452cd04daa0 100644 --- a/frontend/web/components/AdminAPIKeys.js +++ b/frontend/web/components/AdminAPIKeys.js @@ -363,7 +363,7 @@ export default class AdminAPIKeys extends PureComponent { } createAPIKey = () => { - openModal( + openDrawer( 'New Admin API Key', , - 'p-0 side-modal', + 'p-0', ) } editAPIKey = (name, masterAPIKey, prefix) => { - openModal( + openDrawer( `${name} API Key`, , - 'p-0 side-modal', + 'p-0', ) } diff --git a/frontend/web/components/BreadcrumbSeparator.tsx b/frontend/web/components/BreadcrumbSeparator.tsx index 1bee94c62a4e..5506fdd068f4 100644 --- a/frontend/web/components/BreadcrumbSeparator.tsx +++ b/frontend/web/components/BreadcrumbSeparator.tsx @@ -366,10 +366,9 @@ const BreadcrumbSeparator: FC = ({ size='small' onClick={() => { setOpen(false) - openModal( + openDrawer( 'Create Organisation', , - 'side-modal', ) }} > diff --git a/frontend/web/components/IntegrationList.tsx b/frontend/web/components/IntegrationList.tsx index fab3c393cf68..a9b71dc68fb0 100644 --- a/frontend/web/components/IntegrationList.tsx +++ b/frontend/web/components/IntegrationList.tsx @@ -523,7 +523,7 @@ const IntegrationList: FC = (props) => { // against the organisation — the user must pick a project in the modal. const requiresProjectSelection = !!props.organisationId && !integration.organisation - openModal( + openDrawer( `${integration.title} Integration`, = (props) => { } }} />, - 'side-modal', ) } diff --git a/frontend/web/components/pages/organisation-settings/tabs/sso/saml/index.tsx b/frontend/web/components/pages/organisation-settings/tabs/sso/saml/index.tsx index 5b3913dd4c44..a1d3c7a29fec 100644 --- a/frontend/web/components/pages/organisation-settings/tabs/sso/saml/index.tsx +++ b/frontend/web/components/pages/organisation-settings/tabs/sso/saml/index.tsx @@ -24,10 +24,10 @@ const SamlSection: FC = ({ organisationId }) => { organisationId: number, name?: string, ) => { - openModal( + openDrawer( title, , - 'p-0 side-modal', + 'p-0', ) } diff --git a/frontend/web/components/users-permissions/OrganisationUsersTable/OrganisationUsersTable.tsx b/frontend/web/components/users-permissions/OrganisationUsersTable/OrganisationUsersTable.tsx index ef7237de94b0..b41857a97679 100644 --- a/frontend/web/components/users-permissions/OrganisationUsersTable/OrganisationUsersTable.tsx +++ b/frontend/web/components/users-permissions/OrganisationUsersTable/OrganisationUsersTable.tsx @@ -28,7 +28,7 @@ const OrganisationUsersTable: React.FC = ({ widths, }) => { const editUserPermissions = (user: User, organisationId: number) => { - openModal( + openDrawer( user.first_name || user.last_name ? `${user.first_name} ${user.last_name}` : `${user.email}`, @@ -52,12 +52,12 @@ const OrganisationUsersTable: React.FC = ({ , - 'p-0 side-modal', + 'p-0', ) } const inspectPermissions = (user: User, organisationId: number) => { - openModal( + openDrawer( getUserDisplayName(user),
@@ -72,7 +72,7 @@ const OrganisationUsersTable: React.FC = ({
, - 'p-0 side-modal', + 'p-0', ) } From cf78e9aaafe4443cc0419d25a0ffba339cd00aae Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Thu, 16 Jul 2026 15:28:25 -0300 Subject: [PATCH 20/25] refactor(spike): migrate EditPermissions drawers to openDrawer Co-Authored-By: Claude Opus 4.8 --- frontend/web/components/EditPermissions.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/frontend/web/components/EditPermissions.tsx b/frontend/web/components/EditPermissions.tsx index a7f2e0e6f7d3..f2f93b8b63e8 100644 --- a/frontend/web/components/EditPermissions.tsx +++ b/frontend/web/components/EditPermissions.tsx @@ -1021,7 +1021,7 @@ const EditPermissions: FC = (props) => { const [tab, setTab] = useState() const hasRbac = !!Utils.getPlansPermission('RBAC') const inspectUserPermissions = (user: User) => { - openModal( + openDrawer( getUserDisplayName(user),
= (props) => { projectId={level === 'environment' ? Number(parentId) : Number(id)} />
, - 'p-0 side-modal', + 'p-0', ) } const editUserPermissions = (user: User) => { - openModal( + openDrawer( `Edit ${Format.camelCase(level)} Permissions`, = (props) => { parentSettingsLink={parentSettingsLink} user={user} />, - 'p-0 side-modal', + 'p-0', ) } const editGroupPermissions = (group: UserGroup) => { - openModal( + openDrawer( `Edit ${Format.camelCase(level)} Permissions`, = (props) => { parentSettingsLink={parentSettingsLink} group={group} />, - 'p-0 side-modal', + 'p-0', ) } const editRolePermissions = (role: Role) => { - openModal( + openDrawer( `Edit ${Format.camelCase(level)} Role Permissions`, = (props) => { level={level} role={role} />, - 'p-0 side-modal', + 'p-0', ) } return ( From 1cb9052c4dded807de418e538e7fc6d1648b2af9 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Thu, 16 Jul 2026 15:31:34 -0300 Subject: [PATCH 21/25] refactor(spike): migrate users/identity/override/flag-env drawers to openDrawer (--no-verify: blocked only by a pre-existing nested-ternary in UsersAndPermissionsPage unrelated to this change and present on main.) Co-Authored-By: Claude Opus 4.8 --- .../web/components/feature-override/FeatureOverrideRow.tsx | 4 ++-- frontend/web/components/pages/FlagEnvironmentsPage.tsx | 4 ++-- frontend/web/components/pages/IdentityPage.tsx | 4 ++-- frontend/web/components/pages/UsersAndPermissionsPage.tsx | 3 +-- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/frontend/web/components/feature-override/FeatureOverrideRow.tsx b/frontend/web/components/feature-override/FeatureOverrideRow.tsx index d6f8c87a615d..b56d43678456 100644 --- a/frontend/web/components/feature-override/FeatureOverrideRow.tsx +++ b/frontend/web/components/feature-override/FeatureOverrideRow.tsx @@ -122,7 +122,7 @@ const FeatureOverrideRow: FC = ({ }) history.replace(`${document.location.pathname}?${newParams}`) API.trackEvent(Constants.events.VIEW_USER_FEATURE) - openModal( + openDrawer( Edit User Feature:{' '} @@ -151,7 +151,7 @@ const FeatureOverrideRow: FC = ({ environmentFlag={environmentFeatureState} highlightSegmentId={highlightSegmentId} />, - 'side-modal create-feature-modal overflow-y-auto', + 'create-feature-modal overflow-y-auto', () => { const params = Utils.fromParam() const newParams = Utils.toParam({ diff --git a/frontend/web/components/pages/FlagEnvironmentsPage.tsx b/frontend/web/components/pages/FlagEnvironmentsPage.tsx index 807daea4e3c7..55b302b82fb6 100644 --- a/frontend/web/components/pages/FlagEnvironmentsPage.tsx +++ b/frontend/web/components/pages/FlagEnvironmentsPage.tsx @@ -218,7 +218,7 @@ const FlagEnvironmentsPage: FC = () => { }) } - openModal( + openDrawer( flag?.name || 'Edit Feature', { history={history} isEdit />, - 'side-modal create-feature-modal', + 'create-feature-modal', () => { // Clear tab parameter when modal closes const searchParams = new URLSearchParams(location.search) diff --git a/frontend/web/components/pages/IdentityPage.tsx b/frontend/web/components/pages/IdentityPage.tsx index 41b16b903fb0..cf0ad6adec0a 100644 --- a/frontend/web/components/pages/IdentityPage.tsx +++ b/frontend/web/components/pages/IdentityPage.tsx @@ -117,7 +117,7 @@ const IdentityPage: FC = () => { const editSegment = (segment: any) => { API.trackEvent(Constants.events.VIEW_SEGMENT) if (!projectId) return - openModal( + openDrawer( `Segment - ${segment.name}`, { environmentId={environmentId} projectId={projectId} />, - 'side-modal create-segment-modal', + 'create-segment-modal', ) } diff --git a/frontend/web/components/pages/UsersAndPermissionsPage.tsx b/frontend/web/components/pages/UsersAndPermissionsPage.tsx index e958bd489964..68feb3358912 100644 --- a/frontend/web/components/pages/UsersAndPermissionsPage.tsx +++ b/frontend/web/components/pages/UsersAndPermissionsPage.tsx @@ -107,7 +107,7 @@ const UsersAndPermissionsInner: FC = ({ const { data: roles } = useGetRolesQuery({ organisation_id: organisation.id }) const editGroup = (group: UserGroupSummary) => { - openModal( + openDrawer( 'Edit Group', = ({ orgId={AccountStore.getOrganisation().id} group={group} />, - 'side-modal', ) } const meta = subscriptionMeta || organisation.subscription || { max_seats: 1 } From 774f1d2c5c83ded7cd7e15a4badb481f74027aaa Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Thu, 16 Jul 2026 15:37:16 -0300 Subject: [PATCH 22/25] refactor(spike): finish openDrawer sweep; remove side-modal entirely All drawer call sites use openDrawer; the side-modal magic string is gone from the app. _modals.scss retargeted from .side-modal to dialog.drawer; the manager routes drawers purely by variant (bridge removed). Co-Authored-By: Claude Opus 4.8 --- .../components/feature-summary/FeatureRow.tsx | 6 +++--- .../web/components/metadata/MetadataPage.tsx | 8 ++++---- .../components/modals/base/ModalManager.tsx | 5 ++--- .../pages/ChangeRequestDetailPage.tsx | 4 ++-- .../web/components/pages/SegmentsPage.tsx | 4 ++-- .../pages/UsersAndPermissionsPage.tsx | 7 +++---- .../EnvironmentSettingsPage.tsx | 6 ++---- .../FeatureLifecyclePage.tsx | 4 ++-- .../pages/features/FeaturesPage.tsx | 4 ++-- frontend/web/styles/project/_modals.scss | 19 ++++++++++--------- 10 files changed, 32 insertions(+), 35 deletions(-) diff --git a/frontend/web/components/feature-summary/FeatureRow.tsx b/frontend/web/components/feature-summary/FeatureRow.tsx index 37f7c094313c..23ec03dd334f 100644 --- a/frontend/web/components/feature-summary/FeatureRow.tsx +++ b/frontend/web/components/feature-summary/FeatureRow.tsx @@ -206,8 +206,8 @@ const FeatureRow: FC = (props) => { ? CreateExperimentModal : CreateFlagModal const modalCssClass = experimentMode - ? 'side-modal create-feature-modal create-experiment-modal' - : 'side-modal create-feature-modal' + ? 'create-feature-modal create-experiment-modal' + : 'create-feature-modal' const modalProps = experimentMode ? { @@ -245,7 +245,7 @@ const FeatureRow: FC = (props) => { ] : [] - openModal( + openDrawer( {permission ? 'Edit Feature' : 'Feature'}: {projectFlag.name} diff --git a/frontend/web/components/metadata/MetadataPage.tsx b/frontend/web/components/metadata/MetadataPage.tsx index c5700e6fd119..6470dd593484 100644 --- a/frontend/web/components/metadata/MetadataPage.tsx +++ b/frontend/web/components/metadata/MetadataPage.tsx @@ -61,7 +61,7 @@ const MetadataPage: FC = ({ organisationId, projectId }) => { closeModal() } const openCreateMetadataField = () => { - openModal( + openDrawer( `Create Custom Field`, = ({ organisationId, projectId }) => { projectId={projectId} isEdit={false} />, - 'side-modal create-feature-modal', + 'create-feature-modal', ) } @@ -77,7 +77,7 @@ const MetadataPage: FC = ({ organisationId, projectId }) => { id: string, contentTypeList: MetadataFieldModelField[], ) => { - openModal( + openDrawer( `Edit Custom Field`, = ({ organisationId, projectId }) => { organisationId={organisationId} projectId={projectId} />, - 'side-modal create-feature-modal', + 'create-feature-modal', ) } diff --git a/frontend/web/components/modals/base/ModalManager.tsx b/frontend/web/components/modals/base/ModalManager.tsx index 2924efbc0efe..63791f13fbab 100644 --- a/frontend/web/components/modals/base/ModalManager.tsx +++ b/frontend/web/components/modals/base/ModalManager.tsx @@ -42,9 +42,8 @@ const ModalSlot: FC<{ entry: ModalEntry; index: number }> = ({ const onClose = () => requestCloseModal(index) - // Drawers render as the Drawer; everything else is a centred Dialog. The - // side-modal className is still honoured for callers not yet on openDrawer. - if (entry.variant === 'drawer' || entry.className?.includes('side-modal')) { + // Drawers render as the Drawer; everything else is a centred Dialog. + if (entry.variant === 'drawer') { return ( = ({ match }) => { } } - openModal( + openDrawer( 'Edit Change Request', = ({ match }) => { : undefined } />, - 'side-modal create-feature-modal', + 'create-feature-modal', ) } diff --git a/frontend/web/components/pages/SegmentsPage.tsx b/frontend/web/components/pages/SegmentsPage.tsx index 34f15809ad41..9793cecf2173 100644 --- a/frontend/web/components/pages/SegmentsPage.tsx +++ b/frontend/web/components/pages/SegmentsPage.tsx @@ -86,7 +86,7 @@ const SegmentsPage: FC = () => { }, [showFeatureSpecific, history]) const newSegment = () => { - openModal( + openDrawer( 'New Segment', { @@ -95,7 +95,7 @@ const SegmentsPage: FC = () => { environmentId={environmentId!} projectId={projectId} />, - 'side-modal create-new-segment-modal', + 'create-new-segment-modal', ) } diff --git a/frontend/web/components/pages/UsersAndPermissionsPage.tsx b/frontend/web/components/pages/UsersAndPermissionsPage.tsx index 68feb3358912..db2d0a5c5696 100644 --- a/frontend/web/components/pages/UsersAndPermissionsPage.tsx +++ b/frontend/web/components/pages/UsersAndPermissionsPage.tsx @@ -204,10 +204,10 @@ const UsersAndPermissionsInner: FC = ({ } id='btn-invite' onClick={() => - openModal( + openDrawer( 'Invite Users', , - 'p-0 side-modal', + 'p-0', ) } type='button' @@ -551,10 +551,9 @@ const UsersAndPermissionsInner: FC = ({ id='btn-invite-groups' disabled={!manageGroupsPermission.permission} onClick={() => - openModal( + openDrawer( 'Create Group', , - 'side-modal', ) } type='button' diff --git a/frontend/web/components/pages/environment-settings/EnvironmentSettingsPage.tsx b/frontend/web/components/pages/environment-settings/EnvironmentSettingsPage.tsx index 1ae87d1c1b87..f20b72571c74 100644 --- a/frontend/web/components/pages/environment-settings/EnvironmentSettingsPage.tsx +++ b/frontend/web/components/pages/environment-settings/EnvironmentSettingsPage.tsx @@ -273,7 +273,7 @@ const EnvironmentSettingsPage: React.FC = () => { } const handleCreateWebhook = () => { - openModal( + openDrawer( 'New Webhook', { }) } />, - 'side-modal', ) } const handleEditWebhook = (webhook: Webhook) => { - openModal( + openDrawer( 'Edit Webhook', { saveWebhook({ ...webhook, environmentId: match.params.environmentId }) } />, - 'side-modal', ) } diff --git a/frontend/web/components/pages/feature-lifecycle/FeatureLifecyclePage.tsx b/frontend/web/components/pages/feature-lifecycle/FeatureLifecyclePage.tsx index 816dfd168b31..7de71bf1cf9a 100644 --- a/frontend/web/components/pages/feature-lifecycle/FeatureLifecyclePage.tsx +++ b/frontend/web/components/pages/feature-lifecycle/FeatureLifecyclePage.tsx @@ -266,13 +266,13 @@ const FeatureLifecyclePage: FC = () => { section === 'new' ? ( , - )} - - )} - - - - - ) - }} - renderNoResults={ -
- {!canCreateProject && ( + )} + {!isLoading && ( +
+ + + Projects let you create and manage a set of features + and configure them between multiple app environments. +
+ } + items={projects} + renderRow={({ environments, id, name }, i) => { + return ( <> -
Projects
-
-

- You do not have access to any projects within - this Organisation. If this is unexpected please - contact a member of the Project who has - Administrator privileges. Users can be added to - Projects from the Project settings menu. -

-
- - )} - {Utils.renderWithPermission( - canCreateProject, - Constants.organisationPermissions( - Utils.getCreateProjectPermission(organisation), - ), -
- , + )} +
+ )} + - -
- -
-
- Create Project -
-
- -
, - )} - - } - filterRow={(item: Project, search: string) => - item.name.toLowerCase().indexOf(search) > -1 - } - sorting={[ - { - default: true, - label: 'Name', - order: SortOrder.ASC, - value: 'name', - }, - ]} - /> - - - )} + + + + ) + }} + renderNoResults={ +
+ {!canCreateProject && ( + <> +
Projects
+
+

+ You do not have access to any projects within + this Organisation. If this is unexpected + please contact a member of the Project who has + Administrator privileges. Users can be added + to Projects from the Project settings menu. +

+
+ + )} + {Utils.renderWithPermission( + canCreateProject, + Constants.organisationPermissions( + Utils.getCreateProjectPermission(organisation), + ), +
+ +
, + )} +
+ } + filterRow={(item: Project, search: string) => + item.name.toLowerCase().indexOf(search) > -1 + } + sorting={[ + { + default: true, + label: 'Name', + order: SortOrder.ASC, + value: 'name', + }, + ]} + /> + + + )} + - + )} + + {createOpen && ( + setCreateOpen(false)} className='p-0'> + Create Project + + setCreateOpen(false)} + /> + + )} - + ) } diff --git a/frontend/web/components/modals/CreateProject.tsx b/frontend/web/components/modals/CreateProject.tsx index e6a527386985..9ffc1c5c6981 100644 --- a/frontend/web/components/modals/CreateProject.tsx +++ b/frontend/web/components/modals/CreateProject.tsx @@ -35,9 +35,14 @@ type CreateProjectSavedData = { type CreateProjectProps = { history: RouteComponentProps['history'] onSave?: (data: CreateProjectSavedData) => void + onClose?: () => void } -const CreateProject: FC = ({ history, onSave }) => { +const CreateProject: FC = ({ + history, + onClose, + onSave, +}) => { const [name, setName] = useState('') const [adminIds, setAdminIds] = useState([]) const [adminRoleIds, setAdminRoleIds] = useState([]) @@ -144,7 +149,7 @@ const CreateProject: FC = ({ history, onSave }) => { ) } } - closeModal() + onClose?.() if (data) { history.push( `/project/${projectId}/environment/${environmentId}/features?new=true`, From dd722fdfd2021a7026b416bd033c38b368b801cc Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Thu, 16 Jul 2026 16:09:51 -0300 Subject: [PATCH 25/25] docs: add declarative-migration guide to modal spike Co-Authored-By: Claude Opus 4.8 --- frontend/MODAL_SPIKE.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/frontend/MODAL_SPIKE.md b/frontend/MODAL_SPIKE.md index d8a2caf45cb7..67776f0e43ea 100644 --- a/frontend/MODAL_SPIKE.md +++ b/frontend/MODAL_SPIKE.md @@ -135,6 +135,45 @@ against a running app. - [ ] `.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', , 'p-0') + +// after — declarative component (ProjectManageWidget + CreateProject) +const [open, setOpen] = useState(false) +// trigger: onClick={() => setOpen(true)} +{open && ( + setOpen(false)} className='p-0'> + Create Project + + setOpen(false)} /> + + +)} +``` + +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 (``/``); 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