Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
502094d
PoC to add object filters
dtdesign Mar 13, 2026
982f9db
Clean up the code a bit
dtdesign Mar 29, 2026
9d607c2
Move the JS logic into a separate component
dtdesign Mar 29, 2026
91d6287
Migrate to form builder
dtdesign Mar 29, 2026
30165d1
Basic roundtrip of the data
dtdesign Mar 29, 2026
7063e55
Register object filters through object types, serialization support
dtdesign Mar 30, 2026
d0db381
Use the object type to fetch the builder
dtdesign Mar 30, 2026
f81a876
PoC to filter a query for users by conditions
dtdesign Apr 23, 2026
641f719
Add the ability to test conditions against a user
dtdesign Apr 24, 2026
47a889a
Simplify the calls to apply the conditions
dtdesign Apr 26, 2026
05d3bd0
Add missing column to update script
BurntimeX Sep 23, 2026
7cd8551
Fix some minor issues
BurntimeX Sep 23, 2026
bbef390
Remove object type
BurntimeX Sep 23, 2026
da191f2
Migrate user group assignments to the DatabaseObjectBuilder API
BurntimeX Sep 23, 2026
59446b8
Migrate notices to the DatabaseObjectBuilder API + new conditions API
BurntimeX Sep 25, 2026
f21e90d
Remove unused `IObjectFilter::testValue()`
BurntimeX Sep 25, 2026
0803a73
Generalize object filters to arbitrary DatabaseObjects
BurntimeX Sep 25, 2026
d0c4d21
Split SQL-based filtering into IObjectListFilter / ObjectListFilterHa…
BurntimeX Sep 25, 2026
5c51d98
Extract shared filter/handler caching into AbstractObjectFilterBuilder
BurntimeX Sep 25, 2026
7698e4e
Document object filter builder classes
BurntimeX Sep 25, 2026
20cff89
Document object filter handlers, form field and client-side builder
BurntimeX Sep 25, 2026
4ab366b
Make notice and user group assignment filters extensible via PSR-14 e…
BurntimeX Sep 25, 2026
13d635c
Validate numeric range bounds against number format, minimum and maximum
BurntimeX Sep 26, 2026
733a223
Port old conditions to new API
BurntimeX Sep 26, 2026
e5aca85
Sort object filter options by localized title
BurntimeX Sep 26, 2026
552706d
Require confirmation before removing an object filter
BurntimeX Sep 26, 2026
2015b30
Allow editing configured object filters through a prefilled dialog
BurntimeX Sep 26, 2026
892d2e7
Extract base classes for boolean, selection, multi-selection and rang…
BurntimeX Sep 26, 2026
7c66d8f
Add localized label and icon to the add object filter button
BurntimeX Sep 26, 2026
87bc2a9
Style object filter items as cards and move add button below the list
BurntimeX Sep 26, 2026
7f08226
Show an "AND" joiner between object filters
BurntimeX Sep 26, 2026
e6ea2ad
Move DaysOfWeekObjectFilter into the user filter namespace
BurntimeX Sep 26, 2026
da6dfdb
Move page object filters into the generic filter namespace
BurntimeX Sep 26, 2026
c1670d9
Add start and end date object filters for notices
BurntimeX Sep 26, 2026
f85ebd6
Hide object filters without selectable options from the filter dialog
BurntimeX Sep 27, 2026
797b36e
Remove unused getFormFields() stub from ObjectFilterHandler
BurntimeX Sep 27, 2026
0d52ced
Replace placeholder dialog title and filter label in ObjectFilterBuil…
BurntimeX Sep 27, 2026
a5e6d57
Add signature and cover photo filters
BurntimeX Sep 27, 2026
ca3c7c7
Migrate legacy notice and assignment conditions
BurntimeX Sep 27, 2026
723cbe7
Deprecate legacy getConditions() on Notice and UserGroupAssignment
BurntimeX Sep 27, 2026
cf5e4ae
Prevent non-repeatable object filters from being added more than once
BurntimeX Sep 27, 2026
51e16d7
Group assignment form fields into containers and remove unused phrases
BurntimeX Sep 27, 2026
bef2cd1
Refactor ads to new API
BurntimeX Sep 28, 2026
c203170
Add dependency support to TemplateFormNode
BurntimeX Sep 28, 2026
18d91d6
Re-enable objects once all legacy conditions are migrated
BurntimeX Sep 28, 2026
17416b2
Allow language item variables for UserIntegerPropertyObjectFilter titles
BurntimeX Sep 29, 2026
b1dd093
Remove legacy notice, ad and group assignment condition object types
BurntimeX Sep 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
536 changes: 160 additions & 376 deletions com.woltlab.wcf/objectType.xml

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions com.woltlab.wcf/objectTypeDefinition.xml
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@
<name>com.woltlab.wcf.condition.box</name>
<interfacename>wcf\system\condition\IContentCondition</interfacename>
</definition>
<!-- deprecated since 6.3, only kept for the migration of legacy conditions to object filters -->
<definition>
<name>com.woltlab.wcf.condition.userGroupAssignment</name>
<interfacename>wcf\system\condition\IUserCondition</interfacename>
Expand All @@ -125,6 +126,7 @@
<name>com.woltlab.wcf.condition.trophy</name>
<interfacename>wcf\system\condition\IUserCondition</interfacename>
</definition>
<!-- deprecated since 6.3, only kept for the migration of legacy conditions to object filters -->
<definition>
<name>com.woltlab.wcf.condition.notice</name>
<interfacename>wcf\system\condition\IContentCondition</interfacename>
Expand All @@ -136,6 +138,7 @@
<definition>
<name>com.woltlab.wcf.adLocation</name>
</definition>
<!-- deprecated since 6.3, only kept for the migration of legacy conditions to object filters -->
<definition>
<name>com.woltlab.wcf.condition.ad</name>
<interfacename>wcf\system\condition\IContentCondition</interfacename>
Expand Down
4 changes: 4 additions & 0 deletions com.woltlab.wcf/package.xml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@
Required order of the following steps for the update to 6.3:
<instruction type="script" run="standalone">acp/update_com.woltlab.wcf_6.3_likeObject.php</instruction>
<instruction type="database" run="standalone">acp/database/update_com.woltlab.wcf_6.3_step1.php</instruction>
<instruction type="script" run="standalone">acp/update_com.woltlab.wcf_6.3_conditions.php</instruction>
The `objectType` PIP must run after `update_com.woltlab.wcf_6.3_conditions.php`,
because deleting the legacy condition object types cascades to `wcf1_condition`.
<instruction type="objectType" />
<instruction type="script" run="standalone">acp/update_com.woltlab.wcf_6.3_styleTemplateGroup.php</instruction>
<instruction type="database" run="standalone">acp/database/update_com.woltlab.wcf_6.3_step2.php</instruction>
<instruction type="script" run="standalone">acp/update_com.woltlab.wcf_6.3_embeddedObjects.php</instruction>
Expand Down
1 change: 1 addition & 0 deletions com.woltlab.wcf/templates/shared_multiPageCondition.tpl
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{* @deprecated 6.3 Only used by the deprecated `MultiPageCondition`. *}
<dl>
<dt></dt>
<dd>
Expand Down
13 changes: 13 additions & 0 deletions com.woltlab.wcf/templates/shared_objectFilterFormField.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<div id="{$field->getPrefixedId()}" class="objectFilter__container"></div>

