Skip to content

Commit 9f03cac

Browse files
Cyperghostdtdesign
andauthored
Grouped condition types (#6387)
* Group the condition types and make it possible to open/close the groups in the input field. * Minor code fixes * Remove the collapse feature, improve visuals of categories * Simplify the filtering, use native `hidden` property instead --------- Co-authored-by: Alexander Ebert <ebert@woltlab.com>
1 parent 3013d20 commit 9f03cac

27 files changed

Lines changed: 514 additions & 12 deletions
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<script data-relocate="true">
2+
{jsphrase name='wcf.global.filter.button.visibility'}
3+
{jsphrase name='wcf.global.filter.button.clear'}
4+
{jsphrase name='wcf.global.filter.error.noMatches'}
5+
{jsphrase name='wcf.global.filter.placeholder'}
6+
{jsphrase name='wcf.global.filter.visibility.activeOnly'}
7+
{jsphrase name='wcf.global.filter.visibility.highlightActive'}
8+
{jsphrase name='wcf.global.filter.visibility.showAll'}
9+
10+
require(['WoltLabSuite/Core/Component/ItemList/Categorized'], ({ CategorizedItemList }) => {
11+
new CategorizedItemList('{unsafe:$field->getPrefixedId()|encodeJS}_list');
12+
});
13+
</script>
14+
15+
<div class="itemListFilter" id="{$field->getPrefixedId()}_list">
16+
<div class="inputAddon">
17+
<input type="text" class="long" placeholder="{lang}wcf.global.filter.placeholder{/lang}">
18+
<button type="button" class="button clearButton inputSuffix disabled jsTooltip" title="{lang}wcf.global.filter.button.clear{/lang}">{icon name="xmark" solid=true}</button>
19+
</div>
20+
<ul class="scrollableCheckboxList">
21+
{foreach from=$field->getNestedOptions() item=__fieldNestedOption}
22+
<li
23+
{if $__fieldNestedOption[depth] > 0} style="padding-left: {$__fieldNestedOption[depth]*20}px"{/if}
24+
{if !$__fieldNestedOption[isSelectable]} class="scrollableCheckboxList__category"{/if}
25+
>
26+
{if !$__fieldNestedOption[isSelectable]}
27+
<span class="scrollableCheckboxList__category__label">{unsafe:$__fieldNestedOption[label]}</span>
28+
{else}
29+
<label>
30+
<input {*
31+
*}type="radio" {*
32+
*}name="{$field->getPrefixedId()}" {*
33+
*}value="{$__fieldNestedOption[value]}"{*
34+
*}{if !$field->getFieldClasses()|empty} class="{implode from=$field->getFieldClasses() item='class' glue=' '}{$class}{/implode}"{/if}{*
35+
*}{if $field->getValue() == $__fieldNestedOption[value] && $__fieldNestedOption[isSelectable]} checked{/if}{*
36+
*}{if $field->isImmutable()} disabled{/if}{*
37+
*}> <span>{unsafe:$__fieldNestedOption[label]}</span>
38+
</label>
39+
{/if}
40+
</li>
41+
{/foreach}
42+
</ul>
43+
</div>
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/**
2+
* Provides a filter input for a categorized item list.
3+
*
4+
* @author Olaf Braun
5+
* @copyright 2001-2025 WoltLab GmbH
6+
* @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
7+
* @sice 6.3
8+
*/
9+
10+
import { innerError } from "WoltLabSuite/Core/Dom/Util";
11+
import { getPhrase } from "WoltLabSuite/Core/Language";
12+
import { escapeRegExp } from "WoltLabSuite/Core/StringUtil";
13+
14+
type Item = {
15+
element: HTMLLIElement;
16+
span: HTMLSpanElement;
17+
text: string;
18+
};
19+
20+
type Category = {
21+
items: Item[];
22+
element: HTMLLIElement;
23+
};
24+
25+
export class CategorizedItemList {
26+
readonly #container: HTMLElement;
27+
readonly #elementList: HTMLUListElement;
28+
readonly #input: HTMLInputElement;
29+
#value: string = "";
30+
readonly #clearButton: HTMLButtonElement;
31+
#categories: Category[] = [];
32+
readonly #fragment: DocumentFragment;
33+
34+
constructor(elementId: string) {
35+
this.#fragment = document.createDocumentFragment();
36+
37+
const container = document.getElementById(elementId);
38+
if (!container) {
39+
throw new Error(`Element with ID ${elementId} not found.`);
40+
}
41+
42+
this.#container = container;
43+
this.#elementList = this.#container.querySelector<HTMLUListElement>(".scrollableCheckboxList")!;
44+
45+
this.#input = this.#container.querySelector(".inputAddon > input") as HTMLInputElement;
46+
this.#input.addEventListener("keydown", (event) => {
47+
if (event.key === "Enter") {
48+
event.preventDefault();
49+
}
50+
});
51+
this.#input.addEventListener("keyup", () => this.#keyup());
52+
53+
this.#clearButton = this.#container.querySelector<HTMLButtonElement>(".inputAddon > .clearButton")!;
54+
this.#clearButton.addEventListener("click", (event) => {
55+
event.preventDefault();
56+
57+
this.#input.value = "";
58+
this.#keyup();
59+
});
60+
61+
this.#buildItemMap();
62+
}
63+
64+
#buildItemMap(): void {
65+
let category: Category | null = null;
66+
for (const li of this.#elementList.querySelectorAll<HTMLLIElement>(":scope > li")) {
67+
const input = li.querySelector('input[type="radio"]');
68+
if (input) {
69+
if (!category) {
70+
throw new Error("Input found without a preceding category.");
71+
}
72+
73+
category.items.push({
74+
element: li,
75+
span: li.querySelector("span")!,
76+
text: li.textContent!.trim(),
77+
});
78+
} else {
79+
const items: Item[] = [];
80+
category = {
81+
items: items,
82+
element: li,
83+
};
84+
this.#categories.push(category);
85+
}
86+
}
87+
}
88+
89+
#keyup(): void {
90+
const value = this.#input.value.trim();
91+
if (this.#value === value) {
92+
return;
93+
}
94+
95+
this.#value = value;
96+
97+
if (this.#value) {
98+
this.#clearButton.classList.remove("disabled");
99+
} else {
100+
this.#clearButton.classList.add("disabled");
101+
}
102+
103+
// move list into fragment before editing items, increases performance
104+
// by avoiding the browser to perform repaint/layout over and over again
105+
this.#fragment.appendChild(this.#elementList);
106+
107+
this.#categories.forEach((category) => {
108+
this.#filterItems(category);
109+
});
110+
111+
const hasVisibleItem = this.#elementList.querySelector(".scrollableCheckboxList > li:not([hidden])") !== null;
112+
113+
this.#container.insertAdjacentElement("beforeend", this.#elementList);
114+
115+
innerError(this.#container, hasVisibleItem ? false : getPhrase("wcf.global.filter.error.noMatches"));
116+
}
117+
118+
#filterItems(category: Category): void {
119+
const regexp = new RegExp("(" + escapeRegExp(this.#value) + ")", "i");
120+
121+
let hasMatchingItem = false;
122+
for (const item of category.items) {
123+
if (this.#value === "") {
124+
item.span.innerHTML = item.text; // Reset highlighting
125+
126+
hasMatchingItem = true;
127+
item.element.hidden = false;
128+
} else if (regexp.test(item.text)) {
129+
item.span.innerHTML = item.text.replace(regexp, "<u>$1</u>");
130+
131+
item.element.hidden = false;
132+
hasMatchingItem = true;
133+
} else {
134+
item.element.hidden = true;
135+
}
136+
}
137+
138+
category.element.hidden = !hasMatchingItem;
139+
}
140+
}

