mirror of
https://github.com/discourse/discourse-ai.git
synced 2025-07-02 20:42:16 +00:00
UX: add filter to features page, update styles (#1471)
* UX: add filter to features page, update styles * merge fix * update toggle spec * test fix
This commit is contained in:
parent
57b00526f8
commit
262bd8b145
@ -44,14 +44,19 @@ class ExpandableList extends Component {
|
||||
this.isExpanded = !this.isExpanded;
|
||||
}
|
||||
|
||||
@action
|
||||
isLastItem(index) {
|
||||
return index === this.visibleItems.length - 1;
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#each this.visibleItems as |item index|}}
|
||||
{{yield item index}}
|
||||
{{yield item index this.isLastItem}}
|
||||
{{/each}}
|
||||
|
||||
{{#if this.hasMore}}
|
||||
<DButton
|
||||
class="btn-flat btn-small ai-expanded-list__toggle-button"
|
||||
class="btn-flat ai-expanded-list__toggle-button"
|
||||
@translatedLabel={{this.expandToggleLabel}}
|
||||
@action={{this.toggleExpanded}}
|
||||
/>
|
||||
@ -61,11 +66,11 @@ class ExpandableList extends Component {
|
||||
|
||||
export default class AiFeaturesList extends Component {
|
||||
get sortedModules() {
|
||||
return this.args.modules.sort((a, b) => {
|
||||
const nameA = i18n(`discourse_ai.features.${a.module_name}.name`);
|
||||
const nameB = i18n(`discourse_ai.features.${b.module_name}.name`);
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
if (!this.args.modules || !this.args.modules.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.args.modules.sortBy("module_name");
|
||||
}
|
||||
|
||||
@action
|
||||
@ -149,55 +154,71 @@ export default class AiFeaturesList extends Component {
|
||||
{{/unless}}
|
||||
</div>
|
||||
<div class="ai-feature-card__persona">
|
||||
<span>{{i18n
|
||||
<span class="ai-feature-card__label">
|
||||
{{i18n
|
||||
"discourse_ai.features.persona"
|
||||
count=feature.personas.length
|
||||
}}</span>
|
||||
}}
|
||||
</span>
|
||||
{{#if feature.personas}}
|
||||
<ExpandableList
|
||||
@items={{feature.personas}}
|
||||
@maxItemsToShow={{5}}
|
||||
as |persona|
|
||||
as |persona index isLastItem|
|
||||
>
|
||||
<DButton
|
||||
class="btn-flat btn-small ai-feature-card__persona-button"
|
||||
@translatedLabel={{persona.name}}
|
||||
class="btn-flat ai-feature-card__persona-button btn-text"
|
||||
@translatedLabel={{concat
|
||||
persona.name
|
||||
(unless (isLastItem index) ", ")
|
||||
}}
|
||||
@route="adminPlugins.show.discourse-ai-personas.edit"
|
||||
@routeModels={{persona.id}}
|
||||
/>
|
||||
</ExpandableList>
|
||||
{{else}}
|
||||
{{i18n "discourse_ai.features.no_persona"}}
|
||||
<span class="ai-feature-card__label">
|
||||
{{i18n "discourse_ai.features.no_persona"}}
|
||||
</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="ai-feature-card__llm">
|
||||
{{#if feature.llm_models}}
|
||||
<span>{{i18n
|
||||
<span class="ai-feature-card__label">
|
||||
{{i18n
|
||||
"discourse_ai.features.llm"
|
||||
count=feature.llm_models.length
|
||||
}}</span>
|
||||
}}
|
||||
</span>
|
||||
{{/if}}
|
||||
{{#if feature.llm_models}}
|
||||
<ExpandableList
|
||||
@items={{feature.llm_models}}
|
||||
@maxItemsToShow={{5}}
|
||||
as |llm|
|
||||
as |llm index isLastItem|
|
||||
>
|
||||
<DButton
|
||||
class="btn-flat btn-small ai-feature-card__llm-button"
|
||||
@translatedLabel={{llm.name}}
|
||||
class="btn-flat ai-feature-card__llm-button"
|
||||
@translatedLabel={{concat
|
||||
llm.name
|
||||
(unless (isLastItem index) ", ")
|
||||
}}
|
||||
@route="adminPlugins.show.discourse-ai-llms.edit"
|
||||
@routeModels={{llm.id}}
|
||||
/>
|
||||
</ExpandableList>
|
||||
{{else}}
|
||||
{{i18n "discourse_ai.features.no_llm"}}
|
||||
<span class="ai-feature-card__label">
|
||||
{{i18n "discourse_ai.features.no_llm"}}
|
||||
</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{#unless (this.isSpamModule module)}}
|
||||
{{#if feature.personas}}
|
||||
<div class="ai-feature-card__groups">
|
||||
<span>{{i18n "discourse_ai.features.groups"}}</span>
|
||||
<span class="ai-feature-card__label">
|
||||
{{i18n "discourse_ai.features.groups"}}
|
||||
</span>
|
||||
{{#if (this.hasGroups feature)}}
|
||||
<ul class="ai-feature-card__item-groups">
|
||||
{{#each (this.groupList feature) as |group|}}
|
||||
@ -205,7 +226,9 @@ export default class AiFeaturesList extends Component {
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{else}}
|
||||
{{i18n "discourse_ai.features.no_groups"}}
|
||||
<span class="ai-feature-card__label">
|
||||
{{i18n "discourse_ai.features.no_groups"}}
|
||||
</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
@ -1,54 +1,170 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { fn } from "@ember/helper";
|
||||
import { hash } from "@ember/helper";
|
||||
import { action } from "@ember/object";
|
||||
import { service } from "@ember/service";
|
||||
import { eq } from "truth-helpers";
|
||||
import DBreadcrumbsItem from "discourse/components/d-breadcrumbs-item";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import DPageSubheader from "discourse/components/d-page-subheader";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import DSelect from "discourse/components/d-select";
|
||||
import FilterInput from "discourse/components/filter-input";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import AiFeaturesList from "./ai-features-list";
|
||||
|
||||
const ALL = "all";
|
||||
const CONFIGURED = "configured";
|
||||
const UNCONFIGURED = "unconfigured";
|
||||
|
||||
export default class AiFeatures extends Component {
|
||||
@service adminPluginNavManager;
|
||||
|
||||
@tracked filterValue = "";
|
||||
@tracked selectedFeatureGroup = CONFIGURED;
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
if (this.configuredFeatures.length === 0) {
|
||||
this.selectedFeatureGroup = UNCONFIGURED;
|
||||
// if there are features but none are configured, show unconfigured
|
||||
if (this.args.features?.length > 0) {
|
||||
const configuredCount = this.args.features.filter(
|
||||
(f) => f.module_enabled === true
|
||||
).length;
|
||||
if (configuredCount === 0) {
|
||||
this.selectedFeatureGroup = UNCONFIGURED;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get featureGroups() {
|
||||
get featureGroupOptions() {
|
||||
return [
|
||||
{ id: CONFIGURED, label: "discourse_ai.features.nav.configured" },
|
||||
{ id: UNCONFIGURED, label: "discourse_ai.features.nav.unconfigured" },
|
||||
{ value: ALL, label: i18n("discourse_ai.features.filters.all") },
|
||||
{
|
||||
value: CONFIGURED,
|
||||
label: i18n("discourse_ai.features.nav.configured"),
|
||||
},
|
||||
{
|
||||
value: UNCONFIGURED,
|
||||
label: i18n("discourse_ai.features.nav.unconfigured"),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
get configuredFeatures() {
|
||||
return this.args.features.filter(
|
||||
(feature) => feature.module_enabled === true
|
||||
);
|
||||
}
|
||||
get filteredFeatures() {
|
||||
if (!this.args.features || this.args.features.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
get unconfiguredFeatures() {
|
||||
return this.args.features.filter(
|
||||
(feature) => feature.module_enabled === false
|
||||
);
|
||||
let features = this.args.features;
|
||||
|
||||
if (this.selectedFeatureGroup === CONFIGURED) {
|
||||
features = features.filter((feature) => feature.module_enabled === true);
|
||||
} else if (this.selectedFeatureGroup === UNCONFIGURED) {
|
||||
features = features.filter((feature) => feature.module_enabled === false);
|
||||
}
|
||||
|
||||
if (this.filterValue && this.filterValue.trim() !== "") {
|
||||
const term = this.filterValue.toLowerCase().trim();
|
||||
|
||||
const featureMatches = (module, feature) => {
|
||||
try {
|
||||
const featureName = i18n(
|
||||
`discourse_ai.features.${module.module_name}.${feature.name}`
|
||||
).toLowerCase();
|
||||
if (featureName.includes(term)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const personaMatches = feature.personas?.some((persona) =>
|
||||
persona.name?.toLowerCase().includes(term)
|
||||
);
|
||||
|
||||
const llmMatches = feature.llm_models?.some((llm) =>
|
||||
llm.name?.toLowerCase().includes(term)
|
||||
);
|
||||
|
||||
const groupMatches = feature.personas?.some((persona) =>
|
||||
persona.allowed_groups?.some((group) =>
|
||||
group.name?.toLowerCase().includes(term)
|
||||
)
|
||||
);
|
||||
|
||||
return personaMatches || llmMatches || groupMatches;
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Error filtering features`, error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Filter modules by name or features
|
||||
features = features.filter((module) => {
|
||||
try {
|
||||
const moduleName = i18n(
|
||||
`discourse_ai.features.${module.module_name}.name`
|
||||
).toLowerCase();
|
||||
if (moduleName.includes(term)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (module.features || []).some((feature) =>
|
||||
featureMatches(module, feature)
|
||||
);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Error filtering features`, error);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// For modules that don't match by name, filter features
|
||||
features = features
|
||||
.map((module) => {
|
||||
try {
|
||||
const moduleName = i18n(
|
||||
`discourse_ai.features.${module.module_name}.name`
|
||||
).toLowerCase();
|
||||
|
||||
// if name matches
|
||||
if (moduleName.includes(term)) {
|
||||
return module;
|
||||
}
|
||||
|
||||
// if no name match
|
||||
const matchingFeatures = (module.features || []).filter((feature) =>
|
||||
featureMatches(module, feature)
|
||||
);
|
||||
|
||||
// recreate with matching features
|
||||
return Object.assign({}, module, {
|
||||
features: matchingFeatures,
|
||||
});
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Error filtering features`, error);
|
||||
return module;
|
||||
}
|
||||
})
|
||||
.filter((module) => module.features && module.features.length > 0);
|
||||
}
|
||||
|
||||
return features;
|
||||
}
|
||||
|
||||
@action
|
||||
selectFeatureGroup(groupId) {
|
||||
this.selectedFeatureGroup = groupId;
|
||||
onFilterChange(event) {
|
||||
this.filterValue = event.target?.value || "";
|
||||
}
|
||||
|
||||
@action
|
||||
onFeatureGroupChange(value) {
|
||||
this.selectedFeatureGroup = value;
|
||||
}
|
||||
|
||||
@action
|
||||
resetAndFocus() {
|
||||
this.filterValue = "";
|
||||
this.selectedFeatureGroup = CONFIGURED;
|
||||
document.querySelector(".admin-filter__input").focus();
|
||||
}
|
||||
|
||||
<template>
|
||||
@ -63,27 +179,41 @@ export default class AiFeatures extends Component {
|
||||
@learnMoreUrl="todo"
|
||||
/>
|
||||
|
||||
<div class="ai-feature-groups">
|
||||
{{#each this.featureGroups as |groupData|}}
|
||||
<DButton
|
||||
class={{concatClass
|
||||
groupData.id
|
||||
(if
|
||||
(eq this.selectedFeatureGroup groupData.id)
|
||||
"btn-primary"
|
||||
"btn-default"
|
||||
)
|
||||
}}
|
||||
@action={{fn this.selectFeatureGroup groupData.id}}
|
||||
@label={{groupData.label}}
|
||||
/>
|
||||
{{/each}}
|
||||
<div class="ai-features__controls">
|
||||
<DSelect
|
||||
@value={{this.selectedFeatureGroup}}
|
||||
@includeNone={{false}}
|
||||
@onChange={{this.onFeatureGroupChange}}
|
||||
as |select|
|
||||
>
|
||||
{{#each this.featureGroupOptions as |option|}}
|
||||
<select.Option @value={{option.value}}>
|
||||
{{option.label}}
|
||||
</select.Option>
|
||||
{{/each}}
|
||||
</DSelect>
|
||||
|
||||
<FilterInput
|
||||
placeholder={{i18n "discourse_ai.features.filters.text"}}
|
||||
@filterAction={{this.onFilterChange}}
|
||||
@value={{this.filterValue}}
|
||||
class="admin-filter__input"
|
||||
@icons={{hash left="magnifying-glass"}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{{#if (eq this.selectedFeatureGroup "configured")}}
|
||||
<AiFeaturesList @modules={{this.configuredFeatures}} />
|
||||
{{#if this.filteredFeatures.length}}
|
||||
<AiFeaturesList @modules={{this.filteredFeatures}} />
|
||||
{{else}}
|
||||
<AiFeaturesList @modules={{this.unconfiguredFeatures}} />
|
||||
<div class="ai-features__no-results">
|
||||
<h3>{{i18n "discourse_ai.features.filters.no_results"}}</h3>
|
||||
<DButton
|
||||
@icon="arrow-rotate-left"
|
||||
@label="discourse_ai.features.filters.reset"
|
||||
@action={{this.resetAndFocus}}
|
||||
class="btn-default"
|
||||
/>
|
||||
</div>
|
||||
{{/if}}
|
||||
</section>
|
||||
</template>
|
||||
|
@ -1,11 +1,14 @@
|
||||
.ai-features-list {
|
||||
margin-block: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3rem;
|
||||
}
|
||||
|
||||
.ai-module {
|
||||
&__header {
|
||||
border-bottom: 1px solid var(--primary-low);
|
||||
padding-bottom: 0.5rem;
|
||||
padding-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
&__module-title {
|
||||
@ -15,25 +18,37 @@
|
||||
}
|
||||
|
||||
.ai-feature-cards {
|
||||
margin-top: 0.5rem;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.ai-feature-card {
|
||||
background: var(--primary-very-low);
|
||||
background: var(--secondary);
|
||||
border: 1px solid var(--primary-low);
|
||||
padding: 0.5rem;
|
||||
border-radius: var(--d-border-radius);
|
||||
padding: var(--space-3) var(--space-4) var(--space-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&.admin-section-landing-item {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__feature-name {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
&__label {
|
||||
margin: var(--space-1) var(--space-1) 0 0;
|
||||
}
|
||||
|
||||
&__llm,
|
||||
&__persona,
|
||||
&__groups {
|
||||
font-size: var(--font-down-1-rem);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-flow: row wrap;
|
||||
gap: 0.1em;
|
||||
margin-top: 0.5rem;
|
||||
align-items: center;
|
||||
color: var(--primary-high);
|
||||
}
|
||||
|
||||
&__persona {
|
||||
@ -42,20 +57,28 @@
|
||||
|
||||
&__persona-button,
|
||||
&__llm-button {
|
||||
padding-left: 0.2em;
|
||||
padding: 0;
|
||||
margin-right: var(--space-1);
|
||||
overflow: hidden;
|
||||
|
||||
.d-button-label {
|
||||
min-height: 1.5em;
|
||||
|
||||
@include ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
&__groups {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
gap: 0.25em;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
&__item-groups {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
gap: 0.25em;
|
||||
gap: var(--space-1);
|
||||
margin: 0;
|
||||
|
||||
li {
|
||||
@ -97,6 +120,41 @@
|
||||
.setting-controls,
|
||||
.setting-controls__undo {
|
||||
font-size: var(--font-down-1-rem);
|
||||
margin-top: 0.5rem;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
.ai-features__controls {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
|
||||
.filter-input-container {
|
||||
flex: 6 1 auto;
|
||||
}
|
||||
|
||||
.d-select {
|
||||
flex: 1 1 auto;
|
||||
max-width: 10em;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-features__no-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-6);
|
||||
gap: var(--space-2);
|
||||
|
||||
h3 {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.btn {
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-expanded-list__toggle-button {
|
||||
padding: 0;
|
||||
}
|
||||
|
@ -208,6 +208,11 @@ en:
|
||||
nav:
|
||||
configured: "Configured"
|
||||
unconfigured: "Unconfigured"
|
||||
filters:
|
||||
all: "All"
|
||||
text: "Search features, personas, LLMs, or groups..."
|
||||
no_results: "No features found matching your filters."
|
||||
reset: "Reset"
|
||||
summarization:
|
||||
name: "Summaries"
|
||||
description: "Makes a summarization button available that allows visitors to summarize topics"
|
||||
|
@ -11,11 +11,13 @@ module PageObjects
|
||||
end
|
||||
|
||||
def toggle_configured
|
||||
page.find("#{FEATURES_PAGE} .ai-feature-groups .configured").click
|
||||
select = page.find("#{FEATURES_PAGE} .ai-features__controls .d-select")
|
||||
select.find("option[value='configured']").select_option
|
||||
end
|
||||
|
||||
def toggle_unconfigured
|
||||
page.find("#{FEATURES_PAGE} .ai-feature-groups .unconfigured").click
|
||||
select = page.find("#{FEATURES_PAGE} .ai-features__controls .d-select")
|
||||
select.find("option[value='unconfigured']").select_option
|
||||
end
|
||||
|
||||
def has_listed_modules?(count)
|
||||
|
Loading…
x
Reference in New Issue
Block a user