<script data-relocate="true">
require(["WoltLabSuite/Core/Component/Object/Filter/Builder"], ({ setup }) => {
{jsphrase name='wcf.objectFilter.addFilter'}
{jsphrase name='wcf.objectFilter.joiner'}
setup(
document.getElementById('{unsafe:$field->getPrefixedId()|encodeJS}'),
'{unsafe:$field->getEndpoint()|encodeJS}',
{unsafe:$field->toJson()},
);
});
</script>
5 changes: 5 additions & 0 deletions com.woltlab.wcf/templates/shared_templateFormNode.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<div id="{$node->getPrefixedId()}Container"{if !$node->checkDependencies()} style="display: none;"{/if}>
{unsafe:$html}
</div>

{include file='shared_formFieldDependencies' field=$node}
17 changes: 0 additions & 17 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,6 @@ parameters:
count: 2
path: wcfsetup/install/files/lib/acp/form/AbstractCustomOptionForm.class.php

-
message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#'
identifier: empty.notAllowed
count: 2
path: wcfsetup/install/files/lib/acp/form/AdAddForm.class.php

-
message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#'
Expand Down Expand Up @@ -102,12 +97,6 @@ parameters:
count: 1
path: wcfsetup/install/files/lib/acp/form/LanguageImportForm.class.php

