Skip to content
This repository was archived by the owner on Jul 22, 2025. It is now read-only.

Commit 262bd8b

Browse files
authored
UX: add filter to features page, update styles (#1471)
* UX: add filter to features page, update styles * merge fix * update toggle spec * test fix
1 parent 57b0052 commit 262bd8b

File tree

5 files changed

+288
-70
lines changed

5 files changed

+288
-70
lines changed

assets/javascripts/discourse/components/ai-features-list.gjs

Lines changed: 44 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,19 @@ class ExpandableList extends Component {
4444
this.isExpanded = !this.isExpanded;
4545
}
4646

47+
@action
48+
isLastItem(index) {
49+
return index === this.visibleItems.length - 1;
50+
}
51+
4752
<template>
4853
{{#each this.visibleItems as |item index|}}
49-
{{yield item index}}
54+
{{yield item index this.isLastItem}}
5055
{{/each}}
5156

5257
{{#if this.hasMore}}
5358
<DButton
54-
class="btn-flat btn-small ai-expanded-list__toggle-button"
59+
class="btn-flat ai-expanded-list__toggle-button"
5560
@translatedLabel={{this.expandToggleLabel}}
5661
@action={{this.toggleExpanded}}
5762
/>
@@ -61,11 +66,11 @@ class ExpandableList extends Component {
6166

6267
export default class AiFeaturesList extends Component {
6368
get sortedModules() {
64-
return this.args.modules.sort((a, b) => {
65-
const nameA = i18n(`discourse_ai.features.${a.module_name}.name`);
66-
const nameB = i18n(`discourse_ai.features.${b.module_name}.name`);
67-
return nameA.localeCompare(nameB);
68-
});
69+
if (!this.args.modules || !this.args.modules.length) {
70+
return [];
71+
}
72+
73+
return this.args.modules.sortBy("module_name");
6974
}
7075

7176
@action
@@ -149,63 +154,81 @@ export default class AiFeaturesList extends Component {
149154
{{/unless}}
150155
</div>
151156
<div class="ai-feature-card__persona">
152-
<span>{{i18n
157+
<span class="ai-feature-card__label">
158+
{{i18n
153159
"discourse_ai.features.persona"
154160
count=feature.personas.length
155-
}}</span>
161+
}}
162+
</span>
156163
{{#if feature.personas}}
157164
<ExpandableList
158165
@items={{feature.personas}}
159166
@maxItemsToShow={{5}}
160-
as |persona|
167+
as |persona index isLastItem|
161168
>
162169
<DButton
163-
class="btn-flat btn-small ai-feature-card__persona-button"
164-
@translatedLabel={{persona.name}}
170+
class="btn-flat ai-feature-card__persona-button btn-text"
171+
@translatedLabel={{concat
172+
persona.name
173+
(unless (isLastItem index) ", ")
174+
}}
165175
@route="adminPlugins.show.discourse-ai-personas.edit"
166176
@routeModels={{persona.id}}
167177
/>
168178
</ExpandableList>
169179
{{else}}
170-
{{i18n "discourse_ai.features.no_persona"}}
180+
<span class="ai-feature-card__label">
181+
{{i18n "discourse_ai.features.no_persona"}}
182+
</span>
171183
{{/if}}
172184
</div>
173185
<div class="ai-feature-card__llm">
174186
{{#if feature.llm_models}}
175-
<span>{{i18n
187+
<span class="ai-feature-card__label">
188+
{{i18n
176189
"discourse_ai.features.llm"
177190
count=feature.llm_models.length
178-
}}</span>
191+
}}
192+
</span>
179193
{{/if}}
180194
{{#if feature.llm_models}}
181195
<ExpandableList
182196
@items={{feature.llm_models}}
183197
@maxItemsToShow={{5}}
184-
as |llm|
198+
as |llm index isLastItem|
185199
>
186200
<DButton
187-
class="btn-flat btn-small ai-feature-card__llm-button"
188-
@translatedLabel={{llm.name}}
201+
class="btn-flat ai-feature-card__llm-button"
202+
@translatedLabel={{concat
203+
llm.name
204+
(unless (isLastItem index) ", ")
205+
}}
189206
@route="adminPlugins.show.discourse-ai-llms.edit"
190207
@routeModels={{llm.id}}
191208
/>
192209
</ExpandableList>
193210
{{else}}
194-
{{i18n "discourse_ai.features.no_llm"}}
211+
<span class="ai-feature-card__label">
212+
{{i18n "discourse_ai.features.no_llm"}}
213+
</span>
195214
{{/if}}
196215
</div>
197216
{{#unless (this.isSpamModule module)}}
198217
{{#if feature.personas}}
199218
<div class="ai-feature-card__groups">
200-
<span>{{i18n "discourse_ai.features.groups"}}</span>
219+
<span class="ai-feature-card__label">
220+
{{i18n "discourse_ai.features.groups"}}
221+
</span>
201222
{{#if (this.hasGroups feature)}}
202223
<ul class="ai-feature-card__item-groups">
203224
{{#each (this.groupList feature) as |group|}}
204225
<li>{{group.name}}</li>
205226
{{/each}}
206227
</ul>
207228
{{else}}
208-
{{i18n "discourse_ai.features.no_groups"}}
229+
<span class="ai-feature-card__label">
230+
{{i18n "discourse_ai.features.no_groups"}}
231+
</span>
209232
{{/if}}
210233
</div>
211234
{{/if}}

assets/javascripts/discourse/components/ai-features.gjs

Lines changed: 166 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,170 @@
11
import Component from "@glimmer/component";
22
import { tracked } from "@glimmer/tracking";
3-
import { fn } from "@ember/helper";
3+
import { hash } from "@ember/helper";
44
import { action } from "@ember/object";
55
import { service } from "@ember/service";
6-
import { eq } from "truth-helpers";
76
import DBreadcrumbsItem from "discourse/components/d-breadcrumbs-item";
87
import DButton from "discourse/components/d-button";
98
import DPageSubheader from "discourse/components/d-page-subheader";
10-
import concatClass from "discourse/helpers/concat-class";
9+
import DSelect from "discourse/components/d-select";
10+
import FilterInput from "discourse/components/filter-input";
1111
import { i18n } from "discourse-i18n";
1212
import AiFeaturesList from "./ai-features-list";
1313

14+
const ALL = "all";
1415
const CONFIGURED = "configured";
1516
const UNCONFIGURED = "unconfigured";
1617

1718
export default class AiFeatures extends Component {
1819
@service adminPluginNavManager;
1920

21+
@tracked filterValue = "";
2022
@tracked selectedFeatureGroup = CONFIGURED;
2123

2224
constructor() {
2325
super(...arguments);
2426

25-
if (this.configuredFeatures.length === 0) {
26-
this.selectedFeatureGroup = UNCONFIGURED;
27+
// if there are features but none are configured, show unconfigured
28+
if (this.args.features?.length > 0) {
29+
const configuredCount = this.args.features.filter(
30+
(f) => f.module_enabled === true
31+
).length;
32+
if (configuredCount === 0) {
33+
this.selectedFeatureGroup = UNCONFIGURED;
34+
}
2735
}
2836
}
2937

30-
get featureGroups() {
38+
get featureGroupOptions() {
3139
return [
32-
{ id: CONFIGURED, label: "discourse_ai.features.nav.configured" },
33-
{ id: UNCONFIGURED, label: "discourse_ai.features.nav.unconfigured" },
40+
{ value: ALL, label: i18n("discourse_ai.features.filters.all") },
41+
{
42+
value: CONFIGURED,
43+
label: i18n("discourse_ai.features.nav.configured"),
44+
},
45+
{
46+
value: UNCONFIGURED,
47+
label: i18n("discourse_ai.features.nav.unconfigured"),
48+
},
3449
];
3550
}
3651

37-
get configuredFeatures() {
38-
return this.args.features.filter(
39-
(feature) => feature.module_enabled === true
40-
);
52+
get filteredFeatures() {
53+
if (!this.args.features || this.args.features.length === 0) {
54+
return [];
55+
}
56+
57+
let features = this.args.features;
58+
59+
if (this.selectedFeatureGroup === CONFIGURED) {
60+
features = features.filter((feature) => feature.module_enabled === true);
61+
} else if (this.selectedFeatureGroup === UNCONFIGURED) {
62+
features = features.filter((feature) => feature.module_enabled === false);
63+
}
64+
65+
if (this.filterValue && this.filterValue.trim() !== "") {
66+
const term = this.filterValue.toLowerCase().trim();
67+
68+
const featureMatches = (module, feature) => {
69+
try {
70+
const featureName = i18n(
71+
`discourse_ai.features.${module.module_name}.${feature.name}`
72+
).toLowerCase();
73+
if (featureName.includes(term)) {
74+
return true;
75+
}
76+
77+
const personaMatches = feature.personas?.some((persona) =>
78+
persona.name?.toLowerCase().includes(term)
79+
);
80+
81+
const llmMatches = feature.llm_models?.some((llm) =>
82+
llm.name?.toLowerCase().includes(term)
83+
);
84+
85+
const groupMatches = feature.personas?.some((persona) =>
86+
persona.allowed_groups?.some((group) =>
87+
group.name?.toLowerCase().includes(term)
88+
)
89+
);
90+
91+
return personaMatches || llmMatches || groupMatches;
92+
} catch (error) {
93+
// eslint-disable-next-line no-console
94+
console.error(`Error filtering features`, error);
95+
return false;
96+
}
97+
};
98+
99+
// Filter modules by name or features
100+
features = features.filter((module) => {
101+
try {
102+
const moduleName = i18n(
103+
`discourse_ai.features.${module.module_name}.name`
104+
).toLowerCase();
105+
if (moduleName.includes(term)) {
106+
return true;
107+
}
108+
109+
return (module.features || []).some((feature) =>
110+
featureMatches(module, feature)
111+
);
112+
} catch (error) {
113+
// eslint-disable-next-line no-console
114+
console.error(`Error filtering features`, error);
115+
return false;
116+
}
117+
});
118+
119+
// For modules that don't match by name, filter features
120+
features = features
121+
.map((module) => {
122+
try {
123+
const moduleName = i18n(
124+
`discourse_ai.features.${module.module_name}.name`
125+
).toLowerCase();
126+
127+
// if name matches
128+
if (moduleName.includes(term)) {
129+
return module;
130+
}
131+
132+
// if no name match
133+
const matchingFeatures = (module.features || []).filter((feature) =>
134+
featureMatches(module, feature)
135+
);
136+
137+
// recreate with matching features
138+
return Object.assign({}, module, {
139+
features: matchingFeatures,
140+
});
141+
} catch (error) {
142+
// eslint-disable-next-line no-console
143+
console.error(`Error filtering features`, error);
144+
return module;
145+
}
146+
})
147+
.filter((module) => module.features && module.features.length > 0);
148+
}
149+
150+
return features;
41151
}
42152

43-
get unconfiguredFeatures() {
44-
return this.args.features.filter(
45-
(feature) => feature.module_enabled === false
46-
);
153+
@action
154+
onFilterChange(event) {
155+
this.filterValue = event.target?.value || "";
47156
}
48157

49158
@action
50-
selectFeatureGroup(groupId) {
51-
this.selectedFeatureGroup = groupId;
159+
onFeatureGroupChange(value) {
160+
this.selectedFeatureGroup = value;
161+
}
162+
163+
@action
164+
resetAndFocus() {
165+
this.filterValue = "";
166+
this.selectedFeatureGroup = CONFIGURED;
167+
document.querySelector(".admin-filter__input").focus();
52168
}
53169

54170
<template>
@@ -63,27 +179,41 @@ export default class AiFeatures extends Component {
63179
@learnMoreUrl="todo"
64180
/>
65181

66-
<div class="ai-feature-groups">
67-
{{#each this.featureGroups as |groupData|}}
68-
<DButton
69-
class={{concatClass
70-
groupData.id
71-
(if
72-
(eq this.selectedFeatureGroup groupData.id)
73-
"btn-primary"
74-
"btn-default"
75-
)
76-
}}
77-
@action={{fn this.selectFeatureGroup groupData.id}}
78-
@label={{groupData.label}}
79-
/>
80-
{{/each}}
182+
<div class="ai-features__controls">
183+
<DSelect
184+
@value={{this.selectedFeatureGroup}}
185+
@includeNone={{false}}
186+
@onChange={{this.onFeatureGroupChange}}
187+
as |select|
188+
>
189+
{{#each this.featureGroupOptions as |option|}}
190+
<select.Option @value={{option.value}}>
191+
{{option.label}}
192+
</select.Option>
193+
{{/each}}
194+
</DSelect>
195+
196+
<FilterInput
197+
placeholder={{i18n "discourse_ai.features.filters.text"}}
198+
@filterAction={{this.onFilterChange}}
199+
@value={{this.filterValue}}
200+
class="admin-filter__input"
201+
@icons={{hash left="magnifying-glass"}}
202+
/>
81203
</div>
82204

83-
{{#if (eq this.selectedFeatureGroup "configured")}}
84-
<AiFeaturesList @modules={{this.configuredFeatures}} />
205+
{{#if this.filteredFeatures.length}}
206+
<AiFeaturesList @modules={{this.filteredFeatures}} />
85207
{{else}}
86-
<AiFeaturesList @modules={{this.unconfiguredFeatures}} />
208+
<div class="ai-features__no-results">
209+
<h3>{{i18n "discourse_ai.features.filters.no_results"}}</h3>
210+
<DButton
211+
@icon="arrow-rotate-left"
212+
@label="discourse_ai.features.filters.reset"
213+
@action={{this.resetAndFocus}}
214+
class="btn-default"
215+
/>
216+
</div>
87217
{{/if}}
88218
</section>
89219
</template>

0 commit comments

Comments
 (0)