diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx index b1ec4dd8fe..23461efbdb 100644 --- a/src/components/structures/SpaceRoomView.tsx +++ b/src/components/structures/SpaceRoomView.tsx @@ -363,6 +363,8 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }: ISp const SpaceLandingAddButton = ({ space }) => { const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); + const canCreateRoom = shouldShowComponent(UIComponent.CreateRooms); + const canCreateSpace = shouldShowComponent(UIComponent.CreateSpaces); let contextMenu; if (menuDisplayed) { @@ -376,7 +378,7 @@ const SpaceLandingAddButton = ({ space }) => { compact > - { @@ -388,7 +390,7 @@ const SpaceLandingAddButton = ({ space }) => { defaultDispatcher.fire(Action.UpdateSpaceHierarchy); } }} - /> + /> } { showAddExistingRooms(space); }} /> - { - e.preventDefault(); - e.stopPropagation(); - closeMenu(); - showCreateNewSubspace(space); - }} - > - - + { canCreateSpace && + { + e.preventDefault(); + e.stopPropagation(); + closeMenu(); + showCreateNewSubspace(space); + }} + > + + + } ; } @@ -449,10 +453,11 @@ const SpaceLanding = ({ space }: { space: Room }) => { ); } - const canAddRooms = myMembership === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId); + const hasAddRoomPermissions = myMembership === "join" && + space.currentState.maySendStateEvent(EventType.SpaceChild, userId); let addRoomButton; - if (canAddRooms) { + if (hasAddRoomPermissions) { addRoomButton = ; } diff --git a/src/components/views/context_menus/SpaceContextMenu.tsx b/src/components/views/context_menus/SpaceContextMenu.tsx index f8355681ee..fccd16fd0e 100644 --- a/src/components/views/context_menus/SpaceContextMenu.tsx +++ b/src/components/views/context_menus/SpaceContextMenu.tsx @@ -36,6 +36,8 @@ import defaultDispatcher from "../../../dispatcher/dispatcher"; import { BetaPill } from "../beta/BetaCard"; import SettingsStore from "../../../settings/SettingsStore"; import { Action } from "../../../dispatcher/actions"; +import { shouldShowComponent } from "../../../customisations/helpers/UIComponents"; +import { UIComponent } from "../../../settings/UIFeature"; interface IProps extends IContextMenuProps { space: Room; @@ -58,6 +60,7 @@ const SpaceContextMenu = ({ space, hideHeader, onFinished, ...props }: IProps) = inviteOption = ( { ev.preventDefault(); ev.stopPropagation(); @@ -147,21 +154,27 @@ const SpaceContextMenu = ({ space, hideHeader, onFinished, ...props }: IProps) = }; newRoomSection = <> -
+
{ _t("Add") }
- - - - + { canAddRooms && + + } + { canAddSubSpaces && + + + + } ; } diff --git a/src/components/views/rooms/RoomList.tsx b/src/components/views/rooms/RoomList.tsx index 8c90c1ed18..e561951fae 100644 --- a/src/components/views/rooms/RoomList.tsx +++ b/src/components/views/rooms/RoomList.tsx @@ -191,6 +191,8 @@ const DmAuxButton = ({ tabIndex, dispatcher = defaultDispatcher }: IAuxButtonPro title={_t("Start chat")} />; } + + return null; }; const UntaggedAuxButton = ({ tabIndex }: IAuxButtonProps) => { diff --git a/src/components/views/spaces/SpacePanel.tsx b/src/components/views/spaces/SpacePanel.tsx index 6c2bee4a3c..a1dd62e735 100644 --- a/src/components/views/spaces/SpacePanel.tsx +++ b/src/components/views/spaces/SpacePanel.tsx @@ -70,6 +70,8 @@ import { ActionPayload } from "../../../dispatcher/payloads"; import { Action } from "../../../dispatcher/actions"; import { NotificationState } from "../../../stores/notifications/NotificationState"; import { ALTERNATE_KEY_NAME } from "../../../accessibility/KeyboardShortcuts"; +import { shouldShowComponent } from "../../../customisations/helpers/UIComponents"; +import { UIComponent } from "../../../settings/UIFeature"; const useSpaces = (): [Room[], MetaSpace[], Room[], SpaceKey] => { const invites = useEventEmitterState(SpaceStore.instance, UPDATE_INVITED_SPACES, () => { @@ -235,6 +237,7 @@ const CreateSpaceButton = ({ role="treeitem" > (({ )) } { children } - + { + shouldShowComponent(UIComponent.CreateSpaces) && + + } + ; }); diff --git a/src/settings/UIFeature.ts b/src/settings/UIFeature.ts index f033994214..9f149c4564 100644 --- a/src/settings/UIFeature.ts +++ b/src/settings/UIFeature.ts @@ -38,4 +38,5 @@ export enum UIFeature { export enum UIComponent { InviteUsers = "UIComponent.sendInvites", CreateRooms = "UIComponent.roomCreation", + CreateSpaces = "UIComponent.spaceCreation", } diff --git a/test/components/views/context_menus/SpaceContextMenu-test.tsx b/test/components/views/context_menus/SpaceContextMenu-test.tsx new file mode 100644 index 0000000000..d4c9f726b9 --- /dev/null +++ b/test/components/views/context_menus/SpaceContextMenu-test.tsx @@ -0,0 +1,222 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +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 React from 'react'; +import { mount } from 'enzyme'; +import { Room } from 'matrix-js-sdk'; +import { mocked } from 'jest-mock'; +import { act } from 'react-dom/test-utils'; + +import '../../../skinned-sdk'; +import SpaceContextMenu from '../../../../src/components/views/context_menus/SpaceContextMenu'; +import MatrixClientContext from '../../../../src/contexts/MatrixClientContext'; +import { findByTestId } from '../../../utils/test-utils'; +import { + leaveSpace, + shouldShowSpaceSettings, + showCreateNewRoom, + showCreateNewSubspace, + showSpaceInvite, + showSpaceSettings, +} from '../../../../src/utils/space'; +import { shouldShowComponent } from '../../../../src/customisations/helpers/UIComponents'; +import { UIComponent } from '../../../../src/settings/UIFeature'; + +jest.mock('../../../../src/customisations/helpers/UIComponents', () => ({ + shouldShowComponent: jest.fn(), +})); + +jest.mock('../../../../src/utils/space', () => ({ + leaveSpace: jest.fn(), + shouldShowSpaceSettings: jest.fn(), + showCreateNewRoom: jest.fn(), + showCreateNewSubspace: jest.fn(), + showSpaceInvite: jest.fn(), + showSpacePreferences: jest.fn(), + showSpaceSettings: jest.fn(), +})); +jest.mock('../../../../src/stores/spaces/SpaceStore', () => ({ + spacesEnabled: true, +})); + +describe('', () => { + const userId = '@test:server'; + const mockClient = { + getUserId: jest.fn().mockReturnValue(userId), + }; + const makeMockSpace = (props = {}) => ({ + name: 'test space', + getJoinRule: jest.fn(), + canInvite: jest.fn(), + currentState: { + maySendStateEvent: jest.fn(), + }, + client: mockClient, + getMyMembership: jest.fn(), + ...props, + }) as unknown as Room; + const defaultProps = { + space: makeMockSpace(), + onFinished: jest.fn(), + }; + const getComponent = (props = {}) => + mount(, + { + wrappingComponent: MatrixClientContext.Provider, + wrappingComponentProps: { + value: mockClient, + }, + }); + + beforeEach(() => { + jest.resetAllMocks(); + mockClient.getUserId.mockReturnValue(userId); + }); + + it('renders menu correctly', () => { + const component = getComponent(); + expect(component).toMatchSnapshot(); + }); + + it('renders invite option when space is public', () => { + const space = makeMockSpace({ + getJoinRule: jest.fn().mockReturnValue('public'), + }); + const component = getComponent({ space }); + expect(findByTestId(component, 'invite-option').length).toBeTruthy(); + }); + it('renders invite option when user is has invite rights for space', () => { + const space = makeMockSpace({ + canInvite: jest.fn().mockReturnValue(true), + }); + const component = getComponent({ space }); + expect(space.canInvite).toHaveBeenCalledWith(userId); + expect(findByTestId(component, 'invite-option').length).toBeTruthy(); + }); + it('opens invite dialog when invite option is clicked', () => { + const space = makeMockSpace({ + getJoinRule: jest.fn().mockReturnValue('public'), + }); + const onFinished = jest.fn(); + const component = getComponent({ space, onFinished }); + + act(() => { + findByTestId(component, 'invite-option').at(0).simulate('click'); + }); + + expect(showSpaceInvite).toHaveBeenCalledWith(space); + expect(onFinished).toHaveBeenCalled(); + }); + it('renders space settings option when user has rights', () => { + mocked(shouldShowSpaceSettings).mockReturnValue(true); + const component = getComponent(); + expect(shouldShowSpaceSettings).toHaveBeenCalledWith(defaultProps.space); + expect(findByTestId(component, 'settings-option').length).toBeTruthy(); + }); + it('opens space settings when space settings option is clicked', () => { + mocked(shouldShowSpaceSettings).mockReturnValue(true); + const onFinished = jest.fn(); + const component = getComponent({ onFinished }); + + act(() => { + findByTestId(component, 'settings-option').at(0).simulate('click'); + }); + + expect(showSpaceSettings).toHaveBeenCalledWith(defaultProps.space); + expect(onFinished).toHaveBeenCalled(); + }); + it('renders leave option when user does not have rights to see space settings', () => { + const component = getComponent(); + expect(findByTestId(component, 'leave-option').length).toBeTruthy(); + }); + it('leaves space when leave option is clicked', () => { + const onFinished = jest.fn(); + const component = getComponent({ onFinished }); + act(() => { + findByTestId(component, 'leave-option').at(0).simulate('click'); + }); + expect(leaveSpace).toHaveBeenCalledWith(defaultProps.space); + expect(onFinished).toHaveBeenCalled(); + }); + describe('add children section', () => { + const space = makeMockSpace(); + beforeEach(() => { + // set space to allow adding children to space + mocked(space.currentState.maySendStateEvent).mockReturnValue(true); + mocked(shouldShowComponent).mockReturnValue(true); + }); + it('does not render section when user does not have permission to add children', () => { + mocked(space.currentState.maySendStateEvent).mockReturnValue(false); + const component = getComponent({ space }); + + expect(findByTestId(component, 'add-to-space-header').length).toBeFalsy(); + expect(findByTestId(component, 'new-room-option').length).toBeFalsy(); + expect(findByTestId(component, 'new-subspace-option').length).toBeFalsy(); + }); + it('does not render section when UIComponent customisations disable room and space creation', () => { + mocked(shouldShowComponent).mockReturnValue(false); + const component = getComponent({ space }); + + expect(shouldShowComponent).toHaveBeenCalledWith(UIComponent.CreateRooms); + expect(shouldShowComponent).toHaveBeenCalledWith(UIComponent.CreateSpaces); + + expect(findByTestId(component, 'add-to-space-header').length).toBeFalsy(); + expect(findByTestId(component, 'new-room-option').length).toBeFalsy(); + expect(findByTestId(component, 'new-subspace-option').length).toBeFalsy(); + }); + + it('renders section with add room button when UIComponent customisation allows CreateRoom', () => { + // only allow CreateRoom + mocked(shouldShowComponent).mockImplementation(feature => feature === UIComponent.CreateRooms); + const component = getComponent({ space }); + + expect(findByTestId(component, 'add-to-space-header').length).toBeTruthy(); + expect(findByTestId(component, 'new-room-option').length).toBeTruthy(); + expect(findByTestId(component, 'new-subspace-option').length).toBeFalsy(); + }); + + it('renders section with add space button when UIComponent customisation allows CreateSpace', () => { + // only allow CreateSpaces + mocked(shouldShowComponent).mockImplementation(feature => feature === UIComponent.CreateSpaces); + const component = getComponent({ space }); + + expect(findByTestId(component, 'add-to-space-header').length).toBeTruthy(); + expect(findByTestId(component, 'new-room-option').length).toBeFalsy(); + expect(findByTestId(component, 'new-subspace-option').length).toBeTruthy(); + }); + + it('opens create room dialog on add room button click', () => { + const onFinished = jest.fn(); + const component = getComponent({ space, onFinished }); + + act(() => { + findByTestId(component, 'new-room-option').at(0).simulate('click'); + }); + expect(showCreateNewRoom).toHaveBeenCalledWith(space); + expect(onFinished).toHaveBeenCalled(); + }); + it('opens create space dialog on add space button click', () => { + const onFinished = jest.fn(); + const component = getComponent({ space, onFinished }); + + act(() => { + findByTestId(component, 'new-subspace-option').at(0).simulate('click'); + }); + expect(showCreateNewSubspace).toHaveBeenCalledWith(space); + expect(onFinished).toHaveBeenCalled(); + }); + }); +}); diff --git a/test/components/views/context_menus/__snapshots__/SpaceContextMenu-test.tsx.snap b/test/components/views/context_menus/__snapshots__/SpaceContextMenu-test.tsx.snap new file mode 100644 index 0000000000..2fef194150 --- /dev/null +++ b/test/components/views/context_menus/__snapshots__/SpaceContextMenu-test.tsx.snap @@ -0,0 +1,500 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders menu correctly 1`] = ` + + + + +
+
+ +
+
+ } + > + +
+
+
+
+
+ test space +
+ +
+ + + + + + + Space home + +
, + } + } + onClick={[Function]} + onFocus={[Function]} + role="menuitem" + tabIndex={0} + > +
+ + + Space home + +
+ + + + + + + + + + + Explore rooms + +
, + } + } + onClick={[Function]} + onFocus={[Function]} + role="menuitem" + tabIndex={-1} + > +
+ + + Explore rooms + +
+ + + + + + + + + + + Preferences + +
, + } + } + onClick={[Function]} + onFocus={[Function]} + role="menuitem" + tabIndex={-1} + > +
+ + + Preferences + +
+ + + + + + + + + + + Leave space + +
, + } + } + onClick={[Function]} + onFocus={[Function]} + role="menuitem" + tabIndex={-1} + > +
+ + + Leave space + +
+ + + + +
+ +
+ + + + + + + +`; diff --git a/test/components/views/settings/ThemeChoicePanel-test.tsx b/test/components/views/settings/ThemeChoicePanel-test.tsx index db342f8df8..89dbba0cb5 100644 --- a/test/components/views/settings/ThemeChoicePanel-test.tsx +++ b/test/components/views/settings/ThemeChoicePanel-test.tsx @@ -23,22 +23,6 @@ import _ThemeChoicePanel from '../../../../src/components/views/settings/ThemeCh const ThemeChoicePanel = TestUtils.wrapInMatrixClientContext(_ThemeChoicePanel); -// Avoid errors about global.matchMedia. See: -// https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom -Object.defineProperty(window, 'matchMedia', { - writable: true, - value: jest.fn().mockImplementation(query => ({ - matches: false, - media: query, - onchange: null, - addListener: jest.fn(), // Deprecated - removeListener: jest.fn(), // Deprecated - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - dispatchEvent: jest.fn(), - })), -}); - // Fake random strings to give a predictable snapshot jest.mock( 'matrix-js-sdk/src/randomstring', diff --git a/test/components/views/spaces/SpacePanel-test.tsx b/test/components/views/spaces/SpacePanel-test.tsx new file mode 100644 index 0000000000..af7fcea8ff --- /dev/null +++ b/test/components/views/spaces/SpacePanel-test.tsx @@ -0,0 +1,92 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +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 React from 'react'; +import { mount } from 'enzyme'; +import { mocked } from 'jest-mock'; +import { MatrixClient } from 'matrix-js-sdk'; +import { act } from "react-dom/test-utils"; + +import '../../../skinned-sdk'; +import SpacePanel from '../../../../src/components/views/spaces/SpacePanel'; +import { MatrixClientPeg } from '../../../../src/MatrixClientPeg'; +import { SpaceKey } from '../../../../src/stores/spaces'; +import { findByTestId } from '../../../utils/test-utils'; +import { shouldShowComponent } from '../../../../src/customisations/helpers/UIComponents'; +import { UIComponent } from '../../../../src/settings/UIFeature'; + +jest.mock('../../../../src/stores/spaces/SpaceStore', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const EventEmitter = require("events"); + class MockSpaceStore extends EventEmitter { + invitedSpaces = []; + enabledMetaSpaces = []; + spacePanelSpaces = []; + activeSpace: SpaceKey = '!space1'; + } + return { + instance: new MockSpaceStore(), + }; +}); + +jest.mock('../../../../src/customisations/helpers/UIComponents', () => ({ + shouldShowComponent: jest.fn(), +})); + +describe('', () => { + const defaultProps = {}; + const getComponent = (props = {}) => + mount(); + + const mockClient = { + getUserId: jest.fn().mockReturnValue('@test:test'), + isGuest: jest.fn(), + getAccountData: jest.fn(), + } as unknown as MatrixClient; + + beforeAll(() => { + jest.spyOn(MatrixClientPeg, 'get').mockReturnValue(mockClient); + }); + + beforeEach(() => { + mocked(shouldShowComponent).mockClear().mockReturnValue(true); + }); + + describe('create new space button', () => { + it('renders create space button when UIComponent.CreateSpaces component should be shown', () => { + const component = getComponent(); + expect(findByTestId(component, 'create-space-button').length).toBeTruthy(); + }); + + it('does not render create space button when UIComponent.CreateSpaces component should not be shown', () => { + mocked(shouldShowComponent).mockReturnValue(false); + const component = getComponent(); + expect(shouldShowComponent).toHaveBeenCalledWith(UIComponent.CreateSpaces); + expect(findByTestId(component, 'create-space-button').length).toBeFalsy(); + }); + + it('opens context menu on create space button click', async () => { + const component = getComponent(); + + await act(async () => { + findByTestId(component, 'create-space-button').at(0).simulate('click'); + component.setProps({}); + }); + + expect(component.find('SpaceCreateMenu').length).toBeTruthy(); + }); + }); +}); diff --git a/test/setupTests.js b/test/setupTests.js index e5bdcb2651..fec7e514ab 100644 --- a/test/setupTests.js +++ b/test/setupTests.js @@ -22,3 +22,16 @@ configure({ adapter: new Adapter() }); // maplibre requires a createObjectURL mock global.URL.createObjectURL = jest.fn(); + +// matchMedia is not included in jsdom +const mockMatchMedia = jest.fn().mockImplementation(query => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), // Deprecated + removeListener: jest.fn(), // Deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), +})); +global.matchMedia = mockMatchMedia; diff --git a/test/utils/test-utils.ts b/test/utils/test-utils.ts index f1464f922c..e4bc3740fd 100644 --- a/test/utils/test-utils.ts +++ b/test/utils/test-utils.ts @@ -18,6 +18,7 @@ import { MatrixClient } from "matrix-js-sdk/src/client"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { EventType } from "matrix-js-sdk/src/@types/event"; import { EventEmitter } from "events"; +import { ReactWrapper } from "enzyme"; import { AsyncStoreWithClient } from "../../src/stores/AsyncStoreWithClient"; import { mkEvent, mkStubRoom } from "../test-utils"; @@ -82,3 +83,5 @@ export const mkSpace = ( }; export const emitPromise = (e: EventEmitter, k: string | symbol) => new Promise(r => e.once(k, r)); + +export const findByTestId = (component: ReactWrapper, id: string) => component.find(`[data-test-id="${id}"]`);