wcfsetup/install/files/js/WoltLabSuite/Core/Component/ItemList/Categorized.js

Lines changed: 112 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

wcfsetup/install/files/lib/action/ConditionAddAction.class.php

Lines changed: 70 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -82,27 +82,86 @@ private function getForm(AbstractConditionProvider $provider): Psr15DialogForm
8282
self::class,
8383
WCF::getLanguage()->get('wcf.condition.add')
8484
);
85-
$options = \array_map(
86-
static fn (IConditionType $conditionType) => WCF::getLanguage()->get($conditionType->getLabel()),
87-
$provider->getConditionTypes()
88-
);
89-
$collator = new \Collator(WCF::getLanguage()->getLocale());
90-
\uasort(
91-
$options,
92-
static fn (string $a, string $b) => $collator->compare($a, $b)
93-
);
9485

9586
$form->appendChild(
96-
SingleSelectionFormField::create('conditionType')
87+
$this->getConditionTypeFormField()
88+
->id('conditionType')
9789
->label('wcf.condition.condition')
9890
->filterable()
9991
->required()
100-
->options($options)
92+
->options($this->getOptions($provider), true, false)
10193
);
10294

10395
$form->markRequiredFields(false);
10496
$form->build();
10597

10698
return $form;
10799
}
100+
101+
/**
102+
* @param AbstractConditionProvider<IConditionType<mixed>> $provider
103+
*
104+
* @return array{}
105+
*/
106+
private function getOptions(AbstractConditionProvider $provider): array
107+
{
108+
$conditionTypes = $provider->getConditionTypes();
109+
110+
$grouped = [];
111+
foreach ($conditionTypes as $key => $conditionType) {
112+
$category = $conditionType->getCategory();
113+
$label = $conditionType->getLabel();
114+
115+
if (!isset($grouped[$category])) {
116+
$grouped[$category] = [
117+
'items' => [],
118+
'label' => WCF::getLanguage()->get('wcf.condition.category.' . $category),
119+
];
120+
}
121+
122+
$grouped[$category]['items'][$key] = WCF::getLanguage()->get($label);
123+
}
124+
125+
$collator = new \Collator(WCF::getLanguage()->getLocale());
126+
127+
foreach ($grouped as &$category) {
128+
\uasort($category['items'], static function ($labelA, $labelB) use ($collator) {
129+
return $collator->compare($labelA, $labelB);
130+
});
131+
}
132+
unset($category);
133+
134+
\uasort($grouped, static function ($catA, $catB) use ($collator) {
135+
return $collator->compare($catA['label'], $catB['label']);
136+
});
137+
138+
$options = [];
139+
140+
foreach ($grouped as $categoryKey => $category) {
141+
$options[] = [
142+
'depth' => 0,
143+
'isSelectable' => false,
144+
'label' => $category['label'],
145+
'value' => $categoryKey,
146+
];
147+
148+
foreach ($category['items'] as $key => $label) {
149+
$options[] = [
150+
'depth' => 1,
151+
'isSelectable' => true,
152+
'label' => $label,
153+
'value' => $key,
154+
];
155+
}
156+
}
157+
158+
return $options;
159+
}
160+
161+
private function getConditionTypeFormField(): SingleSelectionFormField
162+
{
163+
return new class extends SingleSelectionFormField {
164+
protected $templateName = 'shared_categorizedSingleSelectionFormField';
165+
};
166+
}
108167
}

0 commit comments

Comments
 (0)