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 && (
+
+ )}
+ >
);
}