mirror of
https://ghfast.top/https://github.com/discourse/discourse-ai.git
synced 2026-07-18 11:49:33 +08:00
The sentiment analysis report page initially showcases sentiments via category/tag by doughnut visualizations. However, this isn't an optimal view for quickly scanning and comparing each result. This PR updates the overview to include a table visualization with horizontal bars to represent sentiment analysis instead of doughnuts. Doughnut visualizations are still maintained however when accessing the sentiment data in the drill down for individual entries. This approach is an intermediary step, as we will eventually add whole clustering and sizing visualization instead of a table. As such, no relevant tests are added in this PR.
450 lines
13 KiB
Text
450 lines
13 KiB
Text
import Component from "@glimmer/component";
|
|
import { tracked } from "@glimmer/tracking";
|
|
import { fn, hash } from "@ember/helper";
|
|
import { on } from "@ember/modifier";
|
|
import { action } from "@ember/object";
|
|
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
|
import { service } from "@ember/service";
|
|
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 bodyClass from "discourse/helpers/body-class";
|
|
import dIcon from "discourse/helpers/d-icon";
|
|
import replaceEmoji from "discourse/helpers/replace-emoji";
|
|
import { ajax } from "discourse/lib/ajax";
|
|
import { popupAjaxError } from "discourse/lib/ajax-error";
|
|
import { getAbsoluteURL } from "discourse/lib/get-url";
|
|
import discourseLater from "discourse/lib/later";
|
|
import { clipboardCopy } from "discourse/lib/utilities";
|
|
import Post from "discourse/models/post";
|
|
import closeOnClickOutside from "discourse/modifiers/close-on-click-outside";
|
|
import { i18n } from "discourse-i18n";
|
|
import DTooltip from "float-kit/components/d-tooltip";
|
|
import DoughnutChart from "discourse/plugins/discourse-ai/discourse/components/doughnut-chart";
|
|
import AiSentimentHorizontalBar from "../components/ai-sentiment-horizontal-bar";
|
|
|
|
export default class AdminReportSentimentAnalysis extends Component {
|
|
@service router;
|
|
|
|
@tracked selectedChart = null;
|
|
@tracked posts = [];
|
|
@tracked hasMorePosts = false;
|
|
@tracked nextOffset = 0;
|
|
@tracked showingSelectedChart = false;
|
|
@tracked activeFilter = "all";
|
|
@tracked shareIcon = "link";
|
|
|
|
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",
|
|
};
|
|
}
|
|
}
|
|
|
|
get groupingType() {
|
|
const dataSample = this.args.model.data[0];
|
|
const localePrefix =
|
|
"discourse_ai.sentiments.sentiment_analysis.group_types";
|
|
return dataSample.category_name
|
|
? i18n(`${localePrefix}.category`)
|
|
: i18n(`${localePrefix}.tag`);
|
|
}
|
|
|
|
get colors() {
|
|
return ["#2ecc71", "#95a5a6", "#e74c3c"];
|
|
}
|
|
|
|
get currentGroupFilter() {
|
|
return this.args.model.available_filters.find(
|
|
(filter) => filter.id === "group_by"
|
|
).default;
|
|
}
|
|
|
|
get currentSortFilter() {
|
|
return this.args.model.available_filters.find(
|
|
(filter) => filter.id === "sort_by"
|
|
).default;
|
|
}
|
|
|
|
get transformedData() {
|
|
return this.args.model.data.map((data) => {
|
|
return {
|
|
title: data.category_name || data.tag_name,
|
|
scores: [
|
|
data.positive_count,
|
|
this.calculateNeutralScore(data),
|
|
data.negative_count,
|
|
],
|
|
score_map: {
|
|
positive: data.positive_count,
|
|
neutral: this.calculateNeutralScore(data),
|
|
negative: data.negative_count,
|
|
},
|
|
total_score: data.total_count,
|
|
widths: {
|
|
positive: (data.positive_count / data.total_count) * 100,
|
|
neutral: (this.calculateNeutralScore(data) / data.total_count) * 100,
|
|
negative: (data.negative_count / data.total_count) * 100,
|
|
},
|
|
};
|
|
});
|
|
}
|
|
|
|
get filteredPosts() {
|
|
if (!this.posts || !this.posts.length) {
|
|
return [];
|
|
}
|
|
|
|
return this.posts.filter((post) => {
|
|
post.topic_title = replaceEmoji(post.topic_title);
|
|
|
|
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";
|
|
},
|
|
},
|
|
];
|
|
}
|
|
|
|
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,
|
|
},
|
|
});
|
|
}
|
|
|
|
@action
|
|
async openToChart() {
|
|
const queryParams = this.router.currentRoute.queryParams;
|
|
if (queryParams.selectedChart) {
|
|
this.selectedChart = this.transformedData.find(
|
|
(data) => data.title === queryParams.selectedChart
|
|
);
|
|
|
|
if (!this.selectedChart) {
|
|
return;
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
@action
|
|
async showDetails(data) {
|
|
if (this.selectedChart === data) {
|
|
// Don't do anything if the same chart is clicked again
|
|
return;
|
|
}
|
|
|
|
const currentQueryParams = this.router.currentRoute.queryParams;
|
|
|
|
const currentFilters = currentQueryParams?.filters
|
|
? JSON.parse(currentQueryParams.filters)
|
|
: {};
|
|
|
|
this.router.transitionTo(this.router.currentRoute.name, {
|
|
queryParams: {
|
|
...currentQueryParams,
|
|
filters: currentFilters, // avoids a double escaping
|
|
selectedChart: data.title,
|
|
},
|
|
});
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
@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;
|
|
|
|
const mappedPosts = response.posts.map((post) => Post.create(post));
|
|
this.posts.pushObjects(mappedPosts);
|
|
return mappedPosts;
|
|
} catch (e) {
|
|
popupAjaxError(e);
|
|
}
|
|
}
|
|
|
|
@action
|
|
backToAllCharts() {
|
|
this.showingSelectedChart = false;
|
|
this.selectedChart = null;
|
|
this.activeFilter = "all";
|
|
this.posts = [];
|
|
|
|
const currentQueryParams = this.router.currentRoute.queryParams;
|
|
const currentFilters = currentQueryParams?.filters
|
|
? JSON.parse(currentQueryParams.filters)
|
|
: {};
|
|
this.router.transitionTo(this.router.currentRoute.name, {
|
|
queryParams: {
|
|
...currentQueryParams,
|
|
filters: currentFilters, // avoids a double escaping
|
|
selectedChart: null,
|
|
},
|
|
});
|
|
}
|
|
|
|
@action
|
|
shareChart() {
|
|
const url = this.router.currentURL;
|
|
if (!url) {
|
|
return;
|
|
}
|
|
|
|
clipboardCopy(getAbsoluteURL(url));
|
|
this.shareIcon = "check";
|
|
|
|
discourseLater(() => {
|
|
this.shareIcon = "link";
|
|
}, 2000);
|
|
}
|
|
|
|
<template>
|
|
<span {{didInsert this.openToChart}}></span>
|
|
|
|
{{#unless this.showingSelectedChart}}
|
|
<div class="admin-report-sentiment-analysis">
|
|
<table class="sentiment-analysis-table md-table">
|
|
<thead>
|
|
<th>{{this.groupingType}}</th>
|
|
<th>{{i18n
|
|
"discourse_ai.sentiments.sentiment_analysis.table.total_count"
|
|
}}</th>
|
|
<th>{{i18n
|
|
"discourse_ai.sentiments.sentiment_analysis.table.sentiment"
|
|
}}</th>
|
|
</thead>
|
|
|
|
<tbody>
|
|
{{#each this.transformedData as |data|}}
|
|
<tr
|
|
class="sentiment-analysis-table__row"
|
|
role="button"
|
|
{{on "click" (fn this.showDetails data)}}
|
|
{{closeOnClickOutside
|
|
(fn (mut this.selectedChart) null)
|
|
(hash
|
|
targetSelector=".admin-report-sentiment-analysis-details"
|
|
secondaryTargetSelector=".admin-report-sentiment-analysis"
|
|
)
|
|
}}
|
|
>
|
|
<td class="sentiment-analysis-table__title">{{data.title}}</td>
|
|
<td
|
|
class="sentiment-analysis-table__total-score"
|
|
>{{data.total_score}}</td>
|
|
<td class="sentiment-horizontal-bar">
|
|
<AiSentimentHorizontalBar
|
|
@type="positive"
|
|
@score={{data.score_map.positive}}
|
|
@width={{data.widths.positive}}
|
|
/>
|
|
<AiSentimentHorizontalBar
|
|
@type="negative"
|
|
@score={{data.score_map.negative}}
|
|
@width={{data.widths.negative}}
|
|
/>
|
|
<AiSentimentHorizontalBar
|
|
@type="neutral"
|
|
@score={{data.score_map.neutral}}
|
|
@width={{data.widths.neutral}}
|
|
/>
|
|
</td>
|
|
</tr>
|
|
{{/each}}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{{/unless}}
|
|
|
|
{{#if (and this.selectedChart this.showingSelectedChart)}}
|
|
{{bodyClass "showing-sentiment-analysis-chart"}}
|
|
<div class="admin-report-sentiment-analysis__selected-chart">
|
|
<div class="admin-report-sentiment-analysis__selected-chart-actions">
|
|
<DButton
|
|
@label="back_button"
|
|
@icon="chevron-left"
|
|
class="btn-flat"
|
|
@action={{this.backToAllCharts}}
|
|
/>
|
|
|
|
<DTooltip
|
|
class="share btn-flat"
|
|
@icon={{this.shareIcon}}
|
|
{{on "click" this.shareChart}}
|
|
@content={{i18n
|
|
"discourse_ai.sentiments.sentiment_analysis.share_chart"
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<DoughnutChart
|
|
@labels={{@model.labels}}
|
|
@colors={{this.colors}}
|
|
@data={{this.selectedChart.scores}}
|
|
@totalScore={{this.selectedChart.total_score}}
|
|
@doughnutTitle={{this.selectedChart.title}}
|
|
@displayLegend={{true}}
|
|
/>
|
|
|
|
</div>
|
|
<div class="admin-report-sentiment-analysis-details">
|
|
<HorizontalOverflowNav
|
|
{{this.setActiveFilter}}
|
|
class="admin-report-sentiment-analysis-details__filters"
|
|
>
|
|
{{#each this.postFilters as |filter|}}
|
|
<li data-filter-type={{filter.id}}>
|
|
<DButton
|
|
@icon={{filter.icon}}
|
|
@translatedLabel={{filter.text}}
|
|
@action={{filter.action}}
|
|
class="btn-transparent"
|
|
/>
|
|
</li>
|
|
{{/each}}
|
|
</HorizontalOverflowNav>
|
|
|
|
<PostList
|
|
@posts={{this.filteredPosts}}
|
|
@urlPath="url"
|
|
@idPath="post_id"
|
|
@titlePath="topic_title"
|
|
@usernamePath="username"
|
|
@fetchMorePosts={{this.fetchMorePosts}}
|
|
class="admin-report-sentiment-analysis-details__post-list"
|
|
>
|
|
<:abovePostItemExcerpt as |post|>
|
|
{{#let (this.sentimentMapping post.sentiment) as |sentiment|}}
|
|
<span
|
|
class="admin-report-sentiment-analysis-details__post-score"
|
|
data-sentiment-score={{sentiment.id}}
|
|
>
|
|
{{dIcon sentiment.icon}}
|
|
{{sentiment.text}}
|
|
</span>
|
|
{{/let}}
|
|
</:abovePostItemExcerpt>
|
|
</PostList>
|
|
</div>
|
|
{{/if}}
|
|
</template>
|
|
}
|