-
Notifications
You must be signed in to change notification settings - Fork 3.7k
fix(sidebar): stop the workspace switcher stranding a phantom hover #6096
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+273
−3
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
237 changes: 237 additions & 0 deletions
237
.../[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,237 @@ | ||
| /** | ||
| * @vitest-environment jsdom | ||
| */ | ||
| import { act } from 'react' | ||
| import { createRoot, type Root } from 'react-dom/client' | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' | ||
|
|
||
| const { mockNavigateToSettings } = vi.hoisted(() => ({ mockNavigateToSettings: vi.fn() })) | ||
|
|
||
| const onWorkspaceSwitch = vi.fn() | ||
|
|
||
| vi.mock('@tanstack/react-query', () => ({ | ||
| useQueryClient: () => ({ invalidateQueries: vi.fn(), setQueryData: vi.fn() }), | ||
| })) | ||
| vi.mock('@/lib/auth/auth-client', () => ({ useActiveOrganization: () => ({ data: null }) })) | ||
| vi.mock('@/hooks/use-settings-navigation', () => ({ | ||
| useSettingsNavigation: () => ({ navigateToSettings: mockNavigateToSettings }), | ||
| })) | ||
| vi.mock('@/hooks/use-permission-config', () => ({ | ||
| usePermissionConfig: () => ({ isInvitationsDisabled: false }), | ||
| })) | ||
| vi.mock('@/app/workspace/[workspaceId]/providers/workspace-permissions-provider', () => ({ | ||
| useWorkspacePermissionsContext: () => ({ | ||
| userPermissions: { canAdmin: true, canEdit: true, canRead: true }, | ||
| }), | ||
| })) | ||
| vi.mock('@/hooks/queries/invitations', () => ({ invitationKeys: { all: ['invitations'] } })) | ||
| vi.mock('@/hooks/queries/workspace', () => ({ workspaceKeys: { all: ['workspaces'] } })) | ||
|
|
||
| /** Modal/menu siblings are irrelevant to the highlight and drag in heavy trees. */ | ||
| vi.mock('@/app/workspace/[workspaceId]/components/invite-modal', () => ({ | ||
| InviteModal: () => null, | ||
| })) | ||
| vi.mock( | ||
| '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu', | ||
| () => ({ ContextMenu: () => null }) | ||
| ) | ||
| vi.mock( | ||
| '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal', | ||
| () => ({ DeleteModal: () => null }) | ||
| ) | ||
| vi.mock( | ||
| '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/create-workspace-modal/create-workspace-modal', | ||
| () => ({ CreateWorkspaceModal: () => null }) | ||
| ) | ||
| vi.mock( | ||
| '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-menu-item', | ||
| () => ({ ViewInvitationsMenuItem: () => null }) | ||
| ) | ||
| vi.mock( | ||
| '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal', | ||
| () => ({ ViewInvitationsModal: () => null }) | ||
| ) | ||
|
|
||
| import { WorkspaceHeader } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header' | ||
|
|
||
| /** | ||
| * `@sim/emcn` is deliberately NOT mocked: the assertion is about the background class | ||
| * `chipVariants` produces, so a stubbed chip would only assert the stub. | ||
| */ | ||
| const ACTIVE_BG = 'bg-[var(--surface-active)]' | ||
|
|
||
| /** | ||
| * Above `WORKSPACE_SEARCH_THRESHOLD` (3), so the searchable/keyboard list renders. | ||
| * The current workspace is deliberately NOT first: the highlight is seeded to row 0 on | ||
| * open, so a current workspace sitting at row 0 would mask the double-mark this guards. | ||
| */ | ||
| const WORKSPACES = [ | ||
| { id: 'ws-rvt', name: 'RVT' }, | ||
| { id: 'ws-emir', name: "Emir's Workspace" }, | ||
| { id: 'ws-acme', name: 'Acme' }, | ||
| { id: 'ws-globex', name: 'Globex' }, | ||
| ] as unknown as Parameters<typeof WorkspaceHeader>[0]['workspaces'] | ||
|
|
||
| let container: HTMLDivElement | ||
| let root: Root | ||
|
|
||
| function render() { | ||
| ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true | ||
| container = document.createElement('div') | ||
| document.body.appendChild(container) | ||
| root = createRoot(container) | ||
| act(() => { | ||
| root.render( | ||
| <WorkspaceHeader | ||
| activeWorkspace={{ name: "Emir's Workspace" }} | ||
| workspaceId='ws-emir' | ||
| workspaces={WORKSPACES} | ||
| isWorkspacesLoading={false} | ||
| isCreatingWorkspace={false} | ||
| isWorkspaceMenuOpen | ||
| setIsWorkspaceMenuOpen={() => {}} | ||
| onWorkspaceSwitch={onWorkspaceSwitch} | ||
| onCreateWorkspace={async () => {}} | ||
| onRenameWorkspace={async () => {}} | ||
| onDeleteWorkspace={async () => {}} | ||
| isDeletingWorkspace={false} | ||
| onUploadLogo={() => {}} | ||
| onLeaveWorkspace={async () => {}} | ||
| isLeavingWorkspace={false} | ||
| /> | ||
| ) | ||
| }) | ||
| } | ||
|
|
||
| function row(name: string): HTMLElement { | ||
| const found = [...document.querySelectorAll('[data-workspace-row-idx]')].find((el) => | ||
| el.textContent?.includes(name) | ||
| ) | ||
| if (!found) throw new Error(`No workspace row rendered for "${name}"`) | ||
| return found as HTMLElement | ||
| } | ||
|
|
||
| /** | ||
| * Whether a row is painted with the persistent active fill. | ||
| * | ||
| * Matches an exact class token, never a substring: the inactive chip carries | ||
| * `hover-hover:bg-[var(--surface-active)]`, which *contains* the active class, so a | ||
| * substring check reports every row as marked. | ||
| */ | ||
| function isMarked(name: string): boolean { | ||
| return [...row(name).querySelectorAll<HTMLElement>('*')].some((el) => | ||
| el.classList.contains(ACTIVE_BG) | ||
| ) | ||
| } | ||
|
|
||
| /** | ||
| * Types into a React-controlled input. Assigning `.value` directly is ignored: React | ||
| * tracks the previous value on the node, so the change must go through the native | ||
| * setter for its synthetic `onChange` to fire. | ||
| */ | ||
| function typeInto(input: HTMLInputElement, value: string) { | ||
| const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set | ||
| setValue?.call(input, value) | ||
| input.dispatchEvent(new Event('input', { bubbles: true })) | ||
| } | ||
|
|
||
| beforeEach(() => { | ||
| vi.clearAllMocks() | ||
| // jsdom implements neither; the component scrolls the active row into view. | ||
| Element.prototype.scrollIntoView = vi.fn() | ||
| }) | ||
|
|
||
| afterEach(() => { | ||
| act(() => root.unmount()) | ||
| container.remove() | ||
| }) | ||
|
|
||
| describe('WorkspaceHeader workspace switcher highlight', () => { | ||
| it('leaves Enter unarmed until a cursor is on screen', () => { | ||
| render() | ||
|
|
||
| const search = document.querySelector('input[placeholder="Search workspaces..."]') | ||
| act(() => { | ||
| search?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) | ||
| }) | ||
|
|
||
| // The search field is focused on open, so acting on the seeded row here would | ||
| // switch workspace with nothing marked. | ||
| expect(onWorkspaceSwitch).not.toHaveBeenCalled() | ||
| }) | ||
|
|
||
| it('arms Enter on the top result once the user types', () => { | ||
| render() | ||
|
|
||
| const search = document.querySelector( | ||
| 'input[placeholder="Search workspaces..."]' | ||
| ) as HTMLInputElement | null | ||
| act(() => { | ||
| if (search) typeInto(search, 'Acme') | ||
| }) | ||
| // Typing counts as keyboard intent, so the target is visible before Enter fires. | ||
| expect(isMarked('Acme')).toBe(true) | ||
|
|
||
| act(() => { | ||
| search?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) | ||
| }) | ||
| expect(onWorkspaceSwitch).toHaveBeenCalledWith(expect.objectContaining({ id: 'ws-acme' })) | ||
| }) | ||
|
|
||
| it('marks only the current workspace when the menu opens', () => { | ||
| render() | ||
|
|
||
| expect(isMarked("Emir's Workspace")).toBe(true) | ||
| // Regression: the keyboard cursor used to be seeded to row 0 on open, so a second | ||
| // row was marked in the same colour as hover before any interaction. | ||
| expect(isMarked('RVT')).toBe(false) | ||
| }) | ||
|
|
||
| it('does not leave a highlight behind when the pointer moves across a row', () => { | ||
| render() | ||
|
|
||
| act(() => { | ||
| row('RVT').dispatchEvent(new MouseEvent('mousemove', { bubbles: true })) | ||
| }) | ||
|
|
||
| // The pointer has moved on; nothing should be painted as if still hovered. | ||
| // Real CSS :hover handles the row actually under the cursor and leaves with it. | ||
| expect(isMarked('RVT')).toBe(false) | ||
| expect(isMarked("Emir's Workspace")).toBe(true) | ||
| }) | ||
|
|
||
| it('shows the cursor once the user navigates by keyboard', () => { | ||
| render() | ||
|
|
||
| const search = document.querySelector('input[placeholder="Search workspaces..."]') | ||
| expect(search).not.toBeNull() | ||
| act(() => { | ||
| search?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })) | ||
| }) | ||
|
|
||
| // ArrowUp from the seeded first row wraps to the last. Asserting on Globex, not on | ||
| // the current workspace: the current one carries its own `isActive` fill, so it | ||
| // stays marked either way and would prove nothing about the cursor being painted. | ||
| expect(isMarked('Globex')).toBe(true) | ||
| }) | ||
|
|
||
| it('drops the keyboard cursor again as soon as the pointer moves', () => { | ||
| render() | ||
|
|
||
| const search = document.querySelector('input[placeholder="Search workspaces..."]') | ||
| act(() => { | ||
| search?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })) | ||
| }) | ||
| expect(isMarked('Globex')).toBe(true) | ||
|
|
||
| act(() => { | ||
| row('Acme').dispatchEvent(new MouseEvent('mousemove', { bubbles: true })) | ||
| }) | ||
|
|
||
| // Back in pointer mode: the cursor is gone and the row just crossed is unmarked, | ||
| // leaving only the current workspace's own fill. | ||
| expect(isMarked('Globex')).toBe(false) | ||
| expect(isMarked('Acme')).toBe(false) | ||
| expect(isMarked("Emir's Workspace")).toBe(true) | ||
| }) | ||
| }) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.