diff --git a/workspaces/app-defaults/.changeset/giant-geese-occur.md b/workspaces/app-defaults/.changeset/giant-geese-occur.md new file mode 100644 index 0000000000..3926dd39e1 --- /dev/null +++ b/workspaces/app-defaults/.changeset/giant-geese-occur.md @@ -0,0 +1,5 @@ +--- +'@red-hat-developer-hub/backstage-plugin-app-react': patch +--- + +Replace context based state with global store. diff --git a/workspaces/app-defaults/packages/app/src/modules/nav/Sidebar.tsx b/workspaces/app-defaults/packages/app/src/modules/nav/Sidebar.tsx index ccfd54a2ed..03e6d83503 100644 --- a/workspaces/app-defaults/packages/app/src/modules/nav/Sidebar.tsx +++ b/workspaces/app-defaults/packages/app/src/modules/nav/Sidebar.tsx @@ -23,12 +23,33 @@ import { } from '@backstage/core-components'; import { NavContentBlueprint } from '@backstage/plugin-app-react'; import { SidebarLogo } from './SidebarLogo'; +import { useAppDrawer } from '@red-hat-developer-hub/backstage-plugin-app-react'; import MenuIcon from '@material-ui/icons/Menu'; import SearchIcon from '@material-ui/icons/Search'; +import ChatIcon from '@material-ui/icons/Chat'; +import HelpIcon from '@material-ui/icons/Help'; import { SidebarSearchModal } from '@backstage/plugin-search'; import { UserSettingsSignInAvatar } from '@backstage/plugin-user-settings'; import { NotificationsSidebarItem } from '@backstage/plugin-notifications'; +const DrawerDemoItems = () => { + const { toggleDrawer } = useAppDrawer(); + return ( + <> + } + text="Chat" + onClick={() => toggleDrawer('demo-chat')} + /> + } + text="Help" + onClick={() => toggleDrawer('demo-help')} + /> + + ); +}; + export const SidebarContent = NavContentBlueprint.make({ params: { component: ({ navItems }) => { @@ -36,8 +57,7 @@ export const SidebarContent = NavContentBlueprint.make({ item.icon} to={item.href} text={item.title} /> )); - // Skipped items - nav.take('page:search'); // Using search modal instead + nav.take('page:search'); return ( @@ -56,6 +76,7 @@ export const SidebarContent = NavContentBlueprint.make({ + JSX_2.Element; - // @public export const ApplicationDrawer: ( input: ApplicationDrawerProps, diff --git a/workspaces/app-defaults/plugins/app-react/report.api.md b/workspaces/app-defaults/plugins/app-react/report.api.md index a5fb64bae6..8a8e0cc5f7 100644 --- a/workspaces/app-defaults/plugins/app-react/report.api.md +++ b/workspaces/app-defaults/plugins/app-react/report.api.md @@ -25,11 +25,6 @@ export interface AppDrawerContent { resizable?: boolean; } -// @public -export const AppDrawerProvider: (input: { - children: React.ReactNode; -}) => JSX_2.Element; - // @public export const ApplicationDrawer: ( input: ApplicationDrawerProps, diff --git a/workspaces/app-defaults/plugins/app-react/src/alpha.ts b/workspaces/app-defaults/plugins/app-react/src/alpha.ts index cb573b833c..929f991052 100644 --- a/workspaces/app-defaults/plugins/app-react/src/alpha.ts +++ b/workspaces/app-defaults/plugins/app-react/src/alpha.ts @@ -15,10 +15,9 @@ */ /** - * New Frontend System extension APIs for the RHDH app drawer. + * New Frontend System extension APIs for the RHDH app shell. * * @packageDocumentation */ -export * from './extensions'; -export type * from './drawer'; +export * from './drawer'; diff --git a/workspaces/app-defaults/plugins/app-react/src/drawer/AppDrawerContext.tsx b/workspaces/app-defaults/plugins/app-react/src/drawer/AppDrawerContext.tsx deleted file mode 100644 index 1d9e2be7d0..0000000000 --- a/workspaces/app-defaults/plugins/app-react/src/drawer/AppDrawerContext.tsx +++ /dev/null @@ -1,121 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - createContext, - useCallback, - useContext, - useMemo, - useState, -} from 'react'; - -import type { AppDrawerApi } from './types'; - -const DEFAULT_WIDTH = 500; - -const noopApi: AppDrawerApi = { - openDrawer: () => {}, - closeDrawer: () => {}, - toggleDrawer: () => {}, - isOpen: () => false, - activeDrawerId: null, - getWidth: () => 500, - setWidth: () => {}, -}; - -const AppDrawerContext = createContext(noopApi); - -/** - * Provider that holds all drawer state. Wrap the app root with this so that - * useAppDrawer() is accessible from any component. - * - * @public - */ -export const AppDrawerProvider = ({ - children, -}: { - children: React.ReactNode; -}) => { - const [activeDrawerId, setActiveDrawerId] = useState(null); - const [widths, setWidths] = useState>(() => new Map()); - - const openDrawer = useCallback((id: string) => { - setActiveDrawerId(id); - }, []); - - const closeDrawer = useCallback((id: string) => { - setActiveDrawerId(prev => (prev === id ? null : prev)); - }, []); - - const toggleDrawer = useCallback((id: string) => { - setActiveDrawerId(prev => (prev === id ? null : id)); - }, []); - - const isOpen = useCallback( - (id: string) => activeDrawerId === id, - [activeDrawerId], - ); - - const getWidth = useCallback( - (id: string) => widths.get(id) ?? DEFAULT_WIDTH, - [widths], - ); - - const setWidth = useCallback((id: string, width: number) => { - setWidths(prev => { - const next = new Map(prev); - next.set(id, width); - return next; - }); - }, []); - - const api = useMemo( - () => ({ - openDrawer, - closeDrawer, - toggleDrawer, - isOpen, - activeDrawerId, - getWidth, - setWidth, - }), - [ - openDrawer, - closeDrawer, - toggleDrawer, - isOpen, - activeDrawerId, - getWidth, - setWidth, - ], - ); - - return ( - - {children} - - ); -}; - -/** - * Hook to access the app drawer API. Returns a no-op implementation if used - * outside AppDrawerProvider, so it's safe to call unconditionally. - * - * @public - */ -export const useAppDrawer = (): AppDrawerApi => { - return useContext(AppDrawerContext); -}; diff --git a/workspaces/app-defaults/plugins/app-react/src/drawer/ApplicationDrawer.test.tsx b/workspaces/app-defaults/plugins/app-react/src/drawer/components/ApplicationDrawer.test.tsx similarity index 90% rename from workspaces/app-defaults/plugins/app-react/src/drawer/ApplicationDrawer.test.tsx rename to workspaces/app-defaults/plugins/app-react/src/drawer/components/ApplicationDrawer.test.tsx index b45dd5a45a..315e96cfa5 100644 --- a/workspaces/app-defaults/plugins/app-react/src/drawer/ApplicationDrawer.test.tsx +++ b/workspaces/app-defaults/plugins/app-react/src/drawer/components/ApplicationDrawer.test.tsx @@ -16,9 +16,10 @@ import { render, screen, act } from '@testing-library/react'; -import { AppDrawerProvider, useAppDrawer } from './AppDrawerContext'; +import { useAppDrawer } from '../hooks/useAppDrawer'; +import { drawerStore } from '../utils/drawerStore'; import { ApplicationDrawer } from './ApplicationDrawer'; -import type { AppDrawerContent } from './types'; +import type { AppDrawerContent } from '../types'; function OpenButton({ id }: { id: string }) { const { openDrawer } = useAppDrawer(); @@ -32,16 +33,15 @@ function CloseButton({ id }: { id: string }) { function renderWithProvider(contents: AppDrawerContent[]) { return render( - - - - - - , + + + + , ); } describe('ApplicationDrawer', () => { + beforeEach(() => drawerStore.reset()); afterEach(() => { document.body.classList.remove('docked-drawer-open'); document.body.style.removeProperty('--docked-drawer-width'); diff --git a/workspaces/app-defaults/plugins/app-react/src/drawer/ApplicationDrawer.tsx b/workspaces/app-defaults/plugins/app-react/src/drawer/components/ApplicationDrawer.tsx similarity index 96% rename from workspaces/app-defaults/plugins/app-react/src/drawer/ApplicationDrawer.tsx rename to workspaces/app-defaults/plugins/app-react/src/drawer/components/ApplicationDrawer.tsx index 59c73bfdf1..eba500ed8f 100644 --- a/workspaces/app-defaults/plugins/app-react/src/drawer/ApplicationDrawer.tsx +++ b/workspaces/app-defaults/plugins/app-react/src/drawer/components/ApplicationDrawer.tsx @@ -16,9 +16,9 @@ import { useEffect, useRef } from 'react'; -import { useAppDrawer } from './AppDrawerContext'; +import { useAppDrawer } from '../hooks/useAppDrawer'; import { DrawerPanel } from './DrawerPanel'; -import type { AppDrawerContent } from './types'; +import type { AppDrawerContent } from '../types'; const DEFAULT_WIDTH = 500; diff --git a/workspaces/app-defaults/plugins/app-react/src/drawer/DrawerPanel.test.tsx b/workspaces/app-defaults/plugins/app-react/src/drawer/components/DrawerPanel.test.tsx similarity index 100% rename from workspaces/app-defaults/plugins/app-react/src/drawer/DrawerPanel.test.tsx rename to workspaces/app-defaults/plugins/app-react/src/drawer/components/DrawerPanel.test.tsx diff --git a/workspaces/app-defaults/plugins/app-react/src/drawer/DrawerPanel.tsx b/workspaces/app-defaults/plugins/app-react/src/drawer/components/DrawerPanel.tsx similarity index 100% rename from workspaces/app-defaults/plugins/app-react/src/drawer/DrawerPanel.tsx rename to workspaces/app-defaults/plugins/app-react/src/drawer/components/DrawerPanel.tsx diff --git a/workspaces/app-defaults/plugins/app-react/src/extensions/AppDrawerContentBlueprint.ts b/workspaces/app-defaults/plugins/app-react/src/drawer/extensions/AppDrawerContentBlueprint.ts similarity index 100% rename from workspaces/app-defaults/plugins/app-react/src/extensions/AppDrawerContentBlueprint.ts rename to workspaces/app-defaults/plugins/app-react/src/drawer/extensions/AppDrawerContentBlueprint.ts diff --git a/workspaces/app-defaults/plugins/app-react/src/extensions/appDrawerContentDataRef.ts b/workspaces/app-defaults/plugins/app-react/src/drawer/extensions/appDrawerContentDataRef.ts similarity index 93% rename from workspaces/app-defaults/plugins/app-react/src/extensions/appDrawerContentDataRef.ts rename to workspaces/app-defaults/plugins/app-react/src/drawer/extensions/appDrawerContentDataRef.ts index 1f98dfff43..0e60978528 100644 --- a/workspaces/app-defaults/plugins/app-react/src/extensions/appDrawerContentDataRef.ts +++ b/workspaces/app-defaults/plugins/app-react/src/drawer/extensions/appDrawerContentDataRef.ts @@ -16,7 +16,7 @@ import { createExtensionDataRef } from '@backstage/frontend-plugin-api'; -import type { AppDrawerContent } from '../drawer/types'; +import type { AppDrawerContent } from '../types'; /** * Extension data ref carrying drawer content from a plugin to the host. diff --git a/workspaces/app-defaults/plugins/app-react/src/extensions/appDrawerModule.tsx b/workspaces/app-defaults/plugins/app-react/src/drawer/extensions/appDrawerModule.tsx similarity index 66% rename from workspaces/app-defaults/plugins/app-react/src/extensions/appDrawerModule.tsx rename to workspaces/app-defaults/plugins/app-react/src/drawer/extensions/appDrawerModule.tsx index 2753752c4c..0e91abc529 100644 --- a/workspaces/app-defaults/plugins/app-react/src/extensions/appDrawerModule.tsx +++ b/workspaces/app-defaults/plugins/app-react/src/drawer/extensions/appDrawerModule.tsx @@ -20,21 +20,16 @@ import { } from '@backstage/frontend-plugin-api'; import { AppRootWrapperBlueprint } from '@backstage/plugin-app-react'; -import { AppDrawerProvider } from '../drawer/AppDrawerContext'; -import { ApplicationDrawer } from '../drawer/ApplicationDrawer'; +import { ApplicationDrawer } from '../components/ApplicationDrawer'; import { appDrawerContentDataRef } from './appDrawerContentDataRef'; /** - * Wrapper extension that provides the AppDrawerContext **and** renders the - * ApplicationDrawer inside it. + * Wrapper extension that renders the ApplicationDrawer around the app content. * * Uses AppRootWrapperBlueprint.makeWithOverrides to stay aligned with the * blueprint API while adding a custom `drawers` input for content extensions. - * - * Because Backstage NFS renders app-root-element extensions as siblings - * (outside) of app-root-wrapper providers, we cannot use a separate - * app-root-element for the drawer. Combining both into one wrapper - * guarantees the drawer lives inside the context provider. + * Drawer state is managed by a global singleton store (see drawerStore.ts) + * rather than a React context provider. */ const appDrawerExtension = AppRootWrapperBlueprint.makeWithOverrides({ name: 'drawer', @@ -45,9 +40,7 @@ const appDrawerExtension = AppRootWrapperBlueprint.makeWithOverrides({ const contents = inputs.drawers.map(d => d.get(appDrawerContentDataRef)); return originalFactory({ component: ({ children }) => ( - - {children} - + {children} ), }); }, @@ -55,8 +48,8 @@ const appDrawerExtension = AppRootWrapperBlueprint.makeWithOverrides({ /** * Frontend module that provides the app drawer system. - * Registers a single wrapper extension that provides context and renders - * the drawer, plus accepts drawer content contributions via inputs. + * Registers a wrapper extension that renders the drawer and accepts + * drawer content contributions via inputs. * * @alpha */ diff --git a/workspaces/app-defaults/plugins/app-react/src/drawer/AppDrawerContext.test.tsx b/workspaces/app-defaults/plugins/app-react/src/drawer/hooks/useAppDrawer.test.tsx similarity index 89% rename from workspaces/app-defaults/plugins/app-react/src/drawer/AppDrawerContext.test.tsx rename to workspaces/app-defaults/plugins/app-react/src/drawer/hooks/useAppDrawer.test.tsx index d4f08220ee..8d5fe5dee9 100644 --- a/workspaces/app-defaults/plugins/app-react/src/drawer/AppDrawerContext.test.tsx +++ b/workspaces/app-defaults/plugins/app-react/src/drawer/hooks/useAppDrawer.test.tsx @@ -16,24 +16,26 @@ import { renderHook, act } from '@testing-library/react'; -import { AppDrawerProvider, useAppDrawer } from './AppDrawerContext'; +import { useAppDrawer } from './useAppDrawer'; +import { drawerStore } from '../utils/drawerStore'; function renderDrawerHook() { - return renderHook(() => useAppDrawer(), { - wrapper: ({ children }) => ( - {children} - ), - }); + return renderHook(() => useAppDrawer()); } -describe('AppDrawerContext', () => { - describe('useAppDrawer outside provider', () => { - it('returns a no-op fallback', () => { +describe('useAppDrawer', () => { + beforeEach(() => drawerStore.reset()); + + describe('without provider', () => { + it('works without a wrapping provider (global store)', () => { const { result } = renderHook(() => useAppDrawer()); expect(result.current.activeDrawerId).toBeNull(); expect(result.current.isOpen('any')).toBe(false); expect(result.current.getWidth('any')).toBe(500); + + act(() => result.current.openDrawer('test')); + expect(result.current.activeDrawerId).toBe('test'); }); }); diff --git a/workspaces/app-defaults/plugins/app-react/src/drawer/hooks/useAppDrawer.tsx b/workspaces/app-defaults/plugins/app-react/src/drawer/hooks/useAppDrawer.tsx new file mode 100644 index 0000000000..9a030deeba --- /dev/null +++ b/workspaces/app-defaults/plugins/app-react/src/drawer/hooks/useAppDrawer.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useSyncExternalStore } from 'react'; + +import type { AppDrawerApi } from '../types'; +import type { DrawerState } from '../utils/drawerStore'; +import { DEFAULT_WIDTH, drawerStore } from '../utils/drawerStore'; + +const SERVER_SNAPSHOT: DrawerState = { + activeDrawerId: null, + widths: new Map(), +}; +const getServerSnapshot = () => SERVER_SNAPSHOT; + +/** + * Hook to access the app drawer API. Backed by a global store so it works + * from any position in the React tree. + * + * @public + */ +export const useAppDrawer = (): AppDrawerApi => { + const snapshot = useSyncExternalStore( + drawerStore.subscribe, + drawerStore.getSnapshot, + getServerSnapshot, + ); + + return { + activeDrawerId: snapshot.activeDrawerId, + isOpen: (id: string) => snapshot.activeDrawerId === id, + getWidth: (id: string) => snapshot.widths.get(id) ?? DEFAULT_WIDTH, + openDrawer: drawerStore.openDrawer, + closeDrawer: drawerStore.closeDrawer, + toggleDrawer: drawerStore.toggleDrawer, + setWidth: drawerStore.setWidth, + }; +}; diff --git a/workspaces/app-defaults/plugins/app-react/src/drawer/index.ts b/workspaces/app-defaults/plugins/app-react/src/drawer/index.ts index f18c27e5da..8d8203c9e6 100644 --- a/workspaces/app-defaults/plugins/app-react/src/drawer/index.ts +++ b/workspaces/app-defaults/plugins/app-react/src/drawer/index.ts @@ -14,10 +14,14 @@ * limitations under the License. */ -export { AppDrawerProvider, useAppDrawer } from './AppDrawerContext'; -export { ApplicationDrawer } from './ApplicationDrawer'; -export { DrawerPanel } from './DrawerPanel'; +export { useAppDrawer } from './hooks/useAppDrawer'; +export { ApplicationDrawer } from './components/ApplicationDrawer'; +export { DrawerPanel } from './components/DrawerPanel'; -export type { ApplicationDrawerProps } from './ApplicationDrawer'; -export type { DrawerPanelProps } from './DrawerPanel'; +export { appDrawerContentDataRef } from './extensions/appDrawerContentDataRef'; +export { AppDrawerContentBlueprint } from './extensions/AppDrawerContentBlueprint'; +export { appDrawerModule } from './extensions/appDrawerModule'; + +export type { ApplicationDrawerProps } from './components/ApplicationDrawer'; +export type { DrawerPanelProps } from './components/DrawerPanel'; export type { AppDrawerContent, AppDrawerApi } from './types'; diff --git a/workspaces/app-defaults/plugins/app-react/src/drawer/utils/drawerStore.ts b/workspaces/app-defaults/plugins/app-react/src/drawer/utils/drawerStore.ts new file mode 100644 index 0000000000..b2d1070b28 --- /dev/null +++ b/workspaces/app-defaults/plugins/app-react/src/drawer/utils/drawerStore.ts @@ -0,0 +1,90 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getOrCreateGlobalSingleton } from '@backstage/version-bridge'; + +/** @internal */ +export const DEFAULT_WIDTH = 500; + +/** @internal */ +export interface DrawerState { + activeDrawerId: string | null; + widths: Map; +} + +function createDrawerStore() { + let state: DrawerState = { activeDrawerId: null, widths: new Map() }; + const listeners = new Set<() => void>(); + + function emit() { + listeners.forEach(l => l()); + } + + function update(fn: (prev: DrawerState) => DrawerState) { + state = fn(state); + emit(); + } + + return { + subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + getSnapshot() { + return state; + }, + openDrawer(id: string) { + update(s => ({ ...s, activeDrawerId: id })); + }, + closeDrawer(id: string) { + update(s => ({ + ...s, + activeDrawerId: s.activeDrawerId === id ? null : s.activeDrawerId, + })); + }, + toggleDrawer(id: string) { + update(s => ({ + ...s, + activeDrawerId: s.activeDrawerId === id ? null : id, + })); + }, + setWidth(id: string, width: number) { + update(s => { + const next = new Map(s.widths); + next.set(id, width); + return { ...s, widths: next }; + }); + }, + reset() { + state = { activeDrawerId: null, widths: new Map() }; + emit(); + }, + }; +} + +/** + * Global drawer store backed by `@backstage/version-bridge` singleton. + * Enables `useAppDrawer()` to work from any position in the React tree + * without requiring a wrapping provider. + * + * @internal + */ +export const drawerStore = getOrCreateGlobalSingleton( + 'rhdh-app-drawer', + createDrawerStore, +); diff --git a/workspaces/app-defaults/plugins/app-react/src/extensions/index.ts b/workspaces/app-defaults/plugins/app-react/src/extensions/index.ts deleted file mode 100644 index 990e4a679c..0000000000 --- a/workspaces/app-defaults/plugins/app-react/src/extensions/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export { appDrawerContentDataRef } from './appDrawerContentDataRef'; -export { AppDrawerContentBlueprint } from './AppDrawerContentBlueprint'; -export { appDrawerModule } from './appDrawerModule'; diff --git a/workspaces/app-defaults/plugins/app-react/src/index.ts b/workspaces/app-defaults/plugins/app-react/src/index.ts index b7778f8d0e..aeab03915d 100644 --- a/workspaces/app-defaults/plugins/app-react/src/index.ts +++ b/workspaces/app-defaults/plugins/app-react/src/index.ts @@ -20,4 +20,9 @@ * @packageDocumentation */ -export * from './drawer'; +export { useAppDrawer } from './drawer'; +export { ApplicationDrawer } from './drawer'; +export { DrawerPanel } from './drawer'; +export type { ApplicationDrawerProps } from './drawer'; +export type { DrawerPanelProps } from './drawer'; +export type { AppDrawerContent, AppDrawerApi } from './drawer'; diff --git a/workspaces/app-defaults/yarn.lock b/workspaces/app-defaults/yarn.lock index 8f599537d4..a6e2b560b3 100644 --- a/workspaces/app-defaults/yarn.lock +++ b/workspaces/app-defaults/yarn.lock @@ -10598,6 +10598,7 @@ __metadata: "@backstage/frontend-plugin-api": "npm:^0.15.1" "@backstage/plugin-app-react": "npm:^0.2.1" "@backstage/test-utils": "npm:^1.7.16" + "@backstage/version-bridge": "npm:^1.0.12" "@mui/material": "npm:5.18.0" "@testing-library/jest-dom": "npm:^6.0.0" "@testing-library/react": "npm:^14.0.0"