Data spillage report generation UI (#36340) (#36612)

Automatic Merge
This commit is contained in:
Mattermost Build
2026-05-18 18:23:41 +02:00
committed by GitHub
parent 7e4df6ac29
commit 0cec0687d7
41 changed files with 2214 additions and 255 deletions
@@ -17,6 +17,12 @@ export default class ContentReviewPage {
readonly confirmRemoveMessageButton: Locator;
readonly confirmKeepMessageButton: Locator;
readonly confirmationModalComment: Locator;
readonly downloadReportCheckbox: Locator;
readonly formContinueButton: Locator;
readonly removePermanentlyButton: Locator;
readonly keepPermanentlyButton: Locator;
readonly removeWithoutReportButton: Locator;
readonly generatedSection: Locator;
constructor(page: Page) {
this.page = page;
@@ -33,6 +39,16 @@ export default class ContentReviewPage {
this.confirmationModalComment = this.postActionConformationModal.getByTestId(
'RemoveFlaggedMessageConfirmationModal__comment',
);
this.downloadReportCheckbox = this.postActionConformationModal.getByTestId('download-report-checkbox');
this.formContinueButton = this.postActionConformationModal.getByRole('button', {name: 'Continue'});
this.removePermanentlyButton = this.postActionConformationModal.getByRole('button', {
name: 'Remove permanently',
});
this.keepPermanentlyButton = this.postActionConformationModal.getByRole('button', {name: 'Keep permanently'});
this.removeWithoutReportButton = this.postActionConformationModal.getByRole('button', {
name: 'Remove without report',
});
this.generatedSection = this.postActionConformationModal.getByTestId('generated-section');
}
async setReportCardByPostID(postID: string) {
@@ -175,4 +191,35 @@ export default class ContentReviewPage {
await this.confirmKeepMessageButton.click();
await this.postActionConformationModal.waitFor({state: 'hidden'});
}
/**
* From the form step, advance to the report-generated step
* (downloadReport checkbox is on by default).
*/
async submitFormAndWaitForReport() {
await this.formContinueButton.click();
await expect(this.generatedSection).toBeVisible({timeout: 30000});
}
async confirmRemovePermanently() {
await this.removePermanentlyButton.click();
await this.postActionConformationModal.waitFor({state: 'hidden'});
}
async confirmKeepPermanently() {
await this.keepPermanentlyButton.click();
await this.postActionConformationModal.waitFor({state: 'hidden'});
}
/**
* Skip-report path: uncheck the download checkbox, submit the form to reach
* the skip-confirm step, then confirm removal without a report.
*/
async confirmRemoveWithoutReport() {
await this.downloadReportCheckbox.uncheck();
await this.confirmRemoveMessageButton.click();
await expect(this.removeWithoutReportButton).toBeVisible({timeout: 10000});
await this.removeWithoutReportButton.click();
await this.postActionConformationModal.waitFor({state: 'hidden'});
}
}
@@ -14,8 +14,8 @@ import {setupContentFlagging, createPost} from './../support';
* 4. Login as the reviewer and navigate to the content review DM
* 5. Verify the deletion report summary table is posted in the reviewer's thread
*/
test.fixme('Reviewer receives a deletion report summary after removing a flagged post', async ({pw}) => {
const {adminClient, team, user: reviewerUser, userClient: reviewerUserClient} = await pw.initSetup();
test('Reviewer receives a deletion report summary after removing a flagged post', async ({pw}) => {
const {adminClient, team, user: reviewerUser} = await pw.initSetup();
// Create author user..
const authorUser = await pw.random.user('author');
@@ -28,18 +28,27 @@ test.fixme('Reviewer receives a deletion report summary after removing a flagged
const message = `Sensitive 2 post by @${authorUser.username} to be removed`;
const {post} = await createPost(adminClient, authorUserClient, team, authorUser, message);
// Flag and remove the post
// Flag the post
await adminClient.flagPost(post.id, 'Classification mismatch', 'This message contains sensitive data');
// Login as reviewer and navigate to content review DM
const {channelsPage} = await pw.testBrowser.login(reviewerUser);
const {channelsPage, contentReviewPage} = await pw.testBrowser.login(reviewerUser);
await channelsPage.goto(team.name, '@content-review');
await channelsPage.toBeVisible();
const lastPost = await channelsPage.centerView.getLastPost();
await lastPost.toContainText(message);
await reviewerUserClient.removeFlaggedPost(post.id, 'Removing: data spillage confirmed');
// Remove the post via the UI. The remove flow still routes through the
// skip-confirm step (destructive action), unlike the keep flow which now
// bypasses it.
await contentReviewPage.setReportCardByPostID(post.id);
await contentReviewPage.openViewDetails();
await contentReviewPage.waitForRHSVisible();
await contentReviewPage.openViewDetails();
await contentReviewPage.clickRemoveMessage();
await contentReviewPage.enterConfirmationComment('Removing: data spillage confirmed');
await contentReviewPage.confirmRemoveWithoutReport();
await channelsPage.goto(team.name, '@content-review');
await channelsPage.toBeVisible();
@@ -5,13 +5,13 @@ import {test} from '@mattermost/playwright-lib';
import {setupContentFlagging, createPost, verifyAuthorNotification} from './../support';
/** @objective Verify Retained and Removed Flagged posts do not appear in RHS after once reviewed
/** @objective Verify Removed Flagged posts show appropriate status and do not show the post message
* @testcase
* 1. Create three users and add them as reviewers to a team
* 2. Setup content flagging with the three users as reviewers
* 3. Create a post and flag it
* 4. As Reviewer 1, Retain the flagged post and verify the status is updated to 'Retained'
* 5. As Reviewer 2, Verify the flagged post status is 'Retained'
* 4. As Reviewer 1, walk through the multi-step removal flow (form → report generated → remove permanently)
* 5. As Reviewer 2, verify the flagged post status is 'Removed' and the message has been replaced
*/
test('Verify Removed Flagged posts show appropriate status and do not show the post message', async ({pw}) => {
const {adminClient, team, user, userClient, adminUser} = await pw.initSetup();
@@ -72,7 +72,10 @@ test('Verify Removed Flagged posts show appropriate status and do not show the p
});
await secondContentReviewPage.clickRemoveMessage();
await secondContentReviewPage.enterConfirmationComment(commentRemove);
await secondContentReviewPage.confirmRemove();
// New multi-step flow: Continue → wait for report to generate → Remove permanently
await secondContentReviewPage.submitFormAndWaitForReport();
await secondContentReviewPage.confirmRemovePermanently();
await setupContentFlagging(adminClient, [adminUser.id, secondUserID, thirdUserID]);
const {channelsPage: channelsPageThird, contentReviewPage: contentReviewPageThird} =
@@ -41,6 +41,7 @@ func generateFlaggedPostReport(c *Context, w http.ResponseWriter, r *http.Reques
model.AddEventParameterToAuditRec(auditRec, "flaggedPostId", postId)
model.AddEventParameterToAuditRec(auditRec, "userId", userId)
model.AddEventParameterToAuditRec(auditRec, "comment", actionRequest.Comment)
model.AddEventParameterToAuditRec(auditRec, "action", actionRequest.Action)
post, appErr := c.App.GetSinglePost(c.AppContext, postId, true)
if appErr != nil {
@@ -65,7 +66,7 @@ func generateFlaggedPostReport(c *Context, w http.ResponseWriter, r *http.Reques
return
}
reportPath, appErr := c.App.GenerateFlaggedPostReport(c.AppContext, postId, userId, actionRequest.Comment)
reportPath, appErr := c.App.GenerateFlaggedPostReport(c.AppContext, postId, userId, actionRequest.Comment, actionRequest.Action)
if appErr != nil {
c.Err = appErr
return
@@ -7,9 +7,11 @@ import (
"archive/zip"
"bytes"
"context"
"io"
"net/http"
"testing"
"github.com/goccy/go-yaml"
"github.com/mattermost/mattermost/server/public/model"
"github.com/stretchr/testify/require"
)
@@ -128,6 +130,45 @@ func TestGenerateFlaggedPostReport(t *testing.T) {
require.True(t, foundAttachment, "attachment for the flagged post should be present in the report archive")
})
t.Run("Should include reviewer decision from request action", func(t *testing.T) {
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
post := th.CreatePost(t)
flagPostViaAPI(t, client, post.Id)
report, resp, err := client.GenerateFlaggedPostReport(context.Background(), post.Id, &model.FlagContentActionRequest{
Comment: "investigation note",
Action: model.ContentFlaggingActionRemove,
})
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NotEmpty(t, report)
zr, err := zip.NewReader(bytes.NewReader(report), int64(len(report)))
require.NoError(t, err)
var review model.FlaggedPostReportContentReview
var found bool
for _, f := range zr.File {
if f.Name != "content_review.yaml" {
continue
}
rc, err := f.Open()
require.NoError(t, err)
b, err := io.ReadAll(rc)
require.NoError(t, err)
_ = rc.Close()
require.NoError(t, yaml.Unmarshal(b, &review))
found = true
break
}
require.True(t, found, "content_review.yaml should be present in the report archive")
require.Equal(t, "remove", review.ActorDecision)
require.Equal(t, th.BasicUser.Id, review.ActorUserId)
require.Equal(t, th.BasicUser.Username, review.ActorUsername)
})
t.Run("Should include edit history entries in the generated report", func(t *testing.T) {
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
+34 -12
View File
@@ -33,7 +33,7 @@ const (
// GenerateFlaggedPostReport builds a ZIP archive of a flagged post's data into a
// temporary file and returns the file path. The caller is responsible for
// removing the file when the response has been served.
func (a *App) GenerateFlaggedPostReport(rctx request.CTX, postID, generatedByUserID, comment string) (string, *model.AppError) {
func (a *App) GenerateFlaggedPostReport(rctx request.CTX, postID, generatedByUserID, comment, action string) (string, *model.AppError) {
if appErr := a.ensureActorCommentForReport(rctx, postID, comment); appErr != nil {
return "", appErr
}
@@ -51,7 +51,7 @@ func (a *App) GenerateFlaggedPostReport(rctx request.CTX, postID, generatedByUse
zw := zip.NewWriter(tmp)
if appErr := a.writeFlaggedPostReport(rctx, zw, postID, generatedByUserID); appErr != nil {
if appErr := a.writeFlaggedPostReport(rctx, zw, postID, generatedByUserID, action); appErr != nil {
_ = zw.Close()
cleanup()
return "", appErr
@@ -73,7 +73,7 @@ func (a *App) GenerateFlaggedPostReport(rctx request.CTX, postID, generatedByUse
return tmpPath, nil
}
func (a *App) writeFlaggedPostReport(rctx request.CTX, zw *zip.Writer, postID, generatedByUserID string) *model.AppError {
func (a *App) writeFlaggedPostReport(rctx request.CTX, zw *zip.Writer, postID, generatedByUserID, action string) *model.AppError {
rc, appErr := a.loadFlaggedPostReportContext(rctx, postID)
if appErr != nil {
return appErr
@@ -89,7 +89,7 @@ func (a *App) writeFlaggedPostReport(rctx request.CTX, zw *zip.Writer, postID, g
if appErr := a.writeEditHistorySection(rctx, zw, rc, seenFiles); appErr != nil {
return appErr
}
if appErr := a.writeContentReviewEntry(rctx, zw, rc.Post); appErr != nil {
if appErr := a.writeContentReviewEntry(rctx, zw, rc.Post, generatedByUserID, action); appErr != nil {
return appErr
}
if appErr := a.writeReportMetadataEntry(zw, generatedByUserID); appErr != nil {
@@ -183,8 +183,8 @@ func (a *App) writeEditHistorySection(rctx request.CTX, zw *zip.Writer, rc *mode
return nil
}
func (a *App) writeContentReviewEntry(rctx request.CTX, zw *zip.Writer, post *model.Post) *model.AppError {
payload, appErr := a.buildContentReviewYAML(rctx, post)
func (a *App) writeContentReviewEntry(rctx request.CTX, zw *zip.Writer, post *model.Post, generatedByUserID, action string) *model.AppError {
payload, appErr := a.buildContentReviewYAML(rctx, post, generatedByUserID, action)
if appErr != nil {
return appErr
}
@@ -199,16 +199,18 @@ func (a *App) writeContentReviewEntry(rctx request.CTX, zw *zip.Writer, post *mo
// already present (set by a prior keep/remove or report-generation), it is
// preserved so the existing reviewer note is never overwritten.
func (a *App) ensureActorCommentForReport(rctx request.CTX, postID, comment string) *model.AppError {
if comment == "" {
return nil
}
existing, appErr := a.GetPostContentFlaggingPropertyValue(postID, contentFlaggingPropertyNameActorComment)
if appErr != nil && appErr.StatusCode != http.StatusNotFound {
return appErr
}
if existing != nil {
return nil
}
if comment == "" {
return nil
}
groupID, gErr := a.ContentFlaggingGroupId()
if gErr != nil {
@@ -233,6 +235,7 @@ func (a *App) ensureActorCommentForReport(rctx request.CTX, postID, comment stri
Value: json.RawMessage(commentBytes),
},
}
if _, appErr := a.CreatePropertyValues(rctx, propertyValues); appErr != nil {
return model.NewAppError("ensureActorCommentForReport", "app.data_spillage.create_property_values.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
}
@@ -279,7 +282,7 @@ func buildPostYAML(post *model.Post, channel *model.Channel, team *model.Team, a
return out
}
func (a *App) buildContentReviewYAML(rctx request.CTX, post *model.Post) (model.FlaggedPostReportContentReview, *model.AppError) {
func (a *App) buildContentReviewYAML(rctx request.CTX, post *model.Post, generatedByUserID, pendingAction string) (model.FlaggedPostReportContentReview, *model.AppError) {
out := model.FlaggedPostReportContentReview{}
values, appErr := a.GetPostContentFlaggingPropertyValues(post.Id)
@@ -332,13 +335,32 @@ func (a *App) buildContentReviewYAML(rctx request.CTX, post *model.Post) (model.
}
}
// Reviewer details: prefer the actor (the one who took the keep/remove action)
// when present, otherwise fall back to the assigned reviewer.
reviewerID := decodePropertyString(rctx, byName, contentFlaggingPropertyNameReviewerUserID)
out.ReviewerUserID = reviewerID
out.ReviewerComment = decodePropertyString(rctx, byName, contentFlaggingPropertyNameActorComment)
out.ActionTime = decodePropertyInt64(rctx, byName, contentFlaggingPropertyNameActionTime)
// We want to include the actor details only when an action is being performed - retain or delete the quarantined post.
if pendingAction != "" {
if u, uErr := a.GetUser(generatedByUserID); uErr == nil {
out.ActorUsername = u.Username
out.ActorUserId = u.Id
} else {
rctx.Logger().Warn("Failed to fetch report generator user for flagged post report", mlog.String("user_id", generatedByUserID), mlog.Err(uErr))
}
}
switch decodePropertyString(rctx, byName, ContentFlaggingPropertyNameStatus) {
case model.ContentFlaggingStatusRetained:
out.ActorDecision = model.ContentFlaggingActionKeep
case model.ContentFlaggingStatusRemoved:
out.ActorDecision = model.ContentFlaggingActionRemove
default:
if pendingAction == model.ContentFlaggingActionKeep || pendingAction == model.ContentFlaggingActionRemove {
out.ActorDecision = pendingAction
}
}
if reviewerID != "" {
if u, uErr := a.GetUser(reviewerID); uErr == nil {
out.ReviewerUsername = u.Username
@@ -52,7 +52,7 @@ func TestGenerateFlaggedPostReport(t *testing.T) {
appErr := setBaseConfig(th)
require.Nil(t, appErr)
path, appErr := th.App.GenerateFlaggedPostReport(th.Context, model.NewId(), th.BasicUser.Id, "")
path, appErr := th.App.GenerateFlaggedPostReport(th.Context, model.NewId(), th.BasicUser.Id, "", "")
require.NotNil(t, appErr)
require.Empty(t, path)
})
@@ -63,7 +63,7 @@ func TestGenerateFlaggedPostReport(t *testing.T) {
post := setupFlaggedPost(t, th)
path, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, model.NewId(), "")
path, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, model.NewId(), "", "")
require.NotNil(t, appErr)
require.Empty(t, path)
})
@@ -74,7 +74,7 @@ func TestGenerateFlaggedPostReport(t *testing.T) {
post := setupFlaggedPost(t, th)
path, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "")
path, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "")
require.Nil(t, appErr)
require.NotEmpty(t, path)
@@ -93,7 +93,7 @@ func TestGenerateFlaggedPostReport(t *testing.T) {
post := setupFlaggedPost(t, th)
path, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "")
path, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "")
require.Nil(t, appErr)
entries := readReportZip(t, path)
@@ -113,7 +113,7 @@ func TestGenerateFlaggedPostReport(t *testing.T) {
post := setupFlaggedPost(t, th)
path, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "")
path, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "")
require.Nil(t, appErr)
entries := readReportZip(t, path)
@@ -131,7 +131,7 @@ func TestGenerateFlaggedPostReport(t *testing.T) {
post := setupFlaggedPost(t, th)
path, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "")
path, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "")
require.Nil(t, appErr)
entries := readReportZip(t, path)
@@ -143,6 +143,85 @@ func TestGenerateFlaggedPostReport(t *testing.T) {
require.Equal(t, "spam", review.ReporterReason)
require.Equal(t, "This is spam content", review.ReporterComment)
require.Greater(t, review.ReportTimestamp, int64(0))
require.Empty(t, review.ActorDecision)
require.Empty(t, review.ActorUserId)
require.Empty(t, review.ActorUsername)
})
t.Run("content_review.yaml records remove decision after permanent delete", func(t *testing.T) {
appErr := setBaseConfig(th)
require.Nil(t, appErr)
post := setupFlaggedPost(t, th)
appErr = th.App.PermanentDeleteFlaggedPost(th.Context, &model.FlagContentActionRequest{Comment: "violates policy"}, th.SystemAdminUser.Id, post)
require.Nil(t, appErr)
path, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "")
require.Nil(t, appErr)
entries := readReportZip(t, path)
var review model.FlaggedPostReportContentReview
require.NoError(t, yaml.Unmarshal(entries["content_review.yaml"], &review))
require.Equal(t, "remove", review.ActorDecision)
require.Empty(t, review.ActorUserId)
require.Empty(t, review.ActorUsername)
})
t.Run("content_review.yaml records keep decision after keep action", func(t *testing.T) {
appErr := setBaseConfig(th)
require.Nil(t, appErr)
post := setupFlaggedPost(t, th)
appErr = th.App.KeepFlaggedPost(th.Context, &model.FlagContentActionRequest{Comment: "looks fine"}, th.SystemAdminUser.Id, post)
require.Nil(t, appErr)
path, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "")
require.Nil(t, appErr)
entries := readReportZip(t, path)
var review model.FlaggedPostReportContentReview
require.NoError(t, yaml.Unmarshal(entries["content_review.yaml"], &review))
require.Equal(t, "keep", review.ActorDecision)
require.Empty(t, review.ActorUserId)
require.Empty(t, review.ActorUsername)
})
t.Run("content_review.yaml uses pending action when status is not yet committed", func(t *testing.T) {
appErr := setBaseConfig(th)
require.Nil(t, appErr)
post := setupFlaggedPost(t, th)
path, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", model.ContentFlaggingActionRemove)
require.Nil(t, appErr)
entries := readReportZip(t, path)
var review model.FlaggedPostReportContentReview
require.NoError(t, yaml.Unmarshal(entries["content_review.yaml"], &review))
require.Equal(t, "remove", review.ActorDecision)
require.Equal(t, th.BasicUser.Id, review.ActorUserId)
require.Equal(t, th.BasicUser.Username, review.ActorUsername)
})
t.Run("content_review.yaml ignores invalid pending action", func(t *testing.T) {
appErr := setBaseConfig(th)
require.Nil(t, appErr)
post := setupFlaggedPost(t, th)
path, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "bogus")
require.Nil(t, appErr)
entries := readReportZip(t, path)
var review model.FlaggedPostReportContentReview
require.NoError(t, yaml.Unmarshal(entries["content_review.yaml"], &review))
require.Empty(t, review.ActorDecision)
// Actor details are still populated whenever a pending action is supplied,
// even when the value isn't a recognised decision.
require.Equal(t, th.BasicUser.Id, review.ActorUserId)
require.Equal(t, th.BasicUser.Username, review.ActorUsername)
})
t.Run("includes file attachments for the base post", func(t *testing.T) {
@@ -181,7 +260,7 @@ func TestGenerateFlaggedPostReport(t *testing.T) {
appErr = th.App.FlagPost(th.Context, post, th.BasicTeam.Id, th.BasicUser2.Id, flagData)
require.Nil(t, appErr)
path, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "")
path, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "")
require.Nil(t, appErr)
entries := readReportZip(t, path)
@@ -217,7 +296,7 @@ func TestGenerateFlaggedPostReport(t *testing.T) {
appErr = th.App.FlagPost(th.Context, post, th.BasicTeam.Id, th.BasicUser2.Id, flagData)
require.Nil(t, appErr)
path, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "")
path, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "")
require.Nil(t, appErr)
entries := readReportZip(t, path)
+6
View File
@@ -26,6 +26,11 @@ const (
ContentFlaggingStatusRetained = "Retained"
)
const (
ContentFlaggingActionKeep = "keep"
ContentFlaggingActionRemove = "remove"
)
type FlagContentRequest struct {
Reason string `json:"reason"`
Comment string `json:"comment,omitempty"`
@@ -53,6 +58,7 @@ func (f *FlagContentRequest) IsValid(commentRequired bool, validReasons []string
type FlagContentActionRequest struct {
Comment string `json:"comment,omitempty"`
Action string `json:"action,omitempty"`
}
func (f *FlagContentActionRequest) IsValid(commentRequired bool) *AppError {
@@ -74,6 +74,9 @@ type FlaggedPostReportContentReview struct {
ReviewerUsername string `yaml:"reviewer_username,omitempty"`
ReviewerComment string `yaml:"reviewer_comment,omitempty"`
ActionTime int64 `yaml:"action_time,omitempty"`
ActorDecision string `yaml:"actor_decision,omitempty"`
ActorUserId string `yaml:"actor_user_id,omitempty"`
ActorUsername string `yaml:"actor_username,omitempty"`
}
// FlaggedPostReportMetadata is the on-disk shape for report_metadata.yaml.
@@ -0,0 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
.DataSpillageDownloadReport {
display: flex;
.btn {
display: inline-flex;
align-items: center;
gap: 4px;
}
.LoadingSpinner {
line-height: 0;
}
}
@@ -0,0 +1,111 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {screen, waitFor} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import {Client4} from 'mattermost-redux/client';
import DataSpillageDownloadReport from 'components/post_view/data_spillage_report/data_spillage_download_report/data_spillage_download_report';
import {renderWithContext} from 'tests/react_testing_utils';
describe('DataSpillageDownloadReport', () => {
const flaggedPostId = 'flagged_post_id';
let originalCreateObjectURL: typeof URL.createObjectURL;
let originalRevokeObjectURL: typeof URL.revokeObjectURL;
beforeEach(() => {
jest.clearAllMocks();
Client4.generateFlaggedPostReport = jest.fn().mockResolvedValue(new Blob(['report'], {type: 'application/zip'}));
originalCreateObjectURL = URL.createObjectURL;
originalRevokeObjectURL = URL.revokeObjectURL;
URL.createObjectURL = jest.fn().mockReturnValue('blob:mock-url');
URL.revokeObjectURL = jest.fn();
// eslint-disable-next-line no-console
console.error = jest.fn();
});
afterEach(() => {
URL.createObjectURL = originalCreateObjectURL;
URL.revokeObjectURL = originalRevokeObjectURL;
});
test('renders idle Download Report button', () => {
renderWithContext(<DataSpillageDownloadReport flaggedPostId={flaggedPostId}/>);
const button = screen.getByTestId('data-spillage-action-download-report');
expect(button).toBeVisible();
expect(button).toHaveTextContent('Download Report');
expect(button).not.toBeDisabled();
});
test('click triggers download and returns to idle on success', async () => {
renderWithContext(<DataSpillageDownloadReport flaggedPostId={flaggedPostId}/>);
await userEvent.click(screen.getByTestId('data-spillage-action-download-report'));
await waitFor(() => {
expect(Client4.generateFlaggedPostReport).toHaveBeenCalledWith(
flaggedPostId,
'',
undefined,
expect.any(AbortSignal),
);
});
await waitFor(() => {
expect(URL.createObjectURL).toHaveBeenCalled();
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url');
});
// Returns to idle state
await waitFor(() => {
expect(screen.getByTestId('data-spillage-action-download-report')).toHaveTextContent('Download Report');
});
});
test('shows error state when request rejects', async () => {
Client4.generateFlaggedPostReport = jest.fn().mockRejectedValue(new Error('boom'));
renderWithContext(<DataSpillageDownloadReport flaggedPostId={flaggedPostId}/>);
await userEvent.click(screen.getByTestId('data-spillage-action-download-report'));
await waitFor(() => {
expect(screen.getByTestId('data-spillage-action-download-report')).toHaveTextContent('Generation failed. Try again.');
});
expect(URL.createObjectURL).not.toHaveBeenCalled();
});
test('aborts in-flight request on unmount', async () => {
// Hold the request promise open until we unmount
let resolveRequest: (value: Blob) => void = () => {};
const requestPromise = new Promise<Blob>((resolve) => {
resolveRequest = resolve;
});
Client4.generateFlaggedPostReport = jest.fn().mockReturnValue(requestPromise);
const {unmount} = renderWithContext(<DataSpillageDownloadReport flaggedPostId={flaggedPostId}/>);
await userEvent.click(screen.getByTestId('data-spillage-action-download-report'));
// While generating, button is disabled and shows generating label
await waitFor(() => {
expect(screen.getByTestId('data-spillage-action-download-report')).toHaveTextContent('Generating report…');
});
expect(screen.getByTestId('data-spillage-action-download-report')).toBeDisabled();
unmount();
// Resolving after unmount should not trigger a download
resolveRequest(new Blob(['report']));
await Promise.resolve();
await Promise.resolve();
expect(URL.createObjectURL).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,131 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {FormattedMessage} from 'react-intl';
import {Client4} from 'mattermost-redux/client';
import LoadingSpinner from 'components/widgets/loading/loading_spinner';
import './data_spillage_download_report.scss';
type Props = {
flaggedPostId: string;
}
type Status = 'idle' | 'generating' | 'error';
export default function DataSpillageDownloadReport({flaggedPostId}: Props) {
const [status, setStatus] = useState<Status>('idle');
const abortControllerRef = useRef<AbortController | null>(null);
useEffect(() => {
// Cleanup function to cancel in-progress API calls
return () => {
abortControllerRef.current?.abort();
};
}, []);
const handleClick = useCallback(async () => {
if (status === 'generating') {
return;
}
const controller = new AbortController();
abortControllerRef.current?.abort();
abortControllerRef.current = controller;
setStatus('generating');
let blob: Blob | undefined;
try {
blob = await Client4.generateFlaggedPostReport(flaggedPostId, '', undefined, controller.signal);
if (controller.signal.aborted) {
return;
}
} catch (err) {
if (controller.signal.aborted) {
return;
}
// eslint-disable-next-line no-console
console.error(err);
setStatus('error');
return;
}
if (controller.signal.aborted || !blob) {
return;
}
const downloadUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = downloadUrl;
a.download = `flagged-post-${flaggedPostId}-${Date.now()}.zip`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(downloadUrl);
setStatus('idle');
}, [flaggedPostId, status]);
let icon;
let label;
let buttonClass;
switch (status) {
case 'generating':
icon = <LoadingSpinner/>;
label = (
<FormattedMessage
id='data_spillage_report.download_report.generating.button_text'
defaultMessage='Generating report…'
/>
);
buttonClass = 'btn-tertiary';
break;
case 'error':
icon = <i className='icon icon-alert-outline'/>;
label = (
<FormattedMessage
id='data_spillage_report.download_report.failed.button_text'
defaultMessage='Generation failed. Try again.'
/>
);
buttonClass = 'btn-danger';
break;
case 'idle':
default:
icon = <i className='icon icon-download-outline'/>;
label = (
<FormattedMessage
id='data_spillage_report.download_report.button_text'
defaultMessage='Download Report'
/>
);
buttonClass = 'btn-tertiary';
break;
}
return (
<div
className='DataSpillageDownloadReport'
data-testid='data-spillage-download-report'
>
<button
type='button'
className={classNames('btn btn-sm', buttonClass)}
onClick={handleClick}
disabled={status === 'generating'}
data-testid='data-spillage-action-download-report'
>
{icon}
{label}
</button>
</div>
);
}
@@ -430,4 +430,37 @@ describe('components/post_view/data_spillage_report/DataSpillageReport', () => {
expect(screen.queryByTestId('data-spillage-action-remove-message')).toBeVisible();
expect(screen.queryByTestId('data-spillage-action-keep-message')).toBeVisible();
});
describe.each([
['Pending', true],
['Retained', false],
['Removed', false],
])('Download Report button when status is %s', (status, expectActions) => {
it('is rendered in action rows in RHS mode', async () => {
usePostContentFlaggingValues.mockReturnValue(
postContentFlaggingValues.map((v) =>
(v.field_id === contentFlaggingFields.status.id ? {...v, value: status} : v),
),
);
renderWithContext(
<DataSpillageReport
post={post}
isRHS={true}
/>,
baseState,
);
await act(async () => {});
expect(screen.getByTestId('data-spillage-action-download-report')).toBeVisible();
expect(screen.getByTestId('data-spillage-action-download-report')).toHaveTextContent('Download Report');
if (expectActions) {
expect(screen.queryByTestId('data-spillage-action')).toBeVisible();
} else {
expect(screen.queryByTestId('data-spillage-action')).not.toBeInTheDocument();
}
});
});
});
@@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import {useIntl} from 'react-intl';
import {FormattedMessage, useIntl} from 'react-intl';
import {ContentFlaggingStatus} from '@mattermost/types/content_flagging';
import type {Post} from '@mattermost/types/posts';
@@ -16,15 +16,17 @@ import {useGetContentFlaggingChannel, useGetContentFlaggingTeam, useGetFlaggedPo
import {useContentFlaggingFields, usePostContentFlaggingValues} from 'components/common/hooks/useContentFlaggingFields';
import {useUser} from 'components/common/hooks/useUser';
import DataSpillageAction from 'components/post_view/data_spillage_report/data_spillage_actions/data_spillage_actions';
import type {PropertiesCardViewMetadata} from 'components/properties_card_view/properties_card_view';
import DataSpillageDownloadReport from 'components/post_view/data_spillage_report/data_spillage_download_report/data_spillage_download_report';
import type {ActionRow, PropertiesCardViewMetadata} from 'components/properties_card_view/properties_card_view';
import PropertiesCardView from 'components/properties_card_view/properties_card_view';
import {DataSpillagePropertyNames} from 'utils/constants';
import './data_spillage_report.scss';
import DataSpillageFooter from './data_spillage_footer/data_spillage_footer';
import {getSyntheticPropertyFields, getSyntheticPropertyValues} from './synthetic_data';
import './data_spillage_report.scss';
// The order of fields to be displayed in the report, from top to bottom.
const orderedFieldName = [
'status',
@@ -115,8 +117,7 @@ export function DataSpillageReport({post, isRHS}: Props) {
fetchDeletedPost: true,
channel,
team,
generateFileDownloadUrl:
generateFileDownloadUrl(reportedPostId),
generateFileDownloadUrl: generateFileDownloadUrl(reportedPostId),
},
reporting_comment: {
placeholder: formatMessage({
@@ -157,24 +158,44 @@ export function DataSpillageReport({post, isRHS}: Props) {
);
}, [isRHS, post, reportedPostId]);
const actionRow = useMemo(() => {
if (!reportedPost || !reportingUser) {
return null;
const actionRows = useMemo<ActionRow[]>(() => {
if (!reportedPost) {
return [];
}
let showActionRow;
if (!propertyFields || !propertyValues) {
showActionRow = true;
} else {
const status = propertyValues.find((value) => value.field_id === propertyFields.status.id)?.value as string | undefined;
showActionRow = reportedPost && reportingUser && status && (status === ContentFlaggingStatus.Pending || status === ContentFlaggingStatus.Assigned);
const rows: ActionRow[] = [];
rows.push({
label: (
<FormattedMessage
id='data_spillage_report.row.report.label'
defaultMessage='Report'
/>
),
content: <DataSpillageDownloadReport flaggedPostId={reportedPost.id}/>,
});
const statusFieldId = propertyFields.status?.id;
const status = statusFieldId ? (propertyValues.find((value) => value.field_id === statusFieldId)?.value as string | undefined) : undefined;
if (reportingUser && (status === ContentFlaggingStatus.Pending || status === ContentFlaggingStatus.Assigned)) {
rows.push({
label: (
<FormattedMessage
id='data_spillage_report.row.actions.label'
defaultMessage='Actions'
/>
),
content: (
<DataSpillageAction
flaggedPost={reportedPost}
reportingUser={reportingUser}
/>
),
});
}
return showActionRow ? (
<DataSpillageAction
flaggedPost={reportedPost}
reportingUser={reportingUser}
/>) : null;
return rows;
}, [propertyFields, propertyValues, reportedPost, reportingUser]);
return (
@@ -189,7 +210,7 @@ export function DataSpillageReport({post, isRHS}: Props) {
propertyValues={propertyValues}
fieldOrder={orderedFieldName}
shortModeFieldOrder={shortModeFieldOrder}
actionsRow={actionRow}
actionRows={actionRows}
mode={mode}
metadata={metadata}
footer={footer}
@@ -0,0 +1,99 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {screen} from '@testing-library/react';
import React from 'react';
import type {NameMappedPropertyFields, PropertyField, PropertyValue} from '@mattermost/types/properties';
import {renderWithContext} from 'tests/react_testing_utils';
import PropertiesCardView from './properties_card_view';
import type {ActionRow} from './properties_card_view';
describe('PropertiesCardView actionRows', () => {
const propertyFields: NameMappedPropertyFields = {
status: {
id: 'status_field_id',
group_id: 'group_id',
name: 'status',
type: 'text',
attrs: null,
target_id: '',
target_type: '',
create_at: 0,
update_at: 0,
delete_at: 0,
} as unknown as PropertyField,
};
const propertyValues: Array<PropertyValue<unknown>> = [
{
id: 'value_id',
field_id: 'status_field_id',
value: 'Pending',
} as PropertyValue<unknown>,
];
const baseProps = {
title: 'Card Title',
propertyFields,
propertyValues,
fieldOrder: ['status'],
shortModeFieldOrder: ['status'],
};
const actionRows: ActionRow[] = [
{label: 'Report', content: <div data-testid='report-content'>{'r'}</div>, testId: 'report-row'},
{label: 'Actions', content: <div data-testid='actions-content'>{'a'}</div>, testId: 'actions-row'},
];
test('renders action rows in full mode with labels and content', () => {
renderWithContext(
<PropertiesCardView
{...baseProps}
mode='full'
actionRows={actionRows}
/>,
);
const reportRow = screen.getByTestId('report-row');
expect(reportRow).toBeVisible();
expect(reportRow).toHaveTextContent('Report');
expect(screen.getByTestId('report-content')).toBeVisible();
const actionsRow = screen.getByTestId('actions-row');
expect(actionsRow).toBeVisible();
expect(actionsRow).toHaveTextContent('Actions');
expect(screen.getByTestId('actions-content')).toBeVisible();
});
test('does not render action rows in short mode', () => {
renderWithContext(
<PropertiesCardView
{...baseProps}
mode='short'
actionRows={actionRows}
/>,
);
expect(screen.queryByTestId('report-row')).not.toBeInTheDocument();
expect(screen.queryByTestId('actions-row')).not.toBeInTheDocument();
});
test('skips action rows whose content is falsy', () => {
renderWithContext(
<PropertiesCardView
{...baseProps}
mode='full'
actionRows={[
{label: 'Empty', content: null, testId: 'empty-row'},
actionRows[0],
]}
/>,
);
expect(screen.queryByTestId('empty-row')).not.toBeInTheDocument();
expect(screen.getByTestId('report-row')).toBeVisible();
});
});
@@ -117,6 +117,12 @@ const fieldNameMessages = defineMessages({
},
});
export type ActionRow = {
label: React.ReactNode;
content: React.ReactNode;
testId?: string;
};
type Props = {
title: React.ReactNode;
propertyFields: NameMappedPropertyFields;
@@ -124,12 +130,12 @@ type Props = {
shortModeFieldOrder: Array<PropertyField['id']>;
propertyValues: Array<PropertyValue<unknown>>;
mode?: 'short' | 'full';
actionsRow?: React.ReactNode;
actionRows?: ActionRow[];
metadata?: PropertiesCardViewMetadata;
footer?: React.ReactNode;
}
export default function PropertiesCardView({title, propertyFields, fieldOrder, shortModeFieldOrder, propertyValues, mode, actionsRow, metadata, footer}: Props) {
export default function PropertiesCardView({title, propertyFields, fieldOrder, shortModeFieldOrder, propertyValues, mode, actionRows, metadata, footer}: Props) {
const orderedRows = useMemo<OrderedRow[]>(() => {
const hasRequiredData =
Object.keys(propertyFields).length > 0 &&
@@ -161,6 +167,22 @@ export default function PropertiesCardView({title, propertyFields, fieldOrder, s
filter((row): row is OrderedRow => row !== null);
}, [fieldOrder, mode, propertyFields, propertyValues, shortModeFieldOrder]);
const actionRowsMemo = useMemo(() => {
return actionRows?.map(({label, content, testId}, idx) => (
content ? (
<div
// eslint-disable-next-line react/no-array-index-key
key={idx}
className='row'
data-testid={testId}
>
<div className='field'>{label}</div>
<div className='value'>{content}</div>
</div>
) : null
));
}, [actionRows]);
return (
<div
className='PropertyCardView'
@@ -200,22 +222,7 @@ export default function PropertiesCardView({title, propertyFields, fieldOrder, s
})
}
{
mode === 'full' && actionsRow &&
<div className='row'>
<div className='field'>
<FormattedMessage
id='property_card.actions_row.label'
defaultMessage='Actions'
/>
</div>
<div className='value'>
{actionsRow}
</div>
</div>
}
{mode === 'full' && actionRowsMemo}
{footer}
</div>
</div>
@@ -0,0 +1,62 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
import type {Post} from '@mattermost/types/posts';
import type {UserProfile} from '@mattermost/types/users';
import AtMention from 'components/at_mention';
import {useChannel} from 'components/common/hooks/useChannel';
import {useUser} from 'components/common/hooks/useUser';
type Props = {
action: 'keep' | 'remove';
flaggedPost: Post;
reportingUser: UserProfile;
};
export default function BodyMainActionText({
action,
flaggedPost,
reportingUser,
}: Props) {
const {formatMessage} = useIntl();
const flaggedPostAuthor = useUser(flaggedPost.user_id);
const flaggedPostChannel = useChannel(flaggedPost.channel_id);
const values = {
flaggedPostChannel: flaggedPostChannel?.display_name,
reportingUser: (
<AtMention mentionName={reportingUser?.username || ''}/>
),
flaggedPostAuthor: (
<AtMention mentionName={flaggedPostAuthor?.username || ''}/>
),
};
let body;
if (action === 'remove') {
body = formatMessage(
{
id: 'keep_remove_quarantined_content_modal.action_remove.body',
defaultMessage:
'You are about to remove a message authored by {flaggedPostAuthor} posted in the {flaggedPostChannel} channel and quarantined for review by {reportingUser}.',
},
values,
);
} else {
body = formatMessage(
{
id: 'keep_remove_quarantined_content_modal.action_keep.body',
defaultMessage:
'You are about to keep a quarantined message authored by {flaggedPostAuthor} posted in the {flaggedPostChannel} channel and quarantined for review by {reportingUser}.',
},
values,
);
}
return <p>{body}</p>;
}
@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
.KeepRemoveFlaggedMessageConfirmationModal .body {
.ErrorStepBody {
display: flex;
flex-direction: column;
gap: 8px;
.errorRetryBtn {
width: min-content;
}
}
}
@@ -0,0 +1,74 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import type {ContentFlaggingConfig} from '@mattermost/types/content_flagging';
import type {Post} from '@mattermost/types/posts';
import type {UserProfile} from '@mattermost/types/users';
import FlaggedMessageBody from '../flagged_message_body';
import ReportNotice from '../report_notice';
import './error_step_body.scss';
type BodyProps = {
action: 'keep' | 'remove';
flaggedPost: Post;
reportingUser: UserProfile;
contentFlaggingConfig: ContentFlaggingConfig | undefined;
onRetry: () => void;
};
export default function ErrorStepBody({
action,
flaggedPost,
reportingUser,
contentFlaggingConfig,
onRetry,
}: BodyProps) {
const {formatMessage} = useIntl();
const tryAgainText = formatMessage({
id: 'keep_remove_quarantined_content_modal.try_again.button_text',
defaultMessage: 'Try again',
});
return (
<>
<FlaggedMessageBody
action={action}
flaggedPost={flaggedPost}
reportingUser={reportingUser}
contentFlaggingConfig={contentFlaggingConfig}
/>
<ReportNotice
variant='warning'
testId='error-section'
icon={<span className='icon icon-information-outline'/>}
title={
<FormattedMessage
id='keep_remove_quarantined_content_modal.error.title'
defaultMessage='Report could not be generated'
/>
}
body={
<div className='ErrorStepBody'>
<FormattedMessage
id='keep_remove_quarantined_content_modal.error.body'
defaultMessage='We were unable to generate and download the report to your device.'
/>
<button
type='button'
className='GenericModal__button btn btn-primary errorRetryBtn'
onClick={onRetry}
data-testid='error-retry-button'
>
{tryAgainText}
</button>
</div>
}
/>
</>
);
}
@@ -0,0 +1,57 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React from 'react';
import {useIntl} from 'react-intl';
type FooterProps = {
action: 'keep' | 'remove';
onSkip: () => void;
onBack: () => void;
};
export default function ErrorStepFooter({action, onSkip, onBack}: FooterProps) {
const {formatMessage} = useIntl();
const skipText = formatMessage({id: 'keep_remove_quarantined_content_modal.skip_report_download.button_text', defaultMessage: 'Skip report download'});
const removePermanentlyText = formatMessage({id: 'keep_remove_quarantined_content_modal.action_remove.permanent_button_text', defaultMessage: 'Remove permanently'});
const keepPermanentlyText = formatMessage({id: 'keep_remove_quarantined_content_modal.action_keep.permanent_button_text', defaultMessage: 'Keep permanently'});
const backText = formatMessage({id: 'keep_remove_quarantined_content_modal.back.button_text', defaultMessage: 'Back'});
const permanentText = action === 'remove' ? removePermanentlyText : keepPermanentlyText;
const permanentClass = action === 'remove' ? 'btn-danger' : 'btn-primary';
return (
<div className='ModalFooterRow'>
<div className='ModalFooterRow__left'>
<button
type='button'
className='GenericModal__button btn btn-tertiary btn-danger skipReportBtn'
onClick={onSkip}
data-testid='error-skip-button'
>
{skipText}
</button>
</div>
<div className='ModalFooterRow__right'>
<button
type='button'
className='GenericModal__button btn btn-tertiary'
onClick={onBack}
data-testid='error-step-back-button'
>
{backText}
</button>
<button
type='button'
className={classNames('GenericModal__button btn', permanentClass)}
disabled={true}
data-testid='error-permanent-button'
>
{permanentText}
</button>
</div>
</div>
);
}
@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
.KeepRemoveFlaggedMessageConfirmationModal .body {
.section.message_body {
p {
margin: 0 0 12px;
&:last-child {
margin-bottom: 0;
}
}
}
}
@@ -0,0 +1,62 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import {useIntl} from 'react-intl';
import type {ContentFlaggingConfig} from '@mattermost/types/content_flagging';
import type {Post} from '@mattermost/types/posts';
import type {UserProfile} from '@mattermost/types/users';
import BodyMainActionText from 'components/remove_flagged_message_confirmation_modal/body_main_action_text';
import './flagged_message_body.scss';
type Props = {
action: 'keep' | 'remove';
flaggedPost: Post;
reportingUser: UserProfile;
contentFlaggingConfig: ContentFlaggingConfig | undefined;
};
export default function FlaggedMessageBody({action, flaggedPost, reportingUser, contentFlaggingConfig}: Props) {
const {formatMessage} = useIntl();
const subtext = useMemo(() => {
if (action === 'remove') {
if (contentFlaggingConfig?.notify_reporter_on_removal) {
return formatMessage({
id: 'keep_remove_quarantined_content_modal.action_remove.subtext.notify_reporter',
defaultMessage: 'If you confirm, the message will be removed from the channel and a notification will be sent to the reporter. This action cannot be reverted.',
});
}
return formatMessage({
id: 'keep_remove_quarantined_content_modal.action_remove.subtext.no_notify_reporter',
defaultMessage: 'If you confirm, the message will be removed from the channel. This action cannot be reverted.',
});
} else if (contentFlaggingConfig?.notify_reporter_on_dismissal) {
return formatMessage({
id: 'keep_remove_quarantined_content_modal.action_keep.subtext.notify_reporter',
defaultMessage: 'If you confirm, the message will be visible to all channel members and a notification will be sent to the reporter.',
});
}
return formatMessage({
id: 'keep_remove_quarantined_content_modal.action_keep.subtext.no_notify_reporter',
defaultMessage: 'If you confirm, the message will be visible to all channel members.',
});
}, [action, contentFlaggingConfig?.notify_reporter_on_dismissal, contentFlaggingConfig?.notify_reporter_on_removal, formatMessage]);
return (
<div
className='section message_body'
data-testid='keep-remove-flagged-message-body'
>
<BodyMainActionText
action={action}
flaggedPost={flaggedPost}
reportingUser={reportingUser}
/>
<p data-testid='keep-remove-flagged-message-subtext'>{subtext}</p>
</div>
);
}
@@ -0,0 +1,30 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
.KeepRemoveFlaggedMessageConfirmationModal .body {
.section {
&.comment_section {
display: flex;
flex-direction: column;
gap: 8px;
}
.section_title {
color: var(--center-channel-color);
font-size: 14px;
font-weight: 600;
}
}
button#PreviewInputTextButton {
position: absolute;
z-index: 2;
top: 8px;
right: 8px;
}
textarea#RemoveFlaggedMessageConfirmationModal__comment {
min-height: 90px !important;
max-height: 400px;
}
}
@@ -0,0 +1,83 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
import type {ContentFlaggingConfig} from '@mattermost/types/content_flagging';
import type {Post} from '@mattermost/types/posts';
import type {UserProfile} from '@mattermost/types/users';
import type {TextboxElement} from 'components/textbox';
import AdvancedTextbox from 'components/widgets/advanced_textbox/advanced_textbox';
import FlaggedMessageBody from '../flagged_message_body';
import './form_step_body.scss';
type BodyProps = {
action: 'keep' | 'remove';
flaggedPost: Post;
reportingUser: UserProfile;
contentFlaggingConfig: ContentFlaggingConfig | undefined;
comment: string;
commentError: string;
showCommentPreview: boolean;
onCommentChange: (e: React.ChangeEvent<TextboxElement>) => void;
onToggleCommentPreview: () => void;
};
export function FormStepBody({
action,
flaggedPost,
reportingUser,
contentFlaggingConfig,
comment,
commentError,
showCommentPreview,
onCommentChange,
onToggleCommentPreview,
}: BodyProps) {
const {formatMessage} = useIntl();
const requiredTitle = formatMessage({id: 'remove_flag_post_confirm_modal.required_comment.title', defaultMessage: 'Comment (required)'});
const optionalTitle = formatMessage({id: 'remove_flag_post_confirm_modal.optional_comment.title', defaultMessage: 'Comment (optional)'});
const sectionTitle = contentFlaggingConfig?.reviewer_comment_required ? requiredTitle : optionalTitle;
const commentPlaceholder = formatMessage({id: 'keep_remove_quarantined_content_modal.comment.placeholder', defaultMessage: 'Add your comment here'});
return (
<>
<FlaggedMessageBody
action={action}
flaggedPost={flaggedPost}
reportingUser={reportingUser}
contentFlaggingConfig={contentFlaggingConfig}
/>
<div className='section comment_section'>
<div
className='section_title'
data-testid='keep-remove-flagged-message-comment-title'
>
{sectionTitle}
</div>
<AdvancedTextbox
id='RemoveFlaggedMessageConfirmationModal__comment'
channelId={flaggedPost.channel_id}
value={comment}
onChange={onCommentChange}
createMessage={commentPlaceholder}
preview={showCommentPreview}
togglePreview={onToggleCommentPreview}
useChannelMentions={false}
onKeyPress={() => {}}
hasError={false}
errorMessage={commentError}
maxLength={1000}
/>
</div>
</>
);
}
@@ -0,0 +1,21 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
.KeepRemoveFlaggedMessageConfirmationModal {
.download_report_checkbox {
display: flex;
align-items: center;
margin: 0;
cursor: pointer;
font-size: 16px;
font-weight: 400;
gap: 10px;
input[type='checkbox'] {
width: 20px;
height: 20px;
margin: 0;
cursor: pointer;
}
}
}
@@ -0,0 +1,82 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import './form_step_footer.scss';
type FooterProps = {
action: 'keep' | 'remove';
downloadReport: boolean;
submitting: boolean;
onToggleDownloadReport: (e: React.ChangeEvent<HTMLInputElement>) => void;
onCancel: () => void;
onPrimary: () => void;
};
export function FormStepFooter({
action,
downloadReport,
submitting,
onToggleDownloadReport,
onCancel,
onPrimary,
}: FooterProps) {
const {formatMessage} = useIntl();
const cancelText = formatMessage({id: 'generic_modal.cancel', defaultMessage: 'Cancel'});
const continueText = formatMessage({id: 'keep_remove_quarantined_content_modal.continue.button_text', defaultMessage: 'Continue'});
const removeText = formatMessage({id: 'keep_remove_quarantined_content_modal.action_remove.button_text', defaultMessage: 'Remove message'});
const keepText = formatMessage({id: 'keep_remove_quarantined_content_modal.action_keep.button_text', defaultMessage: 'Keep message'});
const actionText = action === 'remove' ? removeText : keepText;
const uncheckedClass = action === 'remove' ? 'btn-danger' : 'btn-primary';
const primaryText = downloadReport ? continueText : actionText;
const primaryClass = downloadReport ? 'btn-primary' : uncheckedClass;
return (
<div className='ModalFooterRow'>
<div className='ModalFooterRow__left'>
<label
className='download_report_checkbox'
data-testid='download-report-checkbox-label'
>
<input
type='checkbox'
checked={downloadReport}
onChange={onToggleDownloadReport}
data-testid='download-report-checkbox'
/>
<span>
<FormattedMessage
id='keep_remove_quarantined_content_modal.download_report_checkbox.label'
defaultMessage='Download quarantined message report'
/>
</span>
</label>
</div>
<div className='ModalFooterRow__right'>
<button
type='button'
className='GenericModal__button btn btn-tertiary'
onClick={onCancel}
data-testid='form-cancel-button'
>
{cancelText}
</button>
<button
type='button'
className={classNames('GenericModal__button btn', primaryClass)}
onClick={onPrimary}
disabled={submitting}
data-testid='form-primary-button'
>
{primaryText}
</button>
</div>
</div>
);
}
@@ -0,0 +1,61 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import type {ContentFlaggingConfig} from '@mattermost/types/content_flagging';
import type {Post} from '@mattermost/types/posts';
import type {UserProfile} from '@mattermost/types/users';
import FlaggedMessageBody from '../flagged_message_body';
import ReportNotice from '../report_notice';
type BodyProps = {
action: 'keep' | 'remove';
flaggedPost: Post;
reportingUser: UserProfile;
contentFlaggingConfig: ContentFlaggingConfig | undefined;
};
export default function GeneratedStepBody({
action,
flaggedPost,
reportingUser,
contentFlaggingConfig,
}: BodyProps) {
return (
<>
<FlaggedMessageBody
action={action}
flaggedPost={flaggedPost}
reportingUser={reportingUser}
contentFlaggingConfig={contentFlaggingConfig}
/>
<ReportNotice
variant='success'
testId='generated-section'
icon={<span className='icon icon-check'/>}
title={
<FormattedMessage
id='keep_remove_quarantined_content_modal.generated.title'
defaultMessage='Report generated'
/>
}
body={
action === 'remove' ? (
<FormattedMessage
id='keep_remove_quarantined_content_modal.action_remove.generated.body'
defaultMessage='The report should now be downloading on your device. Once it is downloaded, you can remove the message permanently.'
/>
) : (
<FormattedMessage
id='keep_remove_quarantined_content_modal.action_keep.generated.body'
defaultMessage='The report should now be downloading on your device. Once it is downloaded, you can keep the message permanently.'
/>
)
}
/>
</>
);
}
@@ -0,0 +1,66 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React from 'react';
import {useIntl} from 'react-intl';
type FooterProps = {
action: 'keep' | 'remove';
submitting: boolean;
onDownloadAgain: () => void;
onBack: () => void;
onPermanent: () => void;
};
export default function GeneratedStepFooter({
action,
submitting,
onDownloadAgain,
onBack,
onPermanent,
}: FooterProps) {
const {formatMessage} = useIntl();
const downloadAgainText = formatMessage({id: 'keep_remove_quarantined_content_modal.download_again.button_text', defaultMessage: 'Download again'});
const backText = formatMessage({id: 'keep_remove_quarantined_content_modal.back.button_text', defaultMessage: 'Back'});
const removePermanentlyText = formatMessage({id: 'keep_remove_quarantined_content_modal.action_remove.permanent_button_text', defaultMessage: 'Remove permanently'});
const keepPermanentlyText = formatMessage({id: 'keep_remove_quarantined_content_modal.action_keep.permanent_button_text', defaultMessage: 'Keep permanently'});
const permanentText = action === 'remove' ? removePermanentlyText : keepPermanentlyText;
const permanentClass = action === 'remove' ? 'btn-danger' : 'btn-primary';
return (
<div className='ModalFooterRow'>
<div className='ModalFooterRow__left'>
<button
type='button'
className='GenericModal__button btn btn-tertiary'
onClick={onDownloadAgain}
data-testid='generated-download-again-button'
>
{downloadAgainText}
</button>
</div>
<div className='ModalFooterRow__right'>
<button
type='button'
className='GenericModal__button btn btn-tertiary'
onClick={onBack}
data-testid='generated-back-button'
>
{backText}
</button>
<button
type='button'
className={classNames('GenericModal__button btn', permanentClass)}
onClick={onPermanent}
disabled={submitting}
data-testid='generated-permanent-button'
>
{permanentText}
</button>
</div>
</div>
);
}
@@ -0,0 +1,63 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import type {ContentFlaggingConfig} from '@mattermost/types/content_flagging';
import type {Post} from '@mattermost/types/posts';
import type {UserProfile} from '@mattermost/types/users';
import LoadingSpinner from 'components/widgets/loading/loading_spinner';
import FlaggedMessageBody from '../flagged_message_body';
import ReportNotice from '../report_notice';
type BodyProps = {
action: 'keep' | 'remove';
flaggedPost: Post;
reportingUser: UserProfile;
contentFlaggingConfig: ContentFlaggingConfig | undefined;
};
export function GeneratingStepBody({
action,
flaggedPost,
reportingUser,
contentFlaggingConfig,
}: BodyProps) {
return (
<>
<FlaggedMessageBody
action={action}
flaggedPost={flaggedPost}
reportingUser={reportingUser}
contentFlaggingConfig={contentFlaggingConfig}
/>
<ReportNotice
variant='info'
testId='generating-section'
icon={<LoadingSpinner/>}
title={
<FormattedMessage
id='keep_remove_quarantined_content_modal.generating.title'
defaultMessage='Generating report…'
/>
}
body={
action === 'remove' ? (
<FormattedMessage
id='keep_remove_quarantined_content_modal.action_remove.generating.body'
defaultMessage='Please wait for the report to download before you remove the message permanently. There will be no way to recover the message contents once it is removed.'
/>
) : (
<FormattedMessage
id='keep_remove_quarantined_content_modal.action_keep.generating.body'
defaultMessage='Please wait for the report to download before you keep the message permanently.'
/>
)
}
/>
</>
);
}
@@ -0,0 +1,58 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React from 'react';
import {useIntl} from 'react-intl';
type FooterProps = {
action: 'keep' | 'remove';
onSkip: () => void;
onBack: () => void;
};
export function GeneratingStepFooter({action, onSkip, onBack}: FooterProps) {
const {formatMessage} = useIntl();
const skipText = formatMessage({id: 'keep_remove_quarantined_content_modal.skip_report_download.button_text', defaultMessage: 'Skip report download'});
const backText = formatMessage({id: 'keep_remove_quarantined_content_modal.back.button_text', defaultMessage: 'Back'});
const removePermanentlyText = formatMessage({id: 'keep_remove_quarantined_content_modal.action_remove.permanent_button_text', defaultMessage: 'Remove permanently'});
const keepPermanentlyText = formatMessage({id: 'keep_remove_quarantined_content_modal.action_keep.permanent_button_text', defaultMessage: 'Keep permanently'});
const permanentText =
action === 'remove' ? removePermanentlyText : keepPermanentlyText;
const permanentClass = action === 'remove' ? 'btn-danger' : 'btn-primary';
return (
<div className='ModalFooterRow'>
<div className='ModalFooterRow__left'>
<button
type='button'
className='GenericModal__button btn btn-tertiary btn-danger skipReportBtn'
onClick={onSkip}
data-testid='generating-skip-button'
>
{skipText}
</button>
</div>
<div className='ModalFooterRow__right'>
<button
type='button'
className='GenericModal__button btn btn-tertiary'
onClick={onBack}
data-testid='generating-back-button'
>
{backText}
</button>
<button
type='button'
className={classNames('GenericModal__button btn', permanentClass)}
disabled={true}
data-testid='generating-permanent-button'
>
{permanentText}
</button>
</div>
</div>
);
}
@@ -2,41 +2,22 @@
// See LICENSE.txt for license information.
.KeepRemoveFlaggedMessageConfirmationModal {
width: 704px;
.modal-content {
width: 704px;
}
.modal-footer {
// Allow the custom footer row to span the full width and use space-between layout.
justify-content: stretch;
}
.body {
display: flex;
flex-direction: column;
gap: 24px;
.section{
&.comment_section {
display: flex;
flex-direction: column;
gap: 8px;
}
.section_title {
color: var(--center-channel-color);
font-size: 14px;
font-weight: 600;
}
}
button#PreviewInputTextButton {
position: absolute;
z-index: 2;
top: 8px;
right: 8px;
}
textarea#RemoveFlaggedMessageConfirmationModal__comment {
min-height: 90px !important;
max-height: 400px;
}
.request_error {
display: flex;
width: 90%;
@@ -45,11 +26,24 @@
font-size: 12px;
}
}
.ModalFooterRow {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 8px;
&__left,
&__right {
display: flex;
align-items: center;
gap: 8px;
}
.skipReportBtn,
.skipReportBtn:hover {
color: var(--error-text);
}
}
}
@@ -52,6 +52,17 @@ describe('KeepRemoveFlaggedMessageConfirmationModal', () => {
const onExited = jest.fn();
let originalCreateObjectURL: typeof URL.createObjectURL;
let originalRevokeObjectURL: typeof URL.revokeObjectURL;
const mockReportSuccess = () => {
Client4.generateFlaggedPostReport = jest.fn().mockResolvedValue(new Blob(['report'], {type: 'application/zip'}));
};
const mockReportFailure = () => {
Client4.generateFlaggedPostReport = jest.fn().mockRejectedValue(new Error('boom'));
};
beforeEach(() => {
jest.clearAllMocks();
@@ -63,10 +74,22 @@ describe('KeepRemoveFlaggedMessageConfirmationModal', () => {
Client4.removeFlaggedPost = jest.fn().mockResolvedValue({});
Client4.keepFlaggedPost = jest.fn().mockResolvedValue({});
mockReportSuccess();
originalCreateObjectURL = URL.createObjectURL;
originalRevokeObjectURL = URL.revokeObjectURL;
URL.createObjectURL = jest.fn().mockReturnValue('blob:mock-url');
URL.revokeObjectURL = jest.fn();
// eslint-disable-next-line no-console
console.error = jest.fn();
});
afterEach(() => {
URL.createObjectURL = originalCreateObjectURL;
URL.revokeObjectURL = originalRevokeObjectURL;
});
describe('remove action', () => {
test('should render modal with remove action content', () => {
renderWithContext(
@@ -80,7 +103,9 @@ describe('KeepRemoveFlaggedMessageConfirmationModal', () => {
expect(screen.getByTestId('keep-remove-flagged-message-confirmation-modal')).toBeVisible();
expect(screen.getByRole('heading', {name: 'Remove message from channel'})).toBeVisible();
expect(screen.getByRole('button', {name: 'Remove message'})).toBeVisible();
// Default form step shows the "Continue" primary button (download checkbox is on by default)
expect(screen.getByRole('button', {name: 'Continue'})).toBeVisible();
});
test('should show notification subtext when notify_reporter_on_removal is true', () => {
@@ -118,7 +143,7 @@ describe('KeepRemoveFlaggedMessageConfirmationModal', () => {
expect(subtext).toHaveTextContent(/the message will be removed from the channel. This action cannot be reverted./);
});
test('should call Client4.removeFlaggedPost on confirm', async () => {
test('should call Client4.removeFlaggedPost via download flow on Remove permanently', async () => {
renderWithContext(
<KeepRemoveFlaggedMessageConfirmationModal
action='remove'
@@ -128,14 +153,83 @@ describe('KeepRemoveFlaggedMessageConfirmationModal', () => {
/>,
);
const confirmButton = screen.getByRole('button', {name: 'Remove message'});
await userEvent.click(confirmButton);
// Form step with checkbox checked → click Continue triggers report fetch
await userEvent.click(screen.getByRole('button', {name: 'Continue'}));
await waitFor(() => {
expect(Client4.generateFlaggedPostReport).toHaveBeenCalledWith(
flaggedPost.id,
'',
'remove',
expect.any(AbortSignal),
);
});
await waitFor(() => {
expect(screen.getByTestId('generated-section')).toBeVisible();
});
await userEvent.click(screen.getByRole('button', {name: 'Remove permanently'}));
await waitFor(() => {
expect(Client4.removeFlaggedPost).toHaveBeenCalledWith(flaggedPost.id, '');
});
expect(onExited).toHaveBeenCalled();
});
test('should go through skip-confirm when checkbox is unchecked', async () => {
renderWithContext(
<KeepRemoveFlaggedMessageConfirmationModal
action='remove'
onExited={onExited}
flaggedPost={flaggedPost}
reportingUser={reportingUser}
/>,
);
await userEvent.click(screen.getByTestId('download-report-checkbox'));
// Button label changes to "Remove message" when checkbox unchecked
await userEvent.click(screen.getByRole('button', {name: 'Remove message'}));
await waitFor(() => {
expect(screen.getByTestId('skip-confirm-body')).toBeVisible();
});
await userEvent.click(screen.getByRole('button', {name: 'Remove without report'}));
await waitFor(() => {
expect(Client4.removeFlaggedPost).toHaveBeenCalledWith(flaggedPost.id, '');
});
expect(Client4.generateFlaggedPostReport).not.toHaveBeenCalled();
});
test('should show error step when report generation fails and allow retry', async () => {
mockReportFailure();
renderWithContext(
<KeepRemoveFlaggedMessageConfirmationModal
action='remove'
onExited={onExited}
flaggedPost={flaggedPost}
reportingUser={reportingUser}
/>,
);
await userEvent.click(screen.getByRole('button', {name: 'Continue'}));
await waitFor(() => {
expect(screen.getByTestId('error-section')).toBeVisible();
});
// Switch to success and retry
mockReportSuccess();
await userEvent.click(screen.getByTestId('error-retry-button'));
await waitFor(() => {
expect(screen.getByTestId('generated-section')).toBeVisible();
});
});
});
describe('keep action', () => {
@@ -150,7 +244,7 @@ describe('KeepRemoveFlaggedMessageConfirmationModal', () => {
);
expect(screen.getByTestId('keep-remove-flagged-message-confirmation-modal')).toBeVisible();
expect(screen.getByRole('button', {name: 'Keep message'})).toBeVisible();
expect(screen.getByRole('button', {name: 'Continue'})).toBeVisible();
});
test('should show notification subtext when notify_reporter_on_dismissal is true', () => {
@@ -188,7 +282,7 @@ describe('KeepRemoveFlaggedMessageConfirmationModal', () => {
expect(subtext).toHaveTextContent(/the message will be visible to all channel members./);
});
test('should call Client4.keepFlaggedPost on confirm', async () => {
test('should call Client4.keepFlaggedPost via download flow on Keep permanently', async () => {
renderWithContext(
<KeepRemoveFlaggedMessageConfirmationModal
action='keep'
@@ -198,14 +292,65 @@ describe('KeepRemoveFlaggedMessageConfirmationModal', () => {
/>,
);
const confirmButton = screen.getByRole('button', {name: 'Keep message'});
await userEvent.click(confirmButton);
await userEvent.click(screen.getByRole('button', {name: 'Continue'}));
await waitFor(() => {
expect(screen.getByTestId('generated-section')).toBeVisible();
});
await userEvent.click(screen.getByRole('button', {name: 'Keep permanently'}));
await waitFor(() => {
expect(Client4.keepFlaggedPost).toHaveBeenCalledWith(flaggedPost.id, '');
});
expect(onExited).toHaveBeenCalled();
});
test('should call Client4.keepFlaggedPost directly without skip-confirm when checkbox is unchecked', async () => {
renderWithContext(
<KeepRemoveFlaggedMessageConfirmationModal
action='keep'
onExited={onExited}
flaggedPost={flaggedPost}
reportingUser={reportingUser}
/>,
);
await userEvent.click(screen.getByTestId('download-report-checkbox'));
await userEvent.click(screen.getByRole('button', {name: 'Keep message'}));
await waitFor(() => {
expect(Client4.keepFlaggedPost).toHaveBeenCalledWith(flaggedPost.id, '');
});
expect(screen.queryByTestId('skip-confirm-body')).not.toBeInTheDocument();
expect(Client4.generateFlaggedPostReport).not.toHaveBeenCalled();
});
test('skip from generating step calls keepFlaggedPost directly without skip-confirm', async () => {
Client4.generateFlaggedPostReport = jest.fn().mockReturnValue(new Promise(() => {}));
renderWithContext(
<KeepRemoveFlaggedMessageConfirmationModal
action='keep'
onExited={onExited}
flaggedPost={flaggedPost}
reportingUser={reportingUser}
/>,
);
await userEvent.click(screen.getByRole('button', {name: 'Continue'}));
await waitFor(() => {
expect(screen.getByTestId('generating-section')).toBeVisible();
});
await userEvent.click(screen.getByTestId('generating-skip-button'));
await waitFor(() => {
expect(Client4.keepFlaggedPost).toHaveBeenCalledWith(flaggedPost.id, '');
});
expect(screen.queryByTestId('skip-confirm-body')).not.toBeInTheDocument();
});
});
describe('comment section', () => {
@@ -259,17 +404,119 @@ describe('KeepRemoveFlaggedMessageConfirmationModal', () => {
/>,
);
const confirmButton = screen.getByRole('button', {name: 'Remove message'});
await userEvent.click(confirmButton);
await userEvent.click(screen.getByRole('button', {name: 'Continue'}));
await waitFor(() => {
expect(screen.getByText('Please add a comment.')).toBeVisible();
});
expect(Client4.generateFlaggedPostReport).not.toHaveBeenCalled();
expect(Client4.removeFlaggedPost).not.toHaveBeenCalled();
expect(onExited).not.toHaveBeenCalled();
});
});
describe('step transitions', () => {
test('should pass typed comment to action API', async () => {
renderWithContext(
<KeepRemoveFlaggedMessageConfirmationModal
action='remove'
onExited={onExited}
flaggedPost={flaggedPost}
reportingUser={reportingUser}
/>,
);
await userEvent.type(screen.getByPlaceholderText('Add your comment here'), 'looks fine');
await userEvent.click(screen.getByRole('button', {name: 'Continue'}));
await waitFor(() => {
expect(screen.getByTestId('generated-section')).toBeVisible();
});
await userEvent.click(screen.getByRole('button', {name: 'Remove permanently'}));
await waitFor(() => {
expect(Client4.removeFlaggedPost).toHaveBeenCalledWith(flaggedPost.id, 'looks fine');
});
});
test('clicking "Download again" on generated step retriggers report fetch', async () => {
renderWithContext(
<KeepRemoveFlaggedMessageConfirmationModal
action='remove'
onExited={onExited}
flaggedPost={flaggedPost}
reportingUser={reportingUser}
/>,
);
await userEvent.click(screen.getByRole('button', {name: 'Continue'}));
await waitFor(() => {
expect(screen.getByTestId('generated-section')).toBeVisible();
});
const initialCallCount = (Client4.generateFlaggedPostReport as jest.Mock).mock.calls.length;
await userEvent.click(screen.getByTestId('generated-download-again-button'));
await waitFor(() => {
expect((Client4.generateFlaggedPostReport as jest.Mock).mock.calls.length).toBeGreaterThan(initialCallCount);
});
await waitFor(() => {
expect(screen.getByTestId('generated-section')).toBeVisible();
});
});
test('skip from generating step routes to skip-confirm', async () => {
// Hold the request open so we can interact with the generating footer
Client4.generateFlaggedPostReport = jest.fn().mockReturnValue(new Promise(() => {}));
renderWithContext(
<KeepRemoveFlaggedMessageConfirmationModal
action='remove'
onExited={onExited}
flaggedPost={flaggedPost}
reportingUser={reportingUser}
/>,
);
await userEvent.click(screen.getByRole('button', {name: 'Continue'}));
await waitFor(() => {
expect(screen.getByTestId('generating-section')).toBeVisible();
});
await userEvent.click(screen.getByTestId('generating-skip-button'));
await waitFor(() => {
expect(screen.getByTestId('skip-confirm-body')).toBeVisible();
});
expect(screen.queryByTestId('generated-section')).not.toBeInTheDocument();
});
test('back from skip-confirm returns to form step', async () => {
renderWithContext(
<KeepRemoveFlaggedMessageConfirmationModal
action='remove'
onExited={onExited}
flaggedPost={flaggedPost}
reportingUser={reportingUser}
/>,
);
await userEvent.click(screen.getByTestId('download-report-checkbox'));
await userEvent.click(screen.getByRole('button', {name: 'Remove message'}));
await waitFor(() => {
expect(screen.getByTestId('skip-confirm-body')).toBeVisible();
});
await userEvent.click(screen.getByTestId('skip-confirm-back-button'));
await waitFor(() => {
expect(screen.getByRole('button', {name: 'Remove message'})).toBeVisible();
});
});
});
describe('error handling', () => {
test('should show request error when API call fails', async () => {
const errorMessage = 'Failed to remove flagged post';
@@ -284,8 +531,15 @@ describe('KeepRemoveFlaggedMessageConfirmationModal', () => {
/>,
);
const confirmButton = screen.getByRole('button', {name: 'Remove message'});
await userEvent.click(confirmButton);
// Skip download path so we go directly to API call
await userEvent.click(screen.getByTestId('download-report-checkbox'));
await userEvent.click(screen.getByRole('button', {name: 'Remove message'}));
await waitFor(() => {
expect(screen.getByTestId('skip-confirm-body')).toBeVisible();
});
await userEvent.click(screen.getByRole('button', {name: 'Remove without report'}));
await waitFor(() => {
const errorElement = screen.getByTestId(
@@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {GenericModal} from '@mattermost/components';
@@ -11,17 +11,23 @@ import type {UserProfile} from '@mattermost/types/users';
import {Client4} from 'mattermost-redux/client';
import AtMention from 'components/at_mention';
import {useChannel} from 'components/common/hooks/useChannel';
import {useContentFlaggingConfig} from 'components/common/hooks/useContentFlaggingFields';
import {useUser} from 'components/common/hooks/useUser';
import type {TextboxElement} from 'components/textbox';
import AdvancedTextbox from 'components/widgets/advanced_textbox/advanced_textbox';
import ErrorStepBody from './error_step/error_step_body';
import ErrorStepFooter from './error_step/error_step_footer';
import {FormStepBody} from './form_step/form_step_body';
import {FormStepFooter} from './form_step/form_step_footer';
import GeneratedStepBody from './generated_step/generated_step_body';
import GeneratedStepFooter from './generated_step/generated_step_footer';
import {GeneratingStepBody} from './generating_step/generating_step_body';
import {GeneratingStepFooter} from './generating_step/generating_step_footer';
import {SkipConfirmStepBody} from './skip_confirm_step/skip_confirm_step_body';
import {SkipConfirmStepFooter} from './skip_confirm_step/skip_confirm_step_footer';
import './remove_flagged_message_confirmation_modal.scss';
const noop = () => {};
type Props = {
action: 'keep' | 'remove';
onExited: () => void;
@@ -29,18 +35,28 @@ type Props = {
reportingUser: UserProfile;
}
type Step = 'form' | 'skip_confirm' | 'generating' | 'generated' | 'error';
export default function KeepRemoveFlaggedMessageConfirmationModal({action, onExited, flaggedPost, reportingUser}: Props) {
const {formatMessage} = useIntl();
const flaggedPostAuthor = useUser(flaggedPost.user_id);
const flaggedPostChannel = useChannel(flaggedPost.channel_id);
const contentFlaggingConfig = useContentFlaggingConfig(flaggedPostChannel?.team_id || '');
const [comment, setComment] = React.useState<string>('');
const [commentError, setCommentError] = React.useState<string>('');
const [requestError, setRequestError] = React.useState<string>('');
const [submitting, setSubmitting] = React.useState<boolean>(false);
const [showCommentPreview, setShowCommentPreview] = React.useState<boolean>(false);
const [comment, setComment] = useState<string>('');
const [commentError, setCommentError] = useState<string>('');
const [requestError, setRequestError] = useState<string>('');
const [submitting, setSubmitting] = useState<boolean>(false);
const [showCommentPreview, setShowCommentPreview] = useState<boolean>(false);
const [downloadReport, setDownloadReport] = useState<boolean>(true);
const [step, setStep] = useState<Step>('form');
const abortControllerRef = useRef<AbortController | null>(null);
const handleClose = useCallback(() => {
abortControllerRef.current?.abort();
onExited();
}, [onExited]);
const handleCommentChange = useCallback((e: React.ChangeEvent<TextboxElement>) => {
setComment(e.target.value);
@@ -56,104 +72,26 @@ export default function KeepRemoveFlaggedMessageConfirmationModal({action, onExi
setShowCommentPreview((prev) => !prev);
}, []);
const removeActionLabel = formatMessage({id: 'keep_remove_quarantined_content_modal.action_remove.title', defaultMessage: 'Remove message from channel'});
const keepActionLabel = formatMessage({id: 'keep_remove_quarantined_content_modal.action_keep.title', defaultMessage: 'Keep message'});
const removeActionBody = formatMessage({
id: 'keep_remove_quarantined_content_modal.action_remove.body',
defaultMessage: 'You are about to remove a message authored by {flaggedPostAuthor} posted in the {flaggedPostChannel} channel and quarantined for review by {reportingUser}.',
}, {
flaggedPostChannel: flaggedPostChannel?.display_name,
reportingUser: <AtMention mentionName={reportingUser?.username || ''}/>,
flaggedPostAuthor: <AtMention mentionName={flaggedPostAuthor?.username || ''}/>,
});
const keepActionBody = formatMessage({
id: 'keep_remove_quarantined_content_modal.action_keep.body',
defaultMessage: 'You are about to keep a quarantined message authored by {flaggedPostAuthor} posted in the {flaggedPostChannel} channel and quarantined for review by {reportingUser}.',
}, {
flaggedPostChannel: flaggedPostChannel?.display_name,
reportingUser: <AtMention mentionName={reportingUser?.username || ''}/>,
flaggedPostAuthor: <AtMention mentionName={flaggedPostAuthor?.username || ''}/>,
});
const removeActionBodySubTextReporterNotification = formatMessage({
id: 'keep_remove_quarantined_content_modal.action_remove.subtext.notify_reporter',
defaultMessage: 'If you confirm, the message will be removed from the channel and a notification will be sent to the reporter. This action cannot be reverted.',
});
const removeActionBodySubTextNoReporterNotification = formatMessage({
id: 'keep_remove_quarantined_content_modal.action_remove.subtext.no_notify_reporter',
defaultMessage: 'If you confirm, the message will be removed from the channel. This action cannot be reverted.',
});
const keepActionBodySubTextReporterNotification = formatMessage({
id: 'keep_remove_quarantined_content_modal.action_keep.subtext.notify_reporter',
defaultMessage: 'If you confirm, the message will be visible to all channel members and a notification will be sent to the reporter.',
});
const keepActionBodySubTextNoReporterNotification = formatMessage({
id: 'keep_remove_quarantined_content_modal.action_keep.subtext.no_notify_reporter',
defaultMessage: 'If you confirm, the message will be visible to all channel members.',
});
const requiredCommentSectionTitle = formatMessage({id: 'remove_flag_post_confirm_modal.required_comment.title', defaultMessage: 'Comment (required)'});
const optionalCommentSectionTitle = formatMessage({id: 'remove_flag_post_confirm_modal.optional_comment.title', defaultMessage: 'Comment (optional)'});
const commentPlaceholder = formatMessage({id: 'keep_remove_quarantined_content_modal.comment.placeholder', defaultMessage: 'Add your comment here'});
const removeMessageButtonText = formatMessage({id: 'keep_remove_quarantined_content_modal.action_remove.button_text', defaultMessage: 'Remove message'});
const keepMessageButtonText = formatMessage({id: 'keep_remove_quarantined_content_modal.action_keep.button_text', defaultMessage: 'Keep message'});
let label;
let subtext;
let body;
let buttonText;
let confirmButtonVariant;
if (action === 'remove') {
label = removeActionLabel;
body = removeActionBody;
buttonText = removeMessageButtonText;
confirmButtonVariant = 'destructive' as const;
if (contentFlaggingConfig?.notify_reporter_on_removal) {
subtext = removeActionBodySubTextReporterNotification;
} else {
subtext = removeActionBodySubTextNoReporterNotification;
}
} else {
label = keepActionLabel;
body = keepActionBody;
buttonText = keepMessageButtonText;
if (contentFlaggingConfig?.notify_reporter_on_dismissal) {
subtext = keepActionBodySubTextReporterNotification;
} else {
subtext = keepActionBodySubTextNoReporterNotification;
}
}
const handleToggleDownloadReport = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setDownloadReport(e.target.checked);
}, []);
const validateForm = useCallback(() => {
let hasErrors = false;
if (contentFlaggingConfig?.reviewer_comment_required && comment.trim() === '') {
setCommentError(formatMessage({id: 'keep_remove_quarantined_content_modal.comment_required.error', defaultMessage: 'Please add a comment.'}));
hasErrors = true;
} else {
setCommentError('');
return true;
}
return hasErrors;
setCommentError('');
return false;
}, [comment, contentFlaggingConfig?.reviewer_comment_required, formatMessage]);
const handleConfirm = useCallback(async () => {
const hasError = validateForm();
if (hasError) {
return;
}
const callActionAPI = useCallback(async () => {
const actionFunc = action === 'remove' ? Client4.removeFlaggedPost : Client4.keepFlaggedPost;
try {
setSubmitting(true);
setRequestError('');
await actionFunc(flaggedPost.id, comment);
onExited();
handleClose();
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);
@@ -161,7 +99,181 @@ export default function KeepRemoveFlaggedMessageConfirmationModal({action, onExi
} finally {
setSubmitting(false);
}
}, [action, comment, flaggedPost.id, onExited, validateForm]);
}, [action, comment, flaggedPost.id, handleClose]);
const handleFormPrimary = useCallback(() => {
if (validateForm()) {
return;
}
setRequestError('');
if (downloadReport) {
setStep('generating');
} else if (action === 'keep') {
callActionAPI();
} else {
setStep('skip_confirm');
}
}, [validateForm, downloadReport, action, callActionAPI]);
const handleSkipConfirmBack = useCallback(() => {
setRequestError('');
setStep('form');
}, []);
const handleSkipFromGenerating = useCallback(() => {
abortControllerRef.current?.abort();
setRequestError('');
if (action === 'keep') {
callActionAPI();
} else {
setStep('skip_confirm');
}
}, [action, callActionAPI]);
const handleBackToForm = useCallback(() => {
abortControllerRef.current?.abort();
setRequestError('');
setStep('form');
}, []);
const handleRetryGeneration = useCallback(() => {
setRequestError('');
setStep('generating');
}, []);
useEffect(() => {
if (step !== 'generating') {
return undefined;
}
const controller = new AbortController();
abortControllerRef.current = controller;
(async () => {
try {
const blob = await Client4.generateFlaggedPostReport(flaggedPost.id, comment, action, controller.signal);
if (controller.signal.aborted) {
return;
}
const downloadUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = downloadUrl;
a.download = `flagged-post-${flaggedPost.id}-${Date.now()}.zip`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(downloadUrl);
setStep('generated');
} catch (err) {
if (controller.signal.aborted) {
return;
}
// eslint-disable-next-line no-console
console.error(err);
setStep('error');
}
})();
return () => {
controller.abort();
};
}, [step, flaggedPost.id, comment, action]);
const removeLabel = formatMessage({id: 'keep_remove_quarantined_content_modal.action_remove.title', defaultMessage: 'Remove message from channel'});
const keepLabel = formatMessage({id: 'keep_remove_quarantined_content_modal.action_keep.title', defaultMessage: 'Keep message'});
const removeWithoutReportLabel = formatMessage({id: 'keep_remove_quarantined_content_modal.action_remove_without_report.title', defaultMessage: 'Remove without report?'});
const bodyContentProps = {
action,
flaggedPost,
reportingUser,
contentFlaggingConfig,
};
let label = action === 'remove' ? removeLabel : keepLabel;
let modalBody: React.ReactNode = null;
let footer: React.ReactNode = null;
switch (step) {
case 'form':
modalBody = (
<FormStepBody
{...bodyContentProps}
comment={comment}
commentError={commentError}
showCommentPreview={showCommentPreview}
onCommentChange={handleCommentChange}
onToggleCommentPreview={handleToggleCommentPreview}
/>
);
footer = (
<FormStepFooter
action={action}
downloadReport={downloadReport}
submitting={submitting}
onToggleDownloadReport={handleToggleDownloadReport}
onCancel={handleClose}
onPrimary={handleFormPrimary}
/>
);
break;
case 'skip_confirm':
label = removeWithoutReportLabel;
modalBody = (
<SkipConfirmStepBody
flaggedPost={flaggedPost}
reportingUser={reportingUser}
/>);
footer = (
<SkipConfirmStepFooter
submitting={submitting}
onBack={handleSkipConfirmBack}
onPrimary={callActionAPI}
/>
);
break;
case 'generating':
modalBody = <GeneratingStepBody {...bodyContentProps}/>;
footer = (
<GeneratingStepFooter
action={action}
onSkip={handleSkipFromGenerating}
onBack={handleBackToForm}
/>
);
break;
case 'generated':
modalBody = <GeneratedStepBody {...bodyContentProps}/>;
footer = (
<GeneratedStepFooter
action={action}
submitting={submitting}
onDownloadAgain={handleRetryGeneration}
onBack={handleBackToForm}
onPermanent={callActionAPI}
/>
);
break;
case 'error':
modalBody = (
<ErrorStepBody
{...bodyContentProps}
onRetry={handleRetryGeneration}
/>
);
footer = (
<ErrorStepFooter
action={action}
onSkip={handleSkipFromGenerating}
onBack={handleBackToForm}
/>
);
break;
}
return (
<GenericModal
@@ -172,49 +284,13 @@ export default function KeepRemoveFlaggedMessageConfirmationModal({action, onExi
compassDesign={true}
keyboardEscape={true}
enforceFocus={false}
handleConfirm={handleConfirm}
handleCancel={noop}
onHide={handleClose}
onExited={onExited}
confirmButtonText={buttonText}
confirmButtonVariant={confirmButtonVariant}
autoCloseOnConfirmButton={false}
isConfirmDisabled={submitting}
footerContent={footer}
>
<div className='body'>
<div
className='section'
data-testid='keep-remove-flagged-message-body'
>
{body}
<br/>
<br/>
<span data-testid='keep-remove-flagged-message-subtext'>{subtext}</span>
</div>
<div className='section comment_section'>
<div
className='section_title'
data-testid='keep-remove-flagged-message-comment-title'
>
{contentFlaggingConfig?.reviewer_comment_required ? requiredCommentSectionTitle : optionalCommentSectionTitle}
</div>
<AdvancedTextbox
id='RemoveFlaggedMessageConfirmationModal__comment'
channelId={flaggedPost.channel_id}
value={comment}
onChange={handleCommentChange}
createMessage={commentPlaceholder}
preview={showCommentPreview}
togglePreview={handleToggleCommentPreview}
useChannelMentions={false}
onKeyPress={() => {}}
hasError={false}
errorMessage={commentError}
maxLength={1000}
/>
</div>
{requestError &&
{modalBody}
{requestError && (
<div
className='request_error'
data-testid='keep-remove-flagged-message-request-error'
@@ -222,7 +298,7 @@ export default function KeepRemoveFlaggedMessageConfirmationModal({action, onExi
<i className='icon icon-alert-outline'/>
<span>{requestError}</span>
</div>
}
)}
</div>
</GenericModal>
);
@@ -0,0 +1,76 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
.KeepRemoveFlaggedMessageConfirmationModal {
.ReportNotice {
display: flex;
align-items: flex-start;
padding: 16px;
border: 1px solid;
border-radius: 4px;
gap: 12px;
&__icon {
display: flex;
width: 20px;
height: 20px;
flex-shrink: 0;
align-items: center;
justify-content: center;
.icon {
font-size: 20px;
line-height: 1;
}
}
&__body {
display: flex;
min-width: 0;
flex: 1 1 0;
flex-direction: column;
gap: 8px;
}
&__title {
color: var(--center-channel-color);
font-size: 14px;
font-weight: 600;
line-height: 20px;
}
&__text {
color: var(--center-channel-color);
font-size: 14px;
font-weight: 400;
line-height: 20px;
}
&--info {
border-color: rgba(var(--sidebar-text-active-border-rgb), 0.16);
background: rgba(var(--button-bg-rgb), 0.04);
.ReportNotice__icon {
color: var(--button-bg);
}
}
&--success {
border-color: rgba(var(--online-indicator-rgb), 0.16);
background: rgba(var(--online-indicator-rgb), 0.08);
.ReportNotice__icon {
color: var(--online-indicator);
}
}
&--warning {
border-color: rgba(var(--away-indicator-rgb), 0.16);
background: rgba(var(--away-indicator-rgb), 0.08);
.ReportNotice__icon {
color: var(--away-indicator);
}
}
}
}
@@ -0,0 +1,30 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React from 'react';
import './report_notice.scss';
type Props = {
variant: 'info' | 'success' | 'warning';
icon: React.ReactNode;
title: React.ReactNode;
body: React.ReactNode;
testId?: string;
};
export default function ReportNotice({variant, icon, title, body, testId}: Props) {
return (
<div
className={classNames('ReportNotice', `ReportNotice--${variant}`)}
data-testid={testId}
>
<div className='ReportNotice__icon'>{icon}</div>
<div className='ReportNotice__body'>
<div className='ReportNotice__title'>{title}</div>
<div className='ReportNotice__text'>{body}</div>
</div>
</div>
);
}
@@ -0,0 +1,42 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
import type {Post} from '@mattermost/types/posts';
import type {UserProfile} from '@mattermost/types/users';
import BodyMainActionText from 'components/remove_flagged_message_confirmation_modal/body_main_action_text';
type BodyProps = {
flaggedPost: Post;
reportingUser: UserProfile;
};
export function SkipConfirmStepBody({
flaggedPost,
reportingUser,
}: BodyProps) {
const {formatMessage} = useIntl();
const text = formatMessage({
id: 'keep_remove_quarantined_content_modal.action_remove.skip_confirm.body',
defaultMessage:
'You are proceeding with content removal without downloading a report. Any subsequently generated report will not contain the original message contents. This action cannot be reverted.',
});
return (
<div
className='section'
data-testid='skip-confirm-body'
>
<BodyMainActionText
action='remove'
flaggedPost={flaggedPost}
reportingUser={reportingUser}
/>
{text}
</div>
);
}
@@ -0,0 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
.KeepRemoveFlaggedMessageConfirmationModal {
.ModalFooterRow.ModalFooterRow--end {
justify-content: flex-end;
}
}
@@ -0,0 +1,44 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
import './skip_confirm_step_footer.scss';
type FooterProps = {
submitting: boolean;
onBack: () => void;
onPrimary: () => void;
};
export function SkipConfirmStepFooter({submitting, onBack, onPrimary}: FooterProps) {
const {formatMessage} = useIntl();
const backText = formatMessage({id: 'keep_remove_quarantined_content_modal.back.button_text', defaultMessage: 'Back'});
const primaryText = formatMessage({id: 'keep_remove_quarantined_content_modal.action_remove_without_report.button_text', defaultMessage: 'Remove without report'});
return (
<div className='ModalFooterRow ModalFooterRow--end'>
<div className='ModalFooterRow__right'>
<button
type='button'
className='GenericModal__button btn btn-tertiary'
onClick={onBack}
data-testid='skip-confirm-back-button'
>
{backText}
</button>
<button
type='button'
className='GenericModal__button btn btn-danger'
onClick={onPrimary}
disabled={submitting}
data-testid='skip-confirm-primary-button'
>
{primaryText}
</button>
</div>
</div>
);
}
+24 -1
View File
@@ -4458,8 +4458,13 @@
"custom_status.suggestions.working_from_home": "Working from home",
"data_spillage_report_post.reporting_comment.placeholder": "No comment",
"data_spillage_report_post.title": "{user} submitted a message for review",
"data_spillage_report.download_report.button_text": "Download Report",
"data_spillage_report.download_report.failed.button_text": "Generation failed. Try again.",
"data_spillage_report.download_report.generating.button_text": "Generating report…",
"data_spillage_report.keep_message.button_text": "Keep message",
"data_spillage_report.remove_message.button_text": "Remove message",
"data_spillage_report.row.actions.label": "Actions",
"data_spillage_report.row.report.label": "Report",
"data_spillage_report.view_details.button_text": "View details",
"date_separator.today": "Today",
"date_separator.tomorrow": "Tomorrow",
@@ -5267,16 +5272,35 @@
"katex.error": "Couldn't compile your Latex code. Please review the syntax and try again.",
"keep_remove_quarantined_content_modal.action_keep.body": "You are about to keep a quarantined message authored by {flaggedPostAuthor} posted in the {flaggedPostChannel} channel and quarantined for review by {reportingUser}.",
"keep_remove_quarantined_content_modal.action_keep.button_text": "Keep message",
"keep_remove_quarantined_content_modal.action_keep.generated.body": "The report should now be downloading on your device. Once it is downloaded, you can keep the message permanently.",
"keep_remove_quarantined_content_modal.action_keep.generating.body": "Please wait for the report to download before you keep the message permanently.",
"keep_remove_quarantined_content_modal.action_keep.permanent_button_text": "Keep permanently",
"keep_remove_quarantined_content_modal.action_keep.subtext.no_notify_reporter": "If you confirm, the message will be visible to all channel members.",
"keep_remove_quarantined_content_modal.action_keep.subtext.notify_reporter": "If you confirm, the message will be visible to all channel members and a notification will be sent to the reporter.",
"keep_remove_quarantined_content_modal.action_keep.title": "Keep message",
"keep_remove_quarantined_content_modal.action_remove_without_report.button_text": "Remove without report",
"keep_remove_quarantined_content_modal.action_remove_without_report.title": "Remove without report?",
"keep_remove_quarantined_content_modal.action_remove.body": "You are about to remove a message authored by {flaggedPostAuthor} posted in the {flaggedPostChannel} channel and quarantined for review by {reportingUser}.",
"keep_remove_quarantined_content_modal.action_remove.button_text": "Remove message",
"keep_remove_quarantined_content_modal.action_remove.generated.body": "The report should now be downloading on your device. Once it is downloaded, you can remove the message permanently.",
"keep_remove_quarantined_content_modal.action_remove.generating.body": "Please wait for the report to download before you remove the message permanently. There will be no way to recover the message contents once it is removed.",
"keep_remove_quarantined_content_modal.action_remove.permanent_button_text": "Remove permanently",
"keep_remove_quarantined_content_modal.action_remove.skip_confirm.body": "You are proceeding with content removal without downloading a report. Any subsequently generated report will not contain the original message contents. This action cannot be reverted.",
"keep_remove_quarantined_content_modal.action_remove.subtext.no_notify_reporter": "If you confirm, the message will be removed from the channel. This action cannot be reverted.",
"keep_remove_quarantined_content_modal.action_remove.subtext.notify_reporter": "If you confirm, the message will be removed from the channel and a notification will be sent to the reporter. This action cannot be reverted.",
"keep_remove_quarantined_content_modal.action_remove.title": "Remove message from channel",
"keep_remove_quarantined_content_modal.back.button_text": "Back",
"keep_remove_quarantined_content_modal.comment_required.error": "Please add a comment.",
"keep_remove_quarantined_content_modal.comment.placeholder": "Add your comment here",
"keep_remove_quarantined_content_modal.continue.button_text": "Continue",
"keep_remove_quarantined_content_modal.download_again.button_text": "Download again",
"keep_remove_quarantined_content_modal.download_report_checkbox.label": "Download quarantined message report",
"keep_remove_quarantined_content_modal.error.body": "We were unable to generate and download the report to your device.",
"keep_remove_quarantined_content_modal.error.title": "Report could not be generated",
"keep_remove_quarantined_content_modal.generated.title": "Report generated",
"keep_remove_quarantined_content_modal.generating.title": "Generating report…",
"keep_remove_quarantined_content_modal.skip_report_download.button_text": "Skip report download",
"keep_remove_quarantined_content_modal.try_again.button_text": "Try again",
"last_users_message.added_to_channel.type": "were **added to the channel** by {actor}.",
"last_users_message.added_to_team.type": "were **added to the team** by {actor}.",
"last_users_message.first": "{firstUser} and ",
@@ -5903,7 +5927,6 @@
"promote_to_user_modal.desc": "This action promotes the guest {username} to a member. It will allow the user to join public channels and interact with users outside of the channels they are currently members of. Are you sure you want to promote guest {username} to member?",
"promote_to_user_modal.promote": "Promote",
"promote_to_user_modal.title": "Promote guest {username} to member",
"property_card.actions_row.label": "Actions",
"property_card.field.action_time.label": "Reviewed at",
"property_card.field.actor_comment.label": "Reviewer's comment",
"property_card.field.actor_user_id.label": "Reviewed by",
@@ -162,6 +162,25 @@ describe('Client4', () => {
expect(result[1]).toEqual({user_id: 'dummy-user-id', channel_id: 'channel2', roles: 'channel_user channel_admin'});
expect(result[2]).toEqual({user_id: 'dummy-user-id', channel_id: 'channel3', roles: 'channel_user'});
});
test('should parse ZIP responses as blobs', async () => {
const client = new Client4();
client.setUrl('http://mattermost.example.com');
const postId = 'dummy-post-id';
const zipData = Buffer.from('zip contents');
nock(client.getBaseRoute()).
post(`/content_flagging/post/${postId}/report`, {comment: 'investigation note'}).
reply(200, zipData, {'Content-Type': 'application/zip'});
const result = await client.generateFlaggedPostReport(postId, 'investigation note');
expect(typeof result.text).toBe('function');
expect(result.size).toEqual(zipData.length);
expect(result.type).toEqual('application/zip');
expect(await result.text()).toEqual('zip contents');
});
});
});
+17
View File
@@ -4631,6 +4631,8 @@ export default class Client4 {
const text = await response.text();
const objects = text.trim().split('\n');
data = objects.map((obj) => JSON.parse(obj));
} else if (contentType === 'application/zip') {
data = await response.blob();
} else {
data = await response.text();
}
@@ -5083,6 +5085,21 @@ export default class Client4 {
{method: 'get'},
);
};
getFlaggedPostReportUrl = (postId: string) => {
return `${this.getContentFlaggingRoute()}/post/${postId}/report`;
};
generateFlaggedPostReport = (postId: string, comment: string, action?: 'keep' | 'remove', signal?: AbortSignal): Promise<Blob> => {
return this.doFetch<Blob>(
this.getFlaggedPostReportUrl(postId),
{
method: 'post',
body: JSON.stringify({comment, action}),
signal,
},
);
};
}
export function parseAndMergeNestedHeaders(originalHeaders: any) {