mirror of
https://github.com/mattermost/mattermost.git
synced 2026-02-18 18:18:23 -05:00
[MM-66359] Recaps MVP (#34337)
* initial commit for POC of Plugin Bridge * Updates * POC for plugin bridge * Updates from collaboration * Fixes * Refactor Plugin Bridge to use HTTP/REST instead of RPC - Remove ExecuteBridgeCall hook and Context.SourcePluginId - Implement HTTP-based bridge using existing PluginHTTP infrastructure - Add CallPlugin API method with endpoint parameter instead of method name - Update CallPluginBridge to construct HTTP POST requests - Add proper headers: Mattermost-User-Id, Mattermost-Plugin-ID - Use 'com.mattermost.server' as plugin ID for core server calls - Update ai.go to use REST endpoint /inter-plugin/v1/completion - Add comprehensive spec documentation in server/spec.md - Add MIGRATION_GUIDE.md for plugin developers - Fix 401/404 issues by setting correct headers and URL paths * Improve Plugin Bridge security and architecture - Create ServeInternalPluginRequest for internal plugin calls (core + plugin-to-plugin) - Move header-setting logic from CallPluginBridge to ServeInternalPluginRequest - Improve separation of concerns: business logic vs HTTP transport - Add security documentation explaining header protection Security Improvements: - ServeInternalPluginRequest is NOT exposed as HTTP route (internal only) - Headers (Mattermost-User-Id, Mattermost-Plugin-ID) are set by trusted server code - External requests cannot spoof these headers (stripped by servePluginRequest) - Core calls use 'com.mattermost.server' as plugin ID for authorization - Plugin-to-plugin calls use real plugin ID (enforced by server) Backward Compatibility: - Keep ServeInterPluginRequest for existing API.PluginHTTP callers (deprecated) - All tests pass Docs: - Update spec.md with security model explanation - Update MIGRATION_GUIDE.md with correct header usage examples * Space * cursor please stop creating markdown files * Fix style * Fix i18n, linter * REMOVE MARKDOWN * Remove CallPlugin method from plugin API interface Per review feedback, this method is no longer needed. Co-authored-by: Nick Misasi <nickmisasi@users.noreply.github.com> * Remove CallPlugin method implementation from PluginAPI Co-authored-by: Nick Misasi <nickmisasi@users.noreply.github.com> * fixes * Add AI OpenAPI spec * fix openapi spec * Use agents client (#34225) * Use agents client * Remove default agent * Fixes * fix: modify system prompts to ensure JSON is being returned * Base implementation for recaps working * small fixes * Adjustments * remove webapp changes * Add feature flags for rewrites and ai bridge, clean up * Remove comments that aren't helpful * Fix i18n * Remove rewrites * Fix tests * Fix i18n * adjust i18n again * Add back translations * Remove leftover mock code * remove model file * Changes from PR review * Make the real substitutions * Include a basic invokation of the client with noop to ensure build works * more fix * Remove unneeded change * Updates from review * Fixes * Remove some logic from rewrites to clean up branch * Use v1.5.0 of agents plugin * A bunch more additions for general UX flow * Add missing files * Add mocks * Fixes for vet-api, i18n, build, types, etc * One more linter fix * Fix i18n and some tests * Refactors and cleanup in backend code * remove rogue markdown file * fixes after refactors from backend * Add back renamed files, and add tests * More self code review * More fixes * More refactors * Fix call stack exceeded bug * Include read messages if there are no unreads * Fix test failure: use correct error message key for recap permission denied The getRecapAndCheckOwnership function was using strings.ToLower(callerName) to generate error keys, which caused 'GetRecap' to become 'getrecap' instead of the expected 'get'. Changed to use the correct static key that matches the en.json localization file. Fixes TestGetRecap/get_recap_by_non-owner test failure. Co-authored-by: Nick Misasi <nickmisasi@users.noreply.github.com> * Consolidate permission errors down to a single string * Fixes for i18n, worktrees making this difficult * Fix i18n * Fix i18n once and for all (for real) (final) * Fix duplicate getAgents method in client4.ts * Remove duplicate ai state from initial_state.ts * Fix types * Fix tests * Fix return type of GetAgents and GetServices * Add tests for recaps components * Fix types * Update i18n * Fixes * Fixes * More cleanup * Revert random file * Use undefined * fix linter * Address feedback * Missed a git add * Fixes * Fix i18n * Remove fallback * Fixes for PR --------- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: Nick Misasi <nickmisasi@users.noreply.github.com> Co-authored-by: Christopher Speller <crspeller@gmail.com> Co-authored-by: Felipe Martin <me@fmartingr.com> Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
parent
9e1d4c2072
commit
8e4cadbc88
80 changed files with 7503 additions and 6 deletions
|
|
@ -20,6 +20,8 @@ build-v4: node_modules playbooks
|
|||
@cat $(V4_SRC)/posts.yaml >> $(V4_YAML)
|
||||
@cat $(V4_SRC)/preferences.yaml >> $(V4_YAML)
|
||||
@cat $(V4_SRC)/files.yaml >> $(V4_YAML)
|
||||
@cat $(V4_SRC)/recaps.yaml >> $(V4_YAML)
|
||||
@cat $(V4_SRC)/ai.yaml >> $(V4_YAML)
|
||||
@cat $(V4_SRC)/uploads.yaml >> $(V4_YAML)
|
||||
@cat $(V4_SRC)/jobs.yaml >> $(V4_YAML)
|
||||
@cat $(V4_SRC)/system.yaml >> $(V4_YAML)
|
||||
|
|
|
|||
54
api/v4/source/ai.yaml
Normal file
54
api/v4/source/ai.yaml
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/api/v4/ai/agents:
|
||||
get:
|
||||
tags:
|
||||
- ai
|
||||
summary: Get available AI agents
|
||||
description: >
|
||||
Retrieve all available AI agents from the AI plugin's bridge API.
|
||||
If a user ID is provided, only agents accessible to that user are returned.
|
||||
|
||||
##### Permissions
|
||||
|
||||
Must be authenticated.
|
||||
|
||||
__Minimum server version__: 11.2
|
||||
operationId: GetAIAgents
|
||||
responses:
|
||||
"200":
|
||||
description: AI agents retrieved successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AgentsResponse"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalServerError"
|
||||
/api/v4/ai/services:
|
||||
get:
|
||||
tags:
|
||||
- ai
|
||||
summary: Get available AI services
|
||||
description: >
|
||||
Retrieve all available AI services from the AI plugin's bridge API.
|
||||
If a user ID is provided, only services accessible to that user
|
||||
(via their permitted bots) are returned.
|
||||
|
||||
##### Permissions
|
||||
|
||||
Must be authenticated.
|
||||
|
||||
__Minimum server version__: 11.2
|
||||
operationId: GetAIServices
|
||||
responses:
|
||||
"200":
|
||||
description: AI services retrieved successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ServicesResponse"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalServerError"
|
||||
|
||||
|
|
@ -4633,6 +4633,83 @@ components:
|
|||
active:
|
||||
type: boolean
|
||||
description: The active status of the policy.
|
||||
Recap:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: Unique identifier for the recap
|
||||
user_id:
|
||||
type: string
|
||||
description: ID of the user who created the recap
|
||||
title:
|
||||
type: string
|
||||
description: AI-generated title for the recap (max 5 words)
|
||||
create_at:
|
||||
type: integer
|
||||
format: int64
|
||||
description: The time in milliseconds the recap was created
|
||||
update_at:
|
||||
type: integer
|
||||
format: int64
|
||||
description: The time in milliseconds the recap was last updated
|
||||
delete_at:
|
||||
type: integer
|
||||
format: int64
|
||||
description: The time in milliseconds the recap was deleted
|
||||
read_at:
|
||||
type: integer
|
||||
format: int64
|
||||
description: The time in milliseconds the recap was marked as read
|
||||
total_message_count:
|
||||
type: integer
|
||||
description: Total number of messages summarized across all channels
|
||||
status:
|
||||
type: string
|
||||
enum: [pending, processing, completed, failed]
|
||||
description: Current status of the recap job
|
||||
bot_id:
|
||||
type: string
|
||||
description: ID of the AI agent/bot used to generate this recap
|
||||
channels:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/RecapChannel"
|
||||
description: List of channel summaries included in this recap
|
||||
RecapChannel:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: Unique identifier for the recap channel
|
||||
recap_id:
|
||||
type: string
|
||||
description: ID of the parent recap
|
||||
channel_id:
|
||||
type: string
|
||||
description: ID of the channel that was summarized
|
||||
channel_name:
|
||||
type: string
|
||||
description: Display name of the channel
|
||||
highlights:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Key discussion points and important information from the channel
|
||||
action_items:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Tasks, todos, and action items mentioned in the channel
|
||||
source_post_ids:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: IDs of the posts used to generate this summary
|
||||
create_at:
|
||||
type: integer
|
||||
format: int64
|
||||
description: The time in milliseconds the recap channel was created
|
||||
externalDocs:
|
||||
description: Find out more about Mattermost
|
||||
url: 'https://about.mattermost.com'
|
||||
|
|
|
|||
|
|
@ -462,8 +462,10 @@ tags:
|
|||
description: Endpoints related to metrics, including the Client Performance Monitoring feature.
|
||||
- name: audit_logs
|
||||
description: Endpoints for managing audit log certificates and configuration.
|
||||
- name: ai
|
||||
description: Endpoints for interacting with AI agents and services.
|
||||
- name: recaps
|
||||
description: Endpoints for creating and managing AI-powered channel recaps that summarize unread messages.
|
||||
- name: agents
|
||||
description: Endpoints for interacting with AI agents and LLM services.
|
||||
servers:
|
||||
- url: "{your-mattermost-url}"
|
||||
variables:
|
||||
|
|
|
|||
240
api/v4/source/recaps.yaml
Normal file
240
api/v4/source/recaps.yaml
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
"/api/v4/recaps":
|
||||
post:
|
||||
tags:
|
||||
- recaps
|
||||
- ai
|
||||
summary: Create a channel recap
|
||||
description: >
|
||||
Create a new AI-powered recap for the specified channels. The recap will
|
||||
summarize unread messages in the selected channels, extracting highlights
|
||||
and action items. This creates a background job that processes the recap
|
||||
asynchronously. The recap is created for the authenticated user.
|
||||
|
||||
##### Permissions
|
||||
|
||||
Must be authenticated. User must be a member of all specified channels.
|
||||
|
||||
__Minimum server version__: 11.2
|
||||
operationId: CreateRecap
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- channel_ids
|
||||
- title
|
||||
- agent_id
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
description: Title for the recap
|
||||
channel_ids:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: List of channel IDs to include in the recap
|
||||
minItems: 1
|
||||
agent_id:
|
||||
type: string
|
||||
description: ID of the AI agent to use for generating the recap
|
||||
description: Recap creation request
|
||||
required: true
|
||||
responses:
|
||||
"201":
|
||||
description: Recap creation successful. The recap will be processed asynchronously.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Recap"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
get:
|
||||
tags:
|
||||
- recaps
|
||||
- ai
|
||||
summary: Get current user's recaps
|
||||
description: >
|
||||
Get a paginated list of recaps created by the authenticated user.
|
||||
|
||||
##### Permissions
|
||||
|
||||
Must be authenticated.
|
||||
|
||||
__Minimum server version__: 11.2
|
||||
operationId: GetRecapsForUser
|
||||
parameters:
|
||||
- name: page
|
||||
in: query
|
||||
description: The page to select.
|
||||
schema:
|
||||
type: integer
|
||||
default: 0
|
||||
- name: per_page
|
||||
in: query
|
||||
description: The number of recaps per page.
|
||||
schema:
|
||||
type: integer
|
||||
default: 60
|
||||
responses:
|
||||
"200":
|
||||
description: Recaps retrieval successful
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/Recap"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"/api/v4/recaps/{recap_id}":
|
||||
get:
|
||||
tags:
|
||||
- recaps
|
||||
- ai
|
||||
summary: Get a specific recap
|
||||
description: >
|
||||
Get a recap by its ID, including all channel summaries. Only the authenticated
|
||||
user who created the recap can retrieve it.
|
||||
|
||||
##### Permissions
|
||||
|
||||
Must be authenticated. Can only retrieve recaps created by the current user.
|
||||
|
||||
__Minimum server version__: 11.2
|
||||
operationId: GetRecap
|
||||
parameters:
|
||||
- name: recap_id
|
||||
in: path
|
||||
description: Recap GUID
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Recap retrieval successful
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Recap"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
delete:
|
||||
tags:
|
||||
- recaps
|
||||
- ai
|
||||
summary: Delete a recap
|
||||
description: >
|
||||
Delete a recap by its ID. Only the authenticated user who created the recap
|
||||
can delete it.
|
||||
|
||||
##### Permissions
|
||||
|
||||
Must be authenticated. Can only delete recaps created by the current user.
|
||||
|
||||
__Minimum server version__: 11.2
|
||||
operationId: DeleteRecap
|
||||
parameters:
|
||||
- name: recap_id
|
||||
in: path
|
||||
description: Recap GUID
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Recap deletion successful
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/StatusOK"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"/api/v4/recaps/{recap_id}/read":
|
||||
post:
|
||||
tags:
|
||||
- recaps
|
||||
- ai
|
||||
summary: Mark a recap as read
|
||||
description: >
|
||||
Mark a recap as read by the authenticated user. This updates the recap's
|
||||
read status and timestamp.
|
||||
|
||||
##### Permissions
|
||||
|
||||
Must be authenticated. Can only mark recaps created by the current user as read.
|
||||
|
||||
__Minimum server version__: 11.2
|
||||
operationId: MarkRecapAsRead
|
||||
parameters:
|
||||
- name: recap_id
|
||||
in: path
|
||||
description: Recap GUID
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Recap marked as read successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Recap"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"/api/v4/recaps/{recap_id}/regenerate":
|
||||
post:
|
||||
tags:
|
||||
- recaps
|
||||
- ai
|
||||
summary: Regenerate a recap
|
||||
description: >
|
||||
Regenerate a recap by its ID. This creates a new background job to
|
||||
regenerate the AI-powered recap with the latest messages from the
|
||||
specified channels.
|
||||
|
||||
##### Permissions
|
||||
|
||||
Must be authenticated. Can only regenerate recaps created by the current user.
|
||||
|
||||
__Minimum server version__: 11.2
|
||||
operationId: RegenerateRecap
|
||||
parameters:
|
||||
- name: recap_id
|
||||
in: path
|
||||
description: Recap GUID
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Recap regeneration initiated successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Recap"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
|
||||
|
|
@ -101,6 +101,8 @@ type Routes struct {
|
|||
|
||||
Jobs *mux.Router // 'api/v4/jobs'
|
||||
|
||||
Recaps *mux.Router // 'api/v4/recaps'
|
||||
|
||||
Preferences *mux.Router // 'api/v4/users/{user_id:[A-Za-z0-9]+}/preferences'
|
||||
|
||||
License *mux.Router // 'api/v4/license'
|
||||
|
|
@ -256,6 +258,7 @@ func Init(srv *app.Server) (*API, error) {
|
|||
api.BaseRoutes.Public = api.BaseRoutes.APIRoot.PathPrefix("/public").Subrouter()
|
||||
api.BaseRoutes.Reactions = api.BaseRoutes.APIRoot.PathPrefix("/reactions").Subrouter()
|
||||
api.BaseRoutes.Jobs = api.BaseRoutes.APIRoot.PathPrefix("/jobs").Subrouter()
|
||||
api.BaseRoutes.Recaps = api.BaseRoutes.APIRoot.PathPrefix("/recaps").Subrouter()
|
||||
api.BaseRoutes.Elasticsearch = api.BaseRoutes.APIRoot.PathPrefix("/elasticsearch").Subrouter()
|
||||
api.BaseRoutes.DataRetention = api.BaseRoutes.APIRoot.PathPrefix("/data_retention").Subrouter()
|
||||
|
||||
|
|
@ -337,6 +340,7 @@ func Init(srv *app.Server) (*API, error) {
|
|||
api.InitDataRetention()
|
||||
api.InitBrand()
|
||||
api.InitJob()
|
||||
api.InitRecap()
|
||||
api.InitCommand()
|
||||
api.InitStatus()
|
||||
api.InitWebSocket()
|
||||
|
|
|
|||
210
server/channels/api4/recap.go
Normal file
210
server/channels/api4/recap.go
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api4
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
)
|
||||
|
||||
func (api *API) InitRecap() {
|
||||
api.BaseRoutes.Recaps.Handle("", api.APISessionRequired(createRecap)).Methods(http.MethodPost)
|
||||
api.BaseRoutes.Recaps.Handle("", api.APISessionRequired(getRecaps)).Methods(http.MethodGet)
|
||||
api.BaseRoutes.Recaps.Handle("/{recap_id:[A-Za-z0-9]+}", api.APISessionRequired(getRecap)).Methods(http.MethodGet)
|
||||
api.BaseRoutes.Recaps.Handle("/{recap_id:[A-Za-z0-9]+}/read", api.APISessionRequired(markRecapAsRead)).Methods(http.MethodPost)
|
||||
api.BaseRoutes.Recaps.Handle("/{recap_id:[A-Za-z0-9]+}/regenerate", api.APISessionRequired(regenerateRecap)).Methods(http.MethodPost)
|
||||
api.BaseRoutes.Recaps.Handle("/{recap_id:[A-Za-z0-9]+}", api.APISessionRequired(deleteRecap)).Methods(http.MethodDelete)
|
||||
}
|
||||
|
||||
func requireRecapsEnabled(c *Context) {
|
||||
if !c.App.Config().FeatureFlags.EnableAIRecaps {
|
||||
c.Err = model.NewAppError("requireRecapsEnabled", "api.recap.disabled.app_error", nil, "", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func createRecap(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
requireRecapsEnabled(c)
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var req model.CreateRecapRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
c.SetInvalidParamWithErr("body", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.ChannelIds) == 0 {
|
||||
c.SetInvalidParam("channel_ids")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Title == "" {
|
||||
c.SetInvalidParam("title")
|
||||
return
|
||||
}
|
||||
|
||||
if req.AgentID == "" {
|
||||
c.SetInvalidParam("agent_id")
|
||||
return
|
||||
}
|
||||
|
||||
recap, err := c.App.CreateRecap(c.AppContext, req.Title, req.ChannelIds, req.AgentID)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
if err := json.NewEncoder(w).Encode(recap); err != nil {
|
||||
c.Logger.Warn("Error encoding response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func getRecap(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
requireRecapsEnabled(c)
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
c.RequireRecapId()
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
recap, err := c.App.GetRecap(c.AppContext, c.Params.RecapId)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
|
||||
if recap.UserId != c.AppContext.Session().UserId {
|
||||
c.Err = model.NewAppError("getRecap", "api.recap.permission_denied", nil, "", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(recap); err != nil {
|
||||
c.Logger.Warn("Error encoding response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func getRecaps(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
requireRecapsEnabled(c)
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
recaps, err := c.App.GetRecapsForUser(c.AppContext, c.Params.Page, c.Params.PerPage)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(recaps); err != nil {
|
||||
c.Logger.Warn("Error encoding response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func markRecapAsRead(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
requireRecapsEnabled(c)
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
c.RequireRecapId()
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Check permissions
|
||||
recap, err := c.App.GetRecap(c.AppContext, c.Params.RecapId)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
|
||||
if recap.UserId != c.AppContext.Session().UserId {
|
||||
c.Err = model.NewAppError("markRecapAsRead", "api.recap.permission_denied", nil, "", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
updatedRecap, err := c.App.MarkRecapAsRead(c.AppContext, recap)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(updatedRecap); err != nil {
|
||||
c.Logger.Warn("Error encoding response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func regenerateRecap(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
requireRecapsEnabled(c)
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
c.RequireRecapId()
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Check permissions
|
||||
recap, err := c.App.GetRecap(c.AppContext, c.Params.RecapId)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
|
||||
if recap.UserId != c.AppContext.Session().UserId {
|
||||
c.Err = model.NewAppError("regenerateRecap", "api.recap.permission_denied", nil, "", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
updatedRecap, err := c.App.RegenerateRecap(c.AppContext, c.AppContext.Session().UserId, recap)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(updatedRecap); err != nil {
|
||||
c.Logger.Warn("Error encoding response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func deleteRecap(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
requireRecapsEnabled(c)
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
c.RequireRecapId()
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Check permissions
|
||||
recap, err := c.App.GetRecap(c.AppContext, c.Params.RecapId)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
|
||||
if recap.UserId != c.AppContext.Session().UserId {
|
||||
c.Err = model.NewAppError("deleteRecap", "api.recap.permission_denied", nil, "", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.App.DeleteRecap(c.AppContext, c.Params.RecapId); err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
|
||||
ReturnStatusOK(w)
|
||||
}
|
||||
|
|
@ -4,6 +4,8 @@
|
|||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/blang/semver/v4"
|
||||
|
||||
agentclient "github.com/mattermost/mattermost-plugin-ai/public/bridgeclient"
|
||||
|
|
@ -93,7 +95,7 @@ func (a *App) GetAgents(rctx request.CTX, userID string) ([]agentclient.BridgeAg
|
|||
mlog.Err(err),
|
||||
mlog.String("user_id", userID),
|
||||
)
|
||||
return nil, model.NewAppError("GetAgents", "app.agents.get_agents.bridge_call_failed", nil, err.Error(), 500)
|
||||
return nil, model.NewAppError("GetAgents", "app.agents.get_agents.bridge_call_failed", nil, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
return agents, nil
|
||||
|
|
@ -119,7 +121,7 @@ func (a *App) GetLLMServices(rctx request.CTX, userID string) ([]agentclient.Bri
|
|||
mlog.Err(err),
|
||||
mlog.String("user_id", userID),
|
||||
)
|
||||
return nil, model.NewAppError("GetLLMServices", "app.agents.get_services.bridge_call_failed", nil, err.Error(), 500)
|
||||
return nil, model.NewAppError("GetLLMServices", "app.agents.get_services.bridge_call_failed", nil, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
return services, nil
|
||||
|
|
|
|||
301
server/channels/app/recap.go
Normal file
301
server/channels/app/recap.go
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/request"
|
||||
)
|
||||
|
||||
// CreateRecap creates a new recap job for the specified channels
|
||||
func (a *App) CreateRecap(rctx request.CTX, title string, channelIDs []string, agentID string) (*model.Recap, *model.AppError) {
|
||||
userID := rctx.Session().UserId
|
||||
|
||||
// Validate user is member of all channels
|
||||
for _, channelID := range channelIDs {
|
||||
if !a.HasPermissionToChannel(rctx, userID, channelID, model.PermissionReadChannel) {
|
||||
return nil, model.NewAppError("CreateRecap", "app.recap.permission_denied", nil, "", http.StatusForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
timeNow := model.GetMillis()
|
||||
|
||||
// Create recap record
|
||||
recap := &model.Recap{
|
||||
Id: model.NewId(),
|
||||
UserId: userID,
|
||||
Title: title,
|
||||
CreateAt: timeNow,
|
||||
UpdateAt: timeNow,
|
||||
DeleteAt: 0,
|
||||
ReadAt: 0,
|
||||
TotalMessageCount: 0,
|
||||
Status: model.RecapStatusPending,
|
||||
BotID: agentID,
|
||||
}
|
||||
|
||||
savedRecap, err := a.Srv().Store().Recap().SaveRecap(recap)
|
||||
if err != nil {
|
||||
return nil, model.NewAppError("CreateRecap", "app.recap.save.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
// Create background job
|
||||
jobData := map[string]string{
|
||||
"recap_id": recap.Id,
|
||||
"user_id": userID,
|
||||
"channel_ids": strings.Join(channelIDs, ","),
|
||||
"agent_id": agentID,
|
||||
}
|
||||
|
||||
_, jobErr := a.CreateJob(rctx, &model.Job{
|
||||
Type: model.JobTypeRecap,
|
||||
Data: jobData,
|
||||
})
|
||||
|
||||
if jobErr != nil {
|
||||
return nil, jobErr
|
||||
}
|
||||
|
||||
return savedRecap, nil
|
||||
}
|
||||
|
||||
// GetRecap retrieves a recap by ID
|
||||
func (a *App) GetRecap(rctx request.CTX, recapID string) (*model.Recap, *model.AppError) {
|
||||
recap, err := a.Srv().Store().Recap().GetRecap(recapID)
|
||||
if err != nil {
|
||||
return nil, model.NewAppError("GetRecap", "app.recap.get.app_error", nil, "", http.StatusNotFound).Wrap(err)
|
||||
}
|
||||
|
||||
// Load channels
|
||||
channels, err := a.Srv().Store().Recap().GetRecapChannelsByRecapId(recapID)
|
||||
if err != nil {
|
||||
return nil, model.NewAppError("GetRecap", "app.recap.get_channels.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
recap.Channels = channels
|
||||
|
||||
return recap, nil
|
||||
}
|
||||
|
||||
// GetRecapsForUser retrieves all recaps for a user
|
||||
func (a *App) GetRecapsForUser(rctx request.CTX, page, perPage int) ([]*model.Recap, *model.AppError) {
|
||||
userID := rctx.Session().UserId
|
||||
recaps, err := a.Srv().Store().Recap().GetRecapsForUser(userID, page, perPage)
|
||||
if err != nil {
|
||||
return nil, model.NewAppError("GetRecapsForUser", "app.recap.list.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
return recaps, nil
|
||||
}
|
||||
|
||||
// MarkRecapAsRead marks a recap as read
|
||||
func (a *App) MarkRecapAsRead(rctx request.CTX, recap *model.Recap) (*model.Recap, *model.AppError) {
|
||||
// Mark as read
|
||||
if markErr := a.Srv().Store().Recap().MarkRecapAsRead(recap.Id); markErr != nil {
|
||||
return nil, model.NewAppError("MarkRecapAsRead", "app.recap.mark_read.app_error", nil, "", http.StatusInternalServerError).Wrap(markErr)
|
||||
}
|
||||
|
||||
// Update the passed recap with read timestamp
|
||||
recap.ReadAt = model.GetMillis()
|
||||
recap.UpdateAt = recap.ReadAt
|
||||
|
||||
// Load channels if not already loaded
|
||||
if recap.Channels == nil {
|
||||
channels, err := a.Srv().Store().Recap().GetRecapChannelsByRecapId(recap.Id)
|
||||
if err != nil {
|
||||
return nil, model.NewAppError("MarkRecapAsRead", "app.recap.get_channels.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
recap.Channels = channels
|
||||
}
|
||||
|
||||
return recap, nil
|
||||
}
|
||||
|
||||
// RegenerateRecap regenerates an existing recap
|
||||
func (a *App) RegenerateRecap(rctx request.CTX, userID string, recap *model.Recap) (*model.Recap, *model.AppError) {
|
||||
recapID := recap.Id
|
||||
|
||||
// Get existing recap channels to extract channel IDs
|
||||
channels, err := a.Srv().Store().Recap().GetRecapChannelsByRecapId(recapID)
|
||||
if err != nil {
|
||||
return nil, model.NewAppError("RegenerateRecap", "app.recap.get_channels.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
// Extract channel IDs
|
||||
channelIDs := make([]string, len(channels))
|
||||
for i, channel := range channels {
|
||||
channelIDs[i] = channel.ChannelId
|
||||
}
|
||||
|
||||
// Delete existing recap channels
|
||||
if deleteErr := a.Srv().Store().Recap().DeleteRecapChannels(recapID); deleteErr != nil {
|
||||
return nil, model.NewAppError("RegenerateRecap", "app.recap.delete_channels.app_error", nil, "", http.StatusInternalServerError).Wrap(deleteErr)
|
||||
}
|
||||
|
||||
// Update recap status to pending and reset read status
|
||||
recap.Status = model.RecapStatusPending
|
||||
recap.ReadAt = 0
|
||||
recap.UpdateAt = model.GetMillis()
|
||||
recap.TotalMessageCount = 0
|
||||
|
||||
if _, updateErr := a.Srv().Store().Recap().UpdateRecap(recap); updateErr != nil {
|
||||
return nil, model.NewAppError("RegenerateRecap", "app.recap.update.app_error", nil, "", http.StatusInternalServerError).Wrap(updateErr)
|
||||
}
|
||||
|
||||
// Create new job with same parameters
|
||||
jobData := map[string]string{
|
||||
"recap_id": recapID,
|
||||
"user_id": userID,
|
||||
"channel_ids": strings.Join(channelIDs, ","),
|
||||
"agent_id": recap.BotID,
|
||||
}
|
||||
|
||||
_, jobErr := a.CreateJob(rctx, &model.Job{
|
||||
Type: model.JobTypeRecap,
|
||||
Data: jobData,
|
||||
})
|
||||
|
||||
if jobErr != nil {
|
||||
return nil, jobErr
|
||||
}
|
||||
|
||||
// Return updated recap
|
||||
updatedRecap, getErr := a.GetRecap(rctx, recapID)
|
||||
if getErr != nil {
|
||||
return nil, getErr
|
||||
}
|
||||
|
||||
return updatedRecap, nil
|
||||
}
|
||||
|
||||
// DeleteRecap deletes a recap (soft delete)
|
||||
func (a *App) DeleteRecap(rctx request.CTX, recapID string) *model.AppError {
|
||||
// Delete recap
|
||||
if deleteErr := a.Srv().Store().Recap().DeleteRecap(recapID); deleteErr != nil {
|
||||
return model.NewAppError("DeleteRecap", "app.recap.delete.app_error", nil, "", http.StatusInternalServerError).Wrap(deleteErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProcessRecapChannel processes a single channel for a recap, fetching posts, summarizing them,
|
||||
// and saving the recap channel record. Returns the number of messages processed.
|
||||
func (a *App) ProcessRecapChannel(rctx request.CTX, recapID, channelID, userID, agentID string) (*model.RecapChannelResult, *model.AppError) {
|
||||
result := &model.RecapChannelResult{
|
||||
ChannelID: channelID,
|
||||
Success: false,
|
||||
}
|
||||
|
||||
// Get channel info
|
||||
channel, err := a.GetChannel(rctx, channelID)
|
||||
if err != nil {
|
||||
return result, model.NewAppError("ProcessRecapChannel", "app.recap.get_channel.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
// Get user's last viewed timestamp
|
||||
lastViewedAt, lastViewedErr := a.Srv().Store().Channel().GetMemberLastViewedAt(rctx, channelID, userID)
|
||||
if lastViewedErr != nil {
|
||||
return result, model.NewAppError("ProcessRecapChannel", "app.recap.get_last_viewed.app_error", nil, "", http.StatusInternalServerError).Wrap(lastViewedErr)
|
||||
}
|
||||
|
||||
// Fetch posts for recap
|
||||
posts, postsErr := a.fetchPostsForRecap(rctx, channelID, lastViewedAt, 100)
|
||||
if postsErr != nil {
|
||||
return result, postsErr
|
||||
}
|
||||
|
||||
// No posts to summarize - return success with 0 messages
|
||||
if len(posts) == 0 {
|
||||
result.Success = true
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Get team info for permalink generation
|
||||
team, teamErr := a.GetTeam(channel.TeamId)
|
||||
if teamErr != nil {
|
||||
return result, model.NewAppError("ProcessRecapChannel", "app.recap.get_team.app_error", nil, "", http.StatusInternalServerError).Wrap(teamErr)
|
||||
}
|
||||
|
||||
// Summarize posts
|
||||
summary, err := a.SummarizePosts(rctx, userID, posts, channel.DisplayName, team.Name, agentID)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Save recap channel
|
||||
recapChannel := &model.RecapChannel{
|
||||
Id: model.NewId(),
|
||||
RecapId: recapID,
|
||||
ChannelId: channelID,
|
||||
ChannelName: channel.DisplayName,
|
||||
Highlights: summary.Highlights,
|
||||
ActionItems: summary.ActionItems,
|
||||
SourcePostIds: extractPostIDs(posts),
|
||||
CreateAt: model.GetMillis(),
|
||||
}
|
||||
|
||||
if err := a.Srv().Store().Recap().SaveRecapChannel(recapChannel); err != nil {
|
||||
return result, model.NewAppError("ProcessRecapChannel", "app.recap.save_channel.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
result.MessageCount = len(posts)
|
||||
result.Success = true
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// fetchPostsForRecap fetches posts for a channel after the given timestamp and enriches them with user information
|
||||
func (a *App) fetchPostsForRecap(rctx request.CTX, channelID string, lastViewedAt int64, limit int) ([]*model.Post, *model.AppError) {
|
||||
// Get posts after lastViewedAt
|
||||
options := model.GetPostsSinceOptions{
|
||||
ChannelId: channelID,
|
||||
Time: lastViewedAt,
|
||||
}
|
||||
|
||||
postList, err := a.GetPostsSince(rctx, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(postList.Posts) == 0 {
|
||||
// If there are no unread posts, get the most recent 15 posts to include in the recap
|
||||
postList, err = a.GetPosts(rctx, channelID, 0, 20)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to slice and limit
|
||||
posts := make([]*model.Post, 0, len(postList.Posts))
|
||||
for _, postID := range postList.Order {
|
||||
if post, ok := postList.Posts[postID]; ok {
|
||||
posts = append(posts, post)
|
||||
if len(posts) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enrich with usernames
|
||||
for _, post := range posts {
|
||||
user, _ := a.GetUser(post.UserId)
|
||||
if user != nil {
|
||||
if post.Props == nil {
|
||||
post.Props = make(model.StringInterface)
|
||||
}
|
||||
post.AddProp("username", user.Username)
|
||||
}
|
||||
}
|
||||
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
// extractPostIDs extracts post IDs from a slice of posts
|
||||
func extractPostIDs(posts []*model.Post) []string {
|
||||
ids := make([]string, len(posts))
|
||||
for i, post := range posts {
|
||||
ids[i] = post.Id
|
||||
}
|
||||
return ids
|
||||
}
|
||||
307
server/channels/app/recap_test.go
Normal file
307
server/channels/app/recap_test.go
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/request"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCreateRecap(t *testing.T) {
|
||||
os.Setenv("MM_FEATUREFLAGS_ENABLEAIRECAPS", "true")
|
||||
defer os.Unsetenv("MM_FEATUREFLAGS_ENABLEAIRECAPS")
|
||||
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
t.Run("create recap with valid channels", func(t *testing.T) {
|
||||
channel2 := th.CreateChannel(t, th.BasicTeam)
|
||||
channelIds := []string{th.BasicChannel.Id, channel2.Id}
|
||||
|
||||
ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id})
|
||||
recap, err := th.App.CreateRecap(ctx, "My Test Recap", channelIds, "test-agent-id")
|
||||
require.Nil(t, err)
|
||||
require.NotNil(t, recap)
|
||||
assert.Equal(t, th.BasicUser.Id, recap.UserId)
|
||||
assert.Equal(t, model.RecapStatusPending, recap.Status)
|
||||
assert.Equal(t, "My Test Recap", recap.Title)
|
||||
})
|
||||
|
||||
t.Run("create recap with channel user is not member of", func(t *testing.T) {
|
||||
// Create a private channel and add only BasicUser2
|
||||
privateChannel := th.CreatePrivateChannel(t, th.BasicTeam)
|
||||
// Remove BasicUser if they were added automatically
|
||||
_ = th.App.RemoveUserFromChannel(th.Context, th.BasicUser.Id, "", privateChannel)
|
||||
// Ensure BasicUser2 is a member instead
|
||||
th.AddUserToChannel(t, th.BasicUser2, privateChannel)
|
||||
|
||||
// Try to create recap as BasicUser who is not a member
|
||||
channelIds := []string{privateChannel.Id}
|
||||
ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id})
|
||||
recap, err := th.App.CreateRecap(ctx, "Test Recap", channelIds, "test-agent-id")
|
||||
require.NotNil(t, err)
|
||||
assert.Nil(t, recap)
|
||||
assert.Equal(t, "app.recap.permission_denied", err.Id)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetRecap(t *testing.T) {
|
||||
os.Setenv("MM_FEATUREFLAGS_ENABLEAIRECAPS", "true")
|
||||
defer os.Unsetenv("MM_FEATUREFLAGS_ENABLEAIRECAPS")
|
||||
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
t.Run("get recap by owner", func(t *testing.T) {
|
||||
recap := &model.Recap{
|
||||
Id: model.NewId(),
|
||||
UserId: th.BasicUser.Id,
|
||||
Title: "Test Recap",
|
||||
CreateAt: model.GetMillis(),
|
||||
UpdateAt: model.GetMillis(),
|
||||
DeleteAt: 0,
|
||||
ReadAt: 0,
|
||||
TotalMessageCount: 10,
|
||||
Status: model.RecapStatusCompleted,
|
||||
}
|
||||
|
||||
_, err := th.App.Srv().Store().Recap().SaveRecap(recap)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create recap channel
|
||||
recapChannel := &model.RecapChannel{
|
||||
Id: model.NewId(),
|
||||
RecapId: recap.Id,
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
ChannelName: th.BasicChannel.DisplayName,
|
||||
Highlights: []string{"Test highlight"},
|
||||
ActionItems: []string{"Test action"},
|
||||
SourcePostIds: []string{model.NewId()},
|
||||
CreateAt: model.GetMillis(),
|
||||
}
|
||||
|
||||
err = th.App.Srv().Store().Recap().SaveRecapChannel(recapChannel)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id})
|
||||
retrievedRecap, appErr := th.App.GetRecap(ctx, recap.Id)
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, retrievedRecap)
|
||||
assert.Equal(t, recap.Id, retrievedRecap.Id)
|
||||
assert.Len(t, retrievedRecap.Channels, 1)
|
||||
assert.Equal(t, recapChannel.ChannelName, retrievedRecap.Channels[0].ChannelName)
|
||||
})
|
||||
|
||||
t.Run("get recap by non-owner", func(t *testing.T) {
|
||||
recap := &model.Recap{
|
||||
Id: model.NewId(),
|
||||
UserId: th.BasicUser.Id,
|
||||
Title: "Test Recap",
|
||||
CreateAt: model.GetMillis(),
|
||||
UpdateAt: model.GetMillis(),
|
||||
DeleteAt: 0,
|
||||
ReadAt: 0,
|
||||
TotalMessageCount: 10,
|
||||
Status: model.RecapStatusCompleted,
|
||||
}
|
||||
|
||||
_, err := th.App.Srv().Store().Recap().SaveRecap(recap)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Try to get as a different user - create context with BasicUser2's session
|
||||
ctx := request.TestContext(t).WithSession(&model.Session{UserId: th.BasicUser2.Id})
|
||||
retrievedRecap, appErr := th.App.GetRecap(ctx, recap.Id)
|
||||
// Permissions are now checked in API layer, so App layer should return the recap
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, retrievedRecap)
|
||||
assert.Equal(t, recap.Id, retrievedRecap.Id)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetRecapsForUser(t *testing.T) {
|
||||
os.Setenv("MM_FEATUREFLAGS_ENABLEAIRECAPS", "true")
|
||||
defer os.Unsetenv("MM_FEATUREFLAGS_ENABLEAIRECAPS")
|
||||
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
t.Run("get recaps for user", func(t *testing.T) {
|
||||
// Create multiple recaps for the user
|
||||
for range 5 {
|
||||
recap := &model.Recap{
|
||||
Id: model.NewId(),
|
||||
UserId: th.BasicUser.Id,
|
||||
Title: "Test Recap",
|
||||
CreateAt: model.GetMillis(),
|
||||
UpdateAt: model.GetMillis(),
|
||||
DeleteAt: 0,
|
||||
ReadAt: 0,
|
||||
TotalMessageCount: 10,
|
||||
Status: model.RecapStatusCompleted,
|
||||
}
|
||||
|
||||
_, err := th.App.Srv().Store().Recap().SaveRecap(recap)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id})
|
||||
recaps, err := th.App.GetRecapsForUser(ctx, 0, 10)
|
||||
require.Nil(t, err)
|
||||
assert.Len(t, recaps, 5)
|
||||
})
|
||||
|
||||
t.Run("pagination works correctly", func(t *testing.T) {
|
||||
userId := model.NewId()
|
||||
|
||||
// Create context with the test user's session
|
||||
ctx := request.TestContext(t).WithSession(&model.Session{UserId: userId})
|
||||
|
||||
// Create 15 recaps
|
||||
for range 15 {
|
||||
recap := &model.Recap{
|
||||
Id: model.NewId(),
|
||||
UserId: userId,
|
||||
Title: "Test Recap",
|
||||
CreateAt: model.GetMillis(),
|
||||
UpdateAt: model.GetMillis(),
|
||||
DeleteAt: 0,
|
||||
ReadAt: 0,
|
||||
TotalMessageCount: 10,
|
||||
Status: model.RecapStatusCompleted,
|
||||
}
|
||||
|
||||
_, err := th.App.Srv().Store().Recap().SaveRecap(recap)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Get first page
|
||||
recapsPage1, err := th.App.GetRecapsForUser(ctx, 0, 10)
|
||||
require.Nil(t, err)
|
||||
assert.Len(t, recapsPage1, 10)
|
||||
|
||||
// Get second page
|
||||
recapsPage2, err := th.App.GetRecapsForUser(ctx, 1, 10)
|
||||
require.Nil(t, err)
|
||||
assert.Len(t, recapsPage2, 5)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMarkRecapAsRead(t *testing.T) {
|
||||
os.Setenv("MM_FEATUREFLAGS_ENABLEAIRECAPS", "true")
|
||||
defer os.Unsetenv("MM_FEATUREFLAGS_ENABLEAIRECAPS")
|
||||
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
t.Run("mark recap as read by owner", func(t *testing.T) {
|
||||
recap := &model.Recap{
|
||||
Id: model.NewId(),
|
||||
UserId: th.BasicUser.Id,
|
||||
Title: "Test Recap",
|
||||
CreateAt: model.GetMillis(),
|
||||
UpdateAt: model.GetMillis(),
|
||||
DeleteAt: 0,
|
||||
ReadAt: 0,
|
||||
TotalMessageCount: 10,
|
||||
Status: model.RecapStatusCompleted,
|
||||
}
|
||||
|
||||
savedRecap, err := th.App.Srv().Store().Recap().SaveRecap(recap)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Mark as read
|
||||
ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id})
|
||||
updatedRecap, appErr := th.App.MarkRecapAsRead(ctx, savedRecap)
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, updatedRecap)
|
||||
assert.Greater(t, updatedRecap.ReadAt, int64(0))
|
||||
})
|
||||
|
||||
t.Run("mark recap as read by non-owner", func(t *testing.T) {
|
||||
recap := &model.Recap{
|
||||
Id: model.NewId(),
|
||||
UserId: th.BasicUser.Id,
|
||||
Title: "Test Recap",
|
||||
CreateAt: model.GetMillis(),
|
||||
UpdateAt: model.GetMillis(),
|
||||
DeleteAt: 0,
|
||||
ReadAt: 0,
|
||||
TotalMessageCount: 10,
|
||||
Status: model.RecapStatusCompleted,
|
||||
}
|
||||
|
||||
savedRecap, err := th.App.Srv().Store().Recap().SaveRecap(recap)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Try to mark as read as a different user - create context with BasicUser2's session
|
||||
ctx := request.TestContext(t).WithSession(&model.Session{UserId: th.BasicUser2.Id})
|
||||
updatedRecap, appErr := th.App.MarkRecapAsRead(ctx, savedRecap)
|
||||
// Permissions are now checked in API layer, so App layer should allow it
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, updatedRecap)
|
||||
assert.Greater(t, updatedRecap.ReadAt, int64(0))
|
||||
})
|
||||
}
|
||||
|
||||
func TestProcessRecapChannel(t *testing.T) {
|
||||
os.Setenv("MM_FEATUREFLAGS_ENABLEAIRECAPS", "true")
|
||||
defer os.Unsetenv("MM_FEATUREFLAGS_ENABLEAIRECAPS")
|
||||
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
t.Run("process empty channel", func(t *testing.T) {
|
||||
// Ensure channel has no posts (it shouldn't in init)
|
||||
channel := th.CreateChannel(t, th.BasicTeam)
|
||||
// No posts added
|
||||
|
||||
ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id})
|
||||
recapID := model.NewId()
|
||||
agentID := "test-agent"
|
||||
|
||||
result, err := th.App.ProcessRecapChannel(ctx, recapID, channel.Id, th.BasicUser.Id, agentID)
|
||||
require.Nil(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.True(t, result.Success)
|
||||
assert.Equal(t, 0, result.MessageCount)
|
||||
})
|
||||
|
||||
t.Run("process channel with posts", func(t *testing.T) {
|
||||
// This test expects failure at SummarizePosts because we can't mock AI easily in integration test
|
||||
channel := th.CreateChannel(t, th.BasicTeam)
|
||||
th.CreatePost(t, channel)
|
||||
|
||||
ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id})
|
||||
recapID := model.NewId()
|
||||
agentID := "test-agent"
|
||||
|
||||
result, err := th.App.ProcessRecapChannel(ctx, recapID, channel.Id, th.BasicUser.Id, agentID)
|
||||
// It will fail at SummarizePosts agent call
|
||||
require.NotNil(t, err)
|
||||
assert.Equal(t, "app.ai.summarize.agent_call_failed", err.Id)
|
||||
assert.False(t, result.Success)
|
||||
})
|
||||
}
|
||||
|
||||
func TestExtractPostIDs(t *testing.T) {
|
||||
t.Run("extract post IDs from posts", func(t *testing.T) {
|
||||
posts := []*model.Post{
|
||||
{Id: "post1", Message: "test1"},
|
||||
{Id: "post2", Message: "test2"},
|
||||
{Id: "post3", Message: "test3"},
|
||||
}
|
||||
|
||||
ids := extractPostIDs(posts)
|
||||
assert.Len(t, ids, 3)
|
||||
assert.Equal(t, "post1", ids[0])
|
||||
assert.Equal(t, "post2", ids[1])
|
||||
assert.Equal(t, "post3", ids[2])
|
||||
})
|
||||
|
||||
t.Run("extract from empty posts", func(t *testing.T) {
|
||||
posts := []*model.Post{}
|
||||
ids := extractPostIDs(posts)
|
||||
assert.Len(t, ids, 0)
|
||||
})
|
||||
}
|
||||
|
|
@ -61,6 +61,7 @@ import (
|
|||
"github.com/mattermost/mattermost/server/v8/channels/jobs/plugins"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/jobs/post_persistent_notifications"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/jobs/product_notices"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/jobs/recap"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/jobs/refresh_materialized_views"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/jobs/resend_invitation_email"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/jobs/s3_path_migration"
|
||||
|
|
@ -1624,6 +1625,12 @@ func (s *Server) initJobs() {
|
|||
delete_dms_preferences_migration.MakeWorker(s.Jobs, s.Store(), New(ServerConnector(s.Channels()))),
|
||||
nil)
|
||||
|
||||
s.Jobs.RegisterJobType(
|
||||
model.JobTypeRecap,
|
||||
recap.MakeWorker(s.Jobs, s.Store(), New(ServerConnector(s.Channels()))),
|
||||
nil,
|
||||
)
|
||||
|
||||
s.Jobs.RegisterJobType(
|
||||
model.JobTypeDeleteExpiredPosts,
|
||||
delete_expired_posts.MakeWorker(s.Jobs, s.Store(), New(ServerConnector(s.Channels()))),
|
||||
|
|
|
|||
130
server/channels/app/summarization.go
Normal file
130
server/channels/app/summarization.go
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
agentclient "github.com/mattermost/mattermost-plugin-ai/public/bridgeclient"
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
"github.com/mattermost/mattermost/server/public/shared/request"
|
||||
)
|
||||
|
||||
// SummarizePosts generates an AI summary of posts with highlights and action items
|
||||
func (a *App) SummarizePosts(rctx request.CTX, userID string, posts []*model.Post, channelName, teamName string, agentID string) (*model.AIRecapSummaryResponse, *model.AppError) {
|
||||
if len(posts) == 0 {
|
||||
return &model.AIRecapSummaryResponse{Highlights: []string{}, ActionItems: []string{}}, nil
|
||||
}
|
||||
|
||||
// Get site URL for permalink generation
|
||||
siteURL := a.GetSiteURL()
|
||||
|
||||
// Build conversation context from posts and collect post IDs
|
||||
conversationText, postIDs := buildConversationTextWithIDs(posts)
|
||||
|
||||
systemPrompt := "You are an expert at analyzing team conversations and extracting key information. Your task is to summarize a conversation from a Mattermost channel, identifying the most important highlights and any actionable items. Return ONLY valid JSON with 'highlights' and 'action_items' keys, each containing an array of strings. If there are no highlights or action items, return empty arrays. Do not make up information - only include items explicitly mentioned in the conversation."
|
||||
|
||||
userPrompt := fmt.Sprintf(`Analyze the following conversation from the "%s" channel and provide a summary.
|
||||
|
||||
Site URL: %s
|
||||
Team Name: %s
|
||||
|
||||
Conversation:
|
||||
%s
|
||||
|
||||
Available Post IDs: %s
|
||||
|
||||
Return a JSON object with:
|
||||
- "highlights": array of key discussion points, decisions, or important information
|
||||
- "action_items": array of tasks, todos, or action items mentioned
|
||||
|
||||
IMPORTANT INSTRUCTIONS:
|
||||
1. When your summary includes a user's username, prepend an @ symbol to the username. For example if you return a highlight with text '<username> sent an update about project xyz', where <username> is 'john.smith', you should phrase is as '@john.smith sent an update about project xyz'.
|
||||
|
||||
2. For EACH highlight and action item, you MUST append a permalink to cite the source. The permalink should reference the most relevant post from the conversation. Format the permalink at the END of each item as: [PERMALINK:%s/%s/pl/<POST_ID>] where <POST_ID> is one of the available post IDs provided above. Choose the post ID that is most relevant to that specific highlight or action item.
|
||||
|
||||
Example format: "Team decided to migrate to microservices architecture [PERMALINK:%s/%s/pl/abc123xyz]"
|
||||
|
||||
Your response must be compacted valid JSON only, with no additional text, formatting, nor code blocks.`, channelName, siteURL, teamName, conversationText, strings.Join(postIDs, ", "), siteURL, teamName, siteURL, teamName)
|
||||
|
||||
// Create bridge client
|
||||
sessionUserID := ""
|
||||
if session := rctx.Session(); session != nil {
|
||||
sessionUserID = session.UserId
|
||||
}
|
||||
client := a.getBridgeClient(sessionUserID)
|
||||
|
||||
completionRequest := agentclient.CompletionRequest{
|
||||
Posts: []agentclient.Post{
|
||||
{Role: "system", Message: systemPrompt},
|
||||
{Role: "user", Message: userPrompt},
|
||||
},
|
||||
}
|
||||
|
||||
rctx.Logger().Debug("Calling AI agent for post summarization",
|
||||
mlog.String("channel_name", channelName),
|
||||
mlog.String("user_id", userID),
|
||||
mlog.String("agent_id", agentID),
|
||||
mlog.Int("post_count", len(posts)),
|
||||
)
|
||||
|
||||
completion, err := client.AgentCompletion(agentID, completionRequest)
|
||||
if err != nil {
|
||||
return nil, model.NewAppError("SummarizePosts", "app.ai.summarize.agent_call_failed", nil, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
var summary model.AIRecapSummaryResponse
|
||||
if err := json.Unmarshal([]byte(completion), &summary); err != nil {
|
||||
return nil, model.NewAppError("SummarizePosts", "app.ai.summarize.parse_failed", nil, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
// Ensure arrays are never nil
|
||||
if summary.Highlights == nil {
|
||||
summary.Highlights = []string{}
|
||||
}
|
||||
if summary.ActionItems == nil {
|
||||
summary.ActionItems = []string{}
|
||||
}
|
||||
|
||||
rctx.Logger().Debug("AI summarization successful",
|
||||
mlog.String("channel_name", channelName),
|
||||
mlog.Int("highlights_count", len(summary.Highlights)),
|
||||
mlog.Int("action_items_count", len(summary.ActionItems)),
|
||||
)
|
||||
|
||||
return &summary, nil
|
||||
}
|
||||
|
||||
func buildConversationTextWithIDs(posts []*model.Post) (string, []string) {
|
||||
var sb strings.Builder
|
||||
postIDs := make([]string, 0, len(posts))
|
||||
|
||||
for _, post := range posts {
|
||||
// Collect post ID
|
||||
postIDs = append(postIDs, post.Id)
|
||||
|
||||
// Posts should have Username populated by the caller
|
||||
// For posts without username, use UserId as fallback
|
||||
username := ""
|
||||
if usernameProp := post.GetProp("username"); usernameProp != nil {
|
||||
if usernameStr, ok := usernameProp.(string); ok {
|
||||
username = usernameStr
|
||||
}
|
||||
}
|
||||
if username == "" {
|
||||
username = post.UserId
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("[%s] %s (Post ID: %s): %s\n",
|
||||
time.UnixMilli(post.CreateAt).Format("15:04"),
|
||||
username,
|
||||
post.Id,
|
||||
post.Message))
|
||||
}
|
||||
return sb.String(), postIDs
|
||||
}
|
||||
65
server/channels/app/summarization_test.go
Normal file
65
server/channels/app/summarization_test.go
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestBuildConversationText(t *testing.T) {
|
||||
t.Run("build conversation with posts", func(t *testing.T) {
|
||||
posts := []*model.Post{
|
||||
{
|
||||
Id: model.NewId(),
|
||||
Message: "Hello world",
|
||||
UserId: "user1",
|
||||
CreateAt: 1234567890000,
|
||||
Props: model.StringInterface{
|
||||
"username": "john_doe",
|
||||
},
|
||||
},
|
||||
{
|
||||
Id: model.NewId(),
|
||||
Message: "How are you?",
|
||||
UserId: "user2",
|
||||
CreateAt: 1234567895000,
|
||||
Props: model.StringInterface{
|
||||
"username": "jane_smith",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, _ := buildConversationTextWithIDs(posts)
|
||||
assert.Contains(t, result, "john_doe")
|
||||
assert.Contains(t, result, "jane_smith")
|
||||
assert.Contains(t, result, "Hello world")
|
||||
assert.Contains(t, result, "How are you?")
|
||||
})
|
||||
|
||||
t.Run("build conversation with posts without username", func(t *testing.T) {
|
||||
posts := []*model.Post{
|
||||
{
|
||||
Id: model.NewId(),
|
||||
Message: "Test message",
|
||||
UserId: "user123",
|
||||
CreateAt: 1234567890000,
|
||||
Props: model.StringInterface{},
|
||||
},
|
||||
}
|
||||
|
||||
result, _ := buildConversationTextWithIDs(posts)
|
||||
// Should fallback to user ID when no username prop
|
||||
assert.Contains(t, result, "user123")
|
||||
assert.Contains(t, result, "Test message")
|
||||
})
|
||||
|
||||
t.Run("build conversation with empty posts", func(t *testing.T) {
|
||||
posts := []*model.Post{}
|
||||
result, _ := buildConversationTextWithIDs(posts)
|
||||
assert.Equal(t, "", result)
|
||||
})
|
||||
}
|
||||
|
|
@ -293,3 +293,5 @@ channels/db/migrations/postgres/000147_create_autotranslation_tables.down.sql
|
|||
channels/db/migrations/postgres/000147_create_autotranslation_tables.up.sql
|
||||
channels/db/migrations/postgres/000148_add_burn_on_read_messages.down.sql
|
||||
channels/db/migrations/postgres/000148_add_burn_on_read_messages.up.sql
|
||||
channels/db/migrations/postgres/000149_create_recaps.down.sql
|
||||
channels/db/migrations/postgres/000149_create_recaps.up.sql
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
DROP INDEX IF EXISTS idx_recap_channels_channel_id;
|
||||
DROP INDEX IF EXISTS idx_recap_channels_recap_id;
|
||||
DROP TABLE IF EXISTS RecapChannels;
|
||||
|
||||
DROP INDEX IF EXISTS idx_recaps_bot_id;
|
||||
DROP INDEX IF EXISTS idx_recaps_user_id_read_at;
|
||||
DROP INDEX IF EXISTS idx_recaps_user_id_delete_at;
|
||||
DROP INDEX IF EXISTS idx_recaps_create_at;
|
||||
DROP INDEX IF EXISTS idx_recaps_user_id;
|
||||
DROP TABLE IF EXISTS Recaps;
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
-- Recaps table: stores recap metadata
|
||||
CREATE TABLE IF NOT EXISTS Recaps (
|
||||
Id VARCHAR(26) PRIMARY KEY,
|
||||
UserId VARCHAR(26) NOT NULL,
|
||||
Title VARCHAR(255) NOT NULL,
|
||||
CreateAt BIGINT NOT NULL,
|
||||
UpdateAt BIGINT NOT NULL,
|
||||
DeleteAt BIGINT NOT NULL,
|
||||
TotalMessageCount INT NOT NULL,
|
||||
Status VARCHAR(32) NOT NULL,
|
||||
ReadAt BIGINT DEFAULT 0 NOT NULL,
|
||||
BotID VARCHAR(26) DEFAULT '' NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_recaps_user_id ON Recaps(UserId);
|
||||
CREATE INDEX IF NOT EXISTS idx_recaps_create_at ON Recaps(CreateAt);
|
||||
CREATE INDEX IF NOT EXISTS idx_recaps_user_id_delete_at ON Recaps(UserId, DeleteAt);
|
||||
CREATE INDEX IF NOT EXISTS idx_recaps_user_id_read_at ON Recaps(UserId, ReadAt);
|
||||
CREATE INDEX IF NOT EXISTS idx_recaps_bot_id ON Recaps(BotID);
|
||||
|
||||
-- RecapChannels table: stores per-channel summaries
|
||||
CREATE TABLE IF NOT EXISTS RecapChannels (
|
||||
Id VARCHAR(26) PRIMARY KEY,
|
||||
RecapId VARCHAR(26) NOT NULL,
|
||||
ChannelId VARCHAR(26) NOT NULL,
|
||||
ChannelName VARCHAR(64) NOT NULL,
|
||||
Highlights TEXT,
|
||||
ActionItems TEXT,
|
||||
SourcePostIds TEXT,
|
||||
CreateAt BIGINT NOT NULL,
|
||||
FOREIGN KEY (RecapId) REFERENCES Recaps(Id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_recap_channels_recap_id ON RecapChannels(RecapId);
|
||||
CREATE INDEX IF NOT EXISTS idx_recap_channels_channel_id ON RecapChannels(ChannelId);
|
||||
|
||||
|
||||
129
server/channels/jobs/recap/worker.go
Normal file
129
server/channels/jobs/recap/worker.go
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package recap
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
"github.com/mattermost/mattermost/server/public/shared/request"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/jobs"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/store"
|
||||
)
|
||||
|
||||
type AppIface interface {
|
||||
ProcessRecapChannel(rctx request.CTX, recapID, channelID, userID, agentID string) (*model.RecapChannelResult, *model.AppError)
|
||||
Publish(message *model.WebSocketEvent)
|
||||
}
|
||||
|
||||
func MakeWorker(jobServer *jobs.JobServer, storeInstance store.Store, appInstance AppIface) *jobs.SimpleWorker {
|
||||
isEnabled := func(cfg *model.Config) bool {
|
||||
return cfg.FeatureFlags.EnableAIRecaps
|
||||
}
|
||||
|
||||
execute := func(logger mlog.LoggerIFace, job *model.Job) error {
|
||||
defer jobServer.HandleJobPanic(logger, job)
|
||||
return processRecapJob(logger, job, storeInstance, appInstance, func(progress int64) {
|
||||
_ = jobServer.SetJobProgress(job, progress)
|
||||
})
|
||||
}
|
||||
|
||||
return jobs.NewSimpleWorker("Recap", jobServer, execute, isEnabled)
|
||||
}
|
||||
|
||||
func processRecapJob(logger mlog.LoggerIFace, job *model.Job, storeInstance store.Store, appInstance AppIface, setProgress func(int64)) error {
|
||||
recapID := job.Data["recap_id"]
|
||||
userID := job.Data["user_id"]
|
||||
channelIDs := strings.Split(job.Data["channel_ids"], ",")
|
||||
agentID := job.Data["agent_id"]
|
||||
|
||||
logger.Info("Starting recap job",
|
||||
mlog.String("recap_id", recapID),
|
||||
mlog.String("agent_id", agentID),
|
||||
mlog.Int("channel_count", len(channelIDs)))
|
||||
|
||||
// Update status to processing
|
||||
_ = storeInstance.Recap().UpdateRecapStatus(recapID, model.RecapStatusProcessing)
|
||||
publishRecapUpdate(appInstance, recapID, userID)
|
||||
|
||||
totalMessages := 0
|
||||
successfulChannels := []string{}
|
||||
failedChannels := []string{}
|
||||
|
||||
for i, channelID := range channelIDs {
|
||||
// Update progress
|
||||
progress := int64((i * 100) / len(channelIDs))
|
||||
if setProgress != nil {
|
||||
setProgress(progress)
|
||||
}
|
||||
|
||||
// Process the channel
|
||||
result, err := appInstance.ProcessRecapChannel(request.EmptyContext(logger), recapID, channelID, userID, agentID)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to process channel",
|
||||
mlog.String("channel_id", channelID),
|
||||
mlog.Err(err))
|
||||
failedChannels = append(failedChannels, channelID)
|
||||
continue
|
||||
}
|
||||
|
||||
if !result.Success {
|
||||
logger.Warn("Channel processing unsuccessful", mlog.String("channel_id", channelID))
|
||||
failedChannels = append(failedChannels, channelID)
|
||||
continue
|
||||
}
|
||||
|
||||
totalMessages += result.MessageCount
|
||||
successfulChannels = append(successfulChannels, channelID)
|
||||
}
|
||||
|
||||
// Update recap with final data (title is already set by user in CreateRecap)
|
||||
recap, _ := storeInstance.Recap().GetRecap(recapID)
|
||||
recap.TotalMessageCount = totalMessages
|
||||
recap.UpdateAt = model.GetMillis()
|
||||
|
||||
if len(failedChannels) > 0 && len(successfulChannels) == 0 {
|
||||
recap.Status = model.RecapStatusFailed
|
||||
_, err := storeInstance.Recap().UpdateRecap(recap)
|
||||
if err != nil {
|
||||
logger.Error("Failed to update recap", mlog.Err(err))
|
||||
return fmt.Errorf("failed to update recap: %w", err)
|
||||
}
|
||||
publishRecapUpdate(appInstance, recapID, userID)
|
||||
return fmt.Errorf("all channels failed to process")
|
||||
} else if len(failedChannels) > 0 {
|
||||
recap.Status = model.RecapStatusCompleted
|
||||
_, err := storeInstance.Recap().UpdateRecap(recap)
|
||||
if err != nil {
|
||||
logger.Error("Failed to update recap", mlog.Err(err))
|
||||
return fmt.Errorf("failed to update recap: %w", err)
|
||||
}
|
||||
publishRecapUpdate(appInstance, recapID, userID)
|
||||
logger.Warn("Some channels failed", mlog.Int("failed_count", len(failedChannels)))
|
||||
// Job succeeds with warning
|
||||
} else {
|
||||
recap.Status = model.RecapStatusCompleted
|
||||
_, err := storeInstance.Recap().UpdateRecap(recap)
|
||||
if err != nil {
|
||||
logger.Error("Failed to update recap", mlog.Err(err))
|
||||
return fmt.Errorf("failed to update recap: %w", err)
|
||||
}
|
||||
publishRecapUpdate(appInstance, recapID, userID)
|
||||
}
|
||||
|
||||
logger.Info("Recap job completed",
|
||||
mlog.String("recap_id", recapID),
|
||||
mlog.Int("successful_channels", len(successfulChannels)),
|
||||
mlog.Int("failed_channels", len(failedChannels)))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func publishRecapUpdate(appInstance AppIface, recapID, userID string) {
|
||||
message := model.NewWebSocketEvent(model.WebsocketEventRecapUpdated, "", "", userID, nil, "")
|
||||
message.Add("recap_id", recapID)
|
||||
appInstance.Publish(message)
|
||||
}
|
||||
128
server/channels/jobs/recap/worker_test.go
Normal file
128
server/channels/jobs/recap/worker_test.go
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package recap
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
"github.com/mattermost/mattermost/server/public/shared/request"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type MockAppIface struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockAppIface) ProcessRecapChannel(rctx request.CTX, recapID, channelID, userID, agentID string) (*model.RecapChannelResult, *model.AppError) {
|
||||
args := m.Called(rctx, recapID, channelID, userID, agentID)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Get(1).(*model.AppError)
|
||||
}
|
||||
return args.Get(0).(*model.RecapChannelResult), nil
|
||||
}
|
||||
|
||||
func (m *MockAppIface) Publish(message *model.WebSocketEvent) {
|
||||
m.Called(message)
|
||||
}
|
||||
|
||||
func TestProcessRecapJob(t *testing.T) {
|
||||
logger := mlog.CreateConsoleTestLogger(t)
|
||||
job := &model.Job{
|
||||
Data: map[string]string{
|
||||
"recap_id": "recap1",
|
||||
"user_id": "user1",
|
||||
"channel_ids": "channel1,channel2",
|
||||
"agent_id": "agent1",
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("successful processing", func(t *testing.T) {
|
||||
mockStore := &mocks.Store{}
|
||||
mockRecapStore := &mocks.RecapStore{}
|
||||
mockStore.On("Recap").Return(mockRecapStore)
|
||||
|
||||
mockApp := &MockAppIface{}
|
||||
|
||||
// Setup expectations
|
||||
mockRecapStore.On("UpdateRecapStatus", "recap1", model.RecapStatusProcessing).Return(nil)
|
||||
mockApp.On("Publish", mock.Anything).Return()
|
||||
|
||||
mockApp.On("ProcessRecapChannel", mock.Anything, "recap1", "channel1", "user1", "agent1").Return(&model.RecapChannelResult{
|
||||
ChannelID: "channel1",
|
||||
Success: true,
|
||||
MessageCount: 10,
|
||||
}, nil)
|
||||
|
||||
mockApp.On("ProcessRecapChannel", mock.Anything, "recap1", "channel2", "user1", "agent1").Return(&model.RecapChannelResult{
|
||||
ChannelID: "channel2",
|
||||
Success: true,
|
||||
MessageCount: 5,
|
||||
}, nil)
|
||||
|
||||
recap := &model.Recap{Id: "recap1"}
|
||||
mockRecapStore.On("GetRecap", "recap1").Return(recap, nil)
|
||||
mockRecapStore.On("UpdateRecap", mock.MatchedBy(func(r *model.Recap) bool {
|
||||
return r.TotalMessageCount == 15 && r.Status == model.RecapStatusCompleted
|
||||
})).Return(recap, nil)
|
||||
|
||||
err := processRecapJob(logger, job, mockStore, mockApp, nil)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("partial failure", func(t *testing.T) {
|
||||
mockStore := &mocks.Store{}
|
||||
mockRecapStore := &mocks.RecapStore{}
|
||||
mockStore.On("Recap").Return(mockRecapStore)
|
||||
|
||||
mockApp := &MockAppIface{}
|
||||
|
||||
mockRecapStore.On("UpdateRecapStatus", "recap1", model.RecapStatusProcessing).Return(nil)
|
||||
mockApp.On("Publish", mock.Anything).Return()
|
||||
|
||||
mockApp.On("ProcessRecapChannel", mock.Anything, "recap1", "channel1", "user1", "agent1").Return(&model.RecapChannelResult{
|
||||
ChannelID: "channel1",
|
||||
Success: true,
|
||||
MessageCount: 10,
|
||||
}, nil)
|
||||
|
||||
mockApp.On("ProcessRecapChannel", mock.Anything, "recap1", "channel2", "user1", "agent1").Return(nil, model.NewAppError("fail", "fail", nil, "", 500))
|
||||
|
||||
recap := &model.Recap{Id: "recap1"}
|
||||
mockRecapStore.On("GetRecap", "recap1").Return(recap, nil)
|
||||
mockRecapStore.On("UpdateRecap", mock.MatchedBy(func(r *model.Recap) bool {
|
||||
return r.TotalMessageCount == 10 && r.Status == model.RecapStatusCompleted
|
||||
})).Return(recap, nil)
|
||||
|
||||
err := processRecapJob(logger, job, mockStore, mockApp, nil)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("complete failure", func(t *testing.T) {
|
||||
mockStore := &mocks.Store{}
|
||||
mockRecapStore := &mocks.RecapStore{}
|
||||
mockStore.On("Recap").Return(mockRecapStore)
|
||||
|
||||
mockApp := &MockAppIface{}
|
||||
|
||||
mockRecapStore.On("UpdateRecapStatus", "recap1", model.RecapStatusProcessing).Return(nil)
|
||||
mockApp.On("Publish", mock.Anything).Return()
|
||||
|
||||
mockApp.On("ProcessRecapChannel", mock.Anything, "recap1", "channel1", "user1", "agent1").Return(nil, model.NewAppError("fail", "fail", nil, "", 500))
|
||||
mockApp.On("ProcessRecapChannel", mock.Anything, "recap1", "channel2", "user1", "agent1").Return(nil, model.NewAppError("fail", "fail", nil, "", 500))
|
||||
|
||||
recap := &model.Recap{Id: "recap1"}
|
||||
mockRecapStore.On("GetRecap", "recap1").Return(recap, nil)
|
||||
mockRecapStore.On("UpdateRecap", mock.MatchedBy(func(r *model.Recap) bool {
|
||||
return r.TotalMessageCount == 0 && r.Status == model.RecapStatusFailed
|
||||
})).Return(recap, nil)
|
||||
|
||||
err := processRecapJob(logger, job, mockStore, mockApp, nil)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "all channels failed to process", err.Error())
|
||||
})
|
||||
}
|
||||
299
server/channels/store/sqlstore/recap_store.go
Normal file
299
server/channels/store/sqlstore/recap_store.go
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package sqlstore
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/store"
|
||||
sq "github.com/mattermost/squirrel"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
var (
|
||||
recapColumns = []string{
|
||||
"Id",
|
||||
"UserId",
|
||||
"Title",
|
||||
"CreateAt",
|
||||
"UpdateAt",
|
||||
"DeleteAt",
|
||||
"ReadAt",
|
||||
"TotalMessageCount",
|
||||
"Status",
|
||||
"BotID",
|
||||
}
|
||||
|
||||
recapChannelColumns = []string{
|
||||
"Id",
|
||||
"RecapId",
|
||||
"ChannelId",
|
||||
"ChannelName",
|
||||
"Highlights",
|
||||
"ActionItems",
|
||||
"SourcePostIds",
|
||||
"CreateAt",
|
||||
}
|
||||
)
|
||||
|
||||
type SqlRecapStore struct {
|
||||
*SqlStore
|
||||
|
||||
recapSelectQuery sq.SelectBuilder
|
||||
recapChannelSelectQuery sq.SelectBuilder
|
||||
}
|
||||
|
||||
func newSqlRecapStore(sqlStore *SqlStore) store.RecapStore {
|
||||
s := &SqlRecapStore{
|
||||
SqlStore: sqlStore,
|
||||
}
|
||||
|
||||
s.recapSelectQuery = s.getQueryBuilder().
|
||||
Select(recapColumns...).
|
||||
From("Recaps")
|
||||
|
||||
s.recapChannelSelectQuery = s.getQueryBuilder().
|
||||
Select(recapChannelColumns...).
|
||||
From("RecapChannels")
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *SqlRecapStore) recapToMap(recap *model.Recap) map[string]any {
|
||||
return map[string]any{
|
||||
"Id": recap.Id,
|
||||
"UserId": recap.UserId,
|
||||
"Title": recap.Title,
|
||||
"CreateAt": recap.CreateAt,
|
||||
"UpdateAt": recap.UpdateAt,
|
||||
"DeleteAt": recap.DeleteAt,
|
||||
"ReadAt": recap.ReadAt,
|
||||
"TotalMessageCount": recap.TotalMessageCount,
|
||||
"Status": recap.Status,
|
||||
"BotID": recap.BotID,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SqlRecapStore) recapChannelToMap(rc *model.RecapChannel) (map[string]any, error) {
|
||||
highlightsJSON, err := json.Marshal(rc.Highlights)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to marshal Highlights")
|
||||
}
|
||||
|
||||
actionItemsJSON, err := json.Marshal(rc.ActionItems)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to marshal ActionItems")
|
||||
}
|
||||
|
||||
sourcePostIdsJSON, err := json.Marshal(rc.SourcePostIds)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to marshal SourcePostIds")
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"Id": rc.Id,
|
||||
"RecapId": rc.RecapId,
|
||||
"ChannelId": rc.ChannelId,
|
||||
"ChannelName": rc.ChannelName,
|
||||
"Highlights": string(highlightsJSON),
|
||||
"ActionItems": string(actionItemsJSON),
|
||||
"SourcePostIds": string(sourcePostIdsJSON),
|
||||
"CreateAt": rc.CreateAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *SqlRecapStore) SaveRecap(recap *model.Recap) (*model.Recap, error) {
|
||||
query := s.getQueryBuilder().
|
||||
Insert("Recaps").
|
||||
SetMap(s.recapToMap(recap))
|
||||
|
||||
if _, err := s.GetMaster().ExecBuilder(query); err != nil {
|
||||
return nil, errors.Wrap(err, "failed to save Recap")
|
||||
}
|
||||
|
||||
return recap, nil
|
||||
}
|
||||
|
||||
func (s *SqlRecapStore) GetRecap(id string) (*model.Recap, error) {
|
||||
var recap model.Recap
|
||||
query := s.recapSelectQuery.Where(sq.Eq{"Id": id})
|
||||
|
||||
if err := s.GetReplica().GetBuilder(&recap, query); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, store.NewErrNotFound("Recap", id)
|
||||
}
|
||||
return nil, errors.Wrapf(err, "failed to get Recap with id=%s", id)
|
||||
}
|
||||
|
||||
return &recap, nil
|
||||
}
|
||||
|
||||
func (s *SqlRecapStore) GetRecapsForUser(userId string, page, perPage int) ([]*model.Recap, error) {
|
||||
offset := page * perPage
|
||||
var recaps []*model.Recap
|
||||
|
||||
query := s.recapSelectQuery.
|
||||
Where(sq.Eq{"UserId": userId, "DeleteAt": 0}).
|
||||
OrderBy("CreateAt DESC").
|
||||
Limit(uint64(perPage)).
|
||||
Offset(uint64(offset))
|
||||
|
||||
if err := s.GetReplica().SelectBuilder(&recaps, query); err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to get Recaps for userId=%s", userId)
|
||||
}
|
||||
|
||||
return recaps, nil
|
||||
}
|
||||
|
||||
func (s *SqlRecapStore) UpdateRecap(recap *model.Recap) (*model.Recap, error) {
|
||||
query := s.getQueryBuilder().
|
||||
Update("Recaps").
|
||||
SetMap(map[string]any{
|
||||
"Title": recap.Title,
|
||||
"UpdateAt": recap.UpdateAt,
|
||||
"TotalMessageCount": recap.TotalMessageCount,
|
||||
"Status": recap.Status,
|
||||
}).
|
||||
Where(sq.Eq{"Id": recap.Id})
|
||||
|
||||
if _, err := s.GetMaster().ExecBuilder(query); err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to update Recap with id=%s", recap.Id)
|
||||
}
|
||||
|
||||
return recap, nil
|
||||
}
|
||||
|
||||
func (s *SqlRecapStore) UpdateRecapStatus(id, status string) error {
|
||||
updateAt := model.GetMillis()
|
||||
|
||||
query := s.getQueryBuilder().
|
||||
Update("Recaps").
|
||||
SetMap(map[string]any{
|
||||
"Status": status,
|
||||
"UpdateAt": updateAt,
|
||||
}).
|
||||
Where(sq.Eq{"Id": id})
|
||||
|
||||
if _, err := s.GetMaster().ExecBuilder(query); err != nil {
|
||||
return errors.Wrapf(err, "failed to update Recap status for id=%s", id)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SqlRecapStore) MarkRecapAsRead(id string) error {
|
||||
now := model.GetMillis()
|
||||
|
||||
query := s.getQueryBuilder().
|
||||
Update("Recaps").
|
||||
SetMap(map[string]any{
|
||||
"ReadAt": now,
|
||||
"UpdateAt": now,
|
||||
}).
|
||||
Where(sq.Eq{"Id": id, "ReadAt": 0})
|
||||
|
||||
if _, err := s.GetMaster().ExecBuilder(query); err != nil {
|
||||
return errors.Wrapf(err, "failed to mark Recap as read for id=%s", id)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SqlRecapStore) DeleteRecap(id string) error {
|
||||
deleteAt := model.GetMillis()
|
||||
|
||||
query := s.getQueryBuilder().
|
||||
Update("Recaps").
|
||||
SetMap(map[string]any{
|
||||
"DeleteAt": deleteAt,
|
||||
}).
|
||||
Where(sq.Eq{"Id": id})
|
||||
|
||||
if _, err := s.GetMaster().ExecBuilder(query); err != nil {
|
||||
return errors.Wrapf(err, "failed to delete Recap with id=%s", id)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SqlRecapStore) DeleteRecapChannels(recapId string) error {
|
||||
query := s.getQueryBuilder().
|
||||
Delete("RecapChannels").
|
||||
Where(sq.Eq{"RecapId": recapId})
|
||||
|
||||
if _, err := s.GetMaster().ExecBuilder(query); err != nil {
|
||||
return errors.Wrapf(err, "failed to delete RecapChannels for recapId=%s", recapId)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SqlRecapStore) SaveRecapChannel(recapChannel *model.RecapChannel) error {
|
||||
rcMap, err := s.recapChannelToMap(recapChannel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
query := s.getQueryBuilder().
|
||||
Insert("RecapChannels").
|
||||
SetMap(rcMap)
|
||||
|
||||
if _, err := s.GetMaster().ExecBuilder(query); err != nil {
|
||||
return errors.Wrap(err, "failed to save RecapChannel")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SqlRecapStore) GetRecapChannelsByRecapId(recapId string) ([]*model.RecapChannel, error) {
|
||||
query := s.recapChannelSelectQuery.
|
||||
Where(sq.Eq{"RecapId": recapId}).
|
||||
OrderBy("CreateAt ASC")
|
||||
|
||||
var dbRecapChannels []struct {
|
||||
Id string
|
||||
RecapId string
|
||||
ChannelId string
|
||||
ChannelName string
|
||||
Highlights string
|
||||
ActionItems string
|
||||
SourcePostIds string
|
||||
CreateAt int64
|
||||
}
|
||||
|
||||
if err := s.GetReplica().SelectBuilder(&dbRecapChannels, query); err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to get RecapChannels for recapId=%s", recapId)
|
||||
}
|
||||
|
||||
recapChannels := make([]*model.RecapChannel, 0, len(dbRecapChannels))
|
||||
for _, dbRC := range dbRecapChannels {
|
||||
rc := &model.RecapChannel{
|
||||
Id: dbRC.Id,
|
||||
RecapId: dbRC.RecapId,
|
||||
ChannelId: dbRC.ChannelId,
|
||||
ChannelName: dbRC.ChannelName,
|
||||
CreateAt: dbRC.CreateAt,
|
||||
}
|
||||
|
||||
// Unmarshal JSON strings back to arrays
|
||||
if err := json.Unmarshal([]byte(dbRC.Highlights), &rc.Highlights); err != nil {
|
||||
return nil, errors.Wrap(err, fmt.Sprintf("failed to unmarshal Highlights for recapChannel id=%s", dbRC.Id))
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(dbRC.ActionItems), &rc.ActionItems); err != nil {
|
||||
return nil, errors.Wrap(err, fmt.Sprintf("failed to unmarshal ActionItems for recapChannel id=%s", dbRC.Id))
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(dbRC.SourcePostIds), &rc.SourcePostIds); err != nil {
|
||||
return nil, errors.Wrap(err, fmt.Sprintf("failed to unmarshal SourcePostIds for recapChannel id=%s", dbRC.Id))
|
||||
}
|
||||
|
||||
recapChannels = append(recapChannels, rc)
|
||||
}
|
||||
|
||||
return recapChannels, nil
|
||||
}
|
||||
195
server/channels/store/sqlstore/recap_store_test.go
Normal file
195
server/channels/store/sqlstore/recap_store_test.go
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package sqlstore
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/request"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/store"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRecapStore(t *testing.T) {
|
||||
StoreTest(t, func(t *testing.T, rctx request.CTX, ss store.Store) {
|
||||
t.Run("SaveAndGetRecap", func(t *testing.T) {
|
||||
recap := &model.Recap{
|
||||
Id: model.NewId(),
|
||||
UserId: model.NewId(),
|
||||
Title: "Test Recap",
|
||||
CreateAt: model.GetMillis(),
|
||||
UpdateAt: model.GetMillis(),
|
||||
DeleteAt: 0,
|
||||
ReadAt: 0,
|
||||
TotalMessageCount: 10,
|
||||
Status: model.RecapStatusPending,
|
||||
BotID: "test-bot-id",
|
||||
}
|
||||
|
||||
savedRecap, err := ss.Recap().SaveRecap(recap)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, recap.Id, savedRecap.Id)
|
||||
assert.Equal(t, recap.UserId, savedRecap.UserId)
|
||||
assert.Equal(t, recap.Title, savedRecap.Title)
|
||||
assert.Equal(t, recap.BotID, savedRecap.BotID)
|
||||
|
||||
retrievedRecap, err := ss.Recap().GetRecap(recap.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, recap.Id, retrievedRecap.Id)
|
||||
assert.Equal(t, recap.UserId, retrievedRecap.UserId)
|
||||
assert.Equal(t, recap.Title, retrievedRecap.Title)
|
||||
assert.Equal(t, recap.TotalMessageCount, retrievedRecap.TotalMessageCount)
|
||||
assert.Equal(t, recap.Status, retrievedRecap.Status)
|
||||
assert.Equal(t, recap.BotID, retrievedRecap.BotID)
|
||||
})
|
||||
|
||||
t.Run("GetRecapsForUser", func(t *testing.T) {
|
||||
userId := model.NewId()
|
||||
|
||||
// Create multiple recaps for the same user
|
||||
for range 3 {
|
||||
recap := &model.Recap{
|
||||
Id: model.NewId(),
|
||||
UserId: userId,
|
||||
Title: "Test Recap",
|
||||
CreateAt: model.GetMillis(),
|
||||
UpdateAt: model.GetMillis(),
|
||||
DeleteAt: 0,
|
||||
ReadAt: 0,
|
||||
TotalMessageCount: 10,
|
||||
Status: model.RecapStatusCompleted,
|
||||
BotID: "test-bot-id",
|
||||
}
|
||||
_, err := ss.Recap().SaveRecap(recap)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
recaps, err := ss.Recap().GetRecapsForUser(userId, 0, 10)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, recaps, 3)
|
||||
})
|
||||
|
||||
t.Run("UpdateRecapStatus", func(t *testing.T) {
|
||||
recap := &model.Recap{
|
||||
Id: model.NewId(),
|
||||
UserId: model.NewId(),
|
||||
Title: "Test Recap",
|
||||
CreateAt: model.GetMillis(),
|
||||
UpdateAt: model.GetMillis(),
|
||||
DeleteAt: 0,
|
||||
ReadAt: 0,
|
||||
TotalMessageCount: 10,
|
||||
Status: model.RecapStatusPending,
|
||||
BotID: "test-bot-id",
|
||||
}
|
||||
|
||||
_, err := ss.Recap().SaveRecap(recap)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = ss.Recap().UpdateRecapStatus(recap.Id, model.RecapStatusCompleted)
|
||||
require.NoError(t, err)
|
||||
|
||||
updatedRecap, err := ss.Recap().GetRecap(recap.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, model.RecapStatusCompleted, updatedRecap.Status)
|
||||
})
|
||||
|
||||
t.Run("SaveAndGetRecapChannels", func(t *testing.T) {
|
||||
recapId := model.NewId()
|
||||
|
||||
// Create a recap first
|
||||
recap := &model.Recap{
|
||||
Id: recapId,
|
||||
UserId: model.NewId(),
|
||||
Title: "Test Recap",
|
||||
CreateAt: model.GetMillis(),
|
||||
UpdateAt: model.GetMillis(),
|
||||
DeleteAt: 0,
|
||||
ReadAt: 0,
|
||||
TotalMessageCount: 10,
|
||||
Status: model.RecapStatusPending,
|
||||
BotID: "test-bot-id",
|
||||
}
|
||||
_, err := ss.Recap().SaveRecap(recap)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create recap channels
|
||||
recapChannel1 := &model.RecapChannel{
|
||||
Id: model.NewId(),
|
||||
RecapId: recapId,
|
||||
ChannelId: model.NewId(),
|
||||
ChannelName: "Test Channel 1",
|
||||
Highlights: []string{"Highlight 1", "Highlight 2"},
|
||||
ActionItems: []string{"Action 1"},
|
||||
SourcePostIds: []string{model.NewId(), model.NewId()},
|
||||
CreateAt: model.GetMillis(),
|
||||
}
|
||||
|
||||
recapChannel2 := &model.RecapChannel{
|
||||
Id: model.NewId(),
|
||||
RecapId: recapId,
|
||||
ChannelId: model.NewId(),
|
||||
ChannelName: "Test Channel 2",
|
||||
Highlights: []string{},
|
||||
ActionItems: []string{"Action 2", "Action 3"},
|
||||
SourcePostIds: []string{model.NewId()},
|
||||
CreateAt: model.GetMillis(),
|
||||
}
|
||||
|
||||
err = ss.Recap().SaveRecapChannel(recapChannel1)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = ss.Recap().SaveRecapChannel(recapChannel2)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Retrieve recap channels
|
||||
channels, err := ss.Recap().GetRecapChannelsByRecapId(recapId)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, channels, 2)
|
||||
|
||||
// Verify data integrity
|
||||
for _, ch := range channels {
|
||||
if ch.Id == recapChannel1.Id {
|
||||
assert.Equal(t, recapChannel1.ChannelName, ch.ChannelName)
|
||||
assert.Equal(t, recapChannel1.Highlights, ch.Highlights)
|
||||
assert.Equal(t, recapChannel1.ActionItems, ch.ActionItems)
|
||||
assert.Equal(t, recapChannel1.SourcePostIds, ch.SourcePostIds)
|
||||
} else if ch.Id == recapChannel2.Id {
|
||||
assert.Equal(t, recapChannel2.ChannelName, ch.ChannelName)
|
||||
assert.Equal(t, recapChannel2.Highlights, ch.Highlights)
|
||||
assert.Equal(t, recapChannel2.ActionItems, ch.ActionItems)
|
||||
assert.Equal(t, recapChannel2.SourcePostIds, ch.SourcePostIds)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DeleteRecap", func(t *testing.T) {
|
||||
recap := &model.Recap{
|
||||
Id: model.NewId(),
|
||||
UserId: model.NewId(),
|
||||
Title: "Test Recap",
|
||||
CreateAt: model.GetMillis(),
|
||||
UpdateAt: model.GetMillis(),
|
||||
DeleteAt: 0,
|
||||
ReadAt: 0,
|
||||
TotalMessageCount: 10,
|
||||
Status: model.RecapStatusCompleted,
|
||||
BotID: "test-bot-id",
|
||||
}
|
||||
|
||||
_, err := ss.Recap().SaveRecap(recap)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = ss.Recap().DeleteRecap(recap.Id)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify soft delete - should not appear in user's recaps
|
||||
recaps, err := ss.Recap().GetRecapsForUser(recap.UserId, 0, 10)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, recaps, 0)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -112,6 +112,7 @@ type SqlStoreStores struct {
|
|||
Attributes store.AttributesStore
|
||||
autotranslation store.AutoTranslationStore
|
||||
ContentFlagging store.ContentFlaggingStore
|
||||
recap store.RecapStore
|
||||
readReceipt store.ReadReceiptStore
|
||||
temporaryPost store.TemporaryPostStore
|
||||
}
|
||||
|
|
@ -265,6 +266,7 @@ func New(settings model.SqlSettings, logger mlog.LoggerIFace, metrics einterface
|
|||
store.stores.Attributes = newSqlAttributesStore(store, metrics)
|
||||
store.stores.autotranslation = newSqlAutoTranslationStore(store)
|
||||
store.stores.ContentFlagging = newContentFlaggingStore(store)
|
||||
store.stores.recap = newSqlRecapStore(store)
|
||||
store.stores.readReceipt = newSqlReadReceiptStore(store, metrics)
|
||||
store.stores.temporaryPost = newSqlTemporaryPostStore(store, metrics)
|
||||
|
||||
|
|
@ -886,6 +888,10 @@ func (ss *SqlStore) AutoTranslation() store.AutoTranslationStore {
|
|||
return ss.stores.autotranslation
|
||||
}
|
||||
|
||||
func (ss *SqlStore) Recap() store.RecapStore {
|
||||
return ss.stores.recap
|
||||
}
|
||||
|
||||
func (ss *SqlStore) ReadReceipt() store.ReadReceiptStore {
|
||||
return ss.stores.readReceipt
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ type Store interface {
|
|||
AutoTranslation() AutoTranslationStore
|
||||
GetSchemaDefinition() (*model.SupportPacketDatabaseSchema, error)
|
||||
ContentFlagging() ContentFlaggingStore
|
||||
Recap() RecapStore
|
||||
ReadReceipt() ReadReceiptStore
|
||||
TemporaryPost() TemporaryPostStore
|
||||
}
|
||||
|
|
@ -1291,3 +1292,16 @@ type ThreadMembershipImportData struct {
|
|||
// UnreadMentions is the number of unread mentions to set the UnreadMentions field to.
|
||||
UnreadMentions int64
|
||||
}
|
||||
|
||||
type RecapStore interface {
|
||||
SaveRecap(recap *model.Recap) (*model.Recap, error)
|
||||
UpdateRecap(recap *model.Recap) (*model.Recap, error)
|
||||
GetRecap(id string) (*model.Recap, error)
|
||||
GetRecapsForUser(userId string, page, perPage int) ([]*model.Recap, error)
|
||||
UpdateRecapStatus(id, status string) error
|
||||
MarkRecapAsRead(id string) error
|
||||
DeleteRecap(id string) error
|
||||
DeleteRecapChannels(recapId string) error
|
||||
SaveRecapChannel(recapChannel *model.RecapChannel) error
|
||||
GetRecapChannelsByRecapId(recapId string) ([]*model.RecapChannel, error)
|
||||
}
|
||||
|
|
|
|||
269
server/channels/store/storetest/mocks/RecapStore.go
Normal file
269
server/channels/store/storetest/mocks/RecapStore.go
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
// Code generated by mockery v2.53.4. DO NOT EDIT.
|
||||
|
||||
// Regenerate this file using `make store-mocks`.
|
||||
|
||||
package mocks
|
||||
|
||||
import (
|
||||
model "github.com/mattermost/mattermost/server/public/model"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// RecapStore is an autogenerated mock type for the RecapStore type
|
||||
type RecapStore struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// DeleteRecap provides a mock function with given fields: id
|
||||
func (_m *RecapStore) DeleteRecap(id string) error {
|
||||
ret := _m.Called(id)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for DeleteRecap")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(string) error); ok {
|
||||
r0 = rf(id)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// DeleteRecapChannels provides a mock function with given fields: recapId
|
||||
func (_m *RecapStore) DeleteRecapChannels(recapId string) error {
|
||||
ret := _m.Called(recapId)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for DeleteRecapChannels")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(string) error); ok {
|
||||
r0 = rf(recapId)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// GetRecap provides a mock function with given fields: id
|
||||
func (_m *RecapStore) GetRecap(id string) (*model.Recap, error) {
|
||||
ret := _m.Called(id)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for GetRecap")
|
||||
}
|
||||
|
||||
var r0 *model.Recap
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(string) (*model.Recap, error)); ok {
|
||||
return rf(id)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(string) *model.Recap); ok {
|
||||
r0 = rf(id)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.Recap)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(string) error); ok {
|
||||
r1 = rf(id)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// GetRecapChannelsByRecapId provides a mock function with given fields: recapId
|
||||
func (_m *RecapStore) GetRecapChannelsByRecapId(recapId string) ([]*model.RecapChannel, error) {
|
||||
ret := _m.Called(recapId)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for GetRecapChannelsByRecapId")
|
||||
}
|
||||
|
||||
var r0 []*model.RecapChannel
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(string) ([]*model.RecapChannel, error)); ok {
|
||||
return rf(recapId)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(string) []*model.RecapChannel); ok {
|
||||
r0 = rf(recapId)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]*model.RecapChannel)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(string) error); ok {
|
||||
r1 = rf(recapId)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// GetRecapsForUser provides a mock function with given fields: userId, page, perPage
|
||||
func (_m *RecapStore) GetRecapsForUser(userId string, page int, perPage int) ([]*model.Recap, error) {
|
||||
ret := _m.Called(userId, page, perPage)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for GetRecapsForUser")
|
||||
}
|
||||
|
||||
var r0 []*model.Recap
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(string, int, int) ([]*model.Recap, error)); ok {
|
||||
return rf(userId, page, perPage)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(string, int, int) []*model.Recap); ok {
|
||||
r0 = rf(userId, page, perPage)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]*model.Recap)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(string, int, int) error); ok {
|
||||
r1 = rf(userId, page, perPage)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// MarkRecapAsRead provides a mock function with given fields: id
|
||||
func (_m *RecapStore) MarkRecapAsRead(id string) error {
|
||||
ret := _m.Called(id)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for MarkRecapAsRead")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(string) error); ok {
|
||||
r0 = rf(id)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// SaveRecap provides a mock function with given fields: recap
|
||||
func (_m *RecapStore) SaveRecap(recap *model.Recap) (*model.Recap, error) {
|
||||
ret := _m.Called(recap)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for SaveRecap")
|
||||
}
|
||||
|
||||
var r0 *model.Recap
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(*model.Recap) (*model.Recap, error)); ok {
|
||||
return rf(recap)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(*model.Recap) *model.Recap); ok {
|
||||
r0 = rf(recap)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.Recap)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(*model.Recap) error); ok {
|
||||
r1 = rf(recap)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// SaveRecapChannel provides a mock function with given fields: recapChannel
|
||||
func (_m *RecapStore) SaveRecapChannel(recapChannel *model.RecapChannel) error {
|
||||
ret := _m.Called(recapChannel)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for SaveRecapChannel")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(*model.RecapChannel) error); ok {
|
||||
r0 = rf(recapChannel)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// UpdateRecap provides a mock function with given fields: recap
|
||||
func (_m *RecapStore) UpdateRecap(recap *model.Recap) (*model.Recap, error) {
|
||||
ret := _m.Called(recap)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for UpdateRecap")
|
||||
}
|
||||
|
||||
var r0 *model.Recap
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(*model.Recap) (*model.Recap, error)); ok {
|
||||
return rf(recap)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(*model.Recap) *model.Recap); ok {
|
||||
r0 = rf(recap)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.Recap)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(*model.Recap) error); ok {
|
||||
r1 = rf(recap)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// UpdateRecapStatus provides a mock function with given fields: id, status
|
||||
func (_m *RecapStore) UpdateRecapStatus(id string, status string) error {
|
||||
ret := _m.Called(id, status)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for UpdateRecapStatus")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(string, string) error); ok {
|
||||
r0 = rf(id, status)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// NewRecapStore creates a new instance of RecapStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewRecapStore(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *RecapStore {
|
||||
mock := &RecapStore{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
|
|
@ -966,6 +966,26 @@ func (_m *Store) Reaction() store.ReactionStore {
|
|||
return r0
|
||||
}
|
||||
|
||||
// Recap provides a mock function with no fields
|
||||
func (_m *Store) Recap() store.RecapStore {
|
||||
ret := _m.Called()
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Recap")
|
||||
}
|
||||
|
||||
var r0 store.RecapStore
|
||||
if rf, ok := ret.Get(0).(func() store.RecapStore); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(store.RecapStore)
|
||||
}
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// ReadReceipt provides a mock function with no fields
|
||||
func (_m *Store) ReadReceipt() store.ReadReceiptStore {
|
||||
ret := _m.Called()
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ type Store struct {
|
|||
AttributesStore mocks.AttributesStore
|
||||
AutoTranslationStore mocks.AutoTranslationStore
|
||||
ContentFlaggingStore mocks.ContentFlaggingStore
|
||||
RecapStore mocks.RecapStore
|
||||
ReadReceiptStore mocks.ReadReceiptStore
|
||||
TemporaryPostStore mocks.TemporaryPostStore
|
||||
}
|
||||
|
|
@ -169,6 +170,9 @@ func (s *Store) AutoTranslation() store.AutoTranslationStore {
|
|||
func (s *Store) ContentFlagging() store.ContentFlaggingStore {
|
||||
return &s.ContentFlaggingStore
|
||||
}
|
||||
func (s *Store) Recap() store.RecapStore {
|
||||
return &s.RecapStore
|
||||
}
|
||||
func (s *Store) ReadReceipt() store.ReadReceiptStore {
|
||||
return &s.ReadReceiptStore
|
||||
}
|
||||
|
|
@ -227,6 +231,7 @@ func (s *Store) AssertExpectations(t mock.TestingT) bool {
|
|||
&s.AttributesStore,
|
||||
&s.AutoTranslationStore,
|
||||
&s.ContentFlaggingStore,
|
||||
&s.RecapStore,
|
||||
&s.ReadReceiptStore,
|
||||
&s.TemporaryPostStore,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -768,6 +768,17 @@ func (c *Context) RequireContentReviewerId() *Context {
|
|||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequireRecapId() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if !model.IsValidId(c.Params.RecapId) {
|
||||
c.SetInvalidURLParam("recap_id")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) GetRemoteID(r *http.Request) string {
|
||||
return r.Header.Get(model.HeaderRemoteclusterId)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ type Params struct {
|
|||
Service string
|
||||
JobId string
|
||||
JobType string
|
||||
RecapId string
|
||||
ActionId string
|
||||
RoleId string
|
||||
RoleName string
|
||||
|
|
@ -169,6 +170,7 @@ func ParamsFromRequest(r *http.Request) *Params {
|
|||
params.EmojiName = props["emoji_name"]
|
||||
params.JobId = props["job_id"]
|
||||
params.JobType = props["job_type"]
|
||||
params.RecapId = props["recap_id"]
|
||||
params.ActionId = props["action_id"]
|
||||
params.RoleId = props["role_id"]
|
||||
params.RoleName = props["role_name"]
|
||||
|
|
|
|||
|
|
@ -3018,6 +3018,14 @@
|
|||
"id": "api.reaction.save_reaction.user_id.app_error",
|
||||
"translation": "You cannot save reaction for the other user."
|
||||
},
|
||||
{
|
||||
"id": "api.recap.disabled.app_error",
|
||||
"translation": "This feature is not enabled."
|
||||
},
|
||||
{
|
||||
"id": "api.recap.permission_denied",
|
||||
"translation": "You do not have permission to access this recap."
|
||||
},
|
||||
{
|
||||
"id": "api.remote_cluster.accept_invitation_error",
|
||||
"translation": "Could not accept the remote cluster invitation"
|
||||
|
|
@ -4850,6 +4858,14 @@
|
|||
"id": "app.agents.get_services.bridge_call_failed",
|
||||
"translation": "Bridge call failed."
|
||||
},
|
||||
{
|
||||
"id": "app.ai.summarize.agent_call_failed",
|
||||
"translation": "AI agent call failed."
|
||||
},
|
||||
{
|
||||
"id": "app.ai.summarize.parse_failed",
|
||||
"translation": "Failed to parse AI summarization response."
|
||||
},
|
||||
{
|
||||
"id": "app.analytics.getanalytics.internal_error",
|
||||
"translation": "Unable to get the analytics."
|
||||
|
|
@ -7366,6 +7382,58 @@
|
|||
"id": "app.reaction.save.save.too_many_reactions",
|
||||
"translation": "Reaction limit has been reached for this post."
|
||||
},
|
||||
{
|
||||
"id": "app.recap.delete.app_error",
|
||||
"translation": "Failed to delete recap."
|
||||
},
|
||||
{
|
||||
"id": "app.recap.delete_channels.app_error",
|
||||
"translation": "Failed to delete recap channels."
|
||||
},
|
||||
{
|
||||
"id": "app.recap.get.app_error",
|
||||
"translation": "Failed to get recap."
|
||||
},
|
||||
{
|
||||
"id": "app.recap.get_channel.app_error",
|
||||
"translation": "Failed to get channel."
|
||||
},
|
||||
{
|
||||
"id": "app.recap.get_channels.app_error",
|
||||
"translation": "Failed to get recap channels."
|
||||
},
|
||||
{
|
||||
"id": "app.recap.get_last_viewed.app_error",
|
||||
"translation": "Failed to get last viewed timestamp."
|
||||
},
|
||||
{
|
||||
"id": "app.recap.get_team.app_error",
|
||||
"translation": "Failed to get team."
|
||||
},
|
||||
{
|
||||
"id": "app.recap.list.app_error",
|
||||
"translation": "Failed to get recaps."
|
||||
},
|
||||
{
|
||||
"id": "app.recap.mark_read.app_error",
|
||||
"translation": "Failed to mark recap as read."
|
||||
},
|
||||
{
|
||||
"id": "app.recap.permission_denied",
|
||||
"translation": "No permission for recap."
|
||||
},
|
||||
{
|
||||
"id": "app.recap.save.app_error",
|
||||
"translation": "Failed to save recap."
|
||||
},
|
||||
{
|
||||
"id": "app.recap.save_channel.app_error",
|
||||
"translation": "Failed to save recap channel."
|
||||
},
|
||||
{
|
||||
"id": "app.recap.update.app_error",
|
||||
"translation": "Failed to update recap."
|
||||
},
|
||||
{
|
||||
"id": "app.recover.delete.app_error",
|
||||
"translation": "Unable to delete token."
|
||||
|
|
|
|||
|
|
@ -88,6 +88,9 @@ type FeatureFlags struct {
|
|||
|
||||
// FEATURE_FLAG_REMOVAL: EnableAIPluginBridge
|
||||
EnableAIPluginBridge bool
|
||||
|
||||
// FEATURE_FLAG_REMOVAL: EnableAIRecaps - Remove this when GA is released
|
||||
EnableAIRecaps bool
|
||||
}
|
||||
|
||||
func (f *FeatureFlags) SetDefaults() {
|
||||
|
|
@ -129,6 +132,8 @@ func (f *FeatureFlags) SetDefaults() {
|
|||
|
||||
// FEATURE_FLAG_REMOVAL: EnableAIPluginBridge - Remove this default when MVP is to be released
|
||||
f.EnableAIPluginBridge = false
|
||||
|
||||
f.EnableAIRecaps = false
|
||||
}
|
||||
|
||||
// ToMap returns the feature flags as a map[string]string
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ const (
|
|||
JobTypeMobileSessionMetadata = "mobile_session_metadata"
|
||||
JobTypeAccessControlSync = "access_control_sync"
|
||||
JobTypePushProxyAuth = "push_proxy_auth"
|
||||
JobTypeRecap = "recap"
|
||||
JobTypeDeleteExpiredPosts = "delete_expired_posts"
|
||||
|
||||
JobStatusPending = "pending"
|
||||
|
|
|
|||
54
server/public/model/recap.go
Normal file
54
server/public/model/recap.go
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package model
|
||||
|
||||
type Recap struct {
|
||||
Id string `json:"id"`
|
||||
UserId string `json:"user_id"`
|
||||
Title string `json:"title"`
|
||||
CreateAt int64 `json:"create_at"`
|
||||
UpdateAt int64 `json:"update_at"`
|
||||
DeleteAt int64 `json:"delete_at"`
|
||||
ReadAt int64 `json:"read_at"`
|
||||
TotalMessageCount int `json:"total_message_count"`
|
||||
Status string `json:"status"`
|
||||
BotID string `json:"bot_id"`
|
||||
Channels []*RecapChannel `json:"channels,omitempty"`
|
||||
}
|
||||
|
||||
type RecapChannel struct {
|
||||
Id string `json:"id"`
|
||||
RecapId string `json:"recap_id"`
|
||||
ChannelId string `json:"channel_id"`
|
||||
ChannelName string `json:"channel_name"`
|
||||
Highlights []string `json:"highlights"`
|
||||
ActionItems []string `json:"action_items"`
|
||||
SourcePostIds []string `json:"source_post_ids"`
|
||||
CreateAt int64 `json:"create_at"`
|
||||
}
|
||||
|
||||
type CreateRecapRequest struct {
|
||||
Title string `json:"title"`
|
||||
ChannelIds []string `json:"channel_ids"`
|
||||
AgentID string `json:"agent_id"`
|
||||
}
|
||||
|
||||
type AIRecapSummaryResponse struct {
|
||||
Highlights []string `json:"highlights"`
|
||||
ActionItems []string `json:"action_items"`
|
||||
}
|
||||
|
||||
// RecapChannelResult represents the result of processing a single channel for a recap
|
||||
type RecapChannelResult struct {
|
||||
ChannelID string
|
||||
MessageCount int
|
||||
Success bool
|
||||
}
|
||||
|
||||
const (
|
||||
RecapStatusPending = "pending"
|
||||
RecapStatusProcessing = "processing"
|
||||
RecapStatusCompleted = "completed"
|
||||
RecapStatusFailed = "failed"
|
||||
)
|
||||
|
|
@ -99,6 +99,7 @@ const (
|
|||
WebsocketEventCPAFieldDeleted WebsocketEventType = "custom_profile_attributes_field_deleted"
|
||||
WebsocketEventCPAValuesUpdated WebsocketEventType = "custom_profile_attributes_values_updated"
|
||||
WebsocketContentFlaggingReportValueUpdated WebsocketEventType = "content_flagging_report_value_updated"
|
||||
WebsocketEventRecapUpdated WebsocketEventType = "recap_updated"
|
||||
WebsocketEventPostRevealed WebsocketEventType = "post_revealed"
|
||||
WebsocketEventPostBurned WebsocketEventType = "post_burned"
|
||||
WebsocketEventBurnOnReadAllRevealed WebsocketEventType = "burn_on_read_all_revealed"
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ import {
|
|||
receivedNewPost,
|
||||
receivedPost,
|
||||
} from 'mattermost-redux/actions/posts';
|
||||
import {getRecap} from 'mattermost-redux/actions/recaps';
|
||||
import {loadRolesIfNeeded} from 'mattermost-redux/actions/roles';
|
||||
import {fetchTeamScheduledPosts} from 'mattermost-redux/actions/scheduled_posts';
|
||||
import {batchFetchStatusesProfilesGroupsFromPosts} from 'mattermost-redux/actions/status_profile_polling';
|
||||
|
|
@ -664,6 +665,9 @@ export function handleEvent(msg) {
|
|||
case SocketEvents.CONTENT_FLAGGING_REPORT_VALUE_CHANGED:
|
||||
dispatch(handleContentFlaggingReportValueChanged(msg));
|
||||
break;
|
||||
case SocketEvents.RECAP_UPDATED:
|
||||
dispatch(handleRecapUpdated(msg));
|
||||
break;
|
||||
default:
|
||||
}
|
||||
|
||||
|
|
@ -2001,3 +2005,12 @@ export function handleContentFlaggingReportValueChanged(msg) {
|
|||
data: msg.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function handleRecapUpdated(msg) {
|
||||
const recapId = msg.data.recap_id;
|
||||
|
||||
return async (doDispatch) => {
|
||||
// Fetch the updated recap and dispatch to Redux
|
||||
doDispatch(getRecap(recapId));
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,13 @@ const Drafts = makeAsyncComponent('Drafts', lazy(() => import('components/drafts
|
|||
</div>
|
||||
),
|
||||
);
|
||||
const Recaps = makeAsyncComponent('Recaps', lazy(() => import('components/recaps')),
|
||||
(
|
||||
<div className='app__content'>
|
||||
<LoadingScreen/>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
const PermalinkView = makeAsyncComponent('PermalinkView', lazy(() => import('components/permalink_view')));
|
||||
const PlaybookRunner = makeAsyncComponent('PlaybookRunner', lazy(() => import('components/channel_layout/playbook_runner')));
|
||||
|
||||
|
|
@ -103,6 +110,10 @@ export default class CenterChannel extends React.PureComponent<Props, State> {
|
|||
component={GlobalThreads}
|
||||
/>
|
||||
) : null}
|
||||
<Route
|
||||
path={`/:team(${TEAM_NAME_PATH_PATTERN})/recaps`}
|
||||
component={Recaps}
|
||||
/>
|
||||
<Route
|
||||
path={`/:team(${TEAM_NAME_PATH_PATTERN})/drafts`}
|
||||
component={Drafts}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
export {default} from './pagination_dots';
|
||||
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
.pagination-dots {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
.pagination-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: rgba(var(--center-channel-color-rgb), 0.16);
|
||||
transition: background 0.2s ease;
|
||||
|
||||
&.active {
|
||||
background: var(--button-bg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import './pagination_dots.scss';
|
||||
|
||||
type Props = {
|
||||
totalSteps: number;
|
||||
currentStep: number;
|
||||
};
|
||||
|
||||
const PaginationDots = ({totalSteps, currentStep}: Props) => {
|
||||
return (
|
||||
<div className='pagination-dots'>
|
||||
{Array.from({length: totalSteps}).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`pagination-dot ${index + 1 === currentStep ? 'active' : ''}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaginationDots;
|
||||
|
||||
|
|
@ -0,0 +1,290 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import type {Channel} from '@mattermost/types/channels';
|
||||
|
||||
import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils';
|
||||
|
||||
import ChannelSelector from './channel_selector';
|
||||
|
||||
describe('ChannelSelector', () => {
|
||||
const mockChannels: Channel[] = [
|
||||
{
|
||||
id: 'channel1',
|
||||
name: 'town-square',
|
||||
display_name: 'Town Square',
|
||||
type: 'O',
|
||||
create_at: 1000,
|
||||
update_at: 1000,
|
||||
delete_at: 0,
|
||||
team_id: 'team1',
|
||||
creator_id: 'user1',
|
||||
} as Channel,
|
||||
{
|
||||
id: 'channel2',
|
||||
name: 'off-topic',
|
||||
display_name: 'Off-Topic',
|
||||
type: 'O',
|
||||
create_at: 2000,
|
||||
update_at: 2000,
|
||||
delete_at: 0,
|
||||
team_id: 'team1',
|
||||
creator_id: 'user1',
|
||||
} as Channel,
|
||||
{
|
||||
id: 'channel3',
|
||||
name: 'private-channel',
|
||||
display_name: 'Private Channel',
|
||||
type: 'P',
|
||||
create_at: 3000,
|
||||
update_at: 3000,
|
||||
delete_at: 0,
|
||||
team_id: 'team1',
|
||||
creator_id: 'user1',
|
||||
} as Channel,
|
||||
{
|
||||
id: 'channel4',
|
||||
name: 'dev-team',
|
||||
display_name: 'Dev Team',
|
||||
type: 'O',
|
||||
create_at: 4000,
|
||||
update_at: 4000,
|
||||
delete_at: 0,
|
||||
team_id: 'team1',
|
||||
creator_id: 'user1',
|
||||
} as Channel,
|
||||
];
|
||||
|
||||
const mockUnreadChannels: Channel[] = [mockChannels[0], mockChannels[2]];
|
||||
|
||||
const defaultProps = {
|
||||
selectedChannelIds: [],
|
||||
setSelectedChannelIds: jest.fn(),
|
||||
myChannels: mockChannels,
|
||||
unreadChannels: mockUnreadChannels,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render the component with label', () => {
|
||||
renderWithContext(<ChannelSelector {...defaultProps}/>);
|
||||
|
||||
expect(screen.getByText('Select the channels you want to include')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render search input', () => {
|
||||
renderWithContext(<ChannelSelector {...defaultProps}/>);
|
||||
|
||||
expect(screen.getByPlaceholderText('Search and select channels')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render all channels', () => {
|
||||
renderWithContext(<ChannelSelector {...defaultProps}/>);
|
||||
|
||||
expect(screen.getByText('Town Square')).toBeInTheDocument();
|
||||
expect(screen.getByText('Off-Topic')).toBeInTheDocument();
|
||||
expect(screen.getByText('Private Channel')).toBeInTheDocument();
|
||||
expect(screen.getByText('Dev Team')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Channel Groups', () => {
|
||||
it('should show recommended channels group with unread channels', () => {
|
||||
renderWithContext(<ChannelSelector {...defaultProps}/>);
|
||||
|
||||
expect(screen.getByText('RECOMMENDED')).toBeInTheDocument();
|
||||
expect(screen.getByText('Town Square')).toBeInTheDocument();
|
||||
expect(screen.getByText('Private Channel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show all channels group', () => {
|
||||
renderWithContext(<ChannelSelector {...defaultProps}/>);
|
||||
|
||||
expect(screen.getByText('ALL CHANNELS')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should limit recommended channels to 5', () => {
|
||||
const manyUnreadChannels: Channel[] = Array.from({length: 10}, (_, i) => ({
|
||||
id: `channel${i}`,
|
||||
name: `channel-${i}`,
|
||||
display_name: `Channel ${i}`,
|
||||
type: 'O',
|
||||
create_at: i * 1000,
|
||||
update_at: i * 1000,
|
||||
delete_at: 0,
|
||||
team_id: 'team1',
|
||||
creator_id: 'user1',
|
||||
} as Channel));
|
||||
|
||||
renderWithContext(
|
||||
<ChannelSelector
|
||||
{...defaultProps}
|
||||
myChannels={manyUnreadChannels}
|
||||
unreadChannels={manyUnreadChannels}
|
||||
/>,
|
||||
);
|
||||
|
||||
const recommendedSection = screen.getByText('RECOMMENDED').parentElement;
|
||||
const channelItems = recommendedSection?.querySelectorAll('.channel-selector-item');
|
||||
expect(channelItems?.length).toBeLessThanOrEqual(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Search Functionality', () => {
|
||||
it('should filter channels by display name', async () => {
|
||||
renderWithContext(<ChannelSelector {...defaultProps}/>);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search and select channels');
|
||||
await userEvent.type(searchInput, 'Town');
|
||||
|
||||
expect(screen.getByText('Town Square')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Off-Topic')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should filter channels by channel name', async () => {
|
||||
renderWithContext(<ChannelSelector {...defaultProps}/>);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search and select channels');
|
||||
await userEvent.type(searchInput, 'off-topic');
|
||||
|
||||
expect(screen.getByText('Off-Topic')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Town Square')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should be case insensitive', async () => {
|
||||
renderWithContext(<ChannelSelector {...defaultProps}/>);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search and select channels');
|
||||
await userEvent.type(searchInput, 'PRIVATE');
|
||||
|
||||
expect(screen.getByText('Private Channel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show empty state when no channels match search', async () => {
|
||||
renderWithContext(<ChannelSelector {...defaultProps}/>);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search and select channels');
|
||||
await userEvent.type(searchInput, 'nonexistent');
|
||||
|
||||
expect(screen.getByText('No channels found')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should update recommended channels based on search', async () => {
|
||||
renderWithContext(<ChannelSelector {...defaultProps}/>);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search and select channels');
|
||||
await userEvent.type(searchInput, 'Private');
|
||||
|
||||
// Only Private Channel should be visible, and it should be in recommended
|
||||
expect(screen.getByText('Private Channel')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Town Square')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Channel Selection', () => {
|
||||
it('should show checkbox for each channel', () => {
|
||||
renderWithContext(<ChannelSelector {...defaultProps}/>);
|
||||
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
expect(checkboxes.length).toBe(mockChannels.length);
|
||||
});
|
||||
|
||||
it('should check checkbox for selected channels', () => {
|
||||
renderWithContext(
|
||||
<ChannelSelector
|
||||
{...defaultProps}
|
||||
selectedChannelIds={['channel1', 'channel2']}
|
||||
/>,
|
||||
);
|
||||
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
const checkedBoxes = checkboxes.filter((cb) => (cb as HTMLInputElement).checked);
|
||||
expect(checkedBoxes.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should call setSelectedChannelIds when channel is clicked', async () => {
|
||||
const setSelectedChannelIds = jest.fn();
|
||||
renderWithContext(
|
||||
<ChannelSelector
|
||||
{...defaultProps}
|
||||
setSelectedChannelIds={setSelectedChannelIds}
|
||||
/>,
|
||||
);
|
||||
|
||||
const channelItem = screen.getByText('Town Square').closest('.channel-selector-item');
|
||||
await userEvent.click(channelItem!);
|
||||
|
||||
expect(setSelectedChannelIds).toHaveBeenCalledWith(['channel1']);
|
||||
});
|
||||
|
||||
it('should add channel to selection when unselected channel is clicked', async () => {
|
||||
const setSelectedChannelIds = jest.fn();
|
||||
renderWithContext(
|
||||
<ChannelSelector
|
||||
{...defaultProps}
|
||||
selectedChannelIds={['channel1']}
|
||||
setSelectedChannelIds={setSelectedChannelIds}
|
||||
/>,
|
||||
);
|
||||
|
||||
const channelItem = screen.getByText('Off-Topic').closest('.channel-selector-item');
|
||||
await userEvent.click(channelItem!);
|
||||
|
||||
expect(setSelectedChannelIds).toHaveBeenCalledWith(['channel1', 'channel2']);
|
||||
});
|
||||
|
||||
it('should remove channel from selection when selected channel is clicked', async () => {
|
||||
const setSelectedChannelIds = jest.fn();
|
||||
renderWithContext(
|
||||
<ChannelSelector
|
||||
{...defaultProps}
|
||||
selectedChannelIds={['channel1', 'channel2']}
|
||||
setSelectedChannelIds={setSelectedChannelIds}
|
||||
/>,
|
||||
);
|
||||
|
||||
const channelItem = screen.getByText('Town Square').closest('.channel-selector-item');
|
||||
await userEvent.click(channelItem!);
|
||||
|
||||
expect(setSelectedChannelIds).toHaveBeenCalledWith(['channel2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Channel Icons', () => {
|
||||
it('should show globe icon for open channels', () => {
|
||||
renderWithContext(<ChannelSelector {...defaultProps}/>);
|
||||
|
||||
const townSquareItem = screen.getByText('Town Square').closest('.channel-selector-item');
|
||||
const icon = townSquareItem?.querySelector('.icon-globe');
|
||||
expect(icon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show lock icon for private channels', () => {
|
||||
renderWithContext(<ChannelSelector {...defaultProps}/>);
|
||||
|
||||
const privateChannelItem = screen.getByText('Private Channel').closest('.channel-selector-item');
|
||||
const icon = privateChannelItem?.querySelector('.icon-lock-outline');
|
||||
expect(icon).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Empty State', () => {
|
||||
it('should show empty state when no channels available', () => {
|
||||
renderWithContext(
|
||||
<ChannelSelector
|
||||
{...defaultProps}
|
||||
myChannels={[]}
|
||||
unreadChannels={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('No channels found')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useState, useMemo} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
|
||||
import type {Channel} from '@mattermost/types/channels';
|
||||
|
||||
import Input from 'components/widgets/inputs/input/input';
|
||||
|
||||
import {Constants} from 'utils/constants';
|
||||
|
||||
type Props = {
|
||||
selectedChannelIds: string[];
|
||||
setSelectedChannelIds: (ids: string[]) => void;
|
||||
myChannels: Channel[];
|
||||
unreadChannels: Channel[];
|
||||
};
|
||||
|
||||
const ChannelSelector = ({selectedChannelIds, setSelectedChannelIds, myChannels, unreadChannels}: Props) => {
|
||||
const {formatMessage} = useIntl();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const toggleChannel = (channelId: string) => {
|
||||
if (selectedChannelIds.includes(channelId)) {
|
||||
setSelectedChannelIds(selectedChannelIds.filter((id) => id !== channelId));
|
||||
} else {
|
||||
setSelectedChannelIds([...selectedChannelIds, channelId]);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredChannels = useMemo(() => {
|
||||
const term = searchTerm.toLowerCase();
|
||||
return myChannels.filter((channel) => {
|
||||
return channel.display_name.toLowerCase().includes(term) ||
|
||||
channel.name.toLowerCase().includes(term);
|
||||
});
|
||||
}, [myChannels, searchTerm]);
|
||||
|
||||
const recommendedChannels = useMemo(() => {
|
||||
// Recommended channels are unread channels that match the search
|
||||
return filteredChannels.filter((channel) =>
|
||||
unreadChannels.some((uc) => uc.id === channel.id),
|
||||
).slice(0, 5);
|
||||
}, [filteredChannels, unreadChannels]);
|
||||
|
||||
const otherChannels = useMemo(() => {
|
||||
const recommendedIds = recommendedChannels.map((c) => c.id);
|
||||
return filteredChannels.filter((channel) => !recommendedIds.includes(channel.id));
|
||||
}, [filteredChannels, recommendedChannels]);
|
||||
|
||||
const getChannelIcon = (channel: Channel) => {
|
||||
switch (channel.type) {
|
||||
case Constants.OPEN_CHANNEL:
|
||||
return 'icon-globe';
|
||||
case Constants.PRIVATE_CHANNEL:
|
||||
return 'icon-lock-outline';
|
||||
case Constants.GM_CHANNEL:
|
||||
return 'icon-account-multiple-outline';
|
||||
case Constants.DM_CHANNEL:
|
||||
return 'icon-account-outline';
|
||||
default:
|
||||
return 'icon-globe';
|
||||
}
|
||||
};
|
||||
|
||||
const renderChannelItem = (channel: Channel) => {
|
||||
const isSelected = selectedChannelIds.includes(channel.id);
|
||||
return (
|
||||
<div
|
||||
key={channel.id}
|
||||
className='channel-selector-item'
|
||||
onClick={() => toggleChannel(channel.id)}
|
||||
>
|
||||
<div className='channel-selector-checkbox'>
|
||||
<input
|
||||
type='checkbox'
|
||||
checked={isSelected}
|
||||
onChange={() => {}} // Handled by parent div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
<div className='channel-selector-channel-info'>
|
||||
<i className={`icon ${getChannelIcon(channel)}`}/>
|
||||
<span className='channel-name'>{channel.display_name}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='step-two-channel-selector'>
|
||||
<label className='form-label'>
|
||||
{formatMessage({id: 'recaps.modal.selectChannels', defaultMessage: 'Select the channels you want to include'})}
|
||||
</label>
|
||||
|
||||
<div className='channel-selector-container'>
|
||||
<div className='channel-selector-search'>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder={{id: 'recaps.modal.searchChannels', defaultMessage: 'Search and select channels'}}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
useLegend={false}
|
||||
containerClassName='channel-selector-input-container'
|
||||
inputPrefix={<i className='icon icon-magnify'/>}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='channel-selector-list'>
|
||||
{recommendedChannels.length > 0 && (
|
||||
<div className='channel-group'>
|
||||
<div className='channel-group-title'>
|
||||
{formatMessage({id: 'recaps.modal.recommended', defaultMessage: 'RECOMMENDED'})}
|
||||
</div>
|
||||
{recommendedChannels.map(renderChannelItem)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{otherChannels.length > 0 && (
|
||||
<div className='channel-group'>
|
||||
<div className='channel-group-title'>
|
||||
{formatMessage({id: 'recaps.modal.allChannels', defaultMessage: 'ALL CHANNELS'})}
|
||||
</div>
|
||||
{otherChannels.map(renderChannelItem)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredChannels.length === 0 && (
|
||||
<div className='channel-selector-empty'>
|
||||
{formatMessage({id: 'recaps.modal.noChannels', defaultMessage: 'No channels found'})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelSelector;
|
||||
|
||||
|
|
@ -0,0 +1,288 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import type {Channel} from '@mattermost/types/channels';
|
||||
|
||||
import {renderWithContext, screen} from 'tests/react_testing_utils';
|
||||
|
||||
import ChannelSummary from './channel_summary';
|
||||
|
||||
describe('ChannelSummary', () => {
|
||||
const mockChannels: Channel[] = [
|
||||
{
|
||||
id: 'channel1',
|
||||
name: 'town-square',
|
||||
display_name: 'Town Square',
|
||||
type: 'O',
|
||||
create_at: 1000,
|
||||
update_at: 1000,
|
||||
delete_at: 0,
|
||||
team_id: 'team1',
|
||||
creator_id: 'user1',
|
||||
} as Channel,
|
||||
{
|
||||
id: 'channel2',
|
||||
name: 'off-topic',
|
||||
display_name: 'Off-Topic',
|
||||
type: 'O',
|
||||
create_at: 2000,
|
||||
update_at: 2000,
|
||||
delete_at: 0,
|
||||
team_id: 'team1',
|
||||
creator_id: 'user1',
|
||||
} as Channel,
|
||||
{
|
||||
id: 'channel3',
|
||||
name: 'private-channel',
|
||||
display_name: 'Private Channel',
|
||||
type: 'P',
|
||||
create_at: 3000,
|
||||
update_at: 3000,
|
||||
delete_at: 0,
|
||||
team_id: 'team1',
|
||||
creator_id: 'user1',
|
||||
} as Channel,
|
||||
{
|
||||
id: 'channel4',
|
||||
name: 'group-message',
|
||||
display_name: 'Group Message',
|
||||
type: 'G',
|
||||
create_at: 4000,
|
||||
update_at: 4000,
|
||||
delete_at: 0,
|
||||
team_id: 'team1',
|
||||
creator_id: 'user1',
|
||||
} as Channel,
|
||||
{
|
||||
id: 'channel5',
|
||||
name: 'direct-message',
|
||||
display_name: 'Direct Message',
|
||||
type: 'D',
|
||||
create_at: 5000,
|
||||
update_at: 5000,
|
||||
delete_at: 0,
|
||||
team_id: 'team1',
|
||||
creator_id: 'user1',
|
||||
} as Channel,
|
||||
];
|
||||
|
||||
const defaultProps = {
|
||||
selectedChannelIds: ['channel1', 'channel2'],
|
||||
myChannels: mockChannels,
|
||||
};
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render the component with title', () => {
|
||||
renderWithContext(<ChannelSummary {...defaultProps}/>);
|
||||
|
||||
expect(screen.getByText('The following channels will be included in your recap')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render selected channels only', () => {
|
||||
renderWithContext(<ChannelSummary {...defaultProps}/>);
|
||||
|
||||
expect(screen.getByText('Town Square')).toBeInTheDocument();
|
||||
expect(screen.getByText('Off-Topic')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Private Channel')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render all selected channels when multiple are selected', () => {
|
||||
renderWithContext(
|
||||
<ChannelSummary
|
||||
{...defaultProps}
|
||||
selectedChannelIds={['channel1', 'channel2', 'channel3']}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Town Square')).toBeInTheDocument();
|
||||
expect(screen.getByText('Off-Topic')).toBeInTheDocument();
|
||||
expect(screen.getByText('Private Channel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render any channels when none are selected', () => {
|
||||
renderWithContext(
|
||||
<ChannelSummary
|
||||
{...defaultProps}
|
||||
selectedChannelIds={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Town Square')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Off-Topic')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Channel Icons', () => {
|
||||
it('should show globe icon for open channels', () => {
|
||||
renderWithContext(
|
||||
<ChannelSummary
|
||||
{...defaultProps}
|
||||
selectedChannelIds={['channel1']}
|
||||
/>,
|
||||
);
|
||||
|
||||
const channelItem = screen.getByText('Town Square').closest('.summary-channel-item');
|
||||
const icon = channelItem?.querySelector('.icon-globe');
|
||||
expect(icon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show lock icon for private channels', () => {
|
||||
renderWithContext(
|
||||
<ChannelSummary
|
||||
{...defaultProps}
|
||||
selectedChannelIds={['channel3']}
|
||||
/>,
|
||||
);
|
||||
|
||||
const channelItem = screen.getByText('Private Channel').closest('.summary-channel-item');
|
||||
const icon = channelItem?.querySelector('.icon-lock-outline');
|
||||
expect(icon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show group icon for group messages', () => {
|
||||
renderWithContext(
|
||||
<ChannelSummary
|
||||
{...defaultProps}
|
||||
selectedChannelIds={['channel4']}
|
||||
/>,
|
||||
);
|
||||
|
||||
const channelItem = screen.getByText('Group Message').closest('.summary-channel-item');
|
||||
const icon = channelItem?.querySelector('.icon-account-multiple-outline');
|
||||
expect(icon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show account icon for direct messages', () => {
|
||||
renderWithContext(
|
||||
<ChannelSummary
|
||||
{...defaultProps}
|
||||
selectedChannelIds={['channel5']}
|
||||
/>,
|
||||
);
|
||||
|
||||
const channelItem = screen.getByText('Direct Message').closest('.summary-channel-item');
|
||||
const icon = channelItem?.querySelector('.icon-account-outline');
|
||||
expect(icon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should default to globe icon for unknown channel types', () => {
|
||||
const unknownChannel: Channel = {
|
||||
id: 'channel6',
|
||||
name: 'unknown',
|
||||
display_name: 'Unknown Channel',
|
||||
type: 'X' as any,
|
||||
create_at: 6000,
|
||||
update_at: 6000,
|
||||
delete_at: 0,
|
||||
team_id: 'team1',
|
||||
creator_id: 'user1',
|
||||
} as Channel;
|
||||
|
||||
renderWithContext(
|
||||
<ChannelSummary
|
||||
selectedChannelIds={['channel6']}
|
||||
myChannels={[...mockChannels, unknownChannel]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const channelItem = screen.getByText('Unknown Channel').closest('.summary-channel-item');
|
||||
const icon = channelItem?.querySelector('.icon-globe');
|
||||
expect(icon).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Channel Filtering', () => {
|
||||
it('should only show channels that are both selected and in myChannels', () => {
|
||||
renderWithContext(
|
||||
<ChannelSummary
|
||||
selectedChannelIds={['channel1', 'channel999']}
|
||||
myChannels={mockChannels}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Town Square')).toBeInTheDocument();
|
||||
expect(screen.queryByText('channel999')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle empty myChannels array', () => {
|
||||
renderWithContext(
|
||||
<ChannelSummary
|
||||
selectedChannelIds={['channel1', 'channel2']}
|
||||
myChannels={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Town Square')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Off-Topic')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should maintain order of channels based on myChannels array', () => {
|
||||
renderWithContext(
|
||||
<ChannelSummary
|
||||
{...defaultProps}
|
||||
selectedChannelIds={['channel3', 'channel1', 'channel2']}
|
||||
/>,
|
||||
);
|
||||
|
||||
const channelItems = screen.getAllByRole('generic').filter(
|
||||
(el) => el.className === 'summary-channel-item',
|
||||
);
|
||||
|
||||
// Channels should appear in the order they appear in myChannels (channel1, channel2, channel3)
|
||||
expect(channelItems[0]).toHaveTextContent('Town Square');
|
||||
expect(channelItems[1]).toHaveTextContent('Off-Topic');
|
||||
expect(channelItems[2]).toHaveTextContent('Private Channel');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Display Names', () => {
|
||||
it('should display channel display_name not name', () => {
|
||||
renderWithContext(<ChannelSummary {...defaultProps}/>);
|
||||
|
||||
expect(screen.getByText('Town Square')).toBeInTheDocument();
|
||||
expect(screen.queryByText('town-square')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle duplicate channel IDs in selectedChannelIds', () => {
|
||||
renderWithContext(
|
||||
<ChannelSummary
|
||||
selectedChannelIds={['channel1', 'channel1', 'channel2']}
|
||||
myChannels={mockChannels}
|
||||
/>,
|
||||
);
|
||||
|
||||
const townSquareItems = screen.getAllByText('Town Square');
|
||||
|
||||
// Should only render each channel once even if ID appears multiple times
|
||||
expect(townSquareItems.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle very long channel names', () => {
|
||||
const longNameChannel: Channel = {
|
||||
id: 'channel-long',
|
||||
name: 'long-channel-name',
|
||||
display_name: 'A'.repeat(100),
|
||||
type: 'O',
|
||||
create_at: 6000,
|
||||
update_at: 6000,
|
||||
delete_at: 0,
|
||||
team_id: 'team1',
|
||||
creator_id: 'user1',
|
||||
} as Channel;
|
||||
|
||||
renderWithContext(
|
||||
<ChannelSummary
|
||||
selectedChannelIds={['channel-long']}
|
||||
myChannels={[longNameChannel]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('A'.repeat(100))).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
// 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 {Channel} from '@mattermost/types/channels';
|
||||
|
||||
import {Constants} from 'utils/constants';
|
||||
|
||||
type Props = {
|
||||
selectedChannelIds: string[];
|
||||
myChannels: Channel[];
|
||||
};
|
||||
|
||||
const ChannelSummary = ({selectedChannelIds, myChannels}: Props) => {
|
||||
const {formatMessage} = useIntl();
|
||||
|
||||
const selectedChannels = myChannels.filter((channel) =>
|
||||
selectedChannelIds.includes(channel.id),
|
||||
);
|
||||
|
||||
const getChannelIcon = (channel: Channel) => {
|
||||
switch (channel.type) {
|
||||
case Constants.OPEN_CHANNEL:
|
||||
return 'icon-globe';
|
||||
case Constants.PRIVATE_CHANNEL:
|
||||
return 'icon-lock-outline';
|
||||
case Constants.GM_CHANNEL:
|
||||
return 'icon-account-multiple-outline';
|
||||
case Constants.DM_CHANNEL:
|
||||
return 'icon-account-outline';
|
||||
default:
|
||||
return 'icon-globe';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='step-two-summary'>
|
||||
<label className='form-label'>
|
||||
{formatMessage({id: 'recaps.modal.summaryTitle', defaultMessage: 'The following channels will be included in your recap'})}
|
||||
</label>
|
||||
|
||||
<div className='summary-channels-list'>
|
||||
{selectedChannels.map((channel) => (
|
||||
<div
|
||||
key={channel.id}
|
||||
className='summary-channel-item'
|
||||
>
|
||||
<i className={`icon ${getChannelIcon(channel)}`}/>
|
||||
<span className='channel-name'>{channel.display_name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelSummary;
|
||||
|
||||
|
|
@ -0,0 +1,389 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
.create-recap-modal {
|
||||
.modal-content {
|
||||
width: 600px;
|
||||
min-height: 560px; // Prevents height shifting between steps (matches channel selector step)
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
min-height: 440px; // Ensures footer stays at bottom (560px modal - 60px header - 60px footer)
|
||||
}
|
||||
|
||||
.create-recap-modal-body {
|
||||
max-height: 500px;
|
||||
padding: 0px 0px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.create-recap-modal-header {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.create-recap-modal-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.create-recap-modal-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid rgba(var(--error-text-rgb), 0.24);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
background-color: rgba(var(--error-text-rgb), 0.08);
|
||||
color: var(--error-text);
|
||||
font-size: 14px;
|
||||
gap: 8px;
|
||||
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.create-recap-modal-footer {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
&-left {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.btn-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--button-bg);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
gap: 6px;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
.icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step One Styles
|
||||
.step-one {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.form-label {
|
||||
margin-bottom: 0;
|
||||
color: var(--center-channel-color);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&.name-input-group {
|
||||
margin-bottom: 28px;
|
||||
|
||||
.form-label {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
&.type-selection-group {
|
||||
.form-label {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.input-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.icon {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.64);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
height: 40px;
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
|
||||
border-radius: 4px;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--button-bg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.recap-type-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
.recap-type-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 20px 12px 12px;
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
|
||||
border-radius: 4px;
|
||||
background-color: var(--center-channel-bg);
|
||||
cursor: pointer;
|
||||
gap: 12px;
|
||||
text-align: left;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(var(--center-channel-color-rgb), 0.04);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: var(--button-bg);
|
||||
background-color: rgba(var(--button-bg-rgb), 0.04);
|
||||
|
||||
.recap-type-card-icon {
|
||||
background-color: rgba(var(--button-bg-rgb), 0.08);
|
||||
|
||||
.icon {
|
||||
color: var(--button-bg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--center-channel-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.recap-type-card-icon {
|
||||
display: flex;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 20px;
|
||||
background-color: rgba(var(--center-channel-color-rgb), 0.08);
|
||||
|
||||
.icon {
|
||||
color: rgba(var(--center-channel-color-rgb), 0.56);
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.recap-type-card-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
padding: 1px 0 3px;
|
||||
gap: 2px;
|
||||
|
||||
.recap-type-card-title {
|
||||
color: var(--center-channel-color);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.recap-type-card-description {
|
||||
color: rgba(var(--center-channel-color-rgb), 0.72);
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.selected-icon {
|
||||
color: var(--button-bg);
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step Two Channel Selector Styles
|
||||
.step-two-channel-selector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
.form-label {
|
||||
margin-bottom: 0;
|
||||
color: var(--center-channel-color);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.channel-selector-container {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
max-height: 400px;
|
||||
flex-direction: column;
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb), 0.12);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.channel-selector-search {
|
||||
position: relative;
|
||||
padding: 8px 12px 12px;
|
||||
border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.08);
|
||||
background-color: var(--center-channel-bg);
|
||||
|
||||
|
||||
.Input {
|
||||
border: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.channel-selector-list {
|
||||
flex: 1;
|
||||
background-color: var(--center-channel-bg);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.channel-group {
|
||||
.channel-group-title {
|
||||
padding: 6px 20px;
|
||||
background-color: var(--center-channel-bg);
|
||||
color: rgba(var(--center-channel-color-rgb), 0.56);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.24px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.channel-selector-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 20px 6px 16px;
|
||||
background-color: var(--center-channel-bg);
|
||||
cursor: pointer;
|
||||
gap: 8px;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(var(--center-channel-color-rgb), 0.04);
|
||||
}
|
||||
|
||||
.channel-selector-checkbox {
|
||||
padding: 4px;
|
||||
|
||||
input[type='checkbox'] {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.channel-selector-channel-info {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
padding: 2px 4px;
|
||||
gap: 8px;
|
||||
|
||||
.icon {
|
||||
color: rgba(var(--center-channel-color-rgb), 0.56);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.channel-name {
|
||||
color: var(--center-channel-color);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.channel-selector-empty {
|
||||
padding: 40px 20px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.64);
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
// Step Two Summary Styles
|
||||
.step-two-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
.form-label {
|
||||
margin-bottom: 0;
|
||||
color: var(--center-channel-color);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.summary-channels-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.summary-channel-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 16px;
|
||||
margin-left: -16px;
|
||||
gap: 8px;
|
||||
|
||||
.icon {
|
||||
color: rgba(var(--center-channel-color-rgb), 0.56);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.channel-name {
|
||||
color: var(--center-channel-color);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,306 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import {getAgents} from 'mattermost-redux/actions/agents';
|
||||
|
||||
import {renderWithContext, screen, userEvent, waitFor, waitForElementToBeRemoved} from 'tests/react_testing_utils';
|
||||
|
||||
import CreateRecapModal from './create_recap_modal';
|
||||
|
||||
jest.mock('mattermost-redux/actions/recaps', () => ({
|
||||
createRecap: jest.fn(() => ({type: 'CREATE_RECAP'})),
|
||||
}));
|
||||
|
||||
jest.mock('mattermost-redux/actions/agents', () => ({
|
||||
getAgents: jest.fn(() => ({type: 'GET_AGENTS'})),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useHistory: () => ({
|
||||
push: jest.fn(),
|
||||
}),
|
||||
useRouteMatch: () => ({
|
||||
url: '/team/test',
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('CreateRecapModal', () => {
|
||||
const defaultProps = {
|
||||
onExited: jest.fn(),
|
||||
};
|
||||
|
||||
const mockAgents = [
|
||||
{
|
||||
id: 'copilot-bot',
|
||||
displayName: 'Copilot',
|
||||
username: 'copilot',
|
||||
service_id: 'copilot-service',
|
||||
service_type: 'copilot',
|
||||
},
|
||||
{
|
||||
id: 'openai-bot',
|
||||
displayName: 'OpenAI',
|
||||
username: 'openai',
|
||||
service_id: 'openai-service',
|
||||
service_type: 'openai',
|
||||
},
|
||||
{
|
||||
id: 'azure-bot',
|
||||
displayName: 'Azure OpenAI',
|
||||
username: 'azureopenai',
|
||||
service_id: 'azure-service',
|
||||
service_type: 'azure',
|
||||
},
|
||||
];
|
||||
|
||||
const initialState = {
|
||||
entities: {
|
||||
users: {
|
||||
currentUserId: 'user1',
|
||||
profiles: {
|
||||
user1: {id: 'user1', username: 'testuser'},
|
||||
},
|
||||
},
|
||||
teams: {
|
||||
currentTeamId: 'team1',
|
||||
teams: {
|
||||
team1: {id: 'team1', name: 'test-team', display_name: 'Test Team'},
|
||||
},
|
||||
myMembers: {
|
||||
team1: {team_id: 'team1', user_id: 'user1'},
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
channels: {
|
||||
channel1: {id: 'channel1', name: 'test-channel', display_name: 'Test Channel', team_id: 'team1'},
|
||||
channel2: {id: 'channel2', name: 'another-channel', display_name: 'Another Channel', team_id: 'team1'},
|
||||
},
|
||||
channelsInTeam: {
|
||||
team1: new Set(['channel1', 'channel2']),
|
||||
},
|
||||
myMembers: {
|
||||
channel1: {channel_id: 'channel1', msg_count: 5, mention_count: 0},
|
||||
channel2: {channel_id: 'channel2', msg_count: 3, mention_count: 0},
|
||||
},
|
||||
messageCounts: {
|
||||
channel1: {total: 10, root: 10},
|
||||
channel2: {total: 5, root: 5},
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
agents: mockAgents,
|
||||
},
|
||||
preferences: {
|
||||
myPreferences: {},
|
||||
},
|
||||
general: {
|
||||
config: {},
|
||||
},
|
||||
},
|
||||
views: {
|
||||
channel: {
|
||||
postVisibility: {},
|
||||
lastChannelViewTime: {},
|
||||
loadingPosts: {},
|
||||
focusedPostId: '',
|
||||
mobileView: false,
|
||||
lastUnreadChannel: null,
|
||||
lastGetPosts: {},
|
||||
channelPrefetchStatus: {},
|
||||
toastStatus: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should render modal with header including AI agent dropdown', () => {
|
||||
renderWithContext(<CreateRecapModal {...defaultProps}/>, initialState);
|
||||
|
||||
expect(screen.getByText('Set up your recap')).toBeInTheDocument();
|
||||
expect(screen.getByText('GENERATE WITH:')).toBeInTheDocument();
|
||||
expect(screen.getByText('Copilot')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should fetch AI agents on mount', () => {
|
||||
renderWithContext(<CreateRecapModal {...defaultProps}/>, initialState);
|
||||
|
||||
expect(getAgents).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should show AI agent dropdown with default bot selected and label', () => {
|
||||
renderWithContext(<CreateRecapModal {...defaultProps}/>, initialState);
|
||||
|
||||
// The default bot (Copilot) should be displayed
|
||||
expect(screen.getByText('Copilot')).toBeInTheDocument();
|
||||
|
||||
// Label should be shown
|
||||
expect(screen.getByText('GENERATE WITH:')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should open AI agent dropdown and show bot options', async () => {
|
||||
renderWithContext(<CreateRecapModal {...defaultProps}/>, initialState);
|
||||
|
||||
const dropdownButton = screen.getByLabelText('Agent selector');
|
||||
await userEvent.click(dropdownButton);
|
||||
|
||||
expect(screen.getByText('CHOOSE A BOT')).toBeInTheDocument();
|
||||
expect(screen.getByText('Copilot (default)')).toBeInTheDocument();
|
||||
expect(screen.getByText('OpenAI')).toBeInTheDocument();
|
||||
expect(screen.getByText('Azure OpenAI')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should change selected bot when clicking on a different bot', async () => {
|
||||
renderWithContext(<CreateRecapModal {...defaultProps}/>, initialState);
|
||||
|
||||
// Wait for initial bot to be selected
|
||||
await waitFor(() => {
|
||||
const dropdownButton = screen.getByLabelText('Agent selector');
|
||||
expect(dropdownButton).toHaveTextContent('Copilot');
|
||||
});
|
||||
|
||||
// Open dropdown
|
||||
const dropdownButton = screen.getByLabelText('Agent selector');
|
||||
await userEvent.click(dropdownButton);
|
||||
|
||||
// Click on OpenAI
|
||||
const openAIOption = screen.getByText('OpenAI');
|
||||
await userEvent.click(openAIOption);
|
||||
|
||||
// Wait for menu to close
|
||||
await waitForElementToBeRemoved(() => screen.queryByText('CHOOSE A BOT'));
|
||||
|
||||
// OpenAI should now be displayed in the button
|
||||
expect(screen.getByText('OpenAI')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should disable AI agent dropdown when submitting', async () => {
|
||||
renderWithContext(<CreateRecapModal {...defaultProps}/>, initialState);
|
||||
|
||||
// Fill in the form to enable submit
|
||||
const nameInput = screen.getByPlaceholderText('Give your recap a name');
|
||||
await userEvent.type(nameInput, 'Test Recap');
|
||||
|
||||
// Select all unreads option
|
||||
const allUnreadsButton = screen.getByText('Recap all my unreads');
|
||||
await userEvent.click(allUnreadsButton);
|
||||
|
||||
// Go to next step
|
||||
const nextButton = screen.getByRole('button', {name: /next/i});
|
||||
await userEvent.click(nextButton);
|
||||
|
||||
// The dropdown button should still be enabled before submission
|
||||
const dropdownButton = screen.getByLabelText('Agent selector');
|
||||
expect(dropdownButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
test('should render step one initially', () => {
|
||||
renderWithContext(<CreateRecapModal {...defaultProps}/>, initialState);
|
||||
|
||||
expect(screen.getByText('Give your recap a name')).toBeInTheDocument();
|
||||
expect(screen.getByText('What type of recap would you like?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should show pagination dots', () => {
|
||||
renderWithContext(<CreateRecapModal {...defaultProps}/>, initialState);
|
||||
|
||||
const paginationDots = document.querySelectorAll('.pagination-dot');
|
||||
expect(paginationDots.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('should show Next button on first step', () => {
|
||||
renderWithContext(<CreateRecapModal {...defaultProps}/>, initialState);
|
||||
|
||||
expect(screen.getByRole('button', {name: /next/i})).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', {name: /previous/i})).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should disable Next button when form is incomplete', () => {
|
||||
renderWithContext(<CreateRecapModal {...defaultProps}/>, initialState);
|
||||
|
||||
const nextButton = screen.getByRole('button', {name: /next/i});
|
||||
expect(nextButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test('should enable Next button when form is complete', async () => {
|
||||
renderWithContext(<CreateRecapModal {...defaultProps}/>, initialState);
|
||||
|
||||
// Wait for bot to be selected automatically
|
||||
await waitFor(() => {
|
||||
const dropdownButton = screen.getByLabelText('Agent selector');
|
||||
expect(dropdownButton).toHaveTextContent('Copilot');
|
||||
});
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('Give your recap a name');
|
||||
await userEvent.type(nameInput, 'Test Recap');
|
||||
|
||||
const allUnreadsButton = screen.getByText('Recap all my unreads');
|
||||
await userEvent.click(allUnreadsButton);
|
||||
|
||||
const nextButton = screen.getByRole('button', {name: /next/i});
|
||||
await waitFor(() => expect(nextButton).not.toBeDisabled());
|
||||
});
|
||||
|
||||
test('should show Previous button on later steps', async () => {
|
||||
renderWithContext(<CreateRecapModal {...defaultProps}/>, initialState);
|
||||
|
||||
// Fill form to enable navigation
|
||||
await waitFor(() => {
|
||||
const dropdownButton = screen.getByLabelText('Agent selector');
|
||||
expect(dropdownButton).toHaveTextContent('Copilot');
|
||||
});
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('Give your recap a name');
|
||||
await userEvent.type(nameInput, 'Test Recap');
|
||||
|
||||
// Select "Recap selected channels" to go to step 2
|
||||
const selectedChannelsButton = screen.getByText('Recap selected channels');
|
||||
await userEvent.click(selectedChannelsButton);
|
||||
|
||||
const nextButton = screen.getByRole('button', {name: /next/i});
|
||||
await userEvent.click(nextButton);
|
||||
|
||||
// Now we should be on step 2 and see the Previous button
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', {name: /previous/i})).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('should maintain selected bot across step navigation', async () => {
|
||||
renderWithContext(<CreateRecapModal {...defaultProps}/>, initialState);
|
||||
|
||||
// Wait for initial bot to be selected
|
||||
await waitFor(() => {
|
||||
const dropdownButton = screen.getByLabelText('Agent selector');
|
||||
expect(dropdownButton).toHaveTextContent('Copilot');
|
||||
});
|
||||
|
||||
// Change bot to OpenAI
|
||||
const dropdownButton = screen.getByLabelText('Agent selector');
|
||||
await userEvent.click(dropdownButton);
|
||||
const openAIOption = screen.getByText('OpenAI');
|
||||
await userEvent.click(openAIOption);
|
||||
|
||||
// Wait for menu to close
|
||||
await waitForElementToBeRemoved(() => screen.queryByText('CHOOSE A BOT'));
|
||||
|
||||
// Fill form and go to next step
|
||||
const nameInput = screen.getByPlaceholderText('Give your recap a name');
|
||||
await userEvent.type(nameInput, 'Test Recap');
|
||||
|
||||
const allUnreadsButton = screen.getByText('Recap all my unreads');
|
||||
await userEvent.click(allUnreadsButton);
|
||||
|
||||
const nextButton = screen.getByRole('button', {name: /next/i});
|
||||
await userEvent.click(nextButton);
|
||||
|
||||
// OpenAI should still be selected in the header
|
||||
expect(screen.getByText('OpenAI')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useState, useCallback, useEffect} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {useDispatch, useSelector} from 'react-redux';
|
||||
import {useHistory, useRouteMatch} from 'react-router-dom';
|
||||
|
||||
import {ChevronLeftIcon, ChevronRightIcon} from '@mattermost/compass-icons/components';
|
||||
import {GenericModal} from '@mattermost/components';
|
||||
import type {Channel} from '@mattermost/types/channels';
|
||||
|
||||
import {getAgents} from 'mattermost-redux/actions/agents';
|
||||
import {createRecap} from 'mattermost-redux/actions/recaps';
|
||||
import {getAgents as getAgentsSelector} from 'mattermost-redux/selectors/entities/agents';
|
||||
import {getMyChannels, getUnreadChannelIds} from 'mattermost-redux/selectors/entities/channels';
|
||||
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
|
||||
|
||||
import {AgentDropdown} from 'components/common/agents';
|
||||
import PaginationDots from 'components/common/pagination_dots';
|
||||
|
||||
import ChannelSelector from './channel_selector';
|
||||
import ChannelSummary from './channel_summary';
|
||||
import RecapConfiguration from './recap_configuration';
|
||||
|
||||
import './create_recap_modal.scss';
|
||||
|
||||
type Props = {
|
||||
onExited: () => void;
|
||||
};
|
||||
|
||||
type RecapType = 'selected' | 'all_unreads';
|
||||
|
||||
const CreateRecapModal = ({onExited}: Props) => {
|
||||
const {formatMessage} = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const history = useHistory();
|
||||
const {url} = useRouteMatch();
|
||||
const currentUserId = useSelector(getCurrentUserId);
|
||||
const myChannels = useSelector(getMyChannels);
|
||||
const unreadChannelIds = useSelector(getUnreadChannelIds);
|
||||
const agents = useSelector(getAgentsSelector);
|
||||
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [recapName, setRecapName] = useState('');
|
||||
const [recapType, setRecapType] = useState<RecapType | null>(null);
|
||||
const [selectedChannelIds, setSelectedChannelIds] = useState<string[]>([]);
|
||||
const [selectedBotId, setSelectedBotId] = useState<string>('');
|
||||
const [isAgentMenuOpen, setIsAgentMenuOpen] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fetch AI agents on mount
|
||||
useEffect(() => {
|
||||
dispatch(getAgents());
|
||||
}, [dispatch]);
|
||||
|
||||
// Set default bot when agents are loaded
|
||||
useEffect(() => {
|
||||
if (agents.length > 0 && !selectedBotId) {
|
||||
setSelectedBotId(agents[0].id);
|
||||
}
|
||||
}, [agents, selectedBotId]);
|
||||
|
||||
// Get unread channels
|
||||
const unreadChannels = myChannels.filter((channel: Channel) =>
|
||||
unreadChannelIds.includes(channel.id),
|
||||
);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
if (currentStep === 1) {
|
||||
if (recapType === 'all_unreads') {
|
||||
// For all unreads, skip channel selector and go to summary
|
||||
setSelectedChannelIds(unreadChannels.map((c: Channel) => c.id));
|
||||
setCurrentStep(3); // Go to summary
|
||||
} else {
|
||||
// For selected channels, go to channel selector
|
||||
setCurrentStep(2);
|
||||
}
|
||||
} else if (currentStep === 2) {
|
||||
// From channel selector to summary
|
||||
setCurrentStep(3);
|
||||
}
|
||||
}, [currentStep, recapType, unreadChannels]);
|
||||
|
||||
const handlePrevious = useCallback(() => {
|
||||
if (currentStep === 3 && recapType === 'all_unreads') {
|
||||
// From summary back to step 1 if all unreads
|
||||
setCurrentStep(1);
|
||||
} else if (currentStep > 1) {
|
||||
setCurrentStep(currentStep - 1);
|
||||
}
|
||||
}, [currentStep, recapType]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (selectedChannelIds.length === 0) {
|
||||
setError(formatMessage({id: 'recaps.modal.error.noChannels', defaultMessage: 'Please select at least one channel.'}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedBotId) {
|
||||
setError(formatMessage({id: 'recaps.modal.error.noBot', defaultMessage: 'Please select an AI agent.'}));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await dispatch(createRecap(recapName, selectedChannelIds, selectedBotId));
|
||||
onExited();
|
||||
history.push(`${url}/recaps`);
|
||||
} catch (err) {
|
||||
setError(formatMessage({id: 'recaps.modal.error.createFailed', defaultMessage: 'Failed to create recap. Please try again.'}));
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [selectedChannelIds, currentUserId, selectedBotId, dispatch, onExited, history, url, formatMessage, recapName]);
|
||||
|
||||
const canProceed = () => {
|
||||
if (currentStep === 1) {
|
||||
return recapName.trim().length > 0 && recapType !== null && selectedBotId.length > 0;
|
||||
} else if (currentStep === 2) {
|
||||
return selectedChannelIds.length > 0;
|
||||
} else if (currentStep === 3) {
|
||||
return selectedChannelIds.length > 0 && selectedBotId.length > 0;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const getTotalSteps = () => {
|
||||
return recapType === 'all_unreads' ? 2 : 3;
|
||||
};
|
||||
|
||||
const getActualStep = () => {
|
||||
if (recapType === 'all_unreads') {
|
||||
return currentStep === 1 ? 1 : 2;
|
||||
}
|
||||
return currentStep;
|
||||
};
|
||||
|
||||
const renderStep = () => {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return (
|
||||
<RecapConfiguration
|
||||
recapName={recapName}
|
||||
setRecapName={setRecapName}
|
||||
recapType={recapType}
|
||||
setRecapType={setRecapType}
|
||||
unreadChannels={unreadChannels}
|
||||
/>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<ChannelSelector
|
||||
selectedChannelIds={selectedChannelIds}
|
||||
setSelectedChannelIds={setSelectedChannelIds}
|
||||
myChannels={myChannels}
|
||||
unreadChannels={unreadChannels}
|
||||
/>
|
||||
);
|
||||
case 3:
|
||||
return (
|
||||
<ChannelSummary
|
||||
selectedChannelIds={selectedChannelIds}
|
||||
myChannels={myChannels}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const confirmButtonText = currentStep === 3 ? formatMessage({id: 'recaps.modal.startRecap', defaultMessage: 'Start recap'}) : formatMessage({id: 'generic_modal.next', defaultMessage: 'Next'});
|
||||
|
||||
const handleBotSelect = useCallback((botId: string) => {
|
||||
setSelectedBotId(botId);
|
||||
}, []);
|
||||
|
||||
const handleAgentMenuToggle = useCallback((isOpen: boolean) => {
|
||||
setIsAgentMenuOpen(isOpen);
|
||||
}, []);
|
||||
|
||||
const headerText = (
|
||||
<div className='create-recap-modal-header'>
|
||||
<span>{formatMessage({id: 'recaps.modal.title', defaultMessage: 'Set up your recap'})}</span>
|
||||
<div className='create-recap-modal-header-actions'>
|
||||
<AgentDropdown
|
||||
showLabel={true}
|
||||
selectedBotId={selectedBotId}
|
||||
onBotSelect={handleBotSelect}
|
||||
bots={agents}
|
||||
defaultBotId={agents.length > 0 ? agents[0].id : undefined}
|
||||
disabled={isSubmitting}
|
||||
onMenuToggle={handleAgentMenuToggle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const handleConfirmClick = useCallback(() => {
|
||||
if (currentStep === 3) {
|
||||
handleSubmit();
|
||||
return;
|
||||
}
|
||||
handleNext();
|
||||
}, [currentStep, handleSubmit, handleNext]);
|
||||
|
||||
const footerContent = (
|
||||
<div className='create-recap-modal-footer'>
|
||||
<div className='create-recap-modal-footer-left'>
|
||||
<PaginationDots
|
||||
totalSteps={getTotalSteps()}
|
||||
currentStep={getActualStep()}
|
||||
/>
|
||||
</div>
|
||||
<div className='create-recap-modal-footer-actions'>
|
||||
{currentStep > 1 && (
|
||||
<button
|
||||
type='button'
|
||||
className='GenericModal__button btn btn-tertiary'
|
||||
onClick={handlePrevious}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<ChevronLeftIcon size={16}/>
|
||||
{formatMessage({id: 'generic_modal.previous', defaultMessage: 'Previous'})}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type='submit'
|
||||
className='GenericModal__button btn btn-primary'
|
||||
onClick={handleConfirmClick}
|
||||
disabled={!canProceed() || isSubmitting}
|
||||
>
|
||||
{confirmButtonText}
|
||||
{currentStep < 3 && <ChevronRightIcon size={16}/>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<GenericModal
|
||||
className='create-recap-modal'
|
||||
id='createRecapModal'
|
||||
onExited={onExited}
|
||||
modalHeaderText={headerText}
|
||||
enforceFocus={!isAgentMenuOpen}
|
||||
compassDesign={true}
|
||||
footerDivider={false}
|
||||
footerContent={footerContent}
|
||||
>
|
||||
<div className='create-recap-modal-body'>
|
||||
{error && (
|
||||
<div className='create-recap-modal-error'>
|
||||
<i className='icon icon-alert-circle-outline'/>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
{renderStep()}
|
||||
</div>
|
||||
</GenericModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateRecapModal;
|
||||
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
export {default} from './create_recap_modal';
|
||||
|
||||
|
|
@ -0,0 +1,250 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import type {Channel} from '@mattermost/types/channels';
|
||||
|
||||
import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils';
|
||||
|
||||
import RecapConfiguration from './recap_configuration';
|
||||
|
||||
describe('RecapConfiguration', () => {
|
||||
const mockUnreadChannels: Channel[] = [
|
||||
{
|
||||
id: 'channel1',
|
||||
name: 'channel-1',
|
||||
display_name: 'Channel 1',
|
||||
type: 'O',
|
||||
} as Channel,
|
||||
{
|
||||
id: 'channel2',
|
||||
name: 'channel-2',
|
||||
display_name: 'Channel 2',
|
||||
type: 'P',
|
||||
} as Channel,
|
||||
];
|
||||
|
||||
const defaultProps = {
|
||||
recapName: '',
|
||||
setRecapName: jest.fn(),
|
||||
recapType: null as 'selected' | 'all_unreads' | null,
|
||||
setRecapType: jest.fn(),
|
||||
unreadChannels: mockUnreadChannels,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Recap Name Input', () => {
|
||||
it('should render name input field', () => {
|
||||
renderWithContext(<RecapConfiguration {...defaultProps}/>);
|
||||
|
||||
expect(screen.getByPlaceholderText('Give your recap a name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display current recap name value', () => {
|
||||
renderWithContext(
|
||||
<RecapConfiguration
|
||||
{...defaultProps}
|
||||
recapName='My Test Recap'
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText('Give your recap a name') as HTMLInputElement;
|
||||
expect(input.value).toBe('My Test Recap');
|
||||
});
|
||||
|
||||
it('should call setRecapName when name is changed', async () => {
|
||||
const setRecapName = jest.fn();
|
||||
renderWithContext(
|
||||
<RecapConfiguration
|
||||
{...defaultProps}
|
||||
setRecapName={setRecapName}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText('Give your recap a name');
|
||||
await userEvent.type(input, 'New Recap');
|
||||
|
||||
expect(setRecapName).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should enforce maxLength of 100 characters', () => {
|
||||
renderWithContext(<RecapConfiguration {...defaultProps}/>);
|
||||
|
||||
const input = screen.getByPlaceholderText('Give your recap a name') as HTMLInputElement;
|
||||
expect(input.maxLength).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Recap Type Selection', () => {
|
||||
it('should render both recap type options', () => {
|
||||
renderWithContext(<RecapConfiguration {...defaultProps}/>);
|
||||
|
||||
expect(screen.getByText('Recap selected channels')).toBeInTheDocument();
|
||||
expect(screen.getByText('Recap all my unreads')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call setRecapType when selected channels option is clicked', async () => {
|
||||
const setRecapType = jest.fn();
|
||||
renderWithContext(
|
||||
<RecapConfiguration
|
||||
{...defaultProps}
|
||||
setRecapType={setRecapType}
|
||||
/>,
|
||||
);
|
||||
|
||||
const selectedChannelsButton = screen.getByText('Recap selected channels').closest('button');
|
||||
await userEvent.click(selectedChannelsButton!);
|
||||
|
||||
expect(setRecapType).toHaveBeenCalledWith('selected');
|
||||
});
|
||||
|
||||
it('should call setRecapType when all unreads option is clicked', async () => {
|
||||
const setRecapType = jest.fn();
|
||||
renderWithContext(
|
||||
<RecapConfiguration
|
||||
{...defaultProps}
|
||||
setRecapType={setRecapType}
|
||||
/>,
|
||||
);
|
||||
|
||||
const allUnreadsButton = screen.getByText('Recap all my unreads').closest('button');
|
||||
await userEvent.click(allUnreadsButton!);
|
||||
|
||||
expect(setRecapType).toHaveBeenCalledWith('all_unreads');
|
||||
});
|
||||
|
||||
it('should show selected state for selected channels option', () => {
|
||||
renderWithContext(
|
||||
<RecapConfiguration
|
||||
{...defaultProps}
|
||||
recapType='selected'
|
||||
/>,
|
||||
);
|
||||
|
||||
const selectedButton = screen.getByText('Recap selected channels').closest('button');
|
||||
expect(selectedButton).toHaveClass('selected');
|
||||
});
|
||||
|
||||
it('should show selected state for all unreads option', () => {
|
||||
renderWithContext(
|
||||
<RecapConfiguration
|
||||
{...defaultProps}
|
||||
recapType='all_unreads'
|
||||
/>,
|
||||
);
|
||||
|
||||
const allUnreadsButton = screen.getByText('Recap all my unreads').closest('button');
|
||||
expect(allUnreadsButton).toHaveClass('selected');
|
||||
});
|
||||
|
||||
it('should show check icon when selected channels is selected', () => {
|
||||
renderWithContext(
|
||||
<RecapConfiguration
|
||||
{...defaultProps}
|
||||
recapType='selected'
|
||||
/>,
|
||||
);
|
||||
|
||||
const selectedButton = screen.getByText('Recap selected channels').closest('button');
|
||||
const checkIcon = selectedButton?.querySelector('.selected-icon');
|
||||
expect(checkIcon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show check icon when all unreads is selected', () => {
|
||||
renderWithContext(
|
||||
<RecapConfiguration
|
||||
{...defaultProps}
|
||||
recapType='all_unreads'
|
||||
/>,
|
||||
);
|
||||
|
||||
const allUnreadsButton = screen.getByText('Recap all my unreads').closest('button');
|
||||
const checkIcon = allUnreadsButton?.querySelector('.selected-icon');
|
||||
expect(checkIcon).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unread Channels Handling', () => {
|
||||
it('should disable all unreads option when no unread channels', () => {
|
||||
renderWithContext(
|
||||
<RecapConfiguration
|
||||
{...defaultProps}
|
||||
unreadChannels={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const allUnreadsButton = screen.getByText('Recap all my unreads').closest('button');
|
||||
expect(allUnreadsButton).toBeDisabled();
|
||||
expect(allUnreadsButton).toHaveClass('disabled');
|
||||
});
|
||||
|
||||
it('should enable all unreads option when unread channels exist', () => {
|
||||
renderWithContext(<RecapConfiguration {...defaultProps}/>);
|
||||
|
||||
const allUnreadsButton = screen.getByText('Recap all my unreads').closest('button');
|
||||
expect(allUnreadsButton).not.toBeDisabled();
|
||||
expect(allUnreadsButton).not.toHaveClass('disabled');
|
||||
});
|
||||
|
||||
it('should not call setRecapType when all unreads is clicked with no unread channels', async () => {
|
||||
const setRecapType = jest.fn();
|
||||
renderWithContext(
|
||||
<RecapConfiguration
|
||||
{...defaultProps}
|
||||
setRecapType={setRecapType}
|
||||
unreadChannels={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const allUnreadsButton = screen.getByText('Recap all my unreads').closest('button');
|
||||
await userEvent.click(allUnreadsButton!);
|
||||
|
||||
expect(setRecapType).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show tooltip when all unreads option is disabled', () => {
|
||||
renderWithContext(
|
||||
<RecapConfiguration
|
||||
{...defaultProps}
|
||||
unreadChannels={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
// The WithTooltip component wraps the button when there are no unreads
|
||||
expect(screen.getByText('Recap all my unreads')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Labels', () => {
|
||||
it('should display name label', () => {
|
||||
renderWithContext(<RecapConfiguration {...defaultProps}/>);
|
||||
|
||||
expect(screen.getByText('Give your recap a name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display type selection label', () => {
|
||||
renderWithContext(<RecapConfiguration {...defaultProps}/>);
|
||||
|
||||
expect(screen.getByText('What type of recap would you like?')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Type Descriptions', () => {
|
||||
it('should show description for selected channels option', () => {
|
||||
renderWithContext(<RecapConfiguration {...defaultProps}/>);
|
||||
|
||||
expect(screen.getByText('Choose the channels you would like included in your recap')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show description for all unreads option', () => {
|
||||
renderWithContext(<RecapConfiguration {...defaultProps}/>);
|
||||
|
||||
expect(screen.getByText('Copilot will create a recap of all unreads across your channels.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {useIntl, FormattedMessage} from 'react-intl';
|
||||
|
||||
import {ProductChannelsIcon, LightningBoltOutlineIcon, CheckCircleIcon} from '@mattermost/compass-icons/components';
|
||||
import type {Channel} from '@mattermost/types/channels';
|
||||
|
||||
import WithTooltip from 'components/with_tooltip';
|
||||
|
||||
const RECAP_NAME_MAX_LENGTH = 100;
|
||||
|
||||
type Props = {
|
||||
recapName: string;
|
||||
setRecapName: (name: string) => void;
|
||||
recapType: 'selected' | 'all_unreads' | null;
|
||||
setRecapType: (type: 'selected' | 'all_unreads') => void;
|
||||
unreadChannels: Channel[];
|
||||
};
|
||||
|
||||
const RecapConfiguration = ({recapName, setRecapName, recapType, setRecapType, unreadChannels}: Props) => {
|
||||
const {formatMessage} = useIntl();
|
||||
const hasUnreadChannels = unreadChannels.length > 0;
|
||||
|
||||
const allUnreadsButton = (
|
||||
<button
|
||||
type='button'
|
||||
className={`recap-type-card ${recapType === 'all_unreads' ? 'selected' : ''} ${hasUnreadChannels ? '' : 'disabled'}`}
|
||||
onClick={() => hasUnreadChannels && setRecapType('all_unreads')}
|
||||
disabled={!hasUnreadChannels}
|
||||
>
|
||||
<div className='recap-type-card-icon'>
|
||||
<LightningBoltOutlineIcon size={24}/>
|
||||
</div>
|
||||
<div className='recap-type-card-content'>
|
||||
<div className='recap-type-card-title'>
|
||||
<FormattedMessage
|
||||
id='recaps.modal.allUnreads'
|
||||
defaultMessage='Recap all my unreads'
|
||||
/>
|
||||
</div>
|
||||
<div className='recap-type-card-description'>
|
||||
<FormattedMessage
|
||||
id='recaps.modal.allUnreadsDesc'
|
||||
defaultMessage='Copilot will create a recap of all unreads across your channels.'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{recapType === 'all_unreads' && <CheckCircleIcon className='selected-icon'/>}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='step-one'>
|
||||
<div className='form-group name-input-group'>
|
||||
<label
|
||||
className='form-label'
|
||||
htmlFor='recap-name-input'
|
||||
>
|
||||
<FormattedMessage
|
||||
id='recaps.modal.nameLabel'
|
||||
defaultMessage='Give your recap a name'
|
||||
/>
|
||||
</label>
|
||||
<div className='input-container'>
|
||||
<input
|
||||
id='recap-name-input'
|
||||
type='text'
|
||||
className='form-control'
|
||||
placeholder={formatMessage({id: 'recaps.modal.namePlaceholder', defaultMessage: 'Give your recap a name'})}
|
||||
value={recapName}
|
||||
onChange={(e) => setRecapName(e.target.value)}
|
||||
maxLength={RECAP_NAME_MAX_LENGTH}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='form-group type-selection-group'>
|
||||
<div
|
||||
className='form-label'
|
||||
id='recap-type-label'
|
||||
>
|
||||
<FormattedMessage
|
||||
id='recaps.modal.typeLabel'
|
||||
defaultMessage='What type of recap would you like?'
|
||||
/>
|
||||
</div>
|
||||
<div className='recap-type-options'>
|
||||
<button
|
||||
type='button'
|
||||
className={`recap-type-card ${recapType === 'selected' ? 'selected' : ''}`}
|
||||
onClick={() => setRecapType('selected')}
|
||||
>
|
||||
<div className='recap-type-card-icon'>
|
||||
<ProductChannelsIcon size={24}/>
|
||||
</div>
|
||||
<div className='recap-type-card-content'>
|
||||
<div className='recap-type-card-title'>
|
||||
<FormattedMessage
|
||||
id='recaps.modal.selectedChannels'
|
||||
defaultMessage='Recap selected channels'
|
||||
/>
|
||||
</div>
|
||||
<div className='recap-type-card-description'>
|
||||
<FormattedMessage
|
||||
id='recaps.modal.selectedChannelsDesc'
|
||||
defaultMessage='Choose the channels you would like included in your recap'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{recapType === 'selected' && <CheckCircleIcon className='selected-icon'/>}
|
||||
</button>
|
||||
|
||||
{hasUnreadChannels ? allUnreadsButton : (
|
||||
<WithTooltip
|
||||
title={formatMessage({id: 'recaps.modal.noUnreadsAvailable', defaultMessage: 'No unread channels available'})}
|
||||
hint={formatMessage({id: 'recaps.modal.noUnreadsAvailableHint', defaultMessage: 'You currently have no unread messages in any channels'})}
|
||||
>
|
||||
{allUnreadsButton}
|
||||
</WithTooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecapConfiguration;
|
||||
|
||||
5
webapp/channels/src/components/recaps/index.ts
Normal file
5
webapp/channels/src/components/recaps/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
export {default} from './recaps';
|
||||
|
||||
|
|
@ -0,0 +1,323 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import type {RecapChannel} from '@mattermost/types/recaps';
|
||||
|
||||
import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils';
|
||||
import {TestHelper} from 'utils/test_helper';
|
||||
|
||||
import RecapChannelCard from './recap_channel_card';
|
||||
|
||||
const mockDispatch = jest.fn();
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useDispatch: () => mockDispatch,
|
||||
}));
|
||||
|
||||
jest.mock('mattermost-redux/actions/channels', () => ({
|
||||
readMultipleChannels: jest.fn((channelIds) => ({type: 'READ_MULTIPLE_CHANNELS', channelIds})),
|
||||
}));
|
||||
|
||||
jest.mock('actions/views/channel', () => ({
|
||||
switchToChannel: jest.fn((channel) => ({type: 'SWITCH_TO_CHANNEL', channel})),
|
||||
}));
|
||||
|
||||
jest.mock('components/external_link', () => {
|
||||
return function ExternalLink({children, href}: {children: React.ReactNode; href: string}) {
|
||||
return <a href={href}>{children}</a>;
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('./recap_menu', () => {
|
||||
return function RecapMenu({actions}: {actions: any[]}) {
|
||||
return (
|
||||
<div data-testid='recap-menu'>
|
||||
{actions.map((action) => (
|
||||
<button
|
||||
key={action.id}
|
||||
onClick={action.onClick}
|
||||
data-testid={`menu-action-${action.id}`}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('./recap_text_formatter', () => {
|
||||
return function RecapTextFormatter({text}: {text: string}) {
|
||||
return <div data-testid='recap-text'>{text}</div>;
|
||||
};
|
||||
});
|
||||
|
||||
describe('RecapChannelCard', () => {
|
||||
const mockChannel = TestHelper.getChannelMock({
|
||||
id: 'channel1',
|
||||
name: 'test-channel',
|
||||
display_name: 'Test Channel',
|
||||
});
|
||||
|
||||
const baseState = {
|
||||
entities: {
|
||||
channels: {
|
||||
channels: {
|
||||
channel1: mockChannel,
|
||||
},
|
||||
},
|
||||
teams: {
|
||||
currentTeamId: 'team1',
|
||||
teams: {
|
||||
team1: TestHelper.getTeamMock({
|
||||
id: 'team1',
|
||||
name: 'test-team',
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockRecapChannel: RecapChannel = {
|
||||
id: 'recap_channel1',
|
||||
recap_id: 'recap1',
|
||||
channel_id: 'channel1',
|
||||
channel_name: 'test-channel',
|
||||
highlights: ['Important update from @john', 'New feature released'],
|
||||
action_items: ['Review PR #123', 'Schedule meeting'],
|
||||
source_post_ids: ['post1', 'post2'],
|
||||
create_at: 1000,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should render channel name', () => {
|
||||
renderWithContext(
|
||||
<RecapChannelCard channel={mockRecapChannel}/>,
|
||||
baseState,
|
||||
);
|
||||
|
||||
expect(screen.getByText('test-channel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render highlights section', () => {
|
||||
renderWithContext(
|
||||
<RecapChannelCard channel={mockRecapChannel}/>,
|
||||
baseState,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Highlights')).toBeInTheDocument();
|
||||
expect(screen.getByText('Important update from @john')).toBeInTheDocument();
|
||||
expect(screen.getByText('New feature released')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render action items section', () => {
|
||||
renderWithContext(
|
||||
<RecapChannelCard channel={mockRecapChannel}/>,
|
||||
baseState,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Action items:')).toBeInTheDocument();
|
||||
expect(screen.getByText('Review PR #123')).toBeInTheDocument();
|
||||
expect(screen.getByText('Schedule meeting')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should not render when no highlights or action items', () => {
|
||||
const emptyChannel: RecapChannel = {
|
||||
...mockRecapChannel,
|
||||
highlights: [],
|
||||
action_items: [],
|
||||
};
|
||||
|
||||
const {container} = renderWithContext(
|
||||
<RecapChannelCard channel={emptyChannel}/>,
|
||||
baseState,
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
test('should toggle collapse state when collapse button clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithContext(
|
||||
<RecapChannelCard channel={mockRecapChannel}/>,
|
||||
baseState,
|
||||
);
|
||||
|
||||
// Initially expanded, content should be visible
|
||||
expect(screen.getByText('Highlights')).toBeInTheDocument();
|
||||
|
||||
// Find and click the collapse button
|
||||
const collapseButton = screen.getByRole('button', {name: ''});
|
||||
await user.click(collapseButton);
|
||||
|
||||
// Content should be hidden after collapse
|
||||
expect(screen.queryByText('Highlights')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should dispatch switchToChannel when channel name clicked', async () => {
|
||||
const {switchToChannel} = require('actions/views/channel');
|
||||
const user = userEvent.setup();
|
||||
renderWithContext(
|
||||
<RecapChannelCard channel={mockRecapChannel}/>,
|
||||
baseState,
|
||||
);
|
||||
|
||||
const channelButton = screen.getByText('test-channel');
|
||||
await user.click(channelButton);
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalled();
|
||||
expect(switchToChannel).toHaveBeenCalledWith(mockChannel);
|
||||
});
|
||||
|
||||
test('should render menu with actions', () => {
|
||||
renderWithContext(
|
||||
<RecapChannelCard channel={mockRecapChannel}/>,
|
||||
baseState,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('recap-menu')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should call mark channel as read action', async () => {
|
||||
const {readMultipleChannels} = require('mattermost-redux/actions/channels');
|
||||
const user = userEvent.setup();
|
||||
renderWithContext(
|
||||
<RecapChannelCard channel={mockRecapChannel}/>,
|
||||
baseState,
|
||||
);
|
||||
|
||||
const markReadButton = screen.getByTestId('menu-action-mark-channel-read');
|
||||
await user.click(markReadButton);
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalled();
|
||||
expect(readMultipleChannels).toHaveBeenCalledWith(['channel1']);
|
||||
});
|
||||
|
||||
test('should call open channel action', async () => {
|
||||
const {switchToChannel} = require('actions/views/channel');
|
||||
const user = userEvent.setup();
|
||||
renderWithContext(
|
||||
<RecapChannelCard channel={mockRecapChannel}/>,
|
||||
baseState,
|
||||
);
|
||||
|
||||
const openChannelButton = screen.getByTestId('menu-action-open-channel');
|
||||
await user.click(openChannelButton);
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalled();
|
||||
expect(switchToChannel).toHaveBeenCalledWith(mockChannel);
|
||||
});
|
||||
|
||||
test('should parse permalinks from highlights', () => {
|
||||
const channelWithPermalinks: RecapChannel = {
|
||||
...mockRecapChannel,
|
||||
highlights: ['Update from @john [PERMALINK:https://example.com/post1]'],
|
||||
};
|
||||
|
||||
const {container} = renderWithContext(
|
||||
<RecapChannelCard channel={channelWithPermalinks}/>,
|
||||
baseState,
|
||||
);
|
||||
|
||||
// Check that the text is rendered without the permalink tag
|
||||
expect(screen.getByText('Update from @john')).toBeInTheDocument();
|
||||
|
||||
// Check that a link is rendered
|
||||
const link = container.querySelector('a[href="https://example.com/post1"]');
|
||||
expect(link).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should parse permalinks from action items', () => {
|
||||
const channelWithPermalinks: RecapChannel = {
|
||||
...mockRecapChannel,
|
||||
action_items: ['Review PR [PERMALINK:https://example.com/pr123]'],
|
||||
};
|
||||
|
||||
const {container} = renderWithContext(
|
||||
<RecapChannelCard channel={channelWithPermalinks}/>,
|
||||
baseState,
|
||||
);
|
||||
|
||||
// Check that the text is rendered without the permalink tag
|
||||
expect(screen.getByText('Review PR')).toBeInTheDocument();
|
||||
|
||||
// Check that a link is rendered
|
||||
const link = container.querySelector('a[href="https://example.com/pr123"]');
|
||||
expect(link).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render badges for items without permalinks', () => {
|
||||
const {container} = renderWithContext(
|
||||
<RecapChannelCard channel={mockRecapChannel}/>,
|
||||
baseState,
|
||||
);
|
||||
|
||||
// Check for badge elements
|
||||
const badges = container.querySelectorAll('.recap-item-badge');
|
||||
expect(badges.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('should disable channel button when channel object not found', () => {
|
||||
const stateWithoutChannel = {
|
||||
entities: {
|
||||
channels: {
|
||||
channels: {},
|
||||
},
|
||||
teams: {
|
||||
currentTeamId: 'team1',
|
||||
teams: {
|
||||
team1: TestHelper.getTeamMock({
|
||||
id: 'team1',
|
||||
name: 'test-team',
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
renderWithContext(
|
||||
<RecapChannelCard channel={mockRecapChannel}/>,
|
||||
stateWithoutChannel,
|
||||
);
|
||||
|
||||
const channelButton = screen.getByText('test-channel');
|
||||
expect(channelButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test('should render only highlights when action items are empty', () => {
|
||||
const channelWithOnlyHighlights: RecapChannel = {
|
||||
...mockRecapChannel,
|
||||
action_items: [],
|
||||
};
|
||||
|
||||
renderWithContext(
|
||||
<RecapChannelCard channel={channelWithOnlyHighlights}/>,
|
||||
baseState,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Highlights')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Action items:')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render only action items when highlights are empty', () => {
|
||||
const channelWithOnlyActions: RecapChannel = {
|
||||
...mockRecapChannel,
|
||||
highlights: [],
|
||||
};
|
||||
|
||||
renderWithContext(
|
||||
<RecapChannelCard channel={channelWithOnlyActions}/>,
|
||||
baseState,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Highlights')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Action items:')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
224
webapp/channels/src/components/recaps/recap_channel_card.tsx
Normal file
224
webapp/channels/src/components/recaps/recap_channel_card.tsx
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useState, useCallback, useMemo} from 'react';
|
||||
import {useIntl, FormattedMessage} from 'react-intl';
|
||||
import {useDispatch, useSelector} from 'react-redux';
|
||||
|
||||
import {CheckAllIcon, ArrowExpandIcon, ChevronDownIcon, ChevronUpIcon} from '@mattermost/compass-icons/components';
|
||||
import type {RecapChannel} from '@mattermost/types/recaps';
|
||||
|
||||
import {readMultipleChannels} from 'mattermost-redux/actions/channels';
|
||||
import {getChannel} from 'mattermost-redux/selectors/entities/channels';
|
||||
|
||||
import {switchToChannel} from 'actions/views/channel';
|
||||
|
||||
import ExternalLink from 'components/external_link';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
|
||||
import RecapMenu from './recap_menu';
|
||||
import type {RecapMenuAction} from './recap_menu';
|
||||
import RecapTextFormatter from './recap_text_formatter';
|
||||
|
||||
type Props = {
|
||||
channel: RecapChannel;
|
||||
};
|
||||
|
||||
type ParsedItem = {
|
||||
text: string;
|
||||
permalink: string | null;
|
||||
};
|
||||
|
||||
// Helper function to parse permalink from text
|
||||
const parsePermalink = (text: string): ParsedItem => {
|
||||
// Match pattern: [PERMALINK:url]
|
||||
const permalinkRegex = /\[PERMALINK:([^\]]+)\]/;
|
||||
const match = text.match(permalinkRegex);
|
||||
|
||||
if (match) {
|
||||
return {
|
||||
text: text.replace(permalinkRegex, '').trim(),
|
||||
permalink: match[1],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
text,
|
||||
permalink: null,
|
||||
};
|
||||
};
|
||||
|
||||
const RecapChannelCard = ({channel}: Props) => {
|
||||
const {formatMessage} = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
|
||||
const channelObject = useSelector((state: GlobalState) => getChannel(state, channel.channel_id));
|
||||
|
||||
const hasHighlights = channel.highlights && channel.highlights.length > 0;
|
||||
const hasActionItems = channel.action_items && channel.action_items.length > 0;
|
||||
|
||||
const handleChannelClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (channelObject) {
|
||||
dispatch(switchToChannel(channelObject));
|
||||
}
|
||||
}, [dispatch, channelObject]);
|
||||
|
||||
const handleMarkChannelRead = useCallback(() => {
|
||||
dispatch(readMultipleChannels([channel.channel_id]));
|
||||
}, [dispatch, channel.channel_id]);
|
||||
|
||||
const handleOpenChannel = useCallback(() => {
|
||||
if (channelObject) {
|
||||
dispatch(switchToChannel(channelObject));
|
||||
}
|
||||
}, [dispatch, channelObject]);
|
||||
|
||||
const menuActions: RecapMenuAction[] = useMemo(() => [
|
||||
|
||||
{
|
||||
id: 'mark-channel-read',
|
||||
icon: <CheckAllIcon size={18}/>,
|
||||
label: formatMessage({
|
||||
id: 'recaps.menu.markChannelRead',
|
||||
defaultMessage: 'Mark this channel as read',
|
||||
}),
|
||||
onClick: handleMarkChannelRead,
|
||||
},
|
||||
{
|
||||
id: 'open-channel',
|
||||
icon: <ArrowExpandIcon size={18}/>,
|
||||
label: formatMessage({
|
||||
id: 'recaps.menu.openChannel',
|
||||
defaultMessage: 'Open channel',
|
||||
}),
|
||||
onClick: handleOpenChannel,
|
||||
},
|
||||
], [formatMessage, handleMarkChannelRead, handleOpenChannel]);
|
||||
|
||||
if (!hasHighlights && !hasActionItems) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='recap-channel-card'>
|
||||
<div className='recap-channel-header'>
|
||||
<button
|
||||
className='recap-channel-name-tag'
|
||||
onClick={handleChannelClick}
|
||||
disabled={!channelObject}
|
||||
>
|
||||
{channel.channel_name}
|
||||
</button>
|
||||
<div className='recap-channel-header-actions'>
|
||||
<button
|
||||
className='recap-channel-collapse-button'
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
>
|
||||
{isCollapsed ? <ChevronDownIcon size={16}/> : <ChevronUpIcon size={16}/>}
|
||||
</button>
|
||||
<RecapMenu
|
||||
actions={menuActions}
|
||||
ariaLabel={formatMessage(
|
||||
{
|
||||
id: 'recaps.channelMenu.ariaLabel',
|
||||
defaultMessage: 'Options for {channelName}',
|
||||
},
|
||||
{channelName: channel.channel_name},
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className='recap-channel-content'>
|
||||
{hasHighlights && (
|
||||
<div className='recap-section'>
|
||||
<h4 className='recap-section-title'>
|
||||
<FormattedMessage
|
||||
id='recaps.highlights'
|
||||
defaultMessage='Highlights'
|
||||
/>
|
||||
</h4>
|
||||
<ul className='recap-list'>
|
||||
{channel.highlights.map((highlight, index) => {
|
||||
const {text, permalink} = parsePermalink(highlight);
|
||||
return (
|
||||
<li
|
||||
key={index}
|
||||
className='recap-list-item'
|
||||
>
|
||||
<RecapTextFormatter
|
||||
text={text}
|
||||
className='recap-item-text'
|
||||
/>
|
||||
{permalink ? (
|
||||
<ExternalLink
|
||||
href={permalink}
|
||||
className='recap-item-badge recap-item-badge-link'
|
||||
location='recap_highlight_badge'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{index + 1}
|
||||
</ExternalLink>
|
||||
) : (
|
||||
<span className='recap-item-badge'>{index + 1}</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasActionItems && (
|
||||
<div className='recap-section'>
|
||||
<h4 className='recap-section-title'>
|
||||
<FormattedMessage
|
||||
id='recaps.actionItems'
|
||||
defaultMessage='Action items:'
|
||||
/>
|
||||
</h4>
|
||||
<ul className='recap-list'>
|
||||
{channel.action_items.map((actionItem, index) => {
|
||||
const {text, permalink} = parsePermalink(actionItem);
|
||||
const badgeNumber = (hasHighlights ? channel.highlights.length : 0) + index + 1;
|
||||
return (
|
||||
<li
|
||||
key={index}
|
||||
className='recap-list-item'
|
||||
>
|
||||
<RecapTextFormatter
|
||||
text={text}
|
||||
className='recap-item-text'
|
||||
/>
|
||||
{permalink ? (
|
||||
<ExternalLink
|
||||
href={permalink}
|
||||
className='recap-item-badge recap-item-badge-link'
|
||||
location='recap_action_item_badge'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{badgeNumber}
|
||||
</ExternalLink>
|
||||
) : (
|
||||
<span className='recap-item-badge'>
|
||||
{badgeNumber}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecapChannelCard;
|
||||
|
||||
224
webapp/channels/src/components/recaps/recap_item.tsx
Normal file
224
webapp/channels/src/components/recaps/recap_item.tsx
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useState, useMemo, useCallback} from 'react';
|
||||
import {useIntl, FormattedDate, FormattedMessage} from 'react-intl';
|
||||
import {useDispatch, useSelector} from 'react-redux';
|
||||
|
||||
import {CheckAllIcon, RefreshIcon, TrashCanOutlineIcon, CheckCircleIcon} from '@mattermost/compass-icons/components';
|
||||
import type {Recap} from '@mattermost/types/recaps';
|
||||
import {RecapStatus} from '@mattermost/types/recaps';
|
||||
|
||||
import {readMultipleChannels} from 'mattermost-redux/actions/channels';
|
||||
import {markRecapAsRead, deleteRecap, regenerateRecap} from 'mattermost-redux/actions/recaps';
|
||||
import {getAgents} from 'mattermost-redux/selectors/entities/agents';
|
||||
|
||||
import useGetAgentsBridgeEnabled from 'components/common/hooks/useGetAgentsBridgeEnabled';
|
||||
import ConfirmModal from 'components/confirm_modal';
|
||||
|
||||
import RecapChannelCard from './recap_channel_card';
|
||||
import RecapMenu from './recap_menu';
|
||||
import type {RecapMenuAction} from './recap_menu';
|
||||
import RecapProcessing from './recap_processing';
|
||||
|
||||
type Props = {
|
||||
recap: Recap;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
};
|
||||
|
||||
const RecapItem = ({recap, isExpanded, onToggle}: Props) => {
|
||||
const {formatMessage} = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const agents = useSelector(getAgents);
|
||||
const agentsBridgeEnabled = useGetAgentsBridgeEnabled();
|
||||
|
||||
const isProcessing = recap.status === RecapStatus.PENDING || recap.status === RecapStatus.PROCESSING;
|
||||
const isFailed = recap.status === RecapStatus.FAILED;
|
||||
|
||||
// Find the agent that generated this recap
|
||||
const generatingAgent = agents.find((agent) => agent.id === recap.bot_id);
|
||||
const agentDisplayName = generatingAgent?.displayName || formatMessage({id: 'recaps.defaultAgent', defaultMessage: 'Copilot'});
|
||||
|
||||
const handleMarkAllChannelsRead = useCallback(() => {
|
||||
if (recap.channels && recap.channels.length > 0) {
|
||||
const channelIds = recap.channels.map((channel) => channel.channel_id);
|
||||
dispatch(readMultipleChannels(channelIds));
|
||||
}
|
||||
}, [dispatch, recap.channels]);
|
||||
|
||||
const handleRegenerateRecap = useCallback(() => {
|
||||
dispatch(regenerateRecap(recap.id));
|
||||
}, [dispatch, recap.id]);
|
||||
|
||||
const menuActions: RecapMenuAction[] = useMemo(() => {
|
||||
const actions: RecapMenuAction[] = [];
|
||||
|
||||
// Only show "Mark all channels as read" for successful recaps
|
||||
if (!isFailed) {
|
||||
actions.push({
|
||||
id: 'mark-all-channels-read',
|
||||
icon: <CheckAllIcon size={18}/>,
|
||||
label: formatMessage({
|
||||
id: 'recaps.menu.markAllChannelsRead',
|
||||
defaultMessage: 'Mark all channels as read',
|
||||
}),
|
||||
onClick: handleMarkAllChannelsRead,
|
||||
});
|
||||
}
|
||||
|
||||
actions.push({
|
||||
id: 'regenerate-recap',
|
||||
icon: <RefreshIcon size={18}/>,
|
||||
label: formatMessage({
|
||||
id: 'recaps.menu.regenerateRecap',
|
||||
defaultMessage: 'Regenerate this recap',
|
||||
}),
|
||||
onClick: handleRegenerateRecap,
|
||||
disabled: !agentsBridgeEnabled,
|
||||
});
|
||||
|
||||
return actions;
|
||||
}, [formatMessage, handleMarkAllChannelsRead, handleRegenerateRecap, isFailed, agentsBridgeEnabled]);
|
||||
|
||||
const handleDelete = () => {
|
||||
dispatch(deleteRecap(recap.id));
|
||||
setShowDeleteConfirm(false);
|
||||
};
|
||||
|
||||
if (isProcessing) {
|
||||
return <RecapProcessing recap={recap}/>;
|
||||
}
|
||||
|
||||
// Determine class names based on state
|
||||
let itemClassName = 'recap-item';
|
||||
if (isFailed) {
|
||||
itemClassName += ' recap-item-failed collapsed';
|
||||
} else {
|
||||
itemClassName += isExpanded ? ' expanded' : ' collapsed';
|
||||
}
|
||||
|
||||
// Only make header clickable for successful recaps
|
||||
const headerProps = isFailed ? {} : {onClick: onToggle};
|
||||
|
||||
return (
|
||||
<div className={itemClassName}>
|
||||
<div
|
||||
className='recap-item-header'
|
||||
{...headerProps}
|
||||
>
|
||||
<div className='recap-item-title-section'>
|
||||
<h3 className='recap-item-title'>{recap.title}</h3>
|
||||
<div className='recap-item-metadata'>
|
||||
<span className='metadata-item'>
|
||||
<FormattedDate
|
||||
value={new Date(recap.create_at)}
|
||||
month='long'
|
||||
day='numeric'
|
||||
year='numeric'
|
||||
/>
|
||||
</span>
|
||||
{isFailed ? (
|
||||
<>
|
||||
<span className='metadata-separator'>{'•'}</span>
|
||||
<span className='metadata-item error-text'>
|
||||
{formatMessage({id: 'recaps.status.failed', defaultMessage: 'Failed'})}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{recap.total_message_count > 0 && (
|
||||
<>
|
||||
<span className='metadata-separator'>{'•'}</span>
|
||||
<span className='metadata-item'>
|
||||
{formatMessage(
|
||||
{id: 'recaps.messageCount', defaultMessage: 'Recapped {count} {count, plural, one {message} other {messages}}'},
|
||||
{count: recap.total_message_count},
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className='metadata-separator'>{'•'}</span>
|
||||
<span className='metadata-item'>
|
||||
{formatMessage(
|
||||
{id: 'recaps.generatedBy', defaultMessage: 'Generated by {agentName}'},
|
||||
{agentName: agentDisplayName},
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className='recap-item-actions'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{!isFailed && recap.read_at === 0 && (
|
||||
<button
|
||||
className='recap-action-button'
|
||||
onClick={() => dispatch(markRecapAsRead(recap.id))}
|
||||
>
|
||||
<CheckCircleIcon size={12}/>
|
||||
{formatMessage({id: 'recaps.markRead', defaultMessage: 'Mark read'})}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className='recap-icon-button recap-delete-button'
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
>
|
||||
<TrashCanOutlineIcon size={16}/>
|
||||
</button>
|
||||
{recap.read_at === 0 && (
|
||||
<RecapMenu
|
||||
actions={menuActions}
|
||||
ariaLabel={formatMessage(
|
||||
{
|
||||
id: 'recaps.menu.ariaLabel',
|
||||
defaultMessage: 'Options for {title}',
|
||||
},
|
||||
{title: recap.title},
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isFailed && isExpanded && recap.channels && recap.channels.length > 0 && (
|
||||
<div className='recap-item-content'>
|
||||
<div className='recap-channels-list'>
|
||||
{recap.channels.map((channel) => (
|
||||
<RecapChannelCard
|
||||
key={channel.id}
|
||||
channel={channel}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
show={showDeleteConfirm}
|
||||
title={formatMessage({id: 'recaps.delete.confirm.title', defaultMessage: 'Delete recap?'})}
|
||||
message={
|
||||
<FormattedMessage
|
||||
id='recaps.delete.confirm.message'
|
||||
defaultMessage='Are you sure you want to delete <strong>{title}</strong>? This action cannot be undone.'
|
||||
values={{
|
||||
title: recap.title,
|
||||
strong: (chunks: React.ReactNode) => <strong>{chunks}</strong>,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
confirmButtonText={formatMessage({id: 'recaps.delete.confirm.button', defaultMessage: 'Delete'})}
|
||||
confirmButtonClass='btn btn-danger'
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setShowDeleteConfirm(false)}
|
||||
onExited={() => setShowDeleteConfirm(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecapItem;
|
||||
|
||||
168
webapp/channels/src/components/recaps/recap_menu.test.tsx
Normal file
168
webapp/channels/src/components/recaps/recap_menu.test.tsx
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import {CheckAllIcon, RefreshIcon} from '@mattermost/compass-icons/components';
|
||||
|
||||
import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils';
|
||||
|
||||
import RecapMenu from './recap_menu';
|
||||
import type {RecapMenuAction} from './recap_menu';
|
||||
|
||||
jest.mock('components/menu', () => ({
|
||||
Container: ({children, menuButton}: any) => {
|
||||
const {class: className, ...buttonProps} = menuButton;
|
||||
return (
|
||||
<div data-testid='menu-container'>
|
||||
<button
|
||||
{...buttonProps}
|
||||
className={className}
|
||||
data-testid='menu-button'
|
||||
>
|
||||
{menuButton.children}
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
Item: ({leadingElement, labels, onClick, disabled}: any) => (
|
||||
<button
|
||||
data-testid='menu-item'
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{leadingElement}
|
||||
{labels}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('RecapMenu', () => {
|
||||
const mockOnClick1 = jest.fn();
|
||||
const mockOnClick2 = jest.fn();
|
||||
|
||||
const mockActions: RecapMenuAction[] = [
|
||||
{
|
||||
id: 'action1',
|
||||
icon: <CheckAllIcon size={18}/>,
|
||||
label: 'Mark as read',
|
||||
onClick: mockOnClick1,
|
||||
},
|
||||
{
|
||||
id: 'action2',
|
||||
icon: <RefreshIcon size={18}/>,
|
||||
label: 'Regenerate',
|
||||
onClick: mockOnClick2,
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should render menu button', () => {
|
||||
renderWithContext(<RecapMenu actions={mockActions}/>);
|
||||
|
||||
expect(screen.getByTestId('menu-container')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render all menu items', () => {
|
||||
renderWithContext(<RecapMenu actions={mockActions}/>);
|
||||
|
||||
const menuItems = screen.getAllByTestId('menu-item');
|
||||
expect(menuItems).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('should render menu item labels', () => {
|
||||
renderWithContext(<RecapMenu actions={mockActions}/>);
|
||||
|
||||
expect(screen.getByText('Mark as read')).toBeInTheDocument();
|
||||
expect(screen.getByText('Regenerate')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should call onClick when menu item is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithContext(<RecapMenu actions={mockActions}/>);
|
||||
|
||||
const menuItems = screen.getAllByTestId('menu-item');
|
||||
await user.click(menuItems[0]);
|
||||
|
||||
expect(mockOnClick1).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnClick2).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should handle disabled menu items', () => {
|
||||
const disabledActions: RecapMenuAction[] = [
|
||||
{
|
||||
id: 'action1',
|
||||
icon: <CheckAllIcon size={18}/>,
|
||||
label: 'Disabled action',
|
||||
onClick: mockOnClick1,
|
||||
disabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
renderWithContext(<RecapMenu actions={disabledActions}/>);
|
||||
|
||||
const menuItem = screen.getByTestId('menu-item');
|
||||
expect(menuItem).toBeDisabled();
|
||||
});
|
||||
|
||||
test('should render with custom button className', () => {
|
||||
const customClassName = 'custom-menu-button';
|
||||
renderWithContext(
|
||||
<RecapMenu
|
||||
actions={mockActions}
|
||||
buttonClassName={customClassName}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByTestId('menu-button');
|
||||
expect(button).toHaveClass(customClassName);
|
||||
});
|
||||
|
||||
test('should use custom aria label when provided', () => {
|
||||
const customAriaLabel = 'Custom options menu';
|
||||
renderWithContext(
|
||||
<RecapMenu
|
||||
actions={mockActions}
|
||||
ariaLabel={customAriaLabel}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByTestId('menu-button');
|
||||
expect(button).toHaveAttribute('aria-label', customAriaLabel);
|
||||
});
|
||||
|
||||
test('should use default aria label when not provided', () => {
|
||||
renderWithContext(<RecapMenu actions={mockActions}/>);
|
||||
|
||||
const button = screen.getByTestId('menu-button');
|
||||
expect(button).toHaveAttribute('aria-label', 'Recap options');
|
||||
});
|
||||
|
||||
test('should handle empty actions array', () => {
|
||||
renderWithContext(<RecapMenu actions={[]}/>);
|
||||
|
||||
const menuItems = screen.queryAllByTestId('menu-item');
|
||||
expect(menuItems).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('should handle destructive menu items', () => {
|
||||
const destructiveAction: RecapMenuAction[] = [
|
||||
{
|
||||
id: 'delete',
|
||||
icon: <RefreshIcon size={18}/>,
|
||||
label: 'Delete',
|
||||
onClick: mockOnClick1,
|
||||
isDestructive: true,
|
||||
},
|
||||
];
|
||||
|
||||
renderWithContext(<RecapMenu actions={destructiveAction}/>);
|
||||
|
||||
expect(screen.getByText('Delete')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
70
webapp/channels/src/components/recaps/recap_menu.tsx
Normal file
70
webapp/channels/src/components/recaps/recap_menu.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import type {ReactNode} from 'react';
|
||||
|
||||
import {DotsHorizontalIcon} from '@mattermost/compass-icons/components';
|
||||
|
||||
import * as Menu from 'components/menu';
|
||||
|
||||
export type RecapMenuAction = {
|
||||
id: string;
|
||||
icon: ReactNode;
|
||||
label: ReactNode;
|
||||
onClick: () => void;
|
||||
isDestructive?: boolean;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
interface RecapMenuProps {
|
||||
actions: RecapMenuAction[];
|
||||
buttonClassName?: string;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export const RecapMenu: React.FC<RecapMenuProps> = ({
|
||||
actions,
|
||||
buttonClassName = 'recap-icon-button',
|
||||
ariaLabel = 'Recap options',
|
||||
}) => {
|
||||
const menuId = `recap-menu-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const buttonId = `${menuId}-button`;
|
||||
|
||||
return (
|
||||
<Menu.Container
|
||||
menuButton={{
|
||||
id: buttonId,
|
||||
class: buttonClassName,
|
||||
'aria-label': ariaLabel,
|
||||
children: <DotsHorizontalIcon size={16}/>,
|
||||
}}
|
||||
menu={{
|
||||
id: menuId,
|
||||
'aria-label': ariaLabel,
|
||||
}}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
>
|
||||
{actions.map((action) => (
|
||||
<Menu.Item
|
||||
key={action.id}
|
||||
leadingElement={action.icon}
|
||||
labels={<span>{action.label}</span>}
|
||||
onClick={action.onClick}
|
||||
isDestructive={action.isDestructive}
|
||||
disabled={action.disabled}
|
||||
/>
|
||||
))}
|
||||
</Menu.Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecapMenu;
|
||||
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import type {Recap} from '@mattermost/types/recaps';
|
||||
import {RecapStatus} from '@mattermost/types/recaps';
|
||||
|
||||
import {renderWithContext, screen} from 'tests/react_testing_utils';
|
||||
|
||||
import RecapProcessing from './recap_processing';
|
||||
|
||||
describe('RecapProcessing', () => {
|
||||
const mockRecap: Recap = {
|
||||
id: 'recap1',
|
||||
title: 'Daily Standup Recap',
|
||||
user_id: 'user1',
|
||||
bot_id: 'bot1',
|
||||
status: RecapStatus.PROCESSING,
|
||||
create_at: 1000,
|
||||
update_at: 1000,
|
||||
delete_at: 0,
|
||||
read_at: 0,
|
||||
channels: [],
|
||||
total_message_count: 0,
|
||||
};
|
||||
|
||||
test('should render recap title', () => {
|
||||
renderWithContext(<RecapProcessing recap={mockRecap}/>);
|
||||
|
||||
expect(screen.getByText('Daily Standup Recap')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render processing subtitle message', () => {
|
||||
renderWithContext(<RecapProcessing recap={mockRecap}/>);
|
||||
|
||||
expect(screen.getByText("Recap created. You'll receive a summary shortly")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render processing message', () => {
|
||||
renderWithContext(<RecapProcessing recap={mockRecap}/>);
|
||||
|
||||
expect(screen.getByText("We're working on your recap. Check back shortly")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render spinner', () => {
|
||||
const {container} = renderWithContext(<RecapProcessing recap={mockRecap}/>);
|
||||
|
||||
expect(container.querySelector('.spinner-large')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should have correct CSS classes', () => {
|
||||
const {container} = renderWithContext(<RecapProcessing recap={mockRecap}/>);
|
||||
|
||||
expect(container.querySelector('.recap-processing')).toBeInTheDocument();
|
||||
expect(container.querySelector('.recap-processing-header')).toBeInTheDocument();
|
||||
expect(container.querySelector('.recap-processing-content')).toBeInTheDocument();
|
||||
expect(container.querySelector('.recap-processing-spinner')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
42
webapp/channels/src/components/recaps/recap_processing.tsx
Normal file
42
webapp/channels/src/components/recaps/recap_processing.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
// 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 {Recap} from '@mattermost/types/recaps';
|
||||
|
||||
type Props = {
|
||||
recap: Recap;
|
||||
};
|
||||
|
||||
const RecapProcessing = ({recap}: Props) => {
|
||||
return (
|
||||
<div className='recap-processing'>
|
||||
<div className='recap-processing-header'>
|
||||
<h2 className='recap-processing-title'>{recap.title}</h2>
|
||||
<div className='recap-processing-subtitle'>
|
||||
<FormattedMessage
|
||||
id='recaps.processing.subtitle'
|
||||
defaultMessage="Recap created. You'll receive a summary shortly"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='recap-processing-content'>
|
||||
<div className='recap-processing-spinner'>
|
||||
<div className='spinner-large'/>
|
||||
</div>
|
||||
<p className='recap-processing-message'>
|
||||
<FormattedMessage
|
||||
id='recaps.processing.message'
|
||||
defaultMessage="We're working on your recap. Check back shortly"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecapProcessing;
|
||||
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import {renderWithContext, screen} from 'tests/react_testing_utils';
|
||||
import {TestHelper} from 'utils/test_helper';
|
||||
|
||||
import RecapTextFormatter from './recap_text_formatter';
|
||||
|
||||
jest.mock('components/markdown', () => {
|
||||
return function Markdown({message}: {message: string}) {
|
||||
return <div data-testid='markdown'>{message}</div>;
|
||||
};
|
||||
});
|
||||
|
||||
describe('RecapTextFormatter', () => {
|
||||
const baseState = {
|
||||
entities: {
|
||||
channels: {
|
||||
channels: {},
|
||||
channelsInTeam: {},
|
||||
myMembers: {},
|
||||
},
|
||||
teams: {
|
||||
currentTeamId: 'team1',
|
||||
teams: {
|
||||
team1: TestHelper.getTeamMock({
|
||||
id: 'team1',
|
||||
name: 'test-team',
|
||||
display_name: 'Test Team',
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
test('should render text content', () => {
|
||||
const text = 'This is a recap message';
|
||||
renderWithContext(
|
||||
<RecapTextFormatter text={text}/>,
|
||||
baseState,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('markdown')).toHaveTextContent(text);
|
||||
});
|
||||
|
||||
test('should strip HTML tags from text', () => {
|
||||
const text = 'This is <strong>bold</strong> text';
|
||||
renderWithContext(
|
||||
<RecapTextFormatter text={text}/>,
|
||||
baseState,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('markdown')).toHaveTextContent('This is bold text');
|
||||
});
|
||||
|
||||
test('should apply custom className when provided', () => {
|
||||
const text = 'Test message';
|
||||
const className = 'custom-class';
|
||||
const {container} = renderWithContext(
|
||||
<RecapTextFormatter
|
||||
text={text}
|
||||
className={className}
|
||||
/>,
|
||||
baseState,
|
||||
);
|
||||
|
||||
expect(container.querySelector(`.${className}`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should handle text with multiple HTML tags', () => {
|
||||
const text = '<div><p>Nested <span>HTML</span> tags</p></div>';
|
||||
renderWithContext(
|
||||
<RecapTextFormatter text={text}/>,
|
||||
baseState,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('markdown')).toHaveTextContent('Nested HTML tags');
|
||||
});
|
||||
|
||||
test('should handle empty text', () => {
|
||||
renderWithContext(
|
||||
<RecapTextFormatter text=''/>,
|
||||
baseState,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('markdown')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render with default props when className not provided', () => {
|
||||
const text = 'Default styling test';
|
||||
const {container} = renderWithContext(
|
||||
<RecapTextFormatter text={text}/>,
|
||||
baseState,
|
||||
);
|
||||
|
||||
// Should render a div wrapper
|
||||
expect(container.querySelector('div')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback} from 'react';
|
||||
import {useSelector} from 'react-redux';
|
||||
|
||||
import {getChannelsNameMapInCurrentTeam} from 'mattermost-redux/selectors/entities/channels';
|
||||
import {getCurrentRelativeTeamUrl, getCurrentTeam} from 'mattermost-redux/selectors/entities/teams';
|
||||
|
||||
import Markdown from 'components/markdown';
|
||||
|
||||
import {handleFormattedTextClick} from 'utils/utils';
|
||||
|
||||
type Props = {
|
||||
text: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const RecapTextFormatter = ({text, className}: Props) => {
|
||||
const channelNamesMap = useSelector(getChannelsNameMapInCurrentTeam);
|
||||
const currentTeam = useSelector(getCurrentTeam);
|
||||
const currentRelativeTeamUrl = useSelector(getCurrentRelativeTeamUrl);
|
||||
|
||||
// Remove any existing HTML tags from the text for safety
|
||||
const cleanText = text.replace(/<[^>]*>/g, '');
|
||||
|
||||
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
handleFormattedTextClick(e, currentRelativeTeamUrl);
|
||||
}, [currentRelativeTeamUrl]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* This component is leveraged so that @username's can be clicked, showing the user info popover */}
|
||||
<Markdown
|
||||
message={cleanText}
|
||||
channelNamesMap={channelNamesMap}
|
||||
options={{
|
||||
atMentions: true,
|
||||
markdown: false, // Disable markdown parsing since this is plain text
|
||||
singleline: false,
|
||||
mentionHighlight: false, // Don't highlight mentions in recaps
|
||||
team: currentTeam,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecapTextFormatter;
|
||||
|
||||
536
webapp/channels/src/components/recaps/recaps.scss
Normal file
536
webapp/channels/src/components/recaps/recaps.scss
Normal file
|
|
@ -0,0 +1,536 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
.recaps-container {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
background-color: var(--center-channel-bg);
|
||||
|
||||
.recaps-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.12);
|
||||
background-color: var(--center-channel-bg);
|
||||
|
||||
.recaps-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.recaps-title-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px;
|
||||
gap: 8px;
|
||||
|
||||
.icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.recaps-title {
|
||||
margin: 0;
|
||||
color: var(--center-channel-color);
|
||||
font-family: 'Metropolis', sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.recaps-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
|
||||
.recaps-tab {
|
||||
padding: 22px 6px;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: none;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.64);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 12px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: var(--center-channel-color);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-bottom-color: var(--button-bg);
|
||||
color: var(--button-bg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.recap-add-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: var(--radius-s);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
gap: 6px;
|
||||
line-height: 16px;
|
||||
|
||||
.icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(var(--button-bg-rgb), 0.12);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.recaps-content {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
background-color: rgba(var(--center-channel-color-rgb), 0.04);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
// Recaps List Styles
|
||||
.recaps-list {
|
||||
display: flex;
|
||||
max-width: 966px;
|
||||
flex-direction: column;
|
||||
margin: 0 auto;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.recap-item {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb), 0.12);
|
||||
border-radius: var(--radius-s);
|
||||
background-color: var(--center-channel-bg);
|
||||
transition: all 0.2s;
|
||||
|
||||
&.expanded {
|
||||
.recap-item-header {
|
||||
border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
&.recap-item-failed {
|
||||
|
||||
.recap-item-header {
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.recap-item-header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(var(--center-channel-color-rgb), 0.04);
|
||||
}
|
||||
|
||||
.recap-item-title-section {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
|
||||
.recap-item-title {
|
||||
margin: 0 0 8px;
|
||||
color: var(--center-channel-color);
|
||||
font-family: 'Metropolis', sans-serif;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.recap-item-metadata {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.64);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
gap: 4px;
|
||||
line-height: 16px;
|
||||
|
||||
.metadata-separator {
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.copilot-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: var(--error-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.recap-item-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 16px;
|
||||
gap: 8px;
|
||||
|
||||
.recap-action-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: var(--radius-s);
|
||||
background-color: transparent;
|
||||
color: var(--button-bg);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
gap: 4px;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(var(--button-bg-rgb), 0.08);
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.recap-icon-button {
|
||||
padding: 6px;
|
||||
border: none;
|
||||
border-radius: var(--radius-s);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
|
||||
.icon {
|
||||
color: rgba(var(--center-channel-color-rgb), 0.56);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(var(--center-channel-color-rgb), 0.08);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
// Trash icon should be red
|
||||
&.recap-delete-button {
|
||||
color: var(--dnd-indicator);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.recap-item-content {
|
||||
padding: 24px;
|
||||
|
||||
.recap-channels-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.recap-all-caught-up {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px;
|
||||
margin-top: 12px;
|
||||
color: var(--center-channel-color);
|
||||
font-family: 'Metropolis', sans-serif;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
gap: 12px;
|
||||
line-height: 28px;
|
||||
|
||||
.icon {
|
||||
color: rgba(var(--center-channel-color-rgb), 0.64);
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.recaps-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 24px;
|
||||
text-align: center;
|
||||
|
||||
.empty-state-icon {
|
||||
margin-bottom: 12px;
|
||||
|
||||
.icon {
|
||||
color: rgba(var(--center-channel-color-rgb), 0.64);
|
||||
font-size: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state-title {
|
||||
margin: 0 0 8px;
|
||||
color: var(--center-channel-color);
|
||||
font-family: 'Metropolis', sans-serif;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.empty-state-description {
|
||||
margin: 0;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.72);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Recap Channel Card Styles
|
||||
.recap-channel-card {
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb), 0.12);
|
||||
border-radius: var(--radius-s);
|
||||
background-color: var(--center-channel-bg);
|
||||
|
||||
.recap-channel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.recap-channel-name-tag {
|
||||
padding: 2px 6px;
|
||||
border: none;
|
||||
border-radius: var(--radius-s);
|
||||
background-color: rgba(var(--center-channel-color-rgb), 0.08);
|
||||
color: var(--center-channel-color);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: rgba(var(--center-channel-color-rgb), 0.16);
|
||||
color: var(--button-bg);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.recap-channel-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.recap-channel-collapse-button {
|
||||
padding: 6px;
|
||||
border: none;
|
||||
border-radius: var(--radius-s);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
|
||||
.icon {
|
||||
color: rgba(var(--center-channel-color-rgb), 0.56);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(var(--center-channel-color-rgb), 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.recap-icon-button {
|
||||
padding: 6px;
|
||||
border: none;
|
||||
border-radius: var(--radius-s);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
|
||||
.icon {
|
||||
color: rgba(var(--center-channel-color-rgb), 0.56);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(var(--center-channel-color-rgb), 0.08);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.recap-channel-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.recap-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.recap-section-title {
|
||||
margin: 0;
|
||||
color: var(--center-channel-color);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.recap-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-left: 0;
|
||||
margin: 0;
|
||||
gap: 4px;
|
||||
list-style: none;
|
||||
|
||||
.recap-list-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding-left: 21px;
|
||||
gap: 8px;
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--center-channel-color);
|
||||
content: '•';
|
||||
}
|
||||
|
||||
.recap-item-text {
|
||||
flex: 1;
|
||||
color: var(--center-channel-color);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
|
||||
// Style @mentions
|
||||
.mention {
|
||||
color: var(--button-bg);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.recap-item-badge {
|
||||
flex-shrink: 0;
|
||||
padding: 0 4px;
|
||||
border-radius: var(--radius-s);
|
||||
margin-top: 2px;
|
||||
background-color: rgba(var(--button-bg-rgb), 0.08);
|
||||
color: var(--button-bg);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1px;
|
||||
line-height: 16px;
|
||||
|
||||
&.recap-item-badge-link {
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(var(--button-bg-rgb), 0.16);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recap Processing Styles
|
||||
.recap-processing {
|
||||
overflow: hidden;
|
||||
padding: 24px;
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb), 0.12);
|
||||
border-radius: var(--radius-s);
|
||||
background-color: var(--center-channel-bg);
|
||||
transition: all 0.2s;
|
||||
|
||||
.recap-processing-header {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.recap-processing-title {
|
||||
margin: 0 0 8px;
|
||||
color: var(--center-channel-color);
|
||||
font-family: 'Metropolis', sans-serif;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.recap-processing-subtitle {
|
||||
color: rgba(var(--center-channel-color-rgb), 0.64);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.recap-processing-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 0;
|
||||
|
||||
.recap-processing-spinner {
|
||||
margin-bottom: 16px;
|
||||
|
||||
.spinner-large {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid rgba(var(--center-channel-color-rgb), 0.12);
|
||||
border-radius: var(--radius-full);
|
||||
border-top-color: var(--button-bg);
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.recap-processing-message {
|
||||
margin: 0;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.64);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
100
webapp/channels/src/components/recaps/recaps.tsx
Normal file
100
webapp/channels/src/components/recaps/recaps.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {useDispatch, useSelector} from 'react-redux';
|
||||
import {Redirect} from 'react-router-dom';
|
||||
|
||||
import {PlusIcon} from '@mattermost/compass-icons/components';
|
||||
|
||||
import {getAgents} from 'mattermost-redux/actions/agents';
|
||||
import {getRecaps} from 'mattermost-redux/actions/recaps';
|
||||
import {getUnreadRecaps, getReadRecaps} from 'mattermost-redux/selectors/entities/recaps';
|
||||
|
||||
import {openModal} from 'actions/views/modals';
|
||||
|
||||
import useGetAgentsBridgeEnabled from 'components/common/hooks/useGetAgentsBridgeEnabled';
|
||||
import useGetFeatureFlagValue from 'components/common/hooks/useGetFeatureFlagValue';
|
||||
import CreateRecapModal from 'components/create_recap_modal';
|
||||
|
||||
import {ModalIdentifiers} from 'utils/constants';
|
||||
|
||||
import RecapsList from './recaps_list';
|
||||
|
||||
import './recaps.scss';
|
||||
|
||||
const Recaps = () => {
|
||||
const {formatMessage} = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const [activeTab, setActiveTab] = useState<'unread' | 'read'>('unread');
|
||||
const enableAIRecaps = useGetFeatureFlagValue('EnableAIRecaps');
|
||||
const agentsBridgeEnabled = useGetAgentsBridgeEnabled();
|
||||
|
||||
const unreadRecaps = useSelector(getUnreadRecaps);
|
||||
const readRecaps = useSelector(getReadRecaps);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(getRecaps(0, 60));
|
||||
dispatch(getAgents());
|
||||
}, [dispatch]);
|
||||
|
||||
// Redirect if feature flag is disabled
|
||||
if (enableAIRecaps !== 'true') {
|
||||
return <Redirect to='/'/>;
|
||||
}
|
||||
|
||||
const handleAddRecap = () => {
|
||||
dispatch(openModal({
|
||||
modalId: ModalIdentifiers.CREATE_RECAP_MODAL,
|
||||
dialogType: CreateRecapModal,
|
||||
}));
|
||||
};
|
||||
|
||||
const displayedRecaps = activeTab === 'unread' ? unreadRecaps : readRecaps;
|
||||
|
||||
return (
|
||||
<div className='recaps-container'>
|
||||
<div className='recaps-header'>
|
||||
<div className='recaps-header-left'>
|
||||
<div className='recaps-title-container'>
|
||||
<i className='icon icon-robot-outline'/>
|
||||
<h1 className='recaps-title'>
|
||||
{formatMessage({id: 'recaps.title', defaultMessage: 'Recaps'})}
|
||||
</h1>
|
||||
</div>
|
||||
<div className='recaps-tabs'>
|
||||
<button
|
||||
className={`recaps-tab ${activeTab === 'unread' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('unread')}
|
||||
>
|
||||
{formatMessage({id: 'recaps.unreadTab', defaultMessage: 'Unread'})}
|
||||
</button>
|
||||
<button
|
||||
className={`recaps-tab ${activeTab === 'read' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('read')}
|
||||
>
|
||||
{formatMessage({id: 'recaps.readTab', defaultMessage: 'Read'})}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className='btn btn-tertiary recap-add-button'
|
||||
onClick={handleAddRecap}
|
||||
disabled={agentsBridgeEnabled === false}
|
||||
title={agentsBridgeEnabled ? undefined : formatMessage({id: 'recaps.addRecap.disabled', defaultMessage: 'Agents Bridge is not enabled'})}
|
||||
>
|
||||
<PlusIcon size={12}/>
|
||||
{formatMessage({id: 'recaps.addRecap', defaultMessage: 'Add a recap'})}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='recaps-content'>
|
||||
<RecapsList recaps={displayedRecaps}/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Recaps;
|
||||
|
||||
72
webapp/channels/src/components/recaps/recaps_list.test.tsx
Normal file
72
webapp/channels/src/components/recaps/recaps_list.test.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import type {Recap} from '@mattermost/types/recaps';
|
||||
import {RecapStatus} from '@mattermost/types/recaps';
|
||||
|
||||
import {renderWithContext, screen} from 'tests/react_testing_utils';
|
||||
|
||||
import RecapsList from './recaps_list';
|
||||
|
||||
jest.mock('mattermost-redux/actions/recaps', () => ({
|
||||
pollRecapStatus: jest.fn(() => ({type: 'POLL_RECAP_STATUS'})),
|
||||
}));
|
||||
|
||||
describe('RecapsList', () => {
|
||||
const mockCompletedRecaps: Recap[] = [
|
||||
{
|
||||
id: 'recap1',
|
||||
title: 'Morning Standup',
|
||||
user_id: 'user1',
|
||||
bot_id: 'bot1',
|
||||
status: RecapStatus.COMPLETED,
|
||||
create_at: 1000,
|
||||
update_at: 1000,
|
||||
delete_at: 0,
|
||||
read_at: 0,
|
||||
channels: [],
|
||||
total_message_count: 5,
|
||||
},
|
||||
{
|
||||
id: 'recap2',
|
||||
title: 'Weekly Review',
|
||||
user_id: 'user1',
|
||||
bot_id: 'bot1',
|
||||
status: RecapStatus.COMPLETED,
|
||||
create_at: 2000,
|
||||
update_at: 2000,
|
||||
delete_at: 0,
|
||||
read_at: 0,
|
||||
channels: [],
|
||||
total_message_count: 10,
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should render empty state when no recaps', () => {
|
||||
renderWithContext(<RecapsList recaps={[]}/>);
|
||||
|
||||
expect(screen.getByText("You're all caught up")).toBeInTheDocument();
|
||||
expect(screen.getByText("You don't have any recaps yet. Create one to get started.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render recap items when recaps exist', () => {
|
||||
renderWithContext(<RecapsList recaps={mockCompletedRecaps}/>);
|
||||
|
||||
expect(screen.getByText('Morning Standup')).toBeInTheDocument();
|
||||
expect(screen.getByText('Weekly Review')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should show "all caught up" message at the bottom', () => {
|
||||
renderWithContext(<RecapsList recaps={mockCompletedRecaps}/>);
|
||||
|
||||
const allCaughtUpMessages = screen.getAllByText("You're all caught up");
|
||||
expect(allCaughtUpMessages.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
100
webapp/channels/src/components/recaps/recaps_list.tsx
Normal file
100
webapp/channels/src/components/recaps/recaps_list.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useState, useEffect, useRef} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {useDispatch} from 'react-redux';
|
||||
|
||||
import type {Recap} from '@mattermost/types/recaps';
|
||||
import {RecapStatus} from '@mattermost/types/recaps';
|
||||
|
||||
import {getRecap} from 'mattermost-redux/actions/recaps';
|
||||
|
||||
import RecapItem from './recap_item';
|
||||
|
||||
type Props = {
|
||||
recaps: Recap[];
|
||||
};
|
||||
|
||||
const RecapsList = ({recaps}: Props) => {
|
||||
const {formatMessage} = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const [expandedRecapIds, setExpandedRecapIds] = useState<Set<string>>(new Set());
|
||||
const previousRecapStatuses = useRef<Map<string, string>>(new Map());
|
||||
|
||||
// Auto-expand recaps when they finish processing
|
||||
useEffect(() => {
|
||||
const newExpanded = new Set(expandedRecapIds);
|
||||
let hasChanges = false;
|
||||
|
||||
recaps.forEach((recap) => {
|
||||
const previousStatus = previousRecapStatuses.current.get(recap.id);
|
||||
const isProcessing = previousStatus === RecapStatus.PENDING || previousStatus === RecapStatus.PROCESSING;
|
||||
const isCompleted = recap.status === RecapStatus.COMPLETED;
|
||||
|
||||
// If recap just finished processing, expand it and fetch details
|
||||
if (isProcessing && isCompleted && !expandedRecapIds.has(recap.id)) {
|
||||
newExpanded.add(recap.id);
|
||||
hasChanges = true;
|
||||
dispatch(getRecap(recap.id));
|
||||
}
|
||||
|
||||
// Update the previous status
|
||||
previousRecapStatuses.current.set(recap.id, recap.status);
|
||||
});
|
||||
|
||||
if (hasChanges) {
|
||||
setExpandedRecapIds(newExpanded);
|
||||
}
|
||||
}, [recaps, expandedRecapIds, dispatch]);
|
||||
|
||||
const toggleRecap = (recapId: string) => {
|
||||
const newExpanded = new Set(expandedRecapIds);
|
||||
if (newExpanded.has(recapId)) {
|
||||
newExpanded.delete(recapId);
|
||||
} else {
|
||||
newExpanded.add(recapId);
|
||||
|
||||
// Fetch full recap with channels if not already loaded
|
||||
dispatch(getRecap(recapId));
|
||||
}
|
||||
setExpandedRecapIds(newExpanded);
|
||||
};
|
||||
|
||||
if (recaps.length === 0) {
|
||||
return (
|
||||
<div className='recaps-empty-state'>
|
||||
<div className='empty-state-icon'>
|
||||
<i className='icon icon-check-circle'/>
|
||||
</div>
|
||||
<h2 className='empty-state-title'>
|
||||
{formatMessage({id: 'recaps.emptyState.title', defaultMessage: "You're all caught up"})}
|
||||
</h2>
|
||||
<p className='empty-state-description'>
|
||||
{formatMessage({id: 'recaps.emptyState.description', defaultMessage: "You don't have any recaps yet. Create one to get started."})}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='recaps-list'>
|
||||
{recaps.map((recap) => (
|
||||
<RecapItem
|
||||
key={recap.id}
|
||||
recap={recap}
|
||||
isExpanded={expandedRecapIds.has(recap.id)}
|
||||
onToggle={() => toggleRecap(recap.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className='recap-all-caught-up'>
|
||||
<i className='icon icon-check-circle'/>
|
||||
<span>{formatMessage({id: 'recaps.allCaughtUp', defaultMessage: "You're all caught up"})}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecapsList;
|
||||
|
||||
5
webapp/channels/src/components/recaps_link/index.ts
Normal file
5
webapp/channels/src/components/recaps_link/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
export {default} from './recaps_link';
|
||||
|
||||
76
webapp/channels/src/components/recaps_link/recaps_link.scss
Normal file
76
webapp/channels/src/components/recaps_link/recaps_link.scss
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
.SidebarRecaps {
|
||||
margin-bottom: 12px;
|
||||
|
||||
.SidebarChannel {
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
|
||||
&.active {
|
||||
background-color: rgba(var(--sidebar-text-rgb), 0.08);
|
||||
|
||||
.SidebarLink::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
border-radius: 0 var(--radius-s) var(--radius-s) 0;
|
||||
background-color: var(--sidebar-text-active-border);
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
|
||||
&:hover:not(.active) {
|
||||
background-color: rgba(var(--sidebar-text-rgb), 0.04);
|
||||
}
|
||||
}
|
||||
|
||||
.SidebarLink {
|
||||
position: relative;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
padding: 0 16px 0 18px;
|
||||
color: rgba(var(--sidebar-text-rgb), 0.72);
|
||||
text-decoration: none;
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 3px;
|
||||
margin-right: 4px;
|
||||
font-size: 18px;
|
||||
opacity: 0.64;
|
||||
}
|
||||
|
||||
.SidebarChannelLinkLabel_wrapper {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.SidebarChannelLinkLabel {
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.active .SidebarLink {
|
||||
color: var(--sidebar-text);
|
||||
|
||||
.icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.SidebarChannelLinkLabel {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
66
webapp/channels/src/components/recaps_link/recaps_link.tsx
Normal file
66
webapp/channels/src/components/recaps_link/recaps_link.tsx
Normal file
|
|
@ -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 {FormattedMessage} from 'react-intl';
|
||||
import {useSelector} from 'react-redux';
|
||||
import {Link, useLocation, matchPath, useRouteMatch} from 'react-router-dom';
|
||||
|
||||
import {CreationOutlineIcon} from '@mattermost/compass-icons/components';
|
||||
|
||||
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
|
||||
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
|
||||
|
||||
import useGetFeatureFlagValue from 'components/common/hooks/useGetFeatureFlagValue';
|
||||
|
||||
import './recaps_link.scss';
|
||||
|
||||
const RecapsLink = () => {
|
||||
const {url} = useRouteMatch();
|
||||
const {pathname} = useLocation();
|
||||
const currentTeamId = useSelector(getCurrentTeamId);
|
||||
const currentUserId = useSelector(getCurrentUserId);
|
||||
const enableAIRecaps = useGetFeatureFlagValue('EnableAIRecaps');
|
||||
|
||||
const inRecaps = matchPath(pathname, {path: '/:team/recaps/:recapId?'}) != null;
|
||||
|
||||
if (!currentTeamId || !currentUserId || enableAIRecaps !== 'true') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className='SidebarRecaps NavGroupContent nav nav-pills__container'>
|
||||
<li
|
||||
id={'sidebar-recaps-button'}
|
||||
className={classNames('SidebarChannel', {
|
||||
active: inRecaps,
|
||||
})}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<Link
|
||||
to={`${url}/recaps`}
|
||||
id='sidebarItem_recaps'
|
||||
draggable='false'
|
||||
className='SidebarLink sidebar-item'
|
||||
tabIndex={0}
|
||||
>
|
||||
<span className='icon'>
|
||||
<CreationOutlineIcon size={18}/>
|
||||
</span>
|
||||
<div className='SidebarChannelLinkLabel_wrapper'>
|
||||
<span className='SidebarChannelLinkLabel sidebar-item__name'>
|
||||
<FormattedMessage
|
||||
id='recaps.sidebarLink'
|
||||
defaultMessage='Recaps'
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecapsLink;
|
||||
|
||||
|
|
@ -503,7 +503,7 @@ export default class Root extends React.PureComponent<Props, State> {
|
|||
export function doesRouteBelongToTeamControllerRoutes(pathname: RouteComponentProps['location']['pathname']): boolean {
|
||||
// Note: we have specifically added admin_console to the negative lookahead as admin_console can have integrations as subpaths (admin_console/integrations/bot_accounts)
|
||||
// and we don't want to treat those as team controller routes.
|
||||
const TEAM_CONTROLLER_PATH_PATTERN = new RegExp(`^/(?!admin_console)([a-z0-9\\-_]+)/(channels|messages|threads|drafts|integrations|emoji|${SCHEDULED_POST_URL_SUFFIX})(/.*)?$`);
|
||||
const TEAM_CONTROLLER_PATH_PATTERN = new RegExp(`^/(?!admin_console)([a-z0-9\\-_]+)/(channels|messages|threads|recaps|drafts|integrations|emoji|${SCHEDULED_POST_URL_SUFFIX})(/.*)?$`);
|
||||
|
||||
return TEAM_CONTROLLER_PATH_PATTERN.test(pathname);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ exports[`SidebarList should match snapshot 1`] = `
|
|||
<Fragment>
|
||||
<GlobalThreadsLink />
|
||||
<DraftsLink />
|
||||
<RecapsLink />
|
||||
<div
|
||||
aria-label="channel sidebar region"
|
||||
className="SidebarNavContainer a11y__region"
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import type {StaticPage} from 'types/store/lhs';
|
|||
|
||||
const DraftsLink = makeAsyncComponent('DraftsLink', lazy(() => import('components/drafts/drafts_link/drafts_link')));
|
||||
const GlobalThreadsLink = makeAsyncComponent('GlobalThreadsLink', lazy(() => import('components/threading/global_threads_link')));
|
||||
const RecapsLink = makeAsyncComponent('RecapsLink', lazy(() => import('components/recaps_link')));
|
||||
const UnreadChannelIndicator = makeAsyncComponent('UnreadChannelIndicator', lazy(() => import('../unread_channel_indicator')));
|
||||
const UnreadChannels = makeAsyncComponent('UnreadChannels', lazy(() => import('../unread_channels')));
|
||||
|
||||
|
|
@ -495,6 +496,7 @@ export class SidebarList extends React.PureComponent<Props, State> {
|
|||
<>
|
||||
<GlobalThreadsLink/>
|
||||
<DraftsLink/>
|
||||
<RecapsLink/>
|
||||
<div
|
||||
id='sidebar-left'
|
||||
role='application'
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ const Control = <T extends OptionType>(props: ControlProps<T, false>) => (
|
|||
</div>
|
||||
);
|
||||
|
||||
const Option = <T extends OptionType>(props: OptionProps<T, false, GroupBase<T>>) => (
|
||||
const Option = <T extends OptionType>(props: OptionProps<T, false, GroupBase<T>>): JSX.Element => (
|
||||
<div
|
||||
className={classNames('DropdownInput__option', {
|
||||
selected: props.isSelected,
|
||||
|
|
|
|||
|
|
@ -4559,6 +4559,8 @@
|
|||
"generic_icons.warning": "Warning Icon",
|
||||
"generic_modal.cancel": "Cancel",
|
||||
"generic_modal.confirm": "Confirm",
|
||||
"generic_modal.next": "Next",
|
||||
"generic_modal.previous": "Previous",
|
||||
"generic.close": "Close",
|
||||
"generic.done": "Done",
|
||||
"generic.enterprise_feature": "Enterprise Feature",
|
||||
|
|
@ -5490,6 +5492,52 @@
|
|||
"reaction.usersAndOthersReacted": "{users} and {otherUsers, number} other {otherUsers, plural, one {user} other {users}}",
|
||||
"reaction.usersReacted": "{users} and {lastUser}",
|
||||
"reaction.you": "You",
|
||||
"recaps.actionItems": "Action items:",
|
||||
"recaps.addRecap": "Add a recap",
|
||||
"recaps.addRecap.disabled": "Agents Bridge is not enabled",
|
||||
"recaps.allCaughtUp": "You're all caught up",
|
||||
"recaps.channelMenu.ariaLabel": "Options for {channelName}",
|
||||
"recaps.defaultAgent": "Copilot",
|
||||
"recaps.delete.confirm.button": "Delete",
|
||||
"recaps.delete.confirm.message": "Are you sure you want to delete <strong>{title}</strong>? This action cannot be undone.",
|
||||
"recaps.delete.confirm.title": "Delete recap?",
|
||||
"recaps.emptyState.description": "You don't have any recaps yet. Create one to get started.",
|
||||
"recaps.emptyState.title": "You're all caught up",
|
||||
"recaps.generatedBy": "Generated by {agentName}",
|
||||
"recaps.highlights": "Highlights",
|
||||
"recaps.markRead": "Mark read",
|
||||
"recaps.menu.ariaLabel": "Options for {title}",
|
||||
"recaps.menu.markAllChannelsRead": "Mark all channels as read",
|
||||
"recaps.menu.markChannelRead": "Mark this channel as read",
|
||||
"recaps.menu.openChannel": "Open channel",
|
||||
"recaps.menu.regenerateRecap": "Regenerate this recap",
|
||||
"recaps.messageCount": "Recapped {count} {count, plural, one {message} other {messages}}",
|
||||
"recaps.modal.allChannels": "ALL CHANNELS",
|
||||
"recaps.modal.allUnreads": "Recap all my unreads",
|
||||
"recaps.modal.allUnreadsDesc": "Copilot will create a recap of all unreads across your channels.",
|
||||
"recaps.modal.error.createFailed": "Failed to create recap. Please try again.",
|
||||
"recaps.modal.error.noBot": "Please select an AI agent.",
|
||||
"recaps.modal.error.noChannels": "Please select at least one channel.",
|
||||
"recaps.modal.nameLabel": "Give your recap a name",
|
||||
"recaps.modal.namePlaceholder": "Give your recap a name",
|
||||
"recaps.modal.noChannels": "No channels found",
|
||||
"recaps.modal.noUnreadsAvailable": "No unread channels available",
|
||||
"recaps.modal.noUnreadsAvailableHint": "You currently have no unread messages in any channels",
|
||||
"recaps.modal.recommended": "RECOMMENDED",
|
||||
"recaps.modal.selectChannels": "Select the channels you want to include",
|
||||
"recaps.modal.selectedChannels": "Recap selected channels",
|
||||
"recaps.modal.selectedChannelsDesc": "Choose the channels you would like included in your recap",
|
||||
"recaps.modal.startRecap": "Start recap",
|
||||
"recaps.modal.summaryTitle": "The following channels will be included in your recap",
|
||||
"recaps.modal.title": "Set up your recap",
|
||||
"recaps.modal.typeLabel": "What type of recap would you like?",
|
||||
"recaps.processing.message": "We're working on your recap. Check back shortly",
|
||||
"recaps.processing.subtitle": "Recap created. You'll receive a summary shortly",
|
||||
"recaps.readTab": "Read",
|
||||
"recaps.sidebarLink": "Recaps",
|
||||
"recaps.status.failed": "Failed",
|
||||
"recaps.title": "Recaps",
|
||||
"recaps.unreadTab": "Unread",
|
||||
"remove_flag_post_confirm_modal.optional_comment.title": "Comment (optional)",
|
||||
"remove_flag_post_confirm_modal.required_comment.title": "Comment (required)",
|
||||
"remove_group_confirm_button": "Yes, Remove Group and {memberCount, plural, one {Member} other {Members}}",
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import PlaybookType from './playbooks';
|
|||
import PluginTypes from './plugins';
|
||||
import PostTypes from './posts';
|
||||
import PreferenceTypes from './preferences';
|
||||
import RecapTypes from './recaps';
|
||||
import RoleTypes from './roles';
|
||||
import SchemeTypes from './schemes';
|
||||
import ScheduledPostTypes from './scheudled_posts';
|
||||
|
|
@ -44,6 +45,7 @@ export {
|
|||
PostTypes,
|
||||
FileTypes,
|
||||
PreferenceTypes,
|
||||
RecapTypes,
|
||||
IntegrationTypes,
|
||||
EmojiTypes,
|
||||
AdminTypes,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import keyMirror from 'mattermost-redux/utils/key_mirror';
|
||||
|
||||
export default keyMirror({
|
||||
CREATE_RECAP_REQUEST: null,
|
||||
CREATE_RECAP_SUCCESS: null,
|
||||
CREATE_RECAP_FAILURE: null,
|
||||
|
||||
RECEIVED_RECAP: null,
|
||||
RECEIVED_RECAPS: null,
|
||||
|
||||
GET_RECAP_REQUEST: null,
|
||||
GET_RECAP_SUCCESS: null,
|
||||
GET_RECAP_FAILURE: null,
|
||||
|
||||
GET_RECAPS_REQUEST: null,
|
||||
GET_RECAPS_SUCCESS: null,
|
||||
GET_RECAPS_FAILURE: null,
|
||||
|
||||
MARK_RECAP_READ_REQUEST: null,
|
||||
MARK_RECAP_READ_SUCCESS: null,
|
||||
MARK_RECAP_READ_FAILURE: null,
|
||||
|
||||
DELETE_RECAP_REQUEST: null,
|
||||
DELETE_RECAP_SUCCESS: null,
|
||||
DELETE_RECAP_FAILURE: null,
|
||||
|
||||
REGENERATE_RECAP_REQUEST: null,
|
||||
REGENERATE_RECAP_SUCCESS: null,
|
||||
REGENERATE_RECAP_FAILURE: null,
|
||||
});
|
||||
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Recap} from '@mattermost/types/recaps';
|
||||
|
||||
import {RecapTypes} from 'mattermost-redux/action_types';
|
||||
import {logError} from 'mattermost-redux/actions/errors';
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
import type {ActionFuncAsync} from 'mattermost-redux/types/actions';
|
||||
|
||||
import {bindClientFunc, forceLogoutIfNecessary} from './helpers';
|
||||
|
||||
export function createRecap(title: string, channelIds: string[], agentId: string): ActionFuncAsync<Recap> {
|
||||
return bindClientFunc({
|
||||
clientFunc: () => Client4.createRecap({title, channel_ids: channelIds, agent_id: agentId}),
|
||||
onRequest: RecapTypes.CREATE_RECAP_REQUEST,
|
||||
onSuccess: [RecapTypes.CREATE_RECAP_SUCCESS, RecapTypes.RECEIVED_RECAP],
|
||||
onFailure: RecapTypes.CREATE_RECAP_FAILURE,
|
||||
});
|
||||
}
|
||||
|
||||
export function getRecaps(page = 0, perPage = 60): ActionFuncAsync<Recap[]> {
|
||||
return bindClientFunc({
|
||||
clientFunc: () => Client4.getRecaps(page, perPage),
|
||||
onRequest: RecapTypes.GET_RECAPS_REQUEST,
|
||||
onSuccess: [RecapTypes.GET_RECAPS_SUCCESS, RecapTypes.RECEIVED_RECAPS],
|
||||
onFailure: RecapTypes.GET_RECAPS_FAILURE,
|
||||
});
|
||||
}
|
||||
|
||||
export function getRecap(recapId: string): ActionFuncAsync<Recap> {
|
||||
return bindClientFunc({
|
||||
clientFunc: () => Client4.getRecap(recapId),
|
||||
onRequest: RecapTypes.GET_RECAP_REQUEST,
|
||||
onSuccess: [RecapTypes.GET_RECAP_SUCCESS, RecapTypes.RECEIVED_RECAP],
|
||||
onFailure: RecapTypes.GET_RECAP_FAILURE,
|
||||
});
|
||||
}
|
||||
|
||||
export function markRecapAsRead(recapId: string): ActionFuncAsync<Recap> {
|
||||
return bindClientFunc({
|
||||
clientFunc: () => Client4.markRecapAsRead(recapId),
|
||||
onRequest: RecapTypes.MARK_RECAP_READ_REQUEST,
|
||||
onSuccess: [RecapTypes.MARK_RECAP_READ_SUCCESS, RecapTypes.RECEIVED_RECAP],
|
||||
onFailure: RecapTypes.MARK_RECAP_READ_FAILURE,
|
||||
});
|
||||
}
|
||||
|
||||
export function regenerateRecap(recapId: string): ActionFuncAsync<Recap> {
|
||||
return bindClientFunc({
|
||||
clientFunc: () => Client4.regenerateRecap(recapId),
|
||||
onRequest: RecapTypes.REGENERATE_RECAP_REQUEST,
|
||||
onSuccess: [RecapTypes.REGENERATE_RECAP_SUCCESS, RecapTypes.RECEIVED_RECAP],
|
||||
onFailure: RecapTypes.REGENERATE_RECAP_FAILURE,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteRecap(recapId: string): ActionFuncAsync {
|
||||
return async (dispatch, getState) => {
|
||||
dispatch({type: RecapTypes.DELETE_RECAP_REQUEST, data: recapId});
|
||||
|
||||
try {
|
||||
await Client4.deleteRecap(recapId);
|
||||
|
||||
dispatch({
|
||||
type: RecapTypes.DELETE_RECAP_SUCCESS,
|
||||
data: {recapId},
|
||||
});
|
||||
|
||||
return {data: true};
|
||||
} catch (error) {
|
||||
dispatch(logError(error));
|
||||
forceLogoutIfNecessary(error, dispatch, getState);
|
||||
dispatch({
|
||||
type: RecapTypes.DELETE_RECAP_FAILURE,
|
||||
error,
|
||||
});
|
||||
return {error};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ import jobs from './jobs';
|
|||
import limits from './limits';
|
||||
import posts from './posts';
|
||||
import preferences from './preferences';
|
||||
import recaps from './recaps';
|
||||
import roles from './roles';
|
||||
import scheduledPosts from './scheduled_posts';
|
||||
import schemes from './schemes';
|
||||
|
|
@ -43,6 +44,7 @@ export default combineReducers({
|
|||
posts,
|
||||
files,
|
||||
preferences,
|
||||
recaps,
|
||||
typing,
|
||||
integrations,
|
||||
emojis,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Recap} from '@mattermost/types/recaps';
|
||||
|
||||
import type {MMReduxAction} from 'mattermost-redux/action_types';
|
||||
import {RecapTypes, UserTypes} from 'mattermost-redux/action_types';
|
||||
|
||||
export type RecapsState = {
|
||||
byId: Record<string, Recap>;
|
||||
allIds: string[];
|
||||
};
|
||||
|
||||
const initialState: RecapsState = {
|
||||
byId: {},
|
||||
allIds: [],
|
||||
};
|
||||
|
||||
export default function recapsReducer(state = initialState, action: MMReduxAction): RecapsState {
|
||||
switch (action.type) {
|
||||
case RecapTypes.RECEIVED_RECAP: {
|
||||
const recap = action.data as Recap;
|
||||
const nextState = {...state};
|
||||
nextState.byId = {
|
||||
...state.byId,
|
||||
[recap.id]: recap,
|
||||
};
|
||||
if (!state.allIds.includes(recap.id)) {
|
||||
nextState.allIds = [...state.allIds, recap.id];
|
||||
}
|
||||
return nextState;
|
||||
}
|
||||
|
||||
case RecapTypes.RECEIVED_RECAPS: {
|
||||
const recaps = action.data as Recap[];
|
||||
const nextState = {...state};
|
||||
const newById: Record<string, Recap> = {...state.byId};
|
||||
const newAllIds = new Set(state.allIds);
|
||||
|
||||
recaps.forEach((recap) => {
|
||||
newById[recap.id] = recap;
|
||||
newAllIds.add(recap.id);
|
||||
});
|
||||
|
||||
nextState.byId = newById;
|
||||
nextState.allIds = Array.from(newAllIds);
|
||||
return nextState;
|
||||
}
|
||||
|
||||
case RecapTypes.DELETE_RECAP_SUCCESS: {
|
||||
const {recapId} = action.data as {recapId: string};
|
||||
const nextState = {...state};
|
||||
const newById = {...state.byId};
|
||||
delete newById[recapId];
|
||||
|
||||
nextState.byId = newById;
|
||||
nextState.allIds = state.allIds.filter((id) => id !== recapId);
|
||||
return nextState;
|
||||
}
|
||||
|
||||
case UserTypes.LOGOUT_SUCCESS:
|
||||
return initialState;
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Recap} from '@mattermost/types/recaps';
|
||||
import {RecapStatus} from '@mattermost/types/recaps';
|
||||
import type {GlobalState} from '@mattermost/types/store';
|
||||
|
||||
import {createSelector} from 'mattermost-redux/selectors/create_selector';
|
||||
|
||||
export function getAllRecaps(state: GlobalState): Recap[] {
|
||||
const {byId, allIds} = state.entities.recaps;
|
||||
return allIds.map((id) => byId[id]);
|
||||
}
|
||||
|
||||
export function getRecap(state: GlobalState, recapId: string): Recap | undefined {
|
||||
return state.entities.recaps.byId[recapId] || undefined;
|
||||
}
|
||||
|
||||
export const getRecapsByStatus = createSelector(
|
||||
'getRecapsByStatus',
|
||||
getAllRecaps,
|
||||
(_state: GlobalState, status: RecapStatus) => status,
|
||||
(recaps, status) => {
|
||||
return recaps.filter((recap) => recap.status === status);
|
||||
},
|
||||
);
|
||||
|
||||
export const getSortedRecaps = createSelector(
|
||||
'getSortedRecaps',
|
||||
getAllRecaps,
|
||||
(recaps) => {
|
||||
return [...recaps].sort((a, b) => b.create_at - a.create_at);
|
||||
},
|
||||
);
|
||||
|
||||
export const getCompletedRecaps = createSelector(
|
||||
'getCompletedRecaps',
|
||||
getAllRecaps,
|
||||
(recaps) => {
|
||||
return recaps.filter((recap) => recap.status === RecapStatus.COMPLETED).sort((a, b) => b.create_at - a.create_at);
|
||||
},
|
||||
);
|
||||
|
||||
export const getPendingRecaps = createSelector(
|
||||
'getPendingRecaps',
|
||||
getAllRecaps,
|
||||
(recaps) => {
|
||||
return recaps.filter((recap) => recap.status === RecapStatus.PENDING || recap.status === RecapStatus.PROCESSING);
|
||||
},
|
||||
);
|
||||
|
||||
export const getUnreadRecaps = createSelector(
|
||||
'getUnreadRecaps',
|
||||
getAllRecaps,
|
||||
(recaps) => {
|
||||
return recaps.filter((recap) => recap.read_at === 0).sort((a, b) => b.create_at - a.create_at);
|
||||
},
|
||||
);
|
||||
|
||||
export const getReadRecaps = createSelector(
|
||||
'getReadRecaps',
|
||||
getAllRecaps,
|
||||
(recaps) => {
|
||||
return recaps.filter((recap) => recap.read_at > 0).sort((a, b) => b.read_at - a.read_at);
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -102,6 +102,10 @@ const state: GlobalState = {
|
|||
counts: {},
|
||||
countsIncludingDirect: {},
|
||||
},
|
||||
recaps: {
|
||||
byId: {},
|
||||
allIds: [],
|
||||
},
|
||||
preferences: {
|
||||
myPreferences: {},
|
||||
userPreferences: {},
|
||||
|
|
|
|||
|
|
@ -480,6 +480,7 @@ export const ModalIdentifiers = {
|
|||
ATTRIBUTE_MODAL_SAML: 'attribute_modal_saml',
|
||||
FLAG_POST: 'flag_post',
|
||||
REMOVE_FLAGGED_POST: 'remove_flagged_post',
|
||||
CREATE_RECAP_MODAL: 'create_recap_modal',
|
||||
};
|
||||
|
||||
export const UserStatuses = {
|
||||
|
|
@ -701,6 +702,7 @@ export const SocketEvents = {
|
|||
CPA_FIELD_DELETED: 'custom_profile_attributes_field_deleted',
|
||||
CPA_VALUES_UPDATED: 'custom_profile_attributes_values_updated',
|
||||
CONTENT_FLAGGING_REPORT_VALUE_CHANGED: 'content_flagging_report_value_updated',
|
||||
RECAP_UPDATED: 'recap_updated',
|
||||
};
|
||||
|
||||
export const TutorialSteps = {
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@ import type {
|
|||
PropertyValue,
|
||||
} from '@mattermost/types/properties';
|
||||
import type {Reaction} from '@mattermost/types/reactions';
|
||||
import type {Recap, CreateRecapRequest} from '@mattermost/types/recaps';
|
||||
import type {RemoteCluster, RemoteClusterAcceptInvite, RemoteClusterPatch, RemoteClusterWithPassword} from '@mattermost/types/remote_clusters';
|
||||
import type {UserReport, UserReportFilter, UserReportOptions} from '@mattermost/types/reports';
|
||||
import type {Role} from '@mattermost/types/roles';
|
||||
|
|
@ -452,6 +453,10 @@ export default class Client4 {
|
|||
return `${this.getBaseRoute()}/jobs`;
|
||||
}
|
||||
|
||||
getRecapsRoute() {
|
||||
return `${this.getBaseRoute()}/recaps`;
|
||||
}
|
||||
|
||||
getPluginsRoute() {
|
||||
return `${this.getBaseRoute()}/plugins`;
|
||||
}
|
||||
|
|
@ -3296,6 +3301,49 @@ export default class Client4 {
|
|||
);
|
||||
};
|
||||
|
||||
// Recaps Routes
|
||||
createRecap = (request: CreateRecapRequest) => {
|
||||
return this.doFetch<Recap>(
|
||||
`${this.getRecapsRoute()}`,
|
||||
{method: 'post', body: JSON.stringify(request)},
|
||||
);
|
||||
};
|
||||
|
||||
getRecaps = (page = 0, perPage = PER_PAGE_DEFAULT) => {
|
||||
return this.doFetch<Recap[]>(
|
||||
`${this.getRecapsRoute()}${buildQueryString({page, per_page: perPage})}`,
|
||||
{method: 'get'},
|
||||
);
|
||||
};
|
||||
|
||||
getRecap = (recapId: string) => {
|
||||
return this.doFetch<Recap>(
|
||||
`${this.getRecapsRoute()}/${recapId}`,
|
||||
{method: 'get'},
|
||||
);
|
||||
};
|
||||
|
||||
markRecapAsRead = (recapId: string) => {
|
||||
return this.doFetch<Recap>(
|
||||
`${this.getRecapsRoute()}/${recapId}/read`,
|
||||
{method: 'post'},
|
||||
);
|
||||
};
|
||||
|
||||
regenerateRecap = (recapId: string) => {
|
||||
return this.doFetch<Recap>(
|
||||
`${this.getRecapsRoute()}/${recapId}/regenerate`,
|
||||
{method: 'post'},
|
||||
);
|
||||
};
|
||||
|
||||
deleteRecap = (recapId: string) => {
|
||||
return this.doFetch<StatusOK>(
|
||||
`${this.getRecapsRoute()}/${recapId}`,
|
||||
{method: 'delete'},
|
||||
);
|
||||
};
|
||||
|
||||
// Admin Routes
|
||||
|
||||
getLogs = (logFilter: LogFilterQuery) => {
|
||||
|
|
|
|||
41
webapp/platform/types/src/recaps.ts
Normal file
41
webapp/platform/types/src/recaps.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
export type Recap = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
title: string;
|
||||
create_at: number;
|
||||
update_at: number;
|
||||
delete_at: number;
|
||||
read_at: number;
|
||||
total_message_count: number;
|
||||
status: RecapStatus;
|
||||
bot_id: string;
|
||||
channels?: RecapChannel[];
|
||||
};
|
||||
|
||||
export type RecapChannel = {
|
||||
id: string;
|
||||
recap_id: string;
|
||||
channel_id: string;
|
||||
channel_name: string;
|
||||
highlights: string[];
|
||||
action_items: string[];
|
||||
source_post_ids: string[];
|
||||
create_at: number;
|
||||
};
|
||||
|
||||
export type CreateRecapRequest = {
|
||||
title: string;
|
||||
channel_ids: string[];
|
||||
agent_id: string;
|
||||
};
|
||||
|
||||
export enum RecapStatus {
|
||||
PENDING = 'pending',
|
||||
PROCESSING = 'processing',
|
||||
COMPLETED = 'completed',
|
||||
FAILED = 'failed',
|
||||
}
|
||||
|
||||
|
|
@ -19,6 +19,7 @@ import type {JobsState} from './jobs';
|
|||
import type {LimitsState} from './limits';
|
||||
import type {PostsState} from './posts';
|
||||
import type {PreferenceType} from './preferences';
|
||||
import type {Recap} from './recaps';
|
||||
import type {
|
||||
AdminRequestsStatuses, ChannelsRequestsStatuses,
|
||||
FilesRequestsStatuses, GeneralRequestsStatuses,
|
||||
|
|
@ -45,6 +46,10 @@ export type GlobalState = {
|
|||
channelBookmarks: ChannelBookmarksState;
|
||||
posts: PostsState;
|
||||
threads: ThreadsState;
|
||||
recaps: {
|
||||
byId: Record<string, Recap>;
|
||||
allIds: string[];
|
||||
};
|
||||
agents: {
|
||||
agents: Array<{
|
||||
id: string;
|
||||
|
|
|
|||
Loading…
Reference in a new issue