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;
}
@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}}

View File

@ -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>

View File

@ -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;
}

View File

@ -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"

View File

@ -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)