diff --git a/app/controllers/discourse_ai/sentiment/sentiment_controller.rb b/app/controllers/discourse_ai/sentiment/sentiment_controller.rb index 6fbe3fd2..5173c007 100644 --- a/app/controllers/discourse_ai/sentiment/sentiment_controller.rb +++ b/app/controllers/discourse_ai/sentiment/sentiment_controller.rb @@ -6,6 +6,9 @@ module DiscourseAi include Constants requires_plugin ::DiscourseAi::PLUGIN_NAME + DEFAULT_POSTS_LIMIT = 50 + MAX_POSTS_LIMIT = 100 + def posts group_by = params.required(:group_by)&.to_sym group_value = params.required(:group_value).presence @@ -15,10 +18,13 @@ module DiscourseAi raise Discourse::InvalidParameters if %i[category tag].exclude?(group_by) + limit = fetch_limit_from_params(default: DEFAULT_POSTS_LIMIT, max: MAX_POSTS_LIMIT) + offset = params[:offset].to_i || 0 + case group_by when :category grouping_clause = "c.name" - grouping_join = "INNER JOIN categories c ON c.id = t.category_id" + grouping_join = "" # categories already joined when :tag grouping_clause = "tags.name" grouping_join = @@ -38,6 +44,11 @@ module DiscourseAi u.username, u.name, u.uploaded_avatar_id, + c.id AS category_id, + c.name AS category_name, + c.color AS category_color, + c.slug AS category_slug, + c.description AS category_description, (CASE WHEN (cr.classification::jsonb->'positive')::float > :threshold THEN 'positive' WHEN (cr.classification::jsonb->'negative')::float > :threshold THEN 'negative' @@ -47,6 +58,7 @@ module DiscourseAi INNER JOIN topics t ON t.id = p.topic_id INNER JOIN classification_results cr ON cr.target_id = p.id AND cr.target_type = 'Post' LEFT JOIN users u ON u.id = p.user_id + LEFT JOIN categories c ON c.id = t.category_id #{grouping_join} WHERE #{grouping_clause} = :group_value AND @@ -56,22 +68,31 @@ module DiscourseAi ((:start_date IS NULL OR p.created_at > :start_date) AND (:end_date IS NULL OR p.created_at < :end_date)) AND p.deleted_at IS NULL ORDER BY p.created_at DESC + LIMIT :limit OFFSET :offset SQL group_value: group_value, start_date: start_date, end_date: end_date, threshold: threshold, + limit: limit + 1, + offset: offset, ) + has_more = posts.length > limit + posts.pop if has_more + render_json_dump( - serialize_data( - posts, - AiSentimentPostSerializer, - scope: guardian, - add_raw: true, - add_excerpt: true, - add_title: true, - ), + posts: + serialize_data( + posts, + AiSentimentPostSerializer, + scope: guardian, + add_raw: true, + add_excerpt: true, + add_title: true, + ), + has_more: has_more, + next_offset: has_more ? offset + limit : nil, ) end end diff --git a/app/serializers/ai_sentiment_post_serializer.rb b/app/serializers/ai_sentiment_post_serializer.rb index 264f6df2..f6420352 100644 --- a/app/serializers/ai_sentiment_post_serializer.rb +++ b/app/serializers/ai_sentiment_post_serializer.rb @@ -10,7 +10,8 @@ class AiSentimentPostSerializer < ApplicationSerializer :avatar_template, :excerpt, :sentiment, - :truncated + :truncated, + :category def avatar_template User.avatar_template(object.username, object.uploaded_avatar_id) @@ -23,4 +24,14 @@ class AiSentimentPostSerializer < ApplicationSerializer def truncated true end + + def category + { + id: object.category_id, + name: object.category_name, + color: object.category_color, + slug: object.category_slug, + description: object.category_description, + } + end end diff --git a/assets/javascripts/discourse/components/admin-report-sentiment-analysis.gjs b/assets/javascripts/discourse/components/admin-report-sentiment-analysis.gjs index 77e29a9b..7dcda1ae 100644 --- a/assets/javascripts/discourse/components/admin-report-sentiment-analysis.gjs +++ b/assets/javascripts/discourse/components/admin-report-sentiment-analysis.gjs @@ -2,7 +2,11 @@ import Component from "@glimmer/component"; import { tracked } from "@glimmer/tracking"; import { fn, hash } from "@ember/helper"; import { on } from "@ember/modifier"; -import { action, get } from "@ember/object"; +import { action } from "@ember/object"; +import { modifier } from "ember-modifier"; +import { and } from "truth-helpers"; +import DButton from "discourse/components/d-button"; +import HorizontalOverflowNav from "discourse/components/horizontal-overflow-nav"; import PostList from "discourse/components/post-list"; import dIcon from "discourse/helpers/d-icon"; import { ajax } from "discourse/lib/ajax"; @@ -15,15 +19,88 @@ import DoughnutChart from "discourse/plugins/discourse-ai/discourse/components/d export default class AdminReportSentimentAnalysis extends Component { @tracked selectedChart = null; @tracked posts = null; + @tracked hasMorePosts = false; + @tracked nextOffset = 0; + @tracked showingSelectedChart = false; + @tracked activeFilter = "all"; - get colors() { - return ["#2ecc71", "#95a5a6", "#e74c3c"]; + setActiveFilter = modifier((element) => { + this.clearActiveFilters(element); + element + .querySelector(`li[data-filter-type="${this.activeFilter}"] button`) + .classList.add("active"); + }); + + clearActiveFilters(element) { + const filterButtons = element.querySelectorAll("li button"); + for (let button of filterButtons) { + button.classList.remove("active"); + } } calculateNeutralScore(data) { return data.total_count - (data.positive_count + data.negative_count); } + sentimentMapping(sentiment) { + switch (sentiment) { + case "positive": + return { + id: "positive", + text: i18n( + "discourse_ai.sentiments.sentiment_analysis.filter_types.positive" + ), + icon: "face-smile", + }; + case "neutral": + return { + id: "neutral", + text: i18n( + "discourse_ai.sentiments.sentiment_analysis.filter_types.neutral" + ), + icon: "face-meh", + }; + case "negative": + return { + id: "negative", + text: i18n( + "discourse_ai.sentiments.sentiment_analysis.filter_types.negative" + ), + icon: "face-angry", + }; + } + } + + doughnutTitle(data) { + const MAX_TITLE_LENGTH = 18; + const title = data?.title || ""; + const score = data?.total_score ? ` (${data.total_score})` : ""; + + if (title.length + score.length > MAX_TITLE_LENGTH) { + return ( + title.substring(0, MAX_TITLE_LENGTH - score.length) + "..." + score + ); + } + + return title + score; + } + + async postRequest() { + return await ajax("/discourse-ai/sentiment/posts", { + data: { + group_by: this.currentGroupFilter, + group_value: this.selectedChart?.title, + start_date: this.args.model.start_date, + end_date: this.args.model.end_date, + offset: this.nextOffset, + }, + }); + } + + get colors() { + return ["#2ecc71", "#95a5a6", "#e74c3c"]; + } + get currentGroupFilter() { return this.args.model.available_filters.find( (filter) => filter.id === "group_by" @@ -50,120 +127,179 @@ export default class AdminReportSentimentAnalysis extends Component { }); } + get filteredPosts() { + if (!this.posts || !this.posts.length) { + return []; + } + + return this.posts.filter((post) => { + if (this.activeFilter === "all") { + return true; + } + + return post.sentiment === this.activeFilter; + }); + } + + get postFilters() { + return [ + { + id: "all", + text: `${i18n( + "discourse_ai.sentiments.sentiment_analysis.filter_types.all" + )} (${this.selectedChart.total_score})`, + icon: "bars-staggered", + action: () => { + this.activeFilter = "all"; + }, + }, + { + id: "positive", + text: `${i18n( + "discourse_ai.sentiments.sentiment_analysis.filter_types.positive" + )} (${this.selectedChart.scores[0]})`, + icon: "face-smile", + action: () => { + this.activeFilter = "positive"; + }, + }, + { + id: "neutral", + text: `${i18n( + "discourse_ai.sentiments.sentiment_analysis.filter_types.neutral" + )} (${this.selectedChart.scores[1]})`, + icon: "face-meh", + action: () => { + this.activeFilter = "neutral"; + }, + }, + { + id: "negative", + text: `${i18n( + "discourse_ai.sentiments.sentiment_analysis.filter_types.negative" + )} (${this.selectedChart.scores[2]})`, + icon: "face-angry", + action: () => { + this.activeFilter = "negative"; + }, + }, + ]; + } + @action async showDetails(data) { - this.selectedChart = data; - try { - const posts = await ajax(`/discourse-ai/sentiment/posts`, { - data: { - group_by: this.currentGroupFilter, - group_value: data.title, - start_date: this.args.model.start_date, - end_date: this.args.model.end_date, - }, - }); + if (this.selectedChart === data) { + // Don't do anything if the same chart is clicked again + return; + } - this.posts = posts.map((post) => Post.create(post)); + this.selectedChart = data; + this.showingSelectedChart = true; + + try { + const response = await this.postRequest(); + this.posts = response.posts.map((post) => Post.create(post)); + this.hasMorePosts = response.has_more; + this.nextOffset = response.next_offset; } catch (e) { popupAjaxError(e); } } - sentimentMapping(sentiment) { - switch (sentiment) { - case "positive": - return { - id: "positive", - text: i18n( - "discourse_ai.sentiments.sentiment_analysis.score_types.positive" - ), - icon: "face-smile", - }; - case "neutral": - return { - id: "neutral", - text: i18n( - "discourse_ai.sentiments.sentiment_analysis.score_types.neutral" - ), - icon: "face-meh", - }; - case "negative": - return { - id: "negative", - text: i18n( - "discourse_ai.sentiments.sentiment_analysis.score_types.negative" - ), - icon: "face-angry", - }; + @action + async fetchMorePosts() { + if (!this.hasMorePosts || this.selectedChart === null) { + return []; + } + + try { + const response = await this.postRequest(); + + this.hasMorePosts = response.has_more; + this.nextOffset = response.next_offset; + return response.posts.map((post) => Post.create(post)); + } catch (e) { + popupAjaxError(e); } } - doughnutTitle(data) { - if (data?.total_score) { - return `${data.title} (${data.total_score})`; - } else { - return data.title; - } + @action + backToAllCharts() { + this.showingSelectedChart = false; + this.selectedChart = null; + this.activeFilter = "all"; }