mirror of
https://github.com/mattermost/mattermost.git
synced 2026-09-19 10:12:47 +08:00
@@ -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
-5
@@ -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();
|
||||
|
||||
+7
-4
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
+16
@@ -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;
|
||||
}
|
||||
}
|
||||
+111
@@ -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();
|
||||
});
|
||||
});
|
||||
+131
@@ -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>
|
||||
);
|
||||
}
|
||||
+33
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+41
-20
@@ -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>
|
||||
|
||||
+62
@@ -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>;
|
||||
}
|
||||
+14
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+74
@@ -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>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+57
@@ -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>
|
||||
);
|
||||
}
|
||||
+14
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+62
@@ -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>
|
||||
);
|
||||
}
|
||||
+30
@@ -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;
|
||||
}
|
||||
}
|
||||
+83
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+21
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+82
@@ -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>
|
||||
);
|
||||
}
|
||||
+61
@@ -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.'
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+66
@@ -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>
|
||||
);
|
||||
}
|
||||
+63
@@ -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.'
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+58
@@ -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>
|
||||
);
|
||||
}
|
||||
+27
-33
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+266
-12
@@ -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(
|
||||
|
||||
+217
-141
@@ -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>
|
||||
);
|
||||
|
||||
+76
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -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>
|
||||
);
|
||||
}
|
||||
+42
@@ -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>
|
||||
);
|
||||
}
|
||||
+8
@@ -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;
|
||||
}
|
||||
}
|
||||
+44
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user