diff --git a/e2e-tests/playwright/specs/functional/channels/channel_settings/channel_perm_rules_v0_4.spec.ts b/e2e-tests/playwright/specs/functional/channels/channel_settings/channel_perm_rules_v0_4.spec.ts index cafda1f1a4f..8a1114aa873 100644 --- a/e2e-tests/playwright/specs/functional/channels/channel_settings/channel_perm_rules_v0_4.spec.ts +++ b/e2e-tests/playwright/specs/functional/channels/channel_settings/channel_perm_rules_v0_4.spec.ts @@ -154,4 +154,67 @@ test.describe('Channel Settings Modal - Permissions Policy tab (v0.4)', () => { await channelSettings.close(); }); + + /** + * @objective Verify that an open permission rule editor, which blocks navigation to the modal's other + * sections, says so instead of silently swallowing the click. + */ + test( + 'MM-PP_v0_4_c4 blocked section switch while editing a permission rule explains itself', + {tag: '@channel_settings'}, + async ({pw}) => { + const {adminUser, adminClient, team} = await pw.initSetup(); + await enableABACConfig(adminClient); + await ensureDepartmentAttribute(adminClient); + + const channel = await createPrivateChannel(adminClient, team.id); + + const {page} = await pw.testBrowser.login(adminUser); + const channelsPage = new ChannelsPage(page); + await channelsPage.goto(team.name, channel.name); + await channelsPage.toBeVisible(); + + const channelSettings = await channelsPage.openChannelSettings(); + const permissionsTab = channelSettings.container.getByTestId('permissions_policy-tab-button'); + await expect(permissionsTab).toBeVisible(); + await permissionsTab.click(); + + const tab = channelSettings.container.locator('.ChannelSettingsModal__permissionsPolicyTab'); + await expect(tab).toBeVisible({timeout: 10000}); + + // # Open the rule editor and start a draft + await tab.getByTestId('permissions-policy-add-rule').click(); + const editor = channelSettings.container.getByTestId('permissions-policy-editor'); + await expect(editor).toBeVisible({timeout: 5000}); + await editor.getByTestId('permissions-policy-editor-name').fill('Block external uploads'); + + // # Try to leave the editor by selecting the Info section + await channelSettings.infoTab.click(); + + // * The blocked switch is reported through the modal's floating + // footer in its error state, with saving from there disabled. The + // modal withdraws it after 3s, so assert inside that window. + const savePanel = channelSettings.container.locator('.SaveChangesPanel'); + await expect(savePanel).toContainText('You have unsaved changes', {timeout: 2000}); + await expect(savePanel).toHaveClass(/error/); + await expect(channelSettings.saveButton).toBeDisabled(); + + // * The Permissions Policy section is still the selected one and the draft is intact + await expect(permissionsTab).toHaveAttribute('aria-selected', 'true'); + await expect(editor.getByTestId('permissions-policy-editor-name')).toHaveValue('Block external uploads'); + await expect(channelSettings.infoSettings.container).not.toBeVisible(); + + // # Take the way out the footer offers + await savePanel.getByTestId('SaveChangesPanel__cancel-btn').click(); + + // * The draft is dropped and the rules list is back + await expect(editor).not.toBeVisible(); + await expect(tab.getByTestId('permissions-policy-add-rule')).toBeVisible(); + + // * Section switching works again once no rule is being edited + await channelSettings.openInfoTab(); + + await channelSettings.close(); + }, + ); }); diff --git a/webapp/channels/src/components/channel_settings_modal/channel_settings_modal.test.tsx b/webapp/channels/src/components/channel_settings_modal/channel_settings_modal.test.tsx index 483f5dfbde8..5ed309792b0 100644 --- a/webapp/channels/src/components/channel_settings_modal/channel_settings_modal.test.tsx +++ b/webapp/channels/src/components/channel_settings_modal/channel_settings_modal.test.tsx @@ -128,6 +128,31 @@ jest.mock('./channel_settings_access_rules_tab', () => { }; }); +jest.mock('./channel_settings_permissions_policy_tab', () => { + return function MockPermissionsPolicyTab({ + setAreThereUnsavedChanges, + showTabSwitchError, + }: { + setAreThereUnsavedChanges: (value: boolean) => void; + showTabSwitchError?: boolean; + }): JSX.Element { + return ( +
+ {'Permissions Policy Tab Content'} + + {showTabSwitchError && ( +
{'You have unsaved changes'}
+ )} +
+ ); + }; +}); + // Define the tab type for the settings sidebar type TabType = { name: string; @@ -1032,6 +1057,33 @@ describe('ChannelSettingsModal', () => { }); }); + describe('permissions policy tab wiring', () => { + it('tells the permissions policy tab when it blocks a section switch', async () => { + mockManageChannelAccessRulesPermission = true; + + const testState = makeTestState(); + testState.entities.channels.channels[channelId].type = General.PRIVATE_CHANNEL; + testState.entities.general.config.FeatureFlagPermissionPolicies = 'true'; + testState.entities.general.config.FeatureFlagChannelPermissionPolicies = 'true'; + + renderWithContext(, testState); + + await userEvent.click(await screen.findByRole('tab', {name: /permissions policy/i})); + expect(await screen.findByTestId('permissions-policy-tab')).toBeInTheDocument(); + + // Opening the rule editor reports unsaved changes, which is what makes + // the modal refuse the switch — but is not, on its own, an error to show. + await userEvent.click(screen.getByTestId('permissions-policy-open-editor')); + expect(screen.queryByTestId('permissions-policy-tab-switch-error')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole('tab', {name: /info/i})); + + expect(await screen.findByTestId('permissions-policy-tab-switch-error')).toBeInTheDocument(); + expect(screen.queryByTestId('info-tab')).not.toBeInTheDocument(); + expect(screen.getByTestId('permissions-policy-tab')).toBeInTheDocument(); + }); + }); + describe('schema tab wiring', () => { it('renders declarative schema controls without a save bar until changed', async () => { const onSave = jest.fn(async () => {}); diff --git a/webapp/channels/src/components/channel_settings_modal/channel_settings_permissions_policy_tab.test.tsx b/webapp/channels/src/components/channel_settings_modal/channel_settings_permissions_policy_tab.test.tsx index a1cff872722..bb1b9669674 100644 --- a/webapp/channels/src/components/channel_settings_modal/channel_settings_permissions_policy_tab.test.tsx +++ b/webapp/channels/src/components/channel_settings_modal/channel_settings_permissions_policy_tab.test.tsx @@ -174,13 +174,35 @@ describe('components/channel_settings_modal/ChannelSettingsPermissionsPolicyTab' }; const openNewRuleEditor = async () => { - renderWithContext(, initialState); + const view = renderWithContext(, initialState); const addRuleButton = await screen.findByTestId('permissions-policy-add-rule'); await waitFor(() => expect(addRuleButton).toBeEnabled()); await userEvent.click(addRuleButton); await screen.findByTestId('permissions-policy-editor'); + + return view; + }; + + const openExistingRuleEditor = async (props = baseProps) => { + mockActions.getChannelPolicy.mockResolvedValue({ + data: { + rules: [{ + name: 'Existing rule', + role: ACCESS_CONTROL_CHANNEL_ROLE_USER, + actions: [ACCESS_CONTROL_ACTION_UPLOAD_FILE], + expression: 'user.attributes.team == "eng"', + }], + }, + }); + + const view = renderWithContext(, initialState); + + await userEvent.click(await screen.findByText('Existing rule')); + await screen.findByTestId('permissions-policy-editor'); + + return view; }; const openNewRuleEditorWithFields = async () => { @@ -615,4 +637,127 @@ describe('components/channel_settings_modal/ChannelSettingsPermissionsPolicyTab' expect(screen.getByTestId('table-editor-value')).toHaveTextContent(newExpression); expect(screen.getByTestId(`permissions-policy-editor-action-${ACCESS_CONTROL_ACTION_UPLOAD_FILE}`)).toBeInTheDocument(); }); + + test('reports unsaved changes to the modal while a rule is open in the editor', async () => { + const setAreThereUnsavedChanges = jest.fn(); + + await openExistingRuleEditor({...baseProps, setAreThereUnsavedChanges}); + + // An open editor holds a draft the modal cannot save for the user, so it + // counts as unsaved even before the rule is edited — that is what makes + // the modal block section switches. + await waitFor(() => { + expect(setAreThereUnsavedChanges).toHaveBeenLastCalledWith(true); + }); + + await userEvent.click(screen.getByTestId('permissions-policy-editor-cancel')); + + await waitFor(() => { + expect(setAreThereUnsavedChanges).toHaveBeenLastCalledWith(false); + }); + }); + + test('explains the blocked section switch while the rule editor is open', async () => { + const {rerender} = await openExistingRuleEditor(); + + expect(screen.queryByText('You have unsaved changes')).not.toBeInTheDocument(); + + rerender( + , + ); + + // The same floating footer the rest of the modal uses, in its error state. + expect(await screen.findByText('You have unsaved changes')).toBeInTheDocument(); + expect(screen.getByTestId('SaveChangesPanel__save-btn')).toBeDisabled(); + + // The modal clears the flag on a timeout. + rerender( + , + ); + + await waitFor(() => { + expect(screen.queryByText('You have unsaved changes')).not.toBeInTheDocument(); + }); + }); + + test('explains the blocked section switch while a new rule is being added', async () => { + const {rerender} = await openNewRuleEditor(); + + rerender( + , + ); + + expect(await screen.findByText('You have unsaved changes')).toBeInTheDocument(); + }); + + test('leaves the rule editor when the blocked-switch footer is cancelled', async () => { + const {rerender} = await openExistingRuleEditor(); + + rerender( + , + ); + + await userEvent.click(await screen.findByTestId('SaveChangesPanel__cancel-btn')); + + // Back on the rules list, with the rule the editor was showing intact. + expect(screen.queryByTestId('permissions-policy-editor')).not.toBeInTheDocument(); + expect(screen.getByText('Existing rule')).toBeInTheDocument(); + }); + + test('keeps the in-progress draft when a section switch is blocked', async () => { + const {rerender} = await openExistingRuleEditor(); + await screen.findByTestId('table-editor'); + + const newExpression = 'user.attributes.team == "ops"'; + await userEvent.clear(screen.getByTestId('permissions-policy-editor-name')); + await userEvent.type(screen.getByTestId('permissions-policy-editor-name'), 'Renamed rule'); + act(() => { + latestTableEditorProps().onChange(newExpression); + }); + + rerender( + , + ); + + expect(await screen.findByText('You have unsaved changes')).toBeInTheDocument(); + + // The message asks the user to save the rule, so the rule has to survive it. + expect(screen.getByTestId('permissions-policy-editor-name')).toHaveValue('Renamed rule'); + expect(screen.getByTestId('table-editor-value')).toHaveTextContent(newExpression); + }); + + test('keeps a save validation error visible when a section switch is blocked', async () => { + const {rerender} = await openExistingRuleEditor(); + + await userEvent.clear(screen.getByTestId('permissions-policy-editor-name')); + await userEvent.click(screen.getByTestId('permissions-policy-editor-save')); + expect(await screen.findByTestId('permissions-policy-editor-error')).toHaveTextContent('Each permission rule needs a unique name.'); + + // A blocked section switch must not overwrite the reason the rule + // cannot be saved in the first place. + rerender( + , + ); + + expect(screen.getByTestId('permissions-policy-editor-error')).toHaveTextContent('Each permission rule needs a unique name.'); + expect(screen.getByText('You have unsaved changes')).toBeInTheDocument(); + }); }); diff --git a/webapp/channels/src/components/channel_settings_modal/channel_settings_permissions_policy_tab.tsx b/webapp/channels/src/components/channel_settings_modal/channel_settings_permissions_policy_tab.tsx index ecc6f8ba8f6..ea2d9304b2c 100644 --- a/webapp/channels/src/components/channel_settings_modal/channel_settings_permissions_policy_tab.tsx +++ b/webapp/channels/src/components/channel_settings_modal/channel_settings_permissions_policy_tab.tsx @@ -644,22 +644,44 @@ function ChannelSettingsPermissionsPolicyTab({ } return ( - + <> + + + {/* An open rule editor counts as unsaved changes, so the modal + * refuses section switches while it is open. The list view's + * panel is not rendered here, so bring up the same floating + * footer the rest of the modal uses to report that refusal. + * Its Save is disabled in this state — the rule itself is + * committed from the editor's own button. */} + {showTabSwitchError && ( + + )} + ); }