Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/server-ci-artifacts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
## We only need the condition on the first job
## This will run only when a pull request is created with server changes
update-initial-status:
if: github.repository_owner == 'mattermost' && github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
if: github.repository_owner == 'mattermost' && github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_repository.full_name == github.repository
runs-on: ubuntu-22.04
steps:
- uses: mattermost/actions/delivery/update-commit-status@f324ac89b05cc3511cb06e60642ac2fb829f0a63
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/server-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
# - server-ci-report.yml
# - sentry.yaml
# If you rename this workflow, be sure to update those workflows as well.
name: Server CI

Check warning on line 6 in .github/workflows/server-ci.yml

View workflow job for this annotation

GitHub Actions / yamllint

6:1 [document-start] missing document start "---"

Check warning on line 6 in .github/workflows/server-ci.yml

View workflow job for this annotation

GitHub Actions / yamllint

6:1 [document-start] missing document start "---"
on:

Check warning on line 7 in .github/workflows/server-ci.yml

View workflow job for this annotation

GitHub Actions / yamllint

7:1 [truthy] truthy value should be one of [false, true]

Check warning on line 7 in .github/workflows/server-ci.yml

View workflow job for this annotation

GitHub Actions / yamllint

7:1 [truthy] truthy value should be one of [false, true]
workflow_dispatch: # Allow manual/API triggering for linked plugin CI
push:
branches:
Expand Down Expand Up @@ -37,14 +37,14 @@
gomod-changed: ${{ steps.changed-files.outputs.any_changed }}
steps:
- name: Checkout mattermost project
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

Check warning on line 40 in .github/workflows/server-ci.yml

View workflow job for this annotation

GitHub Actions / yamllint

40:73 [comments] too few spaces before comment: expected 2

Check warning on line 40 in .github/workflows/server-ci.yml

View workflow job for this annotation

GitHub Actions / yamllint

40:73 [comments] too few spaces before comment: expected 2
- name: Calculate version
id: calculate
working-directory: server/
run: echo GO_VERSION=$(cat .go-version) >> "${GITHUB_OUTPUT}"
- name: Check for go.mod changes
id: changed-files
uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5

Check warning on line 47 in .github/workflows/server-ci.yml

View workflow job for this annotation

GitHub Actions / yamllint

47:81 [comments] too few spaces before comment: expected 2

Check warning on line 47 in .github/workflows/server-ci.yml

View workflow job for this annotation

GitHub Actions / yamllint

47:81 [comments] too few spaces before comment: expected 2
with:
files: |
**/go.mod
Expand All @@ -58,7 +58,7 @@
working-directory: server
steps:
- name: Checkout mattermost project
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

Check warning on line 61 in .github/workflows/server-ci.yml

View workflow job for this annotation

GitHub Actions / yamllint

61:73 [comments] too few spaces before comment: expected 2

Check warning on line 61 in .github/workflows/server-ci.yml

View workflow job for this annotation

GitHub Actions / yamllint

61:73 [comments] too few spaces before comment: expected 2
- name: Run setup-go-work
run: make setup-go-work
- name: Generate mocks
Expand All @@ -75,7 +75,7 @@
working-directory: server
steps:
- name: Checkout mattermost project
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

Check warning on line 78 in .github/workflows/server-ci.yml

View workflow job for this annotation

GitHub Actions / yamllint

78:73 [comments] too few spaces before comment: expected 2

Check warning on line 78 in .github/workflows/server-ci.yml

View workflow job for this annotation

GitHub Actions / yamllint

78:73 [comments] too few spaces before comment: expected 2
- name: Run setup-go-work
run: make setup-go-work
- name: Run go mod tidy
Expand Down Expand Up @@ -396,6 +396,7 @@
make build-cmd
make package
- name: Persist dist artifacts
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: server-dist-artifact
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export default class ConfigurationSettings {
await textBox.fill(text);
}