-
message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#'
identifier: empty.notAllowed
count: 3
path: wcfsetup/install/files/lib/acp/form/NoticeAddForm.class.php

-
message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#'
identifier: empty.notAllowed
Expand Down Expand Up @@ -204,12 +193,6 @@ parameters:
count: 1
path: wcfsetup/install/files/lib/acp/form/UserGroupAddForm.class.php

-
message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#'
identifier: empty.notAllowed
count: 1
path: wcfsetup/install/files/lib/acp/form/UserGroupAssignmentAddForm.class.php

-
message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#'
identifier: empty.notAllowed
Expand Down
235 changes: 235 additions & 0 deletions ts/WoltLabSuite/Core/Component/Object/Filter/Builder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
/**
* Manages the list of configured object filters of an `ObjectFilterFormField`.
* New filters are configured through a dialog, the resulting list is written
* into a hidden input field when the form is submitted.
*
* @author Alexander Ebert
* @copyright 2001-2026 WoltLab GmbH
* @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
* @since 6.3
*/

import { promiseMutex } from "WoltLabSuite/Core/Helper/PromiseMutex";
import { confirmationFactory } from "../../Confirmation";
import { dialogFactory } from "../../Dialog";
import { getPhrase } from "WoltLabSuite/Core/Language";

/**
* The filter returned by the dialog after it has been submitted.
*/
type Response = {
identifier: string;
summary: string;
value: string;
};

/**
* A filter that has already been configured.
*/
type Filter = {
identifier: string;
summary: string;
value: string;
};

type SerializedData = Filter[];

class ObjectFilterBuilder {
readonly #conditions: Map<HTMLElement, Response> = new Map();
readonly #container: HTMLElement;
readonly #endpoint: string;

constructor(container: HTMLElement, endpoint: string, values: SerializedData) {
this.#container = container;
this.#endpoint = endpoint;

const button = document.createElement("button");
button.type = "button";
button.classList.add("button");

const icon = document.createElement("fa-icon");
icon.setIcon("plus");
button.appendChild(icon);

const span = document.createElement("span");
span.textContent = getPhrase("wcf.objectFilter.addFilter");
button.appendChild(span);

button.addEventListener(
"click",
promiseMutex(() => this.#addFilter()),
);

this.#container.insertAdjacentElement("afterend", button);

const form = this.#container.closest("form");
let shadow: HTMLInputElement | undefined = undefined;
form?.addEventListener("submit", () => {
if (shadow === undefined) {
shadow = document.createElement("input");
shadow.type = "hidden";
shadow.name = this.#container.id;

this.#container.insertAdjacentElement("afterend", shadow);
}

shadow.value = this.#serializeConditions();
});

this.#fromSerializedData(values);
}

/**
* Restores the filters that were configured before.
*/
#fromSerializedData(values: SerializedData): void {
for (const filter of values) {
this.#createCondition(filter);
}
}

/**
* Returns the URL of the dialog, passing the identifiers of the configured
* filters except for the given one that is being edited. Filters that are
* not repeatable are no longer offered once they have been configured.
*/
#getEndpoint(editedItem?: HTMLElement): URL {
const url = new URL(this.#endpoint, window.location.href);
this.#conditions.forEach((condition, item) => {
if (item !== editedItem) {
url.searchParams.append("used[]", condition.identifier);
}
});

return url;
}

/**
* Opens the dialog to configure a new filter and adds it to the list.
*/
async #addFilter(): Promise<void> {
const url = this.#getEndpoint();

const response = await dialogFactory().usingFormBuilder().fromEndpoint<Response>(url.toString());
if (response.ok) {
this.#createCondition(response.result);
}
}

