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:
Kris 2025-06-29 19:26:53 -04:00 committed by GitHub
parent 57b00526f8
commit 262bd8b145
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 289 additions and 71 deletions

View File

@ -44,14 +44,19 @@ class ExpandableList extends Component {
this.isExpanded = !this.isExpanded; this.isExpanded = !this.isExpanded;
} }
@action
isLastItem(index) {
return index === this.visibleItems.length - 1;
}
<template> <template>
{{#each this.visibleItems as |item index|}} {{#each this.visibleItems as |item index|}}
{{yield item index}} {{yield item index this.isLastItem}}
{{/each}} {{/each}}
{{#if this.hasMore}} {{#if this.hasMore}}
<DButton <DButton
class="btn-flat btn-small ai-expanded-list__toggle-button" class="btn-flat ai-expanded-list__toggle-button"
@translatedLabel={{this.expandToggleLabel}} @translatedLabel={{this.expandToggleLabel}}
@action={{this.toggleExpanded}} @action={{this.toggleExpanded}}
/> />
@ -61,11 +66,11 @@ class ExpandableList extends Component {
export default class AiFeaturesList extends Component { export default class AiFeaturesList extends Component {
get sortedModules() { get sortedModules() {
return this.args.modules.sort((a, b) => { if (!this.args.modules || !this.args.modules.length) {
const nameA = i18n(`discourse_ai.features.${a.module_name}.name`); return [];
const nameB = i18n(`discourse_ai.features.${b.module_name}.name`); }
return nameA.localeCompare(nameB);
}); return this.args.modules.sortBy("module_name");
} }
@action @action
@ -149,55 +154,71 @@ export default class AiFeaturesList extends Component {
{{/unless}} {{/unless}}
</div> </div>
<div class="ai-feature-card__persona"> <div class="ai-feature-card__persona">
<span>{{i18n <span class="ai-feature-card__label">
{{i18n
"discourse_ai.features.persona" "discourse_ai.features.persona"
count=feature.personas.length count=feature.personas.length
}}</span> }}
</span>
{{#if feature.personas}} {{#if feature.personas}}
<ExpandableList <ExpandableList
@items={{feature.personas}} @items={{feature.personas}}
@maxItemsToShow={{5}} @maxItemsToShow={{5}}
as |persona| as |persona index isLastItem|
> >
<DButton <DButton
class="btn-flat btn-small ai-feature-card__persona-button" class="btn-flat ai-feature-card__persona-button btn-text"
@translatedLabel={{persona.name}} @translatedLabel={{concat
persona.name
(unless (isLastItem index) ", ")
}}
@route="adminPlugins.show.discourse-ai-personas.edit" @route="adminPlugins.show.discourse-ai-personas.edit"
@routeModels={{persona.id}} @routeModels={{persona.id}}
/> />
</ExpandableList> </ExpandableList>
{{else}} {{else}}
<span class="ai-feature-card__label">
{{i18n "discourse_ai.features.no_persona"}} {{i18n "discourse_ai.features.no_persona"}}
</span>
{{/if}} {{/if}}
</div> </div>
<div class="ai-feature-card__llm"> <div class="ai-feature-card__llm">
{{#if feature.llm_models}} {{#if feature.llm_models}}
<span>{{i18n <span class="ai-feature-card__label">
{{i18n
"discourse_ai.features.llm" "discourse_ai.features.llm"
count=feature.llm_models.length count=feature.llm_models.length
}}</span> }}
</span>
{{/if}} {{/if}}
{{#if feature.llm_models}} {{#if feature.llm_models}}
<ExpandableList <ExpandableList
@items={{feature.llm_models}} @items={{feature.llm_models}}
@maxItemsToShow={{5}} @maxItemsToShow={{5}}
as |llm| as |llm index isLastItem|
> >
<DButton <DButton
class="btn-flat btn-small ai-feature-card__llm-button" class="btn-flat ai-feature-card__llm-button"
@translatedLabel={{llm.name}} @translatedLabel={{concat
llm.name
(unless (isLastItem index) ", ")
}}
@route="adminPlugins.show.discourse-ai-llms.edit" @route="adminPlugins.show.discourse-ai-llms.edit"
@routeModels={{llm.id}} @routeModels={{llm.id}}
/> />
</ExpandableList> </ExpandableList>
{{else}} {{else}}
<span class="ai-feature-card__label">
{{i18n "discourse_ai.features.no_llm"}} {{i18n "discourse_ai.features.no_llm"}}
</span>
{{/if}} {{/if}}
</div> </div>
{{#unless (this.isSpamModule module)}} {{#unless (this.isSpamModule module)}}
{{#if feature.personas}} {{#if feature.personas}}
<div class="ai-feature-card__groups"> <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)}} {{#if (this.hasGroups feature)}}
<ul class="ai-feature-card__item-groups"> <ul class="ai-feature-card__item-groups">
{{#each (this.groupList feature) as |group|}} {{#each (this.groupList feature) as |group|}}
@ -205,7 +226,9 @@ export default class AiFeaturesList extends Component {
{{/each}} {{/each}}
</ul> </ul>
{{else}} {{else}}
<span class="ai-feature-card__label">
{{i18n "discourse_ai.features.no_groups"}} {{i18n "discourse_ai.features.no_groups"}}
</span>
{{/if}} {{/if}}
</div> </div>
{{/if}} {{/if}}

View File

@ -1,54 +1,170 @@
import Component from "@glimmer/component"; import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking"; import { tracked } from "@glimmer/tracking";
import { fn } from "@ember/helper"; import { hash } from "@ember/helper";
import { action } from "@ember/object"; import { action } from "@ember/object";
import { service } from "@ember/service"; import { service } from "@ember/service";
import { eq } from "truth-helpers";
import DBreadcrumbsItem from "discourse/components/d-breadcrumbs-item"; import DBreadcrumbsItem from "discourse/components/d-breadcrumbs-item";
import DButton from "discourse/components/d-button"; import DButton from "discourse/components/d-button";
import DPageSubheader from "discourse/components/d-page-subheader"; 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 { i18n } from "discourse-i18n";
import AiFeaturesList from "./ai-features-list"; import AiFeaturesList from "./ai-features-list";
const ALL = "all";
const CONFIGURED = "configured"; const CONFIGURED = "configured";
const UNCONFIGURED = "unconfigured"; const UNCONFIGURED = "unconfigured";
export default class AiFeatures extends Component { export default class AiFeatures extends Component {
@service adminPluginNavManager; @service adminPluginNavManager;
@tracked filterValue = "";
@tracked selectedFeatureGroup = CONFIGURED; @tracked selectedFeatureGroup = CONFIGURED;
constructor() { constructor() {
super(...arguments); super(...arguments);
if (this.configuredFeatures.length === 0) { // 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; this.selectedFeatureGroup = UNCONFIGURED;
} }
} }
}
get featureGroups() { get featureGroupOptions() {
return [ return [
{ id: CONFIGURED, label: "discourse_ai.features.nav.configured" }, { value: ALL, label: i18n("discourse_ai.features.filters.all") },
{ id: UNCONFIGURED, label: "discourse_ai.features.nav.unconfigured" }, {
value: CONFIGURED,
label: i18n("discourse_ai.features.nav.configured"),
},
{
value: UNCONFIGURED,
label: i18n("discourse_ai.features.nav.unconfigured"),
},
]; ];
} }
get configuredFeatures() { get filteredFeatures() {
return this.args.features.filter( if (!this.args.features || this.args.features.length === 0) {
(feature) => feature.module_enabled === true return [];
);
} }
get unconfiguredFeatures() { let features = this.args.features;
return this.args.features.filter(
(feature) => feature.module_enabled === false 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 @action
selectFeatureGroup(groupId) { onFilterChange(event) {
this.selectedFeatureGroup = groupId; 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> <template>
@ -63,27 +179,41 @@ export default class AiFeatures extends Component {
@learnMoreUrl="todo" @learnMoreUrl="todo"
/> />
<div class="ai-feature-groups"> <div class="ai-features__controls">
{{#each this.featureGroups as |groupData|}} <DSelect
<DButton @value={{this.selectedFeatureGroup}}
class={{concatClass @includeNone={{false}}
groupData.id @onChange={{this.onFeatureGroupChange}}
(if as |select|
(eq this.selectedFeatureGroup groupData.id) >
"btn-primary" {{#each this.featureGroupOptions as |option|}}
"btn-default" <select.Option @value={{option.value}}>
) {{option.label}}
}} </select.Option>
@action={{fn this.selectFeatureGroup groupData.id}}
@label={{groupData.label}}
/>
{{/each}} {{/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> </div>
{{#if (eq this.selectedFeatureGroup "configured")}} {{#if this.filteredFeatures.length}}
<AiFeaturesList @modules={{this.configuredFeatures}} /> <AiFeaturesList @modules={{this.filteredFeatures}} />
{{else}} {{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}} {{/if}}
</section> </section>
</template> </template>

View File

@ -1,11 +1,14 @@
.ai-features-list { .ai-features-list {
margin-block: 2rem; margin-block: 2rem;
display: flex;
flex-direction: column;
gap: 3rem;
} }
.ai-module { .ai-module {
&__header { &__header {
border-bottom: 1px solid var(--primary-low); border-bottom: 1px solid var(--primary-low);
padding-bottom: 0.5rem; padding-bottom: var(--space-2);
} }
&__module-title { &__module-title {
@ -15,25 +18,37 @@
} }
.ai-feature-cards { .ai-feature-cards {
margin-top: 0.5rem; gap: var(--space-4);
} }
.ai-feature-card { .ai-feature-card {
background: var(--primary-very-low); background: var(--secondary);
border: 1px solid var(--primary-low); 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; display: flex;
flex-direction: column; 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, &__llm,
&__persona, &__persona,
&__groups { &__groups {
font-size: var(--font-down-1-rem); font-size: var(--font-down-1-rem);
display: flex; display: flex;
align-items: baseline;
flex-flow: row wrap; flex-flow: row wrap;
gap: 0.1em; color: var(--primary-high);
margin-top: 0.5rem;
align-items: center;
} }
&__persona { &__persona {
@ -42,20 +57,28 @@
&__persona-button, &__persona-button,
&__llm-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 { &__groups {
display: flex; display: flex;
flex-flow: row wrap; flex-flow: row wrap;
gap: 0.25em; gap: var(--space-1);
} }
&__item-groups { &__item-groups {
list-style: none; list-style: none;
display: flex; display: flex;
flex-flow: row wrap; flex-flow: row wrap;
gap: 0.25em; gap: var(--space-1);
margin: 0; margin: 0;
li { li {
@ -97,6 +120,41 @@
.setting-controls, .setting-controls,
.setting-controls__undo { .setting-controls__undo {
font-size: var(--font-down-1-rem); 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;
}

View File

@ -208,6 +208,11 @@ en:
nav: nav:
configured: "Configured" configured: "Configured"
unconfigured: "Unconfigured" unconfigured: "Unconfigured"
filters:
all: "All"
text: "Search features, personas, LLMs, or groups..."
no_results: "No features found matching your filters."
reset: "Reset"
summarization: summarization:
name: "Summaries" name: "Summaries"
description: "Makes a summarization button available that allows visitors to summarize topics" description: "Makes a summarization button available that allows visitors to summarize topics"

View File

@ -11,11 +11,13 @@ module PageObjects
end end
def toggle_configured 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 end
def toggle_unconfigured 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 end
def has_listed_modules?(count) def has_listed_modules?(count)