async setChannelBannerTextColor(color: string) {
async setChannelBannerBackgroundColor(color: string) {
const colorInput = this.container.locator('#channel_banner_banner_background_color_picker-inputColorValue');
await expect(colorInput).toBeVisible();
await colorInput.fill(color);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ test('Should show channel banner when configured', async ({pw}) => {

await configurationTab.enableChannelBanner();
await configurationTab.setChannelBannerText('Example channel banner text');
await configurationTab.setChannelBannerTextColor('77DD88');
await configurationTab.setChannelBannerBackgroundColor('77DD88');

await configurationTab.save();
await channelSettingsModal.close();
Expand Down Expand Up @@ -67,7 +67,7 @@ test('Should show channel banner in thread view', async ({pw}) => {

await configurationTab.enableChannelBanner();
await configurationTab.setChannelBannerText('Thread banner text');
await configurationTab.setChannelBannerTextColor('AA33BB');
await configurationTab.setChannelBannerBackgroundColor('AA33BB');

await configurationTab.save();
await channelSettingsModal.close();
Expand Down Expand Up @@ -119,7 +119,7 @@ test('Should render image emoticons without clipping', async ({pw}) => {
const configurationTab = await channelSettingsModal.openConfigurationTab();

await configurationTab.enableChannelBanner();
await configurationTab.setChannelBannerTextColor('77DD88');
await configurationTab.setChannelBannerBackgroundColor('77DD88');
// :dog: is in Mattermost's emoji map → renders as .emoticon (background-image).
// Unicode emojis that are also in the map (e.g. 🐶) follow the same path.
await configurationTab.setChannelBannerText('Hello :dog:');
Expand All @@ -144,7 +144,7 @@ test('Should render unsupported unicode emoji without clipping', async ({pw}) =>
const configurationTab = await channelSettingsModal.openConfigurationTab();

await configurationTab.enableChannelBanner();
await configurationTab.setChannelBannerTextColor('77DD88');
await configurationTab.setChannelBannerBackgroundColor('77DD88');
// 🫠 (U+1FAE0, Unicode 14.0) is above Mattermost's emoji map ceiling (1FAD6)
// so it falls through to the .emoticon--unicode span path.
await configurationTab.setChannelBannerText('Hello 🫠');
Expand All @@ -169,7 +169,7 @@ test('Should render text with descenders without clipping', async ({pw}) => {
const configurationTab = await channelSettingsModal.openConfigurationTab();

await configurationTab.enableChannelBanner();
await configurationTab.setChannelBannerTextColor('77DD88');
await configurationTab.setChannelBannerBackgroundColor('77DD88');
// Characters with descenders (parts that extend below the baseline).
// Previously clipped because line-height equalled font-size (13px), leaving
// no room below the baseline for g, j, p, q, y etc.
Expand All @@ -196,7 +196,7 @@ test('Should render markdown', async ({pw}) => {

await configurationTab.enableChannelBanner();
await configurationTab.setChannelBannerText('**bold** *italic* ~~strikethrough~~');
await configurationTab.setChannelBannerTextColor('77DD88');
await configurationTab.setChannelBannerBackgroundColor('77DD88');

await configurationTab.save();
await channelSettingsModal.close();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,59 @@ test.describe('Channel Classification - Existing channel settings', () => {
expect(colorValue.toLowerCase().replace('#', '')).toBe(selectedLevel!.color.toLowerCase().replace('#', ''));
});

test('Disabling classification with a custom banner color preserves it server-side, and re-enabling restores the Save button', async ({
pw,
}) => {
const {adminUser, team, adminClient} = await initSetupTracked(pw);

const channel = await adminClient.createChannel(
pw.random.channel({teamId: team.id, name: `cls-${pw.random.id()}`, displayName: `Cls ${pw.random.id()}`}),
);
await adminClient.addToChannel(adminUser.id, channel.id);

const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name, channel.name);
await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 60000});

// Step 1: enable classification, select a level, save
const channelSettingsModal = await channelsPage.openChannelSettings();
const configurationTab = await channelSettingsModal.openConfigurationTab();

const classificationToggle = channelsPage.page.getByTestId('channelClassificationToggle-button');
await classificationToggle.click();

const dropdownContainer = channelsPage.page.getByTestId('channelClassificationLevel');
await dropdownContainer.click();
const selectedLevel = classificationLevels.find((l) => l.name === 'SECRET')!;
await channelsPage.page.locator('.DropDown__menu').getByText(selectedLevel.name, {exact: true}).click();
await configurationTab.save();

// Step 2: in the same open modal, disable classification, enable a manual banner,
// set a custom color, save. The manual banner toggle is needed because master no
// longer auto-enables banner_info.enabled when classification is on, so the banner
// section body (and its color input) is hidden after toggling classification off
// until a manual banner is enabled.
await classificationToggle.click();
await configurationTab.enableChannelBanner();
const customColor = 'aa00aa';
await configurationTab.setChannelBannerBackgroundColor(customColor);
await configurationTab.save();

// Symptom 1 guard: color input reflects what we typed and the server persisted the custom color
const colorInput = channelsPage.page.locator('#channel_banner_banner_background_color_picker-inputColorValue');
await expect(colorInput).toHaveValue(`#${customColor}`);
const persisted = await adminClient.getChannel(channel.id);
expect(persisted.banner_info?.background_color?.toLowerCase().replace('#', '')).toBe(customColor);

// Symptom 2 guard: re-enable classification → Save button reappears (panel transitions out of 'saved')
await classificationToggle.click();
const saveButton = channelsPage.page.getByTestId('SaveChangesPanel__save-btn');
await expect(saveButton).toBeVisible();
await expect(saveButton).toBeEnabled();

await channelSettingsModal.close();
});

test('Editing banner text and saving updates the banner in real time', async ({pw}) => {
const {adminUser, team, adminClient} = await initSetupTracked(pw);

Expand Down
2 changes: 1 addition & 1 deletion server/public/model/feature_flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ func (f *FeatureFlags) SetDefaults() {

f.AutoTranslation = true

f.ClassificationMarkings = false
f.ClassificationMarkings = true

f.BurnOnRead = true

Expand Down
10 changes: 5 additions & 5 deletions server/public/model/feature_flags_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,17 @@ func TestFeatureFlagsSetDefaults(t *testing.T) {
f := &FeatureFlags{}
f.SetDefaults()

t.Run("ClassificationMarkings should default to false", func(t *testing.T) {
require.False(t, f.ClassificationMarkings)
t.Run("ClassificationMarkings should default to true", func(t *testing.T) {
require.True(t, f.ClassificationMarkings)
})

t.Run("ClassificationMarkings should serialize correctly", func(t *testing.T) {
m := f.ToMap()
require.Equal(t, "false", m["ClassificationMarkings"])
require.Equal(t, "true", m["ClassificationMarkings"])

f.ClassificationMarkings = true
f.ClassificationMarkings = false
m = f.ToMap()
require.Equal(t, "true", m["ClassificationMarkings"])
require.Equal(t, "false", m["ClassificationMarkings"])
})
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -971,16 +971,116 @@ describe('ChannelSettingsConfigurationTab', () => {
});
});

it('preserves a saved regular banner color and shows Save when classification is re-enabled', async () => {
const {Client4} = require('mattermost-redux/client');
const {patchChannel} = require('mattermost-redux/actions/channels');
patchChannel.mockReturnValue({type: 'MOCK_ACTION', data: {}});
Client4.patchPropertyValues.mockResolvedValueOnce([]);
enableClassification({
hasClassification: true,
classificationId: LEVEL_UNCLASSIFIED.id,
bannerText: `**${LEVEL_UNCLASSIFIED.name}**`,
});

const classifiedChannel = TestHelper.getChannelMock({
...mockChannel,
banner_info: {
enabled: true,
text: `**${LEVEL_UNCLASSIFIED.name}**`,
background_color: LEVEL_UNCLASSIFIED.color,
},
});
const savedRegularBannerChannel = TestHelper.getChannelMock({
...mockChannel,
banner_info: {
enabled: true,
text: `**${LEVEL_UNCLASSIFIED.name}**`,
background_color: '#aa00aa',
},
});

const {rerender} = renderWithContext(
<ChannelSettingsConfigurationTab
{...baseProps}
channel={classifiedChannel}
canManageSharedChannels={true}
/>,
{},
{useMockedStore: true},
);

await userEvent.click(screen.getByTestId('channelClassificationToggle-button'));

const colorInput = screen.getByTestId('color-inputColorValue');
await userEvent.clear(colorInput);
await userEvent.type(colorInput, '#AA00AA');

const saveButton = await screen.findByRole('button', {name: 'Save'});
await userEvent.click(saveButton);

await waitFor(() => {
expect(patchChannel).toHaveBeenCalledWith(
'channel1',
expect.objectContaining({
banner_info: expect.objectContaining({
enabled: true,
background_color: '#aa00aa',
}),
}),
);
});
expect(Client4.patchPropertyValues).toHaveBeenCalledWith(
'access_control',
'channel',
'channel1',
[{field_id: CHANNEL_FIELD_ID, value: null}],
);

mockedUseChannelClassificationBanner.mockReturnValue({
hasClassification: false,
classificationBanner: undefined,
classificationId: undefined,
bannerText: undefined,
});
rerender(
<ChannelSettingsConfigurationTab
{...baseProps}
channel={savedRegularBannerChannel}
canManageSharedChannels={true}
/>,
);

await waitFor(() => {
expect(screen.getByTestId('color-inputColorValue')).toHaveValue('#aa00aa');
});
expect(screen.queryByRole('button', {name: 'Save'})).not.toBeInTheDocument();

await userEvent.click(screen.getByTestId('channelClassificationToggle-button'));

await waitFor(() => {
expect(screen.getByRole('button', {name: 'Save'})).toBeEnabled();
});
});

it('resets classification form to initial state when Reset is clicked', async () => {
enableClassification({
hasClassification: true,
classificationId: LEVEL_UNCLASSIFIED.id,
bannerText: `**${LEVEL_UNCLASSIFIED.name}**`,
});
const classifiedChannel = TestHelper.getChannelMock({
...mockChannel,
banner_info: {
enabled: true,
text: `**${LEVEL_UNCLASSIFIED.name}**`,
background_color: LEVEL_UNCLASSIFIED.color,
},
});

renderWithContext(
<ChannelSettingsConfigurationTab
{...baseProps}
channel={classifiedChannel}
canManageSharedChannels={true}
/>,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,28 +111,8 @@ function ChannelSettingsConfigurationTab({
const canManageClassification = classification.available && canManageChannelRoles;
const [classificationEnabled, setClassificationEnabled] = useState(classificationBanner.hasClassification);
const [selectedClassificationId, setSelectedClassificationId] = useState(classificationBanner.classificationId || '');

const bannerLockedByClassification = classificationEnabled && Boolean(selectedClassificationId);

useEffect(() => {
setClassificationEnabled(classificationBanner.hasClassification);
setSelectedClassificationId(classificationBanner.classificationId || '');

// Mirror the classification text/color into the local banner_info form
// state so the user can edit text while a classification is active —
// but never flip banner_info.enabled. The classification banner renders
// off the property value (see channel_banner.tsx); leaving banner_info
// disabled means deleting the property value makes the banner disappear
// without dragging stale text/color into the manual banner slot.
if (classificationBanner.hasClassification && classificationBanner.classificationBanner) {
setUpdatedChannelBanner((prev) => ({
...prev,
text: classificationBanner.classificationBanner?.text ?? prev.text,
background_color: classificationBanner.classificationBanner?.background_color || prev.background_color || DEFAULT_CHANNEL_BANNER.background_color,
}));
}
}, [classificationBanner.hasClassification, classificationBanner.classificationId, classificationBanner.classificationBanner]);

const classificationOptions = useMemo(() => {
return classification.levels.
filter((l) => l.name.trim() !== '').
Expand Down Expand Up @@ -164,7 +144,7 @@ function ChannelSettingsConfigurationTab({
}), [classificationBanner.hasClassification, classificationBanner.classificationId]);

const hasClassificationChanges = classificationEnabled !== initialClassificationState.enabled ||
selectedClassificationId !== initialClassificationState.classificationId;
(classificationEnabled && selectedClassificationId !== initialClassificationState.classificationId);

const handleClassificationToggle = useCallback(() => {
setClassificationEnabled((prev) => {
Expand Down Expand Up @@ -360,9 +340,40 @@ function ChannelSettingsConfigurationTab({
hasClassificationChanges ||
(canManageSharedChannels && hasWorkspaceChanges);

useEffect(() => {
if (hasUnsavedChanges) {
return;
}

setClassificationEnabled(classificationBanner.hasClassification);
setSelectedClassificationId(classificationBanner.classificationId || '');

// Mirror the classification text/color into the local banner_info form
// state so the user can edit text while a classification is active —
// but never flip banner_info.enabled. The classification banner renders
// off the property value (see channel_banner.tsx); leaving banner_info
// disabled means deleting the property value makes the banner disappear
// without dragging stale text/color into the manual banner slot.
if (classificationBanner.hasClassification && classificationBanner.classificationBanner) {
setUpdatedChannelBanner((prev) => ({
...prev,
text: classificationBanner.classificationBanner?.text ?? prev.text,
background_color: classificationBanner.classificationBanner?.background_color || prev.background_color || DEFAULT_CHANNEL_BANNER.background_color,
}));
}
}, [
classificationBanner.hasClassification,
classificationBanner.classificationId,
classificationBanner.classificationBanner,
hasUnsavedChanges,
]);

useEffect(() => {
setRequireConfirm(hasUnsavedChanges);
setAreThereUnsavedChanges?.(hasUnsavedChanges);
if (hasUnsavedChanges) {
setSaveChangesPanelState((current) => (current === 'saved' ? undefined : current));
}
}, [hasUnsavedChanges, setAreThereUnsavedChanges]);

const handleServerError = useCallback((err: ServerError) => {
Expand Down
Loading