/**
* Opens the dialog prefilled with the given filter and replaces it with the result.
*/
async #editFilter(item: HTMLElement, summary: HTMLElement): Promise<void> {
const data = this.#conditions.get(item)!;

const url = this.#getEndpoint(item);
url.searchParams.set("filter", data.identifier);
url.searchParams.set("value", data.value);

const response = await dialogFactory().usingFormBuilder().fromEndpoint<Response>(url.toString());
if (response.ok) {
summary.innerHTML = response.result.summary;
this.#conditions.set(item, response.result);
}
}

/**
* Adds the given filter to the list, showing its summary, a button to edit it
* and a button to remove it after a confirmation.
*/
#createCondition(data: Response): void {
const item = document.createElement("div");
item.classList.add("objectFilter__item");

const summary = document.createElement("span");
summary.innerHTML = data.summary;

const buttons = document.createElement("div");
buttons.classList.add("objectFilter__item__buttons");

const editButton = document.createElement("button");
editButton.type = "button";
editButton.classList.add("button", "small", "jsTooltip");
editButton.title = getPhrase("wcf.global.button.edit");
editButton.innerHTML = '<fa-icon name="pencil"></fa-icon>';
editButton.addEventListener(
"click",
promiseMutex(() => this.#editFilter(item, summary)),
);

const deleteButton = document.createElement("button");
deleteButton.type = "button";
deleteButton.classList.add("button", "small", "jsTooltip");
deleteButton.title = getPhrase("wcf.global.button.delete");
deleteButton.innerHTML = '<fa-icon name="times"></fa-icon>';
deleteButton.addEventListener(
"click",
promiseMutex(async () => {
// Read the title when clicked, the summary changes when the filter is edited.
if (await confirmationFactory().delete(summary.textContent)) {
this.#deleteCondition(item);
}
}),
);

buttons.append(editButton, deleteButton);
item.append(summary, buttons);

if (this.#conditions.size > 0) {
this.#container.append(this.#createJoiner());
}
this.#container.append(item);

this.#conditions.set(item, data);
}

/**
* Creates the element that is shown between two filters, all filters must be satisfied.
*/
#createJoiner(): HTMLElement {
const joiner = document.createElement("div");
joiner.classList.add("objectFilter__joiner");
joiner.textContent = getPhrase("wcf.objectFilter.joiner");

return joiner;
}

/**
* Removes the given filter from the list together with the joiner that
* separates it from the previous filter, or from the next filter if it is
* the first one.
*/
#deleteCondition(element: HTMLElement): void {
const joiner = element.previousElementSibling ?? element.nextElementSibling;
if (joiner?.classList.contains("objectFilter__joiner")) {
joiner.remove();
}

element.remove();
this.#conditions.delete(element);
}

/**
* Serializes the configured filters as a JSON-encoded list of
* `[filterIdentifier, serializedValue]` pairs.
*/
#serializeConditions(): string {
const values: [string, string][] = [];
this.#conditions.forEach((condition) => {
values.push([condition.identifier, condition.value]);
});

return JSON.stringify(values);
}
}

/**
* Initializes the filter builder for the given container.
*
* @param container element that holds the list of configured filters, its id is used as the name of the submitted value
* @param endpoint URL of the dialog to configure a new filter
* @param values filters that were configured before
*/
export function setup(container: HTMLElement, endpoint: string, values: SerializedData): void {
new ObjectFilterBuilder(container, endpoint, values);
}
Original file line number Diff line number Diff line change
Expand Up @@ -120,4 +120,16 @@
->onDelete('SET NULL')
->onUpdate('NO ACTION'),
]),
PartialDatabaseTable::create('wcf1_user_group_assignment')
->columns([
JsonDatabaseTableColumn::create('conditions'),
]),
PartialDatabaseTable::create('wcf1_notice')
->columns([
JsonDatabaseTableColumn::create('conditions'),
]),
PartialDatabaseTable::create('wcf1_ad')
->columns([
JsonDatabaseTableColumn::create('conditions'),
]),
];
Loading
Loading