diff --git a/src/components/ChannelListItem/ChannelListItemActionButtons.defaults.tsx b/src/components/ChannelListItem/ChannelListItemActionButtons.defaults.tsx index 385dd798e..8aa91f887 100644 --- a/src/components/ChannelListItem/ChannelListItemActionButtons.defaults.tsx +++ b/src/components/ChannelListItem/ChannelListItemActionButtons.defaults.tsx @@ -153,6 +153,7 @@ const defaultComponents = { return ( @@ -176,6 +177,7 @@ const defaultComponents = { return ( { @@ -244,6 +246,7 @@ const defaultComponents = { return ( { @@ -290,6 +293,7 @@ const defaultComponents = { return ( @@ -314,6 +318,7 @@ const defaultComponents = { return ( { @@ -377,6 +382,7 @@ const defaultComponents = { appearance='ghost' aria-label={behaviorProps.title} circular + data-testid='quick-action-archive' size='sm' variant='secondary' {...behaviorProps} @@ -393,6 +399,7 @@ const defaultComponents = { appearance='ghost' aria-label={behaviorProps.title} circular + data-testid='quick-action-mute' size='sm' variant='secondary' {...behaviorProps} @@ -418,6 +425,7 @@ const defaultComponents = { aria-expanded={dialogIsOpen} aria-pressed={dialogIsOpen} circular + data-testid='channel-list-item-dropdown-toggle' onClick={(e) => { e.stopPropagation(); @@ -472,6 +480,7 @@ export const useBaseChannelActionSetFilter = (channelActionSet: ChannelActionIte const membership = useChannelMembershipState(channel); const memberCount = channel.data?.member_count ?? 0; const connectedUserIsMember = typeof membership.user !== 'undefined'; + const isDirectMessageChannel = connectedUserIsMember && memberCount === 2; const ownCapabilities = channel.data?.own_capabilities; @@ -486,9 +495,7 @@ export const useBaseChannelActionSetFilter = (channelActionSet: ChannelActionIte return ownCapabilities?.includes('mute-channel'); case 'ban': return ( - memberCount > 0 && - memberCount <= 2 && - ownCapabilities?.includes('ban-channel-members') + isDirectMessageChannel && ownCapabilities?.includes('ban-channel-members') ); case 'leave': return ownCapabilities?.includes('leave-channel'); @@ -500,5 +507,5 @@ export const useBaseChannelActionSetFilter = (channelActionSet: ChannelActionIte }); return filtered; - }, [channelActionSet, memberCount, ownCapabilities, connectedUserIsMember]); + }, [channelActionSet, connectedUserIsMember, ownCapabilities, isDirectMessageChannel]); }; diff --git a/src/components/ChannelListItem/ChannelListItemActionButtons.tsx b/src/components/ChannelListItem/ChannelListItemActionButtons.tsx index 4bfe9e241..1326fbec7 100644 --- a/src/components/ChannelListItem/ChannelListItemActionButtons.tsx +++ b/src/components/ChannelListItem/ChannelListItemActionButtons.tsx @@ -44,6 +44,7 @@ export const ChannelListItemActionButtons: ChannelListItemActionButtonsInterface className={clsx('str-chat__channel-list-item__action-buttons', { 'str-chat__channel-list-item__action-buttons--active': dialogIsOpen, })} + data-testid='channel-list-item-action-buttons' > {quickDropdownToggleAction && dropdownActionSet.length > 0 && ( @@ -53,6 +54,7 @@ export const ChannelListItemActionButtons: ChannelListItemActionButtonsInterface ))} { afterEach(() => { vi.clearAllMocks(); + // @ts-expect-error - restore original ResizeObserver after test delete window.ResizeObserver; }); - const setupTwoMemberGroupChannel = async () => { + const setupTwoMemberGroupChannel = async (overrides?: { + channelOverrides?: Record; + memberOverrides?: Array>; + }) => { const { channels, client } = await initClientWithChannels({ channelsData: [ { @@ -42,139 +49,768 @@ describe('ChannelListItemActionButtons defaults', () => { id: 'two-member-group', member_count: 2, own_capabilities: ownCapabilities, + ...overrides?.channelOverrides, }, - members: [generateMember({ user: alice }), generateMember({ user: bob })], + members: [ + generateMember({ user: alice, ...overrides?.memberOverrides?.[0] }), + generateMember({ user: bob, ...overrides?.memberOverrides?.[1] }), + ], messages: [], }, ], customUser: alice, }); - return { channel: channels[0], client }; + const channel = channels[0]; + // Set membership so the filter recognizes the connected user as a member + channel.state.membership = fromPartial({ + user: alice, + user_id: alice.id, + }); + return { channel, client }; }; - it('mutes channel from quick action with success notification', async () => { - const { channel, client } = await setupTwoMemberGroupChannel(); - vi.spyOn(channel, 'mute').mockResolvedValue(undefined); - const addSpy = vi.spyOn(client.notifications, 'add'); - + const openDropdownMenu = () => { + const toggle = screen.getByTestId('channel-list-item-dropdown-toggle'); act(() => { - render( - - - , + fireEvent.click(toggle); + }); + }; + + // ---------- Mute / Unmute ---------- + + describe('mute action', () => { + it('mutes channel from quick action with success notification', async () => { + const { channel, client } = await setupTwoMemberGroupChannel(); + vi.spyOn(channel, 'mute').mockResolvedValue(fromPartial({})); + const addSpy = vi.spyOn(client.notifications, 'add'); + + act(() => { + render( + + + , + ); + }); + + const muteButton = screen.getByTestId('quick-action-mute'); + act(() => { + fireEvent.click(muteButton); + }); + + await waitFor(() => { + expect(channel.mute).toHaveBeenCalledTimes(1); + expect(addSpy).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Channel muted', + options: expect.objectContaining({ severity: 'success' }), + }), + ); + }); + }); + + it('shows error notification when mute fails', async () => { + const { channel, client } = await setupTwoMemberGroupChannel(); + vi.spyOn(channel, 'mute').mockRejectedValueOnce(new Error('mute failed')); + const addSpy = vi.spyOn(client.notifications, 'add'); + + act(() => { + render( + + + , + ); + }); + + const muteButton = screen.getByTestId('quick-action-mute'); + act(() => { + fireEvent.click(muteButton); + }); + + await waitFor(() => { + expect(addSpy).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Failed to update channel mute status', + options: expect.objectContaining({ severity: 'error' }), + }), + ); + }); + }); + + it('unmutes a muted channel from quick action with success notification', async () => { + const { channel, client } = await setupTwoMemberGroupChannel(); + // Simulate muted state by setting up the muteStatus + vi.spyOn(channel, 'muteStatus').mockReturnValue( + fromPartial({ muted: true, createdAt: new Date() }), ); + vi.spyOn(channel, 'unmute').mockResolvedValue(fromPartial({})); + const addSpy = vi.spyOn(client.notifications, 'add'); + + act(() => { + render( + + + , + ); + }); + + const unmuteButton = screen.getByTestId('quick-action-mute'); + expect(unmuteButton).toHaveAttribute('aria-pressed', 'true'); + + act(() => { + fireEvent.click(unmuteButton); + }); + + await waitFor(() => { + expect(channel.unmute).toHaveBeenCalledTimes(1); + expect(addSpy).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Channel unmuted', + options: expect.objectContaining({ severity: 'success' }), + }), + ); + }); }); - act(() => { - fireEvent.click(screen.getByRole('button', { name: 'Mute' })); - }); - await waitFor(() => { - expect(channel.mute).toHaveBeenCalledTimes(1); - expect(addSpy).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'Channel muted', - options: expect.objectContaining({ severity: 'success' }), - }), + it('shows error notification when unmute fails', async () => { + const { channel, client } = await setupTwoMemberGroupChannel(); + vi.spyOn(channel, 'muteStatus').mockReturnValue( + fromPartial({ muted: true, createdAt: new Date() }), ); + vi.spyOn(channel, 'unmute').mockRejectedValueOnce(new Error('unmute failed')); + const addSpy = vi.spyOn(client.notifications, 'add'); + + act(() => { + render( + + + , + ); + }); + + const unmuteButton = screen.getByTestId('quick-action-mute'); + act(() => { + fireEvent.click(unmuteButton); + }); + + await waitFor(() => { + expect(addSpy).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Failed to update channel mute status', + options: expect.objectContaining({ severity: 'error' }), + }), + ); + }); + }); + + it('disables mute button while request is in progress', async () => { + const { channel, client } = await setupTwoMemberGroupChannel(); + const p = Promise.withResolvers(); + vi.spyOn(channel, 'mute').mockReturnValue(p.promise); + + act(() => { + render( + + + , + ); + }); + + const muteButton = screen.getByTestId('quick-action-mute'); + act(() => { + fireEvent.click(muteButton); + }); + + await waitFor(() => { + expect(muteButton).toBeDisabled(); + }); + + act(() => { + p.resolve(fromPartial({})); + }); + + await waitFor(() => { + expect(muteButton).not.toBeDisabled(); + }); }); }); - it('shows mute error notification when mute fails', async () => { - const { channel, client } = await setupTwoMemberGroupChannel(); - vi.spyOn(channel, 'mute').mockRejectedValueOnce(new Error('mute failed')); - const addSpy = vi.spyOn(client.notifications, 'add'); + // ---------- Block / Unblock ---------- - act(() => { - render( - - - , - ); + describe('block user action', () => { + it('blocks the other member from dropdown', async () => { + const { channel, client } = await setupTwoMemberGroupChannel(); + vi.spyOn(channel, 'banUser').mockResolvedValue(fromPartial({})); + const addSpy = vi.spyOn(client.notifications, 'add'); + + act(() => { + render( + + + , + ); + }); + + openDropdownMenu(); + + const blockButton = screen.getByTestId('dropdown-action-ban'); + act(() => { + fireEvent.click(blockButton); + }); + + await waitFor(() => { + expect(channel.banUser).toHaveBeenCalledWith(bob.id, {}); + expect(addSpy).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'User blocked', + options: expect.objectContaining({ severity: 'success' }), + }), + ); + }); }); - act(() => { - fireEvent.click(screen.getByRole('button', { name: 'Mute' })); + it('unblocks a banned user from dropdown', async () => { + const { channel, client } = await setupTwoMemberGroupChannel({ + memberOverrides: [undefined as never, { banned: true }], + }); + vi.spyOn(channel, 'unbanUser').mockResolvedValue(fromPartial({})); + const addSpy = vi.spyOn(client.notifications, 'add'); + + act(() => { + render( + + + , + ); + }); + + openDropdownMenu(); + + const unblockButton = screen.getByTestId('dropdown-action-ban'); + expect(unblockButton).toHaveTextContent('Unblock User'); + + act(() => { + fireEvent.click(unblockButton); + }); + + await waitFor(() => { + expect(channel.unbanUser).toHaveBeenCalledWith(bob.id); + expect(addSpy).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'User unblocked', + options: expect.objectContaining({ severity: 'success' }), + }), + ); + }); }); - await waitFor(() => { - expect(addSpy).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'Failed to update channel mute status', - options: expect.objectContaining({ severity: 'error' }), - }), - ); + it('shows error notification when block fails', async () => { + const { channel, client } = await setupTwoMemberGroupChannel(); + vi.spyOn(channel, 'banUser').mockRejectedValueOnce(new Error('ban failed')); + const addSpy = vi.spyOn(client.notifications, 'add'); + + act(() => { + render( + + + , + ); + }); + + openDropdownMenu(); + + const blockButton = screen.getByTestId('dropdown-action-ban'); + act(() => { + fireEvent.click(blockButton); + }); + + await waitFor(() => { + expect(addSpy).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Failed to block user', + options: expect.objectContaining({ severity: 'error' }), + }), + ); + }); }); }); - it('blocks the other member from dropdown', async () => { - const { channel, client } = await setupTwoMemberGroupChannel(); - vi.spyOn(channel, 'banUser').mockResolvedValue(undefined); - const addSpy = vi.spyOn(client.notifications, 'add'); + // ---------- Leave channel ---------- - act(() => { - render( - - - , - ); + describe('leave channel action', () => { + it('leaves channel from dropdown', async () => { + const { channel, client } = await setupTwoMemberGroupChannel(); + vi.spyOn(channel, 'removeMembers').mockResolvedValue(fromPartial({})); + const addSpy = vi.spyOn(client.notifications, 'add'); + + act(() => { + render( + + + , + ); + }); + + openDropdownMenu(); + + const leaveButton = screen.getByTestId('dropdown-action-leave'); + act(() => { + fireEvent.click(leaveButton); + }); + + await waitFor(() => { + expect(channel.removeMembers).toHaveBeenCalledWith([alice.id]); + expect(addSpy).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Left channel', + options: expect.objectContaining({ severity: 'success' }), + }), + ); + }); }); - const toggle = document.querySelector( - '.str-chat__channel-list-item__action-buttons .str-chat__button--circular', - ) as HTMLButtonElement; - act(() => { - fireEvent.click(toggle); + it('shows error notification when leave fails', async () => { + const { channel, client } = await setupTwoMemberGroupChannel(); + vi.spyOn(channel, 'removeMembers').mockRejectedValueOnce(new Error('leave failed')); + const addSpy = vi.spyOn(client.notifications, 'add'); + + act(() => { + render( + + + , + ); + }); + + openDropdownMenu(); + + const leaveButton = screen.getByTestId('dropdown-action-leave'); + act(() => { + fireEvent.click(leaveButton); + }); + + await waitFor(() => { + expect(addSpy).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Failed to leave channel', + options: expect.objectContaining({ severity: 'error' }), + }), + ); + }); }); + }); - const menu = document.querySelector('.str-chat__context-menu') as HTMLElement; - act(() => { - fireEvent.click(within(menu).getByRole('button', { name: 'Block User' })); + // ---------- Archive / Unarchive ---------- + + describe('archive action', () => { + it('archives channel from dropdown', async () => { + const { channel, client } = await setupTwoMemberGroupChannel(); + vi.spyOn(channel, 'archive').mockResolvedValue(fromPartial({})); + const addSpy = vi.spyOn(client.notifications, 'add'); + + act(() => { + render( + + + , + ); + }); + + openDropdownMenu(); + + const archiveButton = screen.getByTestId('dropdown-action-archive'); + expect(archiveButton).toHaveTextContent('Archive'); + + act(() => { + fireEvent.click(archiveButton); + }); + + await waitFor(() => { + expect(channel.archive).toHaveBeenCalledTimes(1); + expect(addSpy).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Channel archived', + options: expect.objectContaining({ severity: 'success' }), + }), + ); + }); }); - await waitFor(() => { - expect(channel.banUser).toHaveBeenCalledWith(bob.id, {}); - expect(addSpy).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'User blocked', - options: expect.objectContaining({ severity: 'success' }), - }), - ); + it('unarchives an archived channel from dropdown', async () => { + const { channel, client } = await setupTwoMemberGroupChannel(); + // Simulate archived state + channel.state.membership = fromPartial({ + ...channel.state.membership, + archived_at: '2024-01-01T00:00:00Z', + }); + vi.spyOn(channel, 'unarchive').mockResolvedValue(fromPartial({})); + const addSpy = vi.spyOn(client.notifications, 'add'); + + act(() => { + render( + + + , + ); + }); + + openDropdownMenu(); + + const unarchiveButton = screen.getByTestId('dropdown-action-archive'); + expect(unarchiveButton).toHaveTextContent('Unarchive'); + + act(() => { + fireEvent.click(unarchiveButton); + }); + + await waitFor(() => { + expect(channel.unarchive).toHaveBeenCalledTimes(1); + expect(addSpy).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Channel unarchived', + options: expect.objectContaining({ severity: 'success' }), + }), + ); + }); + }); + + it('shows error notification when archive fails', async () => { + const { channel, client } = await setupTwoMemberGroupChannel(); + vi.spyOn(channel, 'archive').mockRejectedValueOnce(new Error('archive failed')); + const addSpy = vi.spyOn(client.notifications, 'add'); + + act(() => { + render( + + + , + ); + }); + + openDropdownMenu(); + + const archiveButton = screen.getByTestId('dropdown-action-archive'); + act(() => { + fireEvent.click(archiveButton); + }); + + await waitFor(() => { + expect(addSpy).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Failed to update channel archive status', + options: expect.objectContaining({ severity: 'error' }), + }), + ); + }); }); }); - it('leaves channel from dropdown', async () => { - const { channel, client } = await setupTwoMemberGroupChannel(); - vi.spyOn(channel, 'removeMembers').mockResolvedValue(undefined); - const addSpy = vi.spyOn(client.notifications, 'add'); + // ---------- Pin / Unpin ---------- - act(() => { - render( - - - , - ); + describe('pin action', () => { + it('pins channel from dropdown', async () => { + const { channel, client } = await setupTwoMemberGroupChannel(); + vi.spyOn(channel, 'pin').mockResolvedValue(fromPartial({})); + const addSpy = vi.spyOn(client.notifications, 'add'); + + act(() => { + render( + + + , + ); + }); + + openDropdownMenu(); + + const pinButton = screen.getByTestId('dropdown-action-pin'); + expect(pinButton).toHaveTextContent('Pin'); + + act(() => { + fireEvent.click(pinButton); + }); + + await waitFor(() => { + expect(channel.pin).toHaveBeenCalledTimes(1); + expect(addSpy).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Channel pinned', + options: expect.objectContaining({ severity: 'success' }), + }), + ); + }); }); - const toggle = document.querySelector( - '.str-chat__channel-list-item__action-buttons .str-chat__button--circular', - ) as HTMLButtonElement; - act(() => { - fireEvent.click(toggle); + it('unpins a pinned channel from dropdown', async () => { + const { channel, client } = await setupTwoMemberGroupChannel(); + // Simulate pinned state + channel.state.membership = fromPartial({ + ...channel.state.membership, + pinned_at: '2024-01-01T00:00:00Z', + }); + vi.spyOn(channel, 'unpin').mockResolvedValue(fromPartial({})); + const addSpy = vi.spyOn(client.notifications, 'add'); + + act(() => { + render( + + + , + ); + }); + + openDropdownMenu(); + + const unpinButton = screen.getByTestId('dropdown-action-pin'); + expect(unpinButton).toHaveTextContent('Unpin'); + + act(() => { + fireEvent.click(unpinButton); + }); + + await waitFor(() => { + expect(channel.unpin).toHaveBeenCalledTimes(1); + expect(addSpy).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Channel unpinned', + options: expect.objectContaining({ severity: 'success' }), + }), + ); + }); }); - const menu = document.querySelector('.str-chat__context-menu') as HTMLElement; - act(() => { - fireEvent.click(within(menu).getByRole('button', { name: 'Leave Channel' })); + it('shows error notification when pin fails', async () => { + const { channel, client } = await setupTwoMemberGroupChannel(); + vi.spyOn(channel, 'pin').mockRejectedValueOnce(new Error('pin failed')); + const addSpy = vi.spyOn(client.notifications, 'add'); + + act(() => { + render( + + + , + ); + }); + + openDropdownMenu(); + + const pinButton = screen.getByTestId('dropdown-action-pin'); + act(() => { + fireEvent.click(pinButton); + }); + + await waitFor(() => { + expect(addSpy).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Failed to update channel pinned status', + options: expect.objectContaining({ severity: 'error' }), + }), + ); + }); }); + }); - await waitFor(() => { - expect(channel.removeMembers).toHaveBeenCalledWith([alice.id]); - expect(addSpy).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'Left channel', - options: expect.objectContaining({ severity: 'success' }), - }), - ); + // ---------- Action filtering ---------- + + describe('action set filtering', () => { + it('hides ban action when channel has more than 2 members', async () => { + const charlie = generateUser({ id: 'charlie-id' }); + const { channels, client } = await initClientWithChannels({ + channelsData: [ + { + channel: { + id: 'three-member-group', + member_count: 3, + own_capabilities: ownCapabilities, + }, + members: [ + generateMember({ user: alice }), + generateMember({ user: bob }), + generateMember({ user: charlie }), + ], + messages: [], + }, + ], + customUser: alice, + }); + channels[0].state.membership = fromPartial({ user: alice, user_id: alice.id }); + + act(() => { + render( + + + , + ); + }); + + openDropdownMenu(); + + expect(screen.queryByTestId('dropdown-action-ban')).not.toBeInTheDocument(); + }); + + it('hides mute action when mute-channel capability is absent', async () => { + const { channels, client } = await initClientWithChannels({ + channelsData: [ + { + channel: { + id: 'no-mute', + member_count: 2, + own_capabilities: ['leave-channel', 'ban-channel-members', 'send-message'], + }, + members: [generateMember({ user: alice }), generateMember({ user: bob })], + messages: [], + }, + ], + customUser: alice, + }); + channels[0].state.membership = fromPartial({ user: alice, user_id: alice.id }); + + act(() => { + render( + + + , + ); + }); + + expect(screen.queryByTestId('quick-action-mute')).not.toBeInTheDocument(); + }); + + it('hides leave action when leave-channel capability is absent', async () => { + const { channels, client } = await initClientWithChannels({ + channelsData: [ + { + channel: { + id: 'no-leave', + member_count: 2, + own_capabilities: ['mute-channel', 'ban-channel-members', 'send-message'], + }, + members: [generateMember({ user: alice }), generateMember({ user: bob })], + messages: [], + }, + ], + customUser: alice, + }); + channels[0].state.membership = fromPartial({ user: alice, user_id: alice.id }); + + act(() => { + render( + + + , + ); + }); + + openDropdownMenu(); + + expect(screen.queryByTestId('dropdown-action-leave')).not.toBeInTheDocument(); + }); + + it('hides ban action when user is not a channel member', async () => { + const { channels, client } = await initClientWithChannels({ + channelsData: [ + { + channel: { + id: 'non-member', + member_count: 2, + own_capabilities: ownCapabilities, + }, + members: [generateMember({ user: alice }), generateMember({ user: bob })], + messages: [], + }, + ], + customUser: alice, + }); + + act(() => { + render( + + + , + ); + }); + + openDropdownMenu(); + + expect(screen.queryByTestId('dropdown-action-ban')).not.toBeInTheDocument(); + }); + + it('hides ban action when ban-channel-members capability is absent', async () => { + const { channels, client } = await initClientWithChannels({ + channelsData: [ + { + channel: { + id: 'no-ban', + member_count: 2, + own_capabilities: ['mute-channel', 'leave-channel', 'send-message'], + }, + members: [generateMember({ user: alice }), generateMember({ user: bob })], + messages: [], + }, + ], + customUser: alice, + }); + channels[0].state.membership = fromPartial({ user: alice, user_id: alice.id }); + + act(() => { + render( + + + , + ); + }); + + openDropdownMenu(); + + expect(screen.queryByTestId('dropdown-action-ban')).not.toBeInTheDocument(); + }); + }); + + // ---------- Dropdown toggle ---------- + + describe('dropdown toggle', () => { + it('renders action buttons wrapper', async () => { + const { channel, client } = await setupTwoMemberGroupChannel(); + + act(() => { + render( + + + , + ); + }); + + expect(screen.getByTestId('channel-list-item-action-buttons')).toBeInTheDocument(); + }); + + it('toggles dropdown menu open and closed', async () => { + const { channel, client } = await setupTwoMemberGroupChannel(); + + act(() => { + render( + + + , + ); + }); + + const toggle = screen.getByTestId('channel-list-item-dropdown-toggle'); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + + act(() => { + fireEvent.click(toggle); + }); + + await waitFor(() => { + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + }); + + act(() => { + fireEvent.click(toggle); + }); + + await waitFor(() => { + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + }); }); }); });