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/MODAL_SPIKE.md b/frontend/MODAL_SPIKE.md new file mode 100644 index 000000000000..67776f0e43ea --- /dev/null +++ b/frontend/MODAL_SPIKE.md @@ -0,0 +1,181 @@ +# Spike: modal mounting via in-tree portals + +## Question + +Our modals mount as **separate React roots** — `Modal.tsx` does `createRoot` on the +`#modal`/`#modal2`/`#confirm` divs and wraps each in its own ``. +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. + +## 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). + +## DS Dialog + Drawer (native ``) + +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. +- **Declarative** — the parent owns `open`; `onClose` fires on Esc, backdrop click, and the close + button. New modal code can use `Dialog` or `Drawer` directly today. + +### Sequence + +1. **DS `Dialog` component** — done. Usable for new declarative modals. +2. **Point the imperative manager at `Dialog`** — done. `ModalManager` renders `Dialog` + (default stack + inline confirm) instead of reactstrap. `interceptClose` and `setModalTitle` + moved to the controller (the manager honours them); `ModalDefault` is a re-export shim; + `ModalConfirm` removed. `openModal`/`openModal2`/`openConfirm` signatures unchanged. +3. **Other reactstrap consumers migrated + `side` split into `Drawer`** — done. + `IntegrationSelect`, `useFormNotSavedModal`, `CenteredModal` use `Dialog`; the manager routes + legacy `side-modal` to `Drawer`. `ModalDefault`/`ModalConfirm` are deleted and the guard/title + helpers moved to the controller. The reactstrap `` portal is gone from the modal path + (reactstrap's `ModalBody`/`ModalFooter` helper divs still appear in modal *content* — dropping + the dep entirely is a separate follow-up). +4. **Variant CSS migrated** — done (but unverified). `_modals.scss` retargeted from bootstrap's + `.modal-dialog`/`.modal-content`/`.modal-body` to the new `.dialog__panel`/`.dialog__body`; + the legacy `openModal` className (`side-modal`, `create-feature-modal`, `p-0`, `modal-full-screen`) + passes through to the dialog element, so those hooks still land. Dead reactstrap-portal rules + removed, colours moved to `--color-*` tokens (the `.dark` overrides drop out). Content JSX is + unchanged — standard `.modal-footer` divs keep bootstrap's base styling inside `Dialog.Body`. + Animations (fade-up / drawer slide) done via `@starting-style`. + +The only thing left is **runtime QA** — this CSS was written without a browser, so the +`create-feature` drawer (absolute tab-height calcs) and general visual parity need checking +against a running app. + +### QA checklist (needs a dev server) + +- [ ] Plain modals open/close: Esc, backdrop click, close button, and programmatic `closeModal()`. +- [ ] `openConfirm` yes/no + destructive styling. +- [ ] Stacked `openModal2` on top of a modal (z-index via the top layer). +- [ ] Unsaved-changes guard (`interceptClose`) on create/edit modals. +- [ ] Dynamic title (`setModalTitle`) in create-feature / create-experiment. +- [ ] **side-modal / create-feature drawer** — layout, tabs, height calcs (the hotspot). +- [ ] Padding parity vs the old `$modal-*-padding` tokens; dark mode. +- [ ] `.modal-open` body effects: scroll lock, support-chat hidden. +- [ ] E2E modal-heavy flows + Chromatic. + +## Eliminating the imperative API (declarative components) + +The imperative `openModal`/`openModal2`/`openDrawer`/`openConfirm` globals still exist as a +compatibility facade over the DS components. They are not the legacy *implementation* (that is +gone) — they are the public API the ~177 call sites use. The modern end-state is to drop them and +use the components directly. Several places already do (`IntegrationSelect`, `CenteredModal`, +`useFormNotSavedModal`, every Storybook story), and `ProjectManageWidget` is a worked example of +converting an imperative site: + +```tsx +// before — imperative global +const create = () => openDrawer('Create Project', , '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 +(~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/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/documentation/components/Dialog.stories.tsx b/frontend/documentation/components/Dialog.stories.tsx new file mode 100644 index 000000000000..d00aec5fbab7 --- /dev/null +++ b/frontend/documentation/components/Dialog.stories.tsx @@ -0,0 +1,68 @@ +import type { Meta, StoryObj } from 'storybook' + +import Dialog from 'components/base/Dialog' +import Button from 'components/base/forms/Button' + +const meta: Meta = { + 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} + + ), +} 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/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/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/package-lock.json b/frontend/package-lock.json index eca8b19061ed..c8b5f941ad68 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -83,7 +83,6 @@ "react-select": "^5.9.0", "react-tooltip": "^5.30.0", "react-virtualized": "^9.22.5", - "reactstrap": "^9.2.3", "recharts": "^2.1.14", "redux-persist": "^6.0.0", "refractor": "^4.8.1", @@ -5708,6 +5707,7 @@ "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/popperjs" @@ -19721,12 +19721,6 @@ "react-is": "^16.13.1" } }, - "node_modules/react-fast-compare": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", - "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==", - "license": "MIT" - }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -19805,21 +19799,6 @@ "react": "^0.14.9 || ^15.3.0 || ^16.0.0" } }, - "node_modules/react-popper": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-2.3.0.tgz", - "integrity": "sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q==", - "license": "MIT", - "dependencies": { - "react-fast-compare": "^3.0.1", - "warning": "^4.0.2" - }, - "peerDependencies": { - "@popperjs/core": "^2.0.0", - "react": "^16.8.0 || ^17 || ^18", - "react-dom": "^16.8.0 || ^17 || ^18" - } - }, "node_modules/react-redux": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.0.2.tgz", @@ -20043,24 +20022,6 @@ "react-is": "^16.13.1" } }, - "node_modules/reactstrap": { - "version": "9.2.3", - "resolved": "https://registry.npmjs.org/reactstrap/-/reactstrap-9.2.3.tgz", - "integrity": "sha512-1nXy7FIBIoOgXr3AIHOpgzcZXdj6rZE5YvNSPd1hYgwv8X64m6TAJsU0ExlieJdlRXhaRfTYRSZoTWa127b0gw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5", - "@popperjs/core": "^2.6.0", - "classnames": "^2.2.3", - "prop-types": "^15.5.8", - "react-popper": "^2.2.4", - "react-transition-group": "^4.4.2" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, "node_modules/readable-stream": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", @@ -23044,15 +23005,6 @@ "makeerror": "1.0.12" } }, - "node_modules/warning": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", - "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.0.0" - } - }, "node_modules/watchpack": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 5c8c46bf0403..4839af55f2f1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -121,7 +121,6 @@ "react-select": "^5.9.0", "react-tooltip": "^5.30.0", "react-virtualized": "^9.22.5", - "reactstrap": "^9.2.3", "recharts": "^2.1.14", "redux-persist": "^6.0.0", "refractor": "^4.8.1", diff --git a/frontend/web/components/AdminAPIKeys.js b/frontend/web/components/AdminAPIKeys.js index 45c9f13d4a0f..b452cd04daa0 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 { @@ -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/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 ( + = ({ size='small' onClick={() => { setOpen(false) - openModal( + openDrawer( 'Create Organisation', , - 'side-modal', ) }} > 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 ( 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/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/ProjectManageWidget.tsx b/frontend/web/components/ProjectManageWidget.tsx index cfd9768cfdea..5fd2fe61cd52 100644 --- a/frontend/web/components/ProjectManageWidget.tsx +++ b/frontend/web/components/ProjectManageWidget.tsx @@ -1,4 +1,4 @@ -import React, { FC, useCallback, useEffect, useMemo } from 'react' +import React, { FC, useCallback, useEffect, useMemo, useState } from 'react' import { Link } from 'react-router-dom' import { useHistory, useLocation } from 'react-router-dom' @@ -16,6 +16,7 @@ import PanelSearch from './PanelSearch' import Icon from './icons/Icon' import AppActions from 'common/dispatcher/app-actions' import CreateProjectModal from './modals/CreateProject' +import Drawer from './base/Drawer' type SegmentsPageType = { organisationId: number | null @@ -46,13 +47,10 @@ const ProjectManageWidget: FC = ({ organisationId }) => { } //eslint-disable-next-line }, [organisationId, organisation, canCreateProject, create]) + const [createOpen, setCreateOpen] = useState(false) const handleCreateProjectClick = useCallback(() => { - openModal( - 'Create Project', - , - 'p-0 side-modal', - ) - }, [history]) + setCreateOpen(true) + }, []) useEffect(() => { const { state } = location as { state: { create?: boolean } } @@ -66,180 +64,198 @@ const ProjectManageWidget: FC = ({ organisationId }) => { } }, [organisationId]) return ( - { - toast('Your project has been removed') - }} - > - {({ isLoading, projects }) => ( -
-
- {(projects && projects.length) || isLoading ? ( -
- ) : ( - isAdmin && ( -
-
- Great! Now you can create your first project. -
-

- When you create a project we'll also generate a{' '} - development and production{' '} - environment for you. -

-

- You can create features for your project, then enable and - configure them per environment. -

+ <> + { + toast('Your project has been removed') + }} + > + {({ isLoading, projects }) => ( +
+
+ {(projects && projects.length) || isLoading ? ( +
+ ) : ( + isAdmin && ( +
+
+ Great! Now you can create your first project. +
+

+ When you create a project we'll also generate a{' '} + development and{' '} + production environment for you. +

+

+ You can create features for your project, then enable and + configure them per environment. +

+
+ ) + )} + {(isLoading || !projects) && ( +
+
- ) - )} - {(isLoading || !projects) && ( -
- -
- )} - {!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 ( - <> - {i === 0 && ( -
- {Utils.renderWithPermission( - canCreateProject, - Constants.organisationPermissions( - Utils.getCreateProjectPermission( - AccountStore.getOrganisation(), - ), - ), - , - )} -
- )} - - - - - ) - }} - 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/RolesTable.tsx b/frontend/web/components/RolesTable.tsx index c5d09e55fd08..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) => { @@ -62,7 +61,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/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/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.scss b/frontend/web/components/base/Dialog/Dialog.scss new file mode 100644 index 000000000000..e5d53696a10a --- /dev/null +++ b/frontend/web/components/base/Dialog/Dialog.scss @@ -0,0 +1,119 @@ +// 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; + margin: 0; + width: 100%; + height: 100%; + max-width: 100vw; + max-height: 100dvh; + 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); + 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 + // backdrop click (see onClick in useNativeDialog). + &[open] { + display: flex; + 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); + } + } +} + +.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; +} + +// 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); +} + +.dialog__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 20px 24px; + border-bottom: 1px solid var(--color-border-default); +} + +.dialog__title { + margin: 0; + font-size: 1rem; + font-weight: 600; +} + +.dialog__body { + padding: 24px; + overflow-y: auto; +} + +.dialog__footer { + display: flex; + justify-content: flex-end; + gap: 8px; + 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/Dialog/Dialog.tsx b/frontend/web/components/base/Dialog/Dialog.tsx new file mode 100644 index 000000000000..fe1d88c72564 --- /dev/null +++ b/frontend/web/components/base/Dialog/Dialog.tsx @@ -0,0 +1,51 @@ +import { FC, ReactNode, useId } from 'react' +import { useNativeDialog } from './useNativeDialog' +import { + DialogBody, + DialogContext, + DialogFooter, + DialogHeader, +} from './DialogSlots' +import './Dialog.scss' + +export type DialogSize = 'sm' | 'md' | 'lg' | 'full' + +export type DialogProps = { + open: boolean + onClose: () => void + size?: DialogSize + className?: string + children: ReactNode +} + +const DialogRoot: FC = ({ + children, + className, + onClose, + open, + size = 'md', +}) => { + const { onCancel, onClick, ref } = useNativeDialog(open, onClose) + const titleId = useId() + return ( + + +
{children}
+
+
+ ) +} + +const Dialog = Object.assign(DialogRoot, { + Body: DialogBody, + Footer: DialogFooter, + Header: DialogHeader, +}) + +export default Dialog diff --git a/frontend/web/components/base/Dialog/DialogSlots.tsx b/frontend/web/components/base/Dialog/DialogSlots.tsx new file mode 100644 index 000000000000..1232798dccb9 --- /dev/null +++ b/frontend/web/components/base/Dialog/DialogSlots.tsx @@ -0,0 +1,36 @@ +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. +// titleId links the header title to the dialog via aria-labelledby. +export const DialogContext = createContext<{ + onClose: () => void + titleId?: string +}>({ + onClose: () => undefined, +}) + +type SlotProps = { + children: ReactNode + className?: string +} + +export const DialogHeader: FC = ({ children, className }) => { + const { onClose, titleId } = 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/index.ts b/frontend/web/components/base/Dialog/index.ts new file mode 100644 index 000000000000..558c6ee7527a --- /dev/null +++ b/frontend/web/components/base/Dialog/index.ts @@ -0,0 +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/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..7273d13783ba --- /dev/null +++ b/frontend/web/components/base/Drawer/Drawer.scss @@ -0,0 +1,64 @@ +// 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; + + // 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); + 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); + } + } +} + +// 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..b460aacf2e65 --- /dev/null +++ b/frontend/web/components/base/Drawer/Drawer.tsx @@ -0,0 +1,53 @@ +import { FC, ReactNode, useId } 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) + const titleId = useId() + 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/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/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/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/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/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/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..9ffc1c5c6981 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' @@ -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`, 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/Modal.tsx b/frontend/web/components/modals/base/Modal.tsx index aa8525b1c563..b90898c415b3 100644 --- a/frontend/web/components/modals/base/Modal.tsx +++ b/frontend/web/components/modals/base/Modal.tsx @@ -1,183 +1,11 @@ -import { - Modal as _Modal, - ModalBody as _ModalBody, - ModalFooter as _ModalFooter, - ModalHeader as _ModalHeader, - ModalProps, -} from 'reactstrap' -import { JSXElementConstructor, ReactNode, useCallback, useState } from 'react' -import { createRoot, Root } from 'react-dom/client' -import Confirm from './ModalConfirm' -import ModalDefault, { interceptClose, setInterceptClose } from './ModalDefault' -import { getStore } from 'common/store' -import { Provider } from 'react-redux' - -type OpenConfirmParams = { - title: ReactNode - body: ReactNode - onYes: () => 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} - , - ) -}) +// Modal opening is delegated to the in-tree controller/manager, which renders +// the native- DS Dialog/Drawer. reactstrap is no longer used anywhere. +// Signatures are unchanged, so call sites and the window.openModal* wiring in +// main.js keep working. +export { + openModal, + openModal2, + openDrawer, + 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 deleted file mode 100644 index 5c866168e5ef..000000000000 --- a/frontend/web/components/modals/base/ModalConfirm.tsx +++ /dev/null @@ -1,89 +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 -} - -const Confirm: FC = ({ - children, - 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 deleted file mode 100644 index 8354c7bc4c7b..000000000000 --- a/frontend/web/components/modals/base/ModalDefault.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { Modal, ModalBody } from 'reactstrap' -import React, { FC, ReactNode, useState } from 'react' -import ModalHeader from './ModalHeader' - -interface ModalDefault { - title: ReactNode - isOpen: boolean - onDismiss: () => void - toggle: () => void - zIndex?: number - children: ReactNode - className?: string -} - -export let interceptClose: (() => Promise) | null = null -export const setInterceptClose = (promise: (() => Promise) | null) => { - interceptClose = promise -} - -let cb -export const setModalTitle = (title: string) => { - cb?.(title) -} -const ModalDefault: FC = ({ - children, - className, - isOpen, - onClosed, - onDismiss, - title: _title, - toggle, -}) => { - const [title, setTitle] = useState(_title) - cb = setTitle - const onDismissClick = async () => { - if (interceptClose) { - const shouldClose = await interceptClose() - if (!shouldClose) { - return - } - interceptClose = null - } - if (onDismiss) { - onDismiss() - } - toggle() - } - return ( - - {title} - {children} - - ) -} - -export default ModalDefault diff --git a/frontend/web/components/modals/base/ModalManager.tsx b/frontend/web/components/modals/base/ModalManager.tsx new file mode 100644 index 000000000000..63791f13fbab --- /dev/null +++ b/frontend/web/components/modals/base/ModalManager.tsx @@ -0,0 +1,111 @@ +import { FC, ReactNode, useEffect, useState, useSyncExternalStore } from 'react' +import Dialog, { ConfirmDialog, DialogSize } from 'components/base/Dialog' +import Drawer from 'components/base/Drawer' +import { + clearConfirm, + ConfirmEntry, + getModalState, + ModalEntry, + ModalState, + registerModalTitleSetter, + 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 . +// 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 => { + if (!className) return 'md' + 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 [title, setTitle] = useState(entry.title) + + // 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 onClose = () => requestCloseModal(index) + + // Drawers render as the Drawer; everything else is a centred Dialog. + if (entry.variant === 'drawer') { + return ( + + {title} + {entry.body} + + ) + } + + return ( + + {title} + {entry.body} + + ) +} + +const ConfirmSlot: FC<{ entry: ConfirmEntry }> = ({ entry }) => { + const no = () => { + entry.onNo?.() + clearConfirm() + } + const yes = () => { + entry.onYes?.() + clearConfirm() + } + 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/__tests__/modalController.test.ts b/frontend/web/components/modals/base/__tests__/modalController.test.ts new file mode 100644 index 000000000000..24eda800c26e --- /dev/null +++ b/frontend/web/components/modals/base/__tests__/modalController.test.ts @@ -0,0 +1,96 @@ +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() + }) + + 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 new file mode 100644 index 000000000000..2c7125eb3914 --- /dev/null +++ b/frontend/web/components/modals/base/modalController.ts @@ -0,0 +1,180 @@ +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 + variant?: 'modal' | 'drawer' +} + +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() +} + +// 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() +} + +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() +} + +// 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) +} + +// 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. 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 + 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) +} +legacyGlobal.closeModal2 = () => { + requestCloseModal(1) +} 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' diff --git a/frontend/web/components/pages/ChangeRequestDetailPage.tsx b/frontend/web/components/pages/ChangeRequestDetailPage.tsx index a338e377eb8d..d1d10aee93b0 100644 --- a/frontend/web/components/pages/ChangeRequestDetailPage.tsx +++ b/frontend/web/components/pages/ChangeRequestDetailPage.tsx @@ -213,7 +213,7 @@ const ChangeRequestDetailPage: FC = ({ match }) => { } } - openModal( + openDrawer( 'Edit Change Request', = ({ match }) => { : undefined } />, - 'side-modal create-feature-modal', + 'create-feature-modal', ) } 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/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', ) } 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/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) => { 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 e958bd489964..db2d0a5c5696 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 } @@ -205,10 +204,10 @@ const UsersAndPermissionsInner: FC = ({ } id='btn-invite' onClick={() => - openModal( + openDrawer( 'Invite Users', , - 'p-0 side-modal', + 'p-0', ) } type='button' @@ -552,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' ? (