From: WoltLab GmbH
It is strongly recommended to keep the template logic as simple as possible by moving the heavy lifting into regular PHP code, reducing the number of (specialized) modifiers that need to be applied.
See WoltLab/WCF#4788 for details.
+The |time
, |plainTime
and |date
modifiers have been deprecated and replaced by a unified {time}
function.
The main benefit is that it is no longer necessary to specify the @
symbol when rendering the interactive time element, making it easier to perform a security review of templates by searching for the @
symbol.
See WoltLab/WCF#5459 for details.
In WoltLab Suite 6.0 the comment system has been overhauled. In the process, the integration of comments via templates has been significantly simplified:
@@ -2685,7 +2703,7 @@ In the process, the integration of comments via templates has been significantly Last update: - 2023-02-03 + 2023-04-27 diff --git a/6.0/search/search_index.json b/6.0/search/search_index.json index 4d44359a..ce474025 100644 --- a/6.0/search/search_index.json +++ b/6.0/search/search_index.json @@ -1 +1 @@ -{"config":{"lang":["en"],"separator":"[\\s\\-]+","pipeline":["stopWordFilter"]},"docs":[{"location":"","title":"WoltLab Suite 6.0 Documentation","text":""},{"location":"#introduction","title":"Introduction","text":"This documentation explains the basic API functionality and the creation of own packages. It is expected that you are somewhat experienced with PHP, object-oriented programming and MySQL.
Head over to the quick start tutorial to learn more.
"},{"location":"#about-woltlab-suite","title":"About WoltLab Suite","text":"WoltLab Suite Core as well as most of the other packages are available on GitHub and are licensed under the terms of the GNU Lesser General Public License 2.1.
"},{"location":"getting-started/","title":"Creating a simple package","text":""},{"location":"getting-started/#setup-and-requirements","title":"Setup and Requirements","text":"This guide will help you to create a simple package that provides a simple test page. It is nothing too fancy, but you can use it as the foundation for your next project.
There are some requirements you should met before starting:
*.php
and *.tpl
should be encoded with ANSI/ASCII*.xml
are always encoded with UTF-8, but omit the BOM (byte-order-mark)8
spaces, this is used in the entire software and will ease reading the source files*.tar
archives, e.g. 7-Zip on WindowsWe want to create a simple page that will display the sentence \"Hello World\" embedded into the application frame. Create an empty directory in the workspace of your choice to start with.
Create a new file called package.xml
and insert the code below:
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<package xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/package.xsd\" name=\"com.example.test\">\n<packageinformation>\n<!-- com.example.test -->\n<packagename>Simple Package</packagename>\n<packagedescription>A simple package to demonstrate the package system of WoltLab Suite Core</packagedescription>\n<version>1.0.0</version>\n<date>2019-04-28</date>\n</packageinformation>\n<authorinformation>\n<author>Your Name</author>\n<authorurl>http://www.example.com</authorurl>\n</authorinformation>\n<excludedpackages>\n<excludedpackage version=\"6.0.0 Alpha 1\">com.woltlab.wcf</excludedpackage>\n</excludedpackages>\n<instructions type=\"install\">\n<instruction type=\"file\" />\n<instruction type=\"template\" />\n<instruction type=\"page\" />\n</instructions>\n</package>\n
There is an entire chapter on the package system that explains what the code above does and how you can adjust it to fit your needs. For now we'll keep it as it is.
"},{"location":"getting-started/#the-php-class","title":"The PHP Class","text":"The next step is to create the PHP class which will serve our page:
files
in the same directory where package.xml
is locatedfiles
and create the directory lib
lib
and create the directory page
page
, please create the file TestPage.class.php
Copy and paste the following code into the TestPage.class.php
:
<?php\nnamespace wcf\\page;\nuse wcf\\system\\WCF;\n\n/**\n * A simple test page for demonstration purposes.\n *\n * @author YOUR NAME\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n */\nclass TestPage extends AbstractPage {\n /**\n * @var string\n */\n protected $greet = '';\n\n /**\n * @inheritDoc\n */\n public function readParameters() {\n parent::readParameters();\n\n if (isset($_GET['greet'])) $this->greet = $_GET['greet'];\n }\n\n /**\n * @inheritDoc\n */\n public function readData() {\n parent::readData();\n\n if (empty($this->greet)) {\n $this->greet = 'World';\n }\n }\n\n /**\n * @inheritDoc\n */\n public function assignVariables() {\n parent::assignVariables();\n\n WCF::getTPL()->assign([\n 'greet' => $this->greet\n ]);\n }\n}\n
The class inherits from wcf\\page\\AbstractPage, the default implementation of pages without form controls. It defines quite a few methods that will be automatically invoked in a specific order, for example readParameters()
before readData()
and finally assignVariables()
to pass arbitrary values to the template.
The property $greet
is defined as World
, but can optionally be populated through a GET variable (index.php?test/&greet=You
would output Hello You!
). This extra code illustrates the separation of data processing that takes place within all sort of pages, where all user-supplied data is read from within a single method. It helps organizing the code, but most of all it enforces a clean class logic that does not start reading user input at random places, including the risk to only escape the input of variable $_GET['foo']
4 out of 5 times.
Reading and processing the data is only half the story, now we need a template to display the actual content for our page. You don't need to specify it yourself, it will be automatically guessed based on your namespace and class name, you can read more about it later.
Last but not least, you must not include the closing PHP tag ?>
at the end, it can cause PHP to break on whitespaces and is not required at all.
Navigate back to the root directory of your package until you see both the files
directory and the package.xml
. Now create a directory called templates
, open it and create the file test.tpl
.
{include file='header'}\n\n<div class=\"section\">\n Hello {$greet}!\n</div>\n\n{include file='footer'}\n
Templates are a mixture of HTML and Smarty-like template scripting to overcome the static nature of raw HTML. The above code will display the phrase Hello World!
in the application frame, just as any other page would render. The included templates header
and footer
are responsible for the majority of the overall page functionality, but offer a whole lot of customization abilities to influence their behavior and appearance.
The package now contains the PHP class and the matching template, but it is still missing the page definition. Please create the file page.xml
in your project's root directory, thus on the same level as the package.xml
.
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/page.xsd\">\n<import>\n<page identifier=\"com.example.test.Test\">\n<controller>wcf\\page\\TestPage</controller>\n<name language=\"en\">Test Page</name>\n<pageType>system</pageType>\n</page>\n</import>\n</data>\n
You can provide a lot more data for a page, including logical nesting and dedicated handler classes for display in menus.
"},{"location":"getting-started/#building-the-package","title":"Building the Package","text":"If you have followed the above guidelines carefully, your package directory should now look like this:
\u251c\u2500\u2500 files\n\u2502 \u2514\u2500\u2500 lib\n\u2502 \u2514\u2500\u2500 page\n\u2502 \u2514\u2500\u2500 TestPage.class.php\n\u251c\u2500\u2500 package.xml\n\u251c\u2500\u2500 page.xml\n\u2514\u2500\u2500 templates\n \u2514\u2500\u2500 test.tpl\n
Both files and templates are archive-based package components, that deploy their payload using tar archives rather than adding the raw files to the package file. Please create the archive files.tar
and add the contents of the files/*
directory, but not the directory files/
itself. Repeat the same process for the templates
directory, but this time with the file name templates.tar
. Place both files in the root of your project.
Last but not least, create the package archive com.example.test.tar
and add all the files listed below.
files.tar
package.xml
page.xml
templates.tar
The archive's filename can be anything you want, all though it is the general convention to use the package name itself for easier recognition.
"},{"location":"getting-started/#installation","title":"Installation","text":"Open the Administration Control Panel and navigate to Configuration > Packages > Install Package
, click on Upload Package
and select the file com.example.test.tar
from your disk. Follow the on-screen instructions until it has been successfully installed.
Open a new browser tab and navigate to your newly created page. If WoltLab Suite is installed at https://example.com/wsc/
, then the URL should read https://example.com/wsc/index.php?test/
.
Congratulations, you have just created your first package!
"},{"location":"getting-started/#developer-tools","title":"Developer Tools","text":"The developer tools provide an interface to synchronize the data of an installed package with a bare repository on the local disk. You can re-import most PIPs at any time and have the changes applied without crafting a manual update. This process simulates a regular package update with a single PIP only, and resets the cache after the import has been completed.
"},{"location":"getting-started/#registering-a-project","title":"Registering a Project","text":"Projects require the absolute path to the package directory, that is, the directory where it can find the package.xml
. It is not required to install an package to register it as a project, but you have to install it in order to work with it. It does not install the package by itself!
There is a special button on the project list that allows for a mass-import of projects based on a search path. Each direct child directory of the provided path will be tested and projects created this way will use the identifier extracted from the package.xml
.
The install instructions in the package.xml
are ignored when offering the PIP imports, the detection works entirely based on the default filename for each PIP. On top of that, only PIPs that implement the interface wcf\\system\\devtools\\pip\\IIdempotentPackageInstallationPlugin
are valid for import, as it indicates that importing the PIP multiple times will have no side-effects and that the result is deterministic regardless of the number of times it has been imported.
Some built-in PIPs, such as sql
or script
, do not qualify for this step and remain unavailable at all times. However, you can still craft and perform an actual package update to have these PIPs executed.
The class name including the namespace is used to automatically determine the path to the template and its name. The example above used the page class name wcf\\page\\TestPage
that is then split into four distinct parts:
wcf
, the internal abbreviation of WoltLab Suite Core (previously known as WoltLab Community Framework)\\page\\
(ignored)Test
, the actual name that is used for both the template and the URLPage
(page type, ignored)The fragments 1.
and 3.
from above are used to construct the path to the template: <installDirOfWSC>/templates/test.tpl
(the first letter of Test
is being converted to lower-case).
This is a list of code snippets that do not fit into any of the other articles and merely describe how to achieve something very specific, rather than explaining the inner workings of a function.
"},{"location":"javascript/code-snippets/#imageviewer","title":"ImageViewer","text":"The ImageViewer is available on all frontend pages by default, you can easily add images to the viewer by wrapping the thumbnails with a link with the CSS class jsImageViewer
that points to the full version.
<a href=\"http://example.com/full.jpg\" class=\"jsImageViewer\">\n <img src=\"http://example.com/thumbnail.jpg\">\n</a>\n
"},{"location":"javascript/components_confirmation/","title":"Confirmation - JavaScript API","text":"The purpose of confirmation dialogs is to prevent misclicks and to inform the user of potential consequences of their action. A confirmation dialog should always ask a concise question that includes a reference to the object the action is performed upon.
You can exclude extra information or form elements in confirmation dialogs, but these should be kept as compact as possible.
"},{"location":"javascript/components_confirmation/#example","title":"Example","text":"const result = await confirmationFactory()\n.custom(\"Do you want a cookie?\")\n.withoutMessage();\nif (result) {\n// User has confirmed the dialog.\n}\n
Confirmation dialogs are a special type that use the role=\"alertdialog\"
attribute and will always include a cancel button. The dialog itself will be limited to a width of 500px, the title can wrap into multiple lines and there will be no \u201cX\u201d button to close the dialog.
Over the past few years the term \u201cConfirmation Fatique\u201d has emerged that describes the issue of having too many confirmation dialogs even there is no real need for them. A confirmation dialog should only be displayed when the action requires further inputs, for example, a soft delete that requires a reason, or when the action is destructive.
"},{"location":"javascript/components_confirmation/#proper-wording","title":"Proper Wording","text":"The confirmation question should hint the severity of the action, in particular whether or not it is destructive. Destructive actions are those that cannot be undone and either cause a permanent mutation or that cause data loss. All questions should be phrased in one or two ways depending on the action.
Destructive action:
Are you sure you want to delete \u201cExample Object\u201d? (German) Wollen Sie \u201eBeispiel-Objekt\u201c wirklich l\u00f6schen?
All other actions:
Do you want to move \u201cExample Object\u201d to the trash bin? (German) M\u00f6chten Sie \u201eBeispiel-Objekt\u201c in den Papierkorb verschieben?
"},{"location":"javascript/components_confirmation/#available-presets","title":"Available Presets","text":"WoltLab Suite 6.0 currently ships with three presets for common confirmation dialogs.
All three presets have an optional parameter for the title of the related object as part of the question asked to the user. It is strongly recommended to provide the title if it exists, otherwise it can be omitted and an indeterminate variant is used instead.
"},{"location":"javascript/components_confirmation/#soft-delete","title":"Soft Delete","text":"Soft deleting objects with an optional input field for a reason:
const askForReason = true;\nconst { result, reason } = await confirmationFactory().softDelete(\ntheObjectName,\naskForReason\n);\nif (result) {\nconsole.log(\n\"The user has requested a soft delete, the following reason was provided:\",\nreason\n);\n}\n
The reason
will always be a string, but with a length of zero if the result
is false
or if no reason was requested. You can simply omit the value if you do not use the reason.
const askForReason = false;\nconst { result } = await confirmationFactory().softDelete(\ntheObjectName,\naskForReason\n);\nif (result) {\nconsole.log(\"The user has requested a soft delete.\");\n}\n
"},{"location":"javascript/components_confirmation/#restore","title":"Restore","text":"Restore a previously soft deleted object:
const result = await confirmationFactory().restore(theObjectName);\nif (result) {\nconsole.log(\"The user has requested to restore the object.\");\n}\n
"},{"location":"javascript/components_confirmation/#delete","title":"Delete","text":"Permanently delete an object, will inform the user that the action cannot be undone:
const result = await confirmationFactory().delete(theObjectName);\nif (result) {\nconsole.log(\"The user has requested to delete the object.\");\n}\n
"},{"location":"javascript/components_dialog/","title":"Dialogs - JavaScript API","text":"Modal dialogs are a powerful tool to draw the viewer\u2019s attention to an important message, question or form. Dialogs naturally interrupt the workflow and prevent the navigation to other sections by making other elements on the page inert.
WoltLab Suite 6.0 ships with four different types of dialogs.
"},{"location":"javascript/components_dialog/#quickstart","title":"Quickstart","text":"There are four different types of dialogs that each fulfill their own specialized role and that provide built-in features to make the development much easier. Please see the following list to make a quick decision of what kind of dialog you need.
Dialogs may contain just an explanation or extra information that should be presented to the viewer without requiring any further interaction. The dialog can be closed via the \u201cX\u201d button or by clicking the modal backdrop.
const dialog = dialogFactory().fromHtml(\"<p>Hello World</p>\").withoutControls();\ndialog.show(\"Greetings from my dialog\");\n
"},{"location":"javascript/components_dialog/#when-to-use","title":"When to Use","text":"The short answer is: Don\u2019t.
Dialogs without controls are an anti-pattern because they only contain content that does not require the modal appearance of a dialog. More often than not dialogs are used for this kind of content because they are easy to use without thinking about better ways to present the content.
If possible these dialogs should be avoided and the content is presented in a more suitable way, for example, as a flyout or by showing content on an existing or new page.
"},{"location":"javascript/components_dialog/#alerts","title":"Alerts","text":"Alerts are designed to inform the user of something important that requires no further action by the user. Typical examples for alerts are error messages or warnings.
An alert will only provide a single button to acknowledge the dialog and must not contain interactive content. The dialog itself will be limited to a width of 500px, the title can wrap into multiple lines and there will be no \u201cX\u201d button to close the dialog.
const dialog = dialogFactory()\n.fromHtml(\"<p>ERROR: Something went wrong!</p>\")\n.asAlert();\ndialog.show(\"Server Error\");\n
You can customize the label of the primary button to better explain what will happen next. This can be useful for alerts that will have a side-effect when closing the dialog, such as redirect to a different page.
const dialog = dialogFactory()\n.fromHtml(\"<p>Something went wrong, we cannot find your shopping cart.</p>\")\n.asAlert({\nprimary: \"Back to the Store Page\",\n});\n\ndialog.addEventListener(\"primary\", () => {\nwindow.location.href = \"https://example.com/shop/\";\n});\n\ndialog.show(\"The shopping cart is missing\");\n
The primary
event is triggered both by clicking on the primary button and by clicks on the modal backdrop.
Alerts are a special type of dialog that use the role=\"alert\"
attribute to signal its importance to assistive tools. Use alerts sparingly when there is no other way to communicate that something did not work as expected.
Alerts should not be used for cases where you expect an error to happen. For example, a form control that expectes an input to fall within a restricted range should show an inline error message instead of raising an alert.
"},{"location":"javascript/components_dialog/#confirmation","title":"Confirmation","text":"Confirmation dialogs are supported through a separate factory function that provides a set of presets as well as a generic API. Please see the separate documentation for confirmation dialogs to learn more.
"},{"location":"javascript/components_dialog/#prompts","title":"Prompts","text":"The most common type of dialogs are prompts that are similar to confirmation dialogs, but without the restrictions and with a regular title. These dialogs can be used universally and provide a submit and cancel button by default.
In addition they offer an \u201cextra\u201d button that is placed to the left of the default buttons are can be used to offer a single additional action. A possible use case for an \u201cextra\u201d button would be a dialog that includes an instance of the WYSIWYG editor, the extra button could be used to trigger a message preview.
"},{"location":"javascript/components_dialog/#code-example","title":"Code Example","text":"<button id=\"showMyDialog\">Show the dialog</button>\n\n<template id=\"myDialog\">\n <dl>\n <dt>\n <label for=\"myInput\">Title</label>\n </dt>\n <dd>\n <input type=\"text\" name=\"myInput\" id=\"myInput\" value=\"\" required />\n </dd>\n </dl>\n</template>\n
document.getElementById(\"showMyDialog\")!.addEventListener(\"click\", () => {\nconst dialog = dialogFactory().fromId(\"myDialog\").asPrompt();\n\ndialog.addEventListener(\"primary\", () => {\nconst myInput = document.getElementById(\"myInput\");\n\nconsole.log(\"Provided title:\", myInput.value.trim());\n});\n});\n
"},{"location":"javascript/components_dialog/#custom-buttons","title":"Custom Buttons","text":"The asPrompt()
call permits some level of customization of the form control buttons.
The primary
option is used to change the default label of the primary button.
dialogFactory()\n.fromId(\"myDialog\")\n.asPrompt({\nprimary: Language.get(\"wcf.dialog.button.primary\"),\n});\n
"},{"location":"javascript/components_dialog/#adding-an-extra-button","title":"Adding an Extra Button","text":"The extra button has no default label, enabling it requires you to provide a readable name.
const dialog = dialogFactory()\n.fromId(\"myDialog\")\n.asPrompt({\nextra: Language.get(\"my.extra.button.name\"),\n});\n\ndialog.addEventListener(\"extra\", () => {\n// The extra button does nothing on its own. If you want\n// to close the button after performing an action you\u2019ll\n// need to call `dialog.close()` yourself.\n});\n
"},{"location":"javascript/components_dialog/#interacting-with-dialogs","title":"Interacting with dialogs","text":"Dialogs are represented by the <woltlab-core-dialog>
element that exposes a set of properties and methods to interact with it.
You can open a dialog through the .show()
method that expects the title of the dialog as the only argument. Check the .open
property to determine if the dialog is currently open.
Programmatically closing a dialog is possibly through .close()
.
All contents of a dialog exists within a child element that can be accessed through the content
property.
// Add some text to the dialog.\nconst p = document.createElement(\"p\");\np.textContent = \"Hello World\";\ndialog.content.append(p);\n\n// Find a text input inside the dialog.\nconst input = dialog.content.querySelector('input[type=\"text\"]');\n
"},{"location":"javascript/components_dialog/#disabling-the-submit-button-of-a-dialog","title":"Disabling the Submit Button of a Dialog","text":"You can prevent the dialog submission until a condition is met, allowing you to dynamically enable or disable the button at will.
dialog.incomplete = false;\n\nconst checkbox = dialog.content.querySelector('input[type=\"checkbox\"]')!;\ncheckbox.addEventListener(\"change\", () => {\n// Block the dialog submission unless the checkbox is checked.\ndialog.incomplete = !checkbox.checked;\n});\n
"},{"location":"javascript/components_dialog/#managing-an-instance-of-a-dialog","title":"Managing an Instance of a Dialog","text":"The old API for dialogs implicitly kept track of the instance by binding it to the this
parameter as seen in calls like UiDialog.open(this);
. The new implementation requires to you to keep track of the dialog on your own.
class MyComponent {\n#dialog?: WoltlabCoreDialogElement;\n\nconstructor() {\nconst button = document.querySelector(\".myButton\") as HTMLButtonElement;\nbutton.addEventListener(\"click\", () => {\nthis.#showGreeting(button.dataset.name);\n});\n}\n\n#showGreeting(name: string | undefined): void {\nconst dialog = this.#getDialog();\n\nconst p = dialog.content.querySelector(\"p\")!;\nif (name === undefined) {\np.textContent = \"Hello World\";\n} else {\np.textContent = `Hello ${name}`;\n}\n\ndialog.show(\"Greetings!\");\n}\n\n#getDialog(): WoltlabCoreDialogElement {\nif (this.#dialog === undefined) {\nthis.#dialog = dialogFactory()\n.fromHtml(\"<p>Hello from MyComponent</p>\")\n.withoutControls();\n}\n\nreturn this.#dialog;\n}\n}\n
"},{"location":"javascript/components_dialog/#event-access","title":"Event Access","text":"You can bind event listeners to specialized events to get notified of events and to modify its behavior.
"},{"location":"javascript/components_dialog/#afterclose","title":"afterClose
","text":"This event cannot be canceled.
Fires when the dialog has closed.
dialog.addEventListener(\"afterClose\", () => {\n// Dialog was closed.\n});\n
"},{"location":"javascript/components_dialog/#close","title":"close
","text":"Fires when the dialog is about to close.
dialog.addEventListener(\"close\", (event) => {\nif (someCondition) {\nevent.preventDefault();\n}\n});\n
"},{"location":"javascript/components_dialog/#cancel","title":"cancel
","text":"Fires only when there is a \u201cCancel\u201d button and the user has either pressed that button or clicked on the modal backdrop. The dialog will close if the event is not canceled.
dialog.addEventListener(\"cancel\", (event) => {\nif (someCondition) {\nevent.preventDefault();\n}\n});\n
"},{"location":"javascript/components_dialog/#extra","title":"extra
","text":"This event cannot be canceled.
Fires when an extra button is present and the button was clicked by the user. This event does nothing on its own and is supported for dialogs of type \u201cPrompt\u201d only.
dialog.addEventListener(\"extra\", () => {\n// The extra button was clicked.\n});\n
"},{"location":"javascript/components_dialog/#primary","title":"primary
","text":"This event cannot be canceled.
Fires only when there is a primary action button and the user has either pressed that button or submitted the form through keyboard controls.
dialog.addEventListener(\"primary\", () => {\n// The primary action button was clicked or the\n// form was submitted through keyboard controls.\n//\n// The `validate` event has completed successfully.\n});\n
"},{"location":"javascript/components_dialog/#validate","title":"validate
","text":"Fires only when there is a form and the user has pressed the primary action button or submitted the form through keyboard controls. Canceling this event is interpreted as a form validation failure.
const input = document.createElement(\"input\");\ndialog.content.append(input);\n\ndialog.addEventListener(\"validate\", (event) => {\nif (input.value.trim() === \"\") {\nevent.preventDefault();\n\n// Display an inline error message.\n}\n});\n
"},{"location":"javascript/components_google_maps/","title":"Google Maps - JavaScript API","text":"The Google Maps component is used to show a map using the Google Maps API.
"},{"location":"javascript/components_google_maps/#example","title":"Example","text":"The component can be included directly as follows:
<woltlab-core-google-maps\n id=\"id\"\n class=\"googleMap\"\n api-key=\"your_api_key\"\n></woltlab-core-google-maps>\n
Alternatively, the component can be included via a template that uses the API key from the configuration and also handles the user content:
{include file='googleMapsElement' googleMapsElementID=\"id\"}\n
"},{"location":"javascript/components_google_maps/#parameters","title":"Parameters","text":""},{"location":"javascript/components_google_maps/#id","title":"id
","text":"ID of the map instance.
"},{"location":"javascript/components_google_maps/#api-key","title":"api-key
","text":"Google Maps API key.
"},{"location":"javascript/components_google_maps/#zoom","title":"zoom
","text":"Defaults to 13
.
Default zoom factor of the map.
"},{"location":"javascript/components_google_maps/#lat","title":"lat
","text":"Defaults to 0
.
Latitude of the default map position.
"},{"location":"javascript/components_google_maps/#lng","title":"lng
","text":"Defaults to 0
.
Longitude of the default map position.
"},{"location":"javascript/components_google_maps/#access-user-location","title":"access-user-location
","text":"If set, the map will try to center based on the user's current position.
"},{"location":"javascript/components_google_maps/#map-related-functions","title":"Map-related Functions","text":""},{"location":"javascript/components_google_maps/#addmarker","title":"addMarker
","text":"Adds a marker to the map.
"},{"location":"javascript/components_google_maps/#example_1","title":"Example","text":"<script data-relocate=\"true\">\nrequire(['WoltLabSuite/Core/Component/GoogleMaps/Marker'], ({ addMarker }) => {\nvoid addMarker(document.getElementById('map_id'), 52.4505, 13.7546, 'Title', true);\n});\n</script>\n
"},{"location":"javascript/components_google_maps/#parameters_1","title":"Parameters","text":""},{"location":"javascript/components_google_maps/#element","title":"element
","text":"<woltlab-core-google-maps>
element.
latitude
","text":"Marker position (latitude)
"},{"location":"javascript/components_google_maps/#longitude","title":"longitude
","text":"Marker position (longitude)
"},{"location":"javascript/components_google_maps/#title","title":"title
","text":"Title of the marker.
"},{"location":"javascript/components_google_maps/#focus","title":"focus
","text":"Defaults to false
.
True, to focus the map on the position of the marker.
"},{"location":"javascript/components_google_maps/#adddraggablemarker","title":"addDraggableMarker
","text":"Adds a draggable marker to the map.
"},{"location":"javascript/components_google_maps/#example_2","title":"Example","text":"<script data-relocate=\"true\">\nrequire(['WoltLabSuite/Core/Component/GoogleMaps/Marker'], ({ addDraggableMarker }) => {\nvoid addDraggableMarker(document.getElementById('map_id'), 52.4505, 13.7546);\n});\n</script>\n
"},{"location":"javascript/components_google_maps/#parameters_2","title":"Parameters","text":""},{"location":"javascript/components_google_maps/#element_1","title":"element
","text":"<woltlab-core-google-maps>
element.
latitude
","text":"Marker position (latitude)
"},{"location":"javascript/components_google_maps/#longitude_1","title":"longitude
","text":"Marker position (longitude)
"},{"location":"javascript/components_google_maps/#geocoding","title":"Geocoding
","text":"Enables the geocoding feature for a map.
"},{"location":"javascript/components_google_maps/#example_3","title":"Example","text":"<input\n type=\"text\"\n data-google-maps-geocoding=\"map_id\"\n data-google-maps-marker\n data-google-maps-geocoding-store=\"prefix\"\n>\n
"},{"location":"javascript/components_google_maps/#parameters_3","title":"Parameters","text":""},{"location":"javascript/components_google_maps/#data-google-maps-geocoding","title":"data-google-maps-geocoding
","text":"ID of the <woltlab-core-google-maps>
element.
data-google-maps-marker
","text":"If set, a movable marker is created that is coupled with the input field.
"},{"location":"javascript/components_google_maps/#data-google-maps-geocoding-store","title":"data-google-maps-geocoding-store
","text":"If set, the coordinates (latitude and longitude) are stored comma-separated in a hidden input field. Optionally, a value can be passed that is used as a prefix for the name of the input field.
"},{"location":"javascript/components_google_maps/#markerloader","title":"MarkerLoader
","text":"Handles a large map with many markers where markers are loaded via AJAX.
"},{"location":"javascript/components_google_maps/#example_4","title":"Example","text":"<script data-relocate=\"true\">\nrequire(['WoltLabSuite/Core/Component/GoogleMaps/MarkerLoader'], ({ setup }) => {\nsetup(document.getElementById('map_id'), 'action_classname', {});\n});\n</script>\n
"},{"location":"javascript/components_google_maps/#parameters_4","title":"Parameters","text":""},{"location":"javascript/components_google_maps/#element_2","title":"element
","text":"<woltlab-core-google-maps>
element.
actionClassName
","text":"Name of the PHP class that is called to retrieve the markers via AJAX.
"},{"location":"javascript/components_google_maps/#additionalparameters","title":"additionalParameters
","text":"Additional parameters that are transmitted when querying the markers via AJAX.
"},{"location":"javascript/components_pagination/","title":"Pagination - JavaScript API","text":"The pagination component is used to expose multiple pages to the end user. This component supports both static URLs and dynamic navigation using DOM events.
"},{"location":"javascript/components_pagination/#example","title":"Example","text":"<woltlab-core-pagination page=\"1\" count=\"10\" url=\"https://www.woltlab.com\"></woltlab-core-pagination>\n
"},{"location":"javascript/components_pagination/#parameters","title":"Parameters","text":""},{"location":"javascript/components_pagination/#page","title":"page
","text":"Defaults to 1
.
The number of the currently displayed page.
"},{"location":"javascript/components_pagination/#count","title":"count
","text":"Defaults to 0
.
Number of available pages. Must be greater than 1
for the pagination to be displayed.
url
","text":"Defaults to an empty string.
If defined, static pagination links are created based on the URL with the pageNo
parameter appended to it. Otherwise only the switchPage
event will be fired if a user clicks on a pagination link.
switchPage
","text":"The switchPage
event will be fired when the user clicks on a pagination link. The event detail will contain the number of the selected page. The event can be canceled to prevent navigation.
jumpToPage
","text":"The switchPage
event will be fired when the user clicks on one of the ellipsis buttons within the pagination.
WoltLab Suite 5.4 introduced support for TypeScript, migrating all existing modules to TypeScript. The JavaScript section of the documentation is not yet updated to account for the changes, possibly explaining concepts that cannot be applied as-is when writing TypeScript. You can learn about basic TypeScript use in WoltLab Suite, such as consuming WoltLab Suite\u2019s types in own packages, within in the TypeScript section.
"},{"location":"javascript/general-usage/#the-history-of-the-legacy-api","title":"The History of the Legacy API","text":"The WoltLab Suite 3.0 introduced a new API based on AMD-Modules with ES5-JavaScript that was designed with high performance and visible dependencies in mind. This was a fundamental change in comparison to the legacy API that was build many years before while jQuery was still a thing and we had to deal with ancient browsers such as Internet Explorer 9 that felt short in both CSS and JavaScript capabilities.
Fast forward a few years, the old API is still around and most important, it is actively being used by some components that have not been rewritten yet. This has been done to preserve the backwards-compatibility and to avoid the significant amount of work that it requires to rewrite a component. The components invoked on page initialization have all been rewritten to use the modern API, but some deferred objects that are invoked later during the page runtime may still use the old API.
However, the legacy API is deprecated and you should not rely on it for new components at all. It slowly but steadily gets replaced up until a point where its last bits are finally removed from the code base.
"},{"location":"javascript/general-usage/#embedding-javascript-inside-templates","title":"Embedding JavaScript inside Templates","text":"The <script>
-tags are extracted and moved during template processing, eventually placing them at the very end of the body element while preserving their order of appearance.
This behavior is controlled through the data-relocate=\"true\"
attribute on the <script>
which is mandatory for almost all scripts, mostly because their dependencies (such as jQuery) are moved to the bottom anyway.
<script data-relocate=\"true\">\n$(function() {\n// Code that uses jQuery (Legacy API)\n});\n</script>\n\n<!-- or -->\n\n<script data-relocate=\"true\">\nrequire([\"Some\", \"Dependencies\"], function(Some, Dependencies) {\n// Modern API\n});\n</script>\n
"},{"location":"javascript/general-usage/#including-external-javascript-files","title":"Including External JavaScript Files","text":"The AMD-Modules used in the new API are automatically recognized and lazy-loaded on demand, so unless you have a rather large and pre-compiled code-base, there is nothing else to worry about.
"},{"location":"javascript/general-usage/#debug-variants-and-cache-buster","title":"Debug-Variants and Cache-Buster","text":"Your JavaScript files may change over time and you would want the users' browsers to always load and use the latest version of your files. This can be achieved by appending the special LAST_UPDATE_TIME
constant to your file path. It contains the unix timestamp of the last time any package was installed, updated or removed and thus avoid outdated caches by relying on a unique value, without invalidating the cache more often that it needs to be.
<script data-relocate=\"true\" src=\"{$__wcf->getPath('app')}js/App.js?t={@LAST_UPDATE_TIME}\"></script>\n
For small scripts you can simply serve the full, non-minified version to the user at all times, the differences in size and execution speed are insignificant and are very unlikely to offer any benefits. They might even yield a worse performance, because you'll have to include them statically in the template, even if the code is never called.
However, if you're including a minified build in your app or plugin, you should include a switch to load the uncompressed version in the debug mode, while serving the minified and optimized file to the average visitor. You should use the ENABLE_DEBUG_MODE
constant to decide which version should be loaded.
<script data-relocate=\"true\" src=\"{$__wcf->getPath('app')}js/App{if !ENABLE_DEBUG_MODE}.min{/if}.js?t={@LAST_UPDATE_TIME}\"></script>\n
"},{"location":"javascript/general-usage/#the-accelerated-guest-view-tiny-builds","title":"The Accelerated Guest View (\"Tiny Builds\")","text":"You can learn more on the Accelerated Guest View in the migration docs.
The \u201cAccelerated Guest View\u201d aims to decrease page size and to improve responsiveness by enabling a read-only mode for visitors. If you are providing a separate compiled build for this mode, you'll need to include yet another switch to serve the right version to the visitor.
<script data-relocate=\"true\" src=\"{$__wcf->getPath('app')}js/App{if !ENABLE_DEBUG_MODE}{if VISITOR_USE_TINY_BUILD}.tiny{/if}.min{/if}.js?t={@LAST_UPDATE_TIME}\"></script>\n
"},{"location":"javascript/general-usage/#the-js-template-plugin","title":"The {js}
Template Plugin","text":"The {js}
template plugin exists solely to provide a much easier and less error-prone method to include external JavaScript files.
{js application='app' file='App' hasTiny=true}\n
The hasTiny
attribute is optional, you can set it to false
or just omit it entirely if you do not provide a tiny build for your file.
The legacy JavaScript API is the original code that was part of the 2.x series of WoltLab Suite, formerly known as WoltLab Community Framework. It has been superseded for the most part by the ES5/AMD-modules API introduced with WoltLab Suite 3.0.
Some parts still exist to this day for backwards-compatibility and because some less important components have not been rewritten yet. The old API is still supported, but marked as deprecated and will continue to be replaced parts by part in future releases, up until their entire removal, including jQuery support.
This guide does not provide any explanation on the usage of those legacy components, but instead serves as a cheat sheet to convert code to use the new API.
"},{"location":"javascript/legacy-api/#classes","title":"Classes","text":""},{"location":"javascript/legacy-api/#singletons","title":"Singletons","text":"Singleton instances are designed to provide a unique \"instance\" of an object regardless of when its first instance was created. Due to the lack of a class
construct in ES5, they are represented by mere objects that act as an instance.
// App.js\nwindow.App = {};\nApp.Foo = {\nbar: function() {}\n};\n\n// --- NEW API ---\n\n// App/Foo.js\ndefine([], function() {\n\"use strict\";\n\nreturn {\nbar: function() {}\n};\n});\n
"},{"location":"javascript/legacy-api/#regular-classes","title":"Regular Classes","text":"// App.js\nwindow.App = {};\nApp.Foo = Class.extend({\nbar: function() {}\n});\n\n// --- NEW API ---\n\n// App/Foo.js\ndefine([], function() {\n\"use strict\";\n\nfunction Foo() {};\nFoo.prototype = {\nbar: function() {}\n};\n\nreturn Foo;\n});\n
"},{"location":"javascript/legacy-api/#inheritance","title":"Inheritance","text":"// App.js\nwindow.App = {};\nApp.Foo = Class.extend({\nbar: function() {}\n});\nApp.Baz = App.Foo.extend({\nmakeSnafucated: function() {}\n});\n\n// --- NEW API ---\n\n// App/Foo.js\ndefine([], function() {\n\"use strict\";\n\nfunction Foo() {};\nFoo.prototype = {\nbar: function() {}\n};\n\nreturn Foo;\n});\n\n// App/Baz.js\ndefine([\"Core\", \"./Foo\"], function(Core, Foo) {\n\"use strict\";\n\nfunction Baz() {};\nCore.inherit(Baz, Foo, {\nmakeSnafucated: function() {}\n});\n\nreturn Baz;\n});\n
"},{"location":"javascript/legacy-api/#ajax-requests","title":"Ajax Requests","text":"// App.js\nApp.Foo = Class.extend({\n_proxy: null,\n\ninit: function() {\nthis._proxy = new WCF.Action.Proxy({\nsuccess: $.proxy(this._success, this)\n});\n},\n\nbar: function() {\nthis._proxy.setOption(\"data\", {\nactionName: \"baz\",\nclassName: \"app\\\\foo\\\\FooAction\",\nobjectIDs: [1, 2, 3],\nparameters: {\nfoo: \"bar\",\nbaz: true\n}\n});\nthis._proxy.sendRequest();\n},\n\n_success: function(data) {\n// ajax request result\n}\n});\n\n// --- NEW API ---\n\n// App/Foo.js\ndefine([\"Ajax\"], function(Ajax) {\n\"use strict\";\n\nfunction Foo() {}\nFoo.prototype = {\nbar: function() {\nAjax.api(this, {\nobjectIDs: [1, 2, 3],\nparameters: {\nfoo: \"bar\",\nbaz: true\n}\n});\n},\n\n// magic method!\n_ajaxSuccess: function(data) {\n// ajax request result\n},\n\n// magic method!\n_ajaxSetup: function() {\nreturn {\nactionName: \"baz\",\nclassName: \"app\\\\foo\\\\FooAction\"\n}\n}\n}\n\nreturn Foo;\n});\n
"},{"location":"javascript/legacy-api/#phrases","title":"Phrases","text":"<script data-relocate=\"true\">\n$(function() {\nWCF.Language.addObject({\n'app.foo.bar': '{lang}app.foo.bar{/lang}'\n});\n\nconsole.log(WCF.Language.get(\"app.foo.bar\"));\n});\n</script>\n\n<!-- NEW API -->\n\n<script data-relocate=\"true\">\nrequire([\"Language\"], function(Language) {\nLanguage.addObject({\n'app.foo.bar': '{jslang}app.foo.bar{/jslang}'\n});\n\nconsole.log(Language.get(\"app.foo.bar\"));\n});\n</script>\n
"},{"location":"javascript/legacy-api/#event-listener","title":"Event-Listener","text":"<script data-relocate=\"true\">\n$(function() {\nWCF.System.Event.addListener(\"app.foo.bar\", \"makeSnafucated\", function(data) {\nconsole.log(\"Event was invoked.\");\n});\n\nWCF.System.Event.fireEvent(\"app.foo.bar\", \"makeSnafucated\", { some: \"data\" });\n});\n</script>\n\n<!-- NEW API -->\n\n<script data-relocate=\"true\">\nrequire([\"EventHandler\"], function(EventHandler) {\nEventHandler.add(\"app.foo.bar\", \"makeSnafucated\", function(data) {\nconsole.log(\"Event was invoked\");\n});\n\nEventHandler.fire(\"app.foo.bar\", \"makeSnafucated\", { some: \"data\" });\n});\n</script>\n
"},{"location":"javascript/new-api_ajax/","title":"Ajax Requests - JavaScript API","text":""},{"location":"javascript/new-api_ajax/#promise-based-api-for-databaseobjectaction","title":"Promise
-based API for DatabaseObjectAction
","text":"WoltLab Suite 5.5 introduces a new API for Ajax requests that uses Promise
s to control the code flow. It does not rely on references to existing objects and does not use arbitrary callbacks to handle the setup and handling of the request.
import * as Ajax from \"./Ajax\";\n\ntype ResponseGetLatestFoo = {\ntemplate: string;\n};\n\nexport class MyModule {\nprivate readonly bar: string;\nprivate readonly objectId: number;\n\nconstructor(objectId: number, bar: string, buttonId: string) {\nthis.bar = bar;\nthis.objectId = objectId;\n\nconst button = document.getElementById(buttonId);\nbutton?.addEventListener(\"click\", (event) => void this.click(event));\n}\n\nasync click(event: MouseEvent): Promise<void> {\nevent.preventDefault();\n\nconst button = event.currentTarget as HTMLElement;\nif (button.classList.contains(\"disabled\")) {\nreturn;\n}\nbutton.classList.add(\"disabled\");\n\ntry {\nconst response = (await Ajax.dboAction(\"getLatestFoo\", \"wcf\\\\data\\\\foo\\\\FooAction\")\n.objectIds([this.objectId])\n.payload({ bar: this.bar })\n.dispatch()) as ResponseGetLatestFoo;\n\ndocument.getElementById(\"latestFoo\")!.innerHTML = response.template;\n} finally {\nbutton.classList.remove(\"disabled\");\n}\n}\n}\n\nexport default MyModule;\n
The actual code to dispatch and evaluate a request is only four lines long and offers full IDE auto completion support. This example uses a finally
block to reset the button class once the request has finished, regardless of the result.
If you do not handle the errors (or chose not to handle some errors), the global rejection handler will take care of this and show an dialog that informs about the failed request. This mimics the behavior of the _ajaxFailure()
callback in the legacy API.
Sometimes new requests are dispatched against the same API before the response from the previous has arrived. This applies to either long running requests or requests that are dispatched in rapid succession, for example, looking up values when the user is actively typing into a search field.
RapidRequests.tsimport * as Ajax from \"./Ajax\";\n\nexport class RapidRequests {\nprivate lastRequest: AbortController | undefined = undefined;\n\nconstructor(inputId: string) {\nconst input = document.getElementById(inputId) as HTMLInputElement;\ninput.addEventListener(\"input\", (event) => void this.input(event));\n}\n\nasync input(event: Event): Promise<void> {\nevent.preventDefault();\n\nconst input = event.currentTarget as HTMLInputElement;\nconst value = input.value.trim();\n\nif (this.lastRequest) {\nthis.lastRequest.abort();\n}\n\nif (value) {\nconst request = Ajax.dboAction(\"getSuggestions\", \"wcf\\\\data\\\\bar\\\\BarAction\").payload({ value });\nthis.lastRequest = request.getAbortController();\n\nconst response = await request.dispatch();\n// Handle the response\n}\n}\n}\n\nexport default RapidRequests;\n
"},{"location":"javascript/new-api_ajax/#ajax-inside-modules-legacy-api","title":"Ajax inside Modules (Legacy API)","text":"The Ajax component was designed to be used from inside modules where an object reference is used to delegate request callbacks. This is acomplished through a set of magic methods that are automatically called when the request is created or its state has changed.
"},{"location":"javascript/new-api_ajax/#_ajaxsetup","title":"_ajaxSetup()
","text":"The lazy initialization is performed upon the first invocation from the callee, using the magic _ajaxSetup()
method to retrieve the basic configuration for this and any future requests.
The data returned by _ajaxSetup()
is cached and the data will be used to pre-populate the request data before sending it. The callee can overwrite any of these properties. It is intended to reduce the overhead when issuing request when these requests share the same properties, such as accessing the same endpoint.
// App/Foo.js\ndefine([\"Ajax\"], function(Ajax) {\n\"use strict\";\n\nfunction Foo() {};\nFoo.prototype = {\none: function() {\n// this will issue an ajax request with the parameter `value` set to `1`\nAjax.api(this);\n},\n\ntwo: function() {\n// this request is almost identical to the one issued with `.one()`, but\n// the value is now set to `2` for this invocation only.\nAjax.api(this, {\nparameters: {\nvalue: 2\n}\n});\n},\n\n_ajaxSetup: function() {\nreturn {\ndata: {\nactionName: \"makeSnafucated\",\nclassName: \"app\\\\data\\\\foo\\\\FooAction\",\nparameters: {\nvalue: 1\n}\n}\n}\n}\n};\n\nreturn Foo;\n});\n
"},{"location":"javascript/new-api_ajax/#request-settings","title":"Request Settings","text":"The object returned by the aforementioned _ajaxSetup()
callback can contain these values:
data
","text":"Defaults to {}
.
A plain JavaScript object that contains the request data that represents the form data of the request. The parameters
key is recognized by the PHP Ajax API and becomes accessible through $this->parameters
.
contentType
","text":"Defaults to application/x-www-form-urlencoded; charset=UTF-8
.
The request content type, sets the Content-Type
HTTP header if it is not empty.
responseType
","text":"Defaults to application/json
.
The server must respond with the Content-Type
HTTP header set to this value, otherwise the request will be treated as failed. Requests for application/json
will have the return body attempted to be evaluated as JSON.
Other content types will only be validated based on the HTTP header, but no additional transformation is performed. For example, setting the responseType
to application/xml
will check the HTTP header, but will not transform the data
parameter, you'll still receive a string in _ajaxSuccess
!
type
","text":"Defaults to POST
.
The HTTP Verb used for this request.
"},{"location":"javascript/new-api_ajax/#url","title":"url
","text":"Defaults to an empty string.
Manual override for the request endpoint, it will be automatically set to the Core API endpoint if left empty. If the Core API endpoint is used, the options includeRequestedWith
and withCredentials
will be force-set to true.
withCredentials
","text":"Enabling this parameter for any domain other than the current will trigger a CORS preflight request.
Defaults to false
.
Include cookies with this requested, is always true when url
is (implicitly) set to the Core API endpoint.
autoAbort
","text":"Defaults to false
.
When set to true
, any pending responses to earlier requests will be silently discarded when issuing a new request. This only makes sense if the new request is meant to completely replace the result of the previous one, regardless of its reponse body.
Typical use-cases include input field with suggestions, where possible values are requested from the server, but the input changed faster than the server was able to reply. In this particular case the client is not interested in the result for an earlier value, auto-aborting these requests avoids implementing this logic in the requesting code.
"},{"location":"javascript/new-api_ajax/#ignoreerror","title":"ignoreError
","text":"Defaults to false
.
Any failing request will invoke the failure
-callback to check if an error message should be displayed. Enabling this option will suppress the general error overlay that reports a failed request.
You can achieve the same result by returning false
in the failure
-callback.
silent
","text":"Defaults to false
.
Enabling this option will suppress the loading indicator overlay for this request, other non-\"silent\" requests will still trigger the loading indicator.
"},{"location":"javascript/new-api_ajax/#includerequestedwith","title":"includeRequestedWith
","text":"Enabling this parameter for any domain other than the current will trigger a CORS preflight request.
Defaults to true
.
Sets the custom HTTP header X-Requested-With: XMLHttpRequest
for the request, it is automatically set to true
when url
is pointing at the WSC API endpoint.
failure
","text":"Defaults to null
.
Optional callback function that will be invoked for requests that have failed for one of these reasons: 1. The request timed out. 2. The HTTP status is not 2xx
or 304
. 3. A responseType
was set, but the response HTTP header Content-Type
did not match the expected value. 4. The responseType
was set to application/json
, but the response body was not valid JSON.
The callback function receives the parameter xhr
(the XMLHttpRequest
object) and options
(deep clone of the request parameters). If the callback returns false
, the general error overlay for failed requests will be suppressed.
There will be no error overlay if ignoreError
is set to true
or if the request failed while attempting to evaluate the response body as JSON.
finalize
","text":"Defaults to null
.
Optional callback function that will be invoked once the request has completed, regardless if it succeeded or failed. The only parameter it receives is options
(the request parameters object), but it does not receive the request's XMLHttpRequest
.
success
","text":"Defaults to null
.
This semi-optional callback function will always be set to _ajaxSuccess()
when invoking Ajax.api()
. It receives four parameters: 1. data
- The request's response body as a string, or a JavaScript object if contentType
was set to application/json
. 2. responseText
- The unmodified response body, it equals the value for data
for non-JSON requests. 3. xhr
- The underlying XMLHttpRequest
object. 4. requestData
- The request parameters that were supplied when the request was issued.
_ajaxSuccess()
","text":"This callback method is automatically called for successful AJAX requests, it receives four parameters, with the first one containing either the response body as a string, or a JavaScript object for JSON requests.
"},{"location":"javascript/new-api_ajax/#_ajaxfailure","title":"_ajaxFailure()
","text":"Optional callback function that is invoked for failed requests, it will be automatically called if the callee implements it, otherwise the global error handler will be executed.
"},{"location":"javascript/new-api_ajax/#single-requests-without-a-module-legacy-api","title":"Single Requests Without a Module (Legacy API)","text":"The Ajax.api()
method expects an object that is used to extract the request configuration as well as providing the callback functions when the request state changes.
You can issue a simple Ajax request without object binding through Ajax.apiOnce()
that will destroy the instance after the request was finalized. This method is significantly more expensive for repeated requests and does not offer deriving modules from altering the behavior. It is strongly recommended to always use Ajax.api()
for requests to the WSC API endpoint.
<script data-relocate=\"true\">\nrequire([\"Ajax\"], function(Ajax) {\nAjax.apiOnce({\ndata: {\nactionName: \"makeSnafucated\",\nclassName: \"app\\\\data\\\\foo\\\\FooAction\",\nparameters: {\nvalue: 3\n}\n},\nsuccess: function(data) {\nelBySel(\".some-element\").textContent = data.bar;\n}\n})\n});\n</script>\n
"},{"location":"javascript/new-api_browser/","title":"Browser and Screen Sizes - JavaScript API","text":""},{"location":"javascript/new-api_browser/#uiscreen","title":"Ui/Screen
","text":"CSS offers powerful media queries that alter the layout depending on the screen sizes, including but not limited to changes between landscape and portrait mode on mobile devices.
The Ui/Screen
module exposes a consistent interface to execute JavaScript code based on the same media queries that are available in the CSS code already. It features support for unmatching and executing code when a rule matches for the first time during the page lifecycle.
You can pass in custom media queries, but it is strongly recommended to use the built-in media queries that match the same dimensions as your CSS.
Alias Media Queryscreen-xs
(max-width: 544px)
screen-sm
(min-width: 545px) and (max-width: 768px)
screen-sm-down
(max-width: 768px)
screen-sm-up
(min-width: 545px)
screen-sm-md
(min-width: 545px) and (max-width: 1024px)
screen-md
(min-width: 769px) and (max-width: 1024px)
screen-md-down
(max-width: 1024px)
screen-md-up
(min-width: 769px)
screen-lg
(min-width: 1025px)
"},{"location":"javascript/new-api_browser/#onquery-string-callbacks-object-string","title":"on(query: string, callbacks: Object): string
","text":"Registers a set of callback functions for the provided media query, the possible keys are match
, unmatch
and setup
. The method returns a randomly generated UUIDv4 that is used to identify these callbacks and allows them to be removed via .remove()
.
remove(query: string, uuid: string)
","text":"Removes all callbacks for a media query that match the UUIDv4 that was previously obtained from the call to .on()
.
is(query: string): boolean
","text":"Tests if the provided media query currently matches and returns true on match.
"},{"location":"javascript/new-api_browser/#scrolldisable","title":"scrollDisable()
","text":"Temporarily prevents the page from being scrolled, until .scrollEnable()
is called.
scrollEnable()
","text":"Enables page scrolling again, unless another pending action has also prevented the page scrolling.
"},{"location":"javascript/new-api_browser/#environment","title":"Environment
","text":"The Environment
module uses a mixture of feature detection and user agent sniffing to determine the browser and platform. In general, its results have proven to be very accurate, but it should be taken with a grain of salt regardless. Especially the browser checks are designed to be your last resort, please use feature detection instead whenever it is possible!
Sometimes it may be necessary to alter the behavior of your code depending on the browser platform (e. g. mobile devices) or based on a specific browser in order to work-around some quirks.
"},{"location":"javascript/new-api_browser/#browser-string","title":"browser(): string
","text":"Attempts to detect browsers based on their technology and supported CSS vendor prefixes, and although somewhat reliable for major browsers, it is highly recommended to use feature detection instead.
Possible values: - chrome
(includes Opera 15+ and Vivaldi) - firefox
- safari
- microsoft
(Internet Explorer and Edge) - other
(default)
platform(): string
","text":"Attempts to detect the browser platform using user agent sniffing.
Possible values: - ios
- android
- windows
(IE Mobile) - mobile
(generic mobile device) - desktop
(default)
A brief overview of common methods that may be useful when writing any module.
"},{"location":"javascript/new-api_core/#core","title":"Core
","text":""},{"location":"javascript/new-api_core/#cloneobject-object-object","title":"clone(object: Object): Object
","text":"Creates a deep-clone of the provided object by value, removing any references on the original element, including arrays. However, this does not clone references to non-plain objects, these instances will be copied by reference.
require([\"Core\"], function(Core) {\nvar obj1 = { a: 1 };\nvar obj2 = Core.clone(obj1);\n\nconsole.log(obj1 === obj2); // output: false\nconsole.log(obj2.hasOwnProperty(\"a\") && obj2.a === 1); // output: true\n});\n
"},{"location":"javascript/new-api_core/#extendbase-object-merge-object-object","title":"extend(base: Object, ...merge: Object[]): Object
","text":"Accepts an infinite amount of plain objects as parameters, values will be copied from the 2nd...nth object into the first object. The first parameter will be cloned and the resulting object is returned.
require([\"Core\"], function(Core) {\nvar obj1 = { a: 2 };\nvar obj2 = { a: 1, b: 2 };\nvar obj = Core.extend({\nb: 1\n}, obj1, obj2);\n\nconsole.log(obj.b === 2); // output: true\nconsole.log(obj.hasOwnProperty(\"a\") && obj.a === 2); // output: false\n});\n
"},{"location":"javascript/new-api_core/#inheritbase-object-target-object-merge-object","title":"inherit(base: Object, target: Object, merge?: Object)
","text":"Derives the second object's prototype from the first object, afterwards the derived class will pass the instanceof
check against the original class.
// App.js\nwindow.App = {};\nApp.Foo = Class.extend({\nbar: function() {}\n});\nApp.Baz = App.Foo.extend({\nmakeSnafucated: function() {}\n});\n\n// --- NEW API ---\n\n// App/Foo.js\ndefine([], function() {\n\"use strict\";\n\nfunction Foo() {};\nFoo.prototype = {\nbar: function() {}\n};\n\nreturn Foo;\n});\n\n// App/Baz.js\ndefine([\"Core\", \"./Foo\"], function(Core, Foo) {\n\"use strict\";\n\nfunction Baz() {};\nCore.inherit(Baz, Foo, {\nmakeSnafucated: function() {}\n});\n\nreturn Baz;\n});\n
"},{"location":"javascript/new-api_core/#isplainobjectobject-object-boolean","title":"isPlainObject(object: Object): boolean
","text":"Verifies if an object is a plain JavaScript object and not an object instance.
require([\"Core\"], function(Core) {\nfunction Foo() {}\nFoo.prototype = {\nhello: \"world\";\n};\n\nvar obj1 = { hello: \"world\" };\nvar obj2 = new Foo();\n\nconsole.log(Core.isPlainObject(obj1)); // output: true\nconsole.log(obj1.hello === obj2.hello); // output: true\nconsole.log(Core.isPlainObject(obj2)); // output: false\n});\n
"},{"location":"javascript/new-api_core/#triggereventelement-element-eventname-string","title":"triggerEvent(element: Element, eventName: string)
","text":"Creates and dispatches a synthetic JavaScript event on an element.
require([\"Core\"], function(Core) {\nvar element = elBySel(\".some-element\");\nCore.triggerEvent(element, \"click\");\n});\n
"},{"location":"javascript/new-api_core/#language","title":"Language
","text":""},{"location":"javascript/new-api_core/#addkey-string-value-string","title":"add(key: string, value: string)
","text":"Registers a new phrase.
<script data-relocate=\"true\">\nrequire([\"Language\"], function(Language) {\nLanguage.add('app.foo.bar', '{jslang}app.foo.bar{/jslang}');\n});\n</script>\n
"},{"location":"javascript/new-api_core/#addobjectobject-object","title":"addObject(object: Object)
","text":"Registers a list of phrases using a plain object.
<script data-relocate=\"true\">\nrequire([\"Language\"], function(Language) {\nLanguage.addObject({\n'app.foo.bar': '{jslang}app.foo.bar{/jslang}'\n});\n});\n</script>\n
"},{"location":"javascript/new-api_core/#getkey-string-parameters-object-string","title":"get(key: string, parameters?: Object): string
","text":"Retrieves a phrase by its key, optionally supporting basic template scripting with dynamic variables passed using the parameters
object.
require([\"Language\"], function(Language) {\nvar title = Language.get(\"app.foo.title\");\nvar content = Language.get(\"app.foo.content\", {\nsome: \"value\"\n});\n});\n
"},{"location":"javascript/new-api_core/#stringutil","title":"StringUtil
","text":""},{"location":"javascript/new-api_core/#escapehtmlstr-string-string","title":"escapeHTML(str: string): string
","text":"Escapes special HTML characters by converting them into an HTML entity.
Character Replacement&
&
\"
"
<
<
>
>
"},{"location":"javascript/new-api_core/#escaperegexpstr-string-string","title":"escapeRegExp(str: string): string
","text":"Escapes a list of characters that have a special meaning in regular expressions and could alter the behavior when embedded into regular expressions.
"},{"location":"javascript/new-api_core/#lcfirststr-string-string","title":"lcfirst(str: string): string
","text":"Makes a string's first character lowercase.
"},{"location":"javascript/new-api_core/#ucfirststr-string-string","title":"ucfirst(str: string): string
","text":"Makes a string's first character uppercase.
"},{"location":"javascript/new-api_core/#unescapehtmlstr-string-string","title":"unescapeHTML(str: string): string
","text":"Converts some HTML entities into their original character. This is the reverse function of escapeHTML()
.
This API has been deprecated in WoltLab Suite 6.0, please refer to the new dialog implementation.
"},{"location":"javascript/new-api_dialogs/#introduction","title":"Introduction","text":"Dialogs are full screen overlays that cover the currently visible window area using a semi-opague backdrop and a prominently placed dialog window in the foreground. They shift the attention away from the original content towards the dialog and usually contain additional details and/or dedicated form inputs.
"},{"location":"javascript/new-api_dialogs/#_dialogsetup","title":"_dialogSetup()
","text":"The lazy initialization is performed upon the first invocation from the callee, using the magic _dialogSetup()
method to retrieve the basic configuration for the dialog construction and any event callbacks.
// App/Foo.js\ndefine([\"Ui/Dialog\"], function(UiDialog) {\n\"use strict\";\n\nfunction Foo() {};\nFoo.prototype = {\nbar: function() {\n// this will open the dialog constructed by _dialogSetup\nUiDialog.open(this);\n},\n\n_dialogSetup: function() {\nreturn {\nid: \"myDialog\",\nsource: \"<p>Hello World!</p>\",\noptions: {\nonClose: function() {\n// the fancy dialog was closed!\n}\n}\n}\n}\n};\n\nreturn Foo;\n});\n
"},{"location":"javascript/new-api_dialogs/#id-string","title":"id: string
","text":"The id
is used to identify a dialog on runtime, but is also part of the first- time setup when the dialog has not been opened before. If source
is undefined
, the module attempts to construct the dialog using an element with the same id.
source: any
","text":"There are six different types of value that source
does allow and each of them changes how the initial dialog is constructed:
undefined
The dialog exists already and the value of id
should be used to identify the element.null
The HTML is provided using the second argument of .open()
.() => void
If the source
is a function, it is executed and is expected to start the dialog initialization itself.Object
Plain objects are interpreted as parameters for an Ajax request, in particular source.data
will be used to issue the request. It is possible to specify the key source.after
as a callback (content: Element, responseData: Object) => void
that is executed after the dialog was opened.string
The string is expected to be plain HTML that should be used to construct the dialog.DocumentFragment
A new container <div>
with the provided id
is created and the contents of the DocumentFragment
is appended to it. This container is then used for the dialog.options: Object
","text":"All configuration options and callbacks are handled through this object.
"},{"location":"javascript/new-api_dialogs/#optionsbackdropcloseonclick-boolean","title":"options.backdropCloseOnClick: boolean
","text":"Defaults to true
.
Clicks on the dialog backdrop will close the top-most dialog. This option will be force-disabled if the option closeable
is set to false
.
options.closable: boolean
","text":"Defaults to true
.
Enables the close button in the dialog title, when disabled the dialog can be closed through the .close()
API call only.
options.closeButtonLabel: string
","text":"Defaults to Language.get(\"wcf.global.button.close\")
.
The phrase that is displayed in the tooltip for the close button.
"},{"location":"javascript/new-api_dialogs/#optionscloseconfirmmessage-string","title":"options.closeConfirmMessage: string
","text":"Defaults to \"\"
.
Shows a confirmation dialog using the configured message before closing the dialog. The dialog will not be closed if the dialog is rejected by the user.
"},{"location":"javascript/new-api_dialogs/#optionstitle-string","title":"options.title: string
","text":"Defaults to \"\"
.
The phrase that is displayed in the dialog title.
"},{"location":"javascript/new-api_dialogs/#optionsonbeforeclose-id-string-void","title":"options.onBeforeClose: (id: string) => void
","text":"Defaults to null
.
The callback is executed when the user clicks on the close button or, if enabled, on the backdrop. The callback is responsible to close the dialog by itself, the default close behavior is automatically prevented.
"},{"location":"javascript/new-api_dialogs/#optionsonclose-id-string-void","title":"options.onClose: (id: string) => void
","text":"Defaults to null
.
The callback is notified once the dialog is about to be closed, but is still visible at this point. It is not possible to abort the close operation at this point.
"},{"location":"javascript/new-api_dialogs/#optionsonshow-content-element-void","title":"options.onShow: (content: Element) => void
","text":"Defaults to null
.
Receives the dialog content element as its only argument, allowing the callback to modify the DOM or to register event listeners before the dialog is presented to the user. The dialog is already visible at call time, but the dialog has not been finalized yet.
"},{"location":"javascript/new-api_dialogs/#settitleid-string-object-title-string","title":"setTitle(id: string | Object, title: string)
","text":"Sets the title of a dialog.
"},{"location":"javascript/new-api_dialogs/#setcallbackid-string-object-key-string-value-data-any-void-null","title":"setCallback(id: string | Object, key: string, value: (data: any) => void | null)
","text":"Sets a callback function after the dialog initialization, the special value null
will remove a previously set callback. Valid values for key
are onBeforeClose
, onClose
and onShow
.
rebuild(id: string | Object)
","text":"Rebuilds a dialog by performing various calculations on the maximum dialog height in regards to the overflow handling and adjustments for embedded forms. This method is automatically invoked whenever a dialog is shown, after invoking the options.onShow
callback.
close(id: string | Object)
","text":"Closes an open dialog, this will neither trigger a confirmation dialog, nor does it invoke the options.onBeforeClose
callback. The options.onClose
callback will always be invoked, but it cannot abort the close operation.
getDialog(id: string | Object): Object
","text":"This method returns an internal data object by reference, any modifications made do have an effect on the dialogs behavior and in particular no validation is performed on the modification. It is strongly recommended to use the .set*()
methods only.
Returns the internal dialog data that is attached to a dialog. The most important key is .content
which holds a reference to the dialog's inner content element.
isOpen(id: string | Object): boolean
","text":"Returns true if the dialog exists and is open.
"},{"location":"javascript/new-api_dom/","title":"Working with the DOM - JavaScript API","text":""},{"location":"javascript/new-api_dom/#domutil","title":"Dom/Util
","text":""},{"location":"javascript/new-api_dom/#createfragmentfromhtmlhtml-string-documentfragment","title":"createFragmentFromHtml(html: string): DocumentFragment
","text":"Parses a HTML string and creates a DocumentFragment
object that holds the resulting nodes.
identify(element: Element): string
","text":"Retrieves the unique identifier (id
) of an element. If it does not currently have an id assigned, a generic identifier is used instead.
outerHeight(element: Element, styles?: CSSStyleDeclaration): number
","text":"Computes the outer height of an element using the element's offsetHeight
and the sum of the rounded down values for margin-top
and margin-bottom
.
outerWidth(element: Element, styles?: CSSStyleDeclaration): number
","text":"Computes the outer width of an element using the element's offsetWidth
and the sum of the rounded down values for margin-left
and margin-right
.
outerDimensions(element: Element): { height: number, width: number }
","text":"Computes the outer dimensions of an element including its margins.
"},{"location":"javascript/new-api_dom/#offsetelement-element-top-number-left-number","title":"offset(element: Element): { top: number, left: number }
","text":"Computes the element's offset relative to the top left corner of the document.
"},{"location":"javascript/new-api_dom/#setinnerhtmlelement-element-innerhtml-string","title":"setInnerHtml(element: Element, innerHtml: string)
","text":"Sets the inner HTML of an element via element.innerHTML = innerHtml
. Browsers do not evaluate any embedded <script>
tags, therefore this method extracts each of them, creates new <script>
tags and inserts them in their original order of appearance.
contains(element: Element, child: Element): boolean
","text":"Evaluates if element
is a direct or indirect parent element of child
.
unwrapChildNodes(element: Element)
","text":"Moves all child nodes out of element
while maintaining their order, then removes element
from the document.
Dom/ChangeListener
","text":"This class is used to observe specific changes to the DOM, for example after an Ajax request has completed. For performance reasons this is a manually-invoked listener that does not rely on a MutationObserver
.
require([\"Dom/ChangeListener\"], function(DomChangeListener) {\nDomChangeListener.add(\"App/Foo\", function() {\n// the DOM may have been altered significantly\n});\n\n// propagate changes to the DOM\nDomChangeListener.trigger();\n});\n
"},{"location":"javascript/new-api_events/","title":"Event Handling - JavaScript API","text":""},{"location":"javascript/new-api_events/#eventkey","title":"EventKey
","text":"This class offers a set of static methods that can be used to determine if some common keys are being pressed. Internally it compares either the .key
property if it is supported or the value of .which
.
require([\"EventKey\"], function(EventKey) {\nelBySel(\".some-input\").addEventListener(\"keydown\", function(event) {\nif (EventKey.Enter(event)) {\n// the `Enter` key was pressed\n}\n});\n});\n
"},{"location":"javascript/new-api_events/#arrowdownevent-keyboardevent-boolean","title":"ArrowDown(event: KeyboardEvent): boolean
","text":"Returns true if the user has pressed the \u2193
key.
ArrowLeft(event: KeyboardEvent): boolean
","text":"Returns true if the user has pressed the \u2190
key.
ArrowRight(event: KeyboardEvent): boolean
","text":"Returns true if the user has pressed the \u2192
key.
ArrowUp(event: KeyboardEvent): boolean
","text":"Returns true if the user has pressed the \u2191
key.
Comma(event: KeyboardEvent): boolean
","text":"Returns true if the user has pressed the ,
key.
Enter(event: KeyboardEvent): boolean
","text":"Returns true if the user has pressed the \u21b2
key.
Escape(event: KeyboardEvent): boolean
","text":"Returns true if the user has pressed the Esc
key.
Tab(event: KeyboardEvent): boolean
","text":"Returns true if the user has pressed the \u21b9
key.
EventHandler
","text":"A synchronous event system based on string identifiers rather than DOM elements, similar to the PHP event system in WoltLab Suite. Any components can listen to events or trigger events itself at any time.
"},{"location":"javascript/new-api_events/#identifiying-events-with-the-developer-tools","title":"Identifiying Events with the Developer Tools","text":"The Developer Tools offer an easy option to identify existing events that are fired while code is being executed. You can enable this watch mode through your browser's console using Devtools.toggleEventLogging()
:
> Devtools.toggleEventLogging();\n< Event logging enabled\n< [Devtools.EventLogging] Firing event: bar @ com.example.app.foo\n< [Devtools.EventLogging] Firing event: baz @ com.example.app.foo\n
"},{"location":"javascript/new-api_events/#addidentifier-string-action-string-callback-data-object-void-string","title":"add(identifier: string, action: string, callback: (data: Object) => void): string
","text":"Adding an event listeners returns a randomly generated UUIDv4 that is used to identify the listener. This UUID is required to remove a specific listener through the remove()
method.
fire(identifier: string, action: string, data?: Object)
","text":"Triggers an event using an optional data
object that is passed to each listener by reference.
remove(identifier: string, action: string, uuid: string)
","text":"Removes a previously registered event listener using the UUID returned by add()
.
removeAll(identifier: string, action: string)
","text":"Removes all event listeners registered for the provided identifier
and action
.
removeAllBySuffix(identifier: string, suffix: string)
","text":"Removes all event listeners for an identifier
whose action ends with the value of suffix
.
Ui/Alignment
","text":"Calculates the alignment of one element relative to another element, with support for boundary constraints, alignment restrictions and additional pointer elements.
"},{"location":"javascript/new-api_ui/#setelement-element-referenceelement-element-options-object","title":"set(element: Element, referenceElement: Element, options: Object)
","text":"Calculates and sets the alignment of the element element
.
verticalOffset: number
","text":"Defaults to 0
.
Creates a gap between the element and the reference element, in pixels.
"},{"location":"javascript/new-api_ui/#pointer-boolean","title":"pointer: boolean
","text":"Defaults to false
.
Sets the position of the pointer element, requires an existing child of the element with the CSS class .elementPointer
.
pointerOffset: number
","text":"Defaults to 4
.
The margin from the left/right edge of the element and is used to avoid the arrow from being placed right at the edge.
Does not apply when aligning the element to the reference elemnent's center.
"},{"location":"javascript/new-api_ui/#pointerclassnames-string","title":"pointerClassNames: string[]
","text":"Defaults to []
.
If your element uses CSS-only pointers, such as using the ::before
or ::after
pseudo selectors, you can specifiy two separate CSS class names that control the alignment:
pointerClassNames[0]
is applied to the element when the pointer is displayed at the bottom.pointerClassNames[1]
is used to align the pointer to the right side of the element.refDimensionsElement: Element
","text":"Defaults to null
.
An alternative element that will be used to determine the position and dimensions of the reference element. This can be useful if you reference element is contained in a wrapper element with alternating dimensions.
"},{"location":"javascript/new-api_ui/#horizontal-string","title":"horizontal: string
","text":"This value is automatically flipped for RTL (right-to-left) languages, left
is changed into right
and vice versa.
Defaults to \"left\"
.
Sets the prefered alignment, accepts either left
or right
. The value left
instructs the module to align the element with the left boundary of the reference element.
The horizontal
alignment is used as the default and a flip only occurs, if there is not enough space in the desired direction. If the element exceeds the boundaries in both directions, the value of horizontal
is used.
vertical: string
","text":"Defaults to \"bottom\"
.
Sets the prefered alignment, accepts either bottom
or top
. The value bottom
instructs the module to align the element below the reference element.
The vertical
alignment is used as the default and a flip only occurs, if there is not enough space in the desired direction. If the element exceeds the boundaries in both directions, the value of vertical
is used.
allowFlip: string
","text":"The value for horizontal
is automatically flipped for RTL (right-to-left) languages, left
is changed into right
and vice versa. This setting only controls the behavior when violating space constraints, therefore the aforementioned transformation is always applied.
Defaults to \"both\"
.
Restricts the automatic alignment flipping if the element exceeds the window boundaries in the instructed direction.
both
- No restrictions.horizontal
- Element can be aligned with the left or the right boundary of the reference element, but the vertical position is fixed.vertical
- Element can be aligned below or above the reference element, but the vertical position is fixed.none
- No flipping can occur, the element will be aligned regardless of any space constraints.Ui/CloseOverlay
","text":"Register elements that should be closed when the user clicks anywhere else, such as drop-down menus or tooltips.
require([\"Ui/CloseOverlay\"], function(UiCloseOverlay) {\nUiCloseOverlay.add(\"App/Foo\", function() {\n// invoked, close something\n});\n});\n
"},{"location":"javascript/new-api_ui/#addidentifier-string-callback-void","title":"add(identifier: string, callback: () => void)
","text":"Adds a callback that will be invoked when the user clicks anywhere else.
"},{"location":"javascript/new-api_ui/#uiconfirmation","title":"Ui/Confirmation
","text":"Prompt the user to make a decision before carrying out an action, such as a safety warning before permanently deleting content.
require([\"Ui/Confirmation\"], function(UiConfirmation) {\nUiConfirmation.show({\nconfirm: function() {\n// the user has confirmed the dialog\n},\nmessage: \"Do you really want to continue?\"\n});\n});\n
"},{"location":"javascript/new-api_ui/#showoptions-object","title":"show(options: Object)
","text":"Displays a dialog overlay with actions buttons to confirm or reject the dialog.
"},{"location":"javascript/new-api_ui/#cancel-parameters-object-void","title":"cancel: (parameters: Object) => void
","text":"Defaults to null
.
Callback that is invoked when the dialog was rejected.
"},{"location":"javascript/new-api_ui/#confirm-parameters-object-void","title":"confirm: (parameters: Object) => void
","text":"Defaults to null
.
Callback that is invoked when the user has confirmed the dialog.
"},{"location":"javascript/new-api_ui/#message-string","title":"message: string
","text":"Defaults to '\"\"'.
Text that is displayed in the content area of the dialog, optionally this can be HTML, but this requires messageIsHtml
to be enabled.
messageIsHtml
","text":"Defaults to false
.
The message
option is interpreted as text-only, setting this option to true
will cause the message
to be evaluated as HTML.
parameters: Object
","text":"Optional list of parameter options that will be passed to the cancel()
and confirm()
callbacks.
template: string
","text":"An optional HTML template that will be inserted into the dialog content area, but after the message
section.
Ui/Notification
","text":"Displays a simple notification at the very top of the window, such as a success message for Ajax based actions.
require([\"Ui/Notification\"], function(UiNotification) {\nUiNotification.show(\n\"Your changes have been saved.\",\nfunction() {\n// this callback will be invoked after 2 seconds\n},\n\"success\"\n);\n});\n
"},{"location":"javascript/new-api_ui/#showmessage-string-callback-void-cssclassname-string","title":"show(message: string, callback?: () => void, cssClassName?: string)
","text":"Shows the notification and executes the callback after 2 seconds.
"},{"location":"javascript/new-api_writing-a-module/","title":"Writing a Module - JavaScript API","text":""},{"location":"javascript/new-api_writing-a-module/#introduction","title":"Introduction","text":"The new JavaScript-API was introduced with WoltLab Suite 3.0 and was a major change in all regards. The previously used API heavily relied on larger JavaScript files that contained a lot of different components with hidden dependencies and suffered from extensive jQuery usage for historic reasons.
Eventually a new API was designed that solves the issues with the legacy API by following a few basic principles: 1. Vanilla ES5-JavaScript. It allows us to achieve the best performance across all platforms, there is simply no reason to use jQuery today and the performance penalty on mobile devices is a real issue. 2. Strict usage of modules. Each component is placed in an own file and all dependencies are explicitly declared and injected at the top.Eventually we settled with AMD-style modules using require.js which offers both lazy loading and \"ahead of time\"-compilatio with r.js
. 3. No jQuery-based components on page init. Nothing is more annoying than loading a page and then wait for JavaScript to modify the page before it becomes usable, forcing the user to sit and wait. Heavily optimized vanilla JavaScript components offered the speed we wanted. 4. Limited backwards-compatibility. The new API should make it easy to update existing components by providing similar interfaces, while still allowing legacy code to run side-by-side for best compatibility and to avoid rewritting everything from the start.
The default location for modules is js/
in the Core's app dir, but every app and plugin can register their own lookup path by providing the path using a template-listener on requirePaths@headIncludeJavaScript
.
For this example we'll assume the file is placed at js/WoltLabSuite/Core/Ui/Foo.js
, the module name is therefore WoltLabSuite/Core/Ui/Foo
, it is automatically derived from the file path and name.
For further instructions on how to define and require modules head over to the RequireJS API.
define([\"Ajax\", \"WoltLabSuite/Core/Ui/Bar\"], function(Ajax, UiBar) {\n\"use strict\";\n\nfunction Foo() { this.init(); }\nFoo.prototype = {\ninit: function() {\nelBySel(\".myButton\").addEventListener(WCF_CLICK_EVENT, this._click.bind(this));\n},\n\n_click: function(event) {\nevent.preventDefault();\n\nif (UiBar.isSnafucated()) {\nAjax.api(this);\n}\n},\n\n_ajaxSuccess: function(data) {\nconsole.log(\"Received response\", data);\n},\n\n_ajaxSetup: function() {\nreturn {\ndata: {\nactionName: \"makeSnafucated\",\nclassName: \"wcf\\\\data\\\\foo\\\\FooAction\"\n}\n};\n}\n}\n\nreturn Foo;\n});\n
"},{"location":"javascript/new-api_writing-a-module/#loading-a-module","title":"Loading a Module","text":"Modules can then be loaded through their derived name:
<script data-relocate=\"true\">\nrequire([\"WoltLabSuite/Core/Ui/Foo\"], function(UiFoo) {\nnew UiFoo();\n});\n</script>\n
"},{"location":"javascript/new-api_writing-a-module/#module-aliases","title":"Module Aliases","text":"Some common modules have short-hand aliases that can be used to include them without writing out their full name. You can still use their original path, but it is strongly recommended to use the aliases for consistency.
Alias Full Path Ajax WoltLabSuite/Core/Ajax AjaxJsonp WoltLabSuite/Core/Ajax/Jsonp AjaxRequest WoltLabSuite/Core/Ajax/Request CallbackList WoltLabSuite/Core/CallbackList ColorUtil WoltLabSuite/Core/ColorUtil Core WoltLabSuite/Core/Core DateUtil WoltLabSuite/Core/Date/Util Devtools WoltLabSuite/Core/Devtools Dom/ChangeListener WoltLabSuite/Core/Dom/Change/Listener Dom/Traverse WoltLabSuite/Core/Dom/Traverse Dom/Util WoltLabSuite/Core/Dom/Util Environment WoltLabSuite/Core/Environment EventHandler WoltLabSuite/Core/Event/Handler EventKey WoltLabSuite/Core/Event/Key Language WoltLabSuite/Core/Language Permission WoltLabSuite/Core/Permission StringUtil WoltLabSuite/Core/StringUtil Ui/Alignment WoltLabSuite/Core/Ui/Alignment Ui/CloseOverlay WoltLabSuite/Core/Ui/CloseOverlay Ui/Confirmation WoltLabSuite/Core/Ui/Confirmation Ui/Dialog WoltLabSuite/Core/Ui/Dialog Ui/Notification WoltLabSuite/Core/Ui/Notification Ui/ReusableDropdown WoltLabSuite/Core/Ui/Dropdown/Reusable Ui/Screen WoltLabSuite/Core/Ui/Screen Ui/Scroll WoltLabSuite/Core/Ui/Scroll Ui/SimpleDropdown WoltLabSuite/Core/Ui/Dropdown/Simple Ui/TabMenu WoltLabSuite/Core/Ui/TabMenu Upload WoltLabSuite/Core/Upload User WoltLabSuite/Core/User"},{"location":"javascript/typescript/","title":"TypeScript","text":""},{"location":"javascript/typescript/#consuming-woltlab-suites-types","title":"Consuming WoltLab Suite\u2019s Types","text":"To consume the types of WoltLab Suite, you will need to install the @woltlab/wcf
npm package using a git URL that refers to the appropriate branch of WoltLab/WCF.
A full package.json
that includes WoltLab Suite, TypeScript, eslint and Prettier could look like the following.
{\n\"devDependencies\": {\n\"@typescript-eslint/eslint-plugin\": \"^5.51.0\",\n\"@typescript-eslint/parser\": \"^5.51.0\",\n\"eslint\": \"^8.33.0\",\n\"eslint-config-prettier\": \"^8.6.0\",\n\"prettier\": \"^2.8.4\",\n\"typescript\": \"^4.9.5\"\n},\n\"dependencies\": {\n\"@woltlab/d.ts\": \"https://github.com/WoltLab/d.ts.git#4040fc083245edeb2f8832d4613b9e76aa9c17a5\"\n}\n}\n
After installing the types using npm, you will also need to configure tsconfig.json
to take the types into account. To do so, you will need to add them to the compilerOptions.paths
option. A complete tsconfig.json
file that matches the configuration of WoltLab Suite could look like the following.
{\n\"include\": [\n\"node_modules/@woltlab/d.ts/global.d.ts\",\n\"ts/**/*\"\n],\n\"compilerOptions\": {\n\"target\": \"ES2022\",\n\"module\": \"amd\",\n\"rootDir\": \"ts/\",\n\"outDir\": \"files/js/\",\n\"lib\": [\n\"DOM\",\n\"DOM.Iterable\",\n\"ES2022\"\n],\n\"strictNullChecks\": true,\n\"moduleResolution\": \"node\",\n\"esModuleInterop\": true,\n\"noImplicitThis\": true,\n\"strictBindCallApply\": true,\n\"baseUrl\": \".\",\n\"paths\": {\n\"*\": [\n\"node_modules/@woltlab/d.ts/*\"\n]\n},\n\"importHelpers\": true,\n\"newLine\": \"lf\"\n}\n}\n
After this initial set-up, you would place your TypeScript source files into the ts/
folder of your project. The generated JavaScript target files will be placed into files/js/
and thus will be installed by the file PIP.
To update the TypeScript types, the commit hash in package.json
needs to be updated to an appropriate commit in the d.ts repository and npm install
needs to be rerun.
WoltLab Suite uses additional tools to ensure the high quality and a consistent code style of the TypeScript modules. The current configuration of these tools is as follows. It is recommended to re-use this configuration as is.
.prettierrctrailingComma: all\nprintWidth: 120\n
.eslintrc.js module.exports = {\nroot: true,\nparser: \"@typescript-eslint/parser\",\nparserOptions: {\ntsconfigRootDir: __dirname,\nproject: [\"./tsconfig.json\"]\n},\nplugins: [\"@typescript-eslint\"],\nextends: [\n\"eslint:recommended\",\n\"plugin:@typescript-eslint/recommended\",\n\"plugin:@typescript-eslint/recommended-requiring-type-checking\",\n\"prettier\"\n],\nrules: {\n\"@typescript-eslint/ban-types\": [\n\"error\", {\ntypes: {\n\"object\": false\n},\nextendDefaults: true\n}\n],\n\"@typescript-eslint/no-explicit-any\": 0,\n\"@typescript-eslint/no-non-null-assertion\": 0,\n\"@typescript-eslint/no-unsafe-assignment\": 0,\n\"@typescript-eslint/no-unsafe-call\": 0,\n\"@typescript-eslint/no-unsafe-member-access\": 0,\n\"@typescript-eslint/no-unsafe-return\": 0,\n\"@typescript-eslint/no-unused-vars\": [\n\"error\", {\n\"argsIgnorePattern\": \"^_\"\n}\n]\n}\n};\n
.eslintignore **/*.js\nvendor/**\n
This .gitattributes
configuration will automatically collapse the generated JavaScript target files in GitHub\u2019s Diff view. You will not need it if you do not use git or GitHub.
files/js/**/*.js linguist-generated\n
"},{"location":"javascript/typescript/#writing-a-simple-module","title":"Writing a simple module","text":"After completing this initial set-up you can start writing your first TypeScript module. The TypeScript compiler can be launched in Watch Mode by running npx tsc -w
.
WoltLab Suite\u2019s modules can be imported using the standard ECMAScript module import syntax by specifying the full module name. The public API of the module can also be exported using the standard ECMAScript module export syntax.
ts/Example.tsimport * as Language from \"WoltLabSuite/Core/Language\";\n\nexport function run() {\nalert(Language.get(\"wcf.foo.bar\"));\n}\n
This simple example module will compile to plain JavaScript that is compatible with the AMD loader that is used by WoltLab Suite.
files/js/Example.jsdefine([\"require\", \"exports\", \"tslib\", \"WoltLabSuite/Core/Language\"], function (require, exports, tslib_1, Language) {\n\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.run = void 0;\nLanguage = tslib_1.__importStar(Language);\nfunction run() {\nalert(Language.get(\"wcf.foo.bar\"));\n}\nexports.run = run;\n});\n
Within templates it can be consumed as follows.
<script data-relocate=\"true\">\nrequire([\"Example\"], (Example) => {\nExample.run(); // Alerts the contents of the `wcf.foo.bar` phrase.\n});\n</script>\n
"},{"location":"migration/wcf21/css/","title":"WCF 2.1.x - CSS","text":"The LESS compiler has been in use since WoltLab Community Framework 2.0, but was replaced with a SCSS compiler in WoltLab Suite 3.0. This change was motivated by SCSS becoming the de facto standard for CSS pre-processing and some really annoying shortcomings in the old LESS compiler.
The entire CSS has been rewritten from scratch, please read the docs on CSS to learn what has changed.
"},{"location":"migration/wcf21/package/","title":"WCF 2.1.x - Package Components","text":""},{"location":"migration/wcf21/package/#packagexml","title":"package.xml","text":""},{"location":"migration/wcf21/package/#short-instructions","title":"Short Instructions","text":"Instructions can now omit the filename, causing them to use the default filename if defined by the package installation plugin (in short: PIP
). Unless overridden it will default to the PIP's class name with the first letter being lower-cased, e.g. EventListenerPackageInstallationPlugin
implies the filename eventListener.xml
. The file is always assumed to be in the archive's root, files located in subdirectories need to be explicitly stated, just as it worked before.
Every PIP can define a custom filename if the default value cannot be properly derived. For example the ACPMenu
-pip would default to aCPMenu.xml
, requiring the class to explicitly override the default filename with acpMenu.xml
for readability.
<instructions type=\"install\">\n<!-- assumes `eventListener.xml` -->\n<instruction type=\"eventListener\" />\n<!-- assumes `install.sql` -->\n<instruction type=\"sql\" />\n<!-- assumes `language/*.xml` -->\n<instruction type=\"language\" />\n\n<!-- exceptions -->\n\n<!-- assumes `files.tar` -->\n<instruction type=\"file\" />\n<!-- no default value, requires relative path -->\n<instruction type=\"script\">acp/install_com.woltlab.wcf_3.0.php</instruction>\n</instructions>\n
"},{"location":"migration/wcf21/package/#exceptions","title":"Exceptions","text":"These exceptions represent the built-in PIPs only, 3rd party plugins and apps may define their own exceptions.
PIP Default ValueacpTemplate
acptemplates.tar
file
files.tar
language
language/*.xml
script
(No default value) sql
install.sql
template
templates.tar
"},{"location":"migration/wcf21/package/#acpmenuxml","title":"acpMenu.xml","text":""},{"location":"migration/wcf21/package/#renamed-categories","title":"Renamed Categories","text":"The following categories have been renamed, menu items need to be adjusted to reflect the new names:
Old Value New Valuewcf.acp.menu.link.system
wcf.acp.menu.link.configuration
wcf.acp.menu.link.display
wcf.acp.menu.link.customization
wcf.acp.menu.link.community
wcf.acp.menu.link.application
"},{"location":"migration/wcf21/package/#submenu-items","title":"Submenu Items","text":"Menu items can now offer additional actions to be accessed from within the menu using an icon-based navigation. This step avoids filling the menu with dozens of Add \u2026
links, shifting the focus on to actual items. Adding more than one action is not recommended and you should at maximum specify two actions per item.
<!-- category -->\n<acpmenuitem name=\"wcf.acp.menu.link.group\">\n<parent>wcf.acp.menu.link.user</parent>\n<showorder>2</showorder>\n</acpmenuitem>\n\n<!-- menu item -->\n<acpmenuitem name=\"wcf.acp.menu.link.group.list\">\n<controller>wcf\\acp\\page\\UserGroupListPage</controller>\n<parent>wcf.acp.menu.link.group</parent>\n<permissions>admin.user.canEditGroup,admin.user.canDeleteGroup</permissions>\n</acpmenuitem>\n<!-- menu item action -->\n<acpmenuitem name=\"wcf.acp.menu.link.group.add\">\n<controller>wcf\\acp\\form\\UserGroupAddForm</controller>\n<!-- actions are defined by menu items of menu items -->\n<parent>wcf.acp.menu.link.group.list</parent>\n<permissions>admin.user.canAddGroup</permissions>\n<!-- required FontAwesome icon name used for display -->\n<icon>fa-plus</icon>\n</acpmenuitem>\n
"},{"location":"migration/wcf21/package/#common-icon-names","title":"Common Icon Names","text":"You should use the same icon names for the (logically) same task, unifying the meaning of items and making the actions predictable.
Meaning Icon Name Result Add or createfa-plus
Search fa-search
Upload fa-upload
"},{"location":"migration/wcf21/package/#boxxml","title":"box.xml","text":"The box PIP has been added.
"},{"location":"migration/wcf21/package/#cronjobxml","title":"cronjob.xml","text":"Legacy cronjobs are assigned a non-deterministic generic name, the only way to assign them a name is removing them and then adding them again.
Cronjobs can now be assigned a name using the name attribute as in <cronjob name=\"com.woltlab.wcf.refreshPackageUpdates\">
, it will be used to identify cronjobs during an update or delete.
Legacy event listeners are assigned a non-deterministic generic name, the only way to assign them a name is removing them and then adding them again.
Event listeners can now be assigned a name using the name attribute as in <eventlistener name=\"sessionPageAccessLog\">
, it will be used to identify event listeners during an update or delete.
The menu PIP has been added.
"},{"location":"migration/wcf21/package/#menuitemxml","title":"menuItem.xml","text":"The menuItem PIP has been added.
"},{"location":"migration/wcf21/package/#objecttypexml","title":"objectType.xml","text":"The definition com.woltlab.wcf.user.dashboardContainer
has been removed, it was previously used to register pages that qualify for dashboard boxes. Since WoltLab Suite 3.0, all pages registered through the page.xml
are valid containers and therefore there is no need for this definition anymore.
The definitions com.woltlab.wcf.page
and com.woltlab.wcf.user.online.location
have been superseded by the page.xml
, they're no longer supported.
The module.display
category has been renamed into module.customization
.
The page PIP has been added.
"},{"location":"migration/wcf21/package/#pagemenuxml","title":"pageMenu.xml","text":"The pageMenu.xml
has been superseded by the page.xml
and is no longer available.
WoltLab Suite 3.0 finally made the transition from raw bbcode to bbcode-flavored HTML, with many new features related to message processing being added. This change impacts both message validation and storing, requiring slightly different APIs to get the job done.
"},{"location":"migration/wcf21/php/#input-processing-for-storage","title":"Input Processing for Storage","text":"The returned HTML is an intermediate representation with a maximum of meta data embedded into it, designed to be stored in the database. Some bbcodes are replaced during this process, for example [b]\u2026[/b]
becomes <strong>\u2026</strong>
, while others are converted into a metacode tag for later processing.
<?php\n$processor = new \\wcf\\system\\html\\input\\HtmlInputProcessor();\n$processor->process($message, $messageObjectType, $messageObjectID);\n$html = $processor->getHtml();\n
The $messageObjectID
can be zero if the element did not exist before, but it should be non-zero when saving an edited message.
Embedded objects need to be registered after saving the message, but once again you can use the processor instance to do the job.
<?php\n$processor = new \\wcf\\system\\html\\input\\HtmlInputProcessor();\n$processor->process($message, $messageObjectType, $messageObjectID);\n$html = $processor->getHtml();\n\n// at this point the message is saved to database and the created object\n// `$example` is a `DatabaseObject` with the id column `$exampleID`\n\n$processor->setObjectID($example->exampleID);\nif (\\wcf\\system\\message\\embedded\\object\\MessageEmbeddedObjectManager::getInstance()->registerObjects($processor)) {\n // there is at least one embedded object, this is also the point at which you\n // would set `hasEmbeddedObjects` to true (if implemented by your type)\n (new \\wcf\\data\\example\\ExampleEditor($example))->update(['hasEmbeddedObjects' => 1]);\n}\n
"},{"location":"migration/wcf21/php/#rendering-the-message","title":"Rendering the Message","text":"The output processor will parse the intermediate HTML and finalize the output for display. This step is highly dynamic and allows for bbcode evaluation and contextual output based on the viewer's permissions.
<?php\n$processor = new \\wcf\\system\\html\\output\\HtmlOutputProcessor();\n$processor->process($html, $messageObjectType, $messageObjectID);\n$renderedHtml = $processor->getHtml();\n
"},{"location":"migration/wcf21/php/#simplified-output","title":"Simplified Output","text":"At some point there can be the need of a simplified output HTML that includes only basic HTML formatting and reduces more sophisticated bbcodes into a simpler representation.
<?php\n$processor = new \\wcf\\system\\html\\output\\HtmlOutputProcessor();\n$processor->setOutputType('text/simplified-html');\n$processor->process(\u2026);\n
"},{"location":"migration/wcf21/php/#plaintext-output","title":"Plaintext Output","text":"The text/plain
output type will strip down the simplified HTML into pure text, suitable for text-only output such as the plaintext representation of an email.
<?php\n$processor = new \\wcf\\system\\html\\output\\HtmlOutputProcessor();\n$processor->setOutputType('text/plain');\n$processor->process(\u2026);\n
"},{"location":"migration/wcf21/php/#rebuilding-data","title":"Rebuilding Data","text":""},{"location":"migration/wcf21/php/#converting-from-bbcode","title":"Converting from BBCode","text":"Enabling message conversion for HTML messages is undefined and yields unexpected results.
Legacy message that still use raw bbcodes must be converted to be properly parsed by the html processors. This process is enabled by setting the fourth parameter of process()
to true
.
<?php\n$processor = new \\wcf\\system\\html\\input\\HtmlInputProcessor();\n$processor->process($html, $messageObjectType, $messageObjectID, true);\n$renderedHtml = $processor->getHtml();\n
"},{"location":"migration/wcf21/php/#extracting-embedded-objects","title":"Extracting Embedded Objects","text":"The process()
method of the input processor is quite expensive, as it runs through the full message validation including the invocation of HTMLPurifier. This is perfectly fine when dealing with single messages, but when you're handling messages in bulk to extract their embedded objects, you're better of with processEmbeddedContent()
. This method deconstructs the message, but skips all validation and expects the input to be perfectly valid, that is the output of a previous run of process()
saved to storage.
<?php\n$processor = new \\wcf\\system\\html\\input\\HtmlInputProcessor();\n$processor->processEmbeddedContent($html, $messageObjectType, $messageObjectID);\n\n// invoke `MessageEmbeddedObjectManager::registerObjects` here\n
"},{"location":"migration/wcf21/php/#breadcrumbs-page-location","title":"Breadcrumbs / Page Location","text":"Breadcrumbs used to be added left to right, but parent locations are added from the bottom to the top, starting with the first ancestor and going upwards. In most cases you simply need to reverse the order.
Breadcrumbs used to be a lose collection of arbitrary links, but are now represented by actual page objects and the control has shifted over to the PageLocationManager
.
<?php\n// before\n\\wcf\\system\\WCF::getBreadcrumbs()->add(new \\wcf\\system\\breadcrumb\\Breadcrumb('title', 'link'));\n\n// after\n\\wcf\\system\\page\\PageLocationManager::getInstance()->addParentLocation($pageIdentifier, $pageObjectID, $object);\n
"},{"location":"migration/wcf21/php/#pages-and-forms","title":"Pages and Forms","text":"The property $activeMenuItem
has been deprecated for the front end and is no longer evaluated at runtime. Recognition of the active item is entirely based around the invoked controller class name and its definition in the page table. You need to properly register your pages for this feature to work.
Added the setLocation()
method that is used to set the current page location based on the search result.
The methods SearchIndexManager::add()
and SearchIndexManager::update()
have been deprecated and forward their call to the new method SearchIndexManager::set()
.
The template structure has been overhauled and it is no longer required nor recommended to include internal templates, such as documentHeader
, headInclude
or userNotice
. Instead use a simple {include file='header'}
that now takes care of of the entire application frame.
</body></html>
after including the footer
template.documentHeader
, headInclude
and userNotice
template should no longer be included manually, the same goes with the <body>
element, please use {include file='header'}
instead.sidebarOrientation
variable for the header
template has been removed and no longer works.header.boxHeadline
has been unified and now reads header.contentHeader
Please see the full example at the end of this page for more information.
"},{"location":"migration/wcf21/templates/#sidebars","title":"Sidebars","text":"Sidebars are now dynamically populated by the box system, this requires a small change to unify the markup. Additionally the usage of <fieldset>
has been deprecated due to browser inconsistencies and bugs and should be replaced with section.box
.
Previous markup used in WoltLab Community Framework 2.1 and earlier:
<fieldset>\n <legend><!-- Title --></legend>\n\n <div>\n <!-- Content -->\n </div>\n</fieldset>\n
The new markup since WoltLab Suite 3.0:
<section class=\"box\">\n <h2 class=\"boxTitle\"><!-- Title --></h2>\n\n <div class=\"boxContent\">\n <!-- Content -->\n </div>\n</section>\n
"},{"location":"migration/wcf21/templates/#forms","title":"Forms","text":"The input tag for session ids SID_INPUT_TAG
has been deprecated and no longer yields any content, it can be safely removed. In previous versions forms have been wrapped in <div class=\"container containerPadding marginTop\">\u2026</div>
which no longer has any effect and should be removed.
If you're using the preview feature for WYSIWYG-powered input fields, you need to alter the preview button include instruction:
{include file='messageFormPreviewButton' previewMessageObjectType='com.example.foo.bar' previewMessageObjectID=0}\n
The message object id should be non-zero when editing.
"},{"location":"migration/wcf21/templates/#icons","title":"Icons","text":"The old .icon-<iconName>
classes have been removed, you are required to use the official .fa-<iconName>
class names from FontAwesome. This does not affect the generic classes .icon
(indicates an icon) and .icon<size>
(e.g. .icon16
that sets the dimensions), these are still required and have not been deprecated.
Before:
<span class=\"icon icon16 icon-list\">\n
Now:
<span class=\"icon icon16 fa-list\">\n
"},{"location":"migration/wcf21/templates/#changed-icon-names","title":"Changed Icon Names","text":"Quite a few icon names have been renamed, the official wiki lists the new icon names in FontAwesome 4.
"},{"location":"migration/wcf21/templates/#changed-classes","title":"Changed Classes","text":".dataList
has been replaced and should now read <ol class=\"inlineList commaSeparated\">
(same applies to <ul>
).framedIconList
has been changed into .userAvatarList
<nav class=\"jsClipboardEditor\">
and <div class=\"jsClipboardContainer\">
have been replaced with a floating button.a.toTopLink
have been replaced with a floating button.framed
dl.condensed
class, as seen in the editor tab menu, is no longer required.sidebarCollapsed
has been removed as sidebars are no longer collapsible.The code below includes only the absolute minimum required to display a page, the content title is already included in the output.
{include file='header'}\n\n<div class=\"section\">\n Hello World!\n</div>\n\n{include file='footer'}\n
"},{"location":"migration/wcf21/templates/#full-example","title":"Full Example","text":"{*\n The page title is automatically set using the page definition, avoid setting it if you can!\n If you really need to modify the title, you can still reference the original title with:\n {$__wcf->getActivePage()->getTitle()}\n*}\n{capture assign='pageTitle'}Custom Page Title{/capture}\n\n{*\n NOTICE: The content header goes here, see the section after this to learn more.\n*}\n\n{* you must not use `headContent` for JavaScript *}\n{capture assign='headContent'}\n <link rel=\"alternate\" type=\"application/rss+xml\" title=\"{lang}wcf.global.button.rss{/lang}\" href=\"\u2026\">\n{/capture}\n\n{* optional, content will be added to the top of the left sidebar *}\n{capture assign='sidebarLeft'}\n \u2026\n\n{event name='boxes'}\n{/capture}\n\n{* optional, content will be added to the top of the right sidebar *}\n{capture assign='sidebarRight'}\n \u2026\n\n{event name='boxes'}\n{/capture}\n\n{capture assign='headerNavigation'}\n <li><a href=\"#\" title=\"Custom Button\" class=\"jsTooltip\"><span class=\"icon icon16 fa-check\"></span> <span class=\"invisible\">Custom Button</span></a></li>\n{/capture}\n\n{include file='header'}\n\n{hascontent}\n <div class=\"paginationTop\">\n{content}\n{pages \u2026}\n{/content}\n </div>\n{/hascontent}\n\n{* the actual content *}\n<div class=\"section\">\n \u2026\n</div>\n\n<footer class=\"contentFooter\">\n{* skip this if you're not using any pagination *}\n{hascontent}\n <div class=\"paginationBottom\">\n{content}{@$pagesLinks}{/content}\n </div>\n{/hascontent}\n\n <nav class=\"contentFooterNavigation\">\n <ul>\n <li><a href=\"\u2026\" class=\"button\"><span class=\"icon icon16 fa-plus\"></span> <span>Custom Button</span></a></li>\n{event name='contentFooterNavigation'}\n </ul>\n </nav>\n</footer>\n\n<script data-relocate=\"true\">\n /* any JavaScript code you need */\n</script>\n\n{* do not include `</body></html>` here, the footer template is the last bit of code! *}\n{include file='footer'}\n
"},{"location":"migration/wcf21/templates/#content-header","title":"Content Header","text":"There are two different methods to set the content header, one sets only the actual values, but leaves the outer HTML untouched, that is generated by the header
template. This is the recommended approach and you should avoid using the alternative method whenever possible.
{* This is automatically set using the page data and should not be set manually! *}\n{capture assign='contentTitle'}Custom Content Title{/capture}\n\n{capture assign='contentDescription'}Optional description that is displayed right after the title.{/capture}\n\n{capture assign='contentHeaderNavigation'}List of navigation buttons displayed right next to the title.{/capture}\n
"},{"location":"migration/wcf21/templates/#alternative","title":"Alternative","text":"{capture assign='contentHeader'}\n <header class=\"contentHeader\">\n <div class=\"contentHeaderTitle\">\n <h1 class=\"contentTitle\">Custom Content Title</h1>\n <p class=\"contentHeaderDescription\">Custom Content Description</p>\n </div>\n\n <nav class=\"contentHeaderNavigation\">\n <ul>\n <li><a href=\"{link controller='CustomController'}{/link}\" class=\"button\"><span class=\"icon icon16 fa-plus\"></span> <span>Custom Button</span></a></li>\n{event name='contentHeaderNavigation'}\n </ul>\n </nav>\n </header>\n{/capture}\n
"},{"location":"migration/wsc30/css/","title":"Migrating from WSC 3.0 - CSS","text":""},{"location":"migration/wsc30/css/#new-style-variables","title":"New Style Variables","text":"The new style variables are only applied to styles that have the compatibility set to WSC 3.1
"},{"location":"migration/wsc30/css/#wcfcontentcontainer","title":"wcfContentContainer","text":"The page content is encapsulated in a new container that wraps around the inner content, but excludes the sidebars, header and page navigation elements.
$wcfContentContainerBackground
- background color$wcfContentContainerBorder
- border colorThese variables control the appearance of the editor toolbar and its buttons.
$wcfEditorButtonBackground
- button and toolbar background color$wcfEditorButtonBackgroundActive
- active button background color$wcfEditorButtonText
- text color for available buttons$wcfEditorButtonTextActive
- text color for active buttons$wcfEditorButtonTextDisabled
- text color for disabled buttonsalert.scss
","text":"The color values for <small class=\"innerError\">
used to be hardcoded values, but have now been changed to use the values for error messages (wcfStatusError*
) instead.
The new tiny builds are highly optimized variants of existing JavaScript files and modules, aiming for significant performance improvements for guests and search engines alike. This is accomplished by heavily restricting page interaction to read-only actions whenever possible, which in return removes the need to provide certain JavaScript modules in general.
For example, disallowing guests to write any formatted messages will in return remove the need to provide the WYSIWYG editor at all. But it doesn't stop there, there are a lot of other modules that provide additional features for the editor, and by excluding the editor, we can also exclude these modules too.
Long story short, the tiny mode guarantees that certain actions will never be carried out by guests or search engines, therefore some modules are not going to be needed by them ever.
"},{"location":"migration/wsc30/javascript/#code-templates-for-tiny-builds","title":"Code Templates for Tiny Builds","text":"The following examples assume that you use the virtual constant COMPILER_TARGET_DEFAULT
as a switch for the optimized code path. This is also the constant used by the official build scripts for JavaScript files.
We recommend that you provide a mock implementation for existing code to ensure 3rd party compatibility. It is enough to provide a bare object or class that exposes the original properties using the same primitive data types. This is intended to provide a soft-fail for implementations that are not aware of the tiny mode yet, but is not required for classes that did not exist until now.
"},{"location":"migration/wsc30/javascript/#legacy-javascript","title":"Legacy JavaScript","text":"if (COMPILER_TARGET_DEFAULT) {\nWCF.Example.Foo = {\nmakeSnafucated: function() {\nreturn \"Hello World\";\n}\n};\n\nWCF.Example.Bar = Class.extend({\nfoobar: \"baz\",\n\nfoo: function($bar) {\nreturn $bar + this.foobar;\n}\n});\n}\nelse {\nWCF.Example.Foo = {\nmakeSnafucated: function() {}\n};\n\nWCF.Example.Bar = Class.extend({\nfoobar: \"\",\nfoo: function() {}\n});\n}\n
"},{"location":"migration/wsc30/javascript/#requirejs-modules","title":"require.js Modules","text":"define([\"some\", \"fancy\", \"dependencies\"], function(Some, Fancy, Dependencies) {\n\"use strict\";\n\nif (!COMPILER_TARGET_DEFAULT) {\nvar Fake = function() {};\nFake.prototype = {\ninit: function() {},\nmakeSnafucated: function() {}\n};\nreturn Fake;\n}\n\nfunction MyAwesomeClass(niceArgument) { this.init(niceArgument); }\nMyAwesomeClass.prototype = {\ninit: function(niceArgument) {\nif (niceArgument) {\nthis.makeSnafucated();\n}\n},\n\nmakeSnafucated: function() {\nconsole.log(\"Hello World\");\n}\n}\n\nreturn MyAwesomeClass;\n});\n
"},{"location":"migration/wsc30/javascript/#including-tinified-builds-through-js","title":"Including tinified builds through {js}
","text":"The {js}
template-plugin has been updated to include support for tiny builds controlled through the optional flag hasTiny=true
:
{js application='wcf' file='WCF.Example' hasTiny=true}\n
This line generates a different output depending on the debug mode and the user login-state.
"},{"location":"migration/wsc30/javascript/#real-error-messages-for-ajax-responses","title":"Real Error Messages for AJAX Responses","text":"The errorMessage
property in the returned response object for failed AJAX requests contained an exception-specific but still highly generic error message. This issue has been around for quite a long time and countless of implementations are relying on this false behavior, eventually forcing us to leave the value unchanged.
This problem is solved by adding the new property realErrorMessage
that exposes the message exactly as it was provided and now matches the value that would be displayed to users in traditional forms.
define(['Ajax'], function(Ajax) {\nreturn {\n// ...\n_ajaxFailure: function(responseData, responseText, xhr, requestData) {\nconsole.log(responseData.realErrorMessage);\n}\n// ...\n};\n});\n
"},{"location":"migration/wsc30/javascript/#simplified-form-submit-in-dialogs","title":"Simplified Form Submit in Dialogs","text":"Forms embedded in dialogs often do not contain the HTML <form>
-element and instead rely on JavaScript click- and key-handlers to emulate a <form>
-like submit behavior. This has spawned a great amount of nearly identical implementations that all aim to handle the form submit through the Enter
-key, still leaving some dialogs behind.
WoltLab Suite 3.1 offers automatic form submit that is enabled through a set of specific conditions and data attributes:
.formSubmit > input[type=\"submit\"], .formSubmit > button[data-type=\"submit\"]
.UiDialog.open()
implements the method _dialogSubmit()
.data-dialog-submit-on-enter=\"true\"
to be set, the type
must be one of number
, password
, search
, tel
, text
or url
.Clicking on the submit button or pressing the Enter
-key in any watched input field will start the submit process. This is done automatically and does not require a manual interaction in your code, therefore you should not bind any click listeners on the submit button yourself.
Any input field with the required
attribute set will be validated to contain a non-empty string after processing the value with String.prototype.trim()
. An empty field will abort the submit process and display a visible error message next to the offending field.
Displaying inline error messages on-the-fly required quite a few DOM operations that were quite simple but also super repetitive and thus error-prone when incorrectly copied over. The global helper function elInnerError()
was added to provide a simple and consistent behavior of inline error messages.
You can display an error message by invoking elInnerError(elementRef, \"Your Error Message\")
, it will insert a new <small class=\"innerError\">
and sets the given message. If there is already an inner error present, then the message will be replaced instead.
Hiding messages is done by setting the 2nd parameter to false
or an empty string:
elInnerError(elementRef, false)
elInnerError(elementRef, '')
The special values null
and undefined
are supported too, but their usage is discouraged, because they make it harder to understand the intention by reading the code:
elInnerError(elementRef, null)
elInnerError(elementRef)
require(['Language'], function(Language)) {\nvar input = elBySel('input[type=\"text\"]');\nif (input.value.trim() === '') {\n// displays a new inline error or replaces the message if there is one already\nelInnerError(input, Language.get('wcf.global.form.error.empty'));\n}\nelse {\n// removes the inline error if it exists\nelInnerError(input, false);\n}\n\n// the above condition is equivalent to this:\nelInnerError(input, (input.value.trim() === '' ? Language.get('wcf.global.form.error.empty') : false));\n}\n
"},{"location":"migration/wsc30/package/","title":"Migrating from WSC 3.0 - Package Components","text":""},{"location":"migration/wsc30/package/#cronjob-scheduler-uses-server-timezone","title":"Cronjob Scheduler uses Server Timezone","text":"The execution time of cronjobs was previously calculated based on the coordinated universal time (UTC). This was changed in WoltLab Suite 3.1 to use the server timezone or, to be precise, the default timezone set in the administration control panel.
"},{"location":"migration/wsc30/package/#exclude-pages-from-becoming-a-landing-page","title":"Exclude Pages from becoming a Landing Page","text":"Some pages do not qualify as landing page, because they're designed around specific expectations that aren't matched in all cases. Examples include the user control panel and its sub-pages that cannot be accessed by guests and will therefore break the landing page for those. While it is somewhat to be expected from control panel pages, there are enough pages that fall under the same restrictions, but aren't easily recognized as such by an administrator.
You can exclude these pages by adding <excludeFromLandingPage>1</excludeFromLandingPage>
(case-sensitive) to the relevant pages in your page.xml
.
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/tornado/page.xsd\">\n<import>\n<page identifier=\"com.example.foo.Bar\">\n<!-- ... -->\n<excludeFromLandingPage>1</excludeFromLandingPage>\n<!-- ... -->\n</page>\n</import>\n</data>\n
"},{"location":"migration/wsc30/package/#new-package-installation-plugin-for-media-providers","title":"New Package Installation Plugin for Media Providers","text":"Please refer to the documentation of the mediaProvider.xml
to learn more.
Please refer to the documentation of the <compatibility>
tag in the package.xml
.
Comments can now be set to require approval by a moderator before being published. This feature is disabled by default if you do not provide a permission in the manager class, enabling it requires a new permission that has to be provided in a special property of your manage implementation.
files/lib/system/comment/manager/ExampleCommentManager.class.php<?php\nclass ExampleCommentManager extends AbstractCommentManager {\n protected $permissionAddWithoutModeration = 'foo.bar.example.canAddCommentWithoutModeration';\n}\n
"},{"location":"migration/wsc30/php/#raw-html-in-user-activity-events","title":"Raw HTML in User Activity Events","text":"User activity events were previously encapsulated inside <div class=\"htmlContent\">\u2026</div>
, with impacts on native elements such as lists. You can now disable the class usage by defining your event as raw HTML:
<?php\nclass ExampleUserActivityEvent {\n // enables raw HTML for output, defaults to `false`\n protected $isRawHtml = true;\n}\n
"},{"location":"migration/wsc30/php/#permission-to-view-likes-of-an-object","title":"Permission to View Likes of an Object","text":"Being able to view the like summary of an object was restricted to users that were able to like the object itself. This creates situations where the object type in general is likable, but the particular object cannot be liked by the current users, while also denying them to view the like summary (but it gets partly exposed through the footer note/summary!).
Implement the interface \\wcf\\data\\like\\IRestrictedLikeObjectTypeProvider
in your object provider to add support for this new permission check.
<?php\nclass LikeableExampleProvider extends ExampleProvider implements IRestrictedLikeObjectTypeProvider, IViewableLikeProvider {\n public function canViewLikes(ILikeObject $object) {\n // perform your permission checks here\n return true;\n }\n}\n
"},{"location":"migration/wsc30/php/#developer-tools-sync-feature","title":"Developer Tools: Sync Feature","text":"The synchronization feature of the newly added developer tools works by invoking a package installation plugin (PIP) outside of a regular installation, while simulating the basic environment that is already exposed by the API.
However, not all PIPs qualify for this kind of execution, especially because it could be invoked multiple times in a row by the user. This is solved by requiring a special marking for PIPs that have no side-effects (= idempotent) when invoked any amount of times with the same arguments.
There's another feature that allows all matching PIPs to be executed in a row using a single button click. In order to solve dependencies on other PIPs, any implementing PIP must also provide the method getSyncDependencies()
that returns the dependent PIPs in an arbitrary order.
<?php\nclass ExamplePackageInstallationPlugin extends AbstractXMLPackageInstallationPlugin implements IIdempotentPackageInstallationPlugin {\n public static function getSyncDependencies() {\n // provide a list of dependent PIPs in arbitrary order\n return [];\n }\n}\n
"},{"location":"migration/wsc30/php/#media-providers","title":"Media Providers","text":"Media providers were added through regular SQL queries in earlier versions, but this is neither convenient, nor did it offer a reliable method to update an existing provider. WoltLab Suite 3.1 adds a new mediaProvider
-PIP that also offers a className
parameter to off-load the result evaluation and HTML generation.
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/tornado/mediaProvider.xsd\">\n<import>\n<provider name=\"example\">\n<title>Example Provider</title>\n<regex>https?://example.com/watch?v=(?P<ID>[a-zA-Z0-9])</regex>\n<className>wcf\\system\\bbcode\\media\\provider\\ExampleBBCodeMediaProvider</className>\n</provider>\n</import>\n</data>\n
"},{"location":"migration/wsc30/php/#php-callback","title":"PHP Callback","text":"The full match is provided for $url
, while any capture groups from the regular expression are assigned to $matches
.
<?php\nclass ExampleBBCodeMediaProvider implements IBBCodeMediaProvider {\n public function parse($url, array $matches = []) {\n return \"final HTML output\";\n }\n}\n
"},{"location":"migration/wsc30/php/#re-evaluate-html-messages","title":"Re-Evaluate HTML Messages","text":"You need to manually set the disallowed bbcodes in order to avoid unintentional bbcode evaluation. Please see this commit for a reference implementation inside worker processes.
The HtmlInputProcessor only supported two ways to handle an existing HTML message:
process()
and run it through the validation and sanitation process, both of them are rather expensive operations and do not qualify for rebuild data workers.processEmbeddedContent()
which bypasses most tasks that are carried out by process()
which aren't required, but does not allow a modification of the message.The newly added method reprocess($message, $objectType, $objectID)
solves this short-coming by offering a full bbcode and text re-evaluation while bypassing any input filters, assuming that the input HTML was already filtered previously.
<?php\n// rebuild data workers tend to contain code similar to this:\nforeach ($this->objectList as $message) {\n // ...\n if (!$message->enableHtml) {\n // ...\n }\n else {\n // OLD:\n $this->getHtmlInputProcessor()->processEmbeddedContent($message->message, 'com.example.foo.message', $message->messageID);\n\n // REPLACE WITH:\n $this->getHtmlInputProcessor()->reprocess($message->message, 'com.example.foo.message', $message->messageID);\n $data['message'] = $this->getHtmlInputProcessor()->getHtml();\n }\n // ...\n}\n
"},{"location":"migration/wsc30/templates/","title":"Migrating from WSC 3.0 - Templates","text":""},{"location":"migration/wsc30/templates/#comment-system-overhaul","title":"Comment-System Overhaul","text":"Unfortunately, there has been a breaking change related to the creation of comments. You need to apply the changes below before being able to create new comments.
"},{"location":"migration/wsc30/templates/#adding-comments","title":"Adding Comments","text":"Existing implementations need to include a new template right before including the generic commentList
template.
<ul id=\"exampleCommentList\" class=\"commentList containerList\" data-...>\n {include file='commentListAddComment' wysiwygSelector='exampleCommentListAddComment'}\n {include file='commentList'}\n</ul>\n
"},{"location":"migration/wsc30/templates/#redesigned-acp-user-list","title":"Redesigned ACP User List","text":"Custom interaction buttons were previously added through the template event rowButtons
and were merely a link-like element with an icon inside. This is still valid and supported for backwards-compatibility, but it is recommend to adapt to the new drop-down-style options using the new template event dropdownItems
.
<!-- button for usage with the `rowButtons` event -->\n<span class=\"icon icon16 fa-list jsTooltip\" title=\"Button Title\"></span>\n\n<!-- new drop-down item for the `dropdownItems` event -->\n<li><a href=\"#\" class=\"jsMyButton\">Button Title</a></li>\n
"},{"location":"migration/wsc30/templates/#sidebar-toogle-buttons-on-mobile-device","title":"Sidebar Toogle-Buttons on Mobile Device","text":"You cannot override the button label for sidebars containing navigation menus.
The page sidebars are automatically collapsed and presented as one or, when both sidebar are present, two condensed buttons. They use generic sidebar-related labels when open or closed, with the exception of embedded menus which will change the button label to read \"Show/Hide Navigation\".
You can provide a custom label before including the sidebars by assigning the new labels to a few special variables:
{assign var='__sidebarLeftShow' value='Show Left Sidebar'}\n{assign var='__sidebarLeftHide' value='Hide Left Sidebar'}\n{assign var='__sidebarRightShow' value='Show Right Sidebar'}\n{assign var='__sidebarRightHide' value='Hide Right Sidebar'}\n
"},{"location":"migration/wsc31/form-builder/","title":"Migrating from WSC 3.1 - Form Builder","text":""},{"location":"migration/wsc31/form-builder/#example-two-text-form-fields","title":"Example: Two Text Form Fields","text":"As the first example, the pre-WoltLab Suite Core 5.2 versions of the forms to add and edit persons from the first part of the tutorial series will be updated to the new form builder API. This form is the perfect first examples as it is very simple with only two text fields whose only restriction is that they have to be filled out and that their values may not be longer than 255 characters each.
As a reminder, here are the two relevant PHP files and the relevant template file:
files/lib/acp/form/PersonAddForm.class.php<?php\nnamespace wcf\\acp\\form;\nuse wcf\\data\\person\\PersonAction;\nuse wcf\\form\\AbstractForm;\nuse wcf\\system\\exception\\UserInputException;\nuse wcf\\system\\WCF;\nuse wcf\\util\\StringUtil;\n\n/**\n * Shows the form to create a new person.\n * \n * @author Matthias Schmidt\n * @copyright 2001-2019 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\Acp\\Form\n */\nclass PersonAddForm extends AbstractForm {\n /**\n * @inheritDoc\n */\n public $activeMenuItem = 'wcf.acp.menu.link.person.add';\n\n /**\n * first name of the person\n * @var string\n */\n public $firstName = '';\n\n /**\n * last name of the person\n * @var string\n */\n public $lastName = '';\n\n /**\n * @inheritDoc\n */\n public $neededPermissions = ['admin.content.canManagePeople'];\n\n /**\n * @inheritDoc\n */\n public function assignVariables() {\n parent::assignVariables();\n\n WCF::getTPL()->assign([\n 'action' => 'add',\n 'firstName' => $this->firstName,\n 'lastName' => $this->lastName\n ]);\n }\n\n /**\n * @inheritDoc\n */\n public function readFormParameters() {\n parent::readFormParameters();\n\n if (isset($_POST['firstName'])) $this->firstName = StringUtil::trim($_POST['firstName']);\n if (isset($_POST['lastName'])) $this->lastName = StringUtil::trim($_POST['lastName']);\n }\n\n /**\n * @inheritDoc\n */\n public function save() {\n parent::save();\n\n $this->objectAction = new PersonAction([], 'create', [\n 'data' => array_merge($this->additionalFields, [\n 'firstName' => $this->firstName,\n 'lastName' => $this->lastName\n ])\n ]);\n $this->objectAction->executeAction();\n\n $this->saved();\n\n // reset values\n $this->firstName = '';\n $this->lastName = '';\n\n // show success message\n WCF::getTPL()->assign('success', true);\n }\n\n /**\n * @inheritDoc\n */\n public function validate() {\n parent::validate();\n\n // validate first name\n if (empty($this->firstName)) {\n throw new UserInputException('firstName');\n }\n if (mb_strlen($this->firstName) > 255) {\n throw new UserInputException('firstName', 'tooLong');\n }\n\n // validate last name\n if (empty($this->lastName)) {\n throw new UserInputException('lastName');\n }\n if (mb_strlen($this->lastName) > 255) {\n throw new UserInputException('lastName', 'tooLong');\n }\n }\n}\n
files/lib/acp/form/PersonEditForm.class.php <?php\nnamespace wcf\\acp\\form;\nuse wcf\\data\\person\\Person;\nuse wcf\\data\\person\\PersonAction;\nuse wcf\\form\\AbstractForm;\nuse wcf\\system\\exception\\IllegalLinkException;\nuse wcf\\system\\WCF;\n\n/**\n * Shows the form to edit an existing person.\n * \n * @author Matthias Schmidt\n * @copyright 2001-2019 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\Acp\\Form\n */\nclass PersonEditForm extends PersonAddForm {\n /**\n * @inheritDoc\n */\n public $activeMenuItem = 'wcf.acp.menu.link.person';\n\n /**\n * edited person object\n * @var Person\n */\n public $person = null;\n\n /**\n * id of the edited person\n * @var integer\n */\n public $personID = 0;\n\n /**\n * @inheritDoc\n */\n public function assignVariables() {\n parent::assignVariables();\n\n WCF::getTPL()->assign([\n 'action' => 'edit',\n 'person' => $this->person\n ]);\n }\n\n /**\n * @inheritDoc\n */\n public function readData() {\n parent::readData();\n\n if (empty($_POST)) {\n $this->firstName = $this->person->firstName;\n $this->lastName = $this->person->lastName;\n }\n }\n\n /**\n * @inheritDoc\n */\n public function readParameters() {\n parent::readParameters();\n\n if (isset($_REQUEST['id'])) $this->personID = intval($_REQUEST['id']);\n $this->person = new Person($this->personID);\n if (!$this->person->personID) {\n throw new IllegalLinkException();\n }\n }\n\n /**\n * @inheritDoc\n */\n public function save() {\n AbstractForm::save();\n\n $this->objectAction = new PersonAction([$this->person], 'update', [\n 'data' => array_merge($this->additionalFields, [\n 'firstName' => $this->firstName,\n 'lastName' => $this->lastName\n ])\n ]);\n $this->objectAction->executeAction();\n\n $this->saved();\n\n // show success message\n WCF::getTPL()->assign('success', true);\n }\n}\n
acptemplates/personAdd.tpl {include file='header' pageTitle='wcf.acp.person.'|concat:$action}\n\n<header class=\"contentHeader\">\n <div class=\"contentHeaderTitle\">\n <h1 class=\"contentTitle\">{lang}wcf.acp.person.{$action}{/lang}</h1>\n </div>\n\n <nav class=\"contentHeaderNavigation\">\n <ul>\n <li><a href=\"{link controller='PersonList'}{/link}\" class=\"button\"><span class=\"icon icon16 fa-list\"></span> <span>{lang}wcf.acp.menu.link.person.list{/lang}</span></a></li>\n\n{event name='contentHeaderNavigation'}\n </ul>\n </nav>\n</header>\n\n{include file='formError'}\n\n{if $success|isset}\n <p class=\"success\">{lang}wcf.global.success.{$action}{/lang}</p>\n{/if}\n\n<form method=\"post\" action=\"{if $action == 'add'}{link controller='PersonAdd'}{/link}{else}{link controller='PersonEdit' object=$person}{/link}{/if}\">\n <div class=\"section\">\n <dl{if $errorField == 'firstName'} class=\"formError\"{/if}>\n <dt><label for=\"firstName\">{lang}wcf.person.firstName{/lang}</label></dt>\n <dd>\n <input type=\"text\" id=\"firstName\" name=\"firstName\" value=\"{$firstName}\" required autofocus maxlength=\"255\" class=\"long\">\n{if $errorField == 'firstName'}\n <small class=\"innerError\">\n{if $errorType == 'empty'}\n{lang}wcf.global.form.error.empty{/lang}\n{else}\n{lang}wcf.acp.person.firstName.error.{$errorType}{/lang}\n{/if}\n </small>\n{/if}\n </dd>\n </dl>\n\n <dl{if $errorField == 'lastName'} class=\"formError\"{/if}>\n <dt><label for=\"lastName\">{lang}wcf.person.lastName{/lang}</label></dt>\n <dd>\n <input type=\"text\" id=\"lastName\" name=\"lastName\" value=\"{$lastName}\" required maxlength=\"255\" class=\"long\">\n{if $errorField == 'lastName'}\n <small class=\"innerError\">\n{if $errorType == 'empty'}\n{lang}wcf.global.form.error.empty{/lang}\n{else}\n{lang}wcf.acp.person.lastName.error.{$errorType}{/lang}\n{/if}\n </small>\n{/if}\n </dd>\n </dl>\n\n{event name='dataFields'}\n </div>\n\n{event name='sections'}\n\n <div class=\"formSubmit\">\n <input type=\"submit\" value=\"{lang}wcf.global.button.submit{/lang}\" accesskey=\"s\">\n{@SECURITY_TOKEN_INPUT_TAG}\n </div>\n</form>\n\n{include file='footer'}\n
Updating the template is easy as the complete form is replace by a single line of code:
acptemplates/personAdd.tpl{include file='header' pageTitle='wcf.acp.person.'|concat:$action}\n\n<header class=\"contentHeader\">\n <div class=\"contentHeaderTitle\">\n <h1 class=\"contentTitle\">{lang}wcf.acp.person.{$action}{/lang}</h1>\n </div>\n\n <nav class=\"contentHeaderNavigation\">\n <ul>\n <li><a href=\"{link controller='PersonList'}{/link}\" class=\"button\"><span class=\"icon icon16 fa-list\"></span> <span>{lang}wcf.acp.menu.link.person.list{/lang}</span></a></li>\n\n{event name='contentHeaderNavigation'}\n </ul>\n </nav>\n</header>\n\n{@$form->getHtml()}\n\n{include file='footer'}\n
PersonEditForm
also becomes much simpler: only the edited Person
object must be read:
<?php\nnamespace wcf\\acp\\form;\nuse wcf\\data\\person\\Person;\nuse wcf\\system\\exception\\IllegalLinkException;\n\n/**\n * Shows the form to edit an existing person.\n * \n * @author Matthias Schmidt\n * @copyright 2001-2019 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\Acp\\Form\n */\nclass PersonEditForm extends PersonAddForm {\n /**\n * @inheritDoc\n */\n public $activeMenuItem = 'wcf.acp.menu.link.person';\n\n /**\n * @inheritDoc\n */\n public function readParameters() {\n parent::readParameters();\n\n if (isset($_REQUEST['id'])) {\n $this->formObject = new Person(intval($_REQUEST['id']));\n if (!$this->formObject->personID) {\n throw new IllegalLinkException();\n }\n }\n }\n}\n
Most of the work is done in PersonAddForm
:
<?php\nnamespace wcf\\acp\\form;\nuse wcf\\data\\person\\PersonAction;\nuse wcf\\form\\AbstractFormBuilderForm;\nuse wcf\\system\\form\\builder\\container\\FormContainer;\nuse wcf\\system\\form\\builder\\field\\TextFormField;\n\n/**\n * Shows the form to create a new person.\n * \n * @author Matthias Schmidt\n * @copyright 2001-2019 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\Acp\\Form\n */\nclass PersonAddForm extends AbstractFormBuilderForm {\n /**\n * @inheritDoc\n */\n public $activeMenuItem = 'wcf.acp.menu.link.person.add';\n\n /**\n * @inheritDoc\n */\n public $formAction = 'create';\n\n /**\n * @inheritDoc\n */\n public $neededPermissions = ['admin.content.canManagePeople'];\n\n /**\n * @inheritDoc\n */\n public $objectActionClass = PersonAction::class;\n\n /**\n * @inheritDoc\n */\n protected function createForm() {\n parent::createForm();\n\n $dataContainer = FormContainer::create('data')\n ->appendChildren([\n TextFormField::create('firstName')\n ->label('wcf.person.firstName')\n ->required()\n ->maximumLength(255),\n\n TextFormField::create('lastName')\n ->label('wcf.person.lastName')\n ->required()\n ->maximumLength(255)\n ]);\n\n $this->form->appendChild($dataContainer);\n }\n}\n
But, as you can see, the number of lines almost decreased by half. All changes are due to extending AbstractFormBuilderForm
:
$formAction
is added and set to create
as the form is used to create a new person. In the edit form, $formAction
has not to be set explicitly as it is done automatically if a $formObject
is set.$objectActionClass
is set to PersonAction::class
and is the class name of the used AbstractForm::$objectAction
object to create and update the Person
object.AbstractFormBuilderForm::createForm()
is overridden and the form contents are added: a form container representing the div.section
element from the old version and the two form fields with the same ids and labels as before. The contents of the old validate()
method is put into two method calls: required()
to ensure that the form is filled out and maximumLength(255)
to ensure that the names are not longer than 255 characters.With version 5.2 of WoltLab Suite Core the like system was completely replaced by the new reactions system. This makes it necessary to make some adjustments to existing code so that your plugin integrates completely into the new system. However, we have kept these adjustments as small as possible so that it is possible to use the reaction system with slight restrictions even without adjustments.
"},{"location":"migration/wsc31/like/#limitations-if-no-adjustments-are-made-to-the-existing-code","title":"Limitations if no adjustments are made to the existing code","text":"If no adjustments are made to the existing code, the following functions are not available: * Notifications about reactions/likes * Recent Activity Events for reactions/likes
"},{"location":"migration/wsc31/like/#migration","title":"Migration","text":""},{"location":"migration/wsc31/like/#notifications","title":"Notifications","text":""},{"location":"migration/wsc31/like/#mark-notification-as-compatible","title":"Mark notification as compatible","text":"Since there are no more likes with the new version, it makes no sense to send notifications about it. Instead of notifications about likes, notifications about reactions are now sent. However, this only changes the notification text and not the notification itself. To update the notification, we first add the interface \\wcf\\data\\reaction\\object\\IReactionObject
to the \\wcf\\data\\like\\object\\ILikeObject
object (e.g. in WoltLab Suite Forum we added the interface to the class \\wbb\\data\\post\\LikeablePost
). After that the object is marked as \"compatible with WoltLab Suite Core 5.2\" and notifications about reactions are sent again.
Next, to display all reactions for the current notification in the notification text, we include the trait \\wcf\\system\\user\\notification\\event\\TReactionUserNotificationEvent
in the user notification event class (typically named like *LikeUserNotificationEvent
). These trait provides a new function that reads out and groups the reactions. The result of this function must now only be passed to the language variable. The name \"reactions\" is typically used as the variable name for the language variable.
As a final step, we only need to change the language variables themselves. To ensure a consistent usability, the same formulations should be used as in the WoltLab Suite Core.
"},{"location":"migration/wsc31/like/#english","title":"English","text":"{prefix}.like.title
Reaction to a {objectName}\n
{prefix}.like.title.stacked
{#$count} users reacted to your {objectName}\n
{prefix}.like.message
{@$author->getAnchorTag()} reacted to your {objectName} ({implode from=$reactions key=reactionID item=count}{@$__wcf->getReactionHandler()->getReactionTypeByID($reactionID)->renderIcon()}\u00d7{#$count}{/implode}).\n
{prefix}.like.message.stacked
{if $count < 4}{@$authors[0]->getAnchorTag()}{if $count == 2} and {else}, {/if}{@$authors[1]->getAnchorTag()}{if $count == 3} and {@$authors[2]->getAnchorTag()}{/if}{else}{@$authors[0]->getAnchorTag()} and {#$others} others{/if} reacted to your {objectName} ({implode from=$reactions key=reactionID item=count}{@$__wcf->getReactionHandler()->getReactionTypeByID($reactionID)->renderIcon()}\u00d7{#$count}{/implode}).\n
wcf.user.notification.{objectTypeName}.like.notification.like
Notify me when someone reacted to my {objectName}\n
"},{"location":"migration/wsc31/like/#german","title":"German","text":"{prefix}.like.title
Reaktion auf einen {objectName}\n
{prefix}.like.title.stacked
{#$count} Benutzern haben auf {if LANGUAGE_USE_INFORMAL_VARIANT}dein(en){else}Ihr(en){/if} {objectName} reagiert\n
{prefix}.like.message
{@$author->getAnchorTag()} hat auf {if LANGUAGE_USE_INFORMAL_VARIANT}dein(en){else}Ihr(en){/if} {objectName} reagiert ({implode from=$reactions key=reactionID item=count}{@$__wcf->getReactionHandler()->getReactionTypeByID($reactionID)->renderIcon()}\u00d7{#$count}{/implode}).\n
{prefix}.like.message.stacked
{if $count < 4}{@$authors[0]->getAnchorTag()}{if $count == 2} und {else}, {/if}{@$authors[1]->getAnchorTag()}{if $count == 3} und {@$authors[2]->getAnchorTag()}{/if}{else}{@$authors[0]->getAnchorTag()} und {#$others} weitere{/if} haben auf {if LANGUAGE_USE_INFORMAL_VARIANT}dein(en){else}Ihr(en){/if} {objectName} reagiert ({implode from=$reactions key=reactionID item=count}{@$__wcf->getReactionHandler()->getReactionTypeByID($reactionID)->renderIcon()}\u00d7{#$count}{/implode}).\n
wcf.user.notification.{object_type_name}.like.notification.like
Jemandem hat auf {if LANGUAGE_USE_INFORMAL_VARIANT}dein(en){else}Ihr(en){/if} {objectName} reagiert\n
"},{"location":"migration/wsc31/like/#recent-activity","title":"Recent Activity","text":"To adjust entries in the Recent Activity, only three small steps are necessary. First we pass the concrete reaction to the language variable, so that we can use the reaction object there. To do this, we add the following variable to the text of the \\wcf\\system\\user\\activity\\event\\IUserActivityEvent
object: $event->reactionType
. Typically we name the variable reactionType
. In the second step, we mark the event as compatible. Therefore we set the parameter supportsReactions
in the objectType.xml
to 1
. So for example the entry looks like this:
<type>\n<name>com.woltlab.example.likeableObject.recentActivityEvent</name>\n<definitionname>com.woltlab.wcf.user.recentActivityEvent</definitionname>\n<classname>wcf\\system\\user\\activity\\event\\LikeableObjectUserActivityEvent</classname>\n<supportsReactions>1</supportsReactions>\n</type>\n
Finally we modify our language variable. To ensure a consistent usability, the same formulations should be used as in the WoltLab Suite Core.
"},{"location":"migration/wsc31/like/#english_1","title":"English","text":"wcf.user.recentActivity.{object_type_name}.recentActivityEvent
Reaction ({objectName})\n
Your language variable for the recent activity text
Reacted with <span title=\"{$reactionType->getTitle()}\" class=\"jsTooltip\">{@$reactionType->renderIcon()}</span> to the {objectName}.\n
"},{"location":"migration/wsc31/like/#german_1","title":"German","text":"wcf.user.recentActivity.{objectTypeName}.recentActivityEvent
Reaktion ({objectName})\n
Your language variable for the recent activity text
Hat mit <span title=\"{$reactionType->getTitle()}\" class=\"jsTooltip\">{@$reactionType->renderIcon()}</span> auf {objectName} reagiert.\n
"},{"location":"migration/wsc31/like/#comments","title":"Comments","text":"If comments send notifications, they must also be updated. The language variables are changed in the same way as described in the section Notifications / Language. After that comment must be marked as compatible. Therefore we set the parameter supportsReactions
in the objectType.xml
to 1
. So for example the entry looks like this:
<type>\n<name>com.woltlab.wcf.objectComment.response.like.notification</name>\n<definitionname>com.woltlab.wcf.notification.objectType</definitionname>\n<classname>wcf\\system\\user\\notification\\object\\type\\LikeUserNotificationObjectType</classname>\n<category>com.woltlab.example</category>\n<supportsReactions>1</supportsReactions>\n</type>\n
"},{"location":"migration/wsc31/like/#forward-compatibility","title":"Forward Compatibility","text":"So that these changes also work in older versions of WoltLab Suite Core, the used classes and traits were backported with WoltLab Suite Core 3.0.22 and WoltLab Suite Core 3.1.10.
"},{"location":"migration/wsc31/php/","title":"Migrating from WSC 3.1 - PHP","text":""},{"location":"migration/wsc31/php/#form-builder","title":"Form Builder","text":"WoltLab Suite Core 5.2 introduces a new, simpler and quicker way of creating forms: form builder. You can find examples of how to migrate existing forms to form builder here.
In the near future, to ensure backwards compatibility within WoltLab packages, we will only use form builder for new forms or for major rewrites of existing forms that would break backwards compatibility anyway.
"},{"location":"migration/wsc31/php/#like-system","title":"Like System","text":"WoltLab Suite Core 5.2 replaced the like system with the reaction system. You can find the migration guide here.
"},{"location":"migration/wsc31/php/#user-content-providers","title":"User Content Providers","text":"User content providers help the WoltLab Suite to find user generated content. They provide a class with which you can find content from a particular user and delete objects.
"},{"location":"migration/wsc31/php/#php-class","title":"PHP Class","text":"First, we create the PHP class that provides our interface to provide the data. The class must implement interface wcf\\system\\user\\content\\provider\\IUserContentProvider
in any case. Mostly we process data which is based on wcf\\data\\DatabaseObject
. In this case, the WoltLab Suite provides an abstract class wcf\\system\\user\\content\\provider\\AbstractDatabaseUserContentProvider
that can be used to automatically generates the standardized classes to generate the list and deletes objects via the DatabaseObjectAction. For example, if we would create a content provider for comments, the class would look like this:
<?php\nnamespace wcf\\system\\user\\content\\provider;\nuse wcf\\data\\comment\\Comment;\n\n/**\n * User content provider for comments.\n *\n * @author Joshua Ruesweg\n * @copyright 2001-2018 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\System\\User\\Content\\Provider\n * @since 5.2\n */\nclass CommentUserContentProvider extends AbstractDatabaseUserContentProvider {\n /**\n * @inheritdoc\n */\n public static function getDatabaseObjectClass() {\n return Comment::class;\n }\n}\n
"},{"location":"migration/wsc31/php/#object-type","title":"Object Type","text":"Now the appropriate object type must be created for the class. This object type must be from the definition com.woltlab.wcf.content.userContentProvider
and include the previous created class as FQN in the parameter classname
. Also the following parameters can be used in the object type:
nicevalue
","text":"Optional
The nice value is used to determine the order in which the remove content worker are execute the provider. Content provider with lower nice values are executed first.
"},{"location":"migration/wsc31/php/#hidden","title":"hidden
","text":"Optional
Specifies whether or not this content provider can be actively selected in the Content Remove Worker. If it cannot be selected, it will not be executed automatically!
"},{"location":"migration/wsc31/php/#requiredobjecttype","title":"requiredobjecttype
","text":"Optional
The specified list of comma-separated object types are automatically removed during content removal when this object type is being removed. Attention: The order of removal is undefined by default, specify a nicevalue
if the order is important.
WoltLab Suite 5.2 introduces a new way to update the database scheme: database PHP API.
"},{"location":"migration/wsc52/libraries/","title":"Migrating from WoltLab Suite 5.2 - Third Party Libraries","text":""},{"location":"migration/wsc52/libraries/#scss-compiler","title":"SCSS Compiler","text":"WoltLab Suite Core 5.3 upgrades the bundled SCSS compiler from leafo/scssphp
0.7.x to scssphp/scssphp
1.1.x. With the updated composer package name the SCSS compiler also received updated namespaces. WoltLab Suite Core adds a compatibility layer that maps the old namespace to the new namespace. The classes themselves appear to be drop-in compatible. Exceptions cannot be mapped using this compatibility layer, any catch
blocks catching a specific Exception within the Leafo
namespace will need to be adjusted.
More details can be found in the Pull Request WoltLab/WCF#3415.
"},{"location":"migration/wsc52/libraries/#guzzle","title":"Guzzle","text":"WoltLab Suite Core 5.3 ships with a bundled version of Guzzle 6. Going forward using Guzzle is the recommended way to perform HTTP requests. The \\wcf\\util\\HTTPRequest
class should no longer be used and transparently uses Guzzle under the hood.
Use \\wcf\\system\\io\\HttpFactory
to retrieve a correctly configured GuzzleHttp\\ClientInterface
.
Please note that it is recommended to explicitely specify a sink
when making requests, due to a PHP / Guzzle bug. Have a look at the implementation in WoltLab/WCF for an example.
The ICommentManager::isContentAuthor(Comment|CommentResponse): bool
method was added. A default implementation that always returns false
is available when inheriting from AbstractCommentManager
.
It is strongly recommended to implement isContentAuthor
within your custom comment manager. An example implementation can be found in ArticleCommentManager
.
The AbstractEventListener
class was added. AbstractEventListener
contains an implementation of execute()
that will dispatch the event handling to dedicated methods based on the $eventName
and, in case of the event object being an AbstractDatabaseObjectAction
, the action name.
Find the details of the dispatch behavior within the class comment of AbstractEventListener
.
Starting with WoltLab Suite 5.3 the user activation status is independent of the email activation status. A user can be activated even though their email address has not been confirmed, preventing emails being sent to these users. Going forward the new User::isEmailConfirmed()
method should be used to check whether sending automated emails to this user is acceptable. If you need to check the user's activation status you should use the new method User::pendingActivation()
instead of relying on activationCode
. To check, which type of activation is missing, you can use the new methods User::requiresEmailActivation()
and User::requiresAdminActivation()
.
*AddForm
","text":"WoltLab Suite 5.3 provides a new framework to allow the administrator to easily edit newly created objects by adding an edit link to the success message. To support this edit link two small changes are required within your *AddForm
.
Update the template.
Replace:
{include file='formError'}\n\n{if $success|isset}\n <p class=\"success\">{lang}wcf.global.success.{$action}{/lang}</p>\n{/if}\n
With:
{include file='formNotice'}\n
Expose objectEditLink
to the template.
Example ($object
being the newly created object):
WCF::getTPL()->assign([\n 'success' => true,\n 'objectEditLink' => LinkHandler::getInstance()->getControllerLink(ObjectEditForm::class, ['id' => $object->objectID]),\n]);\n
It is recommended by search engines to mark up links within user generated content using the rel=\"ugc\"
attribute to indicate that they might be less trustworthy or spammy.
WoltLab Suite 5.3 will automatically sets that attribute on external links during message output processing. Set the new HtmlOutputProcessor::$enableUgc
property to false
if the type of message is not user-generated content, but restricted to a set of trustworthy users. An example of such a type of message would be official news articles.
If you manually generate links based off user input you need to specify the attribute yourself. The $isUgc
attribute was added to StringUtil::getAnchorTag(string, string, bool, bool): string
, allowing you to easily generate a correct anchor tag.
If you need to specify additional HTML attributes for the anchor tag you can use the new StringUtil::getAnchorTagAttributes(string, bool): string
method to generate the anchor attributes that are dependent on the target URL. Specifically the attributes returned are the class=\"externalURL\"
attribute, the rel=\"\u2026\"
attribute and the target=\"\u2026\"
attribute.
Within the template the {anchorAttributes}
template plugin is newly available.
It was discovered that the code holds references to scaled image resources for an unnecessarily long time, taking up memory. This becomes especially apparent when multiple images are scaled within a loop, reusing the same variable name for consecutive images. Unless the destination variable is explicitely cleared before processing the next image up to two images will be stored in memory concurrently. This possibly causes the request to exceed the memory limit or ImageMagick's internal resource limits, even if sufficient resources would have been available to scale the current image.
Starting with WoltLab Suite 5.3 it is recommended to clear image handles as early as possible. The usual pattern of creating a thumbnail for an existing image would then look like this:
<?php\nforeach ([ 200, 500 ] as $size) {\n $adapter = ImageHandler::getInstance()->getAdapter();\n $adapter->loadFile($src);\n $thumbnail = $adapter->createThumbnail(\n $size,\n $size,\n true\n );\n $adapter->writeImage($thumbnail, $destination);\n // New: Clear thumbnail as soon as possible to free up the memory.\n $thumbnail = null;\n}\n
Refer to WoltLab/WCF#3505 for additional details.
"},{"location":"migration/wsc52/php/#toggle-for-accelerated-mobile-pages-amp","title":"Toggle for Accelerated Mobile Pages (AMP)","text":"Controllers delivering AMP versions of pages have to check for the new option MODULE_AMP
and the templates of the non-AMP versions have to also check if the option is enabled before outputting the <link rel=\"amphtml\" />
element.
{jslang}
","text":"Starting with WoltLab Suite 5.3 the {jslang}
template plugin is available. {jslang}
works like {lang}
, with the difference that the result is automatically encoded for use within a single quoted JavaScript string.
Before:
<script>\nrequire(['Language', /* \u2026 */], function(Language, /* \u2026 */) {\n Language.addObject({\n 'app.foo.bar': '{lang}app.foo.bar{/lang}',\n });\n\n // \u2026\n});\n</script>\n
After:
<script>\nrequire(['Language', /* \u2026 */], function(Language, /* \u2026 */) {\n Language.addObject({\n 'app.foo.bar': '{jslang}app.foo.bar{/jslang}',\n });\n\n // \u2026\n});\n</script>\n
"},{"location":"migration/wsc52/templates/#template-plugins","title":"Template Plugins","text":"The {anchor}
, {plural}
, and {user}
template plugins have been added.
In addition to using the new template plugins mentioned above, language items for notifications have been further simplified.
As the whole notification is clickable now, all a
elements have been replaced with strong
elements in notification messages.
The template code to output reactions has been simplified by introducing helper methods:
{* old *}\n{implode from=$reactions key=reactionID item=count}{@$__wcf->getReactionHandler()->getReactionTypeByID($reactionID)->renderIcon()}\u00d7{#$count}{/implode}\n{* new *}\n{@$__wcf->getReactionHandler()->renderInlineList($reactions)}\n\n{* old *}\n<span title=\"{$like->getReactionType()->getTitle()}\" class=\"jsTooltip\">{@$like->getReactionType()->renderIcon()}</span>\n{* new *}\n{@$like->render()}\n
Similarly, showing labels is now also easier due to the new render
method:
{* old *}\n<span class=\"label badge{if $label->getClassNames()} {$label->getClassNames()}{/if}\">{$label->getTitle()}</span>\n{* new *}\n{@$label->render()}\n
The commonly used template code
{if $count < 4}{@$authors[0]->getAnchorTag()}{if $count != 1}{if $count == 2 && !$guestTimesTriggered} and {else}, {/if}{@$authors[1]->getAnchorTag()}{if $count == 3}{if !$guestTimesTriggered} and {else}, {/if} {@$authors[2]->getAnchorTag()}{/if}{/if}{if $guestTimesTriggered} and {if $guestTimesTriggered == 1}a guest{else}guests{/if}{/if}{else}{@$authors[0]->getAnchorTag()}{if $guestTimesTriggered},{else} and{/if} {#$others} other users {if $guestTimesTriggered}and {if $guestTimesTriggered == 1}a guest{else}guests{/if}{/if}{/if}\n
in stacked notification messages can be replaced with a new language item:
{@'wcf.user.notification.stacked.authorList'|language}\n
"},{"location":"migration/wsc52/templates/#popovers","title":"Popovers","text":"Popovers provide additional information of the linked object when a user hovers over a link. We unified the approach for such links:
wcf\\data\\IPopoverObject
.wcf\\data\\IPopoverAction
and the getPopover()
method returns an array with popover content.WoltLabSuite/Core/Controller/Popover
is initialized with the relevant data.anchor
template plugin with an additional class
attribute whose value is the return value of IPopoverObject::getPopoverLinkClass()
.Example:
files/lib/data/foo/Foo.class.phpclass Foo extends DatabaseObject implements IPopoverObject {\n public function getPopoverLinkClass() {\n return 'fooLink';\n }\n}\n
files/lib/data/foo/FooAction.class.phpclass FooAction extends AbstractDatabaseObjectAction implements IPopoverAction {\n public function validateGetPopover() {\n // \u2026\n }\n\n public function getPopover() {\n return [\n 'template' => '\u2026',\n ];\n }\n}\n
require(['WoltLabSuite/Core/Controller/Popover'], function(ControllerPopover) {\nControllerPopover.init({\nclassName: 'fooLink',\ndboAction: 'wcf\\\\data\\\\foo\\\\FooAction',\nidentifier: 'com.woltlab.wcf.foo'\n});\n});\n
{anchor object=$foo class='fooLink'}\n
"},{"location":"migration/wsc53/javascript/","title":"Migrating from WoltLab Suite 5.3 - TypeScript and JavaScript","text":""},{"location":"migration/wsc53/javascript/#typescript","title":"TypeScript","text":"WoltLab Suite 5.4 introduces TypeScript support. Learn about consuming WoltLab Suite\u2019s types in the TypeScript section of the JavaScript API documentation.
The JavaScript API documentation will be updated to properly take into account the changes that came with the new TypeScript support in the future. Existing AMD based modules have been migrated to TypeScript, but will expose the existing and known API.
It is recommended that you migrate your custom packages to make use of TypeScript. It will make consuming newly written modules that properly leverage TypeScript\u2019s features much more pleasant and will also ease using existing modules due to proper autocompletion and type checking.
"},{"location":"migration/wsc53/javascript/#replacements-for-deprecated-components","title":"Replacements for Deprecated Components","text":"The helper functions in wcf.globalHelper.js
should not be used anymore but replaced by their native counterpart:
elCreate(tag)
document.createElement(tag)
elRemove(el)
el.remove()
elShow(el)
DomUtil.show(el)
elHide(el)
DomUtil.hide(el)
elIsHidden(el)
DomUtil.isHidden(el)
elToggle(el)
DomUtil.toggle(el)
elAttr(el, \"attr\")
el.attr
or el.getAttribute(\"attr\")
elData(el, \"data\")
el.dataset.data
elDataBool(element, \"data\")
Core.stringToBool(el.dataset.data)
elById(id)
document.getElementById(id)
elBySel(sel)
document.querySelector(sel)
elBySel(sel, el)
el.querySelector(sel)
elBySelAll(sel)
document.querySelectorAll(sel)
elBySelAll(sel, el)
el.querySelectorAll(sel)
elBySelAll(sel, el, callback)
el.querySelectorAll(sel).forEach((el) => callback(el));
elClosest(el, sel)
el.closest(sel)
elByClass(class)
document.getElementsByClassName(class)
elByClass(class, el)
el.getElementsByClassName(class)
elByTag(tag)
document.getElementsByTagName(tag)
elByTag(tag, el)
el.getElementsByTagName(tag)
elInnerError(el, message, isHtml)
DomUtil.innerError(el, message, isHtml)
Additionally, the following modules should also be replaced by their native counterpart:
Module Native ReplacementWoltLabSuite/Core/Dictionary
Map
WoltLabSuite/Core/List
Set
WoltLabSuite/Core/ObjectMap
WeakMap
For event listeners on click events, WCF_CLICK_EVENT
is deprecated and should no longer be used. Instead, use click
directly:
// before\nelement.addEventListener(WCF_CLICK_EVENT, this._click.bind(this));\n\n// after\nelement.addEventListener('click', (ev) => this._click(ev));\n
"},{"location":"migration/wsc53/javascript/#wcfactiondelete-and-wcfactiontoggle","title":"WCF.Action.Delete
and WCF.Action.Toggle
","text":"WCF.Action.Delete
and WCF.Action.Toggle
were used for buttons to delete or enable/disable objects via JavaScript. In each template, WCF.Action.Delete
or WCF.Action.Toggle
instances had to be manually created for each object listing.
With version 5.4 of WoltLab Suite, we have added a CSS selector-based global TypeScript module that only requires specific CSS classes to be added to the HTML structure for these buttons to work. Additionally, we have added a new {objectAction}
template plugin, which generates these buttons reducing the amount of boilerplate template code.
The required base HTML structure is as follows:
.jsObjectActionContainer
element with a data-object-action-class-name
attribute that contains the name of PHP class that executes the actions..jsObjectActionObject
elements within .jsObjectActionContainer
that represent the objects for which actions can be executed. Each .jsObjectActionObject
element must have a data-object-id
attribute with the id of the object..jsObjectAction
elements within .jsObjectActionObject
for each action with a data-object-action
attribute with the name of the action. These elements can be generated with the {objectAction}
template plugin for the delete
and toggle
action.Example:
<table class=\"table jsObjectActionContainer\" {*\n *}data-object-action-class-name=\"wcf\\data\\foo\\FooAction\">\n <thead>\n <tr>\n{* \u2026 *}\n </tr>\n </thead>\n\n <tbody>\n{foreach from=$objects item=foo}\n <tr class=\"jsObjectActionObject\" data-object-id=\"{$foo->getObjectID()}\">\n <td class=\"columnIcon\">\n{objectAction action=\"toggle\" isDisabled=$foo->isDisabled}\n{objectAction action=\"delete\" objectTitle=$foo->getTitle()}\n{* \u2026 *}\n </td>\n{* \u2026 *}\n </tr>\n{/foreach}\n </tbody>\n</table>\n
Please refer to the documentation in ObjectActionFunctionTemplatePlugin
for details and examples on how to use this template plugin.
The relevant TypeScript module registering the event listeners on the object action buttons is Ui/Object/Action
. When an action button is clicked, an AJAX request is sent using the PHP class name and action name. After the successful execution of the action, the page is either reloaded if the action button has a data-object-action-success=\"reload\"
attribute or an event using the EventHandler
module is fired using WoltLabSuite/Core/Ui/Object/Action
as the identifier and the object action name. Ui/Object/Action/Delete
and Ui/Object/Action/Toggle
listen to these events and update the user interface depending on the execute action by removing the object or updating the toggle button, respectively.
Converting from WCF.Action.*
to the new approach requires minimal changes per template, as shown in the relevant pull request #4080.
WCF.Table.EmptyTableHandler
","text":"When all objects in a table or list are deleted via their delete button or clipboard actions, an empty table or list can remain. Previously, WCF.Table.EmptyTableHandler
had to be explicitly used in each template for these tables and lists to reload the page. As a TypeScript-based replacement for WCF.Table.EmptyTableHandler
that is only initialized once globally, WoltLabSuite/Core/Ui/Empty
was added. To use this new module, you only have to add the CSS class jsReloadPageWhenEmpty
to the relevant HTML element. Once this HTML element no longer has child elements, the page is reloaded. To also cover scenarios in which there are fixed child elements that should not be considered when determining if there are no child elements, the data-reload-page-when-empty=\"ignore\"
can be set for these elements.
Examples:
<table class=\"table\">\n <thead>\n <tr>\n{* \u2026 *}\n </tr>\n </thead>\n\n <tbody class=\"jsReloadPageWhenEmpty\">\n{foreach from=$objects item=object}\n <tr>\n{* \u2026 *}\n </tr>\n{/foreach}\n </tbody>\n</table>\n
<div class=\"section tabularBox messageGroupList\">\n <ol class=\"tabularList jsReloadPageWhenEmpty\">\n <li class=\"tabularListRow tabularListRowHead\" data-reload-page-when-empty=\"ignore\">\n{* \u2026 *}\n </li>\n\n{foreach from=$objects item=object}\n <li>\n{* \u2026 *}\n </li>\n{/foreach}\n </ol>\n</div>\n
"},{"location":"migration/wsc53/libraries/","title":"Migrating from WoltLab Suite 5.3 - Third Party Libraries","text":""},{"location":"migration/wsc53/libraries/#guzzle","title":"Guzzle","text":"The bundled Guzzle version was updated to Guzzle 7. No breaking changes are expected for simple uses. A detailed Guzzle migration guide can be found in the Guzzle documentation.
The explicit sink
that was recommended in the migration guide for WoltLab Suite 5.2 can now be removed, as the Guzzle issue #2735 was fixed in Guzzle 7.
The Emogrifier library was updated from version 2.2 to 5.0. This update comes with a breaking change, as the Emogrifier
class was removed. With the updated Emogrifier library, the CssInliner
class must be used instead.
No compatibility layer was added for the Emogrifier
class, as the Emogrifier library's purpose was to be used within the email subsystem of WoltLab Suite. In case you use Emogrifier directly within your own code, you will need to adjust the usage. Refer to the Emogrifier CHANGELOG and WoltLab/WCF #3738 if you need help making the necessary adjustments.
If you only use Emogrifier indirectly by sending HTML mail via the email subsystem then you might notice unexpected visual changes due to the improved CSS support. Double check your CSS declarations and particularly the specificity of your selectors in these cases.
"},{"location":"migration/wsc53/libraries/#scssphp","title":"scssphp","text":"scssphp was updated from version 1.1 to 1.4.
If you interact with scssphp only by deploying .scss
files, then you should not experience any breaking changes, except when the improved SCSS compatibility interprets your SCSS code differently.
If you happen to directly use scssphp in your PHP code, you should be aware that scssphp deprecated the use of output formatters in favor of a simple output style enum.
Refer to WoltLab/WCF #3851 and the scssphp releases for details.
"},{"location":"migration/wsc53/libraries/#constant-time-encoder","title":"Constant Time Encoder","text":"WoltLab Suite 5.4 ships the paragonie/constant_time_encoding
library. It is recommended to use this library to perform encoding and decoding of secrets to prevent leaks via cache timing attacks. Refer to the library author\u2019s blog post for more background detail.
For the common case of encoding the bytes taken from a CSPRNG in hexadecimal form, the required change would look like the following:
Previously:
<?php\n$encoded = hex2bin(random_bytes(16));\n
Now:
<?php\nuse ParagonIE\\ConstantTime\\Hex;\n\n// For security reasons you should add the backslash\n// to ensure you refer to the `random_bytes` function\n// within the global namespace and not a function\n// defined in the current namespace.\n$encoded = Hex::encode(\\random_bytes(16));\n
Please refer to the documentation and source code of the paragonie/constant_time_encoding
library to learn how to use the library with different encodings (e.g. base64).
The minimum requirements have been increased to the following:
Most notably PHP 7.2 contains usable support for scalar types by the addition of nullable types in PHP 7.1 and parameter type widening in PHP 7.2.
It is recommended to make use of scalar types and other newly introduced features whereever possible. Please refer to the PHP documentation for details.
"},{"location":"migration/wsc53/php/#flood-control","title":"Flood Control","text":"To prevent users from creating massive amounts of contents in short periods of time, i.e., spam, existing systems already use flood control mechanisms to limit the amount of contents created within a certain period of time. With WoltLab Suite 5.4, we have added a general API that manages such rate limiting. Leveraging this API is easily done.
com.woltlab.wcf.floodControl
: com.example.foo.myContent
.FloodControl::getInstance()->registerContent('com.example.foo.myContent');\n
You should only call this method if the user creates the content themselves. If the content is automatically created by the system, for example when copying / duplicating existing content, no activity should be registered.To check the last time when the active user created content of the relevant type, use
FloodControl::getInstance()->getLastTime('com.example.foo.myContent');\n
If you want to limit the number of content items created within a certain period of time, for example within one day, use $data = FloodControl::getInstance()->countContent('com.example.foo.myContent', new \\DateInterval('P1D'));\n// number of content items created within the last day\n$count = $data['count'];\n// timestamp when the earliest content item was created within the last day\n$earliestTime = $data['earliestTime'];\n
The method also returns earliestTime
so that you can tell the user in the error message when they are able again to create new content of the relevant type. Flood control entries are only stored for 31 days and older entries are cleaned up daily.
The previously mentioned methods of FloodControl
use the active user and the current timestamp as reference point. FloodControl
also provides methods to register content or check flood control for other registered users or for guests via their IP address. For further details on these methods, please refer to the documentation in the FloodControl class.
Do not interact directly with the flood control database table but only via the FloodControl
class!
DatabasePackageInstallationPlugin
is a new idempotent package installation plugin (thus it is available in the sync function in the devtools) to update the database schema using the PHP-based database API. DatabasePackageInstallationPlugin
is similar to ScriptPackageInstallationPlugin
by requiring a PHP script that is included during the execution of the script. The script is expected to return an array of DatabaseTable
objects representing the schema changes so that in contrast to using ScriptPackageInstallationPlugin
, no DatabaseTableChangeProcessor
object has to be created. The PHP file must be located in the acp/database/
directory for the devtools sync function to recognize the file.
The PHP API to add and change database tables during package installations and updates in the wcf\\system\\database\\table
namespace now also supports renaming existing table columns with the new IDatabaseTableColumn::renameTo()
method:
PartialDatabaseTable::create('wcf1_test')\n ->columns([\n NotNullInt10DatabaseTableColumn::create('oldName')\n ->renameTo('newName')\n ]);\n
Like with every change to existing database tables, packages can only rename columns that they installed.
"},{"location":"migration/wsc53/php/#captcha","title":"Captcha","text":"The reCAPTCHA v1 implementation was completely removed. This includes the \\wcf\\system\\recaptcha\\RecaptchaHandler
class (not to be confused with the one in the captcha
namespace).
The reCAPTCHA v1 endpoints have already been turned off by Google and always return a HTTP 404. Thus the implementation was completely non-functional even before this change.
See WoltLab/WCF#3781 for details.
"},{"location":"migration/wsc53/php/#search","title":"Search","text":"The generic implementation in the AbstractSearchEngine::parseSearchQuery()
method was dangerous, because it did not have knowledge about the search engine\u2019s specifics. The implementation was completely removed: AbstractSearchEngine::parseSearchQuery()
now always throws a \\BadMethodCallException
.
If you implemented a custom search engine and relied on this method, you can inline the previous implementation to preserve existing behavior. You should take the time to verify the rewritten queries against the manual of the search engine to make sure it cannot generate malformed queries or security issues.
See WoltLab/WCF#3815 for details.
"},{"location":"migration/wsc53/php/#styles","title":"Styles","text":"The StyleCompiler
class is marked final
now. The internal SCSS compiler object being stored in the $compiler
property was a design issue that leaked compiler state across multiple compiled styles, possibly causing misgenerated stylesheets. As the removal of the $compiler
property effectively broke compatibility within the StyleCompiler
and as the StyleCompiler
never was meant to be extended, it was marked final.
See WoltLab/WCF#3929 for details.
"},{"location":"migration/wsc53/php/#tags","title":"Tags","text":"Use of the wcf1_tag_to_object.languageID
column is deprecated. The languageID
column is redundant, because its value can be derived from the tagID
. With WoltLab Suite 5.4, it will no longer be part of any indices, allowing more efficient index usage in the general case.
If you need to filter the contents of wcf1_tag_to_object
by language, you should perform an INNER JOIN wcf1_tag tag ON tag.tagID = tag_to_object.tagID
and filter on wcf1_tag.languageID
.
See WoltLab/WCF#3904 for details.
"},{"location":"migration/wsc53/php/#avatars","title":"Avatars","text":"The ISafeFormatAvatar
interface was added to properly support fallback image types for use in emails. If your custom IUserAvatar
implementation supports image types without broad support (i.e. anything other than PNG, JPEG, and GIF), then you should implement the ISafeFormatAvatar
interface to return a fallback PNG, JPEG, or GIF image.
See WoltLab/WCF#4001 for details.
"},{"location":"migration/wsc53/php/#linebreakseparatedtext-option-type","title":"lineBreakSeparatedText
Option Type","text":"Currently, several of the (user group) options installed by our packages use the textarea
option type and split its value by linebreaks to get a list of items, for example for allowed file extensions. To improve the user interface when setting up the value of such options, we have added the lineBreakSeparatedText
option type as a drop-in replacement where the individual items are explicitly represented as distinct items in the user interface.
WoltLab Suite 5.4 distinguishes between blocking direct contact only and hiding all contents when ignoring users. To allow for detecting the difference, the UserProfile::getIgnoredUsers()
and UserProfile::isIgnoredUser()
methods received a new $type
parameter. Pass either UserIgnore::TYPE_BLOCK_DIRECT_CONTACT
or UserIgnore::TYPE_HIDE_MESSAGES
depending on whether the check refers to a non-directed usage or content.
See WoltLab/WCF#4064 and WoltLab/WCF#3981 for details.
"},{"location":"migration/wsc53/php/#databaseprepare","title":"Database::prepare()
","text":"Database::prepare(string $statement, int $limit = 0, int $offset = 0): PreparedStatement
works the same way as Database::prepareStatement()
but additionally also replaces all occurences of app1_
with app{WCF_N}_
for all installed apps. This new method makes it superfluous to use WCF_N
when building queries.
WoltLab Suite 5.4 includes a completely refactored session handling. As long as you only interact with sessions via WCF::getSession()
, especially when you perform read-only accesses, you should not notice any breaking changes.
You might appreciate some of the new session methods if you process security sensitive data.
"},{"location":"migration/wsc53/session/#summary-and-concepts","title":"Summary and Concepts","text":"Most of the changes revolve around the removal of the legacy persistent login functionality and the assumption that every user has a single session only. Both aspects are related to each other.
"},{"location":"migration/wsc53/session/#legacy-persistent-login","title":"Legacy Persistent Login","text":"The legacy persistent login was rather an automated login. Upon bootstrapping a session, it was checked whether the user had a cookie pair storing the user\u2019s userID
and (a single BCrypt hash of) the user\u2019s password. If such a cookie pair exists and the BCrypt hash within the cookie matches the user\u2019s password hash when hashed again, the session would immediately changeUser()
to the respective user.
This legacy persistent login was completely removed. Instead, any sessions that belong to an authenticated user will automatically be long-lived. These long-lived sessions expire no sooner than 14 days after the last activity, ensuring that the user continously stays logged in, provided that they visit the page at least once per fortnight.
"},{"location":"migration/wsc53/session/#multiple-sessions","title":"Multiple Sessions","text":"To allow for a proper separation of these long-lived user sessions, WoltLab Suite now allows for multiple sessions per user. These sessions are completely unrelated to each other. Specifically, they do not share session variables and they expire independently.
As the existing wcf1_session
table is also used for the online lists and location tracking, it will be maintained on a best effort basis. It no longer stores any private session data.
The actual sessions storing security sensitive information are in an unrelated location. They must only be accessed via the PHP API exposed by the SessionHandler
.
WoltLab Suite 5.4 shares a single session across both the frontend, as well as the ACP. When a user logs in to the frontend, they will also be logged into the ACP and vice versa.
Actual access to the ACP is controlled via the new reauthentication mechanism.
The session variable store is scoped: Session variables set within the frontend are not available within the ACP and vice versa.
"},{"location":"migration/wsc53/session/#improved-authentication-and-reauthentication","title":"Improved Authentication and Reauthentication","text":"WoltLab Suite 5.4 ships with multi-factor authentication support and a generic re-authentication implementation that can be used to verify the account owner\u2019s presence.
"},{"location":"migration/wsc53/session/#additions-and-changes","title":"Additions and Changes","text":""},{"location":"migration/wsc53/session/#password-hashing","title":"Password Hashing","text":"WoltLab Suite 5.4 includes a new object-oriented password hashing framework that is modeled after PHP\u2019s password_*
API. Check PasswordAlgorithmManager
and IPasswordAlgorithm
for details.
The new default password hash is a standard BCrypt hash. All newly generated hashes in wcf1_user.password
will now include a type prefix, instead of just passwords imported from other systems.
The wcf1_session
table will no longer be used for session storage. Instead, it is maintained for compatibility with existing online lists.
The actual session storage is considered an implementation detail and you must not directly interact with the session tables. Future versions might support alternative session backends, such as Redis.
Do not interact directly with the session database tables but only via the SessionHandler
class!
For security sensitive processing, you might want to ensure that the account owner is actually present instead of a third party accessing a session that was accidentally left logged in.
WoltLab Suite 5.4 ships with a generic reauthentication framework. To request reauthentication within your controller you need to:
wcf\\system\\user\\authentication\\TReauthenticationCheck
trait.$this->requestReauthentication(LinkHandler::getInstance()->getControllerLink(static::class, [\n /* additional parameters */\n]));\n
requestReauthentication()
will check if the user has recently authenticated themselves. If they did, the request proceeds as usual. Otherwise, they will be asked to reauthenticate themselves. After the successful authentication, they will be redirected to the URL that was passed as the first parameter (the current controller within the example).
Details can be found in WoltLab/WCF#3775.
"},{"location":"migration/wsc53/session/#multi-factor-authentication","title":"Multi-factor Authentication","text":"To implement multi-factor authentication securely, WoltLab Suite 5.4 implements the concept of a \u201cpending user change\u201d. The user will not be logged in (i.e. WCF::getUser()->userID
returns null
) until they authenticate themselves with their second factor.
Requesting multi-factor authentication is done on an opt-in basis for compatibility reasons. If you perform authentication yourself and do not trust the authentication source to perform multi-factor authentication itself, you will need to adjust your logic to request multi-factor authentication from WoltLab Suite:
Previously:
WCF::getSession()->changeUser($targetUser);\n
Now:
$isPending = WCF::getSession()->changeUserAfterMultifactorAuthentication($targetUser);\nif ($isPending) {\n // Redirect to the authentication form. The user will not be logged in.\n // Note: Do not use `getControllerLink` to support both the frontend as well as the ACP.\n HeaderUtil::redirect(LinkHandler::getInstance()->getLink('MultifactorAuthentication', [\n 'url' => /* Return To */,\n ]));\n exit;\n}\n// Proceed as usual. The user will be logged in.\n
"},{"location":"migration/wsc53/session/#adding-multi-factor-methods","title":"Adding Multi-factor Methods","text":"Adding your own multi-factor method requires the implementation of a single object type:
objectType.xml<type>\n<name>com.example.multifactor.foobar</name>\n<definitionname>com.woltlab.wcf.multifactor</definitionname>\n<icon><!-- Font Awesome 4 Icon Name goes here. --></icon>\n<priority><!-- Determines the sort order, higher priority will be preferred for authentication. --></priority>\n<classname>wcf\\system\\user\\multifactor\\FoobarMultifactorMethod</classname>\n</type>\n
The given classname must implement the IMultifactorMethod
interface.
As a self-contained example, you can find the initial implementation of the email multi-factor method in WoltLab/WCF#3729. Please check the version history of the PHP class to make sure you do not miss important changes that were added later.
Multi-factor authentication is security sensitive. Make sure to carefully read the remarks in IMultifactorMethod
for possible issues. Also make sure to carefully test your implementation against all sorts of incorrect input and consider attack vectors such as race conditions. It is strongly recommended to generously check the current state by leveraging assertions and exceptions.
To enforce Multi-factor Authentication within your controller you need to:
wcf\\system\\user\\multifactor\\TMultifactorRequirementEnforcer
trait.$this->enforceMultifactorAuthentication();
enforceMultifactorAuthentication()
will check if the user is in a group that requires multi-factor authentication, but does not yet have multi-factor authentication enabled. If they did, the request proceeds as usual. Otherwise, a NamedUserException
is thrown.
Most of the changes with regard to the new session handling happened in SessionHandler
. Most notably, SessionHandler
now is marked final
to ensure proper encapsulation of data.
A number of methods in SessionHandler
are now deprecated and result in a noop. This change mostly affects methods that have been used to bootstrap the session, such as setHasValidCookie()
.
Additionally, accessing the following keys on the session is deprecated. They directly map to an existing method in another class and any uses can easily be updated: - ipAddress
- userAgent
- requestURI
- requestMethod
- lastActivityTime
Refer to the implementation for details.
"},{"location":"migration/wsc53/session/#acp-sessions","title":"ACP Sessions","text":"The database tables related to ACP sessions have been removed. The PHP classes have been preserved due to being used within the class hierarchy of the legacy sessions.
"},{"location":"migration/wsc53/session/#cookies","title":"Cookies","text":"The _userID
, _password
, _cookieHash
and _cookieHash_acp
cookies will no longer be created nor consumed.
The virtual session logic existed to support multiple devices per single session in wcf1_session
. Virtual sessions are no longer required with the refactored session handling.
Anything related to virtual sessions has been completely removed as they are considered an implementation detail. This removal includes PHP classes and database tables.
"},{"location":"migration/wsc53/session/#security-token-constants","title":"Security Token Constants","text":"The security token constants are deprecated. Instead, the methods of SessionHandler
should be used (e.g. ->getSecurityToken()
). Within templates, you should migrate to the {csrfToken}
tag in place of {@SECURITY_TOKEN_INPUT_TAG}
. The {csrfToken}
tag is a drop-in replacement and was backported to WoltLab Suite 5.2+, allowing you to maintain compatibility across a broad range of versions.
Most of the methods in PasswordUtil are deprecated in favor of the new password hashing framework.
"},{"location":"migration/wsc53/templates/","title":"Migrating from WoltLab Suite 5.3 - Templates and Languages","text":""},{"location":"migration/wsc53/templates/#csrftoken","title":"{csrfToken}
","text":"Going forward, any uses of the SECURITY_TOKEN_*
constants should be avoided. To reference the CSRF token (\u201cSecurity Token\u201d) within templates, the {csrfToken}
template plugin was added.
Before:
{@SECURITY_TOKEN_INPUT_TAG}\n{link controller=\"Foo\"}t={@SECURITY_TOKEN}{/link}\n
After:
{csrfToken}\n{link controller=\"Foo\"}t={csrfToken type=url}{/link} {* The use of the CSRF token in URLs is discouraged.\n Modifications should happen by means of a POST request. *}\n
The {csrfToken}
plugin was backported to WoltLab Suite 5.2 and higher, allowing compatibility with a large range of WoltLab Suite branches. See WoltLab/WCF#3612 for details.
Prior to version 5.4 of WoltLab Suite, all RSS feed links contained the access token for logged-in users so that the feed shows all contents the specific user has access to. With version 5.4, links with the CSS class rssFeed
will open a dialog when clicked that offers the feed link with the access token for personal use and an anonymous feed link that can be shared with others.
{* before *}\n<li>\n <a rel=\"alternate\" {*\n *}href=\"{if $__wcf->getUser()->userID}{link controller='ArticleFeed'}at={@$__wcf->getUser()->userID}-{@$__wcf->getUser()->accessToken}{/link}{else}{link controller='ArticleFeed'}{/link}{/if}\" {*\n *}title=\"{lang}wcf.global.button.rss{/lang}\" {*\n *}class=\"jsTooltip\"{*\n *}>\n <span class=\"icon icon16 fa-rss\"></span>\n <span class=\"invisible\">{lang}wcf.global.button.rss{/lang}</span>\n </a>\n</li>\n\n{* after *}\n<li>\n <a rel=\"alternate\" {*\n *}href=\"{if $__wcf->getUser()->userID}{link controller='ArticleFeed'}at={@$__wcf->getUser()->userID}-{@$__wcf->getUser()->accessToken}{/link}{else}{link controller='ArticleFeed'}{/link}{/if}\" {*\n *}title=\"{lang}wcf.global.button.rss{/lang}\" {*\n *}class=\"rssFeed jsTooltip\"{*\n *}>\n <span class=\"icon icon16 fa-rss\"></span>\n <span class=\"invisible\">{lang}wcf.global.button.rss{/lang}</span>\n </a>\n</li>\n
"},{"location":"migration/wsc54/deprecations_removals/","title":"Migrating from WoltLab Suite 5.4 - Deprecations and Removals","text":"With version 5.5, we have deprecated certain components and removed several other components that have been deprecated for many years.
"},{"location":"migration/wsc54/deprecations_removals/#deprecations","title":"Deprecations","text":""},{"location":"migration/wsc54/deprecations_removals/#php","title":"PHP","text":""},{"location":"migration/wsc54/deprecations_removals/#classes","title":"Classes","text":"filebase\\system\\file\\FileDataHandler
(use filebase\\system\\cache\\runtime\\FileRuntimeCache
)wcf\\action\\AbstractAjaxAction
(use PSR-7 responses, WoltLab/WCF#4437)wcf\\data\\IExtendedMessageQuickReplyAction
(WoltLab/WCF#4575)wcf\\form\\SearchForm
(see WoltLab/WCF#4605)wcf\\page\\AbstractSecurePage
(WoltLab/WCF#4515)wcf\\page\\SearchResultPage
(see WoltLab/WCF#4605)wcf\\system\\database\\table\\column\\TUnsupportedDefaultValue
(do not implement IDefaultValueDatabaseTableColumn
, see WoltLab/WCF#4733)wcf\\system\\exception\\ILoggingAwareException
(WoltLab/WCF#4547)wcf\\system\\io\\FTP
(directly use the FTP extension)wcf\\system\\search\\AbstractSearchableObjectType
(use AbstractSearchProvider
instead, see WoltLab/WCF#4605)wcf\\system\\search\\elasticsearch\\ElasticsearchException
wcf\\system\\search\\ISearchableObjectType
(use ISearchProvider
instead, see WoltLab/WCF#4605)wcf\\util\\PasswordUtil
wcf\\action\\MessageQuoteAction::markForRemoval()
(WoltLab/WCF#4452)wcf\\data\\user\\avatar\\UserAvatarAction::fetchRemoteAvatar()
(WoltLab/WCF#4744)wcf\\data\\user\\notification\\UserNotificationAction::getOutstandingNotifications()
(WoltLab/WCF#4603)wcf\\data\\moderation\\queue\\ModerationQueueAction::getOutstandingQueues()
(WoltLab/WCF#4603)wcf\\system\\message\\QuickReplyManager::setTmpHash()
(WoltLab/WCF#4575)wcf\\system\\request\\Request::isExecuted()
(WoltLab/WCF#4485)wcf\\system\\search\\elasticsearch\\ElasticsearchHandler::query()
wcf\\system\\session\\Session::getDeviceIcon()
(WoltLab/WCF#4525)wcf\\system\\user\\authentication\\password\\algorithm\\TPhpass::hash()
(WoltLab/WCF#4602)wcf\\system\\user\\authentication\\password\\algorithm\\TPhpass::needsRehash()
(WoltLab/WCF#4602)wcf\\system\\WCF::getAnchor()
(WoltLab/WCF#4580)wcf\\util\\MathUtil::getRandomValue()
(WoltLab/WCF#4280)wcf\\util\\StringUtil::encodeJSON()
(WoltLab/WCF#4645)wcf\\util\\StringUtil::endsWith()
(WoltLab/WCF#4509)wcf\\util\\StringUtil::getHash()
(WoltLab/WCF#4279)wcf\\util\\StringUtil::split()
(WoltLab/WCF#4513)wcf\\util\\StringUtil::startsWith()
(WoltLab/WCF#4509)wcf\\util\\UserUtil::isAvailableEmail()
(WoltLab/WCF#4602)wcf\\util\\UserUtil::isAvailableUsername()
(WoltLab/WCF#4602)wcf\\acp\\page\\PackagePage::$compatibleVersions
(WoltLab/WCF#4371)wcf\\system\\io\\GZipFile::$gzopen64
(WoltLab/WCF#4381)wcf\\system\\search\\elasticsearch\\ElasticsearchHandler::DELETE
wcf\\system\\search\\elasticsearch\\ElasticsearchHandler::GET
wcf\\system\\search\\elasticsearch\\ElasticsearchHandler::POST
wcf\\system\\search\\elasticsearch\\ElasticsearchHandler::PUT
wcf\\system\\visitTracker\\VisitTracker::DEFAULT_LIFETIME
(WoltLab/WCF#4757)escapeString
helper (WoltLab/WCF#4506)HTTP_SEND_X_FRAME_OPTIONS
(WoltLab/WCF#4474)ELASTICSEARCH_ALLOW_LEADING_WILDCARD
WBB_MODULE_IGNORE_BOARDS
(The option will be always on with WoltLab Suite Forum 5.5 and will be removed with a future version.)ENABLE_DESKTOP_NOTIFICATIONS
(WoltLab/WCF#4806)WCF.Message.Quote.Manager.markQuotesForRemoval()
(WoltLab/WCF#4452)WCF.Search.Message.KeywordList
(WoltLab/WCF#4402)SECURITY_TOKEN
(use Core.getXsrfToken()
, WoltLab/WCF#4523)WCF.Dropdown.Interactive.Handler
(WoltLab/WCF#4603)WCF.Dropdown.Interactive.Instance
(WoltLab/WCF#4603)WCF.User.Panel.Abstract
(WoltLab/WCF#4603)wcf1_package_compatibility
(WoltLab/WCF#4371)wcf1_package_update_compatibility
(WoltLab/WCF#4385)wcf1_package_update_optional
(WoltLab/WCF#4432)|encodeJSON
(WoltLab/WCF#4645){fetch}
(WoltLab/WCF#4891)pageNavbarTop::navigationIcons
search::queryOptions
search::authorOptions
search::periodOptions
search::displayOptions
search::generalFields
$_REQUEST['styleID']
) is deprecated (WoltLab/WCF@0c0111e946)gallery\\util\\ExifUtil
wbb\\action\\BoardQuickSearchAction
wbb\\data\\thread\\NewsList
wbb\\data\\thread\\News
wcf\\action\\PollAction
(WoltLab/WCF#4662)wcf\\form\\RecaptchaForm
(WoltLab/WCF#4289)wcf\\system\\background\\job\\ElasticSearchIndexBackgroundJob
wcf\\system\\cache\\builder\\TemplateListenerCacheBuilder
(WoltLab/WCF#4297)wcf\\system\\log\\modification\\ModificationLogHandler
(WoltLab/WCF#4340)wcf\\system\\recaptcha\\RecaptchaHandlerV2
(WoltLab/WCF#4289)wcf\\system\\search\\SearchKeywordManager
(WoltLab/WCF#4313)Leafo
class aliases (WoltLab/WCF#4343, Migration Guide from 5.2 to 5.3)wbb\\data\\board\\BoardCache::getLabelGroups()
wbb\\data\\post\\PostAction::jumpToExtended()
(this method always threw a BadMethodCallException
)wbb\\data\\thread\\ThreadAction::countReplies()
wbb\\data\\thread\\ThreadAction::validateCountReplies()
wcf\\acp\\form\\UserGroupOptionForm::verifyPermissions()
(WoltLab/WCF#4312)wcf\\data\\conversation\\message\\ConversationMessageAction::jumpToExtended()
(WoltLab/com.woltlab.wcf.conversation#162)wcf\\data\\moderation\\queue\\ModerationQueueEditor::markAsDone()
(WoltLab/WCF#4317)wcf\\data\\tag\\TagCloudTag::getSize()
(WoltLab/WCF#4325)wcf\\data\\tag\\TagCloudTag::setSize()
(WoltLab/WCF#4325)wcf\\data\\user\\User::getSocialNetworkPrivacySettings()
(WoltLab/WCF#4308)wcf\\data\\user\\UserAction::getSocialNetworkPrivacySettings()
(WoltLab/WCF#4308)wcf\\data\\user\\UserAction::saveSocialNetworkPrivacySettings()
(WoltLab/WCF#4308)wcf\\data\\user\\UserAction::validateGetSocialNetworkPrivacySettings()
(WoltLab/WCF#4308)wcf\\data\\user\\UserAction::validateSaveSocialNetworkPrivacySettings()
(WoltLab/WCF#4308)wcf\\data\\user\\avatar\\DefaultAvatar::canCrop()
(WoltLab/WCF#4310)wcf\\data\\user\\avatar\\DefaultAvatar::getCropImageTag()
(WoltLab/WCF#4310)wcf\\data\\user\\avatar\\UserAvatar::canCrop()
(WoltLab/WCF#4310)wcf\\data\\user\\avatar\\UserAvatar::getCropImageTag()
(WoltLab/WCF#4310)wcf\\system\\bbcode\\BBCodeHandler::setAllowedBBCodes()
(WoltLab/WCF#4319)wcf\\system\\bbcode\\BBCodeParser::validateBBCodes()
(WoltLab/WCF#4319)wcf\\system\\breadcrumb\\Breadcrumbs::add()
(WoltLab/WCF#4298)wcf\\system\\breadcrumb\\Breadcrumbs::remove()
(WoltLab/WCF#4298)wcf\\system\\breadcrumb\\Breadcrumbs::replace()
(WoltLab/WCF#4298)wcf\\system\\form\\builder\\IFormNode::create()
(Removal from Interface, see WoltLab/WCF#4468)wcf\\system\\form\\builder\\IFormNode::validateAttribute()
(Removal from Interface, see WoltLab/WCF#4468)wcf\\system\\form\\builder\\IFormNode::validateClass()
(Removal from Interface, see WoltLab/WCF#4468)wcf\\system\\form\\builder\\IFormNode::validateId()
(Removal from Interface, see WoltLab/WCF#4468)wcf\\system\\form\\builder\\field\\IAttributeFormField::validateFieldAttribute()
(Removal from Interface, see WoltLab/WCF#4468)wcf\\system\\form\\builder\\field\\dependency\\IFormFieldDependency::create()
(Removal from Interface, see WoltLab/WCF#4468)wcf\\system\\form\\builder\\field\\validation\\IFormFieldValidator::validateId()
(Removal from Interface, see WoltLab/WCF#4468)wcf\\system\\message\\embedded\\object\\MessageEmbeddedObjectManager::parseTemporaryMessage()
(WoltLab/WCF#4299)wcf\\system\\package\\PackageArchive::getPhpRequirements()
(WoltLab/WCF#4311)wcf\\system\\search\\ISearchIndexManager::add()
(Removal from Interface, see WoltLab/WCF#4508)wcf\\system\\search\\ISearchIndexManager::update()
(Removal from Interface, see WoltLab/WCF#4508)wcf\\system\\search\\elasticsearch\\ElasticsearchHandler::_add()
wcf\\system\\search\\elasticsearch\\ElasticsearchHandler::_delete()
wcf\\system\\search\\elasticsearch\\ElasticsearchHandler::add()
wcf\\system\\search\\elasticsearch\\ElasticsearchHandler::bulkAdd()
wcf\\system\\search\\elasticsearch\\ElasticsearchHandler::bulkDelete()
wcf\\system\\search\\elasticsearch\\ElasticsearchHandler::delete()
wcf\\system\\search\\elasticsearch\\ElasticsearchHandler::update()
wcf\\system\\search\\elasticsearch\\ElasticsearchSearchIndexManager::add()
wcf\\system\\search\\elasticsearch\\ElasticsearchSearchIndexManager::update()
wcf\\system\\search\\elasticsearch\\ElasticsearchSearchEngine::parseSearchQuery()
wcf\\data\\category\\Category::$permissions
(WoltLab/WCF#4303)wcf\\system\\search\\elasticsearch\\ElasticsearchSearchIndexManager::$bulkTypeName
wcf\\system\\search\\elasticsearch\\ElasticsearchHandler::HEAD
wcf\\system\\tagging\\TagCloud::MAX_FONT_SIZE
(WoltLab/WCF#4325)wcf\\system\\tagging\\TagCloud::MIN_FONT_SIZE
(WoltLab/WCF#4325)ENABLE_CENSORSHIP
(always call Censorship::test()
, see WoltLab/WCF#4567)MODULE_SYSTEM_RECAPTCHA
(WoltLab/WCF#4305)PROFILE_MAIL_USE_CAPTCHA
(WoltLab/WCF#4399)may
value for MAIL_SMTP_STARTTLS
(WoltLab/WCF#4398)SEARCH_USE_CAPTCHA
(see WoltLab/WCF#4605)acp/dereferrer.php
Blog.Entry.QuoteHandler
(use WoltLabSuite/Blog/Ui/Entry/Quote
)Calendar.Event.QuoteHandler
(use WoltLabSuite/Calendar/Ui/Event/Quote
)WBB.Board.IgnoreBoards
(use WoltLabSuite/Forum/Ui/Board/Ignore
)WBB.Board.MarkAllAsRead
(use WoltLabSuite/Forum/Ui/Board/MarkAllAsRead
)WBB.Board.MarkAsRead
(use WoltLabSuite/Forum/Ui/Board/MarkAsRead
)WBB.Post.QuoteHandler
(use WoltLabSuite/Forum/Ui/Post/Quote
)WBB.Thread.LastPageHandler
(use WoltLabSuite/Forum/Ui/Thread/LastPageHandler
)WBB.Thread.MarkAsRead
(use WoltLabSuite/Forum/Ui/Thread/MarkAsRead
)WBB.Thread.SimilarThreads
(use WoltLabSuite/Forum/Ui/Thread/SimilarThreads
)WBB.Thread.WatchedThreadList
(use WoltLabSuite/Forum/Controller/Thread/WatchedList
)WCF.ACP.Style.ImageUpload
(WoltLab/WCF#4323)WCF.ColorPicker
(see migration guide for WCF.ColorPicker
)WCF.Conversation.Message.QuoteHandler
(use WoltLabSuite/Core/Conversation/Ui/Message/Quote
, see WoltLab/com.woltlab.wcf.conversation#155)WCF.Like.js
(WoltLab/WCF#4300)WCF.Message.UserMention
(WoltLab/WCF#4324)WCF.Poll.Manager
(WoltLab/WCF#4662)WCF.UserPanel
(WoltLab/WCF#4316)WCF.User.Panel.Moderation
(WoltLab/WCF#4603)WCF.User.Panel.Notification
(WoltLab/WCF#4603)WCF.User.Panel.UserMenu
(WoltLab/WCF#4603)wbb.search.boards.all
wcf.global.form.error.greaterThan.javaScript
(WoltLab/WCF#4306)wcf.global.form.error.lessThan.javaScript
(WoltLab/WCF#4306)wcf.search.type.keywords
wcf.acp.option.search_use_captcha
wcf.search.query.description
wcf.search.results.change
wcf.search.results.description
wcf.search.general
wcf.search.query
wcf.search.error.noMatches
wcf.search.error.user.noMatches
searchResult
search::tabMenuTabs
search::sections
tagSearch::tabMenuTabs
tagSearch::sections
With WoltLab Suite Forum 5.5 we have introduced a new system for subscribing to threads and boards, which also offers the possibility to ignore threads and boards. You can learn more about this feature in our blog. The new system uses a separate mechanism to track the subscribed forums as well as the subscribed threads. The previously used object type com.woltlab.wcf.user.objectWatch
is now discontinued, because the object watch system turned out to be too limited for the complex logic behind thread and forum subscriptions.
Previously:
$action = new UserObjectWatchAction([], 'subscribe', [\n 'data' => [\n 'objectID' => $threadID,\n 'objectType' => 'com.woltlab.wbb.thread',\n ]\n]);\n$action->executeAction();\n
Now:
ThreadStatusHandler::saveSubscriptionStatus(\n $threadID,\n ThreadStatusHandler::SUBSCRIPTION_MODE_WATCHING\n);\n
"},{"location":"migration/wsc54/forum_subscriptions/#filter-ignored-threads","title":"Filter Ignored Threads","text":"To filter ignored threads from a given ThreadList
, you can use the method ThreadStatusHandler::addFilterForIgnoredThreads()
to append the filter for ignored threads. The ViewableThreadList
filters out ignored threads by default.
Example:
$user = new User(123);\n$threadList = new ThreadList();\nThreadStatusHandler::addFilterForIgnoredThreads(\n $threadList,\n // This parameter specifies the target user. Defaults to the current user if the parameter\n // is omitted or `null`.\n $user\n);\n$threadList->readObjects();\n
"},{"location":"migration/wsc54/forum_subscriptions/#filter-ignored-users","title":"Filter Ignored Users","text":"Avoid issuing notifications to users that have ignored the target thread by filtering those out.
$userIDs = [1, 2, 3];\n$users = ThreadStatusHandler::filterIgnoredUserIDs(\n $userIDs,\n $thread->threadID\n);\n
"},{"location":"migration/wsc54/forum_subscriptions/#subscribe-to-boards","title":"Subscribe to Boards","text":"Previously:
$action = new UserObjectWatchAction([], 'subscribe', [\n 'data' => [\n 'objectID' => $boardID,\n 'objectType' => 'com.woltlab.wbb.board',\n ]\n]);\n$action->executeAction();\n
Now:
BoardStatusHandler::saveSubscriptionStatus(\n $boardID,\n ThreadStatusHandler::SUBSCRIPTION_MODE_WATCHING\n);\n
"},{"location":"migration/wsc54/forum_subscriptions/#filter-ignored-boards","title":"Filter Ignored Boards","text":"Similar to ignored threads you will also have to avoid issuing notifications for boards that a user has ignored.
$userIDs = [1, 2, 3];\n$users = BoardStatusHandler::filterIgnoredUserIDs(\n $userIDs,\n $board->boardID\n);\n
"},{"location":"migration/wsc54/javascript/","title":"Migrating from WoltLab Suite 5.4 - TypeScript and JavaScript","text":""},{"location":"migration/wsc54/javascript/#ajaxdboaction","title":"Ajax.dboAction()
","text":"We have introduced a new Promise
based API for the interaction with wcf\\data\\DatabaseObjectAction
. It provides full IDE autocompletion support and transparent error handling, but is designed to be used with DatabaseObjectAction
only.
See the documentation for the new API and WoltLab/WCF#4585 for details.
"},{"location":"migration/wsc54/javascript/#wcfcolorpicker","title":"WCF.ColorPicker
","text":"We have replaced the old jQuery-based color picker WCF.ColorPicker
with a more lightweight replacement WoltLabSuite/Core/Ui/Color/Picker
, which uses the build-in input[type=color]
field. To support transparency, which input[type=color]
does not, we also added a slider to set the alpha value. WCF.ColorPicker
has been adjusted to internally use WoltLabSuite/Core/Ui/Color/Picker
and it has been deprecated.
Be aware that the new color picker requires the following new phrases to be available in the TypeScript/JavaScript code:
wcf.style.colorPicker.alpha
,wcf.style.colorPicker.color
,wcf.style.colorPicker.error.invalidColor
,wcf.style.colorPicker.hexAlpha
,wcf.style.colorPicker.new
.See WoltLab/WCF#4353 for details.
"},{"location":"migration/wsc54/javascript/#codemirror","title":"CodeMirror","text":"The bundled version of CodeMirror was updated and should be loaded using the AMD loader going forward.
See the third party libraries migration guide for details.
"},{"location":"migration/wsc54/javascript/#new-user-menu","title":"New User Menu","text":"The legacy implementation WCF.User.Panel.Abstract
was based on jQuery and has now been retired in favor of a new lightweight implementation that provides a clean interface and improved accessibility. You are strongly encouraged to migrate your existing implementation to integrate with existing menus.
Please use WoltLabSuite/Core/Ui/User/Menu/Data/ModerationQueue.ts
as a template for your own implementation, it contains only strictly the code you will need. It makes use of the new Ajax.dboAction()
(see above) for improved readability and flexibility.
You must update your trigger button to include the role
, tabindex
and ARIA attributes! Please take a look at the links in pageHeaderUser.tpl
to see these four attributes in action.
See WoltLab/WCF#4603 for details.
"},{"location":"migration/wsc54/libraries/","title":"Migrating from WoltLab Suite 5.4 - Third Party Libraries","text":""},{"location":"migration/wsc54/libraries/#symfony-php-polyfills","title":"Symfony PHP Polyfills","text":"WoltLab Suite 5.5 ships with Symfony's PHP 7.3, 7.4, and 8.0 polyfills. These polyfills allow you to reliably use some of the PHP functions only available in PHP versions that are newer than the current minimum of PHP 7.2. Notable mentions are str_starts_with
, str_ends_with
, array_key_first
, and array_key_last
.
Refer to the documentation within the symfony/polyfill repository for details.
"},{"location":"migration/wsc54/libraries/#scssphp","title":"scssphp","text":"scssphp was updated from version 1.4 to 1.10.
If you interact with scssphp only by deploying .scss
files, then you should not experience any breaking changes, except when the improved SCSS compatibility interprets your SCSS code differently.
If you happen to directly use scssphp in your PHP code, you should be aware that scssphp deprecated the use of the compile()
method, non-UTF-8 processing and also adjusted the handling of pure PHP values for variable handling.
Refer to WoltLab/WCF#4345 and the scssphp releases for details.
"},{"location":"migration/wsc54/libraries/#emogrifier-css-inliner","title":"Emogrifier / CSS Inliner","text":"The Emogrifier library was updated from version 5.0 to 6.0.
"},{"location":"migration/wsc54/libraries/#codemirror","title":"CodeMirror","text":"CodeMirror, the code editor we use for editing templates and SCSS, for example, has been updated to version 5.61.1 and we now also deliver all supported languages/modes. To properly support all languages/modes, CodeMirror is now loaded via the AMD module loader, which requires the original structure of the CodeMirror package, i.e. codemirror.js
being in a lib
folder. To preserve backward-compatibility, we also keep copies of codemirror.js
and codemirror.css
in version 5.61.1 directly in js/3rdParty/codemirror
. These files are, however, considered deprecated and you should migrate to using require()
(see codemirror
ACP template).
See WoltLab/WCF#4277 for details.
"},{"location":"migration/wsc54/libraries/#zendprogressbar","title":"Zend/ProgressBar","text":"The old bundled version of Zend/ProgressBar was replaced by a current version of laminas-progressbar.
Due to laminas-zendframework-bridge this update is a drop-in replacement. Existing code should continue to work as-is.
It is recommended to cleanly migrate to laminas-progressbar to allow for a future removal of the bridge. Updating the use
imports should be sufficient to switch to the laminas-progressbar.
See WoltLab/WCF#4460 for details.
"},{"location":"migration/wsc54/php/","title":"Migrating from WoltLab Suite 5.4 - PHP","text":""},{"location":"migration/wsc54/php/#initial-psr-7-support","title":"Initial PSR-7 support","text":"WoltLab Suite will incrementally add support for object oriented request/response handling based off the PSR-7 and PSR-15 standards in the upcoming versions.
WoltLab Suite 5.5 adds initial support by allowing to define the response using objects implementing the PSR-7 ResponseInterface
. If a controller returns such a response object from its __run()
method, this response will automatically emitted to the client.
Any PSR-7 implementation is supported, but WoltLab Suite 5.5 ships with laminas-diactoros as the recommended \u201cbatteries included\u201d implementation of PSR-7.
Support for PSR-7 requests and PSR-15 middlewares is expected to follow in future versions.
See WoltLab/WCF#4437 for details.
"},{"location":"migration/wsc54/php/#recommended-changes-for-woltlab-suite-55","title":"Recommended changes for WoltLab Suite 5.5","text":"With the current support in WoltLab Suite 5.5 it is recommended to migrate the *Action
classes to make use of PSR-7 responses. Control and data flow is typically fairly simple in *Action
classes with most requests ending up in either a redirect or a JSON response, commonly followed by a call to exit;
.
Experimental support for *Page
and *Form
is available. It is recommended to wait for a future version before migrating these types of controllers.
Previously:
lib/action/ExampleRedirectAction.class.php<?php\n\nnamespace wcf\\action;\n\nuse wcf\\system\\request\\LinkHandler;\nuse wcf\\util\\HeaderUtil;\n\nfinal class ExampleRedirectAction extends AbstractAction\n{\n public function execute()\n {\n parent::execute();\n\n // Redirect to the landing page.\n HeaderUtil::redirect(\n LinkHandler::getInstance()->getLink()\n );\n\n exit;\n }\n}\n
Now:
lib/action/ExampleRedirectAction.class.php<?php\n\nnamespace wcf\\action;\n\nuse Laminas\\Diactoros\\Response\\RedirectResponse;\nuse wcf\\system\\request\\LinkHandler;\n\nfinal class ExampleRedirectAction extends AbstractAction\n{\n public function execute()\n {\n parent::execute();\n\n // Redirect to the landing page.\n return new RedirectResponse(\n LinkHandler::getInstance()->getLink()\n );\n }\n}\n
"},{"location":"migration/wsc54/php/#migrating-json-responses","title":"Migrating JSON responses","text":"Previously:
lib/action/ExampleJsonAction.class.php<?php\n\nnamespace wcf\\action;\n\nuse wcf\\util\\JSON;\n\nfinal class ExampleJsonAction extends AbstractAction\n{\n public function execute()\n {\n parent::execute();\n\n \\header('Content-type: application/json; charset=UTF-8');\n\n echo JSON::encode([\n 'foo' => 'bar',\n ]);\n\n exit;\n }\n}\n
Now:
lib/action/ExampleJsonAction.class.php<?php\n\nnamespace wcf\\action;\n\nuse Laminas\\Diactoros\\Response\\JsonResponse;\n\nfinal class ExampleJsonAction extends AbstractAction\n{\n public function execute()\n {\n parent::execute();\n\n return new JsonResponse([\n 'foo' => 'bar',\n ]);\n }\n}\n
"},{"location":"migration/wsc54/php/#events","title":"Events","text":"Historically, events were tightly coupled with a single class, with the event object being an object of this class, expecting the event listener to consume public properties and method of the event object. The $parameters
array was introduced due to limitations of this pattern, avoiding moving all the values that might be of interest to the event listener into the state of the object. Events were still tightly coupled with the class that fired the event and using the opaque parameters array prevented IDEs from assisting with autocompletion and typing.
WoltLab Suite 5.5 introduces the concept of dedicated, reusable event classes. Any newly introduced event will receive a dedicated class, implementing the wcf\\system\\event\\IEvent
interface. These event classes may be fired from multiple locations, making them reusable to convey that a conceptual action happened, instead of a specific class doing something. An example for using the new event system could be a user logging in: Instead of listening on a the login form being submitted and the Facebook login action successfully running, an event UserLoggedIn
might be fired whenever a user logs in, no matter how the login is performed.
Additionally, these dedicated event classes will benefit from full IDE support. All the relevant values may be stored as real properties on the event object.
Event classes should not have an Event
suffix and should be stored in an event
namespace in a matching location. Thus, the UserLoggedIn
example might have a FQCN of \\wcf\\system\\user\\authentication\\event\\UserLoggedIn
.
Event listeners for events implementing IEvent
need to follow PSR-14, i.e. they need to be callable. In practice, this means that the event listener class needs to implement __invoke()
. No interface has to be implemented in this case.
Previously:
$parameters = [\n 'value' => \\random_int(1, 1024),\n];\n\nEventHandler::getInstance()->fireAction($this, 'valueAvailable', $parameters);\n
lib/system/event/listener/ValueDumpListener.class.php<?php\n\nnamespace wcf\\system\\event\\listener;\n\nuse wcf\\form\\ValueForm;\n\nfinal class ValueDumpListener implements IParameterizedEventListener\n{\n /**\n * @inheritDoc\n * @param ValueForm $eventObj\n */\n public function execute($eventObj, $className, $eventName, array &$parameters)\n {\n var_dump($parameters['value']);\n }\n}\n
Now:
EventHandler::getInstance()->fire(new ValueAvailable(\\random_int(1, 1024)));\n
lib/system/foo/event/ValueAvailable.class.php<?php\n\nnamespace wcf\\system\\foo\\event;\n\nuse wcf\\system\\event\\IEvent;\n\nfinal class ValueAvailable implements IEvent\n{\n /**\n * @var int\n */\n private $value;\n\n public function __construct(int $value)\n {\n $this->value = $value;\n }\n\n public function getValue(): int\n {\n return $this->value;\n }\n}\n
lib/system/event/listener/ValueDumpListener.class.php<?php\n\nnamespace wcf\\system\\event\\listener;\n\nuse wcf\\system\\foo\\event\\ValueAvailable;\n\nfinal class ValueDumpListener\n{\n public function __invoke(ValueAvailable $event): void\n {\n \\var_dump($event->getValue());\n }\n}\n
See WoltLab/WCF#4000 and WoltLab/WCF#4265 for details.
"},{"location":"migration/wsc54/php/#authentication","title":"Authentication","text":"The UserLoggedIn
event was added. You should fire this event if you implement a custom login process (e.g. when adding additional external authentication providers).
Example:
EventHandler::getInstance()->fire(\n new UserLoggedIn($user)\n);\n
See WoltLab/WCF#4356 for details.
"},{"location":"migration/wsc54/php/#embedded-objects-in-comments","title":"Embedded Objects in Comments","text":"WoltLab/WCF#4275 added support for embedded objects like mentions for comments and comment responses. To properly render embedded objects whenever you are using comments in your packages, you have to use ViewableCommentList
/ViewableCommentResponseList
in these places or ViewableCommentRuntimeCache
/ViewableCommentResponseRuntimeCache
. While these runtime caches are only available since version 5.5, the viewable list classes have always been available so that changing CommentList
to ViewableCommentList
, for example, is a backwards-compatible change.
The Mailbox
and UserMailbox
classes no longer store the passed Language
and User
objects, but the respective ID instead. This change reduces the size of the serialized email when stored in the background queue.
If you inherit from the Mailbox
or UserMailbox
classes, you might experience issues if you directly access the $this->language
or $this->user
properties. Adjust your class to use composition instead of inheritance if possible. Use the getLanguage()
or getUser()
getters if using composition is not possible.
See WoltLab/WCF#4389 for details.
"},{"location":"migration/wsc54/php/#smtp","title":"SMTP","text":"The SmtpEmailTransport
no longer supports a value of may
for the $starttls
property.
Using the may
value is unsafe as it allows for undetected MITM attacks. The use of encrypt
is recommended, unless it is certain that the SMTP server does not support TLS.
See WoltLab/WCF#4398 for details.
"},{"location":"migration/wsc54/php/#search","title":"Search","text":""},{"location":"migration/wsc54/php/#search-form","title":"Search Form","text":"After the overhaul of the search form, search providers are no longer bound to SearchForm
and SearchResultPage
. The interface ISearchObjectType
and the abstract implementation AbstractSearchableObjectType
have been replaced by ISearchProvider
and AbstractSearchProvider
.
Please use ArticleSearch
as a template for your own implementation
See WoltLab/WCF#4605 for details.
"},{"location":"migration/wsc54/php/#exceptions","title":"Exceptions","text":"A new wcf\\system\\search\\exception\\SearchFailed
exception was added. This exception should be thrown when executing the search query fails for (mostly) temporary reasons, such as a network partition to a remote service. It is not meant as a blanket exception to wrap everything. For example it must not be returned obvious programming errors, such as an access to an undefined variable (ErrorException
).
Catching the SearchFailed
exception allows consuming code to gracefully handle search requests that are not essential for proceeding, without silencing other types of error.
See WoltLab/WCF#4476 and WoltLab/WCF#4483 for details.
"},{"location":"migration/wsc54/php/#package-installation-plugins","title":"Package Installation Plugins","text":""},{"location":"migration/wsc54/php/#database","title":"Database","text":"WoltLab Suite 5.5 changes the factory classes for common configurations of database columns within the PHP-based DDL API to contain a private constructor, preventing object creation.
This change affects the following classes:
DefaultFalseBooleanDatabaseTableColumn
DefaultTrueBooleanDatabaseTableColumn
NotNullInt10DatabaseTableColumn
NotNullVarchar191DatabaseTableColumn
NotNullVarchar255DatabaseTableColumn
ObjectIdDatabaseTableColumn
DatabaseTablePrimaryIndex
The static create()
method never returned an object of the factory class, but instead in object of the base type (e.g. IntDatabaseTableColumn
for NotNullInt10DatabaseTableColumn
). Constructing an object of these factory classes is considered a bug, as the class name implies a specific column configuration, that might or might not hold if the object is modified afterwards.
See WoltLab/WCF#4564 for details.
WoltLab Suite 5.5 adds the IDefaultValueDatabaseTableColumn
interface which is used to check whether specifying a default value is legal. For backwards compatibility this interface is implemented by AbstractDatabaseTableColumn
. You should explicitly add this interface to custom table column type classes to avoid breakage if the interface is removed from AbstractDatabaseTableColumn
in a future version. Likewise you should explicitly check for the interface before attempting to access the methods related to the default value of a column.
See WoltLab/WCF#4733 for details.
"},{"location":"migration/wsc54/php/#file-deletion","title":"File Deletion","text":"Three new package installation plugins have been added to delete ACP templates with acpTemplateDelete, files with fileDelete, and templates with templateDelete.
"},{"location":"migration/wsc54/php/#language","title":"Language","text":"WoltLab/WCF#4261 has added support for deleting existing phrases with the language
package installation plugin.
The current structure of the language XML files
language/en.xml<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<language xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/5.4/language.xsd\" languagecode=\"en\" languagename=\"English\" countrycode=\"gb\">\n<category name=\"wcf.foo\">\n<item name=\"wcf.foo.bar\"><![CDATA[Bar]]></item>\n</category>\n</language>\n
is deprecated and should be replaced with the new structure with an explicit <import>
element like in the other package installation plugins:
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<language xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/5.4/language.xsd\" languagecode=\"en\" languagename=\"English\" countrycode=\"gb\">\n<import>\n<category name=\"wcf.foo\">\n<item name=\"wcf.foo.bar\"><![CDATA[Bar]]></item>\n</category>\n</import>\n</language>\n
Additionally, to now also support deleting phrases with this package installation plugin, support for a <delete>
element has been added:
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<language xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/5.4/language.xsd\" languagecode=\"en\" languagename=\"English\" countrycode=\"gb\">\n<import>\n<category name=\"wcf.foo\">\n<item name=\"wcf.foo.bar\"><![CDATA[Bar]]></item>\n</category>\n</import>\n<delete>\n<item name=\"wcf.foo.barrr\"/>\n</delete>\n</language>\n
Note that when deleting phrases, the category does not have to be specified because phrase identifiers are unique globally.
Mixing the old structure and the new structure is not supported and will result in an error message during the import!
"},{"location":"migration/wsc54/php/#board-and-thread-subscriptions","title":"Board and Thread Subscriptions","text":"WoltLab Suite Forum 5.5 updates the subscription logic for boards and threads to properly support the ignoring of threads. See the dedicated migration guide for details.
"},{"location":"migration/wsc54/php/#miscellaneous-changes","title":"Miscellaneous Changes","text":""},{"location":"migration/wsc54/php/#view-counters","title":"View Counters","text":"With WoltLab Suite 5.5 it is expected that view/download counters do not increase for disabled content.
See WoltLab/WCF#4374 for details.
"},{"location":"migration/wsc54/php/#form-builder","title":"Form Builder","text":"ValueIntervalFormFieldDependency
ColorFormField
MultipleBoardSelectionFormField
Content interactions buttons are a new way to display action buttons at the top of the page. They are intended to replace the icons in pageNavigationIcons
for better accessibility while reducing the amount of buttons in contentHeaderNavigation
.
As a rule of thumb, there should be at most only one button in contentHeaderNavigation
(primary action on this page) and three buttons in contentInteractionButtons
(important actions on this page). Use contentInteractionDropdownItems
for all other buttons.
The template contentInteraction
is included in the header and the corresponding placeholders are thus available on every page.
See WoltLab/WCF#4315 for details.
"},{"location":"migration/wsc54/templates/#phrase-modifier","title":"Phrase Modifier","text":"The |language
modifier was added to allow the piping of the phrase through other functions. This has some unwanted side effects when used with plain strings that should not support variable interpolation. Another difference to {lang}
is the evaluation on runtime rather than at compile time, allowing the phrase to be taken from a variable instead.
We introduces the new modifier |phrase
as a thin wrapper around \\wcf\\system::WCF::getLanguage()->get()
. Use |phrase
instead of |language
unless you want to explicitly allow template scripting on a variable's output.
See WoltLab/WCF#4657 for details.
"},{"location":"migration/wsc55/deprecations_removals/","title":"Migrating from WoltLab Suite 5.5 - Deprecations and Removals","text":"With version 6.0, we have deprecated certain components and removed several other components that have been deprecated for many years.
"},{"location":"migration/wsc55/deprecations_removals/#deprecations","title":"Deprecations","text":""},{"location":"migration/wsc55/deprecations_removals/#php","title":"PHP","text":""},{"location":"migration/wsc55/deprecations_removals/#classes","title":"Classes","text":"wcf\\action\\AbstractDialogAction
(WoltLab/WCF#4947)wcf\\SensitiveArgument
(WoltLab/WCF#4802)wcf\\system\\cli\\command\\IArgumentedCLICommand
(WoltLab/WCF#5185)wcf\\system\\template\\plugin\\DateModifierTemplatePlugin
(WoltLab/WCF#5459)wcf\\system\\template\\plugin\\TimeModifierTemplatePlugin
(WoltLab/WCF#5459)wcf\\system\\template\\plugin\\PlainTimeModifierTemplatePlugin
(WoltLab/WCF#5459)wcf\\util\\CronjobUtil
(WoltLab/WCF#4923)wcf\\data\\cronjob\\CronjobAction::executeCronjobs()
(WoltLab/WCF#5171)wcf\\data\\package\\update\\server\\PackageUpdateServer::attemptSecureConnection()
(WoltLab/WCF#4790)wcf\\data\\package\\update\\server\\PackageUpdateServer::isValidServerURL()
(WoltLab/WCF#4790)wcf\\data\\page\\Page::setAsLandingPage()
(WoltLab/WCF#4842)wcf\\data\\style\\Style::getRelativeFavicon()
(WoltLab/WCF#5201)wcf\\system\\cli\\command\\CLICommandHandler::getCommands()
(WoltLab/WCF#5185)wcf\\system\\io\\RemoteFile::disableSSL()
(WoltLab/WCF#4790)wcf\\system\\io\\RemoteFile::supportsSSL()
(WoltLab/WCF#4790)wcf\\system\\request\\RequestHandler::inRescueMode()
(WoltLab/WCF#4831)wcf\\system\\session\\SessionHandler::getLanguageIDs()
(WoltLab/WCF#4839)wcf\\system\\user\\multifactor\\webauthn\\Challenge::getOptionsAsJson()
wcf\\system\\WCF::getActivePath()
(WoltLab/WCF#4827)wcf\\system\\WCF::getFavicon()
(WoltLab/WCF#4785)wcf\\system\\WCF::useDesktopNotifications()
(WoltLab/WCF#4785)wcf\\util\\CryptoUtil::validateSignedString()
(WoltLab/WCF#5083)wcf\\util\\Diff::__construct()
(WoltLab/WCF#4918)wcf\\util\\Diff::__toString()
(WoltLab/WCF#4918)wcf\\util\\Diff::getLCS()
(WoltLab/WCF#4918)wcf\\util\\Diff::getRawDiff()
(WoltLab/WCF#4918)wcf\\util\\Diff::getUnixDiff()
(WoltLab/WCF#4918)wcf\\util\\StringUtil::convertEncoding()
(WoltLab/WCF#4800)wcf\\system\\condition\\UserAvatarCondition::GRAVATAR
(WoltLab/WCF#4929)WCF.Comment
(WoltLab/WCF#5210)WCF.Location
(WoltLab/WCF#4972)WCF.Message.Share.Content
(WoltLab/WCF/commit/624b9db73daf8030aa1c3e49d4ffc785760a283f)WCF.User.ObjectWatch.Subscribe
(WoltLab/WCF#4962)WCF.User.List
(WoltLab/WCF#5039)WoltLabSuite/Core/Controller/Map/Route/Planner
(WoltLab/WCF#4972)WoltLabSuite/Core/NumberUtil
(WoltLab/WCF#5071)WoltLabSuite/Core/Ui/User/List
(WoltLab/WCF#5039)__commentJavaScript
(WoltLab/WCF#5210)commentListAddComment
(WoltLab/WCF#5210)calendar\\data\\CALENDARDatabaseObject
calendar\\system\\image\\EventDataHandler
gallery\\data\\GalleryDatabaseObject
gallery\\system\\image\\ImageDataHandler
wbb\\system\\user\\object\\watch\\BoardUserObjectWatch
and the corresponding object typewbb\\system\\user\\object\\watch\\ThreadUserObjectWatch
and the corresponding object typewcf\\acp\\form\\ApplicationEditForm
(WoltLab/WCF#4785)wcf\\action\\GravatarDownloadAction
(WoltLab/WCF#4929)wcf\\data\\user\\avatar\\Gravatar
(WoltLab/WCF#4929)wcf\\system\\bbcode\\highlighter\\*Highlighter
(WoltLab/WCF#4926)wcf\\system\\bbcode\\highlighter\\Highlighter
(WoltLab/WCF#4926)wcf\\system\\cache\\source\\MemcachedCacheSource
(WoltLab/WCF#4928)wcf\\system\\cli\\command\\CLICommandNameCompleter
(WoltLab/WCF#5185)wcf\\system\\cli\\command\\CommandsCLICommand
(WoltLab/WCF#5185)wcf\\system\\cli\\command\\CronjobCLICommand
(WoltLab/WCF#5171)wcf\\system\\cli\\command\\HelpCLICommand
(WoltLab/WCF#5185)wcf\\system\\cli\\command\\PackageCLICommand
(WoltLab/WCF#4946)wcf\\system\\cli\\DatabaseCLICommandHistory
(WoltLab/WCF#5058)wcf\\system\\database\\table\\column/\\UnsupportedDefaultValue
(WoltLab/WCF#5012)wcf\\system\\exception\\ILoggingAwareException
(and associated functionality) (WoltLab/WCF#5086)wcf\\system\\mail\\Mail
(WoltLab/WCF#4941)wcf\\system\\option\\DesktopNotificationApplicationSelectOptionType
(WoltLab/WCF#4785)wcf\\system\\search\\elasticsearch\\ElasticsearchException
$forceHTTP
parameter of wcf\\data\\package\\update\\server\\PackageUpdateServer::getListURL()
(WoltLab/WCF#4790)$forceHTTP
parameter of wcf\\system\\package\\PackageUpdateDispatcher::getPackageUpdateXML()
(WoltLab/WCF#4790)wcf\\data\\bbcode\\BBCodeCache::getHighlighters()
(WoltLab/WCF#4926)wcf\\data\\conversation\\ConversationAction::getMixedConversationList()
(WoltLab/com.woltlab.wcf.conversation#176)wcf\\data\\moderation\\queue\\ModerationQueueAction::getOutstandingQueues()
(WoltLab/WCF#4944)wcf\\data\\package\\installation\\queue\\PackageInstallationQueueAction::prepareQueue()
(WoltLab/WCF#4997)wcf\\data\\user\\avatar\\UserAvatarAction::enforceDimensions()
(WoltLab/WCF#5007)wcf\\data\\user\\avatar\\UserAvatarAction::fetchRemoteAvatar()
(WoltLab/WCF#5007)wcf\\data\\user\\notification\\UserNotificationAction::getOustandingNotifications()
(WoltLab/WCF#4944)wcf\\data\\user\\UserRegistrationAction::validatePassword()
(WoltLab/WCF#5244)wcf\\system\\bbcode\\BBCodeParser::getRemoveLinks()
(WoltLab/WCF#4986)wcf\\system\\bbcode\\HtmlBBCodeParser::setRemoveLinks()
(WoltLab/WCF#4986)wcf\\system\\html\\output\\node\\AbstractHtmlOutputNode::setRemoveLinks()
(WoltLab/WCF#4986)wcf\\system\\package\\PackageArchive::downloadArchive()
(WoltLab/WCF#5006)wcf\\system\\package\\PackageArchive::filterUpdateInstructions()
(WoltLab/WCF#5129)wcf\\system\\package\\PackageArchive::getAllExistingRequirements()
(WoltLab/WCF#5125)wcf\\system\\package\\PackageArchive::getInstructions()
(WoltLab/WCF#5120)wcf\\system\\package\\PackageArchive::getUpdateInstructions()
(WoltLab/WCF#5129)wcf\\system\\package\\PackageArchive::isValidInstall()
(WoltLab/WCF#5125)wcf\\system\\package\\PackageArchive::isValidUpdate()
(WoltLab/WCF#5126)wcf\\system\\package\\PackageArchive::setPackage()
(WoltLab/WCF#5120)wcf\\system\\package\\PackageArchive::unzipPackageArchive()
(WoltLab/WCF#4949)wcf\\system\\package\\PackageInstallationDispatcher::checkPackageInstallationQueue()
(WoltLab/WCF#4947)wcf\\system\\package\\PackageInstallationDispatcher::completeSetup()
(WoltLab/WCF#4947)wcf\\system\\package\\PackageInstallationDispatcher::convertShorthandByteValue()
(WoltLab/WCF#4949)wcf\\system\\package\\PackageInstallationDispatcher::functionExists()
(WoltLab/WCF#4949)wcf\\system\\package\\PackageInstallationDispatcher::openQueue()
(WoltLab/WCF#4947)wcf\\system\\package\\PackageInstallationDispatcher::validatePHPRequirements()
(WoltLab/WCF#4949)wcf\\system\\package\\PackageInstallationNodeBuilder::insertNode()
(WoltLab/WCF#4997)wcf\\system\\package\\PackageUpdateDispatcher::prepareInstallation()
(WoltLab/WCF#4997)wcf\\system\\request\\Request::execute()
(WoltLab/WCF#4820)wcf\\system\\request\\Request::getPageType()
(WoltLab/WCF#4822)wcf\\system\\request\\Request::getPageType()
(WoltLab/WCF#4822)wcf\\system\\request\\Request::isExecuted()
wcf\\system\\request\\Request::setIsLandingPage()
wcf\\system\\request\\RouteHandler::getDefaultController()
(WoltLab/WCF#4832)wcf\\system\\request\\RouteHandler::loadDefaultControllers()
(WoltLab/WCF#4832)wcf\\system\\search\\AbstractSearchEngine::getFulltextMinimumWordLength()
(WoltLab/WCF#4933)wcf\\system\\search\\AbstractSearchEngine::parseSearchQuery()
(WoltLab/WCF#4933)wcf\\system\\search\\elasticsearch\\ElasticsearchSearchEngine::getFulltextMinimumWordLength()
wcf\\system\\search\\elasticsearch\\ElasticsearchHandler::query()
wcf\\system\\search\\SearchIndexManager::add()
(WoltLab/WCF#4925)wcf\\system\\search\\SearchIndexManager::update()
(WoltLab/WCF#4925)wcf\\system\\session\\SessionHandler::getStyleID()
(WoltLab/WCF#4837)wcf\\system\\session\\SessionHandler::setStyleID()
(WoltLab/WCF#4837)wcf\\system\\CLIWCF::checkForUpdates()
(WoltLab/WCF#5058)wcf\\system\\WCFACP::checkMasterPassword()
(WoltLab/WCF#4977)wcf\\system\\WCFACP::getFrontendMenu()
(WoltLab/WCF#4812)wcf\\system\\WCFACP::initPackage()
(WoltLab/WCF#4794)wcf\\util\\CryptoUtil::randomBytes()
(WoltLab/WCF#4924)wcf\\util\\CryptoUtil::randomInt()
(WoltLab/WCF#4924)wcf\\util\\CryptoUtil::secureCompare()
(WoltLab/WCF#4924)wcf\\util\\FileUtil::downloadFileFromHttp()
(WoltLab/WCF#4942)wcf\\util\\PasswordUtil::secureCompare()
(WoltLab/WCF#4924)wcf\\util\\PasswordUtil::secureRandomNumber()
(WoltLab/WCF#4924)wcf\\util\\StringUtil::encodeJSON()
(WoltLab/WCF#5073)wcf\\util\\StyleUtil::updateStyleFile()
(WoltLab/WCF#4977)wcf\\util\\UserRegistrationUtil::isSecurePassword()
(WoltLab/WCF#4977)wcf\\acp\\form\\PageAddForm::$isLandingPage
(WoltLab/WCF#4842)wcf\\system\\appliation\\ApplicationHandler::$isMultiDomain
(WoltLab/WCF#4785)wcf\\system\\package\\PackageArchive::$package
(WoltLab/WCF#5129)wcf\\system\\request\\RequestHandler::$inRescueMode
(WoltLab/WCF#4831)wcf\\system\\request\\RouteHandler::$defaultControllers
(WoltLab/WCF#4832)wcf\\system\\template\\TemplateScriptingCompiler::$disabledPHPFunctions
(WoltLab/WCF#4788)wcf\\system\\template\\TemplateScriptingCompiler::$enterpriseFunctions
(WoltLab/WCF#4788)wcf\\system\\WCF::$forceLogout
(WoltLab/WCF#4799)beforeArgumentParsing@wcf\\system\\CLIWCF
(WoltLab/WCF#5058)afterArgumentParsing@wcf\\system\\CLIWCF
(WoltLab/WCF#5058)wcf\\system\\search\\elasticsearch\\ElasticsearchHandler::DELETE
wcf\\system\\search\\elasticsearch\\ElasticsearchHandler::GET
wcf\\system\\search\\elasticsearch\\ElasticsearchHandler::POST
wcf\\system\\search\\elasticsearch\\ElasticsearchHandler::PUT
PACKAGE_NAME
(WoltLab/WCF#5006)PACKAGE_VERSION
(WoltLab/WCF#5006)SECURITY_TOKEN_INPUT_TAG
(WoltLab/WCF#4934)SECURITY_TOKEN
(WoltLab/WCF#4934)WSC_API_VERSION
(WoltLab/WCF#4943)escapeString
helper (WoltLab/WCF#5085)CACHE_SOURCE_MEMCACHED_HOST
(WoltLab/WCF#4928)DESKTOP_NOTIFICATION_PACKAGE_ID
(WoltLab/WCF#4785)GRAVATAR_DEFAULT_TYPE
(WoltLab/WCF#4929)HTTP_SEND_X_FRAME_OPTIONS
(WoltLab/WCF#4786)MODULE_GRAVATAR
(WoltLab/WCF#4929)scss.inc.php
compatibility include. (WoltLab/WCF#4932)config.inc.php
in app directories. (WoltLab/WCF#5006)Blog.Blog.Archive
Blog.Category.MarkAllAsRead
Blog.Entry.Delete
Blog.Entry.Preview
Blog.Entry.QuoteHandler
Calendar.Category.MarkAllAsRead
(WoltLab/com.woltlab.calendar#169)Calendar.Event.Coordinates
Calendar.Event.Date.FullDay
(WoltLab/com.woltlab.calendar#171)Calendar.Event.Date.Participation.RemoveParticipant
Calendar.Event.Preview
Calendar.Event.QuoteHandler
Calendar.Event.Share
Calendar.Event.TabMenu
Calendar.Event.Thread.ShowParticipants
Calendar.Map
Calendar.UI.Calendar
Calendar/Ui/Event/Date/Cancel.js
Calendar.Export.iCal
Filebase.Category.MarkAllAsRead
Filebase.File.MarkAsRead
Filebase.File.Preview
Filebase.File.Share
flexibleArea.js
(WoltLab/WCF#4945)Gallery.Album.Share
Gallery.Category.MarkAllAsRead
Gallery.Image.Delete
Gallery.Image.Share
Gallery.Map.LargeMap
Gallery.Map.InfoWindowImageListDialog
jQuery.browser.smartphone
(WoltLab/WCF#4945)Prism.wscSplitIntoLines
(WoltLab/WCF#4940)SID_ARG_2ND
(WoltLab/WCF#4998)WBB.Board.Collapsible
WBB.Board.IgnoreBoards
WBB.Post.IPAddressHandler
WBB.Post.Preview
WBB.Post.QuoteHandler
WBB.Thread.LastPageHandler
WBB.Thread.SimilarThreads
WBB.Thread.UpdateHandler.Thread
WBB.Thread.WatchedThreadList
WCF.Action.Scroll
(WoltLab/WCF#4945)WCF.Conversation.MarkAllAsRead
WCF.Conversation.MarkAsRead
WCF.Conversation.Message.QuoteHandler
WCF.Conversation.Preview
WCF.Conversation.RemoveParticipant
WCF.Date.Picker
(WoltLab/WCF#4945)WCF.Date.Util
(WoltLab/WCF#4945)WCF.Dropdown.Interactive.Handler
(WoltLab/WCF#4944)WCF.Dropdown.Interactive.Instance
(WoltLab/WCF#4944)WCF.Message.Share.Page
(WoltLab/WCF#4945)WCF.Message.Smilies
(WoltLab/WCF#4945)WCF.ModeratedUserGroup.AddMembers
WCF.Moderation.Queue.MarkAllAsRead
WCF.Moderation.Queue.MarkAsRead
WCF.Search.Message.KeywordList
(WoltLab/WCF#4945)WCF.System.FlexibleMenu
(WoltLab/WCF#4945)WCF.System.Fullscreen
(WoltLab/WCF#4945)WCF.System.PageNavigation
(WoltLab/WCF#4945)WCF.Template
(WoltLab/WCF#5070)WCF.ToggleOptions
(WoltLab/WCF#4945)WCF.User.Panel.Abstract
(WoltLab/WCF#4944)WCF.User.Registration.Validation.Password
(WoltLab/WCF#5244)window.shuffle()
(WoltLab/WCF#4945)WSC_API_VERSION
(WoltLab/WCF#4943)WCF.Infraction.Warning.ShowDetails
WoltLabSuite/Core/Ui/Comment/Add
(WoltLab/WCF#5210)WoltLabSuite/Core/Ui/Comment/Edit
(WoltLab/WCF#5210)WoltLabSuite/Core/Ui/Response/Comment/Add
(WoltLab/WCF#5210)WoltLabSuite/Core/Ui/Response/Comment/Edit
(WoltLab/WCF#5210)wcf1_cli_history
(WoltLab/WCF#5058)wcf1_package_compatibility
(WoltLab/WCF#4992)wcf1_package_update_compatibility
(WoltLab/WCF#5005)wcf1_package_update_optional
(WoltLab/WCF#5005)wcf1_user_notification_to_user
(WoltLab/WCF#5005)wcf1_user.enableGravatar
(WoltLab/WCF#4929)wcf1_user.gravatarFileExtension
(WoltLab/WCF#4929)conversationListUserPanel
(WoltLab/com.woltlab.wcf.conversation#176)moderationQueueList
(WoltLab/WCF#4944)notificationListUserPanel
(WoltLab/WCF#4944){fetch}
(WoltLab/WCF#4892)|encodeJSON
(WoltLab/WCF#5073)headInclude::javascriptInclude
(WoltLab/WCF#4801)headInclude::javascriptInit
(WoltLab/WCF#4801)headInclude::javascriptLanguageImport
(WoltLab/WCF#4801)$__sessionKeepAlive
(WoltLab/WCF#5055)$__wcfVersion
(WoltLab/WCF#4927)$tpl.cookie
(WoltLab/WCF@7cfd5578ede22e)$tpl.env
(WoltLab/WCF@7cfd5578ede22e)$tpl.get
(WoltLab/WCF@7cfd5578ede22e)$tpl.now
(WoltLab/WCF@7cfd5578ede22e)$tpl.post
(WoltLab/WCF@7cfd5578ede22e)$tpl.server
(WoltLab/WCF@7cfd5578ede22e)1
WCF_N
in WCFSetup. (WoltLab/WCF#4791)$_REQUEST['styleID']
) is removed. (WoltLab/WCF#4533)http://
scheme for package servers is no longer supported. The use of https://
is enforced. (WoltLab/WCF#4790)In the past dialogs have been used for all kinds of purposes, for example, to provide more details. Dialogs make it incredibly easy to add extra information or forms to an existing page without giving much thought: A simple button is all that it takes to show a dialog.
This has lead to an abundance of dialogs that have been used in a lot of places where dialogs are not the right choice, something we are guilty of in a lot of cases. A lot of research has gone into the accessibility of dialogs and the general recommendations towards their usage and the behavior.
One big issue of dialogs have been their inconsistent appearance in terms of form buttons and their (lack of) keyboard support for input fields. WoltLab Suite 6.0 provides a completely redesigned API that strives to make the process of creating dialogs much easier and features a consistent keyboard support out of the box.
"},{"location":"migration/wsc55/dialogs/#conceptual-changes","title":"Conceptual Changes","text":"Dialogs are a powerful tool, but as will all things, it is easy to go overboard and eventually one starts using dialogs out of convenience rather than necessity. In general dialogs should only be used when you need to provide information out of flow, for example, for urgent error messages.
A common misuse that we are guilty of aswell is the use of dialogs to present content. Dialogs completely interrupt whatever the user is doing and can sometimes even hide contextual relevant content on the page. It is best to embed information into regular pages and either use deep links to refer to them or make use of flyovers to present the content in place.
Another important change is the handling of form inputs. Previously it was required to manually craft the form submit buttons, handle button clicks and implement a proper validation. The new API provides the \u201cprompt\u201d type which implements all this for you and exposing JavaScript events to validate, submit and cancel the dialog.
Last but not least there have been updates to the visual appearance of dialogs. The new dialogs mimic the appearance on modern desktop operating systems as well as smartphones by aligning the buttons to the bottom right. In addition the order of buttons has been changed to always show the primary button on the rightmost position. These changes were made in an effort to make it easier for users to adopt an already well known control concept and to improve the overall accessibility.
"},{"location":"migration/wsc55/dialogs/#migrating-to-the-dialogs-of-woltlab-suite-60","title":"Migrating to the Dialogs of WoltLab Suite 6.0","text":"The old dialogs are still fully supported and have remained unchanged apart from a visual update to bring them in line with the new dialogs. We do recommend that you use the new dialog API exclusively for new components and migrate the existing dialogs whenever you see it fit, we\u2019ll continue to support the legacy dialog API for the entire 6.x series at minimum.
"},{"location":"migration/wsc55/dialogs/#comparison-of-the-apis","title":"Comparison of the APIs","text":"The legacy API relied on implicit callbacks to initialize dialogs and to handle the entire lifecycle. The _dialogSetup()
method was complex, offered subpar auto completition support and generally became very bloated when utilizing the events.
source
","text":"The source of a dialog is provided directly through the fluent API of dialogFactory()
which provides methods to spawn dialogs using elements, HTML strings or completely empty.
The major change is the removal of the AJAX support as the content source, you should use dboAction()
instead and then create the dialog.
options.onSetup(content: HTMLElement)
","text":"You can now access the content element directly, because everything happens in-place.
const dialog = dialogFactory().fromHtml(\"<p>Hello World!</p>\").asAlert();\n\n// Do something with `dialog.content` or bind event listeners.\n\ndialog.show(\"My Title\");\n
"},{"location":"migration/wsc55/dialogs/#optionsonshowcontent-htmlelement","title":"options.onShow(content: HTMLElement)
","text":"There is no equivalent in the new API, because you can simply store a reference to your dialog and access the .content
property at any time.
_dialogSubmit()
","text":"This is the most awkward feature of the legacy dialog API: Poorly documented and cumbersome to use. Implementing it required a dedicated form submit button and the keyboard interaction required the data-dialog-submit-on-enter=\"true\"
attribute to be set on all input elements that should submit the form through Enter
.
The new dialog API takes advantage of the form[method=\"dialog\"]
functionality which behaves similar to regular forms but will signal the form submit to the surrounding dialog. As a developer you only need to listen for the validate
and the primary
event to implement your logic.
const dialog = dialogFactory()\n.fromId(\"myComplexDialogWithFormInputs\")\n.asPrompt();\n\ndialog.addEventListener(\"validate\", (event) => {\n// Validate the form inputs in `dialog.content`.\n\nif (validationHasFailed) {\nevent.preventDefault();\n}\n});\n\ndialog.addEventListener(\"primary\", () => {\n// The dialog has been successfully validated\n// and was submitted by the user.\n// You can access form inputs through `dialog.content`.\n});\n
"},{"location":"migration/wsc55/dialogs/#changes-to-the-template","title":"Changes to the Template","text":"Both the old and the new API support the use of existing elements to create dialogs. It is recommended to use <template>
in this case which will never be rendered and has a well-defined role.
<!-- Previous -->\n<div id=\"myDialog\" style=\"display: none\">\n <!-- \u2026 -->\n</div>\n\n<!-- Use instead -->\n<template id=\"myDialog\">\n <!-- \u2026 -->\n</template>\n
Dialogs have historically been using the same HTML markup that regular pages do, including but not limited to the use of sections. For dialogs that use only a single container it is recommend to drop the section entirely.
If your dialog contain multiple sections it is recommended to skip the title of the first section.
"},{"location":"migration/wsc55/dialogs/#formsubmit","title":".formSubmit
","text":"Form controls are no longer defined through the template, instead those are implicitly generated by the new dialog API. Please see the explanation on the four different dialog types to learn more about form controls.
"},{"location":"migration/wsc55/dialogs/#migration-by-example","title":"Migration by Example","text":"There is no universal pattern that fits every case, because dialogs vary greatly between each other and the required functionality causes the actual implementation to be different.
As an example we have migrated the dialog to create a new box to use the new API. It uses a prompt dialog that automagically adds form controls and fires an event once the user submits the dialog. You can find the commit 3a9210f229f6a2cf5e800c2c4536c9774d02fc86 on GitHub.
The changes can be summed up as follows:
<template>
element for the dialog content..formSubmit
section from the HTML..section
..content
property and make use of an event listener to handle the user interaction._dialogSetup() {\nreturn {\n// \u2026\nid: \"myDialog\",\n};\n}\n
New API
dialogFactory().fromId(\"myDialog\").withoutControls();\n
"},{"location":"migration/wsc55/dialogs/#using-source-to-provide-the-dialog-html","title":"Using source
to Provide the Dialog HTML","text":"_dialogSetup() {\nreturn {\n// \u2026\nsource: \"<p>Hello World</p>\",\n};\n}\n
New API
dialogFactory().fromHtml(\"<p>Hello World</p>\").withoutControls();\n
"},{"location":"migration/wsc55/dialogs/#updating-the-html-when-the-dialog-is-shown","title":"Updating the HTML When the Dialog Is Shown","text":"_dialogSetup() {\nreturn {\n// \u2026\noptions: {\n// \u2026\nonShow: (content) => {\ncontent.querySelector(\"p\")!.textContent = \"Hello World\";\n},\n},\n};\n}\n
New API
const dialog = dialogFactory().fromHtml(\"<p></p>\").withoutControls();\n\n// Somewhere later in the code\n\ndialog.content.querySelector(\"p\")!.textContent = \"Hello World\";\n\ndialog.show(\"Some Title\");\n
"},{"location":"migration/wsc55/dialogs/#specifying-the-title-of-a-dialog","title":"Specifying the Title of a Dialog","text":"The title was previously fixed in the _dialogSetup()
method and could only be changed on runtime using the setTitle()
method.
_dialogSetup() {\nreturn {\n// \u2026\noptions: {\n// \u2026\ntitle: \"Some Title\",\n},\n};\n}\n
The title is now provided whenever the dialog should be opened, permitting changes in place.
const dialog = dialogFactory().fromHtml(\"<p></p>\").withoutControls();\n\n// Somewhere later in the code\n\ndialog.show(\"Some Title\");\n
"},{"location":"migration/wsc55/icons/","title":"Migrating from WoltLab Suite 5.5 - Icons","text":"WoltLab Suite 6.0 introduces Font Awesome 6.0 which is a major upgrade over the previously used Font Awesome 4.7 icon library. The new version features not only many hundreds of new icons but also focused a lot more on icon consistency, namely the proper alignment of icons within the grid.
The previous implementation of Font Awesome 4 included shims for Font Awesome 3 that was used before, the most notable one being the .icon
notation instead of .fa
as seen in Font Awesome 4 and later. In addition, Font Awesome 5 introduced the concept of different font weights to separate icons which was further extended in Font Awesome 6.
In WoltLab Suite 6.0 we have made the decision to make a clean cut and drop support for the Font Awesome 3 shim as well as a Font Awesome 4 shim in order to dramatically reduce the CSS size and to clean up the implementation. Brand icons had been moved to a separate font in Font Awesome 5, but since more and more fonts are being added we have stepped back from relying on that font. We have instead made the decision to embed brand icons using inline SVGs which are much more efficient when you only need a handful of brand icons instead of loading a 100kB+ font just for a few icons.
"},{"location":"migration/wsc55/icons/#misuse-of-icons-as-buttons","title":"Misuse of Icons as Buttons","text":"One pattern that could be found every here and then was the use of icons as buttons. Using icons in buttons is fine, as long as there is a readable title and that they are properly marked as buttons.
A common misuse looks like this:
<span class=\"icon icon16 fa-times pointer jsMyDeleteButton\" data-some-object-id=\"123\"></span>\n
This example has a few problems, for starters it is not marked as a button which would require both role=\"button\"
and tabindex=\"0\"
to be recognized as such. Additionally there is no title which leaves users clueless about what the option does, especially visually impaired users are possibly unable to identify the icon.
WoltLab Suite 6.0 addresses this issue by removing all default styling from <button>
elements, making them the ideal choice for button type elements.
<button class=\"jsMyDeleteButton\" data-some-object-id=\"123\" title=\"descriptive title here\">{icon name='xmark'}</button>\n
The icon will appear just as before, but the button is now properly accessible.
"},{"location":"migration/wsc55/icons/#using-css-classes-with-icons","title":"Using CSS Classes With Icons","text":"It is strongly discouraged to apply CSS classes to icons themselves. Icons inherit the text color from the surrounding context which removes the need to manually apply the color.
If you ever need to alter the icons, such as applying a special color or transformation, you should wrap the icon in an element like <span>
and apply the changes to that element instead.
The new template function {icon}
was added to take care of generating the HTML code for icons, including the embedded SVGs for brand icons. Icons in HTML should not be constructed using the actual HTML element, but instead always use {icon}
.
<button class=\"button\">{icon name='bell'} I\u2019m a button with a bell icon</button>\n
Unless specified the icon will attempt to use a non-solid variant of the icon if it is available. You can explicitly request a solid version of the icon by specifying it with type='solid'
.
<button class=\"button\">{icon name='bell' type='solid'} I\u2019m a button with a solid bell icon</button>\n
Icons will implicitly assume the size 16
, but you can explicitly request a different icon size using the size
attribute:
{icon size=24 name='bell' type='solid'}\n
"},{"location":"migration/wsc55/icons/#brand-icons","title":"Brand Icons","text":"The syntax for brand icons is very similar, but you are required to specifiy parameter type='brand'
to access them.
<button class=\"button\">{icon size=16 name='facebook' type='brand'} Share on Facebook</button>\n
"},{"location":"migration/wsc55/icons/#using-icons-in-typescriptjavascript","title":"Using Icons in TypeScript/JavaScript","text":"Buttons can be dynamically created using the native document.createElement()
using the new fa-icon
element.
const icon = document.createElement(\"fa-icon\");\nicon.setIcon(\"bell\", true);\n\n// This is the same as the following call in templates:\n// {icon name='bell' type='solid'}\n
You can request a size other than the default value of 16
through the size
property:
const icon = document.createElement(\"fa-icon\");\nicon.size = 24;\nicon.setIcon(\"bell\", true);\n
"},{"location":"migration/wsc55/icons/#creating-icons-in-html-strings","title":"Creating Icons in HTML Strings","text":"You can embed icons in HTML strings by constructing the fa-icon
element yourself.
element.innerHTML = '<fa-icon name=\"bell\" solid></fa-icon>';\n
"},{"location":"migration/wsc55/icons/#changing-an-icon-on-runtime","title":"Changing an Icon on Runtime","text":"You can alter the size by changing the size
property which accepts the numbers 16
, 24
, 32
, 48
, 64
, 96
, 128
and 144
. The icon itself should be always set through the setIcon(name: string, isSolid: boolean)
function which validates the values and rejects unknown icons.
We provide a helper script that eases the transition by replacing icons in templates, JavaScript and TypeScript files. The script itself is very defensive and only replaces obvious matches, it will leave icons with additional CSS classes or attributes as-is and will need to be manually adjusted.
"},{"location":"migration/wsc55/icons/#replacing-icons-with-the-helper-script","title":"Replacing Icons With the Helper Script","text":"The helper script is part of WoltLab Suite Core and can be found in the repository at extra/migrate-fa-v4.php
. The script must be executed from CLI and requires PHP 8.1.
$> php extra/migrate-fa-v4.php /path/to/the/target/directory/\n
The target directory will be searched recursively for files with the extension tpl
, js
and ts
.
The helper script above is limited to only perform replacements for occurrences that it can identify without doubt. It will not replace occurrences that are formatted differently and/or make use of additional attributes, including the icon misuse as clickable elements.
<li>\n <span class=\"icon icon16 fa-times pointer jsButtonFoo jsTooltip\" title=\"{lang}foo.bar.baz{/lang}\">\n</li>\n
This can be replaced using a proper button element which also provides proper accessibility for free.
<li>\n <button class=\"jsButtonFoo jsTooltip\" title=\"{lang}foo.bar.baz{/lang}\">\n{icon name='xmark'}\n </button>\n</li>\n
"},{"location":"migration/wsc55/icons/#migrating-admin-configurable-icons","title":"Migrating Admin-Configurable Icons","text":"If admin-configurable icon names (e.g. created by IconFormField
) are stored within the database, these need to be migrated with an upgrade script.
The FontAwesomeIcon::mapVersion4()
maps a Font Awesome 4 icon name to a string that may be passed to FontAwesomeIcon::fromString()
. It will throw an UnknownIcon
exception if the icon cannot be mapped. It is important to catch and handle this exception to ensure a reliable upgrade even when facing malformed data.
See WoltLab/WCF#5288 for an example script.
"},{"location":"migration/wsc55/javascript/","title":"Migrating from WoltLab Suite 5.5 - TypeScript and JavaScript","text":""},{"location":"migration/wsc55/javascript/#minimum-requirements","title":"Minimum requirements","text":"The ECMAScript target version has been increased to ES2022 from es2017.
"},{"location":"migration/wsc55/javascript/#subscribe-button-wcfuserobjectwatchsubscribe","title":"Subscribe Button (WCF.User.ObjectWatch.Subscribe)","text":"We have replaced the old jQuery-based WCF.User.ObjectWatch.Subscribe
with a more modern replacement WoltLabSuite/Core/Ui/User/ObjectWatch
.
The new implementation comes with a ready-to-use template (__userObjectWatchButton
) for use within contentInteractionButtons
:
{include file='__userObjectWatchButton' isSubscribed=$isSubscribed objectType='foo.bar' objectID=$id}\n
See WoltLab/WCF#4962 for details.
"},{"location":"migration/wsc55/javascript/#support-for-legacy-inheritance","title":"Support for Legacy Inheritance","text":"The migration from JavaScript to TypeScript was a breaking change because the previous prototype based inheritance was incompatible with ES6 classes. Core.enableLegacyInheritance()
was added in an effort to emulate the previous behavior to aid in the migration process.
This workaround was unstable at best and was designed as a temporary solution only. WoltLab/WCF#5041 removed the legacy inheritance, requiring all depending implementations to migrate to ES6 classes.
"},{"location":"migration/wsc55/libraries/","title":"Migrating from WoltLab Suite 5.5 - Third Party Libraries","text":""},{"location":"migration/wsc55/libraries/#symfony-php-polyfills","title":"Symfony PHP Polyfills","text":"The Symfony Polyfills for 7.3, 7.4, and 8.0 were removed, as the minimum PHP version was increased to PHP 8.1. The Polyfill for PHP 8.2 was added.
Refer to the documentation within the symfony/polyfill repository for details.
"},{"location":"migration/wsc55/libraries/#idna-handling","title":"IDNA Handling","text":"The true/punycode and pear/net_idna2 dependencies were removed, because of a lack of upstream maintenance and because the intl
extension is now required. Instead the idn_to_ascii
function should be used.
Diactoros was updated from version 2.4 to 2.25.
"},{"location":"migration/wsc55/libraries/#input-validation","title":"Input Validation","text":"WoltLab Suite 6.0 ships with cuyz/valinor 1.4 as a reliable solution to validate untrusted external input values.
Refer to the documentation within the CuyZ/Valinor repository for details.
"},{"location":"migration/wsc55/libraries/#diff","title":"Diff","text":"WoltLab Suite 6.0 ships with sebastian/diff as a replacement for wcf\\util\\Diff
. The wcf\\util\\Diff::rawDiffFromSebastianDiff()
method was added as a compatibility helper to transform sebastian/diff's output format into Diff's output format.
Refer to the documentation within the sebastianbergmann/diff repository for details on how to use the library.
See WoltLab/WCF#4918 for examples on how to use the compatibility helper if you need to preserve the output format for the time being.
"},{"location":"migration/wsc55/libraries/#content-negotiation","title":"Content Negotiation","text":"WoltLab Suite 6.0 ships with willdurand/negotiation to perform HTTP content negotiation based on the headers sent within the request. The wcf\\http\\Helper::getPreferredContentType()
method provides a convenience interface to perform content negotiation with regard to the MIME type. It is strongly recommended to make use of this method instead of interacting with the library directly.
In case the API provided by the helper method is insufficient, please refer to the documentation within the willdurand/Negotiation repository for details on how to use the library.
"},{"location":"migration/wsc55/libraries/#cronjobs","title":"Cronjobs","text":"WoltLab Suite 6.0 ships with dragonmantank/cron-expression as a replacement for wcf\\util\\CronjobUtil
.
This library is considered an internal library / implementation detail and not covered by backwards compatibility promises of WoltLab Suite.
"},{"location":"migration/wsc55/libraries/#ico-converter","title":".ico converter","text":"The chrisjean/php-ico dependency was removed, because of a lack of upstream maintenance. As the library was only used for Favicon generation, no replacement is made available. The favicons are now delivered as PNG files.
"},{"location":"migration/wsc55/php/","title":"Migrating from WoltLab Suite 5.5 - PHP","text":""},{"location":"migration/wsc55/php/#minimum-requirements","title":"Minimum requirements","text":"The minimum requirements have been increased to the following:
intl
extensionIt is recommended to make use of the newly introduced features whereever possible. Please refer to the PHP documentation for details.
"},{"location":"migration/wsc55/php/#inheritance","title":"Inheritance","text":""},{"location":"migration/wsc55/php/#parameter-return-property-types","title":"Parameter / Return / Property Types","text":"Parameter, return, and property types have been added to methods of various classes/interfaces. This might cause errors during inheritance, because the types are not compatible with the newly added types in the parent class.
Return types may already be added in package versions for older WoltLab Suite branches to be forward compatible, because return types are covariant.
"},{"location":"migration/wsc55/php/#final","title":"final","text":"The final
modifier was added to several classes that were not usefully set up for inheritance in the first place to make it explicit that inheriting from these classes is unsupported.
Historically the application boot in WCF
\u2019s constructor performed processing based on fundamentally request-specific values, such as the accessed URL, the request body, or cookies. This is problematic, because this makes the boot dependent on the HTTP environment which may not be be available, e.g. when using the CLI interface for maintenance jobs. The latter needs to emulate certain aspects of the HTTP environment for the boot to succeed. Furthermore one of the goals of the introduction of PSR-7/PSR-15-based request processing that was started in WoltLab Suite 5.5 is the removal of implicit global state in favor of explicitly provided values by means of a ServerRequestInterface
and thus to achieve a cleaner architecture.
To achieve a clean separation this type of request-specific logic will incrementally be moved out of the application boot in WCF
\u2019s constructor and into the request processing stack that is launched by RequestHandler
, e.g. by running appropriate PSR-15 middleware.
An example of this type of request-specific logic that was previously happening during application boot is the check that verifies whether a user is banned and denies access otherwise. This check is based on a request-specific value, namely the user\u2019s session which in turn is based on a provided (HTTP) cookie. It is now moved into the CheckUserBan
middleware.
This move implies that custom scripts that include WoltLab Suite Core\u2019s global.php
, without also invoking RequestHandler
will no longer be able to rely on this type of access control having happened and will need to implement it themselves, e.g. by manually running the appropriate middlewares.
Notably the following checks have been moved into a middleware:
The initialization of the session itself and dependent subsystems (e.g. the user object and thus the current language) is still running during application boot for now. However it is planned to also move the session initialization into the middleware in a future version and then providing access to the session by adding an attribute on the ServerRequestInterface
, instead of querying the session via WCF::getSession()
. As such you should begin to stop relying on the session and user outside of RequestHandler
\u2019s middleware stack and should also avoid calling WCF::getUser()
and WCF::getSession()
outside of a controller, instead adding a User
parameter to your methods to allow an appropriate user to be passed from the outside.
An example of a method that implicitly relies on these global values is the VisitTracker's trackObjectVisit()
method. It only takes the object type, object ID and timestamp as the parameter and will determine the userID
by itself. The trackObjectVisitByUserIDs()
method on the other hand does not rely on global values. Instead the relevant user IDs need to be passed explicitly from the controller as parameters, thus making the information the method works with explicit. This also makes the method reusable for use cases where an object should be marked as visited for a user other than the active user, without needing to temporarily switch the active user in the session.
The same is true for \u201cpermission checking\u201d methods on DatabaseObject
s. Instead of having a $myObject->canView()
method that uses WCF::getSession()
or WCF::getUser()
internally, the user should explicitly be passed to the method as a parameter, allowing for permission checks to happen in a different context, for example send sending notification emails.
Likewise event listeners should not access these request-specific values at all, because they are unable to know whether the event was fired based on these request-specific values or whether some programmatic action fired the event for another arbitrary user. Instead they must retrieve the appropriate information from the event data only.
"},{"location":"migration/wsc55/php/#bootstrap-scripts","title":"Bootstrap Scripts","text":"WoltLab Suite 6.0 adds package-specific bootstrap scripts allowing a package to execute logic during the application boot to prepare the environment before the request is passed through the middleware pipeline into the controller in RequestHandler
.
Bootstrap scripts are stored in the lib/bootstrap/
directory of WoltLab Suite Core with the package identifier as the file name. They do not need to be registered explicitly, as one future goal of the bootstrap scripts is reducing the amount of system state that needs to be stored within the database. Instead WoltLab Suite Core will automatically create a bootstrap loader that includes all installed bootstrap scripts as part of the package installation and uninstallation process.
Bootstrap scripts will be loaded and the bootstrap functions will executed based on a topological sorting of all installed packages. A package can rely on all bootstrap scripts of its dependencies being loaded before its own bootstrap script is loaded. It can also rely on all bootstrap functions of its dependencies having executed before its own bootstrap functions is executed. However it cannot rely on any specific loading and execution order of non-dependencies.
As hinted at in the previous paragraph, executing the bootstrap scripts happens in two phases:
include()
d in topological order. The script is expected to return a Closure
that is executed in phase 2.Closure
s will be executed in the same order the bootstrap scripts were loaded.<?php\n\n// Phase (1).\n\nreturn static function (): void {\n // Phase (2).\n};\n
For the vast majority of packages it is expected that the phase (1) bootstrapping is not used, except to return the Closure
. Instead the logic should reside in the Closure
s body that is executed in phase (2).
IEvent
listeners","text":"An example use case for bootstrap scripts with WoltLab Suite 6.0 is registering event listeners for IEvent
-based events that were added with WoltLab Suite 5.5, instead of using the eventListener PIP. Registering event listeners within the bootstrap script allows you to leverage your IDE\u2019s autocompletion for class names and and prevents forgetting the explicit uninstallation of old event listeners during a package upgrade.
<?php\n\nuse wcf\\system\\event\\EventHandler;\nuse wcf\\system\\event\\listener\\ValueDumpListener;\nuse wcf\\system\\foo\\event\\ValueAvailable;\n\nreturn static function (): void {\n EventHandler::getInstance()->register(\n ValueAvailable::class,\n ValueDumpListener::class\n );\n\n EventHandler::getInstance()->register(\n ValueAvailable::class,\n static function (ValueAvailable $event): void {\n // For simple use cases a `Closure` instead of a class name may be used.\n \\var_dump($event->getValue());\n }\n );\n};\n
"},{"location":"migration/wsc55/php/#request-processing","title":"Request Processing","text":"As previously mentioned in the Application Boot section, WoltLab Suite 6.0 improves support for PSR-7/PSR-15-based request processing that was initially announced with WoltLab Suite 5.5.
WoltLab Suite 5.5 added support for returning a PSR-7 ResponseInterface
from a controller and recommended to migrate existing controllers based on AbstractAction
to make use of RedirectResponse
and JsonResponse
instead of using HeaderUtil::redirect()
or manually emitting JSON with appropriate headers. Processing the request values still used PHP\u2019s superglobals (specifically $_GET
and $_POST
).
WoltLab Suite 6.0 adds support for controllers based on PSR-15\u2019s RequestHandlerInterface
, supporting request processing based on a provided PSR-7 ServerRequestInterface
object.
It is recommended to use RequestHandlerInterface
-based controllers whenever an AbstractAction
would previously be used. Furthermore any AJAX-based logic that would previously rely on AJAXProxyAction
combined with a method in an AbstractDatabaseObjectAction
should also be implemented using a dedicated RequestHandlerInterface
-based controller. Both AbstractAction
and AJAXProxyAction
-based AJAX requests should be considered soft-deprecated going forward.
When creating a RequestHandlerInterface
-based controller, care should be taken to ensure no mutable state is stored in object properties of the controller itself. The state of the controller object must be identical before, during and after a request was processed. Any required values must be passed explicitly by means of method parameters and return values. Likewise any functionality called by the controller\u2019s handle()
method should not rely on implicit global values, such as WCF::getUser()
, as was explained in the previous section about request-specific logic.
The recommended pattern for a RequestHandlerInterface
-based controller looks as follows:
<?php\n\nnamespace wcf\\action;\n\nuse Laminas\\Diactoros\\Response;\nuse Psr\\Http\\Message\\ServerRequestInterface;\nuse Psr\\Http\\Message\\ResponseInterface;\nuse Psr\\Http\\Server\\RequestHandlerInterface;\n\nfinal class MyFancyAction implements RequestHandlerInterface\n{\n public function __construct()\n {\n /* 0. Explicitly register services used by the controller, to\n * make dependencies explicit and to avoid accidentally using\n * global state outside of a controller.\n */\n }\n\n public function handle(ServerRequestInterface $request): ResponseInterface\n {\n /* 1. Perform permission checks and input validation. */\n\n /* 2. Perform the action. The action must not rely on global state,\n * but instead only on explicitly passed values. It should assume\n * that permissions have already been validated by the controller,\n * allowing it to be reusable programmatically.\n */\n\n /* 3. Perform post processing. */\n\n /* 4. Prepare the response, e.g. by querying an updated object from\n * the database.\n */\n\n /* 5. Send the response. */\n return new Response();\n }\n}\n
It is recommended to leverage Valinor for structural validation of input values if using the FormBuilder is not a good fit, specifically for any values that are provided implicitly and are expected to be correct. WoltLab Suite includes a middleware that will automatically convert unhandled MappingError
s into a response with status HTTP 400 Bad Request.
XSRF validation will implicitly be performed for any request that uses a HTTP verb other than GET
. Likewise any requests with a JSON body will automatically be decoded by a middleware and stored as the ServerRequestInterface
\u2019s parsed body.
The new WoltLabSuite/Core/Ajax/Backend
module may be used to easily query a RequestHandlerInterface
-based controller. The JavaScript code must not make any assumptions about the URI structure to reach the controller. Instead the endpoint must be generated using LinkHandler
and explicitly provided, e.g. by storing it in a data-endpoint
attribute:
<button\n class=\"button fancyButton\"\n data-endpoint=\"{link controller='MyFancy'}{/link}\"\n>Click me!</button>\n
const button = document.querySelector('.fancyButton');\nbutton.addEventListener('click', async (event) => {\nconst request = prepareRequest(button.dataset.endpoint)\n.get(); // or: .post(\u2026)\n\nconst response = await request.fetchAsResponse(); // or: .fetchAsJson()\n});\n
"},{"location":"migration/wsc55/php/#formbuilder","title":"FormBuilder","text":"The Psr15DialogForm
class combined with the usingFormBuilder()
method of dialogFactory()
provides a \u201cbatteries-included\u201d solution to create a AJAX- and FormBuilder-based RequestHandlerInterface
-based controller.
Within the JavaScript code the endpoint is queried using:
const { ok, result } = await dialogFactory()\n.usingFormBuilder()\n.fromEndpoint(url);\n
The returned Promise
will resolve when the dialog is closed, either by successfully submitting the form or by manually closing it and thus aborting the process. If the form was submitted successfully ok
will be true
and result
will contain the controller\u2019s response. If the dialog was closed without successfully submitting the form, ok
will be false
and result
will be set to undefined
.
Within the PHP code, the form may be created as usual, but use Psr15DialogForm
as the form document. The controller must return $dialogForm->toJsonResponse()
for GET
requests and validate the ServerRequestInterface
using $dialogForm->validateRequest($request)
for POST
requests. The latter will return a ResponseInterface
to be returned if the validation fails, otherwise null
is returned. If validation succeeded, the controller must perform the resulting action and return a JsonResponse
with the result
key:
if ($request->getMethod() === 'GET') {\n return $dialogForm->toResponse();\n} elseif ($request->getMethod() === 'POST') {\n $response = $dialogForm->validateRequest($request);\n if ($response !== null) {\n return $response;\n }\n\n $data = $dialogForm->getData();\n\n // Use $data.\n\n return new JsonResponse([\n 'result' => [\n 'some' => 'value',\n ],\n ]);\n} else {\n // The used method is validated by a middleware. Methods that are not\n // GET or POST need to be explicitly allowed using the 'AllowHttpMethod'\n // attribute.\n throw new \\LogicException('Unreachable');\n}\n
"},{"location":"migration/wsc55/php/#example","title":"Example","text":"A complete example, showcasing all the patterns can be found in WoltLab/WCF#5106. This example showcases how to:
data-*
attribute.The minversion
attribute of the <requiredpackage>
tag is now required.
Woltlab Suite 6.0 no longer accepts package versions with the \u201cpl\u201d suffix as valid.
"},{"location":"migration/wsc55/php/#removal-of-api-compatibility","title":"Removal of API compatibility","text":"WoltLab Suite 6.0 removes support for the deprecated API compatibility functionality. Any packages with a <compatibility>
tag in their package.xml are assumed to not have been updated for WoltLab Suite 6.0 and will be rejected during installation. Furthermore any packages without an explicit requirement for com.woltlab.wcf
in at least version 5.4.22
are also assumed to not have been updated for WoltLab Suite 6.0 and will also be rejected. The latter check is intended to reject old and most likely incompatible packages where the author forgot to add either an <excludedpackage>
or a <compatibility>
tag before releasing it.
Installing unnamed event listeners is no longer supported. The name
attribute needs to be specified for all event listeners.
Deleting unnamed event listeners still is possible to allow for a clean migration of existing listeners.
"},{"location":"migration/wsc55/php/#cronjob","title":"Cronjob","text":"Installing unnamed cronjobs is no longer supported. The name
attribute needs to be specified for all event listeners.
Deleting unnamed cronjobs still is possible to allow for a clean migration of existing cronjobs.
The cronjob PIP now supports the <expression>
element, allowing to define the cronjob schedule using a full expression instead of specifying the five elements separately.
The $name
parameter of DatabaseTableIndex::create()
is no longer optional. Relying on the auto-generated index name is strongly discouraged, because of unfixable inconsistent behavior between the SQL PIP and the PHP DDL API. See WoltLab/WCF#4505 for further background information.
The autogenerated name can still be requested by passing an empty string as the $name
. This should only be done for backwards compatibility purposes and to migrate an index with an autogenerated name to an index with an explicit name. An example script can be found in WoltLab/com.woltlab.wcf.conversation@a33677ca051f.
WoltLab Suite 6.0 increases the System Requirements to require PHP\u2019s intl extension to be installed and enabled, allowing you to rely on the functionality provided by it to better match the rules and conventions of the different languages and regions of the world.
One example would be the formatting of numbers. WoltLab Suite included a feature to group digits within large numbers since early versions using the StringUtil::addThousandsSeparator()
method. While this method was able to account for some language-specific differences, e.g. by selecting an appropriate separator character based on a phrase, it failed to account for all the differences in number formatting across countries and cultures.
As an example, English as written in the United States of America uses commas to create groups of three digits within large numbers: 123,456,789. English as written in India on the other hand also uses commas, but digits are not grouped into groups of three. Instead the right-most three digits form a group and then another comma is added every two digits: 12,34,56,789.
Another example would be German as used within Germany and Switzerland. While both countries use groups of three, the separator character differs. Germany uses a dot (123.456.789), whereas Switzerland uses an apostrophe (123\u2019456\u2019789). The correct choice of separator could already be configured using the afore-mentioned phrase, but this is both inconvenient and fails to account for other differences between the two countries. It also made it hard to keep the behavior up to date when rules change.
PHP\u2019s intl extension on the other hand builds on the official Unicode rules, by relying on the ICU library published by the Unicode consortium. As such it is aware of the rules of all relevant languages and regions of the world and it is already kept up to date by the operating system\u2019s package manager.
For the four example regions (en_US, en_IN, de_DE, de_CH) intl\u2019s NumberFormatter
class will format the number 123456789 as follows, correctly implementing the rules:
php > var_dump((new NumberFormatter('en_US', \\NumberFormatter::DEFAULT_STYLE))->format(123_456_789));\nstring(11) \"123,456,789\"\nphp > var_dump((new NumberFormatter('en_IN', \\NumberFormatter::DEFAULT_STYLE))->format(123_456_789));\nstring(12) \"12,34,56,789\"\nphp > var_dump((new NumberFormatter('de_DE', \\NumberFormatter::DEFAULT_STYLE))->format(123_456_789));\nstring(11) \"123.456.789\"\nphp > var_dump((new NumberFormatter('de_CH', \\NumberFormatter::DEFAULT_STYLE))->format(123_456_789));\nstring(15) \"123\u2019456\u2019789\"\n
WoltLab Suite\u2019s StringUtil::formatNumeric()
method is updated to leverage the NumberFormatter
internally. However your package might have special requirements regarding formatting, for example when formatting currencies where the position of the currency symbol differs across languages. In those cases your package should manually create an appropriately configured class from Intl\u2019s feature set. The correct locale can be queried by the new Language::getLocale()
method.
Another use case that showcases the Language::getLocale()
method might be localizing a country name using locale_get_display_region()
:
php > var_dump(\\wcf\\system\\WCF::getLanguage()->getLocale());\nstring(5) \"en_US\"\nphp > var_dump(locale_get_display_region('_DE', \\wcf\\system\\WCF::getLanguage()->getLocale()));\nstring(7) \"Germany\"\nphp > var_dump(locale_get_display_region('_US', \\wcf\\system\\WCF::getLanguage()->getLocale()));\nstring(13) \"United States\"\nphp > var_dump(locale_get_display_region('_IN', \\wcf\\system\\WCF::getLanguage()->getLocale()));\nstring(5) \"India\"\nphp > var_dump(locale_get_display_region('_BR', \\wcf\\system\\WCF::getLanguage()->getLocale()));\nstring(6) \"Brazil\"\n
See WoltLab/WCF#5048 for details.
"},{"location":"migration/wsc55/php/#indicating-parameters-that-hold-sensitive-information","title":"Indicating parameters that hold sensitive information","text":"PHP 8.2 adds native support for redacting parameters holding sensitive information in stack traces. Parameters with the #[\\SensitiveParameter]
attribute will show a placeholder value within the stack trace and the error log.
WoltLab Suite\u2019s exception handler contains logic to manually apply the sanitization for PHP versions before 8.2.
It is strongly recommended to add this attribute to all parameters holding sensitive information. Examples for sensitive parameters include passwords/passphrases, access tokens, plaintext values to be encrypted, or private keys.
As attributes are fully backwards and forwards compatible it is possible to apply the attribute to packages targeting older WoltLab Suite or PHP versions without causing errors.
Example:
function checkPassword(\n #[\\SensitiveParameter]\n $password,\n): bool {\n // \u2026\n}\n
See the PHP RFC: Redacting parameters in back traces for more details.
"},{"location":"migration/wsc55/php/#conditions","title":"Conditions","text":""},{"location":"migration/wsc55/php/#abstractintegercondition","title":"AbstractIntegerCondition","text":"Deriving from AbstractIntegerCondition
now requires to explicitly implement protected function getIdentifier(): string
, instead of setting the $identifier
property. This is to ensure that all conditions specify a unique identifier, instead of accidentally relying on a default value. The $identifier
property will no longer be used and may be removed.
See WoltLab/WCF#5077 for details.
"},{"location":"migration/wsc55/php/#rebuild-workers","title":"Rebuild Workers","text":"Rebuild workers should no longer be registered using the com.woltlab.wcf.rebuildData
object type definition. You can attach an event listener to the wcf\\system\\worker\\event\\RebuildWorkerCollecting
event inside a bootstrap script to lazily register workers. The class name of the worker is registered using the event\u2019s register()
method:
<?php\n\nuse wcf\\system\\event\\EventHandler;\nuse wcf\\system\\worker\\event\\RebuildWorkerCollecting;\n\nreturn static function (): void {\n $eventHandler = EventHandler::getInstance();\n\n $eventHandler->register(RebuildWorkerCollecting::class, static function (RebuildWorkerCollecting $event) {\n $event->register(\\bar\\system\\worker\\BazWorker::class, 0);\n });\n};\n
"},{"location":"migration/wsc55/templates/","title":"Migrating from WoltLab Suite 5.5 - Templates","text":""},{"location":"migration/wsc55/templates/#template-modifiers","title":"Template Modifiers","text":"WoltLab Suite featured a strict allow-list for template modifiers within the enterprise mode since 5.2. This allow-list has proved to be a reliable solution against malicious templates. To improve security and to reduce the number of differences between enterprise mode and non-enterprise mode the allow-list will always be enabled going forward.
It is strongly recommended to keep the template logic as simple as possible by moving the heavy lifting into regular PHP code, reducing the number of (specialized) modifiers that need to be applied.
See WoltLab/WCF#4788 for details.
"},{"location":"migration/wsc55/templates/#comments","title":"Comments","text":"In WoltLab Suite 6.0 the comment system has been overhauled. In the process, the integration of comments via templates has been significantly simplified:
{include file='comments' commentContainerID='someElementId' commentObjectID=$someObjectID}\n
An example for the migration of existing template integrations can be found here.
See WoltLab/WCF#5210 for more details.
"},{"location":"package/database-php-api/","title":"Database PHP API","text":"While the sql package installation plugin supports adding and removing tables, columns, and indices, it is not able to handle cases where the added table, column, or index already exist. We have added a new PHP-based API to manipulate the database scheme which can be used in combination with the database package installation plugin that skips parts that already exist:
return [\n // list of `DatabaseTable` objects\n];\n
All of the relevant components can be found in the wcf\\system\\database\\table
namespace.
There are two classes representing database tables: DatabaseTable
and PartialDatabaseTable
. If a new table should be created, use DatabaseTable
. In all other cases, PartialDatabaseTable
should be used as it provides an additional save-guard against accidentally creating a new table by having a typo in the table name: If the tables does not already exist, a table represented by PartialDatabaseTable
will cause an exception (while a DatabaseTable
table will simply be created).
To create a table, a DatabaseTable
object with the table's name as to be created and table's columns, foreign keys and indices have to be specified:
DatabaseTable::create('foo1_bar')\n ->columns([\n // columns\n ])\n ->foreignKeys([\n // foreign keys\n ])\n ->indices([\n // indices\n ])\n
To update a table, the same code as above can be used, except for PartialDatabaseTable
being used instead of DatabaseTable
.
To drop a table, only the drop()
method has to be called:
PartialDatabaseTable::create('foo1_bar')\n ->drop()\n
"},{"location":"package/database-php-api/#columns","title":"Columns","text":"To represent a column of a database table, you have to create an instance of the relevant column class found in the wcf\\system\\database\\table\\column
namespace. Such instances are created similarly to database table objects using the create()
factory method and passing the column name as the parameter.
Every column type supports the following methods:
defaultValue($defaultValue)
sets the default value of the column (default: none).drop()
to drop the column.notNull($notNull = true)
sets if the value of the column can be NULL
(default: false
).Depending on the specific column class implementing additional interfaces, the following methods are also available:
IAutoIncrementDatabaseTableColumn::autoIncrement($autoIncrement = true)
sets if the value of the colum is auto-incremented.IDecimalsDatabaseTableColumn::decimals($decimals)
sets the number of decimals the column supports.IEnumDatabaseTableColumn::enumValues(array $values)
sets the predetermined set of valid values of the column.ILengthDatabaseTableColumn::length($length)
sets the (maximum) length of the column.Additionally, there are some additionally classes of commonly used columns with specific properties:
DefaultFalseBooleanDatabaseTableColumn
(a tinyint
column with length 1
, default value 0
and whose values cannot be null
)DefaultTrueBooleanDatabaseTableColumn
(a tinyint
column with length 1
, default value 1
and whose values cannot be null
)NotNullInt10DatabaseTableColumn
(a int
column with length 10
and whose values cannot be null
)NotNullVarchar191DatabaseTableColumn
(a varchar
column with length 191
and whose values cannot be null
)NotNullVarchar255DatabaseTableColumn
(a varchar
column with length 255
and whose values cannot be null
)ObjectIdDatabaseTableColumn
(a int
column with length 10
, whose values cannot be null
, and whose values are auto-incremented)Examples:
DefaultFalseBooleanDatabaseTableColumn::create('isDisabled')\n\nNotNullInt10DatabaseTableColumn::create('fooTypeID')\n\nSmallintDatabaseTableColumn::create('bar')\n ->length(5)\n ->notNull()\n
"},{"location":"package/database-php-api/#foreign-keys","title":"Foreign Keys","text":"Foreign keys are represented by DatabaseTableForeignKey
objects:
DatabaseTableForeignKey::create()\n ->columns(['fooID'])\n ->referencedTable('wcf1_foo')\n ->referencedColumns(['fooID'])\n ->onDelete('CASCADE')\n
The supported actions for onDelete()
and onUpdate()
are CASCADE
, NO ACTION
, and SET NULL
. To drop a foreign key, all of the relevant data to create the foreign key has to be present and the drop()
method has to be called.
DatabaseTableForeignKey::create()
also supports the foreign key name as a parameter. If it is not present, DatabaseTable::foreignKeys()
will automatically set one based on the foreign key's data.
Indices are represented by DatabaseTableIndex
objects:
DatabaseTableIndex::create('fooID')\n ->type(DatabaseTableIndex::UNIQUE_TYPE)\n ->columns(['fooID'])\n
There are four different types: DatabaseTableIndex::DEFAULT_TYPE
(default), DatabaseTableIndex::PRIMARY_TYPE
, DatabaseTableIndex::UNIQUE_TYPE
, and DatabaseTableIndex::FULLTEXT_TYPE
. For primary keys, there is also the DatabaseTablePrimaryIndex
class which automatically sets the type to DatabaseTableIndex::PRIMARY_TYPE
. To drop a index, all of the relevant data to create the index has to be present and the drop()
method has to be called.
The index name is specified as the parameter to DatabaseTableIndex::create()
. It is strongly recommended to specify an explicit name (WoltLab/WCF#4505). If no name is given, DatabaseTable::indices()
will automatically set one based on the index data.
The package.xml
is the core component of every package. It provides the meta data (e.g. package name, description, author) and the instruction set for a new installation and/or updating from a previous version.
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<package name=\"com.example.package\" xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/package.xsd\">\n<packageinformation>\n<packagename>Simple Package</packagename>\n<packagedescription>A simple package to demonstrate the package system of WoltLab Suite Core</packagedescription>\n<version>1.0.0</version>\n<date>2022-01-17</date>\n</packageinformation>\n\n<authorinformation>\n<author>YOUR NAME</author>\n<authorurl>http://www.example.com</authorurl>\n</authorinformation>\n\n<requiredpackages>\n<requiredpackage minversion=\"5.4.10\">com.woltlab.wcf</requiredpackage>\n</requiredpackages>\n\n<excludedpackages>\n<excludedpackage version=\"6.0.0 Alpha 1\">com.woltlab.wcf</excludedpackage>\n</excludedpackages>\n\n<instructions type=\"install\">\n<instruction type=\"file\" />\n<instruction type=\"template\">templates.tar</instruction>\n</instructions>\n</package>\n
"},{"location":"package/package-xml/#elements","title":"Elements","text":""},{"location":"package/package-xml/#package","title":"<package>
","text":"The root node of every package.xml
it contains the reference to the namespace and the location of the XML Schema Definition (XSD).
The attribute name
is the most important part, it holds the unique package identifier and is mandatory. It is based upon your domain name and the package name of your choice.
For example WoltLab Suite Forum (formerly know an WoltLab Burning Board and usually abbreviated as wbb
) is created by WoltLab which owns the domain woltlab.com
. The resulting package identifier is com.woltlab.wbb
(<tld>.<domain>.<packageName>
).
<packageinformation>
","text":"Holds the entire meta data of the package.
"},{"location":"package/package-xml/#packagename","title":"<packagename>
","text":"This is the actual package name displayed to the end user, this can be anything you want, try to keep it short. It supports the attribute language
which allows you to provide the package name in different languages, please be aware that if it is not present, en
(English) is assumed:
<packageinformation>\n<packagename>Simple Package</packagename>\n<packagename language=\"de\">Einfaches Paket</packagename>\n</packageinformation>\n
"},{"location":"package/package-xml/#packagedescription","title":"<packagedescription>
","text":"Brief summary of the package, use it to explain what it does since the package name might not always be clear enough. The attribute language
can also be used here, please reference to <packagename>
for details.
<version>
","text":"The package's version number, this is a string consisting of three numbers separated with a dot and optionally followed by a keyword (must be followed with another number).
The possible keywords are:
Valid examples:
Invalid examples:
<date>
","text":"Must be a valid ISO 8601 date, e.g. 2013-12-27
.
<packageurl>
","text":"(optional)
URL to the package website that provides detailed information about the package.
"},{"location":"package/package-xml/#license","title":"<license>
","text":"(optional)
Name of a generic license type or URL to a custom license. The attribute language
can also be used here, please reference to <packagename>
for details.
<authorinformation>
","text":"Holds meta data regarding the package's author.
"},{"location":"package/package-xml/#author","title":"<author>
","text":"Can be anything you want.
"},{"location":"package/package-xml/#authorurl","title":"<authorurl>
","text":"(optional)
URL to the author's website.
"},{"location":"package/package-xml/#requiredpackages","title":"<requiredpackages>
","text":"A list of packages including their version required for this package to work.
"},{"location":"package/package-xml/#requiredpackage","title":"<requiredpackage>
","text":"Example:
<requiredpackage minversion=\"2.7.5\" file=\"requirements/com.example.foo.tar\">com.example.foo</requiredpackage>\n
The attribute minversion
must be a valid version number as described in <version>
. The file
attribute is optional and specifies the location of the required package's archive relative to the package.xml
.
<optionalpackage>
","text":"A list of optional packages which can be selected by the user at the very end of the installation process.
"},{"location":"package/package-xml/#optionalpackage_1","title":"<optionalpackage>
","text":"Example:
<optionalpackage file=\"optionals/com.example.bar.tar\">com.example.bar</optionalpackage>\n
The file
attribute specifies the location of the optional package's archive relative to the package.xml
.
<excludedpackages>
","text":"List of packages which conflict with this package. It is not possible to install it if any of the specified packages is installed. In return you cannot install an excluded package if this package is installed.
"},{"location":"package/package-xml/#excludedpackage","title":"<excludedpackage>
","text":"Example:
<excludedpackage version=\"7.0.0 Alpha 1\">com.woltlab.wcf</excludedpackage>\n
The attribute version
must be a valid version number as described in the \\<version> section. In the example above it will be impossible to install this package in WoltLab Suite Core 7.0.0 Alpha 1 or higher.
<instructions>
","text":"List of instructions to be executed upon install or update. The order is important, the topmost <instruction>
will be executed first.
<instructions type=\"install\">
","text":"List of instructions for a new installation of this package.
"},{"location":"package/package-xml/#instructions-typeupdate-fromversion","title":"<instructions type=\"update\" fromversion=\"\u2026\">
","text":"The attribute fromversion
must be a valid version number as described in the \\<version> section and specifies a possible update from that very version to the package's version.
The installation process will pick exactly one update instruction, ignoring everything else. Please read the explanation below!
Example:
1.0.0
1.0.2
<instructions type=\"update\" fromversion=\"1.0.0\">\n<!-- \u2026 -->\n</instructions>\n<instructions type=\"update\" fromversion=\"1.0.1\">\n<!-- \u2026 -->\n</instructions>\n
In this example WoltLab Suite Core will pick the first update block since it allows an update from 1.0.0 -> 1.0.2
. The other block is not considered, since the currently installed version is 1.0.0
. After applying the update block (fromversion=\"1.0.0\"
), the version now reads 1.0.2
.
<instruction>
","text":"Example:
<instruction type=\"objectTypeDefinition\">objectTypeDefinition.xml</instruction>\n
The attribute type
specifies the instruction type which is used to determine the package installation plugin (PIP) invoked to handle its value. The value must be a valid file relative to the location of package.xml
. Many PIPs provide default file names which are used if no value is given:
<instruction type=\"objectTypeDefinition\" />\n
There is a list of all default PIPs available.
Both the type
-attribute and the element value are case-sensitive. Windows does not care if the file is called objecttypedefinition.xml
but was referenced as objectTypeDefinition.xml
, but both Linux and Mac systems will be unable to find the file.
In addition to the type
attribute, an optional run
attribute (with standalone
as the only valid value) is supported which forces the installation to execute this PIP in an isolated request, allowing a single, resource-heavy PIP to execute without encountering restrictions such as PHP\u2019s memory_limit
or max_execution_time
:
<instruction type=\"file\" run=\"standalone\" />\n
"},{"location":"package/package-xml/#void","title":"<void/>
","text":"Sometimes a package update should only adjust the metadata of the package, for example, an optional package was added. However, WoltLab Suite Core requires that the list of <instructions>
is non-empty. Instead of using a dummy <instruction>
that idempotently updates some PIP, the <void/>
tag can be used for this use-case.
Using the <void/>
tag is only valid for <instructions type=\"update\">
and must not be accompanied by other <instruction>
tags.
Example:
<instructions type=\"update\" fromversion=\"1.0.0\">\n<void/>\n</instructions>\n
"},{"location":"package/pip/","title":"Package Installation Plugins","text":"Package Installation Plugins (PIPs) are interfaces to deploy and edit content as well as components.
For XML-based PIPs: <![CDATA[]]>
must be used for language items and page contents. In all other cases it may only be used when necessary.
Add customizable permissions for individual objects.
"},{"location":"package/pip/acl-option/#option-components","title":"Option Components","text":"Each acl option is described as an <option>
element with the mandatory attribute name
.
<categoryname>
","text":"Optional
The name of the acl option category to which the option belongs.
"},{"location":"package/pip/acl-option/#objecttype","title":"<objecttype>
","text":"The name of the acl object type (of the object type definition com.woltlab.wcf.acl
).
Each acl option category is described as an <category>
element with the mandatory attribute name
that should follow the naming pattern <permissionName>
or <permissionType>.<permissionName>
, with <permissionType>
generally having user
or mod
as value.
<objecttype>
","text":"The name of the acl object type (of the object type definition com.woltlab.wcf.acl
).
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/aclOption.xsd\">\n<import>\n<categories>\n<category name=\"user.example\">\n<objecttype>com.example.wcf.example</objecttype>\n</category>\n<category name=\"mod.example\">\n<objecttype>com.example.wcf.example</objecttype>\n</category>\n</categories>\n\n<options>\n<option name=\"canAddExample\">\n<categoryname>user.example</categoryname>\n<objecttype>com.example.wcf.example</objecttype>\n</option>\n<option name=\"canDeleteExample\">\n<categoryname>mod.example</categoryname>\n<objecttype>com.example.wcf.example</objecttype>\n</option>\n</options>\n</import>\n\n<delete>\n<optioncategory name=\"old.example\">\n<objecttype>com.example.wcf.example</objecttype>\n</optioncategory>\n<option name=\"canDoSomethingWithExample\">\n<objecttype>com.example.wcf.example</objecttype>\n</option>\n</delete>\n</data>\n
"},{"location":"package/pip/acp-menu/","title":"ACP Menu Package Installation Plugin","text":"Registers new ACP menu items.
"},{"location":"package/pip/acp-menu/#components","title":"Components","text":"Each item is described as an <acpmenuitem>
element with the mandatory attribute name
.
<parent>
","text":"Optional
The item\u2019s parent item.
"},{"location":"package/pip/acp-menu/#showorder","title":"<showorder>
","text":"Optional
Specifies the order of this item within the parent item.
"},{"location":"package/pip/acp-menu/#controller","title":"<controller>
","text":"The fully qualified class name of the target controller. If not specified this item serves as a category.
"},{"location":"package/pip/acp-menu/#link","title":"<link>
","text":"Additional components if <controller>
is set, the full external link otherwise.
<icon>
","text":"Use an icon only for top-level and 4th-level items.
Name of the Font Awesome icon class.
"},{"location":"package/pip/acp-menu/#options","title":"<options>
","text":"Optional
The options element can contain a comma-separated list of options of which at least one needs to be enabled for the tab to be shown.
"},{"location":"package/pip/acp-menu/#permissions","title":"<permissions>
","text":"Optional
The permissions element can contain a comma-separated list of permissions of which the active user needs to have at least one for the tab to be shown.
"},{"location":"package/pip/acp-menu/#example","title":"Example","text":"acpMenu.xml<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/acpMenu.xsd\">\n<import>\n<acpmenuitem name=\"foo.acp.menu.link.example\">\n<parent>wcf.acp.menu.link.application</parent>\n</acpmenuitem>\n\n<acpmenuitem name=\"foo.acp.menu.link.example.list\">\n<controller>foo\\acp\\page\\ExampleListPage</controller>\n<parent>foo.acp.menu.link.example</parent>\n<permissions>admin.foo.canManageExample</permissions>\n<showorder>1</showorder>\n</acpmenuitem>\n\n<acpmenuitem name=\"foo.acp.menu.link.example.add\">\n<controller>foo\\acp\\form\\ExampleAddForm</controller>\n<parent>foo.acp.menu.link.example.list</parent>\n<permissions>admin.foo.canManageExample</permissions>\n<icon>fa-plus</icon>\n</acpmenuitem>\n</import>\n</data>\n
"},{"location":"package/pip/acp-search-provider/","title":"ACP Search Provider Package Installation Plugin","text":"Registers data provider for the admin panel search.
"},{"location":"package/pip/acp-search-provider/#components","title":"Components","text":"Each acp search result provider is described as an <acpsearchprovider>
element with the mandatory attribute name
.
<classname>
","text":"The name of the class providing the search results, the class has to implement the wcf\\system\\search\\acp\\IACPSearchResultProvider
interface.
<showorder>
","text":"Optional
Determines at which position of the search result list the provided results are shown.
"},{"location":"package/pip/acp-search-provider/#example","title":"Example","text":"acpSearchProvider.xml<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/acpSearchProvider.xsd\">\n<import>\n<acpsearchprovider name=\"com.woltlab.wcf.example\">\n<classname>wcf\\system\\search\\acp\\ExampleACPSearchResultProvider</classname>\n<showorder>1</showorder>\n</acpsearchprovider>\n</import>\n</data>\n
"},{"location":"package/pip/acp-template-delete/","title":"ACP Template Delete Package Installation Plugin","text":"Available since WoltLab Suite 5.5.
Deletes admin panel templates installed with the acpTemplate package installation plugin.
You cannot delete acp templates provided by other packages.
"},{"location":"package/pip/acp-template-delete/#components","title":"Components","text":"Each item is described as a <template>
element with an optional application
, which behaves like it does for acp templates. The templates are identified by their name like when adding template listeners, i.e. by the file name without the .tpl
file extension.
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/acpTemplateDelete.xsd\">\n<delete>\n<template>fouAdd</template>\n<template application=\"app\">__appAdd</template>\n</delete>\n</data>\n
"},{"location":"package/pip/acp-template/","title":"ACP Template Installation Plugin","text":"Add templates for acp pages and forms by providing an archive containing the template files.
You cannot overwrite acp templates provided by other packages.
"},{"location":"package/pip/acp-template/#archive","title":"Archive","text":"The acpTemplate
package installation plugins expects a .tar
(recommended) or .tar.gz
archive. The templates must all be in the root of the archive. Do not include any directories in the archive. The file path given in the instruction
element as its value must be relative to the package.xml
file.
application
","text":"The application
attribute determines to which application the installed acp templates belong and thus in which directory the templates are installed. The value of the application
attribute has to be the abbreviation of an installed application. If no application
attribute is given, the following rules are applied:
package.xml
","text":"<instruction type=\"acpTemplate\" />\n<!-- is the same as -->\n<instruction type=\"acpTemplate\">acptemplates.tar</instruction>\n\n<!-- if an application \"com.woltlab.example\" is being installed, the following lines are equivalent -->\n<instruction type=\"acpTemplate\" />\n<instruction type=\"acpTemplate\" application=\"example\" />\n
"},{"location":"package/pip/bbcode/","title":"BBCode Package Installation Plugin","text":"Registers new BBCodes.
"},{"location":"package/pip/bbcode/#components","title":"Components","text":"Each bbcode is described as an <bbcode>
element with the mandatory attribute name
. The name
attribute must contain alphanumeric characters only and is exposed to the user.
<htmlopen>
","text":"Optional: Must not be provided if the BBCode is being processed a PHP class (<classname>
).
The contents of this tag are literally copied into the opening tag of the bbcode.
"},{"location":"package/pip/bbcode/#htmlclose","title":"<htmlclose>
","text":"Optional: Must not be provided if <htmlopen>
is not given.
Must match the <htmlopen>
tag. Do not provide for self-closing tags.
<classname>
","text":"The name of the class providing the bbcode output, the class has to implement the wcf\\system\\bbcode\\IBBCode
interface.
BBCodes can be statically converted to HTML during input processing using a wcf\\system\\html\\metacode\\converter\\*MetaConverter
class. This class does not need to be registered.
<wysiwygicon>
","text":"Optional
Name of the Font Awesome icon class or path to a gif
, jpg
, jpeg
, png
, or svg
image (placed inside the icon/
directory) to show in the editor toolbar.
<buttonlabel>
","text":"Optional: Must be provided if an icon is given.
Explanatory text to show when hovering the icon.
"},{"location":"package/pip/bbcode/#sourcecode","title":"<sourcecode>
","text":"Do not set this to 1
if you don't specify a PHP class for processing. You must perform XSS sanitizing yourself!
If set to 1
contents of this BBCode will not be interpreted, but literally passed through instead.
<isBlockElement>
","text":"Set to 1
if the output of this BBCode is a HTML block element (according to the HTML specification).
<attributes>
","text":"Each bbcode is described as an <attribute>
element with the mandatory attribute name
. The name
attribute is a 0-indexed integer.
<html>
","text":"Optional: Must not be provided if the BBCode is being processed a PHP class (<classname>
).
The contents of this tag are copied into the opening tag of the bbcode. %s
is replaced by the attribute value.
<validationpattern>
","text":"Optional
Defines a regular expression that is used to validate the value of the attribute.
"},{"location":"package/pip/bbcode/#required","title":"<required>
","text":"Optional
Specifies whether this attribute must be provided.
"},{"location":"package/pip/bbcode/#usetext","title":"<usetext>
","text":"Optional
Should only be set to 1
for the attribute with name 0
.
Specifies whether the text content of the BBCode should become this attribute's value.
"},{"location":"package/pip/bbcode/#example","title":"Example","text":"bbcode.xml<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/bbcode.xsd\">\n<import>\n<bbcode name=\"foo\">\n<classname>wcf\\system\\bbcode\\FooBBCode</classname>\n<attributes>\n<attribute name=\"0\">\n<validationpattern>^\\d+$</validationpattern>\n<required>1</required>\n</attribute>\n</attributes>\n</bbcode>\n\n<bbcode name=\"example\">\n<htmlopen>div</htmlopen>\n<htmlclose>div</htmlclose>\n<isBlockElement>1</isBlockElement>\n<wysiwygicon>fa-bath</wysiwygicon>\n<buttonlabel>wcf.editor.button.example</buttonlabel>\n</bbcode>\n</import>\n</data>\n
"},{"location":"package/pip/box/","title":"Box Package Installation Plugin","text":"Deploy and manage boxes that can be placed anywhere on the site, they come in two flavors: system and content-based.
"},{"location":"package/pip/box/#components","title":"Components","text":"Each item is described as a <box>
element with the mandatory attribute name
that should follow the naming pattern <packageIdentifier>.<BoxName>
, e.g. com.woltlab.wcf.RecentActivity
.
<name>
","text":"The language
attribute is required and should specify the ISO-639-1 language code.
The internal name displayed in the admin panel only, can be fully customized by the administrator and is immutable. Only one value is accepted and will be picked based on the site's default language, but you can provide localized values by including multiple <name>
elements.
<boxType>
","text":""},{"location":"package/pip/box/#system","title":"system
","text":"The special system
type is reserved for boxes that pull their properties and content from a registered PHP class. Requires the <objectType>
element.
html
, text
or tpl
","text":"Provide arbitrary content, requires the <content>
element.
<objectType>
","text":"Required for boxes with boxType = system
, must be registered through the objectType PIP for the definition com.woltlab.wcf.boxController
.
<position>
","text":"The default display position of this box, can be any of the following:
<showHeader>
","text":"Setting this to 0
will suppress display of the box title, useful for boxes containing advertisements or similar. Defaults to 1
.
<visibleEverywhere>
","text":"Controls the display on all pages (1
) or none (0
), can be used in conjunction with <visibilityExceptions>
.
<visibilityExceptions>
","text":"Inverts the <visibleEverywhere>
setting for the listed pages only.
<cssClassName>
","text":"Provide a custom CSS class name that is added to the menu container, allowing further customization of the menu's appearance.
"},{"location":"package/pip/box/#content","title":"<content>
","text":"The language
attribute is required and should specify the ISO-639-1 language code.
<title>
","text":"The title element is required and controls the box title shown to the end users.
"},{"location":"package/pip/box/#content_1","title":"<content>
","text":"The content that should be used to populate the box, only used and required if the boxType
equals text
, html
and tpl
.
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/box.xsd\">\n<import>\n<box identifier=\"com.woltlab.wcf.RecentActivity\">\n<name language=\"de\">Letzte Aktivit\u00e4ten</name>\n<name language=\"en\">Recent Activities</name>\n<boxType>system</boxType>\n<objectType>com.woltlab.wcf.recentActivityList</objectType>\n<position>contentBottom</position>\n<showHeader>0</showHeader>\n<visibleEverywhere>0</visibleEverywhere>\n<visibilityExceptions>\n<page>com.woltlab.wcf.Dashboard</page>\n</visibilityExceptions>\n<limit>10</limit>\n\n<content language=\"de\">\n<title>Letzte Aktivit\u00e4ten</title>\n</content>\n<content language=\"en\">\n<title>Recent Activities</title>\n</content>\n</box>\n</import>\n\n<delete>\n<box identifier=\"com.woltlab.wcf.RecentActivity\" />\n</delete>\n</data>\n
"},{"location":"package/pip/clipboard-action/","title":"Clipboard Action Package Installation Plugin","text":"Registers clipboard actions.
"},{"location":"package/pip/clipboard-action/#components","title":"Components","text":"Each clipboard action is described as an <action>
element with the mandatory attribute name
.
<actionclassname>
","text":"The name of the class used by the clipboard API to process the concrete action. The class has to implement the wcf\\system\\clipboard\\action\\IClipboardAction
interface, best by extending wcf\\system\\clipboard\\action\\AbstractClipboardAction
.
<pages>
","text":"Element with <page>
children whose value contains the class name of the controller of the page on which the clipboard action is available.
<showorder>
","text":"Optional
Determines at which position of the clipboard action list the action is shown.
"},{"location":"package/pip/clipboard-action/#example","title":"Example","text":"clipboardAction.xml<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/clipboardAction.xsd\">\n<import>\n<action name=\"delete\">\n<actionclassname>wcf\\system\\clipboard\\action\\ExampleClipboardAction</actionclassname>\n<showorder>1</showorder>\n<pages>\n<page>wcf\\acp\\page\\ExampleListPage</page>\n</pages>\n</action>\n<action name=\"foo\">\n<actionclassname>wcf\\system\\clipboard\\action\\ExampleClipboardAction</actionclassname>\n<showorder>2</showorder>\n<pages>\n<page>wcf\\acp\\page\\ExampleListPage</page>\n</pages>\n</action>\n<action name=\"bar\">\n<actionclassname>wcf\\system\\clipboard\\action\\ExampleClipboardAction</actionclassname>\n<showorder>3</showorder>\n<pages>\n<page>wcf\\acp\\page\\ExampleListPage</page>\n</pages>\n</action>\n</import>\n</data>\n
"},{"location":"package/pip/core-object/","title":"Core Object Package Installation Plugin","text":"Registers wcf\\system\\SingletonFactory
objects to be accessible in templates.
Each item is described as a <coreobject>
element with the mandatory element objectname
.
<objectname>
","text":"The fully qualified class name of the class.
"},{"location":"package/pip/core-object/#example","title":"Example","text":"coreObject.xml<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/coreObject.xsd\">\n<import>\n<coreobject>\n<objectname>wcf\\system\\example\\ExampleHandler</objectname>\n</coreobject>\n</import>\n</data>\n
This object can be accessed in templates via $__wcf->getExampleHandler()
(in general: the method name begins with get
and ends with the unqualified class name).
Registers new cronjobs. The cronjob schedular works similar to the cron(8)
daemon, which might not available to web applications on regular webspaces. The main difference is that WoltLab Suite\u2019s cronjobs do not guarantee execution at the specified points in time: WoltLab Suite\u2019s cronjobs are triggered by regular visitors in an AJAX request, once the next execution point lies in the past.
Each cronjob is described as an <cronjob>
element with the mandatory attribute name
.
<classname>
","text":"The name of the class providing the cronjob's behaviour, the class has to implement the wcf\\system\\cronjob\\ICronjob
interface.
<description>
","text":"The language
attribute is optional and should specify the ISO-639-1 language code.
Provides a human readable description for the administrator.
"},{"location":"package/pip/cronjob/#expression","title":"<expression>
","text":"The cronjob schedule. The expression accepts the same syntax as described in crontab(5)
of a cron daemon.
<canbeedited>
","text":"Controls whether the administrator may edit the fields of the cronjob. Defaults to 1
.
<canbedisabled>
","text":"Controls whether the administrator may disable the cronjob. Defaults to 1
.
<isdisabled>
","text":"Controls whether the cronjob is disabled by default. Defaults to 0
.
<options>
","text":"The options element can contain a comma-separated list of options of which at least one needs to be enabled for the template listener to be executed.
"},{"location":"package/pip/cronjob/#example","title":"Example","text":"cronjob.xml<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/cronjob.xsd\">\n<import>\n<cronjob name=\"com.example.package.example\">\n<classname>wcf\\system\\cronjob\\ExampleCronjob</classname>\n<description>Serves as an example</description>\n<description language=\"de\">Stellt ein Beispiel dar</description>\n<expression>0 2 */2 * *</expression>\n<canbeedited>1</canbeedited>\n<canbedisabled>1</canbedisabled>\n</cronjob>\n</import>\n</data>\n
"},{"location":"package/pip/database/","title":"Database Package Installation Plugin","text":"Available since WoltLab Suite 5.4.
Update the database layout using the PHP API.
You must install the PHP script through the file package installation plugin.
The installation will attempt to delete the script after successful execution.
"},{"location":"package/pip/database/#attributes","title":"Attributes","text":""},{"location":"package/pip/database/#application","title":"application
","text":"The application
attribute must have the same value as the application
attribute of the file
package installation plugin instruction so that the correct file in the intended application directory is executed. For further information about the application
attribute, refer to its documentation on the acpTemplate package installation plugin page.
The database
-PIP expects a relative path to a .php
file that returns an array of DatabaseTable
objects.
The PHP script is deployed by using the file package installation plugin. To prevent it from colliding with other install script (remember: You cannot overwrite files created by another plugin), we highly recommend to make use of these naming conventions:
acp/database/install_<package>.php
(example: acp/database/install_com.woltlab.wbb.php
)acp/database/update_<package>_<targetVersion>.php
(example: acp/database/update_com.woltlab.wbb_5.4.1.php
)<targetVersion>
equals the version number of the current package being installed. If you're updating from 1.0.0
to 1.0.1
, <targetVersion>
should read 1.0.1
.
If you run multiple update scripts, you can append additional information in the filename.
"},{"location":"package/pip/database/#execution-environment","title":"Execution environment","text":"The script is included using include()
within DatabasePackageInstallationPlugin::updateDatabase().
Registers event listeners. An explanation of events and event listeners can be found here.
"},{"location":"package/pip/event-listener/#components","title":"Components","text":"Each event listener is described as an <eventlistener>
element with a name
attribute. As the name
attribute has only be introduced with WSC 3.0, it is not yet mandatory to allow backwards compatibility. If name
is not given, the system automatically sets the name based on the id of the event listener in the database.
<eventclassname>
","text":"The event class name is the name of the class in which the event is fired.
"},{"location":"package/pip/event-listener/#eventname","title":"<eventname>
","text":"The event name is the name given when the event is fired to identify different events within the same class. You can either give a single event name or a comma-separated list of event names in which case the event listener listens to all of the listed events.
Since the introduction of the new event system with version 5.5, the event name is optional and defaults to :default
.
<listenerclassname>
","text":"The listener class name is the name of the class which is triggered if the relevant event is fired. The PHP class has to implement the wcf\\system\\event\\listener\\IParameterizedEventListener
interface.
Legacy event listeners are only required to implement the deprecated wcf\\system\\event\\IEventListener
interface. When writing new code or update existing code, you should always implement the wcf\\system\\event\\listener\\IParameterizedEventListener
interface!
<inherit>
","text":"The inherit value can either be 0
(default value if the element is omitted) or 1
and determines if the event listener is also triggered for child classes of the given event class name. This is the case if 1
is used as the value.
<environment>
","text":"The value of the environment element must be one of user
, admin
or all
and defaults to user
if no value is given. The value determines if the event listener will be executed in the frontend (user
), the backend (admin
) or both (all
).
<nice>
","text":"The nice value element can contain an integer value out of the interval [-128,127]
with 0
being the default value if the element is omitted. The nice value determines the execution order of event listeners. Event listeners with smaller nice values are executed first. If the nice value of two event listeners is equal, they are sorted by the listener class name.
If you pass a value out of the mentioned interval, the value will be adjusted to the closest value in the interval.
"},{"location":"package/pip/event-listener/#options","title":"<options>
","text":"The options element can contain a comma-separated list of options of which at least one needs to be enabled for the event listener to be executed.
"},{"location":"package/pip/event-listener/#permissions","title":"<permissions>
","text":"The permissions element can contain a comma-separated list of permissions of which the active user needs to have at least one for the event listener to be executed.
"},{"location":"package/pip/event-listener/#example","title":"Example","text":"eventListener.xml<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/eventListener.xsd\">\n<import>\n<eventlistener name=\"inheritedAdminExample\">\n<eventclassname>wcf\\acp\\form\\UserAddForm</eventclassname>\n<eventname>assignVariables,readFormParameters,save,validate</eventname>\n<listenerclassname>wcf\\system\\event\\listener\\InheritedAdminExampleListener</listenerclassname>\n<inherit>1</inherit>\n<environment>admin</environment>\n</eventlistener>\n\n<eventlistener name=\"nonInheritedUserExample\">\n<eventclassname>wcf\\form\\SettingsForm</eventclassname>\n<eventname>assignVariables</eventname>\n<listenerclassname>wcf\\system\\event\\listener\\NonInheritedUserExampleListener</listenerclassname>\n</eventlistener>\n</import>\n\n<delete>\n<eventlistener name=\"oldEventListenerName\" />\n</delete>\n</data>\n
"},{"location":"package/pip/file-delete/","title":"File Delete Package Installation Plugin","text":"Available since WoltLab Suite 5.5.
Deletes files installed with the file package installation plugin.
You cannot delete files provided by other packages.
"},{"location":"package/pip/file-delete/#components","title":"Components","text":"Each item is described as a <file>
element with an optional application
, which behaves like it does for acp templates. The file path is relative to the installation of the app to which the file belongs.
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/fileDelete.xsd\">\n<delete>\n<file>path/file.ext</file>\n<file application=\"app\">lib/data/foo/Fou.class.php</file>\n</delete>\n</data>\n
"},{"location":"package/pip/file/","title":"File Package Installation Plugin","text":"Adds any type of files with the exception of templates.
You cannot overwrite files provided by other packages.
The application
attribute behaves like it does for acp templates.
The acpTemplate
package installation plugins expects a .tar
(recommended) or .tar.gz
archive. The file path given in the instruction
element as its value must be relative to the package.xml
file.
package.xml
","text":"<instruction type=\"file\" />\n<!-- is the same as -->\n<instruction type=\"file\">files.tar</instruction>\n\n<!-- if an application \"com.woltlab.example\" is being installed, the following lines are equivalent -->\n<instruction type=\"file\" />\n<instruction type=\"file\" application=\"example\" />\n\n<!-- if the same application wants to install additional files, in WoltLab Suite Core's directory: -->\n<instruction type=\"file\" application=\"wcf\">files_wcf.tar</instruction>\n
"},{"location":"package/pip/language/","title":"Language Package Installation Plugin","text":"Registers new language items.
"},{"location":"package/pip/language/#components","title":"Components","text":"The languagecode
attribute is required and should specify the ISO-639-1 language code.
The top level <language>
node must contain a languagecode
attribute.
<category>
","text":"Each category must contain a name
attribute containing two or three components consisting of alphanumeric character only, separated by a single full stop (.
, U+002E).
<item>
","text":"Each language item must contain a name
attribute containing at least three components consisting of alphanumeric character only, separated by a single full stop (.
, U+002E). The name
of the parent <category>
node followed by a full stop must be a prefix of the <item>
\u2019s name
.
Wrap the text content inside a CDATA to avoid escaping of special characters.
Do not use the {lang}
tag inside a language item.
The text content of the <item>
node is the value of the language item. Language items that are not in the wcf.global
category support template scripting.
Prior to version 5.5, there was no support for deleting language items and the category
elements had to be placed directly as children of the language
element, see the migration guide to version 5.5.
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<language xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/language.xsd\" languagecode=\"en\">\n<import>\n<category name=\"wcf.example\">\n<item name=\"wcf.example.foo\"><![CDATA[<strong>Look!</strong>]]></item>\n</category>\n</import>\n<delete>\n<item name=\"wcf.example.obsolete\"/>\n</delete>\n</language>\n
"},{"location":"package/pip/media-provider/","title":"Media Provider Package Installation Plugin","text":"Media providers are responsible to detect and convert links to a 3rd party service inside messages.
"},{"location":"package/pip/media-provider/#components","title":"Components","text":"Each item is described as a <provider>
element with the mandatory attribute name
that should equal the lower-cased provider name. If a provider provides multiple components that are (largely) unrelated to each other, it is recommended to use a dash to separate the name and the component, e. g. youtube-playlist
.
<title>
","text":"The title is displayed in the administration control panel and is only used there, the value is neither localizable nor is it ever exposed to regular users.
"},{"location":"package/pip/media-provider/#regex","title":"<regex>
","text":"The regular expression used to identify links to this provider, it must not contain anchors or delimiters. It is strongly recommended to capture the primary object id using the (?P<ID>...)
group.
<className>
","text":"<className>
and <html>
are mutually exclusive.
PHP-Callback-Class that is invoked to process the matched link in case that additional logic must be applied that cannot be handled through a simple replacement as defined by the <html>
element.
The callback-class must implement the interface \\wcf\\system\\bbcode\\media\\provider\\IBBCodeMediaProvider
.
<html>
","text":"<className>
and <html>
are mutually exclusive.
Replacement HTML that gets populated using the captured matches in <regex>
, variables are accessed as {$VariableName}
. For example, the capture group (?P<ID>...)
is accessed using {$ID}
.
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/mediaProvider.xsd\">\n<import>\n<provider name=\"youtube\">\n<title>YouTube</title>\n<regex><![CDATA[https?://(?:.+?\\.)?youtu(?:\\.be/|be\\.com/(?:#/)?watch\\?(?:.*?&)?v=)(?P<ID>[a-zA-Z0-9_-]+)(?:(?:\\?|&)t=(?P<start>[0-9hms]+)$)?]]></regex>\n<!-- advanced PHP callback -->\n<className><![CDATA[wcf\\system\\bbcode\\media\\provider\\YouTubeBBCodeMediaProvider]]></className>\n</provider>\n\n<provider name=\"youtube-playlist\">\n<title>YouTube Playlist</title>\n<regex><![CDATA[https?://(?:.+?\\.)?youtu(?:\\.be/|be\\.com/)playlist\\?(?:.*?&)?list=(?P<ID>[a-zA-Z0-9_-]+)]]></regex>\n<!-- uses a simple HTML replacement -->\n<html><![CDATA[<div class=\"videoContainer\"><iframe src=\"https://www.youtube.com/embed/videoseries?list={$ID}\" allowfullscreen></iframe></div>]]></html>\n</provider>\n</import>\n\n<delete>\n<provider name=\"example\" />\n</delete>\n</data>\n
"},{"location":"package/pip/menu-item/","title":"Menu Item Package Installation Plugin","text":"Adds menu items to existing menus.
"},{"location":"package/pip/menu-item/#components","title":"Components","text":"Each item is described as an <item>
element with the mandatory attribute identifier
that should follow the naming pattern <packageIdentifier>.<PageName>
, e.g. com.woltlab.wcf.Dashboard
.
<menu>
","text":"The target menu that the item should be added to, requires the internal identifier set by creating a menu through the menu.xml.
"},{"location":"package/pip/menu-item/#title","title":"<title>
","text":"The language
attribute is required and should specify the ISO-639-1 language code.
The title is displayed as the link title of the menu item and can be fully customized by the administrator, thus is immutable after deployment. Supports multiple <title>
elements to provide localized values.
<page>
","text":"The page that the link should point to, requires the internal identifier set by creating a page through the page.xml.
"},{"location":"package/pip/menu-item/#example","title":"Example","text":"menuItem.xml<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/menuItem.xsd\">\n<import>\n<item identifier=\"com.woltlab.wcf.Dashboard\">\n<menu>com.woltlab.wcf.MainMenu</menu>\n<title language=\"de\">Dashboard</title>\n<title language=\"en\">Dashboard</title>\n<page>com.woltlab.wcf.Dashboard</page>\n</item>\n</import>\n\n<delete>\n<item identifier=\"com.woltlab.wcf.FooterLinks\" />\n</delete>\n</data>\n
"},{"location":"package/pip/menu/","title":"Menu Package Installation Plugin","text":"Deploy and manage menus that can be placed anywhere on the site.
"},{"location":"package/pip/menu/#components","title":"Components","text":"Each item is described as a <menu>
element with the mandatory attribute identifier
that should follow the naming pattern <packageIdentifier>.<MenuName>
, e.g. com.woltlab.wcf.MainMenu
.
<title>
","text":"The language
attribute is required and should specify the ISO-639-1 language code.
The internal name displayed in the admin panel only, can be fully customized by the administrator and is immutable. Only one value is accepted and will be picked based on the site's default language, but you can provide localized values by including multiple <title>
elements.
<box>
","text":"The following elements of the box PIP are supported, please refer to the documentation to learn more about them:
<position>
<showHeader>
<visibleEverywhere>
<visibilityExceptions>
cssClassName
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/menu.xsd\">\n<import>\n<menu identifier=\"com.woltlab.wcf.FooterLinks\">\n<title language=\"de\">Footer-Links</title>\n<title language=\"en\">Footer Links</title>\n\n<box>\n<position>footer</position>\n<cssClassName>boxMenuLinkGroup</cssClassName>\n<showHeader>0</showHeader>\n<visibleEverywhere>1</visibleEverywhere>\n</box>\n</menu>\n</import>\n\n<delete>\n<menu identifier=\"com.woltlab.wcf.FooterLinks\" />\n</delete>\n</data>\n
"},{"location":"package/pip/object-type-definition/","title":"Object Type Definition Package Installation Plugin","text":"Registers an object type definition. An object type definition is a blueprint for a certain behaviour that is particularized by objectTypes. As an example: Tags can be attached to different types of content (such as forum posts or gallery images). The bulk of the work is implemented in a generalized fashion, with all the tags stored in a single database table. Certain things, such as permission checking, need to be particularized for the specific type of content, though. Thus tags (or rather \u201ctaggable content\u201d) are registered as an object type definition. Posts are then registered as an object type, implementing the \u201ctaggable content\u201d behaviour.
Other types of object type definitions include attachments, likes, polls, subscriptions, or even the category system.
"},{"location":"package/pip/object-type-definition/#components","title":"Components","text":"Each item is described as a <definition>
element with the mandatory child <name>
that should follow the naming pattern <packageIdentifier>.<definition>
, e.g. com.woltlab.wcf.example
.
<interfacename>
","text":"Optional
The name of the PHP interface objectTypes have to implement.
"},{"location":"package/pip/object-type-definition/#example","title":"Example","text":"objectTypeDefinition.xml<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/objectTypeDefinition.xsd\">\n<import>\n<definition>\n<name>com.woltlab.wcf.example</name>\n<interfacename>wcf\\system\\example\\IExampleObjectType</interfacename>\n</definition>\n</import>\n</data>\n
"},{"location":"package/pip/object-type/","title":"Object Type Package Installation Plugin","text":"Registers an object type. Read about object types in the objectTypeDefinition PIP.
"},{"location":"package/pip/object-type/#components","title":"Components","text":"Each item is described as a <type>
element with the mandatory child <name>
that should follow the naming pattern <packageIdentifier>.<definition>
, e.g. com.woltlab.wcf.example
.
<definitionname>
","text":"The <name>
of the objectTypeDefinition.
<classname>
","text":"The name of the class providing the object types's behaviour, the class has to implement the <interfacename>
interface of the object type definition.
<*>
","text":"Optional
Additional fields may be defined for specific definitions of object types. Refer to the documentation of these for further explanation.
"},{"location":"package/pip/object-type/#example","title":"Example","text":"objectType.xml<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/objectType.xsd\">\n<import>\n<type>\n<name>com.woltlab.wcf.example</name>\n<definitionname>com.woltlab.wcf.bulkProcessing.user.condition</definitionname>\n<classname>wcf\\system\\condition\\UserIntegerPropertyCondition</classname>\n<conditiongroup>contents</conditiongroup>\n<propertyname>example</propertyname>\n<minvalue>0</minvalue>\n</type>\n</import>\n</data>\n
"},{"location":"package/pip/option/","title":"Option Package Installation Plugin","text":"Registers new options. Options allow the administrator to configure the behaviour of installed packages. The specified values are exposed as PHP constants.
"},{"location":"package/pip/option/#category-components","title":"Category Components","text":"Each category is described as an <category>
element with the mandatory attribute name
.
<parent>
","text":"Optional
The category\u2019s parent category.
"},{"location":"package/pip/option/#showorder","title":"<showorder>
","text":"Optional
Specifies the order of this option within the parent category.
"},{"location":"package/pip/option/#options","title":"<options>
","text":"Optional
The options element can contain a comma-separated list of options of which at least one needs to be enabled for the category to be shown to the administrator.
"},{"location":"package/pip/option/#option-components","title":"Option Components","text":"Each option is described as an <option>
element with the mandatory attribute name
. The name
is transformed into a PHP constant name by uppercasing it.
<categoryname>
","text":"The option\u2019s category.
"},{"location":"package/pip/option/#optiontype","title":"<optiontype>
","text":"The type of input to be used for this option. Valid types are defined by the wcf\\system\\option\\*OptionType
classes.
<defaultvalue>
","text":"The value that is set after installation of a package. Valid values are defined by the optiontype
.
<validationpattern>
","text":"Optional
Defines a regular expression that is used to validate the value of a free form option (such as text
).
<showorder>
","text":"Optional
Specifies the order of this option within the category.
"},{"location":"package/pip/option/#selectoptions","title":"<selectoptions>
","text":"Optional
Defined only for select
, multiSelect
and radioButton
types.
Specifies a newline-separated list of selectable values. Each line consists of an internal handle, followed by a colon (:
, U+003A), followed by a language item. The language item is shown to the administrator, the internal handle is what is saved and exposed to the code.
<enableoptions>
","text":"Optional
Defined only for boolean
, select
and radioButton
types.
Specifies a comma-separated list of options which should be visually enabled when this option is enabled. A leading exclamation mark (!
, U+0021) will disable the specified option when this option is enabled. For select
and radioButton
types the list should be prefixed by the internal selectoptions
handle followed by a colon (:
, U+003A).
This setting is a visual helper for the administrator only. It does not have an effect on the server side processing of the option.
"},{"location":"package/pip/option/#hidden","title":"<hidden>
","text":"Optional
If hidden
is set to 1
the option will not be shown to the administrator. It still can be modified programmatically.
<options>
","text":"Optional
The options element can contain a comma-separated list of options of which at least one needs to be enabled for the option to be shown to the administrator.
"},{"location":"package/pip/option/#supporti18n","title":"<supporti18n>
","text":"Optional
Specifies whether this option supports localized input.
"},{"location":"package/pip/option/#requirei18n","title":"<requirei18n>
","text":"Optional
Specifies whether this option requires localized input (i.e. the administrator must specify a value for every installed language).
"},{"location":"package/pip/option/#_1","title":"<*>
","text":"Optional
Additional fields may be defined by specific types of options. Refer to the documentation of these for further explanation.
"},{"location":"package/pip/option/#language-items","title":"Language Items","text":"All relevant language items have to be put into the wcf.acp.option
language item category.
If you install a category named example.sub
, you have to provide the language item wcf.acp.option.category.example.sub
, which is used when displaying the options. If you want to provide an optional description of the category, you have to provide the language item wcf.acp.option.category.example.sub.description
. Descriptions are only relevant for categories whose parent has a parent itself, i.e. categories on the third level.
If you install an option named module_example
, you have to provide the language item wcf.acp.option.module_example
, which is used as a label for setting the option value. If you want to provide an optional description of the option, you have to provide the language item wcf.acp.option.module_example.description
.
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/option.xsd\">\n<import>\n<categories>\n<category name=\"example\" />\n<category name=\"example.sub\">\n<parent>example</parent>\n<options>module_example</options>\n</category>\n</categories>\n\n<options>\n<option name=\"module_example\">\n<categoryname>module.community</categoryname>\n<optiontype>boolean</optiontype>\n<defaultvalue>1</defaultvalue>\n</option>\n\n<option name=\"example_integer\">\n<categoryname>example.sub</categoryname>\n<optiontype>integer</optiontype>\n<defaultvalue>10</defaultvalue>\n<minvalue>5</minvalue>\n<maxvalue>40</maxvalue>\n</option>\n\n<option name=\"example_select\">\n<categoryname>example.sub</categoryname>\n<optiontype>select</optiontype>\n<defaultvalue>DESC</defaultvalue>\n<selectoptions>ASC:wcf.global.sortOrder.ascending\n DESC:wcf.global.sortOrder.descending</selectoptions>\n</option>\n</options>\n</import>\n\n<delete>\n<option name=\"outdated_example\" />\n</delete>\n</data>\n
"},{"location":"package/pip/page/","title":"Page Package Installation Plugin","text":"Registers page controllers, making them available for selection and configuration, including but not limited to boxes and menus.
"},{"location":"package/pip/page/#components","title":"Components","text":"Each item is described as a <page>
element with the mandatory attribute identifier
that should follow the naming pattern <packageIdentifier>.<PageName>
, e.g. com.woltlab.wcf.MembersList
.
<pageType>
","text":""},{"location":"package/pip/page/#system","title":"system
","text":"The special system
type is reserved for pages that pull their properties and content from a registered PHP class. Requires the <controller>
element.
html
, text
or tpl
","text":"Provide arbitrary content, requires the <content>
element.
<controller>
","text":"Fully qualified class name for the controller, must implement wcf\\page\\IPage
or wcf\\form\\IForm
.
<handler>
","text":"Fully qualified class name that can be optionally set to provide additional methods, such as displaying a badge for unread content and verifying permissions per page object id.
"},{"location":"package/pip/page/#name","title":"<name>
","text":"The language
attribute is required and should specify the ISO-639-1 language code.
The internal name displayed in the admin panel only, can be fully customized by the administrator and is immutable. Only one value is accepted and will be picked based on the site's default language, but you can provide localized values by including multiple <name>
elements.
<parent>
","text":"Sets the default parent page using its internal identifier, this setting controls the breadcrumbs and active menu item hierarchy.
"},{"location":"package/pip/page/#hasfixedparent","title":"<hasFixedParent>
","text":"Pages can be assigned any other page as parent page by default, set to 1
to make the parent setting immutable.
<permissions>
","text":"The comma represents a logical or
, the check is successful if at least one permission is set.
Comma separated list of permission names that will be checked one after another until at least one permission is set.
"},{"location":"package/pip/page/#options","title":"<options>
","text":"The comma represents a logical or
, the check is successful if at least one option is enabled.
Comma separated list of options that will be checked one after another until at least one option is set.
"},{"location":"package/pip/page/#excludefromlandingpage","title":"<excludeFromLandingPage>
","text":"Some pages should not be used as landing page, because they may not always be available and/or accessible to the user. For example, the account management page is available to logged-in users only and any guest attempting to visit that page would be presented with a permission denied message.
Set this to 1
to prevent this page from becoming a landing page ever.
<requireObjectID>
","text":"If the page requires an id of a specific object, like the user profile page requires the id of the user whose profile page is requested, <requireObjectID>1</requireObjectID>
has to be added. If this item is not present, requireObjectID
defaults to 0
.
<availableDuringOfflineMode>
","text":"During offline mode, most pages should generally not be available. Certain pages, however, might still have to be accessible due to, for example, legal reasons. To make a page available during offline mode, <availableDuringOfflineMode>1</availableDuringOfflineMode>
has to be added. If this item is not present, availableDuringOfflineMode
defaults to 0
.
<allowSpidersToIndex>
","text":"Administrators are able to set in the admin panel for each page, whether or not spiders are allowed to index it. The default value for this option can be set with the allowSpidersToIndex
item whose value defaults to 0
.
<cssClassName>
","text":"To add custom CSS classes to a page\u2019s <body>
HTML element, you can specify them via the cssClassName
item.
If you want to add multiple CSS classes, separate them with spaces!
"},{"location":"package/pip/page/#content","title":"<content>
","text":"The language
attribute is required and should specify the ISO-639-1 language code.
<title>
","text":"The title element is required and controls the page title shown to the end users.
"},{"location":"package/pip/page/#content_1","title":"<content>
","text":"The content that should be used to populate the page, only used and required if the pageType
equals text
, html
and tpl
.
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/page.xsd\">\n<import>\n<page identifier=\"com.woltlab.wcf.MembersList\">\n<pageType>system</pageType>\n<controller>wcf\\page\\MembersListPage</controller>\n<name language=\"de\">Mitglieder</name>\n<name language=\"en\">Members</name>\n<options>module_members_list</options>\n<permissions>user.profile.canViewMembersList</permissions>\n<allowSpidersToIndex>1</allowSpidersToIndex>\n<content language=\"en\">\n<title>Members</title>\n</content>\n<content language=\"de\">\n<title>Mitglieder</title>\n</content>\n</page>\n</import>\n\n<delete>\n<page identifier=\"com.woltlab.wcf.MembersList\" />\n</delete>\n</data>\n
"},{"location":"package/pip/pip/","title":"Package Installation Plugin Package Installation Plugin","text":"Registers new package installation plugins.
"},{"location":"package/pip/pip/#components","title":"Components","text":"Each package installation plugin is described as an <pip>
element with a name
attribute and a PHP classname as the text content.
The package installation plugin\u2019s class file must be installed into the wcf
application and must not include classes outside the \\wcf\\*
hierarchy to allow for proper uninstallation!
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/packageInstallationPlugin.xsd\">\n<import>\n<pip name=\"custom\">wcf\\system\\package\\plugin\\CustomPackageInstallationPlugin</pip>\n</import>\n<delete>\n<pip name=\"outdated\" />\n</delete>\n</data>\n
"},{"location":"package/pip/script/","title":"Script Package Installation Plugin","text":"Execute arbitrary PHP code during installation, update and uninstallation of the package.
You must install the PHP script through the file package installation plugin.
The installation will attempt to delete the script after successful execution.
"},{"location":"package/pip/script/#attributes","title":"Attributes","text":""},{"location":"package/pip/script/#application","title":"application
","text":"The application
attribute must have the same value as the application
attribute of the file
package installation plugin instruction so that the correct file in the intended application directory is executed. For further information about the application
attribute, refer to its documentation on the acpTemplate package installation plugin page.
The script
-PIP expects a relative path to a .php
file.
The PHP script is deployed by using the file package installation plugin. To prevent it from colliding with other install script (remember: You cannot overwrite files created by another plugin), we highly recommend to make use of these naming conventions:
install_<package>.php
(example: install_com.woltlab.wbb.php
)update_<package>_<targetVersion>.php
(example: update_com.woltlab.wbb_5.0.0_pl_1.php
)<targetVersion>
equals the version number of the current package being installed. If you're updating from 1.0.0
to 1.0.1
, <targetVersion>
should read 1.0.1
.
The script is included using include()
within ScriptPackageInstallationPlugin::run(). This grants you access to the class members, including $this->installation
.
You can retrieve the package id of the current package through $this->installation->getPackageID()
.
Installs new smileys.
"},{"location":"package/pip/smiley/#components","title":"Components","text":"Each smiley is described as an <smiley>
element with the mandatory attribute name
.
<title>
","text":"Short human readable description of the smiley.
"},{"location":"package/pip/smiley/#path2x","title":"<path(2x)?>
","text":"The files must be installed using the file PIP.
File path relative to the root of WoltLab Suite Core. path2x
is optional and being used for High-DPI screens.
<aliases>
","text":"Optional
List of smiley aliases. Aliases must be separated by a line feed character (\\n
, U+000A).
<showorder>
","text":"Optional
Determines at which position of the smiley list the smiley is shown.
"},{"location":"package/pip/smiley/#example","title":"Example","text":"smiley.xml<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/smiley.xsd\">\n<import>\n<smiley name=\":example:\">\n<title>example</title>\n<path>images/smilies/example.png</path>\n<path2x>images/smilies/example@2x.png</path2x>\n<aliases><![CDATA[:alias:\n:more_aliases:]]></aliases>\n</smiley>\n</import>\n</data>\n
"},{"location":"package/pip/sql/","title":"SQL Package Installation Plugin","text":"Execute SQL instructions using a MySQL-flavored syntax.
This file is parsed by WoltLab Suite Core to allow reverting of certain changes, but not every syntax MySQL supports is recognized by the parser. To avoid any troubles, you should always use statements relying on the SQL standard.
"},{"location":"package/pip/sql/#expected-value","title":"Expected Value","text":"The sql
package installation plugin expects a relative path to a .sql
file.
WoltLab Suite Core uses a SQL parser to extract queries and log certain actions. This allows WoltLab Suite Core to revert some of the changes you apply upon package uninstallation.
The logged changes are:
CREATE TABLE
ALTER TABLE \u2026 ADD COLUMN
ALTER TABLE \u2026 ADD \u2026 KEY
It is possible to use different instance numbers, e.g. two separate WoltLab Suite Core installations within one database. WoltLab Suite Core requires you to always use wcf1_<tableName>
or <app>1_<tableName>
(e.g. blog1_blog
in WoltLab Suite Blog), the number (1
) will be automatically replaced prior to execution. If you every use anything other but 1
, you will eventually break things, thus always use 1
!
WoltLab Suite Core will determine the type of database tables on its own: If the table contains a FULLTEXT
index, it uses MyISAM
, otherwise InnoDB
is used.
WoltLab Suite Core cannot revert changes to the database structure which would cause to the data to be either changed or new data to be incompatible with the original format. Additionally, WoltLab Suite Core does not track regular SQL queries such as DELETE
or UPDATE
.
WoltLab Suite Core does not support trigger since MySQL does not support execution of triggers if the event was fired by a cascading foreign key action. If you really need triggers, you should consider adding them by custom SQL queries using a script.
"},{"location":"package/pip/sql/#example","title":"Example","text":"package.xml
:
<instruction type=\"sql\">install.sql</instruction>\n
Example content:
install.sqlCREATE TABLE wcf1_foo_bar (\nfooID INT(10) NOT NULL AUTO_INCREMENT PRIMARY KEY,\npackageID INT(10) NOT NULL,\nbar VARCHAR(255) NOT NULL DEFAULT '',\nfoobar VARCHAR(50) NOT NULL DEFAULT '',\n\nUNIQUE KEY baz (bar, foobar)\n);\n\nALTER TABLE wcf1_foo_bar ADD FOREIGN KEY (packageID) REFERENCES wcf1_package (packageID) ON DELETE CASCADE;\n
"},{"location":"package/pip/style/","title":"Style Package Installation Plugin","text":"Install styles during package installation.
The style
package installation plugins expects a relative path to a .tar
file, a.tar.gz
file or a .tgz
file. Please use the ACP's export mechanism to export styles.
package.xml
","text":"<instruction type=\"style\">style.tgz</instruction>\n
"},{"location":"package/pip/template-delete/","title":"Template Delete Package Installation Plugin","text":"Available since WoltLab Suite 5.5.
Deletes frontend templates installed with the template package installation plugin.
You cannot delete templates provided by other packages.
"},{"location":"package/pip/template-delete/#components","title":"Components","text":"Each item is described as a <template>
element with an optional application
, which behaves like it does for acp templates. The templates are identified by their name like when adding template listeners, i.e. by the file name without the .tpl
file extension.
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/templateDelete.xsd\">\n<delete>\n<template>fouAdd</template>\n<template application=\"app\">__appAdd</template>\n</delete>\n</data>\n
"},{"location":"package/pip/template-listener/","title":"Template Listener Package Installation Plugin","text":"Registers template listeners. Template listeners supplement event listeners, which modify server side behaviour, by adding additional template code to display additional elements. The added template code behaves as if it was part of the original template (i.e. it has access to all local variables).
"},{"location":"package/pip/template-listener/#components","title":"Components","text":"Each event listener is described as an <templatelistener>
element with a name
attribute. As the name
attribute has only be introduced with WSC 3.0, it is not yet mandatory to allow backwards compatibility. If name
is not given, the system automatically sets the name based on the id of the event listener in the database.
<templatename>
","text":"The template name is the name of the template in which the event is fired. It correspondes to the eventclassname
field of event listeners.
<eventname>
","text":"The event name is the name given when the event is fired to identify different events within the same template.
"},{"location":"package/pip/template-listener/#templatecode","title":"<templatecode>
","text":"The given template code is literally copied into the target template during compile time. The original template is not modified. If multiple template listeners listen to a single event their output is concatenated using the line feed character (\\n
, U+000A) in the order defined by the niceValue
.
It is recommend that the only code is an {include}
of a template to enable changes by the administrator. Names of templates included by a template listener start with two underscores by convention.
<environment>
","text":"The value of the environment element can either be admin
or user
and is user
if no value is given. The value determines if the template listener will be executed in the frontend (user
) or the backend (admin
).
<nice>
","text":"Optional
The nice value element can contain an integer value out of the interval [-128,127]
with 0
being the default value if the element is omitted. The nice value determines the execution order of template listeners. Template listeners with smaller nice values are executed first. If the nice value of two template listeners is equal, the order is undefined.
If you pass a value out of the mentioned interval, the value will be adjusted to the closest value in the interval.
"},{"location":"package/pip/template-listener/#options","title":"<options>
","text":"Optional
The options element can contain a comma-separated list of options of which at least one needs to be enabled for the template listener to be executed.
"},{"location":"package/pip/template-listener/#permissions","title":"<permissions>
","text":"Optional
The permissions element can contain a comma-separated list of permissions of which the active user needs to have at least one for the template listener to be executed.
"},{"location":"package/pip/template-listener/#example","title":"Example","text":"templateListener.xml<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/templatelistener.xsd\">\n<import>\n<templatelistener name=\"example\">\n<environment>user</environment>\n<templatename>headIncludeJavaScript</templatename>\n<eventname>javascriptInclude</eventname>\n<templatecode><![CDATA[{include file='__myCustomJavaScript'}]]></templatecode>\n</templatelistener>\n</import>\n\n<delete>\n<templatelistener name=\"oldTemplateListenerName\">\n<environment>user</environment>\n<templatename>headIncludeJavaScript</templatename>\n<eventname>javascriptInclude</eventname>\n</templatelistener>\n</delete>\n</data>\n
"},{"location":"package/pip/template/","title":"Template Package Installation Plugin","text":"Add templates for frontend pages and forms by providing an archive containing the template files.
You cannot overwrite templates provided by other packages.
This package installation plugin behaves exactly like the acpTemplate package installation plugin except for installing frontend templates instead of backend/acp templates.
"},{"location":"package/pip/user-group-option/","title":"User Group Option Package Installation Plugin","text":"Registers new user group options (\u201cpermissions\u201d). The behaviour of this package installation plugin closely follows the option PIP.
"},{"location":"package/pip/user-group-option/#category-components","title":"Category Components","text":"The category definition works exactly like the option PIP.
"},{"location":"package/pip/user-group-option/#option-components","title":"Option Components","text":"The fields hidden
, supporti18n
and requirei18n
do not apply. The following extra fields are defined:
<(admin|mod|user)defaultvalue>
","text":"Defines the defaultvalue
s for subsets of the groups:
admin.user.accessibleGroups
user group option includes every group. mod Groups where the mod.general.canUseModeration
is set to true
. user Groups where the internal group type is neither UserGroup::EVERYONE
nor UserGroup::GUESTS
."},{"location":"package/pip/user-group-option/#usersonly","title":"<usersonly>
","text":"Makes the option unavailable for groups with the group type UserGroup::GUESTS
.
All relevant language items have to be put into the wcf.acp.group
language item category.
If you install a category named user.foo
, you have to provide the language item wcf.acp.group.option.category.user.foo
, which is used when displaying the options. If you want to provide an optional description of the category, you have to provide the language item wcf.acp.group.option.category.user.foo.description
. Descriptions are only relevant for categories whose parent has a parent itself, i.e. categories on the third level.
If you install an option named user.foo.canBar
, you have to provide the language item wcf.acp.group.option.user.foo.canBar
, which is used as a label for setting the option value. If you want to provide an optional description of the option, you have to provide the language item wcf.acp.group.option.user.foo.canBar.description
.
Registers new user menu items.
"},{"location":"package/pip/user-menu/#components","title":"Components","text":"Each item is described as an <usermenuitem>
element with the mandatory attribute name
.
<parent>
","text":"Optional
The item\u2019s parent item.
"},{"location":"package/pip/user-menu/#showorder","title":"<showorder>
","text":"Optional
Specifies the order of this item within the parent item.
"},{"location":"package/pip/user-menu/#controller","title":"<controller>
","text":"The fully qualified class name of the target controller. If not specified this item serves as a category.
"},{"location":"package/pip/user-menu/#link","title":"<link>
","text":"Additional components if <controller>
is set, the full external link otherwise.
<iconclassname>
","text":"Use an icon only for top-level items.
Name of the Font Awesome icon class.
"},{"location":"package/pip/user-menu/#options","title":"<options>
","text":"Optional
The options element can contain a comma-separated list of options of which at least one needs to be enabled for the menu item to be shown.
"},{"location":"package/pip/user-menu/#permissions","title":"<permissions>
","text":"Optional
The permissions element can contain a comma-separated list of permissions of which the active user needs to have at least one for the menu item to be shown.
"},{"location":"package/pip/user-menu/#classname","title":"<classname>
","text":"The name of the class providing the user menu item\u2019s behaviour, the class has to implement the wcf\\system\\menu\\user\\IUserMenuItemProvider
interface.
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/userMenu.xsd\">\n<import>\n<usermenuitem name=\"wcf.user.menu.foo\">\n<iconclassname>fa-home</iconclassname>\n</usermenuitem>\n\n<usermenuitem name=\"wcf.user.menu.foo.bar\">\n<controller>wcf\\page\\FooBarListPage</controller>\n<parent>wcf.user.menu.foo</parent>\n<permissions>user.foo.canBar</permissions>\n<classname>wcf\\system\\menu\\user\\FooBarMenuItemProvider</classname>\n</usermenuitem>\n\n<usermenuitem name=\"wcf.user.menu.foo.baz\">\n<controller>wcf\\page\\FooBazListPage</controller>\n<parent>wcf.user.menu.foo</parent>\n<permissions>user.foo.canBaz</permissions>\n<options>module_foo_bar</options>\n</usermenuitem>\n</import>\n</data>\n
"},{"location":"package/pip/user-notification-event/","title":"User Notification Event Package Installation Plugin","text":"Registers new user notification events.
"},{"location":"package/pip/user-notification-event/#components","title":"Components","text":"Each package installation plugin is described as an <event>
element with the mandatory child <name>
.
<objectType>
","text":"The (name, objectType)
pair must be unique.
The given object type must implement the com.woltlab.wcf.notification.objectType
definition.
<classname>
","text":"The name of the class providing the event's behaviour, the class has to implement the wcf\\system\\user\\notification\\event\\IUserNotificationEvent
interface.
<preset>
","text":"Defines whether this event is enabled by default.
"},{"location":"package/pip/user-notification-event/#presetmailnotificationtype","title":"<presetmailnotificationtype>
","text":"Avoid using this option, as sending unsolicited mail can be seen as spamming.
One of instant
or daily
. Defines whether this type of email notifications is enabled by default.
<options>
","text":"Optional
The options element can contain a comma-separated list of options of which at least one needs to be enabled for the notification type to be available.
"},{"location":"package/pip/user-notification-event/#permissions","title":"<permissions>
","text":"Optional
The permissions element can contain a comma-separated list of permissions of which the active user needs to have at least one for the notification type to be available.
"},{"location":"package/pip/user-notification-event/#example","title":"Example","text":"userNotificationEvent.xml<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/userNotificationEvent.xsd\">\n<import>\n<event>\n<name>like</name>\n<objecttype>com.woltlab.example.comment.like.notification</objecttype>\n<classname>wcf\\system\\user\\notification\\event\\ExampleCommentLikeUserNotificationEvent</classname>\n<preset>1</preset>\n<options>module_like</options>\n</event>\n</import>\n</data>\n
"},{"location":"package/pip/user-option/","title":"User Option Package Installation Plugin","text":"Registers new user options (profile fields / user settings). The behaviour of this package installation plugin closely follows the option PIP.
"},{"location":"package/pip/user-option/#category-components","title":"Category Components","text":"The category definition works exactly like the option PIP.
"},{"location":"package/pip/user-option/#option-components","title":"Option Components","text":"The fields hidden
, supporti18n
and requirei18n
do not apply. The following extra fields are defined:
<required>
","text":"Requires that a value is provided.
"},{"location":"package/pip/user-option/#askduringregistration","title":"<askduringregistration>
","text":"If set to 1
the field is shown during user registration in the frontend.
<editable>
","text":"Bitfield with the following options (constants in wcf\\data\\user\\option\\UserOption
)
<visible>
","text":"Bitfield with the following options (constants in wcf\\data\\user\\option\\UserOption
)
<searchable>
","text":"If set to 1
the field is searchable.
<outputclass>
","text":"PHP class responsible for output formatting of this field. the class has to implement the wcf\\system\\option\\user\\IUserOptionOutput
interface.
All relevant language items have to be put into the wcf.user.option
language item category.
If you install a category named example
, you have to provide the language item wcf.user.option.category.example
, which is used when displaying the options. If you want to provide an optional description of the category, you have to provide the language item wcf.user.option.category.example.description
. Descriptions are only relevant for categories whose parent has a parent itself, i.e. categories on the third level.
If you install an option named exampleOption
, you have to provide the language item wcf.user.option.exampleOption
, which is used as a label for setting the option value. If you want to provide an optional description of the option, you have to provide the language item wcf.user.option.exampleOption.description
.
Registers new user profile tabs.
"},{"location":"package/pip/user-profile-menu/#components","title":"Components","text":"Each tab is described as an <userprofilemenuitem>
element with the mandatory attribute name
.
<classname>
","text":"The name of the class providing the tab\u2019s behaviour, the class has to implement the wcf\\system\\menu\\user\\profile\\content\\IUserProfileMenuContent
interface.
<showorder>
","text":"Optional
Determines at which position of the tab list the tab is shown.
"},{"location":"package/pip/user-profile-menu/#options","title":"<options>
","text":"Optional
The options element can contain a comma-separated list of options of which at least one needs to be enabled for the tab to be shown.
"},{"location":"package/pip/user-profile-menu/#permissions","title":"<permissions>
","text":"Optional
The permissions element can contain a comma-separated list of permissions of which the active user needs to have at least one for the tab to be shown.
"},{"location":"package/pip/user-profile-menu/#example","title":"Example","text":"userProfileMenu.xml<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/userProfileMenu.xsd\">\n<import>\n<userprofilemenuitem name=\"example\">\n<classname>wcf\\system\\menu\\user\\profile\\content\\ExampleProfileMenuContent</classname>\n<showorder>3</showorder>\n<options>module_example</options>\n</userprofilemenuitem>\n</import>\n</data>\n
"},{"location":"php/apps/","title":"Apps for WoltLab Suite","text":""},{"location":"php/apps/#introduction","title":"Introduction","text":"Apps are among the most powerful components in WoltLab Suite. Unlike plugins that extend an existing functionality and pages, apps have their own frontend with a dedicated namespace, database table prefixes and template locations.
However, apps are meant to be a logical (and to some extent physical) separation from other parts of the framework, including other installed apps. They offer an additional layer of isolation and enable you to re-use class and template names that are already in use by the Core itself.
If you've come here, thinking about the question if your next package should be an app instead of a regular plugin, the result is almost always: No.
"},{"location":"php/apps/#differences-to-plugins","title":"Differences to Plugins","text":"Apps do offer a couple of unique features that are not available to plugins and there are valid reasons to use one instead of a plugin, but they also increase both the code and system complexity. There is a performance penalty for each installed app, regardless if it is actively used in a request or not, simplying being there forces the Core to include it in many places, for example, class resolution or even simple tasks such as constructing a link.
"},{"location":"php/apps/#unique-namespace","title":"Unique Namespace","text":"Each app has its own unique namespace that is entirely separated from the Core and any other installed apps. The namespace is derived from the last part of the package identifier, for example, com.example.foo
will yield the namespace foo
.
The namespace is always relative to the installation directory of the app, it doesn't matter if the app is installed on example.com/foo/
or in example.com/bar/
, the namespace will always resolve to the right directory.
This app namespace is also used for ACP templates, frontend templates and files:
<!-- somewhere in the package.xml -->\n<instructions type=\"file\" application=\"foo\" />\n
"},{"location":"php/apps/#unique-database-table-prefix","title":"Unique Database Table Prefix","text":"All database tables make use of a generic prefix that is derived from one of the installed apps, including wcf
which resolves to the Core itself. Following the aforementioned example, the new prefix fooN_
will be automatically registered and recognized in any generated statement.
Any DatabaseObject
that uses the app's namespace is automatically assumed to use the app's database prefix. For instance, foo\\data\\bar\\Bar
is implicitly mapped to the database table fooN_bar
.
The app prefix is recognized in SQL-PIPs and statements that reference one of its database tables are automatically rewritten to use the Core's instance number.
"},{"location":"php/apps/#separate-domain-and-path-configuration","title":"Separate Domain and Path Configuration","text":"Any controller that is provided by a plugin is served from the configured domain and path of the corresponding app, such as plugins for the Core are always served from the Core's directory. Apps are different and use their own domain and/or path to present their content, additionally, this allows the app to re-use a controller name that is already provided by the Core or any other app itself.
"},{"location":"php/apps/#creating-an-app","title":"Creating an App","text":"This is a non-reversible operation! Once a package has been installed, its type cannot be changed without uninstalling and reinstalling the entire package, an app will always be an app and vice versa.
"},{"location":"php/apps/#packagexml","title":"package.xml
","text":"The package.xml
supports two additional elements in the <packageinformation>
block that are unique to applications.
<isapplication>1</isapplication>
","text":"This element is responsible to flag a package as an app.
"},{"location":"php/apps/#applicationdirectoryexampleapplicationdirectory","title":"<applicationdirectory>example</applicationdirectory>
","text":"Sets the suggested name of the application directory when installing it, the path result in <path-to-the-core>/example/
. If you leave this element out, the app identifier (com.example.foo -> foo
) will be used instead.
An example project with the source code can be found on GitHub, it includes everything that is required for a basic app.
"},{"location":"php/code-style-documentation/","title":"Documentation","text":"The following documentation conventions are used by us for our own packages. While you do not have to follow every rule, you are encouraged to do so.
"},{"location":"php/code-style-documentation/#database-objects","title":"Database Objects","text":""},{"location":"php/code-style-documentation/#database-table-columns-as-properties","title":"Database Table Columns as Properties","text":"As the database table columns are not explicit properties of the classes extending wcf\\data\\DatabaseObject
but rather stored in DatabaseObject::$data
and accessible via DatabaseObject::__get($name)
, the IDE we use, PhpStorm, is neither able to autocomplete such property access nor to interfere the type of the property.
To solve this problem, @property-read
tags must be added to the class documentation which registers the database table columns as public read-only properties:
* @property-read propertyType $propertyName property description\n
The properties have to be in the same order as the order in the database table.
The following table provides templates for common description texts so that similar database table columns have similar description texts.
property description template and example unique object idunique id of the {object name}
example: unique id of the acl option
id of the delivering package id of the package which delivers the {object name}
example: id of the package which delivers the acl option
show order for nested structure position of the {object name} in relation to its siblings
example: position of the ACP menu item in relation to its siblings
show order within different object position of the {object name} in relation to the other {object name}s in the {parent object name}
example: position of the label in relation to the other labels in the label group
required permissions comma separated list of user group permissions of which the active user needs to have at least one to see (access, \u2026) the {object name}
example:comma separated list of user group permissions of which the active user needs to have at least one to see the ACP menu item
required options comma separated list of options of which at least one needs to be enabled for the {object name} to be shown (accessible, \u2026)
example:comma separated list of options of which at least one needs to be enabled for the ACP menu item to be shown
id of the user who has created the object id of the user who created (wrote, \u2026) the {object name} (or `null` if the user does not exist anymore (or if the {object name} has been created by a guest))
example:id of the user who wrote the comment or `null` if the user does not exist anymore or if the comment has been written by a guest
name of the user who has created the object name of the user (or guest) who created (wrote, \u2026) the {object name}
example:name of the user or guest who wrote the comment
additional data array with additional data of the {object name}
example:array with additional data of the user activity event
time-related columns timestamp at which the {object name} has been created (written, \u2026)
example:timestamp at which the comment has been written
boolean options is `1` (or `0`) if the {object name} \u2026 (and thus \u2026), otherwise `0` (or `1`)
example:is `1` if the ad is disabled and thus not shown, otherwise `0`
$cumulativeLikes
cumulative result of likes (counting `+1`) and dislikes (counting `-1`) for the {object name}
example:cumulative result of likes (counting `+1`) and dislikes (counting `-1`) for the article
$comments
number of comments on the {object name}
example:number of comments on the article
$views
number of times the {object name} has been viewed
example:number of times the article has been viewed
text field with potential language item name as value {text type} of the {object name} or name of language item which contains the {text type}
example:description of the cronjob or name of language item which contains the description
$objectTypeID
id of the `{object type definition name}` object type
example:id of the `com.woltlab.wcf.modifiableContent` object type
"},{"location":"php/code-style-documentation/#database-object-editors","title":"Database Object Editors","text":""},{"location":"php/code-style-documentation/#class-tags","title":"Class Tags","text":"Any database object editor class comment must have to following tags to properly support autocompletion by IDEs:
/**\n * \u2026\n * @method static {DBO class name} create(array $parameters = [])\n * @method {DBO class name} getDecoratedObject()\n * @mixin {DBO class name}\n */\n
The only exception to this rule is if the class overwrites the create()
method which itself has to be properly documentation then.
The first and second line makes sure that when calling the create()
or getDecoratedObject()
method, the return value is correctly recognized and not just a general DatabaseObject
instance. The third line tells the IDE (if @mixin
is supported) that the database object editor decorates the database object and therefore offers autocompletion for properties and methods from the database object class itself.
Any class implementing the IRuntimeCache interface must have the following class tags:
/**\n * \u2026\n * @method {DBO class name}[] getCachedObjects()\n * @method {DBO class name} getObject($objectID)\n * @method {DBO class name}[] getObjects(array $objectIDs)\n */\n
These tags ensure that when calling any of the mentioned methods, the return value refers to the concrete database object and not just generically to DatabaseObject.
"},{"location":"php/code-style/","title":"Code Style","text":"The following code style conventions are used by us for our own packages. While you do not have to follow every rule, you are encouraged to do so.
For information about how to document your code, please refer to the documentation page.
"},{"location":"php/code-style/#general-code-style","title":"General Code Style","text":""},{"location":"php/code-style/#naming-conventions","title":"Naming conventions","text":"The relevant naming conventions are:
$variableName
Class upper camel case class UserGroupEditor
Properties lower camel case public $propertyName
Method lower camel case public function getObjectByName()
Constant screaming snake case MODULE_USER_THING
"},{"location":"php/code-style/#arrays","title":"Arrays","text":"For arrays, use the short array syntax introduced with PHP 5.4. The following example illustrates the different cases that can occur when working with arrays and how to format them:
<?php\n\n$empty = [];\n\n$oneElement = [1];\n$multipleElements = [1, 2, 3];\n\n$oneElementWithKey = ['firstElement' => 1];\n$multipleElementsWithKey = [\n 'firstElement' => 1,\n 'secondElement' => 2,\n 'thirdElement' => 3\n];\n
"},{"location":"php/code-style/#ternary-operator","title":"Ternary Operator","text":"The ternary operator can be used for short conditioned assignments:
<?php\n\n$name = isset($tagArgs['name']) ? $tagArgs['name'] : 'default';\n
The condition and the values should be short so that the code does not result in a very long line which thus decreases the readability compared to an if-else
statement.
Parentheses may only be used around the condition and not around the whole statement:
<?php\n\n// do not do it like this\n$name = (isset($tagArgs['name']) ? $tagArgs['name'] : 'default');\n
Parentheses around the conditions may not be used to wrap simple function calls:
<?php\n\n// do not do it like this\n$name = (isset($tagArgs['name'])) ? $tagArgs['name'] : 'default';\n
but have to be used for comparisons or other binary operators:
<?php\n\n$value = ($otherValue > $upperLimit) ? $additionalValue : $otherValue;\n
If you need to use more than one binary operator, use an if-else
statement.
The same rules apply to assigning array values:
<?php\n\n$values = [\n 'first' => $firstValue,\n 'second' => $secondToggle ? $secondValueA : $secondValueB,\n 'third' => ($thirdToogle > 13) ? $thirdToogleA : $thirdToogleB\n];\n
or return values:
<?php\n\nreturn isset($tagArgs['name']) ? $tagArgs['name'] : 'default';\n
"},{"location":"php/code-style/#whitespaces","title":"Whitespaces","text":"You have to put a whitespace in front of the following things:
$x = 1;
$x == 1
public function test() {
You have to put a whitespace behind the following things:
$x = 1;
$x == 1
public function test($a, $b) {
if
, for
, foreach
, while
: if ($x == 1)
If you have to reference a class name inside a php file, you have to use the class
keyword.
<?php\n\n// not like this\n$className = 'wcf\\data\\example\\Example';\n\n// like this\nuse wcf\\data\\example\\Example;\n$className = Example::class;\n
"},{"location":"php/code-style/#static-getters-of-databaseobject-classes","title":"Static Getters (of DatabaseObject
Classes)","text":"Some database objects provide static getters, either if they are decorators or for a unique combination of database table columns, like wcf\\data\\box\\Box::getBoxByIdentifier()
:
<?php\nnamespace wcf\\data\\box;\nuse wcf\\data\\DatabaseObject;\nuse wcf\\system\\WCF;\n\nclass Box extends DatabaseObject {\n /**\n * Returns the box with the given identifier.\n *\n * @param string $identifier\n * @return Box|null\n */\n public static function getBoxByIdentifier($identifier) {\n $sql = \"SELECT *\n FROM wcf1_box\n WHERE identifier = ?\";\n $statement = WCF::getDB()->prepare($sql);\n $statement->execute([$identifier]);\n\n return $statement->fetchObject(self::class);\n }\n}\n
Such methods should always either return the desired object or null
if the object does not exist. wcf\\system\\database\\statement\\PreparedStatement::fetchObject()
already takes care of this distinction so that its return value can simply be returned by such methods.
The name of such getters should generally follow the convention get{object type}By{column or other description}
.
In some instances, methods with many argument have to be called which can result in lines of code like this one:
<?php\n\n\\wcf\\system\\search\\SearchIndexManager::getInstance()->set('com.woltlab.wcf.article', $articleContent->articleContentID, $articleContent->content, $articleContent->title, $articles[$articleContent->articleID]->time, $articles[$articleContent->articleID]->userID, $articles[$articleContent->articleID]->username, $articleContent->languageID, $articleContent->teaser);\n
which is hardly readable. Therefore, the line must be split into multiple lines with each argument in a separate line:
<?php\n\n\\wcf\\system\\search\\SearchIndexManager::getInstance()->set(\n 'com.woltlab.wcf.article',\n $articleContent->articleContentID,\n $articleContent->content,\n $articleContent->title,\n $articles[$articleContent->articleID]->time,\n $articles[$articleContent->articleID]->userID,\n $articles[$articleContent->articleID]->username,\n $articleContent->languageID,\n $articleContent->teaser\n);\n
In general, this rule applies to the following methods:
wcf\\system\\edit\\EditHistoryManager::add()
wcf\\system\\message\\quote\\MessageQuoteManager::addQuote()
wcf\\system\\message\\quote\\MessageQuoteManager::getQuoteID()
wcf\\system\\search\\SearchIndexManager::set()
wcf\\system\\user\\object\\watch\\UserObjectWatchHandler::updateObject()
wcf\\system\\user\\notification\\UserNotificationHandler::fireEvent()
Database Objects provide a convenient and object-oriented approach to work with the database, but there can be use-cases that require raw access including writing methods for model classes. This section assumes that you have either used prepared statements before or at least understand how it works.
"},{"location":"php/database-access/#the-preparedstatement-object","title":"The PreparedStatement Object","text":"The database access is designed around PreparedStatement, built on top of PHP's PDOStatement
so that you call all of PDOStatement
's methods, and each query requires you to obtain a statement object.
<?php\n$statement = \\wcf\\system\\WCF::getDB()->prepare(\"SELECT * FROM wcf1_example\");\n$statement->execute();\nwhile ($row = $statement->fetchArray()) {\n // handle result\n}\n
"},{"location":"php/database-access/#query-parameters","title":"Query Parameters","text":"The example below illustrates the usage of parameters where each value is replaced with the generic ?
-placeholder. Values are provided by calling $statement->execute()
with a continuous, one-dimensional array that exactly match the number of question marks.
<?php\n$sql = \"SELECT *\n FROM wcf1_example\n WHERE exampleID = ?\n OR bar IN (?, ?, ?, ?, ?)\";\n$statement = \\wcf\\system\\WCF::getDB()->prepare($sql);\n$statement->execute([\n $exampleID,\n $list, $of, $values, $for, $bar\n]);\nwhile ($row = $statement->fetchArray()) {\n // handle result\n}\n
"},{"location":"php/database-access/#fetching-a-single-result","title":"Fetching a Single Result","text":"Do not attempt to use fetchSingleRow()
or fetchSingleColumn()
if the result contains more than one row.
You can opt-in to retrieve only a single row from database and make use of shortcut methods to reduce the code that you have to write.
<?php\n$sql = \"SELECT *\n FROM wcf1_example\n WHERE exampleID = ?\";\n$statement = \\wcf\\system\\WCF::getDB()->prepare($sql, 1);\n$statement->execute([$exampleID]);\n$row = $statement->fetchSingleRow();\n
There are two distinct differences when comparing with the example on query parameters above:
prepare()
receives a secondary parameter that will be appended to the query as LIMIT 1
.fetchSingleRow()
instead of fetchArray()
or similar methods, that will read one result and close the cursor.There is no way to return another column from the same row if you use fetchColumn()
to retrieve data.
Fetching an array is only useful if there is going to be more than one column per result row, otherwise accessing the column directly is much more convenient and increases the code readability.
<?php\n$sql = \"SELECT bar\n FROM wcf1_example\";\n$statement = \\wcf\\system\\WCF::getDB()->prepare($sql);\n$statement->execute();\nwhile ($bar = $statement->fetchColumn()) {\n // handle result\n}\n$bar = $statement->fetchSingleColumn();\n
Similar to fetching a single row, you can also issue a query that will select a single row, but reads only one column from the result row.
<?php\n$sql = \"SELECT bar\n FROM wcf1_example\n WHERE exampleID = ?\";\n$statement = \\wcf\\system\\WCF::getDB()->prepare($sql, 1);\n$statement->execute([$exampleID]);\n$bar = $statement->fetchSingleColumn();\n
"},{"location":"php/database-access/#fetching-all-results","title":"Fetching All Results","text":"If you want to fetch all results of a query but only store them in an array without directly processing them, in most cases, you can rely on built-in methods.
To fetch all rows of query, you can use PDOStatement::fetchAll()
with \\PDO::FETCH_ASSOC
as the first parameter:
<?php\n$sql = \"SELECT *\n FROM wcf1_example\";\n$statement = \\wcf\\system\\WCF::getDB()->prepare($sql);\n$statement->execute();\n$rows = $statement->fetchAll(\\PDO::FETCH_ASSOC);\n
As a result, you get an array containing associative arrays with the rows of the wcf{WCF_N}_example
database table as content.
If you only want to fetch a list of the values of a certain column, you can use \\PDO::FETCH_COLUMN
as the first parameter:
<?php\n$sql = \"SELECT exampleID\n FROM wcf1_example\";\n$statement = \\wcf\\system\\WCF::getDB()->prepare($sql);\n$statement->execute();\n$exampleIDs = $statement->fetchAll(\\PDO::FETCH_COLUMN);\n
As a result, you get an array with all exampleID
values.
The PreparedStatement
class adds an additional methods that covers another common use case in our code: Fetching two columns and using the first column's value as the array key and the second column's value as the array value. This case is covered by PreparedStatement::fetchMap()
:
<?php\n$sql = \"SELECT exampleID, userID\n FROM wcf1_example_mapping\";\n$statement = \\wcf\\system\\WCF::getDB()->prepare($sql);\n$statement->execute();\n$map = $statement->fetchMap('exampleID', 'userID');\n
$map
is a one-dimensional array where each exampleID
value maps to the corresponding userID
value.
If there are multiple entries for a certain exampleID
value with different userID
values, the existing entry in the array will be overwritten and contain the last read value from the database table. Therefore, this method should generally only be used for unique combinations.
If you do not have a combination of columns with unique pairs of values, but you want to get a list of userID
values with the same exampleID
, you can set the third parameter of fetchMap()
to false
and get a list:
<?php\n$sql = \"SELECT exampleID, userID\n FROM wcf1_example_mapping\";\n$statement = \\wcf\\system\\WCF::getDB()->prepare($sql);\n$statement->execute();\n$map = $statement->fetchMap('exampleID', 'userID', false);\n
Now, as a result, you get a two-dimensional array with the array keys being the exampleID
values and the array values being arrays with all userID
values from rows with the respective exampleID
value.
Building conditional conditions can turn out to be a real mess and it gets even worse with SQL's IN (\u2026)
which requires as many placeholders as there will be values. The solutions is PreparedStatementConditionBuilder
, a simple but useful helper class with a bulky name, it is also the class used when accessing DatabaseObjecList::getConditionBuilder()
.
<?php\n$conditions = new \\wcf\\system\\database\\util\\PreparedStatementConditionBuilder();\n$conditions->add(\"exampleID = ?\", [$exampleID]);\nif (!empty($valuesForBar)) {\n $conditions->add(\"(bar IN (?) OR baz = ?)\", [$valuesForBar, $baz]);\n}\n
The IN (?)
in the example above is automatically expanded to match the number of items contained in $valuesForBar
. Be aware that the method will generate an invalid query if $valuesForBar
is empty!
Prepared statements not only protect against SQL injection by separating the logical query and the actual data, but also provides the ability to reuse the same query with different values. This leads to a performance improvement as the code does not have to transmit the query with for every data set and only has to parse and analyze the query once.
<?php\n$data = ['abc', 'def', 'ghi'];\n\n$sql = \"INSERT INTO wcf1_example\n (bar)\n VALUES (?)\";\n$statement = \\wcf\\system\\WCF::getDB()->prepare($sql);\n\n\\wcf\\system\\WCF::getDB()->beginTransaction();\nforeach ($data as $bar) {\n $statement->execute([$bar]);\n}\n\\wcf\\system\\WCF::getDB()->commitTransaction();\n
It is generally advised to wrap bulk operations in a transaction as it allows the database to optimize the process, including fewer I/O operations.
<?php\n$data = [\n 1 => 'abc',\n 3 => 'def',\n 4 => 'ghi'\n];\n\n$sql = \"UPDATE wcf1_example\n SET bar = ?\n WHERE exampleID = ?\";\n$statement = \\wcf\\system\\WCF::getDB()->prepare($sql);\n\n\\wcf\\system\\WCF::getDB()->beginTransaction();\nforeach ($data as $exampleID => $bar) {\n $statement->execute([\n $bar,\n $exampleID\n ]);\n}\n\\wcf\\system\\WCF::getDB()->commitTransaction();\n
"},{"location":"php/database-objects/","title":"Database Objects","text":"WoltLab Suite uses a unified interface to work with database rows using an object based approach instead of using native arrays holding arbitrary data. Each database table is mapped to a model class that is designed to hold a single record from that table and expose methods to work with the stored data, for example providing assistance when working with normalized datasets.
Developers are required to provide the proper DatabaseObject implementations themselves, they're not automatically generated, all though the actual code that needs to be written is rather small. The following examples assume the fictional database table wcf1_example
, exampleID
as the auto-incrementing primary key and the column bar
to store some text.
The basic model derives from wcf\\data\\DatabaseObject
and provides a convenient constructor to fetch a single row or construct an instance using pre-loaded rows.
<?php\nnamespace wcf\\data\\example;\nuse wcf\\data\\DatabaseObject;\n\nclass Example extends DatabaseObject {}\n
The class is intended to be empty by default and there only needs to be code if you want to add additional logic to your model. Both the class name and primary key are determined by DatabaseObject
using the namespace and class name of the derived class. The example above uses the namespace wcf\\\u2026
which is used as table prefix and the class name Example
is converted into exampleID
, resulting in the database table name wcfN_example
with the primary key exampleID
.
You can prevent this automatic guessing by setting the class properties $databaseTableName
and $databaseTableIndexName
manually.
If you already have a DatabaseObject
class and would like to extend it with additional data or methods, for example by providing a class ViewableExample
which features view-related changes without polluting the original object, you can use DatabaseObjectDecorator
which a default implementation of a decorator for database objects.
<?php\nnamespace wcf\\data\\example;\nuse wcf\\data\\DatabaseObjectDecorator;\n\nclass ViewableExample extends DatabaseObjectDecorator {\n protected static $baseClass = Example::class;\n\n public function getOutput() {\n $output = '';\n\n // [determine output]\n\n return $output;\n }\n}\n
It is mandatory to set the static $baseClass
property to the name of the decorated class.
Like for any decorator, you can directly access the decorated object's properties and methods for a decorated object by accessing the property or calling the method on the decorated object. You can access the decorated objects directly via DatabaseObjectDecorator::getDecoratedObject()
.
This is the low-level interface to manipulate data rows, it is recommended to use AbstractDatabaseObjectAction
.
Adding, editing and deleting models is done using the DatabaseObjectEditor
class that decorates a DatabaseObject
and uses its data to perform the actions.
<?php\nnamespace wcf\\data\\example;\nuse wcf\\data\\DatabaseObjectEditor;\n\nclass ExampleEditor extends DatabaseObjectEditor {\n protected static $baseClass = Example::class;\n}\n
The editor class requires you to provide the fully qualified name of the model, that is the class name including the complete namespace. Database table name and index key will be pulled directly from the model.
"},{"location":"php/database-objects/#create-a-new-row","title":"Create a new row","text":"Inserting a new row into the database table is provided through DatabaseObjectEditor::create()
which yields a DatabaseObject
instance after creation.
<?php\n$example = \\wcf\\data\\example\\ExampleEditor::create([\n 'bar' => 'Hello World!'\n]);\n\n// output: Hello World!\necho $example->bar;\n
"},{"location":"php/database-objects/#updating-an-existing-row","title":"Updating an existing row","text":"The internal state of the decorated DatabaseObject
is not altered at any point, the values will still be the same after editing or deleting the represented row. If you need an object with the latest data, you'll have to discard the current object and refetch the data from database.
<?php\n$example = new \\wcf\\data\\example\\Example($id);\n$exampleEditor = new \\wcf\\data\\example\\ExampleEditor($example);\n$exampleEditor->update([\n 'bar' => 'baz'\n]);\n\n// output: Hello World!\necho $example->bar;\n\n// re-creating the object will query the database again and retrieve the updated value\n$example = new \\wcf\\data\\example\\Example($example->id);\n\n// output: baz\necho $example->bar;\n
"},{"location":"php/database-objects/#deleting-a-row","title":"Deleting a row","text":"Similar to the update process, the decorated DatabaseObject
is not altered and will then point to an inexistent row.
<?php\n$example = new \\wcf\\data\\example\\Example($id);\n$exampleEditor = new \\wcf\\data\\example\\ExampleEditor($example);\n$exampleEditor->delete();\n
"},{"location":"php/database-objects/#databaseobjectlist","title":"DatabaseObjectList","text":"Every row is represented as a single instance of the model, but the instance creation deals with single rows only. Retrieving larger sets of rows would be quite inefficient due to the large amount of queries that will be dispatched. This is solved with the DatabaseObjectList
object that exposes an interface to query the database table using arbitrary conditions for data selection. All rows will be fetched using a single query and the resulting rows are automatically loaded into separate models.
<?php\nnamespace wcf\\data\\example;\nuse wcf\\data\\DatabaseObjectList;\n\nclass ExampleList extends DatabaseObjectList {\n public $className = Example::class;\n}\n
The following code listing illustrates loading a large set of examples and iterating over the list to retrieve the objects.
<?php\n$exampleList = new \\wcf\\data\\example\\ExampleList();\n// add constraints using the condition builder\n$exampleList->getConditionBuilder()->add('bar IN (?)', [['Hello World!', 'bar', 'baz']]);\n// actually read the rows\n$exampleList->readObjects();\nforeach ($exampleList as $example) {\n echo $example->bar;\n}\n\n// retrieve the models directly instead of iterating over them\n$examples = $exampleList->getObjects();\n\n// just retrieve the number of rows\n$exampleCount = $exampleList->countObjects();\n
DatabaseObjectList
implements both SeekableIterator and Countable.
Additionally, DatabaseObjectList
objects has the following three public properties that are useful when fetching data with lists:
$sqlLimit
determines how many rows are fetched. If its value is 0
(which is the default value), all results are fetched. So be careful when dealing with large tables and you only want a limited number of rows: Set $sqlLimit
to a value larger than zero!$sqlOffset
: Paginated pages like a thread list use this feature a lot, it allows you to skip a given number of results. Imagine you want to display 20 threads per page but there are a total of 60 threads available. In this case you would specify $sqlLimit = 20
and $sqlOffset = 20
which will skip the first 20 threads, effectively displaying thread 21 to 40.$sqlOrderBy
determines by which column(s) the rows are sorted in which order. Using our example in $sqlOffset
you might want to display the 20 most recent threads on page 1, thus you should specify the order field and its direction, e.g. $sqlOrderBy = 'thread.lastPostTime DESC'
which returns the most recent thread first.For more advanced usage, there two additional fields that deal with the type of objects returned. First, let's go into a bit more detail what setting the $className
property actually does:
$databaseTableName
and $databaseTableIndexName
properties of DatabaseObject
).Sometimes you might use the database table of some database object but wrap the rows in another database object. This can be achieved by setting the $objectClassName
property to the desired class name.
In other cases, you might want to wrap the created objects in a database object decorator which can be done by setting the $decoratorClassName
property to the desired class name:
<?php\n$exampleList = new \\wcf\\data\\example\\ExampleList();\n$exampleList->decoratorClassName = \\wcf\\data\\example\\ViewableExample::class;\n
Of course, you do not have to set the property after creating the list object, you can also set it by creating a dedicated class:
files/lib/data/example/ViewableExampleList.class.php<?php\nnamespace wcf\\data\\example;\n\nclass ViewableExampleList extends ExampleList {\n public $decoratorClassName = ViewableExample::class;\n}\n
"},{"location":"php/database-objects/#abstractdatabaseobjectaction","title":"AbstractDatabaseObjectAction","text":"Row creation and manipulation can be performed using the aforementioned DatabaseObjectEditor
class, but this approach has two major issues:
The AbstractDatabaseObjectAction
solves both problems by wrapping around the editor class and thus provide an additional layer between the action that should be taken and the actual process. The first problem is solved by a fixed set of events being fired, the second issue is addressed by having a single entry point for all data editing.
<?php\nnamespace wcf\\data\\example;\nuse wcf\\data\\AbstractDatabaseObjectAction;\n\nclass ExampleAction extends AbstractDatabaseObjectAction {\n public $className = ExampleEditor::class;\n}\n
"},{"location":"php/database-objects/#executing-an-action","title":"Executing an Action","text":"The method AbstractDatabaseObjectAction::validateAction()
is internally used for AJAX method invocation and must not be called programmatically.
The next example represents the same functionality as seen for DatabaseObjectEditor
:
<?php\nuse wcf\\data\\example\\ExampleAction;\n\n// create a row\n$exampleAction = new ExampleAction([], 'create', [\n 'data' => ['bar' => 'Hello World']\n]);\n$example = $exampleAction->executeAction()['returnValues'];\n\n// update a row using the id\n$exampleAction = new ExampleAction([1], 'update', [\n 'data' => ['bar' => 'baz']\n]);\n$exampleAction->executeAction();\n\n// delete a row using a model\n$exampleAction = new ExampleAction([$example], 'delete');\n$exampleAction->executeAction();\n
You can access the return values both by storing the return value of executeAction()
or by retrieving it via getReturnValues()
.
Events initializeAction
, validateAction
and finalizeAction
The AJAX interface of database object actions is considered soft-deprecated in WoltLab Suite 6.0. Instead a dedicated PSR-15 controller should be used. Please refer to the migration guide.
This section is about adding the method baz()
to ExampleAction
and calling it via AJAX.
Methods of an action cannot be called via AJAX, unless they have a validation method. This means that ExampleAction
must define both a public function baz()
and public function validateBaz()
, the name for the validation method is constructed by upper-casing the first character of the method name and prepending validate
.
The lack of the companion validate*
method will cause the AJAX proxy to deny the request instantaneously. Do not add a validation method if you don't want it to be callable via AJAX ever!
The methods create
, update
and delete
are available for all classes deriving from AbstractDatabaseObjectAction
and directly pass the input data to the DatabaseObjectEditor
. These methods deny access to them via AJAX by default, unless you explicitly enable access. Depending on your case, there are two different strategies to enable AJAX access to them.
<?php\nnamespace wcf\\data\\example;\nuse wcf\\data\\AbstractDatabaseObjectAction;\n\nclass ExampleAction extends AbstractDatabaseObjectAction {\n // `create()` can now be called via AJAX if the requesting user posses the listed permissions\n protected $permissionsCreate = ['admin.example.canManageExample'];\n\n public function validateUpdate() {\n // your very own validation logic that does not make use of the\n // built-in `$permissionsUpdate` property\n\n // you can still invoke the built-in permissions check if you like to\n parent::validateUpdate();\n }\n}\n
"},{"location":"php/database-objects/#allow-invokation-by-guests","title":"Allow Invokation by Guests","text":"Invoking methods is restricted to logged-in users by default and the only way to override this behavior is to alter the property $allowGuestAccess
. It is a simple string array that is expected to hold all methods that should be accessible by users, excluding their companion validation methods.
Method access is usually limited by permissions, but sometimes there might be the need for some added security to avoid mistakes. The $requireACP
property works similar to $allowGuestAccess
, but enforces the request to originate from the ACP together with a valid ACP session, ensuring that only users able to access the ACP can actually invoke these methods.
The Standard PHP Library (SPL) provides some exceptions that should be used whenever possible.
"},{"location":"php/exceptions/#custom-exceptions","title":"Custom Exceptions","text":"Do not use wcf\\system\\exception\\SystemException
anymore, use specific exception classes!
The following table contains a list of custom exceptions that are commonly used. All of the exceptions are found in the wcf\\system\\exception
namespace.
IllegalLinkException
access to a page that belongs to a non-existing object, executing actions on specific non-existing objects (is shown as http 404 error to the user) ImplementationException
a class does not implement an expected interface InvalidObjectArgument
5.4+ API method support generic objects but specific implementation requires objects of specific (sub)class and different object is given InvalidObjectTypeException
object type is not of an expected object type definition InvalidSecurityTokenException
given security token does not match the security token of the active user's session ParentClassException
a class does not extend an expected (parent) class PermissionDeniedException
page access without permission, action execution without permission (is shown as http 403 error to the user) UserInputException
user input does not pass validation"},{"location":"php/exceptions/#sensitive-arguments-in-stack-traces","title":"Sensitive Arguments in Stack Traces","text":"Sometimes sensitive values are passed as a function or method argument. If the callee throws an Exception, these values will be part of the Exception\u2019s stack trace and logged, unless the Exception is caught and ignored.
WoltLab Suite will automatically suppress the values of parameters named like they might contain sensitive values, namely arguments matching the regular expression /(?:^(?:password|passphrase|secret)|(?:Password|Passphrase|Secret))/
.
If you need to suppress additional arguments from appearing in the stack trace, you can add the \\wcf\\SensitiveArgument
attribute to such parameters. Arguments are only supported as of PHP 8 and ignored as comments in lower PHP versions. In PHP 7, such arguments will not be suppressed, but the code will continue to work. Make sure to insert a linebreak between the attribute and the parameter name.
Example:
wcfsetup/install/files/lib/data/user/User.class.php<?php\n\nnamespace wcf\\data\\user;\n\n// \u2026\n\nfinal class User extends DatabaseObject implements IPopoverObject, IRouteController, IUserContent\n{\n // \u2026\n\n public function checkPassword(\n #[\\wcf\\SensitiveArgument()]\n $password\n ) {\n // \u2026\n }\n\n // \u2026\n}\n
"},{"location":"php/gdpr/","title":"General Data Protection Regulation (GDPR)","text":""},{"location":"php/gdpr/#introduction","title":"Introduction","text":"The General Data Protection Regulation (GDPR) of the European Union enters into force on May 25, 2018. It comes with a set of restrictions when handling users' personal data as well as to provide an interface to export this data on demand.
If you're looking for a guide on the implications of the GDPR and what you will need or consider to do, please read the article Implementation of the GDPR on woltlab.com.
"},{"location":"php/gdpr/#including-data-in-the-export","title":"Including Data in the Export","text":"The wcf\\acp\\action\\UserExportGdprAction
already includes WoltLab Suite Core itself as well as all official apps, but you'll need to include any personal data stored for your plugin or app by yourself.
The event export
is fired before any data is sent out, but after any Core data has been dumped to the $data
property.
<?php\nnamespace wcf\\system\\event\\listener;\nuse wcf\\acp\\action\\UserExportGdprAction;\nuse wcf\\data\\user\\UserProfile;\n\nclass MyUserExportGdprActionListener implements IParameterizedEventListener {\n public function execute(/** @var UserExportGdprAction $eventObj */$eventObj, $className, $eventName, array &$parameters) {\n /** @var UserProfile $user */\n $user = $eventObj->user;\n\n $eventObj->data['my.fancy.plugin'] = [\n 'superPersonalData' => \"This text is super personal and should be included in the output\",\n 'weirdIpAddresses' => $eventObj->exportIpAddresses('app'.WCF_N.'_non_standard_column_names_for_ip_addresses', 'ipAddressColumnName', 'timeColumnName', 'userIDColumnName')\n ];\n $eventObj->exportUserProperties[] = 'shouldAlwaysExportThisField';\n $eventObj->exportUserPropertiesIfNotEmpty[] = 'myFancyField';\n $eventObj->exportUserOptionSettings[] = 'thisSettingIsAlwaysExported';\n $eventObj->exportUserOptionSettingsIfNotEmpty[] = 'someSettingContainingPersonalData';\n $eventObj->ipAddresses['my.fancy.plugin'] = ['wcf'.WCF_N.'_my_fancy_table', 'wcf'.WCF_N.'_i_also_store_ipaddresses_here'];\n $eventObj->skipUserOptions[] = 'thisLooksLikePersonalDataButItIsNot';\n $eventObj->skipUserOptions[] = 'thisIsAlsoNotPersonalDataPleaseIgnoreIt';\n }\n}\n
"},{"location":"php/gdpr/#data","title":"$data
","text":"Contains the entire data that will be included in the exported JSON file, some fields may already exist (such as 'com.woltlab.wcf'
) and while you may add or edit any fields within, you should restrict yourself to only append data from your plugin or app.
$exportUserProperties
","text":"Only a whitelist of columns in wcfN_user
is exported by default, if your plugin or app adds one or more columns to this table that do hold personal data, then you will have to append it to this array. The listed properties will always be included regardless of their content.
$exportUserPropertiesIfNotEmpty
","text":"Only a whitelist of columns in wcfN_user
is exported by default, if your plugin or app adds one or more columns to this table that do hold personal data, then you will have to append it to this array. Empty values will not be added to the output.
$exportUserOptionSettings
","text":"Any user option that exists within a settings.*
category is automatically excluded from the export, with the notable exception of the timezone
option. You can opt-in to include your setting by appending to this array, if it contains any personal data. The listed settings are always included regardless of their content.
$exportUserOptionSettingsIfNotEmpty
","text":"Any user option that exists within a settings.*
category is automatically excluded from the export, with the notable exception of the timezone
option. You can opt-in to include your setting by appending to this array, if it contains any personal data.
$ipAddresses
","text":"List of database table names per package identifier that contain ip addresses. The contained ip addresses will be exported when the ip logging module is enabled.
It expects the database table to use the column names ipAddress
, time
and userID
. If your table does not match this pattern for whatever reason, you'll need to manually probe for LOG_IP_ADDRESS
and then call exportIpAddresses()
to retrieve the list. Afterwards you are responsible to append these ip addresses to the $data
array to have it exported.
$skipUserOptions
","text":"All user options are included in the export by default, unless they start with can*
or admin*
, or are blacklisted using this array. You should append any of your plugin's or app's user option that should not be exported, for example because it does not contain personal data, such as internal data.
The default implementation for pages to present any sort of content, but are designed to handle GET
requests only. They usually follow a fixed method chain that will be invoked one after another, adding logical sections to the request flow.
This is the only method being invoked from the outside and starts the whole chain.
"},{"location":"php/pages/#readparameters","title":"readParameters()","text":"Reads and sanitizes request parameters, this should be the only method to ever read user-supplied input. Read data should be stored in class properties to be accessible at a later point, allowing your code to safely assume that the data has been sanitized and is safe to work with.
A typical example is the board page from the forum app that reads the id and attempts to identify the request forum.
public function readParameters() {\n parent::readParameters();\n\n if (isset($_REQUEST['id'])) $this->boardID = intval($_REQUEST['id']);\n $this->board = BoardCache::getInstance()->getBoard($this->boardID);\n if ($this->board === null) {\n throw new IllegalLinkException();\n }\n\n // check permissions\n if (!$this->board->canEnter()) {\n throw new PermissionDeniedException();\n }\n}\n
Events readParameters
Used to be the method of choice to handle permissions and module option checks, but has been used almost entirely as an internal method since the introduction of the properties $loginRequired
, $neededModules
and $neededPermissions
.
Events checkModules
, checkPermissions
and show
Central method for data retrieval based on class properties including those populated with user data in readParameters()
. It is strongly recommended to use this method to read data in order to properly separate the business logic present in your class.
Events readData
Last method call before the template engine kicks in and renders the template. All though some properties are bound to the template automatically, you still need to pass any custom variables and class properties to the engine to make them available in templates.
Following the example in readParameters()
, the code below adds the board data to the template.
public function assignVariables() {\n parent::assignVariables();\n\n WCF::getTPL()->assign([\n 'board' => $this->board,\n 'boardID' => $this->boardID\n ]);\n}\n
Events assignVariables
Extends the AbstractPage implementation with additional methods designed to handle form submissions properly.
"},{"location":"php/pages/#method-chain_1","title":"Method Chain","text":""},{"location":"php/pages/#__run_1","title":"__run()","text":"Inherited from AbstractPage.
"},{"location":"php/pages/#readparameters_1","title":"readParameters()","text":"Inherited from AbstractPage.
"},{"location":"php/pages/#show_1","title":"show()","text":"Inherited from AbstractPage.
"},{"location":"php/pages/#submit","title":"submit()","text":"The methods submit()
up until save()
are only invoked if either $_POST
or $_FILES
are not empty, otherwise they won't be invoked and the execution will continue with readData()
.
This is an internal method that is responsible of input processing and validation.
Events submit
This method is quite similar to readParameters()
that is being called earlier, but is designed around reading form data submitted through POST requests. You should avoid accessing $_GET
or $_REQUEST
in this context to avoid mixing up parameters evaluated when retrieving the page on first load and when submitting to it.
Events readFormParameters
Deals with input validation and automatically catches exceptions deriving from wcf\\system\\exception\\UserInputException
, resulting in a clean and consistent error handling for the user.
Events validate
Saves the processed data to database or any other source of your choice. Please keep in mind to invoke $this->saved()
before resetting the form data.
Events save
This method is not called automatically and must be invoked manually by executing $this->saved()
inside save()
.
The only purpose of this method is to fire the event saved
that signals that the form data has been processed successfully and data has been saved. It is somewhat special as it is dispatched after the data has been saved, but before the data is purged during form reset. This is by default the last event that has access to the processed data.
Events saved
Inherited from AbstractPage.
"},{"location":"php/pages/#assignvariables_1","title":"assignVariables()","text":"Inherited from AbstractPage.
"},{"location":"php/api/caches/","title":"Caches","text":"WoltLab Suite offers two distinct types of caches:
Every so often, plugins make use of cache builders or runtime caches to store their data, even if there is absolutely no need for them to do so. Usually, this involves a strong opinion about the total number of SQL queries on a page, including but not limited to some magic treshold numbers, which should not be exceeded for \"performance reasons\".
This misconception can easily lead into thinking that SQL queries should be avoided or at least written to a cache, so that it doesn't need to be executed so often. Unfortunately, this completely ignores the fact that both a single query can take down your app (e. g. full table scan on millions of rows), but 10 queries using a primary key on a table with a few hundred rows will not slow down your page.
There are some queries that should go into caches by design, but most of the cache builders weren't initially there, but instead have been added because they were required to reduce the load significantly. You need to understand that caches always come at a cost, even a runtime cache does! In particular, they will always consume memory that is not released over the duration of the request lifecycle and potentially even leak memory by holding references to objects and data structures that are no longer required.
Caching should always be a solution for a problem.
"},{"location":"php/api/caches/#when-to-use-a-cache","title":"When to Use a Cache","text":"It's difficult to provide a definite answer or checklist when to use a cache and why it is required at this point, because the answer is: It depends. The permission cache for user groups is a good example for a valid cache, where we can achieve significant performance improvement compared to processing this data on every request.
Its caches are build for each permutation of user group memberships that are encountered for a page request. Building this data is an expensive process that involves both inheritance and specific rules in regards to when a value for a permission overrules another value. The added benefit of this cache is that one cache usually serves a large number of users with the same group memberships and by computing these permissions once, we can serve many different requests. Also, the permissions are rather static values that change very rarely and thus we can expect a very high cache lifetime before it gets rebuild.
"},{"location":"php/api/caches/#when-not-to-use-a-cache","title":"When not to Use a Cache","text":"I remember, a few years ago, there was a plugin that displayed a user's character from an online video game. The character sheet not only included a list of basic statistics, but also displayed the items that this character was wearing and or holding at the time.
The data for these items were downloaded in bulk from the game's vendor servers and stored in a persistent cache file that periodically gets renewed. There is nothing wrong with the idea of caching the data on your own server rather than requesting them everytime from the vendor's servers - not only because they imposed a limit on the number of requests per hour.
Unfortunately, the character sheet had a sub-par performance and the users were upset by the significant loading times compared to literally every other page on the same server. The author of the plugin was working hard to resolve this issue and was evaluating all kind of methods to improve the page performance, including deep-diving into the realm of micro-optimizations to squeeze out every last bit of performance that is possible.
The real problem was the cache file itself, it turns out that it was holding the data for several thousand items with a total file size of about 13 megabytes. It doesn't look that much at first glance, after all this isn't the '90s anymore, but unserializing a 13 megabyte array is really slow and looking up items in such a large array isn't exactly fast either.
The solution was rather simple, the data that was fetched from the vendor's API was instead written into a separate database table. Next, the persistent cache was removed and the character sheet would now request the item data for that specific character straight from the database. Previously, the character sheet took several seconds to load and after the change it was done in a fraction of a second. Although quite extreme, this illustrates a situation where the cache file was introduced in the design process, without evaluating if the cache - at least how it was implemented - was really necessary.
Caching should always be a solution for a problem. Not the other way around.
"},{"location":"php/api/caches_persistent-caches/","title":"Persistent Caches","text":"Relational databases are designed around the principle of normalized data that is organized across clearly separated tables with defined releations between data rows. While this enables you to quickly access and modify individual rows and columns, it can create the problem that re-assembling this data into a more complex structure can be quite expensive.
For example, the user group permissions are stored for each user group and each permissions separately, but in order to be applied, they need to be fetched and the cumulative values across all user groups of an user have to be calculated. These repetitive tasks on barely ever changing data make them an excellent target for caching, where all sub-sequent requests are accelerated because they no longer have to perform the same expensive calculations every time.
It is easy to get lost in the realm of caching, especially when it comes to the decision if you should use a cache or not. When in doubt, you should opt to not use them, because they also come at a hidden cost that cannot be expressed through simple SQL query counts. If you haven't already, it is recommended that you read the introduction article on caching first, it provides a bit of background on caches and examples that should help you in your decision.
"},{"location":"php/api/caches_persistent-caches/#abstractcachebuilder","title":"AbstractCacheBuilder
","text":"Every cache builder should derive from the base class AbstractCacheBuilder that already implements the mandatory interface ICacheBuilder.
files/lib/system/cache/builder/ExampleCacheBuilder.class.php<?php\nnamespace wcf\\system\\cache\\builder;\n\nclass ExampleCacheBuilder extends AbstractCacheBuilder {\n // 3600 = 1hr\n protected $maxLifetime = 3600;\n\n public function rebuild(array $parameters) {\n $data = [];\n\n // fetch and process your data and assign it to `$data`\n\n return $data;\n }\n}\n
Reading data from your cache builder is quite simple and follows a consistent pattern. The callee only needs to know the name of the cache builder, which parameters it requires and how the returned data looks like. It does not need to know how the data is retrieve, where it was stored, nor if it had to be rebuild due to the maximum lifetime.
<?php\nuse wcf\\system\\cache\\builder\\ExampleCacheBuilder;\n\n$data = ExampleCacheBuilder::getInstance()->getData($parameters);\n
"},{"location":"php/api/caches_persistent-caches/#getdataarray-parameters-string-arrayindex-array","title":"getData(array $parameters = [], string $arrayIndex = ''): array
","text":"Retrieves the data from the cache builder, the $parameters
array is automatically sorted to allow sub-sequent requests for the same parameters to be recognized, even if their parameters are mixed. For example, getData([1, 2])
and getData([2, 1])
will have the same exact result.
The optional $arrayIndex
will instruct the cache builder to retrieve the data and examine if the returned data is an array that has the index $arrayIndex
. If it is set, the potion below this index is returned instead.
getMaxLifetime(): int
","text":"Returns the maximum lifetime of a cache in seconds. It can be controlled through the protected $maxLifetime
property which defaults to 0
. Any cache that has a lifetime greater than 0 is automatically discarded when exceeding this age, otherwise it will remain forever until it is explicitly removed or invalidated.
reset(array $parameters = []): void
","text":"Invalidates a cache, the $parameters
array will again be ordered using the same rules that are applied for getData()
.
rebuild(array $parameters): array
","text":"This method is protected.
This is the only method that a cache builder deriving from AbstractCacheBuilder
has to implement and it will be invoked whenever the cache is required to be rebuild for whatever reason.
Runtime caches store objects created during the runtime of the script and are automatically discarded after the script terminates. Runtime caches are especially useful when objects are fetched by different APIs, each requiring separate requests. By using a runtime cache, you have two advantages:
IRuntimeCache
","text":"Every runtime cache has to implement the IRuntimeCache interface. It is recommended, however, that you extend AbstractRuntimeCache, the default implementation of the runtime cache interface. In most instances, you only need to set the AbstractRuntimeCache::$listClassName
property to the name of database object list class which fetches the cached objects from database (see example).
<?php\nuse wcf\\system\\cache\\runtime\\UserRuntimeCache;\n\n$userIDs = [1, 2];\n\n// first (optional) step: tell runtime cache to remember user ids\nUserRuntimeCache::getInstance()->cacheObjectIDs($userIDs);\n\n// [\u2026]\n\n// second step: fetch the objects from database\n$users = UserRuntimeCache::getInstance()->getObjects($userIDs);\n\n// somewhere else: fetch only one user\n$userID = 1;\n\nUserRuntimeCache::getInstance()->cacheObjectID($userID);\n\n// [\u2026]\n\n// get user without the cache actually fetching it from database because it has already been loaded\n$user = UserRuntimeCache::getInstance()->getObject($userID);\n\n// somewhere else: fetch users directly without caching user ids first\n$users = UserRuntimeCache::getInstance()->getObjects([3, 4]);\n
"},{"location":"php/api/caches_runtime-caches/#example","title":"Example","text":"files/lib/system/cache/runtime/UserRuntimeCache.class.php <?php\nnamespace wcf\\system\\cache\\runtime;\nuse wcf\\data\\user\\User;\nuse wcf\\data\\user\\UserList;\n\n/**\n * Runtime cache implementation for users.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2016 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\System\\Cache\\Runtime\n * @since 3.0\n *\n * @method User[] getCachedObjects()\n * @method User getObject($objectID)\n * @method User[] getObjects(array $objectIDs)\n */\nclass UserRuntimeCache extends AbstractRuntimeCache {\n /**\n * @inheritDoc\n */\n protected $listClassName = UserList::class;\n}\n
"},{"location":"php/api/comments/","title":"Comments","text":""},{"location":"php/api/comments/#user-group-options","title":"User Group Options","text":"You need to create the following permissions:
user group type permission type naming user creating commentsuser.foo.canAddComment
user editing own comments user.foo.canEditComment
user deleting own comments user.foo.canDeleteComment
moderator moderating comments mod.foo.canModerateComment
moderator editing comments mod.foo.canEditComment
moderator deleting comments mod.foo.canDeleteComment
Within their respective user group option category, the options should be listed in the same order as in the table above.
"},{"location":"php/api/comments/#language-items","title":"Language Items","text":""},{"location":"php/api/comments/#user-group-options_1","title":"User Group Options","text":"The language items for the comment-related user group options generally have the same values:
wcf.acp.group.option.user.foo.canAddComment
German: Kann Kommentare erstellen
English: Can create comments
wcf.acp.group.option.user.foo.canEditComment
German: Kann eigene Kommentare bearbeiten
English: Can edit their comments
wcf.acp.group.option.user.foo.canDeleteComment
German: Kann eigene Kommentare l\u00f6schen
English: Can delete their comments
wcf.acp.group.option.mod.foo.canModerateComment
German: Kann Kommentare moderieren
English: Can moderate comments
wcf.acp.group.option.mod.foo.canEditComment
German: Kann Kommentare bearbeiten
English: Can edit comments
wcf.acp.group.option.mod.foo.canDeleteComment
German: Kann Kommentare l\u00f6schen
English: Can delete comments
Cronjobs offer an easy way to execute actions periodically, like cleaning up the database.
The execution of cronjobs is not guaranteed but requires someone to access the page with JavaScript enabled.
This page focuses on the technical aspects of cronjobs, the cronjob package installation plugin page covers how you can actually register a cronjob.
"},{"location":"php/api/cronjobs/#example","title":"Example","text":"files/lib/system/cronjob/LastActivityCronjob.class.php<?php\nnamespace wcf\\system\\cronjob;\nuse wcf\\data\\cronjob\\Cronjob;\nuse wcf\\system\\WCF;\n\n/**\n * Updates the last activity timestamp in the user table.\n *\n * @author Marcel Werk\n * @copyright 2001-2016 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\System\\Cronjob\n */\nclass LastActivityCronjob extends AbstractCronjob {\n /**\n * @inheritDoc\n */\n public function execute(Cronjob $cronjob) {\n parent::execute($cronjob);\n\n $sql = \"UPDATE wcf1_user user_table,\n wcf1_session session\n SET user_table.lastActivityTime = session.lastActivityTime\n WHERE user_table.userID = session.userID\n AND session.userID <> 0\";\n $statement = WCF::getDB()->prepare($sql);\n $statement->execute();\n }\n}\n
"},{"location":"php/api/cronjobs/#icronjob-interface","title":"ICronjob
Interface","text":"Every cronjob needs to implement the wcf\\system\\cronjob\\ICronjob
interface which requires the execute(Cronjob $cronjob)
method to be implemented. This method is called by wcf\\system\\cronjob\\CronjobScheduler when executing the cronjobs.
In practice, however, you should extend the AbstractCronjob
class and also call the AbstractCronjob::execute()
method as it fires an event which makes cronjobs extendable by plugins (see event documentation).
Events whose name is marked with an asterisk are called from a static method and thus do not provide any object, just the class name.
"},{"location":"php/api/event_list/#woltlab-suite-core","title":"WoltLab Suite Core","text":"Class Event Namewcf\\acp\\action\\UserExportGdprAction
export
wcf\\acp\\form\\StyleAddForm
setVariables
wcf\\acp\\form\\UserSearchForm
search
wcf\\action\\AbstractAction
checkModules
wcf\\action\\AbstractAction
checkPermissions
wcf\\action\\AbstractAction
execute
wcf\\action\\AbstractAction
executed
wcf\\action\\AbstractAction
readParameters
wcf\\data\\attachment\\AttachmentAction
generateThumbnail
wcf\\data\\session\\SessionAction
keepAlive
wcf\\data\\session\\SessionAction
poll
wcf\\data\\trophy\\Trophy
renderTrophy
wcf\\data\\user\\online\\UserOnline
getBrowser
wcf\\data\\user\\online\\UsersOnlineList
isVisible
wcf\\data\\user\\online\\UsersOnlineList
isVisibleUser
wcf\\data\\user\\trophy\\UserTrophy
getReplacements
wcf\\data\\user\\UserAction
beforeFindUsers
wcf\\data\\user\\UserAction
rename
wcf\\data\\user\\UserProfile
getAvatar
wcf\\data\\user\\UserProfile
isAccessible
wcf\\data\\AbstractDatabaseObjectAction
finalizeAction
wcf\\data\\AbstractDatabaseObjectAction
initializeAction
wcf\\data\\AbstractDatabaseObjectAction
validateAction
wcf\\data\\DatabaseObjectList
init
wcf\\form\\AbstractForm
readFormParameters
wcf\\form\\AbstractForm
save
wcf\\form\\AbstractForm
saved
wcf\\form\\AbstractForm
submit
wcf\\form\\AbstractForm
validate
wcf\\form\\AbstractFormBuilderForm
createForm
wcf\\form\\AbstractFormBuilderForm
buildForm
wcf\\form\\AbstractModerationForm
prepareSave
wcf\\page\\AbstractPage
assignVariables
wcf\\page\\AbstractPage
checkModules
wcf\\page\\AbstractPage
checkPermissions
wcf\\page\\AbstractPage
readData
wcf\\page\\AbstractPage
readParameters
wcf\\page\\AbstractPage
show
wcf\\page\\MultipleLinkPage
beforeReadObjects
wcf\\page\\MultipleLinkPage
insteadOfReadObjects
wcf\\page\\MultipleLinkPage
afterInitObjectList
wcf\\page\\MultipleLinkPage
calculateNumberOfPages
wcf\\page\\MultipleLinkPage
countItems
wcf\\page\\SortablePage
validateSortField
wcf\\page\\SortablePage
validateSortOrder
wcf\\system\\bbcode\\MessageParser
afterParsing
wcf\\system\\bbcode\\MessageParser
beforeParsing
wcf\\system\\bbcode\\SimpleMessageParser
afterParsing
wcf\\system\\bbcode\\SimpleMessageParser
beforeParsing
wcf\\system\\box\\BoxHandler
loadBoxes
wcf\\system\\box\\AbstractBoxController
__construct
wcf\\system\\box\\AbstractBoxController
afterLoadContent
wcf\\system\\box\\AbstractBoxController
beforeLoadContent
wcf\\system\\box\\AbstractDatabaseObjectListBoxController
afterLoadContent
wcf\\system\\box\\AbstractDatabaseObjectListBoxController
beforeLoadContent
wcf\\system\\box\\AbstractDatabaseObjectListBoxController
hasContent
wcf\\system\\box\\AbstractDatabaseObjectListBoxController
readObjects
wcf\\system\\cronjob\\AbstractCronjob
execute
wcf\\system\\email\\Email
getJobs
wcf\\system\\form\\builder\\container\\wysiwyg\\WysiwygFormContainer
populate
wcf\\system\\html\\input\\filter\\MessageHtmlInputFilter
setAttributeDefinitions
wcf\\system\\html\\input\\node\\HtmlInputNodeProcessor
afterProcess
wcf\\system\\html\\input\\node\\HtmlInputNodeProcessor
beforeEmbeddedProcess
wcf\\system\\html\\input\\node\\HtmlInputNodeProcessor
beforeProcess
wcf\\system\\html\\input\\node\\HtmlInputNodeProcessor
convertPlainLinks
wcf\\system\\html\\input\\node\\HtmlInputNodeProcessor
getTextContent
wcf\\system\\html\\input\\node\\HtmlInputNodeProcessor
parseEmbeddedContent
wcf\\system\\html\\input\\node\\HtmlInputNodeWoltlabMetacodeMarker
filterGroups
wcf\\system\\html\\output\\node\\HtmlOutputNodePre
selectHighlighter
wcf\\system\\html\\output\\node\\HtmlOutputNodeProcessor
beforeProcess
wcf\\system\\image\\adapter\\ImagickImageAdapter
getResizeFilter
wcf\\system\\menu\\user\\profile\\UserProfileMenu
init
wcf\\system\\menu\\user\\profile\\UserProfileMenu
loadCache
wcf\\system\\menu\\TreeMenu
init
wcf\\system\\menu\\TreeMenu
loadCache
wcf\\system\\message\\QuickReplyManager
addFullQuote
wcf\\system\\message\\QuickReplyManager
allowedDataParameters
wcf\\system\\message\\QuickReplyManager
beforeRenderQuote
wcf\\system\\message\\QuickReplyManager
createMessage
wcf\\system\\message\\QuickReplyManager
createdMessage
wcf\\system\\message\\QuickReplyManager
getMessage
wcf\\system\\message\\QuickReplyManager
validateParameters
wcf\\system\\message\\quote\\MessageQuoteManager
addFullQuote
wcf\\system\\message\\quote\\MessageQuoteManager
beforeRenderQuote
wcf\\system\\option\\OptionHandler
afterReadCache
wcf\\system\\package\\plugin\\AbstractPackageInstallationPlugin
construct
wcf\\system\\package\\plugin\\AbstractPackageInstallationPlugin
hasUninstall
wcf\\system\\package\\plugin\\AbstractPackageInstallationPlugin
install
wcf\\system\\package\\plugin\\AbstractPackageInstallationPlugin
uninstall
wcf\\system\\package\\plugin\\AbstractPackageInstallationPlugin
update
wcf\\system\\package\\plugin\\ObjectTypePackageInstallationPlugin
addConditionFields
wcf\\system\\package\\PackageInstallationDispatcher
postInstall
wcf\\system\\package\\PackageUninstallationDispatcher
postUninstall
wcf\\system\\reaction\\ReactionHandler
getDataAttributes
wcf\\system\\request\\RouteHandler
didInit
wcf\\system\\session\\ACPSessionFactory
afterInit
wcf\\system\\session\\ACPSessionFactory
beforeInit
wcf\\system\\session\\SessionHandler
afterChangeUser
wcf\\system\\session\\SessionHandler
beforeChangeUser
wcf\\system\\style\\StyleCompiler
compile
wcf\\system\\template\\TemplateEngine
afterDisplay
wcf\\system\\template\\TemplateEngine
beforeDisplay
wcf\\system\\upload\\DefaultUploadFileSaveStrategy
generateThumbnails
wcf\\system\\upload\\DefaultUploadFileSaveStrategy
save
wcf\\system\\user\\authentication\\UserAuthenticationFactory
init
wcf\\system\\user\\notification\\UserNotificationHandler
createdNotification
wcf\\system\\user\\notification\\UserNotificationHandler
fireEvent
wcf\\system\\user\\notification\\UserNotificationHandler
markAsConfirmed
wcf\\system\\user\\notification\\UserNotificationHandler
markAsConfirmedByIDs
wcf\\system\\user\\notification\\UserNotificationHandler
removeNotifications
wcf\\system\\user\\notification\\UserNotificationHandler
updateTriggerCount
wcf\\system\\user\\UserBirthdayCache
loadMonth
wcf\\system\\worker\\AbstractRebuildDataWorker
execute
wcf\\system\\WCF
initialized
wcf\\util\\HeaderUtil
parseOutput
*"},{"location":"php/api/event_list/#woltlab-suite-core-conversations","title":"WoltLab Suite Core: Conversations","text":"Class Event Name wcf\\data\\conversation\\ConversationAction
addParticipants_validateParticipants
wcf\\data\\conversation\\message\\ConversationMessageAction
afterQuickReply
"},{"location":"php/api/event_list/#woltlab-suite-core-infractions","title":"WoltLab Suite Core: Infractions","text":"Class Event Name wcf\\system\\infraction\\suspension\\BanSuspensionAction
suspend
wcf\\system\\infraction\\suspension\\BanSuspensionAction
unsuspend
"},{"location":"php/api/event_list/#woltlab-suite-forum","title":"WoltLab Suite Forum","text":"Class Event Name wbb\\data\\board\\BoardAction
cloneBoard
wbb\\data\\post\\PostAction
quickReplyShouldMerge
wbb\\system\\thread\\ThreadHandler
didInit
"},{"location":"php/api/event_list/#woltlab-suite-filebase","title":"WoltLab Suite Filebase","text":"Class Event Name filebase\\data\\file\\File
getPrice
filebase\\data\\file\\ViewableFile
getUnreadFiles
"},{"location":"php/api/events/","title":"Events","text":"WoltLab Suite's event system allows manipulation of program flows and data without having to change any of the original source code. At many locations throughout the PHP code of WoltLab Suite Core and mainly through inheritance also in the applications and plugins, so called events are fired which trigger registered event listeners that get access to the object firing the event (or at least the class name if the event has been fired in a static method).
This page focuses on the technical aspects of events and event listeners, the eventListener package installation plugin page covers how you can actually register an event listener. A comprehensive list of all available events is provided here.
"},{"location":"php/api/events/#introductory-example","title":"Introductory Example","text":"Let's start with a simple example to illustrate how the event system works. Consider this pre-existing class:
files/lib/system/example/ExampleComponent.class.php<?php\nnamespace wcf\\system\\example;\nuse wcf\\system\\event\\EventHandler;\n\nclass ExampleComponent {\n public $var = 1;\n\n public function getVar() {\n EventHandler::getInstance()->fireAction($this, 'getVar');\n\n return $this->var;\n }\n}\n
where an event with event name getVar
is fired in the getVar()
method.
If you create an object of this class and call the getVar()
method, the return value will be 1
, of course:
<?php\n\n$example = new wcf\\system\\example\\ExampleComponent();\nif ($example->getVar() == 1) {\n echo \"var is 1!\";\n}\nelse if ($example->getVar() == 2) {\n echo \"var is 2!\";\n}\nelse {\n echo \"No, var is neither 1 nor 2.\";\n}\n\n// output: var is 1!\n
Now, consider that we have registered the following event listener to this event:
files/lib/system/event/listener/ExampleEventListener.class.php<?php\nnamespace wcf\\system\\event\\listener;\n\nclass ExampleEventListener implements IParameterizedEventListener {\n public function execute($eventObj, $className, $eventName, array &$parameters) {\n $eventObj->var = 2;\n }\n}\n
Whenever the event in the getVar()
method is called, this method (of the same event listener object) is called. In this case, the value of the method's first parameter is the ExampleComponent
object passed as the first argument of the EventHandler::fireAction()
call in ExampleComponent::getVar()
. As ExampleComponent::$var
is a public property, the event listener code can change it and set it to 2
.
If you now execute the example code from above again, the output will change from var is 1!
to var is 2!
because prior to returning the value, the event listener code changes the value from 1
to 2
.
This introductory example illustrates how event listeners can change data in a non-intrusive way. Program flow can be changed, for example, by throwing a wcf\\system\\exception\\PermissionDeniedException
if some additional constraint to access a page is not fulfilled.
In order to listen to events, you need to register the event listener and the event listener itself needs to implement the interface wcf\\system\\event\\listener\\IParameterizedEventListener
which only contains the execute
method (see example above).
The first parameter $eventObj
of the method contains the passed object where the event is fired or the name of the class in which the event is fired if it is fired from a static method. The second parameter $className
always contains the name of the class where the event has been fired. The third parameter $eventName
provides the name of the event within a class to uniquely identify the exact location in the class where the event has been fired. The last parameter $parameters
is a reference to the array which contains additional data passed by the method firing the event. If no additional data is passed, $parameters
is empty.
If you write code and want plugins to have access at certain points, you can fire an event on your own. The only thing to do is to call the wcf\\system\\event\\EventHandler::fireAction($eventObj, $eventName, array &$parameters = [])
method and pass the following parameters:
$eventObj
should be $this
if you fire from an object context, otherwise pass the class name static::class
.$eventName
identifies the event within the class and generally has the same name as the method. In cases, were you might fire more than one event in a method, for example before and after a certain piece of code, you can use the prefixes before*
and after*
in your event names.$parameters
is an optional array which allows you to pass additional data to the event listeners without having to make this data accessible via a property explicitly only created for this purpose. This additional data can either be just additional information for the event listeners about the context of the method call or allow the event listener to manipulate local data if the code, where the event has been fired, uses the passed data afterwards. $parameters
argument","text":"Consider the following method which gets some text that the methods parses.
files/lib/system/example/ExampleParser.class.php<?php\nnamespace wcf\\system\\example;\nuse wcf\\system\\event\\EventHandler;\n\nclass ExampleParser {\n public function parse($text) {\n // [some parsing done by default]\n\n $parameters = ['text' => $text];\n EventHandler::getInstance()->fireAction($this, 'parse', $parameters);\n\n return $parameters['text'];\n }\n}\n
After the default parsing by the method itself, the author wants to enable plugins to do additional parsing and thus fires an event and passes the parsed text as an additional parameter. Then, a plugin can deliver the following event listener
files/lib/system/event/listener/ExampleParserEventListener.class.php<?php\nnamespace wcf\\system\\event\\listener;\n\nclass ExampleParserEventListener implements IParameterizedEventListener {\n public function execute($eventObj, $className, $eventName, array &$parameters) {\n $text = $parameters['text'];\n\n // [some additional parsing which changes $text]\n\n $parameters['text'] = $text;\n }\n}\n
which can access the text via $parameters['text']
.
This example can also be perfectly used to illustrate how to name multiple events in the same method. Let's assume that the author wants to enable plugins to change the text before and after the method does its own parsing and thus fires two events:
files/lib/system/example/ExampleParser.class.php<?php\nnamespace wcf\\system\\example;\nuse wcf\\system\\event\\EventHandler;\n\nclass ExampleParser {\n public function parse($text) {\n $parameters = ['text' => $text];\n EventHandler::getInstance()->fireAction($this, 'beforeParsing', $parameters);\n $text = $parameters['text'];\n\n // [some parsing done by default]\n\n $parameters = ['text' => $text];\n EventHandler::getInstance()->fireAction($this, 'afterParsing', $parameters);\n\n return $parameters['text'];\n }\n}\n
"},{"location":"php/api/events/#advanced-example-additional-form-field","title":"Advanced Example: Additional Form Field","text":"One common reason to use event listeners is to add an additional field to a pre-existing form (in combination with template listeners, which we will not cover here). We will assume that users are able to do both, create and edit the objects via this form. The points in the program flow of AbstractForm that are relevant here are:
saving the additional value after successful validation and resetting locally stored value or assigning the current value of the field to the template after unsuccessful validation
editing object:
All of these cases can be covered the by following code in which we assume that wcf\\form\\ExampleAddForm
is the form to create example objects and that wcf\\form\\ExampleEditForm
extends wcf\\form\\ExampleAddForm
and is used for editing existing example objects.
<?php\nnamespace wcf\\system\\event\\listener;\nuse wcf\\form\\ExampleAddForm;\nuse wcf\\form\\ExampleEditForm;\nuse wcf\\system\\exception\\UserInputException;\nuse wcf\\system\\WCF;\n\nclass ExampleAddFormListener implements IParameterizedEventListener {\n protected $var = 0;\n\n public function execute($eventObj, $className, $eventName, array &$parameters) {\n $this->$eventName($eventObj);\n }\n\n protected function assignVariables() {\n WCF::getTPL()->assign('var', $this->var);\n }\n\n protected function readData(ExampleEditForm $eventObj) {\n if (empty($_POST)) {\n $this->var = $eventObj->example->var;\n }\n }\n\n protected function readFormParameters() {\n if (isset($_POST['var'])) $this->var = intval($_POST['var']);\n }\n\n protected function save(ExampleAddForm $eventObj) {\n $eventObj->additionalFields = array_merge($eventObj->additionalFields, ['var' => $this->var]);\n }\n\n protected function saved() {\n $this->var = 0;\n }\n\n protected function validate() {\n if ($this->var < 0) {\n throw new UserInputException('var', 'isNegative');\n }\n }\n}\n
The execute
method in this example just delegates the call to a method with the same name as the event so that this class mimics the structure of a form class itself. The form object is passed to the methods but is only given in the method signatures as a parameter here whenever the form object is actually used. Furthermore, the type-hinting of the parameter illustrates in which contexts the method is actually called which will become clear in the following discussion of the individual methods:
assignVariables()
is called for the add and the edit form and simply assigns the current value of the variable to the template.readData()
reads the pre-existing value of $var
if the form has not been submitted and thus is only relevant when editing objects which is illustrated by the explicit type-hint of ExampleEditForm
.readFormParameters()
reads the value for both, the add and the edit form.save()
is, of course, also relevant in both cases but requires the form object to store the additional value in the wcf\\form\\AbstractForm::$additionalFields
array which can be used if a var
column has been added to the database table in which the example objects are stored.saved()
is only called for the add form as it clears the internal value so that in the assignVariables()
call, the default value will be assigned to the template to create an \"empty\" form. During edits, this current value is the actual value that should be shown.validate()
also needs to be called in both cases as the input data always has to be validated.Lastly, the following XML file has to be used to register the event listeners (you can find more information about how to register event listeners on the eventListener package installation plugin page):
eventListener.xml<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/eventListener.xsd\">\n<import>\n<eventlistener name=\"exampleAddInherited\">\n<eventclassname>wcf\\form\\ExampleAddForm</eventclassname>\n<eventname>assignVariables,readFormParameters,save,validate</eventname>\n<listenerclassname>wcf\\system\\event\\listener\\ExampleAddFormListener</listenerclassname>\n<inherit>1</inherit>\n</eventlistener>\n\n<eventlistener name=\"exampleAdd\">\n<eventclassname>wcf\\form\\ExampleAddForm</eventclassname>\n<eventname>saved</eventname>\n<listenerclassname>wcf\\system\\event\\listener\\ExampleAddFormListener</listenerclassname>\n</eventlistener>\n\n<eventlistener name=\"exampleEdit\">\n<eventclassname>wcf\\form\\ExampleEditForm</eventclassname>\n<eventname>readData</eventname>\n<listenerclassname>wcf\\system\\event\\listener\\ExampleAddFormListener</listenerclassname>\n</eventlistener>\n</import>\n</data>\n
"},{"location":"php/api/package_installation_plugins/","title":"Package Installation Plugins","text":"A package installation plugin (PIP) defines the behavior to handle a specific instruction during package installation, update or uninstallation.
"},{"location":"php/api/package_installation_plugins/#abstractpackageinstallationplugin","title":"AbstractPackageInstallationPlugin
","text":"Any package installation plugin has to implement the IPackageInstallationPlugin interface. It is recommended however, to extend the abstract implementation AbstractPackageInstallationPlugin of this interface instead of directly implementing the interface. The abstract implementation will always provide sane methods in case of any API changes.
"},{"location":"php/api/package_installation_plugins/#class-members","title":"Class Members","text":"Package Installation Plugins have a few notable class members easing your work:
"},{"location":"php/api/package_installation_plugins/#installation","title":"$installation
","text":"This member contains an instance of PackageInstallationDispatcher which provides you with all meta data related to the current package being processed. The most common usage is the retrieval of the package ID via $this->installation->getPackageID()
.
$application
","text":"Represents the abbreviation of the target application, e.g. wbb
(default value: wcf
), used for the name of database table in which the installed data is stored.
AbstractXMLPackageInstallationPlugin
","text":"AbstractPackageInstallationPlugin is the default implementation for all package installation plugins based upon a single XML document. It handles the evaluation of the document and provide you an object-orientated approach to handle its data.
"},{"location":"php/api/package_installation_plugins/#class-members_1","title":"Class Members","text":""},{"location":"php/api/package_installation_plugins/#classname","title":"$className
","text":"Value must be the qualified name of a class deriving from DatabaseObjectEditor which is used to create and update objects.
"},{"location":"php/api/package_installation_plugins/#tagname","title":"$tagName
","text":"Specifies the tag name within a <import>
or <delete>
section of the XML document used for each installed object.
prepareImport(array $data)
","text":"The passed array $data
contains the parsed value from each evaluated tag in the <import>
section:
$data['elements']
contains a list of tag names and their value.$data['attributes']
contains a list of attributes present on the tag identified by $tagName.This method should return an one-dimensional array, where each key maps to the corresponding database column name (key names are case-sensitive). It will be passed to either DatabaseObjectEditor::create()
or DatabaseObjectEditor::update()
.
Example:
<?php\nreturn [\n 'environment' => $data['elements']['environment'],\n 'eventName' => $data['elements']['eventname'],\n 'name' => $data['attributes']['name']\n];\n
"},{"location":"php/api/package_installation_plugins/#validateimportarray-data","title":"validateImport(array $data)
","text":"The passed array $data
equals the data returned by prepareImport(). This method has no return value, instead you should throw an exception if the passed data is invalid.
findExistingItem(array $data)
","text":"The passed array $data
equals the data returned by prepareImport(). This method is expected to return an array with two keys:
sql
contains the SQL query with placeholders.parameters
contains an array with values used for the SQL query.<?php\n$sql = \"SELECT *\n FROM wcf\".WCF_N.\"_\".$this->tableName.\"\n WHERE packageID = ?\n AND name = ?\n AND templateName = ?\n AND eventName = ?\n AND environment = ?\";\n$parameters = [\n $this->installation->getPackageID(),\n $data['name'],\n $data['templateName'],\n $data['eventName'],\n $data['environment']\n];\n\nreturn [\n 'sql' => $sql,\n 'parameters' => $parameters\n];\n
"},{"location":"php/api/package_installation_plugins/#handledeletearray-items","title":"handleDelete(array $items)
","text":"The passed array $items
contains the original node data, similar to prepareImport(). You should make use of this data to remove the matching element from database.
Example:
<?php\n$sql = \"DELETE FROM wcf1_{$this->tableName}\n WHERE packageID = ?\n AND environment = ?\n AND eventName = ?\n AND name = ?\n AND templateName = ?\";\n$statement = WCF::getDB()->prepare($sql);\nforeach ($items as $item) {\n $statement->execute([\n $this->installation->getPackageID(),\n $item['elements']['environment'],\n $item['elements']['eventname'],\n $item['attributes']['name'],\n $item['elements']['templatename']\n ]);\n}\n
"},{"location":"php/api/package_installation_plugins/#postimport","title":"postImport()
","text":"Allows you to (optionally) run additionally actions after all elements were processed.
"},{"location":"php/api/package_installation_plugins/#abstractoptionpackageinstallationplugin","title":"AbstractOptionPackageInstallationPlugin
","text":"AbstractOptionPackageInstallationPlugin is an abstract implementation for options, used for:
AbstractXMLPackageInstallationPlugin
","text":""},{"location":"php/api/package_installation_plugins/#reservedtags","title":"$reservedTags
","text":"$reservedTags
is a list of reserved tag names so that any tag encountered but not listed here will be added to the database column additionalData
. This allows options to store arbitrary data which can be accessed but were not initially part of the PIP specifications.
WoltLab Suite is capable of automatically creating a sitemap. This sitemap contains all static pages registered via the page package installation plugin and which may be indexed by search engines (checking the allowSpidersToIndex
parameter and page permissions) and do not expect an object ID. Other pages have to be added to the sitemap as a separate object.
The only prerequisite for sitemap objects is that the objects are instances of wcf\\data\\DatabaseObject
and that there is a wcf\\data\\DatabaseObjectList
implementation.
First, we implement the PHP class, which provides us all database objects and optionally checks the permissions for a single object. The class must implement the interface wcf\\system\\sitemap\\object\\ISitemapObjectObjectType
. However, in order to have some methods already implemented and ensure backwards compatibility, you should use the abstract class wcf\\system\\sitemap\\object\\AbstractSitemapObjectObjectType
. The abstract class takes care of generating the DatabaseObjectList
class name and list directly and implements optional methods with the default values. The only method that you have to implement yourself is the getObjectClass()
method which returns the fully qualified name of the DatabaseObject
class. The DatabaseObject
class must implement the interface wcf\\data\\ILinkableObject
.
Other optional methods are:
getLastModifiedColumn()
method returns the name of the column in the database where the last modification date is stored. If there is none, this method must return null
.canView()
method checks whether the passed DatabaseObject
is visible to the current user with the current user always being a guest.getObjectListClass()
method returns a non-standard DatabaseObjectList
class name.getObjectList()
method returns the DatabaseObjectList
instance. You can, for example, specify additional query conditions in the method.As an example, the implementation for users looks like this:
files/lib/system/sitemap/object/UserSitemapObject.class.php<?php\nnamespace wcf\\system\\sitemap\\object;\nuse wcf\\data\\user\\User;\nuse wcf\\data\\DatabaseObject;\nuse wcf\\system\\WCF;\n\n/**\n * User sitemap implementation.\n *\n * @author Joshua Ruesweg\n * @copyright 2001-2017 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\Sitemap\\Object\n * @since 3.1\n */\nclass UserSitemapObject extends AbstractSitemapObjectObjectType {\n /**\n * @inheritDoc\n */\n public function getObjectClass() {\n return User::class;\n }\n\n /**\n * @inheritDoc\n */\n public function getLastModifiedColumn() {\n return 'lastActivityTime';\n }\n\n /**\n * @inheritDoc\n */\n public function canView(DatabaseObject $object) {\n return WCF::getSession()->getPermission('user.profile.canViewUserProfile');\n }\n}\n
Next, the sitemap object must be registered as an object type:
<type>\n<name>com.example.plugin.sitemap.object.user</name>\n<definitionname>com.woltlab.wcf.sitemap.object</definitionname>\n<classname>wcf\\system\\sitemap\\object\\UserSitemapObject</classname>\n<priority>0.5</priority>\n<changeFreq>monthly</changeFreq>\n<rebuildTime>259200</rebuildTime>\n</type>\n
In addition to the fully qualified class name, the object type definition com.woltlab.wcf.sitemap.object
and the object type name, the parameters priority
, changeFreq
and rebuildTime
must also be specified. priority
(https://www.sitemaps.org/protocol.html#prioritydef) and changeFreq
(https://www.sitemaps.org/protocol.html#changefreqdef) are specifications in the sitemaps protocol and can be changed by the user in the ACP. The priority
should be 0.5
by default, unless there is an important reason to change it. The parameter rebuildTime
specifies the number of seconds after which the sitemap should be regenerated.
Finally, you have to create the language variable for the sitemap object. The language variable follows the pattern wcf.acp.sitemap.objectType.{objectTypeName}
and is in the category wcf.acp.sitemap
.
Users get activity points whenever they create content to award them for their contribution. Activity points are used to determine the rank of a user and can also be used for user conditions, for example for automatic user group assignments.
To integrate activity points into your package, you have to register an object type for the defintion com.woltlab.wcf.user.activityPointEvent
and specify a default number of points:
<type>\n<name>com.example.foo.activityPointEvent.bar</name>\n<definitionname>com.woltlab.wcf.user.activityPointEvent</definitionname>\n<points>10</points>\n</type>\n
The number of points awarded for this type of activity point event can be changed by the administrator in the admin control panel. For this form and the user activity point list shown in the frontend, you have to provide the language item
wcf.user.activityPoint.objectType.com.example.foo.activityPointEvent.bar\n
that contains the name of the content for which the activity points are awarded.
If a relevant object is created, you have to use UserActivityPointHandler::fireEvent()
which expects the name of the activity point event object type, the id of the object for which the points are awarded (though the object id is not used at the moment) and the user who gets the points:
UserActivityPointHandler::getInstance()->fireEvent(\n 'com.example.foo.activityPointEvent.bar',\n $bar->barID,\n $bar->userID\n);\n
To remove activity points once objects are deleted, you have to use UserActivityPointHandler::removeEvents()
which also expects the name of the activity point event object type and additionally an array mapping the id of the user whose activity points will be reduced to the number of objects that are removed for the relevant user:
UserActivityPointHandler::getInstance()->removeEvents(\n 'com.example.foo.activityPointEvent.bar',\n [\n 1 => 1, // remove points for one object for user with id `1`\n 4 => 2 // remove points for two objects for user with id `4`\n ]\n);\n
"},{"location":"php/api/user_notifications/","title":"User Notifications","text":"WoltLab Suite includes a powerful user notification system that supports notifications directly shown on the website and emails sent immediately or on a daily basis.
"},{"location":"php/api/user_notifications/#objecttypexml","title":"objectType.xml
","text":"For any type of object related to events, you have to define an object type for the object type definition com.woltlab.wcf.notification.objectType
:
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/objectType.xsd\">\n<import>\n<type>\n<name>com.woltlab.example.foo</name>\n<definitionname>com.woltlab.wcf.notification.objectType</definitionname>\n<classname>example\\system\\user\\notification\\object\\type\\FooUserNotificationObjectType</classname>\n<category>com.woltlab.example</category>\n</type>\n</import>\n</data>\n
The referenced class FooUserNotificationObjectType
has to implement the IUserNotificationObjectType interface, which should be done by extending AbstractUserNotificationObjectType.
<?php\nnamespace example\\system\\user\\notification\\object\\type;\nuse example\\data\\foo\\Foo;\nuse example\\data\\foo\\FooList;\nuse example\\system\\user\\notification\\object\\FooUserNotificationObject;\nuse wcf\\system\\user\\notification\\object\\type\\AbstractUserNotificationObjectType;\n\n/**\n * Represents a foo as a notification object type.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2017 WoltLab GmbH\n * @license WoltLab License <http://www.woltlab.com/license-agreement.html>\n * @package WoltLabSuite\\Example\\System\\User\\Notification\\Object\\Type\n */\nclass FooUserNotificationObjectType extends AbstractUserNotificationObjectType {\n /**\n * @inheritDoc\n */\n protected static $decoratorClassName = FooUserNotificationObject::class;\n\n /**\n * @inheritDoc\n */\n protected static $objectClassName = Foo::class;\n\n /**\n * @inheritDoc\n */\n protected static $objectListClassName = FooList::class;\n}\n
You have to set the class names of the database object ($objectClassName
) and the related list ($objectListClassName
). Additionally, you have to create a class that implements the IUserNotificationObject whose name you have to set as the value of the $decoratorClassName
property.
<?php\nnamespace example\\system\\user\\notification\\object;\nuse example\\data\\foo\\Foo;\nuse wcf\\data\\DatabaseObjectDecorator;\nuse wcf\\system\\user\\notification\\object\\IUserNotificationObject;\n\n/**\n * Represents a foo as a notification object.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2017 WoltLab GmbH\n * @license WoltLab License <http://www.woltlab.com/license-agreement.html>\n * @package WoltLabSuite\\Example\\System\\User\\Notification\\Object\n *\n * @method Foo getDecoratedObject()\n * @mixin Foo\n */\nclass FooUserNotificationObject extends DatabaseObjectDecorator implements IUserNotificationObject {\n /**\n * @inheritDoc\n */\n protected static $baseClass = Foo::class;\n\n /**\n * @inheritDoc\n */\n public function getTitle() {\n return $this->getDecoratedObject()->getTitle();\n }\n\n /**\n * @inheritDoc\n */\n public function getURL() {\n return $this->getDecoratedObject()->getLink();\n }\n\n /**\n * @inheritDoc\n */\n public function getAuthorID() {\n return $this->getDecoratedObject()->userID;\n }\n}\n
getTitle()
method returns the title of the object. In this case, we assume that the Foo
class has implemented the ITitledObject interface so that the decorated Foo
can handle this method call itself.getURL()
method returns the link to the object. As for the getTitle()
, we assume that the Foo
class has implemented the ILinkableObject interface so that the decorated Foo
can also handle this method call itself.getAuthorID()
method returns the id of the user who created the decorated Foo
object. We assume that Foo
objects have a userID
property that contains this id.userNotificationEvent.xml
","text":"Each event that you fire in your package needs to be registered using the user notification event package installation plugin. An example file might look like this:
userNotificationEvent.xml<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/userNotificationEvent.xsd\">\n<import>\n<event>\n<name>bar</name>\n<objecttype>com.woltlab.example.foo</objecttype>\n<classname>example\\system\\user\\notification\\event\\FooUserNotificationEvent</classname>\n<preset>1</preset>\n</event>\n</import>\n</data>\n
Here, you reference the user notification object type created via objectType.xml
. The referenced class in the <classname>
element has to implement the IUserNotificationEvent interface by extending the AbstractUserNotificationEvent class or the AbstractSharedUserNotificationEvent class if you want to pre-load additional data before processing notifications. In AbstractSharedUserNotificationEvent::prepare()
, you can, for example, tell runtime caches to prepare to load certain objects which then are loaded all at once when the objects are needed.
<?php\nnamespace example\\system\\user\\notification\\event;\nuse example\\system\\cache\\runtime\\BazRuntimeCache;\nuse example\\system\\user\\notification\\object\\FooUserNotificationObject;\nuse wcf\\system\\email\\Email;\nuse wcf\\system\\request\\LinkHandler;\nuse wcf\\system\\user\\notification\\event\\AbstractSharedUserNotificationEvent;\n\n/**\n * Notification event for foos.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2017 WoltLab GmbH\n * @license WoltLab License <http://www.woltlab.com/license-agreement.html>\n * @package WoltLabSuite\\Example\\System\\User\\Notification\\Event\n *\n * @method FooUserNotificationObject getUserNotificationObject()\n */\nclass FooUserNotificationEvent extends AbstractSharedUserNotificationEvent {\n /**\n * @inheritDoc\n */\n protected $stackable = true;\n\n /** @noinspection PhpMissingParentCallCommonInspection */\n /**\n * @inheritDoc\n */\n public function checkAccess() {\n $this->getUserNotificationObject()->setBaz(BazRuntimeCache::getInstance()->getObject($this->getUserNotificationObject()->bazID));\n\n if (!$this->getUserNotificationObject()->isAccessible()) {\n // do some cleanup, if necessary\n\n return false;\n }\n\n return true;\n }\n\n /** @noinspection PhpMissingParentCallCommonInspection */\n /**\n * @inheritDoc\n */\n public function getEmailMessage($notificationType = 'instant') {\n $this->getUserNotificationObject()->setBaz(BazRuntimeCache::getInstance()->getObject($this->getUserNotificationObject()->bazID));\n\n $messageID = '<com.woltlab.example.baz/'.$this->getUserNotificationObject()->bazID.'@'.Email::getHost().'>';\n\n return [\n 'application' => 'example',\n 'in-reply-to' => [$messageID],\n 'message-id' => 'com.woltlab.example.foo/'.$this->getUserNotificationObject()->fooID,\n 'references' => [$messageID],\n 'template' => 'email_notification_foo'\n ];\n }\n\n /**\n * @inheritDoc\n * @since 5.0\n */\n public function getEmailTitle() {\n $this->getUserNotificationObject()->setBaz(BazRuntimeCache::getInstance()->getObject($this->getUserNotificationObject()->bazID));\n\n return $this->getLanguage()->getDynamicVariable('example.foo.notification.mail.title', [\n 'userNotificationObject' => $this->getUserNotificationObject()\n ]);\n }\n\n /** @noinspection PhpMissingParentCallCommonInspection */\n /**\n * @inheritDoc\n */\n public function getEventHash() {\n return sha1($this->eventID . '-' . $this->getUserNotificationObject()->bazID);\n }\n\n /**\n * @inheritDoc\n */\n public function getLink() {\n return LinkHandler::getInstance()->getLink('Foo', [\n 'application' => 'example',\n 'object' => $this->getUserNotificationObject()->getDecoratedObject()\n ]);\n }\n\n /**\n * @inheritDoc\n */\n public function getMessage() {\n $authors = $this->getAuthors();\n $count = count($authors);\n\n if ($count > 1) {\n if (isset($authors[0])) {\n unset($authors[0]);\n }\n $count = count($authors);\n\n return $this->getLanguage()->getDynamicVariable('example.foo.notification.message.stacked', [\n 'author' => $this->author,\n 'authors' => array_values($authors),\n 'count' => $count,\n 'guestTimesTriggered' => $this->notification->guestTimesTriggered,\n 'message' => $this->getUserNotificationObject(),\n 'others' => $count - 1\n ]);\n }\n\n return $this->getLanguage()->getDynamicVariable('example.foo.notification.message', [\n 'author' => $this->author,\n 'userNotificationObject' => $this->getUserNotificationObject()\n ]);\n }\n\n /**\n * @inheritDoc\n */\n public function getTitle() {\n $count = count($this->getAuthors());\n if ($count > 1) {\n return $this->getLanguage()->getDynamicVariable('example.foo.notification.title.stacked', [\n 'count' => $count,\n 'timesTriggered' => $this->notification->timesTriggered\n ]);\n }\n\n return $this->getLanguage()->get('example.foo.notification.title');\n }\n\n /**\n * @inheritDoc\n */\n protected function prepare() {\n BazRuntimeCache::getInstance()->cacheObjectID($this->getUserNotificationObject()->bazID);\n }\n}\n
$stackable
property is false
by default and has to be explicitly set to true
if stacking of notifications should be enabled. Stacking of notification does not create new notifications for the same event for a certain object if the related action as been triggered by different users. For example, if something is liked by one user and then liked again by another user before the recipient of the notification has confirmed it, the existing notification will be amended to include both users who liked the content. Stacking can thus be used to avoid cluttering the notification list of users.checkAccess()
method makes sure that the active user still has access to the object related to the notification. If that is not the case, the user notification system will automatically deleted the user notification based on the return value of the method. If you have any cached values related to notifications, you should also reset these values here.getEmailMessage()
method return data to create the instant email or the daily summary email. For instant emails ($notificationType = 'instant'
), you have to return an array like the one shown in the code above with the following components:application
: abbreviation of applicationin-reply-to
(optional): message id of the notification for the parent item and used to improve the ordering in threaded email clientsmessage-id
(optional): message id of the notification mail and has to be used in in-reply-to
and references
for follow up mailsreferences
(optional): all of the message ids of parent items (i.e. recursive in-reply-to)template
: name of the template used to render the email body, should start with email_
variables
(optional): template variables passed to the email template where they can be accessed via $notificationContent[variables]
For daily emails ($notificationType = 'daily'
), only application
, template
, and variables
are supported. - The getEmailTitle()
returns the title of the instant email sent to the user. By default, getEmailTitle()
simply calls getTitle()
. - The getEventHash()
method returns a hash by which user notifications are grouped. Here, we want to group them not by the actual Foo
object but by its parent Baz
object and thus overwrite the default implementation provided by AbstractUserNotificationEvent
. - The getLink()
returns the link to the Foo
object the notification belongs to. - The getMessage()
method and the getTitle()
return the message and the title of the user notification respectively. By checking the value of count($this->getAuthors())
, we check if the notification is stacked, thus if the event has been triggered for multiple users so that different languages items are used. If your notification event does not support stacking, this distinction is not necessary. - The prepare()
method is called for each user notification before all user notifications are rendered. This allows to tell runtime caches to prepare to load objects later on (see Runtime Caches).
When the action related to a user notification is executed, you can use UserNotificationHandler::fireEvent()
to create the notifications:
$recipientIDs = []; // fill with user ids of the recipients of the notification\nUserNotificationHandler::getInstance()->fireEvent(\n 'bar', // event name\n 'com.woltlab.example.foo', // event object type name\n new FooUserNotificationObject(new Foo($fooID)), // object related to the event\n $recipientIDs\n);\n
"},{"location":"php/api/user_notifications/#marking-notifications-as-confirmed","title":"Marking Notifications as Confirmed","text":"In some instances, you might want to manually mark user notifications as confirmed without the user manually confirming them, for example when they visit the page that is related to the user notification. In this case, you can use UserNotificationHandler::markAsConfirmed()
:
$recipientIDs = []; // fill with user ids of the recipients of the notification\n$fooIDs = []; // fill with ids of related foo objects\nUserNotificationHandler::getInstance()->markAsConfirmed(\n 'bar', // event name\n 'com.woltlab.example.foo', // event object type name\n $recipientIDs,\n $fooIDs\n);\n
"},{"location":"php/api/form_builder/dependencies/","title":"Form Node Dependencies","text":"Form node dependencies allow to make parts of a form dynamically available or unavailable depending on the values of form fields. Dependencies are always added to the object whose visibility is determined by certain form fields. They are not added to the form field\u2019s whose values determine the visibility! An example is a text form field that should only be available if a certain option from a single selection form field is selected. Form builder\u2019s dependency system supports such scenarios and also automatically making form containers unavailable once all of its children are unavailable.
If a form node has multiple dependencies and one of them is not met, the form node is unavailable. A form node not being available due to dependencies has to the following consequences:
IFormDocument::getData()
.IFormFieldDependency
","text":"The basis of the dependencies is the IFormFieldDependency
interface that has to be implemented by every dependency class. The interface requires the following methods:
checkDependency()
checks if the dependency is met, thus if the dependant form field should be considered available.dependentNode(IFormNode $node)
and getDependentNode()
can be used to set and get the node whose availability depends on the referenced form field. TFormNode::addDependency()
automatically calls dependentNode(IFormNode $node)
with itself as the dependent node, thus the dependent node is automatically set by the API.field(IFormField $field)
and getField()
can be used to set and get the form field that influences the availability of the dependent node.fieldId($fieldId)
and getFieldId()
can be used to set and get the id of the form field that influences the availability of the dependent node.getHtml()
returns JavaScript code required to ensure the dependency in the form output.getId()
returns the id of the dependency used to identify multiple dependencies of the same form node.static create($id)
is the factory method that has to be used to create new dependencies with the given id.AbstractFormFieldDependency
provides default implementations for all methods except for checkDependency()
.
Using fieldId($fieldId)
instead of field(IFormField $field)
makes sense when adding the dependency directly when setting up the form:
$container->appendChildren([\n FooField::create('a'),\n\n BarField::create('b')\n ->addDependency(\n BazDependency::create('a')\n ->fieldId('a')\n )\n]);\n
Here, without an additional assignment, the first field with id a
cannot be accessed thus fieldId($fieldId)
should be used as the id of the relevant field is known. When the form is built, all dependencies that only know the id of the relevant field and do not have a reference for the actual object are populated with the actual form field objects.
WoltLab Suite Core delivers the following default dependency classes by default:
"},{"location":"php/api/form_builder/dependencies/#nonemptyformfielddependency","title":"NonEmptyFormFieldDependency
","text":"NonEmptyFormFieldDependency
can be used to ensure that a node is only shown if the value of the referenced form field is not empty (being empty is determined using PHP\u2019s empty()
language construct).
EmptyFormFieldDependency
","text":"This is the inverse of NonEmptyFormFieldDependency
, checking for !empty()
.
ValueFormFieldDependency
","text":"ValueFormFieldDependency
can be used to ensure that a node is only shown if the value of the referenced form field is from a specified list of of values (see methods values($values)
and getValues()
). Additionally, via negate($negate = true)
and isNegated()
, the logic can also be inverted by requiring the value of the referenced form field not to be from a specified list of values.
ValueIntervalFormFieldDependency
","text":"Only available since version 5.5.
ValueIntervalFormFieldDependency
can be used to ensure that a node is only shown if the value of the referenced form field is in a specific interval whose boundaries are set via minimum(?float $minimum = null)
and maximum(?float $maximum = null)
.
IsNotClickedFormFieldDependency
","text":"IsNotClickedFormFieldDependency
is a special dependency for ButtonFormField
s. Refer to the documentation of ButtomFormField
for details.
To ensure that dependent node are correctly shown and hidden when changing the value of referenced form fields, every PHP dependency class has a corresponding JavaScript module that checks the dependency in the browser. Every JavaScript dependency has to extend WoltLabSuite/Core/Form/Builder/Field/Dependency/Abstract
and implement the checkDependency()
function, the JavaScript version of IFormFieldDependency::checkDependency()
.
All of the JavaScript dependency objects automatically register themselves during initialization with the WoltLabSuite/Core/Form/Builder/Field/Dependency/Manager
which takes care of checking the dependencies at the correct points in time.
Additionally, the dependency manager also ensures that form containers in which all children are hidden due to dependencies are also hidden and, once any child becomes available again, makes the container also available again. Every form container has to create a matching form container dependency object from a module based on WoltLabSuite/Core/Form/Builder/Field/Dependency/Abstract
.
If $booleanFormField
is an instance of BooleanFormField
and the text form field $textFormField
should only be available if \u201cYes\u201d has been selected, the following condition has to be set up:
$textFormField->addDependency(\n NonEmptyFormFieldDependency::create('booleanFormField')\n ->field($booleanFormField)\n);\n
If $singleSelectionFormField
is an instance of SingleSelectionFormField
that offers the options 1
, 2
, and 3
and $textFormField
should only be available if 1
or 3
is selected, the following condition has to be set up:
$textFormField->addDependency(\n ValueFormFieldDependency::create('singleSelectionFormField')\n ->field($singleSelectionFormField)\n ->values([1, 3])\n);\n
If, in contrast, $singleSelectionFormField
has many available options and 7
is the only option for which $textFormField
should not be available, negate()
should be used:
$textFormField->addDependency(\n ValueFormFieldDependency::create('singleSelectionFormField')\n ->field($singleSelectionFormField)\n ->values([7])\n ->negate()\n);\n
"},{"location":"php/api/form_builder/form_fields/","title":"Form Builder Fields","text":""},{"location":"php/api/form_builder/form_fields/#abstract-form-fields","title":"Abstract Form Fields","text":"The following form field classes cannot be instantiated directly because they are abstract, but they can/must be used when creating own form field classes.
"},{"location":"php/api/form_builder/form_fields/#abstractformfield","title":"AbstractFormField
","text":"AbstractFormField
is the abstract default implementation of the IFormField
interface and it is expected that every implementation of IFormField
implements the interface by extending this class.
AbstractNumericFormField
","text":"AbstractNumericFormField
is the abstract implementation of a form field handling a single numeric value. The class implements IAttributeFormField
, IAutoCompleteFormField
, ICssClassFormField
, IImmutableFormField
, IInputModeFormField
, IMaximumFormField
, IMinimumFormField
, INullableFormField
, IPlaceholderFormField
and ISuffixedFormField
. If the property $integerValues
is true
, the form field works with integer values, otherwise it works with floating point numbers. The methods step($step = null)
and getStep()
can be used to set and get the step attribute of the input
element. The default step for form fields with integer values is 1
. Otherwise, the default step is any
.
AbstractFormFieldDecorator
","text":"Only available since version 5.4.5.
AbstractFormFieldDecorator
is a default implementation of a decorator for form fields that forwards calls to all methods defined in IFormField
to the respective method of the decorated object. The class implements IFormfield
. If the implementation of a more specific interface is required then the remaining methods must be implemented in the concrete decorator derived from AbstractFormFieldDecorator
and the type of the $field
property must be narrowed appropriately.
The following form fields are general reusable fields without any underlying context.
"},{"location":"php/api/form_builder/form_fields/#booleanformfield","title":"BooleanFormField
","text":"BooleanFormField
is used for boolean (0
or 1
, yes
or no
) values. Objects of this class require a label. The return value of getSaveValue()
is the integer representation of the boolean value, i.e. 0
or 1
. The class implements IAttributeFormField
, IAutoFocusFormField
, ICssClassFormField
, and IImmutableFormField
.
CheckboxFormField
","text":"CheckboxFormField
extends BooleanFormField
and offers a simple HTML checkbox.
ClassNameFormField
","text":"ClassNameFormField
is a text form field that supports additional settings, specific to entering a PHP class name:
classExists($classExists = true)
and getClassExists()
can be used to ensure that the entered class currently exists in the installation. By default, the existance of the entered class is required.implementedInterface($interface)
and getImplementedInterface()
can be used to ensure that the entered class implements the specified interface. By default, no interface is required.parentClass($parentClass)
and getParentClass()
can be used to ensure that the entered class extends the specified class. By default, no parent class is required.instantiable($instantiable = true)
and isInstantiable()
can be used to ensure that the entered class is instantiable. By default, entered classes have to instantiable.Additionally, the default id of a ClassNameFormField
object is className
, the default label is wcf.form.field.className
, and if either an interface or a parent class is required, a default description is set if no description has already been set (wcf.form.field.className.description.interface
and wcf.form.field.className.description.parentClass
, respectively).
DateFormField
","text":"DateFormField
is a form field to enter a date (and optionally a time). The class implements IAttributeFormField
, IAutoFocusFormField
, ICssClassFormField
, IImmutableFormField
, and INullableFormField
. The following methods are specific to this form field class:
earliestDate($earliestDate)
and getEarliestDate()
can be used to get and set the earliest selectable/valid date and latestDate($latestDate)
and getLatestDate()
can be used to get and set the latest selectable/valid date. The date passed to the setters must have the same format as set via saveValueFormat()
. If a custom format is used, that format has to be set via saveValueFormat()
before calling any of the setters.saveValueFormat($saveValueFormat)
and getSaveValueFormat()
can be used to specify the date format of the value returned by getSaveValue()
. By default, U
is used as format. The PHP manual provides an overview of supported formats.supportTime($supportsTime = true)
and supportsTime()
can be used to toggle whether, in addition to a date, a time can also be specified. By default, specifying a time is disabled.DescriptionFormField
","text":"DescriptionFormField
is a multi-line text form field with description
as the default id and wcf.global.description
as the default label.
EmailFormField
","text":"EmailFormField
is a form field to enter an email address which is internally validated using UserUtil::isValidEmail()
. The class implements IAttributeFormField
, IAutoCompleteFormField
, IAutoFocusFormField
, ICssClassFormField
, II18nFormField
, IImmutableFormField
, IInputModeFormField
, IPatternFormField
, and IPlaceholderFormField
.
FloatFormField
","text":"FloatFormField
is an implementation of AbstractNumericFormField for floating point numbers.
HiddenFormField
","text":"HiddenFormField
is a form field without any user-visible UI. Even though the form field is invisible to the user, the value can still be modified by the user, e.g. by leveraging the web browsers developer tools. The HiddenFormField
must not be used to transfer sensitive information or information that the user should not be able to modify.
IconFormField
","text":"IconFormField
is a form field to select a FontAwesome icon.
IntegerFormField
","text":"IntegerFormField
is an implementation of AbstractNumericFormField for integers.
IsDisabledFormField
","text":"IsDisabledFormField
is a boolean form field with isDisabled
as the default id.
ItemListFormField
","text":"ItemListFormField
is a form field in which multiple values can be entered and returned in different formats as save value. The class implements IAttributeFormField
, IAutoFocusFormField
, ICssClassFormField
, IImmutableFormField
, and IMultipleFormField
. The saveValueType($saveValueType)
and getSaveValueType()
methods are specific to this form field class and determine the format of the save value. The following save value types are supported:
ItemListFormField::SAVE_VALUE_TYPE_ARRAY
adds a custom data processor that writes the form field data directly in the parameters array and not in the data sub-array of the parameters array.ItemListFormField::SAVE_VALUE_TYPE_CSV
lets the value be returned as a string in which the values are concatenated by commas.ItemListFormField::SAVE_VALUE_TYPE_NSV
lets the value be returned as a string in which the values are concatenated by \\n
.ItemListFormField::SAVE_VALUE_TYPE_SSV
lets the value be returned as a string in which the values are concatenated by spaces.By default, ItemListFormField::SAVE_VALUE_TYPE_CSV
is used.
If ItemListFormField::SAVE_VALUE_TYPE_ARRAY
is used as save value type, ItemListFormField
objects register a custom form field data processor to add the relevant array into the $parameters
array directly using the object property as the array key.
MultilineTextFormField
","text":"MultilineTextFormField
is a text form field that supports multiple rows of text. The methods rows($rows)
and getRows()
can be used to set and get the number of rows of the textarea
elements. The default number of rows is 10
. These methods do not, however, restrict the number of text rows that can be entered.
MultipleSelectionFormField
","text":"MultipleSelectionFormField
is a form fields that allows the selection of multiple options out of a predefined list of available options. The class implements IAttributeFormField
, ICssClassFormField
, IFilterableSelectionFormField
, and IImmutableFormField
.
RadioButtonFormField
","text":"RadioButtonFormField
is a form fields that allows the selection of a single option out of a predefined list of available options using radiobuttons. The class implements IAttributeFormField
, ICssClassFormField
, IImmutableFormField
, and ISelectionFormField
.
RatingFormField
","text":"RatingFormField
is a form field to set a rating for an object. The class implements IImmutableFormField
, IMaximumFormField
, IMinimumFormField
, and INullableFormField
. Form fields of this class have rating
as their default id, wcf.form.field.rating
as their default label, 1
as their default minimum, and 5
as their default maximum. For this field, the minimum and maximum refer to the minimum and maximum rating an object can get. When the field is shown, there will be maximum() - minimum() + 1
icons be shown with additional CSS classes that can be set and gotten via defaultCssClasses(array $cssClasses)
and getDefaultCssClasses()
. If a rating values is set, the first getValue()
icons will instead use the classes that can be set and gotten via activeCssClasses(array $cssClasses)
and getActiveCssClasses()
. By default, the only default class is fa-star-o
and the active classes are fa-star
and orange
.
ShowOrderFormField
","text":"ShowOrderFormField
is a single selection form field for which the selected value determines the position at which an object is shown. The show order field provides a list of all siblings and the object will be positioned after the selected sibling. To insert objects at the very beginning, the options()
automatically method prepends an additional option for that case so that only the existing siblings need to be passed. The default id of instances of this class is showOrder
and their default label is wcf.form.field.showOrder
.
It is important that the relevant object property is always kept updated. Whenever a new object is added or an existing object is edited or delete, the values of the other objects have to be adjusted to ensure consecutive numbering.
"},{"location":"php/api/form_builder/form_fields/#singleselectionformfield","title":"SingleSelectionFormField
","text":"SingleSelectionFormField
is a form fields that allows the selection of a single option out of a predefined list of available options. The class implements ICssClassFormField
, IFilterableSelectionFormField
, IImmutableFormField
, and INullableFormField
. If the field is nullable and the current form field value is considered empty
by PHP, null
is returned as the save value.
SortOrderFormField
","text":"SingleSelectionFormField
is a single selection form field with default id sortOrder
, default label wcf.global.showOrder
and default options ASC: wcf.global.sortOrder.ascending
and DESC: wcf.global.sortOrder.descending
.
TextFormField
","text":"TextFormField
is a form field that allows entering a single line of text. The class implements IAttributeFormField
, IAutoCompleteFormField
, ICssClassFormField
, IImmutableFormField
, II18nFormField
, IInputModeFormField
, IMaximumLengthFormField
, IMinimumLengthFormField
, IPatternFormField
, and IPlaceholderFormField
.
TitleFormField
","text":"TitleFormField
is a text form field with title
as the default id and wcf.global.title
as the default label.
UrlFormField
","text":"UrlFormField
is a text form field whose values are checked via Url::is()
.
The following form fields are reusable fields that generally are bound to a certain API or DatabaseObject
implementation.
AclFormField
","text":"AclFormField
is used for setting up acl values for specific objects. The class implements IObjectTypeFormField
and requires an object type of the object type definition com.woltlab.wcf.acl
. Additionally, the class provides the methods categoryName($categoryName)
and getCategoryName()
that allow setting a specific name or filter for the acl option categories whose acl options are shown. A category name of null
signals that no category filter is used.
Since version 5.5, the category name also supports filtering using a wildcard like user.*
, see WoltLab/WCF#4355.
AclFormField
objects register a custom form field data processor to add the relevant ACL object type id into the $parameters
array directly using {$objectProperty}_aclObjectTypeID
as the array key. The relevant database object action method is expected, based on the given ACL object type id, to save the ACL option values appropriately.
ButtonFormField
","text":"Only available since version 5.4.
ButtonFormField
shows a submit button as part of the form. The class implements IAttributeFormField
and ICssClassFormField
.
Specifically for this form field, there is the IsNotClickedFormFieldDependency
dependency with which certain parts of the form will only be processed if the relevent button has not clicked.
CaptchaFormField
","text":"CaptchaFormField
is used to add captcha protection to the form.
You must specify a captcha object type (com.woltlab.wcf.captcha
) using the objectType()
method.
ColorFormField
","text":"Only available since version 5.5.
ColorFormField
is used to specify RGBA colors using the rgba(r, g, b, a)
format. The class implements IImmutableFormField
.
ContentLanguageFormField
","text":"ContentLanguageFormField
is used to select the content language of an object. Fields of this class are only available if multilingualism is enabled and if there are content languages. The class implements IImmutableFormField
.
LabelFormField
","text":"LabelFormField
is used to select a label from a specific label group. The class implements IObjectTypeFormNode
.
The labelGroup(ViewableLabelGroup $labelGroup)
and getLabelGroup()
methods are specific to this form field class and can be used to set and get the label group whose labels can be selected. Additionally, there is the static method createFields($objectType, array $labelGroups, $objectProperty = 'labelIDs)
that can be used to create all relevant label form fields for a given list of label groups. In most cases, LabelFormField::createFields()
should be used.
OptionFormField
","text":"OptionFormField
is an item list form field to set a list of options. The class implements IPackagesFormField
and only options of the set packages are considered available. The default label of instances of this class is wcf.form.field.option
and their default id is options
.
SimpleAclFormField
","text":"SimpleAclFormField
is used for setting up simple acl values (one yes
/no
option per user and user group) for specific objects.
SimpleAclFormField
objects register a custom form field data processor to add the relevant simple ACL data array into the $parameters
array directly using the object property as the array key.
Since version 5.5, the field also supports inverted permissions, see WoltLab/WCF#4570.
The SimpleAclFormField
supports inverted permissions, allowing the administrator to grant access to all non-selected users and groups. If this behavior is desired, it needs to be enabled by calling supportInvertedPermissions
. An invertPermissions
key containing a boolean value with the users selection will be provided together with the ACL values when saving the field.
SingleMediaSelectionFormField
","text":"SingleMediaSelectionFormField
is used to select a specific media file. The class implements IImmutableFormField
.
The following methods are specific to this form field class:
imageOnly($imageOnly = true)
and isImageOnly()
can be used to set and check if only images may be selected.getMedia()
returns the media file based on the current field value if a field is set.TagFormField
","text":"TagFormField
is a form field to enter tags. The class implements IAttributeFormField
and IObjectTypeFormNode
. Arrays passed to TagFormField::values()
can contain tag names as strings and Tag
objects. The default label of instances of this class is wcf.tagging.tags
and their default description is wcf.tagging.tags.description
.
TagFormField
objects register a custom form field data processor to add the array with entered tag names into the $parameters
array directly using the object property as the array key.
UploadFormField
","text":"UploadFormField
is a form field that allows uploading files by the user.
UploadFormField
objects register a custom form field data processor to add the array of wcf\\system\\file\\upload\\UploadFile\\UploadFile
into the $parameters
array directly using the object property as the array key. Also it registers the removed files as an array of wcf\\system\\file\\upload\\UploadFile\\UploadFile
into the $parameters
array directly using the object property with the suffix _removedFiles
as the array key.
The field supports additional settings:
imageOnly($imageOnly = true)
and isImageOnly()
can be used to ensure that the uploaded files are only images.allowSvgImage($allowSvgImages = true)
and svgImageAllowed()
can be used to allow SVG images, if the image only mode is enabled (otherwise, the method will throw an exception). By default, SVG images are not allowed.To provide values from a database object, you should implement the method get{$objectProperty}UploadFileLocations()
to your database object class. This method must return an array of strings with the locations of the files.
To process files in the database object action class, you must rename
the file to the final destination. You get the temporary location, by calling the method getLocation()
on the given UploadFile
objects. After that, you call setProcessed($location)
with $location
contains the new file location. This method sets the isProcessed
flag to true and saves the new location. For updating files, it is relevant, whether a given file is already processed or not. For this case, the UploadFile
object has an method isProcessed()
which indicates, whether a file is already processed or new uploaded.
UserFormField
","text":"UserFormField
is a form field to enter existing users. The class implements IAutoCompleteFormField
, IAutoFocusFormField
, IImmutableFormField
, IMultipleFormField
, and INullableFormField
. While the user is presented the names of the specified users in the user interface, the field returns the ids of the users as data. The relevant UserProfile
objects can be accessed via the getUsers()
method.
UserPasswordField
","text":"Only available since version 5.4.
UserPasswordField
is a form field for users' to enter their current password. The class implements IAttributeFormField
, IAttributeFormField
, IAutoCompleteFormField
, IAutoFocusFormField
, and IPlaceholderFormField
UserGroupOptionFormField
","text":"UserGroupOptionFormField
is an item list form field to set a list of user group options/permissions. The class implements IPackagesFormField
and only user group options of the set packages are considered available. The default label of instances of this class is wcf.form.field.userGroupOption
and their default id is permissions
.
UsernameFormField
","text":"UsernameFormField
is used for entering one non-existing username. The class implements IAttributeFormField
, IImmutableFormField
, IMaximumLengthFormField
, IMinimumLengthFormField
, INullableFormField
, and IPlaceholderFormField
. As usernames have a system-wide restriction of a minimum length of 3 and a maximum length of 100 characters, these values are also used as the default value for the field\u2019s minimum and maximum length.
To integrate a wysiwyg editor into a form, you have to create a WysiwygFormContainer
object. This container takes care of creating all necessary form nodes listed below for a wysiwyg editor.
When creating the container object, its id has to be the id of the form field that will manage the actual text.
The following methods are specific to this form container class:
addSettingsNode(IFormChildNode $settingsNode)
and addSettingsNodes(array $settingsNodes)
can be used to add nodes to the settings tab container.attachmentData($objectType, $parentObjectID)
can be used to set the data relevant for attachment support. By default, not attachment data is set, thus attachments are not supported.getAttachmentField()
, getPollContainer()
, getSettingsContainer()
, getSmiliesContainer()
, and getWysiwygField()
can be used to get the different components of the wysiwyg form container once the form has been built.enablePreviewButton($enablePreviewButton)
can be used to set whether the preview button for the message is shown or not. By default, the preview button is shown. This method is only relevant before the form is built. Afterwards, the preview button availability can not be changed.getObjectId()
returns the id of the edited object or 0
if no object is edited.getPreselect()
, preselect($preselect)
can be used to set the value of the wysiwyg tab menu's data-preselect
attribute used to determine which tab is preselected. By default, the preselect is 'true'
which is used to pre-select the first tab.messageObjectType($messageObjectType)
can be used to set the message object type.pollObjectType($pollObjectType)
can be used to set the poll object type. By default, no poll object type is set, thus the poll form field container is not available.supportMentions($supportMentions)
can be used to set if mentions are supported. By default, mentions are not supported. This method is only relevant before the form is built. Afterwards, mention support can only be changed via the wysiwyg form field.supportSmilies($supportSmilies)
can be used to set if smilies are supported. By default, smilies are supported. This method is only relevant before the form is built. Afterwards, smiley availability can only be changed via changing the availability of the smilies form container.WysiwygAttachmentFormField
","text":"WysiwygAttachmentFormField
provides attachment support for a wysiwyg editor via a tab in the menu below the editor. This class should not be used directly but only via WysiwygFormContainer
. The methods attachmentHandler(AttachmentHandler $attachmentHandler)
and getAttachmentHandler()
can be used to set and get the AttachmentHandler
object that is used for uploaded attachments.
WysiwygPollFormContainer
","text":"WysiwygPollFormContainer
provides poll support for a wysiwyg editor via a tab in the menu below the editor. This class should not be used directly but only via WysiwygFormContainer
. WysiwygPollFormContainer
contains all form fields that are required to create polls and requires edited objects to implement IPollContainer
.
The following methods are specific to this form container class:
getEndTimeField()
returns the form field to set the end time of the poll once the form has been built.getIsChangeableField()
returns the form field to set if poll votes can be changed once the form has been built.getIsPublicField()
returns the form field to set if poll results are public once the form has been built.getMaxVotesField()
returns the form field to set the maximum number of votes once the form has been built.getOptionsField()
returns the form field to set the poll options once the form has been built.getQuestionField()
returns the form field to set the poll question once the form has been built.getResultsRequireVoteField()
returns the form field to set if viewing the poll results requires voting once the form has been built.getSortByVotesField()
returns the form field to set if the results are sorted by votes once the form has been built.WysiwygSmileyFormContainer
","text":"WysiwygSmileyFormContainer
provides smiley support for a wysiwyg editor via a tab in the menu below the editor. This class should not be used directly but only via WysiwygFormContainer
. WysiwygSmileyFormContainer
creates a sub-tab for each smiley category.
WysiwygSmileyFormNode
","text":"WysiwygSmileyFormNode
is contains the smilies of a specific category. This class should not be used directly but only via WysiwygSmileyFormContainer
.
The following code creates a WYSIWYG editor component for a message
object property. As smilies are supported by default and an attachment object type is given, the tab menu below the editor has two tabs: \u201cSmilies\u201d and \u201cAttachments\u201d. Additionally, mentions and quotes are supported.
WysiwygFormContainer::create('message')\n ->label('foo.bar.message')\n ->messageObjectType('com.example.foo.bar')\n ->attachmentData('com.example.foo.bar')\n ->supportMentions()\n ->supportQuotes()\n
"},{"location":"php/api/form_builder/form_fields/#wysiwygformfield","title":"WysiwygFormField
","text":"WysiwygFormField
is used for wysiwyg editor form fields. This class should, in general, not be used directly but only via WysiwygFormContainer
. The class implements IAttributeFormField
, IMaximumLengthFormField
, IMinimumLengthFormField
, and IObjectTypeFormNode
and requires an object type of the object type definition com.woltlab.wcf.message
. The following methods are specific to this form field class:
autosaveId($autosaveId)
and getAutosaveId()
can be used enable automatically saving the current editor contents in the browser using the given id. An empty string signals that autosaving is disabled.lastEditTime($lastEditTime)
and getLastEditTime()
can be used to set the last time the contents have been edited and saved so that the JavaScript can determine if the contents stored in the browser are older or newer. 0
signals that no last edit time has been set.supportAttachments($supportAttachments)
and supportsAttachments()
can be used to set and check if the form field supports attachments.
It is not sufficient to simply signal attachment support via these methods for attachments to work. These methods are relevant internally to signal the Javascript code that the editor supports attachments. Actual attachment support is provided by WysiwygAttachmentFormField
.
supportMentions($supportMentions)
and supportsMentions()
can be used to set and check if the form field supports mentions of other users.
WysiwygFormField
objects register a custom form field data processor to add the relevant simple ACL data array into the $parameters
array directly using the object property as the array key.
TWysiwygFormNode
","text":"All form nodes that need to know the id of the WysiwygFormField
field should use TWysiwygFormNode
. This trait provides getWysiwygId()
and wysiwygId($wysiwygId)
to get and set the relevant wysiwyg editor id.
MultipleBoardSelectionFormField
","text":"Only available since version 5.5.
MultipleBoardSelectionFormField
is used to select multiple forums. The class implements IAttributeFormField
, ICssClassFormField
, and IImmutableFormField
.
The field supports additional settings:
boardNodeList(BoardNodeList $boardNodeList): self
and getBoardNodeList(): BoardNodeList
are used to set and get the list of board nodes used to render the board selection. boardNodeList(BoardNodeList $boardNodeList): self
will automatically call readNodeTree()
on the given board node list.categoriesSelectable(bool $categoriesSelectable = true): self
and areCategoriesSelectable(): bool
are used to set and check if the categories in the board node list are selectable. By default, categories are selectable. This option is useful if only actual boards, in which threads can be posted, should be selectable but the categories must still be shown so that the overall forum structure is still properly shown.supportExternalLinks(bool $supportExternalLinks): self
and supportsExternalLinks(): bool
are used to set and check if external links will be shown in the selection list. By default, external links are shown. Like in the example given before, in cases where only actual boards, in which threads can be posted, are relevant, this option allows to exclude external links.The following form fields are specific for certain forms and hardly reusable in other contexts.
"},{"location":"php/api/form_builder/form_fields/#bbcodeattributesformfield","title":"BBCodeAttributesFormField
","text":"DevtoolsProjectExcludedPackagesFormField
is a form field for setting the attributes of a BBCode.
DevtoolsProjectExcludedPackagesFormField
","text":"DevtoolsProjectExcludedPackagesFormField
is a form field for setting the excluded packages of a devtools project.
DevtoolsProjectInstructionsFormField
","text":"DevtoolsProjectExcludedPackagesFormField
is a form field for setting the installation and update instructions of a devtools project.
DevtoolsProjectOptionalPackagesFormField
","text":"DevtoolsProjectExcludedPackagesFormField
is a form field for setting the optional packages of a devtools project.
DevtoolsProjectRequiredPackagesFormField
","text":"DevtoolsProjectExcludedPackagesFormField
is a form field for setting the required packages of a devtools project.
WoltLab Suite includes a powerful way of creating forms: Form Builder. Form builder allows you to easily define all the fields and their constraints and interdependencies within PHP with full IDE support. It will then automatically generate the necessary HTML with full interactivity to render all the fields and also validate the fields\u2019 contents upon submission.
The migration guide for WoltLab Suite Core 5.2 provides some examples of how to migrate existing forms to form builder that can also help in understanding form builder if the old way of creating forms is familiar.
"},{"location":"php/api/form_builder/overview/#form-builder-components","title":"Form Builder Components","text":"Form builder consists of several components that are presented on the following pages:
In general, form builder provides default implementation of interfaces by providing either abstract classes or traits. It is expected that the interfaces are always implemented using these abstract classes and traits! This way, if new methods are added to the interfaces, default implementations can be provided by the abstract classes and traits without causing backwards compatibility problems.
"},{"location":"php/api/form_builder/overview/#abstractformbuilderform","title":"AbstractFormBuilderForm
","text":"To make using form builder easier, AbstractFormBuilderForm
extends AbstractForm
and provides most of the code needed to set up a form (of course without specific fields, those have to be added by the concrete form class), like reading and validating form values and using a database object action to use the form data to create or update a database object.
In addition to the existing methods inherited by AbstractForm
, AbstractFormBuilderForm
provides the following methods:
buildForm()
builds the form in the following steps:
Call AbtractFormBuilderForm::createForm()
to create the IFormDocument
object and add the form fields.
IFormDocument::build()
to build the form.AbtractFormBuilderForm::finalizeForm()
to finalize the form like adding dependencies.Additionally, between steps 1 and 2 and after step 3, the method provides two events, createForm
and buildForm
to allow plugins to register event listeners to execute additional code at the right point in time. - createForm()
creates the FormDocument
object and sets the form mode. Classes extending AbstractFormBuilderForm
have to override this method (and call parent::createForm()
as the first line in the overridden method) to add concrete form containers and form fields to the bare form document. - finalizeForm()
is called after the form has been built and the complete form hierarchy has been established. This method should be overridden to add dependencies, for example. - setFormAction()
is called at the end of readData()
and sets the form document\u2019s action based on the controller class name and whether an object is currently edited. - If an object is edited, at the beginning of readData()
, setFormObjectData()
is called which calls IFormDocument::loadValuesFromObject()
. If values need to be loaded from additional sources, this method should be used for that.
AbstractFormBuilderForm
also provides the following (public) properties:
$form
contains the IFormDocument
object created in createForm()
.$formAction
is either create
(default) or edit
and handles which method of the database object is called by default (create
is called for $formAction = 'create'
and update
is called for $formAction = 'edit'
) and is used to set the value of the action
template variable.$formObject
contains the IStorableObject
if the form is used to edit an existing object. For forms used to create objects, $formObject
is always null
. Edit forms have to manually identify the edited object based on the request data and set the value of $formObject
. $objectActionName
can be used to set an alternative action to be executed by the database object action that deviates from the default action determined by the value of $formAction
.$objectActionClass
is the name of the database object action class that is used to create or update the database object.DialogFormDocument
","text":"Form builder forms can also be used in dialogs. For such forms, DialogFormDocument
should be used which provides the additional methods cancelable($cancelable = true)
and isCancelable()
to set and check if the dialog can be canceled. If a dialog form can be canceled, a cancel button is added.
If the dialog form is fetched via an AJAX request, IFormDocument::ajax()
has to be called. AJAX forms are registered with WoltLabSuite/Core/Form/Builder/Manager
which also supports getting all of the data of a form via the getData(formId)
function. The getData()
function relies on all form fields creating and registering a WoltLabSuite/Core/Form/Builder/Field/Field
object that provides the data of a specific field.
To make it as easy as possible to work with AJAX forms in dialogs, WoltLabSuite/Core/Form/Builder/Dialog
(abbreviated as FormBuilderDialog
from now on) should generally be used instead of WoltLabSuite/Core/Form/Builder/Manager
directly. The constructor of FormBuilderDialog
expects the following parameters:
dialogId
: id of the dialog elementclassName
: PHP class used to get the form dialog (and save the data if options.submitActionName
is set)actionName
: name of the action/method of className
that returns the dialog; the method is expected to return an array with formId
containg the id of the returned form and dialog
containing the rendered form dialogoptions
: additional options:actionParameters
(default: empty): additional parameters sent during AJAX requestsdestroyOnClose
(default: false
): if true
, whenever the dialog is closed, the form is destroyed so that a new form is fetched if the dialog is opened againdialog
: additional dialog options used as options
during dialog setuponSubmit
: callback when the form is submitted (takes precedence over submitActionName
)submitActionName
(default: not set): name of the action/method of className
called when the form is submittedThe three public functions of FormBuilderDialog
are:
destroy()
destroys the dialog, the form, and all of the form fields.getData()
returns a Promise that returns the form data.open()
opens the dialog.Example:
require(['WoltLabSuite/Core/Form/Builder/Dialog'], function(FormBuilderDialog) {\nvar dialog = new FormBuilderDialog(\n'testDialog',\n'wcf\\\\data\\\\test\\\\TestAction',\n'getDialog',\n{\ndestroyOnClose: true,\ndialog: {\ntitle: 'Test Dialog'\n},\nsubmitActionName: 'saveDialog'\n}\n);\n\nelById('testDialogButton').addEventListener('click', function() {\ndialog.open();\n});\n});\n
"},{"location":"php/api/form_builder/structure/","title":"Structure of Form Builder","text":"Forms built with form builder consist of three major structural elements listed from top to bottom:
The basis for all three elements are form nodes.
The form builder API uses fluent interfaces heavily, meaning that unless a method is a getter, it generally returns the objects itself to support method chaining.
"},{"location":"php/api/form_builder/structure/#form-nodes","title":"Form Nodes","text":"IFormNode
is the base interface that any node of a form has to implement.IFormChildNode
extends IFormNode
for such elements of a form that can be a child node to a parent node.IFormParentNode
extends IFormNode
for such elements of a form that can be a parent to child nodes.IFormElement
extends IFormNode
for such elements of a form that can have a description and a label.IFormNode
","text":"IFormNode
is the base interface that any node of a form has to implement and it requires the following methods:
addClass($class)
, addClasses(array $classes)
, removeClass($class)
, getClasses()
, and hasClass($class)
add, remove, get, and check for CSS classes of the HTML element representing the form node. If the form node consists of multiple (nested) HTML elements, the classes are generally added to the top element. static validateClass($class)
is used to check if a given CSS class is valid. By default, a form node has no CSS classes.addDependency(IFormFieldDependency $dependency)
, removeDependency($dependencyId)
, getDependencies()
, and hasDependency($dependencyId)
add, remove, get, and check for dependencies of this form node on other form fields. checkDependencies()
checks if all of the node\u2019s dependencies are met and returns a boolean value reflecting the check\u2019s result. The form builder dependency documentation provides more detailed information about dependencies and how they work. By default, a form node has no dependencies.attribute($name, $value = null)
, removeAttribute($name)
, getAttribute($name)
, getAttributes()
, hasAttribute($name)
add, remove, get, and check for attributes of the HTML element represting the form node. The attributes are added to the same element that the CSS classes are added to. static validateAttribute($name)
is used to check if a given attribute is valid. By default, a form node has no attributes.available($available = true)
and isAvailable()
can be used to set and check if the node is available. The availability functionality can be used to easily toggle form nodes based, for example, on options without having to create a condition to append the relevant. This way of checking availability makes it easier to set up forms. By default, every form node is available.The following aspects are important when working with availability:
Availability sets the static availability for form nodes that does not change during the lifetime of a form. In contrast, dependencies represent a dynamic availability for form nodes that depends on the current value of certain form fields. - cleanup()
is called after the whole form is not used anymore to reset other APIs if the form fields depends on them and they expect such a reset. This method is not intended to clean up the form field\u2019s value as a new form document object is created to show a clean form. - getDocument()
returns the IFormDocument
object the node belongs to. (As IFormDocument
extends IFormNode
, form document objects simply return themselves.) - getHtml()
returns the HTML representation of the node. getHtmlVariables()
return template variables (in addition to the form node itself) to render the node\u2019s HTML representation. - id($id)
and getId()
set and get the id of the form node. Every id has to be unique within a form. getPrefixedId()
returns the prefixed version of the node\u2019s id (see IFormDocument::getPrefix()
and IFormDocument::prefix()
). static validateId($id)
is used to check if a given id is valid. - populate()
is called by IFormDocument::build()
after all form nodes have been added. This method should finilize the initialization of the form node after all parent-child relations of the form document have been established. This method is needed because during the construction of a form node, it neither knows the form document it will belong to nor does it know its parent. - validate()
checks, after the form is submitted, if the form node is valid. A form node with children is valid if all of its child nodes are valid. A form field is valid if its value is valid. - static create($id)
is the factory method that has to be used to create new form nodes with the given id.
TFormNode
provides a default implementation of most of these methods.
IFormChildNode
","text":"IFormChildNode
extends IFormNode
for such elements of a form that can be a child node to a parent node and it requires the parent(IFormParentNode $parentNode)
and getParent()
methods used to set and get the node\u2019s parent node. TFormChildNode
provides a default implementation of these two methods and also of IFormNode::getDocument()
.
IFormParentNode
","text":"IFormParentNode
extends IFormNode
for such elements of a form that can be a parent to child nodes. Additionally, the interface also extends \\Countable
and \\RecursiveIterator
. The interface requires the following methods:
appendChild(IFormChildNode $child)
, appendChildren(array $children)
, insertAfter(IFormChildNode $child, $referenceNodeId)
, and insertBefore(IFormChildNode $child, $referenceNodeId)
are used to insert new children either at the end or at specific positions. validateChild(IFormChildNode $child)
is used to check if a given child node can be added. A child node cannot be added if it would cause an id to be used twice.children()
returns the direct children of a form node.getIterator()
return a recursive iterator for a form node.getNodeById($nodeId)
returns the node with the given id by searching for it in the node\u2019s children and recursively in all of their children. contains($nodeId)
can be used to simply check if a node with the given id exists.hasValidationErrors()
checks if a form node or any of its children has a validation error (see IFormField::getValidationErrors()
).readValues()
recursively calls IFormParentNode::readValues()
and IFormField::readValue()
on its children.IFormElement
","text":"IFormElement
extends IFormNode
for such elements of a form that can have a description and a label and it requires the following methods:
label($languageItem = null, array $variables = [])
and getLabel()
can be used to set and get the label of the form element. requiresLabel()
can be checked if the form element requires a label. A label-less form element that requires a label will prevent the form from being rendered by throwing an exception.description($languageItem = null, array $variables = [])
and getDescription()
can be used to set and get the description of the form element.IObjectTypeFormNode
","text":"IObjectTypeFormField
has to be implemented by form nodes that rely on a object type of a specific object type definition in order to function. The implementing class has to implement the methods objectType($objectType)
, getObjectType()
, and getObjectTypeDefinition()
. TObjectTypeFormNode
provides a default implementation of these three methods.
CustomFormNode
","text":"CustomFormNode
is a form node whose contents can be set directly via content($content)
.
This class should generally not be relied on. Instead, TemplateFormNode
should be used.
TemplateFormNode
","text":"TemplateFormNode
is a form node whose contents are read from a template. TemplateFormNode
has the following additional methods:
application($application)
and getApplicaton()
can be used to set and get the abbreviation of the application the shown template belongs to. If no template has been set explicitly, getApplicaton()
returns wcf
.templateName($templateName)
and getTemplateName()
can be used to set and get the name of the template containing the node contents. If no template has been set and the node is rendered, an exception will be thrown.variables(array $variables)
and getVariables()
can be used to set and get additional variables passed to the template.A form document object represents the form as a whole and has to implement the IFormDocument
interface. WoltLab Suite provides a default implementation with the FormDocument
class. IFormDocument
should not be implemented directly but instead FormDocument
should be extended to avoid issues if the IFormDocument
interface changes in the future.
IFormDocument
extends IFormParentNode
and requires the following additional methods:
action($action)
and getAction()
can be used set and get the action
attribute of the <form>
HTML element.addButton(IFormButton $button)
and getButtons()
can be used add and get form buttons that are shown at the bottom of the form. addDefaultButton($addDefaultButton)
and hasDefaultButton()
can be used to set and check if the form has the default button which is added by default unless specified otherwise. Each implementing class may define its own default button. FormDocument
has a button with id submitButton
, label wcf.global.button.submit
, access key s
, and CSS class buttonPrimary
as its default button. ajax($ajax)
and isAjax()
can be used to set and check if the form document is requested via an AJAX request or processes data via an AJAX request. These methods are helpful for form fields that behave differently when providing data via AJAX.build()
has to be called once after all nodes have been added to this document to trigger IFormNode::populate()
.formMode($formMode)
and getFormMode()
sets the form mode. Possible form modes are:
IFormDocument::FORM_MODE_CREATE
has to be used when the form is used to create a new object.
IFormDocument::FORM_MODE_UPDATE
has to be used when the form is used to edit an existing object.getData()
returns the array containing the form data and which is passed as the $parameters
argument of the constructor of a database object action object.getDataHandler()
returns the data handler for this document that is used to process the field data into a parameters array for the constructor of a database object action object.getEnctype()
returns the encoding type of the form. If the form contains a IFileFormField
, multipart/form-data
is returned, otherwise null
is returned.loadValues(array $data, IStorableObject $object)
is used when editing an existing object to set the form field values by calling IFormField::loadValue()
for all form fields. Additionally, the form mode is set to IFormDocument::FORM_MODE_UPDATE
.markRequiredFields(bool $markRequiredFields = true): self
and marksRequiredFields(): bool
can be used to set and check whether fields that are required are marked (with an asterisk in the label) in the output.method($method)
and getMethod()
can be used to set and get the method
attribute of the <form>
HTML element. By default, the method is post
.prefix($prefix)
and getPrefix()
can be used to set and get a global form prefix that is prepended to form elements\u2019 names and ids to avoid conflicts with other forms. By default, the prefix is an empty string. If a prefix of foo
is set, getPrefix()
returns foo_
(additional trailing underscore).requestData(array $requestData)
, getRequestData($index = null)
, and hasRequestData($index = null)
can be used to set, get and check for specific request data. In most cases, the relevant request data is the $_POST
array. In default AJAX requests handled by database object actions, however, the request data generally is in AbstractDatabaseObjectAction::$parameters
. By default, $_POST
is the request data.The last aspect is relevant for DialogFormDocument
objects. DialogFormDocument
is a specialized class for forms in dialogs that, in contrast to FormDocument
do not require an action
to be set. Additionally, DialogFormDocument
provides the cancelable($cancelable = true)
and isCancelable()
methods used to determine if the dialog from can be canceled. By default, dialog forms are cancelable.
A form button object represents a button shown at the end of the form that, for example, submits the form. Every form button has to implement the IFormButton
interface that extends IFormChildNode
and IFormElement
. IFormButton
requires four methods to be implemented:
accessKey($accessKey)
and getAccessKey()
can be used to set and get the access key with which the form button can be activated. By default, form buttons have no access key set.submit($submitButton)
and isSubmit()
can be used to set and check if the form button is a submit button. A submit button is an input[type=submit]
element. Otherwise, the button is a button
element. A form container object represents a container for other form containers or form field directly. Every form container has to implement the IFormContainer
interface which requires the following method:
loadValues(array $data, IStorableObject $object)
is called by IFormDocument::loadValuesFromObject()
to inform the container that object data is loaded. This method is not intended to generally call IFormField::loadValues()
on its form field children as these methods are already called by IFormDocument::loadValuesFromObject()
. This method is intended for specialized form containers with more complex logic.There are multiple default container implementations:
FormContainer
is the default implementation of IFormContainer
.TabMenuFormContainer
represents the container of tab menu, whileTabFormContainer
represents a tab of a tab menu andTabTabMenuFormContainer
represents a tab of a tab menu that itself contains a tab menu.RowFormContainer
are shown in a row and should use col-*
classes.RowFormFieldContainer
are also shown in a row but does not show the labels and descriptions of the individual form fields. Instead of the individual labels and descriptions, the container's label and description is shown and both span all of fields.SuffixFormFieldContainer
can be used for one form field with a second selection form field used as a suffix.The methods of the interfaces that FormContainer
is implementing are well documented, but here is a short overview of the most important methods when setting up a form or extending a form with an event listener:
appendChild(IFormChildNode $child)
, appendChildren(array $children)
, and insertBefore(IFormChildNode $child, $referenceNodeId)
are used to insert new children into the form container.description($languageItem = null, array $variables = [])
and label($languageItem = null, array $variables = [])
are used to set the description and the label or title of the form container.A form field object represents a concrete form field that allows entering data. Every form field has to implement the IFormField
interface which extends IFormChildNode
and IFormElement
.
IFormField
requires the following additional methods:
addValidationError(IFormFieldValidationError $error)
and getValidationErrors()
can be used to get and set validation errors of the form field (see form validation).addValidator(IFormFieldValidator $validator)
, getValidators()
, removeValidator($validatorId)
, and hasValidator($validatorId)
can be used to get, set, remove, and check for validators for the form field (see form validation).getFieldHtml()
returns the field's HTML output without the surrounding dl
structure.objectProperty($objectProperty)
and getObjectProperty()
can be used to get and set the object property that the field represents. When setting the object property is set to an empty string, the previously set object property is unset. If no object property has been set, the field\u2019s (non-prefixed) id is returned.The object property allows having different fields (requiring different ids) that represent the same object property which is handy when available options of the field\u2019s value depend on another field. Having object property allows to define different fields for each value of the other field and to use form field dependencies to only show the appropriate field. - readValue()
reads the form field value from the request data after the form is submitted. - required($required = true)
and isRequired()
can be used to determine if the form field has to be filled out. By default, form fields do not have to be filled out. - value($value)
and getSaveValue()
can be used to get and set the value of the form field to be used outside of the context of forms. getValue()
, in contrast, returns the internal representation of the form field\u2019s value. In general, the internal representation is only relevant when validating the value in additional validators. loadValue(array $data, IStorableObject $object)
extracts the form field value from the given data array (and additional, non-editable data from the object if the field needs them).
AbstractFormField
provides default implementations of many of the listed methods above and should be extended instead of implementing IFormField
directly.
An overview of the form fields provided by default can be found here.
"},{"location":"php/api/form_builder/structure/#form-field-interfaces-and-traits","title":"Form Field Interfaces and Traits","text":"WoltLab Suite Core provides a variety of interfaces and matching traits with default implementations for several common features of form fields:
"},{"location":"php/api/form_builder/structure/#iattributeformfield","title":"IAttributeFormField
","text":"Only available since version 5.4.
IAttributeFormField
has to be implemented by form fields for which attributes can be added to the actual form element (in addition to adding attributes to the surrounding element via the attribute-related methods of IFormNode
). The implementing class has to implement the methods fieldAttribute(string $name, string $value = null): self
and getFieldAttribute(string $name): self
/getFieldAttributes(): array
, which are used to add and get the attributes, respectively. Additionally, hasFieldAttribute(string $name): bool
has to implemented to check if a certain attribute is present, removeFieldAttribute(string $name): self
to remove an attribute, and static validateFieldAttribute(string $name)
to check if the attribute is valid for this specific class. TAttributeFormField
provides a default implementation of these methods and TInputAttributeFormField
specializes the trait for input
-based form fields. These two traits also ensure that if a specific interface that handles a specific attribute is implemented, like IAutoCompleteFormField
handling autocomplete
, this attribute cannot be set with this API. Instead, the dedicated API provided by the relevant interface has to be used.
IAutoCompleteFormField
","text":"Only available since version 5.4.
IAutoCompleteFormField
has to be implemented by form fields that support the autocomplete
attribute. The implementing class has to implement the methods autoComplete(?string $autoComplete): self
and getAutoComplete(): ?string
, which are used to set and get the autocomplete value, respectively. TAutoCompleteFormField
provides a default implementation of these two methods and TTextAutoCompleteFormField
specializes the trait for text form fields. When using TAutoCompleteFormField
, you have to implement the getValidAutoCompleteTokens(): array
method which returns all valid autocomplete
tokens.
IAutoFocusFormField
","text":"IAutoFocusFormField
has to be implemented by form fields that can be auto-focused. The implementing class has to implement the methods autoFocus($autoFocus = true)
and isAutoFocused()
. By default, form fields are not auto-focused. TAutoFocusFormField
provides a default implementation of these two methods.
ICssClassFormField
","text":"Only available since version 5.4.
ICssClassFormField
has to be implemented by form fields for which CSS classes can be added to the actual form element (in addition to adding CSS classes to the surrounding element via the class-related methods of IFormNode
). The implementing class has to implement the methods addFieldClass(string $class): self
/addFieldClasses(array $classes): self
and getFieldClasses(): array
, which are used to add and get the CSS classes, respectively. Additionally, hasFieldClass(string $class): bool
has to implemented to check if a certain CSS class is present and removeFieldClass(string $class): self
to remove a CSS class. TCssClassFormField
provides a default implementation of these methods.
IFileFormField
","text":"IFileFormField
has to be implemented by every form field that uploads files so that the enctype
attribute of the form document is multipart/form-data
(see IFormDocument::getEnctype()
).
IFilterableSelectionFormField
","text":"IFilterableSelectionFormField
extends ISelectionFormField
by the possibilty for users when selecting the value(s) to filter the list of available options. The implementing class has to implement the methods filterable($filterable = true)
and isFilterable()
. TFilterableSelectionFormField
provides a default implementation of these two methods.
II18nFormField
","text":"II18nFormField
has to be implemented by form fields if the form field value can be entered separately for all available languages. The implementing class has to implement the following methods:
i18n($i18n = true)
and isI18n()
can be used to set whether a specific instance of the class actually supports multilingual input.i18nRequired($i18nRequired = true)
and isI18nRequired()
can be used to set whether a specific instance of the class requires separate values for all languages.languageItemPattern($pattern)
and getLanguageItemPattern()
can be used to set the pattern/regular expression for the language item used to save the multilingual values.hasI18nValues()
and hasPlainValue()
check if the current value is a multilingual or monolingual value.TI18nFormField
provides a default implementation of these eight methods and additional default implementations of some of the IFormField
methods. If multilingual input is enabled for a specific form field, classes using TI18nFormField
register a custom form field data processor to add the array with multilingual input into the $parameters
array directly using {$objectProperty}_i18n
as the array key. If multilingual input is enabled but only a monolingual value is entered, the custom form field data processor does nothing and the form field\u2019s value is added by the DefaultFormDataProcessor
into the data
sub-array of the $parameters
array.
TI18nFormField
already provides a default implementation of IFormField::validate()
.
IImmutableFormField
","text":"IImmutableFormField
has to be implemented by form fields that support being displayed but whose value cannot be changed. The implementing class has to implement the methods immutable($immutable = true)
and isImmutable()
that can be used to determine if the value of the form field is mutable or immutable. By default, form field are mutable.
IInputModeFormField
","text":"Only available since version 5.4.
IInputModeFormField
has to be implemented by form fields that support the inputmode
attribute. The implementing class has to implement the methods inputMode(?string $inputMode): self
and getInputMode(): ?string
, which are used to set and get the input mode, respectively. TInputModeFormField
provides a default implementation of these two methods.
IMaximumFormField
","text":"IMaximumFormField
has to be implemented by form fields if the entered value must have a maximum value. The implementing class has to implement the methods maximum($maximum = null)
and getMaximum()
. A maximum of null
signals that no maximum value has been set. TMaximumFormField
provides a default implementation of these two methods.
The implementing class has to validate the entered value against the maximum value manually.
"},{"location":"php/api/form_builder/structure/#imaximumlengthformfield","title":"IMaximumLengthFormField
","text":"IMaximumLengthFormField
has to be implemented by form fields if the entered value must have a maximum length. The implementing class has to implement the methods maximumLength($maximumLength = null)
, getMaximumLength()
, and validateMaximumLength($text, Language $language = null)
. A maximum length of null
signals that no maximum length has been set. TMaximumLengthFormField
provides a default implementation of these two methods.
The implementing class has to validate the entered value against the maximum value manually by calling validateMaximumLength()
.
IMinimumFormField
","text":"IMinimumFormField
has to be implemented by form fields if the entered value must have a minimum value. The implementing class has to implement the methods minimum($minimum = null)
and getMinimum()
. A minimum of null
signals that no minimum value has been set. TMinimumFormField
provides a default implementation of these three methods.
The implementing class has to validate the entered value against the minimum value manually.
"},{"location":"php/api/form_builder/structure/#iminimumlengthformfield","title":"IMinimumLengthFormField
","text":"IMinimumLengthFormField
has to be implemented by form fields if the entered value must have a minimum length. The implementing class has to implement the methods minimumLength($minimumLength = null)
, getMinimumLength()
, and validateMinimumLength($text, Language $language = null)
. A minimum length of null
signals that no minimum length has been set. TMinimumLengthFormField
provides a default implementation of these three methods.
The implementing class has to validate the entered value against the minimum value manually by calling validateMinimumLength()
.
IMultipleFormField
","text":"IMinimumLengthFormField
has to be implemented by form fields that support selecting or setting multiple values. The implementing class has to implement the following methods:
multiple($multiple = true)
and allowsMultiple()
can be used to set whether a specific instance of the class actually should support multiple values. By default, multiple values are not supported.minimumMultiples($minimum)
and getMinimumMultiples()
can be used to set the minimum number of values that have to be selected/entered. By default, there is no required minimum number of values.maximumMultiples($minimum)
and getMaximumMultiples()
can be used to set the maximum number of values that have to be selected/entered. By default, there is no maximum number of values. IMultipleFormField::NO_MAXIMUM_MULTIPLES
is returned if no maximum number of values has been set and it can also be used to unset a previously set maximum number of values.TMultipleFormField
provides a default implementation of these six methods and classes using TMultipleFormField
register a custom form field data processor to add the HtmlInputProcessor
object with the text into the $parameters
array directly using {$objectProperty}_htmlInputProcessor
as the array key.
The implementing class has to validate the values against the minimum and maximum number of values manually.
"},{"location":"php/api/form_builder/structure/#inullableformfield","title":"INullableFormField
","text":"INullableFormField
has to be implemented by form fields that support null
as their (empty) value. The implementing class has to implement the methods nullable($nullable = true)
and isNullable()
. TNullableFormField
provides a default implementation of these two methods.
null
should be returned by IFormField::getSaveValue()
is the field is considered empty and the form field has been set as nullable.
IPackagesFormField
","text":"IPackagesFormField
has to be implemented by form fields that, in some way, considers packages whose ids may be passed to the field object. The implementing class has to implement the methods packageIDs(array $packageIDs)
and getPackageIDs()
. TPackagesFormField
provides a default implementation of these two methods.
IPatternFormField
","text":"Only available since version 5.4.
IPatternFormField
has to be implemented by form fields that support the pattern
attribute. The implementing class has to implement the methods pattern(?string $pattern): self
and getPattern(): ?string
, which are used to set and get the pattern, respectively. TPatternFormField
provides a default implementation of these two methods.
IPlaceholderFormField
","text":"IPlaceholderFormField
has to be implemented by form fields that support a placeholder value for empty fields. The implementing class has to implement the methods placeholder($languageItem = null, array $variables = [])
and getPlaceholder()
. TPlaceholderFormField
provides a default implementation of these two methods.
ISelectionFormField
","text":"ISelectionFormField
has to be implemented by form fields with a predefined set of possible values. The implementing class has to implement the getter and setter methods options($options, $nestedOptions = false, $labelLanguageItems = true)
and getOptions()
and additionally two methods related to nesting, i.e. whether the selectable options have a hierarchy: supportsNestedOptions()
and getNestedOptions()
. TSelectionFormField
provides a default implementation of these four methods.
ISuffixedFormField
","text":"ISuffixedFormField
has to be implemented by form fields that support supports displaying a suffix behind the actual input field. The implementing class has to implement the methods suffix($languageItem = null, array $variables = [])
and getSuffix()
. TSuffixedFormField
provides a default implementation of these two methods.
TDefaultIdFormField
","text":"Form fields that have a default id have to use TDefaultIdFormField
and have to implement the method getDefaultId()
.
The only thing to do in a template to display the whole form including all of the necessary JavaScript is to put
{@$form->getHtml()}\n
into the template file at the relevant position.
"},{"location":"php/api/form_builder/validation_data/","title":"Form Validation and Form Data","text":""},{"location":"php/api/form_builder/validation_data/#form-validation","title":"Form Validation","text":"Every form field class has to implement IFormField::validate()
according to their internal logic of what constitutes a valid value. If a certain constraint for the value is not met, a form field validation error object is added to the form field. Form field validation error classes have to implement the interface IFormFieldValidationError
.
In addition to intrinsic validations like checking the length of the value of a text form field, in many cases, there are additional constraints specific to the form like ensuring that the text is not already used by a different object of the same database object class. Such additional validations can be added to (and removed from) the form field via implementations of the IFormFieldValidator
interface.
IFormFieldValidationError
/ FormFieldValidationError
","text":"IFormFieldValidationError
requires the following methods:
__construct($type, $languageItem = null, array $information = [])
creates a new validation error object for an error with the given type and message stored in the given language items. The information array is used when generating the error message.getHtml()
returns the HTML element representing the error that is shown to the user.getMessage()
returns the error message based on the language item and information array given in the constructor.getInformation()
and getType()
are getters for the first and third parameter of the constructor.FormFieldValidationError
is a default implementation of the interface that shows the error in an small.innerError
HTML element below the form field.
Form field validation errors are added to form fields via the IFormField::addValidationError(IFormFieldValidationError $error)
method.
IFormFieldValidator
/ FormFieldValidator
","text":"IFormFieldValidator
requires the following methods:
__construct($id, callable $validator)
creates a new validator with the given id that passes the validated form field to the given callable that does the actual validation. static validateId($id)
is used to check if the given id is valid.__invoke(IFormField $field)
is used when the form field is validated to execute the validator.getId()
returns the id of the validator.FormFieldValidator
is a default implementation of the interface.
Form field validators are added to form fields via the addValidator(IFormFieldValidator $validator)
method.
The following source code adds a validator that validates whether the value in the input field matches a specific value.
$container->appendChildren([\n FooField::create('a')\n ->addValidator(new FormFieldValidator('b', function (FooField $formField) {\n if ($formField->getValue() != 'value') {\n $formField->addValidationError(\n new FormFieldValidationError(\n 'type',\n 'phrase'\n )\n );\n }\n })),\n]);\n
"},{"location":"php/api/form_builder/validation_data/#form-data","title":"Form Data","text":"After a form is successfully validated, the data of the form fields (returned by IFormDocument::getData()
) have to be extracted which is the job of the IFormDataHandler
object returned by IFormDocument::getDataHandler()
. Form data handlers themselves, however, are only iterating through all IFormDataProcessor
instances that have been registered with the data handler.
IFormDataHandler
/ FormDataHandler
","text":"IFormDataHandler
requires the following methods:
addProcessor(IFormDataProcessor $processor)
adds a new data processor to the data handler.getFormData(IFormDocument $document)
returns the data of the given form by applying all registered data handlers on the form.getObjectData(IFormDocument $document, IStorableObject $object)
returns the data of the given object which will be used to populate the form field values of the given form.FormDataHandler
is the default implementation of this interface and should also be extended instead of implementing the interface directly.
IFormDataProcessor
/ DefaultFormDataProcessor
","text":"IFormDataProcessor
requires the following methods:
processFormData(IFormDocument $document, array $parameters)
is called by IFormDataHandler::getFormData()
. The method processes the given parameters array and returns the processed version.processObjectData(IFormDocument $document, array $data, IStorableObject $object)
is called by IFormDataHandler::getObjectData()
. The method processes the given object data array and returns the processed version.When FormDocument
creates its FormDataHandler
instance, it automatically registers an DefaultFormDataProcessor
object as the first data processor. DefaultFormDataProcessor
puts the save value of all form fields that are available and have a save value into $parameters['data']
using the form field\u2019s object property as the array key.
IFormDataProcessor
should not be implemented directly. Instead, AbstractFormDataProcessor
should be extended.
All form data is put into the data
sub-array so that the whole $parameters
array can be passed to a database object action object that requires the actual database object data to be in the data
sub-array.
When adding a data processor to a form, make sure to add the data processor after the form has been built.
"},{"location":"php/api/form_builder/validation_data/#additional-data-processors","title":"Additional Data Processors","text":""},{"location":"php/api/form_builder/validation_data/#customformdataprocessor","title":"CustomFormDataProcessor
","text":"As mentioned above, the data in the data
sub-array is intended to directly create or update the database object with. As these values are used in the database query directly, these values cannot contain arrays. Several form fields, however, store and return their data in form of arrays. Thus, this data cannot be returned by IFormField::getSaveValue()
so that IFormField::hasSaveValue()
returns false
and the form field\u2019s data is not collected by the standard DefaultFormDataProcessor
object.
Instead, such form fields register a CustomFormDataProcessor
in their IFormField::populate()
method that inserts the form field value into the $parameters
array directly. This way, the relevant database object action method has access to the data to save it appropriately.
The constructor of CustomFormDataProcessor
requires an id (that is primarily used in error messages during the validation of the second parameter) and callables for IFormDataProcessor::processFormData()
and IFormDataProcessor::processObjectData()
which are passed the same parameters as the IFormDataProcessor
methods. Only one of the callables has to be given, the other one then defaults to simply returning the relevant array unchanged.
VoidFormDataProcessor
","text":"Some form fields might only exist to toggle the visibility of other form fields (via dependencies) but the data of form field itself is irrelevant. As DefaultFormDataProcessor
collects the data of all form fields, an additional data processor in the form of a VoidFormDataProcessor
can be added whose constructor __construct($property, $isDataProperty = true)
requires the name of the relevant object property/form id and whether the form field value is stored in the data
sub-array or directory in the $parameters
array. When the data processor is invoked, it checks whether the relevant entry in the $parameters
array exists and voids it by removing it from the array.
In this tutorial series, we will code a package that allows administrators to create a registry of people. In this context, \"people\" does not refer to users registered on the website but anybody living, dead or fictional.
We will start this tutorial series by creating a base structure for the package and then continue by adding further features step by step using different APIs. Note that in the context of this example, not every added feature might make perfect sense but the goal of this tutorial is not to create a useful package but to introduce you to WoltLab Suite.
In the first part of this tutorial series, we will lay out what the basic version of package should be able to do and how to implement these functions.
"},{"location":"tutorial/series/part_1/#package-functionality","title":"Package Functionality","text":"The package should provide the following possibilities/functions:
We will use the following package installation plugins:
use database objects, create pages and use templates.
"},{"location":"tutorial/series/part_1/#package-structure","title":"Package Structure","text":"The package will have the following file structure:
\u251c\u2500\u2500 acpMenu.xml\n\u251c\u2500\u2500 acptemplates\n\u2502 \u251c\u2500\u2500 personAdd.tpl\n\u2502 \u2514\u2500\u2500 personList.tpl\n\u251c\u2500\u2500 files\n\u2502 \u251c\u2500\u2500 acp\n\u2502 \u2502 \u2514\u2500\u2500 database\n\u2502 \u2502 \u2514\u2500\u2500 install_com.woltlab.wcf.people.php\n\u2502 \u2514\u2500\u2500 lib\n\u2502 \u251c\u2500\u2500 acp\n\u2502 \u2502 \u251c\u2500\u2500 form\n\u2502 \u2502 \u2502 \u251c\u2500\u2500 PersonAddForm.class.php\n\u2502 \u2502 \u2502 \u2514\u2500\u2500 PersonEditForm.class.php\n\u2502 \u2502 \u2514\u2500\u2500 page\n\u2502 \u2502 \u2514\u2500\u2500 PersonListPage.class.php\n\u2502 \u251c\u2500\u2500 data\n\u2502 \u2502 \u2514\u2500\u2500 person\n\u2502 \u2502 \u251c\u2500\u2500 Person.class.php\n\u2502 \u2502 \u251c\u2500\u2500 PersonAction.class.php\n\u2502 \u2502 \u251c\u2500\u2500 PersonEditor.class.php\n\u2502 \u2502 \u2514\u2500\u2500 PersonList.class.php\n\u2502 \u2514\u2500\u2500 page\n\u2502 \u2514\u2500\u2500 PersonListPage.class.php\n\u251c\u2500\u2500 language\n\u2502 \u251c\u2500\u2500 de.xml\n\u2502 \u2514\u2500\u2500 en.xml\n\u251c\u2500\u2500 menuItem.xml\n\u251c\u2500\u2500 package.xml\n\u251c\u2500\u2500 page.xml\n\u251c\u2500\u2500 templates\n\u2502 \u2514\u2500\u2500 personList.tpl\n\u2514\u2500\u2500 userGroupOption.xml\n
"},{"location":"tutorial/series/part_1/#person-modeling","title":"Person Modeling","text":""},{"location":"tutorial/series/part_1/#database-table","title":"Database Table","text":"As the first step, we have to model the people we want to manage with this package. As this is only an introductory tutorial, we will keep things simple and only consider the first and last name of a person. Thus, the database table we will store the people in only contains three columns:
personID
is the unique numeric identifier of each person created,firstName
contains the first name of the person,lastName
contains the last name of the person.The first file for our package is the install_com.woltlab.wcf.people.php
file used to create such a database table during package installation:
<?php\n\nuse wcf\\system\\database\\table\\column\\NotNullVarchar255DatabaseTableColumn;\nuse wcf\\system\\database\\table\\column\\ObjectIdDatabaseTableColumn;\nuse wcf\\system\\database\\table\\DatabaseTable;\nuse wcf\\system\\database\\table\\index\\DatabaseTablePrimaryIndex;\n\nreturn [\n DatabaseTable::create('wcf1_person')\n ->columns([\n ObjectIdDatabaseTableColumn::create('personID'),\n NotNullVarchar255DatabaseTableColumn::create('firstName'),\n NotNullVarchar255DatabaseTableColumn::create('lastName'),\n ])\n ->indices([\n DatabaseTablePrimaryIndex::create()\n ->columns(['personID']),\n ]),\n];\n
"},{"location":"tutorial/series/part_1/#database-object","title":"Database Object","text":""},{"location":"tutorial/series/part_1/#person","title":"Person
","text":"In our PHP code, each person will be represented by an object of the following class:
files/lib/data/person/Person.class.php<?php\n\nnamespace wcf\\data\\person;\n\nuse wcf\\data\\DatabaseObject;\nuse wcf\\system\\request\\IRouteController;\n\n/**\n * Represents a person.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2021 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\Data\\Person\n *\n * @property-read int $personID unique id of the person\n * @property-read string $firstName first name of the person\n * @property-read string $lastName last name of the person\n */\nclass Person extends DatabaseObject implements IRouteController\n{\n /**\n * Returns the first and last name of the person if a person object is treated as a string.\n *\n * @return string\n */\n public function __toString()\n {\n return $this->getTitle();\n }\n\n /**\n * @inheritDoc\n */\n public function getTitle()\n {\n return $this->firstName . ' ' . $this->lastName;\n }\n}\n
The important thing here is that Person
extends DatabaseObject
. Additionally, we implement the IRouteController
interface, which allows us to use Person
objects to create links, and we implement PHP's magic __toString() method for convenience.
For every database object, you need to implement three additional classes: an action class, an editor class and a list class.
"},{"location":"tutorial/series/part_1/#personaction","title":"PersonAction
","text":"files/lib/data/person/PersonAction.class.php <?php\n\nnamespace wcf\\data\\person;\n\nuse wcf\\data\\AbstractDatabaseObjectAction;\n\n/**\n * Executes person-related actions.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2021 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\Data\\Person\n *\n * @method Person create()\n * @method PersonEditor[] getObjects()\n * @method PersonEditor getSingleObject()\n */\nclass PersonAction extends AbstractDatabaseObjectAction\n{\n /**\n * @inheritDoc\n */\n protected $permissionsDelete = ['admin.content.canManagePeople'];\n\n /**\n * @inheritDoc\n */\n protected $requireACP = ['delete'];\n}\n
This implementation of AbstractDatabaseObjectAction
is very basic and only sets the $permissionsDelete
and $requireACP
properties. This is done so that later on, when implementing the people list for the ACP, we can delete people simply via AJAX. $permissionsDelete
has to be set to the permission needed in order to delete a person. We will later use the userGroupOption package installation plugin to create the admin.content.canManagePeople
permission. $requireACP
restricts deletion of people to the ACP.
PersonEditor
","text":"files/lib/data/person/PersonEditor.class.php <?php\n\nnamespace wcf\\data\\person;\n\nuse wcf\\data\\DatabaseObjectEditor;\n\n/**\n * Provides functions to edit people.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2021 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\Data\\Person\n *\n * @method static Person create(array $parameters = [])\n * @method Person getDecoratedObject()\n * @mixin Person\n */\nclass PersonEditor extends DatabaseObjectEditor\n{\n /**\n * @inheritDoc\n */\n protected static $baseClass = Person::class;\n}\n
This implementation of DatabaseObjectEditor
fulfills the minimum requirement for a database object editor: setting the static $baseClass
property to the database object class name.
PersonList
","text":"files/lib/data/person/PersonList.class.php <?php\n\nnamespace wcf\\data\\person;\n\nuse wcf\\data\\DatabaseObjectList;\n\n/**\n * Represents a list of people.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2021 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\Data\\Person\n *\n * @method Person current()\n * @method Person[] getObjects()\n * @method Person|null search($objectID)\n * @property Person[] $objects\n */\nclass PersonList extends DatabaseObjectList\n{\n}\n
Due to the default implementation of DatabaseObjectList
, our PersonList
class just needs to extend it and everything else is either automatically set by the code of DatabaseObjectList
or, in the case of properties and methods, provided by that class.
Next, we will take care of the controllers and views for the ACP. In total, we need three each:
Before we create the controllers and views, let us first create the menu items for the pages in the ACP menu.
"},{"location":"tutorial/series/part_1/#acp-menu","title":"ACP Menu","text":"We need to create three menu items:
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/5.4/acpMenu.xsd\">\n<import>\n<acpmenuitem name=\"wcf.acp.menu.link.person\">\n<parent>wcf.acp.menu.link.content</parent>\n</acpmenuitem>\n<acpmenuitem name=\"wcf.acp.menu.link.person.list\">\n<controller>wcf\\acp\\page\\PersonListPage</controller>\n<parent>wcf.acp.menu.link.person</parent>\n<permissions>admin.content.canManagePeople</permissions>\n</acpmenuitem>\n<acpmenuitem name=\"wcf.acp.menu.link.person.add\">\n<controller>wcf\\acp\\form\\PersonAddForm</controller>\n<parent>wcf.acp.menu.link.person.list</parent>\n<permissions>admin.content.canManagePeople</permissions>\n<icon>fa-plus</icon>\n</acpmenuitem>\n</import>\n</data>\n
We choose wcf.acp.menu.link.content
as the parent menu item for the first menu item wcf.acp.menu.link.person
because the people we are managing is just one form of content. The fourth level menu item wcf.acp.menu.link.person.add
will only be shown as an icon and thus needs an additional element icon
which takes a FontAwesome icon class as value.
To list the people in the ACP, we need a PersonListPage
class and a personList
template.
PersonListPage
","text":"files/lib/acp/page/PersonListPage.class.php <?php\n\nnamespace wcf\\acp\\page;\n\nuse wcf\\data\\person\\PersonList;\nuse wcf\\page\\SortablePage;\n\n/**\n * Shows the list of people.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2021 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\Acp\\Page\n */\nclass PersonListPage extends SortablePage\n{\n /**\n * @inheritDoc\n */\n public $activeMenuItem = 'wcf.acp.menu.link.person.list';\n\n /**\n * @inheritDoc\n */\n public $neededPermissions = ['admin.content.canManagePeople'];\n\n /**\n * @inheritDoc\n */\n public $objectListClassName = PersonList::class;\n\n /**\n * @inheritDoc\n */\n public $validSortFields = ['personID', 'firstName', 'lastName'];\n}\n
As WoltLab Suite Core already provides a powerful default implementation of a sortable page, our work here is minimal:
$activeMenuItem
.$neededPermissions
contains a list of permissions of which the user needs to have at least one in order to see the person list. We use the same permission for both the menu item and the page.$objectListClassName
and that handles fetching the people from database is the PersonList
class, which we have already created.$validSortFields
to the available database table columns.personList.tpl
","text":"acptemplates/personList.tpl {include file='header' pageTitle='wcf.acp.person.list'}\n\n<header class=\"contentHeader\">\n <div class=\"contentHeaderTitle\">\n <h1 class=\"contentTitle\">{lang}wcf.acp.person.list{/lang}</h1>\n </div>\n\n <nav class=\"contentHeaderNavigation\">\n <ul>\n <li><a href=\"{link controller='PersonAdd'}{/link}\" class=\"button\"><span class=\"icon icon16 fa-plus\"></span> <span>{lang}wcf.acp.menu.link.person.add{/lang}</span></a></li>\n\n{event name='contentHeaderNavigation'}\n </ul>\n </nav>\n</header>\n\n{hascontent}\n <div class=\"paginationTop\">\n{content}{pages print=true assign=pagesLinks controller=\"PersonList\" link=\"pageNo=%d&sortField=$sortField&sortOrder=$sortOrder\"}{/content}\n </div>\n{/hascontent}\n\n{if $objects|count}\n <div class=\"section tabularBox\">\n <table class=\"table jsObjectActionContainer\" data-object-action-class-name=\"wcf\\data\\person\\PersonAction\">\n <thead>\n <tr>\n <th class=\"columnID columnPersonID{if $sortField == 'personID'} active {@$sortOrder}{/if}\" colspan=\"2\"><a href=\"{link controller='PersonList'}pageNo={@$pageNo}&sortField=personID&sortOrder={if $sortField == 'personID' && $sortOrder == 'ASC'}DESC{else}ASC{/if}{/link}\">{lang}wcf.global.objectID{/lang}</a></th>\n <th class=\"columnTitle columnFirstName{if $sortField == 'firstName'} active {@$sortOrder}{/if}\"><a href=\"{link controller='PersonList'}pageNo={@$pageNo}&sortField=firstName&sortOrder={if $sortField == 'firstName' && $sortOrder == 'ASC'}DESC{else}ASC{/if}{/link}\">{lang}wcf.person.firstName{/lang}</a></th>\n <th class=\"columnTitle columnLastName{if $sortField == 'lastName'} active {@$sortOrder}{/if}\"><a href=\"{link controller='PersonList'}pageNo={@$pageNo}&sortField=lastName&sortOrder={if $sortField == 'lastName' && $sortOrder == 'ASC'}DESC{else}ASC{/if}{/link}\">{lang}wcf.person.lastName{/lang}</a></th>\n\n{event name='columnHeads'}\n </tr>\n </thead>\n\n <tbody class=\"jsReloadPageWhenEmpty\">\n{foreach from=$objects item=person}\n <tr class=\"jsObjectActionObject\" data-object-id=\"{@$person->getObjectID()}\">\n <td class=\"columnIcon\">\n <a href=\"{link controller='PersonEdit' object=$person}{/link}\" title=\"{lang}wcf.global.button.edit{/lang}\" class=\"jsTooltip\"><span class=\"icon icon16 fa-pencil\"></span></a>\n{objectAction action=\"delete\" objectTitle=$person->getTitle()}\n\n{event name='rowButtons'}\n </td>\n <td class=\"columnID\">{#$person->personID}</td>\n <td class=\"columnTitle columnFirstName\"><a href=\"{link controller='PersonEdit' object=$person}{/link}\">{$person->firstName}</a></td>\n <td class=\"columnTitle columnLastName\"><a href=\"{link controller='PersonEdit' object=$person}{/link}\">{$person->lastName}</a></td>\n\n{event name='columns'}\n </tr>\n{/foreach}\n </tbody>\n </table>\n </div>\n\n <footer class=\"contentFooter\">\n{hascontent}\n <div class=\"paginationBottom\">\n{content}{@$pagesLinks}{/content}\n </div>\n{/hascontent}\n\n <nav class=\"contentFooterNavigation\">\n <ul>\n <li><a href=\"{link controller='PersonAdd'}{/link}\" class=\"button\"><span class=\"icon icon16 fa-plus\"></span> <span>{lang}wcf.acp.menu.link.person.add{/lang}</span></a></li>\n\n{event name='contentFooterNavigation'}\n </ul>\n </nav>\n </footer>\n{else}\n <p class=\"info\">{lang}wcf.global.noItems{/lang}</p>\n{/if}\n\n{include file='footer'}\n
We will go piece by piece through the template code:
header
template and set the page title wcf.acp.person.list
. You have to include this template for every page!pages
template plugin. The {hascontent}{content}{/content}{/hascontent}
construct ensures the .paginationTop
element is only shown if the pages
template plugin has a return value, thus if a pagination is necessary.wcf.global.noItems
language item. The $objects
template variable is automatically assigned by wcf\\page\\MultipleLinkPage
and contains the PersonList
object used to read the people from database. The table itself consists of a thead
and a tbody
element and is extendable with more columns using the template events columnHeads
and columns
. In general, every table should provide these events. The default structure of a table is used here so that the first column of the content rows contains icons to edit and to delete the row (and provides another standard event rowButtons
) and that the second column contains the ID of the person. The table can be sorted by clicking on the head of each column. The used variables $sortField
and $sortOrder
are automatically assigned to the template by SortablePage
..contentFooter
element is only shown if people exist as it basically repeats the .contentHeaderNavigation
and .paginationTop
element..columnIcon
element relies on the global WoltLabSuite/Core/Ui/Object/Action
module which only requires the jsObjectActionContainer
CSS class in combination with the data-object-action-class-name
attribute for the table
element, the jsObjectActionObject
CSS class for each person's tr
element in combination with the data-object-id
attribute, and lastly the delete button itself, which is created with the objectAction
template plugin..jsReloadPageWhenEmpty
CSS class on the tbody
element ensures that once all persons on the page have been deleted, the page is reloaded.footer
template is included that terminates the page. You also have to include this template for every page!Now, we have finished the page to manage the people so that we can move on to the forms with which we actually create and edit the people.
"},{"location":"tutorial/series/part_1/#person-add-form","title":"Person Add Form","text":"Like the person list, the form to add new people requires a controller class and a template.
"},{"location":"tutorial/series/part_1/#personaddform","title":"PersonAddForm
","text":"files/lib/acp/form/PersonAddForm.class.php <?php\n\nnamespace wcf\\acp\\form;\n\nuse wcf\\data\\person\\PersonAction;\nuse wcf\\form\\AbstractFormBuilderForm;\nuse wcf\\system\\form\\builder\\container\\FormContainer;\nuse wcf\\system\\form\\builder\\field\\TextFormField;\n\n/**\n * Shows the form to create a new person.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2021 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\Acp\\Form\n */\nclass PersonAddForm extends AbstractFormBuilderForm\n{\n /**\n * @inheritDoc\n */\n public $activeMenuItem = 'wcf.acp.menu.link.person.add';\n\n /**\n * @inheritDoc\n */\n public $formAction = 'create';\n\n /**\n * @inheritDoc\n */\n public $neededPermissions = ['admin.content.canManagePeople'];\n\n /**\n * @inheritDoc\n */\n public $objectActionClass = PersonAction::class;\n\n /**\n * @inheritDoc\n */\n public $objectEditLinkController = PersonEditForm::class;\n\n /**\n * @inheritDoc\n */\n protected function createForm()\n {\n parent::createForm();\n\n $this->form->appendChild(\n FormContainer::create('data')\n ->label('wcf.global.form.data')\n ->appendChildren([\n TextFormField::create('firstName')\n ->label('wcf.person.firstName')\n ->required()\n ->autoFocus()\n ->maximumLength(255),\n\n TextFormField::create('lastName')\n ->label('wcf.person.lastName')\n ->required()\n ->maximumLength(255),\n ])\n );\n }\n}\n
The properties here consist of three types: the \u201chousekeeping\u201d properties $activeMenuItem
and $neededPermissions
, which fulfill the same roles as for PersonListPage
, and the $objectEditLinkController
property, which is used to generate a link to edit the newly created person after submitting the form, and finally $formAction
and $objectActionClass
required by the PHP form builder API used to generate the form.
Because of using form builder, we only have to set up the two form fields for entering the first and last name, respectively:
TextFormField
.create()
method expects the id of the field/name of the database object property, which is firstName
and lastName
, respectively, here.label()
method.required()
is called, and the maximum length is set via maximumLength()
.autoFocus()
.personAdd.tpl
","text":"acptemplates/personAdd.tpl {include file='header' pageTitle='wcf.acp.person.'|concat:$action}\n\n<header class=\"contentHeader\">\n <div class=\"contentHeaderTitle\">\n <h1 class=\"contentTitle\">{lang}wcf.acp.person.{$action}{/lang}</h1>\n </div>\n\n <nav class=\"contentHeaderNavigation\">\n <ul>\n <li><a href=\"{link controller='PersonList'}{/link}\" class=\"button\"><span class=\"icon icon16 fa-list\"></span> <span>{lang}wcf.acp.menu.link.person.list{/lang}</span></a></li>\n\n{event name='contentHeaderNavigation'}\n </ul>\n </nav>\n</header>\n\n{@$form->getHtml()}\n\n{include file='footer'}\n
We will now only concentrate on the new parts compared to personList.tpl
:
$action
variable to distinguish between the languages items used for adding a person and for creating a person.{@$form->getHtml()}
to generate all relevant output for the form.As mentioned before, for the form to edit existing people, we only need a new controller as the template has already been implemented in a way that it handles both, adding and editing.
"},{"location":"tutorial/series/part_1/#personeditform","title":"PersonEditForm
","text":"files/lib/acp/form/PersonEditForm.class.php <?php\n\nnamespace wcf\\acp\\form;\n\nuse wcf\\data\\person\\Person;\nuse wcf\\system\\exception\\IllegalLinkException;\n\n/**\n * Shows the form to edit an existing person.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2021 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\Acp\\Form\n */\nclass PersonEditForm extends PersonAddForm\n{\n /**\n * @inheritDoc\n */\n public $activeMenuItem = 'wcf.acp.menu.link.person';\n\n /**\n * @inheritDoc\n */\n public $formAction = 'update';\n\n /**\n * @inheritDoc\n */\n public function readParameters()\n {\n parent::readParameters();\n\n if (isset($_REQUEST['id'])) {\n $this->formObject = new Person($_REQUEST['id']);\n\n if (!$this->formObject->getObjectID()) {\n throw new IllegalLinkException();\n }\n }\n }\n}\n
In general, edit forms extend the associated add form so that the code to read and to validate the input data is simply inherited.
After setting a different active menu item, we have to change the value of $formAction
because this form, in contrast to PersonAddForm
, does not create but update existing persons.
As we rely on form builder, the only thing necessary in this controller is to read and validate the edit object, i.e. the edited person, which is done in readParameters()
.
For the front end, that means the part with which the visitors of a website interact, we want to implement a simple sortable page that lists the people. This page should also be directly linked in the main menu.
"},{"location":"tutorial/series/part_1/#pagexml","title":"page.xml
","text":"First, let us register the page with the system because every front end page or form needs to be explicitly registered using the page package installation plugin:
page.xml<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/5.4/page.xsd\">\n<import>\n<page identifier=\"com.woltlab.wcf.people.PersonList\">\n<pageType>system</pageType>\n<controller>wcf\\page\\PersonListPage</controller>\n<name language=\"de\">Personen-Liste</name>\n<name language=\"en\">Person List</name>\n\n<content language=\"de\">\n<title>Personen</title>\n</content>\n<content language=\"en\">\n<title>People</title>\n</content>\n</page>\n</import>\n</data>\n
For more information about what each of the elements means, please refer to the page package installation plugin page.
"},{"location":"tutorial/series/part_1/#menuitemxml","title":"menuItem.xml
","text":"Next, we register the menu item using the menuItem package installation plugin:
menuItem.xml<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/5.4/menuItem.xsd\">\n<import>\n<item identifier=\"com.woltlab.wcf.people.PersonList\">\n<menu>com.woltlab.wcf.MainMenu</menu>\n<title language=\"de\">Personen</title>\n<title language=\"en\">People</title>\n<page>com.woltlab.wcf.people.PersonList</page>\n</item>\n</import>\n</data>\n
Here, the import parts are that we register the menu item for the main menu com.woltlab.wcf.MainMenu
and link the menu item with the page com.woltlab.wcf.people.PersonList
, which we just registered.
As in the ACP, we need a controller and a template. You might notice that both the controller\u2019s (unqualified) class name and the template name are the same for the ACP and the front end. This is no problem because the qualified names of the classes differ and the files are stored in different directories and because the templates are installed by different package installation plugins and are also stored in different directories.
"},{"location":"tutorial/series/part_1/#personlistpage_1","title":"PersonListPage
","text":"files/lib/page/PersonListPage.class.php <?php\n\nnamespace wcf\\page;\n\nuse wcf\\data\\person\\PersonList;\n\n/**\n * Shows the list of people.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2021 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\Page\n */\nclass PersonListPage extends SortablePage\n{\n /**\n * @inheritDoc\n */\n public $defaultSortField = 'lastName';\n\n /**\n * @inheritDoc\n */\n public $objectListClassName = PersonList::class;\n\n /**\n * @inheritDoc\n */\n public $validSortFields = ['personID', 'firstName', 'lastName'];\n}\n
This class is almost identical to the ACP version. In the front end, we do not need to set the active menu item manually because the system determines the active menu item automatically based on the requested page. Furthermore, $neededPermissions
has not been set because in the front end, users do not need any special permission to access the page. In the front end, we explicitly set the $defaultSortField
so that the people listed on the page are sorted by their last name (in ascending order) by default.
personList.tpl
","text":"templates/personList.tpl {capture assign='contentTitle'}{lang}wcf.person.list{/lang} <span class=\"badge\">{#$items}</span>{/capture}\n\n{capture assign='headContent'}\n{if $pageNo < $pages}\n <link rel=\"next\" href=\"{link controller='PersonList'}pageNo={@$pageNo+1}{/link}\">\n{/if}\n{if $pageNo > 1}\n <link rel=\"prev\" href=\"{link controller='PersonList'}{if $pageNo > 2}pageNo={@$pageNo-1}{/if}{/link}\">\n{/if}\n <link rel=\"canonical\" href=\"{link controller='PersonList'}{if $pageNo > 1}pageNo={@$pageNo}{/if}{/link}\">\n{/capture}\n\n{capture assign='sidebarRight'}\n <section class=\"box\">\n <form method=\"post\" action=\"{link controller='PersonList'}{/link}\">\n <h2 class=\"boxTitle\">{lang}wcf.global.sorting{/lang}</h2>\n\n <div class=\"boxContent\">\n <dl>\n <dt></dt>\n <dd>\n <select id=\"sortField\" name=\"sortField\">\n <option value=\"firstName\"{if $sortField == 'firstName'} selected{/if}>{lang}wcf.person.firstName{/lang}</option>\n <option value=\"lastName\"{if $sortField == 'lastName'} selected{/if}>{lang}wcf.person.lastName{/lang}</option>\n{event name='sortField'}\n </select>\n <select name=\"sortOrder\">\n <option value=\"ASC\"{if $sortOrder == 'ASC'} selected{/if}>{lang}wcf.global.sortOrder.ascending{/lang}</option>\n <option value=\"DESC\"{if $sortOrder == 'DESC'} selected{/if}>{lang}wcf.global.sortOrder.descending{/lang}</option>\n </select>\n </dd>\n </dl>\n\n <div class=\"formSubmit\">\n <input type=\"submit\" value=\"{lang}wcf.global.button.submit{/lang}\" accesskey=\"s\">\n </div>\n </div>\n </form>\n </section>\n{/capture}\n\n{include file='header'}\n\n{hascontent}\n <div class=\"paginationTop\">\n{content}\n{pages print=true assign=pagesLinks controller='PersonList' link=\"pageNo=%d&sortField=$sortField&sortOrder=$sortOrder\"}\n{/content}\n </div>\n{/hascontent}\n\n{if $items}\n <div class=\"section sectionContainerList\">\n <ol class=\"containerList personList\">\n{foreach from=$objects item=person}\n <li>\n <div class=\"box48\">\n <span class=\"icon icon48 fa-user\"></span>\n\n <div class=\"details personInformation\">\n <div class=\"containerHeadline\">\n <h3>{$person}</h3>\n </div>\n\n{hascontent}\n <ul class=\"inlineList commaSeparated\">\n{content}{event name='personData'}{/content}\n </ul>\n{/hascontent}\n\n{hascontent}\n <dl class=\"plain inlineDataList small\">\n{content}{event name='personStatistics'}{/content}\n </dl>\n{/hascontent}\n </div>\n </div>\n </li>\n{/foreach}\n </ol>\n </div>\n{else}\n <p class=\"info\">{lang}wcf.global.noItems{/lang}</p>\n{/if}\n\n<footer class=\"contentFooter\">\n{hascontent}\n <div class=\"paginationBottom\">\n{content}{@$pagesLinks}{/content}\n </div>\n{/hascontent}\n\n{hascontent}\n <nav class=\"contentFooterNavigation\">\n <ul>\n{content}{event name='contentFooterNavigation'}{/content}\n </ul>\n </nav>\n{/hascontent}\n</footer>\n\n{include file='footer'}\n
If you compare this template to the one used in the ACP, you will recognize similar elements like the .paginationTop
element, the p.info
element if no people exist, and the .contentFooter
element. Furthermore, we include a template called header
before actually showing any of the page contents and terminate the template by including the footer
template.
Now, let us take a closer look at the differences:
.contentHeader
element but simply assign the title to the contentTitle
variable. The value of the assignment is simply the title of the page and a badge showing the number of listed people. The header
template that we include later will handle correctly displaying the content header on its own based on the $contentTitle
variable.<head>
element. In this case, we define the canonical link of the page and, because we are showing paginated content, add links to the previous and next page (if they exist).select
elements to determine sort field and sort order.Person::__toString()
. Additionally, like in the user list, we provide the initially empty ul.inlineList.commaSeparated
and dl.plain.inlineDataList.small
elements that can be filled by plugins using the templates events. userGroupOption.xml
","text":"We have already used the admin.content.canManagePeople
permissions several times, now we need to install it using the userGroupOption package installation plugin:
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/5.4/userGroupOption.xsd\">\n<import>\n<options>\n<option name=\"admin.content.canManagePeople\">\n<categoryname>admin.content</categoryname>\n<optiontype>boolean</optiontype>\n<defaultvalue>0</defaultvalue>\n<admindefaultvalue>1</admindefaultvalue>\n<usersonly>1</usersonly>\n</option>\n</options>\n</import>\n</data>\n
We use the existing admin.content
user group option category for the permission as the people are \u201ccontent\u201d (similar the the ACP menu item). As the permission is for administrators only, we set defaultvalue
to 0
and admindefaultvalue
to 1
. This permission is only relevant for registered users so that it should not be visible when editing the guest user group. This is achieved by setting usersonly
to 1
.
package.xml
","text":"Lastly, we need to create the package.xml
file. For more information about this kind of file, please refer to the package.xml
page.
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<package name=\"com.woltlab.wcf.people\" xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/5.4/package.xsd\">\n<packageinformation>\n<packagename>WoltLab Suite Core Tutorial: People</packagename>\n<packagedescription>Adds a simple management system for people as part of a tutorial to create packages.</packagedescription>\n<version>5.4.0</version>\n<date>2022-01-17</date>\n</packageinformation>\n\n<authorinformation>\n<author>WoltLab GmbH</author>\n<authorurl>http://www.woltlab.com</authorurl>\n</authorinformation>\n\n<requiredpackages>\n<requiredpackage minversion=\"5.4.10\">com.woltlab.wcf</requiredpackage>\n</requiredpackages>\n\n<excludedpackages>\n<excludedpackage version=\"6.0.0 Alpha 1\">com.woltlab.wcf</excludedpackage>\n</excludedpackages>\n\n<instructions type=\"install\">\n<instruction type=\"acpTemplate\" />\n<instruction type=\"file\" />\n<instruction type=\"database\">acp/database/install_com.woltlab.wcf.people.php</instruction>\n<instruction type=\"template\" />\n<instruction type=\"language\" />\n\n<instruction type=\"acpMenu\" />\n<instruction type=\"page\" />\n<instruction type=\"menuItem\" />\n<instruction type=\"userGroupOption\" />\n</instructions>\n</package>\n
As this is a package for WoltLab Suite Core 3, we need to require it using <requiredpackage>
. We require the latest version (when writing this tutorial) 5.4.0 Alpha 1
. Additionally, we disallow installation of the package in the next major version 6.0
by excluding the 6.0.0 Alpha 1
version.
The most important part are to installation instructions. First, we install the ACP templates, files and templates, create the database table and import the language item. Afterwards, the ACP menu items and the permission are added. Now comes the part of the instructions where the order of the instructions is crucial: In menuItem.xml
, we refer to the com.woltlab.wcf.people.PersonList
page that is delivered by page.xml
. As the menu item package installation plugin validates the given page and throws an exception if the page does not exist, we need to install the page before the menu item!
This concludes the first part of our tutorial series after which you now have a working simple package with which you can manage people in the ACP and show the visitors of your website a simple list of all created people in the front end.
The complete source code of this part can be found on GitHub.
"},{"location":"tutorial/series/part_2/","title":"Part 2: Event and Template Listeners","text":"In the first part of this tutorial series, we have created the base structure of our people management package. In further parts, we will use the package of the first part as a basis to directly add new features. In order to explain how event listeners and template works, however, we will not directly adding a new feature to the package by altering it in this part, but we will assume that somebody else created the package and that we want to extend it the \u201ccorrect\u201d way by creating a plugin.
The goal of the small plugin that will be created in this part is to add the birthday of the managed people. As in the first part, we will not bother with careful validation of the entered date but just make sure that it is a valid date.
"},{"location":"tutorial/series/part_2/#package-functionality","title":"Package Functionality","text":"The package should provide the following possibilities/functions:
We will use the following package installation plugins:
For more information about the event system, please refer to the dedicated page on events.
"},{"location":"tutorial/series/part_2/#package-structure","title":"Package Structure","text":"The package will have the following file structure:
\u251c\u2500\u2500 eventListener.xml\n\u251c\u2500\u2500 files\n\u2502 \u251c\u2500\u2500 acp\n\u2502 \u2502 \u2514\u2500\u2500 database\n\u2502 \u2502 \u2514\u2500\u2500 install_com.woltlab.wcf.people.birthday.php\n\u2502 \u2514\u2500\u2500 lib\n\u2502 \u2514\u2500\u2500 system\n\u2502 \u2514\u2500\u2500 event\n\u2502 \u2514\u2500\u2500 listener\n\u2502 \u251c\u2500\u2500 BirthdayPersonAddFormListener.class.php\n\u2502 \u2514\u2500\u2500 BirthdaySortFieldPersonListPageListener.class.php\n\u251c\u2500\u2500 language\n\u2502 \u251c\u2500\u2500 de.xml\n\u2502 \u2514\u2500\u2500 en.xml\n\u251c\u2500\u2500 package.xml\n\u251c\u2500\u2500 templateListener.xml\n\u2514\u2500\u2500 templates\n \u251c\u2500\u2500 __personListBirthday.tpl\n \u2514\u2500\u2500 __personListBirthdaySortField.tpl\n
"},{"location":"tutorial/series/part_2/#extending-person-model","title":"Extending Person Model","text":"The existing model of a person only contains the person\u2019s first name and their last name (in additional to the id used to identify created people). To add the birthday to the model, we need to create an additional database table column using the database
package installation plugin:
<?php\n\nuse wcf\\system\\database\\table\\column\\DateDatabaseTableColumn;\nuse wcf\\system\\database\\table\\PartialDatabaseTable;\n\nreturn [\nPartialDatabaseTable::create('wcf1_person')\n->columns([\nDateDatabaseTableColumn::create('birthday'),\n]),\n];\n
If we have a Person
object, this new property can be accessed the same way as the personID
property, the firstName
property, or the lastName
property from the base package: $person->birthday
.
To set the birthday of a person, we only have to add another form field with an event listener:
files/lib/system/event/listener/BirthdayPersonAddFormListener.class.php<?php\n\nnamespace wcf\\system\\event\\listener;\n\nuse wcf\\acp\\form\\PersonAddForm;\nuse wcf\\form\\AbstractFormBuilderForm;\nuse wcf\\system\\form\\builder\\container\\FormContainer;\nuse wcf\\system\\form\\builder\\field\\DateFormField;\n\n/**\n * Handles setting the birthday when adding and editing people.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2022 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\System\\Event\\Listener\n */\nfinal class BirthdayPersonAddFormListener extends AbstractEventListener\n{\n /**\n * @see AbstractFormBuilderForm::createForm()\n */\n protected function onCreateForm(PersonAddForm $form): void\n {\n $dataContainer = $form->form->getNodeById('data');\n \\assert($dataContainer instanceof FormContainer);\n $dataContainer->appendChild(\n DateFormField::create('birthday')\n ->label('wcf.person.birthday')\n ->saveValueFormat('Y-m-d')\n ->nullable()\n );\n }\n}\n
registered via
<eventlistener name=\"createForm@wcf\\acp\\form\\PersonAddForm\">\n<environment>admin</environment>\n<eventclassname>wcf\\acp\\form\\PersonAddForm</eventclassname>\n<eventname>createForm</eventname>\n<listenerclassname>wcf\\system\\event\\listener\\BirthdayPersonAddFormListener</listenerclassname>\n<inherit>1</inherit>\n</eventlistener>\n
in eventListener.xml
, see below.
As BirthdayPersonAddFormListener
extends AbstractEventListener
and as the name of relevant event is createForm
, AbstractEventListener
internally automatically calls onCreateForm()
with the event object as the parameter. It is important to set <inherit>1</inherit>
so that the event listener is also executed for PersonEditForm
, which extends PersonAddForm
.
The language item wcf.person.birthday
used in the label is the only new one for this package:
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<language xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/5.4/language.xsd\" languagecode=\"de\">\n<category name=\"wcf.person\">\n<item name=\"wcf.person.birthday\"><![CDATA[Geburtstag]]></item>\n</category>\n</language>\n
language/en.xml <?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<language xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/5.4/language.xsd\" languagecode=\"en\">\n<category name=\"wcf.person\">\n<item name=\"wcf.person.birthday\"><![CDATA[Birthday]]></item>\n</category>\n</language>\n
"},{"location":"tutorial/series/part_2/#adding-birthday-table-column-in-acp","title":"Adding Birthday Table Column in ACP","text":"To add a birthday column to the person list page in the ACP, we need three parts:
birthday
database table column a valid sort field,The first part is a very simple class:
files/lib/system/event/listener/BirthdaySortFieldPersonListPageListener.class.php<?php\n\nnamespace wcf\\system\\event\\listener;\n\nuse wcf\\page\\SortablePage;\n\n/**\n * Makes people's birthday a valid sort field in the ACP and the front end.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2022 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\System\\Event\\Listener\n */\nfinal class BirthdaySortFieldPersonListPageListener extends AbstractEventListener\n{\n /**\n * @see SortablePage::validateSortField()\n */\n public function onValidateSortField(SortablePage $page): void\n {\n $page->validSortFields[] = 'birthday';\n }\n}\n
We use SortablePage
as a type hint instead of wcf\\acp\\page\\PersonListPage
because we will be using the same event listener class in the front end to also allow sorting that list by birthday.
As the relevant template codes are only one line each, we will simply put them directly in the templateListener.xml
file that will be shown later on. The code for the table head is similar to the other th
elements:
<th class=\"columnDate columnBirthday{if $sortField == 'birthday'} active {$sortOrder}{/if}\"><a href=\"{link controller='PersonList'}pageNo={@$pageNo}&sortField=birthday&sortOrder={if $sortField == 'birthday' && $sortOrder == 'ASC'}DESC{else}ASC{/if}{/link}\">{lang}wcf.person.birthday{/lang}</a></th>\n
For the table body\u2019s column, we need to make sure that the birthday is only show if it is actually set:
<td class=\"columnDate columnBirthday\">{if $person->birthday}{@$person->birthday|strtotime|date}{/if}</td>\n
"},{"location":"tutorial/series/part_2/#adding-birthday-in-front-end","title":"Adding Birthday in Front End","text":"In the front end, we also want to make the list sortable by birthday and show the birthday as part of each person\u2019s \u201cstatistics\u201d.
To add the birthday as a valid sort field, we use BirthdaySortFieldPersonListPageListener
just as in the ACP. In the front end, we will now use a template (__personListBirthdaySortField.tpl
) instead of a directly putting the template code in the templateListener.xml
file:
<option value=\"birthday\"{if $sortField == 'birthday'} selected{/if}>{lang}wcf.person.birthday{/lang}</option>\n
You might have noticed the two underscores at the beginning of the template file. For templates that are included via template listeners, this is the naming convention we use.
Putting the template code into a file has the advantage that in the administrator is able to edit the code directly via a custom template group, even though in this case this might not be very probable.
To show the birthday, we use the following template code for the personStatistics
template event, which again makes sure that the birthday is only shown if it is actually set:
{if $person->birthday}\n <dt>{lang}wcf.person.birthday{/lang}</dt>\n <dd>{@$person->birthday|strtotime|date}</dd>\n{/if}\n
"},{"location":"tutorial/series/part_2/#templatelistenerxml","title":"templateListener.xml
","text":"The following code shows the templateListener.xml
file used to install all mentioned template listeners:
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/tornado/5.4/templateListener.xsd\">\n<import>\n<!-- admin -->\n<templatelistener name=\"personListBirthdayColumnHead\">\n<eventname>columnHeads</eventname>\n<environment>admin</environment>\n<templatecode><![CDATA[<th class=\"columnDate columnBirthday{if $sortField == 'birthday'} active {@$sortOrder}{/if}\"><a href=\"{link controller='PersonList'}pageNo={@$pageNo}&sortField=birthday&sortOrder={if $sortField == 'birthday' && $sortOrder == 'ASC'}DESC{else}ASC{/if}{/link}\">{lang}wcf.person.birthday{/lang}</a></th>]]></templatecode>\n<templatename>personList</templatename>\n</templatelistener>\n<templatelistener name=\"personListBirthdayColumn\">\n<eventname>columns</eventname>\n<environment>admin</environment>\n<templatecode><![CDATA[<td class=\"columnDate columnBirthday\">{if $person->birthday}{@$person->birthday|strtotime|date}{/if}</td>]]></templatecode>\n<templatename>personList</templatename>\n</templatelistener>\n<!-- /admin -->\n\n<!-- user -->\n<templatelistener name=\"personListBirthday\">\n<eventname>personStatistics</eventname>\n<environment>user</environment>\n<templatecode><![CDATA[{include file='__personListBirthday'}]]></templatecode>\n<templatename>personList</templatename>\n</templatelistener>\n<templatelistener name=\"personListBirthdaySortField\">\n<eventname>sortField</eventname>\n<environment>user</environment>\n<templatecode><![CDATA[{include file='__personListBirthdaySortField'}]]></templatecode>\n<templatename>personList</templatename>\n</templatelistener>\n<!-- /user -->\n</import>\n</data>\n
In cases where a template is used, we simply use the include
syntax to load the template.
eventListener.xml
","text":"There are two event listeners that make birthday
a valid sort field in the ACP and the front end, respectively, and the third event listener takes care of setting the birthday.
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/5.4/eventListener.xsd\">\n<import>\n<!-- admin -->\n<eventlistener name=\"validateSortField@wcf\\acp\\page\\PersonListPage\">\n<environment>admin</environment>\n<eventclassname>wcf\\acp\\page\\PersonListPage</eventclassname>\n<eventname>validateSortField</eventname>\n<listenerclassname>wcf\\system\\event\\listener\\BirthdaySortFieldPersonListPageListener</listenerclassname>\n</eventlistener>\n<eventlistener name=\"createForm@wcf\\acp\\form\\PersonAddForm\">\n<environment>admin</environment>\n<eventclassname>wcf\\acp\\form\\PersonAddForm</eventclassname>\n<eventname>createForm</eventname>\n<listenerclassname>wcf\\system\\event\\listener\\BirthdayPersonAddFormListener</listenerclassname>\n<inherit>1</inherit>\n</eventlistener>\n<!-- /admin -->\n\n<!-- user -->\n<eventlistener name=\"validateSortField@wcf\\page\\PersonListPage\">\n<environment>user</environment>\n<eventclassname>wcf\\page\\PersonListPage</eventclassname>\n<eventname>validateSortField</eventname>\n<listenerclassname>wcf\\system\\event\\listener\\BirthdaySortFieldPersonListPageListener</listenerclassname>\n</eventlistener>\n<!-- /user -->\n</import>\n</data>\n
"},{"location":"tutorial/series/part_2/#packagexml","title":"package.xml
","text":"The only relevant difference between the package.xml
file of the base page from part 1 and the package.xml
file of this package is that this package requires the base package com.woltlab.wcf.people
(see <requiredpackages>
):
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<package name=\"com.woltlab.wcf.people.birthday\" xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/5.4/package.xsd\">\n<packageinformation>\n<packagename>WoltLab Suite Core Tutorial: People (Birthday)</packagename>\n<packagedescription>Adds a birthday field to the people management system as part of a tutorial to create packages.</packagedescription>\n<version>5.4.0</version>\n<date>2022-01-17</date>\n</packageinformation>\n\n<authorinformation>\n<author>WoltLab GmbH</author>\n<authorurl>http://www.woltlab.com</authorurl>\n</authorinformation>\n\n<requiredpackages>\n<requiredpackage minversion=\"5.4.10\">com.woltlab.wcf</requiredpackage>\n<requiredpackage minversion=\"5.4.0\">com.woltlab.wcf.people</requiredpackage>\n</requiredpackages>\n\n<excludedpackages>\n<excludedpackage version=\"6.0.0 Alpha 1\">com.woltlab.wcf</excludedpackage>\n</excludedpackages>\n\n<instructions type=\"install\">\n<instruction type=\"file\" />\n<instruction type=\"database\">acp/database/install_com.woltlab.wcf.people.birthday.php</instruction>\n<instruction type=\"template\" />\n<instruction type=\"language\" />\n\n<instruction type=\"eventListener\" />\n<instruction type=\"templateListener\" />\n</instructions>\n</package>\n
This concludes the second part of our tutorial series after which you now have extended the base package using event listeners and template listeners that allow you to enter the birthday of the people.
The complete source code of this part can be found on GitHub.
"},{"location":"tutorial/series/part_3/","title":"Part 3: Person Page and Comments","text":"In this part of our tutorial series, we will add a new front end page to our package that is dedicated to each person and shows their personal details. To make good use of this new page and introduce a new API of WoltLab Suite, we will add the opportunity for users to comment on the person using WoltLab Suite\u2019s reusable comment functionality.
"},{"location":"tutorial/series/part_3/#package-functionality","title":"Package Functionality","text":"In addition to the existing functions from part 1, the package will provide the following possibilities/functions after this part of the tutorial:
In addition to the components used in part 1, we will use the objectType package installation plugin, use the comment API, create a runtime cache, and create a page handler.
"},{"location":"tutorial/series/part_3/#package-structure","title":"Package Structure","text":"The complete package will have the following file structure (including the files from part 1):
\u251c\u2500\u2500 acpMenu.xml\n\u251c\u2500\u2500 acptemplates\n\u2502 \u251c\u2500\u2500 personAdd.tpl\n\u2502 \u2514\u2500\u2500 personList.tpl\n\u251c\u2500\u2500 files\n\u2502 \u251c\u2500\u2500 acp\n\u2502 \u2502 \u2514\u2500\u2500 database\n\u2502 \u2502 \u2514\u2500\u2500 install_com.woltlab.wcf.people.php\n\u2502 \u2514\u2500\u2500 lib\n\u2502 \u251c\u2500\u2500 acp\n\u2502 \u2502 \u251c\u2500\u2500 form\n\u2502 \u2502 \u2502 \u251c\u2500\u2500 PersonAddForm.class.php\n\u2502 \u2502 \u2502 \u2514\u2500\u2500 PersonEditForm.class.php\n\u2502 \u2502 \u2514\u2500\u2500 page\n\u2502 \u2502 \u2514\u2500\u2500 PersonListPage.class.php\n\u2502 \u251c\u2500\u2500 data\n\u2502 \u2502 \u2514\u2500\u2500 person\n\u2502 \u2502 \u251c\u2500\u2500 Person.class.php\n\u2502 \u2502 \u251c\u2500\u2500 PersonAction.class.php\n\u2502 \u2502 \u251c\u2500\u2500 PersonEditor.class.php\n\u2502 \u2502 \u2514\u2500\u2500 PersonList.class.php\n\u2502 \u251c\u2500\u2500 page\n\u2502 \u2502 \u251c\u2500\u2500 PersonListPage.class.php\n\u2502 \u2502 \u2514\u2500\u2500 PersonPage.class.php\n\u2502 \u2514\u2500\u2500 system\n\u2502 \u251c\u2500\u2500 cache\n\u2502 \u2502 \u2514\u2500\u2500 runtime\n\u2502 \u2502 \u2514\u2500\u2500 PersonRuntimeCache.class.php\n\u2502 \u251c\u2500\u2500 comment\n\u2502 \u2502 \u2514\u2500\u2500 manager\n\u2502 \u2502 \u2514\u2500\u2500 PersonCommentManager.class.php\n\u2502 \u2514\u2500\u2500 page\n\u2502 \u2514\u2500\u2500 handler\n\u2502 \u2514\u2500\u2500 PersonPageHandler.class.php\n\u251c\u2500\u2500 language\n\u2502 \u251c\u2500\u2500 de.xml\n\u2502 \u2514\u2500\u2500 en.xml\n\u251c\u2500\u2500 menuItem.xml\n\u251c\u2500\u2500 objectType.xml\n\u251c\u2500\u2500 package.xml\n\u251c\u2500\u2500 page.xml\n\u251c\u2500\u2500 templates\n\u2502 \u251c\u2500\u2500 person.tpl\n\u2502 \u2514\u2500\u2500 personList.tpl\n\u2514\u2500\u2500 userGroupOption.xml\n
We will not mention every code change between the first part and this part, as we only want to focus on the important, new parts of the code. For example, there is a new Person::getLink()
method and new language items have been added. For all changes, please refer to the source code on GitHub.
To reduce the number of database queries when different APIs require person objects, we implement a runtime cache for people:
files/lib/system/cache/runtime/PersonRuntimeCache.class.php<?php\n\nnamespace wcf\\system\\cache\\runtime;\n\nuse wcf\\data\\person\\Person;\nuse wcf\\data\\person\\PersonList;\n\n/**\n * Runtime cache implementation for people.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2022 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\System\\Cache\\Runtime\n *\n * @method Person[] getCachedObjects()\n * @method Person getObject($objectID)\n * @method Person[] getObjects(array $objectIDs)\n */\nfinal class PersonRuntimeCache extends AbstractRuntimeCache\n{\n /**\n * @inheritDoc\n */\n protected $listClassName = PersonList::class;\n}\n
"},{"location":"tutorial/series/part_3/#comments","title":"Comments","text":"To allow users to comment on people, we need to tell the system that people support comments. This is done by registering a com.woltlab.wcf.comment.commentableContent
object type whose processor implements ICommentManager:
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/5.4/objectType.xsd\">\n<import>\n<type>\n<name>com.woltlab.wcf.person.personComment</name>\n<definitionname>com.woltlab.wcf.comment.commentableContent</definitionname>\n<classname>wcf\\system\\comment\\manager\\PersonCommentManager</classname>\n</type>\n</import>\n</data>\n
The PersonCommentManager
class extended ICommentManager
\u2019s default implementation AbstractCommentManager:
<?php\n\nnamespace wcf\\system\\comment\\manager;\n\nuse wcf\\data\\person\\Person;\nuse wcf\\data\\person\\PersonEditor;\nuse wcf\\system\\cache\\runtime\\PersonRuntimeCache;\nuse wcf\\system\\WCF;\n\n/**\n * Comment manager implementation for people.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2022 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\System\\Comment\\Manager\n */\nfinal class PersonCommentManager extends AbstractCommentManager\n{\n /**\n * @inheritDoc\n */\n protected $permissionAdd = 'user.person.canAddComment';\n\n /**\n * @inheritDoc\n */\n protected $permissionAddWithoutModeration = 'user.person.canAddCommentWithoutModeration';\n\n /**\n * @inheritDoc\n */\n protected $permissionCanModerate = 'mod.person.canModerateComment';\n\n /**\n * @inheritDoc\n */\n protected $permissionDelete = 'user.person.canDeleteComment';\n\n /**\n * @inheritDoc\n */\n protected $permissionEdit = 'user.person.canEditComment';\n\n /**\n * @inheritDoc\n */\n protected $permissionModDelete = 'mod.person.canDeleteComment';\n\n /**\n * @inheritDoc\n */\n protected $permissionModEdit = 'mod.person.canEditComment';\n\n /**\n * @inheritDoc\n */\n public function getLink($objectTypeID, $objectID)\n {\n return PersonRuntimeCache::getInstance()->getObject($objectID)->getLink();\n }\n\n /**\n * @inheritDoc\n */\n public function isAccessible($objectID, $validateWritePermission = false)\n {\n return PersonRuntimeCache::getInstance()->getObject($objectID) !== null;\n }\n\n /**\n * @inheritDoc\n */\n public function getTitle($objectTypeID, $objectID, $isResponse = false)\n {\n if ($isResponse) {\n return WCF::getLanguage()->get('wcf.person.commentResponse');\n }\n\n return WCF::getLanguage()->getDynamicVariable('wcf.person.comment');\n }\n\n /**\n * @inheritDoc\n */\n public function updateCounter($objectID, $value)\n {\n (new PersonEditor(new Person($objectID)))->updateCounters(['comments' => $value]);\n }\n}\n
$permission*
properties. More information about comment permissions can be found here.getLink()
method returns the link to the person with the passed comment id. As in isAccessible()
, PersonRuntimeCache
is used to potentially save database queries.isAccessible()
method checks if the active user can access the relevant person. As we do not have any special restrictions for accessing people, we only need to check if the person exists.getTitle()
method returns the title used for comments and responses, which is just a generic language item in this case.updateCounter()
updates the comments\u2019 counter of the person. We have added a new comments
database table column to the wcf1_person
database table in order to keep track on the number of comments.Additionally, we have added a new enableComments
database table column to the wcf1_person
database table whose value can be set when creating or editing a person in the ACP. With this option, comments on individual people can be disabled.
Liking comments is already built-in and only requires some extra code in the PersonPage
class for showing the likes of pre-loaded comments.
PersonPage
","text":"files/lib/page/PersonPage.class.php <?php\n\nnamespace wcf\\page;\n\nuse wcf\\data\\comment\\StructuredCommentList;\nuse wcf\\data\\person\\Person;\nuse wcf\\system\\comment\\CommentHandler;\nuse wcf\\system\\comment\\manager\\PersonCommentManager;\nuse wcf\\system\\exception\\IllegalLinkException;\nuse wcf\\system\\WCF;\n\n/**\n * Shows the details of a certain person.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2021 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\Page\n */\nclass PersonPage extends AbstractPage\n{\n /**\n * list of comments\n * @var StructuredCommentList\n */\n public $commentList;\n\n /**\n * person comment manager object\n * @var PersonCommentManager\n */\n public $commentManager;\n\n /**\n * id of the person comment object type\n * @var int\n */\n public $commentObjectTypeID = 0;\n\n /**\n * shown person\n * @var Person\n */\n public $person;\n\n /**\n * id of the shown person\n * @var int\n */\n public $personID = 0;\n\n /**\n * @inheritDoc\n */\n public function assignVariables()\n {\n parent::assignVariables();\n\n WCF::getTPL()->assign([\n 'commentCanAdd' => WCF::getSession()->getPermission('user.person.canAddComment'),\n 'commentList' => $this->commentList,\n 'commentObjectTypeID' => $this->commentObjectTypeID,\n 'lastCommentTime' => $this->commentList ? $this->commentList->getMinCommentTime() : 0,\n 'likeData' => MODULE_LIKE && $this->commentList ? $this->commentList->getLikeData() : [],\n 'person' => $this->person,\n ]);\n }\n\n /**\n * @inheritDoc\n */\n public function readData()\n {\n parent::readData();\n\n if ($this->person->enableComments) {\n $this->commentObjectTypeID = CommentHandler::getInstance()->getObjectTypeID(\n 'com.woltlab.wcf.person.personComment'\n );\n $this->commentManager = CommentHandler::getInstance()->getObjectType(\n $this->commentObjectTypeID\n )->getProcessor();\n $this->commentList = CommentHandler::getInstance()->getCommentList(\n $this->commentManager,\n $this->commentObjectTypeID,\n $this->person->personID\n );\n }\n }\n\n /**\n * @inheritDoc\n */\n public function readParameters()\n {\n parent::readParameters();\n\n if (isset($_REQUEST['id'])) {\n $this->personID = \\intval($_REQUEST['id']);\n }\n $this->person = new Person($this->personID);\n if (!$this->person->personID) {\n throw new IllegalLinkException();\n }\n }\n}\n
The PersonPage
class is similar to the PersonEditForm
in the ACP in that it reads the id of the requested person from the request data and validates the id in readParameters()
. The rest of the code only handles fetching the list of comments on the requested person. In readData()
, this list is fetched using CommentHandler::getCommentList()
if comments are enabled for the person. The assignVariables()
method assigns some additional template variables like $commentCanAdd
, which is 1
if the active person can add comments and is 0
otherwise, $lastCommentTime
, which contains the UNIX timestamp of the last comment, and $likeData
, which contains data related to the likes for the disabled comments.
person.tpl
","text":"templates/person.tpl {capture assign='pageTitle'}{$person} - {lang}wcf.person.list{/lang}{/capture}\n\n{capture assign='contentTitle'}{$person}{/capture}\n\n{include file='header'}\n\n{if $person->enableComments}\n {if $commentList|count || $commentCanAdd}\n <section id=\"comments\" class=\"section sectionContainerList\">\n <header class=\"sectionHeader\">\n <h2 class=\"sectionTitle\">\n {lang}wcf.person.comments{/lang}\n {if $person->comments}<span class=\"badge\">{#$person->comments}</span>{/if}\n </h2>\n </header>\n\n {include file='__commentJavaScript' commentContainerID='personCommentList'}\n\n <div class=\"personComments\">\n <ul id=\"personCommentList\" class=\"commentList containerList\" {*\n *}data-can-add=\"{if $commentCanAdd}true{else}false{/if}\" {*\n *}data-object-id=\"{@$person->personID}\" {*\n *}data-object-type-id=\"{@$commentObjectTypeID}\" {*\n *}data-comments=\"{if $person->comments}{@$commentList->countObjects()}{else}0{/if}\" {*\n *}data-last-comment-time=\"{@$lastCommentTime}\" {*\n *}>\n {include file='commentListAddComment' wysiwygSelector='personCommentListAddComment'}\n {include file='commentList'}\n </ul>\n </div>\n </section>\n {/if}\n{/if}\n\n<footer class=\"contentFooter\">\n {hascontent}\n <nav class=\"contentFooterNavigation\">\n <ul>\n {content}{event name='contentFooterNavigation'}{/content}\n </ul>\n </nav>\n {/hascontent}\n</footer>\n\n{include file='footer'}\n
For now, the person
template is still very empty and only shows the comments in the content area. The template code shown for comments is very generic and used in this form in many locations as it only sets the header of the comment list and the container ul#personCommentList
element for the comments shown by commentList
template. The ul#personCommentList
elements has five additional data-
attributes required by the JavaScript API for comments for loading more comments or creating new ones. The commentListAddComment
template adds the WYSIWYG support. The attribute wysiwygSelector
should be the id of the comment list personCommentList
with an additional AddComment
suffix.
page.xml
","text":"page.xml <?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/5.4/page.xsd\">\n<import>\n<page identifier=\"com.woltlab.wcf.people.PersonList\">\n<pageType>system</pageType>\n<controller>wcf\\page\\PersonListPage</controller>\n<name language=\"de\">Personen-Liste</name>\n<name language=\"en\">Person List</name>\n\n<content language=\"de\">\n<title>Personen</title>\n</content>\n<content language=\"en\">\n<title>People</title>\n</content>\n</page>\n<page identifier=\"com.woltlab.wcf.people.Person\">\n<pageType>system</pageType>\n<controller>wcf\\page\\PersonPage</controller>\n<handler>wcf\\system\\page\\handler\\PersonPageHandler</handler>\n<name language=\"de\">Person</name>\n<name language=\"en\">Person</name>\n<requireObjectID>1</requireObjectID>\n<parent>com.woltlab.wcf.people.PersonList</parent>\n</page>\n</import>\n</data>\n
The page.xml
file has been extended for the new person page with identifier com.woltlab.wcf.people.Person
. Compared to the pre-existing com.woltlab.wcf.people.PersonList
page, there are four differences:
<handler>
element with a class name as value. This aspect will be discussed in more detail in the next section.<content>
elements because, both, the title and the content of the page are dynamically generated in the template.<requireObjectID>
tells the system that this page requires an object id to properly work, in this case a valid person id.<parent>
page, the person list page. In general, the details page for any type of object that is listed on a different page has the list page as its parent.PersonPageHandler
","text":"files/lib/system/page/handler/PersonPageHandler.class.php <?php\n\nnamespace wcf\\system\\page\\handler;\n\nuse wcf\\data\\page\\Page;\nuse wcf\\data\\person\\PersonList;\nuse wcf\\data\\user\\online\\UserOnline;\nuse wcf\\system\\cache\\runtime\\PersonRuntimeCache;\nuse wcf\\system\\database\\util\\PreparedStatementConditionBuilder;\nuse wcf\\system\\WCF;\n\n/**\n * Page handler implementation for person page.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2022 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\System\\Page\\Handler\n */\nfinal class PersonPageHandler extends AbstractLookupPageHandler implements IOnlineLocationPageHandler\n{\n use TOnlineLocationPageHandler;\n\n /**\n * @inheritDoc\n */\n public function getLink($objectID)\n {\n return PersonRuntimeCache::getInstance()->getObject($objectID)->getLink();\n }\n\n /**\n * Returns the textual description if a user is currently online viewing this page.\n *\n * @see IOnlineLocationPageHandler::getOnlineLocation()\n *\n * @param Page $page visited page\n * @param UserOnline $user user online object with request data\n * @return string\n */\n public function getOnlineLocation(Page $page, UserOnline $user)\n {\n if ($user->pageObjectID === null) {\n return '';\n }\n\n $person = PersonRuntimeCache::getInstance()->getObject($user->pageObjectID);\n if ($person === null) {\n return '';\n }\n\n return WCF::getLanguage()->getDynamicVariable('wcf.page.onlineLocation.' . $page->identifier, ['person' => $person]);\n }\n\n /**\n * @inheritDoc\n */\n public function isValid($objectID = null)\n {\n return PersonRuntimeCache::getInstance()->getObject($objectID) !== null;\n }\n\n /**\n * @inheritDoc\n */\n public function lookup($searchString)\n {\n $conditionBuilder = new PreparedStatementConditionBuilder(false, 'OR');\n $conditionBuilder->add('person.firstName LIKE ?', ['%' . $searchString . '%']);\n $conditionBuilder->add('person.lastName LIKE ?', ['%' . $searchString . '%']);\n\n $personList = new PersonList();\n $personList->getConditionBuilder()->add($conditionBuilder, $conditionBuilder->getParameters());\n $personList->readObjects();\n\n $results = [];\n foreach ($personList as $person) {\n $results[] = [\n 'image' => 'fa-user',\n 'link' => $person->getLink(),\n 'objectID' => $person->personID,\n 'title' => $person->getTitle(),\n ];\n }\n\n return $results;\n }\n\n /**\n * Prepares fetching all necessary data for the textual description if a user is currently online\n * viewing this page.\n *\n * @see IOnlineLocationPageHandler::prepareOnlineLocation()\n *\n * @param Page $page visited page\n * @param UserOnline $user user online object with request data\n */\n public function prepareOnlineLocation(Page $page, UserOnline $user)\n {\n if ($user->pageObjectID !== null) {\n PersonRuntimeCache::getInstance()->cacheObjectID($user->pageObjectID);\n }\n }\n}\n
Like any page handler, the PersonPageHandler
class has to implement the IMenuPageHandler interface, which should be done by extending the AbstractMenuPageHandler class. As we want administrators to link to specific people in menus, for example, we have to also implement the ILookupPageHandler interface by extending the AbstractLookupPageHandler class.
For the ILookupPageHandler
interface, we need to implement three methods:
getLink($objectID)
returns the link to the person page with the given id. In this case, we simply delegate this method call to the Person
object returned by PersonRuntimeCache::getObject()
.isValid($objectID)
returns true
if the person with the given id exists, otherwise false
. Here, we use PersonRuntimeCache::getObject()
again and check if the return value is null
, which is the case for non-existing people.lookup($searchString)
is used when setting up an internal link and when searching for the linked person. This method simply searches the first and last name of the people and returns an array with the person data. While the link
, the objectID
, and the title
element are self-explanatory, the image
element can either contain an HTML <img>
tag, which is displayed next to the search result (WoltLab Suite uses an image tag for users showing their avatar, for example), or a FontAwesome icon class (starting with fa-
).Additionally, the class also implements IOnlineLocationPageHandler which is used to determine the online location of users. To ensure upwards-compatibility if the IOnlineLocationPageHandler
interface changes, the TOnlineLocationPageHandler trait is used. The IOnlineLocationPageHandler
interface requires two methods to be implemented:
getOnlineLocation(Page $page, UserOnline $user)
returns the textual description of the online location. The language item for the user online locations should use the pattern wcf.page.onlineLocation.{page identifier}
.prepareOnlineLocation(Page $page, UserOnline $user)
is called for each user online before the getOnlineLocation()
calls. In this case, calling prepareOnlineLocation()
first enables us to add all relevant person ids to the person runtime cache so that for all getOnlineLocation()
calls combined, only one database query is necessary to fetch all person objects.This concludes the third part of our tutorial series after which each person has a dedicated page on which people can comment on the person.
The complete source code of this part can be found on GitHub.
"},{"location":"tutorial/series/part_4/","title":"Part 4: Box and Box Conditions","text":"In this part of our tutorial series, we add support for creating boxes listing people.
"},{"location":"tutorial/series/part_4/#package-functionality","title":"Package Functionality","text":"In addition to the existing functions from part 3, the package will provide the following functionality after this part of the tutorial:
In addition to the components used in previous parts, we will use the objectTypeDefinition
package installation plugin and use the box and condition APIs.
To pre-install a specific person list box, we refer to the documentation of the box
package installation plugin.
The complete package will have the following file structure (excluding unchanged files from part 3):
\u251c\u2500\u2500 files\n\u2502 \u2514\u2500\u2500 lib\n\u2502 \u2514\u2500\u2500 system\n\u2502 \u251c\u2500\u2500 box\n\u2502 \u2502 \u2514\u2500\u2500 PersonListBoxController.class.php\n\u2502 \u2514\u2500\u2500 condition\n\u2502 \u2514\u2500\u2500 person\n\u2502 \u251c\u2500\u2500 PersonFirstNameTextPropertyCondition.class.php\n\u2502 \u2514\u2500\u2500 PersonLastNameTextPropertyCondition.class.php\n\u251c\u2500\u2500 language\n\u2502 \u251c\u2500\u2500 de.xml\n\u2502 \u2514\u2500\u2500 en.xml\n\u251c\u2500\u2500 objectType.xml\n\u251c\u2500\u2500 objectTypeDefinition.xml\n\u2514\u2500\u2500 templates\n \u2514\u2500\u2500 boxPersonList.tpl\n
For all changes, please refer to the source code on GitHub.
"},{"location":"tutorial/series/part_4/#box-controller","title":"Box Controller","text":"In addition to static boxes with fixed contents, administrators are able to create dynamic boxes with contents from the database. In our case here, we want administrators to be able to create boxes listing people. To do so, we first have to register a new object type for this person list box controller for the object type definition com.woltlab.wcf.boxController
:
<type>\n<name>com.woltlab.wcf.personList</name>\n<definitionname>com.woltlab.wcf.boxController</definitionname>\n<classname>wcf\\system\\box\\PersonListBoxController</classname>\n</type>\n
The com.woltlab.wcf.boxController
object type definition requires the provided class to implement wcf\\system\\box\\IBoxController
:
<?php\n\nnamespace wcf\\system\\box;\n\nuse wcf\\data\\person\\PersonList;\nuse wcf\\system\\WCF;\n\n/**\n * Dynamic box controller implementation for a list of persons.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2022 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\System\\Box\n */\nfinal class PersonListBoxController extends AbstractDatabaseObjectListBoxController\n{\n /**\n * @inheritDoc\n */\n protected $conditionDefinition = 'com.woltlab.wcf.box.personList.condition';\n\n /**\n * @inheritDoc\n */\n public $defaultLimit = 5;\n\n /**\n * @inheritDoc\n */\n protected $sortFieldLanguageItemPrefix = 'wcf.person';\n\n /**\n * @inheritDoc\n */\n protected static $supportedPositions = [\n 'sidebarLeft',\n 'sidebarRight',\n ];\n\n /**\n * @inheritDoc\n */\n public $validSortFields = [\n 'firstName',\n 'lastName',\n 'comments',\n ];\n\n /**\n * @inheritDoc\n */\n protected function getObjectList()\n {\n return new PersonList();\n }\n\n /**\n * @inheritDoc\n */\n protected function getTemplate()\n {\n return WCF::getTPL()->fetch('boxPersonList', 'wcf', [\n 'boxPersonList' => $this->objectList,\n 'boxSortField' => $this->sortField,\n 'boxPosition' => $this->box->position,\n ], true);\n }\n}\n
By extending AbstractDatabaseObjectListBoxController
, we only have to provide minimal data ourself and rely mostly on the default implementation provided by AbstractDatabaseObjectListBoxController
:
$conditionDefinition
.AbstractDatabaseObjectListBoxController
already supports restricting the number of listed objects. To do so, you only have to specify the default number of listed objects via $defaultLimit
.AbstractDatabaseObjectListBoxController
also supports setting the sort order of the listed objects. You have to provide the supported sort fields via $validSortFields
and specify the prefix used for the language items of the sort fields via $sortFieldLanguageItemPrefix
so that for every $validSortField
in $validSortFields
, the language item {$sortFieldLanguageItemPrefix}.{$validSortField}
must exist.$supportedPositions
. To keep the implementation simple here as different positions might require different output in the template, we restrict ourselves to sidebars.getObjectList()
returns an instance of DatabaseObjectList
that is used to read the listed objects. getObjectList()
itself must not call readObjects()
, as AbstractDatabaseObjectListBoxController
takes care of calling the method after adding the conditions and setting the sort order.getTemplate()
returns the contents of the box relying on the boxPersonList
template here:<ul class=\"sidebarItemList\">\n{foreach from=$boxPersonList item=boxPerson}\n <li class=\"box24\">\n <span class=\"icon icon24 fa-user\"></span>\n\n <div class=\"sidebarItemTitle\">\n <h3>{anchor object=$boxPerson}</h3>\n{capture assign='__boxPersonDescription'}{lang __optional=true}wcf.person.boxList.description.{$boxSortField}{/lang}{/capture}\n{if $__boxPersonDescription}\n <small>{@$__boxPersonDescription}</small>\n{/if}\n </div>\n </li>\n{/foreach}\n</ul>\n
The template relies on a .sidebarItemList
element, which is generally used for sidebar listings. (If different box positions were supported, we either have to generate different output by considering the value of $boxPosition
in the template or by using different templates in getTemplate()
.) One specific piece of code is the $__boxPersonDescription
variable, which supports an optional description below the person's name relying on the optional language item wcf.person.boxList.description.{$boxSortField}
. We only add one such language item when sorting the people by comments: In such a case, the number of comments will be shown. (When sorting by first and last name, there are no additional useful information that could be shown here, though the plugin from part 2 adding support for birthdays might also show the birthday when sorting by first or last name.)
Lastly, we also provide the language item wcf.acp.box.boxController.com.woltlab.wcf.personList
, which is used in the list of available box controllers.
The condition system can be used to generally filter a list of objects. In our case, the box system supports conditions to filter the objects shown in a specific box. Admittedly, our current person implementation only contains minimal data so that filtering might not make the most sense here but it will still show how to use the condition system for boxes. We will support filtering the people by their first and last name so that, for example, a box can be created listing all people with a specific first name.
The first step for condition support is to register a object type definition for the relevant conditions requiring the IObjectListCondition
interface:
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/5.4/objectTypeDefinition.xsd\">\n<import>\n<definition>\n<name>com.woltlab.wcf.box.personList.condition</name>\n<interfacename>wcf\\system\\condition\\IObjectListCondition</interfacename>\n</definition>\n</import>\n</data>\n
Next, we register the specific conditions for filtering by the first and last name using this object type condition:
<type>\n<name>com.woltlab.wcf.people.firstName</name>\n<definitionname>com.woltlab.wcf.box.personList.condition</definitionname>\n<classname>wcf\\system\\condition\\person\\PersonFirstNameTextPropertyCondition</classname>\n</type>\n<type>\n<name>com.woltlab.wcf.people.lastName</name>\n<definitionname>com.woltlab.wcf.box.personList.condition</definitionname>\n<classname>wcf\\system\\condition\\person\\PersonLastNameTextPropertyCondition</classname>\n</type>\n
PersonFirstNameTextPropertyCondition
and PersonLastNameTextPropertyCondition
only differ minimally so that we only focus on PersonFirstNameTextPropertyCondition
here, which relies on the default implementation AbstractObjectTextPropertyCondition
and only requires specifying different object properties:
<?php\n\nnamespace wcf\\system\\condition\\person;\n\nuse wcf\\data\\person\\Person;\nuse wcf\\system\\condition\\AbstractObjectTextPropertyCondition;\n\n/**\n * Condition implementation for the first name of a person.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2022 WoltLab GmbH\n * @license WoltLab License <http://www.woltlab.com/license-agreement.html>\n * @package WoltLabSuite\\Core\\System\\Condition\n */\nfinal class PersonFirstNameTextPropertyCondition extends AbstractObjectTextPropertyCondition\n{\n /**\n * @inheritDoc\n */\n protected $className = Person::class;\n\n /**\n * @inheritDoc\n */\n protected $description = 'wcf.person.condition.firstName.description';\n\n /**\n * @inheritDoc\n */\n protected $fieldName = 'personFirstName';\n\n /**\n * @inheritDoc\n */\n protected $label = 'wcf.person.firstName';\n\n /**\n * @inheritDoc\n */\n protected $propertyName = 'firstName';\n\n /**\n * @inheritDoc\n */\n protected $supportsMultipleValues = true;\n}\n
$className
contains the class name of the relevant database object from which the class name of the database object list is derived and $propertyName
is the name of the database object's property that contains the value used for filtering.$supportsMultipleValues
to true
, multiple comma-separated values can be specified so that, for example, a box can also only list people with either of two specific first names.$description
(optional), $fieldName
, and $label
are used in the output of the form field.(The implementation here is specific for AbstractObjectTextPropertyCondition
. The wcf\\system\\condition
namespace also contains several other default condition implementations.)
This part of our tutorial series lays the foundation for future parts in which we will be using additional APIs, which we have not used in this series yet. To make use of those APIs, we need content generated by users in the frontend.
"},{"location":"tutorial/series/part_5/#package-functionality","title":"Package Functionality","text":"In addition to the existing functions from part 4, the package will provide the following functionality after this part of the tutorial:
In addition to the components used in previous parts, we will use the form builder API to create forms shown in dialogs instead of dedicated pages and we will, for the first time, add TypeScript code.
"},{"location":"tutorial/series/part_5/#package-structure","title":"Package Structure","text":"The package will have the following file structure excluding unchanged files from previous parts:
\u251c\u2500\u2500 files\n\u2502 \u251c\u2500\u2500 acp\n\u2502 \u2502 \u2514\u2500\u2500 database\n\u2502 \u2502 \u2514\u2500\u2500 install_com.woltlab.wcf.people.php\n\u2502 \u251c\u2500\u2500 js\n\u2502 \u2502 \u2514\u2500\u2500 WoltLabSuite\n\u2502 \u2502 \u2514\u2500\u2500 Core\n\u2502 \u2502 \u2514\u2500\u2500 Controller\n\u2502 \u2502 \u2514\u2500\u2500 Person.js\n\u2502 \u2514\u2500\u2500 lib\n\u2502 \u251c\u2500\u2500 data\n\u2502 \u2502 \u2514\u2500\u2500 person\n\u2502 \u2502 \u251c\u2500\u2500 Person.class.php\n\u2502 \u2502 \u2514\u2500\u2500 information\n\u2502 \u2502 \u251c\u2500\u2500 PersonInformation.class.php\n\u2502 \u2502 \u251c\u2500\u2500 PersonInformationAction.class.php\n\u2502 \u2502 \u251c\u2500\u2500 PersonInformationEditor.class.php\n\u2502 \u2502 \u2514\u2500\u2500 PersonInformationList.class.php\n\u2502 \u2514\u2500\u2500 system\n\u2502 \u2514\u2500\u2500 worker\n\u2502 \u2514\u2500\u2500 PersonRebuildDataWorker.class.php\n\u251c\u2500\u2500 language\n\u2502 \u251c\u2500\u2500 de.xml\n\u2502 \u2514\u2500\u2500 en.xml\n\u251c\u2500\u2500 objectType.xml\n\u251c\u2500\u2500 templates\n\u2502 \u251c\u2500\u2500 person.tpl\n\u2502 \u2514\u2500\u2500 personList.tpl\n\u251c\u2500\u2500 ts\n\u2502 \u2514\u2500\u2500 WoltLabSuite\n\u2502 \u2514\u2500\u2500 Core\n\u2502 \u2514\u2500\u2500 Controller\n\u2502 \u2514\u2500\u2500 Person.ts\n\u2514\u2500\u2500 userGroupOption.xml\n
For all changes, please refer to the source code on GitHub.
"},{"location":"tutorial/series/part_5/#miscellaneous","title":"Miscellaneous","text":"Before we focus on the main aspects of this part, we mention some minor aspects that will be used later on:
mod.person.canEditInformation
and mod.person.canDeleteInformation
are moderative permissions to edit and delete any piece of information, regardless of who created it.user.person.canAddInformation
is the permission for users to add new pieces of information.user.person.canEditInformation
and user.person.canDeleteInformation
are the user permissions to edit and the piece of information they created.com.woltlab.wcf.message
: com.woltlab.wcf.people.information
.personList.tpl
has been adjusted to show the number of pieces of information in the person statistics section.The PHP file with the database layout has been updated as follows:
files/acp/database/install_com.woltlab.wcf.people.php<?php\n\nuse wcf\\system\\database\\table\\column\\DefaultTrueBooleanDatabaseTableColumn;\nuse wcf\\system\\database\\table\\column\\IntDatabaseTableColumn;\nuse wcf\\system\\database\\table\\column\\NotNullInt10DatabaseTableColumn;\nuse wcf\\system\\database\\table\\column\\NotNullVarchar255DatabaseTableColumn;\nuse wcf\\system\\database\\table\\column\\ObjectIdDatabaseTableColumn;\nuse wcf\\system\\database\\table\\column\\SmallintDatabaseTableColumn;\nuse wcf\\system\\database\\table\\column\\TextDatabaseTableColumn;\nuse wcf\\system\\database\\table\\column\\VarcharDatabaseTableColumn;\nuse wcf\\system\\database\\table\\DatabaseTable;\nuse wcf\\system\\database\\table\\index\\DatabaseTableForeignKey;\nuse wcf\\system\\database\\table\\index\\DatabaseTablePrimaryIndex;\n\nreturn [\n DatabaseTable::create('wcf1_person')\n ->columns([\n ObjectIdDatabaseTableColumn::create('personID'),\n NotNullVarchar255DatabaseTableColumn::create('firstName'),\n NotNullVarchar255DatabaseTableColumn::create('lastName'),\n NotNullInt10DatabaseTableColumn::create('informationCount')\n ->defaultValue(0),\n SmallintDatabaseTableColumn::create('comments')\n ->length(5)\n ->notNull()\n ->defaultValue(0),\n DefaultTrueBooleanDatabaseTableColumn::create('enableComments'),\n ])\n ->indices([\n DatabaseTablePrimaryIndex::create()\n ->columns(['personID']),\n ]),\n\n DatabaseTable::create('wcf1_person_information')\n ->columns([\n ObjectIdDatabaseTableColumn::create('informationID'),\n NotNullInt10DatabaseTableColumn::create('personID'),\n TextDatabaseTableColumn::create('information'),\n IntDatabaseTableColumn::create('userID')\n ->length(10),\n NotNullVarchar255DatabaseTableColumn::create('username'),\n VarcharDatabaseTableColumn::create('ipAddress')\n ->length(39)\n ->notNull(true)\n ->defaultValue(''),\n NotNullInt10DatabaseTableColumn::create('time'),\n ])\n ->indices([\n DatabaseTablePrimaryIndex::create()\n ->columns(['informationID']),\n ])\n ->foreignKeys([\n DatabaseTableForeignKey::create()\n ->columns(['personID'])\n ->referencedTable('wcf1_person')\n ->referencedColumns(['personID'])\n ->onDelete('CASCADE'),\n DatabaseTableForeignKey::create()\n ->columns(['userID'])\n ->referencedTable('wcf1_user')\n ->referencedColumns(['userID'])\n ->onDelete('SET NULL'),\n ]),\n];\n
informationCount
column.wcf1_person_information
table has been added for the PersonInformation
model. The meaning of the different columns is explained in the property documentation part of PersonInformation
's documentation (see below). The two foreign keys ensure that if a person is deleted, all of their information is also deleted, and that if a user is deleted, the userID
column is set to NULL
.<?php\n\nnamespace wcf\\data\\person\\information;\n\nuse wcf\\data\\DatabaseObject;\nuse wcf\\data\\person\\Person;\nuse wcf\\data\\user\\UserProfile;\nuse wcf\\system\\cache\\runtime\\PersonRuntimeCache;\nuse wcf\\system\\cache\\runtime\\UserProfileRuntimeCache;\nuse wcf\\system\\html\\output\\HtmlOutputProcessor;\nuse wcf\\system\\WCF;\n\n/**\n * Represents a piece of information for a person.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2021 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\Data\\Person\\Information\n *\n * @property-read int $informationID unique id of the information\n * @property-read int $personID id of the person the information belongs to\n * @property-read string $information information text\n * @property-read int|null $userID id of the user who added the information or `null` if the user no longer exists\n * @property-read string $username name of the user who added the information\n * @property-read int $time timestamp at which the information was created\n */\nclass PersonInformation extends DatabaseObject\n{\n /**\n * Returns `true` if the active user can delete this piece of information and `false` otherwise.\n */\n public function canDelete(): bool\n {\n if (\n WCF::getUser()->userID\n && WCF::getUser()->userID == $this->userID\n && WCF::getSession()->getPermission('user.person.canDeleteInformation')\n ) {\n return true;\n }\n\n return WCF::getSession()->getPermission('mod.person.canDeleteInformation');\n }\n\n /**\n * Returns `true` if the active user can edit this piece of information and `false` otherwise.\n */\n public function canEdit(): bool\n {\n if (\n WCF::getUser()->userID\n && WCF::getUser()->userID == $this->userID\n && WCF::getSession()->getPermission('user.person.canEditInformation')\n ) {\n return true;\n }\n\n return WCF::getSession()->getPermission('mod.person.canEditInformation');\n }\n\n /**\n * Returns the formatted information.\n */\n public function getFormattedInformation(): string\n {\n $processor = new HtmlOutputProcessor();\n $processor->process(\n $this->information,\n 'com.woltlab.wcf.people.information',\n $this->informationID\n );\n\n return $processor->getHtml();\n }\n\n /**\n * Returns the person the information belongs to.\n */\n public function getPerson(): Person\n {\n return PersonRuntimeCache::getInstance()->getObject($this->personID);\n }\n\n /**\n * Returns the user profile of the user who added the information.\n */\n public function getUserProfile(): UserProfile\n {\n if ($this->userID) {\n return UserProfileRuntimeCache::getInstance()->getObject($this->userID);\n } else {\n return UserProfile::getGuestUserProfile($this->username);\n }\n }\n}\n
PersonInformation
provides two methods, canDelete()
and canEdit()
, to check whether the active user can delete or edit a specific piece of information. In both cases, it is checked if the current user has created the relevant piece of information to check the user-specific permissions or to fall back to the moderator-specific permissions.
There also two getter methods for the person, the piece of information belongs to (getPerson()
), and for the user profile of the user who created the information (getUserProfile()
). In both cases, we use runtime caches, though in getUserProfile()
, we also have to consider the case of the user who created the information being deleted, i.e. userID
being null
. For such a case, we also save the name of the user who created the information in username
, so that we can return a guest user profile object in this case. The most interesting method is getFormattedInformation()
, which returns the HTML code of the information text meant for output. To generate such an output, HtmlOutputProcessor::process()
is used and here is where we first use the associated message object type com.woltlab.wcf.people.information
mentioned before.
While PersonInformationEditor
is simply the default implementation and thus not explicitly shown here, PersonInformationList::readObjects()
caches the relevant ids of the associated people and users who created the pieces of information using runtime caches:
<?php\n\nnamespace wcf\\data\\person\\information;\n\nuse wcf\\data\\DatabaseObjectList;\nuse wcf\\system\\cache\\runtime\\PersonRuntimeCache;\nuse wcf\\system\\cache\\runtime\\UserProfileRuntimeCache;\n\n/**\n * Represents a list of person information.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2021 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\Data\\PersonInformation\n *\n * @method PersonInformation current()\n * @method PersonInformation[] getObjects()\n * @method PersonInformation|null search($objectID)\n * @property PersonInformation[] $objects\n */\nclass PersonInformationList extends DatabaseObjectList\n{\n public function readObjects()\n {\n parent::readObjects();\n\n UserProfileRuntimeCache::getInstance()->cacheObjectIDs(\\array_unique(\\array_filter(\\array_column(\n $this->objects,\n 'userID'\n ))));\n PersonRuntimeCache::getInstance()->cacheObjectIDs(\\array_unique(\\array_column(\n $this->objects,\n 'personID'\n )));\n }\n}\n
"},{"location":"tutorial/series/part_5/#listing-and-deleting-person-information","title":"Listing and Deleting Person Information","text":"The person.tpl
template has been updated to include a block for listing the information at the beginning:
{capture assign='pageTitle'}{$person} - {lang}wcf.person.list{/lang}{/capture}\n\n{capture assign='contentTitle'}{$person}{/capture}\n\n{include file='header'}\n\n{if $person->informationCount || $__wcf->session->getPermission('user.person.canAddInformation')}\n <section class=\"section sectionContainerList\">\n <header class=\"sectionHeader\">\n <h2 class=\"sectionTitle\">\n{lang}wcf.person.information.list{/lang}\n{if $person->informationCount}\n <span class=\"badge\">{#$person->informationCount}</span>\n{/if}\n </h2>\n </header>\n\n <ul class=\"commentList containerList personInformationList jsObjectActionContainer\" {*\n *}data-object-action-class-name=\"wcf\\data\\person\\information\\PersonInformationAction\"{*\n *}>\n{if $__wcf->session->getPermission('user.person.canAddInformation')}\n <li class=\"containerListButtonGroup\">\n <ul class=\"buttonGroup\">\n <li>\n <a href=\"#\" class=\"button\" id=\"personInformationAddButton\">\n <span class=\"icon icon16 fa-plus\"></span>\n <span>{lang}wcf.person.information.add{/lang}</span>\n </a>\n </li>\n </ul>\n </li>\n{/if}\n\n{foreach from=$person->getInformation() item=$information}\n <li class=\"comment personInformation jsObjectActionObject\" data-object-id=\"{@$information->getObjectID()}\">\n <div class=\"box48{if $__wcf->getUserProfileHandler()->isIgnoredUser($information->userID, 2)} ignoredUserContent{/if}\">\n{user object=$information->getUserProfile() type='avatar48' ariaHidden='true' tabindex='-1'}\n\n <div class=\"commentContentContainer\">\n <div class=\"commentContent\">\n <div class=\"containerHeadline\">\n <h3>\n{if $information->userID}\n{user object=$information->getUserProfile()}\n{else}\n <span>{$information->username}</span>\n{/if}\n\n <small class=\"separatorLeft\">{@$information->time|time}</small>\n </h3>\n </div>\n\n <div class=\"htmlContent userMessage\" id=\"personInformation{@$information->getObjectID()}\">\n{@$information->getFormattedInformation()}\n </div>\n\n <nav class=\"jsMobileNavigation buttonGroupNavigation\">\n <ul class=\"buttonList iconList\">\n{if $information->canEdit()}\n <li class=\"jsOnly\">\n <a href=\"#\" title=\"{lang}wcf.global.button.edit{/lang}\" class=\"jsEditInformation jsTooltip\">\n <span class=\"icon icon16 fa-pencil\"></span>\n <span class=\"invisible\">{lang}wcf.global.button.edit{/lang}</span>\n </a>\n </li>\n{/if}\n{if $information->canDelete()}\n <li class=\"jsOnly\">\n <a href=\"#\" title=\"{lang}wcf.global.button.delete{/lang}\" class=\"jsObjectAction jsTooltip\" data-object-action=\"delete\" data-confirm-message=\"{lang}wcf.person.information.delete.confirmMessage{/lang}\">\n <span class=\"icon icon16 fa-times\"></span>\n <span class=\"invisible\">{lang}wcf.global.button.edit{/lang}</span>\n </a>\n </li>\n{/if}\n\n{event name='informationOptions'}\n </ul>\n </nav>\n </div>\n </div>\n </div>\n </li>\n{/foreach}\n </ul>\n </section>\n{/if}\n\n{if $person->enableComments}\n{if $commentList|count || $commentCanAdd}\n <section id=\"comments\" class=\"section sectionContainerList\">\n <header class=\"sectionHeader\">\n <h2 class=\"sectionTitle\">\n{lang}wcf.person.comments{/lang}\n{if $person->comments}<span class=\"badge\">{#$person->comments}</span>{/if}\n </h2>\n </header>\n\n{include file='__commentJavaScript' commentContainerID='personCommentList'}\n\n <div class=\"personComments\">\n <ul id=\"personCommentList\" class=\"commentList containerList\" {*\n *}data-can-add=\"{if $commentCanAdd}true{else}false{/if}\" {*\n *}data-object-id=\"{@$person->personID}\" {*\n *}data-object-type-id=\"{@$commentObjectTypeID}\" {*\n *}data-comments=\"{if $person->comments}{@$commentList->countObjects()}{else}0{/if}\" {*\n *}data-last-comment-time=\"{@$lastCommentTime}\" {*\n *}>\n{include file='commentListAddComment' wysiwygSelector='personCommentListAddComment'}\n{include file='commentList'}\n </ul>\n </div>\n </section>\n{/if}\n{/if}\n\n<footer class=\"contentFooter\">\n{hascontent}\n <nav class=\"contentFooterNavigation\">\n <ul>\n{content}{event name='contentFooterNavigation'}{/content}\n </ul>\n </nav>\n{/hascontent}\n</footer>\n\n<script data-relocate=\"true\">\n require(['Language', 'WoltLabSuite/Core/Controller/Person'], (Language, ControllerPerson) => {\n Language.addObject({\n 'wcf.person.information.add': '{jslang}wcf.person.information.add{/jslang}',\n 'wcf.person.information.add.success': '{jslang}wcf.person.information.add.success{/jslang}',\n 'wcf.person.information.edit': '{jslang}wcf.person.information.edit{/jslang}',\n 'wcf.person.information.edit.success': '{jslang}wcf.person.information.edit.success{/jslang}',\n });\n\n ControllerPerson.init({@$person->personID}, {\n canAddInformation: {if $__wcf->session->getPermission('user.person.canAddInformation')}true{else}false{/if},\n });\n });\n</script>\n\n{include file='footer'}\n
To keep things simple here, we reuse the structure and CSS classes used for comments. Additionally, we always list all pieces of information. If there are many pieces of information, a nicer solution would be a pagination or loading more pieces of information with JavaScript.
First, we note the jsObjectActionContainer
class in combination with the data-object-action-class-name
attribute, which are needed for the delete button for each piece of information, as explained here. In PersonInformationAction
, we have overridden the default implementations of validateDelete()
and delete()
which are called after clicking on a delete button. In validateDelete()
, we call PersonInformation::canDelete()
on all pieces of information to be deleted for proper permission validation, and in delete()
, we update the informationCount
values of the people the deleted pieces of information belong to (see below).
The button to add a new piece of information, #personInformationAddButton
, and the buttons to edit existing pieces of information, .jsEditInformation
, are controlled with JavaScript code initialized at the very end of the template.
Lastly, in create()
we provide default values for the time
, userID
, username
, and ipAddress
for cases like here when creating a new piece of information, where do not explicitly provide this data. Additionally, we extract the information text from the information_htmlInputProcessor
parameter provided by the associated WYSIWYG form field and update the number of pieces of information created for the relevant person.
To create new pieces of information or editing existing ones, we do not add new form controllers but instead use dialogs generated by the form builder API so that the user does not have to leave the person page.
When clicking on the add button or on any of the edit buttons, a dialog opens with the relevant form:
ts/WoltLabSuite/Core/Controller/Person.ts/**\n * Provides the JavaScript code for the person page.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2021 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @module WoltLabSuite/Core/Controller/Person\n */\n\nimport FormBuilderDialog from \"WoltLabSuite/Core/Form/Builder/Dialog\";\nimport * as Language from \"WoltLabSuite/Core/Language\";\nimport * as UiNotification from \"WoltLabSuite/Core/Ui/Notification\";\n\nlet addDialog: FormBuilderDialog;\nconst editDialogs = new Map<string, FormBuilderDialog>();\n\ninterface EditReturnValues {\nformattedInformation: string;\ninformationID: number;\n}\n\ninterface Options {\ncanAddInformation: true;\n}\n\n/**\n * Opens the edit dialog after clicking on the edit button for a piece of information.\n */\nfunction editInformation(event: Event): void {\nevent.preventDefault();\n\nconst currentTarget = event.currentTarget as HTMLElement;\nconst information = currentTarget.closest(\".jsObjectActionObject\") as HTMLElement;\nconst informationId = information.dataset.objectId!;\n\nif (!editDialogs.has(informationId)) {\neditDialogs.set(\ninformationId,\nnew FormBuilderDialog(\n`personInformationEditDialog${informationId}`,\n\"wcf\\\\data\\\\person\\\\information\\\\PersonInformationAction\",\n\"getEditDialog\",\n{\nactionParameters: {\ninformationID: informationId,\n},\ndialog: {\ntitle: Language.get(\"wcf.person.information.edit\"),\n},\nsubmitActionName: \"submitEditDialog\",\nsuccessCallback(returnValues: EditReturnValues) {\ndocument.getElementById(`personInformation${returnValues.informationID}`)!.innerHTML =\nreturnValues.formattedInformation;\n\nUiNotification.show(Language.get(\"wcf.person.information.edit.success\"));\n},\n},\n),\n);\n}\n\neditDialogs.get(informationId)!.open();\n}\n\n/**\n * Initializes the JavaScript code for the person page.\n */\nexport function init(personId: number, options: Options): void {\nif (options.canAddInformation) {\n// Initialize the dialog to add new information.\naddDialog = new FormBuilderDialog(\n\"personInformationAddDialog\",\n\"wcf\\\\data\\\\person\\\\information\\\\PersonInformationAction\",\n\"getAddDialog\",\n{\nactionParameters: {\npersonID: personId,\n},\ndialog: {\ntitle: Language.get(\"wcf.person.information.add\"),\n},\nsubmitActionName: \"submitAddDialog\",\nsuccessCallback() {\nUiNotification.show(Language.get(\"wcf.person.information.add.success\"), () => window.location.reload());\n},\n},\n);\n\ndocument.getElementById(\"personInformationAddButton\")!.addEventListener(\"click\", (event) => {\nevent.preventDefault();\n\naddDialog.open();\n});\n}\n\ndocument\n.querySelectorAll(\".jsEditInformation\")\n.forEach((el) => el.addEventListener(\"click\", (ev) => editInformation(ev)));\n}\n
We use the WoltLabSuite/Core/Form/Builder/Dialog
module, which takes care of the internal handling with regard to these dialogs. We only have to provide some data during for initializing these objects and call the open()
function after a button has been clicked.
Explanation of the initialization arguments for WoltLabSuite/Core/Form/Builder/Dialog
used here:
actionParameters
are additional parameters send during each AJAX request. Here, we either pass the id of the person for who a new piece of information is added or the id of the edited piece of information.dialog
contains the options for the dialog, see the DialogOptions
interface. Here, we only provide the title of the dialog.submitActionName
is the name of the method in the referenced PHP class that is called with the form data after submitting the form.successCallback
is called after the submit AJAX request was successful. After adding a new piece of information, we reload the page, and after editing an existing piece of information, we update the existing information text with the updated text. (Dynamically inserting a newly added piece of information instead of reloading the page would also be possible, of course, but for this tutorial series, we kept things simple.)Next, we focus on PersonInformationAction
, which actually provides the contents of these dialogs and creates and edits the information:
<?php\n\nnamespace wcf\\data\\person\\information;\n\nuse wcf\\data\\AbstractDatabaseObjectAction;\nuse wcf\\data\\person\\PersonAction;\nuse wcf\\data\\person\\PersonEditor;\nuse wcf\\system\\cache\\runtime\\PersonRuntimeCache;\nuse wcf\\system\\event\\EventHandler;\nuse wcf\\system\\exception\\IllegalLinkException;\nuse wcf\\system\\exception\\PermissionDeniedException;\nuse wcf\\system\\exception\\UserInputException;\nuse wcf\\system\\form\\builder\\container\\wysiwyg\\WysiwygFormContainer;\nuse wcf\\system\\form\\builder\\DialogFormDocument;\nuse wcf\\system\\html\\input\\HtmlInputProcessor;\nuse wcf\\system\\WCF;\nuse wcf\\util\\UserUtil;\n\n/**\n * Executes person information-related actions.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2021 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\Data\\Person\\Information\n *\n * @method PersonInformationEditor[] getObjects()\n * @method PersonInformationEditor getSingleObject()\n */\nclass PersonInformationAction extends AbstractDatabaseObjectAction\n{\n /**\n * @var DialogFormDocument\n */\n public $dialog;\n\n /**\n * @var PersonInformation\n */\n public $information;\n\n /**\n * @return PersonInformation\n */\n public function create()\n {\n if (!isset($this->parameters['data']['time'])) {\n $this->parameters['data']['time'] = TIME_NOW;\n }\n if (!isset($this->parameters['data']['userID'])) {\n $this->parameters['data']['userID'] = WCF::getUser()->userID;\n $this->parameters['data']['username'] = WCF::getUser()->username;\n }\n\n if (LOG_IP_ADDRESS) {\n if (!isset($this->parameters['data']['ipAddress'])) {\n $this->parameters['data']['ipAddress'] = UserUtil::getIpAddress();\n }\n } else {\n unset($this->parameters['data']['ipAddress']);\n }\n\n if (!empty($this->parameters['information_htmlInputProcessor'])) {\n /** @var HtmlInputProcessor $htmlInputProcessor */\n $htmlInputProcessor = $this->parameters['information_htmlInputProcessor'];\n $this->parameters['data']['information'] = $htmlInputProcessor->getHtml();\n }\n\n /** @var PersonInformation $information */\n $information = parent::create();\n\n (new PersonAction([$information->personID], 'update', [\n 'counters' => [\n 'informationCount' => 1,\n ],\n ]))->executeAction();\n\n return $information;\n }\n\n /**\n * @inheritDoc\n */\n public function update()\n {\n if (!empty($this->parameters['information_htmlInputProcessor'])) {\n /** @var HtmlInputProcessor $htmlInputProcessor */\n $htmlInputProcessor = $this->parameters['information_htmlInputProcessor'];\n $this->parameters['data']['information'] = $htmlInputProcessor->getHtml();\n }\n\n parent::update();\n }\n\n /**\n * @inheritDoc\n */\n public function validateDelete()\n {\n if (empty($this->objects)) {\n $this->readObjects();\n\n if (empty($this->objects)) {\n throw new UserInputException('objectIDs');\n }\n }\n\n foreach ($this->getObjects() as $informationEditor) {\n if (!$informationEditor->canDelete()) {\n throw new PermissionDeniedException();\n }\n }\n }\n\n /**\n * @inheritDoc\n */\n public function delete()\n {\n $deleteCount = parent::delete();\n\n if (!$deleteCount) {\n return $deleteCount;\n }\n\n $counterUpdates = [];\n foreach ($this->getObjects() as $informationEditor) {\n if (!isset($counterUpdates[$informationEditor->personID])) {\n $counterUpdates[$informationEditor->personID] = 0;\n }\n\n $counterUpdates[$informationEditor->personID]--;\n }\n\n WCF::getDB()->beginTransaction();\n foreach ($counterUpdates as $personID => $counterUpdate) {\n (new PersonEditor(PersonRuntimeCache::getInstance()->getObject($personID)))->updateCounters([\n 'informationCount' => $counterUpdate,\n ]);\n }\n WCF::getDB()->commitTransaction();\n\n return $deleteCount;\n }\n\n /**\n * Validates the `getAddDialog` action.\n */\n public function validateGetAddDialog(): void\n {\n WCF::getSession()->checkPermissions(['user.person.canAddInformation']);\n\n $this->readInteger('personID');\n if (PersonRuntimeCache::getInstance()->getObject($this->parameters['personID']) === null) {\n throw new UserInputException('personID');\n }\n }\n\n /**\n * Returns the data to show the dialog to add a new piece of information on a person.\n *\n * @return string[]\n */\n public function getAddDialog(): array\n {\n $this->buildDialog();\n\n return [\n 'dialog' => $this->dialog->getHtml(),\n 'formId' => $this->dialog->getId(),\n ];\n }\n\n /**\n * Validates the `submitAddDialog` action.\n */\n public function validateSubmitAddDialog(): void\n {\n $this->validateGetAddDialog();\n\n $this->buildDialog();\n $this->dialog->requestData($_POST['parameters']['data'] ?? []);\n $this->dialog->readValues();\n $this->dialog->validate();\n }\n\n /**\n * Creates a new piece of information on a person after submitting the dialog.\n *\n * @return string[]\n */\n public function submitAddDialog(): array\n {\n // If there are any validation errors, show the form again.\n if ($this->dialog->hasValidationErrors()) {\n return [\n 'dialog' => $this->dialog->getHtml(),\n 'formId' => $this->dialog->getId(),\n ];\n }\n\n (new static([], 'create', \\array_merge($this->dialog->getData(), [\n 'data' => [\n 'personID' => $this->parameters['personID'],\n ],\n ])))->executeAction();\n\n return [];\n }\n\n /**\n * Validates the `getEditDialog` action.\n */\n public function validateGetEditDialog(): void\n {\n WCF::getSession()->checkPermissions(['user.person.canAddInformation']);\n\n $this->readInteger('informationID');\n $this->information = new PersonInformation($this->parameters['informationID']);\n if (!$this->information->getObjectID()) {\n throw new UserInputException('informationID');\n }\n if (!$this->information->canEdit()) {\n throw new IllegalLinkException();\n }\n }\n\n /**\n * Returns the data to show the dialog to edit a piece of information on a person.\n *\n * @return string[]\n */\n public function getEditDialog(): array\n {\n $this->buildDialog();\n $this->dialog->updatedObject($this->information);\n\n return [\n 'dialog' => $this->dialog->getHtml(),\n 'formId' => $this->dialog->getId(),\n ];\n }\n\n /**\n * Validates the `submitEditDialog` action.\n */\n public function validateSubmitEditDialog(): void\n {\n $this->validateGetEditDialog();\n\n $this->buildDialog();\n $this->dialog->updatedObject($this->information, false);\n $this->dialog->requestData($_POST['parameters']['data'] ?? []);\n $this->dialog->readValues();\n $this->dialog->validate();\n }\n\n /**\n * Updates a piece of information on a person after submitting the edit dialog.\n *\n * @return string[]\n */\n public function submitEditDialog(): array\n {\n // If there are any validation errors, show the form again.\n if ($this->dialog->hasValidationErrors()) {\n return [\n 'dialog' => $this->dialog->getHtml(),\n 'formId' => $this->dialog->getId(),\n ];\n }\n\n (new static([$this->information], 'update', $this->dialog->getData()))->executeAction();\n\n // Reload the information with the updated data.\n $information = new PersonInformation($this->information->getObjectID());\n\n return [\n 'formattedInformation' => $information->getFormattedInformation(),\n 'informationID' => $this->information->getObjectID(),\n ];\n }\n\n /**\n * Builds the dialog to create or edit person information.\n */\n protected function buildDialog(): void\n {\n if ($this->dialog !== null) {\n return;\n }\n\n $this->dialog = DialogFormDocument::create('personInformationAddDialog')\n ->appendChild(\n WysiwygFormContainer::create('information')\n ->messageObjectType('com.woltlab.wcf.people.information')\n ->required()\n );\n\n EventHandler::getInstance()->fireAction($this, 'buildDialog');\n\n $this->dialog->build();\n }\n}\n
When setting up the WoltLabSuite/Core/Form/Builder/Dialog
object for adding new pieces of information, we specified getAddDialog
and submitAddDialog
as the names of the dialog getter and submit handler. In addition to these two methods, the matching validation methods validateGetAddDialog()
and validateGetAddDialog()
are also added. As the forms for adding and editing pieces of information have the same structure, this form is created in buildDialog()
using a DialogFormDocument
object, which is intended for forms in dialogs. We fire an event in buildDialog()
so that plugins are able to easily extend the dialog with additional data.
validateGetAddDialog()
checks if the user has the permission to create new pieces of information and if a valid id for the person, the information will belong to, is given. The method configured in the WoltLabSuite/Core/Form/Builder/Dialog
object returning the dialog is expected to return two values: the id of the form (formId
) and the contents of form shown in the dialog (dialog
). This data is returned by getAddDialog
using the dialog build previously by buildDialog()
.
After the form is submitted, validateSubmitAddDialog()
has to do the same basic validation as validateGetAddDialog()
so that validateGetAddDialog()
is simply called. Additionally, the form data is read and validated. In submitAddDialog()
, we first check if there have been any validation errors: If any error occured during validation, we return the same data as in getAddDialog()
so that the dialog is shown again with the erroneous fields marked as such. Otherwise, if the validation succeeded, the form data is used to create the new piece of information. In addition to the form data, we manually add the id of the person to whom the information belongs to. Lastly, we could return some data that we could access in the JavaScript callback function after successfully submitting the dialog. As we will simply be reloading the page, no such data is returned. An alternative to reloading to the page would be dynamically inserting the new piece of information in the list so that we would have to return the rendered list item for the new piece of information.
The process for getting and submitting the dialog to edit existing pieces of information is similar to the process for adding new pieces of information. Instead of the id of the person, however, we now pass the id of the edited piece of information and in submitEditDialog()
, we update the edited information instead of creating a new one like in submitAddDialog()
. After editing a piece of information, we do not reload the page but dynamically update the text of the information in the TypeScript code so that we return the updated rendered information text and id of the edited pieced of information in submitAddDialog()
.
To ensure the integrity of the person data, PersonRebuildDataWorker
updates the informationCount
counter:
<?php\n\nnamespace wcf\\system\\worker;\n\nuse wcf\\data\\person\\PersonList;\nuse wcf\\system\\WCF;\n\n/**\n * Worker implementation for updating people.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2022 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\System\\Worker\n *\n * @method PersonList getObjectList()\n */\nfinal class PersonRebuildDataWorker extends AbstractRebuildDataWorker\n{\n /**\n * @inheritDoc\n */\n protected $limit = 500;\n\n /**\n * @inheritDoc\n */\n protected $objectListClassName = PersonList::class;\n\n /**\n * @inheritDoc\n */\n protected function initObjectList()\n {\n parent::initObjectList();\n\n $this->objectList->sqlOrderBy = 'person.personID';\n }\n\n /**\n * @inheritDoc\n */\n public function execute()\n {\n parent::execute();\n\n if (!\\count($this->objectList)) {\n return;\n }\n\n $sql = \"UPDATE wcf1_person person\n SET informationCount = (\n SELECT COUNT(*)\n FROM wcf1_person_information person_information\n WHERE person_information.personID = person.personID\n )\n WHERE person.personID = ?\";\n $statement = WCF::getDB()->prepare($sql);\n\n WCF::getDB()->beginTransaction();\n foreach ($this->getObjectList() as $person) {\n $statement->execute([$person->personID]);\n }\n WCF::getDB()->commitTransaction();\n }\n}\n
"},{"location":"tutorial/series/part_5/#username-and-ip-address-event-listeners","title":"Username and IP Address Event Listeners","text":"As we store the name of the user who create a new piece of information and store their IP address, we have to add event listeners to properly handle the following scenarios:
username
stored with the person information has to be updated, which can be achieved by a simple event listener that only has to specify the name of relevant database table if AbstractUserActionRenameListener
is extended:<?php\n\nnamespace wcf\\system\\event\\listener;\n\n/**\n * Updates person information during user renaming.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2022 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\System\\Event\\Listener\n */\nfinal class PersonUserActionRenameListener extends AbstractUserActionRenameListener\n{\n /**\n * @inheritDoc\n */\n protected $databaseTables = [\n 'wcf{WCF_N}_person_information',\n ];\n}\n
AbstractUserMergeListener
is extended:<?php\n\nnamespace wcf\\system\\event\\listener;\n\n/**\n * Updates person information during user merging.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2022 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\System\\Event\\Listener\n */\nfinal class PersonUserMergeListener extends AbstractUserMergeListener\n{\n /**\n * @inheritDoc\n */\n protected $databaseTables = [\n 'wcf{WCF_N}_person_information',\n ];\n}\n
ipAddress
column to the time
column:<?php\n\nnamespace wcf\\system\\event\\listener;\n\nuse wcf\\system\\cronjob\\PruneIpAddressesCronjob;\n\n/**\n * Prunes old ip addresses.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2022 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\System\\Event\\Listener\n */\nfinal class PersonPruneIpAddressesCronjobListener extends AbstractEventListener\n{\n protected function onExecute(PruneIpAddressesCronjob $cronjob): void\n {\n $cronjob->columns['wcf' . WCF_N . '_person_information']['ipAddress'] = 'time';\n }\n}\n
<?php\n\nnamespace wcf\\system\\event\\listener;\n\nuse wcf\\acp\\action\\UserExportGdprAction;\n\n/**\n * Adds the ip addresses stored with the person information during user data export.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2022 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\System\\Event\\Listener\n */\nfinal class PersonUserExportGdprListener extends AbstractEventListener\n{\n protected function onExport(UserExportGdprAction $action): void\n {\n $action->ipAddresses['com.woltlab.wcf.people'] = ['wcf' . WCF_N . '_person_information'];\n }\n}\n
Lastly, we present the updated eventListener.xml
file with new entries for all of these event listeners:
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/5.4/eventListener.xsd\">\n<import>\n<eventlistener name=\"rename@wcf\\data\\user\\UserAction\">\n<eventclassname>wcf\\data\\user\\UserAction</eventclassname>\n<eventname>rename</eventname>\n<listenerclassname>wcf\\system\\event\\listener\\PersonUserActionRenameListener</listenerclassname>\n<environment>all</environment>\n</eventlistener>\n<eventlistener name=\"save@wcf\\acp\\form\\UserMergeForm\">\n<eventclassname>wcf\\acp\\form\\UserMergeForm</eventclassname>\n<eventname>save</eventname>\n<listenerclassname>wcf\\system\\event\\listener\\PersonUserMergeListener</listenerclassname>\n<environment>admin</environment>\n</eventlistener>\n<eventlistener name=\"execute@wcf\\system\\cronjob\\PruneIpAddressesCronjob\">\n<eventclassname>wcf\\system\\cronjob\\PruneIpAddressesCronjob</eventclassname>\n<eventname>execute</eventname>\n<listenerclassname>wcf\\system\\event\\listener\\PersonPruneIpAddressesCronjobListener</listenerclassname>\n<environment>all</environment>\n</eventlistener>\n<eventlistener name=\"export@wcf\\acp\\action\\UserExportGdprAction\">\n<eventclassname>wcf\\acp\\action\\UserExportGdprAction</eventclassname>\n<eventname>export</eventname>\n<listenerclassname>wcf\\system\\event\\listener\\PersonUserExportGdprListener</listenerclassname>\n<environment>admin</environment>\n</eventlistener>\n</import>\n</data>\n
"},{"location":"tutorial/series/part_6/","title":"Part 6: Activity Points and Activity Events","text":"In this part of our tutorial series, we use the person information added in the previous part to award activity points to users adding new pieces of information and to also create activity events for these pieces of information.
"},{"location":"tutorial/series/part_6/#package-functionality","title":"Package Functionality","text":"In addition to the existing functions from part 5, the package will provide the following functionality after this part of the tutorial:
In addition to the components used in previous parts, we will use the user activity points API and the user activity events API.
"},{"location":"tutorial/series/part_6/#package-structure","title":"Package Structure","text":"The package will have the following file structure excluding unchanged files from previous parts:
\u251c\u2500\u2500 files\n\u2502 \u2514\u2500\u2500 lib\n\u2502 \u251c\u2500\u2500 data\n\u2502 \u2502 \u2514\u2500\u2500 person\n\u2502 \u2502 \u251c\u2500\u2500 PersonAction.class.php\n\u2502 \u2502 \u2514\u2500\u2500 information\n\u2502 \u2502 \u251c\u2500\u2500 PersonInformation.class.php\n\u2502 \u2502 \u2514\u2500\u2500 PersonInformationAction.class.php\n\u2502 \u2514\u2500\u2500 system\n\u2502 \u251c\u2500\u2500 user\n\u2502 \u2502 \u2514\u2500\u2500 activity\n\u2502 \u2502 \u2514\u2500\u2500 event\n\u2502 \u2502 \u2514\u2500\u2500 PersonInformationUserActivityEvent.class.php\n\u2502 \u2514\u2500\u2500 worker\n\u2502 \u251c\u2500\u2500 PersonInformationRebuildDataWorker.class.php\n\u2502 \u2514\u2500\u2500 PersonRebuildDataWorker.class.php\n\u251c\u2500\u2500 eventListener.xml\n\u251c\u2500\u2500 language\n\u2502 \u251c\u2500\u2500 de.xml\n\u2502 \u2514\u2500\u2500 en.xml\n\u2514\u2500\u2500 objectType.xml\n
For all changes, please refer to the source code on GitHub.
"},{"location":"tutorial/series/part_6/#user-activity-points","title":"User Activity Points","text":"The first step to support activity points is to register an object type for the com.woltlab.wcf.user.activityPointEvent
object type definition for created person information and specify the default number of points awarded per piece of information:
<type>\n<name>com.woltlab.wcf.people.information</name>\n<definitionname>com.woltlab.wcf.user.activityPointEvent</definitionname>\n<points>2</points>\n</type>\n
Additionally, the phrase wcf.user.activityPoint.objectType.com.woltlab.wcf.people.information
(in general: wcf.user.activityPoint.objectType.{objectType}
) has to be added.
The activity points are awarded when new pieces are created via PersonInformation::create()
using UserActivityPointHandler::fireEvent()
and removed in PersonInformation::create()
via UserActivityPointHandler::removeEvents()
if pieces of information are deleted.
Lastly, we have to add two components for updating data: First, we register a new rebuild data worker
objectType.xml<type>\n<name>com.woltlab.wcf.people.information</name>\n<definitionname>com.woltlab.wcf.rebuildData</definitionname>\n<classname>wcf\\system\\worker\\PersonInformationRebuildDataWorker</classname>\n</type>\n
files/lib/system/worker/PersonInformationRebuildDataWorker.class.php <?php\n\nnamespace wcf\\system\\worker;\n\nuse wcf\\data\\person\\information\\PersonInformationList;\nuse wcf\\system\\user\\activity\\point\\UserActivityPointHandler;\n\n/**\n * Worker implementation for updating person information.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2022 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\System\\Worker\n *\n * @method PersonInformationList getObjectList()\n */\nfinal class PersonInformationRebuildDataWorker extends AbstractRebuildDataWorker\n{\n /**\n * @inheritDoc\n */\n protected $objectListClassName = PersonInformationList::class;\n\n /**\n * @inheritDoc\n */\n protected $limit = 500;\n\n /**\n * @inheritDoc\n */\n protected function initObjectList()\n {\n parent::initObjectList();\n\n $this->objectList->sqlOrderBy = 'person_information.personID';\n }\n\n /**\n * @inheritDoc\n */\n public function execute()\n {\n parent::execute();\n\n if (!$this->loopCount) {\n UserActivityPointHandler::getInstance()->reset('com.woltlab.wcf.people.information');\n }\n\n if (!\\count($this->objectList)) {\n return;\n }\n\n $itemsToUser = [];\n foreach ($this->getObjectList() as $personInformation) {\n if ($personInformation->userID) {\n if (!isset($itemsToUser[$personInformation->userID])) {\n $itemsToUser[$personInformation->userID] = 0;\n }\n\n $itemsToUser[$personInformation->userID]++;\n }\n }\n\n UserActivityPointHandler::getInstance()->fireEvents(\n 'com.woltlab.wcf.people.information',\n $itemsToUser,\n false\n );\n }\n}\n
which updates the number of instances for which any user received person information activity points. (This data worker also requires the phrases wcf.acp.rebuildData.com.woltlab.wcf.people.information
and wcf.acp.rebuildData.com.woltlab.wcf.people.information.description
).
Second, we add an event listener for UserActivityPointItemsRebuildDataWorker
to update the total user activity points awarded for person information:
<eventlistener name=\"execute@wcf\\system\\worker\\UserActivityPointItemsRebuildDataWorker\">\n<eventclassname>wcf\\system\\worker\\UserActivityPointItemsRebuildDataWorker</eventclassname>\n<eventname>execute</eventname>\n<listenerclassname>wcf\\system\\event\\listener\\PersonUserActivityPointItemsRebuildDataWorkerListener</listenerclassname>\n<environment>admin</environment>\n</eventlistener>\n
files/lib/system/event/listener/PersonUserActivityPointItemsRebuildDataWorkerListener.class.php <?php\n\nnamespace wcf\\system\\event\\listener;\n\nuse wcf\\system\\database\\util\\PreparedStatementConditionBuilder;\nuse wcf\\system\\user\\activity\\point\\UserActivityPointHandler;\nuse wcf\\system\\WCF;\nuse wcf\\system\\worker\\UserActivityPointItemsRebuildDataWorker;\n\n/**\n * Updates the user activity point items counter for person information.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2022 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\System\\Event\\Listener\n */\nfinal class PersonUserActivityPointItemsRebuildDataWorkerListener extends AbstractEventListener\n{\n protected function onExecute(UserActivityPointItemsRebuildDataWorker $worker): void\n {\n $objectType = UserActivityPointHandler::getInstance()\n ->getObjectTypeByName('com.woltlab.wcf.people.information');\n\n $conditionBuilder = new PreparedStatementConditionBuilder();\n $conditionBuilder->add('user_activity_point.objectTypeID = ?', [$objectType->objectTypeID]);\n $conditionBuilder->add('user_activity_point.userID IN (?)', [$worker->getObjectList()->getObjectIDs()]);\n\n $sql = \"UPDATE wcf1_user_activity_point user_activity_point\n SET user_activity_point.items = (\n SELECT COUNT(*)\n FROM wcf1_person_information person_information\n WHERE person_information.userID = user_activity_point.userID\n ),\n user_activity_point.activityPoints = user_activity_point.items * ?\n{$conditionBuilder}\";\n $statement = WCF::getDB()->prepare($sql);\n $statement->execute([\n $objectType->points,\n ...$conditionBuilder->getParameters()\n ]);\n }\n}\n
"},{"location":"tutorial/series/part_6/#user-activity-events","title":"User Activity Events","text":"To support user activity events, an object type for com.woltlab.wcf.user.recentActivityEvent
has to be registered with a class implementing wcf\\system\\user\\activity\\event\\IUserActivityEvent
:
<type>\n<name>com.woltlab.wcf.people.information</name>\n<definitionname>com.woltlab.wcf.user.recentActivityEvent</definitionname>\n<classname>wcf\\system\\user\\activity\\event\\PersonInformationUserActivityEvent</classname>\n</type>\n
files/lib/system/user/activity/event/PersonInformationUserActivityEvent.class.php <?php\n\nnamespace wcf\\system\\user\\activity\\event;\n\nuse wcf\\data\\person\\information\\PersonInformationList;\nuse wcf\\system\\SingletonFactory;\nuse wcf\\system\\WCF;\n\n/**\n * User activity event implementation for person information.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2022 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\System\\User\\Activity\\Event\n */\nfinal class PersonInformationUserActivityEvent extends SingletonFactory implements IUserActivityEvent\n{\n /**\n * @inheritDoc\n */\n public function prepare(array $events)\n {\n $objectIDs = \\array_column($events, 'objectID');\n\n $informationList = new PersonInformationList();\n $informationList->setObjectIDs($objectIDs);\n $informationList->readObjects();\n $information = $informationList->getObjects();\n\n foreach ($events as $event) {\n if (isset($information[$event->objectID])) {\n $personInformation = $information[$event->objectID];\n\n $event->setIsAccessible();\n $event->setTitle(\n WCF::getLanguage()->getDynamicVariable(\n 'wcf.user.profile.recentActivity.personInformation',\n [\n 'person' => $personInformation->getPerson(),\n 'personInformation' => $personInformation,\n ]\n )\n );\n $event->setDescription($personInformation->getFormattedExcerpt());\n }\n }\n }\n}\n
PersonInformationUserActivityEvent::prepare()
must check for all events whether the associated piece of information still exists and if it is the case, mark the event as accessible via the setIsAccessible()
method, set the title of the activity event via setTitle()
, and set a description of the event via setDescription()
for which we use the newly added PersonInformation::getFormattedExcerpt()
method.
Lastly, we have to add the phrase wcf.user.recentActivity.com.woltlab.wcf.people.information
, which is shown in the list of activity events as the type of activity event.
SCSS is a scripting language that features a syntax similar to CSS and compiles into native CSS at runtime. It provides many great additions to CSS such as declaration nesting and variables, it is recommended to read the official guide to learn more.
You can create .scss
files containing only pure CSS code and it will work just fine, you are at no point required to write actual SCSS code.
Please place your style files in a subdirectory of the style/
directory of the target application or the Core's style directory, for example style/layout/pageHeader.scss
.
You can access variables with $myVariable
, variable interpolation (variables inside strings) is accomplished with #{$myVariable}
.
Images used within a style must be located in the style's image folder. To get the folder name within the CSS the SCSS variable #{$style_image_path}
can be used. The value will contain a trailing slash.
Media breakpoints instruct the browser to apply different CSS depending on the viewport dimensions, e.g. serving a desktop PC a different view than when viewed on a smartphone.
/* red background color for desktop pc */\n@include screen-lg {\nbody {\nbackground-color: red;\n}\n}\n\n/* green background color on smartphones and tablets */\n@include screen-md-down {\nbody {\nbackground-color: green;\n}\n}\n
"},{"location":"view/css/#available-breakpoints","title":"Available Breakpoints","text":"Some very large smartphones, for example the Apple iPhone 7 Plus, do match the media query for Tablets (portrait)
when viewed in landscape mode.
@media
equivalent screen-xs
Smartphones only (max-width: 544px)
screen-sm
Tablets (portrait) (min-width: 545px) and (max-width: 768px)
screen-sm-down
Tablets (portrait) and smartphones (max-width: 768px)
screen-sm-up
Tablets and desktop PC (min-width: 545px)
screen-sm-md
Tablets only (min-width: 545px) and (max-width: 1024px)
screen-md
Tablets (landscape) (min-width: 769px) and (max-width: 1024px)
screen-md-down
Smartphones and tablets (max-width: 1024px)
screen-md-up
Tablets (landscape) and desktop PC (min-width: 769px)
screen-lg
Desktop PC (min-width: 1025px)
screen-lg-only
Desktop PC (min-width: 1025px) and (max-width: 1280px)
screen-lg-down
Smartphones, tablets, and desktop PC (max-width: 1280px)
screen-xl
Desktop PC (min-width: 1281px)
"},{"location":"view/css/#asset-preloading","title":"Asset Preloading","text":"WoltLab Suite\u2019s SCSS compiler supports adding preloading metadata to the CSS. To communicate the preloading intent to the compiler, the --woltlab-suite-preload
CSS variable is set to the result of the preload()
function:
.fooBar {\n--woltlab-suite-preload: #{preload(\n'#{$style_image_path}custom/background.png',\n$as: \"image\",\n$crossorigin: false,\n$type: \"image/png\"\n)};\n\nbackground: url('#{$style_image_path}custom/background.png');\n}\n
The parameters of the preload()
function map directly to the preloading properties that are used within the <link>
tag and the link:
HTTP response header.
The above example will result in a <link>
similar to the following being added to the generated HTML:
<link rel=\"preload\" href=\"https://example.com/images/style-1/custom/background.png\" as=\"image\" type=\"image/png\">\n
Use preloading sparingly for the most important resources where you can be certain that the browser will need them. Unused preloaded resources will unnecessarily waste bandwidth.
"},{"location":"view/languages-naming-conventions/","title":"Language Naming Conventions","text":"This page contains general rules for naming language items and for their values. API-specific rules are listed on the relevant API page:
If you have an application foo
and a database object foo\\data\\bar\\Bar
with a property baz
that can be set via a form field, the name of the corresponding language item has to be foo.bar.baz
. If you want to add an additional description below the field, use the language item foo.bar.baz.description
.
If an error of type {error type}
for the previously mentioned form field occurs during validation, you have to use the language item foo.bar.baz.error.{error type}
for the language item describing the error.
Exception to this rule: There are several general error messages like wcf.global.form.error.empty
that have to be used for general errors like an empty field that may not be empty to avoid duplication of the same error message text over and over again in different language items.
invalid
as error type.notUnique
as error type.If the language item for an action is foo.bar.action
, the language item for the confirmation message has to be foo.bar.action.confirmMessage
instead of foo.bar.action.sure
which is still used by some older language items.
{if LANGUAGE_USE_INFORMAL_VARIANT}Willst du{else}Wollen Sie{/if} {element type} wirklich l\u00f6schen?\n
Example:
{if LANGUAGE_USE_INFORMAL_VARIANT}Willst du{else}Wollen Sie{/if} das Icon wirklich l\u00f6schen?\n
"},{"location":"view/languages-naming-conventions/#english","title":"English","text":"Do you really want delete the {element type}?\n
Example:
Do you really want delete the icon?\n
"},{"location":"view/languages-naming-conventions/#object-specific-deletion-confirmation-message","title":"Object-Specific Deletion Confirmation Message","text":""},{"location":"view/languages-naming-conventions/#german_1","title":"German","text":"{if LANGUAGE_USE_INFORMAL_VARIANT}Willst du{else}Wollen Sie{/if} {element type} <span class=\"confirmationObject\">{object name}</span> wirklich l\u00f6schen?\n
Example:
{if LANGUAGE_USE_INFORMAL_VARIANT}Willst du{else}Wollen Sie{/if} den Artikel <span class=\"confirmationObject\">{$article->getTitle()}</span> wirklich l\u00f6schen?\n
"},{"location":"view/languages-naming-conventions/#english_1","title":"English","text":"Do you really want to delete the {element type} <span class=\"confirmationObject\">{object name}</span>?\n
Example:
Do you really want to delete the article <span class=\"confirmationObject\">{$article->getTitle()}</span>?\n
"},{"location":"view/languages-naming-conventions/#user-group-options","title":"User Group Options","text":""},{"location":"view/languages-naming-conventions/#comments","title":"Comments","text":""},{"location":"view/languages-naming-conventions/#german_2","title":"German","text":"group type action example permission name language item user adding user.foo.canAddComment
Kann Kommentare erstellen
user deleting user.foo.canDeleteComment
Kann eigene Kommentare l\u00f6schen
user editing user.foo.canEditComment
Kann eigene Kommentare bearbeiten
moderator deleting mod.foo.canDeleteComment
Kann Kommentare l\u00f6schen
moderator editing mod.foo.canEditComment
Kann Kommentare bearbeiten
moderator moderating mod.foo.canModerateComment
Kann Kommentare moderieren
"},{"location":"view/languages-naming-conventions/#english_2","title":"English","text":"group type action example permission name language item user adding user.foo.canAddComment
Can create comments
user deleting user.foo.canDeleteComment
Can delete their comments
user editing user.foo.canEditComment
Can edit their comments
moderator deleting mod.foo.canDeleteComment
Can delete comments
moderator editing mod.foo.canEditComment
Can edit comments
moderator moderating mod.foo.canModerateComment
Can moderate comments
"},{"location":"view/languages/","title":"Languages","text":"WoltLab Suite offers full i18n support with its integrated language system, including but not limited to dynamic phrases using template scripting and the built-in support for right-to-left languages.
Phrases are deployed using the language package installation plugin, please also read the naming conventions for language items.
"},{"location":"view/languages/#special-phrases","title":"Special Phrases","text":""},{"location":"view/languages/#wcfdatedateformat","title":"wcf.date.dateFormat
","text":"Many characters in the format have a special meaning and will be replaced with date fragments. If you want to include a literal character, you'll have to use the backslash \\
as an escape sequence to indicate that the character should be output as-is rather than being replaced. For example, Y-m-d
will be output as 2018-03-30
, but \\Y-m-d
will result in Y-03-30
.
Defaults to M jS Y
.
The date format without time using PHP's format characters for the date()
function. This value is also used inside the JavaScript implementation, where the characters are mapped to an equivalent representation.
wcf.date.timeFormat
","text":"Defaults to g:i a
.
The date format that is used to represent a time, but not a date. Please see the explanation on wcf.date.dateFormat
to learn more about the format characters.
wcf.date.firstDayOfTheWeek
","text":"Defaults to 0
.
Sets the first day of the week: * 0
- Sunday * 1
- Monday
wcf.global.pageDirection
- RTL support","text":"Defaults to ltr
.
Changing this value to rtl
will reverse the page direction and enable the right-to-left support for phrases. Additionally, a special version of the stylesheet is loaded that contains all necessary adjustments for the reverse direction.
{anchor}
","text":"The anchor
template plugin creates a
HTML elements. The easiest way to use the template plugin is to pass it an instance of ITitledLinkObject
:
{anchor object=$object}\n
generates the same output as
<a href=\"{$object->getLink()}\">{$object->getTitle()}</a>\n
Instead of an object
parameter, a link
and content
parameter can be used:
{anchor link=$linkObject content=$content}\n
where $linkObject
implements ILinkableObject
and $content
is either an object implementing ITitledObject
or having a __toString()
method or $content
is a string or a number.
The last special attribute is append
whose contents are appended to the href
attribute of the generated anchor element.
All of the other attributes matching ~^[a-z]+([A-z]+)+$~
, expect for href
which is disallowed, are added as attributes to the anchor element.
If an object
attribute is present, the object also implements IPopoverObject
and if the return value of IPopoverObject::getPopoverLinkClass()
is included in the class
attribute of the anchor
tag, data-object-id
is automatically added. This functionality makes it easy to generate links with popover support. Instead of
<a href=\"{$entry->getLink()}\" class=\"blogEntryLink\" data-object-id=\"{$entry->entryID}\">{$entry->subject}</a>\n
using
{anchor object=$entry class='blogEntryLink'}\n
is sufficient if Entry::getPopoverLinkClass()
returns blogEntryLink
.
{anchorAttributes}
","text":"anchorAttributes
compliments the StringUtil::getAnchorTagAttributes(string, bool): string
method. It allows to easily generate the necessary attributes for an anchor tag based off the destination URL.
<a href=\"https://www.example.com\" {anchorAttributes url='https://www.example.com' appendHref=false appendClassname=true isUgc=true}>\n
Attribute Description url
destination URL appendHref
whether the href
attribute should be generated; true
by default isUgc
whether the rel=\"ugc\"
attribute should be generated; false
by default appendClassname
whether the class=\"externalURL\"
attribute should be generated; true
by default"},{"location":"view/template-plugins/#append","title":"{append}
","text":"If a string should be appended to the value of a variable, append
can be used:
{assign var=templateVariable value='newValue'}\n\n{$templateVariable} {* prints 'newValue *}\n\n{append var=templateVariable value='2'}\n\n{$templateVariable} {* now prints 'newValue2 *}\n
If the variables does not exist yet, append
creates a new one with the given value. If append
is used on an array as the variable, the value is appended to all elements of the array.
{assign}
","text":"New template variables can be declared and new values can be assigned to existing template variables using assign
:
{assign var=templateVariable value='newValue'}\n\n{$templateVariable} {* prints 'newValue *}\n
"},{"location":"view/template-plugins/#capture","title":"{capture}
","text":"In some situations, assign
is not sufficient to assign values to variables in templates if the value is complex. Instead, capture
can be used:
{capture var=templateVariable}\n{if $foo}\n <p>{$bar}</p>\n{else}\n <small>{$baz}</small>\n{/if}\n{/capture}\n
"},{"location":"view/template-plugins/#concat","title":"|concat
","text":"concat
is a modifier used to concatenate multiple strings:
{assign var=foo value='foo'}\n\n{assign var=templateVariable value='bar'|concat:$foo}\n\n{$templateVariable} {* prints 'foobar *}\n
"},{"location":"view/template-plugins/#counter","title":"{counter}
","text":"counter
can be used to generate and optionally print a counter:
{counter name=fooCounter print=true} {* prints '1' *}\n\n{counter name=fooCounter print=true} {* prints '2' now *}\n\n{counter name=fooCounter} {* prints nothing, but counter value is '3' now internally *}\n\n{counter name=fooCounter print=true} {* prints '4' *}\n
Counter supports the following attributes:
Attribute Descriptionassign
optional name of the template variable the current counter value is assigned to direction
counting direction, either up
or down
; up
by default name
name of the counter, relevant if multiple counters are used simultaneously print
if true
, the current counter value is printed; false
by default skip
positive counting increment; 1
by default start
start counter value; 1
by default"},{"location":"view/template-plugins/#54-csrftoken","title":"5.4+ csrfToken
","text":"{csrfToken}
prints out the session's CSRF token (\u201cSecurity Token\u201d).
<form action=\"{link controller=\"Foo\"}{/link}\" method=\"post\">\n{* snip *}\n\n{csrfToken}\n</form>\n
The {csrfToken}
template plugin supports a type
parameter. Specifying this parameter might be required in rare situations. Please check the implementation for details.
|currency
","text":"currency
is a modifier used to format currency values with two decimals using language dependent thousands separators and decimal point:
{assign var=currencyValue value=12.345}\n\n{$currencyValue|currency} {* prints '12.34' *}\n
"},{"location":"view/template-plugins/#cycle","title":"{cycle}
","text":"cycle
can be used to cycle between different values:
{cycle name=fooCycle values='bar,baz'} {* prints 'bar' *}\n\n{cycle name=fooCycle} {* prints 'baz' *}\n\n{cycle name=fooCycle advance=false} {* prints 'baz' again *}\n\n{cycle name=fooCycle} {* prints 'bar' *}\n
The values attribute only has to be present for the first call. If cycle
is used in a loop, the presence of the same values in consecutive calls has no effect. Only once the values change, the cycle is reset.
advance
if true
, the current cycle value is advanced to the next value; true
by default assign
optional name of the template variable the current cycle value is assigned to; if used, print
is set to false
delimiter
delimiter between the different cycle values; ,
by default name
name of the cycle, relevant if multiple cycles are used simultaneously print
if true
, the current cycle value is printed, false
by default reset
if true
, the current cycle value is set to the first value, false
by default values
string containing the different cycles values, also see delimiter
"},{"location":"view/template-plugins/#date","title":"|date
","text":"date
generated a formatted date using wcf\\util\\DateUtil::format()
with DateUtil::DATE_FORMAT
internally.
{$timestamp|date}\n
"},{"location":"view/template-plugins/#dateinterval","title":"{dateInterval}
","text":"dateInterval
calculates the difference between two unix timestamps and generated a textual date interval.
{dateInterval start=$startTimestamp end=$endTimestamp full=true format='sentence'}\n
Attribute Description end
end of the time interval; current timestamp by default (though either start
or end
has to be set) format
output format, either default
, sentence
, or plain
; defaults to default
, see wcf\\util\\DateUtil::FORMAT_*
constants full
if true
, full difference in minutes is shown; if false
, only the longest time interval is shown; false
by default start
start of the time interval; current timestamp by default (though either start
or end
has to be set)"},{"location":"view/template-plugins/#encodejs","title":"|encodeJS
","text":"encodeJS
encodes a string to be used as a single-quoted string in JavaScript by replacing \\\\
with \\\\\\\\
, '
with \\'
, linebreaks with \\n
, and /
with \\/
.
<script>\n var foo = '{@$foo|encodeJS}';\n</script>\n
"},{"location":"view/template-plugins/#escapecdata","title":"|escapeCDATA
","text":"escapeCDATA
encodes a string to be used in a CDATA
element by replacing ]]>
with ]]]]><![CDATA[>
.
<![CDATA[{@$foo|encodeCDATA}]]>\n
"},{"location":"view/template-plugins/#event","title":"{event}
","text":"event
provides extension points in templates that template listeners can use.
{event name='foo'}\n
"},{"location":"view/template-plugins/#filesizebinary","title":"|filesizeBinary
","text":"filesizeBinary
formats the filesize using binary filesize (in bytes).
{$filesize|filesizeBinary}\n
"},{"location":"view/template-plugins/#filesize","title":"|filesize
","text":"filesize
formats the filesize using filesize (in bytes).
{$filesize|filesize}\n
"},{"location":"view/template-plugins/#hascontent","title":"{hascontent}
","text":"In many cases, conditional statements can be used to determine if a certain section of a template is shown:
{if $foo === 'bar'}\n only shown if $foo is bar\n{/if}\n
In some situations, however, such conditional statements are not sufficient. One prominent example is a template event:
{if $foo === 'bar'}\n <ul>\n{if $foo === 'bar'}\n <li>Bar</li>\n{/if}\n\n{event name='listItems'}\n </li>\n{/if}\n
In this example, if $foo !== 'bar'
, the list will not be shown, regardless of the additional template code provided by template listeners. In such a situation, hascontent
has to be used:
{hascontent}\n <ul>\n{content}\n{if $foo === 'bar'}\n <li>Bar</li>\n{/if}\n\n{event name='listItems'}\n{/content}\n </ul>\n{/hascontent}\n
If the part of the template wrapped in the content
tags has any (trimmed) content, the part of the template wrapped by hascontent
tags is shown (including the part wrapped by the content
tags), otherwise nothing is shown. Thus, this construct avoids an empty list compared to the if
solution above.
Like foreach
, hascontent
also supports an else
part:
{hascontent}\n <ul>\n{content}\n{* \u2026 *}\n{/content}\n </ul>\n{hascontentelse}\n no list\n{/hascontent}\n
"},{"location":"view/template-plugins/#htmlcheckboxes","title":"{htmlCheckboxes}
","text":"htmlCheckboxes
generates a list of HTML checkboxes.
{htmlCheckboxes name=foo options=$fooOptions selected=$currentFoo}\n\n{htmlCheckboxes name=bar output=$barLabels values=$barValues selected=$currentBar}\n
Attribute Description disabled
if true
, all checkboxes are disabled disableEncoding
if true
, the values are not passed through wcf\\util\\StringUtil::encodeHTML()
; false
by default name
name
attribute of the input
checkbox element output
array used as keys and values for options
if present; not present by default options
array selectable options with the key used as value
attribute and the value as the checkbox label selected
current selected value(s) separator
separator between the different checkboxes in the generated output; empty string by default values
array with values used in combination with output
, where output
is only used as keys for options
"},{"location":"view/template-plugins/#htmloptions","title":"{htmlOptions}
","text":"htmlOptions
generates an select
HTML element.
{htmlOptions name='foo' options=$options selected=$selected}\n\n<select name=\"bar\">\n <option value=\"\"{if !$selected} selected{/if}>{lang}foo.bar.default{/lang}</option>\n{htmlOptions options=$options selected=$selected} {* no `name` attribute *}\n</select>\n
Attribute Description disableEncoding
if true
, the values are not passed through wcf\\util\\StringUtil::encodeHTML()
; false
by default object
optional instance of wcf\\data\\DatabaseObjectList
that provides the selectable options (overwrites options
attribute internally) name
name
attribute of the select
element; if not present, only the contents of the select
element are printed output
array used as keys and values for options
if present; not present by default values
array with values used in combination with output
, where output
is only used as keys for options
options
array selectable options with the key used as value
attribute and the value as the option label; if a value is an array, an optgroup
is generated with the array key as the optgroup
label selected
current selected value(s) All additional attributes are added as attributes of the select
HTML element.
{implode}
","text":"implodes
transforms an array into a string and prints it.
{implode from=$array key=key item=item glue=\";\"}{$key}: {$value}{/implode}\n
Attribute Description from
array with the imploded values glue
separator between the different array values; ', '
by default item
template variable name where the current array value is stored during the iteration key
optional template variable name where the current array key is stored during the iteration"},{"location":"view/template-plugins/#ipsearch","title":"|ipSearch
","text":"ipSearch
generates a link to search for an IP address.
{\"127.0.0.1\"|ipSearch}\n
"},{"location":"view/template-plugins/#js","title":"{js}
","text":"js
generates script tags based on whether ENABLE_DEBUG_MODE
and VISITOR_USE_TINY_BUILD
are enabled.
{js application='wbb' file='WBB'} {* generates 'http://example.com/js/WBB.js' *}\n\n{js application='wcf' file='WCF.User' bundle='WCF.Combined'}\n{* generates 'http://example.com/wcf/js/WCF.User.js' if ENABLE_DEBUG_MODE=1 *}\n{* generates 'http://example.com/wcf/js/WCF.Combined.min.js' if ENABLE_DEBUG_MODE=0 *}\n\n{js application='wcf' lib='jquery'}\n{* generates 'http://example.com/wcf/js/3rdParty/jquery.js' *}\n\n{js application='wcf' lib='jquery-ui' file='awesomeWidget'}\n{* generates 'http://example.com/wcf/js/3rdParty/jquery-ui/awesomeWidget.js' *}\n\n{js application='wcf' file='WCF.User' bundle='WCF.Combined' hasTiny=true}\n{* generates 'http://example.com/wcf/js/WCF.User.js' if ENABLE_DEBUG_MODE=1 *}\n{* generates 'http://example.com/wcf/js/WCF.Combined.min.js' (ENABLE_DEBUG_MODE=0 *}\n{* generates 'http://example.com/wcf/js/WCF.Combined.tiny.min.js' if ENABLE_DEBUG_MODE=0 and VISITOR_USE_TINY_BUILD=1 *}\n
"},{"location":"view/template-plugins/#jslang","title":"{jslang}
","text":"jslang
works like lang
with the difference that the resulting string is automatically passed through encodeJS
.
require(['Language', /* \u2026 */], function(Language, /* \u2026 */) {\n Language.addObject({\n 'app.foo.bar': '{jslang}app.foo.bar{/jslang}',\n });\n\n // \u2026\n});\n
"},{"location":"view/template-plugins/#55-json","title":"5.5+ |json
","text":"json
JSON-encodes the given value.
<script>\nlet data = { \"title\": {@$foo->getTitle()|json} };\n</script>\n
"},{"location":"view/template-plugins/#60-jsphrase","title":"6.0+ {jsphrase}
","text":"jsphrase
generates the necessary JavaScript code to register a phrase in the JavaScript language store. This plugin only supports static phrase names. If a dynamic phrase should be registered, the jslang
plugin needs to be used.
<script data-relocate=\"true\">\n{jsphrase name='app.foo.bar'}\n\n// \u2026\n</script>\n
"},{"location":"view/template-plugins/#lang","title":"{lang}
","text":"lang
replaces a language items with its value.
{lang}foo.bar.baz{/lang}\n\n{lang __literal=true}foo.bar.baz{/lang}\n\n{lang foo='baz'}foo.bar.baz{/lang}\n\n{lang}foo.bar.baz.{$action}{/lang}\n
Attribute Description __encode
if true
, the output will be passed through StringUtil::encodeHTML()
__literal
if true
, template variables will not resolved but printed as they are in the language item; false
by default __optional
if true
and the language item does not exist, an empty string is printed; false
by default All additional attributes are available when parsing the language item.
"},{"location":"view/template-plugins/#language","title":"|language
","text":"language
replaces a language items with its value. If the template variable __language
exists, this language object will be used instead of WCF::getLanguage()
. This modifier is useful when assigning the value directly to a variable.
Note that template scripting is applied to the output of the variable, which can lead to unwanted side effects. Use phrase
instead if you don't want to use template scripting.
{$languageItem|language}\n\n{assign var=foo value=$languageItem|language}\n
"},{"location":"view/template-plugins/#link","title":"{link}
","text":"link
generates internal links using LinkHandler
.
<a href=\"{link controller='FooList' application='bar'}param1=2¶m2=A{/link}\">Foo</a>\n
Attribute Description application
abbreviation of the application the controller belongs to; wcf
by default controller
name of the controller; if not present, the landing page is linked in the frontend and the index page in the ACP encode
if true
, the generated link is passed through wcf\\util\\StringUtil::encodeHTML()
; true
by default isEmail
sets encode=false
and forces links to link to the frontend Additional attributes are passed to LinkHandler::getLink()
.
|newlineToBreak
","text":"newlineToBreak
transforms newlines into HTML <br>
elements after encoding the content via wcf\\util\\StringUtil::encodeHTML()
.
{$foo|newlineToBreak}\n
"},{"location":"view/template-plugins/#54-objectaction","title":"5.4+ objectAction
","text":"objectAction
generates action buttons to be used in combination with the WoltLabSuite/Core/Ui/Object/Action
API. For detailed information on its usage, we refer to the extensive documentation in the ObjectActionFunctionTemplatePlugin
class itself.
{page}
","text":"page
generates an internal link to a CMS page.
{page}com.woltlab.wcf.CookiePolicy{/page}\n\n{page pageID=1}{/page}\n\n{page language='de'}com.woltlab.wcf.CookiePolicy{/page}\n\n{page languageID=2}com.woltlab.wcf.CookiePolicy{/page}\n
Attribute Description pageID
unique id of the page (cannot be used together with a page identifier as value) languageID
id of the page language (cannot be used together with language
) language
language code of the page language (cannot be used together with languageID
)"},{"location":"view/template-plugins/#pages","title":"{pages}
","text":"This template plugin has been deprecated in WoltLab Suite 6.0.
pages
generates a pagination.
{pages controller='FooList' link=\"pageNo=%d\" print=true assign=pagesLinks} {* prints pagination *}\n\n{@$pagesLinks} {* prints same pagination again *}\n
Attribute Description assign
optional name of the template variable the pagination is assigned to controller
controller name of the generated links link
additional link parameter where %d
will be replaced with the relevant page number pages
maximum number of of pages; by default, the template variable $pages
is used print
if false
and assign=true
, the pagination is not printed application
, id
, object
, title
additional parameters passed to LinkHandler::getLink()
to generate page links"},{"location":"view/template-plugins/#55-phrase","title":"5.5+ |phrase
","text":"phrase
replaces a language items with its value. If the template variable __language
exists, this language object will be used instead of WCF::getLanguage()
. This modifier is useful when assigning the value directly to a variable.
phrase
should be used instead of language
unless you want to explicitly allow template scripting on a variable's output.
{$languageItem|phrase}\n\n{assign var=foo value=$languageItem|phrase}\n
"},{"location":"view/template-plugins/#plaintime","title":"|plainTime
","text":"plainTime
formats a timestamp to include year, month, day, hour, and minutes. The exact formatting depends on the current language (via the language items wcf.date.dateTimeFormat
, wcf.date.dateFormat
, and wcf.date.timeFormat
).
{$timestamp|plainTime}\n
"},{"location":"view/template-plugins/#plural","title":"{plural}
","text":"plural
allows to easily select the correct plural form of a phrase based on a given value
. The pluralization logic follows the Unicode Language Plural Rules for cardinal numbers.
The #
placeholder within the resulting phrase is replaced by the value
. It is automatically formatted using StringUtil::formatNumeric
.
English:
Note the use of 1
if the number (#
) is not used within the phrase and the use of one
otherwise. They are equivalent for English, but following this rule generalizes better to other languages, helping the translator.
{assign var=numberOfWorlds value=2}\n<h1>Hello {plural value=$numberOfWorlds 1='World' other='Worlds'}!</h1>\n<p>There {plural value=$numberOfWorlds 1='is one world' other='are # worlds'}!</p>\n<p>There {plural value=$numberOfWorlds one='is # world' other='are # worlds'}!</p>\n
German:
{assign var=numberOfWorlds value=2}\n<h1>Hallo {plural value=$numberOfWorlds 1='Welt' other='Welten'}!</h1>\n<p>Es gibt {plural value=$numberOfWorlds 1='eine Welt' other='# Welten'}!</p>\n<p>Es gibt {plural value=$numberOfWorlds one='# Welt' other='# Welten'}!</p>\n
Romanian:
Note the additional use of few
which is not required in English or German.
{assign var=numberOfWorlds value=2}\n<h1>Salut {plural value=$numberOfWorlds 1='lume' other='lumi'}!</h1>\n<p>Exist\u0103 {plural value=$numberOfWorlds 1='o lume' few='# lumi' other='# de lumi'}!</p>\n<p>Exist\u0103 {plural value=$numberOfWorlds one='# lume' few='# lumi' other='# de lumi'}!</p>\n
Russian:
Note the difference between 1
(exactly 1
) and one
(ending in 1
, except ending in 11
).
{assign var=numberOfWorlds value=2}\n<h1>\u041f\u0440\u0438\u0432\u0435\u0442 {plural value=$numberOfWorld 1='\u043c\u0438\u0440' other='\u043c\u0438\u0440\u044b'}!</h1>\n<p>\u0415\u0441\u0442\u044c {plural value=$numberOfWorlds 1='\u043c\u0438\u0440' one='# \u043c\u0438\u0440' few='# \u043c\u0438\u0440\u0430' many='# \u043c\u0438\u0440\u043e\u0432' other='# \u043c\u0438\u0440\u043e\u0432'}!</p>\n
Attribute Description value The value that is used to select the proper phrase. other The phrase that is used when no other selector matches. Any Category Name The phrase that is used when value
belongs to the named category. Available categories depend on the language. Any Integer The phrase that is used when value
is that exact integer."},{"location":"view/template-plugins/#prepend","title":"{prepend}
","text":"If a string should be prepended to the value of a variable, prepend
can be used:
{assign var=templateVariable value='newValue'}\n\n{$templateVariable} {* prints 'newValue *}\n\n{prepend var=templateVariable value='2'}\n\n{$templateVariable} {* now prints '2newValue' *}\n
If the variables does not exist yet, prepend
creates a new one with the given value. If prepend
is used on an array as the variable, the value is prepended to all elements of the array.
|shortUnit
","text":"shortUnit
shortens numbers larger than 1000 by using unit suffixes:
{10000|shortUnit} {* prints 10k *}\n{5400000|shortUnit} {* prints 5.4M *}\n
"},{"location":"view/template-plugins/#tablewordwrap","title":"|tableWordwrap
","text":"tableWordwrap
inserts zero width spaces every 30 characters in words longer than 30 characters.
{$foo|tableWordwrap}\n
"},{"location":"view/template-plugins/#time","title":"|time
","text":"time
generates an HTML time
elements based on a timestamp that shows a relative time or the absolute time if the timestamp more than six days ago.
{$timestamp|time} {* prints a '<time>' element *}\n
"},{"location":"view/template-plugins/#truncate","title":"|truncate
","text":"truncate
truncates a long string into a shorter one:
{$foo|truncate:35}\n\n{$foo|truncate:35:'_':true}\n
Parameter Number Description 0 truncated string 1 truncated length; 80
by default 2 ellipsis symbol; wcf\\util\\StringUtil::HELLIP
by default 3 if true
, words can be broken up in the middle; false
by default"},{"location":"view/template-plugins/#user","title":"{user}
","text":"user
generates links to user profiles. The mandatory object
parameter requires an instances of UserProfile
. The optional type
parameter is responsible for what the generated link contains:
type='default'
(also applies if no type
is given) outputs the formatted username relying on the \u201cUser Marking\u201d setting of the relevant user group. Additionally, the user popover card will be shown when hovering over the generated link.type='plain'
outputs the username without additional formatting.type='avatar(\\d+)'
outputs the user\u2019s avatar in the specified size, i.e., avatar48
outputs the avatar with a width and height of 48 pixels.The last special attribute is append
whose contents are appended to the href
attribute of the generated anchor element.
All of the other attributes matching ~^[a-z]+([A-z]+)+$~
, except for href
which may not be added, are added as attributes to the anchor element.
Examples:
{user object=$user}\n
generates
<a href=\"{$user->getLink()}\" data-object-id=\"{$user->userID}\" class=\"userLink\">{@$user->getFormattedUsername()}</a>\n
and
{user object=$user type='avatar48' foo='bar'}\n
generates
<a href=\"{$user->getLink()}\" foo=\"bar\">{@$object->getAvatar()->getImageTag(48)}</a>\n
"},{"location":"view/templates/","title":"Templates","text":"Templates are responsible for the output a user sees when requesting a page (while the PHP code is responsible for providing the data that will be shown). Templates are text files with .tpl
as the file extension. WoltLab Suite Core compiles the template files once into a PHP file that is executed when a user requests the page. In subsequent request, as the PHP file containing the compiled template already exists, compiling the template is not necessary anymore.
WoltLab Suite Core supports two types of templates: frontend templates (or simply templates) and backend templates (ACP templates). Each type of template is only available in its respective domain, thus frontend templates cannot be included or used in the ACP and vice versa.
For pages and forms, the name of the template matches the unqualified name of the PHP class except for the Page
or Form
suffix:
RegisterForm.class.php
\u2192 register.tpl
UserPage.class.php
\u2192 user.tpl
If you follow this convention, WoltLab Suite Core will automatically determine the template name so that you do not have to explicitly set it.
For forms that handle creating and editing objects, in general, there are two form classes: FooAddForm
and FooEditForm
. WoltLab Suite Core, however, generally only uses one template fooAdd.tpl
and the template variable $action
to distinguish between creating a new object ($action = 'add'
) and editing an existing object ($action = 'edit'
) as the differences between templates for adding and editing an object are minimal.
Templates and ACP templates are installed by two different package installation plugins: the template PIP and the ACP template PIP. More information about installing templates can be found on those pages.
"},{"location":"view/templates/#base-templates","title":"Base Templates","text":""},{"location":"view/templates/#frontend","title":"Frontend","text":"{include file='header'}\n\n{* content *}\n\n{include file='footer'}\n
"},{"location":"view/templates/#backend","title":"Backend","text":"{include file='header' pageTitle='foo.bar.baz'}\n\n<header class=\"contentHeader\">\n <div class=\"contentHeaderTitle\">\n <h1 class=\"contentTitle\">Title</h1>\n </div>\n\n <nav class=\"contentHeaderNavigation\">\n <ul>\n{* your default content header navigation buttons *}\n\n{event name='contentHeaderNavigation'}\n </ul>\n </nav>\n</header>\n\n{* content *}\n\n{include file='footer'}\n
foo.bar.baz
is the language item that contains the title of the page.
For new forms, use the new form builder API introduced with WoltLab Suite 5.2.
<form method=\"post\" action=\"{link controller='FooBar'}{/link}\">\n <div class=\"section\">\n <dl{if $errorField == 'baz'} class=\"formError\"{/if}>\n <dt><label for=\"baz\">{lang}foo.bar.baz{/lang}</label></dt>\n <dd>\n <input type=\"text\" id=\"baz\" name=\"baz\" value=\"{$baz}\" class=\"long\" required autofocus>\n{if $errorField == 'baz'}\n <small class=\"innerError\">\n{if $errorType == 'empty'}\n{lang}wcf.global.form.error.empty{/lang}\n{else}\n{lang}foo.bar.baz.error.{@$errorType}{/lang}\n{/if}\n </small>\n{/if}\n </dd>\n </dl>\n\n <dl>\n <dt><label for=\"bar\">{lang}foo.bar.bar{/lang}</label></dt>\n <dd>\n <textarea name=\"bar\" id=\"bar\" cols=\"40\" rows=\"10\">{$bar}</textarea>\n{if $errorField == 'bar'}\n <small class=\"innerError\">{lang}foo.bar.bar.error.{@$errorType}{/lang}</small>\n{/if}\n </dd>\n </dl>\n\n{* other fields *}\n\n{event name='dataFields'}\n </div>\n\n{* other sections *}\n\n{event name='sections'}\n\n <div class=\"formSubmit\">\n <input type=\"submit\" value=\"{lang}wcf.global.button.submit{/lang}\" accesskey=\"s\">\n{csrfToken}\n </div>\n</form>\n
"},{"location":"view/templates/#tab-menus","title":"Tab Menus","text":"<div class=\"section tabMenuContainer\">\n <nav class=\"tabMenu\">\n <ul>\n <li><a href=\"#tab1\">Tab 1</a></li>\n <li><a href=\"#tab2\">Tab 2</a></li>\n\n{event name='tabMenuTabs'}\n </ul>\n </nav>\n\n <div id=\"tab1\" class=\"tabMenuContent\">\n <div class=\"section\">\n{* contents of first tab *}\n </div>\n </div>\n\n <div id=\"tab2\" class=\"tabMenuContainer tabMenuContent\">\n <nav class=\"menu\">\n <ul>\n <li><a href=\"#tab2A\">Tab 2A</a></li>\n <li><a href=\"#tab2B\">Tab 2B</a></li>\n\n{event name='tabMenuTab2Subtabs'}\n </ul>\n </nav>\n\n <div id=\"tab2A\" class=\"tabMenuContent\">\n <div class=\"section\">\n{* contents of first subtab for second tab *}\n </div>\n </div>\n\n <div id=\"tab2B\" class=\"tabMenuContent\">\n <div class=\"section\">\n{* contents of second subtab for second tab *}\n </div>\n </div>\n\n{event name='tabMenuTab2Contents'}\n </div>\n\n{event name='tabMenuContents'}\n</div>\n
"},{"location":"view/templates/#template-scripting","title":"Template Scripting","text":""},{"location":"view/templates/#template-variables","title":"Template Variables","text":"Template variables can be assigned via WCF::getTPL()->assign('foo', 'bar')
and accessed in templates via $foo
:
{$foo}
will result in the contents of $foo
to be passed to StringUtil::encodeHTML()
before being printed.{#$foo}
will result in the contents of $foo
to be passed to StringUtil::formatNumeric()
before being printed. Thus, this method is relevant when printing numbers and having them formatted correctly according the the user\u2019s language.{@$foo}
will result in the contents of $foo
to be printed directly. In general, this method should not be used for user-generated input.Multiple template variables can be assigned by passing an array:
WCF::getTPL()->assign([\n 'foo' => 'bar',\n 'baz' => false \n]);\n
"},{"location":"view/templates/#modifiers","title":"Modifiers","text":"If you want to call a function on a variable, you can use the modifier syntax: {@$foo|trim}
, for example, results in the trimmed contents of $foo
to be printed.
$__wcf
contains the WCF
object (or WCFACP
object in the backend).Comments are wrapped in {*
and *}
and can span multiple lines:
{* some\n comment *}\n
The template compiler discards the comments, so that they not included in the compiled template.
"},{"location":"view/templates/#conditions","title":"Conditions","text":"Conditions follow a similar syntax to PHP code:
{if $foo === 'bar'}\n foo is bar\n{elseif $foo === 'baz'}\n foo is baz\n{else}\n foo is neither bar nor baz\n{/if}\n
The supported operators in conditions are ===
, !==
, ==
, !=
, <=
, <
, >=
, >
, ||
, &&
, !
, and =
.
More examples:
{if $bar|isset}\u2026{/if}\n\n{if $bar|count > 3 && $bar|count < 100}\u2026{/if}\n
"},{"location":"view/templates/#foreach-loops","title":"Foreach Loops","text":"Foreach loops allow to iterate over arrays or iterable objects:
<ul>\n{foreach from=$array key=key item=value}\n <li>{$key}: {$value}</li>\n{/foreach}\n</ul>\n
While the from
attribute containing the iterated structure and the item
attribute containg the current value are mandatory, the key
attribute is optional. If the foreach loop has a name assigned to it via the name
attribute, the $tpl
template variable provides additional data about the loop:
<ul>\n{foreach from=$array key=key item=value name=foo}\n{if $tpl[foreach][foo][first]}\n something special for the first iteration\n{elseif $tpl[foreach][foo][last]}\n something special for the last iteration\n{/if}\n\n <li>iteration {#$tpl[foreach][foo][iteration]+1} out of {#$tpl[foreach][foo][total]} {$key}: {$value}</li>\n{/foreach}\n</ul>\n
In contrast to PHP\u2019s foreach loop, templates also support foreachelse
:
{foreach from=$array item=value}\n \u2026\n{foreachelse}\n there is nothing to iterate over\n{/foreach}\n
"},{"location":"view/templates/#including-other-templates","title":"Including Other Templates","text":"To include template named foo
from the same domain (frontend/backend), you can use
{include file='foo'}\n
If the template belongs to an application, you have to specify that application using the application
attribute:
{include file='foo' application='app'}\n
Additional template variables can be passed to the included template as additional attributes:
{include file='foo' application='app' var1='foo1' var2='foo2'}\n
"},{"location":"view/templates/#template-plugins","title":"Template Plugins","text":"An overview of all available template plugins can be found here.
"}]} \ No newline at end of file +{"config":{"lang":["en"],"separator":"[\\s\\-]+","pipeline":["stopWordFilter"]},"docs":[{"location":"","title":"WoltLab Suite 6.0 Documentation","text":""},{"location":"#introduction","title":"Introduction","text":"This documentation explains the basic API functionality and the creation of own packages. It is expected that you are somewhat experienced with PHP, object-oriented programming and MySQL.
Head over to the quick start tutorial to learn more.
"},{"location":"#about-woltlab-suite","title":"About WoltLab Suite","text":"WoltLab Suite Core as well as most of the other packages are available on GitHub and are licensed under the terms of the GNU Lesser General Public License 2.1.
"},{"location":"getting-started/","title":"Creating a simple package","text":""},{"location":"getting-started/#setup-and-requirements","title":"Setup and Requirements","text":"This guide will help you to create a simple package that provides a simple test page. It is nothing too fancy, but you can use it as the foundation for your next project.
There are some requirements you should met before starting:
*.php
and *.tpl
should be encoded with ANSI/ASCII*.xml
are always encoded with UTF-8, but omit the BOM (byte-order-mark)8
spaces, this is used in the entire software and will ease reading the source files*.tar
archives, e.g. 7-Zip on WindowsWe want to create a simple page that will display the sentence \"Hello World\" embedded into the application frame. Create an empty directory in the workspace of your choice to start with.
Create a new file called package.xml
and insert the code below:
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<package xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/package.xsd\" name=\"com.example.test\">\n<packageinformation>\n<!-- com.example.test -->\n<packagename>Simple Package</packagename>\n<packagedescription>A simple package to demonstrate the package system of WoltLab Suite Core</packagedescription>\n<version>1.0.0</version>\n<date>2019-04-28</date>\n</packageinformation>\n<authorinformation>\n<author>Your Name</author>\n<authorurl>http://www.example.com</authorurl>\n</authorinformation>\n<excludedpackages>\n<excludedpackage version=\"6.0.0 Alpha 1\">com.woltlab.wcf</excludedpackage>\n</excludedpackages>\n<instructions type=\"install\">\n<instruction type=\"file\" />\n<instruction type=\"template\" />\n<instruction type=\"page\" />\n</instructions>\n</package>\n
There is an entire chapter on the package system that explains what the code above does and how you can adjust it to fit your needs. For now we'll keep it as it is.
"},{"location":"getting-started/#the-php-class","title":"The PHP Class","text":"The next step is to create the PHP class which will serve our page:
files
in the same directory where package.xml
is locatedfiles
and create the directory lib
lib
and create the directory page
page
, please create the file TestPage.class.php
Copy and paste the following code into the TestPage.class.php
:
<?php\nnamespace wcf\\page;\nuse wcf\\system\\WCF;\n\n/**\n * A simple test page for demonstration purposes.\n *\n * @author YOUR NAME\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n */\nclass TestPage extends AbstractPage {\n /**\n * @var string\n */\n protected $greet = '';\n\n /**\n * @inheritDoc\n */\n public function readParameters() {\n parent::readParameters();\n\n if (isset($_GET['greet'])) $this->greet = $_GET['greet'];\n }\n\n /**\n * @inheritDoc\n */\n public function readData() {\n parent::readData();\n\n if (empty($this->greet)) {\n $this->greet = 'World';\n }\n }\n\n /**\n * @inheritDoc\n */\n public function assignVariables() {\n parent::assignVariables();\n\n WCF::getTPL()->assign([\n 'greet' => $this->greet\n ]);\n }\n}\n
The class inherits from wcf\\page\\AbstractPage, the default implementation of pages without form controls. It defines quite a few methods that will be automatically invoked in a specific order, for example readParameters()
before readData()
and finally assignVariables()
to pass arbitrary values to the template.
The property $greet
is defined as World
, but can optionally be populated through a GET variable (index.php?test/&greet=You
would output Hello You!
). This extra code illustrates the separation of data processing that takes place within all sort of pages, where all user-supplied data is read from within a single method. It helps organizing the code, but most of all it enforces a clean class logic that does not start reading user input at random places, including the risk to only escape the input of variable $_GET['foo']
4 out of 5 times.
Reading and processing the data is only half the story, now we need a template to display the actual content for our page. You don't need to specify it yourself, it will be automatically guessed based on your namespace and class name, you can read more about it later.
Last but not least, you must not include the closing PHP tag ?>
at the end, it can cause PHP to break on whitespaces and is not required at all.
Navigate back to the root directory of your package until you see both the files
directory and the package.xml
. Now create a directory called templates
, open it and create the file test.tpl
.
{include file='header'}\n\n<div class=\"section\">\n Hello {$greet}!\n</div>\n\n{include file='footer'}\n
Templates are a mixture of HTML and Smarty-like template scripting to overcome the static nature of raw HTML. The above code will display the phrase Hello World!
in the application frame, just as any other page would render. The included templates header
and footer
are responsible for the majority of the overall page functionality, but offer a whole lot of customization abilities to influence their behavior and appearance.
The package now contains the PHP class and the matching template, but it is still missing the page definition. Please create the file page.xml
in your project's root directory, thus on the same level as the package.xml
.
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/page.xsd\">\n<import>\n<page identifier=\"com.example.test.Test\">\n<controller>wcf\\page\\TestPage</controller>\n<name language=\"en\">Test Page</name>\n<pageType>system</pageType>\n</page>\n</import>\n</data>\n
You can provide a lot more data for a page, including logical nesting and dedicated handler classes for display in menus.
"},{"location":"getting-started/#building-the-package","title":"Building the Package","text":"If you have followed the above guidelines carefully, your package directory should now look like this:
\u251c\u2500\u2500 files\n\u2502 \u2514\u2500\u2500 lib\n\u2502 \u2514\u2500\u2500 page\n\u2502 \u2514\u2500\u2500 TestPage.class.php\n\u251c\u2500\u2500 package.xml\n\u251c\u2500\u2500 page.xml\n\u2514\u2500\u2500 templates\n \u2514\u2500\u2500 test.tpl\n
Both files and templates are archive-based package components, that deploy their payload using tar archives rather than adding the raw files to the package file. Please create the archive files.tar
and add the contents of the files/*
directory, but not the directory files/
itself. Repeat the same process for the templates
directory, but this time with the file name templates.tar
. Place both files in the root of your project.
Last but not least, create the package archive com.example.test.tar
and add all the files listed below.
files.tar
package.xml
page.xml
templates.tar
The archive's filename can be anything you want, all though it is the general convention to use the package name itself for easier recognition.
"},{"location":"getting-started/#installation","title":"Installation","text":"Open the Administration Control Panel and navigate to Configuration > Packages > Install Package
, click on Upload Package
and select the file com.example.test.tar
from your disk. Follow the on-screen instructions until it has been successfully installed.
Open a new browser tab and navigate to your newly created page. If WoltLab Suite is installed at https://example.com/wsc/
, then the URL should read https://example.com/wsc/index.php?test/
.
Congratulations, you have just created your first package!
"},{"location":"getting-started/#developer-tools","title":"Developer Tools","text":"The developer tools provide an interface to synchronize the data of an installed package with a bare repository on the local disk. You can re-import most PIPs at any time and have the changes applied without crafting a manual update. This process simulates a regular package update with a single PIP only, and resets the cache after the import has been completed.
"},{"location":"getting-started/#registering-a-project","title":"Registering a Project","text":"Projects require the absolute path to the package directory, that is, the directory where it can find the package.xml
. It is not required to install an package to register it as a project, but you have to install it in order to work with it. It does not install the package by itself!
There is a special button on the project list that allows for a mass-import of projects based on a search path. Each direct child directory of the provided path will be tested and projects created this way will use the identifier extracted from the package.xml
.
The install instructions in the package.xml
are ignored when offering the PIP imports, the detection works entirely based on the default filename for each PIP. On top of that, only PIPs that implement the interface wcf\\system\\devtools\\pip\\IIdempotentPackageInstallationPlugin
are valid for import, as it indicates that importing the PIP multiple times will have no side-effects and that the result is deterministic regardless of the number of times it has been imported.
Some built-in PIPs, such as sql
or script
, do not qualify for this step and remain unavailable at all times. However, you can still craft and perform an actual package update to have these PIPs executed.
The class name including the namespace is used to automatically determine the path to the template and its name. The example above used the page class name wcf\\page\\TestPage
that is then split into four distinct parts:
wcf
, the internal abbreviation of WoltLab Suite Core (previously known as WoltLab Community Framework)\\page\\
(ignored)Test
, the actual name that is used for both the template and the URLPage
(page type, ignored)The fragments 1.
and 3.
from above are used to construct the path to the template: <installDirOfWSC>/templates/test.tpl
(the first letter of Test
is being converted to lower-case).
This is a list of code snippets that do not fit into any of the other articles and merely describe how to achieve something very specific, rather than explaining the inner workings of a function.
"},{"location":"javascript/code-snippets/#imageviewer","title":"ImageViewer","text":"The ImageViewer is available on all frontend pages by default, you can easily add images to the viewer by wrapping the thumbnails with a link with the CSS class jsImageViewer
that points to the full version.
<a href=\"http://example.com/full.jpg\" class=\"jsImageViewer\">\n <img src=\"http://example.com/thumbnail.jpg\">\n</a>\n
"},{"location":"javascript/components_confirmation/","title":"Confirmation - JavaScript API","text":"The purpose of confirmation dialogs is to prevent misclicks and to inform the user of potential consequences of their action. A confirmation dialog should always ask a concise question that includes a reference to the object the action is performed upon.
You can exclude extra information or form elements in confirmation dialogs, but these should be kept as compact as possible.
"},{"location":"javascript/components_confirmation/#example","title":"Example","text":"const result = await confirmationFactory()\n.custom(\"Do you want a cookie?\")\n.withoutMessage();\nif (result) {\n// User has confirmed the dialog.\n}\n
Confirmation dialogs are a special type that use the role=\"alertdialog\"
attribute and will always include a cancel button. The dialog itself will be limited to a width of 500px, the title can wrap into multiple lines and there will be no \u201cX\u201d button to close the dialog.
Over the past few years the term \u201cConfirmation Fatique\u201d has emerged that describes the issue of having too many confirmation dialogs even there is no real need for them. A confirmation dialog should only be displayed when the action requires further inputs, for example, a soft delete that requires a reason, or when the action is destructive.
"},{"location":"javascript/components_confirmation/#proper-wording","title":"Proper Wording","text":"The confirmation question should hint the severity of the action, in particular whether or not it is destructive. Destructive actions are those that cannot be undone and either cause a permanent mutation or that cause data loss. All questions should be phrased in one or two ways depending on the action.
Destructive action:
Are you sure you want to delete \u201cExample Object\u201d? (German) Wollen Sie \u201eBeispiel-Objekt\u201c wirklich l\u00f6schen?
All other actions:
Do you want to move \u201cExample Object\u201d to the trash bin? (German) M\u00f6chten Sie \u201eBeispiel-Objekt\u201c in den Papierkorb verschieben?
"},{"location":"javascript/components_confirmation/#available-presets","title":"Available Presets","text":"WoltLab Suite 6.0 currently ships with three presets for common confirmation dialogs.
All three presets have an optional parameter for the title of the related object as part of the question asked to the user. It is strongly recommended to provide the title if it exists, otherwise it can be omitted and an indeterminate variant is used instead.
"},{"location":"javascript/components_confirmation/#soft-delete","title":"Soft Delete","text":"Soft deleting objects with an optional input field for a reason:
const askForReason = true;\nconst { result, reason } = await confirmationFactory().softDelete(\ntheObjectName,\naskForReason\n);\nif (result) {\nconsole.log(\n\"The user has requested a soft delete, the following reason was provided:\",\nreason\n);\n}\n
The reason
will always be a string, but with a length of zero if the result
is false
or if no reason was requested. You can simply omit the value if you do not use the reason.
const askForReason = false;\nconst { result } = await confirmationFactory().softDelete(\ntheObjectName,\naskForReason\n);\nif (result) {\nconsole.log(\"The user has requested a soft delete.\");\n}\n
"},{"location":"javascript/components_confirmation/#restore","title":"Restore","text":"Restore a previously soft deleted object:
const result = await confirmationFactory().restore(theObjectName);\nif (result) {\nconsole.log(\"The user has requested to restore the object.\");\n}\n
"},{"location":"javascript/components_confirmation/#delete","title":"Delete","text":"Permanently delete an object, will inform the user that the action cannot be undone:
const result = await confirmationFactory().delete(theObjectName);\nif (result) {\nconsole.log(\"The user has requested to delete the object.\");\n}\n
"},{"location":"javascript/components_dialog/","title":"Dialogs - JavaScript API","text":"Modal dialogs are a powerful tool to draw the viewer\u2019s attention to an important message, question or form. Dialogs naturally interrupt the workflow and prevent the navigation to other sections by making other elements on the page inert.
WoltLab Suite 6.0 ships with four different types of dialogs.
"},{"location":"javascript/components_dialog/#quickstart","title":"Quickstart","text":"There are four different types of dialogs that each fulfill their own specialized role and that provide built-in features to make the development much easier. Please see the following list to make a quick decision of what kind of dialog you need.
Dialogs may contain just an explanation or extra information that should be presented to the viewer without requiring any further interaction. The dialog can be closed via the \u201cX\u201d button or by clicking the modal backdrop.
const dialog = dialogFactory().fromHtml(\"<p>Hello World</p>\").withoutControls();\ndialog.show(\"Greetings from my dialog\");\n
"},{"location":"javascript/components_dialog/#when-to-use","title":"When to Use","text":"The short answer is: Don\u2019t.
Dialogs without controls are an anti-pattern because they only contain content that does not require the modal appearance of a dialog. More often than not dialogs are used for this kind of content because they are easy to use without thinking about better ways to present the content.
If possible these dialogs should be avoided and the content is presented in a more suitable way, for example, as a flyout or by showing content on an existing or new page.
"},{"location":"javascript/components_dialog/#alerts","title":"Alerts","text":"Alerts are designed to inform the user of something important that requires no further action by the user. Typical examples for alerts are error messages or warnings.
An alert will only provide a single button to acknowledge the dialog and must not contain interactive content. The dialog itself will be limited to a width of 500px, the title can wrap into multiple lines and there will be no \u201cX\u201d button to close the dialog.
const dialog = dialogFactory()\n.fromHtml(\"<p>ERROR: Something went wrong!</p>\")\n.asAlert();\ndialog.show(\"Server Error\");\n
You can customize the label of the primary button to better explain what will happen next. This can be useful for alerts that will have a side-effect when closing the dialog, such as redirect to a different page.
const dialog = dialogFactory()\n.fromHtml(\"<p>Something went wrong, we cannot find your shopping cart.</p>\")\n.asAlert({\nprimary: \"Back to the Store Page\",\n});\n\ndialog.addEventListener(\"primary\", () => {\nwindow.location.href = \"https://example.com/shop/\";\n});\n\ndialog.show(\"The shopping cart is missing\");\n
The primary
event is triggered both by clicking on the primary button and by clicks on the modal backdrop.
Alerts are a special type of dialog that use the role=\"alert\"
attribute to signal its importance to assistive tools. Use alerts sparingly when there is no other way to communicate that something did not work as expected.
Alerts should not be used for cases where you expect an error to happen. For example, a form control that expectes an input to fall within a restricted range should show an inline error message instead of raising an alert.
"},{"location":"javascript/components_dialog/#confirmation","title":"Confirmation","text":"Confirmation dialogs are supported through a separate factory function that provides a set of presets as well as a generic API. Please see the separate documentation for confirmation dialogs to learn more.
"},{"location":"javascript/components_dialog/#prompts","title":"Prompts","text":"The most common type of dialogs are prompts that are similar to confirmation dialogs, but without the restrictions and with a regular title. These dialogs can be used universally and provide a submit and cancel button by default.
In addition they offer an \u201cextra\u201d button that is placed to the left of the default buttons are can be used to offer a single additional action. A possible use case for an \u201cextra\u201d button would be a dialog that includes an instance of the WYSIWYG editor, the extra button could be used to trigger a message preview.
"},{"location":"javascript/components_dialog/#code-example","title":"Code Example","text":"<button id=\"showMyDialog\">Show the dialog</button>\n\n<template id=\"myDialog\">\n <dl>\n <dt>\n <label for=\"myInput\">Title</label>\n </dt>\n <dd>\n <input type=\"text\" name=\"myInput\" id=\"myInput\" value=\"\" required />\n </dd>\n </dl>\n</template>\n
document.getElementById(\"showMyDialog\")!.addEventListener(\"click\", () => {\nconst dialog = dialogFactory().fromId(\"myDialog\").asPrompt();\n\ndialog.addEventListener(\"primary\", () => {\nconst myInput = document.getElementById(\"myInput\");\n\nconsole.log(\"Provided title:\", myInput.value.trim());\n});\n});\n
"},{"location":"javascript/components_dialog/#custom-buttons","title":"Custom Buttons","text":"The asPrompt()
call permits some level of customization of the form control buttons.
The primary
option is used to change the default label of the primary button.
dialogFactory()\n.fromId(\"myDialog\")\n.asPrompt({\nprimary: Language.get(\"wcf.dialog.button.primary\"),\n});\n
"},{"location":"javascript/components_dialog/#adding-an-extra-button","title":"Adding an Extra Button","text":"The extra button has no default label, enabling it requires you to provide a readable name.
const dialog = dialogFactory()\n.fromId(\"myDialog\")\n.asPrompt({\nextra: Language.get(\"my.extra.button.name\"),\n});\n\ndialog.addEventListener(\"extra\", () => {\n// The extra button does nothing on its own. If you want\n// to close the button after performing an action you\u2019ll\n// need to call `dialog.close()` yourself.\n});\n
"},{"location":"javascript/components_dialog/#interacting-with-dialogs","title":"Interacting with dialogs","text":"Dialogs are represented by the <woltlab-core-dialog>
element that exposes a set of properties and methods to interact with it.
You can open a dialog through the .show()
method that expects the title of the dialog as the only argument. Check the .open
property to determine if the dialog is currently open.
Programmatically closing a dialog is possibly through .close()
.
All contents of a dialog exists within a child element that can be accessed through the content
property.
// Add some text to the dialog.\nconst p = document.createElement(\"p\");\np.textContent = \"Hello World\";\ndialog.content.append(p);\n\n// Find a text input inside the dialog.\nconst input = dialog.content.querySelector('input[type=\"text\"]');\n
"},{"location":"javascript/components_dialog/#disabling-the-submit-button-of-a-dialog","title":"Disabling the Submit Button of a Dialog","text":"You can prevent the dialog submission until a condition is met, allowing you to dynamically enable or disable the button at will.
dialog.incomplete = false;\n\nconst checkbox = dialog.content.querySelector('input[type=\"checkbox\"]')!;\ncheckbox.addEventListener(\"change\", () => {\n// Block the dialog submission unless the checkbox is checked.\ndialog.incomplete = !checkbox.checked;\n});\n
"},{"location":"javascript/components_dialog/#managing-an-instance-of-a-dialog","title":"Managing an Instance of a Dialog","text":"The old API for dialogs implicitly kept track of the instance by binding it to the this
parameter as seen in calls like UiDialog.open(this);
. The new implementation requires to you to keep track of the dialog on your own.
class MyComponent {\n#dialog?: WoltlabCoreDialogElement;\n\nconstructor() {\nconst button = document.querySelector(\".myButton\") as HTMLButtonElement;\nbutton.addEventListener(\"click\", () => {\nthis.#showGreeting(button.dataset.name);\n});\n}\n\n#showGreeting(name: string | undefined): void {\nconst dialog = this.#getDialog();\n\nconst p = dialog.content.querySelector(\"p\")!;\nif (name === undefined) {\np.textContent = \"Hello World\";\n} else {\np.textContent = `Hello ${name}`;\n}\n\ndialog.show(\"Greetings!\");\n}\n\n#getDialog(): WoltlabCoreDialogElement {\nif (this.#dialog === undefined) {\nthis.#dialog = dialogFactory()\n.fromHtml(\"<p>Hello from MyComponent</p>\")\n.withoutControls();\n}\n\nreturn this.#dialog;\n}\n}\n
"},{"location":"javascript/components_dialog/#event-access","title":"Event Access","text":"You can bind event listeners to specialized events to get notified of events and to modify its behavior.
"},{"location":"javascript/components_dialog/#afterclose","title":"afterClose
","text":"This event cannot be canceled.
Fires when the dialog has closed.
dialog.addEventListener(\"afterClose\", () => {\n// Dialog was closed.\n});\n
"},{"location":"javascript/components_dialog/#close","title":"close
","text":"Fires when the dialog is about to close.
dialog.addEventListener(\"close\", (event) => {\nif (someCondition) {\nevent.preventDefault();\n}\n});\n
"},{"location":"javascript/components_dialog/#cancel","title":"cancel
","text":"Fires only when there is a \u201cCancel\u201d button and the user has either pressed that button or clicked on the modal backdrop. The dialog will close if the event is not canceled.
dialog.addEventListener(\"cancel\", (event) => {\nif (someCondition) {\nevent.preventDefault();\n}\n});\n
"},{"location":"javascript/components_dialog/#extra","title":"extra
","text":"This event cannot be canceled.
Fires when an extra button is present and the button was clicked by the user. This event does nothing on its own and is supported for dialogs of type \u201cPrompt\u201d only.
dialog.addEventListener(\"extra\", () => {\n// The extra button was clicked.\n});\n
"},{"location":"javascript/components_dialog/#primary","title":"primary
","text":"This event cannot be canceled.
Fires only when there is a primary action button and the user has either pressed that button or submitted the form through keyboard controls.
dialog.addEventListener(\"primary\", () => {\n// The primary action button was clicked or the\n// form was submitted through keyboard controls.\n//\n// The `validate` event has completed successfully.\n});\n
"},{"location":"javascript/components_dialog/#validate","title":"validate
","text":"Fires only when there is a form and the user has pressed the primary action button or submitted the form through keyboard controls. Canceling this event is interpreted as a form validation failure.
const input = document.createElement(\"input\");\ndialog.content.append(input);\n\ndialog.addEventListener(\"validate\", (event) => {\nif (input.value.trim() === \"\") {\nevent.preventDefault();\n\n// Display an inline error message.\n}\n});\n
"},{"location":"javascript/components_google_maps/","title":"Google Maps - JavaScript API","text":"The Google Maps component is used to show a map using the Google Maps API.
"},{"location":"javascript/components_google_maps/#example","title":"Example","text":"The component can be included directly as follows:
<woltlab-core-google-maps\n id=\"id\"\n class=\"googleMap\"\n api-key=\"your_api_key\"\n></woltlab-core-google-maps>\n
Alternatively, the component can be included via a template that uses the API key from the configuration and also handles the user content:
{include file='googleMapsElement' googleMapsElementID=\"id\"}\n
"},{"location":"javascript/components_google_maps/#parameters","title":"Parameters","text":""},{"location":"javascript/components_google_maps/#id","title":"id
","text":"ID of the map instance.
"},{"location":"javascript/components_google_maps/#api-key","title":"api-key
","text":"Google Maps API key.
"},{"location":"javascript/components_google_maps/#zoom","title":"zoom
","text":"Defaults to 13
.
Default zoom factor of the map.
"},{"location":"javascript/components_google_maps/#lat","title":"lat
","text":"Defaults to 0
.
Latitude of the default map position.
"},{"location":"javascript/components_google_maps/#lng","title":"lng
","text":"Defaults to 0
.
Longitude of the default map position.
"},{"location":"javascript/components_google_maps/#access-user-location","title":"access-user-location
","text":"If set, the map will try to center based on the user's current position.
"},{"location":"javascript/components_google_maps/#map-related-functions","title":"Map-related Functions","text":""},{"location":"javascript/components_google_maps/#addmarker","title":"addMarker
","text":"Adds a marker to the map.
"},{"location":"javascript/components_google_maps/#example_1","title":"Example","text":"<script data-relocate=\"true\">\nrequire(['WoltLabSuite/Core/Component/GoogleMaps/Marker'], ({ addMarker }) => {\nvoid addMarker(document.getElementById('map_id'), 52.4505, 13.7546, 'Title', true);\n});\n</script>\n
"},{"location":"javascript/components_google_maps/#parameters_1","title":"Parameters","text":""},{"location":"javascript/components_google_maps/#element","title":"element
","text":"<woltlab-core-google-maps>
element.
latitude
","text":"Marker position (latitude)
"},{"location":"javascript/components_google_maps/#longitude","title":"longitude
","text":"Marker position (longitude)
"},{"location":"javascript/components_google_maps/#title","title":"title
","text":"Title of the marker.
"},{"location":"javascript/components_google_maps/#focus","title":"focus
","text":"Defaults to false
.
True, to focus the map on the position of the marker.
"},{"location":"javascript/components_google_maps/#adddraggablemarker","title":"addDraggableMarker
","text":"Adds a draggable marker to the map.
"},{"location":"javascript/components_google_maps/#example_2","title":"Example","text":"<script data-relocate=\"true\">\nrequire(['WoltLabSuite/Core/Component/GoogleMaps/Marker'], ({ addDraggableMarker }) => {\nvoid addDraggableMarker(document.getElementById('map_id'), 52.4505, 13.7546);\n});\n</script>\n
"},{"location":"javascript/components_google_maps/#parameters_2","title":"Parameters","text":""},{"location":"javascript/components_google_maps/#element_1","title":"element
","text":"<woltlab-core-google-maps>
element.
latitude
","text":"Marker position (latitude)
"},{"location":"javascript/components_google_maps/#longitude_1","title":"longitude
","text":"Marker position (longitude)
"},{"location":"javascript/components_google_maps/#geocoding","title":"Geocoding
","text":"Enables the geocoding feature for a map.
"},{"location":"javascript/components_google_maps/#example_3","title":"Example","text":"<input\n type=\"text\"\n data-google-maps-geocoding=\"map_id\"\n data-google-maps-marker\n data-google-maps-geocoding-store=\"prefix\"\n>\n
"},{"location":"javascript/components_google_maps/#parameters_3","title":"Parameters","text":""},{"location":"javascript/components_google_maps/#data-google-maps-geocoding","title":"data-google-maps-geocoding
","text":"ID of the <woltlab-core-google-maps>
element.
data-google-maps-marker
","text":"If set, a movable marker is created that is coupled with the input field.
"},{"location":"javascript/components_google_maps/#data-google-maps-geocoding-store","title":"data-google-maps-geocoding-store
","text":"If set, the coordinates (latitude and longitude) are stored comma-separated in a hidden input field. Optionally, a value can be passed that is used as a prefix for the name of the input field.
"},{"location":"javascript/components_google_maps/#markerloader","title":"MarkerLoader
","text":"Handles a large map with many markers where markers are loaded via AJAX.
"},{"location":"javascript/components_google_maps/#example_4","title":"Example","text":"<script data-relocate=\"true\">\nrequire(['WoltLabSuite/Core/Component/GoogleMaps/MarkerLoader'], ({ setup }) => {\nsetup(document.getElementById('map_id'), 'action_classname', {});\n});\n</script>\n
"},{"location":"javascript/components_google_maps/#parameters_4","title":"Parameters","text":""},{"location":"javascript/components_google_maps/#element_2","title":"element
","text":"<woltlab-core-google-maps>
element.
actionClassName
","text":"Name of the PHP class that is called to retrieve the markers via AJAX.
"},{"location":"javascript/components_google_maps/#additionalparameters","title":"additionalParameters
","text":"Additional parameters that are transmitted when querying the markers via AJAX.
"},{"location":"javascript/components_pagination/","title":"Pagination - JavaScript API","text":"The pagination component is used to expose multiple pages to the end user. This component supports both static URLs and dynamic navigation using DOM events.
"},{"location":"javascript/components_pagination/#example","title":"Example","text":"<woltlab-core-pagination page=\"1\" count=\"10\" url=\"https://www.woltlab.com\"></woltlab-core-pagination>\n
"},{"location":"javascript/components_pagination/#parameters","title":"Parameters","text":""},{"location":"javascript/components_pagination/#page","title":"page
","text":"Defaults to 1
.
The number of the currently displayed page.
"},{"location":"javascript/components_pagination/#count","title":"count
","text":"Defaults to 0
.
Number of available pages. Must be greater than 1
for the pagination to be displayed.
url
","text":"Defaults to an empty string.
If defined, static pagination links are created based on the URL with the pageNo
parameter appended to it. Otherwise only the switchPage
event will be fired if a user clicks on a pagination link.
switchPage
","text":"The switchPage
event will be fired when the user clicks on a pagination link. The event detail will contain the number of the selected page. The event can be canceled to prevent navigation.
jumpToPage
","text":"The switchPage
event will be fired when the user clicks on one of the ellipsis buttons within the pagination.
WoltLab Suite 5.4 introduced support for TypeScript, migrating all existing modules to TypeScript. The JavaScript section of the documentation is not yet updated to account for the changes, possibly explaining concepts that cannot be applied as-is when writing TypeScript. You can learn about basic TypeScript use in WoltLab Suite, such as consuming WoltLab Suite\u2019s types in own packages, within in the TypeScript section.
"},{"location":"javascript/general-usage/#the-history-of-the-legacy-api","title":"The History of the Legacy API","text":"The WoltLab Suite 3.0 introduced a new API based on AMD-Modules with ES5-JavaScript that was designed with high performance and visible dependencies in mind. This was a fundamental change in comparison to the legacy API that was build many years before while jQuery was still a thing and we had to deal with ancient browsers such as Internet Explorer 9 that felt short in both CSS and JavaScript capabilities.
Fast forward a few years, the old API is still around and most important, it is actively being used by some components that have not been rewritten yet. This has been done to preserve the backwards-compatibility and to avoid the significant amount of work that it requires to rewrite a component. The components invoked on page initialization have all been rewritten to use the modern API, but some deferred objects that are invoked later during the page runtime may still use the old API.
However, the legacy API is deprecated and you should not rely on it for new components at all. It slowly but steadily gets replaced up until a point where its last bits are finally removed from the code base.
"},{"location":"javascript/general-usage/#embedding-javascript-inside-templates","title":"Embedding JavaScript inside Templates","text":"The <script>
-tags are extracted and moved during template processing, eventually placing them at the very end of the body element while preserving their order of appearance.
This behavior is controlled through the data-relocate=\"true\"
attribute on the <script>
which is mandatory for almost all scripts, mostly because their dependencies (such as jQuery) are moved to the bottom anyway.
<script data-relocate=\"true\">\n$(function() {\n// Code that uses jQuery (Legacy API)\n});\n</script>\n\n<!-- or -->\n\n<script data-relocate=\"true\">\nrequire([\"Some\", \"Dependencies\"], function(Some, Dependencies) {\n// Modern API\n});\n</script>\n
"},{"location":"javascript/general-usage/#including-external-javascript-files","title":"Including External JavaScript Files","text":"The AMD-Modules used in the new API are automatically recognized and lazy-loaded on demand, so unless you have a rather large and pre-compiled code-base, there is nothing else to worry about.
"},{"location":"javascript/general-usage/#debug-variants-and-cache-buster","title":"Debug-Variants and Cache-Buster","text":"Your JavaScript files may change over time and you would want the users' browsers to always load and use the latest version of your files. This can be achieved by appending the special LAST_UPDATE_TIME
constant to your file path. It contains the unix timestamp of the last time any package was installed, updated or removed and thus avoid outdated caches by relying on a unique value, without invalidating the cache more often that it needs to be.
<script data-relocate=\"true\" src=\"{$__wcf->getPath('app')}js/App.js?t={@LAST_UPDATE_TIME}\"></script>\n
For small scripts you can simply serve the full, non-minified version to the user at all times, the differences in size and execution speed are insignificant and are very unlikely to offer any benefits. They might even yield a worse performance, because you'll have to include them statically in the template, even if the code is never called.
However, if you're including a minified build in your app or plugin, you should include a switch to load the uncompressed version in the debug mode, while serving the minified and optimized file to the average visitor. You should use the ENABLE_DEBUG_MODE
constant to decide which version should be loaded.
<script data-relocate=\"true\" src=\"{$__wcf->getPath('app')}js/App{if !ENABLE_DEBUG_MODE}.min{/if}.js?t={@LAST_UPDATE_TIME}\"></script>\n
"},{"location":"javascript/general-usage/#the-accelerated-guest-view-tiny-builds","title":"The Accelerated Guest View (\"Tiny Builds\")","text":"You can learn more on the Accelerated Guest View in the migration docs.
The \u201cAccelerated Guest View\u201d aims to decrease page size and to improve responsiveness by enabling a read-only mode for visitors. If you are providing a separate compiled build for this mode, you'll need to include yet another switch to serve the right version to the visitor.
<script data-relocate=\"true\" src=\"{$__wcf->getPath('app')}js/App{if !ENABLE_DEBUG_MODE}{if VISITOR_USE_TINY_BUILD}.tiny{/if}.min{/if}.js?t={@LAST_UPDATE_TIME}\"></script>\n
"},{"location":"javascript/general-usage/#the-js-template-plugin","title":"The {js}
Template Plugin","text":"The {js}
template plugin exists solely to provide a much easier and less error-prone method to include external JavaScript files.
{js application='app' file='App' hasTiny=true}\n
The hasTiny
attribute is optional, you can set it to false
or just omit it entirely if you do not provide a tiny build for your file.
The legacy JavaScript API is the original code that was part of the 2.x series of WoltLab Suite, formerly known as WoltLab Community Framework. It has been superseded for the most part by the ES5/AMD-modules API introduced with WoltLab Suite 3.0.
Some parts still exist to this day for backwards-compatibility and because some less important components have not been rewritten yet. The old API is still supported, but marked as deprecated and will continue to be replaced parts by part in future releases, up until their entire removal, including jQuery support.
This guide does not provide any explanation on the usage of those legacy components, but instead serves as a cheat sheet to convert code to use the new API.
"},{"location":"javascript/legacy-api/#classes","title":"Classes","text":""},{"location":"javascript/legacy-api/#singletons","title":"Singletons","text":"Singleton instances are designed to provide a unique \"instance\" of an object regardless of when its first instance was created. Due to the lack of a class
construct in ES5, they are represented by mere objects that act as an instance.
// App.js\nwindow.App = {};\nApp.Foo = {\nbar: function() {}\n};\n\n// --- NEW API ---\n\n// App/Foo.js\ndefine([], function() {\n\"use strict\";\n\nreturn {\nbar: function() {}\n};\n});\n
"},{"location":"javascript/legacy-api/#regular-classes","title":"Regular Classes","text":"// App.js\nwindow.App = {};\nApp.Foo = Class.extend({\nbar: function() {}\n});\n\n// --- NEW API ---\n\n// App/Foo.js\ndefine([], function() {\n\"use strict\";\n\nfunction Foo() {};\nFoo.prototype = {\nbar: function() {}\n};\n\nreturn Foo;\n});\n
"},{"location":"javascript/legacy-api/#inheritance","title":"Inheritance","text":"// App.js\nwindow.App = {};\nApp.Foo = Class.extend({\nbar: function() {}\n});\nApp.Baz = App.Foo.extend({\nmakeSnafucated: function() {}\n});\n\n// --- NEW API ---\n\n// App/Foo.js\ndefine([], function() {\n\"use strict\";\n\nfunction Foo() {};\nFoo.prototype = {\nbar: function() {}\n};\n\nreturn Foo;\n});\n\n// App/Baz.js\ndefine([\"Core\", \"./Foo\"], function(Core, Foo) {\n\"use strict\";\n\nfunction Baz() {};\nCore.inherit(Baz, Foo, {\nmakeSnafucated: function() {}\n});\n\nreturn Baz;\n});\n
"},{"location":"javascript/legacy-api/#ajax-requests","title":"Ajax Requests","text":"// App.js\nApp.Foo = Class.extend({\n_proxy: null,\n\ninit: function() {\nthis._proxy = new WCF.Action.Proxy({\nsuccess: $.proxy(this._success, this)\n});\n},\n\nbar: function() {\nthis._proxy.setOption(\"data\", {\nactionName: \"baz\",\nclassName: \"app\\\\foo\\\\FooAction\",\nobjectIDs: [1, 2, 3],\nparameters: {\nfoo: \"bar\",\nbaz: true\n}\n});\nthis._proxy.sendRequest();\n},\n\n_success: function(data) {\n// ajax request result\n}\n});\n\n// --- NEW API ---\n\n// App/Foo.js\ndefine([\"Ajax\"], function(Ajax) {\n\"use strict\";\n\nfunction Foo() {}\nFoo.prototype = {\nbar: function() {\nAjax.api(this, {\nobjectIDs: [1, 2, 3],\nparameters: {\nfoo: \"bar\",\nbaz: true\n}\n});\n},\n\n// magic method!\n_ajaxSuccess: function(data) {\n// ajax request result\n},\n\n// magic method!\n_ajaxSetup: function() {\nreturn {\nactionName: \"baz\",\nclassName: \"app\\\\foo\\\\FooAction\"\n}\n}\n}\n\nreturn Foo;\n});\n
"},{"location":"javascript/legacy-api/#phrases","title":"Phrases","text":"<script data-relocate=\"true\">\n$(function() {\nWCF.Language.addObject({\n'app.foo.bar': '{lang}app.foo.bar{/lang}'\n});\n\nconsole.log(WCF.Language.get(\"app.foo.bar\"));\n});\n</script>\n\n<!-- NEW API -->\n\n<script data-relocate=\"true\">\nrequire([\"Language\"], function(Language) {\nLanguage.addObject({\n'app.foo.bar': '{jslang}app.foo.bar{/jslang}'\n});\n\nconsole.log(Language.get(\"app.foo.bar\"));\n});\n</script>\n
"},{"location":"javascript/legacy-api/#event-listener","title":"Event-Listener","text":"<script data-relocate=\"true\">\n$(function() {\nWCF.System.Event.addListener(\"app.foo.bar\", \"makeSnafucated\", function(data) {\nconsole.log(\"Event was invoked.\");\n});\n\nWCF.System.Event.fireEvent(\"app.foo.bar\", \"makeSnafucated\", { some: \"data\" });\n});\n</script>\n\n<!-- NEW API -->\n\n<script data-relocate=\"true\">\nrequire([\"EventHandler\"], function(EventHandler) {\nEventHandler.add(\"app.foo.bar\", \"makeSnafucated\", function(data) {\nconsole.log(\"Event was invoked\");\n});\n\nEventHandler.fire(\"app.foo.bar\", \"makeSnafucated\", { some: \"data\" });\n});\n</script>\n
"},{"location":"javascript/new-api_ajax/","title":"Ajax Requests - JavaScript API","text":""},{"location":"javascript/new-api_ajax/#promise-based-api-for-databaseobjectaction","title":"Promise
-based API for DatabaseObjectAction
","text":"WoltLab Suite 5.5 introduces a new API for Ajax requests that uses Promise
s to control the code flow. It does not rely on references to existing objects and does not use arbitrary callbacks to handle the setup and handling of the request.
import * as Ajax from \"./Ajax\";\n\ntype ResponseGetLatestFoo = {\ntemplate: string;\n};\n\nexport class MyModule {\nprivate readonly bar: string;\nprivate readonly objectId: number;\n\nconstructor(objectId: number, bar: string, buttonId: string) {\nthis.bar = bar;\nthis.objectId = objectId;\n\nconst button = document.getElementById(buttonId);\nbutton?.addEventListener(\"click\", (event) => void this.click(event));\n}\n\nasync click(event: MouseEvent): Promise<void> {\nevent.preventDefault();\n\nconst button = event.currentTarget as HTMLElement;\nif (button.classList.contains(\"disabled\")) {\nreturn;\n}\nbutton.classList.add(\"disabled\");\n\ntry {\nconst response = (await Ajax.dboAction(\"getLatestFoo\", \"wcf\\\\data\\\\foo\\\\FooAction\")\n.objectIds([this.objectId])\n.payload({ bar: this.bar })\n.dispatch()) as ResponseGetLatestFoo;\n\ndocument.getElementById(\"latestFoo\")!.innerHTML = response.template;\n} finally {\nbutton.classList.remove(\"disabled\");\n}\n}\n}\n\nexport default MyModule;\n
The actual code to dispatch and evaluate a request is only four lines long and offers full IDE auto completion support. This example uses a finally
block to reset the button class once the request has finished, regardless of the result.
If you do not handle the errors (or chose not to handle some errors), the global rejection handler will take care of this and show an dialog that informs about the failed request. This mimics the behavior of the _ajaxFailure()
callback in the legacy API.
Sometimes new requests are dispatched against the same API before the response from the previous has arrived. This applies to either long running requests or requests that are dispatched in rapid succession, for example, looking up values when the user is actively typing into a search field.
RapidRequests.tsimport * as Ajax from \"./Ajax\";\n\nexport class RapidRequests {\nprivate lastRequest: AbortController | undefined = undefined;\n\nconstructor(inputId: string) {\nconst input = document.getElementById(inputId) as HTMLInputElement;\ninput.addEventListener(\"input\", (event) => void this.input(event));\n}\n\nasync input(event: Event): Promise<void> {\nevent.preventDefault();\n\nconst input = event.currentTarget as HTMLInputElement;\nconst value = input.value.trim();\n\nif (this.lastRequest) {\nthis.lastRequest.abort();\n}\n\nif (value) {\nconst request = Ajax.dboAction(\"getSuggestions\", \"wcf\\\\data\\\\bar\\\\BarAction\").payload({ value });\nthis.lastRequest = request.getAbortController();\n\nconst response = await request.dispatch();\n// Handle the response\n}\n}\n}\n\nexport default RapidRequests;\n
"},{"location":"javascript/new-api_ajax/#ajax-inside-modules-legacy-api","title":"Ajax inside Modules (Legacy API)","text":"The Ajax component was designed to be used from inside modules where an object reference is used to delegate request callbacks. This is acomplished through a set of magic methods that are automatically called when the request is created or its state has changed.
"},{"location":"javascript/new-api_ajax/#_ajaxsetup","title":"_ajaxSetup()
","text":"The lazy initialization is performed upon the first invocation from the callee, using the magic _ajaxSetup()
method to retrieve the basic configuration for this and any future requests.
The data returned by _ajaxSetup()
is cached and the data will be used to pre-populate the request data before sending it. The callee can overwrite any of these properties. It is intended to reduce the overhead when issuing request when these requests share the same properties, such as accessing the same endpoint.
// App/Foo.js\ndefine([\"Ajax\"], function(Ajax) {\n\"use strict\";\n\nfunction Foo() {};\nFoo.prototype = {\none: function() {\n// this will issue an ajax request with the parameter `value` set to `1`\nAjax.api(this);\n},\n\ntwo: function() {\n// this request is almost identical to the one issued with `.one()`, but\n// the value is now set to `2` for this invocation only.\nAjax.api(this, {\nparameters: {\nvalue: 2\n}\n});\n},\n\n_ajaxSetup: function() {\nreturn {\ndata: {\nactionName: \"makeSnafucated\",\nclassName: \"app\\\\data\\\\foo\\\\FooAction\",\nparameters: {\nvalue: 1\n}\n}\n}\n}\n};\n\nreturn Foo;\n});\n
"},{"location":"javascript/new-api_ajax/#request-settings","title":"Request Settings","text":"The object returned by the aforementioned _ajaxSetup()
callback can contain these values:
data
","text":"Defaults to {}
.
A plain JavaScript object that contains the request data that represents the form data of the request. The parameters
key is recognized by the PHP Ajax API and becomes accessible through $this->parameters
.
contentType
","text":"Defaults to application/x-www-form-urlencoded; charset=UTF-8
.
The request content type, sets the Content-Type
HTTP header if it is not empty.
responseType
","text":"Defaults to application/json
.
The server must respond with the Content-Type
HTTP header set to this value, otherwise the request will be treated as failed. Requests for application/json
will have the return body attempted to be evaluated as JSON.
Other content types will only be validated based on the HTTP header, but no additional transformation is performed. For example, setting the responseType
to application/xml
will check the HTTP header, but will not transform the data
parameter, you'll still receive a string in _ajaxSuccess
!
type
","text":"Defaults to POST
.
The HTTP Verb used for this request.
"},{"location":"javascript/new-api_ajax/#url","title":"url
","text":"Defaults to an empty string.
Manual override for the request endpoint, it will be automatically set to the Core API endpoint if left empty. If the Core API endpoint is used, the options includeRequestedWith
and withCredentials
will be force-set to true.
withCredentials
","text":"Enabling this parameter for any domain other than the current will trigger a CORS preflight request.
Defaults to false
.
Include cookies with this requested, is always true when url
is (implicitly) set to the Core API endpoint.
autoAbort
","text":"Defaults to false
.
When set to true
, any pending responses to earlier requests will be silently discarded when issuing a new request. This only makes sense if the new request is meant to completely replace the result of the previous one, regardless of its reponse body.
Typical use-cases include input field with suggestions, where possible values are requested from the server, but the input changed faster than the server was able to reply. In this particular case the client is not interested in the result for an earlier value, auto-aborting these requests avoids implementing this logic in the requesting code.
"},{"location":"javascript/new-api_ajax/#ignoreerror","title":"ignoreError
","text":"Defaults to false
.
Any failing request will invoke the failure
-callback to check if an error message should be displayed. Enabling this option will suppress the general error overlay that reports a failed request.
You can achieve the same result by returning false
in the failure
-callback.
silent
","text":"Defaults to false
.
Enabling this option will suppress the loading indicator overlay for this request, other non-\"silent\" requests will still trigger the loading indicator.
"},{"location":"javascript/new-api_ajax/#includerequestedwith","title":"includeRequestedWith
","text":"Enabling this parameter for any domain other than the current will trigger a CORS preflight request.
Defaults to true
.
Sets the custom HTTP header X-Requested-With: XMLHttpRequest
for the request, it is automatically set to true
when url
is pointing at the WSC API endpoint.
failure
","text":"Defaults to null
.
Optional callback function that will be invoked for requests that have failed for one of these reasons: 1. The request timed out. 2. The HTTP status is not 2xx
or 304
. 3. A responseType
was set, but the response HTTP header Content-Type
did not match the expected value. 4. The responseType
was set to application/json
, but the response body was not valid JSON.
The callback function receives the parameter xhr
(the XMLHttpRequest
object) and options
(deep clone of the request parameters). If the callback returns false
, the general error overlay for failed requests will be suppressed.
There will be no error overlay if ignoreError
is set to true
or if the request failed while attempting to evaluate the response body as JSON.
finalize
","text":"Defaults to null
.
Optional callback function that will be invoked once the request has completed, regardless if it succeeded or failed. The only parameter it receives is options
(the request parameters object), but it does not receive the request's XMLHttpRequest
.
success
","text":"Defaults to null
.
This semi-optional callback function will always be set to _ajaxSuccess()
when invoking Ajax.api()
. It receives four parameters: 1. data
- The request's response body as a string, or a JavaScript object if contentType
was set to application/json
. 2. responseText
- The unmodified response body, it equals the value for data
for non-JSON requests. 3. xhr
- The underlying XMLHttpRequest
object. 4. requestData
- The request parameters that were supplied when the request was issued.
_ajaxSuccess()
","text":"This callback method is automatically called for successful AJAX requests, it receives four parameters, with the first one containing either the response body as a string, or a JavaScript object for JSON requests.
"},{"location":"javascript/new-api_ajax/#_ajaxfailure","title":"_ajaxFailure()
","text":"Optional callback function that is invoked for failed requests, it will be automatically called if the callee implements it, otherwise the global error handler will be executed.
"},{"location":"javascript/new-api_ajax/#single-requests-without-a-module-legacy-api","title":"Single Requests Without a Module (Legacy API)","text":"The Ajax.api()
method expects an object that is used to extract the request configuration as well as providing the callback functions when the request state changes.
You can issue a simple Ajax request without object binding through Ajax.apiOnce()
that will destroy the instance after the request was finalized. This method is significantly more expensive for repeated requests and does not offer deriving modules from altering the behavior. It is strongly recommended to always use Ajax.api()
for requests to the WSC API endpoint.
<script data-relocate=\"true\">\nrequire([\"Ajax\"], function(Ajax) {\nAjax.apiOnce({\ndata: {\nactionName: \"makeSnafucated\",\nclassName: \"app\\\\data\\\\foo\\\\FooAction\",\nparameters: {\nvalue: 3\n}\n},\nsuccess: function(data) {\nelBySel(\".some-element\").textContent = data.bar;\n}\n})\n});\n</script>\n
"},{"location":"javascript/new-api_browser/","title":"Browser and Screen Sizes - JavaScript API","text":""},{"location":"javascript/new-api_browser/#uiscreen","title":"Ui/Screen
","text":"CSS offers powerful media queries that alter the layout depending on the screen sizes, including but not limited to changes between landscape and portrait mode on mobile devices.
The Ui/Screen
module exposes a consistent interface to execute JavaScript code based on the same media queries that are available in the CSS code already. It features support for unmatching and executing code when a rule matches for the first time during the page lifecycle.
You can pass in custom media queries, but it is strongly recommended to use the built-in media queries that match the same dimensions as your CSS.
Alias Media Queryscreen-xs
(max-width: 544px)
screen-sm
(min-width: 545px) and (max-width: 768px)
screen-sm-down
(max-width: 768px)
screen-sm-up
(min-width: 545px)
screen-sm-md
(min-width: 545px) and (max-width: 1024px)
screen-md
(min-width: 769px) and (max-width: 1024px)
screen-md-down
(max-width: 1024px)
screen-md-up
(min-width: 769px)
screen-lg
(min-width: 1025px)
"},{"location":"javascript/new-api_browser/#onquery-string-callbacks-object-string","title":"on(query: string, callbacks: Object): string
","text":"Registers a set of callback functions for the provided media query, the possible keys are match
, unmatch
and setup
. The method returns a randomly generated UUIDv4 that is used to identify these callbacks and allows them to be removed via .remove()
.
remove(query: string, uuid: string)
","text":"Removes all callbacks for a media query that match the UUIDv4 that was previously obtained from the call to .on()
.
is(query: string): boolean
","text":"Tests if the provided media query currently matches and returns true on match.
"},{"location":"javascript/new-api_browser/#scrolldisable","title":"scrollDisable()
","text":"Temporarily prevents the page from being scrolled, until .scrollEnable()
is called.
scrollEnable()
","text":"Enables page scrolling again, unless another pending action has also prevented the page scrolling.
"},{"location":"javascript/new-api_browser/#environment","title":"Environment
","text":"The Environment
module uses a mixture of feature detection and user agent sniffing to determine the browser and platform. In general, its results have proven to be very accurate, but it should be taken with a grain of salt regardless. Especially the browser checks are designed to be your last resort, please use feature detection instead whenever it is possible!
Sometimes it may be necessary to alter the behavior of your code depending on the browser platform (e. g. mobile devices) or based on a specific browser in order to work-around some quirks.
"},{"location":"javascript/new-api_browser/#browser-string","title":"browser(): string
","text":"Attempts to detect browsers based on their technology and supported CSS vendor prefixes, and although somewhat reliable for major browsers, it is highly recommended to use feature detection instead.
Possible values: - chrome
(includes Opera 15+ and Vivaldi) - firefox
- safari
- microsoft
(Internet Explorer and Edge) - other
(default)
platform(): string
","text":"Attempts to detect the browser platform using user agent sniffing.
Possible values: - ios
- android
- windows
(IE Mobile) - mobile
(generic mobile device) - desktop
(default)
A brief overview of common methods that may be useful when writing any module.
"},{"location":"javascript/new-api_core/#core","title":"Core
","text":""},{"location":"javascript/new-api_core/#cloneobject-object-object","title":"clone(object: Object): Object
","text":"Creates a deep-clone of the provided object by value, removing any references on the original element, including arrays. However, this does not clone references to non-plain objects, these instances will be copied by reference.
require([\"Core\"], function(Core) {\nvar obj1 = { a: 1 };\nvar obj2 = Core.clone(obj1);\n\nconsole.log(obj1 === obj2); // output: false\nconsole.log(obj2.hasOwnProperty(\"a\") && obj2.a === 1); // output: true\n});\n
"},{"location":"javascript/new-api_core/#extendbase-object-merge-object-object","title":"extend(base: Object, ...merge: Object[]): Object
","text":"Accepts an infinite amount of plain objects as parameters, values will be copied from the 2nd...nth object into the first object. The first parameter will be cloned and the resulting object is returned.
require([\"Core\"], function(Core) {\nvar obj1 = { a: 2 };\nvar obj2 = { a: 1, b: 2 };\nvar obj = Core.extend({\nb: 1\n}, obj1, obj2);\n\nconsole.log(obj.b === 2); // output: true\nconsole.log(obj.hasOwnProperty(\"a\") && obj.a === 2); // output: false\n});\n
"},{"location":"javascript/new-api_core/#inheritbase-object-target-object-merge-object","title":"inherit(base: Object, target: Object, merge?: Object)
","text":"Derives the second object's prototype from the first object, afterwards the derived class will pass the instanceof
check against the original class.
// App.js\nwindow.App = {};\nApp.Foo = Class.extend({\nbar: function() {}\n});\nApp.Baz = App.Foo.extend({\nmakeSnafucated: function() {}\n});\n\n// --- NEW API ---\n\n// App/Foo.js\ndefine([], function() {\n\"use strict\";\n\nfunction Foo() {};\nFoo.prototype = {\nbar: function() {}\n};\n\nreturn Foo;\n});\n\n// App/Baz.js\ndefine([\"Core\", \"./Foo\"], function(Core, Foo) {\n\"use strict\";\n\nfunction Baz() {};\nCore.inherit(Baz, Foo, {\nmakeSnafucated: function() {}\n});\n\nreturn Baz;\n});\n
"},{"location":"javascript/new-api_core/#isplainobjectobject-object-boolean","title":"isPlainObject(object: Object): boolean
","text":"Verifies if an object is a plain JavaScript object and not an object instance.
require([\"Core\"], function(Core) {\nfunction Foo() {}\nFoo.prototype = {\nhello: \"world\";\n};\n\nvar obj1 = { hello: \"world\" };\nvar obj2 = new Foo();\n\nconsole.log(Core.isPlainObject(obj1)); // output: true\nconsole.log(obj1.hello === obj2.hello); // output: true\nconsole.log(Core.isPlainObject(obj2)); // output: false\n});\n
"},{"location":"javascript/new-api_core/#triggereventelement-element-eventname-string","title":"triggerEvent(element: Element, eventName: string)
","text":"Creates and dispatches a synthetic JavaScript event on an element.
require([\"Core\"], function(Core) {\nvar element = elBySel(\".some-element\");\nCore.triggerEvent(element, \"click\");\n});\n
"},{"location":"javascript/new-api_core/#language","title":"Language
","text":""},{"location":"javascript/new-api_core/#addkey-string-value-string","title":"add(key: string, value: string)
","text":"Registers a new phrase.
<script data-relocate=\"true\">\nrequire([\"Language\"], function(Language) {\nLanguage.add('app.foo.bar', '{jslang}app.foo.bar{/jslang}');\n});\n</script>\n
"},{"location":"javascript/new-api_core/#addobjectobject-object","title":"addObject(object: Object)
","text":"Registers a list of phrases using a plain object.
<script data-relocate=\"true\">\nrequire([\"Language\"], function(Language) {\nLanguage.addObject({\n'app.foo.bar': '{jslang}app.foo.bar{/jslang}'\n});\n});\n</script>\n
"},{"location":"javascript/new-api_core/#getkey-string-parameters-object-string","title":"get(key: string, parameters?: Object): string
","text":"Retrieves a phrase by its key, optionally supporting basic template scripting with dynamic variables passed using the parameters
object.
require([\"Language\"], function(Language) {\nvar title = Language.get(\"app.foo.title\");\nvar content = Language.get(\"app.foo.content\", {\nsome: \"value\"\n});\n});\n
"},{"location":"javascript/new-api_core/#stringutil","title":"StringUtil
","text":""},{"location":"javascript/new-api_core/#escapehtmlstr-string-string","title":"escapeHTML(str: string): string
","text":"Escapes special HTML characters by converting them into an HTML entity.
Character Replacement&
&
\"
"
<
<
>
>
"},{"location":"javascript/new-api_core/#escaperegexpstr-string-string","title":"escapeRegExp(str: string): string
","text":"Escapes a list of characters that have a special meaning in regular expressions and could alter the behavior when embedded into regular expressions.
"},{"location":"javascript/new-api_core/#lcfirststr-string-string","title":"lcfirst(str: string): string
","text":"Makes a string's first character lowercase.
"},{"location":"javascript/new-api_core/#ucfirststr-string-string","title":"ucfirst(str: string): string
","text":"Makes a string's first character uppercase.
"},{"location":"javascript/new-api_core/#unescapehtmlstr-string-string","title":"unescapeHTML(str: string): string
","text":"Converts some HTML entities into their original character. This is the reverse function of escapeHTML()
.
This API has been deprecated in WoltLab Suite 6.0, please refer to the new dialog implementation.
"},{"location":"javascript/new-api_dialogs/#introduction","title":"Introduction","text":"Dialogs are full screen overlays that cover the currently visible window area using a semi-opague backdrop and a prominently placed dialog window in the foreground. They shift the attention away from the original content towards the dialog and usually contain additional details and/or dedicated form inputs.
"},{"location":"javascript/new-api_dialogs/#_dialogsetup","title":"_dialogSetup()
","text":"The lazy initialization is performed upon the first invocation from the callee, using the magic _dialogSetup()
method to retrieve the basic configuration for the dialog construction and any event callbacks.
// App/Foo.js\ndefine([\"Ui/Dialog\"], function(UiDialog) {\n\"use strict\";\n\nfunction Foo() {};\nFoo.prototype = {\nbar: function() {\n// this will open the dialog constructed by _dialogSetup\nUiDialog.open(this);\n},\n\n_dialogSetup: function() {\nreturn {\nid: \"myDialog\",\nsource: \"<p>Hello World!</p>\",\noptions: {\nonClose: function() {\n// the fancy dialog was closed!\n}\n}\n}\n}\n};\n\nreturn Foo;\n});\n
"},{"location":"javascript/new-api_dialogs/#id-string","title":"id: string
","text":"The id
is used to identify a dialog on runtime, but is also part of the first- time setup when the dialog has not been opened before. If source
is undefined
, the module attempts to construct the dialog using an element with the same id.
source: any
","text":"There are six different types of value that source
does allow and each of them changes how the initial dialog is constructed:
undefined
The dialog exists already and the value of id
should be used to identify the element.null
The HTML is provided using the second argument of .open()
.() => void
If the source
is a function, it is executed and is expected to start the dialog initialization itself.Object
Plain objects are interpreted as parameters for an Ajax request, in particular source.data
will be used to issue the request. It is possible to specify the key source.after
as a callback (content: Element, responseData: Object) => void
that is executed after the dialog was opened.string
The string is expected to be plain HTML that should be used to construct the dialog.DocumentFragment
A new container <div>
with the provided id
is created and the contents of the DocumentFragment
is appended to it. This container is then used for the dialog.options: Object
","text":"All configuration options and callbacks are handled through this object.
"},{"location":"javascript/new-api_dialogs/#optionsbackdropcloseonclick-boolean","title":"options.backdropCloseOnClick: boolean
","text":"Defaults to true
.
Clicks on the dialog backdrop will close the top-most dialog. This option will be force-disabled if the option closeable
is set to false
.
options.closable: boolean
","text":"Defaults to true
.
Enables the close button in the dialog title, when disabled the dialog can be closed through the .close()
API call only.
options.closeButtonLabel: string
","text":"Defaults to Language.get(\"wcf.global.button.close\")
.
The phrase that is displayed in the tooltip for the close button.
"},{"location":"javascript/new-api_dialogs/#optionscloseconfirmmessage-string","title":"options.closeConfirmMessage: string
","text":"Defaults to \"\"
.
Shows a confirmation dialog using the configured message before closing the dialog. The dialog will not be closed if the dialog is rejected by the user.
"},{"location":"javascript/new-api_dialogs/#optionstitle-string","title":"options.title: string
","text":"Defaults to \"\"
.
The phrase that is displayed in the dialog title.
"},{"location":"javascript/new-api_dialogs/#optionsonbeforeclose-id-string-void","title":"options.onBeforeClose: (id: string) => void
","text":"Defaults to null
.
The callback is executed when the user clicks on the close button or, if enabled, on the backdrop. The callback is responsible to close the dialog by itself, the default close behavior is automatically prevented.
"},{"location":"javascript/new-api_dialogs/#optionsonclose-id-string-void","title":"options.onClose: (id: string) => void
","text":"Defaults to null
.
The callback is notified once the dialog is about to be closed, but is still visible at this point. It is not possible to abort the close operation at this point.
"},{"location":"javascript/new-api_dialogs/#optionsonshow-content-element-void","title":"options.onShow: (content: Element) => void
","text":"Defaults to null
.
Receives the dialog content element as its only argument, allowing the callback to modify the DOM or to register event listeners before the dialog is presented to the user. The dialog is already visible at call time, but the dialog has not been finalized yet.
"},{"location":"javascript/new-api_dialogs/#settitleid-string-object-title-string","title":"setTitle(id: string | Object, title: string)
","text":"Sets the title of a dialog.
"},{"location":"javascript/new-api_dialogs/#setcallbackid-string-object-key-string-value-data-any-void-null","title":"setCallback(id: string | Object, key: string, value: (data: any) => void | null)
","text":"Sets a callback function after the dialog initialization, the special value null
will remove a previously set callback. Valid values for key
are onBeforeClose
, onClose
and onShow
.
rebuild(id: string | Object)
","text":"Rebuilds a dialog by performing various calculations on the maximum dialog height in regards to the overflow handling and adjustments for embedded forms. This method is automatically invoked whenever a dialog is shown, after invoking the options.onShow
callback.
close(id: string | Object)
","text":"Closes an open dialog, this will neither trigger a confirmation dialog, nor does it invoke the options.onBeforeClose
callback. The options.onClose
callback will always be invoked, but it cannot abort the close operation.
getDialog(id: string | Object): Object
","text":"This method returns an internal data object by reference, any modifications made do have an effect on the dialogs behavior and in particular no validation is performed on the modification. It is strongly recommended to use the .set*()
methods only.
Returns the internal dialog data that is attached to a dialog. The most important key is .content
which holds a reference to the dialog's inner content element.
isOpen(id: string | Object): boolean
","text":"Returns true if the dialog exists and is open.
"},{"location":"javascript/new-api_dom/","title":"Working with the DOM - JavaScript API","text":""},{"location":"javascript/new-api_dom/#domutil","title":"Dom/Util
","text":""},{"location":"javascript/new-api_dom/#createfragmentfromhtmlhtml-string-documentfragment","title":"createFragmentFromHtml(html: string): DocumentFragment
","text":"Parses a HTML string and creates a DocumentFragment
object that holds the resulting nodes.
identify(element: Element): string
","text":"Retrieves the unique identifier (id
) of an element. If it does not currently have an id assigned, a generic identifier is used instead.
outerHeight(element: Element, styles?: CSSStyleDeclaration): number
","text":"Computes the outer height of an element using the element's offsetHeight
and the sum of the rounded down values for margin-top
and margin-bottom
.
outerWidth(element: Element, styles?: CSSStyleDeclaration): number
","text":"Computes the outer width of an element using the element's offsetWidth
and the sum of the rounded down values for margin-left
and margin-right
.
outerDimensions(element: Element): { height: number, width: number }
","text":"Computes the outer dimensions of an element including its margins.
"},{"location":"javascript/new-api_dom/#offsetelement-element-top-number-left-number","title":"offset(element: Element): { top: number, left: number }
","text":"Computes the element's offset relative to the top left corner of the document.
"},{"location":"javascript/new-api_dom/#setinnerhtmlelement-element-innerhtml-string","title":"setInnerHtml(element: Element, innerHtml: string)
","text":"Sets the inner HTML of an element via element.innerHTML = innerHtml
. Browsers do not evaluate any embedded <script>
tags, therefore this method extracts each of them, creates new <script>
tags and inserts them in their original order of appearance.
contains(element: Element, child: Element): boolean
","text":"Evaluates if element
is a direct or indirect parent element of child
.
unwrapChildNodes(element: Element)
","text":"Moves all child nodes out of element
while maintaining their order, then removes element
from the document.
Dom/ChangeListener
","text":"This class is used to observe specific changes to the DOM, for example after an Ajax request has completed. For performance reasons this is a manually-invoked listener that does not rely on a MutationObserver
.
require([\"Dom/ChangeListener\"], function(DomChangeListener) {\nDomChangeListener.add(\"App/Foo\", function() {\n// the DOM may have been altered significantly\n});\n\n// propagate changes to the DOM\nDomChangeListener.trigger();\n});\n
"},{"location":"javascript/new-api_events/","title":"Event Handling - JavaScript API","text":""},{"location":"javascript/new-api_events/#eventkey","title":"EventKey
","text":"This class offers a set of static methods that can be used to determine if some common keys are being pressed. Internally it compares either the .key
property if it is supported or the value of .which
.
require([\"EventKey\"], function(EventKey) {\nelBySel(\".some-input\").addEventListener(\"keydown\", function(event) {\nif (EventKey.Enter(event)) {\n// the `Enter` key was pressed\n}\n});\n});\n
"},{"location":"javascript/new-api_events/#arrowdownevent-keyboardevent-boolean","title":"ArrowDown(event: KeyboardEvent): boolean
","text":"Returns true if the user has pressed the \u2193
key.
ArrowLeft(event: KeyboardEvent): boolean
","text":"Returns true if the user has pressed the \u2190
key.
ArrowRight(event: KeyboardEvent): boolean
","text":"Returns true if the user has pressed the \u2192
key.
ArrowUp(event: KeyboardEvent): boolean
","text":"Returns true if the user has pressed the \u2191
key.
Comma(event: KeyboardEvent): boolean
","text":"Returns true if the user has pressed the ,
key.
Enter(event: KeyboardEvent): boolean
","text":"Returns true if the user has pressed the \u21b2
key.
Escape(event: KeyboardEvent): boolean
","text":"Returns true if the user has pressed the Esc
key.
Tab(event: KeyboardEvent): boolean
","text":"Returns true if the user has pressed the \u21b9
key.
EventHandler
","text":"A synchronous event system based on string identifiers rather than DOM elements, similar to the PHP event system in WoltLab Suite. Any components can listen to events or trigger events itself at any time.
"},{"location":"javascript/new-api_events/#identifiying-events-with-the-developer-tools","title":"Identifiying Events with the Developer Tools","text":"The Developer Tools offer an easy option to identify existing events that are fired while code is being executed. You can enable this watch mode through your browser's console using Devtools.toggleEventLogging()
:
> Devtools.toggleEventLogging();\n< Event logging enabled\n< [Devtools.EventLogging] Firing event: bar @ com.example.app.foo\n< [Devtools.EventLogging] Firing event: baz @ com.example.app.foo\n
"},{"location":"javascript/new-api_events/#addidentifier-string-action-string-callback-data-object-void-string","title":"add(identifier: string, action: string, callback: (data: Object) => void): string
","text":"Adding an event listeners returns a randomly generated UUIDv4 that is used to identify the listener. This UUID is required to remove a specific listener through the remove()
method.
fire(identifier: string, action: string, data?: Object)
","text":"Triggers an event using an optional data
object that is passed to each listener by reference.
remove(identifier: string, action: string, uuid: string)
","text":"Removes a previously registered event listener using the UUID returned by add()
.
removeAll(identifier: string, action: string)
","text":"Removes all event listeners registered for the provided identifier
and action
.
removeAllBySuffix(identifier: string, suffix: string)
","text":"Removes all event listeners for an identifier
whose action ends with the value of suffix
.
Ui/Alignment
","text":"Calculates the alignment of one element relative to another element, with support for boundary constraints, alignment restrictions and additional pointer elements.
"},{"location":"javascript/new-api_ui/#setelement-element-referenceelement-element-options-object","title":"set(element: Element, referenceElement: Element, options: Object)
","text":"Calculates and sets the alignment of the element element
.
verticalOffset: number
","text":"Defaults to 0
.
Creates a gap between the element and the reference element, in pixels.
"},{"location":"javascript/new-api_ui/#pointer-boolean","title":"pointer: boolean
","text":"Defaults to false
.
Sets the position of the pointer element, requires an existing child of the element with the CSS class .elementPointer
.
pointerOffset: number
","text":"Defaults to 4
.
The margin from the left/right edge of the element and is used to avoid the arrow from being placed right at the edge.
Does not apply when aligning the element to the reference elemnent's center.
"},{"location":"javascript/new-api_ui/#pointerclassnames-string","title":"pointerClassNames: string[]
","text":"Defaults to []
.
If your element uses CSS-only pointers, such as using the ::before
or ::after
pseudo selectors, you can specifiy two separate CSS class names that control the alignment:
pointerClassNames[0]
is applied to the element when the pointer is displayed at the bottom.pointerClassNames[1]
is used to align the pointer to the right side of the element.refDimensionsElement: Element
","text":"Defaults to null
.
An alternative element that will be used to determine the position and dimensions of the reference element. This can be useful if you reference element is contained in a wrapper element with alternating dimensions.
"},{"location":"javascript/new-api_ui/#horizontal-string","title":"horizontal: string
","text":"This value is automatically flipped for RTL (right-to-left) languages, left
is changed into right
and vice versa.
Defaults to \"left\"
.
Sets the prefered alignment, accepts either left
or right
. The value left
instructs the module to align the element with the left boundary of the reference element.
The horizontal
alignment is used as the default and a flip only occurs, if there is not enough space in the desired direction. If the element exceeds the boundaries in both directions, the value of horizontal
is used.
vertical: string
","text":"Defaults to \"bottom\"
.
Sets the prefered alignment, accepts either bottom
or top
. The value bottom
instructs the module to align the element below the reference element.
The vertical
alignment is used as the default and a flip only occurs, if there is not enough space in the desired direction. If the element exceeds the boundaries in both directions, the value of vertical
is used.
allowFlip: string
","text":"The value for horizontal
is automatically flipped for RTL (right-to-left) languages, left
is changed into right
and vice versa. This setting only controls the behavior when violating space constraints, therefore the aforementioned transformation is always applied.
Defaults to \"both\"
.
Restricts the automatic alignment flipping if the element exceeds the window boundaries in the instructed direction.
both
- No restrictions.horizontal
- Element can be aligned with the left or the right boundary of the reference element, but the vertical position is fixed.vertical
- Element can be aligned below or above the reference element, but the vertical position is fixed.none
- No flipping can occur, the element will be aligned regardless of any space constraints.Ui/CloseOverlay
","text":"Register elements that should be closed when the user clicks anywhere else, such as drop-down menus or tooltips.
require([\"Ui/CloseOverlay\"], function(UiCloseOverlay) {\nUiCloseOverlay.add(\"App/Foo\", function() {\n// invoked, close something\n});\n});\n
"},{"location":"javascript/new-api_ui/#addidentifier-string-callback-void","title":"add(identifier: string, callback: () => void)
","text":"Adds a callback that will be invoked when the user clicks anywhere else.
"},{"location":"javascript/new-api_ui/#uiconfirmation","title":"Ui/Confirmation
","text":"Prompt the user to make a decision before carrying out an action, such as a safety warning before permanently deleting content.
require([\"Ui/Confirmation\"], function(UiConfirmation) {\nUiConfirmation.show({\nconfirm: function() {\n// the user has confirmed the dialog\n},\nmessage: \"Do you really want to continue?\"\n});\n});\n
"},{"location":"javascript/new-api_ui/#showoptions-object","title":"show(options: Object)
","text":"Displays a dialog overlay with actions buttons to confirm or reject the dialog.
"},{"location":"javascript/new-api_ui/#cancel-parameters-object-void","title":"cancel: (parameters: Object) => void
","text":"Defaults to null
.
Callback that is invoked when the dialog was rejected.
"},{"location":"javascript/new-api_ui/#confirm-parameters-object-void","title":"confirm: (parameters: Object) => void
","text":"Defaults to null
.
Callback that is invoked when the user has confirmed the dialog.
"},{"location":"javascript/new-api_ui/#message-string","title":"message: string
","text":"Defaults to '\"\"'.
Text that is displayed in the content area of the dialog, optionally this can be HTML, but this requires messageIsHtml
to be enabled.
messageIsHtml
","text":"Defaults to false
.
The message
option is interpreted as text-only, setting this option to true
will cause the message
to be evaluated as HTML.
parameters: Object
","text":"Optional list of parameter options that will be passed to the cancel()
and confirm()
callbacks.
template: string
","text":"An optional HTML template that will be inserted into the dialog content area, but after the message
section.
Ui/Notification
","text":"Displays a simple notification at the very top of the window, such as a success message for Ajax based actions.
require([\"Ui/Notification\"], function(UiNotification) {\nUiNotification.show(\n\"Your changes have been saved.\",\nfunction() {\n// this callback will be invoked after 2 seconds\n},\n\"success\"\n);\n});\n
"},{"location":"javascript/new-api_ui/#showmessage-string-callback-void-cssclassname-string","title":"show(message: string, callback?: () => void, cssClassName?: string)
","text":"Shows the notification and executes the callback after 2 seconds.
"},{"location":"javascript/new-api_writing-a-module/","title":"Writing a Module - JavaScript API","text":""},{"location":"javascript/new-api_writing-a-module/#introduction","title":"Introduction","text":"The new JavaScript-API was introduced with WoltLab Suite 3.0 and was a major change in all regards. The previously used API heavily relied on larger JavaScript files that contained a lot of different components with hidden dependencies and suffered from extensive jQuery usage for historic reasons.
Eventually a new API was designed that solves the issues with the legacy API by following a few basic principles: 1. Vanilla ES5-JavaScript. It allows us to achieve the best performance across all platforms, there is simply no reason to use jQuery today and the performance penalty on mobile devices is a real issue. 2. Strict usage of modules. Each component is placed in an own file and all dependencies are explicitly declared and injected at the top.Eventually we settled with AMD-style modules using require.js which offers both lazy loading and \"ahead of time\"-compilatio with r.js
. 3. No jQuery-based components on page init. Nothing is more annoying than loading a page and then wait for JavaScript to modify the page before it becomes usable, forcing the user to sit and wait. Heavily optimized vanilla JavaScript components offered the speed we wanted. 4. Limited backwards-compatibility. The new API should make it easy to update existing components by providing similar interfaces, while still allowing legacy code to run side-by-side for best compatibility and to avoid rewritting everything from the start.
The default location for modules is js/
in the Core's app dir, but every app and plugin can register their own lookup path by providing the path using a template-listener on requirePaths@headIncludeJavaScript
.
For this example we'll assume the file is placed at js/WoltLabSuite/Core/Ui/Foo.js
, the module name is therefore WoltLabSuite/Core/Ui/Foo
, it is automatically derived from the file path and name.
For further instructions on how to define and require modules head over to the RequireJS API.
define([\"Ajax\", \"WoltLabSuite/Core/Ui/Bar\"], function(Ajax, UiBar) {\n\"use strict\";\n\nfunction Foo() { this.init(); }\nFoo.prototype = {\ninit: function() {\nelBySel(\".myButton\").addEventListener(WCF_CLICK_EVENT, this._click.bind(this));\n},\n\n_click: function(event) {\nevent.preventDefault();\n\nif (UiBar.isSnafucated()) {\nAjax.api(this);\n}\n},\n\n_ajaxSuccess: function(data) {\nconsole.log(\"Received response\", data);\n},\n\n_ajaxSetup: function() {\nreturn {\ndata: {\nactionName: \"makeSnafucated\",\nclassName: \"wcf\\\\data\\\\foo\\\\FooAction\"\n}\n};\n}\n}\n\nreturn Foo;\n});\n
"},{"location":"javascript/new-api_writing-a-module/#loading-a-module","title":"Loading a Module","text":"Modules can then be loaded through their derived name:
<script data-relocate=\"true\">\nrequire([\"WoltLabSuite/Core/Ui/Foo\"], function(UiFoo) {\nnew UiFoo();\n});\n</script>\n
"},{"location":"javascript/new-api_writing-a-module/#module-aliases","title":"Module Aliases","text":"Some common modules have short-hand aliases that can be used to include them without writing out their full name. You can still use their original path, but it is strongly recommended to use the aliases for consistency.
Alias Full Path Ajax WoltLabSuite/Core/Ajax AjaxJsonp WoltLabSuite/Core/Ajax/Jsonp AjaxRequest WoltLabSuite/Core/Ajax/Request CallbackList WoltLabSuite/Core/CallbackList ColorUtil WoltLabSuite/Core/ColorUtil Core WoltLabSuite/Core/Core DateUtil WoltLabSuite/Core/Date/Util Devtools WoltLabSuite/Core/Devtools Dom/ChangeListener WoltLabSuite/Core/Dom/Change/Listener Dom/Traverse WoltLabSuite/Core/Dom/Traverse Dom/Util WoltLabSuite/Core/Dom/Util Environment WoltLabSuite/Core/Environment EventHandler WoltLabSuite/Core/Event/Handler EventKey WoltLabSuite/Core/Event/Key Language WoltLabSuite/Core/Language Permission WoltLabSuite/Core/Permission StringUtil WoltLabSuite/Core/StringUtil Ui/Alignment WoltLabSuite/Core/Ui/Alignment Ui/CloseOverlay WoltLabSuite/Core/Ui/CloseOverlay Ui/Confirmation WoltLabSuite/Core/Ui/Confirmation Ui/Dialog WoltLabSuite/Core/Ui/Dialog Ui/Notification WoltLabSuite/Core/Ui/Notification Ui/ReusableDropdown WoltLabSuite/Core/Ui/Dropdown/Reusable Ui/Screen WoltLabSuite/Core/Ui/Screen Ui/Scroll WoltLabSuite/Core/Ui/Scroll Ui/SimpleDropdown WoltLabSuite/Core/Ui/Dropdown/Simple Ui/TabMenu WoltLabSuite/Core/Ui/TabMenu Upload WoltLabSuite/Core/Upload User WoltLabSuite/Core/User"},{"location":"javascript/typescript/","title":"TypeScript","text":""},{"location":"javascript/typescript/#consuming-woltlab-suites-types","title":"Consuming WoltLab Suite\u2019s Types","text":"To consume the types of WoltLab Suite, you will need to install the @woltlab/wcf
npm package using a git URL that refers to the appropriate branch of WoltLab/WCF.
A full package.json
that includes WoltLab Suite, TypeScript, eslint and Prettier could look like the following.
{\n\"devDependencies\": {\n\"@typescript-eslint/eslint-plugin\": \"^5.51.0\",\n\"@typescript-eslint/parser\": \"^5.51.0\",\n\"eslint\": \"^8.33.0\",\n\"eslint-config-prettier\": \"^8.6.0\",\n\"prettier\": \"^2.8.4\",\n\"typescript\": \"^4.9.5\"\n},\n\"dependencies\": {\n\"@woltlab/d.ts\": \"https://github.com/WoltLab/d.ts.git#4040fc083245edeb2f8832d4613b9e76aa9c17a5\"\n}\n}\n
After installing the types using npm, you will also need to configure tsconfig.json
to take the types into account. To do so, you will need to add them to the compilerOptions.paths
option. A complete tsconfig.json
file that matches the configuration of WoltLab Suite could look like the following.
{\n\"include\": [\n\"node_modules/@woltlab/d.ts/global.d.ts\",\n\"ts/**/*\"\n],\n\"compilerOptions\": {\n\"target\": \"ES2022\",\n\"module\": \"amd\",\n\"rootDir\": \"ts/\",\n\"outDir\": \"files/js/\",\n\"lib\": [\n\"DOM\",\n\"DOM.Iterable\",\n\"ES2022\"\n],\n\"strictNullChecks\": true,\n\"moduleResolution\": \"node\",\n\"esModuleInterop\": true,\n\"noImplicitThis\": true,\n\"strictBindCallApply\": true,\n\"baseUrl\": \".\",\n\"paths\": {\n\"*\": [\n\"node_modules/@woltlab/d.ts/*\"\n]\n},\n\"importHelpers\": true,\n\"newLine\": \"lf\"\n}\n}\n
After this initial set-up, you would place your TypeScript source files into the ts/
folder of your project. The generated JavaScript target files will be placed into files/js/
and thus will be installed by the file PIP.
To update the TypeScript types, the commit hash in package.json
needs to be updated to an appropriate commit in the d.ts repository and npm install
needs to be rerun.
WoltLab Suite uses additional tools to ensure the high quality and a consistent code style of the TypeScript modules. The current configuration of these tools is as follows. It is recommended to re-use this configuration as is.
.prettierrctrailingComma: all\nprintWidth: 120\n
.eslintrc.js module.exports = {\nroot: true,\nparser: \"@typescript-eslint/parser\",\nparserOptions: {\ntsconfigRootDir: __dirname,\nproject: [\"./tsconfig.json\"]\n},\nplugins: [\"@typescript-eslint\"],\nextends: [\n\"eslint:recommended\",\n\"plugin:@typescript-eslint/recommended\",\n\"plugin:@typescript-eslint/recommended-requiring-type-checking\",\n\"prettier\"\n],\nrules: {\n\"@typescript-eslint/ban-types\": [\n\"error\", {\ntypes: {\n\"object\": false\n},\nextendDefaults: true\n}\n],\n\"@typescript-eslint/no-explicit-any\": 0,\n\"@typescript-eslint/no-non-null-assertion\": 0,\n\"@typescript-eslint/no-unsafe-assignment\": 0,\n\"@typescript-eslint/no-unsafe-call\": 0,\n\"@typescript-eslint/no-unsafe-member-access\": 0,\n\"@typescript-eslint/no-unsafe-return\": 0,\n\"@typescript-eslint/no-unused-vars\": [\n\"error\", {\n\"argsIgnorePattern\": \"^_\"\n}\n]\n}\n};\n
.eslintignore **/*.js\nvendor/**\n
This .gitattributes
configuration will automatically collapse the generated JavaScript target files in GitHub\u2019s Diff view. You will not need it if you do not use git or GitHub.
files/js/**/*.js linguist-generated\n
"},{"location":"javascript/typescript/#writing-a-simple-module","title":"Writing a simple module","text":"After completing this initial set-up you can start writing your first TypeScript module. The TypeScript compiler can be launched in Watch Mode by running npx tsc -w
.
WoltLab Suite\u2019s modules can be imported using the standard ECMAScript module import syntax by specifying the full module name. The public API of the module can also be exported using the standard ECMAScript module export syntax.
ts/Example.tsimport * as Language from \"WoltLabSuite/Core/Language\";\n\nexport function run() {\nalert(Language.get(\"wcf.foo.bar\"));\n}\n
This simple example module will compile to plain JavaScript that is compatible with the AMD loader that is used by WoltLab Suite.
files/js/Example.jsdefine([\"require\", \"exports\", \"tslib\", \"WoltLabSuite/Core/Language\"], function (require, exports, tslib_1, Language) {\n\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.run = void 0;\nLanguage = tslib_1.__importStar(Language);\nfunction run() {\nalert(Language.get(\"wcf.foo.bar\"));\n}\nexports.run = run;\n});\n
Within templates it can be consumed as follows.
<script data-relocate=\"true\">\nrequire([\"Example\"], (Example) => {\nExample.run(); // Alerts the contents of the `wcf.foo.bar` phrase.\n});\n</script>\n
"},{"location":"migration/wcf21/css/","title":"WCF 2.1.x - CSS","text":"The LESS compiler has been in use since WoltLab Community Framework 2.0, but was replaced with a SCSS compiler in WoltLab Suite 3.0. This change was motivated by SCSS becoming the de facto standard for CSS pre-processing and some really annoying shortcomings in the old LESS compiler.
The entire CSS has been rewritten from scratch, please read the docs on CSS to learn what has changed.
"},{"location":"migration/wcf21/package/","title":"WCF 2.1.x - Package Components","text":""},{"location":"migration/wcf21/package/#packagexml","title":"package.xml","text":""},{"location":"migration/wcf21/package/#short-instructions","title":"Short Instructions","text":"Instructions can now omit the filename, causing them to use the default filename if defined by the package installation plugin (in short: PIP
). Unless overridden it will default to the PIP's class name with the first letter being lower-cased, e.g. EventListenerPackageInstallationPlugin
implies the filename eventListener.xml
. The file is always assumed to be in the archive's root, files located in subdirectories need to be explicitly stated, just as it worked before.
Every PIP can define a custom filename if the default value cannot be properly derived. For example the ACPMenu
-pip would default to aCPMenu.xml
, requiring the class to explicitly override the default filename with acpMenu.xml
for readability.
<instructions type=\"install\">\n<!-- assumes `eventListener.xml` -->\n<instruction type=\"eventListener\" />\n<!-- assumes `install.sql` -->\n<instruction type=\"sql\" />\n<!-- assumes `language/*.xml` -->\n<instruction type=\"language\" />\n\n<!-- exceptions -->\n\n<!-- assumes `files.tar` -->\n<instruction type=\"file\" />\n<!-- no default value, requires relative path -->\n<instruction type=\"script\">acp/install_com.woltlab.wcf_3.0.php</instruction>\n</instructions>\n
"},{"location":"migration/wcf21/package/#exceptions","title":"Exceptions","text":"These exceptions represent the built-in PIPs only, 3rd party plugins and apps may define their own exceptions.
PIP Default ValueacpTemplate
acptemplates.tar
file
files.tar
language
language/*.xml
script
(No default value) sql
install.sql
template
templates.tar
"},{"location":"migration/wcf21/package/#acpmenuxml","title":"acpMenu.xml","text":""},{"location":"migration/wcf21/package/#renamed-categories","title":"Renamed Categories","text":"The following categories have been renamed, menu items need to be adjusted to reflect the new names:
Old Value New Valuewcf.acp.menu.link.system
wcf.acp.menu.link.configuration
wcf.acp.menu.link.display
wcf.acp.menu.link.customization
wcf.acp.menu.link.community
wcf.acp.menu.link.application
"},{"location":"migration/wcf21/package/#submenu-items","title":"Submenu Items","text":"Menu items can now offer additional actions to be accessed from within the menu using an icon-based navigation. This step avoids filling the menu with dozens of Add \u2026
links, shifting the focus on to actual items. Adding more than one action is not recommended and you should at maximum specify two actions per item.
<!-- category -->\n<acpmenuitem name=\"wcf.acp.menu.link.group\">\n<parent>wcf.acp.menu.link.user</parent>\n<showorder>2</showorder>\n</acpmenuitem>\n\n<!-- menu item -->\n<acpmenuitem name=\"wcf.acp.menu.link.group.list\">\n<controller>wcf\\acp\\page\\UserGroupListPage</controller>\n<parent>wcf.acp.menu.link.group</parent>\n<permissions>admin.user.canEditGroup,admin.user.canDeleteGroup</permissions>\n</acpmenuitem>\n<!-- menu item action -->\n<acpmenuitem name=\"wcf.acp.menu.link.group.add\">\n<controller>wcf\\acp\\form\\UserGroupAddForm</controller>\n<!-- actions are defined by menu items of menu items -->\n<parent>wcf.acp.menu.link.group.list</parent>\n<permissions>admin.user.canAddGroup</permissions>\n<!-- required FontAwesome icon name used for display -->\n<icon>fa-plus</icon>\n</acpmenuitem>\n
"},{"location":"migration/wcf21/package/#common-icon-names","title":"Common Icon Names","text":"You should use the same icon names for the (logically) same task, unifying the meaning of items and making the actions predictable.
Meaning Icon Name Result Add or createfa-plus
Search fa-search
Upload fa-upload
"},{"location":"migration/wcf21/package/#boxxml","title":"box.xml","text":"The box PIP has been added.
"},{"location":"migration/wcf21/package/#cronjobxml","title":"cronjob.xml","text":"Legacy cronjobs are assigned a non-deterministic generic name, the only way to assign them a name is removing them and then adding them again.
Cronjobs can now be assigned a name using the name attribute as in <cronjob name=\"com.woltlab.wcf.refreshPackageUpdates\">
, it will be used to identify cronjobs during an update or delete.
Legacy event listeners are assigned a non-deterministic generic name, the only way to assign them a name is removing them and then adding them again.
Event listeners can now be assigned a name using the name attribute as in <eventlistener name=\"sessionPageAccessLog\">
, it will be used to identify event listeners during an update or delete.
The menu PIP has been added.
"},{"location":"migration/wcf21/package/#menuitemxml","title":"menuItem.xml","text":"The menuItem PIP has been added.
"},{"location":"migration/wcf21/package/#objecttypexml","title":"objectType.xml","text":"The definition com.woltlab.wcf.user.dashboardContainer
has been removed, it was previously used to register pages that qualify for dashboard boxes. Since WoltLab Suite 3.0, all pages registered through the page.xml
are valid containers and therefore there is no need for this definition anymore.
The definitions com.woltlab.wcf.page
and com.woltlab.wcf.user.online.location
have been superseded by the page.xml
, they're no longer supported.
The module.display
category has been renamed into module.customization
.
The page PIP has been added.
"},{"location":"migration/wcf21/package/#pagemenuxml","title":"pageMenu.xml","text":"The pageMenu.xml
has been superseded by the page.xml
and is no longer available.
WoltLab Suite 3.0 finally made the transition from raw bbcode to bbcode-flavored HTML, with many new features related to message processing being added. This change impacts both message validation and storing, requiring slightly different APIs to get the job done.
"},{"location":"migration/wcf21/php/#input-processing-for-storage","title":"Input Processing for Storage","text":"The returned HTML is an intermediate representation with a maximum of meta data embedded into it, designed to be stored in the database. Some bbcodes are replaced during this process, for example [b]\u2026[/b]
becomes <strong>\u2026</strong>
, while others are converted into a metacode tag for later processing.
<?php\n$processor = new \\wcf\\system\\html\\input\\HtmlInputProcessor();\n$processor->process($message, $messageObjectType, $messageObjectID);\n$html = $processor->getHtml();\n
The $messageObjectID
can be zero if the element did not exist before, but it should be non-zero when saving an edited message.
Embedded objects need to be registered after saving the message, but once again you can use the processor instance to do the job.
<?php\n$processor = new \\wcf\\system\\html\\input\\HtmlInputProcessor();\n$processor->process($message, $messageObjectType, $messageObjectID);\n$html = $processor->getHtml();\n\n// at this point the message is saved to database and the created object\n// `$example` is a `DatabaseObject` with the id column `$exampleID`\n\n$processor->setObjectID($example->exampleID);\nif (\\wcf\\system\\message\\embedded\\object\\MessageEmbeddedObjectManager::getInstance()->registerObjects($processor)) {\n // there is at least one embedded object, this is also the point at which you\n // would set `hasEmbeddedObjects` to true (if implemented by your type)\n (new \\wcf\\data\\example\\ExampleEditor($example))->update(['hasEmbeddedObjects' => 1]);\n}\n
"},{"location":"migration/wcf21/php/#rendering-the-message","title":"Rendering the Message","text":"The output processor will parse the intermediate HTML and finalize the output for display. This step is highly dynamic and allows for bbcode evaluation and contextual output based on the viewer's permissions.
<?php\n$processor = new \\wcf\\system\\html\\output\\HtmlOutputProcessor();\n$processor->process($html, $messageObjectType, $messageObjectID);\n$renderedHtml = $processor->getHtml();\n
"},{"location":"migration/wcf21/php/#simplified-output","title":"Simplified Output","text":"At some point there can be the need of a simplified output HTML that includes only basic HTML formatting and reduces more sophisticated bbcodes into a simpler representation.
<?php\n$processor = new \\wcf\\system\\html\\output\\HtmlOutputProcessor();\n$processor->setOutputType('text/simplified-html');\n$processor->process(\u2026);\n
"},{"location":"migration/wcf21/php/#plaintext-output","title":"Plaintext Output","text":"The text/plain
output type will strip down the simplified HTML into pure text, suitable for text-only output such as the plaintext representation of an email.
<?php\n$processor = new \\wcf\\system\\html\\output\\HtmlOutputProcessor();\n$processor->setOutputType('text/plain');\n$processor->process(\u2026);\n
"},{"location":"migration/wcf21/php/#rebuilding-data","title":"Rebuilding Data","text":""},{"location":"migration/wcf21/php/#converting-from-bbcode","title":"Converting from BBCode","text":"Enabling message conversion for HTML messages is undefined and yields unexpected results.
Legacy message that still use raw bbcodes must be converted to be properly parsed by the html processors. This process is enabled by setting the fourth parameter of process()
to true
.
<?php\n$processor = new \\wcf\\system\\html\\input\\HtmlInputProcessor();\n$processor->process($html, $messageObjectType, $messageObjectID, true);\n$renderedHtml = $processor->getHtml();\n
"},{"location":"migration/wcf21/php/#extracting-embedded-objects","title":"Extracting Embedded Objects","text":"The process()
method of the input processor is quite expensive, as it runs through the full message validation including the invocation of HTMLPurifier. This is perfectly fine when dealing with single messages, but when you're handling messages in bulk to extract their embedded objects, you're better of with processEmbeddedContent()
. This method deconstructs the message, but skips all validation and expects the input to be perfectly valid, that is the output of a previous run of process()
saved to storage.
<?php\n$processor = new \\wcf\\system\\html\\input\\HtmlInputProcessor();\n$processor->processEmbeddedContent($html, $messageObjectType, $messageObjectID);\n\n// invoke `MessageEmbeddedObjectManager::registerObjects` here\n
"},{"location":"migration/wcf21/php/#breadcrumbs-page-location","title":"Breadcrumbs / Page Location","text":"Breadcrumbs used to be added left to right, but parent locations are added from the bottom to the top, starting with the first ancestor and going upwards. In most cases you simply need to reverse the order.
Breadcrumbs used to be a lose collection of arbitrary links, but are now represented by actual page objects and the control has shifted over to the PageLocationManager
.
<?php\n// before\n\\wcf\\system\\WCF::getBreadcrumbs()->add(new \\wcf\\system\\breadcrumb\\Breadcrumb('title', 'link'));\n\n// after\n\\wcf\\system\\page\\PageLocationManager::getInstance()->addParentLocation($pageIdentifier, $pageObjectID, $object);\n
"},{"location":"migration/wcf21/php/#pages-and-forms","title":"Pages and Forms","text":"The property $activeMenuItem
has been deprecated for the front end and is no longer evaluated at runtime. Recognition of the active item is entirely based around the invoked controller class name and its definition in the page table. You need to properly register your pages for this feature to work.
Added the setLocation()
method that is used to set the current page location based on the search result.
The methods SearchIndexManager::add()
and SearchIndexManager::update()
have been deprecated and forward their call to the new method SearchIndexManager::set()
.
The template structure has been overhauled and it is no longer required nor recommended to include internal templates, such as documentHeader
, headInclude
or userNotice
. Instead use a simple {include file='header'}
that now takes care of of the entire application frame.
</body></html>
after including the footer
template.documentHeader
, headInclude
and userNotice
template should no longer be included manually, the same goes with the <body>
element, please use {include file='header'}
instead.sidebarOrientation
variable for the header
template has been removed and no longer works.header.boxHeadline
has been unified and now reads header.contentHeader
Please see the full example at the end of this page for more information.
"},{"location":"migration/wcf21/templates/#sidebars","title":"Sidebars","text":"Sidebars are now dynamically populated by the box system, this requires a small change to unify the markup. Additionally the usage of <fieldset>
has been deprecated due to browser inconsistencies and bugs and should be replaced with section.box
.
Previous markup used in WoltLab Community Framework 2.1 and earlier:
<fieldset>\n <legend><!-- Title --></legend>\n\n <div>\n <!-- Content -->\n </div>\n</fieldset>\n
The new markup since WoltLab Suite 3.0:
<section class=\"box\">\n <h2 class=\"boxTitle\"><!-- Title --></h2>\n\n <div class=\"boxContent\">\n <!-- Content -->\n </div>\n</section>\n
"},{"location":"migration/wcf21/templates/#forms","title":"Forms","text":"The input tag for session ids SID_INPUT_TAG
has been deprecated and no longer yields any content, it can be safely removed. In previous versions forms have been wrapped in <div class=\"container containerPadding marginTop\">\u2026</div>
which no longer has any effect and should be removed.
If you're using the preview feature for WYSIWYG-powered input fields, you need to alter the preview button include instruction:
{include file='messageFormPreviewButton' previewMessageObjectType='com.example.foo.bar' previewMessageObjectID=0}\n
The message object id should be non-zero when editing.
"},{"location":"migration/wcf21/templates/#icons","title":"Icons","text":"The old .icon-<iconName>
classes have been removed, you are required to use the official .fa-<iconName>
class names from FontAwesome. This does not affect the generic classes .icon
(indicates an icon) and .icon<size>
(e.g. .icon16
that sets the dimensions), these are still required and have not been deprecated.
Before:
<span class=\"icon icon16 icon-list\">\n
Now:
<span class=\"icon icon16 fa-list\">\n
"},{"location":"migration/wcf21/templates/#changed-icon-names","title":"Changed Icon Names","text":"Quite a few icon names have been renamed, the official wiki lists the new icon names in FontAwesome 4.
"},{"location":"migration/wcf21/templates/#changed-classes","title":"Changed Classes","text":".dataList
has been replaced and should now read <ol class=\"inlineList commaSeparated\">
(same applies to <ul>
).framedIconList
has been changed into .userAvatarList
<nav class=\"jsClipboardEditor\">
and <div class=\"jsClipboardContainer\">
have been replaced with a floating button.a.toTopLink
have been replaced with a floating button.framed
dl.condensed
class, as seen in the editor tab menu, is no longer required.sidebarCollapsed
has been removed as sidebars are no longer collapsible.The code below includes only the absolute minimum required to display a page, the content title is already included in the output.
{include file='header'}\n\n<div class=\"section\">\n Hello World!\n</div>\n\n{include file='footer'}\n
"},{"location":"migration/wcf21/templates/#full-example","title":"Full Example","text":"{*\n The page title is automatically set using the page definition, avoid setting it if you can!\n If you really need to modify the title, you can still reference the original title with:\n {$__wcf->getActivePage()->getTitle()}\n*}\n{capture assign='pageTitle'}Custom Page Title{/capture}\n\n{*\n NOTICE: The content header goes here, see the section after this to learn more.\n*}\n\n{* you must not use `headContent` for JavaScript *}\n{capture assign='headContent'}\n <link rel=\"alternate\" type=\"application/rss+xml\" title=\"{lang}wcf.global.button.rss{/lang}\" href=\"\u2026\">\n{/capture}\n\n{* optional, content will be added to the top of the left sidebar *}\n{capture assign='sidebarLeft'}\n \u2026\n\n{event name='boxes'}\n{/capture}\n\n{* optional, content will be added to the top of the right sidebar *}\n{capture assign='sidebarRight'}\n \u2026\n\n{event name='boxes'}\n{/capture}\n\n{capture assign='headerNavigation'}\n <li><a href=\"#\" title=\"Custom Button\" class=\"jsTooltip\"><span class=\"icon icon16 fa-check\"></span> <span class=\"invisible\">Custom Button</span></a></li>\n{/capture}\n\n{include file='header'}\n\n{hascontent}\n <div class=\"paginationTop\">\n{content}\n{pages \u2026}\n{/content}\n </div>\n{/hascontent}\n\n{* the actual content *}\n<div class=\"section\">\n \u2026\n</div>\n\n<footer class=\"contentFooter\">\n{* skip this if you're not using any pagination *}\n{hascontent}\n <div class=\"paginationBottom\">\n{content}{@$pagesLinks}{/content}\n </div>\n{/hascontent}\n\n <nav class=\"contentFooterNavigation\">\n <ul>\n <li><a href=\"\u2026\" class=\"button\"><span class=\"icon icon16 fa-plus\"></span> <span>Custom Button</span></a></li>\n{event name='contentFooterNavigation'}\n </ul>\n </nav>\n</footer>\n\n<script data-relocate=\"true\">\n /* any JavaScript code you need */\n</script>\n\n{* do not include `</body></html>` here, the footer template is the last bit of code! *}\n{include file='footer'}\n
"},{"location":"migration/wcf21/templates/#content-header","title":"Content Header","text":"There are two different methods to set the content header, one sets only the actual values, but leaves the outer HTML untouched, that is generated by the header
template. This is the recommended approach and you should avoid using the alternative method whenever possible.
{* This is automatically set using the page data and should not be set manually! *}\n{capture assign='contentTitle'}Custom Content Title{/capture}\n\n{capture assign='contentDescription'}Optional description that is displayed right after the title.{/capture}\n\n{capture assign='contentHeaderNavigation'}List of navigation buttons displayed right next to the title.{/capture}\n
"},{"location":"migration/wcf21/templates/#alternative","title":"Alternative","text":"{capture assign='contentHeader'}\n <header class=\"contentHeader\">\n <div class=\"contentHeaderTitle\">\n <h1 class=\"contentTitle\">Custom Content Title</h1>\n <p class=\"contentHeaderDescription\">Custom Content Description</p>\n </div>\n\n <nav class=\"contentHeaderNavigation\">\n <ul>\n <li><a href=\"{link controller='CustomController'}{/link}\" class=\"button\"><span class=\"icon icon16 fa-plus\"></span> <span>Custom Button</span></a></li>\n{event name='contentHeaderNavigation'}\n </ul>\n </nav>\n </header>\n{/capture}\n
"},{"location":"migration/wsc30/css/","title":"Migrating from WSC 3.0 - CSS","text":""},{"location":"migration/wsc30/css/#new-style-variables","title":"New Style Variables","text":"The new style variables are only applied to styles that have the compatibility set to WSC 3.1
"},{"location":"migration/wsc30/css/#wcfcontentcontainer","title":"wcfContentContainer","text":"The page content is encapsulated in a new container that wraps around the inner content, but excludes the sidebars, header and page navigation elements.
$wcfContentContainerBackground
- background color$wcfContentContainerBorder
- border colorThese variables control the appearance of the editor toolbar and its buttons.
$wcfEditorButtonBackground
- button and toolbar background color$wcfEditorButtonBackgroundActive
- active button background color$wcfEditorButtonText
- text color for available buttons$wcfEditorButtonTextActive
- text color for active buttons$wcfEditorButtonTextDisabled
- text color for disabled buttonsalert.scss
","text":"The color values for <small class=\"innerError\">
used to be hardcoded values, but have now been changed to use the values for error messages (wcfStatusError*
) instead.
The new tiny builds are highly optimized variants of existing JavaScript files and modules, aiming for significant performance improvements for guests and search engines alike. This is accomplished by heavily restricting page interaction to read-only actions whenever possible, which in return removes the need to provide certain JavaScript modules in general.
For example, disallowing guests to write any formatted messages will in return remove the need to provide the WYSIWYG editor at all. But it doesn't stop there, there are a lot of other modules that provide additional features for the editor, and by excluding the editor, we can also exclude these modules too.
Long story short, the tiny mode guarantees that certain actions will never be carried out by guests or search engines, therefore some modules are not going to be needed by them ever.
"},{"location":"migration/wsc30/javascript/#code-templates-for-tiny-builds","title":"Code Templates for Tiny Builds","text":"The following examples assume that you use the virtual constant COMPILER_TARGET_DEFAULT
as a switch for the optimized code path. This is also the constant used by the official build scripts for JavaScript files.
We recommend that you provide a mock implementation for existing code to ensure 3rd party compatibility. It is enough to provide a bare object or class that exposes the original properties using the same primitive data types. This is intended to provide a soft-fail for implementations that are not aware of the tiny mode yet, but is not required for classes that did not exist until now.
"},{"location":"migration/wsc30/javascript/#legacy-javascript","title":"Legacy JavaScript","text":"if (COMPILER_TARGET_DEFAULT) {\nWCF.Example.Foo = {\nmakeSnafucated: function() {\nreturn \"Hello World\";\n}\n};\n\nWCF.Example.Bar = Class.extend({\nfoobar: \"baz\",\n\nfoo: function($bar) {\nreturn $bar + this.foobar;\n}\n});\n}\nelse {\nWCF.Example.Foo = {\nmakeSnafucated: function() {}\n};\n\nWCF.Example.Bar = Class.extend({\nfoobar: \"\",\nfoo: function() {}\n});\n}\n
"},{"location":"migration/wsc30/javascript/#requirejs-modules","title":"require.js Modules","text":"define([\"some\", \"fancy\", \"dependencies\"], function(Some, Fancy, Dependencies) {\n\"use strict\";\n\nif (!COMPILER_TARGET_DEFAULT) {\nvar Fake = function() {};\nFake.prototype = {\ninit: function() {},\nmakeSnafucated: function() {}\n};\nreturn Fake;\n}\n\nfunction MyAwesomeClass(niceArgument) { this.init(niceArgument); }\nMyAwesomeClass.prototype = {\ninit: function(niceArgument) {\nif (niceArgument) {\nthis.makeSnafucated();\n}\n},\n\nmakeSnafucated: function() {\nconsole.log(\"Hello World\");\n}\n}\n\nreturn MyAwesomeClass;\n});\n
"},{"location":"migration/wsc30/javascript/#including-tinified-builds-through-js","title":"Including tinified builds through {js}
","text":"The {js}
template-plugin has been updated to include support for tiny builds controlled through the optional flag hasTiny=true
:
{js application='wcf' file='WCF.Example' hasTiny=true}\n
This line generates a different output depending on the debug mode and the user login-state.
"},{"location":"migration/wsc30/javascript/#real-error-messages-for-ajax-responses","title":"Real Error Messages for AJAX Responses","text":"The errorMessage
property in the returned response object for failed AJAX requests contained an exception-specific but still highly generic error message. This issue has been around for quite a long time and countless of implementations are relying on this false behavior, eventually forcing us to leave the value unchanged.
This problem is solved by adding the new property realErrorMessage
that exposes the message exactly as it was provided and now matches the value that would be displayed to users in traditional forms.
define(['Ajax'], function(Ajax) {\nreturn {\n// ...\n_ajaxFailure: function(responseData, responseText, xhr, requestData) {\nconsole.log(responseData.realErrorMessage);\n}\n// ...\n};\n});\n
"},{"location":"migration/wsc30/javascript/#simplified-form-submit-in-dialogs","title":"Simplified Form Submit in Dialogs","text":"Forms embedded in dialogs often do not contain the HTML <form>
-element and instead rely on JavaScript click- and key-handlers to emulate a <form>
-like submit behavior. This has spawned a great amount of nearly identical implementations that all aim to handle the form submit through the Enter
-key, still leaving some dialogs behind.
WoltLab Suite 3.1 offers automatic form submit that is enabled through a set of specific conditions and data attributes:
.formSubmit > input[type=\"submit\"], .formSubmit > button[data-type=\"submit\"]
.UiDialog.open()
implements the method _dialogSubmit()
.data-dialog-submit-on-enter=\"true\"
to be set, the type
must be one of number
, password
, search
, tel
, text
or url
.Clicking on the submit button or pressing the Enter
-key in any watched input field will start the submit process. This is done automatically and does not require a manual interaction in your code, therefore you should not bind any click listeners on the submit button yourself.
Any input field with the required
attribute set will be validated to contain a non-empty string after processing the value with String.prototype.trim()
. An empty field will abort the submit process and display a visible error message next to the offending field.
Displaying inline error messages on-the-fly required quite a few DOM operations that were quite simple but also super repetitive and thus error-prone when incorrectly copied over. The global helper function elInnerError()
was added to provide a simple and consistent behavior of inline error messages.
You can display an error message by invoking elInnerError(elementRef, \"Your Error Message\")
, it will insert a new <small class=\"innerError\">
and sets the given message. If there is already an inner error present, then the message will be replaced instead.
Hiding messages is done by setting the 2nd parameter to false
or an empty string:
elInnerError(elementRef, false)
elInnerError(elementRef, '')
The special values null
and undefined
are supported too, but their usage is discouraged, because they make it harder to understand the intention by reading the code:
elInnerError(elementRef, null)
elInnerError(elementRef)
require(['Language'], function(Language)) {\nvar input = elBySel('input[type=\"text\"]');\nif (input.value.trim() === '') {\n// displays a new inline error or replaces the message if there is one already\nelInnerError(input, Language.get('wcf.global.form.error.empty'));\n}\nelse {\n// removes the inline error if it exists\nelInnerError(input, false);\n}\n\n// the above condition is equivalent to this:\nelInnerError(input, (input.value.trim() === '' ? Language.get('wcf.global.form.error.empty') : false));\n}\n
"},{"location":"migration/wsc30/package/","title":"Migrating from WSC 3.0 - Package Components","text":""},{"location":"migration/wsc30/package/#cronjob-scheduler-uses-server-timezone","title":"Cronjob Scheduler uses Server Timezone","text":"The execution time of cronjobs was previously calculated based on the coordinated universal time (UTC). This was changed in WoltLab Suite 3.1 to use the server timezone or, to be precise, the default timezone set in the administration control panel.
"},{"location":"migration/wsc30/package/#exclude-pages-from-becoming-a-landing-page","title":"Exclude Pages from becoming a Landing Page","text":"Some pages do not qualify as landing page, because they're designed around specific expectations that aren't matched in all cases. Examples include the user control panel and its sub-pages that cannot be accessed by guests and will therefore break the landing page for those. While it is somewhat to be expected from control panel pages, there are enough pages that fall under the same restrictions, but aren't easily recognized as such by an administrator.
You can exclude these pages by adding <excludeFromLandingPage>1</excludeFromLandingPage>
(case-sensitive) to the relevant pages in your page.xml
.
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/tornado/page.xsd\">\n<import>\n<page identifier=\"com.example.foo.Bar\">\n<!-- ... -->\n<excludeFromLandingPage>1</excludeFromLandingPage>\n<!-- ... -->\n</page>\n</import>\n</data>\n
"},{"location":"migration/wsc30/package/#new-package-installation-plugin-for-media-providers","title":"New Package Installation Plugin for Media Providers","text":"Please refer to the documentation of the mediaProvider.xml
to learn more.
Please refer to the documentation of the <compatibility>
tag in the package.xml
.
Comments can now be set to require approval by a moderator before being published. This feature is disabled by default if you do not provide a permission in the manager class, enabling it requires a new permission that has to be provided in a special property of your manage implementation.
files/lib/system/comment/manager/ExampleCommentManager.class.php<?php\nclass ExampleCommentManager extends AbstractCommentManager {\n protected $permissionAddWithoutModeration = 'foo.bar.example.canAddCommentWithoutModeration';\n}\n
"},{"location":"migration/wsc30/php/#raw-html-in-user-activity-events","title":"Raw HTML in User Activity Events","text":"User activity events were previously encapsulated inside <div class=\"htmlContent\">\u2026</div>
, with impacts on native elements such as lists. You can now disable the class usage by defining your event as raw HTML:
<?php\nclass ExampleUserActivityEvent {\n // enables raw HTML for output, defaults to `false`\n protected $isRawHtml = true;\n}\n
"},{"location":"migration/wsc30/php/#permission-to-view-likes-of-an-object","title":"Permission to View Likes of an Object","text":"Being able to view the like summary of an object was restricted to users that were able to like the object itself. This creates situations where the object type in general is likable, but the particular object cannot be liked by the current users, while also denying them to view the like summary (but it gets partly exposed through the footer note/summary!).
Implement the interface \\wcf\\data\\like\\IRestrictedLikeObjectTypeProvider
in your object provider to add support for this new permission check.
<?php\nclass LikeableExampleProvider extends ExampleProvider implements IRestrictedLikeObjectTypeProvider, IViewableLikeProvider {\n public function canViewLikes(ILikeObject $object) {\n // perform your permission checks here\n return true;\n }\n}\n
"},{"location":"migration/wsc30/php/#developer-tools-sync-feature","title":"Developer Tools: Sync Feature","text":"The synchronization feature of the newly added developer tools works by invoking a package installation plugin (PIP) outside of a regular installation, while simulating the basic environment that is already exposed by the API.
However, not all PIPs qualify for this kind of execution, especially because it could be invoked multiple times in a row by the user. This is solved by requiring a special marking for PIPs that have no side-effects (= idempotent) when invoked any amount of times with the same arguments.
There's another feature that allows all matching PIPs to be executed in a row using a single button click. In order to solve dependencies on other PIPs, any implementing PIP must also provide the method getSyncDependencies()
that returns the dependent PIPs in an arbitrary order.
<?php\nclass ExamplePackageInstallationPlugin extends AbstractXMLPackageInstallationPlugin implements IIdempotentPackageInstallationPlugin {\n public static function getSyncDependencies() {\n // provide a list of dependent PIPs in arbitrary order\n return [];\n }\n}\n
"},{"location":"migration/wsc30/php/#media-providers","title":"Media Providers","text":"Media providers were added through regular SQL queries in earlier versions, but this is neither convenient, nor did it offer a reliable method to update an existing provider. WoltLab Suite 3.1 adds a new mediaProvider
-PIP that also offers a className
parameter to off-load the result evaluation and HTML generation.
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/tornado/mediaProvider.xsd\">\n<import>\n<provider name=\"example\">\n<title>Example Provider</title>\n<regex>https?://example.com/watch?v=(?P<ID>[a-zA-Z0-9])</regex>\n<className>wcf\\system\\bbcode\\media\\provider\\ExampleBBCodeMediaProvider</className>\n</provider>\n</import>\n</data>\n
"},{"location":"migration/wsc30/php/#php-callback","title":"PHP Callback","text":"The full match is provided for $url
, while any capture groups from the regular expression are assigned to $matches
.
<?php\nclass ExampleBBCodeMediaProvider implements IBBCodeMediaProvider {\n public function parse($url, array $matches = []) {\n return \"final HTML output\";\n }\n}\n
"},{"location":"migration/wsc30/php/#re-evaluate-html-messages","title":"Re-Evaluate HTML Messages","text":"You need to manually set the disallowed bbcodes in order to avoid unintentional bbcode evaluation. Please see this commit for a reference implementation inside worker processes.
The HtmlInputProcessor only supported two ways to handle an existing HTML message:
process()
and run it through the validation and sanitation process, both of them are rather expensive operations and do not qualify for rebuild data workers.processEmbeddedContent()
which bypasses most tasks that are carried out by process()
which aren't required, but does not allow a modification of the message.The newly added method reprocess($message, $objectType, $objectID)
solves this short-coming by offering a full bbcode and text re-evaluation while bypassing any input filters, assuming that the input HTML was already filtered previously.
<?php\n// rebuild data workers tend to contain code similar to this:\nforeach ($this->objectList as $message) {\n // ...\n if (!$message->enableHtml) {\n // ...\n }\n else {\n // OLD:\n $this->getHtmlInputProcessor()->processEmbeddedContent($message->message, 'com.example.foo.message', $message->messageID);\n\n // REPLACE WITH:\n $this->getHtmlInputProcessor()->reprocess($message->message, 'com.example.foo.message', $message->messageID);\n $data['message'] = $this->getHtmlInputProcessor()->getHtml();\n }\n // ...\n}\n
"},{"location":"migration/wsc30/templates/","title":"Migrating from WSC 3.0 - Templates","text":""},{"location":"migration/wsc30/templates/#comment-system-overhaul","title":"Comment-System Overhaul","text":"Unfortunately, there has been a breaking change related to the creation of comments. You need to apply the changes below before being able to create new comments.
"},{"location":"migration/wsc30/templates/#adding-comments","title":"Adding Comments","text":"Existing implementations need to include a new template right before including the generic commentList
template.
<ul id=\"exampleCommentList\" class=\"commentList containerList\" data-...>\n {include file='commentListAddComment' wysiwygSelector='exampleCommentListAddComment'}\n {include file='commentList'}\n</ul>\n
"},{"location":"migration/wsc30/templates/#redesigned-acp-user-list","title":"Redesigned ACP User List","text":"Custom interaction buttons were previously added through the template event rowButtons
and were merely a link-like element with an icon inside. This is still valid and supported for backwards-compatibility, but it is recommend to adapt to the new drop-down-style options using the new template event dropdownItems
.
<!-- button for usage with the `rowButtons` event -->\n<span class=\"icon icon16 fa-list jsTooltip\" title=\"Button Title\"></span>\n\n<!-- new drop-down item for the `dropdownItems` event -->\n<li><a href=\"#\" class=\"jsMyButton\">Button Title</a></li>\n
"},{"location":"migration/wsc30/templates/#sidebar-toogle-buttons-on-mobile-device","title":"Sidebar Toogle-Buttons on Mobile Device","text":"You cannot override the button label for sidebars containing navigation menus.
The page sidebars are automatically collapsed and presented as one or, when both sidebar are present, two condensed buttons. They use generic sidebar-related labels when open or closed, with the exception of embedded menus which will change the button label to read \"Show/Hide Navigation\".
You can provide a custom label before including the sidebars by assigning the new labels to a few special variables:
{assign var='__sidebarLeftShow' value='Show Left Sidebar'}\n{assign var='__sidebarLeftHide' value='Hide Left Sidebar'}\n{assign var='__sidebarRightShow' value='Show Right Sidebar'}\n{assign var='__sidebarRightHide' value='Hide Right Sidebar'}\n
"},{"location":"migration/wsc31/form-builder/","title":"Migrating from WSC 3.1 - Form Builder","text":""},{"location":"migration/wsc31/form-builder/#example-two-text-form-fields","title":"Example: Two Text Form Fields","text":"As the first example, the pre-WoltLab Suite Core 5.2 versions of the forms to add and edit persons from the first part of the tutorial series will be updated to the new form builder API. This form is the perfect first examples as it is very simple with only two text fields whose only restriction is that they have to be filled out and that their values may not be longer than 255 characters each.
As a reminder, here are the two relevant PHP files and the relevant template file:
files/lib/acp/form/PersonAddForm.class.php<?php\nnamespace wcf\\acp\\form;\nuse wcf\\data\\person\\PersonAction;\nuse wcf\\form\\AbstractForm;\nuse wcf\\system\\exception\\UserInputException;\nuse wcf\\system\\WCF;\nuse wcf\\util\\StringUtil;\n\n/**\n * Shows the form to create a new person.\n * \n * @author Matthias Schmidt\n * @copyright 2001-2019 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\Acp\\Form\n */\nclass PersonAddForm extends AbstractForm {\n /**\n * @inheritDoc\n */\n public $activeMenuItem = 'wcf.acp.menu.link.person.add';\n\n /**\n * first name of the person\n * @var string\n */\n public $firstName = '';\n\n /**\n * last name of the person\n * @var string\n */\n public $lastName = '';\n\n /**\n * @inheritDoc\n */\n public $neededPermissions = ['admin.content.canManagePeople'];\n\n /**\n * @inheritDoc\n */\n public function assignVariables() {\n parent::assignVariables();\n\n WCF::getTPL()->assign([\n 'action' => 'add',\n 'firstName' => $this->firstName,\n 'lastName' => $this->lastName\n ]);\n }\n\n /**\n * @inheritDoc\n */\n public function readFormParameters() {\n parent::readFormParameters();\n\n if (isset($_POST['firstName'])) $this->firstName = StringUtil::trim($_POST['firstName']);\n if (isset($_POST['lastName'])) $this->lastName = StringUtil::trim($_POST['lastName']);\n }\n\n /**\n * @inheritDoc\n */\n public function save() {\n parent::save();\n\n $this->objectAction = new PersonAction([], 'create', [\n 'data' => array_merge($this->additionalFields, [\n 'firstName' => $this->firstName,\n 'lastName' => $this->lastName\n ])\n ]);\n $this->objectAction->executeAction();\n\n $this->saved();\n\n // reset values\n $this->firstName = '';\n $this->lastName = '';\n\n // show success message\n WCF::getTPL()->assign('success', true);\n }\n\n /**\n * @inheritDoc\n */\n public function validate() {\n parent::validate();\n\n // validate first name\n if (empty($this->firstName)) {\n throw new UserInputException('firstName');\n }\n if (mb_strlen($this->firstName) > 255) {\n throw new UserInputException('firstName', 'tooLong');\n }\n\n // validate last name\n if (empty($this->lastName)) {\n throw new UserInputException('lastName');\n }\n if (mb_strlen($this->lastName) > 255) {\n throw new UserInputException('lastName', 'tooLong');\n }\n }\n}\n
files/lib/acp/form/PersonEditForm.class.php <?php\nnamespace wcf\\acp\\form;\nuse wcf\\data\\person\\Person;\nuse wcf\\data\\person\\PersonAction;\nuse wcf\\form\\AbstractForm;\nuse wcf\\system\\exception\\IllegalLinkException;\nuse wcf\\system\\WCF;\n\n/**\n * Shows the form to edit an existing person.\n * \n * @author Matthias Schmidt\n * @copyright 2001-2019 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\Acp\\Form\n */\nclass PersonEditForm extends PersonAddForm {\n /**\n * @inheritDoc\n */\n public $activeMenuItem = 'wcf.acp.menu.link.person';\n\n /**\n * edited person object\n * @var Person\n */\n public $person = null;\n\n /**\n * id of the edited person\n * @var integer\n */\n public $personID = 0;\n\n /**\n * @inheritDoc\n */\n public function assignVariables() {\n parent::assignVariables();\n\n WCF::getTPL()->assign([\n 'action' => 'edit',\n 'person' => $this->person\n ]);\n }\n\n /**\n * @inheritDoc\n */\n public function readData() {\n parent::readData();\n\n if (empty($_POST)) {\n $this->firstName = $this->person->firstName;\n $this->lastName = $this->person->lastName;\n }\n }\n\n /**\n * @inheritDoc\n */\n public function readParameters() {\n parent::readParameters();\n\n if (isset($_REQUEST['id'])) $this->personID = intval($_REQUEST['id']);\n $this->person = new Person($this->personID);\n if (!$this->person->personID) {\n throw new IllegalLinkException();\n }\n }\n\n /**\n * @inheritDoc\n */\n public function save() {\n AbstractForm::save();\n\n $this->objectAction = new PersonAction([$this->person], 'update', [\n 'data' => array_merge($this->additionalFields, [\n 'firstName' => $this->firstName,\n 'lastName' => $this->lastName\n ])\n ]);\n $this->objectAction->executeAction();\n\n $this->saved();\n\n // show success message\n WCF::getTPL()->assign('success', true);\n }\n}\n
acptemplates/personAdd.tpl {include file='header' pageTitle='wcf.acp.person.'|concat:$action}\n\n<header class=\"contentHeader\">\n <div class=\"contentHeaderTitle\">\n <h1 class=\"contentTitle\">{lang}wcf.acp.person.{$action}{/lang}</h1>\n </div>\n\n <nav class=\"contentHeaderNavigation\">\n <ul>\n <li><a href=\"{link controller='PersonList'}{/link}\" class=\"button\"><span class=\"icon icon16 fa-list\"></span> <span>{lang}wcf.acp.menu.link.person.list{/lang}</span></a></li>\n\n{event name='contentHeaderNavigation'}\n </ul>\n </nav>\n</header>\n\n{include file='formError'}\n\n{if $success|isset}\n <p class=\"success\">{lang}wcf.global.success.{$action}{/lang}</p>\n{/if}\n\n<form method=\"post\" action=\"{if $action == 'add'}{link controller='PersonAdd'}{/link}{else}{link controller='PersonEdit' object=$person}{/link}{/if}\">\n <div class=\"section\">\n <dl{if $errorField == 'firstName'} class=\"formError\"{/if}>\n <dt><label for=\"firstName\">{lang}wcf.person.firstName{/lang}</label></dt>\n <dd>\n <input type=\"text\" id=\"firstName\" name=\"firstName\" value=\"{$firstName}\" required autofocus maxlength=\"255\" class=\"long\">\n{if $errorField == 'firstName'}\n <small class=\"innerError\">\n{if $errorType == 'empty'}\n{lang}wcf.global.form.error.empty{/lang}\n{else}\n{lang}wcf.acp.person.firstName.error.{$errorType}{/lang}\n{/if}\n </small>\n{/if}\n </dd>\n </dl>\n\n <dl{if $errorField == 'lastName'} class=\"formError\"{/if}>\n <dt><label for=\"lastName\">{lang}wcf.person.lastName{/lang}</label></dt>\n <dd>\n <input type=\"text\" id=\"lastName\" name=\"lastName\" value=\"{$lastName}\" required maxlength=\"255\" class=\"long\">\n{if $errorField == 'lastName'}\n <small class=\"innerError\">\n{if $errorType == 'empty'}\n{lang}wcf.global.form.error.empty{/lang}\n{else}\n{lang}wcf.acp.person.lastName.error.{$errorType}{/lang}\n{/if}\n </small>\n{/if}\n </dd>\n </dl>\n\n{event name='dataFields'}\n </div>\n\n{event name='sections'}\n\n <div class=\"formSubmit\">\n <input type=\"submit\" value=\"{lang}wcf.global.button.submit{/lang}\" accesskey=\"s\">\n{@SECURITY_TOKEN_INPUT_TAG}\n </div>\n</form>\n\n{include file='footer'}\n
Updating the template is easy as the complete form is replace by a single line of code:
acptemplates/personAdd.tpl{include file='header' pageTitle='wcf.acp.person.'|concat:$action}\n\n<header class=\"contentHeader\">\n <div class=\"contentHeaderTitle\">\n <h1 class=\"contentTitle\">{lang}wcf.acp.person.{$action}{/lang}</h1>\n </div>\n\n <nav class=\"contentHeaderNavigation\">\n <ul>\n <li><a href=\"{link controller='PersonList'}{/link}\" class=\"button\"><span class=\"icon icon16 fa-list\"></span> <span>{lang}wcf.acp.menu.link.person.list{/lang}</span></a></li>\n\n{event name='contentHeaderNavigation'}\n </ul>\n </nav>\n</header>\n\n{@$form->getHtml()}\n\n{include file='footer'}\n
PersonEditForm
also becomes much simpler: only the edited Person
object must be read:
<?php\nnamespace wcf\\acp\\form;\nuse wcf\\data\\person\\Person;\nuse wcf\\system\\exception\\IllegalLinkException;\n\n/**\n * Shows the form to edit an existing person.\n * \n * @author Matthias Schmidt\n * @copyright 2001-2019 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\Acp\\Form\n */\nclass PersonEditForm extends PersonAddForm {\n /**\n * @inheritDoc\n */\n public $activeMenuItem = 'wcf.acp.menu.link.person';\n\n /**\n * @inheritDoc\n */\n public function readParameters() {\n parent::readParameters();\n\n if (isset($_REQUEST['id'])) {\n $this->formObject = new Person(intval($_REQUEST['id']));\n if (!$this->formObject->personID) {\n throw new IllegalLinkException();\n }\n }\n }\n}\n
Most of the work is done in PersonAddForm
:
<?php\nnamespace wcf\\acp\\form;\nuse wcf\\data\\person\\PersonAction;\nuse wcf\\form\\AbstractFormBuilderForm;\nuse wcf\\system\\form\\builder\\container\\FormContainer;\nuse wcf\\system\\form\\builder\\field\\TextFormField;\n\n/**\n * Shows the form to create a new person.\n * \n * @author Matthias Schmidt\n * @copyright 2001-2019 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\Acp\\Form\n */\nclass PersonAddForm extends AbstractFormBuilderForm {\n /**\n * @inheritDoc\n */\n public $activeMenuItem = 'wcf.acp.menu.link.person.add';\n\n /**\n * @inheritDoc\n */\n public $formAction = 'create';\n\n /**\n * @inheritDoc\n */\n public $neededPermissions = ['admin.content.canManagePeople'];\n\n /**\n * @inheritDoc\n */\n public $objectActionClass = PersonAction::class;\n\n /**\n * @inheritDoc\n */\n protected function createForm() {\n parent::createForm();\n\n $dataContainer = FormContainer::create('data')\n ->appendChildren([\n TextFormField::create('firstName')\n ->label('wcf.person.firstName')\n ->required()\n ->maximumLength(255),\n\n TextFormField::create('lastName')\n ->label('wcf.person.lastName')\n ->required()\n ->maximumLength(255)\n ]);\n\n $this->form->appendChild($dataContainer);\n }\n}\n
But, as you can see, the number of lines almost decreased by half. All changes are due to extending AbstractFormBuilderForm
:
$formAction
is added and set to create
as the form is used to create a new person. In the edit form, $formAction
has not to be set explicitly as it is done automatically if a $formObject
is set.$objectActionClass
is set to PersonAction::class
and is the class name of the used AbstractForm::$objectAction
object to create and update the Person
object.AbstractFormBuilderForm::createForm()
is overridden and the form contents are added: a form container representing the div.section
element from the old version and the two form fields with the same ids and labels as before. The contents of the old validate()
method is put into two method calls: required()
to ensure that the form is filled out and maximumLength(255)
to ensure that the names are not longer than 255 characters.With version 5.2 of WoltLab Suite Core the like system was completely replaced by the new reactions system. This makes it necessary to make some adjustments to existing code so that your plugin integrates completely into the new system. However, we have kept these adjustments as small as possible so that it is possible to use the reaction system with slight restrictions even without adjustments.
"},{"location":"migration/wsc31/like/#limitations-if-no-adjustments-are-made-to-the-existing-code","title":"Limitations if no adjustments are made to the existing code","text":"If no adjustments are made to the existing code, the following functions are not available: * Notifications about reactions/likes * Recent Activity Events for reactions/likes
"},{"location":"migration/wsc31/like/#migration","title":"Migration","text":""},{"location":"migration/wsc31/like/#notifications","title":"Notifications","text":""},{"location":"migration/wsc31/like/#mark-notification-as-compatible","title":"Mark notification as compatible","text":"Since there are no more likes with the new version, it makes no sense to send notifications about it. Instead of notifications about likes, notifications about reactions are now sent. However, this only changes the notification text and not the notification itself. To update the notification, we first add the interface \\wcf\\data\\reaction\\object\\IReactionObject
to the \\wcf\\data\\like\\object\\ILikeObject
object (e.g. in WoltLab Suite Forum we added the interface to the class \\wbb\\data\\post\\LikeablePost
). After that the object is marked as \"compatible with WoltLab Suite Core 5.2\" and notifications about reactions are sent again.
Next, to display all reactions for the current notification in the notification text, we include the trait \\wcf\\system\\user\\notification\\event\\TReactionUserNotificationEvent
in the user notification event class (typically named like *LikeUserNotificationEvent
). These trait provides a new function that reads out and groups the reactions. The result of this function must now only be passed to the language variable. The name \"reactions\" is typically used as the variable name for the language variable.
As a final step, we only need to change the language variables themselves. To ensure a consistent usability, the same formulations should be used as in the WoltLab Suite Core.
"},{"location":"migration/wsc31/like/#english","title":"English","text":"{prefix}.like.title
Reaction to a {objectName}\n
{prefix}.like.title.stacked
{#$count} users reacted to your {objectName}\n
{prefix}.like.message
{@$author->getAnchorTag()} reacted to your {objectName} ({implode from=$reactions key=reactionID item=count}{@$__wcf->getReactionHandler()->getReactionTypeByID($reactionID)->renderIcon()}\u00d7{#$count}{/implode}).\n
{prefix}.like.message.stacked
{if $count < 4}{@$authors[0]->getAnchorTag()}{if $count == 2} and {else}, {/if}{@$authors[1]->getAnchorTag()}{if $count == 3} and {@$authors[2]->getAnchorTag()}{/if}{else}{@$authors[0]->getAnchorTag()} and {#$others} others{/if} reacted to your {objectName} ({implode from=$reactions key=reactionID item=count}{@$__wcf->getReactionHandler()->getReactionTypeByID($reactionID)->renderIcon()}\u00d7{#$count}{/implode}).\n
wcf.user.notification.{objectTypeName}.like.notification.like
Notify me when someone reacted to my {objectName}\n
"},{"location":"migration/wsc31/like/#german","title":"German","text":"{prefix}.like.title
Reaktion auf einen {objectName}\n
{prefix}.like.title.stacked
{#$count} Benutzern haben auf {if LANGUAGE_USE_INFORMAL_VARIANT}dein(en){else}Ihr(en){/if} {objectName} reagiert\n
{prefix}.like.message
{@$author->getAnchorTag()} hat auf {if LANGUAGE_USE_INFORMAL_VARIANT}dein(en){else}Ihr(en){/if} {objectName} reagiert ({implode from=$reactions key=reactionID item=count}{@$__wcf->getReactionHandler()->getReactionTypeByID($reactionID)->renderIcon()}\u00d7{#$count}{/implode}).\n
{prefix}.like.message.stacked
{if $count < 4}{@$authors[0]->getAnchorTag()}{if $count == 2} und {else}, {/if}{@$authors[1]->getAnchorTag()}{if $count == 3} und {@$authors[2]->getAnchorTag()}{/if}{else}{@$authors[0]->getAnchorTag()} und {#$others} weitere{/if} haben auf {if LANGUAGE_USE_INFORMAL_VARIANT}dein(en){else}Ihr(en){/if} {objectName} reagiert ({implode from=$reactions key=reactionID item=count}{@$__wcf->getReactionHandler()->getReactionTypeByID($reactionID)->renderIcon()}\u00d7{#$count}{/implode}).\n
wcf.user.notification.{object_type_name}.like.notification.like
Jemandem hat auf {if LANGUAGE_USE_INFORMAL_VARIANT}dein(en){else}Ihr(en){/if} {objectName} reagiert\n
"},{"location":"migration/wsc31/like/#recent-activity","title":"Recent Activity","text":"To adjust entries in the Recent Activity, only three small steps are necessary. First we pass the concrete reaction to the language variable, so that we can use the reaction object there. To do this, we add the following variable to the text of the \\wcf\\system\\user\\activity\\event\\IUserActivityEvent
object: $event->reactionType
. Typically we name the variable reactionType
. In the second step, we mark the event as compatible. Therefore we set the parameter supportsReactions
in the objectType.xml
to 1
. So for example the entry looks like this:
<type>\n<name>com.woltlab.example.likeableObject.recentActivityEvent</name>\n<definitionname>com.woltlab.wcf.user.recentActivityEvent</definitionname>\n<classname>wcf\\system\\user\\activity\\event\\LikeableObjectUserActivityEvent</classname>\n<supportsReactions>1</supportsReactions>\n</type>\n
Finally we modify our language variable. To ensure a consistent usability, the same formulations should be used as in the WoltLab Suite Core.
"},{"location":"migration/wsc31/like/#english_1","title":"English","text":"wcf.user.recentActivity.{object_type_name}.recentActivityEvent
Reaction ({objectName})\n
Your language variable for the recent activity text
Reacted with <span title=\"{$reactionType->getTitle()}\" class=\"jsTooltip\">{@$reactionType->renderIcon()}</span> to the {objectName}.\n
"},{"location":"migration/wsc31/like/#german_1","title":"German","text":"wcf.user.recentActivity.{objectTypeName}.recentActivityEvent
Reaktion ({objectName})\n
Your language variable for the recent activity text
Hat mit <span title=\"{$reactionType->getTitle()}\" class=\"jsTooltip\">{@$reactionType->renderIcon()}</span> auf {objectName} reagiert.\n
"},{"location":"migration/wsc31/like/#comments","title":"Comments","text":"If comments send notifications, they must also be updated. The language variables are changed in the same way as described in the section Notifications / Language. After that comment must be marked as compatible. Therefore we set the parameter supportsReactions
in the objectType.xml
to 1
. So for example the entry looks like this:
<type>\n<name>com.woltlab.wcf.objectComment.response.like.notification</name>\n<definitionname>com.woltlab.wcf.notification.objectType</definitionname>\n<classname>wcf\\system\\user\\notification\\object\\type\\LikeUserNotificationObjectType</classname>\n<category>com.woltlab.example</category>\n<supportsReactions>1</supportsReactions>\n</type>\n
"},{"location":"migration/wsc31/like/#forward-compatibility","title":"Forward Compatibility","text":"So that these changes also work in older versions of WoltLab Suite Core, the used classes and traits were backported with WoltLab Suite Core 3.0.22 and WoltLab Suite Core 3.1.10.
"},{"location":"migration/wsc31/php/","title":"Migrating from WSC 3.1 - PHP","text":""},{"location":"migration/wsc31/php/#form-builder","title":"Form Builder","text":"WoltLab Suite Core 5.2 introduces a new, simpler and quicker way of creating forms: form builder. You can find examples of how to migrate existing forms to form builder here.
In the near future, to ensure backwards compatibility within WoltLab packages, we will only use form builder for new forms or for major rewrites of existing forms that would break backwards compatibility anyway.
"},{"location":"migration/wsc31/php/#like-system","title":"Like System","text":"WoltLab Suite Core 5.2 replaced the like system with the reaction system. You can find the migration guide here.
"},{"location":"migration/wsc31/php/#user-content-providers","title":"User Content Providers","text":"User content providers help the WoltLab Suite to find user generated content. They provide a class with which you can find content from a particular user and delete objects.
"},{"location":"migration/wsc31/php/#php-class","title":"PHP Class","text":"First, we create the PHP class that provides our interface to provide the data. The class must implement interface wcf\\system\\user\\content\\provider\\IUserContentProvider
in any case. Mostly we process data which is based on wcf\\data\\DatabaseObject
. In this case, the WoltLab Suite provides an abstract class wcf\\system\\user\\content\\provider\\AbstractDatabaseUserContentProvider
that can be used to automatically generates the standardized classes to generate the list and deletes objects via the DatabaseObjectAction. For example, if we would create a content provider for comments, the class would look like this:
<?php\nnamespace wcf\\system\\user\\content\\provider;\nuse wcf\\data\\comment\\Comment;\n\n/**\n * User content provider for comments.\n *\n * @author Joshua Ruesweg\n * @copyright 2001-2018 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\System\\User\\Content\\Provider\n * @since 5.2\n */\nclass CommentUserContentProvider extends AbstractDatabaseUserContentProvider {\n /**\n * @inheritdoc\n */\n public static function getDatabaseObjectClass() {\n return Comment::class;\n }\n}\n
"},{"location":"migration/wsc31/php/#object-type","title":"Object Type","text":"Now the appropriate object type must be created for the class. This object type must be from the definition com.woltlab.wcf.content.userContentProvider
and include the previous created class as FQN in the parameter classname
. Also the following parameters can be used in the object type:
nicevalue
","text":"Optional
The nice value is used to determine the order in which the remove content worker are execute the provider. Content provider with lower nice values are executed first.
"},{"location":"migration/wsc31/php/#hidden","title":"hidden
","text":"Optional
Specifies whether or not this content provider can be actively selected in the Content Remove Worker. If it cannot be selected, it will not be executed automatically!
"},{"location":"migration/wsc31/php/#requiredobjecttype","title":"requiredobjecttype
","text":"Optional
The specified list of comma-separated object types are automatically removed during content removal when this object type is being removed. Attention: The order of removal is undefined by default, specify a nicevalue
if the order is important.
WoltLab Suite 5.2 introduces a new way to update the database scheme: database PHP API.
"},{"location":"migration/wsc52/libraries/","title":"Migrating from WoltLab Suite 5.2 - Third Party Libraries","text":""},{"location":"migration/wsc52/libraries/#scss-compiler","title":"SCSS Compiler","text":"WoltLab Suite Core 5.3 upgrades the bundled SCSS compiler from leafo/scssphp
0.7.x to scssphp/scssphp
1.1.x. With the updated composer package name the SCSS compiler also received updated namespaces. WoltLab Suite Core adds a compatibility layer that maps the old namespace to the new namespace. The classes themselves appear to be drop-in compatible. Exceptions cannot be mapped using this compatibility layer, any catch
blocks catching a specific Exception within the Leafo
namespace will need to be adjusted.
More details can be found in the Pull Request WoltLab/WCF#3415.
"},{"location":"migration/wsc52/libraries/#guzzle","title":"Guzzle","text":"WoltLab Suite Core 5.3 ships with a bundled version of Guzzle 6. Going forward using Guzzle is the recommended way to perform HTTP requests. The \\wcf\\util\\HTTPRequest
class should no longer be used and transparently uses Guzzle under the hood.
Use \\wcf\\system\\io\\HttpFactory
to retrieve a correctly configured GuzzleHttp\\ClientInterface
.
Please note that it is recommended to explicitely specify a sink
when making requests, due to a PHP / Guzzle bug. Have a look at the implementation in WoltLab/WCF for an example.
The ICommentManager::isContentAuthor(Comment|CommentResponse): bool
method was added. A default implementation that always returns false
is available when inheriting from AbstractCommentManager
.
It is strongly recommended to implement isContentAuthor
within your custom comment manager. An example implementation can be found in ArticleCommentManager
.
The AbstractEventListener
class was added. AbstractEventListener
contains an implementation of execute()
that will dispatch the event handling to dedicated methods based on the $eventName
and, in case of the event object being an AbstractDatabaseObjectAction
, the action name.
Find the details of the dispatch behavior within the class comment of AbstractEventListener
.
Starting with WoltLab Suite 5.3 the user activation status is independent of the email activation status. A user can be activated even though their email address has not been confirmed, preventing emails being sent to these users. Going forward the new User::isEmailConfirmed()
method should be used to check whether sending automated emails to this user is acceptable. If you need to check the user's activation status you should use the new method User::pendingActivation()
instead of relying on activationCode
. To check, which type of activation is missing, you can use the new methods User::requiresEmailActivation()
and User::requiresAdminActivation()
.
*AddForm
","text":"WoltLab Suite 5.3 provides a new framework to allow the administrator to easily edit newly created objects by adding an edit link to the success message. To support this edit link two small changes are required within your *AddForm
.
Update the template.
Replace:
{include file='formError'}\n\n{if $success|isset}\n <p class=\"success\">{lang}wcf.global.success.{$action}{/lang}</p>\n{/if}\n
With:
{include file='formNotice'}\n
Expose objectEditLink
to the template.
Example ($object
being the newly created object):
WCF::getTPL()->assign([\n 'success' => true,\n 'objectEditLink' => LinkHandler::getInstance()->getControllerLink(ObjectEditForm::class, ['id' => $object->objectID]),\n]);\n
It is recommended by search engines to mark up links within user generated content using the rel=\"ugc\"
attribute to indicate that they might be less trustworthy or spammy.
WoltLab Suite 5.3 will automatically sets that attribute on external links during message output processing. Set the new HtmlOutputProcessor::$enableUgc
property to false
if the type of message is not user-generated content, but restricted to a set of trustworthy users. An example of such a type of message would be official news articles.
If you manually generate links based off user input you need to specify the attribute yourself. The $isUgc
attribute was added to StringUtil::getAnchorTag(string, string, bool, bool): string
, allowing you to easily generate a correct anchor tag.
If you need to specify additional HTML attributes for the anchor tag you can use the new StringUtil::getAnchorTagAttributes(string, bool): string
method to generate the anchor attributes that are dependent on the target URL. Specifically the attributes returned are the class=\"externalURL\"
attribute, the rel=\"\u2026\"
attribute and the target=\"\u2026\"
attribute.
Within the template the {anchorAttributes}
template plugin is newly available.
It was discovered that the code holds references to scaled image resources for an unnecessarily long time, taking up memory. This becomes especially apparent when multiple images are scaled within a loop, reusing the same variable name for consecutive images. Unless the destination variable is explicitely cleared before processing the next image up to two images will be stored in memory concurrently. This possibly causes the request to exceed the memory limit or ImageMagick's internal resource limits, even if sufficient resources would have been available to scale the current image.
Starting with WoltLab Suite 5.3 it is recommended to clear image handles as early as possible. The usual pattern of creating a thumbnail for an existing image would then look like this:
<?php\nforeach ([ 200, 500 ] as $size) {\n $adapter = ImageHandler::getInstance()->getAdapter();\n $adapter->loadFile($src);\n $thumbnail = $adapter->createThumbnail(\n $size,\n $size,\n true\n );\n $adapter->writeImage($thumbnail, $destination);\n // New: Clear thumbnail as soon as possible to free up the memory.\n $thumbnail = null;\n}\n
Refer to WoltLab/WCF#3505 for additional details.
"},{"location":"migration/wsc52/php/#toggle-for-accelerated-mobile-pages-amp","title":"Toggle for Accelerated Mobile Pages (AMP)","text":"Controllers delivering AMP versions of pages have to check for the new option MODULE_AMP
and the templates of the non-AMP versions have to also check if the option is enabled before outputting the <link rel=\"amphtml\" />
element.
{jslang}
","text":"Starting with WoltLab Suite 5.3 the {jslang}
template plugin is available. {jslang}
works like {lang}
, with the difference that the result is automatically encoded for use within a single quoted JavaScript string.
Before:
<script>\nrequire(['Language', /* \u2026 */], function(Language, /* \u2026 */) {\n Language.addObject({\n 'app.foo.bar': '{lang}app.foo.bar{/lang}',\n });\n\n // \u2026\n});\n</script>\n
After:
<script>\nrequire(['Language', /* \u2026 */], function(Language, /* \u2026 */) {\n Language.addObject({\n 'app.foo.bar': '{jslang}app.foo.bar{/jslang}',\n });\n\n // \u2026\n});\n</script>\n
"},{"location":"migration/wsc52/templates/#template-plugins","title":"Template Plugins","text":"The {anchor}
, {plural}
, and {user}
template plugins have been added.
In addition to using the new template plugins mentioned above, language items for notifications have been further simplified.
As the whole notification is clickable now, all a
elements have been replaced with strong
elements in notification messages.
The template code to output reactions has been simplified by introducing helper methods:
{* old *}\n{implode from=$reactions key=reactionID item=count}{@$__wcf->getReactionHandler()->getReactionTypeByID($reactionID)->renderIcon()}\u00d7{#$count}{/implode}\n{* new *}\n{@$__wcf->getReactionHandler()->renderInlineList($reactions)}\n\n{* old *}\n<span title=\"{$like->getReactionType()->getTitle()}\" class=\"jsTooltip\">{@$like->getReactionType()->renderIcon()}</span>\n{* new *}\n{@$like->render()}\n
Similarly, showing labels is now also easier due to the new render
method:
{* old *}\n<span class=\"label badge{if $label->getClassNames()} {$label->getClassNames()}{/if}\">{$label->getTitle()}</span>\n{* new *}\n{@$label->render()}\n
The commonly used template code
{if $count < 4}{@$authors[0]->getAnchorTag()}{if $count != 1}{if $count == 2 && !$guestTimesTriggered} and {else}, {/if}{@$authors[1]->getAnchorTag()}{if $count == 3}{if !$guestTimesTriggered} and {else}, {/if} {@$authors[2]->getAnchorTag()}{/if}{/if}{if $guestTimesTriggered} and {if $guestTimesTriggered == 1}a guest{else}guests{/if}{/if}{else}{@$authors[0]->getAnchorTag()}{if $guestTimesTriggered},{else} and{/if} {#$others} other users {if $guestTimesTriggered}and {if $guestTimesTriggered == 1}a guest{else}guests{/if}{/if}{/if}\n
in stacked notification messages can be replaced with a new language item:
{@'wcf.user.notification.stacked.authorList'|language}\n
"},{"location":"migration/wsc52/templates/#popovers","title":"Popovers","text":"Popovers provide additional information of the linked object when a user hovers over a link. We unified the approach for such links:
wcf\\data\\IPopoverObject
.wcf\\data\\IPopoverAction
and the getPopover()
method returns an array with popover content.WoltLabSuite/Core/Controller/Popover
is initialized with the relevant data.anchor
template plugin with an additional class
attribute whose value is the return value of IPopoverObject::getPopoverLinkClass()
.Example:
files/lib/data/foo/Foo.class.phpclass Foo extends DatabaseObject implements IPopoverObject {\n public function getPopoverLinkClass() {\n return 'fooLink';\n }\n}\n
files/lib/data/foo/FooAction.class.phpclass FooAction extends AbstractDatabaseObjectAction implements IPopoverAction {\n public function validateGetPopover() {\n // \u2026\n }\n\n public function getPopover() {\n return [\n 'template' => '\u2026',\n ];\n }\n}\n
require(['WoltLabSuite/Core/Controller/Popover'], function(ControllerPopover) {\nControllerPopover.init({\nclassName: 'fooLink',\ndboAction: 'wcf\\\\data\\\\foo\\\\FooAction',\nidentifier: 'com.woltlab.wcf.foo'\n});\n});\n
{anchor object=$foo class='fooLink'}\n
"},{"location":"migration/wsc53/javascript/","title":"Migrating from WoltLab Suite 5.3 - TypeScript and JavaScript","text":""},{"location":"migration/wsc53/javascript/#typescript","title":"TypeScript","text":"WoltLab Suite 5.4 introduces TypeScript support. Learn about consuming WoltLab Suite\u2019s types in the TypeScript section of the JavaScript API documentation.
The JavaScript API documentation will be updated to properly take into account the changes that came with the new TypeScript support in the future. Existing AMD based modules have been migrated to TypeScript, but will expose the existing and known API.
It is recommended that you migrate your custom packages to make use of TypeScript. It will make consuming newly written modules that properly leverage TypeScript\u2019s features much more pleasant and will also ease using existing modules due to proper autocompletion and type checking.
"},{"location":"migration/wsc53/javascript/#replacements-for-deprecated-components","title":"Replacements for Deprecated Components","text":"The helper functions in wcf.globalHelper.js
should not be used anymore but replaced by their native counterpart:
elCreate(tag)
document.createElement(tag)
elRemove(el)
el.remove()
elShow(el)
DomUtil.show(el)
elHide(el)
DomUtil.hide(el)
elIsHidden(el)
DomUtil.isHidden(el)
elToggle(el)
DomUtil.toggle(el)
elAttr(el, \"attr\")
el.attr
or el.getAttribute(\"attr\")
elData(el, \"data\")
el.dataset.data
elDataBool(element, \"data\")
Core.stringToBool(el.dataset.data)
elById(id)
document.getElementById(id)
elBySel(sel)
document.querySelector(sel)
elBySel(sel, el)
el.querySelector(sel)
elBySelAll(sel)
document.querySelectorAll(sel)
elBySelAll(sel, el)
el.querySelectorAll(sel)
elBySelAll(sel, el, callback)
el.querySelectorAll(sel).forEach((el) => callback(el));
elClosest(el, sel)
el.closest(sel)
elByClass(class)
document.getElementsByClassName(class)
elByClass(class, el)
el.getElementsByClassName(class)
elByTag(tag)
document.getElementsByTagName(tag)
elByTag(tag, el)
el.getElementsByTagName(tag)
elInnerError(el, message, isHtml)
DomUtil.innerError(el, message, isHtml)
Additionally, the following modules should also be replaced by their native counterpart:
Module Native ReplacementWoltLabSuite/Core/Dictionary
Map
WoltLabSuite/Core/List
Set
WoltLabSuite/Core/ObjectMap
WeakMap
For event listeners on click events, WCF_CLICK_EVENT
is deprecated and should no longer be used. Instead, use click
directly:
// before\nelement.addEventListener(WCF_CLICK_EVENT, this._click.bind(this));\n\n// after\nelement.addEventListener('click', (ev) => this._click(ev));\n
"},{"location":"migration/wsc53/javascript/#wcfactiondelete-and-wcfactiontoggle","title":"WCF.Action.Delete
and WCF.Action.Toggle
","text":"WCF.Action.Delete
and WCF.Action.Toggle
were used for buttons to delete or enable/disable objects via JavaScript. In each template, WCF.Action.Delete
or WCF.Action.Toggle
instances had to be manually created for each object listing.
With version 5.4 of WoltLab Suite, we have added a CSS selector-based global TypeScript module that only requires specific CSS classes to be added to the HTML structure for these buttons to work. Additionally, we have added a new {objectAction}
template plugin, which generates these buttons reducing the amount of boilerplate template code.
The required base HTML structure is as follows:
.jsObjectActionContainer
element with a data-object-action-class-name
attribute that contains the name of PHP class that executes the actions..jsObjectActionObject
elements within .jsObjectActionContainer
that represent the objects for which actions can be executed. Each .jsObjectActionObject
element must have a data-object-id
attribute with the id of the object..jsObjectAction
elements within .jsObjectActionObject
for each action with a data-object-action
attribute with the name of the action. These elements can be generated with the {objectAction}
template plugin for the delete
and toggle
action.Example:
<table class=\"table jsObjectActionContainer\" {*\n *}data-object-action-class-name=\"wcf\\data\\foo\\FooAction\">\n <thead>\n <tr>\n{* \u2026 *}\n </tr>\n </thead>\n\n <tbody>\n{foreach from=$objects item=foo}\n <tr class=\"jsObjectActionObject\" data-object-id=\"{$foo->getObjectID()}\">\n <td class=\"columnIcon\">\n{objectAction action=\"toggle\" isDisabled=$foo->isDisabled}\n{objectAction action=\"delete\" objectTitle=$foo->getTitle()}\n{* \u2026 *}\n </td>\n{* \u2026 *}\n </tr>\n{/foreach}\n </tbody>\n</table>\n
Please refer to the documentation in ObjectActionFunctionTemplatePlugin
for details and examples on how to use this template plugin.
The relevant TypeScript module registering the event listeners on the object action buttons is Ui/Object/Action
. When an action button is clicked, an AJAX request is sent using the PHP class name and action name. After the successful execution of the action, the page is either reloaded if the action button has a data-object-action-success=\"reload\"
attribute or an event using the EventHandler
module is fired using WoltLabSuite/Core/Ui/Object/Action
as the identifier and the object action name. Ui/Object/Action/Delete
and Ui/Object/Action/Toggle
listen to these events and update the user interface depending on the execute action by removing the object or updating the toggle button, respectively.
Converting from WCF.Action.*
to the new approach requires minimal changes per template, as shown in the relevant pull request #4080.
WCF.Table.EmptyTableHandler
","text":"When all objects in a table or list are deleted via their delete button or clipboard actions, an empty table or list can remain. Previously, WCF.Table.EmptyTableHandler
had to be explicitly used in each template for these tables and lists to reload the page. As a TypeScript-based replacement for WCF.Table.EmptyTableHandler
that is only initialized once globally, WoltLabSuite/Core/Ui/Empty
was added. To use this new module, you only have to add the CSS class jsReloadPageWhenEmpty
to the relevant HTML element. Once this HTML element no longer has child elements, the page is reloaded. To also cover scenarios in which there are fixed child elements that should not be considered when determining if there are no child elements, the data-reload-page-when-empty=\"ignore\"
can be set for these elements.
Examples:
<table class=\"table\">\n <thead>\n <tr>\n{* \u2026 *}\n </tr>\n </thead>\n\n <tbody class=\"jsReloadPageWhenEmpty\">\n{foreach from=$objects item=object}\n <tr>\n{* \u2026 *}\n </tr>\n{/foreach}\n </tbody>\n</table>\n
<div class=\"section tabularBox messageGroupList\">\n <ol class=\"tabularList jsReloadPageWhenEmpty\">\n <li class=\"tabularListRow tabularListRowHead\" data-reload-page-when-empty=\"ignore\">\n{* \u2026 *}\n </li>\n\n{foreach from=$objects item=object}\n <li>\n{* \u2026 *}\n </li>\n{/foreach}\n </ol>\n</div>\n
"},{"location":"migration/wsc53/libraries/","title":"Migrating from WoltLab Suite 5.3 - Third Party Libraries","text":""},{"location":"migration/wsc53/libraries/#guzzle","title":"Guzzle","text":"The bundled Guzzle version was updated to Guzzle 7. No breaking changes are expected for simple uses. A detailed Guzzle migration guide can be found in the Guzzle documentation.
The explicit sink
that was recommended in the migration guide for WoltLab Suite 5.2 can now be removed, as the Guzzle issue #2735 was fixed in Guzzle 7.
The Emogrifier library was updated from version 2.2 to 5.0. This update comes with a breaking change, as the Emogrifier
class was removed. With the updated Emogrifier library, the CssInliner
class must be used instead.
No compatibility layer was added for the Emogrifier
class, as the Emogrifier library's purpose was to be used within the email subsystem of WoltLab Suite. In case you use Emogrifier directly within your own code, you will need to adjust the usage. Refer to the Emogrifier CHANGELOG and WoltLab/WCF #3738 if you need help making the necessary adjustments.
If you only use Emogrifier indirectly by sending HTML mail via the email subsystem then you might notice unexpected visual changes due to the improved CSS support. Double check your CSS declarations and particularly the specificity of your selectors in these cases.
"},{"location":"migration/wsc53/libraries/#scssphp","title":"scssphp","text":"scssphp was updated from version 1.1 to 1.4.
If you interact with scssphp only by deploying .scss
files, then you should not experience any breaking changes, except when the improved SCSS compatibility interprets your SCSS code differently.
If you happen to directly use scssphp in your PHP code, you should be aware that scssphp deprecated the use of output formatters in favor of a simple output style enum.
Refer to WoltLab/WCF #3851 and the scssphp releases for details.
"},{"location":"migration/wsc53/libraries/#constant-time-encoder","title":"Constant Time Encoder","text":"WoltLab Suite 5.4 ships the paragonie/constant_time_encoding
library. It is recommended to use this library to perform encoding and decoding of secrets to prevent leaks via cache timing attacks. Refer to the library author\u2019s blog post for more background detail.
For the common case of encoding the bytes taken from a CSPRNG in hexadecimal form, the required change would look like the following:
Previously:
<?php\n$encoded = hex2bin(random_bytes(16));\n
Now:
<?php\nuse ParagonIE\\ConstantTime\\Hex;\n\n// For security reasons you should add the backslash\n// to ensure you refer to the `random_bytes` function\n// within the global namespace and not a function\n// defined in the current namespace.\n$encoded = Hex::encode(\\random_bytes(16));\n
Please refer to the documentation and source code of the paragonie/constant_time_encoding
library to learn how to use the library with different encodings (e.g. base64).
The minimum requirements have been increased to the following:
Most notably PHP 7.2 contains usable support for scalar types by the addition of nullable types in PHP 7.1 and parameter type widening in PHP 7.2.
It is recommended to make use of scalar types and other newly introduced features whereever possible. Please refer to the PHP documentation for details.
"},{"location":"migration/wsc53/php/#flood-control","title":"Flood Control","text":"To prevent users from creating massive amounts of contents in short periods of time, i.e., spam, existing systems already use flood control mechanisms to limit the amount of contents created within a certain period of time. With WoltLab Suite 5.4, we have added a general API that manages such rate limiting. Leveraging this API is easily done.
com.woltlab.wcf.floodControl
: com.example.foo.myContent
.FloodControl::getInstance()->registerContent('com.example.foo.myContent');\n
You should only call this method if the user creates the content themselves. If the content is automatically created by the system, for example when copying / duplicating existing content, no activity should be registered.To check the last time when the active user created content of the relevant type, use
FloodControl::getInstance()->getLastTime('com.example.foo.myContent');\n
If you want to limit the number of content items created within a certain period of time, for example within one day, use $data = FloodControl::getInstance()->countContent('com.example.foo.myContent', new \\DateInterval('P1D'));\n// number of content items created within the last day\n$count = $data['count'];\n// timestamp when the earliest content item was created within the last day\n$earliestTime = $data['earliestTime'];\n
The method also returns earliestTime
so that you can tell the user in the error message when they are able again to create new content of the relevant type. Flood control entries are only stored for 31 days and older entries are cleaned up daily.
The previously mentioned methods of FloodControl
use the active user and the current timestamp as reference point. FloodControl
also provides methods to register content or check flood control for other registered users or for guests via their IP address. For further details on these methods, please refer to the documentation in the FloodControl class.
Do not interact directly with the flood control database table but only via the FloodControl
class!
DatabasePackageInstallationPlugin
is a new idempotent package installation plugin (thus it is available in the sync function in the devtools) to update the database schema using the PHP-based database API. DatabasePackageInstallationPlugin
is similar to ScriptPackageInstallationPlugin
by requiring a PHP script that is included during the execution of the script. The script is expected to return an array of DatabaseTable
objects representing the schema changes so that in contrast to using ScriptPackageInstallationPlugin
, no DatabaseTableChangeProcessor
object has to be created. The PHP file must be located in the acp/database/
directory for the devtools sync function to recognize the file.
The PHP API to add and change database tables during package installations and updates in the wcf\\system\\database\\table
namespace now also supports renaming existing table columns with the new IDatabaseTableColumn::renameTo()
method:
PartialDatabaseTable::create('wcf1_test')\n ->columns([\n NotNullInt10DatabaseTableColumn::create('oldName')\n ->renameTo('newName')\n ]);\n
Like with every change to existing database tables, packages can only rename columns that they installed.
"},{"location":"migration/wsc53/php/#captcha","title":"Captcha","text":"The reCAPTCHA v1 implementation was completely removed. This includes the \\wcf\\system\\recaptcha\\RecaptchaHandler
class (not to be confused with the one in the captcha
namespace).
The reCAPTCHA v1 endpoints have already been turned off by Google and always return a HTTP 404. Thus the implementation was completely non-functional even before this change.
See WoltLab/WCF#3781 for details.
"},{"location":"migration/wsc53/php/#search","title":"Search","text":"The generic implementation in the AbstractSearchEngine::parseSearchQuery()
method was dangerous, because it did not have knowledge about the search engine\u2019s specifics. The implementation was completely removed: AbstractSearchEngine::parseSearchQuery()
now always throws a \\BadMethodCallException
.
If you implemented a custom search engine and relied on this method, you can inline the previous implementation to preserve existing behavior. You should take the time to verify the rewritten queries against the manual of the search engine to make sure it cannot generate malformed queries or security issues.
See WoltLab/WCF#3815 for details.
"},{"location":"migration/wsc53/php/#styles","title":"Styles","text":"The StyleCompiler
class is marked final
now. The internal SCSS compiler object being stored in the $compiler
property was a design issue that leaked compiler state across multiple compiled styles, possibly causing misgenerated stylesheets. As the removal of the $compiler
property effectively broke compatibility within the StyleCompiler
and as the StyleCompiler
never was meant to be extended, it was marked final.
See WoltLab/WCF#3929 for details.
"},{"location":"migration/wsc53/php/#tags","title":"Tags","text":"Use of the wcf1_tag_to_object.languageID
column is deprecated. The languageID
column is redundant, because its value can be derived from the tagID
. With WoltLab Suite 5.4, it will no longer be part of any indices, allowing more efficient index usage in the general case.
If you need to filter the contents of wcf1_tag_to_object
by language, you should perform an INNER JOIN wcf1_tag tag ON tag.tagID = tag_to_object.tagID
and filter on wcf1_tag.languageID
.
See WoltLab/WCF#3904 for details.
"},{"location":"migration/wsc53/php/#avatars","title":"Avatars","text":"The ISafeFormatAvatar
interface was added to properly support fallback image types for use in emails. If your custom IUserAvatar
implementation supports image types without broad support (i.e. anything other than PNG, JPEG, and GIF), then you should implement the ISafeFormatAvatar
interface to return a fallback PNG, JPEG, or GIF image.
See WoltLab/WCF#4001 for details.
"},{"location":"migration/wsc53/php/#linebreakseparatedtext-option-type","title":"lineBreakSeparatedText
Option Type","text":"Currently, several of the (user group) options installed by our packages use the textarea
option type and split its value by linebreaks to get a list of items, for example for allowed file extensions. To improve the user interface when setting up the value of such options, we have added the lineBreakSeparatedText
option type as a drop-in replacement where the individual items are explicitly represented as distinct items in the user interface.
WoltLab Suite 5.4 distinguishes between blocking direct contact only and hiding all contents when ignoring users. To allow for detecting the difference, the UserProfile::getIgnoredUsers()
and UserProfile::isIgnoredUser()
methods received a new $type
parameter. Pass either UserIgnore::TYPE_BLOCK_DIRECT_CONTACT
or UserIgnore::TYPE_HIDE_MESSAGES
depending on whether the check refers to a non-directed usage or content.
See WoltLab/WCF#4064 and WoltLab/WCF#3981 for details.
"},{"location":"migration/wsc53/php/#databaseprepare","title":"Database::prepare()
","text":"Database::prepare(string $statement, int $limit = 0, int $offset = 0): PreparedStatement
works the same way as Database::prepareStatement()
but additionally also replaces all occurences of app1_
with app{WCF_N}_
for all installed apps. This new method makes it superfluous to use WCF_N
when building queries.
WoltLab Suite 5.4 includes a completely refactored session handling. As long as you only interact with sessions via WCF::getSession()
, especially when you perform read-only accesses, you should not notice any breaking changes.
You might appreciate some of the new session methods if you process security sensitive data.
"},{"location":"migration/wsc53/session/#summary-and-concepts","title":"Summary and Concepts","text":"Most of the changes revolve around the removal of the legacy persistent login functionality and the assumption that every user has a single session only. Both aspects are related to each other.
"},{"location":"migration/wsc53/session/#legacy-persistent-login","title":"Legacy Persistent Login","text":"The legacy persistent login was rather an automated login. Upon bootstrapping a session, it was checked whether the user had a cookie pair storing the user\u2019s userID
and (a single BCrypt hash of) the user\u2019s password. If such a cookie pair exists and the BCrypt hash within the cookie matches the user\u2019s password hash when hashed again, the session would immediately changeUser()
to the respective user.
This legacy persistent login was completely removed. Instead, any sessions that belong to an authenticated user will automatically be long-lived. These long-lived sessions expire no sooner than 14 days after the last activity, ensuring that the user continously stays logged in, provided that they visit the page at least once per fortnight.
"},{"location":"migration/wsc53/session/#multiple-sessions","title":"Multiple Sessions","text":"To allow for a proper separation of these long-lived user sessions, WoltLab Suite now allows for multiple sessions per user. These sessions are completely unrelated to each other. Specifically, they do not share session variables and they expire independently.
As the existing wcf1_session
table is also used for the online lists and location tracking, it will be maintained on a best effort basis. It no longer stores any private session data.
The actual sessions storing security sensitive information are in an unrelated location. They must only be accessed via the PHP API exposed by the SessionHandler
.
WoltLab Suite 5.4 shares a single session across both the frontend, as well as the ACP. When a user logs in to the frontend, they will also be logged into the ACP and vice versa.
Actual access to the ACP is controlled via the new reauthentication mechanism.
The session variable store is scoped: Session variables set within the frontend are not available within the ACP and vice versa.
"},{"location":"migration/wsc53/session/#improved-authentication-and-reauthentication","title":"Improved Authentication and Reauthentication","text":"WoltLab Suite 5.4 ships with multi-factor authentication support and a generic re-authentication implementation that can be used to verify the account owner\u2019s presence.
"},{"location":"migration/wsc53/session/#additions-and-changes","title":"Additions and Changes","text":""},{"location":"migration/wsc53/session/#password-hashing","title":"Password Hashing","text":"WoltLab Suite 5.4 includes a new object-oriented password hashing framework that is modeled after PHP\u2019s password_*
API. Check PasswordAlgorithmManager
and IPasswordAlgorithm
for details.
The new default password hash is a standard BCrypt hash. All newly generated hashes in wcf1_user.password
will now include a type prefix, instead of just passwords imported from other systems.
The wcf1_session
table will no longer be used for session storage. Instead, it is maintained for compatibility with existing online lists.
The actual session storage is considered an implementation detail and you must not directly interact with the session tables. Future versions might support alternative session backends, such as Redis.
Do not interact directly with the session database tables but only via the SessionHandler
class!
For security sensitive processing, you might want to ensure that the account owner is actually present instead of a third party accessing a session that was accidentally left logged in.
WoltLab Suite 5.4 ships with a generic reauthentication framework. To request reauthentication within your controller you need to:
wcf\\system\\user\\authentication\\TReauthenticationCheck
trait.$this->requestReauthentication(LinkHandler::getInstance()->getControllerLink(static::class, [\n /* additional parameters */\n]));\n
requestReauthentication()
will check if the user has recently authenticated themselves. If they did, the request proceeds as usual. Otherwise, they will be asked to reauthenticate themselves. After the successful authentication, they will be redirected to the URL that was passed as the first parameter (the current controller within the example).
Details can be found in WoltLab/WCF#3775.
"},{"location":"migration/wsc53/session/#multi-factor-authentication","title":"Multi-factor Authentication","text":"To implement multi-factor authentication securely, WoltLab Suite 5.4 implements the concept of a \u201cpending user change\u201d. The user will not be logged in (i.e. WCF::getUser()->userID
returns null
) until they authenticate themselves with their second factor.
Requesting multi-factor authentication is done on an opt-in basis for compatibility reasons. If you perform authentication yourself and do not trust the authentication source to perform multi-factor authentication itself, you will need to adjust your logic to request multi-factor authentication from WoltLab Suite:
Previously:
WCF::getSession()->changeUser($targetUser);\n
Now:
$isPending = WCF::getSession()->changeUserAfterMultifactorAuthentication($targetUser);\nif ($isPending) {\n // Redirect to the authentication form. The user will not be logged in.\n // Note: Do not use `getControllerLink` to support both the frontend as well as the ACP.\n HeaderUtil::redirect(LinkHandler::getInstance()->getLink('MultifactorAuthentication', [\n 'url' => /* Return To */,\n ]));\n exit;\n}\n// Proceed as usual. The user will be logged in.\n
"},{"location":"migration/wsc53/session/#adding-multi-factor-methods","title":"Adding Multi-factor Methods","text":"Adding your own multi-factor method requires the implementation of a single object type:
objectType.xml<type>\n<name>com.example.multifactor.foobar</name>\n<definitionname>com.woltlab.wcf.multifactor</definitionname>\n<icon><!-- Font Awesome 4 Icon Name goes here. --></icon>\n<priority><!-- Determines the sort order, higher priority will be preferred for authentication. --></priority>\n<classname>wcf\\system\\user\\multifactor\\FoobarMultifactorMethod</classname>\n</type>\n
The given classname must implement the IMultifactorMethod
interface.
As a self-contained example, you can find the initial implementation of the email multi-factor method in WoltLab/WCF#3729. Please check the version history of the PHP class to make sure you do not miss important changes that were added later.
Multi-factor authentication is security sensitive. Make sure to carefully read the remarks in IMultifactorMethod
for possible issues. Also make sure to carefully test your implementation against all sorts of incorrect input and consider attack vectors such as race conditions. It is strongly recommended to generously check the current state by leveraging assertions and exceptions.
To enforce Multi-factor Authentication within your controller you need to:
wcf\\system\\user\\multifactor\\TMultifactorRequirementEnforcer
trait.$this->enforceMultifactorAuthentication();
enforceMultifactorAuthentication()
will check if the user is in a group that requires multi-factor authentication, but does not yet have multi-factor authentication enabled. If they did, the request proceeds as usual. Otherwise, a NamedUserException
is thrown.
Most of the changes with regard to the new session handling happened in SessionHandler
. Most notably, SessionHandler
now is marked final
to ensure proper encapsulation of data.
A number of methods in SessionHandler
are now deprecated and result in a noop. This change mostly affects methods that have been used to bootstrap the session, such as setHasValidCookie()
.
Additionally, accessing the following keys on the session is deprecated. They directly map to an existing method in another class and any uses can easily be updated: - ipAddress
- userAgent
- requestURI
- requestMethod
- lastActivityTime
Refer to the implementation for details.
"},{"location":"migration/wsc53/session/#acp-sessions","title":"ACP Sessions","text":"The database tables related to ACP sessions have been removed. The PHP classes have been preserved due to being used within the class hierarchy of the legacy sessions.
"},{"location":"migration/wsc53/session/#cookies","title":"Cookies","text":"The _userID
, _password
, _cookieHash
and _cookieHash_acp
cookies will no longer be created nor consumed.
The virtual session logic existed to support multiple devices per single session in wcf1_session
. Virtual sessions are no longer required with the refactored session handling.
Anything related to virtual sessions has been completely removed as they are considered an implementation detail. This removal includes PHP classes and database tables.
"},{"location":"migration/wsc53/session/#security-token-constants","title":"Security Token Constants","text":"The security token constants are deprecated. Instead, the methods of SessionHandler
should be used (e.g. ->getSecurityToken()
). Within templates, you should migrate to the {csrfToken}
tag in place of {@SECURITY_TOKEN_INPUT_TAG}
. The {csrfToken}
tag is a drop-in replacement and was backported to WoltLab Suite 5.2+, allowing you to maintain compatibility across a broad range of versions.
Most of the methods in PasswordUtil are deprecated in favor of the new password hashing framework.
"},{"location":"migration/wsc53/templates/","title":"Migrating from WoltLab Suite 5.3 - Templates and Languages","text":""},{"location":"migration/wsc53/templates/#csrftoken","title":"{csrfToken}
","text":"Going forward, any uses of the SECURITY_TOKEN_*
constants should be avoided. To reference the CSRF token (\u201cSecurity Token\u201d) within templates, the {csrfToken}
template plugin was added.
Before:
{@SECURITY_TOKEN_INPUT_TAG}\n{link controller=\"Foo\"}t={@SECURITY_TOKEN}{/link}\n
After:
{csrfToken}\n{link controller=\"Foo\"}t={csrfToken type=url}{/link} {* The use of the CSRF token in URLs is discouraged.\n Modifications should happen by means of a POST request. *}\n
The {csrfToken}
plugin was backported to WoltLab Suite 5.2 and higher, allowing compatibility with a large range of WoltLab Suite branches. See WoltLab/WCF#3612 for details.
Prior to version 5.4 of WoltLab Suite, all RSS feed links contained the access token for logged-in users so that the feed shows all contents the specific user has access to. With version 5.4, links with the CSS class rssFeed
will open a dialog when clicked that offers the feed link with the access token for personal use and an anonymous feed link that can be shared with others.
{* before *}\n<li>\n <a rel=\"alternate\" {*\n *}href=\"{if $__wcf->getUser()->userID}{link controller='ArticleFeed'}at={@$__wcf->getUser()->userID}-{@$__wcf->getUser()->accessToken}{/link}{else}{link controller='ArticleFeed'}{/link}{/if}\" {*\n *}title=\"{lang}wcf.global.button.rss{/lang}\" {*\n *}class=\"jsTooltip\"{*\n *}>\n <span class=\"icon icon16 fa-rss\"></span>\n <span class=\"invisible\">{lang}wcf.global.button.rss{/lang}</span>\n </a>\n</li>\n\n{* after *}\n<li>\n <a rel=\"alternate\" {*\n *}href=\"{if $__wcf->getUser()->userID}{link controller='ArticleFeed'}at={@$__wcf->getUser()->userID}-{@$__wcf->getUser()->accessToken}{/link}{else}{link controller='ArticleFeed'}{/link}{/if}\" {*\n *}title=\"{lang}wcf.global.button.rss{/lang}\" {*\n *}class=\"rssFeed jsTooltip\"{*\n *}>\n <span class=\"icon icon16 fa-rss\"></span>\n <span class=\"invisible\">{lang}wcf.global.button.rss{/lang}</span>\n </a>\n</li>\n
"},{"location":"migration/wsc54/deprecations_removals/","title":"Migrating from WoltLab Suite 5.4 - Deprecations and Removals","text":"With version 5.5, we have deprecated certain components and removed several other components that have been deprecated for many years.
"},{"location":"migration/wsc54/deprecations_removals/#deprecations","title":"Deprecations","text":""},{"location":"migration/wsc54/deprecations_removals/#php","title":"PHP","text":""},{"location":"migration/wsc54/deprecations_removals/#classes","title":"Classes","text":"filebase\\system\\file\\FileDataHandler
(use filebase\\system\\cache\\runtime\\FileRuntimeCache
)wcf\\action\\AbstractAjaxAction
(use PSR-7 responses, WoltLab/WCF#4437)wcf\\data\\IExtendedMessageQuickReplyAction
(WoltLab/WCF#4575)wcf\\form\\SearchForm
(see WoltLab/WCF#4605)wcf\\page\\AbstractSecurePage
(WoltLab/WCF#4515)wcf\\page\\SearchResultPage
(see WoltLab/WCF#4605)wcf\\system\\database\\table\\column\\TUnsupportedDefaultValue
(do not implement IDefaultValueDatabaseTableColumn
, see WoltLab/WCF#4733)wcf\\system\\exception\\ILoggingAwareException
(WoltLab/WCF#4547)wcf\\system\\io\\FTP
(directly use the FTP extension)wcf\\system\\search\\AbstractSearchableObjectType
(use AbstractSearchProvider
instead, see WoltLab/WCF#4605)wcf\\system\\search\\elasticsearch\\ElasticsearchException
wcf\\system\\search\\ISearchableObjectType
(use ISearchProvider
instead, see WoltLab/WCF#4605)wcf\\util\\PasswordUtil
wcf\\action\\MessageQuoteAction::markForRemoval()
(WoltLab/WCF#4452)wcf\\data\\user\\avatar\\UserAvatarAction::fetchRemoteAvatar()
(WoltLab/WCF#4744)wcf\\data\\user\\notification\\UserNotificationAction::getOutstandingNotifications()
(WoltLab/WCF#4603)wcf\\data\\moderation\\queue\\ModerationQueueAction::getOutstandingQueues()
(WoltLab/WCF#4603)wcf\\system\\message\\QuickReplyManager::setTmpHash()
(WoltLab/WCF#4575)wcf\\system\\request\\Request::isExecuted()
(WoltLab/WCF#4485)wcf\\system\\search\\elasticsearch\\ElasticsearchHandler::query()
wcf\\system\\session\\Session::getDeviceIcon()
(WoltLab/WCF#4525)wcf\\system\\user\\authentication\\password\\algorithm\\TPhpass::hash()
(WoltLab/WCF#4602)wcf\\system\\user\\authentication\\password\\algorithm\\TPhpass::needsRehash()
(WoltLab/WCF#4602)wcf\\system\\WCF::getAnchor()
(WoltLab/WCF#4580)wcf\\util\\MathUtil::getRandomValue()
(WoltLab/WCF#4280)wcf\\util\\StringUtil::encodeJSON()
(WoltLab/WCF#4645)wcf\\util\\StringUtil::endsWith()
(WoltLab/WCF#4509)wcf\\util\\StringUtil::getHash()
(WoltLab/WCF#4279)wcf\\util\\StringUtil::split()
(WoltLab/WCF#4513)wcf\\util\\StringUtil::startsWith()
(WoltLab/WCF#4509)wcf\\util\\UserUtil::isAvailableEmail()
(WoltLab/WCF#4602)wcf\\util\\UserUtil::isAvailableUsername()
(WoltLab/WCF#4602)wcf\\acp\\page\\PackagePage::$compatibleVersions
(WoltLab/WCF#4371)wcf\\system\\io\\GZipFile::$gzopen64
(WoltLab/WCF#4381)wcf\\system\\search\\elasticsearch\\ElasticsearchHandler::DELETE
wcf\\system\\search\\elasticsearch\\ElasticsearchHandler::GET
wcf\\system\\search\\elasticsearch\\ElasticsearchHandler::POST
wcf\\system\\search\\elasticsearch\\ElasticsearchHandler::PUT
wcf\\system\\visitTracker\\VisitTracker::DEFAULT_LIFETIME
(WoltLab/WCF#4757)escapeString
helper (WoltLab/WCF#4506)HTTP_SEND_X_FRAME_OPTIONS
(WoltLab/WCF#4474)ELASTICSEARCH_ALLOW_LEADING_WILDCARD
WBB_MODULE_IGNORE_BOARDS
(The option will be always on with WoltLab Suite Forum 5.5 and will be removed with a future version.)ENABLE_DESKTOP_NOTIFICATIONS
(WoltLab/WCF#4806)WCF.Message.Quote.Manager.markQuotesForRemoval()
(WoltLab/WCF#4452)WCF.Search.Message.KeywordList
(WoltLab/WCF#4402)SECURITY_TOKEN
(use Core.getXsrfToken()
, WoltLab/WCF#4523)WCF.Dropdown.Interactive.Handler
(WoltLab/WCF#4603)WCF.Dropdown.Interactive.Instance
(WoltLab/WCF#4603)WCF.User.Panel.Abstract
(WoltLab/WCF#4603)wcf1_package_compatibility
(WoltLab/WCF#4371)wcf1_package_update_compatibility
(WoltLab/WCF#4385)wcf1_package_update_optional
(WoltLab/WCF#4432)|encodeJSON
(WoltLab/WCF#4645){fetch}
(WoltLab/WCF#4891)pageNavbarTop::navigationIcons
search::queryOptions
search::authorOptions
search::periodOptions
search::displayOptions
search::generalFields
$_REQUEST['styleID']
) is deprecated (WoltLab/WCF@0c0111e946)gallery\\util\\ExifUtil
wbb\\action\\BoardQuickSearchAction
wbb\\data\\thread\\NewsList
wbb\\data\\thread\\News
wcf\\action\\PollAction
(WoltLab/WCF#4662)wcf\\form\\RecaptchaForm
(WoltLab/WCF#4289)wcf\\system\\background\\job\\ElasticSearchIndexBackgroundJob
wcf\\system\\cache\\builder\\TemplateListenerCacheBuilder
(WoltLab/WCF#4297)wcf\\system\\log\\modification\\ModificationLogHandler
(WoltLab/WCF#4340)wcf\\system\\recaptcha\\RecaptchaHandlerV2
(WoltLab/WCF#4289)wcf\\system\\search\\SearchKeywordManager
(WoltLab/WCF#4313)Leafo
class aliases (WoltLab/WCF#4343, Migration Guide from 5.2 to 5.3)wbb\\data\\board\\BoardCache::getLabelGroups()
wbb\\data\\post\\PostAction::jumpToExtended()
(this method always threw a BadMethodCallException
)wbb\\data\\thread\\ThreadAction::countReplies()
wbb\\data\\thread\\ThreadAction::validateCountReplies()
wcf\\acp\\form\\UserGroupOptionForm::verifyPermissions()
(WoltLab/WCF#4312)wcf\\data\\conversation\\message\\ConversationMessageAction::jumpToExtended()
(WoltLab/com.woltlab.wcf.conversation#162)wcf\\data\\moderation\\queue\\ModerationQueueEditor::markAsDone()
(WoltLab/WCF#4317)wcf\\data\\tag\\TagCloudTag::getSize()
(WoltLab/WCF#4325)wcf\\data\\tag\\TagCloudTag::setSize()
(WoltLab/WCF#4325)wcf\\data\\user\\User::getSocialNetworkPrivacySettings()
(WoltLab/WCF#4308)wcf\\data\\user\\UserAction::getSocialNetworkPrivacySettings()
(WoltLab/WCF#4308)wcf\\data\\user\\UserAction::saveSocialNetworkPrivacySettings()
(WoltLab/WCF#4308)wcf\\data\\user\\UserAction::validateGetSocialNetworkPrivacySettings()
(WoltLab/WCF#4308)wcf\\data\\user\\UserAction::validateSaveSocialNetworkPrivacySettings()
(WoltLab/WCF#4308)wcf\\data\\user\\avatar\\DefaultAvatar::canCrop()
(WoltLab/WCF#4310)wcf\\data\\user\\avatar\\DefaultAvatar::getCropImageTag()
(WoltLab/WCF#4310)wcf\\data\\user\\avatar\\UserAvatar::canCrop()
(WoltLab/WCF#4310)wcf\\data\\user\\avatar\\UserAvatar::getCropImageTag()
(WoltLab/WCF#4310)wcf\\system\\bbcode\\BBCodeHandler::setAllowedBBCodes()
(WoltLab/WCF#4319)wcf\\system\\bbcode\\BBCodeParser::validateBBCodes()
(WoltLab/WCF#4319)wcf\\system\\breadcrumb\\Breadcrumbs::add()
(WoltLab/WCF#4298)wcf\\system\\breadcrumb\\Breadcrumbs::remove()
(WoltLab/WCF#4298)wcf\\system\\breadcrumb\\Breadcrumbs::replace()
(WoltLab/WCF#4298)wcf\\system\\form\\builder\\IFormNode::create()
(Removal from Interface, see WoltLab/WCF#4468)wcf\\system\\form\\builder\\IFormNode::validateAttribute()
(Removal from Interface, see WoltLab/WCF#4468)wcf\\system\\form\\builder\\IFormNode::validateClass()
(Removal from Interface, see WoltLab/WCF#4468)wcf\\system\\form\\builder\\IFormNode::validateId()
(Removal from Interface, see WoltLab/WCF#4468)wcf\\system\\form\\builder\\field\\IAttributeFormField::validateFieldAttribute()
(Removal from Interface, see WoltLab/WCF#4468)wcf\\system\\form\\builder\\field\\dependency\\IFormFieldDependency::create()
(Removal from Interface, see WoltLab/WCF#4468)wcf\\system\\form\\builder\\field\\validation\\IFormFieldValidator::validateId()
(Removal from Interface, see WoltLab/WCF#4468)wcf\\system\\message\\embedded\\object\\MessageEmbeddedObjectManager::parseTemporaryMessage()
(WoltLab/WCF#4299)wcf\\system\\package\\PackageArchive::getPhpRequirements()
(WoltLab/WCF#4311)wcf\\system\\search\\ISearchIndexManager::add()
(Removal from Interface, see WoltLab/WCF#4508)wcf\\system\\search\\ISearchIndexManager::update()
(Removal from Interface, see WoltLab/WCF#4508)wcf\\system\\search\\elasticsearch\\ElasticsearchHandler::_add()
wcf\\system\\search\\elasticsearch\\ElasticsearchHandler::_delete()
wcf\\system\\search\\elasticsearch\\ElasticsearchHandler::add()
wcf\\system\\search\\elasticsearch\\ElasticsearchHandler::bulkAdd()
wcf\\system\\search\\elasticsearch\\ElasticsearchHandler::bulkDelete()
wcf\\system\\search\\elasticsearch\\ElasticsearchHandler::delete()
wcf\\system\\search\\elasticsearch\\ElasticsearchHandler::update()
wcf\\system\\search\\elasticsearch\\ElasticsearchSearchIndexManager::add()
wcf\\system\\search\\elasticsearch\\ElasticsearchSearchIndexManager::update()
wcf\\system\\search\\elasticsearch\\ElasticsearchSearchEngine::parseSearchQuery()
wcf\\data\\category\\Category::$permissions
(WoltLab/WCF#4303)wcf\\system\\search\\elasticsearch\\ElasticsearchSearchIndexManager::$bulkTypeName
wcf\\system\\search\\elasticsearch\\ElasticsearchHandler::HEAD
wcf\\system\\tagging\\TagCloud::MAX_FONT_SIZE
(WoltLab/WCF#4325)wcf\\system\\tagging\\TagCloud::MIN_FONT_SIZE
(WoltLab/WCF#4325)ENABLE_CENSORSHIP
(always call Censorship::test()
, see WoltLab/WCF#4567)MODULE_SYSTEM_RECAPTCHA
(WoltLab/WCF#4305)PROFILE_MAIL_USE_CAPTCHA
(WoltLab/WCF#4399)may
value for MAIL_SMTP_STARTTLS
(WoltLab/WCF#4398)SEARCH_USE_CAPTCHA
(see WoltLab/WCF#4605)acp/dereferrer.php
Blog.Entry.QuoteHandler
(use WoltLabSuite/Blog/Ui/Entry/Quote
)Calendar.Event.QuoteHandler
(use WoltLabSuite/Calendar/Ui/Event/Quote
)WBB.Board.IgnoreBoards
(use WoltLabSuite/Forum/Ui/Board/Ignore
)WBB.Board.MarkAllAsRead
(use WoltLabSuite/Forum/Ui/Board/MarkAllAsRead
)WBB.Board.MarkAsRead
(use WoltLabSuite/Forum/Ui/Board/MarkAsRead
)WBB.Post.QuoteHandler
(use WoltLabSuite/Forum/Ui/Post/Quote
)WBB.Thread.LastPageHandler
(use WoltLabSuite/Forum/Ui/Thread/LastPageHandler
)WBB.Thread.MarkAsRead
(use WoltLabSuite/Forum/Ui/Thread/MarkAsRead
)WBB.Thread.SimilarThreads
(use WoltLabSuite/Forum/Ui/Thread/SimilarThreads
)WBB.Thread.WatchedThreadList
(use WoltLabSuite/Forum/Controller/Thread/WatchedList
)WCF.ACP.Style.ImageUpload
(WoltLab/WCF#4323)WCF.ColorPicker
(see migration guide for WCF.ColorPicker
)WCF.Conversation.Message.QuoteHandler
(use WoltLabSuite/Core/Conversation/Ui/Message/Quote
, see WoltLab/com.woltlab.wcf.conversation#155)WCF.Like.js
(WoltLab/WCF#4300)WCF.Message.UserMention
(WoltLab/WCF#4324)WCF.Poll.Manager
(WoltLab/WCF#4662)WCF.UserPanel
(WoltLab/WCF#4316)WCF.User.Panel.Moderation
(WoltLab/WCF#4603)WCF.User.Panel.Notification
(WoltLab/WCF#4603)WCF.User.Panel.UserMenu
(WoltLab/WCF#4603)wbb.search.boards.all
wcf.global.form.error.greaterThan.javaScript
(WoltLab/WCF#4306)wcf.global.form.error.lessThan.javaScript
(WoltLab/WCF#4306)wcf.search.type.keywords
wcf.acp.option.search_use_captcha
wcf.search.query.description
wcf.search.results.change
wcf.search.results.description
wcf.search.general
wcf.search.query
wcf.search.error.noMatches
wcf.search.error.user.noMatches
searchResult
search::tabMenuTabs
search::sections
tagSearch::tabMenuTabs
tagSearch::sections
With WoltLab Suite Forum 5.5 we have introduced a new system for subscribing to threads and boards, which also offers the possibility to ignore threads and boards. You can learn more about this feature in our blog. The new system uses a separate mechanism to track the subscribed forums as well as the subscribed threads. The previously used object type com.woltlab.wcf.user.objectWatch
is now discontinued, because the object watch system turned out to be too limited for the complex logic behind thread and forum subscriptions.
Previously:
$action = new UserObjectWatchAction([], 'subscribe', [\n 'data' => [\n 'objectID' => $threadID,\n 'objectType' => 'com.woltlab.wbb.thread',\n ]\n]);\n$action->executeAction();\n
Now:
ThreadStatusHandler::saveSubscriptionStatus(\n $threadID,\n ThreadStatusHandler::SUBSCRIPTION_MODE_WATCHING\n);\n
"},{"location":"migration/wsc54/forum_subscriptions/#filter-ignored-threads","title":"Filter Ignored Threads","text":"To filter ignored threads from a given ThreadList
, you can use the method ThreadStatusHandler::addFilterForIgnoredThreads()
to append the filter for ignored threads. The ViewableThreadList
filters out ignored threads by default.
Example:
$user = new User(123);\n$threadList = new ThreadList();\nThreadStatusHandler::addFilterForIgnoredThreads(\n $threadList,\n // This parameter specifies the target user. Defaults to the current user if the parameter\n // is omitted or `null`.\n $user\n);\n$threadList->readObjects();\n
"},{"location":"migration/wsc54/forum_subscriptions/#filter-ignored-users","title":"Filter Ignored Users","text":"Avoid issuing notifications to users that have ignored the target thread by filtering those out.
$userIDs = [1, 2, 3];\n$users = ThreadStatusHandler::filterIgnoredUserIDs(\n $userIDs,\n $thread->threadID\n);\n
"},{"location":"migration/wsc54/forum_subscriptions/#subscribe-to-boards","title":"Subscribe to Boards","text":"Previously:
$action = new UserObjectWatchAction([], 'subscribe', [\n 'data' => [\n 'objectID' => $boardID,\n 'objectType' => 'com.woltlab.wbb.board',\n ]\n]);\n$action->executeAction();\n
Now:
BoardStatusHandler::saveSubscriptionStatus(\n $boardID,\n ThreadStatusHandler::SUBSCRIPTION_MODE_WATCHING\n);\n
"},{"location":"migration/wsc54/forum_subscriptions/#filter-ignored-boards","title":"Filter Ignored Boards","text":"Similar to ignored threads you will also have to avoid issuing notifications for boards that a user has ignored.
$userIDs = [1, 2, 3];\n$users = BoardStatusHandler::filterIgnoredUserIDs(\n $userIDs,\n $board->boardID\n);\n
"},{"location":"migration/wsc54/javascript/","title":"Migrating from WoltLab Suite 5.4 - TypeScript and JavaScript","text":""},{"location":"migration/wsc54/javascript/#ajaxdboaction","title":"Ajax.dboAction()
","text":"We have introduced a new Promise
based API for the interaction with wcf\\data\\DatabaseObjectAction
. It provides full IDE autocompletion support and transparent error handling, but is designed to be used with DatabaseObjectAction
only.
See the documentation for the new API and WoltLab/WCF#4585 for details.
"},{"location":"migration/wsc54/javascript/#wcfcolorpicker","title":"WCF.ColorPicker
","text":"We have replaced the old jQuery-based color picker WCF.ColorPicker
with a more lightweight replacement WoltLabSuite/Core/Ui/Color/Picker
, which uses the build-in input[type=color]
field. To support transparency, which input[type=color]
does not, we also added a slider to set the alpha value. WCF.ColorPicker
has been adjusted to internally use WoltLabSuite/Core/Ui/Color/Picker
and it has been deprecated.
Be aware that the new color picker requires the following new phrases to be available in the TypeScript/JavaScript code:
wcf.style.colorPicker.alpha
,wcf.style.colorPicker.color
,wcf.style.colorPicker.error.invalidColor
,wcf.style.colorPicker.hexAlpha
,wcf.style.colorPicker.new
.See WoltLab/WCF#4353 for details.
"},{"location":"migration/wsc54/javascript/#codemirror","title":"CodeMirror","text":"The bundled version of CodeMirror was updated and should be loaded using the AMD loader going forward.
See the third party libraries migration guide for details.
"},{"location":"migration/wsc54/javascript/#new-user-menu","title":"New User Menu","text":"The legacy implementation WCF.User.Panel.Abstract
was based on jQuery and has now been retired in favor of a new lightweight implementation that provides a clean interface and improved accessibility. You are strongly encouraged to migrate your existing implementation to integrate with existing menus.
Please use WoltLabSuite/Core/Ui/User/Menu/Data/ModerationQueue.ts
as a template for your own implementation, it contains only strictly the code you will need. It makes use of the new Ajax.dboAction()
(see above) for improved readability and flexibility.
You must update your trigger button to include the role
, tabindex
and ARIA attributes! Please take a look at the links in pageHeaderUser.tpl
to see these four attributes in action.
See WoltLab/WCF#4603 for details.
"},{"location":"migration/wsc54/libraries/","title":"Migrating from WoltLab Suite 5.4 - Third Party Libraries","text":""},{"location":"migration/wsc54/libraries/#symfony-php-polyfills","title":"Symfony PHP Polyfills","text":"WoltLab Suite 5.5 ships with Symfony's PHP 7.3, 7.4, and 8.0 polyfills. These polyfills allow you to reliably use some of the PHP functions only available in PHP versions that are newer than the current minimum of PHP 7.2. Notable mentions are str_starts_with
, str_ends_with
, array_key_first
, and array_key_last
.
Refer to the documentation within the symfony/polyfill repository for details.
"},{"location":"migration/wsc54/libraries/#scssphp","title":"scssphp","text":"scssphp was updated from version 1.4 to 1.10.
If you interact with scssphp only by deploying .scss
files, then you should not experience any breaking changes, except when the improved SCSS compatibility interprets your SCSS code differently.
If you happen to directly use scssphp in your PHP code, you should be aware that scssphp deprecated the use of the compile()
method, non-UTF-8 processing and also adjusted the handling of pure PHP values for variable handling.
Refer to WoltLab/WCF#4345 and the scssphp releases for details.
"},{"location":"migration/wsc54/libraries/#emogrifier-css-inliner","title":"Emogrifier / CSS Inliner","text":"The Emogrifier library was updated from version 5.0 to 6.0.
"},{"location":"migration/wsc54/libraries/#codemirror","title":"CodeMirror","text":"CodeMirror, the code editor we use for editing templates and SCSS, for example, has been updated to version 5.61.1 and we now also deliver all supported languages/modes. To properly support all languages/modes, CodeMirror is now loaded via the AMD module loader, which requires the original structure of the CodeMirror package, i.e. codemirror.js
being in a lib
folder. To preserve backward-compatibility, we also keep copies of codemirror.js
and codemirror.css
in version 5.61.1 directly in js/3rdParty/codemirror
. These files are, however, considered deprecated and you should migrate to using require()
(see codemirror
ACP template).
See WoltLab/WCF#4277 for details.
"},{"location":"migration/wsc54/libraries/#zendprogressbar","title":"Zend/ProgressBar","text":"The old bundled version of Zend/ProgressBar was replaced by a current version of laminas-progressbar.
Due to laminas-zendframework-bridge this update is a drop-in replacement. Existing code should continue to work as-is.
It is recommended to cleanly migrate to laminas-progressbar to allow for a future removal of the bridge. Updating the use
imports should be sufficient to switch to the laminas-progressbar.
See WoltLab/WCF#4460 for details.
"},{"location":"migration/wsc54/php/","title":"Migrating from WoltLab Suite 5.4 - PHP","text":""},{"location":"migration/wsc54/php/#initial-psr-7-support","title":"Initial PSR-7 support","text":"WoltLab Suite will incrementally add support for object oriented request/response handling based off the PSR-7 and PSR-15 standards in the upcoming versions.
WoltLab Suite 5.5 adds initial support by allowing to define the response using objects implementing the PSR-7 ResponseInterface
. If a controller returns such a response object from its __run()
method, this response will automatically emitted to the client.
Any PSR-7 implementation is supported, but WoltLab Suite 5.5 ships with laminas-diactoros as the recommended \u201cbatteries included\u201d implementation of PSR-7.
Support for PSR-7 requests and PSR-15 middlewares is expected to follow in future versions.
See WoltLab/WCF#4437 for details.
"},{"location":"migration/wsc54/php/#recommended-changes-for-woltlab-suite-55","title":"Recommended changes for WoltLab Suite 5.5","text":"With the current support in WoltLab Suite 5.5 it is recommended to migrate the *Action
classes to make use of PSR-7 responses. Control and data flow is typically fairly simple in *Action
classes with most requests ending up in either a redirect or a JSON response, commonly followed by a call to exit;
.
Experimental support for *Page
and *Form
is available. It is recommended to wait for a future version before migrating these types of controllers.
Previously:
lib/action/ExampleRedirectAction.class.php<?php\n\nnamespace wcf\\action;\n\nuse wcf\\system\\request\\LinkHandler;\nuse wcf\\util\\HeaderUtil;\n\nfinal class ExampleRedirectAction extends AbstractAction\n{\n public function execute()\n {\n parent::execute();\n\n // Redirect to the landing page.\n HeaderUtil::redirect(\n LinkHandler::getInstance()->getLink()\n );\n\n exit;\n }\n}\n
Now:
lib/action/ExampleRedirectAction.class.php<?php\n\nnamespace wcf\\action;\n\nuse Laminas\\Diactoros\\Response\\RedirectResponse;\nuse wcf\\system\\request\\LinkHandler;\n\nfinal class ExampleRedirectAction extends AbstractAction\n{\n public function execute()\n {\n parent::execute();\n\n // Redirect to the landing page.\n return new RedirectResponse(\n LinkHandler::getInstance()->getLink()\n );\n }\n}\n
"},{"location":"migration/wsc54/php/#migrating-json-responses","title":"Migrating JSON responses","text":"Previously:
lib/action/ExampleJsonAction.class.php<?php\n\nnamespace wcf\\action;\n\nuse wcf\\util\\JSON;\n\nfinal class ExampleJsonAction extends AbstractAction\n{\n public function execute()\n {\n parent::execute();\n\n \\header('Content-type: application/json; charset=UTF-8');\n\n echo JSON::encode([\n 'foo' => 'bar',\n ]);\n\n exit;\n }\n}\n
Now:
lib/action/ExampleJsonAction.class.php<?php\n\nnamespace wcf\\action;\n\nuse Laminas\\Diactoros\\Response\\JsonResponse;\n\nfinal class ExampleJsonAction extends AbstractAction\n{\n public function execute()\n {\n parent::execute();\n\n return new JsonResponse([\n 'foo' => 'bar',\n ]);\n }\n}\n
"},{"location":"migration/wsc54/php/#events","title":"Events","text":"Historically, events were tightly coupled with a single class, with the event object being an object of this class, expecting the event listener to consume public properties and method of the event object. The $parameters
array was introduced due to limitations of this pattern, avoiding moving all the values that might be of interest to the event listener into the state of the object. Events were still tightly coupled with the class that fired the event and using the opaque parameters array prevented IDEs from assisting with autocompletion and typing.
WoltLab Suite 5.5 introduces the concept of dedicated, reusable event classes. Any newly introduced event will receive a dedicated class, implementing the wcf\\system\\event\\IEvent
interface. These event classes may be fired from multiple locations, making them reusable to convey that a conceptual action happened, instead of a specific class doing something. An example for using the new event system could be a user logging in: Instead of listening on a the login form being submitted and the Facebook login action successfully running, an event UserLoggedIn
might be fired whenever a user logs in, no matter how the login is performed.
Additionally, these dedicated event classes will benefit from full IDE support. All the relevant values may be stored as real properties on the event object.
Event classes should not have an Event
suffix and should be stored in an event
namespace in a matching location. Thus, the UserLoggedIn
example might have a FQCN of \\wcf\\system\\user\\authentication\\event\\UserLoggedIn
.
Event listeners for events implementing IEvent
need to follow PSR-14, i.e. they need to be callable. In practice, this means that the event listener class needs to implement __invoke()
. No interface has to be implemented in this case.
Previously:
$parameters = [\n 'value' => \\random_int(1, 1024),\n];\n\nEventHandler::getInstance()->fireAction($this, 'valueAvailable', $parameters);\n
lib/system/event/listener/ValueDumpListener.class.php<?php\n\nnamespace wcf\\system\\event\\listener;\n\nuse wcf\\form\\ValueForm;\n\nfinal class ValueDumpListener implements IParameterizedEventListener\n{\n /**\n * @inheritDoc\n * @param ValueForm $eventObj\n */\n public function execute($eventObj, $className, $eventName, array &$parameters)\n {\n var_dump($parameters['value']);\n }\n}\n
Now:
EventHandler::getInstance()->fire(new ValueAvailable(\\random_int(1, 1024)));\n
lib/system/foo/event/ValueAvailable.class.php<?php\n\nnamespace wcf\\system\\foo\\event;\n\nuse wcf\\system\\event\\IEvent;\n\nfinal class ValueAvailable implements IEvent\n{\n /**\n * @var int\n */\n private $value;\n\n public function __construct(int $value)\n {\n $this->value = $value;\n }\n\n public function getValue(): int\n {\n return $this->value;\n }\n}\n
lib/system/event/listener/ValueDumpListener.class.php<?php\n\nnamespace wcf\\system\\event\\listener;\n\nuse wcf\\system\\foo\\event\\ValueAvailable;\n\nfinal class ValueDumpListener\n{\n public function __invoke(ValueAvailable $event): void\n {\n \\var_dump($event->getValue());\n }\n}\n
See WoltLab/WCF#4000 and WoltLab/WCF#4265 for details.
"},{"location":"migration/wsc54/php/#authentication","title":"Authentication","text":"The UserLoggedIn
event was added. You should fire this event if you implement a custom login process (e.g. when adding additional external authentication providers).
Example:
EventHandler::getInstance()->fire(\n new UserLoggedIn($user)\n);\n
See WoltLab/WCF#4356 for details.
"},{"location":"migration/wsc54/php/#embedded-objects-in-comments","title":"Embedded Objects in Comments","text":"WoltLab/WCF#4275 added support for embedded objects like mentions for comments and comment responses. To properly render embedded objects whenever you are using comments in your packages, you have to use ViewableCommentList
/ViewableCommentResponseList
in these places or ViewableCommentRuntimeCache
/ViewableCommentResponseRuntimeCache
. While these runtime caches are only available since version 5.5, the viewable list classes have always been available so that changing CommentList
to ViewableCommentList
, for example, is a backwards-compatible change.
The Mailbox
and UserMailbox
classes no longer store the passed Language
and User
objects, but the respective ID instead. This change reduces the size of the serialized email when stored in the background queue.
If you inherit from the Mailbox
or UserMailbox
classes, you might experience issues if you directly access the $this->language
or $this->user
properties. Adjust your class to use composition instead of inheritance if possible. Use the getLanguage()
or getUser()
getters if using composition is not possible.
See WoltLab/WCF#4389 for details.
"},{"location":"migration/wsc54/php/#smtp","title":"SMTP","text":"The SmtpEmailTransport
no longer supports a value of may
for the $starttls
property.
Using the may
value is unsafe as it allows for undetected MITM attacks. The use of encrypt
is recommended, unless it is certain that the SMTP server does not support TLS.
See WoltLab/WCF#4398 for details.
"},{"location":"migration/wsc54/php/#search","title":"Search","text":""},{"location":"migration/wsc54/php/#search-form","title":"Search Form","text":"After the overhaul of the search form, search providers are no longer bound to SearchForm
and SearchResultPage
. The interface ISearchObjectType
and the abstract implementation AbstractSearchableObjectType
have been replaced by ISearchProvider
and AbstractSearchProvider
.
Please use ArticleSearch
as a template for your own implementation
See WoltLab/WCF#4605 for details.
"},{"location":"migration/wsc54/php/#exceptions","title":"Exceptions","text":"A new wcf\\system\\search\\exception\\SearchFailed
exception was added. This exception should be thrown when executing the search query fails for (mostly) temporary reasons, such as a network partition to a remote service. It is not meant as a blanket exception to wrap everything. For example it must not be returned obvious programming errors, such as an access to an undefined variable (ErrorException
).
Catching the SearchFailed
exception allows consuming code to gracefully handle search requests that are not essential for proceeding, without silencing other types of error.
See WoltLab/WCF#4476 and WoltLab/WCF#4483 for details.
"},{"location":"migration/wsc54/php/#package-installation-plugins","title":"Package Installation Plugins","text":""},{"location":"migration/wsc54/php/#database","title":"Database","text":"WoltLab Suite 5.5 changes the factory classes for common configurations of database columns within the PHP-based DDL API to contain a private constructor, preventing object creation.
This change affects the following classes:
DefaultFalseBooleanDatabaseTableColumn
DefaultTrueBooleanDatabaseTableColumn
NotNullInt10DatabaseTableColumn
NotNullVarchar191DatabaseTableColumn
NotNullVarchar255DatabaseTableColumn
ObjectIdDatabaseTableColumn
DatabaseTablePrimaryIndex
The static create()
method never returned an object of the factory class, but instead in object of the base type (e.g. IntDatabaseTableColumn
for NotNullInt10DatabaseTableColumn
). Constructing an object of these factory classes is considered a bug, as the class name implies a specific column configuration, that might or might not hold if the object is modified afterwards.
See WoltLab/WCF#4564 for details.
WoltLab Suite 5.5 adds the IDefaultValueDatabaseTableColumn
interface which is used to check whether specifying a default value is legal. For backwards compatibility this interface is implemented by AbstractDatabaseTableColumn
. You should explicitly add this interface to custom table column type classes to avoid breakage if the interface is removed from AbstractDatabaseTableColumn
in a future version. Likewise you should explicitly check for the interface before attempting to access the methods related to the default value of a column.
See WoltLab/WCF#4733 for details.
"},{"location":"migration/wsc54/php/#file-deletion","title":"File Deletion","text":"Three new package installation plugins have been added to delete ACP templates with acpTemplateDelete, files with fileDelete, and templates with templateDelete.
"},{"location":"migration/wsc54/php/#language","title":"Language","text":"WoltLab/WCF#4261 has added support for deleting existing phrases with the language
package installation plugin.
The current structure of the language XML files
language/en.xml<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<language xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/5.4/language.xsd\" languagecode=\"en\" languagename=\"English\" countrycode=\"gb\">\n<category name=\"wcf.foo\">\n<item name=\"wcf.foo.bar\"><![CDATA[Bar]]></item>\n</category>\n</language>\n
is deprecated and should be replaced with the new structure with an explicit <import>
element like in the other package installation plugins:
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<language xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/5.4/language.xsd\" languagecode=\"en\" languagename=\"English\" countrycode=\"gb\">\n<import>\n<category name=\"wcf.foo\">\n<item name=\"wcf.foo.bar\"><![CDATA[Bar]]></item>\n</category>\n</import>\n</language>\n
Additionally, to now also support deleting phrases with this package installation plugin, support for a <delete>
element has been added:
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<language xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/5.4/language.xsd\" languagecode=\"en\" languagename=\"English\" countrycode=\"gb\">\n<import>\n<category name=\"wcf.foo\">\n<item name=\"wcf.foo.bar\"><![CDATA[Bar]]></item>\n</category>\n</import>\n<delete>\n<item name=\"wcf.foo.barrr\"/>\n</delete>\n</language>\n
Note that when deleting phrases, the category does not have to be specified because phrase identifiers are unique globally.
Mixing the old structure and the new structure is not supported and will result in an error message during the import!
"},{"location":"migration/wsc54/php/#board-and-thread-subscriptions","title":"Board and Thread Subscriptions","text":"WoltLab Suite Forum 5.5 updates the subscription logic for boards and threads to properly support the ignoring of threads. See the dedicated migration guide for details.
"},{"location":"migration/wsc54/php/#miscellaneous-changes","title":"Miscellaneous Changes","text":""},{"location":"migration/wsc54/php/#view-counters","title":"View Counters","text":"With WoltLab Suite 5.5 it is expected that view/download counters do not increase for disabled content.
See WoltLab/WCF#4374 for details.
"},{"location":"migration/wsc54/php/#form-builder","title":"Form Builder","text":"ValueIntervalFormFieldDependency
ColorFormField
MultipleBoardSelectionFormField
Content interactions buttons are a new way to display action buttons at the top of the page. They are intended to replace the icons in pageNavigationIcons
for better accessibility while reducing the amount of buttons in contentHeaderNavigation
.
As a rule of thumb, there should be at most only one button in contentHeaderNavigation
(primary action on this page) and three buttons in contentInteractionButtons
(important actions on this page). Use contentInteractionDropdownItems
for all other buttons.
The template contentInteraction
is included in the header and the corresponding placeholders are thus available on every page.
See WoltLab/WCF#4315 for details.
"},{"location":"migration/wsc54/templates/#phrase-modifier","title":"Phrase Modifier","text":"The |language
modifier was added to allow the piping of the phrase through other functions. This has some unwanted side effects when used with plain strings that should not support variable interpolation. Another difference to {lang}
is the evaluation on runtime rather than at compile time, allowing the phrase to be taken from a variable instead.
We introduces the new modifier |phrase
as a thin wrapper around \\wcf\\system::WCF::getLanguage()->get()
. Use |phrase
instead of |language
unless you want to explicitly allow template scripting on a variable's output.
See WoltLab/WCF#4657 for details.
"},{"location":"migration/wsc55/deprecations_removals/","title":"Migrating from WoltLab Suite 5.5 - Deprecations and Removals","text":"With version 6.0, we have deprecated certain components and removed several other components that have been deprecated for many years.
"},{"location":"migration/wsc55/deprecations_removals/#deprecations","title":"Deprecations","text":""},{"location":"migration/wsc55/deprecations_removals/#php","title":"PHP","text":""},{"location":"migration/wsc55/deprecations_removals/#classes","title":"Classes","text":"wcf\\action\\AbstractDialogAction
(WoltLab/WCF#4947)wcf\\SensitiveArgument
(WoltLab/WCF#4802)wcf\\system\\cli\\command\\IArgumentedCLICommand
(WoltLab/WCF#5185)wcf\\system\\template\\plugin\\DateModifierTemplatePlugin
(WoltLab/WCF#5459)wcf\\system\\template\\plugin\\TimeModifierTemplatePlugin
(WoltLab/WCF#5459)wcf\\system\\template\\plugin\\PlainTimeModifierTemplatePlugin
(WoltLab/WCF#5459)wcf\\util\\CronjobUtil
(WoltLab/WCF#4923)wcf\\data\\cronjob\\CronjobAction::executeCronjobs()
(WoltLab/WCF#5171)wcf\\data\\package\\update\\server\\PackageUpdateServer::attemptSecureConnection()
(WoltLab/WCF#4790)wcf\\data\\package\\update\\server\\PackageUpdateServer::isValidServerURL()
(WoltLab/WCF#4790)wcf\\data\\page\\Page::setAsLandingPage()
(WoltLab/WCF#4842)wcf\\data\\style\\Style::getRelativeFavicon()
(WoltLab/WCF#5201)wcf\\system\\cli\\command\\CLICommandHandler::getCommands()
(WoltLab/WCF#5185)wcf\\system\\io\\RemoteFile::disableSSL()
(WoltLab/WCF#4790)wcf\\system\\io\\RemoteFile::supportsSSL()
(WoltLab/WCF#4790)wcf\\system\\request\\RequestHandler::inRescueMode()
(WoltLab/WCF#4831)wcf\\system\\session\\SessionHandler::getLanguageIDs()
(WoltLab/WCF#4839)wcf\\system\\user\\multifactor\\webauthn\\Challenge::getOptionsAsJson()
wcf\\system\\WCF::getActivePath()
(WoltLab/WCF#4827)wcf\\system\\WCF::getFavicon()
(WoltLab/WCF#4785)wcf\\system\\WCF::useDesktopNotifications()
(WoltLab/WCF#4785)wcf\\util\\CryptoUtil::validateSignedString()
(WoltLab/WCF#5083)wcf\\util\\Diff::__construct()
(WoltLab/WCF#4918)wcf\\util\\Diff::__toString()
(WoltLab/WCF#4918)wcf\\util\\Diff::getLCS()
(WoltLab/WCF#4918)wcf\\util\\Diff::getRawDiff()
(WoltLab/WCF#4918)wcf\\util\\Diff::getUnixDiff()
(WoltLab/WCF#4918)wcf\\util\\StringUtil::convertEncoding()
(WoltLab/WCF#4800)wcf\\system\\condition\\UserAvatarCondition::GRAVATAR
(WoltLab/WCF#4929)WCF.Comment
(WoltLab/WCF#5210)WCF.Location
(WoltLab/WCF#4972)WCF.Message.Share.Content
(WoltLab/WCF/commit/624b9db73daf8030aa1c3e49d4ffc785760a283f)WCF.User.ObjectWatch.Subscribe
(WoltLab/WCF#4962)WCF.User.List
(WoltLab/WCF#5039)WoltLabSuite/Core/Controller/Map/Route/Planner
(WoltLab/WCF#4972)WoltLabSuite/Core/NumberUtil
(WoltLab/WCF#5071)WoltLabSuite/Core/Ui/User/List
(WoltLab/WCF#5039)__commentJavaScript
(WoltLab/WCF#5210)commentListAddComment
(WoltLab/WCF#5210)calendar\\data\\CALENDARDatabaseObject
calendar\\system\\image\\EventDataHandler
gallery\\data\\GalleryDatabaseObject
gallery\\system\\image\\ImageDataHandler
wbb\\system\\user\\object\\watch\\BoardUserObjectWatch
and the corresponding object typewbb\\system\\user\\object\\watch\\ThreadUserObjectWatch
and the corresponding object typewcf\\acp\\form\\ApplicationEditForm
(WoltLab/WCF#4785)wcf\\action\\GravatarDownloadAction
(WoltLab/WCF#4929)wcf\\data\\user\\avatar\\Gravatar
(WoltLab/WCF#4929)wcf\\system\\bbcode\\highlighter\\*Highlighter
(WoltLab/WCF#4926)wcf\\system\\bbcode\\highlighter\\Highlighter
(WoltLab/WCF#4926)wcf\\system\\cache\\source\\MemcachedCacheSource
(WoltLab/WCF#4928)wcf\\system\\cli\\command\\CLICommandNameCompleter
(WoltLab/WCF#5185)wcf\\system\\cli\\command\\CommandsCLICommand
(WoltLab/WCF#5185)wcf\\system\\cli\\command\\CronjobCLICommand
(WoltLab/WCF#5171)wcf\\system\\cli\\command\\HelpCLICommand
(WoltLab/WCF#5185)wcf\\system\\cli\\command\\PackageCLICommand
(WoltLab/WCF#4946)wcf\\system\\cli\\DatabaseCLICommandHistory
(WoltLab/WCF#5058)wcf\\system\\database\\table\\column/\\UnsupportedDefaultValue
(WoltLab/WCF#5012)wcf\\system\\exception\\ILoggingAwareException
(and associated functionality) (WoltLab/WCF#5086)wcf\\system\\mail\\Mail
(WoltLab/WCF#4941)wcf\\system\\option\\DesktopNotificationApplicationSelectOptionType
(WoltLab/WCF#4785)wcf\\system\\search\\elasticsearch\\ElasticsearchException
$forceHTTP
parameter of wcf\\data\\package\\update\\server\\PackageUpdateServer::getListURL()
(WoltLab/WCF#4790)$forceHTTP
parameter of wcf\\system\\package\\PackageUpdateDispatcher::getPackageUpdateXML()
(WoltLab/WCF#4790)wcf\\data\\bbcode\\BBCodeCache::getHighlighters()
(WoltLab/WCF#4926)wcf\\data\\conversation\\ConversationAction::getMixedConversationList()
(WoltLab/com.woltlab.wcf.conversation#176)wcf\\data\\moderation\\queue\\ModerationQueueAction::getOutstandingQueues()
(WoltLab/WCF#4944)wcf\\data\\package\\installation\\queue\\PackageInstallationQueueAction::prepareQueue()
(WoltLab/WCF#4997)wcf\\data\\user\\avatar\\UserAvatarAction::enforceDimensions()
(WoltLab/WCF#5007)wcf\\data\\user\\avatar\\UserAvatarAction::fetchRemoteAvatar()
(WoltLab/WCF#5007)wcf\\data\\user\\notification\\UserNotificationAction::getOustandingNotifications()
(WoltLab/WCF#4944)wcf\\data\\user\\UserRegistrationAction::validatePassword()
(WoltLab/WCF#5244)wcf\\system\\bbcode\\BBCodeParser::getRemoveLinks()
(WoltLab/WCF#4986)wcf\\system\\bbcode\\HtmlBBCodeParser::setRemoveLinks()
(WoltLab/WCF#4986)wcf\\system\\html\\output\\node\\AbstractHtmlOutputNode::setRemoveLinks()
(WoltLab/WCF#4986)wcf\\system\\package\\PackageArchive::downloadArchive()
(WoltLab/WCF#5006)wcf\\system\\package\\PackageArchive::filterUpdateInstructions()
(WoltLab/WCF#5129)wcf\\system\\package\\PackageArchive::getAllExistingRequirements()
(WoltLab/WCF#5125)wcf\\system\\package\\PackageArchive::getInstructions()
(WoltLab/WCF#5120)wcf\\system\\package\\PackageArchive::getUpdateInstructions()
(WoltLab/WCF#5129)wcf\\system\\package\\PackageArchive::isValidInstall()
(WoltLab/WCF#5125)wcf\\system\\package\\PackageArchive::isValidUpdate()
(WoltLab/WCF#5126)wcf\\system\\package\\PackageArchive::setPackage()
(WoltLab/WCF#5120)wcf\\system\\package\\PackageArchive::unzipPackageArchive()
(WoltLab/WCF#4949)wcf\\system\\package\\PackageInstallationDispatcher::checkPackageInstallationQueue()
(WoltLab/WCF#4947)wcf\\system\\package\\PackageInstallationDispatcher::completeSetup()
(WoltLab/WCF#4947)wcf\\system\\package\\PackageInstallationDispatcher::convertShorthandByteValue()
(WoltLab/WCF#4949)wcf\\system\\package\\PackageInstallationDispatcher::functionExists()
(WoltLab/WCF#4949)wcf\\system\\package\\PackageInstallationDispatcher::openQueue()
(WoltLab/WCF#4947)wcf\\system\\package\\PackageInstallationDispatcher::validatePHPRequirements()
(WoltLab/WCF#4949)wcf\\system\\package\\PackageInstallationNodeBuilder::insertNode()
(WoltLab/WCF#4997)wcf\\system\\package\\PackageUpdateDispatcher::prepareInstallation()
(WoltLab/WCF#4997)wcf\\system\\request\\Request::execute()
(WoltLab/WCF#4820)wcf\\system\\request\\Request::getPageType()
(WoltLab/WCF#4822)wcf\\system\\request\\Request::getPageType()
(WoltLab/WCF#4822)wcf\\system\\request\\Request::isExecuted()
wcf\\system\\request\\Request::setIsLandingPage()
wcf\\system\\request\\RouteHandler::getDefaultController()
(WoltLab/WCF#4832)wcf\\system\\request\\RouteHandler::loadDefaultControllers()
(WoltLab/WCF#4832)wcf\\system\\search\\AbstractSearchEngine::getFulltextMinimumWordLength()
(WoltLab/WCF#4933)wcf\\system\\search\\AbstractSearchEngine::parseSearchQuery()
(WoltLab/WCF#4933)wcf\\system\\search\\elasticsearch\\ElasticsearchSearchEngine::getFulltextMinimumWordLength()
wcf\\system\\search\\elasticsearch\\ElasticsearchHandler::query()
wcf\\system\\search\\SearchIndexManager::add()
(WoltLab/WCF#4925)wcf\\system\\search\\SearchIndexManager::update()
(WoltLab/WCF#4925)wcf\\system\\session\\SessionHandler::getStyleID()
(WoltLab/WCF#4837)wcf\\system\\session\\SessionHandler::setStyleID()
(WoltLab/WCF#4837)wcf\\system\\CLIWCF::checkForUpdates()
(WoltLab/WCF#5058)wcf\\system\\WCFACP::checkMasterPassword()
(WoltLab/WCF#4977)wcf\\system\\WCFACP::getFrontendMenu()
(WoltLab/WCF#4812)wcf\\system\\WCFACP::initPackage()
(WoltLab/WCF#4794)wcf\\util\\CryptoUtil::randomBytes()
(WoltLab/WCF#4924)wcf\\util\\CryptoUtil::randomInt()
(WoltLab/WCF#4924)wcf\\util\\CryptoUtil::secureCompare()
(WoltLab/WCF#4924)wcf\\util\\FileUtil::downloadFileFromHttp()
(WoltLab/WCF#4942)wcf\\util\\PasswordUtil::secureCompare()
(WoltLab/WCF#4924)wcf\\util\\PasswordUtil::secureRandomNumber()
(WoltLab/WCF#4924)wcf\\util\\StringUtil::encodeJSON()
(WoltLab/WCF#5073)wcf\\util\\StyleUtil::updateStyleFile()
(WoltLab/WCF#4977)wcf\\util\\UserRegistrationUtil::isSecurePassword()
(WoltLab/WCF#4977)wcf\\acp\\form\\PageAddForm::$isLandingPage
(WoltLab/WCF#4842)wcf\\system\\appliation\\ApplicationHandler::$isMultiDomain
(WoltLab/WCF#4785)wcf\\system\\package\\PackageArchive::$package
(WoltLab/WCF#5129)wcf\\system\\request\\RequestHandler::$inRescueMode
(WoltLab/WCF#4831)wcf\\system\\request\\RouteHandler::$defaultControllers
(WoltLab/WCF#4832)wcf\\system\\template\\TemplateScriptingCompiler::$disabledPHPFunctions
(WoltLab/WCF#4788)wcf\\system\\template\\TemplateScriptingCompiler::$enterpriseFunctions
(WoltLab/WCF#4788)wcf\\system\\WCF::$forceLogout
(WoltLab/WCF#4799)beforeArgumentParsing@wcf\\system\\CLIWCF
(WoltLab/WCF#5058)afterArgumentParsing@wcf\\system\\CLIWCF
(WoltLab/WCF#5058)wcf\\system\\search\\elasticsearch\\ElasticsearchHandler::DELETE
wcf\\system\\search\\elasticsearch\\ElasticsearchHandler::GET
wcf\\system\\search\\elasticsearch\\ElasticsearchHandler::POST
wcf\\system\\search\\elasticsearch\\ElasticsearchHandler::PUT
PACKAGE_NAME
(WoltLab/WCF#5006)PACKAGE_VERSION
(WoltLab/WCF#5006)SECURITY_TOKEN_INPUT_TAG
(WoltLab/WCF#4934)SECURITY_TOKEN
(WoltLab/WCF#4934)WSC_API_VERSION
(WoltLab/WCF#4943)escapeString
helper (WoltLab/WCF#5085)CACHE_SOURCE_MEMCACHED_HOST
(WoltLab/WCF#4928)DESKTOP_NOTIFICATION_PACKAGE_ID
(WoltLab/WCF#4785)GRAVATAR_DEFAULT_TYPE
(WoltLab/WCF#4929)HTTP_SEND_X_FRAME_OPTIONS
(WoltLab/WCF#4786)MODULE_GRAVATAR
(WoltLab/WCF#4929)scss.inc.php
compatibility include. (WoltLab/WCF#4932)config.inc.php
in app directories. (WoltLab/WCF#5006)Blog.Blog.Archive
Blog.Category.MarkAllAsRead
Blog.Entry.Delete
Blog.Entry.Preview
Blog.Entry.QuoteHandler
Calendar.Category.MarkAllAsRead
(WoltLab/com.woltlab.calendar#169)Calendar.Event.Coordinates
Calendar.Event.Date.FullDay
(WoltLab/com.woltlab.calendar#171)Calendar.Event.Date.Participation.RemoveParticipant
Calendar.Event.Preview
Calendar.Event.QuoteHandler
Calendar.Event.Share
Calendar.Event.TabMenu
Calendar.Event.Thread.ShowParticipants
Calendar.Map
Calendar.UI.Calendar
Calendar/Ui/Event/Date/Cancel.js
Calendar.Export.iCal
Filebase.Category.MarkAllAsRead
Filebase.File.MarkAsRead
Filebase.File.Preview
Filebase.File.Share
flexibleArea.js
(WoltLab/WCF#4945)Gallery.Album.Share
Gallery.Category.MarkAllAsRead
Gallery.Image.Delete
Gallery.Image.Share
Gallery.Map.LargeMap
Gallery.Map.InfoWindowImageListDialog
jQuery.browser.smartphone
(WoltLab/WCF#4945)Prism.wscSplitIntoLines
(WoltLab/WCF#4940)SID_ARG_2ND
(WoltLab/WCF#4998)WBB.Board.Collapsible
WBB.Board.IgnoreBoards
WBB.Post.IPAddressHandler
WBB.Post.Preview
WBB.Post.QuoteHandler
WBB.Thread.LastPageHandler
WBB.Thread.SimilarThreads
WBB.Thread.UpdateHandler.Thread
WBB.Thread.WatchedThreadList
WCF.Action.Scroll
(WoltLab/WCF#4945)WCF.Conversation.MarkAllAsRead
WCF.Conversation.MarkAsRead
WCF.Conversation.Message.QuoteHandler
WCF.Conversation.Preview
WCF.Conversation.RemoveParticipant
WCF.Date.Picker
(WoltLab/WCF#4945)WCF.Date.Util
(WoltLab/WCF#4945)WCF.Dropdown.Interactive.Handler
(WoltLab/WCF#4944)WCF.Dropdown.Interactive.Instance
(WoltLab/WCF#4944)WCF.Message.Share.Page
(WoltLab/WCF#4945)WCF.Message.Smilies
(WoltLab/WCF#4945)WCF.ModeratedUserGroup.AddMembers
WCF.Moderation.Queue.MarkAllAsRead
WCF.Moderation.Queue.MarkAsRead
WCF.Search.Message.KeywordList
(WoltLab/WCF#4945)WCF.System.FlexibleMenu
(WoltLab/WCF#4945)WCF.System.Fullscreen
(WoltLab/WCF#4945)WCF.System.PageNavigation
(WoltLab/WCF#4945)WCF.Template
(WoltLab/WCF#5070)WCF.ToggleOptions
(WoltLab/WCF#4945)WCF.User.Panel.Abstract
(WoltLab/WCF#4944)WCF.User.Registration.Validation.Password
(WoltLab/WCF#5244)window.shuffle()
(WoltLab/WCF#4945)WSC_API_VERSION
(WoltLab/WCF#4943)WCF.Infraction.Warning.ShowDetails
WoltLabSuite/Core/Ui/Comment/Add
(WoltLab/WCF#5210)WoltLabSuite/Core/Ui/Comment/Edit
(WoltLab/WCF#5210)WoltLabSuite/Core/Ui/Response/Comment/Add
(WoltLab/WCF#5210)WoltLabSuite/Core/Ui/Response/Comment/Edit
(WoltLab/WCF#5210)wcf1_cli_history
(WoltLab/WCF#5058)wcf1_package_compatibility
(WoltLab/WCF#4992)wcf1_package_update_compatibility
(WoltLab/WCF#5005)wcf1_package_update_optional
(WoltLab/WCF#5005)wcf1_user_notification_to_user
(WoltLab/WCF#5005)wcf1_user.enableGravatar
(WoltLab/WCF#4929)wcf1_user.gravatarFileExtension
(WoltLab/WCF#4929)conversationListUserPanel
(WoltLab/com.woltlab.wcf.conversation#176)moderationQueueList
(WoltLab/WCF#4944)notificationListUserPanel
(WoltLab/WCF#4944){fetch}
(WoltLab/WCF#4892)|encodeJSON
(WoltLab/WCF#5073)headInclude::javascriptInclude
(WoltLab/WCF#4801)headInclude::javascriptInit
(WoltLab/WCF#4801)headInclude::javascriptLanguageImport
(WoltLab/WCF#4801)$__sessionKeepAlive
(WoltLab/WCF#5055)$__wcfVersion
(WoltLab/WCF#4927)$tpl.cookie
(WoltLab/WCF@7cfd5578ede22e)$tpl.env
(WoltLab/WCF@7cfd5578ede22e)$tpl.get
(WoltLab/WCF@7cfd5578ede22e)$tpl.now
(WoltLab/WCF@7cfd5578ede22e)$tpl.post
(WoltLab/WCF@7cfd5578ede22e)$tpl.server
(WoltLab/WCF@7cfd5578ede22e)1
WCF_N
in WCFSetup. (WoltLab/WCF#4791)$_REQUEST['styleID']
) is removed. (WoltLab/WCF#4533)http://
scheme for package servers is no longer supported. The use of https://
is enforced. (WoltLab/WCF#4790)In the past dialogs have been used for all kinds of purposes, for example, to provide more details. Dialogs make it incredibly easy to add extra information or forms to an existing page without giving much thought: A simple button is all that it takes to show a dialog.
This has lead to an abundance of dialogs that have been used in a lot of places where dialogs are not the right choice, something we are guilty of in a lot of cases. A lot of research has gone into the accessibility of dialogs and the general recommendations towards their usage and the behavior.
One big issue of dialogs have been their inconsistent appearance in terms of form buttons and their (lack of) keyboard support for input fields. WoltLab Suite 6.0 provides a completely redesigned API that strives to make the process of creating dialogs much easier and features a consistent keyboard support out of the box.
"},{"location":"migration/wsc55/dialogs/#conceptual-changes","title":"Conceptual Changes","text":"Dialogs are a powerful tool, but as will all things, it is easy to go overboard and eventually one starts using dialogs out of convenience rather than necessity. In general dialogs should only be used when you need to provide information out of flow, for example, for urgent error messages.
A common misuse that we are guilty of aswell is the use of dialogs to present content. Dialogs completely interrupt whatever the user is doing and can sometimes even hide contextual relevant content on the page. It is best to embed information into regular pages and either use deep links to refer to them or make use of flyovers to present the content in place.
Another important change is the handling of form inputs. Previously it was required to manually craft the form submit buttons, handle button clicks and implement a proper validation. The new API provides the \u201cprompt\u201d type which implements all this for you and exposing JavaScript events to validate, submit and cancel the dialog.
Last but not least there have been updates to the visual appearance of dialogs. The new dialogs mimic the appearance on modern desktop operating systems as well as smartphones by aligning the buttons to the bottom right. In addition the order of buttons has been changed to always show the primary button on the rightmost position. These changes were made in an effort to make it easier for users to adopt an already well known control concept and to improve the overall accessibility.
"},{"location":"migration/wsc55/dialogs/#migrating-to-the-dialogs-of-woltlab-suite-60","title":"Migrating to the Dialogs of WoltLab Suite 6.0","text":"The old dialogs are still fully supported and have remained unchanged apart from a visual update to bring them in line with the new dialogs. We do recommend that you use the new dialog API exclusively for new components and migrate the existing dialogs whenever you see it fit, we\u2019ll continue to support the legacy dialog API for the entire 6.x series at minimum.
"},{"location":"migration/wsc55/dialogs/#comparison-of-the-apis","title":"Comparison of the APIs","text":"The legacy API relied on implicit callbacks to initialize dialogs and to handle the entire lifecycle. The _dialogSetup()
method was complex, offered subpar auto completition support and generally became very bloated when utilizing the events.
source
","text":"The source of a dialog is provided directly through the fluent API of dialogFactory()
which provides methods to spawn dialogs using elements, HTML strings or completely empty.
The major change is the removal of the AJAX support as the content source, you should use dboAction()
instead and then create the dialog.
options.onSetup(content: HTMLElement)
","text":"You can now access the content element directly, because everything happens in-place.
const dialog = dialogFactory().fromHtml(\"<p>Hello World!</p>\").asAlert();\n\n// Do something with `dialog.content` or bind event listeners.\n\ndialog.show(\"My Title\");\n
"},{"location":"migration/wsc55/dialogs/#optionsonshowcontent-htmlelement","title":"options.onShow(content: HTMLElement)
","text":"There is no equivalent in the new API, because you can simply store a reference to your dialog and access the .content
property at any time.
_dialogSubmit()
","text":"This is the most awkward feature of the legacy dialog API: Poorly documented and cumbersome to use. Implementing it required a dedicated form submit button and the keyboard interaction required the data-dialog-submit-on-enter=\"true\"
attribute to be set on all input elements that should submit the form through Enter
.
The new dialog API takes advantage of the form[method=\"dialog\"]
functionality which behaves similar to regular forms but will signal the form submit to the surrounding dialog. As a developer you only need to listen for the validate
and the primary
event to implement your logic.
const dialog = dialogFactory()\n.fromId(\"myComplexDialogWithFormInputs\")\n.asPrompt();\n\ndialog.addEventListener(\"validate\", (event) => {\n// Validate the form inputs in `dialog.content`.\n\nif (validationHasFailed) {\nevent.preventDefault();\n}\n});\n\ndialog.addEventListener(\"primary\", () => {\n// The dialog has been successfully validated\n// and was submitted by the user.\n// You can access form inputs through `dialog.content`.\n});\n
"},{"location":"migration/wsc55/dialogs/#changes-to-the-template","title":"Changes to the Template","text":"Both the old and the new API support the use of existing elements to create dialogs. It is recommended to use <template>
in this case which will never be rendered and has a well-defined role.
<!-- Previous -->\n<div id=\"myDialog\" style=\"display: none\">\n <!-- \u2026 -->\n</div>\n\n<!-- Use instead -->\n<template id=\"myDialog\">\n <!-- \u2026 -->\n</template>\n
Dialogs have historically been using the same HTML markup that regular pages do, including but not limited to the use of sections. For dialogs that use only a single container it is recommend to drop the section entirely.
If your dialog contain multiple sections it is recommended to skip the title of the first section.
"},{"location":"migration/wsc55/dialogs/#formsubmit","title":".formSubmit
","text":"Form controls are no longer defined through the template, instead those are implicitly generated by the new dialog API. Please see the explanation on the four different dialog types to learn more about form controls.
"},{"location":"migration/wsc55/dialogs/#migration-by-example","title":"Migration by Example","text":"There is no universal pattern that fits every case, because dialogs vary greatly between each other and the required functionality causes the actual implementation to be different.
As an example we have migrated the dialog to create a new box to use the new API. It uses a prompt dialog that automagically adds form controls and fires an event once the user submits the dialog. You can find the commit 3a9210f229f6a2cf5e800c2c4536c9774d02fc86 on GitHub.
The changes can be summed up as follows:
<template>
element for the dialog content..formSubmit
section from the HTML..section
..content
property and make use of an event listener to handle the user interaction._dialogSetup() {\nreturn {\n// \u2026\nid: \"myDialog\",\n};\n}\n
New API
dialogFactory().fromId(\"myDialog\").withoutControls();\n
"},{"location":"migration/wsc55/dialogs/#using-source-to-provide-the-dialog-html","title":"Using source
to Provide the Dialog HTML","text":"_dialogSetup() {\nreturn {\n// \u2026\nsource: \"<p>Hello World</p>\",\n};\n}\n
New API
dialogFactory().fromHtml(\"<p>Hello World</p>\").withoutControls();\n
"},{"location":"migration/wsc55/dialogs/#updating-the-html-when-the-dialog-is-shown","title":"Updating the HTML When the Dialog Is Shown","text":"_dialogSetup() {\nreturn {\n// \u2026\noptions: {\n// \u2026\nonShow: (content) => {\ncontent.querySelector(\"p\")!.textContent = \"Hello World\";\n},\n},\n};\n}\n
New API
const dialog = dialogFactory().fromHtml(\"<p></p>\").withoutControls();\n\n// Somewhere later in the code\n\ndialog.content.querySelector(\"p\")!.textContent = \"Hello World\";\n\ndialog.show(\"Some Title\");\n
"},{"location":"migration/wsc55/dialogs/#specifying-the-title-of-a-dialog","title":"Specifying the Title of a Dialog","text":"The title was previously fixed in the _dialogSetup()
method and could only be changed on runtime using the setTitle()
method.
_dialogSetup() {\nreturn {\n// \u2026\noptions: {\n// \u2026\ntitle: \"Some Title\",\n},\n};\n}\n
The title is now provided whenever the dialog should be opened, permitting changes in place.
const dialog = dialogFactory().fromHtml(\"<p></p>\").withoutControls();\n\n// Somewhere later in the code\n\ndialog.show(\"Some Title\");\n
"},{"location":"migration/wsc55/icons/","title":"Migrating from WoltLab Suite 5.5 - Icons","text":"WoltLab Suite 6.0 introduces Font Awesome 6.0 which is a major upgrade over the previously used Font Awesome 4.7 icon library. The new version features not only many hundreds of new icons but also focused a lot more on icon consistency, namely the proper alignment of icons within the grid.
The previous implementation of Font Awesome 4 included shims for Font Awesome 3 that was used before, the most notable one being the .icon
notation instead of .fa
as seen in Font Awesome 4 and later. In addition, Font Awesome 5 introduced the concept of different font weights to separate icons which was further extended in Font Awesome 6.
In WoltLab Suite 6.0 we have made the decision to make a clean cut and drop support for the Font Awesome 3 shim as well as a Font Awesome 4 shim in order to dramatically reduce the CSS size and to clean up the implementation. Brand icons had been moved to a separate font in Font Awesome 5, but since more and more fonts are being added we have stepped back from relying on that font. We have instead made the decision to embed brand icons using inline SVGs which are much more efficient when you only need a handful of brand icons instead of loading a 100kB+ font just for a few icons.
"},{"location":"migration/wsc55/icons/#misuse-of-icons-as-buttons","title":"Misuse of Icons as Buttons","text":"One pattern that could be found every here and then was the use of icons as buttons. Using icons in buttons is fine, as long as there is a readable title and that they are properly marked as buttons.
A common misuse looks like this:
<span class=\"icon icon16 fa-times pointer jsMyDeleteButton\" data-some-object-id=\"123\"></span>\n
This example has a few problems, for starters it is not marked as a button which would require both role=\"button\"
and tabindex=\"0\"
to be recognized as such. Additionally there is no title which leaves users clueless about what the option does, especially visually impaired users are possibly unable to identify the icon.
WoltLab Suite 6.0 addresses this issue by removing all default styling from <button>
elements, making them the ideal choice for button type elements.
<button class=\"jsMyDeleteButton\" data-some-object-id=\"123\" title=\"descriptive title here\">{icon name='xmark'}</button>\n
The icon will appear just as before, but the button is now properly accessible.
"},{"location":"migration/wsc55/icons/#using-css-classes-with-icons","title":"Using CSS Classes With Icons","text":"It is strongly discouraged to apply CSS classes to icons themselves. Icons inherit the text color from the surrounding context which removes the need to manually apply the color.
If you ever need to alter the icons, such as applying a special color or transformation, you should wrap the icon in an element like <span>
and apply the changes to that element instead.
The new template function {icon}
was added to take care of generating the HTML code for icons, including the embedded SVGs for brand icons. Icons in HTML should not be constructed using the actual HTML element, but instead always use {icon}
.
<button class=\"button\">{icon name='bell'} I\u2019m a button with a bell icon</button>\n
Unless specified the icon will attempt to use a non-solid variant of the icon if it is available. You can explicitly request a solid version of the icon by specifying it with type='solid'
.
<button class=\"button\">{icon name='bell' type='solid'} I\u2019m a button with a solid bell icon</button>\n
Icons will implicitly assume the size 16
, but you can explicitly request a different icon size using the size
attribute:
{icon size=24 name='bell' type='solid'}\n
"},{"location":"migration/wsc55/icons/#brand-icons","title":"Brand Icons","text":"The syntax for brand icons is very similar, but you are required to specifiy parameter type='brand'
to access them.
<button class=\"button\">{icon size=16 name='facebook' type='brand'} Share on Facebook</button>\n
"},{"location":"migration/wsc55/icons/#using-icons-in-typescriptjavascript","title":"Using Icons in TypeScript/JavaScript","text":"Buttons can be dynamically created using the native document.createElement()
using the new fa-icon
element.
const icon = document.createElement(\"fa-icon\");\nicon.setIcon(\"bell\", true);\n\n// This is the same as the following call in templates:\n// {icon name='bell' type='solid'}\n
You can request a size other than the default value of 16
through the size
property:
const icon = document.createElement(\"fa-icon\");\nicon.size = 24;\nicon.setIcon(\"bell\", true);\n
"},{"location":"migration/wsc55/icons/#creating-icons-in-html-strings","title":"Creating Icons in HTML Strings","text":"You can embed icons in HTML strings by constructing the fa-icon
element yourself.
element.innerHTML = '<fa-icon name=\"bell\" solid></fa-icon>';\n
"},{"location":"migration/wsc55/icons/#changing-an-icon-on-runtime","title":"Changing an Icon on Runtime","text":"You can alter the size by changing the size
property which accepts the numbers 16
, 24
, 32
, 48
, 64
, 96
, 128
and 144
. The icon itself should be always set through the setIcon(name: string, isSolid: boolean)
function which validates the values and rejects unknown icons.
We provide a helper script that eases the transition by replacing icons in templates, JavaScript and TypeScript files. The script itself is very defensive and only replaces obvious matches, it will leave icons with additional CSS classes or attributes as-is and will need to be manually adjusted.
"},{"location":"migration/wsc55/icons/#replacing-icons-with-the-helper-script","title":"Replacing Icons With the Helper Script","text":"The helper script is part of WoltLab Suite Core and can be found in the repository at extra/migrate-fa-v4.php
. The script must be executed from CLI and requires PHP 8.1.
$> php extra/migrate-fa-v4.php /path/to/the/target/directory/\n
The target directory will be searched recursively for files with the extension tpl
, js
and ts
.
The helper script above is limited to only perform replacements for occurrences that it can identify without doubt. It will not replace occurrences that are formatted differently and/or make use of additional attributes, including the icon misuse as clickable elements.
<li>\n <span class=\"icon icon16 fa-times pointer jsButtonFoo jsTooltip\" title=\"{lang}foo.bar.baz{/lang}\">\n</li>\n
This can be replaced using a proper button element which also provides proper accessibility for free.
<li>\n <button class=\"jsButtonFoo jsTooltip\" title=\"{lang}foo.bar.baz{/lang}\">\n{icon name='xmark'}\n </button>\n</li>\n
"},{"location":"migration/wsc55/icons/#migrating-admin-configurable-icons","title":"Migrating Admin-Configurable Icons","text":"If admin-configurable icon names (e.g. created by IconFormField
) are stored within the database, these need to be migrated with an upgrade script.
The FontAwesomeIcon::mapVersion4()
maps a Font Awesome 4 icon name to a string that may be passed to FontAwesomeIcon::fromString()
. It will throw an UnknownIcon
exception if the icon cannot be mapped. It is important to catch and handle this exception to ensure a reliable upgrade even when facing malformed data.
See WoltLab/WCF#5288 for an example script.
"},{"location":"migration/wsc55/javascript/","title":"Migrating from WoltLab Suite 5.5 - TypeScript and JavaScript","text":""},{"location":"migration/wsc55/javascript/#minimum-requirements","title":"Minimum requirements","text":"The ECMAScript target version has been increased to ES2022 from es2017.
"},{"location":"migration/wsc55/javascript/#subscribe-button-wcfuserobjectwatchsubscribe","title":"Subscribe Button (WCF.User.ObjectWatch.Subscribe)","text":"We have replaced the old jQuery-based WCF.User.ObjectWatch.Subscribe
with a more modern replacement WoltLabSuite/Core/Ui/User/ObjectWatch
.
The new implementation comes with a ready-to-use template (__userObjectWatchButton
) for use within contentInteractionButtons
:
{include file='__userObjectWatchButton' isSubscribed=$isSubscribed objectType='foo.bar' objectID=$id}\n
See WoltLab/WCF#4962 for details.
"},{"location":"migration/wsc55/javascript/#support-for-legacy-inheritance","title":"Support for Legacy Inheritance","text":"The migration from JavaScript to TypeScript was a breaking change because the previous prototype based inheritance was incompatible with ES6 classes. Core.enableLegacyInheritance()
was added in an effort to emulate the previous behavior to aid in the migration process.
This workaround was unstable at best and was designed as a temporary solution only. WoltLab/WCF#5041 removed the legacy inheritance, requiring all depending implementations to migrate to ES6 classes.
"},{"location":"migration/wsc55/libraries/","title":"Migrating from WoltLab Suite 5.5 - Third Party Libraries","text":""},{"location":"migration/wsc55/libraries/#symfony-php-polyfills","title":"Symfony PHP Polyfills","text":"The Symfony Polyfills for 7.3, 7.4, and 8.0 were removed, as the minimum PHP version was increased to PHP 8.1. The Polyfill for PHP 8.2 was added.
Refer to the documentation within the symfony/polyfill repository for details.
"},{"location":"migration/wsc55/libraries/#idna-handling","title":"IDNA Handling","text":"The true/punycode and pear/net_idna2 dependencies were removed, because of a lack of upstream maintenance and because the intl
extension is now required. Instead the idn_to_ascii
function should be used.
Diactoros was updated from version 2.4 to 2.25.
"},{"location":"migration/wsc55/libraries/#input-validation","title":"Input Validation","text":"WoltLab Suite 6.0 ships with cuyz/valinor 1.4 as a reliable solution to validate untrusted external input values.
Refer to the documentation within the CuyZ/Valinor repository for details.
"},{"location":"migration/wsc55/libraries/#diff","title":"Diff","text":"WoltLab Suite 6.0 ships with sebastian/diff as a replacement for wcf\\util\\Diff
. The wcf\\util\\Diff::rawDiffFromSebastianDiff()
method was added as a compatibility helper to transform sebastian/diff's output format into Diff's output format.
Refer to the documentation within the sebastianbergmann/diff repository for details on how to use the library.
See WoltLab/WCF#4918 for examples on how to use the compatibility helper if you need to preserve the output format for the time being.
"},{"location":"migration/wsc55/libraries/#content-negotiation","title":"Content Negotiation","text":"WoltLab Suite 6.0 ships with willdurand/negotiation to perform HTTP content negotiation based on the headers sent within the request. The wcf\\http\\Helper::getPreferredContentType()
method provides a convenience interface to perform content negotiation with regard to the MIME type. It is strongly recommended to make use of this method instead of interacting with the library directly.
In case the API provided by the helper method is insufficient, please refer to the documentation within the willdurand/Negotiation repository for details on how to use the library.
"},{"location":"migration/wsc55/libraries/#cronjobs","title":"Cronjobs","text":"WoltLab Suite 6.0 ships with dragonmantank/cron-expression as a replacement for wcf\\util\\CronjobUtil
.
This library is considered an internal library / implementation detail and not covered by backwards compatibility promises of WoltLab Suite.
"},{"location":"migration/wsc55/libraries/#ico-converter","title":".ico converter","text":"The chrisjean/php-ico dependency was removed, because of a lack of upstream maintenance. As the library was only used for Favicon generation, no replacement is made available. The favicons are now delivered as PNG files.
"},{"location":"migration/wsc55/php/","title":"Migrating from WoltLab Suite 5.5 - PHP","text":""},{"location":"migration/wsc55/php/#minimum-requirements","title":"Minimum requirements","text":"The minimum requirements have been increased to the following:
intl
extensionIt is recommended to make use of the newly introduced features whereever possible. Please refer to the PHP documentation for details.
"},{"location":"migration/wsc55/php/#inheritance","title":"Inheritance","text":""},{"location":"migration/wsc55/php/#parameter-return-property-types","title":"Parameter / Return / Property Types","text":"Parameter, return, and property types have been added to methods of various classes/interfaces. This might cause errors during inheritance, because the types are not compatible with the newly added types in the parent class.
Return types may already be added in package versions for older WoltLab Suite branches to be forward compatible, because return types are covariant.
"},{"location":"migration/wsc55/php/#final","title":"final","text":"The final
modifier was added to several classes that were not usefully set up for inheritance in the first place to make it explicit that inheriting from these classes is unsupported.
Historically the application boot in WCF
\u2019s constructor performed processing based on fundamentally request-specific values, such as the accessed URL, the request body, or cookies. This is problematic, because this makes the boot dependent on the HTTP environment which may not be be available, e.g. when using the CLI interface for maintenance jobs. The latter needs to emulate certain aspects of the HTTP environment for the boot to succeed. Furthermore one of the goals of the introduction of PSR-7/PSR-15-based request processing that was started in WoltLab Suite 5.5 is the removal of implicit global state in favor of explicitly provided values by means of a ServerRequestInterface
and thus to achieve a cleaner architecture.
To achieve a clean separation this type of request-specific logic will incrementally be moved out of the application boot in WCF
\u2019s constructor and into the request processing stack that is launched by RequestHandler
, e.g. by running appropriate PSR-15 middleware.
An example of this type of request-specific logic that was previously happening during application boot is the check that verifies whether a user is banned and denies access otherwise. This check is based on a request-specific value, namely the user\u2019s session which in turn is based on a provided (HTTP) cookie. It is now moved into the CheckUserBan
middleware.
This move implies that custom scripts that include WoltLab Suite Core\u2019s global.php
, without also invoking RequestHandler
will no longer be able to rely on this type of access control having happened and will need to implement it themselves, e.g. by manually running the appropriate middlewares.
Notably the following checks have been moved into a middleware:
The initialization of the session itself and dependent subsystems (e.g. the user object and thus the current language) is still running during application boot for now. However it is planned to also move the session initialization into the middleware in a future version and then providing access to the session by adding an attribute on the ServerRequestInterface
, instead of querying the session via WCF::getSession()
. As such you should begin to stop relying on the session and user outside of RequestHandler
\u2019s middleware stack and should also avoid calling WCF::getUser()
and WCF::getSession()
outside of a controller, instead adding a User
parameter to your methods to allow an appropriate user to be passed from the outside.
An example of a method that implicitly relies on these global values is the VisitTracker's trackObjectVisit()
method. It only takes the object type, object ID and timestamp as the parameter and will determine the userID
by itself. The trackObjectVisitByUserIDs()
method on the other hand does not rely on global values. Instead the relevant user IDs need to be passed explicitly from the controller as parameters, thus making the information the method works with explicit. This also makes the method reusable for use cases where an object should be marked as visited for a user other than the active user, without needing to temporarily switch the active user in the session.
The same is true for \u201cpermission checking\u201d methods on DatabaseObject
s. Instead of having a $myObject->canView()
method that uses WCF::getSession()
or WCF::getUser()
internally, the user should explicitly be passed to the method as a parameter, allowing for permission checks to happen in a different context, for example send sending notification emails.
Likewise event listeners should not access these request-specific values at all, because they are unable to know whether the event was fired based on these request-specific values or whether some programmatic action fired the event for another arbitrary user. Instead they must retrieve the appropriate information from the event data only.
"},{"location":"migration/wsc55/php/#bootstrap-scripts","title":"Bootstrap Scripts","text":"WoltLab Suite 6.0 adds package-specific bootstrap scripts allowing a package to execute logic during the application boot to prepare the environment before the request is passed through the middleware pipeline into the controller in RequestHandler
.
Bootstrap scripts are stored in the lib/bootstrap/
directory of WoltLab Suite Core with the package identifier as the file name. They do not need to be registered explicitly, as one future goal of the bootstrap scripts is reducing the amount of system state that needs to be stored within the database. Instead WoltLab Suite Core will automatically create a bootstrap loader that includes all installed bootstrap scripts as part of the package installation and uninstallation process.
Bootstrap scripts will be loaded and the bootstrap functions will executed based on a topological sorting of all installed packages. A package can rely on all bootstrap scripts of its dependencies being loaded before its own bootstrap script is loaded. It can also rely on all bootstrap functions of its dependencies having executed before its own bootstrap functions is executed. However it cannot rely on any specific loading and execution order of non-dependencies.
As hinted at in the previous paragraph, executing the bootstrap scripts happens in two phases:
include()
d in topological order. The script is expected to return a Closure
that is executed in phase 2.Closure
s will be executed in the same order the bootstrap scripts were loaded.<?php\n\n// Phase (1).\n\nreturn static function (): void {\n // Phase (2).\n};\n
For the vast majority of packages it is expected that the phase (1) bootstrapping is not used, except to return the Closure
. Instead the logic should reside in the Closure
s body that is executed in phase (2).
IEvent
listeners","text":"An example use case for bootstrap scripts with WoltLab Suite 6.0 is registering event listeners for IEvent
-based events that were added with WoltLab Suite 5.5, instead of using the eventListener PIP. Registering event listeners within the bootstrap script allows you to leverage your IDE\u2019s autocompletion for class names and and prevents forgetting the explicit uninstallation of old event listeners during a package upgrade.
<?php\n\nuse wcf\\system\\event\\EventHandler;\nuse wcf\\system\\event\\listener\\ValueDumpListener;\nuse wcf\\system\\foo\\event\\ValueAvailable;\n\nreturn static function (): void {\n EventHandler::getInstance()->register(\n ValueAvailable::class,\n ValueDumpListener::class\n );\n\n EventHandler::getInstance()->register(\n ValueAvailable::class,\n static function (ValueAvailable $event): void {\n // For simple use cases a `Closure` instead of a class name may be used.\n \\var_dump($event->getValue());\n }\n );\n};\n
"},{"location":"migration/wsc55/php/#request-processing","title":"Request Processing","text":"As previously mentioned in the Application Boot section, WoltLab Suite 6.0 improves support for PSR-7/PSR-15-based request processing that was initially announced with WoltLab Suite 5.5.
WoltLab Suite 5.5 added support for returning a PSR-7 ResponseInterface
from a controller and recommended to migrate existing controllers based on AbstractAction
to make use of RedirectResponse
and JsonResponse
instead of using HeaderUtil::redirect()
or manually emitting JSON with appropriate headers. Processing the request values still used PHP\u2019s superglobals (specifically $_GET
and $_POST
).
WoltLab Suite 6.0 adds support for controllers based on PSR-15\u2019s RequestHandlerInterface
, supporting request processing based on a provided PSR-7 ServerRequestInterface
object.
It is recommended to use RequestHandlerInterface
-based controllers whenever an AbstractAction
would previously be used. Furthermore any AJAX-based logic that would previously rely on AJAXProxyAction
combined with a method in an AbstractDatabaseObjectAction
should also be implemented using a dedicated RequestHandlerInterface
-based controller. Both AbstractAction
and AJAXProxyAction
-based AJAX requests should be considered soft-deprecated going forward.
When creating a RequestHandlerInterface
-based controller, care should be taken to ensure no mutable state is stored in object properties of the controller itself. The state of the controller object must be identical before, during and after a request was processed. Any required values must be passed explicitly by means of method parameters and return values. Likewise any functionality called by the controller\u2019s handle()
method should not rely on implicit global values, such as WCF::getUser()
, as was explained in the previous section about request-specific logic.
The recommended pattern for a RequestHandlerInterface
-based controller looks as follows:
<?php\n\nnamespace wcf\\action;\n\nuse Laminas\\Diactoros\\Response;\nuse Psr\\Http\\Message\\ServerRequestInterface;\nuse Psr\\Http\\Message\\ResponseInterface;\nuse Psr\\Http\\Server\\RequestHandlerInterface;\n\nfinal class MyFancyAction implements RequestHandlerInterface\n{\n public function __construct()\n {\n /* 0. Explicitly register services used by the controller, to\n * make dependencies explicit and to avoid accidentally using\n * global state outside of a controller.\n */\n }\n\n public function handle(ServerRequestInterface $request): ResponseInterface\n {\n /* 1. Perform permission checks and input validation. */\n\n /* 2. Perform the action. The action must not rely on global state,\n * but instead only on explicitly passed values. It should assume\n * that permissions have already been validated by the controller,\n * allowing it to be reusable programmatically.\n */\n\n /* 3. Perform post processing. */\n\n /* 4. Prepare the response, e.g. by querying an updated object from\n * the database.\n */\n\n /* 5. Send the response. */\n return new Response();\n }\n}\n
It is recommended to leverage Valinor for structural validation of input values if using the FormBuilder is not a good fit, specifically for any values that are provided implicitly and are expected to be correct. WoltLab Suite includes a middleware that will automatically convert unhandled MappingError
s into a response with status HTTP 400 Bad Request.
XSRF validation will implicitly be performed for any request that uses a HTTP verb other than GET
. Likewise any requests with a JSON body will automatically be decoded by a middleware and stored as the ServerRequestInterface
\u2019s parsed body.
The new WoltLabSuite/Core/Ajax/Backend
module may be used to easily query a RequestHandlerInterface
-based controller. The JavaScript code must not make any assumptions about the URI structure to reach the controller. Instead the endpoint must be generated using LinkHandler
and explicitly provided, e.g. by storing it in a data-endpoint
attribute:
<button\n class=\"button fancyButton\"\n data-endpoint=\"{link controller='MyFancy'}{/link}\"\n>Click me!</button>\n
const button = document.querySelector('.fancyButton');\nbutton.addEventListener('click', async (event) => {\nconst request = prepareRequest(button.dataset.endpoint)\n.get(); // or: .post(\u2026)\n\nconst response = await request.fetchAsResponse(); // or: .fetchAsJson()\n});\n
"},{"location":"migration/wsc55/php/#formbuilder","title":"FormBuilder","text":"The Psr15DialogForm
class combined with the usingFormBuilder()
method of dialogFactory()
provides a \u201cbatteries-included\u201d solution to create a AJAX- and FormBuilder-based RequestHandlerInterface
-based controller.
Within the JavaScript code the endpoint is queried using:
const { ok, result } = await dialogFactory()\n.usingFormBuilder()\n.fromEndpoint(url);\n
The returned Promise
will resolve when the dialog is closed, either by successfully submitting the form or by manually closing it and thus aborting the process. If the form was submitted successfully ok
will be true
and result
will contain the controller\u2019s response. If the dialog was closed without successfully submitting the form, ok
will be false
and result
will be set to undefined
.
Within the PHP code, the form may be created as usual, but use Psr15DialogForm
as the form document. The controller must return $dialogForm->toJsonResponse()
for GET
requests and validate the ServerRequestInterface
using $dialogForm->validateRequest($request)
for POST
requests. The latter will return a ResponseInterface
to be returned if the validation fails, otherwise null
is returned. If validation succeeded, the controller must perform the resulting action and return a JsonResponse
with the result
key:
if ($request->getMethod() === 'GET') {\n return $dialogForm->toResponse();\n} elseif ($request->getMethod() === 'POST') {\n $response = $dialogForm->validateRequest($request);\n if ($response !== null) {\n return $response;\n }\n\n $data = $dialogForm->getData();\n\n // Use $data.\n\n return new JsonResponse([\n 'result' => [\n 'some' => 'value',\n ],\n ]);\n} else {\n // The used method is validated by a middleware. Methods that are not\n // GET or POST need to be explicitly allowed using the 'AllowHttpMethod'\n // attribute.\n throw new \\LogicException('Unreachable');\n}\n
"},{"location":"migration/wsc55/php/#example","title":"Example","text":"A complete example, showcasing all the patterns can be found in WoltLab/WCF#5106. This example showcases how to:
data-*
attribute.The minversion
attribute of the <requiredpackage>
tag is now required.
Woltlab Suite 6.0 no longer accepts package versions with the \u201cpl\u201d suffix as valid.
"},{"location":"migration/wsc55/php/#removal-of-api-compatibility","title":"Removal of API compatibility","text":"WoltLab Suite 6.0 removes support for the deprecated API compatibility functionality. Any packages with a <compatibility>
tag in their package.xml are assumed to not have been updated for WoltLab Suite 6.0 and will be rejected during installation. Furthermore any packages without an explicit requirement for com.woltlab.wcf
in at least version 5.4.22
are also assumed to not have been updated for WoltLab Suite 6.0 and will also be rejected. The latter check is intended to reject old and most likely incompatible packages where the author forgot to add either an <excludedpackage>
or a <compatibility>
tag before releasing it.
Installing unnamed event listeners is no longer supported. The name
attribute needs to be specified for all event listeners.
Deleting unnamed event listeners still is possible to allow for a clean migration of existing listeners.
"},{"location":"migration/wsc55/php/#cronjob","title":"Cronjob","text":"Installing unnamed cronjobs is no longer supported. The name
attribute needs to be specified for all event listeners.
Deleting unnamed cronjobs still is possible to allow for a clean migration of existing cronjobs.
The cronjob PIP now supports the <expression>
element, allowing to define the cronjob schedule using a full expression instead of specifying the five elements separately.
The $name
parameter of DatabaseTableIndex::create()
is no longer optional. Relying on the auto-generated index name is strongly discouraged, because of unfixable inconsistent behavior between the SQL PIP and the PHP DDL API. See WoltLab/WCF#4505 for further background information.
The autogenerated name can still be requested by passing an empty string as the $name
. This should only be done for backwards compatibility purposes and to migrate an index with an autogenerated name to an index with an explicit name. An example script can be found in WoltLab/com.woltlab.wcf.conversation@a33677ca051f.
WoltLab Suite 6.0 increases the System Requirements to require PHP\u2019s intl extension to be installed and enabled, allowing you to rely on the functionality provided by it to better match the rules and conventions of the different languages and regions of the world.
One example would be the formatting of numbers. WoltLab Suite included a feature to group digits within large numbers since early versions using the StringUtil::addThousandsSeparator()
method. While this method was able to account for some language-specific differences, e.g. by selecting an appropriate separator character based on a phrase, it failed to account for all the differences in number formatting across countries and cultures.
As an example, English as written in the United States of America uses commas to create groups of three digits within large numbers: 123,456,789. English as written in India on the other hand also uses commas, but digits are not grouped into groups of three. Instead the right-most three digits form a group and then another comma is added every two digits: 12,34,56,789.
Another example would be German as used within Germany and Switzerland. While both countries use groups of three, the separator character differs. Germany uses a dot (123.456.789), whereas Switzerland uses an apostrophe (123\u2019456\u2019789). The correct choice of separator could already be configured using the afore-mentioned phrase, but this is both inconvenient and fails to account for other differences between the two countries. It also made it hard to keep the behavior up to date when rules change.
PHP\u2019s intl extension on the other hand builds on the official Unicode rules, by relying on the ICU library published by the Unicode consortium. As such it is aware of the rules of all relevant languages and regions of the world and it is already kept up to date by the operating system\u2019s package manager.
For the four example regions (en_US, en_IN, de_DE, de_CH) intl\u2019s NumberFormatter
class will format the number 123456789 as follows, correctly implementing the rules:
php > var_dump((new NumberFormatter('en_US', \\NumberFormatter::DEFAULT_STYLE))->format(123_456_789));\nstring(11) \"123,456,789\"\nphp > var_dump((new NumberFormatter('en_IN', \\NumberFormatter::DEFAULT_STYLE))->format(123_456_789));\nstring(12) \"12,34,56,789\"\nphp > var_dump((new NumberFormatter('de_DE', \\NumberFormatter::DEFAULT_STYLE))->format(123_456_789));\nstring(11) \"123.456.789\"\nphp > var_dump((new NumberFormatter('de_CH', \\NumberFormatter::DEFAULT_STYLE))->format(123_456_789));\nstring(15) \"123\u2019456\u2019789\"\n
WoltLab Suite\u2019s StringUtil::formatNumeric()
method is updated to leverage the NumberFormatter
internally. However your package might have special requirements regarding formatting, for example when formatting currencies where the position of the currency symbol differs across languages. In those cases your package should manually create an appropriately configured class from Intl\u2019s feature set. The correct locale can be queried by the new Language::getLocale()
method.
Another use case that showcases the Language::getLocale()
method might be localizing a country name using locale_get_display_region()
:
php > var_dump(\\wcf\\system\\WCF::getLanguage()->getLocale());\nstring(5) \"en_US\"\nphp > var_dump(locale_get_display_region('_DE', \\wcf\\system\\WCF::getLanguage()->getLocale()));\nstring(7) \"Germany\"\nphp > var_dump(locale_get_display_region('_US', \\wcf\\system\\WCF::getLanguage()->getLocale()));\nstring(13) \"United States\"\nphp > var_dump(locale_get_display_region('_IN', \\wcf\\system\\WCF::getLanguage()->getLocale()));\nstring(5) \"India\"\nphp > var_dump(locale_get_display_region('_BR', \\wcf\\system\\WCF::getLanguage()->getLocale()));\nstring(6) \"Brazil\"\n
See WoltLab/WCF#5048 for details.
"},{"location":"migration/wsc55/php/#indicating-parameters-that-hold-sensitive-information","title":"Indicating parameters that hold sensitive information","text":"PHP 8.2 adds native support for redacting parameters holding sensitive information in stack traces. Parameters with the #[\\SensitiveParameter]
attribute will show a placeholder value within the stack trace and the error log.
WoltLab Suite\u2019s exception handler contains logic to manually apply the sanitization for PHP versions before 8.2.
It is strongly recommended to add this attribute to all parameters holding sensitive information. Examples for sensitive parameters include passwords/passphrases, access tokens, plaintext values to be encrypted, or private keys.
As attributes are fully backwards and forwards compatible it is possible to apply the attribute to packages targeting older WoltLab Suite or PHP versions without causing errors.
Example:
function checkPassword(\n #[\\SensitiveParameter]\n $password,\n): bool {\n // \u2026\n}\n
See the PHP RFC: Redacting parameters in back traces for more details.
"},{"location":"migration/wsc55/php/#conditions","title":"Conditions","text":""},{"location":"migration/wsc55/php/#abstractintegercondition","title":"AbstractIntegerCondition","text":"Deriving from AbstractIntegerCondition
now requires to explicitly implement protected function getIdentifier(): string
, instead of setting the $identifier
property. This is to ensure that all conditions specify a unique identifier, instead of accidentally relying on a default value. The $identifier
property will no longer be used and may be removed.
See WoltLab/WCF#5077 for details.
"},{"location":"migration/wsc55/php/#rebuild-workers","title":"Rebuild Workers","text":"Rebuild workers should no longer be registered using the com.woltlab.wcf.rebuildData
object type definition. You can attach an event listener to the wcf\\system\\worker\\event\\RebuildWorkerCollecting
event inside a bootstrap script to lazily register workers. The class name of the worker is registered using the event\u2019s register()
method:
<?php\n\nuse wcf\\system\\event\\EventHandler;\nuse wcf\\system\\worker\\event\\RebuildWorkerCollecting;\n\nreturn static function (): void {\n $eventHandler = EventHandler::getInstance();\n\n $eventHandler->register(RebuildWorkerCollecting::class, static function (RebuildWorkerCollecting $event) {\n $event->register(\\bar\\system\\worker\\BazWorker::class, 0);\n });\n};\n
"},{"location":"migration/wsc55/templates/","title":"Migrating from WoltLab Suite 5.5 - Templates","text":""},{"location":"migration/wsc55/templates/#template-modifiers","title":"Template Modifiers","text":"WoltLab Suite featured a strict allow-list for template modifiers within the enterprise mode since 5.2. This allow-list has proved to be a reliable solution against malicious templates. To improve security and to reduce the number of differences between enterprise mode and non-enterprise mode the allow-list will always be enabled going forward.
It is strongly recommended to keep the template logic as simple as possible by moving the heavy lifting into regular PHP code, reducing the number of (specialized) modifiers that need to be applied.
See WoltLab/WCF#4788 for details.
"},{"location":"migration/wsc55/templates/#time-rendering","title":"Time Rendering","text":"The |time
, |plainTime
and |date
modifiers have been deprecated and replaced by a unified {time}
function.
The main benefit is that it is no longer necessary to specify the @
symbol when rendering the interactive time element, making it easier to perform a security review of templates by searching for the @
symbol.
See WoltLab/WCF#5459 for details.
"},{"location":"migration/wsc55/templates/#comments","title":"Comments","text":"In WoltLab Suite 6.0 the comment system has been overhauled. In the process, the integration of comments via templates has been significantly simplified:
{include file='comments' commentContainerID='someElementId' commentObjectID=$someObjectID}\n
An example for the migration of existing template integrations can be found here.
See WoltLab/WCF#5210 for more details.
"},{"location":"package/database-php-api/","title":"Database PHP API","text":"While the sql package installation plugin supports adding and removing tables, columns, and indices, it is not able to handle cases where the added table, column, or index already exist. We have added a new PHP-based API to manipulate the database scheme which can be used in combination with the database package installation plugin that skips parts that already exist:
return [\n // list of `DatabaseTable` objects\n];\n
All of the relevant components can be found in the wcf\\system\\database\\table
namespace.
There are two classes representing database tables: DatabaseTable
and PartialDatabaseTable
. If a new table should be created, use DatabaseTable
. In all other cases, PartialDatabaseTable
should be used as it provides an additional save-guard against accidentally creating a new table by having a typo in the table name: If the tables does not already exist, a table represented by PartialDatabaseTable
will cause an exception (while a DatabaseTable
table will simply be created).
To create a table, a DatabaseTable
object with the table's name as to be created and table's columns, foreign keys and indices have to be specified:
DatabaseTable::create('foo1_bar')\n ->columns([\n // columns\n ])\n ->foreignKeys([\n // foreign keys\n ])\n ->indices([\n // indices\n ])\n
To update a table, the same code as above can be used, except for PartialDatabaseTable
being used instead of DatabaseTable
.
To drop a table, only the drop()
method has to be called:
PartialDatabaseTable::create('foo1_bar')\n ->drop()\n
"},{"location":"package/database-php-api/#columns","title":"Columns","text":"To represent a column of a database table, you have to create an instance of the relevant column class found in the wcf\\system\\database\\table\\column
namespace. Such instances are created similarly to database table objects using the create()
factory method and passing the column name as the parameter.
Every column type supports the following methods:
defaultValue($defaultValue)
sets the default value of the column (default: none).drop()
to drop the column.notNull($notNull = true)
sets if the value of the column can be NULL
(default: false
).Depending on the specific column class implementing additional interfaces, the following methods are also available:
IAutoIncrementDatabaseTableColumn::autoIncrement($autoIncrement = true)
sets if the value of the colum is auto-incremented.IDecimalsDatabaseTableColumn::decimals($decimals)
sets the number of decimals the column supports.IEnumDatabaseTableColumn::enumValues(array $values)
sets the predetermined set of valid values of the column.ILengthDatabaseTableColumn::length($length)
sets the (maximum) length of the column.Additionally, there are some additionally classes of commonly used columns with specific properties:
DefaultFalseBooleanDatabaseTableColumn
(a tinyint
column with length 1
, default value 0
and whose values cannot be null
)DefaultTrueBooleanDatabaseTableColumn
(a tinyint
column with length 1
, default value 1
and whose values cannot be null
)NotNullInt10DatabaseTableColumn
(a int
column with length 10
and whose values cannot be null
)NotNullVarchar191DatabaseTableColumn
(a varchar
column with length 191
and whose values cannot be null
)NotNullVarchar255DatabaseTableColumn
(a varchar
column with length 255
and whose values cannot be null
)ObjectIdDatabaseTableColumn
(a int
column with length 10
, whose values cannot be null
, and whose values are auto-incremented)Examples:
DefaultFalseBooleanDatabaseTableColumn::create('isDisabled')\n\nNotNullInt10DatabaseTableColumn::create('fooTypeID')\n\nSmallintDatabaseTableColumn::create('bar')\n ->length(5)\n ->notNull()\n
"},{"location":"package/database-php-api/#foreign-keys","title":"Foreign Keys","text":"Foreign keys are represented by DatabaseTableForeignKey
objects:
DatabaseTableForeignKey::create()\n ->columns(['fooID'])\n ->referencedTable('wcf1_foo')\n ->referencedColumns(['fooID'])\n ->onDelete('CASCADE')\n
The supported actions for onDelete()
and onUpdate()
are CASCADE
, NO ACTION
, and SET NULL
. To drop a foreign key, all of the relevant data to create the foreign key has to be present and the drop()
method has to be called.
DatabaseTableForeignKey::create()
also supports the foreign key name as a parameter. If it is not present, DatabaseTable::foreignKeys()
will automatically set one based on the foreign key's data.
Indices are represented by DatabaseTableIndex
objects:
DatabaseTableIndex::create('fooID')\n ->type(DatabaseTableIndex::UNIQUE_TYPE)\n ->columns(['fooID'])\n
There are four different types: DatabaseTableIndex::DEFAULT_TYPE
(default), DatabaseTableIndex::PRIMARY_TYPE
, DatabaseTableIndex::UNIQUE_TYPE
, and DatabaseTableIndex::FULLTEXT_TYPE
. For primary keys, there is also the DatabaseTablePrimaryIndex
class which automatically sets the type to DatabaseTableIndex::PRIMARY_TYPE
. To drop a index, all of the relevant data to create the index has to be present and the drop()
method has to be called.
The index name is specified as the parameter to DatabaseTableIndex::create()
. It is strongly recommended to specify an explicit name (WoltLab/WCF#4505). If no name is given, DatabaseTable::indices()
will automatically set one based on the index data.
The package.xml
is the core component of every package. It provides the meta data (e.g. package name, description, author) and the instruction set for a new installation and/or updating from a previous version.
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<package name=\"com.example.package\" xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/package.xsd\">\n<packageinformation>\n<packagename>Simple Package</packagename>\n<packagedescription>A simple package to demonstrate the package system of WoltLab Suite Core</packagedescription>\n<version>1.0.0</version>\n<date>2022-01-17</date>\n</packageinformation>\n\n<authorinformation>\n<author>YOUR NAME</author>\n<authorurl>http://www.example.com</authorurl>\n</authorinformation>\n\n<requiredpackages>\n<requiredpackage minversion=\"5.4.10\">com.woltlab.wcf</requiredpackage>\n</requiredpackages>\n\n<excludedpackages>\n<excludedpackage version=\"6.0.0 Alpha 1\">com.woltlab.wcf</excludedpackage>\n</excludedpackages>\n\n<instructions type=\"install\">\n<instruction type=\"file\" />\n<instruction type=\"template\">templates.tar</instruction>\n</instructions>\n</package>\n
"},{"location":"package/package-xml/#elements","title":"Elements","text":""},{"location":"package/package-xml/#package","title":"<package>
","text":"The root node of every package.xml
it contains the reference to the namespace and the location of the XML Schema Definition (XSD).
The attribute name
is the most important part, it holds the unique package identifier and is mandatory. It is based upon your domain name and the package name of your choice.
For example WoltLab Suite Forum (formerly know an WoltLab Burning Board and usually abbreviated as wbb
) is created by WoltLab which owns the domain woltlab.com
. The resulting package identifier is com.woltlab.wbb
(<tld>.<domain>.<packageName>
).
<packageinformation>
","text":"Holds the entire meta data of the package.
"},{"location":"package/package-xml/#packagename","title":"<packagename>
","text":"This is the actual package name displayed to the end user, this can be anything you want, try to keep it short. It supports the attribute language
which allows you to provide the package name in different languages, please be aware that if it is not present, en
(English) is assumed:
<packageinformation>\n<packagename>Simple Package</packagename>\n<packagename language=\"de\">Einfaches Paket</packagename>\n</packageinformation>\n
"},{"location":"package/package-xml/#packagedescription","title":"<packagedescription>
","text":"Brief summary of the package, use it to explain what it does since the package name might not always be clear enough. The attribute language
can also be used here, please reference to <packagename>
for details.
<version>
","text":"The package's version number, this is a string consisting of three numbers separated with a dot and optionally followed by a keyword (must be followed with another number).
The possible keywords are:
Valid examples:
Invalid examples:
<date>
","text":"Must be a valid ISO 8601 date, e.g. 2013-12-27
.
<packageurl>
","text":"(optional)
URL to the package website that provides detailed information about the package.
"},{"location":"package/package-xml/#license","title":"<license>
","text":"(optional)
Name of a generic license type or URL to a custom license. The attribute language
can also be used here, please reference to <packagename>
for details.
<authorinformation>
","text":"Holds meta data regarding the package's author.
"},{"location":"package/package-xml/#author","title":"<author>
","text":"Can be anything you want.
"},{"location":"package/package-xml/#authorurl","title":"<authorurl>
","text":"(optional)
URL to the author's website.
"},{"location":"package/package-xml/#requiredpackages","title":"<requiredpackages>
","text":"A list of packages including their version required for this package to work.
"},{"location":"package/package-xml/#requiredpackage","title":"<requiredpackage>
","text":"Example:
<requiredpackage minversion=\"2.7.5\" file=\"requirements/com.example.foo.tar\">com.example.foo</requiredpackage>\n
The attribute minversion
must be a valid version number as described in <version>
. The file
attribute is optional and specifies the location of the required package's archive relative to the package.xml
.
<optionalpackage>
","text":"A list of optional packages which can be selected by the user at the very end of the installation process.
"},{"location":"package/package-xml/#optionalpackage_1","title":"<optionalpackage>
","text":"Example:
<optionalpackage file=\"optionals/com.example.bar.tar\">com.example.bar</optionalpackage>\n
The file
attribute specifies the location of the optional package's archive relative to the package.xml
.
<excludedpackages>
","text":"List of packages which conflict with this package. It is not possible to install it if any of the specified packages is installed. In return you cannot install an excluded package if this package is installed.
"},{"location":"package/package-xml/#excludedpackage","title":"<excludedpackage>
","text":"Example:
<excludedpackage version=\"7.0.0 Alpha 1\">com.woltlab.wcf</excludedpackage>\n
The attribute version
must be a valid version number as described in the \\<version> section. In the example above it will be impossible to install this package in WoltLab Suite Core 7.0.0 Alpha 1 or higher.
<instructions>
","text":"List of instructions to be executed upon install or update. The order is important, the topmost <instruction>
will be executed first.
<instructions type=\"install\">
","text":"List of instructions for a new installation of this package.
"},{"location":"package/package-xml/#instructions-typeupdate-fromversion","title":"<instructions type=\"update\" fromversion=\"\u2026\">
","text":"The attribute fromversion
must be a valid version number as described in the \\<version> section and specifies a possible update from that very version to the package's version.
The installation process will pick exactly one update instruction, ignoring everything else. Please read the explanation below!
Example:
1.0.0
1.0.2
<instructions type=\"update\" fromversion=\"1.0.0\">\n<!-- \u2026 -->\n</instructions>\n<instructions type=\"update\" fromversion=\"1.0.1\">\n<!-- \u2026 -->\n</instructions>\n
In this example WoltLab Suite Core will pick the first update block since it allows an update from 1.0.0 -> 1.0.2
. The other block is not considered, since the currently installed version is 1.0.0
. After applying the update block (fromversion=\"1.0.0\"
), the version now reads 1.0.2
.
<instruction>
","text":"Example:
<instruction type=\"objectTypeDefinition\">objectTypeDefinition.xml</instruction>\n
The attribute type
specifies the instruction type which is used to determine the package installation plugin (PIP) invoked to handle its value. The value must be a valid file relative to the location of package.xml
. Many PIPs provide default file names which are used if no value is given:
<instruction type=\"objectTypeDefinition\" />\n
There is a list of all default PIPs available.
Both the type
-attribute and the element value are case-sensitive. Windows does not care if the file is called objecttypedefinition.xml
but was referenced as objectTypeDefinition.xml
, but both Linux and Mac systems will be unable to find the file.
In addition to the type
attribute, an optional run
attribute (with standalone
as the only valid value) is supported which forces the installation to execute this PIP in an isolated request, allowing a single, resource-heavy PIP to execute without encountering restrictions such as PHP\u2019s memory_limit
or max_execution_time
:
<instruction type=\"file\" run=\"standalone\" />\n
"},{"location":"package/package-xml/#void","title":"<void/>
","text":"Sometimes a package update should only adjust the metadata of the package, for example, an optional package was added. However, WoltLab Suite Core requires that the list of <instructions>
is non-empty. Instead of using a dummy <instruction>
that idempotently updates some PIP, the <void/>
tag can be used for this use-case.
Using the <void/>
tag is only valid for <instructions type=\"update\">
and must not be accompanied by other <instruction>
tags.
Example:
<instructions type=\"update\" fromversion=\"1.0.0\">\n<void/>\n</instructions>\n
"},{"location":"package/pip/","title":"Package Installation Plugins","text":"Package Installation Plugins (PIPs) are interfaces to deploy and edit content as well as components.
For XML-based PIPs: <![CDATA[]]>
must be used for language items and page contents. In all other cases it may only be used when necessary.
Add customizable permissions for individual objects.
"},{"location":"package/pip/acl-option/#option-components","title":"Option Components","text":"Each acl option is described as an <option>
element with the mandatory attribute name
.
<categoryname>
","text":"Optional
The name of the acl option category to which the option belongs.
"},{"location":"package/pip/acl-option/#objecttype","title":"<objecttype>
","text":"The name of the acl object type (of the object type definition com.woltlab.wcf.acl
).
Each acl option category is described as an <category>
element with the mandatory attribute name
that should follow the naming pattern <permissionName>
or <permissionType>.<permissionName>
, with <permissionType>
generally having user
or mod
as value.
<objecttype>
","text":"The name of the acl object type (of the object type definition com.woltlab.wcf.acl
).
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/aclOption.xsd\">\n<import>\n<categories>\n<category name=\"user.example\">\n<objecttype>com.example.wcf.example</objecttype>\n</category>\n<category name=\"mod.example\">\n<objecttype>com.example.wcf.example</objecttype>\n</category>\n</categories>\n\n<options>\n<option name=\"canAddExample\">\n<categoryname>user.example</categoryname>\n<objecttype>com.example.wcf.example</objecttype>\n</option>\n<option name=\"canDeleteExample\">\n<categoryname>mod.example</categoryname>\n<objecttype>com.example.wcf.example</objecttype>\n</option>\n</options>\n</import>\n\n<delete>\n<optioncategory name=\"old.example\">\n<objecttype>com.example.wcf.example</objecttype>\n</optioncategory>\n<option name=\"canDoSomethingWithExample\">\n<objecttype>com.example.wcf.example</objecttype>\n</option>\n</delete>\n</data>\n
"},{"location":"package/pip/acp-menu/","title":"ACP Menu Package Installation Plugin","text":"Registers new ACP menu items.
"},{"location":"package/pip/acp-menu/#components","title":"Components","text":"Each item is described as an <acpmenuitem>
element with the mandatory attribute name
.
<parent>
","text":"Optional
The item\u2019s parent item.
"},{"location":"package/pip/acp-menu/#showorder","title":"<showorder>
","text":"Optional
Specifies the order of this item within the parent item.
"},{"location":"package/pip/acp-menu/#controller","title":"<controller>
","text":"The fully qualified class name of the target controller. If not specified this item serves as a category.
"},{"location":"package/pip/acp-menu/#link","title":"<link>
","text":"Additional components if <controller>
is set, the full external link otherwise.
<icon>
","text":"Use an icon only for top-level and 4th-level items.
Name of the Font Awesome icon class.
"},{"location":"package/pip/acp-menu/#options","title":"<options>
","text":"Optional
The options element can contain a comma-separated list of options of which at least one needs to be enabled for the tab to be shown.
"},{"location":"package/pip/acp-menu/#permissions","title":"<permissions>
","text":"Optional
The permissions element can contain a comma-separated list of permissions of which the active user needs to have at least one for the tab to be shown.
"},{"location":"package/pip/acp-menu/#example","title":"Example","text":"acpMenu.xml<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/acpMenu.xsd\">\n<import>\n<acpmenuitem name=\"foo.acp.menu.link.example\">\n<parent>wcf.acp.menu.link.application</parent>\n</acpmenuitem>\n\n<acpmenuitem name=\"foo.acp.menu.link.example.list\">\n<controller>foo\\acp\\page\\ExampleListPage</controller>\n<parent>foo.acp.menu.link.example</parent>\n<permissions>admin.foo.canManageExample</permissions>\n<showorder>1</showorder>\n</acpmenuitem>\n\n<acpmenuitem name=\"foo.acp.menu.link.example.add\">\n<controller>foo\\acp\\form\\ExampleAddForm</controller>\n<parent>foo.acp.menu.link.example.list</parent>\n<permissions>admin.foo.canManageExample</permissions>\n<icon>fa-plus</icon>\n</acpmenuitem>\n</import>\n</data>\n
"},{"location":"package/pip/acp-search-provider/","title":"ACP Search Provider Package Installation Plugin","text":"Registers data provider for the admin panel search.
"},{"location":"package/pip/acp-search-provider/#components","title":"Components","text":"Each acp search result provider is described as an <acpsearchprovider>
element with the mandatory attribute name
.
<classname>
","text":"The name of the class providing the search results, the class has to implement the wcf\\system\\search\\acp\\IACPSearchResultProvider
interface.
<showorder>
","text":"Optional
Determines at which position of the search result list the provided results are shown.
"},{"location":"package/pip/acp-search-provider/#example","title":"Example","text":"acpSearchProvider.xml<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/acpSearchProvider.xsd\">\n<import>\n<acpsearchprovider name=\"com.woltlab.wcf.example\">\n<classname>wcf\\system\\search\\acp\\ExampleACPSearchResultProvider</classname>\n<showorder>1</showorder>\n</acpsearchprovider>\n</import>\n</data>\n
"},{"location":"package/pip/acp-template-delete/","title":"ACP Template Delete Package Installation Plugin","text":"Available since WoltLab Suite 5.5.
Deletes admin panel templates installed with the acpTemplate package installation plugin.
You cannot delete acp templates provided by other packages.
"},{"location":"package/pip/acp-template-delete/#components","title":"Components","text":"Each item is described as a <template>
element with an optional application
, which behaves like it does for acp templates. The templates are identified by their name like when adding template listeners, i.e. by the file name without the .tpl
file extension.
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/acpTemplateDelete.xsd\">\n<delete>\n<template>fouAdd</template>\n<template application=\"app\">__appAdd</template>\n</delete>\n</data>\n
"},{"location":"package/pip/acp-template/","title":"ACP Template Installation Plugin","text":"Add templates for acp pages and forms by providing an archive containing the template files.
You cannot overwrite acp templates provided by other packages.
"},{"location":"package/pip/acp-template/#archive","title":"Archive","text":"The acpTemplate
package installation plugins expects a .tar
(recommended) or .tar.gz
archive. The templates must all be in the root of the archive. Do not include any directories in the archive. The file path given in the instruction
element as its value must be relative to the package.xml
file.
application
","text":"The application
attribute determines to which application the installed acp templates belong and thus in which directory the templates are installed. The value of the application
attribute has to be the abbreviation of an installed application. If no application
attribute is given, the following rules are applied:
package.xml
","text":"<instruction type=\"acpTemplate\" />\n<!-- is the same as -->\n<instruction type=\"acpTemplate\">acptemplates.tar</instruction>\n\n<!-- if an application \"com.woltlab.example\" is being installed, the following lines are equivalent -->\n<instruction type=\"acpTemplate\" />\n<instruction type=\"acpTemplate\" application=\"example\" />\n
"},{"location":"package/pip/bbcode/","title":"BBCode Package Installation Plugin","text":"Registers new BBCodes.
"},{"location":"package/pip/bbcode/#components","title":"Components","text":"Each bbcode is described as an <bbcode>
element with the mandatory attribute name
. The name
attribute must contain alphanumeric characters only and is exposed to the user.
<htmlopen>
","text":"Optional: Must not be provided if the BBCode is being processed a PHP class (<classname>
).
The contents of this tag are literally copied into the opening tag of the bbcode.
"},{"location":"package/pip/bbcode/#htmlclose","title":"<htmlclose>
","text":"Optional: Must not be provided if <htmlopen>
is not given.
Must match the <htmlopen>
tag. Do not provide for self-closing tags.
<classname>
","text":"The name of the class providing the bbcode output, the class has to implement the wcf\\system\\bbcode\\IBBCode
interface.
BBCodes can be statically converted to HTML during input processing using a wcf\\system\\html\\metacode\\converter\\*MetaConverter
class. This class does not need to be registered.
<wysiwygicon>
","text":"Optional
Name of the Font Awesome icon class or path to a gif
, jpg
, jpeg
, png
, or svg
image (placed inside the icon/
directory) to show in the editor toolbar.
<buttonlabel>
","text":"Optional: Must be provided if an icon is given.
Explanatory text to show when hovering the icon.
"},{"location":"package/pip/bbcode/#sourcecode","title":"<sourcecode>
","text":"Do not set this to 1
if you don't specify a PHP class for processing. You must perform XSS sanitizing yourself!
If set to 1
contents of this BBCode will not be interpreted, but literally passed through instead.
<isBlockElement>
","text":"Set to 1
if the output of this BBCode is a HTML block element (according to the HTML specification).
<attributes>
","text":"Each bbcode is described as an <attribute>
element with the mandatory attribute name
. The name
attribute is a 0-indexed integer.
<html>
","text":"Optional: Must not be provided if the BBCode is being processed a PHP class (<classname>
).
The contents of this tag are copied into the opening tag of the bbcode. %s
is replaced by the attribute value.
<validationpattern>
","text":"Optional
Defines a regular expression that is used to validate the value of the attribute.
"},{"location":"package/pip/bbcode/#required","title":"<required>
","text":"Optional
Specifies whether this attribute must be provided.
"},{"location":"package/pip/bbcode/#usetext","title":"<usetext>
","text":"Optional
Should only be set to 1
for the attribute with name 0
.
Specifies whether the text content of the BBCode should become this attribute's value.
"},{"location":"package/pip/bbcode/#example","title":"Example","text":"bbcode.xml<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/bbcode.xsd\">\n<import>\n<bbcode name=\"foo\">\n<classname>wcf\\system\\bbcode\\FooBBCode</classname>\n<attributes>\n<attribute name=\"0\">\n<validationpattern>^\\d+$</validationpattern>\n<required>1</required>\n</attribute>\n</attributes>\n</bbcode>\n\n<bbcode name=\"example\">\n<htmlopen>div</htmlopen>\n<htmlclose>div</htmlclose>\n<isBlockElement>1</isBlockElement>\n<wysiwygicon>fa-bath</wysiwygicon>\n<buttonlabel>wcf.editor.button.example</buttonlabel>\n</bbcode>\n</import>\n</data>\n
"},{"location":"package/pip/box/","title":"Box Package Installation Plugin","text":"Deploy and manage boxes that can be placed anywhere on the site, they come in two flavors: system and content-based.
"},{"location":"package/pip/box/#components","title":"Components","text":"Each item is described as a <box>
element with the mandatory attribute name
that should follow the naming pattern <packageIdentifier>.<BoxName>
, e.g. com.woltlab.wcf.RecentActivity
.
<name>
","text":"The language
attribute is required and should specify the ISO-639-1 language code.
The internal name displayed in the admin panel only, can be fully customized by the administrator and is immutable. Only one value is accepted and will be picked based on the site's default language, but you can provide localized values by including multiple <name>
elements.
<boxType>
","text":""},{"location":"package/pip/box/#system","title":"system
","text":"The special system
type is reserved for boxes that pull their properties and content from a registered PHP class. Requires the <objectType>
element.
html
, text
or tpl
","text":"Provide arbitrary content, requires the <content>
element.
<objectType>
","text":"Required for boxes with boxType = system
, must be registered through the objectType PIP for the definition com.woltlab.wcf.boxController
.
<position>
","text":"The default display position of this box, can be any of the following:
<showHeader>
","text":"Setting this to 0
will suppress display of the box title, useful for boxes containing advertisements or similar. Defaults to 1
.
<visibleEverywhere>
","text":"Controls the display on all pages (1
) or none (0
), can be used in conjunction with <visibilityExceptions>
.
<visibilityExceptions>
","text":"Inverts the <visibleEverywhere>
setting for the listed pages only.
<cssClassName>
","text":"Provide a custom CSS class name that is added to the menu container, allowing further customization of the menu's appearance.
"},{"location":"package/pip/box/#content","title":"<content>
","text":"The language
attribute is required and should specify the ISO-639-1 language code.
<title>
","text":"The title element is required and controls the box title shown to the end users.
"},{"location":"package/pip/box/#content_1","title":"<content>
","text":"The content that should be used to populate the box, only used and required if the boxType
equals text
, html
and tpl
.
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/box.xsd\">\n<import>\n<box identifier=\"com.woltlab.wcf.RecentActivity\">\n<name language=\"de\">Letzte Aktivit\u00e4ten</name>\n<name language=\"en\">Recent Activities</name>\n<boxType>system</boxType>\n<objectType>com.woltlab.wcf.recentActivityList</objectType>\n<position>contentBottom</position>\n<showHeader>0</showHeader>\n<visibleEverywhere>0</visibleEverywhere>\n<visibilityExceptions>\n<page>com.woltlab.wcf.Dashboard</page>\n</visibilityExceptions>\n<limit>10</limit>\n\n<content language=\"de\">\n<title>Letzte Aktivit\u00e4ten</title>\n</content>\n<content language=\"en\">\n<title>Recent Activities</title>\n</content>\n</box>\n</import>\n\n<delete>\n<box identifier=\"com.woltlab.wcf.RecentActivity\" />\n</delete>\n</data>\n
"},{"location":"package/pip/clipboard-action/","title":"Clipboard Action Package Installation Plugin","text":"Registers clipboard actions.
"},{"location":"package/pip/clipboard-action/#components","title":"Components","text":"Each clipboard action is described as an <action>
element with the mandatory attribute name
.
<actionclassname>
","text":"The name of the class used by the clipboard API to process the concrete action. The class has to implement the wcf\\system\\clipboard\\action\\IClipboardAction
interface, best by extending wcf\\system\\clipboard\\action\\AbstractClipboardAction
.
<pages>
","text":"Element with <page>
children whose value contains the class name of the controller of the page on which the clipboard action is available.
<showorder>
","text":"Optional
Determines at which position of the clipboard action list the action is shown.
"},{"location":"package/pip/clipboard-action/#example","title":"Example","text":"clipboardAction.xml<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/clipboardAction.xsd\">\n<import>\n<action name=\"delete\">\n<actionclassname>wcf\\system\\clipboard\\action\\ExampleClipboardAction</actionclassname>\n<showorder>1</showorder>\n<pages>\n<page>wcf\\acp\\page\\ExampleListPage</page>\n</pages>\n</action>\n<action name=\"foo\">\n<actionclassname>wcf\\system\\clipboard\\action\\ExampleClipboardAction</actionclassname>\n<showorder>2</showorder>\n<pages>\n<page>wcf\\acp\\page\\ExampleListPage</page>\n</pages>\n</action>\n<action name=\"bar\">\n<actionclassname>wcf\\system\\clipboard\\action\\ExampleClipboardAction</actionclassname>\n<showorder>3</showorder>\n<pages>\n<page>wcf\\acp\\page\\ExampleListPage</page>\n</pages>\n</action>\n</import>\n</data>\n
"},{"location":"package/pip/core-object/","title":"Core Object Package Installation Plugin","text":"Registers wcf\\system\\SingletonFactory
objects to be accessible in templates.
Each item is described as a <coreobject>
element with the mandatory element objectname
.
<objectname>
","text":"The fully qualified class name of the class.
"},{"location":"package/pip/core-object/#example","title":"Example","text":"coreObject.xml<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/coreObject.xsd\">\n<import>\n<coreobject>\n<objectname>wcf\\system\\example\\ExampleHandler</objectname>\n</coreobject>\n</import>\n</data>\n
This object can be accessed in templates via $__wcf->getExampleHandler()
(in general: the method name begins with get
and ends with the unqualified class name).
Registers new cronjobs. The cronjob schedular works similar to the cron(8)
daemon, which might not available to web applications on regular webspaces. The main difference is that WoltLab Suite\u2019s cronjobs do not guarantee execution at the specified points in time: WoltLab Suite\u2019s cronjobs are triggered by regular visitors in an AJAX request, once the next execution point lies in the past.
Each cronjob is described as an <cronjob>
element with the mandatory attribute name
.
<classname>
","text":"The name of the class providing the cronjob's behaviour, the class has to implement the wcf\\system\\cronjob\\ICronjob
interface.
<description>
","text":"The language
attribute is optional and should specify the ISO-639-1 language code.
Provides a human readable description for the administrator.
"},{"location":"package/pip/cronjob/#expression","title":"<expression>
","text":"The cronjob schedule. The expression accepts the same syntax as described in crontab(5)
of a cron daemon.
<canbeedited>
","text":"Controls whether the administrator may edit the fields of the cronjob. Defaults to 1
.
<canbedisabled>
","text":"Controls whether the administrator may disable the cronjob. Defaults to 1
.
<isdisabled>
","text":"Controls whether the cronjob is disabled by default. Defaults to 0
.
<options>
","text":"The options element can contain a comma-separated list of options of which at least one needs to be enabled for the template listener to be executed.
"},{"location":"package/pip/cronjob/#example","title":"Example","text":"cronjob.xml<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/cronjob.xsd\">\n<import>\n<cronjob name=\"com.example.package.example\">\n<classname>wcf\\system\\cronjob\\ExampleCronjob</classname>\n<description>Serves as an example</description>\n<description language=\"de\">Stellt ein Beispiel dar</description>\n<expression>0 2 */2 * *</expression>\n<canbeedited>1</canbeedited>\n<canbedisabled>1</canbedisabled>\n</cronjob>\n</import>\n</data>\n
"},{"location":"package/pip/database/","title":"Database Package Installation Plugin","text":"Available since WoltLab Suite 5.4.
Update the database layout using the PHP API.
You must install the PHP script through the file package installation plugin.
The installation will attempt to delete the script after successful execution.
"},{"location":"package/pip/database/#attributes","title":"Attributes","text":""},{"location":"package/pip/database/#application","title":"application
","text":"The application
attribute must have the same value as the application
attribute of the file
package installation plugin instruction so that the correct file in the intended application directory is executed. For further information about the application
attribute, refer to its documentation on the acpTemplate package installation plugin page.
The database
-PIP expects a relative path to a .php
file that returns an array of DatabaseTable
objects.
The PHP script is deployed by using the file package installation plugin. To prevent it from colliding with other install script (remember: You cannot overwrite files created by another plugin), we highly recommend to make use of these naming conventions:
acp/database/install_<package>.php
(example: acp/database/install_com.woltlab.wbb.php
)acp/database/update_<package>_<targetVersion>.php
(example: acp/database/update_com.woltlab.wbb_5.4.1.php
)<targetVersion>
equals the version number of the current package being installed. If you're updating from 1.0.0
to 1.0.1
, <targetVersion>
should read 1.0.1
.
If you run multiple update scripts, you can append additional information in the filename.
"},{"location":"package/pip/database/#execution-environment","title":"Execution environment","text":"The script is included using include()
within DatabasePackageInstallationPlugin::updateDatabase().
Registers event listeners. An explanation of events and event listeners can be found here.
"},{"location":"package/pip/event-listener/#components","title":"Components","text":"Each event listener is described as an <eventlistener>
element with a name
attribute. As the name
attribute has only be introduced with WSC 3.0, it is not yet mandatory to allow backwards compatibility. If name
is not given, the system automatically sets the name based on the id of the event listener in the database.
<eventclassname>
","text":"The event class name is the name of the class in which the event is fired.
"},{"location":"package/pip/event-listener/#eventname","title":"<eventname>
","text":"The event name is the name given when the event is fired to identify different events within the same class. You can either give a single event name or a comma-separated list of event names in which case the event listener listens to all of the listed events.
Since the introduction of the new event system with version 5.5, the event name is optional and defaults to :default
.
<listenerclassname>
","text":"The listener class name is the name of the class which is triggered if the relevant event is fired. The PHP class has to implement the wcf\\system\\event\\listener\\IParameterizedEventListener
interface.
Legacy event listeners are only required to implement the deprecated wcf\\system\\event\\IEventListener
interface. When writing new code or update existing code, you should always implement the wcf\\system\\event\\listener\\IParameterizedEventListener
interface!
<inherit>
","text":"The inherit value can either be 0
(default value if the element is omitted) or 1
and determines if the event listener is also triggered for child classes of the given event class name. This is the case if 1
is used as the value.
<environment>
","text":"The value of the environment element must be one of user
, admin
or all
and defaults to user
if no value is given. The value determines if the event listener will be executed in the frontend (user
), the backend (admin
) or both (all
).
<nice>
","text":"The nice value element can contain an integer value out of the interval [-128,127]
with 0
being the default value if the element is omitted. The nice value determines the execution order of event listeners. Event listeners with smaller nice values are executed first. If the nice value of two event listeners is equal, they are sorted by the listener class name.
If you pass a value out of the mentioned interval, the value will be adjusted to the closest value in the interval.
"},{"location":"package/pip/event-listener/#options","title":"<options>
","text":"The options element can contain a comma-separated list of options of which at least one needs to be enabled for the event listener to be executed.
"},{"location":"package/pip/event-listener/#permissions","title":"<permissions>
","text":"The permissions element can contain a comma-separated list of permissions of which the active user needs to have at least one for the event listener to be executed.
"},{"location":"package/pip/event-listener/#example","title":"Example","text":"eventListener.xml<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/eventListener.xsd\">\n<import>\n<eventlistener name=\"inheritedAdminExample\">\n<eventclassname>wcf\\acp\\form\\UserAddForm</eventclassname>\n<eventname>assignVariables,readFormParameters,save,validate</eventname>\n<listenerclassname>wcf\\system\\event\\listener\\InheritedAdminExampleListener</listenerclassname>\n<inherit>1</inherit>\n<environment>admin</environment>\n</eventlistener>\n\n<eventlistener name=\"nonInheritedUserExample\">\n<eventclassname>wcf\\form\\SettingsForm</eventclassname>\n<eventname>assignVariables</eventname>\n<listenerclassname>wcf\\system\\event\\listener\\NonInheritedUserExampleListener</listenerclassname>\n</eventlistener>\n</import>\n\n<delete>\n<eventlistener name=\"oldEventListenerName\" />\n</delete>\n</data>\n
"},{"location":"package/pip/file-delete/","title":"File Delete Package Installation Plugin","text":"Available since WoltLab Suite 5.5.
Deletes files installed with the file package installation plugin.
You cannot delete files provided by other packages.
"},{"location":"package/pip/file-delete/#components","title":"Components","text":"Each item is described as a <file>
element with an optional application
, which behaves like it does for acp templates. The file path is relative to the installation of the app to which the file belongs.
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/fileDelete.xsd\">\n<delete>\n<file>path/file.ext</file>\n<file application=\"app\">lib/data/foo/Fou.class.php</file>\n</delete>\n</data>\n
"},{"location":"package/pip/file/","title":"File Package Installation Plugin","text":"Adds any type of files with the exception of templates.
You cannot overwrite files provided by other packages.
The application
attribute behaves like it does for acp templates.
The acpTemplate
package installation plugins expects a .tar
(recommended) or .tar.gz
archive. The file path given in the instruction
element as its value must be relative to the package.xml
file.
package.xml
","text":"<instruction type=\"file\" />\n<!-- is the same as -->\n<instruction type=\"file\">files.tar</instruction>\n\n<!-- if an application \"com.woltlab.example\" is being installed, the following lines are equivalent -->\n<instruction type=\"file\" />\n<instruction type=\"file\" application=\"example\" />\n\n<!-- if the same application wants to install additional files, in WoltLab Suite Core's directory: -->\n<instruction type=\"file\" application=\"wcf\">files_wcf.tar</instruction>\n
"},{"location":"package/pip/language/","title":"Language Package Installation Plugin","text":"Registers new language items.
"},{"location":"package/pip/language/#components","title":"Components","text":"The languagecode
attribute is required and should specify the ISO-639-1 language code.
The top level <language>
node must contain a languagecode
attribute.
<category>
","text":"Each category must contain a name
attribute containing two or three components consisting of alphanumeric character only, separated by a single full stop (.
, U+002E).
<item>
","text":"Each language item must contain a name
attribute containing at least three components consisting of alphanumeric character only, separated by a single full stop (.
, U+002E). The name
of the parent <category>
node followed by a full stop must be a prefix of the <item>
\u2019s name
.
Wrap the text content inside a CDATA to avoid escaping of special characters.
Do not use the {lang}
tag inside a language item.
The text content of the <item>
node is the value of the language item. Language items that are not in the wcf.global
category support template scripting.
Prior to version 5.5, there was no support for deleting language items and the category
elements had to be placed directly as children of the language
element, see the migration guide to version 5.5.
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<language xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/language.xsd\" languagecode=\"en\">\n<import>\n<category name=\"wcf.example\">\n<item name=\"wcf.example.foo\"><![CDATA[<strong>Look!</strong>]]></item>\n</category>\n</import>\n<delete>\n<item name=\"wcf.example.obsolete\"/>\n</delete>\n</language>\n
"},{"location":"package/pip/media-provider/","title":"Media Provider Package Installation Plugin","text":"Media providers are responsible to detect and convert links to a 3rd party service inside messages.
"},{"location":"package/pip/media-provider/#components","title":"Components","text":"Each item is described as a <provider>
element with the mandatory attribute name
that should equal the lower-cased provider name. If a provider provides multiple components that are (largely) unrelated to each other, it is recommended to use a dash to separate the name and the component, e. g. youtube-playlist
.
<title>
","text":"The title is displayed in the administration control panel and is only used there, the value is neither localizable nor is it ever exposed to regular users.
"},{"location":"package/pip/media-provider/#regex","title":"<regex>
","text":"The regular expression used to identify links to this provider, it must not contain anchors or delimiters. It is strongly recommended to capture the primary object id using the (?P<ID>...)
group.
<className>
","text":"<className>
and <html>
are mutually exclusive.
PHP-Callback-Class that is invoked to process the matched link in case that additional logic must be applied that cannot be handled through a simple replacement as defined by the <html>
element.
The callback-class must implement the interface \\wcf\\system\\bbcode\\media\\provider\\IBBCodeMediaProvider
.
<html>
","text":"<className>
and <html>
are mutually exclusive.
Replacement HTML that gets populated using the captured matches in <regex>
, variables are accessed as {$VariableName}
. For example, the capture group (?P<ID>...)
is accessed using {$ID}
.
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/mediaProvider.xsd\">\n<import>\n<provider name=\"youtube\">\n<title>YouTube</title>\n<regex><![CDATA[https?://(?:.+?\\.)?youtu(?:\\.be/|be\\.com/(?:#/)?watch\\?(?:.*?&)?v=)(?P<ID>[a-zA-Z0-9_-]+)(?:(?:\\?|&)t=(?P<start>[0-9hms]+)$)?]]></regex>\n<!-- advanced PHP callback -->\n<className><![CDATA[wcf\\system\\bbcode\\media\\provider\\YouTubeBBCodeMediaProvider]]></className>\n</provider>\n\n<provider name=\"youtube-playlist\">\n<title>YouTube Playlist</title>\n<regex><![CDATA[https?://(?:.+?\\.)?youtu(?:\\.be/|be\\.com/)playlist\\?(?:.*?&)?list=(?P<ID>[a-zA-Z0-9_-]+)]]></regex>\n<!-- uses a simple HTML replacement -->\n<html><![CDATA[<div class=\"videoContainer\"><iframe src=\"https://www.youtube.com/embed/videoseries?list={$ID}\" allowfullscreen></iframe></div>]]></html>\n</provider>\n</import>\n\n<delete>\n<provider name=\"example\" />\n</delete>\n</data>\n
"},{"location":"package/pip/menu-item/","title":"Menu Item Package Installation Plugin","text":"Adds menu items to existing menus.
"},{"location":"package/pip/menu-item/#components","title":"Components","text":"Each item is described as an <item>
element with the mandatory attribute identifier
that should follow the naming pattern <packageIdentifier>.<PageName>
, e.g. com.woltlab.wcf.Dashboard
.
<menu>
","text":"The target menu that the item should be added to, requires the internal identifier set by creating a menu through the menu.xml.
"},{"location":"package/pip/menu-item/#title","title":"<title>
","text":"The language
attribute is required and should specify the ISO-639-1 language code.
The title is displayed as the link title of the menu item and can be fully customized by the administrator, thus is immutable after deployment. Supports multiple <title>
elements to provide localized values.
<page>
","text":"The page that the link should point to, requires the internal identifier set by creating a page through the page.xml.
"},{"location":"package/pip/menu-item/#example","title":"Example","text":"menuItem.xml<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/menuItem.xsd\">\n<import>\n<item identifier=\"com.woltlab.wcf.Dashboard\">\n<menu>com.woltlab.wcf.MainMenu</menu>\n<title language=\"de\">Dashboard</title>\n<title language=\"en\">Dashboard</title>\n<page>com.woltlab.wcf.Dashboard</page>\n</item>\n</import>\n\n<delete>\n<item identifier=\"com.woltlab.wcf.FooterLinks\" />\n</delete>\n</data>\n
"},{"location":"package/pip/menu/","title":"Menu Package Installation Plugin","text":"Deploy and manage menus that can be placed anywhere on the site.
"},{"location":"package/pip/menu/#components","title":"Components","text":"Each item is described as a <menu>
element with the mandatory attribute identifier
that should follow the naming pattern <packageIdentifier>.<MenuName>
, e.g. com.woltlab.wcf.MainMenu
.
<title>
","text":"The language
attribute is required and should specify the ISO-639-1 language code.
The internal name displayed in the admin panel only, can be fully customized by the administrator and is immutable. Only one value is accepted and will be picked based on the site's default language, but you can provide localized values by including multiple <title>
elements.
<box>
","text":"The following elements of the box PIP are supported, please refer to the documentation to learn more about them:
<position>
<showHeader>
<visibleEverywhere>
<visibilityExceptions>
cssClassName
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/menu.xsd\">\n<import>\n<menu identifier=\"com.woltlab.wcf.FooterLinks\">\n<title language=\"de\">Footer-Links</title>\n<title language=\"en\">Footer Links</title>\n\n<box>\n<position>footer</position>\n<cssClassName>boxMenuLinkGroup</cssClassName>\n<showHeader>0</showHeader>\n<visibleEverywhere>1</visibleEverywhere>\n</box>\n</menu>\n</import>\n\n<delete>\n<menu identifier=\"com.woltlab.wcf.FooterLinks\" />\n</delete>\n</data>\n
"},{"location":"package/pip/object-type-definition/","title":"Object Type Definition Package Installation Plugin","text":"Registers an object type definition. An object type definition is a blueprint for a certain behaviour that is particularized by objectTypes. As an example: Tags can be attached to different types of content (such as forum posts or gallery images). The bulk of the work is implemented in a generalized fashion, with all the tags stored in a single database table. Certain things, such as permission checking, need to be particularized for the specific type of content, though. Thus tags (or rather \u201ctaggable content\u201d) are registered as an object type definition. Posts are then registered as an object type, implementing the \u201ctaggable content\u201d behaviour.
Other types of object type definitions include attachments, likes, polls, subscriptions, or even the category system.
"},{"location":"package/pip/object-type-definition/#components","title":"Components","text":"Each item is described as a <definition>
element with the mandatory child <name>
that should follow the naming pattern <packageIdentifier>.<definition>
, e.g. com.woltlab.wcf.example
.
<interfacename>
","text":"Optional
The name of the PHP interface objectTypes have to implement.
"},{"location":"package/pip/object-type-definition/#example","title":"Example","text":"objectTypeDefinition.xml<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/objectTypeDefinition.xsd\">\n<import>\n<definition>\n<name>com.woltlab.wcf.example</name>\n<interfacename>wcf\\system\\example\\IExampleObjectType</interfacename>\n</definition>\n</import>\n</data>\n
"},{"location":"package/pip/object-type/","title":"Object Type Package Installation Plugin","text":"Registers an object type. Read about object types in the objectTypeDefinition PIP.
"},{"location":"package/pip/object-type/#components","title":"Components","text":"Each item is described as a <type>
element with the mandatory child <name>
that should follow the naming pattern <packageIdentifier>.<definition>
, e.g. com.woltlab.wcf.example
.
<definitionname>
","text":"The <name>
of the objectTypeDefinition.
<classname>
","text":"The name of the class providing the object types's behaviour, the class has to implement the <interfacename>
interface of the object type definition.
<*>
","text":"Optional
Additional fields may be defined for specific definitions of object types. Refer to the documentation of these for further explanation.
"},{"location":"package/pip/object-type/#example","title":"Example","text":"objectType.xml<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/objectType.xsd\">\n<import>\n<type>\n<name>com.woltlab.wcf.example</name>\n<definitionname>com.woltlab.wcf.bulkProcessing.user.condition</definitionname>\n<classname>wcf\\system\\condition\\UserIntegerPropertyCondition</classname>\n<conditiongroup>contents</conditiongroup>\n<propertyname>example</propertyname>\n<minvalue>0</minvalue>\n</type>\n</import>\n</data>\n
"},{"location":"package/pip/option/","title":"Option Package Installation Plugin","text":"Registers new options. Options allow the administrator to configure the behaviour of installed packages. The specified values are exposed as PHP constants.
"},{"location":"package/pip/option/#category-components","title":"Category Components","text":"Each category is described as an <category>
element with the mandatory attribute name
.
<parent>
","text":"Optional
The category\u2019s parent category.
"},{"location":"package/pip/option/#showorder","title":"<showorder>
","text":"Optional
Specifies the order of this option within the parent category.
"},{"location":"package/pip/option/#options","title":"<options>
","text":"Optional
The options element can contain a comma-separated list of options of which at least one needs to be enabled for the category to be shown to the administrator.
"},{"location":"package/pip/option/#option-components","title":"Option Components","text":"Each option is described as an <option>
element with the mandatory attribute name
. The name
is transformed into a PHP constant name by uppercasing it.
<categoryname>
","text":"The option\u2019s category.
"},{"location":"package/pip/option/#optiontype","title":"<optiontype>
","text":"The type of input to be used for this option. Valid types are defined by the wcf\\system\\option\\*OptionType
classes.
<defaultvalue>
","text":"The value that is set after installation of a package. Valid values are defined by the optiontype
.
<validationpattern>
","text":"Optional
Defines a regular expression that is used to validate the value of a free form option (such as text
).
<showorder>
","text":"Optional
Specifies the order of this option within the category.
"},{"location":"package/pip/option/#selectoptions","title":"<selectoptions>
","text":"Optional
Defined only for select
, multiSelect
and radioButton
types.
Specifies a newline-separated list of selectable values. Each line consists of an internal handle, followed by a colon (:
, U+003A), followed by a language item. The language item is shown to the administrator, the internal handle is what is saved and exposed to the code.
<enableoptions>
","text":"Optional
Defined only for boolean
, select
and radioButton
types.
Specifies a comma-separated list of options which should be visually enabled when this option is enabled. A leading exclamation mark (!
, U+0021) will disable the specified option when this option is enabled. For select
and radioButton
types the list should be prefixed by the internal selectoptions
handle followed by a colon (:
, U+003A).
This setting is a visual helper for the administrator only. It does not have an effect on the server side processing of the option.
"},{"location":"package/pip/option/#hidden","title":"<hidden>
","text":"Optional
If hidden
is set to 1
the option will not be shown to the administrator. It still can be modified programmatically.
<options>
","text":"Optional
The options element can contain a comma-separated list of options of which at least one needs to be enabled for the option to be shown to the administrator.
"},{"location":"package/pip/option/#supporti18n","title":"<supporti18n>
","text":"Optional
Specifies whether this option supports localized input.
"},{"location":"package/pip/option/#requirei18n","title":"<requirei18n>
","text":"Optional
Specifies whether this option requires localized input (i.e. the administrator must specify a value for every installed language).
"},{"location":"package/pip/option/#_1","title":"<*>
","text":"Optional
Additional fields may be defined by specific types of options. Refer to the documentation of these for further explanation.
"},{"location":"package/pip/option/#language-items","title":"Language Items","text":"All relevant language items have to be put into the wcf.acp.option
language item category.
If you install a category named example.sub
, you have to provide the language item wcf.acp.option.category.example.sub
, which is used when displaying the options. If you want to provide an optional description of the category, you have to provide the language item wcf.acp.option.category.example.sub.description
. Descriptions are only relevant for categories whose parent has a parent itself, i.e. categories on the third level.
If you install an option named module_example
, you have to provide the language item wcf.acp.option.module_example
, which is used as a label for setting the option value. If you want to provide an optional description of the option, you have to provide the language item wcf.acp.option.module_example.description
.
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/option.xsd\">\n<import>\n<categories>\n<category name=\"example\" />\n<category name=\"example.sub\">\n<parent>example</parent>\n<options>module_example</options>\n</category>\n</categories>\n\n<options>\n<option name=\"module_example\">\n<categoryname>module.community</categoryname>\n<optiontype>boolean</optiontype>\n<defaultvalue>1</defaultvalue>\n</option>\n\n<option name=\"example_integer\">\n<categoryname>example.sub</categoryname>\n<optiontype>integer</optiontype>\n<defaultvalue>10</defaultvalue>\n<minvalue>5</minvalue>\n<maxvalue>40</maxvalue>\n</option>\n\n<option name=\"example_select\">\n<categoryname>example.sub</categoryname>\n<optiontype>select</optiontype>\n<defaultvalue>DESC</defaultvalue>\n<selectoptions>ASC:wcf.global.sortOrder.ascending\n DESC:wcf.global.sortOrder.descending</selectoptions>\n</option>\n</options>\n</import>\n\n<delete>\n<option name=\"outdated_example\" />\n</delete>\n</data>\n
"},{"location":"package/pip/page/","title":"Page Package Installation Plugin","text":"Registers page controllers, making them available for selection and configuration, including but not limited to boxes and menus.
"},{"location":"package/pip/page/#components","title":"Components","text":"Each item is described as a <page>
element with the mandatory attribute identifier
that should follow the naming pattern <packageIdentifier>.<PageName>
, e.g. com.woltlab.wcf.MembersList
.
<pageType>
","text":""},{"location":"package/pip/page/#system","title":"system
","text":"The special system
type is reserved for pages that pull their properties and content from a registered PHP class. Requires the <controller>
element.
html
, text
or tpl
","text":"Provide arbitrary content, requires the <content>
element.
<controller>
","text":"Fully qualified class name for the controller, must implement wcf\\page\\IPage
or wcf\\form\\IForm
.
<handler>
","text":"Fully qualified class name that can be optionally set to provide additional methods, such as displaying a badge for unread content and verifying permissions per page object id.
"},{"location":"package/pip/page/#name","title":"<name>
","text":"The language
attribute is required and should specify the ISO-639-1 language code.
The internal name displayed in the admin panel only, can be fully customized by the administrator and is immutable. Only one value is accepted and will be picked based on the site's default language, but you can provide localized values by including multiple <name>
elements.
<parent>
","text":"Sets the default parent page using its internal identifier, this setting controls the breadcrumbs and active menu item hierarchy.
"},{"location":"package/pip/page/#hasfixedparent","title":"<hasFixedParent>
","text":"Pages can be assigned any other page as parent page by default, set to 1
to make the parent setting immutable.
<permissions>
","text":"The comma represents a logical or
, the check is successful if at least one permission is set.
Comma separated list of permission names that will be checked one after another until at least one permission is set.
"},{"location":"package/pip/page/#options","title":"<options>
","text":"The comma represents a logical or
, the check is successful if at least one option is enabled.
Comma separated list of options that will be checked one after another until at least one option is set.
"},{"location":"package/pip/page/#excludefromlandingpage","title":"<excludeFromLandingPage>
","text":"Some pages should not be used as landing page, because they may not always be available and/or accessible to the user. For example, the account management page is available to logged-in users only and any guest attempting to visit that page would be presented with a permission denied message.
Set this to 1
to prevent this page from becoming a landing page ever.
<requireObjectID>
","text":"If the page requires an id of a specific object, like the user profile page requires the id of the user whose profile page is requested, <requireObjectID>1</requireObjectID>
has to be added. If this item is not present, requireObjectID
defaults to 0
.
<availableDuringOfflineMode>
","text":"During offline mode, most pages should generally not be available. Certain pages, however, might still have to be accessible due to, for example, legal reasons. To make a page available during offline mode, <availableDuringOfflineMode>1</availableDuringOfflineMode>
has to be added. If this item is not present, availableDuringOfflineMode
defaults to 0
.
<allowSpidersToIndex>
","text":"Administrators are able to set in the admin panel for each page, whether or not spiders are allowed to index it. The default value for this option can be set with the allowSpidersToIndex
item whose value defaults to 0
.
<cssClassName>
","text":"To add custom CSS classes to a page\u2019s <body>
HTML element, you can specify them via the cssClassName
item.
If you want to add multiple CSS classes, separate them with spaces!
"},{"location":"package/pip/page/#content","title":"<content>
","text":"The language
attribute is required and should specify the ISO-639-1 language code.
<title>
","text":"The title element is required and controls the page title shown to the end users.
"},{"location":"package/pip/page/#content_1","title":"<content>
","text":"The content that should be used to populate the page, only used and required if the pageType
equals text
, html
and tpl
.
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/page.xsd\">\n<import>\n<page identifier=\"com.woltlab.wcf.MembersList\">\n<pageType>system</pageType>\n<controller>wcf\\page\\MembersListPage</controller>\n<name language=\"de\">Mitglieder</name>\n<name language=\"en\">Members</name>\n<options>module_members_list</options>\n<permissions>user.profile.canViewMembersList</permissions>\n<allowSpidersToIndex>1</allowSpidersToIndex>\n<content language=\"en\">\n<title>Members</title>\n</content>\n<content language=\"de\">\n<title>Mitglieder</title>\n</content>\n</page>\n</import>\n\n<delete>\n<page identifier=\"com.woltlab.wcf.MembersList\" />\n</delete>\n</data>\n
"},{"location":"package/pip/pip/","title":"Package Installation Plugin Package Installation Plugin","text":"Registers new package installation plugins.
"},{"location":"package/pip/pip/#components","title":"Components","text":"Each package installation plugin is described as an <pip>
element with a name
attribute and a PHP classname as the text content.
The package installation plugin\u2019s class file must be installed into the wcf
application and must not include classes outside the \\wcf\\*
hierarchy to allow for proper uninstallation!
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/packageInstallationPlugin.xsd\">\n<import>\n<pip name=\"custom\">wcf\\system\\package\\plugin\\CustomPackageInstallationPlugin</pip>\n</import>\n<delete>\n<pip name=\"outdated\" />\n</delete>\n</data>\n
"},{"location":"package/pip/script/","title":"Script Package Installation Plugin","text":"Execute arbitrary PHP code during installation, update and uninstallation of the package.
You must install the PHP script through the file package installation plugin.
The installation will attempt to delete the script after successful execution.
"},{"location":"package/pip/script/#attributes","title":"Attributes","text":""},{"location":"package/pip/script/#application","title":"application
","text":"The application
attribute must have the same value as the application
attribute of the file
package installation plugin instruction so that the correct file in the intended application directory is executed. For further information about the application
attribute, refer to its documentation on the acpTemplate package installation plugin page.
The script
-PIP expects a relative path to a .php
file.
The PHP script is deployed by using the file package installation plugin. To prevent it from colliding with other install script (remember: You cannot overwrite files created by another plugin), we highly recommend to make use of these naming conventions:
install_<package>.php
(example: install_com.woltlab.wbb.php
)update_<package>_<targetVersion>.php
(example: update_com.woltlab.wbb_5.0.0_pl_1.php
)<targetVersion>
equals the version number of the current package being installed. If you're updating from 1.0.0
to 1.0.1
, <targetVersion>
should read 1.0.1
.
The script is included using include()
within ScriptPackageInstallationPlugin::run(). This grants you access to the class members, including $this->installation
.
You can retrieve the package id of the current package through $this->installation->getPackageID()
.
Installs new smileys.
"},{"location":"package/pip/smiley/#components","title":"Components","text":"Each smiley is described as an <smiley>
element with the mandatory attribute name
.
<title>
","text":"Short human readable description of the smiley.
"},{"location":"package/pip/smiley/#path2x","title":"<path(2x)?>
","text":"The files must be installed using the file PIP.
File path relative to the root of WoltLab Suite Core. path2x
is optional and being used for High-DPI screens.
<aliases>
","text":"Optional
List of smiley aliases. Aliases must be separated by a line feed character (\\n
, U+000A).
<showorder>
","text":"Optional
Determines at which position of the smiley list the smiley is shown.
"},{"location":"package/pip/smiley/#example","title":"Example","text":"smiley.xml<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/smiley.xsd\">\n<import>\n<smiley name=\":example:\">\n<title>example</title>\n<path>images/smilies/example.png</path>\n<path2x>images/smilies/example@2x.png</path2x>\n<aliases><![CDATA[:alias:\n:more_aliases:]]></aliases>\n</smiley>\n</import>\n</data>\n
"},{"location":"package/pip/sql/","title":"SQL Package Installation Plugin","text":"Execute SQL instructions using a MySQL-flavored syntax.
This file is parsed by WoltLab Suite Core to allow reverting of certain changes, but not every syntax MySQL supports is recognized by the parser. To avoid any troubles, you should always use statements relying on the SQL standard.
"},{"location":"package/pip/sql/#expected-value","title":"Expected Value","text":"The sql
package installation plugin expects a relative path to a .sql
file.
WoltLab Suite Core uses a SQL parser to extract queries and log certain actions. This allows WoltLab Suite Core to revert some of the changes you apply upon package uninstallation.
The logged changes are:
CREATE TABLE
ALTER TABLE \u2026 ADD COLUMN
ALTER TABLE \u2026 ADD \u2026 KEY
It is possible to use different instance numbers, e.g. two separate WoltLab Suite Core installations within one database. WoltLab Suite Core requires you to always use wcf1_<tableName>
or <app>1_<tableName>
(e.g. blog1_blog
in WoltLab Suite Blog), the number (1
) will be automatically replaced prior to execution. If you every use anything other but 1
, you will eventually break things, thus always use 1
!
WoltLab Suite Core will determine the type of database tables on its own: If the table contains a FULLTEXT
index, it uses MyISAM
, otherwise InnoDB
is used.
WoltLab Suite Core cannot revert changes to the database structure which would cause to the data to be either changed or new data to be incompatible with the original format. Additionally, WoltLab Suite Core does not track regular SQL queries such as DELETE
or UPDATE
.
WoltLab Suite Core does not support trigger since MySQL does not support execution of triggers if the event was fired by a cascading foreign key action. If you really need triggers, you should consider adding them by custom SQL queries using a script.
"},{"location":"package/pip/sql/#example","title":"Example","text":"package.xml
:
<instruction type=\"sql\">install.sql</instruction>\n
Example content:
install.sqlCREATE TABLE wcf1_foo_bar (\nfooID INT(10) NOT NULL AUTO_INCREMENT PRIMARY KEY,\npackageID INT(10) NOT NULL,\nbar VARCHAR(255) NOT NULL DEFAULT '',\nfoobar VARCHAR(50) NOT NULL DEFAULT '',\n\nUNIQUE KEY baz (bar, foobar)\n);\n\nALTER TABLE wcf1_foo_bar ADD FOREIGN KEY (packageID) REFERENCES wcf1_package (packageID) ON DELETE CASCADE;\n
"},{"location":"package/pip/style/","title":"Style Package Installation Plugin","text":"Install styles during package installation.
The style
package installation plugins expects a relative path to a .tar
file, a.tar.gz
file or a .tgz
file. Please use the ACP's export mechanism to export styles.
package.xml
","text":"<instruction type=\"style\">style.tgz</instruction>\n
"},{"location":"package/pip/template-delete/","title":"Template Delete Package Installation Plugin","text":"Available since WoltLab Suite 5.5.
Deletes frontend templates installed with the template package installation plugin.
You cannot delete templates provided by other packages.
"},{"location":"package/pip/template-delete/#components","title":"Components","text":"Each item is described as a <template>
element with an optional application
, which behaves like it does for acp templates. The templates are identified by their name like when adding template listeners, i.e. by the file name without the .tpl
file extension.
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/templateDelete.xsd\">\n<delete>\n<template>fouAdd</template>\n<template application=\"app\">__appAdd</template>\n</delete>\n</data>\n
"},{"location":"package/pip/template-listener/","title":"Template Listener Package Installation Plugin","text":"Registers template listeners. Template listeners supplement event listeners, which modify server side behaviour, by adding additional template code to display additional elements. The added template code behaves as if it was part of the original template (i.e. it has access to all local variables).
"},{"location":"package/pip/template-listener/#components","title":"Components","text":"Each event listener is described as an <templatelistener>
element with a name
attribute. As the name
attribute has only be introduced with WSC 3.0, it is not yet mandatory to allow backwards compatibility. If name
is not given, the system automatically sets the name based on the id of the event listener in the database.
<templatename>
","text":"The template name is the name of the template in which the event is fired. It correspondes to the eventclassname
field of event listeners.
<eventname>
","text":"The event name is the name given when the event is fired to identify different events within the same template.
"},{"location":"package/pip/template-listener/#templatecode","title":"<templatecode>
","text":"The given template code is literally copied into the target template during compile time. The original template is not modified. If multiple template listeners listen to a single event their output is concatenated using the line feed character (\\n
, U+000A) in the order defined by the niceValue
.
It is recommend that the only code is an {include}
of a template to enable changes by the administrator. Names of templates included by a template listener start with two underscores by convention.
<environment>
","text":"The value of the environment element can either be admin
or user
and is user
if no value is given. The value determines if the template listener will be executed in the frontend (user
) or the backend (admin
).
<nice>
","text":"Optional
The nice value element can contain an integer value out of the interval [-128,127]
with 0
being the default value if the element is omitted. The nice value determines the execution order of template listeners. Template listeners with smaller nice values are executed first. If the nice value of two template listeners is equal, the order is undefined.
If you pass a value out of the mentioned interval, the value will be adjusted to the closest value in the interval.
"},{"location":"package/pip/template-listener/#options","title":"<options>
","text":"Optional
The options element can contain a comma-separated list of options of which at least one needs to be enabled for the template listener to be executed.
"},{"location":"package/pip/template-listener/#permissions","title":"<permissions>
","text":"Optional
The permissions element can contain a comma-separated list of permissions of which the active user needs to have at least one for the template listener to be executed.
"},{"location":"package/pip/template-listener/#example","title":"Example","text":"templateListener.xml<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/templatelistener.xsd\">\n<import>\n<templatelistener name=\"example\">\n<environment>user</environment>\n<templatename>headIncludeJavaScript</templatename>\n<eventname>javascriptInclude</eventname>\n<templatecode><![CDATA[{include file='__myCustomJavaScript'}]]></templatecode>\n</templatelistener>\n</import>\n\n<delete>\n<templatelistener name=\"oldTemplateListenerName\">\n<environment>user</environment>\n<templatename>headIncludeJavaScript</templatename>\n<eventname>javascriptInclude</eventname>\n</templatelistener>\n</delete>\n</data>\n
"},{"location":"package/pip/template/","title":"Template Package Installation Plugin","text":"Add templates for frontend pages and forms by providing an archive containing the template files.
You cannot overwrite templates provided by other packages.
This package installation plugin behaves exactly like the acpTemplate package installation plugin except for installing frontend templates instead of backend/acp templates.
"},{"location":"package/pip/user-group-option/","title":"User Group Option Package Installation Plugin","text":"Registers new user group options (\u201cpermissions\u201d). The behaviour of this package installation plugin closely follows the option PIP.
"},{"location":"package/pip/user-group-option/#category-components","title":"Category Components","text":"The category definition works exactly like the option PIP.
"},{"location":"package/pip/user-group-option/#option-components","title":"Option Components","text":"The fields hidden
, supporti18n
and requirei18n
do not apply. The following extra fields are defined:
<(admin|mod|user)defaultvalue>
","text":"Defines the defaultvalue
s for subsets of the groups:
admin.user.accessibleGroups
user group option includes every group. mod Groups where the mod.general.canUseModeration
is set to true
. user Groups where the internal group type is neither UserGroup::EVERYONE
nor UserGroup::GUESTS
."},{"location":"package/pip/user-group-option/#usersonly","title":"<usersonly>
","text":"Makes the option unavailable for groups with the group type UserGroup::GUESTS
.
All relevant language items have to be put into the wcf.acp.group
language item category.
If you install a category named user.foo
, you have to provide the language item wcf.acp.group.option.category.user.foo
, which is used when displaying the options. If you want to provide an optional description of the category, you have to provide the language item wcf.acp.group.option.category.user.foo.description
. Descriptions are only relevant for categories whose parent has a parent itself, i.e. categories on the third level.
If you install an option named user.foo.canBar
, you have to provide the language item wcf.acp.group.option.user.foo.canBar
, which is used as a label for setting the option value. If you want to provide an optional description of the option, you have to provide the language item wcf.acp.group.option.user.foo.canBar.description
.
Registers new user menu items.
"},{"location":"package/pip/user-menu/#components","title":"Components","text":"Each item is described as an <usermenuitem>
element with the mandatory attribute name
.
<parent>
","text":"Optional
The item\u2019s parent item.
"},{"location":"package/pip/user-menu/#showorder","title":"<showorder>
","text":"Optional
Specifies the order of this item within the parent item.
"},{"location":"package/pip/user-menu/#controller","title":"<controller>
","text":"The fully qualified class name of the target controller. If not specified this item serves as a category.
"},{"location":"package/pip/user-menu/#link","title":"<link>
","text":"Additional components if <controller>
is set, the full external link otherwise.
<iconclassname>
","text":"Use an icon only for top-level items.
Name of the Font Awesome icon class.
"},{"location":"package/pip/user-menu/#options","title":"<options>
","text":"Optional
The options element can contain a comma-separated list of options of which at least one needs to be enabled for the menu item to be shown.
"},{"location":"package/pip/user-menu/#permissions","title":"<permissions>
","text":"Optional
The permissions element can contain a comma-separated list of permissions of which the active user needs to have at least one for the menu item to be shown.
"},{"location":"package/pip/user-menu/#classname","title":"<classname>
","text":"The name of the class providing the user menu item\u2019s behaviour, the class has to implement the wcf\\system\\menu\\user\\IUserMenuItemProvider
interface.
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/userMenu.xsd\">\n<import>\n<usermenuitem name=\"wcf.user.menu.foo\">\n<iconclassname>fa-home</iconclassname>\n</usermenuitem>\n\n<usermenuitem name=\"wcf.user.menu.foo.bar\">\n<controller>wcf\\page\\FooBarListPage</controller>\n<parent>wcf.user.menu.foo</parent>\n<permissions>user.foo.canBar</permissions>\n<classname>wcf\\system\\menu\\user\\FooBarMenuItemProvider</classname>\n</usermenuitem>\n\n<usermenuitem name=\"wcf.user.menu.foo.baz\">\n<controller>wcf\\page\\FooBazListPage</controller>\n<parent>wcf.user.menu.foo</parent>\n<permissions>user.foo.canBaz</permissions>\n<options>module_foo_bar</options>\n</usermenuitem>\n</import>\n</data>\n
"},{"location":"package/pip/user-notification-event/","title":"User Notification Event Package Installation Plugin","text":"Registers new user notification events.
"},{"location":"package/pip/user-notification-event/#components","title":"Components","text":"Each package installation plugin is described as an <event>
element with the mandatory child <name>
.
<objectType>
","text":"The (name, objectType)
pair must be unique.
The given object type must implement the com.woltlab.wcf.notification.objectType
definition.
<classname>
","text":"The name of the class providing the event's behaviour, the class has to implement the wcf\\system\\user\\notification\\event\\IUserNotificationEvent
interface.
<preset>
","text":"Defines whether this event is enabled by default.
"},{"location":"package/pip/user-notification-event/#presetmailnotificationtype","title":"<presetmailnotificationtype>
","text":"Avoid using this option, as sending unsolicited mail can be seen as spamming.
One of instant
or daily
. Defines whether this type of email notifications is enabled by default.
<options>
","text":"Optional
The options element can contain a comma-separated list of options of which at least one needs to be enabled for the notification type to be available.
"},{"location":"package/pip/user-notification-event/#permissions","title":"<permissions>
","text":"Optional
The permissions element can contain a comma-separated list of permissions of which the active user needs to have at least one for the notification type to be available.
"},{"location":"package/pip/user-notification-event/#example","title":"Example","text":"userNotificationEvent.xml<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/userNotificationEvent.xsd\">\n<import>\n<event>\n<name>like</name>\n<objecttype>com.woltlab.example.comment.like.notification</objecttype>\n<classname>wcf\\system\\user\\notification\\event\\ExampleCommentLikeUserNotificationEvent</classname>\n<preset>1</preset>\n<options>module_like</options>\n</event>\n</import>\n</data>\n
"},{"location":"package/pip/user-option/","title":"User Option Package Installation Plugin","text":"Registers new user options (profile fields / user settings). The behaviour of this package installation plugin closely follows the option PIP.
"},{"location":"package/pip/user-option/#category-components","title":"Category Components","text":"The category definition works exactly like the option PIP.
"},{"location":"package/pip/user-option/#option-components","title":"Option Components","text":"The fields hidden
, supporti18n
and requirei18n
do not apply. The following extra fields are defined:
<required>
","text":"Requires that a value is provided.
"},{"location":"package/pip/user-option/#askduringregistration","title":"<askduringregistration>
","text":"If set to 1
the field is shown during user registration in the frontend.
<editable>
","text":"Bitfield with the following options (constants in wcf\\data\\user\\option\\UserOption
)
<visible>
","text":"Bitfield with the following options (constants in wcf\\data\\user\\option\\UserOption
)
<searchable>
","text":"If set to 1
the field is searchable.
<outputclass>
","text":"PHP class responsible for output formatting of this field. the class has to implement the wcf\\system\\option\\user\\IUserOptionOutput
interface.
All relevant language items have to be put into the wcf.user.option
language item category.
If you install a category named example
, you have to provide the language item wcf.user.option.category.example
, which is used when displaying the options. If you want to provide an optional description of the category, you have to provide the language item wcf.user.option.category.example.description
. Descriptions are only relevant for categories whose parent has a parent itself, i.e. categories on the third level.
If you install an option named exampleOption
, you have to provide the language item wcf.user.option.exampleOption
, which is used as a label for setting the option value. If you want to provide an optional description of the option, you have to provide the language item wcf.user.option.exampleOption.description
.
Registers new user profile tabs.
"},{"location":"package/pip/user-profile-menu/#components","title":"Components","text":"Each tab is described as an <userprofilemenuitem>
element with the mandatory attribute name
.
<classname>
","text":"The name of the class providing the tab\u2019s behaviour, the class has to implement the wcf\\system\\menu\\user\\profile\\content\\IUserProfileMenuContent
interface.
<showorder>
","text":"Optional
Determines at which position of the tab list the tab is shown.
"},{"location":"package/pip/user-profile-menu/#options","title":"<options>
","text":"Optional
The options element can contain a comma-separated list of options of which at least one needs to be enabled for the tab to be shown.
"},{"location":"package/pip/user-profile-menu/#permissions","title":"<permissions>
","text":"Optional
The permissions element can contain a comma-separated list of permissions of which the active user needs to have at least one for the tab to be shown.
"},{"location":"package/pip/user-profile-menu/#example","title":"Example","text":"userProfileMenu.xml<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/userProfileMenu.xsd\">\n<import>\n<userprofilemenuitem name=\"example\">\n<classname>wcf\\system\\menu\\user\\profile\\content\\ExampleProfileMenuContent</classname>\n<showorder>3</showorder>\n<options>module_example</options>\n</userprofilemenuitem>\n</import>\n</data>\n
"},{"location":"php/apps/","title":"Apps for WoltLab Suite","text":""},{"location":"php/apps/#introduction","title":"Introduction","text":"Apps are among the most powerful components in WoltLab Suite. Unlike plugins that extend an existing functionality and pages, apps have their own frontend with a dedicated namespace, database table prefixes and template locations.
However, apps are meant to be a logical (and to some extent physical) separation from other parts of the framework, including other installed apps. They offer an additional layer of isolation and enable you to re-use class and template names that are already in use by the Core itself.
If you've come here, thinking about the question if your next package should be an app instead of a regular plugin, the result is almost always: No.
"},{"location":"php/apps/#differences-to-plugins","title":"Differences to Plugins","text":"Apps do offer a couple of unique features that are not available to plugins and there are valid reasons to use one instead of a plugin, but they also increase both the code and system complexity. There is a performance penalty for each installed app, regardless if it is actively used in a request or not, simplying being there forces the Core to include it in many places, for example, class resolution or even simple tasks such as constructing a link.
"},{"location":"php/apps/#unique-namespace","title":"Unique Namespace","text":"Each app has its own unique namespace that is entirely separated from the Core and any other installed apps. The namespace is derived from the last part of the package identifier, for example, com.example.foo
will yield the namespace foo
.
The namespace is always relative to the installation directory of the app, it doesn't matter if the app is installed on example.com/foo/
or in example.com/bar/
, the namespace will always resolve to the right directory.
This app namespace is also used for ACP templates, frontend templates and files:
<!-- somewhere in the package.xml -->\n<instructions type=\"file\" application=\"foo\" />\n
"},{"location":"php/apps/#unique-database-table-prefix","title":"Unique Database Table Prefix","text":"All database tables make use of a generic prefix that is derived from one of the installed apps, including wcf
which resolves to the Core itself. Following the aforementioned example, the new prefix fooN_
will be automatically registered and recognized in any generated statement.
Any DatabaseObject
that uses the app's namespace is automatically assumed to use the app's database prefix. For instance, foo\\data\\bar\\Bar
is implicitly mapped to the database table fooN_bar
.
The app prefix is recognized in SQL-PIPs and statements that reference one of its database tables are automatically rewritten to use the Core's instance number.
"},{"location":"php/apps/#separate-domain-and-path-configuration","title":"Separate Domain and Path Configuration","text":"Any controller that is provided by a plugin is served from the configured domain and path of the corresponding app, such as plugins for the Core are always served from the Core's directory. Apps are different and use their own domain and/or path to present their content, additionally, this allows the app to re-use a controller name that is already provided by the Core or any other app itself.
"},{"location":"php/apps/#creating-an-app","title":"Creating an App","text":"This is a non-reversible operation! Once a package has been installed, its type cannot be changed without uninstalling and reinstalling the entire package, an app will always be an app and vice versa.
"},{"location":"php/apps/#packagexml","title":"package.xml
","text":"The package.xml
supports two additional elements in the <packageinformation>
block that are unique to applications.
<isapplication>1</isapplication>
","text":"This element is responsible to flag a package as an app.
"},{"location":"php/apps/#applicationdirectoryexampleapplicationdirectory","title":"<applicationdirectory>example</applicationdirectory>
","text":"Sets the suggested name of the application directory when installing it, the path result in <path-to-the-core>/example/
. If you leave this element out, the app identifier (com.example.foo -> foo
) will be used instead.
An example project with the source code can be found on GitHub, it includes everything that is required for a basic app.
"},{"location":"php/code-style-documentation/","title":"Documentation","text":"The following documentation conventions are used by us for our own packages. While you do not have to follow every rule, you are encouraged to do so.
"},{"location":"php/code-style-documentation/#database-objects","title":"Database Objects","text":""},{"location":"php/code-style-documentation/#database-table-columns-as-properties","title":"Database Table Columns as Properties","text":"As the database table columns are not explicit properties of the classes extending wcf\\data\\DatabaseObject
but rather stored in DatabaseObject::$data
and accessible via DatabaseObject::__get($name)
, the IDE we use, PhpStorm, is neither able to autocomplete such property access nor to interfere the type of the property.
To solve this problem, @property-read
tags must be added to the class documentation which registers the database table columns as public read-only properties:
* @property-read propertyType $propertyName property description\n
The properties have to be in the same order as the order in the database table.
The following table provides templates for common description texts so that similar database table columns have similar description texts.
property description template and example unique object idunique id of the {object name}
example: unique id of the acl option
id of the delivering package id of the package which delivers the {object name}
example: id of the package which delivers the acl option
show order for nested structure position of the {object name} in relation to its siblings
example: position of the ACP menu item in relation to its siblings
show order within different object position of the {object name} in relation to the other {object name}s in the {parent object name}
example: position of the label in relation to the other labels in the label group
required permissions comma separated list of user group permissions of which the active user needs to have at least one to see (access, \u2026) the {object name}
example:comma separated list of user group permissions of which the active user needs to have at least one to see the ACP menu item
required options comma separated list of options of which at least one needs to be enabled for the {object name} to be shown (accessible, \u2026)
example:comma separated list of options of which at least one needs to be enabled for the ACP menu item to be shown
id of the user who has created the object id of the user who created (wrote, \u2026) the {object name} (or `null` if the user does not exist anymore (or if the {object name} has been created by a guest))
example:id of the user who wrote the comment or `null` if the user does not exist anymore or if the comment has been written by a guest
name of the user who has created the object name of the user (or guest) who created (wrote, \u2026) the {object name}
example:name of the user or guest who wrote the comment
additional data array with additional data of the {object name}
example:array with additional data of the user activity event
time-related columns timestamp at which the {object name} has been created (written, \u2026)
example:timestamp at which the comment has been written
boolean options is `1` (or `0`) if the {object name} \u2026 (and thus \u2026), otherwise `0` (or `1`)
example:is `1` if the ad is disabled and thus not shown, otherwise `0`
$cumulativeLikes
cumulative result of likes (counting `+1`) and dislikes (counting `-1`) for the {object name}
example:cumulative result of likes (counting `+1`) and dislikes (counting `-1`) for the article
$comments
number of comments on the {object name}
example:number of comments on the article
$views
number of times the {object name} has been viewed
example:number of times the article has been viewed
text field with potential language item name as value {text type} of the {object name} or name of language item which contains the {text type}
example:description of the cronjob or name of language item which contains the description
$objectTypeID
id of the `{object type definition name}` object type
example:id of the `com.woltlab.wcf.modifiableContent` object type
"},{"location":"php/code-style-documentation/#database-object-editors","title":"Database Object Editors","text":""},{"location":"php/code-style-documentation/#class-tags","title":"Class Tags","text":"Any database object editor class comment must have to following tags to properly support autocompletion by IDEs:
/**\n * \u2026\n * @method static {DBO class name} create(array $parameters = [])\n * @method {DBO class name} getDecoratedObject()\n * @mixin {DBO class name}\n */\n
The only exception to this rule is if the class overwrites the create()
method which itself has to be properly documentation then.
The first and second line makes sure that when calling the create()
or getDecoratedObject()
method, the return value is correctly recognized and not just a general DatabaseObject
instance. The third line tells the IDE (if @mixin
is supported) that the database object editor decorates the database object and therefore offers autocompletion for properties and methods from the database object class itself.
Any class implementing the IRuntimeCache interface must have the following class tags:
/**\n * \u2026\n * @method {DBO class name}[] getCachedObjects()\n * @method {DBO class name} getObject($objectID)\n * @method {DBO class name}[] getObjects(array $objectIDs)\n */\n
These tags ensure that when calling any of the mentioned methods, the return value refers to the concrete database object and not just generically to DatabaseObject.
"},{"location":"php/code-style/","title":"Code Style","text":"The following code style conventions are used by us for our own packages. While you do not have to follow every rule, you are encouraged to do so.
For information about how to document your code, please refer to the documentation page.
"},{"location":"php/code-style/#general-code-style","title":"General Code Style","text":""},{"location":"php/code-style/#naming-conventions","title":"Naming conventions","text":"The relevant naming conventions are:
$variableName
Class upper camel case class UserGroupEditor
Properties lower camel case public $propertyName
Method lower camel case public function getObjectByName()
Constant screaming snake case MODULE_USER_THING
"},{"location":"php/code-style/#arrays","title":"Arrays","text":"For arrays, use the short array syntax introduced with PHP 5.4. The following example illustrates the different cases that can occur when working with arrays and how to format them:
<?php\n\n$empty = [];\n\n$oneElement = [1];\n$multipleElements = [1, 2, 3];\n\n$oneElementWithKey = ['firstElement' => 1];\n$multipleElementsWithKey = [\n 'firstElement' => 1,\n 'secondElement' => 2,\n 'thirdElement' => 3\n];\n
"},{"location":"php/code-style/#ternary-operator","title":"Ternary Operator","text":"The ternary operator can be used for short conditioned assignments:
<?php\n\n$name = isset($tagArgs['name']) ? $tagArgs['name'] : 'default';\n
The condition and the values should be short so that the code does not result in a very long line which thus decreases the readability compared to an if-else
statement.
Parentheses may only be used around the condition and not around the whole statement:
<?php\n\n// do not do it like this\n$name = (isset($tagArgs['name']) ? $tagArgs['name'] : 'default');\n
Parentheses around the conditions may not be used to wrap simple function calls:
<?php\n\n// do not do it like this\n$name = (isset($tagArgs['name'])) ? $tagArgs['name'] : 'default';\n
but have to be used for comparisons or other binary operators:
<?php\n\n$value = ($otherValue > $upperLimit) ? $additionalValue : $otherValue;\n
If you need to use more than one binary operator, use an if-else
statement.
The same rules apply to assigning array values:
<?php\n\n$values = [\n 'first' => $firstValue,\n 'second' => $secondToggle ? $secondValueA : $secondValueB,\n 'third' => ($thirdToogle > 13) ? $thirdToogleA : $thirdToogleB\n];\n
or return values:
<?php\n\nreturn isset($tagArgs['name']) ? $tagArgs['name'] : 'default';\n
"},{"location":"php/code-style/#whitespaces","title":"Whitespaces","text":"You have to put a whitespace in front of the following things:
$x = 1;
$x == 1
public function test() {
You have to put a whitespace behind the following things:
$x = 1;
$x == 1
public function test($a, $b) {
if
, for
, foreach
, while
: if ($x == 1)
If you have to reference a class name inside a php file, you have to use the class
keyword.
<?php\n\n// not like this\n$className = 'wcf\\data\\example\\Example';\n\n// like this\nuse wcf\\data\\example\\Example;\n$className = Example::class;\n
"},{"location":"php/code-style/#static-getters-of-databaseobject-classes","title":"Static Getters (of DatabaseObject
Classes)","text":"Some database objects provide static getters, either if they are decorators or for a unique combination of database table columns, like wcf\\data\\box\\Box::getBoxByIdentifier()
:
<?php\nnamespace wcf\\data\\box;\nuse wcf\\data\\DatabaseObject;\nuse wcf\\system\\WCF;\n\nclass Box extends DatabaseObject {\n /**\n * Returns the box with the given identifier.\n *\n * @param string $identifier\n * @return Box|null\n */\n public static function getBoxByIdentifier($identifier) {\n $sql = \"SELECT *\n FROM wcf1_box\n WHERE identifier = ?\";\n $statement = WCF::getDB()->prepare($sql);\n $statement->execute([$identifier]);\n\n return $statement->fetchObject(self::class);\n }\n}\n
Such methods should always either return the desired object or null
if the object does not exist. wcf\\system\\database\\statement\\PreparedStatement::fetchObject()
already takes care of this distinction so that its return value can simply be returned by such methods.
The name of such getters should generally follow the convention get{object type}By{column or other description}
.
In some instances, methods with many argument have to be called which can result in lines of code like this one:
<?php\n\n\\wcf\\system\\search\\SearchIndexManager::getInstance()->set('com.woltlab.wcf.article', $articleContent->articleContentID, $articleContent->content, $articleContent->title, $articles[$articleContent->articleID]->time, $articles[$articleContent->articleID]->userID, $articles[$articleContent->articleID]->username, $articleContent->languageID, $articleContent->teaser);\n
which is hardly readable. Therefore, the line must be split into multiple lines with each argument in a separate line:
<?php\n\n\\wcf\\system\\search\\SearchIndexManager::getInstance()->set(\n 'com.woltlab.wcf.article',\n $articleContent->articleContentID,\n $articleContent->content,\n $articleContent->title,\n $articles[$articleContent->articleID]->time,\n $articles[$articleContent->articleID]->userID,\n $articles[$articleContent->articleID]->username,\n $articleContent->languageID,\n $articleContent->teaser\n);\n
In general, this rule applies to the following methods:
wcf\\system\\edit\\EditHistoryManager::add()
wcf\\system\\message\\quote\\MessageQuoteManager::addQuote()
wcf\\system\\message\\quote\\MessageQuoteManager::getQuoteID()
wcf\\system\\search\\SearchIndexManager::set()
wcf\\system\\user\\object\\watch\\UserObjectWatchHandler::updateObject()
wcf\\system\\user\\notification\\UserNotificationHandler::fireEvent()
Database Objects provide a convenient and object-oriented approach to work with the database, but there can be use-cases that require raw access including writing methods for model classes. This section assumes that you have either used prepared statements before or at least understand how it works.
"},{"location":"php/database-access/#the-preparedstatement-object","title":"The PreparedStatement Object","text":"The database access is designed around PreparedStatement, built on top of PHP's PDOStatement
so that you call all of PDOStatement
's methods, and each query requires you to obtain a statement object.
<?php\n$statement = \\wcf\\system\\WCF::getDB()->prepare(\"SELECT * FROM wcf1_example\");\n$statement->execute();\nwhile ($row = $statement->fetchArray()) {\n // handle result\n}\n
"},{"location":"php/database-access/#query-parameters","title":"Query Parameters","text":"The example below illustrates the usage of parameters where each value is replaced with the generic ?
-placeholder. Values are provided by calling $statement->execute()
with a continuous, one-dimensional array that exactly match the number of question marks.
<?php\n$sql = \"SELECT *\n FROM wcf1_example\n WHERE exampleID = ?\n OR bar IN (?, ?, ?, ?, ?)\";\n$statement = \\wcf\\system\\WCF::getDB()->prepare($sql);\n$statement->execute([\n $exampleID,\n $list, $of, $values, $for, $bar\n]);\nwhile ($row = $statement->fetchArray()) {\n // handle result\n}\n
"},{"location":"php/database-access/#fetching-a-single-result","title":"Fetching a Single Result","text":"Do not attempt to use fetchSingleRow()
or fetchSingleColumn()
if the result contains more than one row.
You can opt-in to retrieve only a single row from database and make use of shortcut methods to reduce the code that you have to write.
<?php\n$sql = \"SELECT *\n FROM wcf1_example\n WHERE exampleID = ?\";\n$statement = \\wcf\\system\\WCF::getDB()->prepare($sql, 1);\n$statement->execute([$exampleID]);\n$row = $statement->fetchSingleRow();\n
There are two distinct differences when comparing with the example on query parameters above:
prepare()
receives a secondary parameter that will be appended to the query as LIMIT 1
.fetchSingleRow()
instead of fetchArray()
or similar methods, that will read one result and close the cursor.There is no way to return another column from the same row if you use fetchColumn()
to retrieve data.
Fetching an array is only useful if there is going to be more than one column per result row, otherwise accessing the column directly is much more convenient and increases the code readability.
<?php\n$sql = \"SELECT bar\n FROM wcf1_example\";\n$statement = \\wcf\\system\\WCF::getDB()->prepare($sql);\n$statement->execute();\nwhile ($bar = $statement->fetchColumn()) {\n // handle result\n}\n$bar = $statement->fetchSingleColumn();\n
Similar to fetching a single row, you can also issue a query that will select a single row, but reads only one column from the result row.
<?php\n$sql = \"SELECT bar\n FROM wcf1_example\n WHERE exampleID = ?\";\n$statement = \\wcf\\system\\WCF::getDB()->prepare($sql, 1);\n$statement->execute([$exampleID]);\n$bar = $statement->fetchSingleColumn();\n
"},{"location":"php/database-access/#fetching-all-results","title":"Fetching All Results","text":"If you want to fetch all results of a query but only store them in an array without directly processing them, in most cases, you can rely on built-in methods.
To fetch all rows of query, you can use PDOStatement::fetchAll()
with \\PDO::FETCH_ASSOC
as the first parameter:
<?php\n$sql = \"SELECT *\n FROM wcf1_example\";\n$statement = \\wcf\\system\\WCF::getDB()->prepare($sql);\n$statement->execute();\n$rows = $statement->fetchAll(\\PDO::FETCH_ASSOC);\n
As a result, you get an array containing associative arrays with the rows of the wcf{WCF_N}_example
database table as content.
If you only want to fetch a list of the values of a certain column, you can use \\PDO::FETCH_COLUMN
as the first parameter:
<?php\n$sql = \"SELECT exampleID\n FROM wcf1_example\";\n$statement = \\wcf\\system\\WCF::getDB()->prepare($sql);\n$statement->execute();\n$exampleIDs = $statement->fetchAll(\\PDO::FETCH_COLUMN);\n
As a result, you get an array with all exampleID
values.
The PreparedStatement
class adds an additional methods that covers another common use case in our code: Fetching two columns and using the first column's value as the array key and the second column's value as the array value. This case is covered by PreparedStatement::fetchMap()
:
<?php\n$sql = \"SELECT exampleID, userID\n FROM wcf1_example_mapping\";\n$statement = \\wcf\\system\\WCF::getDB()->prepare($sql);\n$statement->execute();\n$map = $statement->fetchMap('exampleID', 'userID');\n
$map
is a one-dimensional array where each exampleID
value maps to the corresponding userID
value.
If there are multiple entries for a certain exampleID
value with different userID
values, the existing entry in the array will be overwritten and contain the last read value from the database table. Therefore, this method should generally only be used for unique combinations.
If you do not have a combination of columns with unique pairs of values, but you want to get a list of userID
values with the same exampleID
, you can set the third parameter of fetchMap()
to false
and get a list:
<?php\n$sql = \"SELECT exampleID, userID\n FROM wcf1_example_mapping\";\n$statement = \\wcf\\system\\WCF::getDB()->prepare($sql);\n$statement->execute();\n$map = $statement->fetchMap('exampleID', 'userID', false);\n
Now, as a result, you get a two-dimensional array with the array keys being the exampleID
values and the array values being arrays with all userID
values from rows with the respective exampleID
value.
Building conditional conditions can turn out to be a real mess and it gets even worse with SQL's IN (\u2026)
which requires as many placeholders as there will be values. The solutions is PreparedStatementConditionBuilder
, a simple but useful helper class with a bulky name, it is also the class used when accessing DatabaseObjecList::getConditionBuilder()
.
<?php\n$conditions = new \\wcf\\system\\database\\util\\PreparedStatementConditionBuilder();\n$conditions->add(\"exampleID = ?\", [$exampleID]);\nif (!empty($valuesForBar)) {\n $conditions->add(\"(bar IN (?) OR baz = ?)\", [$valuesForBar, $baz]);\n}\n
The IN (?)
in the example above is automatically expanded to match the number of items contained in $valuesForBar
. Be aware that the method will generate an invalid query if $valuesForBar
is empty!
Prepared statements not only protect against SQL injection by separating the logical query and the actual data, but also provides the ability to reuse the same query with different values. This leads to a performance improvement as the code does not have to transmit the query with for every data set and only has to parse and analyze the query once.
<?php\n$data = ['abc', 'def', 'ghi'];\n\n$sql = \"INSERT INTO wcf1_example\n (bar)\n VALUES (?)\";\n$statement = \\wcf\\system\\WCF::getDB()->prepare($sql);\n\n\\wcf\\system\\WCF::getDB()->beginTransaction();\nforeach ($data as $bar) {\n $statement->execute([$bar]);\n}\n\\wcf\\system\\WCF::getDB()->commitTransaction();\n
It is generally advised to wrap bulk operations in a transaction as it allows the database to optimize the process, including fewer I/O operations.
<?php\n$data = [\n 1 => 'abc',\n 3 => 'def',\n 4 => 'ghi'\n];\n\n$sql = \"UPDATE wcf1_example\n SET bar = ?\n WHERE exampleID = ?\";\n$statement = \\wcf\\system\\WCF::getDB()->prepare($sql);\n\n\\wcf\\system\\WCF::getDB()->beginTransaction();\nforeach ($data as $exampleID => $bar) {\n $statement->execute([\n $bar,\n $exampleID\n ]);\n}\n\\wcf\\system\\WCF::getDB()->commitTransaction();\n
"},{"location":"php/database-objects/","title":"Database Objects","text":"WoltLab Suite uses a unified interface to work with database rows using an object based approach instead of using native arrays holding arbitrary data. Each database table is mapped to a model class that is designed to hold a single record from that table and expose methods to work with the stored data, for example providing assistance when working with normalized datasets.
Developers are required to provide the proper DatabaseObject implementations themselves, they're not automatically generated, all though the actual code that needs to be written is rather small. The following examples assume the fictional database table wcf1_example
, exampleID
as the auto-incrementing primary key and the column bar
to store some text.
The basic model derives from wcf\\data\\DatabaseObject
and provides a convenient constructor to fetch a single row or construct an instance using pre-loaded rows.
<?php\nnamespace wcf\\data\\example;\nuse wcf\\data\\DatabaseObject;\n\nclass Example extends DatabaseObject {}\n
The class is intended to be empty by default and there only needs to be code if you want to add additional logic to your model. Both the class name and primary key are determined by DatabaseObject
using the namespace and class name of the derived class. The example above uses the namespace wcf\\\u2026
which is used as table prefix and the class name Example
is converted into exampleID
, resulting in the database table name wcfN_example
with the primary key exampleID
.
You can prevent this automatic guessing by setting the class properties $databaseTableName
and $databaseTableIndexName
manually.
If you already have a DatabaseObject
class and would like to extend it with additional data or methods, for example by providing a class ViewableExample
which features view-related changes without polluting the original object, you can use DatabaseObjectDecorator
which a default implementation of a decorator for database objects.
<?php\nnamespace wcf\\data\\example;\nuse wcf\\data\\DatabaseObjectDecorator;\n\nclass ViewableExample extends DatabaseObjectDecorator {\n protected static $baseClass = Example::class;\n\n public function getOutput() {\n $output = '';\n\n // [determine output]\n\n return $output;\n }\n}\n
It is mandatory to set the static $baseClass
property to the name of the decorated class.
Like for any decorator, you can directly access the decorated object's properties and methods for a decorated object by accessing the property or calling the method on the decorated object. You can access the decorated objects directly via DatabaseObjectDecorator::getDecoratedObject()
.
This is the low-level interface to manipulate data rows, it is recommended to use AbstractDatabaseObjectAction
.
Adding, editing and deleting models is done using the DatabaseObjectEditor
class that decorates a DatabaseObject
and uses its data to perform the actions.
<?php\nnamespace wcf\\data\\example;\nuse wcf\\data\\DatabaseObjectEditor;\n\nclass ExampleEditor extends DatabaseObjectEditor {\n protected static $baseClass = Example::class;\n}\n
The editor class requires you to provide the fully qualified name of the model, that is the class name including the complete namespace. Database table name and index key will be pulled directly from the model.
"},{"location":"php/database-objects/#create-a-new-row","title":"Create a new row","text":"Inserting a new row into the database table is provided through DatabaseObjectEditor::create()
which yields a DatabaseObject
instance after creation.
<?php\n$example = \\wcf\\data\\example\\ExampleEditor::create([\n 'bar' => 'Hello World!'\n]);\n\n// output: Hello World!\necho $example->bar;\n
"},{"location":"php/database-objects/#updating-an-existing-row","title":"Updating an existing row","text":"The internal state of the decorated DatabaseObject
is not altered at any point, the values will still be the same after editing or deleting the represented row. If you need an object with the latest data, you'll have to discard the current object and refetch the data from database.
<?php\n$example = new \\wcf\\data\\example\\Example($id);\n$exampleEditor = new \\wcf\\data\\example\\ExampleEditor($example);\n$exampleEditor->update([\n 'bar' => 'baz'\n]);\n\n// output: Hello World!\necho $example->bar;\n\n// re-creating the object will query the database again and retrieve the updated value\n$example = new \\wcf\\data\\example\\Example($example->id);\n\n// output: baz\necho $example->bar;\n
"},{"location":"php/database-objects/#deleting-a-row","title":"Deleting a row","text":"Similar to the update process, the decorated DatabaseObject
is not altered and will then point to an inexistent row.
<?php\n$example = new \\wcf\\data\\example\\Example($id);\n$exampleEditor = new \\wcf\\data\\example\\ExampleEditor($example);\n$exampleEditor->delete();\n
"},{"location":"php/database-objects/#databaseobjectlist","title":"DatabaseObjectList","text":"Every row is represented as a single instance of the model, but the instance creation deals with single rows only. Retrieving larger sets of rows would be quite inefficient due to the large amount of queries that will be dispatched. This is solved with the DatabaseObjectList
object that exposes an interface to query the database table using arbitrary conditions for data selection. All rows will be fetched using a single query and the resulting rows are automatically loaded into separate models.
<?php\nnamespace wcf\\data\\example;\nuse wcf\\data\\DatabaseObjectList;\n\nclass ExampleList extends DatabaseObjectList {\n public $className = Example::class;\n}\n
The following code listing illustrates loading a large set of examples and iterating over the list to retrieve the objects.
<?php\n$exampleList = new \\wcf\\data\\example\\ExampleList();\n// add constraints using the condition builder\n$exampleList->getConditionBuilder()->add('bar IN (?)', [['Hello World!', 'bar', 'baz']]);\n// actually read the rows\n$exampleList->readObjects();\nforeach ($exampleList as $example) {\n echo $example->bar;\n}\n\n// retrieve the models directly instead of iterating over them\n$examples = $exampleList->getObjects();\n\n// just retrieve the number of rows\n$exampleCount = $exampleList->countObjects();\n
DatabaseObjectList
implements both SeekableIterator and Countable.
Additionally, DatabaseObjectList
objects has the following three public properties that are useful when fetching data with lists:
$sqlLimit
determines how many rows are fetched. If its value is 0
(which is the default value), all results are fetched. So be careful when dealing with large tables and you only want a limited number of rows: Set $sqlLimit
to a value larger than zero!$sqlOffset
: Paginated pages like a thread list use this feature a lot, it allows you to skip a given number of results. Imagine you want to display 20 threads per page but there are a total of 60 threads available. In this case you would specify $sqlLimit = 20
and $sqlOffset = 20
which will skip the first 20 threads, effectively displaying thread 21 to 40.$sqlOrderBy
determines by which column(s) the rows are sorted in which order. Using our example in $sqlOffset
you might want to display the 20 most recent threads on page 1, thus you should specify the order field and its direction, e.g. $sqlOrderBy = 'thread.lastPostTime DESC'
which returns the most recent thread first.For more advanced usage, there two additional fields that deal with the type of objects returned. First, let's go into a bit more detail what setting the $className
property actually does:
$databaseTableName
and $databaseTableIndexName
properties of DatabaseObject
).Sometimes you might use the database table of some database object but wrap the rows in another database object. This can be achieved by setting the $objectClassName
property to the desired class name.
In other cases, you might want to wrap the created objects in a database object decorator which can be done by setting the $decoratorClassName
property to the desired class name:
<?php\n$exampleList = new \\wcf\\data\\example\\ExampleList();\n$exampleList->decoratorClassName = \\wcf\\data\\example\\ViewableExample::class;\n
Of course, you do not have to set the property after creating the list object, you can also set it by creating a dedicated class:
files/lib/data/example/ViewableExampleList.class.php<?php\nnamespace wcf\\data\\example;\n\nclass ViewableExampleList extends ExampleList {\n public $decoratorClassName = ViewableExample::class;\n}\n
"},{"location":"php/database-objects/#abstractdatabaseobjectaction","title":"AbstractDatabaseObjectAction","text":"Row creation and manipulation can be performed using the aforementioned DatabaseObjectEditor
class, but this approach has two major issues:
The AbstractDatabaseObjectAction
solves both problems by wrapping around the editor class and thus provide an additional layer between the action that should be taken and the actual process. The first problem is solved by a fixed set of events being fired, the second issue is addressed by having a single entry point for all data editing.
<?php\nnamespace wcf\\data\\example;\nuse wcf\\data\\AbstractDatabaseObjectAction;\n\nclass ExampleAction extends AbstractDatabaseObjectAction {\n public $className = ExampleEditor::class;\n}\n
"},{"location":"php/database-objects/#executing-an-action","title":"Executing an Action","text":"The method AbstractDatabaseObjectAction::validateAction()
is internally used for AJAX method invocation and must not be called programmatically.
The next example represents the same functionality as seen for DatabaseObjectEditor
:
<?php\nuse wcf\\data\\example\\ExampleAction;\n\n// create a row\n$exampleAction = new ExampleAction([], 'create', [\n 'data' => ['bar' => 'Hello World']\n]);\n$example = $exampleAction->executeAction()['returnValues'];\n\n// update a row using the id\n$exampleAction = new ExampleAction([1], 'update', [\n 'data' => ['bar' => 'baz']\n]);\n$exampleAction->executeAction();\n\n// delete a row using a model\n$exampleAction = new ExampleAction([$example], 'delete');\n$exampleAction->executeAction();\n
You can access the return values both by storing the return value of executeAction()
or by retrieving it via getReturnValues()
.
Events initializeAction
, validateAction
and finalizeAction
The AJAX interface of database object actions is considered soft-deprecated in WoltLab Suite 6.0. Instead a dedicated PSR-15 controller should be used. Please refer to the migration guide.
This section is about adding the method baz()
to ExampleAction
and calling it via AJAX.
Methods of an action cannot be called via AJAX, unless they have a validation method. This means that ExampleAction
must define both a public function baz()
and public function validateBaz()
, the name for the validation method is constructed by upper-casing the first character of the method name and prepending validate
.
The lack of the companion validate*
method will cause the AJAX proxy to deny the request instantaneously. Do not add a validation method if you don't want it to be callable via AJAX ever!
The methods create
, update
and delete
are available for all classes deriving from AbstractDatabaseObjectAction
and directly pass the input data to the DatabaseObjectEditor
. These methods deny access to them via AJAX by default, unless you explicitly enable access. Depending on your case, there are two different strategies to enable AJAX access to them.
<?php\nnamespace wcf\\data\\example;\nuse wcf\\data\\AbstractDatabaseObjectAction;\n\nclass ExampleAction extends AbstractDatabaseObjectAction {\n // `create()` can now be called via AJAX if the requesting user posses the listed permissions\n protected $permissionsCreate = ['admin.example.canManageExample'];\n\n public function validateUpdate() {\n // your very own validation logic that does not make use of the\n // built-in `$permissionsUpdate` property\n\n // you can still invoke the built-in permissions check if you like to\n parent::validateUpdate();\n }\n}\n
"},{"location":"php/database-objects/#allow-invokation-by-guests","title":"Allow Invokation by Guests","text":"Invoking methods is restricted to logged-in users by default and the only way to override this behavior is to alter the property $allowGuestAccess
. It is a simple string array that is expected to hold all methods that should be accessible by users, excluding their companion validation methods.
Method access is usually limited by permissions, but sometimes there might be the need for some added security to avoid mistakes. The $requireACP
property works similar to $allowGuestAccess
, but enforces the request to originate from the ACP together with a valid ACP session, ensuring that only users able to access the ACP can actually invoke these methods.
The Standard PHP Library (SPL) provides some exceptions that should be used whenever possible.
"},{"location":"php/exceptions/#custom-exceptions","title":"Custom Exceptions","text":"Do not use wcf\\system\\exception\\SystemException
anymore, use specific exception classes!
The following table contains a list of custom exceptions that are commonly used. All of the exceptions are found in the wcf\\system\\exception
namespace.
IllegalLinkException
access to a page that belongs to a non-existing object, executing actions on specific non-existing objects (is shown as http 404 error to the user) ImplementationException
a class does not implement an expected interface InvalidObjectArgument
5.4+ API method support generic objects but specific implementation requires objects of specific (sub)class and different object is given InvalidObjectTypeException
object type is not of an expected object type definition InvalidSecurityTokenException
given security token does not match the security token of the active user's session ParentClassException
a class does not extend an expected (parent) class PermissionDeniedException
page access without permission, action execution without permission (is shown as http 403 error to the user) UserInputException
user input does not pass validation"},{"location":"php/exceptions/#sensitive-arguments-in-stack-traces","title":"Sensitive Arguments in Stack Traces","text":"Sometimes sensitive values are passed as a function or method argument. If the callee throws an Exception, these values will be part of the Exception\u2019s stack trace and logged, unless the Exception is caught and ignored.
WoltLab Suite will automatically suppress the values of parameters named like they might contain sensitive values, namely arguments matching the regular expression /(?:^(?:password|passphrase|secret)|(?:Password|Passphrase|Secret))/
.
If you need to suppress additional arguments from appearing in the stack trace, you can add the \\wcf\\SensitiveArgument
attribute to such parameters. Arguments are only supported as of PHP 8 and ignored as comments in lower PHP versions. In PHP 7, such arguments will not be suppressed, but the code will continue to work. Make sure to insert a linebreak between the attribute and the parameter name.
Example:
wcfsetup/install/files/lib/data/user/User.class.php<?php\n\nnamespace wcf\\data\\user;\n\n// \u2026\n\nfinal class User extends DatabaseObject implements IPopoverObject, IRouteController, IUserContent\n{\n // \u2026\n\n public function checkPassword(\n #[\\wcf\\SensitiveArgument()]\n $password\n ) {\n // \u2026\n }\n\n // \u2026\n}\n
"},{"location":"php/gdpr/","title":"General Data Protection Regulation (GDPR)","text":""},{"location":"php/gdpr/#introduction","title":"Introduction","text":"The General Data Protection Regulation (GDPR) of the European Union enters into force on May 25, 2018. It comes with a set of restrictions when handling users' personal data as well as to provide an interface to export this data on demand.
If you're looking for a guide on the implications of the GDPR and what you will need or consider to do, please read the article Implementation of the GDPR on woltlab.com.
"},{"location":"php/gdpr/#including-data-in-the-export","title":"Including Data in the Export","text":"The wcf\\acp\\action\\UserExportGdprAction
already includes WoltLab Suite Core itself as well as all official apps, but you'll need to include any personal data stored for your plugin or app by yourself.
The event export
is fired before any data is sent out, but after any Core data has been dumped to the $data
property.
<?php\nnamespace wcf\\system\\event\\listener;\nuse wcf\\acp\\action\\UserExportGdprAction;\nuse wcf\\data\\user\\UserProfile;\n\nclass MyUserExportGdprActionListener implements IParameterizedEventListener {\n public function execute(/** @var UserExportGdprAction $eventObj */$eventObj, $className, $eventName, array &$parameters) {\n /** @var UserProfile $user */\n $user = $eventObj->user;\n\n $eventObj->data['my.fancy.plugin'] = [\n 'superPersonalData' => \"This text is super personal and should be included in the output\",\n 'weirdIpAddresses' => $eventObj->exportIpAddresses('app'.WCF_N.'_non_standard_column_names_for_ip_addresses', 'ipAddressColumnName', 'timeColumnName', 'userIDColumnName')\n ];\n $eventObj->exportUserProperties[] = 'shouldAlwaysExportThisField';\n $eventObj->exportUserPropertiesIfNotEmpty[] = 'myFancyField';\n $eventObj->exportUserOptionSettings[] = 'thisSettingIsAlwaysExported';\n $eventObj->exportUserOptionSettingsIfNotEmpty[] = 'someSettingContainingPersonalData';\n $eventObj->ipAddresses['my.fancy.plugin'] = ['wcf'.WCF_N.'_my_fancy_table', 'wcf'.WCF_N.'_i_also_store_ipaddresses_here'];\n $eventObj->skipUserOptions[] = 'thisLooksLikePersonalDataButItIsNot';\n $eventObj->skipUserOptions[] = 'thisIsAlsoNotPersonalDataPleaseIgnoreIt';\n }\n}\n
"},{"location":"php/gdpr/#data","title":"$data
","text":"Contains the entire data that will be included in the exported JSON file, some fields may already exist (such as 'com.woltlab.wcf'
) and while you may add or edit any fields within, you should restrict yourself to only append data from your plugin or app.
$exportUserProperties
","text":"Only a whitelist of columns in wcfN_user
is exported by default, if your plugin or app adds one or more columns to this table that do hold personal data, then you will have to append it to this array. The listed properties will always be included regardless of their content.
$exportUserPropertiesIfNotEmpty
","text":"Only a whitelist of columns in wcfN_user
is exported by default, if your plugin or app adds one or more columns to this table that do hold personal data, then you will have to append it to this array. Empty values will not be added to the output.
$exportUserOptionSettings
","text":"Any user option that exists within a settings.*
category is automatically excluded from the export, with the notable exception of the timezone
option. You can opt-in to include your setting by appending to this array, if it contains any personal data. The listed settings are always included regardless of their content.
$exportUserOptionSettingsIfNotEmpty
","text":"Any user option that exists within a settings.*
category is automatically excluded from the export, with the notable exception of the timezone
option. You can opt-in to include your setting by appending to this array, if it contains any personal data.
$ipAddresses
","text":"List of database table names per package identifier that contain ip addresses. The contained ip addresses will be exported when the ip logging module is enabled.
It expects the database table to use the column names ipAddress
, time
and userID
. If your table does not match this pattern for whatever reason, you'll need to manually probe for LOG_IP_ADDRESS
and then call exportIpAddresses()
to retrieve the list. Afterwards you are responsible to append these ip addresses to the $data
array to have it exported.
$skipUserOptions
","text":"All user options are included in the export by default, unless they start with can*
or admin*
, or are blacklisted using this array. You should append any of your plugin's or app's user option that should not be exported, for example because it does not contain personal data, such as internal data.
The default implementation for pages to present any sort of content, but are designed to handle GET
requests only. They usually follow a fixed method chain that will be invoked one after another, adding logical sections to the request flow.
This is the only method being invoked from the outside and starts the whole chain.
"},{"location":"php/pages/#readparameters","title":"readParameters()","text":"Reads and sanitizes request parameters, this should be the only method to ever read user-supplied input. Read data should be stored in class properties to be accessible at a later point, allowing your code to safely assume that the data has been sanitized and is safe to work with.
A typical example is the board page from the forum app that reads the id and attempts to identify the request forum.
public function readParameters() {\n parent::readParameters();\n\n if (isset($_REQUEST['id'])) $this->boardID = intval($_REQUEST['id']);\n $this->board = BoardCache::getInstance()->getBoard($this->boardID);\n if ($this->board === null) {\n throw new IllegalLinkException();\n }\n\n // check permissions\n if (!$this->board->canEnter()) {\n throw new PermissionDeniedException();\n }\n}\n
Events readParameters
Used to be the method of choice to handle permissions and module option checks, but has been used almost entirely as an internal method since the introduction of the properties $loginRequired
, $neededModules
and $neededPermissions
.
Events checkModules
, checkPermissions
and show
Central method for data retrieval based on class properties including those populated with user data in readParameters()
. It is strongly recommended to use this method to read data in order to properly separate the business logic present in your class.
Events readData
Last method call before the template engine kicks in and renders the template. All though some properties are bound to the template automatically, you still need to pass any custom variables and class properties to the engine to make them available in templates.
Following the example in readParameters()
, the code below adds the board data to the template.
public function assignVariables() {\n parent::assignVariables();\n\n WCF::getTPL()->assign([\n 'board' => $this->board,\n 'boardID' => $this->boardID\n ]);\n}\n
Events assignVariables
Extends the AbstractPage implementation with additional methods designed to handle form submissions properly.
"},{"location":"php/pages/#method-chain_1","title":"Method Chain","text":""},{"location":"php/pages/#__run_1","title":"__run()","text":"Inherited from AbstractPage.
"},{"location":"php/pages/#readparameters_1","title":"readParameters()","text":"Inherited from AbstractPage.
"},{"location":"php/pages/#show_1","title":"show()","text":"Inherited from AbstractPage.
"},{"location":"php/pages/#submit","title":"submit()","text":"The methods submit()
up until save()
are only invoked if either $_POST
or $_FILES
are not empty, otherwise they won't be invoked and the execution will continue with readData()
.
This is an internal method that is responsible of input processing and validation.
Events submit
This method is quite similar to readParameters()
that is being called earlier, but is designed around reading form data submitted through POST requests. You should avoid accessing $_GET
or $_REQUEST
in this context to avoid mixing up parameters evaluated when retrieving the page on first load and when submitting to it.
Events readFormParameters
Deals with input validation and automatically catches exceptions deriving from wcf\\system\\exception\\UserInputException
, resulting in a clean and consistent error handling for the user.
Events validate
Saves the processed data to database or any other source of your choice. Please keep in mind to invoke $this->saved()
before resetting the form data.
Events save
This method is not called automatically and must be invoked manually by executing $this->saved()
inside save()
.
The only purpose of this method is to fire the event saved
that signals that the form data has been processed successfully and data has been saved. It is somewhat special as it is dispatched after the data has been saved, but before the data is purged during form reset. This is by default the last event that has access to the processed data.
Events saved
Inherited from AbstractPage.
"},{"location":"php/pages/#assignvariables_1","title":"assignVariables()","text":"Inherited from AbstractPage.
"},{"location":"php/api/caches/","title":"Caches","text":"WoltLab Suite offers two distinct types of caches:
Every so often, plugins make use of cache builders or runtime caches to store their data, even if there is absolutely no need for them to do so. Usually, this involves a strong opinion about the total number of SQL queries on a page, including but not limited to some magic treshold numbers, which should not be exceeded for \"performance reasons\".
This misconception can easily lead into thinking that SQL queries should be avoided or at least written to a cache, so that it doesn't need to be executed so often. Unfortunately, this completely ignores the fact that both a single query can take down your app (e. g. full table scan on millions of rows), but 10 queries using a primary key on a table with a few hundred rows will not slow down your page.
There are some queries that should go into caches by design, but most of the cache builders weren't initially there, but instead have been added because they were required to reduce the load significantly. You need to understand that caches always come at a cost, even a runtime cache does! In particular, they will always consume memory that is not released over the duration of the request lifecycle and potentially even leak memory by holding references to objects and data structures that are no longer required.
Caching should always be a solution for a problem.
"},{"location":"php/api/caches/#when-to-use-a-cache","title":"When to Use a Cache","text":"It's difficult to provide a definite answer or checklist when to use a cache and why it is required at this point, because the answer is: It depends. The permission cache for user groups is a good example for a valid cache, where we can achieve significant performance improvement compared to processing this data on every request.
Its caches are build for each permutation of user group memberships that are encountered for a page request. Building this data is an expensive process that involves both inheritance and specific rules in regards to when a value for a permission overrules another value. The added benefit of this cache is that one cache usually serves a large number of users with the same group memberships and by computing these permissions once, we can serve many different requests. Also, the permissions are rather static values that change very rarely and thus we can expect a very high cache lifetime before it gets rebuild.
"},{"location":"php/api/caches/#when-not-to-use-a-cache","title":"When not to Use a Cache","text":"I remember, a few years ago, there was a plugin that displayed a user's character from an online video game. The character sheet not only included a list of basic statistics, but also displayed the items that this character was wearing and or holding at the time.
The data for these items were downloaded in bulk from the game's vendor servers and stored in a persistent cache file that periodically gets renewed. There is nothing wrong with the idea of caching the data on your own server rather than requesting them everytime from the vendor's servers - not only because they imposed a limit on the number of requests per hour.
Unfortunately, the character sheet had a sub-par performance and the users were upset by the significant loading times compared to literally every other page on the same server. The author of the plugin was working hard to resolve this issue and was evaluating all kind of methods to improve the page performance, including deep-diving into the realm of micro-optimizations to squeeze out every last bit of performance that is possible.
The real problem was the cache file itself, it turns out that it was holding the data for several thousand items with a total file size of about 13 megabytes. It doesn't look that much at first glance, after all this isn't the '90s anymore, but unserializing a 13 megabyte array is really slow and looking up items in such a large array isn't exactly fast either.
The solution was rather simple, the data that was fetched from the vendor's API was instead written into a separate database table. Next, the persistent cache was removed and the character sheet would now request the item data for that specific character straight from the database. Previously, the character sheet took several seconds to load and after the change it was done in a fraction of a second. Although quite extreme, this illustrates a situation where the cache file was introduced in the design process, without evaluating if the cache - at least how it was implemented - was really necessary.
Caching should always be a solution for a problem. Not the other way around.
"},{"location":"php/api/caches_persistent-caches/","title":"Persistent Caches","text":"Relational databases are designed around the principle of normalized data that is organized across clearly separated tables with defined releations between data rows. While this enables you to quickly access and modify individual rows and columns, it can create the problem that re-assembling this data into a more complex structure can be quite expensive.
For example, the user group permissions are stored for each user group and each permissions separately, but in order to be applied, they need to be fetched and the cumulative values across all user groups of an user have to be calculated. These repetitive tasks on barely ever changing data make them an excellent target for caching, where all sub-sequent requests are accelerated because they no longer have to perform the same expensive calculations every time.
It is easy to get lost in the realm of caching, especially when it comes to the decision if you should use a cache or not. When in doubt, you should opt to not use them, because they also come at a hidden cost that cannot be expressed through simple SQL query counts. If you haven't already, it is recommended that you read the introduction article on caching first, it provides a bit of background on caches and examples that should help you in your decision.
"},{"location":"php/api/caches_persistent-caches/#abstractcachebuilder","title":"AbstractCacheBuilder
","text":"Every cache builder should derive from the base class AbstractCacheBuilder that already implements the mandatory interface ICacheBuilder.
files/lib/system/cache/builder/ExampleCacheBuilder.class.php<?php\nnamespace wcf\\system\\cache\\builder;\n\nclass ExampleCacheBuilder extends AbstractCacheBuilder {\n // 3600 = 1hr\n protected $maxLifetime = 3600;\n\n public function rebuild(array $parameters) {\n $data = [];\n\n // fetch and process your data and assign it to `$data`\n\n return $data;\n }\n}\n
Reading data from your cache builder is quite simple and follows a consistent pattern. The callee only needs to know the name of the cache builder, which parameters it requires and how the returned data looks like. It does not need to know how the data is retrieve, where it was stored, nor if it had to be rebuild due to the maximum lifetime.
<?php\nuse wcf\\system\\cache\\builder\\ExampleCacheBuilder;\n\n$data = ExampleCacheBuilder::getInstance()->getData($parameters);\n
"},{"location":"php/api/caches_persistent-caches/#getdataarray-parameters-string-arrayindex-array","title":"getData(array $parameters = [], string $arrayIndex = ''): array
","text":"Retrieves the data from the cache builder, the $parameters
array is automatically sorted to allow sub-sequent requests for the same parameters to be recognized, even if their parameters are mixed. For example, getData([1, 2])
and getData([2, 1])
will have the same exact result.
The optional $arrayIndex
will instruct the cache builder to retrieve the data and examine if the returned data is an array that has the index $arrayIndex
. If it is set, the potion below this index is returned instead.
getMaxLifetime(): int
","text":"Returns the maximum lifetime of a cache in seconds. It can be controlled through the protected $maxLifetime
property which defaults to 0
. Any cache that has a lifetime greater than 0 is automatically discarded when exceeding this age, otherwise it will remain forever until it is explicitly removed or invalidated.
reset(array $parameters = []): void
","text":"Invalidates a cache, the $parameters
array will again be ordered using the same rules that are applied for getData()
.
rebuild(array $parameters): array
","text":"This method is protected.
This is the only method that a cache builder deriving from AbstractCacheBuilder
has to implement and it will be invoked whenever the cache is required to be rebuild for whatever reason.
Runtime caches store objects created during the runtime of the script and are automatically discarded after the script terminates. Runtime caches are especially useful when objects are fetched by different APIs, each requiring separate requests. By using a runtime cache, you have two advantages:
IRuntimeCache
","text":"Every runtime cache has to implement the IRuntimeCache interface. It is recommended, however, that you extend AbstractRuntimeCache, the default implementation of the runtime cache interface. In most instances, you only need to set the AbstractRuntimeCache::$listClassName
property to the name of database object list class which fetches the cached objects from database (see example).
<?php\nuse wcf\\system\\cache\\runtime\\UserRuntimeCache;\n\n$userIDs = [1, 2];\n\n// first (optional) step: tell runtime cache to remember user ids\nUserRuntimeCache::getInstance()->cacheObjectIDs($userIDs);\n\n// [\u2026]\n\n// second step: fetch the objects from database\n$users = UserRuntimeCache::getInstance()->getObjects($userIDs);\n\n// somewhere else: fetch only one user\n$userID = 1;\n\nUserRuntimeCache::getInstance()->cacheObjectID($userID);\n\n// [\u2026]\n\n// get user without the cache actually fetching it from database because it has already been loaded\n$user = UserRuntimeCache::getInstance()->getObject($userID);\n\n// somewhere else: fetch users directly without caching user ids first\n$users = UserRuntimeCache::getInstance()->getObjects([3, 4]);\n
"},{"location":"php/api/caches_runtime-caches/#example","title":"Example","text":"files/lib/system/cache/runtime/UserRuntimeCache.class.php <?php\nnamespace wcf\\system\\cache\\runtime;\nuse wcf\\data\\user\\User;\nuse wcf\\data\\user\\UserList;\n\n/**\n * Runtime cache implementation for users.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2016 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\System\\Cache\\Runtime\n * @since 3.0\n *\n * @method User[] getCachedObjects()\n * @method User getObject($objectID)\n * @method User[] getObjects(array $objectIDs)\n */\nclass UserRuntimeCache extends AbstractRuntimeCache {\n /**\n * @inheritDoc\n */\n protected $listClassName = UserList::class;\n}\n
"},{"location":"php/api/comments/","title":"Comments","text":""},{"location":"php/api/comments/#user-group-options","title":"User Group Options","text":"You need to create the following permissions:
user group type permission type naming user creating commentsuser.foo.canAddComment
user editing own comments user.foo.canEditComment
user deleting own comments user.foo.canDeleteComment
moderator moderating comments mod.foo.canModerateComment
moderator editing comments mod.foo.canEditComment
moderator deleting comments mod.foo.canDeleteComment
Within their respective user group option category, the options should be listed in the same order as in the table above.
"},{"location":"php/api/comments/#language-items","title":"Language Items","text":""},{"location":"php/api/comments/#user-group-options_1","title":"User Group Options","text":"The language items for the comment-related user group options generally have the same values:
wcf.acp.group.option.user.foo.canAddComment
German: Kann Kommentare erstellen
English: Can create comments
wcf.acp.group.option.user.foo.canEditComment
German: Kann eigene Kommentare bearbeiten
English: Can edit their comments
wcf.acp.group.option.user.foo.canDeleteComment
German: Kann eigene Kommentare l\u00f6schen
English: Can delete their comments
wcf.acp.group.option.mod.foo.canModerateComment
German: Kann Kommentare moderieren
English: Can moderate comments
wcf.acp.group.option.mod.foo.canEditComment
German: Kann Kommentare bearbeiten
English: Can edit comments
wcf.acp.group.option.mod.foo.canDeleteComment
German: Kann Kommentare l\u00f6schen
English: Can delete comments
Cronjobs offer an easy way to execute actions periodically, like cleaning up the database.
The execution of cronjobs is not guaranteed but requires someone to access the page with JavaScript enabled.
This page focuses on the technical aspects of cronjobs, the cronjob package installation plugin page covers how you can actually register a cronjob.
"},{"location":"php/api/cronjobs/#example","title":"Example","text":"files/lib/system/cronjob/LastActivityCronjob.class.php<?php\nnamespace wcf\\system\\cronjob;\nuse wcf\\data\\cronjob\\Cronjob;\nuse wcf\\system\\WCF;\n\n/**\n * Updates the last activity timestamp in the user table.\n *\n * @author Marcel Werk\n * @copyright 2001-2016 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\System\\Cronjob\n */\nclass LastActivityCronjob extends AbstractCronjob {\n /**\n * @inheritDoc\n */\n public function execute(Cronjob $cronjob) {\n parent::execute($cronjob);\n\n $sql = \"UPDATE wcf1_user user_table,\n wcf1_session session\n SET user_table.lastActivityTime = session.lastActivityTime\n WHERE user_table.userID = session.userID\n AND session.userID <> 0\";\n $statement = WCF::getDB()->prepare($sql);\n $statement->execute();\n }\n}\n
"},{"location":"php/api/cronjobs/#icronjob-interface","title":"ICronjob
Interface","text":"Every cronjob needs to implement the wcf\\system\\cronjob\\ICronjob
interface which requires the execute(Cronjob $cronjob)
method to be implemented. This method is called by wcf\\system\\cronjob\\CronjobScheduler when executing the cronjobs.
In practice, however, you should extend the AbstractCronjob
class and also call the AbstractCronjob::execute()
method as it fires an event which makes cronjobs extendable by plugins (see event documentation).
Events whose name is marked with an asterisk are called from a static method and thus do not provide any object, just the class name.
"},{"location":"php/api/event_list/#woltlab-suite-core","title":"WoltLab Suite Core","text":"Class Event Namewcf\\acp\\action\\UserExportGdprAction
export
wcf\\acp\\form\\StyleAddForm
setVariables
wcf\\acp\\form\\UserSearchForm
search
wcf\\action\\AbstractAction
checkModules
wcf\\action\\AbstractAction
checkPermissions
wcf\\action\\AbstractAction
execute
wcf\\action\\AbstractAction
executed
wcf\\action\\AbstractAction
readParameters
wcf\\data\\attachment\\AttachmentAction
generateThumbnail
wcf\\data\\session\\SessionAction
keepAlive
wcf\\data\\session\\SessionAction
poll
wcf\\data\\trophy\\Trophy
renderTrophy
wcf\\data\\user\\online\\UserOnline
getBrowser
wcf\\data\\user\\online\\UsersOnlineList
isVisible
wcf\\data\\user\\online\\UsersOnlineList
isVisibleUser
wcf\\data\\user\\trophy\\UserTrophy
getReplacements
wcf\\data\\user\\UserAction
beforeFindUsers
wcf\\data\\user\\UserAction
rename
wcf\\data\\user\\UserProfile
getAvatar
wcf\\data\\user\\UserProfile
isAccessible
wcf\\data\\AbstractDatabaseObjectAction
finalizeAction
wcf\\data\\AbstractDatabaseObjectAction
initializeAction
wcf\\data\\AbstractDatabaseObjectAction
validateAction
wcf\\data\\DatabaseObjectList
init
wcf\\form\\AbstractForm
readFormParameters
wcf\\form\\AbstractForm
save
wcf\\form\\AbstractForm
saved
wcf\\form\\AbstractForm
submit
wcf\\form\\AbstractForm
validate
wcf\\form\\AbstractFormBuilderForm
createForm
wcf\\form\\AbstractFormBuilderForm
buildForm
wcf\\form\\AbstractModerationForm
prepareSave
wcf\\page\\AbstractPage
assignVariables
wcf\\page\\AbstractPage
checkModules
wcf\\page\\AbstractPage
checkPermissions
wcf\\page\\AbstractPage
readData
wcf\\page\\AbstractPage
readParameters
wcf\\page\\AbstractPage
show
wcf\\page\\MultipleLinkPage
beforeReadObjects
wcf\\page\\MultipleLinkPage
insteadOfReadObjects
wcf\\page\\MultipleLinkPage
afterInitObjectList
wcf\\page\\MultipleLinkPage
calculateNumberOfPages
wcf\\page\\MultipleLinkPage
countItems
wcf\\page\\SortablePage
validateSortField
wcf\\page\\SortablePage
validateSortOrder
wcf\\system\\bbcode\\MessageParser
afterParsing
wcf\\system\\bbcode\\MessageParser
beforeParsing
wcf\\system\\bbcode\\SimpleMessageParser
afterParsing
wcf\\system\\bbcode\\SimpleMessageParser
beforeParsing
wcf\\system\\box\\BoxHandler
loadBoxes
wcf\\system\\box\\AbstractBoxController
__construct
wcf\\system\\box\\AbstractBoxController
afterLoadContent
wcf\\system\\box\\AbstractBoxController
beforeLoadContent
wcf\\system\\box\\AbstractDatabaseObjectListBoxController
afterLoadContent
wcf\\system\\box\\AbstractDatabaseObjectListBoxController
beforeLoadContent
wcf\\system\\box\\AbstractDatabaseObjectListBoxController
hasContent
wcf\\system\\box\\AbstractDatabaseObjectListBoxController
readObjects
wcf\\system\\cronjob\\AbstractCronjob
execute
wcf\\system\\email\\Email
getJobs
wcf\\system\\form\\builder\\container\\wysiwyg\\WysiwygFormContainer
populate
wcf\\system\\html\\input\\filter\\MessageHtmlInputFilter
setAttributeDefinitions
wcf\\system\\html\\input\\node\\HtmlInputNodeProcessor
afterProcess
wcf\\system\\html\\input\\node\\HtmlInputNodeProcessor
beforeEmbeddedProcess
wcf\\system\\html\\input\\node\\HtmlInputNodeProcessor
beforeProcess
wcf\\system\\html\\input\\node\\HtmlInputNodeProcessor
convertPlainLinks
wcf\\system\\html\\input\\node\\HtmlInputNodeProcessor
getTextContent
wcf\\system\\html\\input\\node\\HtmlInputNodeProcessor
parseEmbeddedContent
wcf\\system\\html\\input\\node\\HtmlInputNodeWoltlabMetacodeMarker
filterGroups
wcf\\system\\html\\output\\node\\HtmlOutputNodePre
selectHighlighter
wcf\\system\\html\\output\\node\\HtmlOutputNodeProcessor
beforeProcess
wcf\\system\\image\\adapter\\ImagickImageAdapter
getResizeFilter
wcf\\system\\menu\\user\\profile\\UserProfileMenu
init
wcf\\system\\menu\\user\\profile\\UserProfileMenu
loadCache
wcf\\system\\menu\\TreeMenu
init
wcf\\system\\menu\\TreeMenu
loadCache
wcf\\system\\message\\QuickReplyManager
addFullQuote
wcf\\system\\message\\QuickReplyManager
allowedDataParameters
wcf\\system\\message\\QuickReplyManager
beforeRenderQuote
wcf\\system\\message\\QuickReplyManager
createMessage
wcf\\system\\message\\QuickReplyManager
createdMessage
wcf\\system\\message\\QuickReplyManager
getMessage
wcf\\system\\message\\QuickReplyManager
validateParameters
wcf\\system\\message\\quote\\MessageQuoteManager
addFullQuote
wcf\\system\\message\\quote\\MessageQuoteManager
beforeRenderQuote
wcf\\system\\option\\OptionHandler
afterReadCache
wcf\\system\\package\\plugin\\AbstractPackageInstallationPlugin
construct
wcf\\system\\package\\plugin\\AbstractPackageInstallationPlugin
hasUninstall
wcf\\system\\package\\plugin\\AbstractPackageInstallationPlugin
install
wcf\\system\\package\\plugin\\AbstractPackageInstallationPlugin
uninstall
wcf\\system\\package\\plugin\\AbstractPackageInstallationPlugin
update
wcf\\system\\package\\plugin\\ObjectTypePackageInstallationPlugin
addConditionFields
wcf\\system\\package\\PackageInstallationDispatcher
postInstall
wcf\\system\\package\\PackageUninstallationDispatcher
postUninstall
wcf\\system\\reaction\\ReactionHandler
getDataAttributes
wcf\\system\\request\\RouteHandler
didInit
wcf\\system\\session\\ACPSessionFactory
afterInit
wcf\\system\\session\\ACPSessionFactory
beforeInit
wcf\\system\\session\\SessionHandler
afterChangeUser
wcf\\system\\session\\SessionHandler
beforeChangeUser
wcf\\system\\style\\StyleCompiler
compile
wcf\\system\\template\\TemplateEngine
afterDisplay
wcf\\system\\template\\TemplateEngine
beforeDisplay
wcf\\system\\upload\\DefaultUploadFileSaveStrategy
generateThumbnails
wcf\\system\\upload\\DefaultUploadFileSaveStrategy
save
wcf\\system\\user\\authentication\\UserAuthenticationFactory
init
wcf\\system\\user\\notification\\UserNotificationHandler
createdNotification
wcf\\system\\user\\notification\\UserNotificationHandler
fireEvent
wcf\\system\\user\\notification\\UserNotificationHandler
markAsConfirmed
wcf\\system\\user\\notification\\UserNotificationHandler
markAsConfirmedByIDs
wcf\\system\\user\\notification\\UserNotificationHandler
removeNotifications
wcf\\system\\user\\notification\\UserNotificationHandler
updateTriggerCount
wcf\\system\\user\\UserBirthdayCache
loadMonth
wcf\\system\\worker\\AbstractRebuildDataWorker
execute
wcf\\system\\WCF
initialized
wcf\\util\\HeaderUtil
parseOutput
*"},{"location":"php/api/event_list/#woltlab-suite-core-conversations","title":"WoltLab Suite Core: Conversations","text":"Class Event Name wcf\\data\\conversation\\ConversationAction
addParticipants_validateParticipants
wcf\\data\\conversation\\message\\ConversationMessageAction
afterQuickReply
"},{"location":"php/api/event_list/#woltlab-suite-core-infractions","title":"WoltLab Suite Core: Infractions","text":"Class Event Name wcf\\system\\infraction\\suspension\\BanSuspensionAction
suspend
wcf\\system\\infraction\\suspension\\BanSuspensionAction
unsuspend
"},{"location":"php/api/event_list/#woltlab-suite-forum","title":"WoltLab Suite Forum","text":"Class Event Name wbb\\data\\board\\BoardAction
cloneBoard
wbb\\data\\post\\PostAction
quickReplyShouldMerge
wbb\\system\\thread\\ThreadHandler
didInit
"},{"location":"php/api/event_list/#woltlab-suite-filebase","title":"WoltLab Suite Filebase","text":"Class Event Name filebase\\data\\file\\File
getPrice
filebase\\data\\file\\ViewableFile
getUnreadFiles
"},{"location":"php/api/events/","title":"Events","text":"WoltLab Suite's event system allows manipulation of program flows and data without having to change any of the original source code. At many locations throughout the PHP code of WoltLab Suite Core and mainly through inheritance also in the applications and plugins, so called events are fired which trigger registered event listeners that get access to the object firing the event (or at least the class name if the event has been fired in a static method).
This page focuses on the technical aspects of events and event listeners, the eventListener package installation plugin page covers how you can actually register an event listener. A comprehensive list of all available events is provided here.
"},{"location":"php/api/events/#introductory-example","title":"Introductory Example","text":"Let's start with a simple example to illustrate how the event system works. Consider this pre-existing class:
files/lib/system/example/ExampleComponent.class.php<?php\nnamespace wcf\\system\\example;\nuse wcf\\system\\event\\EventHandler;\n\nclass ExampleComponent {\n public $var = 1;\n\n public function getVar() {\n EventHandler::getInstance()->fireAction($this, 'getVar');\n\n return $this->var;\n }\n}\n
where an event with event name getVar
is fired in the getVar()
method.
If you create an object of this class and call the getVar()
method, the return value will be 1
, of course:
<?php\n\n$example = new wcf\\system\\example\\ExampleComponent();\nif ($example->getVar() == 1) {\n echo \"var is 1!\";\n}\nelse if ($example->getVar() == 2) {\n echo \"var is 2!\";\n}\nelse {\n echo \"No, var is neither 1 nor 2.\";\n}\n\n// output: var is 1!\n
Now, consider that we have registered the following event listener to this event:
files/lib/system/event/listener/ExampleEventListener.class.php<?php\nnamespace wcf\\system\\event\\listener;\n\nclass ExampleEventListener implements IParameterizedEventListener {\n public function execute($eventObj, $className, $eventName, array &$parameters) {\n $eventObj->var = 2;\n }\n}\n
Whenever the event in the getVar()
method is called, this method (of the same event listener object) is called. In this case, the value of the method's first parameter is the ExampleComponent
object passed as the first argument of the EventHandler::fireAction()
call in ExampleComponent::getVar()
. As ExampleComponent::$var
is a public property, the event listener code can change it and set it to 2
.
If you now execute the example code from above again, the output will change from var is 1!
to var is 2!
because prior to returning the value, the event listener code changes the value from 1
to 2
.
This introductory example illustrates how event listeners can change data in a non-intrusive way. Program flow can be changed, for example, by throwing a wcf\\system\\exception\\PermissionDeniedException
if some additional constraint to access a page is not fulfilled.
In order to listen to events, you need to register the event listener and the event listener itself needs to implement the interface wcf\\system\\event\\listener\\IParameterizedEventListener
which only contains the execute
method (see example above).
The first parameter $eventObj
of the method contains the passed object where the event is fired or the name of the class in which the event is fired if it is fired from a static method. The second parameter $className
always contains the name of the class where the event has been fired. The third parameter $eventName
provides the name of the event within a class to uniquely identify the exact location in the class where the event has been fired. The last parameter $parameters
is a reference to the array which contains additional data passed by the method firing the event. If no additional data is passed, $parameters
is empty.
If you write code and want plugins to have access at certain points, you can fire an event on your own. The only thing to do is to call the wcf\\system\\event\\EventHandler::fireAction($eventObj, $eventName, array &$parameters = [])
method and pass the following parameters:
$eventObj
should be $this
if you fire from an object context, otherwise pass the class name static::class
.$eventName
identifies the event within the class and generally has the same name as the method. In cases, were you might fire more than one event in a method, for example before and after a certain piece of code, you can use the prefixes before*
and after*
in your event names.$parameters
is an optional array which allows you to pass additional data to the event listeners without having to make this data accessible via a property explicitly only created for this purpose. This additional data can either be just additional information for the event listeners about the context of the method call or allow the event listener to manipulate local data if the code, where the event has been fired, uses the passed data afterwards. $parameters
argument","text":"Consider the following method which gets some text that the methods parses.
files/lib/system/example/ExampleParser.class.php<?php\nnamespace wcf\\system\\example;\nuse wcf\\system\\event\\EventHandler;\n\nclass ExampleParser {\n public function parse($text) {\n // [some parsing done by default]\n\n $parameters = ['text' => $text];\n EventHandler::getInstance()->fireAction($this, 'parse', $parameters);\n\n return $parameters['text'];\n }\n}\n
After the default parsing by the method itself, the author wants to enable plugins to do additional parsing and thus fires an event and passes the parsed text as an additional parameter. Then, a plugin can deliver the following event listener
files/lib/system/event/listener/ExampleParserEventListener.class.php<?php\nnamespace wcf\\system\\event\\listener;\n\nclass ExampleParserEventListener implements IParameterizedEventListener {\n public function execute($eventObj, $className, $eventName, array &$parameters) {\n $text = $parameters['text'];\n\n // [some additional parsing which changes $text]\n\n $parameters['text'] = $text;\n }\n}\n
which can access the text via $parameters['text']
.
This example can also be perfectly used to illustrate how to name multiple events in the same method. Let's assume that the author wants to enable plugins to change the text before and after the method does its own parsing and thus fires two events:
files/lib/system/example/ExampleParser.class.php<?php\nnamespace wcf\\system\\example;\nuse wcf\\system\\event\\EventHandler;\n\nclass ExampleParser {\n public function parse($text) {\n $parameters = ['text' => $text];\n EventHandler::getInstance()->fireAction($this, 'beforeParsing', $parameters);\n $text = $parameters['text'];\n\n // [some parsing done by default]\n\n $parameters = ['text' => $text];\n EventHandler::getInstance()->fireAction($this, 'afterParsing', $parameters);\n\n return $parameters['text'];\n }\n}\n
"},{"location":"php/api/events/#advanced-example-additional-form-field","title":"Advanced Example: Additional Form Field","text":"One common reason to use event listeners is to add an additional field to a pre-existing form (in combination with template listeners, which we will not cover here). We will assume that users are able to do both, create and edit the objects via this form. The points in the program flow of AbstractForm that are relevant here are:
saving the additional value after successful validation and resetting locally stored value or assigning the current value of the field to the template after unsuccessful validation
editing object:
All of these cases can be covered the by following code in which we assume that wcf\\form\\ExampleAddForm
is the form to create example objects and that wcf\\form\\ExampleEditForm
extends wcf\\form\\ExampleAddForm
and is used for editing existing example objects.
<?php\nnamespace wcf\\system\\event\\listener;\nuse wcf\\form\\ExampleAddForm;\nuse wcf\\form\\ExampleEditForm;\nuse wcf\\system\\exception\\UserInputException;\nuse wcf\\system\\WCF;\n\nclass ExampleAddFormListener implements IParameterizedEventListener {\n protected $var = 0;\n\n public function execute($eventObj, $className, $eventName, array &$parameters) {\n $this->$eventName($eventObj);\n }\n\n protected function assignVariables() {\n WCF::getTPL()->assign('var', $this->var);\n }\n\n protected function readData(ExampleEditForm $eventObj) {\n if (empty($_POST)) {\n $this->var = $eventObj->example->var;\n }\n }\n\n protected function readFormParameters() {\n if (isset($_POST['var'])) $this->var = intval($_POST['var']);\n }\n\n protected function save(ExampleAddForm $eventObj) {\n $eventObj->additionalFields = array_merge($eventObj->additionalFields, ['var' => $this->var]);\n }\n\n protected function saved() {\n $this->var = 0;\n }\n\n protected function validate() {\n if ($this->var < 0) {\n throw new UserInputException('var', 'isNegative');\n }\n }\n}\n
The execute
method in this example just delegates the call to a method with the same name as the event so that this class mimics the structure of a form class itself. The form object is passed to the methods but is only given in the method signatures as a parameter here whenever the form object is actually used. Furthermore, the type-hinting of the parameter illustrates in which contexts the method is actually called which will become clear in the following discussion of the individual methods:
assignVariables()
is called for the add and the edit form and simply assigns the current value of the variable to the template.readData()
reads the pre-existing value of $var
if the form has not been submitted and thus is only relevant when editing objects which is illustrated by the explicit type-hint of ExampleEditForm
.readFormParameters()
reads the value for both, the add and the edit form.save()
is, of course, also relevant in both cases but requires the form object to store the additional value in the wcf\\form\\AbstractForm::$additionalFields
array which can be used if a var
column has been added to the database table in which the example objects are stored.saved()
is only called for the add form as it clears the internal value so that in the assignVariables()
call, the default value will be assigned to the template to create an \"empty\" form. During edits, this current value is the actual value that should be shown.validate()
also needs to be called in both cases as the input data always has to be validated.Lastly, the following XML file has to be used to register the event listeners (you can find more information about how to register event listeners on the eventListener package installation plugin page):
eventListener.xml<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/eventListener.xsd\">\n<import>\n<eventlistener name=\"exampleAddInherited\">\n<eventclassname>wcf\\form\\ExampleAddForm</eventclassname>\n<eventname>assignVariables,readFormParameters,save,validate</eventname>\n<listenerclassname>wcf\\system\\event\\listener\\ExampleAddFormListener</listenerclassname>\n<inherit>1</inherit>\n</eventlistener>\n\n<eventlistener name=\"exampleAdd\">\n<eventclassname>wcf\\form\\ExampleAddForm</eventclassname>\n<eventname>saved</eventname>\n<listenerclassname>wcf\\system\\event\\listener\\ExampleAddFormListener</listenerclassname>\n</eventlistener>\n\n<eventlistener name=\"exampleEdit\">\n<eventclassname>wcf\\form\\ExampleEditForm</eventclassname>\n<eventname>readData</eventname>\n<listenerclassname>wcf\\system\\event\\listener\\ExampleAddFormListener</listenerclassname>\n</eventlistener>\n</import>\n</data>\n
"},{"location":"php/api/package_installation_plugins/","title":"Package Installation Plugins","text":"A package installation plugin (PIP) defines the behavior to handle a specific instruction during package installation, update or uninstallation.
"},{"location":"php/api/package_installation_plugins/#abstractpackageinstallationplugin","title":"AbstractPackageInstallationPlugin
","text":"Any package installation plugin has to implement the IPackageInstallationPlugin interface. It is recommended however, to extend the abstract implementation AbstractPackageInstallationPlugin of this interface instead of directly implementing the interface. The abstract implementation will always provide sane methods in case of any API changes.
"},{"location":"php/api/package_installation_plugins/#class-members","title":"Class Members","text":"Package Installation Plugins have a few notable class members easing your work:
"},{"location":"php/api/package_installation_plugins/#installation","title":"$installation
","text":"This member contains an instance of PackageInstallationDispatcher which provides you with all meta data related to the current package being processed. The most common usage is the retrieval of the package ID via $this->installation->getPackageID()
.
$application
","text":"Represents the abbreviation of the target application, e.g. wbb
(default value: wcf
), used for the name of database table in which the installed data is stored.
AbstractXMLPackageInstallationPlugin
","text":"AbstractPackageInstallationPlugin is the default implementation for all package installation plugins based upon a single XML document. It handles the evaluation of the document and provide you an object-orientated approach to handle its data.
"},{"location":"php/api/package_installation_plugins/#class-members_1","title":"Class Members","text":""},{"location":"php/api/package_installation_plugins/#classname","title":"$className
","text":"Value must be the qualified name of a class deriving from DatabaseObjectEditor which is used to create and update objects.
"},{"location":"php/api/package_installation_plugins/#tagname","title":"$tagName
","text":"Specifies the tag name within a <import>
or <delete>
section of the XML document used for each installed object.
prepareImport(array $data)
","text":"The passed array $data
contains the parsed value from each evaluated tag in the <import>
section:
$data['elements']
contains a list of tag names and their value.$data['attributes']
contains a list of attributes present on the tag identified by $tagName.This method should return an one-dimensional array, where each key maps to the corresponding database column name (key names are case-sensitive). It will be passed to either DatabaseObjectEditor::create()
or DatabaseObjectEditor::update()
.
Example:
<?php\nreturn [\n 'environment' => $data['elements']['environment'],\n 'eventName' => $data['elements']['eventname'],\n 'name' => $data['attributes']['name']\n];\n
"},{"location":"php/api/package_installation_plugins/#validateimportarray-data","title":"validateImport(array $data)
","text":"The passed array $data
equals the data returned by prepareImport(). This method has no return value, instead you should throw an exception if the passed data is invalid.
findExistingItem(array $data)
","text":"The passed array $data
equals the data returned by prepareImport(). This method is expected to return an array with two keys:
sql
contains the SQL query with placeholders.parameters
contains an array with values used for the SQL query.<?php\n$sql = \"SELECT *\n FROM wcf\".WCF_N.\"_\".$this->tableName.\"\n WHERE packageID = ?\n AND name = ?\n AND templateName = ?\n AND eventName = ?\n AND environment = ?\";\n$parameters = [\n $this->installation->getPackageID(),\n $data['name'],\n $data['templateName'],\n $data['eventName'],\n $data['environment']\n];\n\nreturn [\n 'sql' => $sql,\n 'parameters' => $parameters\n];\n
"},{"location":"php/api/package_installation_plugins/#handledeletearray-items","title":"handleDelete(array $items)
","text":"The passed array $items
contains the original node data, similar to prepareImport(). You should make use of this data to remove the matching element from database.
Example:
<?php\n$sql = \"DELETE FROM wcf1_{$this->tableName}\n WHERE packageID = ?\n AND environment = ?\n AND eventName = ?\n AND name = ?\n AND templateName = ?\";\n$statement = WCF::getDB()->prepare($sql);\nforeach ($items as $item) {\n $statement->execute([\n $this->installation->getPackageID(),\n $item['elements']['environment'],\n $item['elements']['eventname'],\n $item['attributes']['name'],\n $item['elements']['templatename']\n ]);\n}\n
"},{"location":"php/api/package_installation_plugins/#postimport","title":"postImport()
","text":"Allows you to (optionally) run additionally actions after all elements were processed.
"},{"location":"php/api/package_installation_plugins/#abstractoptionpackageinstallationplugin","title":"AbstractOptionPackageInstallationPlugin
","text":"AbstractOptionPackageInstallationPlugin is an abstract implementation for options, used for:
AbstractXMLPackageInstallationPlugin
","text":""},{"location":"php/api/package_installation_plugins/#reservedtags","title":"$reservedTags
","text":"$reservedTags
is a list of reserved tag names so that any tag encountered but not listed here will be added to the database column additionalData
. This allows options to store arbitrary data which can be accessed but were not initially part of the PIP specifications.
WoltLab Suite is capable of automatically creating a sitemap. This sitemap contains all static pages registered via the page package installation plugin and which may be indexed by search engines (checking the allowSpidersToIndex
parameter and page permissions) and do not expect an object ID. Other pages have to be added to the sitemap as a separate object.
The only prerequisite for sitemap objects is that the objects are instances of wcf\\data\\DatabaseObject
and that there is a wcf\\data\\DatabaseObjectList
implementation.
First, we implement the PHP class, which provides us all database objects and optionally checks the permissions for a single object. The class must implement the interface wcf\\system\\sitemap\\object\\ISitemapObjectObjectType
. However, in order to have some methods already implemented and ensure backwards compatibility, you should use the abstract class wcf\\system\\sitemap\\object\\AbstractSitemapObjectObjectType
. The abstract class takes care of generating the DatabaseObjectList
class name and list directly and implements optional methods with the default values. The only method that you have to implement yourself is the getObjectClass()
method which returns the fully qualified name of the DatabaseObject
class. The DatabaseObject
class must implement the interface wcf\\data\\ILinkableObject
.
Other optional methods are:
getLastModifiedColumn()
method returns the name of the column in the database where the last modification date is stored. If there is none, this method must return null
.canView()
method checks whether the passed DatabaseObject
is visible to the current user with the current user always being a guest.getObjectListClass()
method returns a non-standard DatabaseObjectList
class name.getObjectList()
method returns the DatabaseObjectList
instance. You can, for example, specify additional query conditions in the method.As an example, the implementation for users looks like this:
files/lib/system/sitemap/object/UserSitemapObject.class.php<?php\nnamespace wcf\\system\\sitemap\\object;\nuse wcf\\data\\user\\User;\nuse wcf\\data\\DatabaseObject;\nuse wcf\\system\\WCF;\n\n/**\n * User sitemap implementation.\n *\n * @author Joshua Ruesweg\n * @copyright 2001-2017 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\Sitemap\\Object\n * @since 3.1\n */\nclass UserSitemapObject extends AbstractSitemapObjectObjectType {\n /**\n * @inheritDoc\n */\n public function getObjectClass() {\n return User::class;\n }\n\n /**\n * @inheritDoc\n */\n public function getLastModifiedColumn() {\n return 'lastActivityTime';\n }\n\n /**\n * @inheritDoc\n */\n public function canView(DatabaseObject $object) {\n return WCF::getSession()->getPermission('user.profile.canViewUserProfile');\n }\n}\n
Next, the sitemap object must be registered as an object type:
<type>\n<name>com.example.plugin.sitemap.object.user</name>\n<definitionname>com.woltlab.wcf.sitemap.object</definitionname>\n<classname>wcf\\system\\sitemap\\object\\UserSitemapObject</classname>\n<priority>0.5</priority>\n<changeFreq>monthly</changeFreq>\n<rebuildTime>259200</rebuildTime>\n</type>\n
In addition to the fully qualified class name, the object type definition com.woltlab.wcf.sitemap.object
and the object type name, the parameters priority
, changeFreq
and rebuildTime
must also be specified. priority
(https://www.sitemaps.org/protocol.html#prioritydef) and changeFreq
(https://www.sitemaps.org/protocol.html#changefreqdef) are specifications in the sitemaps protocol and can be changed by the user in the ACP. The priority
should be 0.5
by default, unless there is an important reason to change it. The parameter rebuildTime
specifies the number of seconds after which the sitemap should be regenerated.
Finally, you have to create the language variable for the sitemap object. The language variable follows the pattern wcf.acp.sitemap.objectType.{objectTypeName}
and is in the category wcf.acp.sitemap
.
Users get activity points whenever they create content to award them for their contribution. Activity points are used to determine the rank of a user and can also be used for user conditions, for example for automatic user group assignments.
To integrate activity points into your package, you have to register an object type for the defintion com.woltlab.wcf.user.activityPointEvent
and specify a default number of points:
<type>\n<name>com.example.foo.activityPointEvent.bar</name>\n<definitionname>com.woltlab.wcf.user.activityPointEvent</definitionname>\n<points>10</points>\n</type>\n
The number of points awarded for this type of activity point event can be changed by the administrator in the admin control panel. For this form and the user activity point list shown in the frontend, you have to provide the language item
wcf.user.activityPoint.objectType.com.example.foo.activityPointEvent.bar\n
that contains the name of the content for which the activity points are awarded.
If a relevant object is created, you have to use UserActivityPointHandler::fireEvent()
which expects the name of the activity point event object type, the id of the object for which the points are awarded (though the object id is not used at the moment) and the user who gets the points:
UserActivityPointHandler::getInstance()->fireEvent(\n 'com.example.foo.activityPointEvent.bar',\n $bar->barID,\n $bar->userID\n);\n
To remove activity points once objects are deleted, you have to use UserActivityPointHandler::removeEvents()
which also expects the name of the activity point event object type and additionally an array mapping the id of the user whose activity points will be reduced to the number of objects that are removed for the relevant user:
UserActivityPointHandler::getInstance()->removeEvents(\n 'com.example.foo.activityPointEvent.bar',\n [\n 1 => 1, // remove points for one object for user with id `1`\n 4 => 2 // remove points for two objects for user with id `4`\n ]\n);\n
"},{"location":"php/api/user_notifications/","title":"User Notifications","text":"WoltLab Suite includes a powerful user notification system that supports notifications directly shown on the website and emails sent immediately or on a daily basis.
"},{"location":"php/api/user_notifications/#objecttypexml","title":"objectType.xml
","text":"For any type of object related to events, you have to define an object type for the object type definition com.woltlab.wcf.notification.objectType
:
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/objectType.xsd\">\n<import>\n<type>\n<name>com.woltlab.example.foo</name>\n<definitionname>com.woltlab.wcf.notification.objectType</definitionname>\n<classname>example\\system\\user\\notification\\object\\type\\FooUserNotificationObjectType</classname>\n<category>com.woltlab.example</category>\n</type>\n</import>\n</data>\n
The referenced class FooUserNotificationObjectType
has to implement the IUserNotificationObjectType interface, which should be done by extending AbstractUserNotificationObjectType.
<?php\nnamespace example\\system\\user\\notification\\object\\type;\nuse example\\data\\foo\\Foo;\nuse example\\data\\foo\\FooList;\nuse example\\system\\user\\notification\\object\\FooUserNotificationObject;\nuse wcf\\system\\user\\notification\\object\\type\\AbstractUserNotificationObjectType;\n\n/**\n * Represents a foo as a notification object type.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2017 WoltLab GmbH\n * @license WoltLab License <http://www.woltlab.com/license-agreement.html>\n * @package WoltLabSuite\\Example\\System\\User\\Notification\\Object\\Type\n */\nclass FooUserNotificationObjectType extends AbstractUserNotificationObjectType {\n /**\n * @inheritDoc\n */\n protected static $decoratorClassName = FooUserNotificationObject::class;\n\n /**\n * @inheritDoc\n */\n protected static $objectClassName = Foo::class;\n\n /**\n * @inheritDoc\n */\n protected static $objectListClassName = FooList::class;\n}\n
You have to set the class names of the database object ($objectClassName
) and the related list ($objectListClassName
). Additionally, you have to create a class that implements the IUserNotificationObject whose name you have to set as the value of the $decoratorClassName
property.
<?php\nnamespace example\\system\\user\\notification\\object;\nuse example\\data\\foo\\Foo;\nuse wcf\\data\\DatabaseObjectDecorator;\nuse wcf\\system\\user\\notification\\object\\IUserNotificationObject;\n\n/**\n * Represents a foo as a notification object.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2017 WoltLab GmbH\n * @license WoltLab License <http://www.woltlab.com/license-agreement.html>\n * @package WoltLabSuite\\Example\\System\\User\\Notification\\Object\n *\n * @method Foo getDecoratedObject()\n * @mixin Foo\n */\nclass FooUserNotificationObject extends DatabaseObjectDecorator implements IUserNotificationObject {\n /**\n * @inheritDoc\n */\n protected static $baseClass = Foo::class;\n\n /**\n * @inheritDoc\n */\n public function getTitle() {\n return $this->getDecoratedObject()->getTitle();\n }\n\n /**\n * @inheritDoc\n */\n public function getURL() {\n return $this->getDecoratedObject()->getLink();\n }\n\n /**\n * @inheritDoc\n */\n public function getAuthorID() {\n return $this->getDecoratedObject()->userID;\n }\n}\n
getTitle()
method returns the title of the object. In this case, we assume that the Foo
class has implemented the ITitledObject interface so that the decorated Foo
can handle this method call itself.getURL()
method returns the link to the object. As for the getTitle()
, we assume that the Foo
class has implemented the ILinkableObject interface so that the decorated Foo
can also handle this method call itself.getAuthorID()
method returns the id of the user who created the decorated Foo
object. We assume that Foo
objects have a userID
property that contains this id.userNotificationEvent.xml
","text":"Each event that you fire in your package needs to be registered using the user notification event package installation plugin. An example file might look like this:
userNotificationEvent.xml<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/2019/userNotificationEvent.xsd\">\n<import>\n<event>\n<name>bar</name>\n<objecttype>com.woltlab.example.foo</objecttype>\n<classname>example\\system\\user\\notification\\event\\FooUserNotificationEvent</classname>\n<preset>1</preset>\n</event>\n</import>\n</data>\n
Here, you reference the user notification object type created via objectType.xml
. The referenced class in the <classname>
element has to implement the IUserNotificationEvent interface by extending the AbstractUserNotificationEvent class or the AbstractSharedUserNotificationEvent class if you want to pre-load additional data before processing notifications. In AbstractSharedUserNotificationEvent::prepare()
, you can, for example, tell runtime caches to prepare to load certain objects which then are loaded all at once when the objects are needed.
<?php\nnamespace example\\system\\user\\notification\\event;\nuse example\\system\\cache\\runtime\\BazRuntimeCache;\nuse example\\system\\user\\notification\\object\\FooUserNotificationObject;\nuse wcf\\system\\email\\Email;\nuse wcf\\system\\request\\LinkHandler;\nuse wcf\\system\\user\\notification\\event\\AbstractSharedUserNotificationEvent;\n\n/**\n * Notification event for foos.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2017 WoltLab GmbH\n * @license WoltLab License <http://www.woltlab.com/license-agreement.html>\n * @package WoltLabSuite\\Example\\System\\User\\Notification\\Event\n *\n * @method FooUserNotificationObject getUserNotificationObject()\n */\nclass FooUserNotificationEvent extends AbstractSharedUserNotificationEvent {\n /**\n * @inheritDoc\n */\n protected $stackable = true;\n\n /** @noinspection PhpMissingParentCallCommonInspection */\n /**\n * @inheritDoc\n */\n public function checkAccess() {\n $this->getUserNotificationObject()->setBaz(BazRuntimeCache::getInstance()->getObject($this->getUserNotificationObject()->bazID));\n\n if (!$this->getUserNotificationObject()->isAccessible()) {\n // do some cleanup, if necessary\n\n return false;\n }\n\n return true;\n }\n\n /** @noinspection PhpMissingParentCallCommonInspection */\n /**\n * @inheritDoc\n */\n public function getEmailMessage($notificationType = 'instant') {\n $this->getUserNotificationObject()->setBaz(BazRuntimeCache::getInstance()->getObject($this->getUserNotificationObject()->bazID));\n\n $messageID = '<com.woltlab.example.baz/'.$this->getUserNotificationObject()->bazID.'@'.Email::getHost().'>';\n\n return [\n 'application' => 'example',\n 'in-reply-to' => [$messageID],\n 'message-id' => 'com.woltlab.example.foo/'.$this->getUserNotificationObject()->fooID,\n 'references' => [$messageID],\n 'template' => 'email_notification_foo'\n ];\n }\n\n /**\n * @inheritDoc\n * @since 5.0\n */\n public function getEmailTitle() {\n $this->getUserNotificationObject()->setBaz(BazRuntimeCache::getInstance()->getObject($this->getUserNotificationObject()->bazID));\n\n return $this->getLanguage()->getDynamicVariable('example.foo.notification.mail.title', [\n 'userNotificationObject' => $this->getUserNotificationObject()\n ]);\n }\n\n /** @noinspection PhpMissingParentCallCommonInspection */\n /**\n * @inheritDoc\n */\n public function getEventHash() {\n return sha1($this->eventID . '-' . $this->getUserNotificationObject()->bazID);\n }\n\n /**\n * @inheritDoc\n */\n public function getLink() {\n return LinkHandler::getInstance()->getLink('Foo', [\n 'application' => 'example',\n 'object' => $this->getUserNotificationObject()->getDecoratedObject()\n ]);\n }\n\n /**\n * @inheritDoc\n */\n public function getMessage() {\n $authors = $this->getAuthors();\n $count = count($authors);\n\n if ($count > 1) {\n if (isset($authors[0])) {\n unset($authors[0]);\n }\n $count = count($authors);\n\n return $this->getLanguage()->getDynamicVariable('example.foo.notification.message.stacked', [\n 'author' => $this->author,\n 'authors' => array_values($authors),\n 'count' => $count,\n 'guestTimesTriggered' => $this->notification->guestTimesTriggered,\n 'message' => $this->getUserNotificationObject(),\n 'others' => $count - 1\n ]);\n }\n\n return $this->getLanguage()->getDynamicVariable('example.foo.notification.message', [\n 'author' => $this->author,\n 'userNotificationObject' => $this->getUserNotificationObject()\n ]);\n }\n\n /**\n * @inheritDoc\n */\n public function getTitle() {\n $count = count($this->getAuthors());\n if ($count > 1) {\n return $this->getLanguage()->getDynamicVariable('example.foo.notification.title.stacked', [\n 'count' => $count,\n 'timesTriggered' => $this->notification->timesTriggered\n ]);\n }\n\n return $this->getLanguage()->get('example.foo.notification.title');\n }\n\n /**\n * @inheritDoc\n */\n protected function prepare() {\n BazRuntimeCache::getInstance()->cacheObjectID($this->getUserNotificationObject()->bazID);\n }\n}\n
$stackable
property is false
by default and has to be explicitly set to true
if stacking of notifications should be enabled. Stacking of notification does not create new notifications for the same event for a certain object if the related action as been triggered by different users. For example, if something is liked by one user and then liked again by another user before the recipient of the notification has confirmed it, the existing notification will be amended to include both users who liked the content. Stacking can thus be used to avoid cluttering the notification list of users.checkAccess()
method makes sure that the active user still has access to the object related to the notification. If that is not the case, the user notification system will automatically deleted the user notification based on the return value of the method. If you have any cached values related to notifications, you should also reset these values here.getEmailMessage()
method return data to create the instant email or the daily summary email. For instant emails ($notificationType = 'instant'
), you have to return an array like the one shown in the code above with the following components:application
: abbreviation of applicationin-reply-to
(optional): message id of the notification for the parent item and used to improve the ordering in threaded email clientsmessage-id
(optional): message id of the notification mail and has to be used in in-reply-to
and references
for follow up mailsreferences
(optional): all of the message ids of parent items (i.e. recursive in-reply-to)template
: name of the template used to render the email body, should start with email_
variables
(optional): template variables passed to the email template where they can be accessed via $notificationContent[variables]
For daily emails ($notificationType = 'daily'
), only application
, template
, and variables
are supported. - The getEmailTitle()
returns the title of the instant email sent to the user. By default, getEmailTitle()
simply calls getTitle()
. - The getEventHash()
method returns a hash by which user notifications are grouped. Here, we want to group them not by the actual Foo
object but by its parent Baz
object and thus overwrite the default implementation provided by AbstractUserNotificationEvent
. - The getLink()
returns the link to the Foo
object the notification belongs to. - The getMessage()
method and the getTitle()
return the message and the title of the user notification respectively. By checking the value of count($this->getAuthors())
, we check if the notification is stacked, thus if the event has been triggered for multiple users so that different languages items are used. If your notification event does not support stacking, this distinction is not necessary. - The prepare()
method is called for each user notification before all user notifications are rendered. This allows to tell runtime caches to prepare to load objects later on (see Runtime Caches).
When the action related to a user notification is executed, you can use UserNotificationHandler::fireEvent()
to create the notifications:
$recipientIDs = []; // fill with user ids of the recipients of the notification\nUserNotificationHandler::getInstance()->fireEvent(\n 'bar', // event name\n 'com.woltlab.example.foo', // event object type name\n new FooUserNotificationObject(new Foo($fooID)), // object related to the event\n $recipientIDs\n);\n
"},{"location":"php/api/user_notifications/#marking-notifications-as-confirmed","title":"Marking Notifications as Confirmed","text":"In some instances, you might want to manually mark user notifications as confirmed without the user manually confirming them, for example when they visit the page that is related to the user notification. In this case, you can use UserNotificationHandler::markAsConfirmed()
:
$recipientIDs = []; // fill with user ids of the recipients of the notification\n$fooIDs = []; // fill with ids of related foo objects\nUserNotificationHandler::getInstance()->markAsConfirmed(\n 'bar', // event name\n 'com.woltlab.example.foo', // event object type name\n $recipientIDs,\n $fooIDs\n);\n
"},{"location":"php/api/form_builder/dependencies/","title":"Form Node Dependencies","text":"Form node dependencies allow to make parts of a form dynamically available or unavailable depending on the values of form fields. Dependencies are always added to the object whose visibility is determined by certain form fields. They are not added to the form field\u2019s whose values determine the visibility! An example is a text form field that should only be available if a certain option from a single selection form field is selected. Form builder\u2019s dependency system supports such scenarios and also automatically making form containers unavailable once all of its children are unavailable.
If a form node has multiple dependencies and one of them is not met, the form node is unavailable. A form node not being available due to dependencies has to the following consequences:
IFormDocument::getData()
.IFormFieldDependency
","text":"The basis of the dependencies is the IFormFieldDependency
interface that has to be implemented by every dependency class. The interface requires the following methods:
checkDependency()
checks if the dependency is met, thus if the dependant form field should be considered available.dependentNode(IFormNode $node)
and getDependentNode()
can be used to set and get the node whose availability depends on the referenced form field. TFormNode::addDependency()
automatically calls dependentNode(IFormNode $node)
with itself as the dependent node, thus the dependent node is automatically set by the API.field(IFormField $field)
and getField()
can be used to set and get the form field that influences the availability of the dependent node.fieldId($fieldId)
and getFieldId()
can be used to set and get the id of the form field that influences the availability of the dependent node.getHtml()
returns JavaScript code required to ensure the dependency in the form output.getId()
returns the id of the dependency used to identify multiple dependencies of the same form node.static create($id)
is the factory method that has to be used to create new dependencies with the given id.AbstractFormFieldDependency
provides default implementations for all methods except for checkDependency()
.
Using fieldId($fieldId)
instead of field(IFormField $field)
makes sense when adding the dependency directly when setting up the form:
$container->appendChildren([\n FooField::create('a'),\n\n BarField::create('b')\n ->addDependency(\n BazDependency::create('a')\n ->fieldId('a')\n )\n]);\n
Here, without an additional assignment, the first field with id a
cannot be accessed thus fieldId($fieldId)
should be used as the id of the relevant field is known. When the form is built, all dependencies that only know the id of the relevant field and do not have a reference for the actual object are populated with the actual form field objects.
WoltLab Suite Core delivers the following default dependency classes by default:
"},{"location":"php/api/form_builder/dependencies/#nonemptyformfielddependency","title":"NonEmptyFormFieldDependency
","text":"NonEmptyFormFieldDependency
can be used to ensure that a node is only shown if the value of the referenced form field is not empty (being empty is determined using PHP\u2019s empty()
language construct).
EmptyFormFieldDependency
","text":"This is the inverse of NonEmptyFormFieldDependency
, checking for !empty()
.
ValueFormFieldDependency
","text":"ValueFormFieldDependency
can be used to ensure that a node is only shown if the value of the referenced form field is from a specified list of of values (see methods values($values)
and getValues()
). Additionally, via negate($negate = true)
and isNegated()
, the logic can also be inverted by requiring the value of the referenced form field not to be from a specified list of values.
ValueIntervalFormFieldDependency
","text":"Only available since version 5.5.
ValueIntervalFormFieldDependency
can be used to ensure that a node is only shown if the value of the referenced form field is in a specific interval whose boundaries are set via minimum(?float $minimum = null)
and maximum(?float $maximum = null)
.
IsNotClickedFormFieldDependency
","text":"IsNotClickedFormFieldDependency
is a special dependency for ButtonFormField
s. Refer to the documentation of ButtomFormField
for details.
To ensure that dependent node are correctly shown and hidden when changing the value of referenced form fields, every PHP dependency class has a corresponding JavaScript module that checks the dependency in the browser. Every JavaScript dependency has to extend WoltLabSuite/Core/Form/Builder/Field/Dependency/Abstract
and implement the checkDependency()
function, the JavaScript version of IFormFieldDependency::checkDependency()
.
All of the JavaScript dependency objects automatically register themselves during initialization with the WoltLabSuite/Core/Form/Builder/Field/Dependency/Manager
which takes care of checking the dependencies at the correct points in time.
Additionally, the dependency manager also ensures that form containers in which all children are hidden due to dependencies are also hidden and, once any child becomes available again, makes the container also available again. Every form container has to create a matching form container dependency object from a module based on WoltLabSuite/Core/Form/Builder/Field/Dependency/Abstract
.
If $booleanFormField
is an instance of BooleanFormField
and the text form field $textFormField
should only be available if \u201cYes\u201d has been selected, the following condition has to be set up:
$textFormField->addDependency(\n NonEmptyFormFieldDependency::create('booleanFormField')\n ->field($booleanFormField)\n);\n
If $singleSelectionFormField
is an instance of SingleSelectionFormField
that offers the options 1
, 2
, and 3
and $textFormField
should only be available if 1
or 3
is selected, the following condition has to be set up:
$textFormField->addDependency(\n ValueFormFieldDependency::create('singleSelectionFormField')\n ->field($singleSelectionFormField)\n ->values([1, 3])\n);\n
If, in contrast, $singleSelectionFormField
has many available options and 7
is the only option for which $textFormField
should not be available, negate()
should be used:
$textFormField->addDependency(\n ValueFormFieldDependency::create('singleSelectionFormField')\n ->field($singleSelectionFormField)\n ->values([7])\n ->negate()\n);\n
"},{"location":"php/api/form_builder/form_fields/","title":"Form Builder Fields","text":""},{"location":"php/api/form_builder/form_fields/#abstract-form-fields","title":"Abstract Form Fields","text":"The following form field classes cannot be instantiated directly because they are abstract, but they can/must be used when creating own form field classes.
"},{"location":"php/api/form_builder/form_fields/#abstractformfield","title":"AbstractFormField
","text":"AbstractFormField
is the abstract default implementation of the IFormField
interface and it is expected that every implementation of IFormField
implements the interface by extending this class.
AbstractNumericFormField
","text":"AbstractNumericFormField
is the abstract implementation of a form field handling a single numeric value. The class implements IAttributeFormField
, IAutoCompleteFormField
, ICssClassFormField
, IImmutableFormField
, IInputModeFormField
, IMaximumFormField
, IMinimumFormField
, INullableFormField
, IPlaceholderFormField
and ISuffixedFormField
. If the property $integerValues
is true
, the form field works with integer values, otherwise it works with floating point numbers. The methods step($step = null)
and getStep()
can be used to set and get the step attribute of the input
element. The default step for form fields with integer values is 1
. Otherwise, the default step is any
.
AbstractFormFieldDecorator
","text":"Only available since version 5.4.5.
AbstractFormFieldDecorator
is a default implementation of a decorator for form fields that forwards calls to all methods defined in IFormField
to the respective method of the decorated object. The class implements IFormfield
. If the implementation of a more specific interface is required then the remaining methods must be implemented in the concrete decorator derived from AbstractFormFieldDecorator
and the type of the $field
property must be narrowed appropriately.
The following form fields are general reusable fields without any underlying context.
"},{"location":"php/api/form_builder/form_fields/#booleanformfield","title":"BooleanFormField
","text":"BooleanFormField
is used for boolean (0
or 1
, yes
or no
) values. Objects of this class require a label. The return value of getSaveValue()
is the integer representation of the boolean value, i.e. 0
or 1
. The class implements IAttributeFormField
, IAutoFocusFormField
, ICssClassFormField
, and IImmutableFormField
.
CheckboxFormField
","text":"CheckboxFormField
extends BooleanFormField
and offers a simple HTML checkbox.
ClassNameFormField
","text":"ClassNameFormField
is a text form field that supports additional settings, specific to entering a PHP class name:
classExists($classExists = true)
and getClassExists()
can be used to ensure that the entered class currently exists in the installation. By default, the existance of the entered class is required.implementedInterface($interface)
and getImplementedInterface()
can be used to ensure that the entered class implements the specified interface. By default, no interface is required.parentClass($parentClass)
and getParentClass()
can be used to ensure that the entered class extends the specified class. By default, no parent class is required.instantiable($instantiable = true)
and isInstantiable()
can be used to ensure that the entered class is instantiable. By default, entered classes have to instantiable.Additionally, the default id of a ClassNameFormField
object is className
, the default label is wcf.form.field.className
, and if either an interface or a parent class is required, a default description is set if no description has already been set (wcf.form.field.className.description.interface
and wcf.form.field.className.description.parentClass
, respectively).
DateFormField
","text":"DateFormField
is a form field to enter a date (and optionally a time). The class implements IAttributeFormField
, IAutoFocusFormField
, ICssClassFormField
, IImmutableFormField
, and INullableFormField
. The following methods are specific to this form field class:
earliestDate($earliestDate)
and getEarliestDate()
can be used to get and set the earliest selectable/valid date and latestDate($latestDate)
and getLatestDate()
can be used to get and set the latest selectable/valid date. The date passed to the setters must have the same format as set via saveValueFormat()
. If a custom format is used, that format has to be set via saveValueFormat()
before calling any of the setters.saveValueFormat($saveValueFormat)
and getSaveValueFormat()
can be used to specify the date format of the value returned by getSaveValue()
. By default, U
is used as format. The PHP manual provides an overview of supported formats.supportTime($supportsTime = true)
and supportsTime()
can be used to toggle whether, in addition to a date, a time can also be specified. By default, specifying a time is disabled.DescriptionFormField
","text":"DescriptionFormField
is a multi-line text form field with description
as the default id and wcf.global.description
as the default label.
EmailFormField
","text":"EmailFormField
is a form field to enter an email address which is internally validated using UserUtil::isValidEmail()
. The class implements IAttributeFormField
, IAutoCompleteFormField
, IAutoFocusFormField
, ICssClassFormField
, II18nFormField
, IImmutableFormField
, IInputModeFormField
, IPatternFormField
, and IPlaceholderFormField
.
FloatFormField
","text":"FloatFormField
is an implementation of AbstractNumericFormField for floating point numbers.
HiddenFormField
","text":"HiddenFormField
is a form field without any user-visible UI. Even though the form field is invisible to the user, the value can still be modified by the user, e.g. by leveraging the web browsers developer tools. The HiddenFormField
must not be used to transfer sensitive information or information that the user should not be able to modify.
IconFormField
","text":"IconFormField
is a form field to select a FontAwesome icon.
IntegerFormField
","text":"IntegerFormField
is an implementation of AbstractNumericFormField for integers.
IsDisabledFormField
","text":"IsDisabledFormField
is a boolean form field with isDisabled
as the default id.
ItemListFormField
","text":"ItemListFormField
is a form field in which multiple values can be entered and returned in different formats as save value. The class implements IAttributeFormField
, IAutoFocusFormField
, ICssClassFormField
, IImmutableFormField
, and IMultipleFormField
. The saveValueType($saveValueType)
and getSaveValueType()
methods are specific to this form field class and determine the format of the save value. The following save value types are supported:
ItemListFormField::SAVE_VALUE_TYPE_ARRAY
adds a custom data processor that writes the form field data directly in the parameters array and not in the data sub-array of the parameters array.ItemListFormField::SAVE_VALUE_TYPE_CSV
lets the value be returned as a string in which the values are concatenated by commas.ItemListFormField::SAVE_VALUE_TYPE_NSV
lets the value be returned as a string in which the values are concatenated by \\n
.ItemListFormField::SAVE_VALUE_TYPE_SSV
lets the value be returned as a string in which the values are concatenated by spaces.By default, ItemListFormField::SAVE_VALUE_TYPE_CSV
is used.
If ItemListFormField::SAVE_VALUE_TYPE_ARRAY
is used as save value type, ItemListFormField
objects register a custom form field data processor to add the relevant array into the $parameters
array directly using the object property as the array key.
MultilineTextFormField
","text":"MultilineTextFormField
is a text form field that supports multiple rows of text. The methods rows($rows)
and getRows()
can be used to set and get the number of rows of the textarea
elements. The default number of rows is 10
. These methods do not, however, restrict the number of text rows that can be entered.
MultipleSelectionFormField
","text":"MultipleSelectionFormField
is a form fields that allows the selection of multiple options out of a predefined list of available options. The class implements IAttributeFormField
, ICssClassFormField
, IFilterableSelectionFormField
, and IImmutableFormField
.
RadioButtonFormField
","text":"RadioButtonFormField
is a form fields that allows the selection of a single option out of a predefined list of available options using radiobuttons. The class implements IAttributeFormField
, ICssClassFormField
, IImmutableFormField
, and ISelectionFormField
.
RatingFormField
","text":"RatingFormField
is a form field to set a rating for an object. The class implements IImmutableFormField
, IMaximumFormField
, IMinimumFormField
, and INullableFormField
. Form fields of this class have rating
as their default id, wcf.form.field.rating
as their default label, 1
as their default minimum, and 5
as their default maximum. For this field, the minimum and maximum refer to the minimum and maximum rating an object can get. When the field is shown, there will be maximum() - minimum() + 1
icons be shown with additional CSS classes that can be set and gotten via defaultCssClasses(array $cssClasses)
and getDefaultCssClasses()
. If a rating values is set, the first getValue()
icons will instead use the classes that can be set and gotten via activeCssClasses(array $cssClasses)
and getActiveCssClasses()
. By default, the only default class is fa-star-o
and the active classes are fa-star
and orange
.
ShowOrderFormField
","text":"ShowOrderFormField
is a single selection form field for which the selected value determines the position at which an object is shown. The show order field provides a list of all siblings and the object will be positioned after the selected sibling. To insert objects at the very beginning, the options()
automatically method prepends an additional option for that case so that only the existing siblings need to be passed. The default id of instances of this class is showOrder
and their default label is wcf.form.field.showOrder
.
It is important that the relevant object property is always kept updated. Whenever a new object is added or an existing object is edited or delete, the values of the other objects have to be adjusted to ensure consecutive numbering.
"},{"location":"php/api/form_builder/form_fields/#singleselectionformfield","title":"SingleSelectionFormField
","text":"SingleSelectionFormField
is a form fields that allows the selection of a single option out of a predefined list of available options. The class implements ICssClassFormField
, IFilterableSelectionFormField
, IImmutableFormField
, and INullableFormField
. If the field is nullable and the current form field value is considered empty
by PHP, null
is returned as the save value.
SortOrderFormField
","text":"SingleSelectionFormField
is a single selection form field with default id sortOrder
, default label wcf.global.showOrder
and default options ASC: wcf.global.sortOrder.ascending
and DESC: wcf.global.sortOrder.descending
.
TextFormField
","text":"TextFormField
is a form field that allows entering a single line of text. The class implements IAttributeFormField
, IAutoCompleteFormField
, ICssClassFormField
, IImmutableFormField
, II18nFormField
, IInputModeFormField
, IMaximumLengthFormField
, IMinimumLengthFormField
, IPatternFormField
, and IPlaceholderFormField
.
TitleFormField
","text":"TitleFormField
is a text form field with title
as the default id and wcf.global.title
as the default label.
UrlFormField
","text":"UrlFormField
is a text form field whose values are checked via Url::is()
.
The following form fields are reusable fields that generally are bound to a certain API or DatabaseObject
implementation.
AclFormField
","text":"AclFormField
is used for setting up acl values for specific objects. The class implements IObjectTypeFormField
and requires an object type of the object type definition com.woltlab.wcf.acl
. Additionally, the class provides the methods categoryName($categoryName)
and getCategoryName()
that allow setting a specific name or filter for the acl option categories whose acl options are shown. A category name of null
signals that no category filter is used.
Since version 5.5, the category name also supports filtering using a wildcard like user.*
, see WoltLab/WCF#4355.
AclFormField
objects register a custom form field data processor to add the relevant ACL object type id into the $parameters
array directly using {$objectProperty}_aclObjectTypeID
as the array key. The relevant database object action method is expected, based on the given ACL object type id, to save the ACL option values appropriately.
ButtonFormField
","text":"Only available since version 5.4.
ButtonFormField
shows a submit button as part of the form. The class implements IAttributeFormField
and ICssClassFormField
.
Specifically for this form field, there is the IsNotClickedFormFieldDependency
dependency with which certain parts of the form will only be processed if the relevent button has not clicked.
CaptchaFormField
","text":"CaptchaFormField
is used to add captcha protection to the form.
You must specify a captcha object type (com.woltlab.wcf.captcha
) using the objectType()
method.
ColorFormField
","text":"Only available since version 5.5.
ColorFormField
is used to specify RGBA colors using the rgba(r, g, b, a)
format. The class implements IImmutableFormField
.
ContentLanguageFormField
","text":"ContentLanguageFormField
is used to select the content language of an object. Fields of this class are only available if multilingualism is enabled and if there are content languages. The class implements IImmutableFormField
.
LabelFormField
","text":"LabelFormField
is used to select a label from a specific label group. The class implements IObjectTypeFormNode
.
The labelGroup(ViewableLabelGroup $labelGroup)
and getLabelGroup()
methods are specific to this form field class and can be used to set and get the label group whose labels can be selected. Additionally, there is the static method createFields($objectType, array $labelGroups, $objectProperty = 'labelIDs)
that can be used to create all relevant label form fields for a given list of label groups. In most cases, LabelFormField::createFields()
should be used.
OptionFormField
","text":"OptionFormField
is an item list form field to set a list of options. The class implements IPackagesFormField
and only options of the set packages are considered available. The default label of instances of this class is wcf.form.field.option
and their default id is options
.
SimpleAclFormField
","text":"SimpleAclFormField
is used for setting up simple acl values (one yes
/no
option per user and user group) for specific objects.
SimpleAclFormField
objects register a custom form field data processor to add the relevant simple ACL data array into the $parameters
array directly using the object property as the array key.
Since version 5.5, the field also supports inverted permissions, see WoltLab/WCF#4570.
The SimpleAclFormField
supports inverted permissions, allowing the administrator to grant access to all non-selected users and groups. If this behavior is desired, it needs to be enabled by calling supportInvertedPermissions
. An invertPermissions
key containing a boolean value with the users selection will be provided together with the ACL values when saving the field.
SingleMediaSelectionFormField
","text":"SingleMediaSelectionFormField
is used to select a specific media file. The class implements IImmutableFormField
.
The following methods are specific to this form field class:
imageOnly($imageOnly = true)
and isImageOnly()
can be used to set and check if only images may be selected.getMedia()
returns the media file based on the current field value if a field is set.TagFormField
","text":"TagFormField
is a form field to enter tags. The class implements IAttributeFormField
and IObjectTypeFormNode
. Arrays passed to TagFormField::values()
can contain tag names as strings and Tag
objects. The default label of instances of this class is wcf.tagging.tags
and their default description is wcf.tagging.tags.description
.
TagFormField
objects register a custom form field data processor to add the array with entered tag names into the $parameters
array directly using the object property as the array key.
UploadFormField
","text":"UploadFormField
is a form field that allows uploading files by the user.
UploadFormField
objects register a custom form field data processor to add the array of wcf\\system\\file\\upload\\UploadFile\\UploadFile
into the $parameters
array directly using the object property as the array key. Also it registers the removed files as an array of wcf\\system\\file\\upload\\UploadFile\\UploadFile
into the $parameters
array directly using the object property with the suffix _removedFiles
as the array key.
The field supports additional settings:
imageOnly($imageOnly = true)
and isImageOnly()
can be used to ensure that the uploaded files are only images.allowSvgImage($allowSvgImages = true)
and svgImageAllowed()
can be used to allow SVG images, if the image only mode is enabled (otherwise, the method will throw an exception). By default, SVG images are not allowed.To provide values from a database object, you should implement the method get{$objectProperty}UploadFileLocations()
to your database object class. This method must return an array of strings with the locations of the files.
To process files in the database object action class, you must rename
the file to the final destination. You get the temporary location, by calling the method getLocation()
on the given UploadFile
objects. After that, you call setProcessed($location)
with $location
contains the new file location. This method sets the isProcessed
flag to true and saves the new location. For updating files, it is relevant, whether a given file is already processed or not. For this case, the UploadFile
object has an method isProcessed()
which indicates, whether a file is already processed or new uploaded.
UserFormField
","text":"UserFormField
is a form field to enter existing users. The class implements IAutoCompleteFormField
, IAutoFocusFormField
, IImmutableFormField
, IMultipleFormField
, and INullableFormField
. While the user is presented the names of the specified users in the user interface, the field returns the ids of the users as data. The relevant UserProfile
objects can be accessed via the getUsers()
method.
UserPasswordField
","text":"Only available since version 5.4.
UserPasswordField
is a form field for users' to enter their current password. The class implements IAttributeFormField
, IAttributeFormField
, IAutoCompleteFormField
, IAutoFocusFormField
, and IPlaceholderFormField
UserGroupOptionFormField
","text":"UserGroupOptionFormField
is an item list form field to set a list of user group options/permissions. The class implements IPackagesFormField
and only user group options of the set packages are considered available. The default label of instances of this class is wcf.form.field.userGroupOption
and their default id is permissions
.
UsernameFormField
","text":"UsernameFormField
is used for entering one non-existing username. The class implements IAttributeFormField
, IImmutableFormField
, IMaximumLengthFormField
, IMinimumLengthFormField
, INullableFormField
, and IPlaceholderFormField
. As usernames have a system-wide restriction of a minimum length of 3 and a maximum length of 100 characters, these values are also used as the default value for the field\u2019s minimum and maximum length.
To integrate a wysiwyg editor into a form, you have to create a WysiwygFormContainer
object. This container takes care of creating all necessary form nodes listed below for a wysiwyg editor.
When creating the container object, its id has to be the id of the form field that will manage the actual text.
The following methods are specific to this form container class:
addSettingsNode(IFormChildNode $settingsNode)
and addSettingsNodes(array $settingsNodes)
can be used to add nodes to the settings tab container.attachmentData($objectType, $parentObjectID)
can be used to set the data relevant for attachment support. By default, not attachment data is set, thus attachments are not supported.getAttachmentField()
, getPollContainer()
, getSettingsContainer()
, getSmiliesContainer()
, and getWysiwygField()
can be used to get the different components of the wysiwyg form container once the form has been built.enablePreviewButton($enablePreviewButton)
can be used to set whether the preview button for the message is shown or not. By default, the preview button is shown. This method is only relevant before the form is built. Afterwards, the preview button availability can not be changed.getObjectId()
returns the id of the edited object or 0
if no object is edited.getPreselect()
, preselect($preselect)
can be used to set the value of the wysiwyg tab menu's data-preselect
attribute used to determine which tab is preselected. By default, the preselect is 'true'
which is used to pre-select the first tab.messageObjectType($messageObjectType)
can be used to set the message object type.pollObjectType($pollObjectType)
can be used to set the poll object type. By default, no poll object type is set, thus the poll form field container is not available.supportMentions($supportMentions)
can be used to set if mentions are supported. By default, mentions are not supported. This method is only relevant before the form is built. Afterwards, mention support can only be changed via the wysiwyg form field.supportSmilies($supportSmilies)
can be used to set if smilies are supported. By default, smilies are supported. This method is only relevant before the form is built. Afterwards, smiley availability can only be changed via changing the availability of the smilies form container.WysiwygAttachmentFormField
","text":"WysiwygAttachmentFormField
provides attachment support for a wysiwyg editor via a tab in the menu below the editor. This class should not be used directly but only via WysiwygFormContainer
. The methods attachmentHandler(AttachmentHandler $attachmentHandler)
and getAttachmentHandler()
can be used to set and get the AttachmentHandler
object that is used for uploaded attachments.
WysiwygPollFormContainer
","text":"WysiwygPollFormContainer
provides poll support for a wysiwyg editor via a tab in the menu below the editor. This class should not be used directly but only via WysiwygFormContainer
. WysiwygPollFormContainer
contains all form fields that are required to create polls and requires edited objects to implement IPollContainer
.
The following methods are specific to this form container class:
getEndTimeField()
returns the form field to set the end time of the poll once the form has been built.getIsChangeableField()
returns the form field to set if poll votes can be changed once the form has been built.getIsPublicField()
returns the form field to set if poll results are public once the form has been built.getMaxVotesField()
returns the form field to set the maximum number of votes once the form has been built.getOptionsField()
returns the form field to set the poll options once the form has been built.getQuestionField()
returns the form field to set the poll question once the form has been built.getResultsRequireVoteField()
returns the form field to set if viewing the poll results requires voting once the form has been built.getSortByVotesField()
returns the form field to set if the results are sorted by votes once the form has been built.WysiwygSmileyFormContainer
","text":"WysiwygSmileyFormContainer
provides smiley support for a wysiwyg editor via a tab in the menu below the editor. This class should not be used directly but only via WysiwygFormContainer
. WysiwygSmileyFormContainer
creates a sub-tab for each smiley category.
WysiwygSmileyFormNode
","text":"WysiwygSmileyFormNode
is contains the smilies of a specific category. This class should not be used directly but only via WysiwygSmileyFormContainer
.
The following code creates a WYSIWYG editor component for a message
object property. As smilies are supported by default and an attachment object type is given, the tab menu below the editor has two tabs: \u201cSmilies\u201d and \u201cAttachments\u201d. Additionally, mentions and quotes are supported.
WysiwygFormContainer::create('message')\n ->label('foo.bar.message')\n ->messageObjectType('com.example.foo.bar')\n ->attachmentData('com.example.foo.bar')\n ->supportMentions()\n ->supportQuotes()\n
"},{"location":"php/api/form_builder/form_fields/#wysiwygformfield","title":"WysiwygFormField
","text":"WysiwygFormField
is used for wysiwyg editor form fields. This class should, in general, not be used directly but only via WysiwygFormContainer
. The class implements IAttributeFormField
, IMaximumLengthFormField
, IMinimumLengthFormField
, and IObjectTypeFormNode
and requires an object type of the object type definition com.woltlab.wcf.message
. The following methods are specific to this form field class:
autosaveId($autosaveId)
and getAutosaveId()
can be used enable automatically saving the current editor contents in the browser using the given id. An empty string signals that autosaving is disabled.lastEditTime($lastEditTime)
and getLastEditTime()
can be used to set the last time the contents have been edited and saved so that the JavaScript can determine if the contents stored in the browser are older or newer. 0
signals that no last edit time has been set.supportAttachments($supportAttachments)
and supportsAttachments()
can be used to set and check if the form field supports attachments.
It is not sufficient to simply signal attachment support via these methods for attachments to work. These methods are relevant internally to signal the Javascript code that the editor supports attachments. Actual attachment support is provided by WysiwygAttachmentFormField
.
supportMentions($supportMentions)
and supportsMentions()
can be used to set and check if the form field supports mentions of other users.
WysiwygFormField
objects register a custom form field data processor to add the relevant simple ACL data array into the $parameters
array directly using the object property as the array key.
TWysiwygFormNode
","text":"All form nodes that need to know the id of the WysiwygFormField
field should use TWysiwygFormNode
. This trait provides getWysiwygId()
and wysiwygId($wysiwygId)
to get and set the relevant wysiwyg editor id.
MultipleBoardSelectionFormField
","text":"Only available since version 5.5.
MultipleBoardSelectionFormField
is used to select multiple forums. The class implements IAttributeFormField
, ICssClassFormField
, and IImmutableFormField
.
The field supports additional settings:
boardNodeList(BoardNodeList $boardNodeList): self
and getBoardNodeList(): BoardNodeList
are used to set and get the list of board nodes used to render the board selection. boardNodeList(BoardNodeList $boardNodeList): self
will automatically call readNodeTree()
on the given board node list.categoriesSelectable(bool $categoriesSelectable = true): self
and areCategoriesSelectable(): bool
are used to set and check if the categories in the board node list are selectable. By default, categories are selectable. This option is useful if only actual boards, in which threads can be posted, should be selectable but the categories must still be shown so that the overall forum structure is still properly shown.supportExternalLinks(bool $supportExternalLinks): self
and supportsExternalLinks(): bool
are used to set and check if external links will be shown in the selection list. By default, external links are shown. Like in the example given before, in cases where only actual boards, in which threads can be posted, are relevant, this option allows to exclude external links.The following form fields are specific for certain forms and hardly reusable in other contexts.
"},{"location":"php/api/form_builder/form_fields/#bbcodeattributesformfield","title":"BBCodeAttributesFormField
","text":"DevtoolsProjectExcludedPackagesFormField
is a form field for setting the attributes of a BBCode.
DevtoolsProjectExcludedPackagesFormField
","text":"DevtoolsProjectExcludedPackagesFormField
is a form field for setting the excluded packages of a devtools project.
DevtoolsProjectInstructionsFormField
","text":"DevtoolsProjectExcludedPackagesFormField
is a form field for setting the installation and update instructions of a devtools project.
DevtoolsProjectOptionalPackagesFormField
","text":"DevtoolsProjectExcludedPackagesFormField
is a form field for setting the optional packages of a devtools project.
DevtoolsProjectRequiredPackagesFormField
","text":"DevtoolsProjectExcludedPackagesFormField
is a form field for setting the required packages of a devtools project.
WoltLab Suite includes a powerful way of creating forms: Form Builder. Form builder allows you to easily define all the fields and their constraints and interdependencies within PHP with full IDE support. It will then automatically generate the necessary HTML with full interactivity to render all the fields and also validate the fields\u2019 contents upon submission.
The migration guide for WoltLab Suite Core 5.2 provides some examples of how to migrate existing forms to form builder that can also help in understanding form builder if the old way of creating forms is familiar.
"},{"location":"php/api/form_builder/overview/#form-builder-components","title":"Form Builder Components","text":"Form builder consists of several components that are presented on the following pages:
In general, form builder provides default implementation of interfaces by providing either abstract classes or traits. It is expected that the interfaces are always implemented using these abstract classes and traits! This way, if new methods are added to the interfaces, default implementations can be provided by the abstract classes and traits without causing backwards compatibility problems.
"},{"location":"php/api/form_builder/overview/#abstractformbuilderform","title":"AbstractFormBuilderForm
","text":"To make using form builder easier, AbstractFormBuilderForm
extends AbstractForm
and provides most of the code needed to set up a form (of course without specific fields, those have to be added by the concrete form class), like reading and validating form values and using a database object action to use the form data to create or update a database object.
In addition to the existing methods inherited by AbstractForm
, AbstractFormBuilderForm
provides the following methods:
buildForm()
builds the form in the following steps:
Call AbtractFormBuilderForm::createForm()
to create the IFormDocument
object and add the form fields.
IFormDocument::build()
to build the form.AbtractFormBuilderForm::finalizeForm()
to finalize the form like adding dependencies.Additionally, between steps 1 and 2 and after step 3, the method provides two events, createForm
and buildForm
to allow plugins to register event listeners to execute additional code at the right point in time. - createForm()
creates the FormDocument
object and sets the form mode. Classes extending AbstractFormBuilderForm
have to override this method (and call parent::createForm()
as the first line in the overridden method) to add concrete form containers and form fields to the bare form document. - finalizeForm()
is called after the form has been built and the complete form hierarchy has been established. This method should be overridden to add dependencies, for example. - setFormAction()
is called at the end of readData()
and sets the form document\u2019s action based on the controller class name and whether an object is currently edited. - If an object is edited, at the beginning of readData()
, setFormObjectData()
is called which calls IFormDocument::loadValuesFromObject()
. If values need to be loaded from additional sources, this method should be used for that.
AbstractFormBuilderForm
also provides the following (public) properties:
$form
contains the IFormDocument
object created in createForm()
.$formAction
is either create
(default) or edit
and handles which method of the database object is called by default (create
is called for $formAction = 'create'
and update
is called for $formAction = 'edit'
) and is used to set the value of the action
template variable.$formObject
contains the IStorableObject
if the form is used to edit an existing object. For forms used to create objects, $formObject
is always null
. Edit forms have to manually identify the edited object based on the request data and set the value of $formObject
. $objectActionName
can be used to set an alternative action to be executed by the database object action that deviates from the default action determined by the value of $formAction
.$objectActionClass
is the name of the database object action class that is used to create or update the database object.DialogFormDocument
","text":"Form builder forms can also be used in dialogs. For such forms, DialogFormDocument
should be used which provides the additional methods cancelable($cancelable = true)
and isCancelable()
to set and check if the dialog can be canceled. If a dialog form can be canceled, a cancel button is added.
If the dialog form is fetched via an AJAX request, IFormDocument::ajax()
has to be called. AJAX forms are registered with WoltLabSuite/Core/Form/Builder/Manager
which also supports getting all of the data of a form via the getData(formId)
function. The getData()
function relies on all form fields creating and registering a WoltLabSuite/Core/Form/Builder/Field/Field
object that provides the data of a specific field.
To make it as easy as possible to work with AJAX forms in dialogs, WoltLabSuite/Core/Form/Builder/Dialog
(abbreviated as FormBuilderDialog
from now on) should generally be used instead of WoltLabSuite/Core/Form/Builder/Manager
directly. The constructor of FormBuilderDialog
expects the following parameters:
dialogId
: id of the dialog elementclassName
: PHP class used to get the form dialog (and save the data if options.submitActionName
is set)actionName
: name of the action/method of className
that returns the dialog; the method is expected to return an array with formId
containg the id of the returned form and dialog
containing the rendered form dialogoptions
: additional options:actionParameters
(default: empty): additional parameters sent during AJAX requestsdestroyOnClose
(default: false
): if true
, whenever the dialog is closed, the form is destroyed so that a new form is fetched if the dialog is opened againdialog
: additional dialog options used as options
during dialog setuponSubmit
: callback when the form is submitted (takes precedence over submitActionName
)submitActionName
(default: not set): name of the action/method of className
called when the form is submittedThe three public functions of FormBuilderDialog
are:
destroy()
destroys the dialog, the form, and all of the form fields.getData()
returns a Promise that returns the form data.open()
opens the dialog.Example:
require(['WoltLabSuite/Core/Form/Builder/Dialog'], function(FormBuilderDialog) {\nvar dialog = new FormBuilderDialog(\n'testDialog',\n'wcf\\\\data\\\\test\\\\TestAction',\n'getDialog',\n{\ndestroyOnClose: true,\ndialog: {\ntitle: 'Test Dialog'\n},\nsubmitActionName: 'saveDialog'\n}\n);\n\nelById('testDialogButton').addEventListener('click', function() {\ndialog.open();\n});\n});\n
"},{"location":"php/api/form_builder/structure/","title":"Structure of Form Builder","text":"Forms built with form builder consist of three major structural elements listed from top to bottom:
The basis for all three elements are form nodes.
The form builder API uses fluent interfaces heavily, meaning that unless a method is a getter, it generally returns the objects itself to support method chaining.
"},{"location":"php/api/form_builder/structure/#form-nodes","title":"Form Nodes","text":"IFormNode
is the base interface that any node of a form has to implement.IFormChildNode
extends IFormNode
for such elements of a form that can be a child node to a parent node.IFormParentNode
extends IFormNode
for such elements of a form that can be a parent to child nodes.IFormElement
extends IFormNode
for such elements of a form that can have a description and a label.IFormNode
","text":"IFormNode
is the base interface that any node of a form has to implement and it requires the following methods:
addClass($class)
, addClasses(array $classes)
, removeClass($class)
, getClasses()
, and hasClass($class)
add, remove, get, and check for CSS classes of the HTML element representing the form node. If the form node consists of multiple (nested) HTML elements, the classes are generally added to the top element. static validateClass($class)
is used to check if a given CSS class is valid. By default, a form node has no CSS classes.addDependency(IFormFieldDependency $dependency)
, removeDependency($dependencyId)
, getDependencies()
, and hasDependency($dependencyId)
add, remove, get, and check for dependencies of this form node on other form fields. checkDependencies()
checks if all of the node\u2019s dependencies are met and returns a boolean value reflecting the check\u2019s result. The form builder dependency documentation provides more detailed information about dependencies and how they work. By default, a form node has no dependencies.attribute($name, $value = null)
, removeAttribute($name)
, getAttribute($name)
, getAttributes()
, hasAttribute($name)
add, remove, get, and check for attributes of the HTML element represting the form node. The attributes are added to the same element that the CSS classes are added to. static validateAttribute($name)
is used to check if a given attribute is valid. By default, a form node has no attributes.available($available = true)
and isAvailable()
can be used to set and check if the node is available. The availability functionality can be used to easily toggle form nodes based, for example, on options without having to create a condition to append the relevant. This way of checking availability makes it easier to set up forms. By default, every form node is available.The following aspects are important when working with availability:
Availability sets the static availability for form nodes that does not change during the lifetime of a form. In contrast, dependencies represent a dynamic availability for form nodes that depends on the current value of certain form fields. - cleanup()
is called after the whole form is not used anymore to reset other APIs if the form fields depends on them and they expect such a reset. This method is not intended to clean up the form field\u2019s value as a new form document object is created to show a clean form. - getDocument()
returns the IFormDocument
object the node belongs to. (As IFormDocument
extends IFormNode
, form document objects simply return themselves.) - getHtml()
returns the HTML representation of the node. getHtmlVariables()
return template variables (in addition to the form node itself) to render the node\u2019s HTML representation. - id($id)
and getId()
set and get the id of the form node. Every id has to be unique within a form. getPrefixedId()
returns the prefixed version of the node\u2019s id (see IFormDocument::getPrefix()
and IFormDocument::prefix()
). static validateId($id)
is used to check if a given id is valid. - populate()
is called by IFormDocument::build()
after all form nodes have been added. This method should finilize the initialization of the form node after all parent-child relations of the form document have been established. This method is needed because during the construction of a form node, it neither knows the form document it will belong to nor does it know its parent. - validate()
checks, after the form is submitted, if the form node is valid. A form node with children is valid if all of its child nodes are valid. A form field is valid if its value is valid. - static create($id)
is the factory method that has to be used to create new form nodes with the given id.
TFormNode
provides a default implementation of most of these methods.
IFormChildNode
","text":"IFormChildNode
extends IFormNode
for such elements of a form that can be a child node to a parent node and it requires the parent(IFormParentNode $parentNode)
and getParent()
methods used to set and get the node\u2019s parent node. TFormChildNode
provides a default implementation of these two methods and also of IFormNode::getDocument()
.
IFormParentNode
","text":"IFormParentNode
extends IFormNode
for such elements of a form that can be a parent to child nodes. Additionally, the interface also extends \\Countable
and \\RecursiveIterator
. The interface requires the following methods:
appendChild(IFormChildNode $child)
, appendChildren(array $children)
, insertAfter(IFormChildNode $child, $referenceNodeId)
, and insertBefore(IFormChildNode $child, $referenceNodeId)
are used to insert new children either at the end or at specific positions. validateChild(IFormChildNode $child)
is used to check if a given child node can be added. A child node cannot be added if it would cause an id to be used twice.children()
returns the direct children of a form node.getIterator()
return a recursive iterator for a form node.getNodeById($nodeId)
returns the node with the given id by searching for it in the node\u2019s children and recursively in all of their children. contains($nodeId)
can be used to simply check if a node with the given id exists.hasValidationErrors()
checks if a form node or any of its children has a validation error (see IFormField::getValidationErrors()
).readValues()
recursively calls IFormParentNode::readValues()
and IFormField::readValue()
on its children.IFormElement
","text":"IFormElement
extends IFormNode
for such elements of a form that can have a description and a label and it requires the following methods:
label($languageItem = null, array $variables = [])
and getLabel()
can be used to set and get the label of the form element. requiresLabel()
can be checked if the form element requires a label. A label-less form element that requires a label will prevent the form from being rendered by throwing an exception.description($languageItem = null, array $variables = [])
and getDescription()
can be used to set and get the description of the form element.IObjectTypeFormNode
","text":"IObjectTypeFormField
has to be implemented by form nodes that rely on a object type of a specific object type definition in order to function. The implementing class has to implement the methods objectType($objectType)
, getObjectType()
, and getObjectTypeDefinition()
. TObjectTypeFormNode
provides a default implementation of these three methods.
CustomFormNode
","text":"CustomFormNode
is a form node whose contents can be set directly via content($content)
.
This class should generally not be relied on. Instead, TemplateFormNode
should be used.
TemplateFormNode
","text":"TemplateFormNode
is a form node whose contents are read from a template. TemplateFormNode
has the following additional methods:
application($application)
and getApplicaton()
can be used to set and get the abbreviation of the application the shown template belongs to. If no template has been set explicitly, getApplicaton()
returns wcf
.templateName($templateName)
and getTemplateName()
can be used to set and get the name of the template containing the node contents. If no template has been set and the node is rendered, an exception will be thrown.variables(array $variables)
and getVariables()
can be used to set and get additional variables passed to the template.A form document object represents the form as a whole and has to implement the IFormDocument
interface. WoltLab Suite provides a default implementation with the FormDocument
class. IFormDocument
should not be implemented directly but instead FormDocument
should be extended to avoid issues if the IFormDocument
interface changes in the future.
IFormDocument
extends IFormParentNode
and requires the following additional methods:
action($action)
and getAction()
can be used set and get the action
attribute of the <form>
HTML element.addButton(IFormButton $button)
and getButtons()
can be used add and get form buttons that are shown at the bottom of the form. addDefaultButton($addDefaultButton)
and hasDefaultButton()
can be used to set and check if the form has the default button which is added by default unless specified otherwise. Each implementing class may define its own default button. FormDocument
has a button with id submitButton
, label wcf.global.button.submit
, access key s
, and CSS class buttonPrimary
as its default button. ajax($ajax)
and isAjax()
can be used to set and check if the form document is requested via an AJAX request or processes data via an AJAX request. These methods are helpful for form fields that behave differently when providing data via AJAX.build()
has to be called once after all nodes have been added to this document to trigger IFormNode::populate()
.formMode($formMode)
and getFormMode()
sets the form mode. Possible form modes are:
IFormDocument::FORM_MODE_CREATE
has to be used when the form is used to create a new object.
IFormDocument::FORM_MODE_UPDATE
has to be used when the form is used to edit an existing object.getData()
returns the array containing the form data and which is passed as the $parameters
argument of the constructor of a database object action object.getDataHandler()
returns the data handler for this document that is used to process the field data into a parameters array for the constructor of a database object action object.getEnctype()
returns the encoding type of the form. If the form contains a IFileFormField
, multipart/form-data
is returned, otherwise null
is returned.loadValues(array $data, IStorableObject $object)
is used when editing an existing object to set the form field values by calling IFormField::loadValue()
for all form fields. Additionally, the form mode is set to IFormDocument::FORM_MODE_UPDATE
.markRequiredFields(bool $markRequiredFields = true): self
and marksRequiredFields(): bool
can be used to set and check whether fields that are required are marked (with an asterisk in the label) in the output.method($method)
and getMethod()
can be used to set and get the method
attribute of the <form>
HTML element. By default, the method is post
.prefix($prefix)
and getPrefix()
can be used to set and get a global form prefix that is prepended to form elements\u2019 names and ids to avoid conflicts with other forms. By default, the prefix is an empty string. If a prefix of foo
is set, getPrefix()
returns foo_
(additional trailing underscore).requestData(array $requestData)
, getRequestData($index = null)
, and hasRequestData($index = null)
can be used to set, get and check for specific request data. In most cases, the relevant request data is the $_POST
array. In default AJAX requests handled by database object actions, however, the request data generally is in AbstractDatabaseObjectAction::$parameters
. By default, $_POST
is the request data.The last aspect is relevant for DialogFormDocument
objects. DialogFormDocument
is a specialized class for forms in dialogs that, in contrast to FormDocument
do not require an action
to be set. Additionally, DialogFormDocument
provides the cancelable($cancelable = true)
and isCancelable()
methods used to determine if the dialog from can be canceled. By default, dialog forms are cancelable.
A form button object represents a button shown at the end of the form that, for example, submits the form. Every form button has to implement the IFormButton
interface that extends IFormChildNode
and IFormElement
. IFormButton
requires four methods to be implemented:
accessKey($accessKey)
and getAccessKey()
can be used to set and get the access key with which the form button can be activated. By default, form buttons have no access key set.submit($submitButton)
and isSubmit()
can be used to set and check if the form button is a submit button. A submit button is an input[type=submit]
element. Otherwise, the button is a button
element. A form container object represents a container for other form containers or form field directly. Every form container has to implement the IFormContainer
interface which requires the following method:
loadValues(array $data, IStorableObject $object)
is called by IFormDocument::loadValuesFromObject()
to inform the container that object data is loaded. This method is not intended to generally call IFormField::loadValues()
on its form field children as these methods are already called by IFormDocument::loadValuesFromObject()
. This method is intended for specialized form containers with more complex logic.There are multiple default container implementations:
FormContainer
is the default implementation of IFormContainer
.TabMenuFormContainer
represents the container of tab menu, whileTabFormContainer
represents a tab of a tab menu andTabTabMenuFormContainer
represents a tab of a tab menu that itself contains a tab menu.RowFormContainer
are shown in a row and should use col-*
classes.RowFormFieldContainer
are also shown in a row but does not show the labels and descriptions of the individual form fields. Instead of the individual labels and descriptions, the container's label and description is shown and both span all of fields.SuffixFormFieldContainer
can be used for one form field with a second selection form field used as a suffix.The methods of the interfaces that FormContainer
is implementing are well documented, but here is a short overview of the most important methods when setting up a form or extending a form with an event listener:
appendChild(IFormChildNode $child)
, appendChildren(array $children)
, and insertBefore(IFormChildNode $child, $referenceNodeId)
are used to insert new children into the form container.description($languageItem = null, array $variables = [])
and label($languageItem = null, array $variables = [])
are used to set the description and the label or title of the form container.A form field object represents a concrete form field that allows entering data. Every form field has to implement the IFormField
interface which extends IFormChildNode
and IFormElement
.
IFormField
requires the following additional methods:
addValidationError(IFormFieldValidationError $error)
and getValidationErrors()
can be used to get and set validation errors of the form field (see form validation).addValidator(IFormFieldValidator $validator)
, getValidators()
, removeValidator($validatorId)
, and hasValidator($validatorId)
can be used to get, set, remove, and check for validators for the form field (see form validation).getFieldHtml()
returns the field's HTML output without the surrounding dl
structure.objectProperty($objectProperty)
and getObjectProperty()
can be used to get and set the object property that the field represents. When setting the object property is set to an empty string, the previously set object property is unset. If no object property has been set, the field\u2019s (non-prefixed) id is returned.The object property allows having different fields (requiring different ids) that represent the same object property which is handy when available options of the field\u2019s value depend on another field. Having object property allows to define different fields for each value of the other field and to use form field dependencies to only show the appropriate field. - readValue()
reads the form field value from the request data after the form is submitted. - required($required = true)
and isRequired()
can be used to determine if the form field has to be filled out. By default, form fields do not have to be filled out. - value($value)
and getSaveValue()
can be used to get and set the value of the form field to be used outside of the context of forms. getValue()
, in contrast, returns the internal representation of the form field\u2019s value. In general, the internal representation is only relevant when validating the value in additional validators. loadValue(array $data, IStorableObject $object)
extracts the form field value from the given data array (and additional, non-editable data from the object if the field needs them).
AbstractFormField
provides default implementations of many of the listed methods above and should be extended instead of implementing IFormField
directly.
An overview of the form fields provided by default can be found here.
"},{"location":"php/api/form_builder/structure/#form-field-interfaces-and-traits","title":"Form Field Interfaces and Traits","text":"WoltLab Suite Core provides a variety of interfaces and matching traits with default implementations for several common features of form fields:
"},{"location":"php/api/form_builder/structure/#iattributeformfield","title":"IAttributeFormField
","text":"Only available since version 5.4.
IAttributeFormField
has to be implemented by form fields for which attributes can be added to the actual form element (in addition to adding attributes to the surrounding element via the attribute-related methods of IFormNode
). The implementing class has to implement the methods fieldAttribute(string $name, string $value = null): self
and getFieldAttribute(string $name): self
/getFieldAttributes(): array
, which are used to add and get the attributes, respectively. Additionally, hasFieldAttribute(string $name): bool
has to implemented to check if a certain attribute is present, removeFieldAttribute(string $name): self
to remove an attribute, and static validateFieldAttribute(string $name)
to check if the attribute is valid for this specific class. TAttributeFormField
provides a default implementation of these methods and TInputAttributeFormField
specializes the trait for input
-based form fields. These two traits also ensure that if a specific interface that handles a specific attribute is implemented, like IAutoCompleteFormField
handling autocomplete
, this attribute cannot be set with this API. Instead, the dedicated API provided by the relevant interface has to be used.
IAutoCompleteFormField
","text":"Only available since version 5.4.
IAutoCompleteFormField
has to be implemented by form fields that support the autocomplete
attribute. The implementing class has to implement the methods autoComplete(?string $autoComplete): self
and getAutoComplete(): ?string
, which are used to set and get the autocomplete value, respectively. TAutoCompleteFormField
provides a default implementation of these two methods and TTextAutoCompleteFormField
specializes the trait for text form fields. When using TAutoCompleteFormField
, you have to implement the getValidAutoCompleteTokens(): array
method which returns all valid autocomplete
tokens.
IAutoFocusFormField
","text":"IAutoFocusFormField
has to be implemented by form fields that can be auto-focused. The implementing class has to implement the methods autoFocus($autoFocus = true)
and isAutoFocused()
. By default, form fields are not auto-focused. TAutoFocusFormField
provides a default implementation of these two methods.
ICssClassFormField
","text":"Only available since version 5.4.
ICssClassFormField
has to be implemented by form fields for which CSS classes can be added to the actual form element (in addition to adding CSS classes to the surrounding element via the class-related methods of IFormNode
). The implementing class has to implement the methods addFieldClass(string $class): self
/addFieldClasses(array $classes): self
and getFieldClasses(): array
, which are used to add and get the CSS classes, respectively. Additionally, hasFieldClass(string $class): bool
has to implemented to check if a certain CSS class is present and removeFieldClass(string $class): self
to remove a CSS class. TCssClassFormField
provides a default implementation of these methods.
IFileFormField
","text":"IFileFormField
has to be implemented by every form field that uploads files so that the enctype
attribute of the form document is multipart/form-data
(see IFormDocument::getEnctype()
).
IFilterableSelectionFormField
","text":"IFilterableSelectionFormField
extends ISelectionFormField
by the possibilty for users when selecting the value(s) to filter the list of available options. The implementing class has to implement the methods filterable($filterable = true)
and isFilterable()
. TFilterableSelectionFormField
provides a default implementation of these two methods.
II18nFormField
","text":"II18nFormField
has to be implemented by form fields if the form field value can be entered separately for all available languages. The implementing class has to implement the following methods:
i18n($i18n = true)
and isI18n()
can be used to set whether a specific instance of the class actually supports multilingual input.i18nRequired($i18nRequired = true)
and isI18nRequired()
can be used to set whether a specific instance of the class requires separate values for all languages.languageItemPattern($pattern)
and getLanguageItemPattern()
can be used to set the pattern/regular expression for the language item used to save the multilingual values.hasI18nValues()
and hasPlainValue()
check if the current value is a multilingual or monolingual value.TI18nFormField
provides a default implementation of these eight methods and additional default implementations of some of the IFormField
methods. If multilingual input is enabled for a specific form field, classes using TI18nFormField
register a custom form field data processor to add the array with multilingual input into the $parameters
array directly using {$objectProperty}_i18n
as the array key. If multilingual input is enabled but only a monolingual value is entered, the custom form field data processor does nothing and the form field\u2019s value is added by the DefaultFormDataProcessor
into the data
sub-array of the $parameters
array.
TI18nFormField
already provides a default implementation of IFormField::validate()
.
IImmutableFormField
","text":"IImmutableFormField
has to be implemented by form fields that support being displayed but whose value cannot be changed. The implementing class has to implement the methods immutable($immutable = true)
and isImmutable()
that can be used to determine if the value of the form field is mutable or immutable. By default, form field are mutable.
IInputModeFormField
","text":"Only available since version 5.4.
IInputModeFormField
has to be implemented by form fields that support the inputmode
attribute. The implementing class has to implement the methods inputMode(?string $inputMode): self
and getInputMode(): ?string
, which are used to set and get the input mode, respectively. TInputModeFormField
provides a default implementation of these two methods.
IMaximumFormField
","text":"IMaximumFormField
has to be implemented by form fields if the entered value must have a maximum value. The implementing class has to implement the methods maximum($maximum = null)
and getMaximum()
. A maximum of null
signals that no maximum value has been set. TMaximumFormField
provides a default implementation of these two methods.
The implementing class has to validate the entered value against the maximum value manually.
"},{"location":"php/api/form_builder/structure/#imaximumlengthformfield","title":"IMaximumLengthFormField
","text":"IMaximumLengthFormField
has to be implemented by form fields if the entered value must have a maximum length. The implementing class has to implement the methods maximumLength($maximumLength = null)
, getMaximumLength()
, and validateMaximumLength($text, Language $language = null)
. A maximum length of null
signals that no maximum length has been set. TMaximumLengthFormField
provides a default implementation of these two methods.
The implementing class has to validate the entered value against the maximum value manually by calling validateMaximumLength()
.
IMinimumFormField
","text":"IMinimumFormField
has to be implemented by form fields if the entered value must have a minimum value. The implementing class has to implement the methods minimum($minimum = null)
and getMinimum()
. A minimum of null
signals that no minimum value has been set. TMinimumFormField
provides a default implementation of these three methods.
The implementing class has to validate the entered value against the minimum value manually.
"},{"location":"php/api/form_builder/structure/#iminimumlengthformfield","title":"IMinimumLengthFormField
","text":"IMinimumLengthFormField
has to be implemented by form fields if the entered value must have a minimum length. The implementing class has to implement the methods minimumLength($minimumLength = null)
, getMinimumLength()
, and validateMinimumLength($text, Language $language = null)
. A minimum length of null
signals that no minimum length has been set. TMinimumLengthFormField
provides a default implementation of these three methods.
The implementing class has to validate the entered value against the minimum value manually by calling validateMinimumLength()
.
IMultipleFormField
","text":"IMinimumLengthFormField
has to be implemented by form fields that support selecting or setting multiple values. The implementing class has to implement the following methods:
multiple($multiple = true)
and allowsMultiple()
can be used to set whether a specific instance of the class actually should support multiple values. By default, multiple values are not supported.minimumMultiples($minimum)
and getMinimumMultiples()
can be used to set the minimum number of values that have to be selected/entered. By default, there is no required minimum number of values.maximumMultiples($minimum)
and getMaximumMultiples()
can be used to set the maximum number of values that have to be selected/entered. By default, there is no maximum number of values. IMultipleFormField::NO_MAXIMUM_MULTIPLES
is returned if no maximum number of values has been set and it can also be used to unset a previously set maximum number of values.TMultipleFormField
provides a default implementation of these six methods and classes using TMultipleFormField
register a custom form field data processor to add the HtmlInputProcessor
object with the text into the $parameters
array directly using {$objectProperty}_htmlInputProcessor
as the array key.
The implementing class has to validate the values against the minimum and maximum number of values manually.
"},{"location":"php/api/form_builder/structure/#inullableformfield","title":"INullableFormField
","text":"INullableFormField
has to be implemented by form fields that support null
as their (empty) value. The implementing class has to implement the methods nullable($nullable = true)
and isNullable()
. TNullableFormField
provides a default implementation of these two methods.
null
should be returned by IFormField::getSaveValue()
is the field is considered empty and the form field has been set as nullable.
IPackagesFormField
","text":"IPackagesFormField
has to be implemented by form fields that, in some way, considers packages whose ids may be passed to the field object. The implementing class has to implement the methods packageIDs(array $packageIDs)
and getPackageIDs()
. TPackagesFormField
provides a default implementation of these two methods.
IPatternFormField
","text":"Only available since version 5.4.
IPatternFormField
has to be implemented by form fields that support the pattern
attribute. The implementing class has to implement the methods pattern(?string $pattern): self
and getPattern(): ?string
, which are used to set and get the pattern, respectively. TPatternFormField
provides a default implementation of these two methods.
IPlaceholderFormField
","text":"IPlaceholderFormField
has to be implemented by form fields that support a placeholder value for empty fields. The implementing class has to implement the methods placeholder($languageItem = null, array $variables = [])
and getPlaceholder()
. TPlaceholderFormField
provides a default implementation of these two methods.
ISelectionFormField
","text":"ISelectionFormField
has to be implemented by form fields with a predefined set of possible values. The implementing class has to implement the getter and setter methods options($options, $nestedOptions = false, $labelLanguageItems = true)
and getOptions()
and additionally two methods related to nesting, i.e. whether the selectable options have a hierarchy: supportsNestedOptions()
and getNestedOptions()
. TSelectionFormField
provides a default implementation of these four methods.
ISuffixedFormField
","text":"ISuffixedFormField
has to be implemented by form fields that support supports displaying a suffix behind the actual input field. The implementing class has to implement the methods suffix($languageItem = null, array $variables = [])
and getSuffix()
. TSuffixedFormField
provides a default implementation of these two methods.
TDefaultIdFormField
","text":"Form fields that have a default id have to use TDefaultIdFormField
and have to implement the method getDefaultId()
.
The only thing to do in a template to display the whole form including all of the necessary JavaScript is to put
{@$form->getHtml()}\n
into the template file at the relevant position.
"},{"location":"php/api/form_builder/validation_data/","title":"Form Validation and Form Data","text":""},{"location":"php/api/form_builder/validation_data/#form-validation","title":"Form Validation","text":"Every form field class has to implement IFormField::validate()
according to their internal logic of what constitutes a valid value. If a certain constraint for the value is not met, a form field validation error object is added to the form field. Form field validation error classes have to implement the interface IFormFieldValidationError
.
In addition to intrinsic validations like checking the length of the value of a text form field, in many cases, there are additional constraints specific to the form like ensuring that the text is not already used by a different object of the same database object class. Such additional validations can be added to (and removed from) the form field via implementations of the IFormFieldValidator
interface.
IFormFieldValidationError
/ FormFieldValidationError
","text":"IFormFieldValidationError
requires the following methods:
__construct($type, $languageItem = null, array $information = [])
creates a new validation error object for an error with the given type and message stored in the given language items. The information array is used when generating the error message.getHtml()
returns the HTML element representing the error that is shown to the user.getMessage()
returns the error message based on the language item and information array given in the constructor.getInformation()
and getType()
are getters for the first and third parameter of the constructor.FormFieldValidationError
is a default implementation of the interface that shows the error in an small.innerError
HTML element below the form field.
Form field validation errors are added to form fields via the IFormField::addValidationError(IFormFieldValidationError $error)
method.
IFormFieldValidator
/ FormFieldValidator
","text":"IFormFieldValidator
requires the following methods:
__construct($id, callable $validator)
creates a new validator with the given id that passes the validated form field to the given callable that does the actual validation. static validateId($id)
is used to check if the given id is valid.__invoke(IFormField $field)
is used when the form field is validated to execute the validator.getId()
returns the id of the validator.FormFieldValidator
is a default implementation of the interface.
Form field validators are added to form fields via the addValidator(IFormFieldValidator $validator)
method.
The following source code adds a validator that validates whether the value in the input field matches a specific value.
$container->appendChildren([\n FooField::create('a')\n ->addValidator(new FormFieldValidator('b', function (FooField $formField) {\n if ($formField->getValue() != 'value') {\n $formField->addValidationError(\n new FormFieldValidationError(\n 'type',\n 'phrase'\n )\n );\n }\n })),\n]);\n
"},{"location":"php/api/form_builder/validation_data/#form-data","title":"Form Data","text":"After a form is successfully validated, the data of the form fields (returned by IFormDocument::getData()
) have to be extracted which is the job of the IFormDataHandler
object returned by IFormDocument::getDataHandler()
. Form data handlers themselves, however, are only iterating through all IFormDataProcessor
instances that have been registered with the data handler.
IFormDataHandler
/ FormDataHandler
","text":"IFormDataHandler
requires the following methods:
addProcessor(IFormDataProcessor $processor)
adds a new data processor to the data handler.getFormData(IFormDocument $document)
returns the data of the given form by applying all registered data handlers on the form.getObjectData(IFormDocument $document, IStorableObject $object)
returns the data of the given object which will be used to populate the form field values of the given form.FormDataHandler
is the default implementation of this interface and should also be extended instead of implementing the interface directly.
IFormDataProcessor
/ DefaultFormDataProcessor
","text":"IFormDataProcessor
requires the following methods:
processFormData(IFormDocument $document, array $parameters)
is called by IFormDataHandler::getFormData()
. The method processes the given parameters array and returns the processed version.processObjectData(IFormDocument $document, array $data, IStorableObject $object)
is called by IFormDataHandler::getObjectData()
. The method processes the given object data array and returns the processed version.When FormDocument
creates its FormDataHandler
instance, it automatically registers an DefaultFormDataProcessor
object as the first data processor. DefaultFormDataProcessor
puts the save value of all form fields that are available and have a save value into $parameters['data']
using the form field\u2019s object property as the array key.
IFormDataProcessor
should not be implemented directly. Instead, AbstractFormDataProcessor
should be extended.
All form data is put into the data
sub-array so that the whole $parameters
array can be passed to a database object action object that requires the actual database object data to be in the data
sub-array.
When adding a data processor to a form, make sure to add the data processor after the form has been built.
"},{"location":"php/api/form_builder/validation_data/#additional-data-processors","title":"Additional Data Processors","text":""},{"location":"php/api/form_builder/validation_data/#customformdataprocessor","title":"CustomFormDataProcessor
","text":"As mentioned above, the data in the data
sub-array is intended to directly create or update the database object with. As these values are used in the database query directly, these values cannot contain arrays. Several form fields, however, store and return their data in form of arrays. Thus, this data cannot be returned by IFormField::getSaveValue()
so that IFormField::hasSaveValue()
returns false
and the form field\u2019s data is not collected by the standard DefaultFormDataProcessor
object.
Instead, such form fields register a CustomFormDataProcessor
in their IFormField::populate()
method that inserts the form field value into the $parameters
array directly. This way, the relevant database object action method has access to the data to save it appropriately.
The constructor of CustomFormDataProcessor
requires an id (that is primarily used in error messages during the validation of the second parameter) and callables for IFormDataProcessor::processFormData()
and IFormDataProcessor::processObjectData()
which are passed the same parameters as the IFormDataProcessor
methods. Only one of the callables has to be given, the other one then defaults to simply returning the relevant array unchanged.
VoidFormDataProcessor
","text":"Some form fields might only exist to toggle the visibility of other form fields (via dependencies) but the data of form field itself is irrelevant. As DefaultFormDataProcessor
collects the data of all form fields, an additional data processor in the form of a VoidFormDataProcessor
can be added whose constructor __construct($property, $isDataProperty = true)
requires the name of the relevant object property/form id and whether the form field value is stored in the data
sub-array or directory in the $parameters
array. When the data processor is invoked, it checks whether the relevant entry in the $parameters
array exists and voids it by removing it from the array.
In this tutorial series, we will code a package that allows administrators to create a registry of people. In this context, \"people\" does not refer to users registered on the website but anybody living, dead or fictional.
We will start this tutorial series by creating a base structure for the package and then continue by adding further features step by step using different APIs. Note that in the context of this example, not every added feature might make perfect sense but the goal of this tutorial is not to create a useful package but to introduce you to WoltLab Suite.
In the first part of this tutorial series, we will lay out what the basic version of package should be able to do and how to implement these functions.
"},{"location":"tutorial/series/part_1/#package-functionality","title":"Package Functionality","text":"The package should provide the following possibilities/functions:
We will use the following package installation plugins:
use database objects, create pages and use templates.
"},{"location":"tutorial/series/part_1/#package-structure","title":"Package Structure","text":"The package will have the following file structure:
\u251c\u2500\u2500 acpMenu.xml\n\u251c\u2500\u2500 acptemplates\n\u2502 \u251c\u2500\u2500 personAdd.tpl\n\u2502 \u2514\u2500\u2500 personList.tpl\n\u251c\u2500\u2500 files\n\u2502 \u251c\u2500\u2500 acp\n\u2502 \u2502 \u2514\u2500\u2500 database\n\u2502 \u2502 \u2514\u2500\u2500 install_com.woltlab.wcf.people.php\n\u2502 \u2514\u2500\u2500 lib\n\u2502 \u251c\u2500\u2500 acp\n\u2502 \u2502 \u251c\u2500\u2500 form\n\u2502 \u2502 \u2502 \u251c\u2500\u2500 PersonAddForm.class.php\n\u2502 \u2502 \u2502 \u2514\u2500\u2500 PersonEditForm.class.php\n\u2502 \u2502 \u2514\u2500\u2500 page\n\u2502 \u2502 \u2514\u2500\u2500 PersonListPage.class.php\n\u2502 \u251c\u2500\u2500 data\n\u2502 \u2502 \u2514\u2500\u2500 person\n\u2502 \u2502 \u251c\u2500\u2500 Person.class.php\n\u2502 \u2502 \u251c\u2500\u2500 PersonAction.class.php\n\u2502 \u2502 \u251c\u2500\u2500 PersonEditor.class.php\n\u2502 \u2502 \u2514\u2500\u2500 PersonList.class.php\n\u2502 \u2514\u2500\u2500 page\n\u2502 \u2514\u2500\u2500 PersonListPage.class.php\n\u251c\u2500\u2500 language\n\u2502 \u251c\u2500\u2500 de.xml\n\u2502 \u2514\u2500\u2500 en.xml\n\u251c\u2500\u2500 menuItem.xml\n\u251c\u2500\u2500 package.xml\n\u251c\u2500\u2500 page.xml\n\u251c\u2500\u2500 templates\n\u2502 \u2514\u2500\u2500 personList.tpl\n\u2514\u2500\u2500 userGroupOption.xml\n
"},{"location":"tutorial/series/part_1/#person-modeling","title":"Person Modeling","text":""},{"location":"tutorial/series/part_1/#database-table","title":"Database Table","text":"As the first step, we have to model the people we want to manage with this package. As this is only an introductory tutorial, we will keep things simple and only consider the first and last name of a person. Thus, the database table we will store the people in only contains three columns:
personID
is the unique numeric identifier of each person created,firstName
contains the first name of the person,lastName
contains the last name of the person.The first file for our package is the install_com.woltlab.wcf.people.php
file used to create such a database table during package installation:
<?php\n\nuse wcf\\system\\database\\table\\column\\NotNullVarchar255DatabaseTableColumn;\nuse wcf\\system\\database\\table\\column\\ObjectIdDatabaseTableColumn;\nuse wcf\\system\\database\\table\\DatabaseTable;\nuse wcf\\system\\database\\table\\index\\DatabaseTablePrimaryIndex;\n\nreturn [\n DatabaseTable::create('wcf1_person')\n ->columns([\n ObjectIdDatabaseTableColumn::create('personID'),\n NotNullVarchar255DatabaseTableColumn::create('firstName'),\n NotNullVarchar255DatabaseTableColumn::create('lastName'),\n ])\n ->indices([\n DatabaseTablePrimaryIndex::create()\n ->columns(['personID']),\n ]),\n];\n
"},{"location":"tutorial/series/part_1/#database-object","title":"Database Object","text":""},{"location":"tutorial/series/part_1/#person","title":"Person
","text":"In our PHP code, each person will be represented by an object of the following class:
files/lib/data/person/Person.class.php<?php\n\nnamespace wcf\\data\\person;\n\nuse wcf\\data\\DatabaseObject;\nuse wcf\\system\\request\\IRouteController;\n\n/**\n * Represents a person.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2021 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\Data\\Person\n *\n * @property-read int $personID unique id of the person\n * @property-read string $firstName first name of the person\n * @property-read string $lastName last name of the person\n */\nclass Person extends DatabaseObject implements IRouteController\n{\n /**\n * Returns the first and last name of the person if a person object is treated as a string.\n *\n * @return string\n */\n public function __toString()\n {\n return $this->getTitle();\n }\n\n /**\n * @inheritDoc\n */\n public function getTitle()\n {\n return $this->firstName . ' ' . $this->lastName;\n }\n}\n
The important thing here is that Person
extends DatabaseObject
. Additionally, we implement the IRouteController
interface, which allows us to use Person
objects to create links, and we implement PHP's magic __toString() method for convenience.
For every database object, you need to implement three additional classes: an action class, an editor class and a list class.
"},{"location":"tutorial/series/part_1/#personaction","title":"PersonAction
","text":"files/lib/data/person/PersonAction.class.php <?php\n\nnamespace wcf\\data\\person;\n\nuse wcf\\data\\AbstractDatabaseObjectAction;\n\n/**\n * Executes person-related actions.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2021 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\Data\\Person\n *\n * @method Person create()\n * @method PersonEditor[] getObjects()\n * @method PersonEditor getSingleObject()\n */\nclass PersonAction extends AbstractDatabaseObjectAction\n{\n /**\n * @inheritDoc\n */\n protected $permissionsDelete = ['admin.content.canManagePeople'];\n\n /**\n * @inheritDoc\n */\n protected $requireACP = ['delete'];\n}\n
This implementation of AbstractDatabaseObjectAction
is very basic and only sets the $permissionsDelete
and $requireACP
properties. This is done so that later on, when implementing the people list for the ACP, we can delete people simply via AJAX. $permissionsDelete
has to be set to the permission needed in order to delete a person. We will later use the userGroupOption package installation plugin to create the admin.content.canManagePeople
permission. $requireACP
restricts deletion of people to the ACP.
PersonEditor
","text":"files/lib/data/person/PersonEditor.class.php <?php\n\nnamespace wcf\\data\\person;\n\nuse wcf\\data\\DatabaseObjectEditor;\n\n/**\n * Provides functions to edit people.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2021 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\Data\\Person\n *\n * @method static Person create(array $parameters = [])\n * @method Person getDecoratedObject()\n * @mixin Person\n */\nclass PersonEditor extends DatabaseObjectEditor\n{\n /**\n * @inheritDoc\n */\n protected static $baseClass = Person::class;\n}\n
This implementation of DatabaseObjectEditor
fulfills the minimum requirement for a database object editor: setting the static $baseClass
property to the database object class name.
PersonList
","text":"files/lib/data/person/PersonList.class.php <?php\n\nnamespace wcf\\data\\person;\n\nuse wcf\\data\\DatabaseObjectList;\n\n/**\n * Represents a list of people.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2021 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\Data\\Person\n *\n * @method Person current()\n * @method Person[] getObjects()\n * @method Person|null search($objectID)\n * @property Person[] $objects\n */\nclass PersonList extends DatabaseObjectList\n{\n}\n
Due to the default implementation of DatabaseObjectList
, our PersonList
class just needs to extend it and everything else is either automatically set by the code of DatabaseObjectList
or, in the case of properties and methods, provided by that class.
Next, we will take care of the controllers and views for the ACP. In total, we need three each:
Before we create the controllers and views, let us first create the menu items for the pages in the ACP menu.
"},{"location":"tutorial/series/part_1/#acp-menu","title":"ACP Menu","text":"We need to create three menu items:
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/5.4/acpMenu.xsd\">\n<import>\n<acpmenuitem name=\"wcf.acp.menu.link.person\">\n<parent>wcf.acp.menu.link.content</parent>\n</acpmenuitem>\n<acpmenuitem name=\"wcf.acp.menu.link.person.list\">\n<controller>wcf\\acp\\page\\PersonListPage</controller>\n<parent>wcf.acp.menu.link.person</parent>\n<permissions>admin.content.canManagePeople</permissions>\n</acpmenuitem>\n<acpmenuitem name=\"wcf.acp.menu.link.person.add\">\n<controller>wcf\\acp\\form\\PersonAddForm</controller>\n<parent>wcf.acp.menu.link.person.list</parent>\n<permissions>admin.content.canManagePeople</permissions>\n<icon>fa-plus</icon>\n</acpmenuitem>\n</import>\n</data>\n
We choose wcf.acp.menu.link.content
as the parent menu item for the first menu item wcf.acp.menu.link.person
because the people we are managing is just one form of content. The fourth level menu item wcf.acp.menu.link.person.add
will only be shown as an icon and thus needs an additional element icon
which takes a FontAwesome icon class as value.
To list the people in the ACP, we need a PersonListPage
class and a personList
template.
PersonListPage
","text":"files/lib/acp/page/PersonListPage.class.php <?php\n\nnamespace wcf\\acp\\page;\n\nuse wcf\\data\\person\\PersonList;\nuse wcf\\page\\SortablePage;\n\n/**\n * Shows the list of people.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2021 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\Acp\\Page\n */\nclass PersonListPage extends SortablePage\n{\n /**\n * @inheritDoc\n */\n public $activeMenuItem = 'wcf.acp.menu.link.person.list';\n\n /**\n * @inheritDoc\n */\n public $neededPermissions = ['admin.content.canManagePeople'];\n\n /**\n * @inheritDoc\n */\n public $objectListClassName = PersonList::class;\n\n /**\n * @inheritDoc\n */\n public $validSortFields = ['personID', 'firstName', 'lastName'];\n}\n
As WoltLab Suite Core already provides a powerful default implementation of a sortable page, our work here is minimal:
$activeMenuItem
.$neededPermissions
contains a list of permissions of which the user needs to have at least one in order to see the person list. We use the same permission for both the menu item and the page.$objectListClassName
and that handles fetching the people from database is the PersonList
class, which we have already created.$validSortFields
to the available database table columns.personList.tpl
","text":"acptemplates/personList.tpl {include file='header' pageTitle='wcf.acp.person.list'}\n\n<header class=\"contentHeader\">\n <div class=\"contentHeaderTitle\">\n <h1 class=\"contentTitle\">{lang}wcf.acp.person.list{/lang}</h1>\n </div>\n\n <nav class=\"contentHeaderNavigation\">\n <ul>\n <li><a href=\"{link controller='PersonAdd'}{/link}\" class=\"button\"><span class=\"icon icon16 fa-plus\"></span> <span>{lang}wcf.acp.menu.link.person.add{/lang}</span></a></li>\n\n{event name='contentHeaderNavigation'}\n </ul>\n </nav>\n</header>\n\n{hascontent}\n <div class=\"paginationTop\">\n{content}{pages print=true assign=pagesLinks controller=\"PersonList\" link=\"pageNo=%d&sortField=$sortField&sortOrder=$sortOrder\"}{/content}\n </div>\n{/hascontent}\n\n{if $objects|count}\n <div class=\"section tabularBox\">\n <table class=\"table jsObjectActionContainer\" data-object-action-class-name=\"wcf\\data\\person\\PersonAction\">\n <thead>\n <tr>\n <th class=\"columnID columnPersonID{if $sortField == 'personID'} active {@$sortOrder}{/if}\" colspan=\"2\"><a href=\"{link controller='PersonList'}pageNo={@$pageNo}&sortField=personID&sortOrder={if $sortField == 'personID' && $sortOrder == 'ASC'}DESC{else}ASC{/if}{/link}\">{lang}wcf.global.objectID{/lang}</a></th>\n <th class=\"columnTitle columnFirstName{if $sortField == 'firstName'} active {@$sortOrder}{/if}\"><a href=\"{link controller='PersonList'}pageNo={@$pageNo}&sortField=firstName&sortOrder={if $sortField == 'firstName' && $sortOrder == 'ASC'}DESC{else}ASC{/if}{/link}\">{lang}wcf.person.firstName{/lang}</a></th>\n <th class=\"columnTitle columnLastName{if $sortField == 'lastName'} active {@$sortOrder}{/if}\"><a href=\"{link controller='PersonList'}pageNo={@$pageNo}&sortField=lastName&sortOrder={if $sortField == 'lastName' && $sortOrder == 'ASC'}DESC{else}ASC{/if}{/link}\">{lang}wcf.person.lastName{/lang}</a></th>\n\n{event name='columnHeads'}\n </tr>\n </thead>\n\n <tbody class=\"jsReloadPageWhenEmpty\">\n{foreach from=$objects item=person}\n <tr class=\"jsObjectActionObject\" data-object-id=\"{@$person->getObjectID()}\">\n <td class=\"columnIcon\">\n <a href=\"{link controller='PersonEdit' object=$person}{/link}\" title=\"{lang}wcf.global.button.edit{/lang}\" class=\"jsTooltip\"><span class=\"icon icon16 fa-pencil\"></span></a>\n{objectAction action=\"delete\" objectTitle=$person->getTitle()}\n\n{event name='rowButtons'}\n </td>\n <td class=\"columnID\">{#$person->personID}</td>\n <td class=\"columnTitle columnFirstName\"><a href=\"{link controller='PersonEdit' object=$person}{/link}\">{$person->firstName}</a></td>\n <td class=\"columnTitle columnLastName\"><a href=\"{link controller='PersonEdit' object=$person}{/link}\">{$person->lastName}</a></td>\n\n{event name='columns'}\n </tr>\n{/foreach}\n </tbody>\n </table>\n </div>\n\n <footer class=\"contentFooter\">\n{hascontent}\n <div class=\"paginationBottom\">\n{content}{@$pagesLinks}{/content}\n </div>\n{/hascontent}\n\n <nav class=\"contentFooterNavigation\">\n <ul>\n <li><a href=\"{link controller='PersonAdd'}{/link}\" class=\"button\"><span class=\"icon icon16 fa-plus\"></span> <span>{lang}wcf.acp.menu.link.person.add{/lang}</span></a></li>\n\n{event name='contentFooterNavigation'}\n </ul>\n </nav>\n </footer>\n{else}\n <p class=\"info\">{lang}wcf.global.noItems{/lang}</p>\n{/if}\n\n{include file='footer'}\n
We will go piece by piece through the template code:
header
template and set the page title wcf.acp.person.list
. You have to include this template for every page!pages
template plugin. The {hascontent}{content}{/content}{/hascontent}
construct ensures the .paginationTop
element is only shown if the pages
template plugin has a return value, thus if a pagination is necessary.wcf.global.noItems
language item. The $objects
template variable is automatically assigned by wcf\\page\\MultipleLinkPage
and contains the PersonList
object used to read the people from database. The table itself consists of a thead
and a tbody
element and is extendable with more columns using the template events columnHeads
and columns
. In general, every table should provide these events. The default structure of a table is used here so that the first column of the content rows contains icons to edit and to delete the row (and provides another standard event rowButtons
) and that the second column contains the ID of the person. The table can be sorted by clicking on the head of each column. The used variables $sortField
and $sortOrder
are automatically assigned to the template by SortablePage
..contentFooter
element is only shown if people exist as it basically repeats the .contentHeaderNavigation
and .paginationTop
element..columnIcon
element relies on the global WoltLabSuite/Core/Ui/Object/Action
module which only requires the jsObjectActionContainer
CSS class in combination with the data-object-action-class-name
attribute for the table
element, the jsObjectActionObject
CSS class for each person's tr
element in combination with the data-object-id
attribute, and lastly the delete button itself, which is created with the objectAction
template plugin..jsReloadPageWhenEmpty
CSS class on the tbody
element ensures that once all persons on the page have been deleted, the page is reloaded.footer
template is included that terminates the page. You also have to include this template for every page!Now, we have finished the page to manage the people so that we can move on to the forms with which we actually create and edit the people.
"},{"location":"tutorial/series/part_1/#person-add-form","title":"Person Add Form","text":"Like the person list, the form to add new people requires a controller class and a template.
"},{"location":"tutorial/series/part_1/#personaddform","title":"PersonAddForm
","text":"files/lib/acp/form/PersonAddForm.class.php <?php\n\nnamespace wcf\\acp\\form;\n\nuse wcf\\data\\person\\PersonAction;\nuse wcf\\form\\AbstractFormBuilderForm;\nuse wcf\\system\\form\\builder\\container\\FormContainer;\nuse wcf\\system\\form\\builder\\field\\TextFormField;\n\n/**\n * Shows the form to create a new person.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2021 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\Acp\\Form\n */\nclass PersonAddForm extends AbstractFormBuilderForm\n{\n /**\n * @inheritDoc\n */\n public $activeMenuItem = 'wcf.acp.menu.link.person.add';\n\n /**\n * @inheritDoc\n */\n public $formAction = 'create';\n\n /**\n * @inheritDoc\n */\n public $neededPermissions = ['admin.content.canManagePeople'];\n\n /**\n * @inheritDoc\n */\n public $objectActionClass = PersonAction::class;\n\n /**\n * @inheritDoc\n */\n public $objectEditLinkController = PersonEditForm::class;\n\n /**\n * @inheritDoc\n */\n protected function createForm()\n {\n parent::createForm();\n\n $this->form->appendChild(\n FormContainer::create('data')\n ->label('wcf.global.form.data')\n ->appendChildren([\n TextFormField::create('firstName')\n ->label('wcf.person.firstName')\n ->required()\n ->autoFocus()\n ->maximumLength(255),\n\n TextFormField::create('lastName')\n ->label('wcf.person.lastName')\n ->required()\n ->maximumLength(255),\n ])\n );\n }\n}\n
The properties here consist of three types: the \u201chousekeeping\u201d properties $activeMenuItem
and $neededPermissions
, which fulfill the same roles as for PersonListPage
, and the $objectEditLinkController
property, which is used to generate a link to edit the newly created person after submitting the form, and finally $formAction
and $objectActionClass
required by the PHP form builder API used to generate the form.
Because of using form builder, we only have to set up the two form fields for entering the first and last name, respectively:
TextFormField
.create()
method expects the id of the field/name of the database object property, which is firstName
and lastName
, respectively, here.label()
method.required()
is called, and the maximum length is set via maximumLength()
.autoFocus()
.personAdd.tpl
","text":"acptemplates/personAdd.tpl {include file='header' pageTitle='wcf.acp.person.'|concat:$action}\n\n<header class=\"contentHeader\">\n <div class=\"contentHeaderTitle\">\n <h1 class=\"contentTitle\">{lang}wcf.acp.person.{$action}{/lang}</h1>\n </div>\n\n <nav class=\"contentHeaderNavigation\">\n <ul>\n <li><a href=\"{link controller='PersonList'}{/link}\" class=\"button\"><span class=\"icon icon16 fa-list\"></span> <span>{lang}wcf.acp.menu.link.person.list{/lang}</span></a></li>\n\n{event name='contentHeaderNavigation'}\n </ul>\n </nav>\n</header>\n\n{@$form->getHtml()}\n\n{include file='footer'}\n
We will now only concentrate on the new parts compared to personList.tpl
:
$action
variable to distinguish between the languages items used for adding a person and for creating a person.{@$form->getHtml()}
to generate all relevant output for the form.As mentioned before, for the form to edit existing people, we only need a new controller as the template has already been implemented in a way that it handles both, adding and editing.
"},{"location":"tutorial/series/part_1/#personeditform","title":"PersonEditForm
","text":"files/lib/acp/form/PersonEditForm.class.php <?php\n\nnamespace wcf\\acp\\form;\n\nuse wcf\\data\\person\\Person;\nuse wcf\\system\\exception\\IllegalLinkException;\n\n/**\n * Shows the form to edit an existing person.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2021 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\Acp\\Form\n */\nclass PersonEditForm extends PersonAddForm\n{\n /**\n * @inheritDoc\n */\n public $activeMenuItem = 'wcf.acp.menu.link.person';\n\n /**\n * @inheritDoc\n */\n public $formAction = 'update';\n\n /**\n * @inheritDoc\n */\n public function readParameters()\n {\n parent::readParameters();\n\n if (isset($_REQUEST['id'])) {\n $this->formObject = new Person($_REQUEST['id']);\n\n if (!$this->formObject->getObjectID()) {\n throw new IllegalLinkException();\n }\n }\n }\n}\n
In general, edit forms extend the associated add form so that the code to read and to validate the input data is simply inherited.
After setting a different active menu item, we have to change the value of $formAction
because this form, in contrast to PersonAddForm
, does not create but update existing persons.
As we rely on form builder, the only thing necessary in this controller is to read and validate the edit object, i.e. the edited person, which is done in readParameters()
.
For the front end, that means the part with which the visitors of a website interact, we want to implement a simple sortable page that lists the people. This page should also be directly linked in the main menu.
"},{"location":"tutorial/series/part_1/#pagexml","title":"page.xml
","text":"First, let us register the page with the system because every front end page or form needs to be explicitly registered using the page package installation plugin:
page.xml<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/5.4/page.xsd\">\n<import>\n<page identifier=\"com.woltlab.wcf.people.PersonList\">\n<pageType>system</pageType>\n<controller>wcf\\page\\PersonListPage</controller>\n<name language=\"de\">Personen-Liste</name>\n<name language=\"en\">Person List</name>\n\n<content language=\"de\">\n<title>Personen</title>\n</content>\n<content language=\"en\">\n<title>People</title>\n</content>\n</page>\n</import>\n</data>\n
For more information about what each of the elements means, please refer to the page package installation plugin page.
"},{"location":"tutorial/series/part_1/#menuitemxml","title":"menuItem.xml
","text":"Next, we register the menu item using the menuItem package installation plugin:
menuItem.xml<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/5.4/menuItem.xsd\">\n<import>\n<item identifier=\"com.woltlab.wcf.people.PersonList\">\n<menu>com.woltlab.wcf.MainMenu</menu>\n<title language=\"de\">Personen</title>\n<title language=\"en\">People</title>\n<page>com.woltlab.wcf.people.PersonList</page>\n</item>\n</import>\n</data>\n
Here, the import parts are that we register the menu item for the main menu com.woltlab.wcf.MainMenu
and link the menu item with the page com.woltlab.wcf.people.PersonList
, which we just registered.
As in the ACP, we need a controller and a template. You might notice that both the controller\u2019s (unqualified) class name and the template name are the same for the ACP and the front end. This is no problem because the qualified names of the classes differ and the files are stored in different directories and because the templates are installed by different package installation plugins and are also stored in different directories.
"},{"location":"tutorial/series/part_1/#personlistpage_1","title":"PersonListPage
","text":"files/lib/page/PersonListPage.class.php <?php\n\nnamespace wcf\\page;\n\nuse wcf\\data\\person\\PersonList;\n\n/**\n * Shows the list of people.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2021 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\Page\n */\nclass PersonListPage extends SortablePage\n{\n /**\n * @inheritDoc\n */\n public $defaultSortField = 'lastName';\n\n /**\n * @inheritDoc\n */\n public $objectListClassName = PersonList::class;\n\n /**\n * @inheritDoc\n */\n public $validSortFields = ['personID', 'firstName', 'lastName'];\n}\n
This class is almost identical to the ACP version. In the front end, we do not need to set the active menu item manually because the system determines the active menu item automatically based on the requested page. Furthermore, $neededPermissions
has not been set because in the front end, users do not need any special permission to access the page. In the front end, we explicitly set the $defaultSortField
so that the people listed on the page are sorted by their last name (in ascending order) by default.
personList.tpl
","text":"templates/personList.tpl {capture assign='contentTitle'}{lang}wcf.person.list{/lang} <span class=\"badge\">{#$items}</span>{/capture}\n\n{capture assign='headContent'}\n{if $pageNo < $pages}\n <link rel=\"next\" href=\"{link controller='PersonList'}pageNo={@$pageNo+1}{/link}\">\n{/if}\n{if $pageNo > 1}\n <link rel=\"prev\" href=\"{link controller='PersonList'}{if $pageNo > 2}pageNo={@$pageNo-1}{/if}{/link}\">\n{/if}\n <link rel=\"canonical\" href=\"{link controller='PersonList'}{if $pageNo > 1}pageNo={@$pageNo}{/if}{/link}\">\n{/capture}\n\n{capture assign='sidebarRight'}\n <section class=\"box\">\n <form method=\"post\" action=\"{link controller='PersonList'}{/link}\">\n <h2 class=\"boxTitle\">{lang}wcf.global.sorting{/lang}</h2>\n\n <div class=\"boxContent\">\n <dl>\n <dt></dt>\n <dd>\n <select id=\"sortField\" name=\"sortField\">\n <option value=\"firstName\"{if $sortField == 'firstName'} selected{/if}>{lang}wcf.person.firstName{/lang}</option>\n <option value=\"lastName\"{if $sortField == 'lastName'} selected{/if}>{lang}wcf.person.lastName{/lang}</option>\n{event name='sortField'}\n </select>\n <select name=\"sortOrder\">\n <option value=\"ASC\"{if $sortOrder == 'ASC'} selected{/if}>{lang}wcf.global.sortOrder.ascending{/lang}</option>\n <option value=\"DESC\"{if $sortOrder == 'DESC'} selected{/if}>{lang}wcf.global.sortOrder.descending{/lang}</option>\n </select>\n </dd>\n </dl>\n\n <div class=\"formSubmit\">\n <input type=\"submit\" value=\"{lang}wcf.global.button.submit{/lang}\" accesskey=\"s\">\n </div>\n </div>\n </form>\n </section>\n{/capture}\n\n{include file='header'}\n\n{hascontent}\n <div class=\"paginationTop\">\n{content}\n{pages print=true assign=pagesLinks controller='PersonList' link=\"pageNo=%d&sortField=$sortField&sortOrder=$sortOrder\"}\n{/content}\n </div>\n{/hascontent}\n\n{if $items}\n <div class=\"section sectionContainerList\">\n <ol class=\"containerList personList\">\n{foreach from=$objects item=person}\n <li>\n <div class=\"box48\">\n <span class=\"icon icon48 fa-user\"></span>\n\n <div class=\"details personInformation\">\n <div class=\"containerHeadline\">\n <h3>{$person}</h3>\n </div>\n\n{hascontent}\n <ul class=\"inlineList commaSeparated\">\n{content}{event name='personData'}{/content}\n </ul>\n{/hascontent}\n\n{hascontent}\n <dl class=\"plain inlineDataList small\">\n{content}{event name='personStatistics'}{/content}\n </dl>\n{/hascontent}\n </div>\n </div>\n </li>\n{/foreach}\n </ol>\n </div>\n{else}\n <p class=\"info\">{lang}wcf.global.noItems{/lang}</p>\n{/if}\n\n<footer class=\"contentFooter\">\n{hascontent}\n <div class=\"paginationBottom\">\n{content}{@$pagesLinks}{/content}\n </div>\n{/hascontent}\n\n{hascontent}\n <nav class=\"contentFooterNavigation\">\n <ul>\n{content}{event name='contentFooterNavigation'}{/content}\n </ul>\n </nav>\n{/hascontent}\n</footer>\n\n{include file='footer'}\n
If you compare this template to the one used in the ACP, you will recognize similar elements like the .paginationTop
element, the p.info
element if no people exist, and the .contentFooter
element. Furthermore, we include a template called header
before actually showing any of the page contents and terminate the template by including the footer
template.
Now, let us take a closer look at the differences:
.contentHeader
element but simply assign the title to the contentTitle
variable. The value of the assignment is simply the title of the page and a badge showing the number of listed people. The header
template that we include later will handle correctly displaying the content header on its own based on the $contentTitle
variable.<head>
element. In this case, we define the canonical link of the page and, because we are showing paginated content, add links to the previous and next page (if they exist).select
elements to determine sort field and sort order.Person::__toString()
. Additionally, like in the user list, we provide the initially empty ul.inlineList.commaSeparated
and dl.plain.inlineDataList.small
elements that can be filled by plugins using the templates events. userGroupOption.xml
","text":"We have already used the admin.content.canManagePeople
permissions several times, now we need to install it using the userGroupOption package installation plugin:
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/5.4/userGroupOption.xsd\">\n<import>\n<options>\n<option name=\"admin.content.canManagePeople\">\n<categoryname>admin.content</categoryname>\n<optiontype>boolean</optiontype>\n<defaultvalue>0</defaultvalue>\n<admindefaultvalue>1</admindefaultvalue>\n<usersonly>1</usersonly>\n</option>\n</options>\n</import>\n</data>\n
We use the existing admin.content
user group option category for the permission as the people are \u201ccontent\u201d (similar the the ACP menu item). As the permission is for administrators only, we set defaultvalue
to 0
and admindefaultvalue
to 1
. This permission is only relevant for registered users so that it should not be visible when editing the guest user group. This is achieved by setting usersonly
to 1
.
package.xml
","text":"Lastly, we need to create the package.xml
file. For more information about this kind of file, please refer to the package.xml
page.
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<package name=\"com.woltlab.wcf.people\" xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/5.4/package.xsd\">\n<packageinformation>\n<packagename>WoltLab Suite Core Tutorial: People</packagename>\n<packagedescription>Adds a simple management system for people as part of a tutorial to create packages.</packagedescription>\n<version>5.4.0</version>\n<date>2022-01-17</date>\n</packageinformation>\n\n<authorinformation>\n<author>WoltLab GmbH</author>\n<authorurl>http://www.woltlab.com</authorurl>\n</authorinformation>\n\n<requiredpackages>\n<requiredpackage minversion=\"5.4.10\">com.woltlab.wcf</requiredpackage>\n</requiredpackages>\n\n<excludedpackages>\n<excludedpackage version=\"6.0.0 Alpha 1\">com.woltlab.wcf</excludedpackage>\n</excludedpackages>\n\n<instructions type=\"install\">\n<instruction type=\"acpTemplate\" />\n<instruction type=\"file\" />\n<instruction type=\"database\">acp/database/install_com.woltlab.wcf.people.php</instruction>\n<instruction type=\"template\" />\n<instruction type=\"language\" />\n\n<instruction type=\"acpMenu\" />\n<instruction type=\"page\" />\n<instruction type=\"menuItem\" />\n<instruction type=\"userGroupOption\" />\n</instructions>\n</package>\n
As this is a package for WoltLab Suite Core 3, we need to require it using <requiredpackage>
. We require the latest version (when writing this tutorial) 5.4.0 Alpha 1
. Additionally, we disallow installation of the package in the next major version 6.0
by excluding the 6.0.0 Alpha 1
version.
The most important part are to installation instructions. First, we install the ACP templates, files and templates, create the database table and import the language item. Afterwards, the ACP menu items and the permission are added. Now comes the part of the instructions where the order of the instructions is crucial: In menuItem.xml
, we refer to the com.woltlab.wcf.people.PersonList
page that is delivered by page.xml
. As the menu item package installation plugin validates the given page and throws an exception if the page does not exist, we need to install the page before the menu item!
This concludes the first part of our tutorial series after which you now have a working simple package with which you can manage people in the ACP and show the visitors of your website a simple list of all created people in the front end.
The complete source code of this part can be found on GitHub.
"},{"location":"tutorial/series/part_2/","title":"Part 2: Event and Template Listeners","text":"In the first part of this tutorial series, we have created the base structure of our people management package. In further parts, we will use the package of the first part as a basis to directly add new features. In order to explain how event listeners and template works, however, we will not directly adding a new feature to the package by altering it in this part, but we will assume that somebody else created the package and that we want to extend it the \u201ccorrect\u201d way by creating a plugin.
The goal of the small plugin that will be created in this part is to add the birthday of the managed people. As in the first part, we will not bother with careful validation of the entered date but just make sure that it is a valid date.
"},{"location":"tutorial/series/part_2/#package-functionality","title":"Package Functionality","text":"The package should provide the following possibilities/functions:
We will use the following package installation plugins:
For more information about the event system, please refer to the dedicated page on events.
"},{"location":"tutorial/series/part_2/#package-structure","title":"Package Structure","text":"The package will have the following file structure:
\u251c\u2500\u2500 eventListener.xml\n\u251c\u2500\u2500 files\n\u2502 \u251c\u2500\u2500 acp\n\u2502 \u2502 \u2514\u2500\u2500 database\n\u2502 \u2502 \u2514\u2500\u2500 install_com.woltlab.wcf.people.birthday.php\n\u2502 \u2514\u2500\u2500 lib\n\u2502 \u2514\u2500\u2500 system\n\u2502 \u2514\u2500\u2500 event\n\u2502 \u2514\u2500\u2500 listener\n\u2502 \u251c\u2500\u2500 BirthdayPersonAddFormListener.class.php\n\u2502 \u2514\u2500\u2500 BirthdaySortFieldPersonListPageListener.class.php\n\u251c\u2500\u2500 language\n\u2502 \u251c\u2500\u2500 de.xml\n\u2502 \u2514\u2500\u2500 en.xml\n\u251c\u2500\u2500 package.xml\n\u251c\u2500\u2500 templateListener.xml\n\u2514\u2500\u2500 templates\n \u251c\u2500\u2500 __personListBirthday.tpl\n \u2514\u2500\u2500 __personListBirthdaySortField.tpl\n
"},{"location":"tutorial/series/part_2/#extending-person-model","title":"Extending Person Model","text":"The existing model of a person only contains the person\u2019s first name and their last name (in additional to the id used to identify created people). To add the birthday to the model, we need to create an additional database table column using the database
package installation plugin:
<?php\n\nuse wcf\\system\\database\\table\\column\\DateDatabaseTableColumn;\nuse wcf\\system\\database\\table\\PartialDatabaseTable;\n\nreturn [\nPartialDatabaseTable::create('wcf1_person')\n->columns([\nDateDatabaseTableColumn::create('birthday'),\n]),\n];\n
If we have a Person
object, this new property can be accessed the same way as the personID
property, the firstName
property, or the lastName
property from the base package: $person->birthday
.
To set the birthday of a person, we only have to add another form field with an event listener:
files/lib/system/event/listener/BirthdayPersonAddFormListener.class.php<?php\n\nnamespace wcf\\system\\event\\listener;\n\nuse wcf\\acp\\form\\PersonAddForm;\nuse wcf\\form\\AbstractFormBuilderForm;\nuse wcf\\system\\form\\builder\\container\\FormContainer;\nuse wcf\\system\\form\\builder\\field\\DateFormField;\n\n/**\n * Handles setting the birthday when adding and editing people.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2022 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\System\\Event\\Listener\n */\nfinal class BirthdayPersonAddFormListener extends AbstractEventListener\n{\n /**\n * @see AbstractFormBuilderForm::createForm()\n */\n protected function onCreateForm(PersonAddForm $form): void\n {\n $dataContainer = $form->form->getNodeById('data');\n \\assert($dataContainer instanceof FormContainer);\n $dataContainer->appendChild(\n DateFormField::create('birthday')\n ->label('wcf.person.birthday')\n ->saveValueFormat('Y-m-d')\n ->nullable()\n );\n }\n}\n
registered via
<eventlistener name=\"createForm@wcf\\acp\\form\\PersonAddForm\">\n<environment>admin</environment>\n<eventclassname>wcf\\acp\\form\\PersonAddForm</eventclassname>\n<eventname>createForm</eventname>\n<listenerclassname>wcf\\system\\event\\listener\\BirthdayPersonAddFormListener</listenerclassname>\n<inherit>1</inherit>\n</eventlistener>\n
in eventListener.xml
, see below.
As BirthdayPersonAddFormListener
extends AbstractEventListener
and as the name of relevant event is createForm
, AbstractEventListener
internally automatically calls onCreateForm()
with the event object as the parameter. It is important to set <inherit>1</inherit>
so that the event listener is also executed for PersonEditForm
, which extends PersonAddForm
.
The language item wcf.person.birthday
used in the label is the only new one for this package:
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<language xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/5.4/language.xsd\" languagecode=\"de\">\n<category name=\"wcf.person\">\n<item name=\"wcf.person.birthday\"><![CDATA[Geburtstag]]></item>\n</category>\n</language>\n
language/en.xml <?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<language xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/5.4/language.xsd\" languagecode=\"en\">\n<category name=\"wcf.person\">\n<item name=\"wcf.person.birthday\"><![CDATA[Birthday]]></item>\n</category>\n</language>\n
"},{"location":"tutorial/series/part_2/#adding-birthday-table-column-in-acp","title":"Adding Birthday Table Column in ACP","text":"To add a birthday column to the person list page in the ACP, we need three parts:
birthday
database table column a valid sort field,The first part is a very simple class:
files/lib/system/event/listener/BirthdaySortFieldPersonListPageListener.class.php<?php\n\nnamespace wcf\\system\\event\\listener;\n\nuse wcf\\page\\SortablePage;\n\n/**\n * Makes people's birthday a valid sort field in the ACP and the front end.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2022 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\System\\Event\\Listener\n */\nfinal class BirthdaySortFieldPersonListPageListener extends AbstractEventListener\n{\n /**\n * @see SortablePage::validateSortField()\n */\n public function onValidateSortField(SortablePage $page): void\n {\n $page->validSortFields[] = 'birthday';\n }\n}\n
We use SortablePage
as a type hint instead of wcf\\acp\\page\\PersonListPage
because we will be using the same event listener class in the front end to also allow sorting that list by birthday.
As the relevant template codes are only one line each, we will simply put them directly in the templateListener.xml
file that will be shown later on. The code for the table head is similar to the other th
elements:
<th class=\"columnDate columnBirthday{if $sortField == 'birthday'} active {$sortOrder}{/if}\"><a href=\"{link controller='PersonList'}pageNo={@$pageNo}&sortField=birthday&sortOrder={if $sortField == 'birthday' && $sortOrder == 'ASC'}DESC{else}ASC{/if}{/link}\">{lang}wcf.person.birthday{/lang}</a></th>\n
For the table body\u2019s column, we need to make sure that the birthday is only show if it is actually set:
<td class=\"columnDate columnBirthday\">{if $person->birthday}{@$person->birthday|strtotime|date}{/if}</td>\n
"},{"location":"tutorial/series/part_2/#adding-birthday-in-front-end","title":"Adding Birthday in Front End","text":"In the front end, we also want to make the list sortable by birthday and show the birthday as part of each person\u2019s \u201cstatistics\u201d.
To add the birthday as a valid sort field, we use BirthdaySortFieldPersonListPageListener
just as in the ACP. In the front end, we will now use a template (__personListBirthdaySortField.tpl
) instead of a directly putting the template code in the templateListener.xml
file:
<option value=\"birthday\"{if $sortField == 'birthday'} selected{/if}>{lang}wcf.person.birthday{/lang}</option>\n
You might have noticed the two underscores at the beginning of the template file. For templates that are included via template listeners, this is the naming convention we use.
Putting the template code into a file has the advantage that in the administrator is able to edit the code directly via a custom template group, even though in this case this might not be very probable.
To show the birthday, we use the following template code for the personStatistics
template event, which again makes sure that the birthday is only shown if it is actually set:
{if $person->birthday}\n <dt>{lang}wcf.person.birthday{/lang}</dt>\n <dd>{@$person->birthday|strtotime|date}</dd>\n{/if}\n
"},{"location":"tutorial/series/part_2/#templatelistenerxml","title":"templateListener.xml
","text":"The following code shows the templateListener.xml
file used to install all mentioned template listeners:
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/tornado/5.4/templateListener.xsd\">\n<import>\n<!-- admin -->\n<templatelistener name=\"personListBirthdayColumnHead\">\n<eventname>columnHeads</eventname>\n<environment>admin</environment>\n<templatecode><![CDATA[<th class=\"columnDate columnBirthday{if $sortField == 'birthday'} active {@$sortOrder}{/if}\"><a href=\"{link controller='PersonList'}pageNo={@$pageNo}&sortField=birthday&sortOrder={if $sortField == 'birthday' && $sortOrder == 'ASC'}DESC{else}ASC{/if}{/link}\">{lang}wcf.person.birthday{/lang}</a></th>]]></templatecode>\n<templatename>personList</templatename>\n</templatelistener>\n<templatelistener name=\"personListBirthdayColumn\">\n<eventname>columns</eventname>\n<environment>admin</environment>\n<templatecode><![CDATA[<td class=\"columnDate columnBirthday\">{if $person->birthday}{@$person->birthday|strtotime|date}{/if}</td>]]></templatecode>\n<templatename>personList</templatename>\n</templatelistener>\n<!-- /admin -->\n\n<!-- user -->\n<templatelistener name=\"personListBirthday\">\n<eventname>personStatistics</eventname>\n<environment>user</environment>\n<templatecode><![CDATA[{include file='__personListBirthday'}]]></templatecode>\n<templatename>personList</templatename>\n</templatelistener>\n<templatelistener name=\"personListBirthdaySortField\">\n<eventname>sortField</eventname>\n<environment>user</environment>\n<templatecode><![CDATA[{include file='__personListBirthdaySortField'}]]></templatecode>\n<templatename>personList</templatename>\n</templatelistener>\n<!-- /user -->\n</import>\n</data>\n
In cases where a template is used, we simply use the include
syntax to load the template.
eventListener.xml
","text":"There are two event listeners that make birthday
a valid sort field in the ACP and the front end, respectively, and the third event listener takes care of setting the birthday.
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/5.4/eventListener.xsd\">\n<import>\n<!-- admin -->\n<eventlistener name=\"validateSortField@wcf\\acp\\page\\PersonListPage\">\n<environment>admin</environment>\n<eventclassname>wcf\\acp\\page\\PersonListPage</eventclassname>\n<eventname>validateSortField</eventname>\n<listenerclassname>wcf\\system\\event\\listener\\BirthdaySortFieldPersonListPageListener</listenerclassname>\n</eventlistener>\n<eventlistener name=\"createForm@wcf\\acp\\form\\PersonAddForm\">\n<environment>admin</environment>\n<eventclassname>wcf\\acp\\form\\PersonAddForm</eventclassname>\n<eventname>createForm</eventname>\n<listenerclassname>wcf\\system\\event\\listener\\BirthdayPersonAddFormListener</listenerclassname>\n<inherit>1</inherit>\n</eventlistener>\n<!-- /admin -->\n\n<!-- user -->\n<eventlistener name=\"validateSortField@wcf\\page\\PersonListPage\">\n<environment>user</environment>\n<eventclassname>wcf\\page\\PersonListPage</eventclassname>\n<eventname>validateSortField</eventname>\n<listenerclassname>wcf\\system\\event\\listener\\BirthdaySortFieldPersonListPageListener</listenerclassname>\n</eventlistener>\n<!-- /user -->\n</import>\n</data>\n
"},{"location":"tutorial/series/part_2/#packagexml","title":"package.xml
","text":"The only relevant difference between the package.xml
file of the base page from part 1 and the package.xml
file of this package is that this package requires the base package com.woltlab.wcf.people
(see <requiredpackages>
):
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<package name=\"com.woltlab.wcf.people.birthday\" xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/5.4/package.xsd\">\n<packageinformation>\n<packagename>WoltLab Suite Core Tutorial: People (Birthday)</packagename>\n<packagedescription>Adds a birthday field to the people management system as part of a tutorial to create packages.</packagedescription>\n<version>5.4.0</version>\n<date>2022-01-17</date>\n</packageinformation>\n\n<authorinformation>\n<author>WoltLab GmbH</author>\n<authorurl>http://www.woltlab.com</authorurl>\n</authorinformation>\n\n<requiredpackages>\n<requiredpackage minversion=\"5.4.10\">com.woltlab.wcf</requiredpackage>\n<requiredpackage minversion=\"5.4.0\">com.woltlab.wcf.people</requiredpackage>\n</requiredpackages>\n\n<excludedpackages>\n<excludedpackage version=\"6.0.0 Alpha 1\">com.woltlab.wcf</excludedpackage>\n</excludedpackages>\n\n<instructions type=\"install\">\n<instruction type=\"file\" />\n<instruction type=\"database\">acp/database/install_com.woltlab.wcf.people.birthday.php</instruction>\n<instruction type=\"template\" />\n<instruction type=\"language\" />\n\n<instruction type=\"eventListener\" />\n<instruction type=\"templateListener\" />\n</instructions>\n</package>\n
This concludes the second part of our tutorial series after which you now have extended the base package using event listeners and template listeners that allow you to enter the birthday of the people.
The complete source code of this part can be found on GitHub.
"},{"location":"tutorial/series/part_3/","title":"Part 3: Person Page and Comments","text":"In this part of our tutorial series, we will add a new front end page to our package that is dedicated to each person and shows their personal details. To make good use of this new page and introduce a new API of WoltLab Suite, we will add the opportunity for users to comment on the person using WoltLab Suite\u2019s reusable comment functionality.
"},{"location":"tutorial/series/part_3/#package-functionality","title":"Package Functionality","text":"In addition to the existing functions from part 1, the package will provide the following possibilities/functions after this part of the tutorial:
In addition to the components used in part 1, we will use the objectType package installation plugin, use the comment API, create a runtime cache, and create a page handler.
"},{"location":"tutorial/series/part_3/#package-structure","title":"Package Structure","text":"The complete package will have the following file structure (including the files from part 1):
\u251c\u2500\u2500 acpMenu.xml\n\u251c\u2500\u2500 acptemplates\n\u2502 \u251c\u2500\u2500 personAdd.tpl\n\u2502 \u2514\u2500\u2500 personList.tpl\n\u251c\u2500\u2500 files\n\u2502 \u251c\u2500\u2500 acp\n\u2502 \u2502 \u2514\u2500\u2500 database\n\u2502 \u2502 \u2514\u2500\u2500 install_com.woltlab.wcf.people.php\n\u2502 \u2514\u2500\u2500 lib\n\u2502 \u251c\u2500\u2500 acp\n\u2502 \u2502 \u251c\u2500\u2500 form\n\u2502 \u2502 \u2502 \u251c\u2500\u2500 PersonAddForm.class.php\n\u2502 \u2502 \u2502 \u2514\u2500\u2500 PersonEditForm.class.php\n\u2502 \u2502 \u2514\u2500\u2500 page\n\u2502 \u2502 \u2514\u2500\u2500 PersonListPage.class.php\n\u2502 \u251c\u2500\u2500 data\n\u2502 \u2502 \u2514\u2500\u2500 person\n\u2502 \u2502 \u251c\u2500\u2500 Person.class.php\n\u2502 \u2502 \u251c\u2500\u2500 PersonAction.class.php\n\u2502 \u2502 \u251c\u2500\u2500 PersonEditor.class.php\n\u2502 \u2502 \u2514\u2500\u2500 PersonList.class.php\n\u2502 \u251c\u2500\u2500 page\n\u2502 \u2502 \u251c\u2500\u2500 PersonListPage.class.php\n\u2502 \u2502 \u2514\u2500\u2500 PersonPage.class.php\n\u2502 \u2514\u2500\u2500 system\n\u2502 \u251c\u2500\u2500 cache\n\u2502 \u2502 \u2514\u2500\u2500 runtime\n\u2502 \u2502 \u2514\u2500\u2500 PersonRuntimeCache.class.php\n\u2502 \u251c\u2500\u2500 comment\n\u2502 \u2502 \u2514\u2500\u2500 manager\n\u2502 \u2502 \u2514\u2500\u2500 PersonCommentManager.class.php\n\u2502 \u2514\u2500\u2500 page\n\u2502 \u2514\u2500\u2500 handler\n\u2502 \u2514\u2500\u2500 PersonPageHandler.class.php\n\u251c\u2500\u2500 language\n\u2502 \u251c\u2500\u2500 de.xml\n\u2502 \u2514\u2500\u2500 en.xml\n\u251c\u2500\u2500 menuItem.xml\n\u251c\u2500\u2500 objectType.xml\n\u251c\u2500\u2500 package.xml\n\u251c\u2500\u2500 page.xml\n\u251c\u2500\u2500 templates\n\u2502 \u251c\u2500\u2500 person.tpl\n\u2502 \u2514\u2500\u2500 personList.tpl\n\u2514\u2500\u2500 userGroupOption.xml\n
We will not mention every code change between the first part and this part, as we only want to focus on the important, new parts of the code. For example, there is a new Person::getLink()
method and new language items have been added. For all changes, please refer to the source code on GitHub.
To reduce the number of database queries when different APIs require person objects, we implement a runtime cache for people:
files/lib/system/cache/runtime/PersonRuntimeCache.class.php<?php\n\nnamespace wcf\\system\\cache\\runtime;\n\nuse wcf\\data\\person\\Person;\nuse wcf\\data\\person\\PersonList;\n\n/**\n * Runtime cache implementation for people.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2022 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\System\\Cache\\Runtime\n *\n * @method Person[] getCachedObjects()\n * @method Person getObject($objectID)\n * @method Person[] getObjects(array $objectIDs)\n */\nfinal class PersonRuntimeCache extends AbstractRuntimeCache\n{\n /**\n * @inheritDoc\n */\n protected $listClassName = PersonList::class;\n}\n
"},{"location":"tutorial/series/part_3/#comments","title":"Comments","text":"To allow users to comment on people, we need to tell the system that people support comments. This is done by registering a com.woltlab.wcf.comment.commentableContent
object type whose processor implements ICommentManager:
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/5.4/objectType.xsd\">\n<import>\n<type>\n<name>com.woltlab.wcf.person.personComment</name>\n<definitionname>com.woltlab.wcf.comment.commentableContent</definitionname>\n<classname>wcf\\system\\comment\\manager\\PersonCommentManager</classname>\n</type>\n</import>\n</data>\n
The PersonCommentManager
class extended ICommentManager
\u2019s default implementation AbstractCommentManager:
<?php\n\nnamespace wcf\\system\\comment\\manager;\n\nuse wcf\\data\\person\\Person;\nuse wcf\\data\\person\\PersonEditor;\nuse wcf\\system\\cache\\runtime\\PersonRuntimeCache;\nuse wcf\\system\\WCF;\n\n/**\n * Comment manager implementation for people.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2022 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\System\\Comment\\Manager\n */\nfinal class PersonCommentManager extends AbstractCommentManager\n{\n /**\n * @inheritDoc\n */\n protected $permissionAdd = 'user.person.canAddComment';\n\n /**\n * @inheritDoc\n */\n protected $permissionAddWithoutModeration = 'user.person.canAddCommentWithoutModeration';\n\n /**\n * @inheritDoc\n */\n protected $permissionCanModerate = 'mod.person.canModerateComment';\n\n /**\n * @inheritDoc\n */\n protected $permissionDelete = 'user.person.canDeleteComment';\n\n /**\n * @inheritDoc\n */\n protected $permissionEdit = 'user.person.canEditComment';\n\n /**\n * @inheritDoc\n */\n protected $permissionModDelete = 'mod.person.canDeleteComment';\n\n /**\n * @inheritDoc\n */\n protected $permissionModEdit = 'mod.person.canEditComment';\n\n /**\n * @inheritDoc\n */\n public function getLink($objectTypeID, $objectID)\n {\n return PersonRuntimeCache::getInstance()->getObject($objectID)->getLink();\n }\n\n /**\n * @inheritDoc\n */\n public function isAccessible($objectID, $validateWritePermission = false)\n {\n return PersonRuntimeCache::getInstance()->getObject($objectID) !== null;\n }\n\n /**\n * @inheritDoc\n */\n public function getTitle($objectTypeID, $objectID, $isResponse = false)\n {\n if ($isResponse) {\n return WCF::getLanguage()->get('wcf.person.commentResponse');\n }\n\n return WCF::getLanguage()->getDynamicVariable('wcf.person.comment');\n }\n\n /**\n * @inheritDoc\n */\n public function updateCounter($objectID, $value)\n {\n (new PersonEditor(new Person($objectID)))->updateCounters(['comments' => $value]);\n }\n}\n
$permission*
properties. More information about comment permissions can be found here.getLink()
method returns the link to the person with the passed comment id. As in isAccessible()
, PersonRuntimeCache
is used to potentially save database queries.isAccessible()
method checks if the active user can access the relevant person. As we do not have any special restrictions for accessing people, we only need to check if the person exists.getTitle()
method returns the title used for comments and responses, which is just a generic language item in this case.updateCounter()
updates the comments\u2019 counter of the person. We have added a new comments
database table column to the wcf1_person
database table in order to keep track on the number of comments.Additionally, we have added a new enableComments
database table column to the wcf1_person
database table whose value can be set when creating or editing a person in the ACP. With this option, comments on individual people can be disabled.
Liking comments is already built-in and only requires some extra code in the PersonPage
class for showing the likes of pre-loaded comments.
PersonPage
","text":"files/lib/page/PersonPage.class.php <?php\n\nnamespace wcf\\page;\n\nuse wcf\\data\\comment\\StructuredCommentList;\nuse wcf\\data\\person\\Person;\nuse wcf\\system\\comment\\CommentHandler;\nuse wcf\\system\\comment\\manager\\PersonCommentManager;\nuse wcf\\system\\exception\\IllegalLinkException;\nuse wcf\\system\\WCF;\n\n/**\n * Shows the details of a certain person.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2021 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\Page\n */\nclass PersonPage extends AbstractPage\n{\n /**\n * list of comments\n * @var StructuredCommentList\n */\n public $commentList;\n\n /**\n * person comment manager object\n * @var PersonCommentManager\n */\n public $commentManager;\n\n /**\n * id of the person comment object type\n * @var int\n */\n public $commentObjectTypeID = 0;\n\n /**\n * shown person\n * @var Person\n */\n public $person;\n\n /**\n * id of the shown person\n * @var int\n */\n public $personID = 0;\n\n /**\n * @inheritDoc\n */\n public function assignVariables()\n {\n parent::assignVariables();\n\n WCF::getTPL()->assign([\n 'commentCanAdd' => WCF::getSession()->getPermission('user.person.canAddComment'),\n 'commentList' => $this->commentList,\n 'commentObjectTypeID' => $this->commentObjectTypeID,\n 'lastCommentTime' => $this->commentList ? $this->commentList->getMinCommentTime() : 0,\n 'likeData' => MODULE_LIKE && $this->commentList ? $this->commentList->getLikeData() : [],\n 'person' => $this->person,\n ]);\n }\n\n /**\n * @inheritDoc\n */\n public function readData()\n {\n parent::readData();\n\n if ($this->person->enableComments) {\n $this->commentObjectTypeID = CommentHandler::getInstance()->getObjectTypeID(\n 'com.woltlab.wcf.person.personComment'\n );\n $this->commentManager = CommentHandler::getInstance()->getObjectType(\n $this->commentObjectTypeID\n )->getProcessor();\n $this->commentList = CommentHandler::getInstance()->getCommentList(\n $this->commentManager,\n $this->commentObjectTypeID,\n $this->person->personID\n );\n }\n }\n\n /**\n * @inheritDoc\n */\n public function readParameters()\n {\n parent::readParameters();\n\n if (isset($_REQUEST['id'])) {\n $this->personID = \\intval($_REQUEST['id']);\n }\n $this->person = new Person($this->personID);\n if (!$this->person->personID) {\n throw new IllegalLinkException();\n }\n }\n}\n
The PersonPage
class is similar to the PersonEditForm
in the ACP in that it reads the id of the requested person from the request data and validates the id in readParameters()
. The rest of the code only handles fetching the list of comments on the requested person. In readData()
, this list is fetched using CommentHandler::getCommentList()
if comments are enabled for the person. The assignVariables()
method assigns some additional template variables like $commentCanAdd
, which is 1
if the active person can add comments and is 0
otherwise, $lastCommentTime
, which contains the UNIX timestamp of the last comment, and $likeData
, which contains data related to the likes for the disabled comments.
person.tpl
","text":"templates/person.tpl {capture assign='pageTitle'}{$person} - {lang}wcf.person.list{/lang}{/capture}\n\n{capture assign='contentTitle'}{$person}{/capture}\n\n{include file='header'}\n\n{if $person->enableComments}\n {if $commentList|count || $commentCanAdd}\n <section id=\"comments\" class=\"section sectionContainerList\">\n <header class=\"sectionHeader\">\n <h2 class=\"sectionTitle\">\n {lang}wcf.person.comments{/lang}\n {if $person->comments}<span class=\"badge\">{#$person->comments}</span>{/if}\n </h2>\n </header>\n\n {include file='__commentJavaScript' commentContainerID='personCommentList'}\n\n <div class=\"personComments\">\n <ul id=\"personCommentList\" class=\"commentList containerList\" {*\n *}data-can-add=\"{if $commentCanAdd}true{else}false{/if}\" {*\n *}data-object-id=\"{@$person->personID}\" {*\n *}data-object-type-id=\"{@$commentObjectTypeID}\" {*\n *}data-comments=\"{if $person->comments}{@$commentList->countObjects()}{else}0{/if}\" {*\n *}data-last-comment-time=\"{@$lastCommentTime}\" {*\n *}>\n {include file='commentListAddComment' wysiwygSelector='personCommentListAddComment'}\n {include file='commentList'}\n </ul>\n </div>\n </section>\n {/if}\n{/if}\n\n<footer class=\"contentFooter\">\n {hascontent}\n <nav class=\"contentFooterNavigation\">\n <ul>\n {content}{event name='contentFooterNavigation'}{/content}\n </ul>\n </nav>\n {/hascontent}\n</footer>\n\n{include file='footer'}\n
For now, the person
template is still very empty and only shows the comments in the content area. The template code shown for comments is very generic and used in this form in many locations as it only sets the header of the comment list and the container ul#personCommentList
element for the comments shown by commentList
template. The ul#personCommentList
elements has five additional data-
attributes required by the JavaScript API for comments for loading more comments or creating new ones. The commentListAddComment
template adds the WYSIWYG support. The attribute wysiwygSelector
should be the id of the comment list personCommentList
with an additional AddComment
suffix.
page.xml
","text":"page.xml <?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/5.4/page.xsd\">\n<import>\n<page identifier=\"com.woltlab.wcf.people.PersonList\">\n<pageType>system</pageType>\n<controller>wcf\\page\\PersonListPage</controller>\n<name language=\"de\">Personen-Liste</name>\n<name language=\"en\">Person List</name>\n\n<content language=\"de\">\n<title>Personen</title>\n</content>\n<content language=\"en\">\n<title>People</title>\n</content>\n</page>\n<page identifier=\"com.woltlab.wcf.people.Person\">\n<pageType>system</pageType>\n<controller>wcf\\page\\PersonPage</controller>\n<handler>wcf\\system\\page\\handler\\PersonPageHandler</handler>\n<name language=\"de\">Person</name>\n<name language=\"en\">Person</name>\n<requireObjectID>1</requireObjectID>\n<parent>com.woltlab.wcf.people.PersonList</parent>\n</page>\n</import>\n</data>\n
The page.xml
file has been extended for the new person page with identifier com.woltlab.wcf.people.Person
. Compared to the pre-existing com.woltlab.wcf.people.PersonList
page, there are four differences:
<handler>
element with a class name as value. This aspect will be discussed in more detail in the next section.<content>
elements because, both, the title and the content of the page are dynamically generated in the template.<requireObjectID>
tells the system that this page requires an object id to properly work, in this case a valid person id.<parent>
page, the person list page. In general, the details page for any type of object that is listed on a different page has the list page as its parent.PersonPageHandler
","text":"files/lib/system/page/handler/PersonPageHandler.class.php <?php\n\nnamespace wcf\\system\\page\\handler;\n\nuse wcf\\data\\page\\Page;\nuse wcf\\data\\person\\PersonList;\nuse wcf\\data\\user\\online\\UserOnline;\nuse wcf\\system\\cache\\runtime\\PersonRuntimeCache;\nuse wcf\\system\\database\\util\\PreparedStatementConditionBuilder;\nuse wcf\\system\\WCF;\n\n/**\n * Page handler implementation for person page.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2022 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\System\\Page\\Handler\n */\nfinal class PersonPageHandler extends AbstractLookupPageHandler implements IOnlineLocationPageHandler\n{\n use TOnlineLocationPageHandler;\n\n /**\n * @inheritDoc\n */\n public function getLink($objectID)\n {\n return PersonRuntimeCache::getInstance()->getObject($objectID)->getLink();\n }\n\n /**\n * Returns the textual description if a user is currently online viewing this page.\n *\n * @see IOnlineLocationPageHandler::getOnlineLocation()\n *\n * @param Page $page visited page\n * @param UserOnline $user user online object with request data\n * @return string\n */\n public function getOnlineLocation(Page $page, UserOnline $user)\n {\n if ($user->pageObjectID === null) {\n return '';\n }\n\n $person = PersonRuntimeCache::getInstance()->getObject($user->pageObjectID);\n if ($person === null) {\n return '';\n }\n\n return WCF::getLanguage()->getDynamicVariable('wcf.page.onlineLocation.' . $page->identifier, ['person' => $person]);\n }\n\n /**\n * @inheritDoc\n */\n public function isValid($objectID = null)\n {\n return PersonRuntimeCache::getInstance()->getObject($objectID) !== null;\n }\n\n /**\n * @inheritDoc\n */\n public function lookup($searchString)\n {\n $conditionBuilder = new PreparedStatementConditionBuilder(false, 'OR');\n $conditionBuilder->add('person.firstName LIKE ?', ['%' . $searchString . '%']);\n $conditionBuilder->add('person.lastName LIKE ?', ['%' . $searchString . '%']);\n\n $personList = new PersonList();\n $personList->getConditionBuilder()->add($conditionBuilder, $conditionBuilder->getParameters());\n $personList->readObjects();\n\n $results = [];\n foreach ($personList as $person) {\n $results[] = [\n 'image' => 'fa-user',\n 'link' => $person->getLink(),\n 'objectID' => $person->personID,\n 'title' => $person->getTitle(),\n ];\n }\n\n return $results;\n }\n\n /**\n * Prepares fetching all necessary data for the textual description if a user is currently online\n * viewing this page.\n *\n * @see IOnlineLocationPageHandler::prepareOnlineLocation()\n *\n * @param Page $page visited page\n * @param UserOnline $user user online object with request data\n */\n public function prepareOnlineLocation(Page $page, UserOnline $user)\n {\n if ($user->pageObjectID !== null) {\n PersonRuntimeCache::getInstance()->cacheObjectID($user->pageObjectID);\n }\n }\n}\n
Like any page handler, the PersonPageHandler
class has to implement the IMenuPageHandler interface, which should be done by extending the AbstractMenuPageHandler class. As we want administrators to link to specific people in menus, for example, we have to also implement the ILookupPageHandler interface by extending the AbstractLookupPageHandler class.
For the ILookupPageHandler
interface, we need to implement three methods:
getLink($objectID)
returns the link to the person page with the given id. In this case, we simply delegate this method call to the Person
object returned by PersonRuntimeCache::getObject()
.isValid($objectID)
returns true
if the person with the given id exists, otherwise false
. Here, we use PersonRuntimeCache::getObject()
again and check if the return value is null
, which is the case for non-existing people.lookup($searchString)
is used when setting up an internal link and when searching for the linked person. This method simply searches the first and last name of the people and returns an array with the person data. While the link
, the objectID
, and the title
element are self-explanatory, the image
element can either contain an HTML <img>
tag, which is displayed next to the search result (WoltLab Suite uses an image tag for users showing their avatar, for example), or a FontAwesome icon class (starting with fa-
).Additionally, the class also implements IOnlineLocationPageHandler which is used to determine the online location of users. To ensure upwards-compatibility if the IOnlineLocationPageHandler
interface changes, the TOnlineLocationPageHandler trait is used. The IOnlineLocationPageHandler
interface requires two methods to be implemented:
getOnlineLocation(Page $page, UserOnline $user)
returns the textual description of the online location. The language item for the user online locations should use the pattern wcf.page.onlineLocation.{page identifier}
.prepareOnlineLocation(Page $page, UserOnline $user)
is called for each user online before the getOnlineLocation()
calls. In this case, calling prepareOnlineLocation()
first enables us to add all relevant person ids to the person runtime cache so that for all getOnlineLocation()
calls combined, only one database query is necessary to fetch all person objects.This concludes the third part of our tutorial series after which each person has a dedicated page on which people can comment on the person.
The complete source code of this part can be found on GitHub.
"},{"location":"tutorial/series/part_4/","title":"Part 4: Box and Box Conditions","text":"In this part of our tutorial series, we add support for creating boxes listing people.
"},{"location":"tutorial/series/part_4/#package-functionality","title":"Package Functionality","text":"In addition to the existing functions from part 3, the package will provide the following functionality after this part of the tutorial:
In addition to the components used in previous parts, we will use the objectTypeDefinition
package installation plugin and use the box and condition APIs.
To pre-install a specific person list box, we refer to the documentation of the box
package installation plugin.
The complete package will have the following file structure (excluding unchanged files from part 3):
\u251c\u2500\u2500 files\n\u2502 \u2514\u2500\u2500 lib\n\u2502 \u2514\u2500\u2500 system\n\u2502 \u251c\u2500\u2500 box\n\u2502 \u2502 \u2514\u2500\u2500 PersonListBoxController.class.php\n\u2502 \u2514\u2500\u2500 condition\n\u2502 \u2514\u2500\u2500 person\n\u2502 \u251c\u2500\u2500 PersonFirstNameTextPropertyCondition.class.php\n\u2502 \u2514\u2500\u2500 PersonLastNameTextPropertyCondition.class.php\n\u251c\u2500\u2500 language\n\u2502 \u251c\u2500\u2500 de.xml\n\u2502 \u2514\u2500\u2500 en.xml\n\u251c\u2500\u2500 objectType.xml\n\u251c\u2500\u2500 objectTypeDefinition.xml\n\u2514\u2500\u2500 templates\n \u2514\u2500\u2500 boxPersonList.tpl\n
For all changes, please refer to the source code on GitHub.
"},{"location":"tutorial/series/part_4/#box-controller","title":"Box Controller","text":"In addition to static boxes with fixed contents, administrators are able to create dynamic boxes with contents from the database. In our case here, we want administrators to be able to create boxes listing people. To do so, we first have to register a new object type for this person list box controller for the object type definition com.woltlab.wcf.boxController
:
<type>\n<name>com.woltlab.wcf.personList</name>\n<definitionname>com.woltlab.wcf.boxController</definitionname>\n<classname>wcf\\system\\box\\PersonListBoxController</classname>\n</type>\n
The com.woltlab.wcf.boxController
object type definition requires the provided class to implement wcf\\system\\box\\IBoxController
:
<?php\n\nnamespace wcf\\system\\box;\n\nuse wcf\\data\\person\\PersonList;\nuse wcf\\system\\WCF;\n\n/**\n * Dynamic box controller implementation for a list of persons.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2022 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\System\\Box\n */\nfinal class PersonListBoxController extends AbstractDatabaseObjectListBoxController\n{\n /**\n * @inheritDoc\n */\n protected $conditionDefinition = 'com.woltlab.wcf.box.personList.condition';\n\n /**\n * @inheritDoc\n */\n public $defaultLimit = 5;\n\n /**\n * @inheritDoc\n */\n protected $sortFieldLanguageItemPrefix = 'wcf.person';\n\n /**\n * @inheritDoc\n */\n protected static $supportedPositions = [\n 'sidebarLeft',\n 'sidebarRight',\n ];\n\n /**\n * @inheritDoc\n */\n public $validSortFields = [\n 'firstName',\n 'lastName',\n 'comments',\n ];\n\n /**\n * @inheritDoc\n */\n protected function getObjectList()\n {\n return new PersonList();\n }\n\n /**\n * @inheritDoc\n */\n protected function getTemplate()\n {\n return WCF::getTPL()->fetch('boxPersonList', 'wcf', [\n 'boxPersonList' => $this->objectList,\n 'boxSortField' => $this->sortField,\n 'boxPosition' => $this->box->position,\n ], true);\n }\n}\n
By extending AbstractDatabaseObjectListBoxController
, we only have to provide minimal data ourself and rely mostly on the default implementation provided by AbstractDatabaseObjectListBoxController
:
$conditionDefinition
.AbstractDatabaseObjectListBoxController
already supports restricting the number of listed objects. To do so, you only have to specify the default number of listed objects via $defaultLimit
.AbstractDatabaseObjectListBoxController
also supports setting the sort order of the listed objects. You have to provide the supported sort fields via $validSortFields
and specify the prefix used for the language items of the sort fields via $sortFieldLanguageItemPrefix
so that for every $validSortField
in $validSortFields
, the language item {$sortFieldLanguageItemPrefix}.{$validSortField}
must exist.$supportedPositions
. To keep the implementation simple here as different positions might require different output in the template, we restrict ourselves to sidebars.getObjectList()
returns an instance of DatabaseObjectList
that is used to read the listed objects. getObjectList()
itself must not call readObjects()
, as AbstractDatabaseObjectListBoxController
takes care of calling the method after adding the conditions and setting the sort order.getTemplate()
returns the contents of the box relying on the boxPersonList
template here:<ul class=\"sidebarItemList\">\n{foreach from=$boxPersonList item=boxPerson}\n <li class=\"box24\">\n <span class=\"icon icon24 fa-user\"></span>\n\n <div class=\"sidebarItemTitle\">\n <h3>{anchor object=$boxPerson}</h3>\n{capture assign='__boxPersonDescription'}{lang __optional=true}wcf.person.boxList.description.{$boxSortField}{/lang}{/capture}\n{if $__boxPersonDescription}\n <small>{@$__boxPersonDescription}</small>\n{/if}\n </div>\n </li>\n{/foreach}\n</ul>\n
The template relies on a .sidebarItemList
element, which is generally used for sidebar listings. (If different box positions were supported, we either have to generate different output by considering the value of $boxPosition
in the template or by using different templates in getTemplate()
.) One specific piece of code is the $__boxPersonDescription
variable, which supports an optional description below the person's name relying on the optional language item wcf.person.boxList.description.{$boxSortField}
. We only add one such language item when sorting the people by comments: In such a case, the number of comments will be shown. (When sorting by first and last name, there are no additional useful information that could be shown here, though the plugin from part 2 adding support for birthdays might also show the birthday when sorting by first or last name.)
Lastly, we also provide the language item wcf.acp.box.boxController.com.woltlab.wcf.personList
, which is used in the list of available box controllers.
The condition system can be used to generally filter a list of objects. In our case, the box system supports conditions to filter the objects shown in a specific box. Admittedly, our current person implementation only contains minimal data so that filtering might not make the most sense here but it will still show how to use the condition system for boxes. We will support filtering the people by their first and last name so that, for example, a box can be created listing all people with a specific first name.
The first step for condition support is to register a object type definition for the relevant conditions requiring the IObjectListCondition
interface:
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/5.4/objectTypeDefinition.xsd\">\n<import>\n<definition>\n<name>com.woltlab.wcf.box.personList.condition</name>\n<interfacename>wcf\\system\\condition\\IObjectListCondition</interfacename>\n</definition>\n</import>\n</data>\n
Next, we register the specific conditions for filtering by the first and last name using this object type condition:
<type>\n<name>com.woltlab.wcf.people.firstName</name>\n<definitionname>com.woltlab.wcf.box.personList.condition</definitionname>\n<classname>wcf\\system\\condition\\person\\PersonFirstNameTextPropertyCondition</classname>\n</type>\n<type>\n<name>com.woltlab.wcf.people.lastName</name>\n<definitionname>com.woltlab.wcf.box.personList.condition</definitionname>\n<classname>wcf\\system\\condition\\person\\PersonLastNameTextPropertyCondition</classname>\n</type>\n
PersonFirstNameTextPropertyCondition
and PersonLastNameTextPropertyCondition
only differ minimally so that we only focus on PersonFirstNameTextPropertyCondition
here, which relies on the default implementation AbstractObjectTextPropertyCondition
and only requires specifying different object properties:
<?php\n\nnamespace wcf\\system\\condition\\person;\n\nuse wcf\\data\\person\\Person;\nuse wcf\\system\\condition\\AbstractObjectTextPropertyCondition;\n\n/**\n * Condition implementation for the first name of a person.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2022 WoltLab GmbH\n * @license WoltLab License <http://www.woltlab.com/license-agreement.html>\n * @package WoltLabSuite\\Core\\System\\Condition\n */\nfinal class PersonFirstNameTextPropertyCondition extends AbstractObjectTextPropertyCondition\n{\n /**\n * @inheritDoc\n */\n protected $className = Person::class;\n\n /**\n * @inheritDoc\n */\n protected $description = 'wcf.person.condition.firstName.description';\n\n /**\n * @inheritDoc\n */\n protected $fieldName = 'personFirstName';\n\n /**\n * @inheritDoc\n */\n protected $label = 'wcf.person.firstName';\n\n /**\n * @inheritDoc\n */\n protected $propertyName = 'firstName';\n\n /**\n * @inheritDoc\n */\n protected $supportsMultipleValues = true;\n}\n
$className
contains the class name of the relevant database object from which the class name of the database object list is derived and $propertyName
is the name of the database object's property that contains the value used for filtering.$supportsMultipleValues
to true
, multiple comma-separated values can be specified so that, for example, a box can also only list people with either of two specific first names.$description
(optional), $fieldName
, and $label
are used in the output of the form field.(The implementation here is specific for AbstractObjectTextPropertyCondition
. The wcf\\system\\condition
namespace also contains several other default condition implementations.)
This part of our tutorial series lays the foundation for future parts in which we will be using additional APIs, which we have not used in this series yet. To make use of those APIs, we need content generated by users in the frontend.
"},{"location":"tutorial/series/part_5/#package-functionality","title":"Package Functionality","text":"In addition to the existing functions from part 4, the package will provide the following functionality after this part of the tutorial:
In addition to the components used in previous parts, we will use the form builder API to create forms shown in dialogs instead of dedicated pages and we will, for the first time, add TypeScript code.
"},{"location":"tutorial/series/part_5/#package-structure","title":"Package Structure","text":"The package will have the following file structure excluding unchanged files from previous parts:
\u251c\u2500\u2500 files\n\u2502 \u251c\u2500\u2500 acp\n\u2502 \u2502 \u2514\u2500\u2500 database\n\u2502 \u2502 \u2514\u2500\u2500 install_com.woltlab.wcf.people.php\n\u2502 \u251c\u2500\u2500 js\n\u2502 \u2502 \u2514\u2500\u2500 WoltLabSuite\n\u2502 \u2502 \u2514\u2500\u2500 Core\n\u2502 \u2502 \u2514\u2500\u2500 Controller\n\u2502 \u2502 \u2514\u2500\u2500 Person.js\n\u2502 \u2514\u2500\u2500 lib\n\u2502 \u251c\u2500\u2500 data\n\u2502 \u2502 \u2514\u2500\u2500 person\n\u2502 \u2502 \u251c\u2500\u2500 Person.class.php\n\u2502 \u2502 \u2514\u2500\u2500 information\n\u2502 \u2502 \u251c\u2500\u2500 PersonInformation.class.php\n\u2502 \u2502 \u251c\u2500\u2500 PersonInformationAction.class.php\n\u2502 \u2502 \u251c\u2500\u2500 PersonInformationEditor.class.php\n\u2502 \u2502 \u2514\u2500\u2500 PersonInformationList.class.php\n\u2502 \u2514\u2500\u2500 system\n\u2502 \u2514\u2500\u2500 worker\n\u2502 \u2514\u2500\u2500 PersonRebuildDataWorker.class.php\n\u251c\u2500\u2500 language\n\u2502 \u251c\u2500\u2500 de.xml\n\u2502 \u2514\u2500\u2500 en.xml\n\u251c\u2500\u2500 objectType.xml\n\u251c\u2500\u2500 templates\n\u2502 \u251c\u2500\u2500 person.tpl\n\u2502 \u2514\u2500\u2500 personList.tpl\n\u251c\u2500\u2500 ts\n\u2502 \u2514\u2500\u2500 WoltLabSuite\n\u2502 \u2514\u2500\u2500 Core\n\u2502 \u2514\u2500\u2500 Controller\n\u2502 \u2514\u2500\u2500 Person.ts\n\u2514\u2500\u2500 userGroupOption.xml\n
For all changes, please refer to the source code on GitHub.
"},{"location":"tutorial/series/part_5/#miscellaneous","title":"Miscellaneous","text":"Before we focus on the main aspects of this part, we mention some minor aspects that will be used later on:
mod.person.canEditInformation
and mod.person.canDeleteInformation
are moderative permissions to edit and delete any piece of information, regardless of who created it.user.person.canAddInformation
is the permission for users to add new pieces of information.user.person.canEditInformation
and user.person.canDeleteInformation
are the user permissions to edit and the piece of information they created.com.woltlab.wcf.message
: com.woltlab.wcf.people.information
.personList.tpl
has been adjusted to show the number of pieces of information in the person statistics section.The PHP file with the database layout has been updated as follows:
files/acp/database/install_com.woltlab.wcf.people.php<?php\n\nuse wcf\\system\\database\\table\\column\\DefaultTrueBooleanDatabaseTableColumn;\nuse wcf\\system\\database\\table\\column\\IntDatabaseTableColumn;\nuse wcf\\system\\database\\table\\column\\NotNullInt10DatabaseTableColumn;\nuse wcf\\system\\database\\table\\column\\NotNullVarchar255DatabaseTableColumn;\nuse wcf\\system\\database\\table\\column\\ObjectIdDatabaseTableColumn;\nuse wcf\\system\\database\\table\\column\\SmallintDatabaseTableColumn;\nuse wcf\\system\\database\\table\\column\\TextDatabaseTableColumn;\nuse wcf\\system\\database\\table\\column\\VarcharDatabaseTableColumn;\nuse wcf\\system\\database\\table\\DatabaseTable;\nuse wcf\\system\\database\\table\\index\\DatabaseTableForeignKey;\nuse wcf\\system\\database\\table\\index\\DatabaseTablePrimaryIndex;\n\nreturn [\n DatabaseTable::create('wcf1_person')\n ->columns([\n ObjectIdDatabaseTableColumn::create('personID'),\n NotNullVarchar255DatabaseTableColumn::create('firstName'),\n NotNullVarchar255DatabaseTableColumn::create('lastName'),\n NotNullInt10DatabaseTableColumn::create('informationCount')\n ->defaultValue(0),\n SmallintDatabaseTableColumn::create('comments')\n ->length(5)\n ->notNull()\n ->defaultValue(0),\n DefaultTrueBooleanDatabaseTableColumn::create('enableComments'),\n ])\n ->indices([\n DatabaseTablePrimaryIndex::create()\n ->columns(['personID']),\n ]),\n\n DatabaseTable::create('wcf1_person_information')\n ->columns([\n ObjectIdDatabaseTableColumn::create('informationID'),\n NotNullInt10DatabaseTableColumn::create('personID'),\n TextDatabaseTableColumn::create('information'),\n IntDatabaseTableColumn::create('userID')\n ->length(10),\n NotNullVarchar255DatabaseTableColumn::create('username'),\n VarcharDatabaseTableColumn::create('ipAddress')\n ->length(39)\n ->notNull(true)\n ->defaultValue(''),\n NotNullInt10DatabaseTableColumn::create('time'),\n ])\n ->indices([\n DatabaseTablePrimaryIndex::create()\n ->columns(['informationID']),\n ])\n ->foreignKeys([\n DatabaseTableForeignKey::create()\n ->columns(['personID'])\n ->referencedTable('wcf1_person')\n ->referencedColumns(['personID'])\n ->onDelete('CASCADE'),\n DatabaseTableForeignKey::create()\n ->columns(['userID'])\n ->referencedTable('wcf1_user')\n ->referencedColumns(['userID'])\n ->onDelete('SET NULL'),\n ]),\n];\n
informationCount
column.wcf1_person_information
table has been added for the PersonInformation
model. The meaning of the different columns is explained in the property documentation part of PersonInformation
's documentation (see below). The two foreign keys ensure that if a person is deleted, all of their information is also deleted, and that if a user is deleted, the userID
column is set to NULL
.<?php\n\nnamespace wcf\\data\\person\\information;\n\nuse wcf\\data\\DatabaseObject;\nuse wcf\\data\\person\\Person;\nuse wcf\\data\\user\\UserProfile;\nuse wcf\\system\\cache\\runtime\\PersonRuntimeCache;\nuse wcf\\system\\cache\\runtime\\UserProfileRuntimeCache;\nuse wcf\\system\\html\\output\\HtmlOutputProcessor;\nuse wcf\\system\\WCF;\n\n/**\n * Represents a piece of information for a person.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2021 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\Data\\Person\\Information\n *\n * @property-read int $informationID unique id of the information\n * @property-read int $personID id of the person the information belongs to\n * @property-read string $information information text\n * @property-read int|null $userID id of the user who added the information or `null` if the user no longer exists\n * @property-read string $username name of the user who added the information\n * @property-read int $time timestamp at which the information was created\n */\nclass PersonInformation extends DatabaseObject\n{\n /**\n * Returns `true` if the active user can delete this piece of information and `false` otherwise.\n */\n public function canDelete(): bool\n {\n if (\n WCF::getUser()->userID\n && WCF::getUser()->userID == $this->userID\n && WCF::getSession()->getPermission('user.person.canDeleteInformation')\n ) {\n return true;\n }\n\n return WCF::getSession()->getPermission('mod.person.canDeleteInformation');\n }\n\n /**\n * Returns `true` if the active user can edit this piece of information and `false` otherwise.\n */\n public function canEdit(): bool\n {\n if (\n WCF::getUser()->userID\n && WCF::getUser()->userID == $this->userID\n && WCF::getSession()->getPermission('user.person.canEditInformation')\n ) {\n return true;\n }\n\n return WCF::getSession()->getPermission('mod.person.canEditInformation');\n }\n\n /**\n * Returns the formatted information.\n */\n public function getFormattedInformation(): string\n {\n $processor = new HtmlOutputProcessor();\n $processor->process(\n $this->information,\n 'com.woltlab.wcf.people.information',\n $this->informationID\n );\n\n return $processor->getHtml();\n }\n\n /**\n * Returns the person the information belongs to.\n */\n public function getPerson(): Person\n {\n return PersonRuntimeCache::getInstance()->getObject($this->personID);\n }\n\n /**\n * Returns the user profile of the user who added the information.\n */\n public function getUserProfile(): UserProfile\n {\n if ($this->userID) {\n return UserProfileRuntimeCache::getInstance()->getObject($this->userID);\n } else {\n return UserProfile::getGuestUserProfile($this->username);\n }\n }\n}\n
PersonInformation
provides two methods, canDelete()
and canEdit()
, to check whether the active user can delete or edit a specific piece of information. In both cases, it is checked if the current user has created the relevant piece of information to check the user-specific permissions or to fall back to the moderator-specific permissions.
There also two getter methods for the person, the piece of information belongs to (getPerson()
), and for the user profile of the user who created the information (getUserProfile()
). In both cases, we use runtime caches, though in getUserProfile()
, we also have to consider the case of the user who created the information being deleted, i.e. userID
being null
. For such a case, we also save the name of the user who created the information in username
, so that we can return a guest user profile object in this case. The most interesting method is getFormattedInformation()
, which returns the HTML code of the information text meant for output. To generate such an output, HtmlOutputProcessor::process()
is used and here is where we first use the associated message object type com.woltlab.wcf.people.information
mentioned before.
While PersonInformationEditor
is simply the default implementation and thus not explicitly shown here, PersonInformationList::readObjects()
caches the relevant ids of the associated people and users who created the pieces of information using runtime caches:
<?php\n\nnamespace wcf\\data\\person\\information;\n\nuse wcf\\data\\DatabaseObjectList;\nuse wcf\\system\\cache\\runtime\\PersonRuntimeCache;\nuse wcf\\system\\cache\\runtime\\UserProfileRuntimeCache;\n\n/**\n * Represents a list of person information.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2021 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\Data\\PersonInformation\n *\n * @method PersonInformation current()\n * @method PersonInformation[] getObjects()\n * @method PersonInformation|null search($objectID)\n * @property PersonInformation[] $objects\n */\nclass PersonInformationList extends DatabaseObjectList\n{\n public function readObjects()\n {\n parent::readObjects();\n\n UserProfileRuntimeCache::getInstance()->cacheObjectIDs(\\array_unique(\\array_filter(\\array_column(\n $this->objects,\n 'userID'\n ))));\n PersonRuntimeCache::getInstance()->cacheObjectIDs(\\array_unique(\\array_column(\n $this->objects,\n 'personID'\n )));\n }\n}\n
"},{"location":"tutorial/series/part_5/#listing-and-deleting-person-information","title":"Listing and Deleting Person Information","text":"The person.tpl
template has been updated to include a block for listing the information at the beginning:
{capture assign='pageTitle'}{$person} - {lang}wcf.person.list{/lang}{/capture}\n\n{capture assign='contentTitle'}{$person}{/capture}\n\n{include file='header'}\n\n{if $person->informationCount || $__wcf->session->getPermission('user.person.canAddInformation')}\n <section class=\"section sectionContainerList\">\n <header class=\"sectionHeader\">\n <h2 class=\"sectionTitle\">\n{lang}wcf.person.information.list{/lang}\n{if $person->informationCount}\n <span class=\"badge\">{#$person->informationCount}</span>\n{/if}\n </h2>\n </header>\n\n <ul class=\"commentList containerList personInformationList jsObjectActionContainer\" {*\n *}data-object-action-class-name=\"wcf\\data\\person\\information\\PersonInformationAction\"{*\n *}>\n{if $__wcf->session->getPermission('user.person.canAddInformation')}\n <li class=\"containerListButtonGroup\">\n <ul class=\"buttonGroup\">\n <li>\n <a href=\"#\" class=\"button\" id=\"personInformationAddButton\">\n <span class=\"icon icon16 fa-plus\"></span>\n <span>{lang}wcf.person.information.add{/lang}</span>\n </a>\n </li>\n </ul>\n </li>\n{/if}\n\n{foreach from=$person->getInformation() item=$information}\n <li class=\"comment personInformation jsObjectActionObject\" data-object-id=\"{@$information->getObjectID()}\">\n <div class=\"box48{if $__wcf->getUserProfileHandler()->isIgnoredUser($information->userID, 2)} ignoredUserContent{/if}\">\n{user object=$information->getUserProfile() type='avatar48' ariaHidden='true' tabindex='-1'}\n\n <div class=\"commentContentContainer\">\n <div class=\"commentContent\">\n <div class=\"containerHeadline\">\n <h3>\n{if $information->userID}\n{user object=$information->getUserProfile()}\n{else}\n <span>{$information->username}</span>\n{/if}\n\n <small class=\"separatorLeft\">{@$information->time|time}</small>\n </h3>\n </div>\n\n <div class=\"htmlContent userMessage\" id=\"personInformation{@$information->getObjectID()}\">\n{@$information->getFormattedInformation()}\n </div>\n\n <nav class=\"jsMobileNavigation buttonGroupNavigation\">\n <ul class=\"buttonList iconList\">\n{if $information->canEdit()}\n <li class=\"jsOnly\">\n <a href=\"#\" title=\"{lang}wcf.global.button.edit{/lang}\" class=\"jsEditInformation jsTooltip\">\n <span class=\"icon icon16 fa-pencil\"></span>\n <span class=\"invisible\">{lang}wcf.global.button.edit{/lang}</span>\n </a>\n </li>\n{/if}\n{if $information->canDelete()}\n <li class=\"jsOnly\">\n <a href=\"#\" title=\"{lang}wcf.global.button.delete{/lang}\" class=\"jsObjectAction jsTooltip\" data-object-action=\"delete\" data-confirm-message=\"{lang}wcf.person.information.delete.confirmMessage{/lang}\">\n <span class=\"icon icon16 fa-times\"></span>\n <span class=\"invisible\">{lang}wcf.global.button.edit{/lang}</span>\n </a>\n </li>\n{/if}\n\n{event name='informationOptions'}\n </ul>\n </nav>\n </div>\n </div>\n </div>\n </li>\n{/foreach}\n </ul>\n </section>\n{/if}\n\n{if $person->enableComments}\n{if $commentList|count || $commentCanAdd}\n <section id=\"comments\" class=\"section sectionContainerList\">\n <header class=\"sectionHeader\">\n <h2 class=\"sectionTitle\">\n{lang}wcf.person.comments{/lang}\n{if $person->comments}<span class=\"badge\">{#$person->comments}</span>{/if}\n </h2>\n </header>\n\n{include file='__commentJavaScript' commentContainerID='personCommentList'}\n\n <div class=\"personComments\">\n <ul id=\"personCommentList\" class=\"commentList containerList\" {*\n *}data-can-add=\"{if $commentCanAdd}true{else}false{/if}\" {*\n *}data-object-id=\"{@$person->personID}\" {*\n *}data-object-type-id=\"{@$commentObjectTypeID}\" {*\n *}data-comments=\"{if $person->comments}{@$commentList->countObjects()}{else}0{/if}\" {*\n *}data-last-comment-time=\"{@$lastCommentTime}\" {*\n *}>\n{include file='commentListAddComment' wysiwygSelector='personCommentListAddComment'}\n{include file='commentList'}\n </ul>\n </div>\n </section>\n{/if}\n{/if}\n\n<footer class=\"contentFooter\">\n{hascontent}\n <nav class=\"contentFooterNavigation\">\n <ul>\n{content}{event name='contentFooterNavigation'}{/content}\n </ul>\n </nav>\n{/hascontent}\n</footer>\n\n<script data-relocate=\"true\">\n require(['Language', 'WoltLabSuite/Core/Controller/Person'], (Language, ControllerPerson) => {\n Language.addObject({\n 'wcf.person.information.add': '{jslang}wcf.person.information.add{/jslang}',\n 'wcf.person.information.add.success': '{jslang}wcf.person.information.add.success{/jslang}',\n 'wcf.person.information.edit': '{jslang}wcf.person.information.edit{/jslang}',\n 'wcf.person.information.edit.success': '{jslang}wcf.person.information.edit.success{/jslang}',\n });\n\n ControllerPerson.init({@$person->personID}, {\n canAddInformation: {if $__wcf->session->getPermission('user.person.canAddInformation')}true{else}false{/if},\n });\n });\n</script>\n\n{include file='footer'}\n
To keep things simple here, we reuse the structure and CSS classes used for comments. Additionally, we always list all pieces of information. If there are many pieces of information, a nicer solution would be a pagination or loading more pieces of information with JavaScript.
First, we note the jsObjectActionContainer
class in combination with the data-object-action-class-name
attribute, which are needed for the delete button for each piece of information, as explained here. In PersonInformationAction
, we have overridden the default implementations of validateDelete()
and delete()
which are called after clicking on a delete button. In validateDelete()
, we call PersonInformation::canDelete()
on all pieces of information to be deleted for proper permission validation, and in delete()
, we update the informationCount
values of the people the deleted pieces of information belong to (see below).
The button to add a new piece of information, #personInformationAddButton
, and the buttons to edit existing pieces of information, .jsEditInformation
, are controlled with JavaScript code initialized at the very end of the template.
Lastly, in create()
we provide default values for the time
, userID
, username
, and ipAddress
for cases like here when creating a new piece of information, where do not explicitly provide this data. Additionally, we extract the information text from the information_htmlInputProcessor
parameter provided by the associated WYSIWYG form field and update the number of pieces of information created for the relevant person.
To create new pieces of information or editing existing ones, we do not add new form controllers but instead use dialogs generated by the form builder API so that the user does not have to leave the person page.
When clicking on the add button or on any of the edit buttons, a dialog opens with the relevant form:
ts/WoltLabSuite/Core/Controller/Person.ts/**\n * Provides the JavaScript code for the person page.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2021 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @module WoltLabSuite/Core/Controller/Person\n */\n\nimport FormBuilderDialog from \"WoltLabSuite/Core/Form/Builder/Dialog\";\nimport * as Language from \"WoltLabSuite/Core/Language\";\nimport * as UiNotification from \"WoltLabSuite/Core/Ui/Notification\";\n\nlet addDialog: FormBuilderDialog;\nconst editDialogs = new Map<string, FormBuilderDialog>();\n\ninterface EditReturnValues {\nformattedInformation: string;\ninformationID: number;\n}\n\ninterface Options {\ncanAddInformation: true;\n}\n\n/**\n * Opens the edit dialog after clicking on the edit button for a piece of information.\n */\nfunction editInformation(event: Event): void {\nevent.preventDefault();\n\nconst currentTarget = event.currentTarget as HTMLElement;\nconst information = currentTarget.closest(\".jsObjectActionObject\") as HTMLElement;\nconst informationId = information.dataset.objectId!;\n\nif (!editDialogs.has(informationId)) {\neditDialogs.set(\ninformationId,\nnew FormBuilderDialog(\n`personInformationEditDialog${informationId}`,\n\"wcf\\\\data\\\\person\\\\information\\\\PersonInformationAction\",\n\"getEditDialog\",\n{\nactionParameters: {\ninformationID: informationId,\n},\ndialog: {\ntitle: Language.get(\"wcf.person.information.edit\"),\n},\nsubmitActionName: \"submitEditDialog\",\nsuccessCallback(returnValues: EditReturnValues) {\ndocument.getElementById(`personInformation${returnValues.informationID}`)!.innerHTML =\nreturnValues.formattedInformation;\n\nUiNotification.show(Language.get(\"wcf.person.information.edit.success\"));\n},\n},\n),\n);\n}\n\neditDialogs.get(informationId)!.open();\n}\n\n/**\n * Initializes the JavaScript code for the person page.\n */\nexport function init(personId: number, options: Options): void {\nif (options.canAddInformation) {\n// Initialize the dialog to add new information.\naddDialog = new FormBuilderDialog(\n\"personInformationAddDialog\",\n\"wcf\\\\data\\\\person\\\\information\\\\PersonInformationAction\",\n\"getAddDialog\",\n{\nactionParameters: {\npersonID: personId,\n},\ndialog: {\ntitle: Language.get(\"wcf.person.information.add\"),\n},\nsubmitActionName: \"submitAddDialog\",\nsuccessCallback() {\nUiNotification.show(Language.get(\"wcf.person.information.add.success\"), () => window.location.reload());\n},\n},\n);\n\ndocument.getElementById(\"personInformationAddButton\")!.addEventListener(\"click\", (event) => {\nevent.preventDefault();\n\naddDialog.open();\n});\n}\n\ndocument\n.querySelectorAll(\".jsEditInformation\")\n.forEach((el) => el.addEventListener(\"click\", (ev) => editInformation(ev)));\n}\n
We use the WoltLabSuite/Core/Form/Builder/Dialog
module, which takes care of the internal handling with regard to these dialogs. We only have to provide some data during for initializing these objects and call the open()
function after a button has been clicked.
Explanation of the initialization arguments for WoltLabSuite/Core/Form/Builder/Dialog
used here:
actionParameters
are additional parameters send during each AJAX request. Here, we either pass the id of the person for who a new piece of information is added or the id of the edited piece of information.dialog
contains the options for the dialog, see the DialogOptions
interface. Here, we only provide the title of the dialog.submitActionName
is the name of the method in the referenced PHP class that is called with the form data after submitting the form.successCallback
is called after the submit AJAX request was successful. After adding a new piece of information, we reload the page, and after editing an existing piece of information, we update the existing information text with the updated text. (Dynamically inserting a newly added piece of information instead of reloading the page would also be possible, of course, but for this tutorial series, we kept things simple.)Next, we focus on PersonInformationAction
, which actually provides the contents of these dialogs and creates and edits the information:
<?php\n\nnamespace wcf\\data\\person\\information;\n\nuse wcf\\data\\AbstractDatabaseObjectAction;\nuse wcf\\data\\person\\PersonAction;\nuse wcf\\data\\person\\PersonEditor;\nuse wcf\\system\\cache\\runtime\\PersonRuntimeCache;\nuse wcf\\system\\event\\EventHandler;\nuse wcf\\system\\exception\\IllegalLinkException;\nuse wcf\\system\\exception\\PermissionDeniedException;\nuse wcf\\system\\exception\\UserInputException;\nuse wcf\\system\\form\\builder\\container\\wysiwyg\\WysiwygFormContainer;\nuse wcf\\system\\form\\builder\\DialogFormDocument;\nuse wcf\\system\\html\\input\\HtmlInputProcessor;\nuse wcf\\system\\WCF;\nuse wcf\\util\\UserUtil;\n\n/**\n * Executes person information-related actions.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2021 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\Data\\Person\\Information\n *\n * @method PersonInformationEditor[] getObjects()\n * @method PersonInformationEditor getSingleObject()\n */\nclass PersonInformationAction extends AbstractDatabaseObjectAction\n{\n /**\n * @var DialogFormDocument\n */\n public $dialog;\n\n /**\n * @var PersonInformation\n */\n public $information;\n\n /**\n * @return PersonInformation\n */\n public function create()\n {\n if (!isset($this->parameters['data']['time'])) {\n $this->parameters['data']['time'] = TIME_NOW;\n }\n if (!isset($this->parameters['data']['userID'])) {\n $this->parameters['data']['userID'] = WCF::getUser()->userID;\n $this->parameters['data']['username'] = WCF::getUser()->username;\n }\n\n if (LOG_IP_ADDRESS) {\n if (!isset($this->parameters['data']['ipAddress'])) {\n $this->parameters['data']['ipAddress'] = UserUtil::getIpAddress();\n }\n } else {\n unset($this->parameters['data']['ipAddress']);\n }\n\n if (!empty($this->parameters['information_htmlInputProcessor'])) {\n /** @var HtmlInputProcessor $htmlInputProcessor */\n $htmlInputProcessor = $this->parameters['information_htmlInputProcessor'];\n $this->parameters['data']['information'] = $htmlInputProcessor->getHtml();\n }\n\n /** @var PersonInformation $information */\n $information = parent::create();\n\n (new PersonAction([$information->personID], 'update', [\n 'counters' => [\n 'informationCount' => 1,\n ],\n ]))->executeAction();\n\n return $information;\n }\n\n /**\n * @inheritDoc\n */\n public function update()\n {\n if (!empty($this->parameters['information_htmlInputProcessor'])) {\n /** @var HtmlInputProcessor $htmlInputProcessor */\n $htmlInputProcessor = $this->parameters['information_htmlInputProcessor'];\n $this->parameters['data']['information'] = $htmlInputProcessor->getHtml();\n }\n\n parent::update();\n }\n\n /**\n * @inheritDoc\n */\n public function validateDelete()\n {\n if (empty($this->objects)) {\n $this->readObjects();\n\n if (empty($this->objects)) {\n throw new UserInputException('objectIDs');\n }\n }\n\n foreach ($this->getObjects() as $informationEditor) {\n if (!$informationEditor->canDelete()) {\n throw new PermissionDeniedException();\n }\n }\n }\n\n /**\n * @inheritDoc\n */\n public function delete()\n {\n $deleteCount = parent::delete();\n\n if (!$deleteCount) {\n return $deleteCount;\n }\n\n $counterUpdates = [];\n foreach ($this->getObjects() as $informationEditor) {\n if (!isset($counterUpdates[$informationEditor->personID])) {\n $counterUpdates[$informationEditor->personID] = 0;\n }\n\n $counterUpdates[$informationEditor->personID]--;\n }\n\n WCF::getDB()->beginTransaction();\n foreach ($counterUpdates as $personID => $counterUpdate) {\n (new PersonEditor(PersonRuntimeCache::getInstance()->getObject($personID)))->updateCounters([\n 'informationCount' => $counterUpdate,\n ]);\n }\n WCF::getDB()->commitTransaction();\n\n return $deleteCount;\n }\n\n /**\n * Validates the `getAddDialog` action.\n */\n public function validateGetAddDialog(): void\n {\n WCF::getSession()->checkPermissions(['user.person.canAddInformation']);\n\n $this->readInteger('personID');\n if (PersonRuntimeCache::getInstance()->getObject($this->parameters['personID']) === null) {\n throw new UserInputException('personID');\n }\n }\n\n /**\n * Returns the data to show the dialog to add a new piece of information on a person.\n *\n * @return string[]\n */\n public function getAddDialog(): array\n {\n $this->buildDialog();\n\n return [\n 'dialog' => $this->dialog->getHtml(),\n 'formId' => $this->dialog->getId(),\n ];\n }\n\n /**\n * Validates the `submitAddDialog` action.\n */\n public function validateSubmitAddDialog(): void\n {\n $this->validateGetAddDialog();\n\n $this->buildDialog();\n $this->dialog->requestData($_POST['parameters']['data'] ?? []);\n $this->dialog->readValues();\n $this->dialog->validate();\n }\n\n /**\n * Creates a new piece of information on a person after submitting the dialog.\n *\n * @return string[]\n */\n public function submitAddDialog(): array\n {\n // If there are any validation errors, show the form again.\n if ($this->dialog->hasValidationErrors()) {\n return [\n 'dialog' => $this->dialog->getHtml(),\n 'formId' => $this->dialog->getId(),\n ];\n }\n\n (new static([], 'create', \\array_merge($this->dialog->getData(), [\n 'data' => [\n 'personID' => $this->parameters['personID'],\n ],\n ])))->executeAction();\n\n return [];\n }\n\n /**\n * Validates the `getEditDialog` action.\n */\n public function validateGetEditDialog(): void\n {\n WCF::getSession()->checkPermissions(['user.person.canAddInformation']);\n\n $this->readInteger('informationID');\n $this->information = new PersonInformation($this->parameters['informationID']);\n if (!$this->information->getObjectID()) {\n throw new UserInputException('informationID');\n }\n if (!$this->information->canEdit()) {\n throw new IllegalLinkException();\n }\n }\n\n /**\n * Returns the data to show the dialog to edit a piece of information on a person.\n *\n * @return string[]\n */\n public function getEditDialog(): array\n {\n $this->buildDialog();\n $this->dialog->updatedObject($this->information);\n\n return [\n 'dialog' => $this->dialog->getHtml(),\n 'formId' => $this->dialog->getId(),\n ];\n }\n\n /**\n * Validates the `submitEditDialog` action.\n */\n public function validateSubmitEditDialog(): void\n {\n $this->validateGetEditDialog();\n\n $this->buildDialog();\n $this->dialog->updatedObject($this->information, false);\n $this->dialog->requestData($_POST['parameters']['data'] ?? []);\n $this->dialog->readValues();\n $this->dialog->validate();\n }\n\n /**\n * Updates a piece of information on a person after submitting the edit dialog.\n *\n * @return string[]\n */\n public function submitEditDialog(): array\n {\n // If there are any validation errors, show the form again.\n if ($this->dialog->hasValidationErrors()) {\n return [\n 'dialog' => $this->dialog->getHtml(),\n 'formId' => $this->dialog->getId(),\n ];\n }\n\n (new static([$this->information], 'update', $this->dialog->getData()))->executeAction();\n\n // Reload the information with the updated data.\n $information = new PersonInformation($this->information->getObjectID());\n\n return [\n 'formattedInformation' => $information->getFormattedInformation(),\n 'informationID' => $this->information->getObjectID(),\n ];\n }\n\n /**\n * Builds the dialog to create or edit person information.\n */\n protected function buildDialog(): void\n {\n if ($this->dialog !== null) {\n return;\n }\n\n $this->dialog = DialogFormDocument::create('personInformationAddDialog')\n ->appendChild(\n WysiwygFormContainer::create('information')\n ->messageObjectType('com.woltlab.wcf.people.information')\n ->required()\n );\n\n EventHandler::getInstance()->fireAction($this, 'buildDialog');\n\n $this->dialog->build();\n }\n}\n
When setting up the WoltLabSuite/Core/Form/Builder/Dialog
object for adding new pieces of information, we specified getAddDialog
and submitAddDialog
as the names of the dialog getter and submit handler. In addition to these two methods, the matching validation methods validateGetAddDialog()
and validateGetAddDialog()
are also added. As the forms for adding and editing pieces of information have the same structure, this form is created in buildDialog()
using a DialogFormDocument
object, which is intended for forms in dialogs. We fire an event in buildDialog()
so that plugins are able to easily extend the dialog with additional data.
validateGetAddDialog()
checks if the user has the permission to create new pieces of information and if a valid id for the person, the information will belong to, is given. The method configured in the WoltLabSuite/Core/Form/Builder/Dialog
object returning the dialog is expected to return two values: the id of the form (formId
) and the contents of form shown in the dialog (dialog
). This data is returned by getAddDialog
using the dialog build previously by buildDialog()
.
After the form is submitted, validateSubmitAddDialog()
has to do the same basic validation as validateGetAddDialog()
so that validateGetAddDialog()
is simply called. Additionally, the form data is read and validated. In submitAddDialog()
, we first check if there have been any validation errors: If any error occured during validation, we return the same data as in getAddDialog()
so that the dialog is shown again with the erroneous fields marked as such. Otherwise, if the validation succeeded, the form data is used to create the new piece of information. In addition to the form data, we manually add the id of the person to whom the information belongs to. Lastly, we could return some data that we could access in the JavaScript callback function after successfully submitting the dialog. As we will simply be reloading the page, no such data is returned. An alternative to reloading to the page would be dynamically inserting the new piece of information in the list so that we would have to return the rendered list item for the new piece of information.
The process for getting and submitting the dialog to edit existing pieces of information is similar to the process for adding new pieces of information. Instead of the id of the person, however, we now pass the id of the edited piece of information and in submitEditDialog()
, we update the edited information instead of creating a new one like in submitAddDialog()
. After editing a piece of information, we do not reload the page but dynamically update the text of the information in the TypeScript code so that we return the updated rendered information text and id of the edited pieced of information in submitAddDialog()
.
To ensure the integrity of the person data, PersonRebuildDataWorker
updates the informationCount
counter:
<?php\n\nnamespace wcf\\system\\worker;\n\nuse wcf\\data\\person\\PersonList;\nuse wcf\\system\\WCF;\n\n/**\n * Worker implementation for updating people.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2022 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\System\\Worker\n *\n * @method PersonList getObjectList()\n */\nfinal class PersonRebuildDataWorker extends AbstractRebuildDataWorker\n{\n /**\n * @inheritDoc\n */\n protected $limit = 500;\n\n /**\n * @inheritDoc\n */\n protected $objectListClassName = PersonList::class;\n\n /**\n * @inheritDoc\n */\n protected function initObjectList()\n {\n parent::initObjectList();\n\n $this->objectList->sqlOrderBy = 'person.personID';\n }\n\n /**\n * @inheritDoc\n */\n public function execute()\n {\n parent::execute();\n\n if (!\\count($this->objectList)) {\n return;\n }\n\n $sql = \"UPDATE wcf1_person person\n SET informationCount = (\n SELECT COUNT(*)\n FROM wcf1_person_information person_information\n WHERE person_information.personID = person.personID\n )\n WHERE person.personID = ?\";\n $statement = WCF::getDB()->prepare($sql);\n\n WCF::getDB()->beginTransaction();\n foreach ($this->getObjectList() as $person) {\n $statement->execute([$person->personID]);\n }\n WCF::getDB()->commitTransaction();\n }\n}\n
"},{"location":"tutorial/series/part_5/#username-and-ip-address-event-listeners","title":"Username and IP Address Event Listeners","text":"As we store the name of the user who create a new piece of information and store their IP address, we have to add event listeners to properly handle the following scenarios:
username
stored with the person information has to be updated, which can be achieved by a simple event listener that only has to specify the name of relevant database table if AbstractUserActionRenameListener
is extended:<?php\n\nnamespace wcf\\system\\event\\listener;\n\n/**\n * Updates person information during user renaming.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2022 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\System\\Event\\Listener\n */\nfinal class PersonUserActionRenameListener extends AbstractUserActionRenameListener\n{\n /**\n * @inheritDoc\n */\n protected $databaseTables = [\n 'wcf{WCF_N}_person_information',\n ];\n}\n
AbstractUserMergeListener
is extended:<?php\n\nnamespace wcf\\system\\event\\listener;\n\n/**\n * Updates person information during user merging.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2022 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\System\\Event\\Listener\n */\nfinal class PersonUserMergeListener extends AbstractUserMergeListener\n{\n /**\n * @inheritDoc\n */\n protected $databaseTables = [\n 'wcf{WCF_N}_person_information',\n ];\n}\n
ipAddress
column to the time
column:<?php\n\nnamespace wcf\\system\\event\\listener;\n\nuse wcf\\system\\cronjob\\PruneIpAddressesCronjob;\n\n/**\n * Prunes old ip addresses.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2022 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\System\\Event\\Listener\n */\nfinal class PersonPruneIpAddressesCronjobListener extends AbstractEventListener\n{\n protected function onExecute(PruneIpAddressesCronjob $cronjob): void\n {\n $cronjob->columns['wcf' . WCF_N . '_person_information']['ipAddress'] = 'time';\n }\n}\n
<?php\n\nnamespace wcf\\system\\event\\listener;\n\nuse wcf\\acp\\action\\UserExportGdprAction;\n\n/**\n * Adds the ip addresses stored with the person information during user data export.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2022 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\System\\Event\\Listener\n */\nfinal class PersonUserExportGdprListener extends AbstractEventListener\n{\n protected function onExport(UserExportGdprAction $action): void\n {\n $action->ipAddresses['com.woltlab.wcf.people'] = ['wcf' . WCF_N . '_person_information'];\n }\n}\n
Lastly, we present the updated eventListener.xml
file with new entries for all of these event listeners:
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/5.4/eventListener.xsd\">\n<import>\n<eventlistener name=\"rename@wcf\\data\\user\\UserAction\">\n<eventclassname>wcf\\data\\user\\UserAction</eventclassname>\n<eventname>rename</eventname>\n<listenerclassname>wcf\\system\\event\\listener\\PersonUserActionRenameListener</listenerclassname>\n<environment>all</environment>\n</eventlistener>\n<eventlistener name=\"save@wcf\\acp\\form\\UserMergeForm\">\n<eventclassname>wcf\\acp\\form\\UserMergeForm</eventclassname>\n<eventname>save</eventname>\n<listenerclassname>wcf\\system\\event\\listener\\PersonUserMergeListener</listenerclassname>\n<environment>admin</environment>\n</eventlistener>\n<eventlistener name=\"execute@wcf\\system\\cronjob\\PruneIpAddressesCronjob\">\n<eventclassname>wcf\\system\\cronjob\\PruneIpAddressesCronjob</eventclassname>\n<eventname>execute</eventname>\n<listenerclassname>wcf\\system\\event\\listener\\PersonPruneIpAddressesCronjobListener</listenerclassname>\n<environment>all</environment>\n</eventlistener>\n<eventlistener name=\"export@wcf\\acp\\action\\UserExportGdprAction\">\n<eventclassname>wcf\\acp\\action\\UserExportGdprAction</eventclassname>\n<eventname>export</eventname>\n<listenerclassname>wcf\\system\\event\\listener\\PersonUserExportGdprListener</listenerclassname>\n<environment>admin</environment>\n</eventlistener>\n</import>\n</data>\n
"},{"location":"tutorial/series/part_6/","title":"Part 6: Activity Points and Activity Events","text":"In this part of our tutorial series, we use the person information added in the previous part to award activity points to users adding new pieces of information and to also create activity events for these pieces of information.
"},{"location":"tutorial/series/part_6/#package-functionality","title":"Package Functionality","text":"In addition to the existing functions from part 5, the package will provide the following functionality after this part of the tutorial:
In addition to the components used in previous parts, we will use the user activity points API and the user activity events API.
"},{"location":"tutorial/series/part_6/#package-structure","title":"Package Structure","text":"The package will have the following file structure excluding unchanged files from previous parts:
\u251c\u2500\u2500 files\n\u2502 \u2514\u2500\u2500 lib\n\u2502 \u251c\u2500\u2500 data\n\u2502 \u2502 \u2514\u2500\u2500 person\n\u2502 \u2502 \u251c\u2500\u2500 PersonAction.class.php\n\u2502 \u2502 \u2514\u2500\u2500 information\n\u2502 \u2502 \u251c\u2500\u2500 PersonInformation.class.php\n\u2502 \u2502 \u2514\u2500\u2500 PersonInformationAction.class.php\n\u2502 \u2514\u2500\u2500 system\n\u2502 \u251c\u2500\u2500 user\n\u2502 \u2502 \u2514\u2500\u2500 activity\n\u2502 \u2502 \u2514\u2500\u2500 event\n\u2502 \u2502 \u2514\u2500\u2500 PersonInformationUserActivityEvent.class.php\n\u2502 \u2514\u2500\u2500 worker\n\u2502 \u251c\u2500\u2500 PersonInformationRebuildDataWorker.class.php\n\u2502 \u2514\u2500\u2500 PersonRebuildDataWorker.class.php\n\u251c\u2500\u2500 eventListener.xml\n\u251c\u2500\u2500 language\n\u2502 \u251c\u2500\u2500 de.xml\n\u2502 \u2514\u2500\u2500 en.xml\n\u2514\u2500\u2500 objectType.xml\n
For all changes, please refer to the source code on GitHub.
"},{"location":"tutorial/series/part_6/#user-activity-points","title":"User Activity Points","text":"The first step to support activity points is to register an object type for the com.woltlab.wcf.user.activityPointEvent
object type definition for created person information and specify the default number of points awarded per piece of information:
<type>\n<name>com.woltlab.wcf.people.information</name>\n<definitionname>com.woltlab.wcf.user.activityPointEvent</definitionname>\n<points>2</points>\n</type>\n
Additionally, the phrase wcf.user.activityPoint.objectType.com.woltlab.wcf.people.information
(in general: wcf.user.activityPoint.objectType.{objectType}
) has to be added.
The activity points are awarded when new pieces are created via PersonInformation::create()
using UserActivityPointHandler::fireEvent()
and removed in PersonInformation::create()
via UserActivityPointHandler::removeEvents()
if pieces of information are deleted.
Lastly, we have to add two components for updating data: First, we register a new rebuild data worker
objectType.xml<type>\n<name>com.woltlab.wcf.people.information</name>\n<definitionname>com.woltlab.wcf.rebuildData</definitionname>\n<classname>wcf\\system\\worker\\PersonInformationRebuildDataWorker</classname>\n</type>\n
files/lib/system/worker/PersonInformationRebuildDataWorker.class.php <?php\n\nnamespace wcf\\system\\worker;\n\nuse wcf\\data\\person\\information\\PersonInformationList;\nuse wcf\\system\\user\\activity\\point\\UserActivityPointHandler;\n\n/**\n * Worker implementation for updating person information.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2022 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\System\\Worker\n *\n * @method PersonInformationList getObjectList()\n */\nfinal class PersonInformationRebuildDataWorker extends AbstractRebuildDataWorker\n{\n /**\n * @inheritDoc\n */\n protected $objectListClassName = PersonInformationList::class;\n\n /**\n * @inheritDoc\n */\n protected $limit = 500;\n\n /**\n * @inheritDoc\n */\n protected function initObjectList()\n {\n parent::initObjectList();\n\n $this->objectList->sqlOrderBy = 'person_information.personID';\n }\n\n /**\n * @inheritDoc\n */\n public function execute()\n {\n parent::execute();\n\n if (!$this->loopCount) {\n UserActivityPointHandler::getInstance()->reset('com.woltlab.wcf.people.information');\n }\n\n if (!\\count($this->objectList)) {\n return;\n }\n\n $itemsToUser = [];\n foreach ($this->getObjectList() as $personInformation) {\n if ($personInformation->userID) {\n if (!isset($itemsToUser[$personInformation->userID])) {\n $itemsToUser[$personInformation->userID] = 0;\n }\n\n $itemsToUser[$personInformation->userID]++;\n }\n }\n\n UserActivityPointHandler::getInstance()->fireEvents(\n 'com.woltlab.wcf.people.information',\n $itemsToUser,\n false\n );\n }\n}\n
which updates the number of instances for which any user received person information activity points. (This data worker also requires the phrases wcf.acp.rebuildData.com.woltlab.wcf.people.information
and wcf.acp.rebuildData.com.woltlab.wcf.people.information.description
).
Second, we add an event listener for UserActivityPointItemsRebuildDataWorker
to update the total user activity points awarded for person information:
<eventlistener name=\"execute@wcf\\system\\worker\\UserActivityPointItemsRebuildDataWorker\">\n<eventclassname>wcf\\system\\worker\\UserActivityPointItemsRebuildDataWorker</eventclassname>\n<eventname>execute</eventname>\n<listenerclassname>wcf\\system\\event\\listener\\PersonUserActivityPointItemsRebuildDataWorkerListener</listenerclassname>\n<environment>admin</environment>\n</eventlistener>\n
files/lib/system/event/listener/PersonUserActivityPointItemsRebuildDataWorkerListener.class.php <?php\n\nnamespace wcf\\system\\event\\listener;\n\nuse wcf\\system\\database\\util\\PreparedStatementConditionBuilder;\nuse wcf\\system\\user\\activity\\point\\UserActivityPointHandler;\nuse wcf\\system\\WCF;\nuse wcf\\system\\worker\\UserActivityPointItemsRebuildDataWorker;\n\n/**\n * Updates the user activity point items counter for person information.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2022 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\System\\Event\\Listener\n */\nfinal class PersonUserActivityPointItemsRebuildDataWorkerListener extends AbstractEventListener\n{\n protected function onExecute(UserActivityPointItemsRebuildDataWorker $worker): void\n {\n $objectType = UserActivityPointHandler::getInstance()\n ->getObjectTypeByName('com.woltlab.wcf.people.information');\n\n $conditionBuilder = new PreparedStatementConditionBuilder();\n $conditionBuilder->add('user_activity_point.objectTypeID = ?', [$objectType->objectTypeID]);\n $conditionBuilder->add('user_activity_point.userID IN (?)', [$worker->getObjectList()->getObjectIDs()]);\n\n $sql = \"UPDATE wcf1_user_activity_point user_activity_point\n SET user_activity_point.items = (\n SELECT COUNT(*)\n FROM wcf1_person_information person_information\n WHERE person_information.userID = user_activity_point.userID\n ),\n user_activity_point.activityPoints = user_activity_point.items * ?\n{$conditionBuilder}\";\n $statement = WCF::getDB()->prepare($sql);\n $statement->execute([\n $objectType->points,\n ...$conditionBuilder->getParameters()\n ]);\n }\n}\n
"},{"location":"tutorial/series/part_6/#user-activity-events","title":"User Activity Events","text":"To support user activity events, an object type for com.woltlab.wcf.user.recentActivityEvent
has to be registered with a class implementing wcf\\system\\user\\activity\\event\\IUserActivityEvent
:
<type>\n<name>com.woltlab.wcf.people.information</name>\n<definitionname>com.woltlab.wcf.user.recentActivityEvent</definitionname>\n<classname>wcf\\system\\user\\activity\\event\\PersonInformationUserActivityEvent</classname>\n</type>\n
files/lib/system/user/activity/event/PersonInformationUserActivityEvent.class.php <?php\n\nnamespace wcf\\system\\user\\activity\\event;\n\nuse wcf\\data\\person\\information\\PersonInformationList;\nuse wcf\\system\\SingletonFactory;\nuse wcf\\system\\WCF;\n\n/**\n * User activity event implementation for person information.\n *\n * @author Matthias Schmidt\n * @copyright 2001-2022 WoltLab GmbH\n * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>\n * @package WoltLabSuite\\Core\\System\\User\\Activity\\Event\n */\nfinal class PersonInformationUserActivityEvent extends SingletonFactory implements IUserActivityEvent\n{\n /**\n * @inheritDoc\n */\n public function prepare(array $events)\n {\n $objectIDs = \\array_column($events, 'objectID');\n\n $informationList = new PersonInformationList();\n $informationList->setObjectIDs($objectIDs);\n $informationList->readObjects();\n $information = $informationList->getObjects();\n\n foreach ($events as $event) {\n if (isset($information[$event->objectID])) {\n $personInformation = $information[$event->objectID];\n\n $event->setIsAccessible();\n $event->setTitle(\n WCF::getLanguage()->getDynamicVariable(\n 'wcf.user.profile.recentActivity.personInformation',\n [\n 'person' => $personInformation->getPerson(),\n 'personInformation' => $personInformation,\n ]\n )\n );\n $event->setDescription($personInformation->getFormattedExcerpt());\n }\n }\n }\n}\n
PersonInformationUserActivityEvent::prepare()
must check for all events whether the associated piece of information still exists and if it is the case, mark the event as accessible via the setIsAccessible()
method, set the title of the activity event via setTitle()
, and set a description of the event via setDescription()
for which we use the newly added PersonInformation::getFormattedExcerpt()
method.
Lastly, we have to add the phrase wcf.user.recentActivity.com.woltlab.wcf.people.information
, which is shown in the list of activity events as the type of activity event.
SCSS is a scripting language that features a syntax similar to CSS and compiles into native CSS at runtime. It provides many great additions to CSS such as declaration nesting and variables, it is recommended to read the official guide to learn more.
You can create .scss
files containing only pure CSS code and it will work just fine, you are at no point required to write actual SCSS code.
Please place your style files in a subdirectory of the style/
directory of the target application or the Core's style directory, for example style/layout/pageHeader.scss
.
You can access variables with $myVariable
, variable interpolation (variables inside strings) is accomplished with #{$myVariable}
.
Images used within a style must be located in the style's image folder. To get the folder name within the CSS the SCSS variable #{$style_image_path}
can be used. The value will contain a trailing slash.
Media breakpoints instruct the browser to apply different CSS depending on the viewport dimensions, e.g. serving a desktop PC a different view than when viewed on a smartphone.
/* red background color for desktop pc */\n@include screen-lg {\nbody {\nbackground-color: red;\n}\n}\n\n/* green background color on smartphones and tablets */\n@include screen-md-down {\nbody {\nbackground-color: green;\n}\n}\n
"},{"location":"view/css/#available-breakpoints","title":"Available Breakpoints","text":"Some very large smartphones, for example the Apple iPhone 7 Plus, do match the media query for Tablets (portrait)
when viewed in landscape mode.
@media
equivalent screen-xs
Smartphones only (max-width: 544px)
screen-sm
Tablets (portrait) (min-width: 545px) and (max-width: 768px)
screen-sm-down
Tablets (portrait) and smartphones (max-width: 768px)
screen-sm-up
Tablets and desktop PC (min-width: 545px)
screen-sm-md
Tablets only (min-width: 545px) and (max-width: 1024px)
screen-md
Tablets (landscape) (min-width: 769px) and (max-width: 1024px)
screen-md-down
Smartphones and tablets (max-width: 1024px)
screen-md-up
Tablets (landscape) and desktop PC (min-width: 769px)
screen-lg
Desktop PC (min-width: 1025px)
screen-lg-only
Desktop PC (min-width: 1025px) and (max-width: 1280px)
screen-lg-down
Smartphones, tablets, and desktop PC (max-width: 1280px)
screen-xl
Desktop PC (min-width: 1281px)
"},{"location":"view/css/#asset-preloading","title":"Asset Preloading","text":"WoltLab Suite\u2019s SCSS compiler supports adding preloading metadata to the CSS. To communicate the preloading intent to the compiler, the --woltlab-suite-preload
CSS variable is set to the result of the preload()
function:
.fooBar {\n--woltlab-suite-preload: #{preload(\n'#{$style_image_path}custom/background.png',\n$as: \"image\",\n$crossorigin: false,\n$type: \"image/png\"\n)};\n\nbackground: url('#{$style_image_path}custom/background.png');\n}\n
The parameters of the preload()
function map directly to the preloading properties that are used within the <link>
tag and the link:
HTTP response header.
The above example will result in a <link>
similar to the following being added to the generated HTML:
<link rel=\"preload\" href=\"https://example.com/images/style-1/custom/background.png\" as=\"image\" type=\"image/png\">\n
Use preloading sparingly for the most important resources where you can be certain that the browser will need them. Unused preloaded resources will unnecessarily waste bandwidth.
"},{"location":"view/languages-naming-conventions/","title":"Language Naming Conventions","text":"This page contains general rules for naming language items and for their values. API-specific rules are listed on the relevant API page:
If you have an application foo
and a database object foo\\data\\bar\\Bar
with a property baz
that can be set via a form field, the name of the corresponding language item has to be foo.bar.baz
. If you want to add an additional description below the field, use the language item foo.bar.baz.description
.
If an error of type {error type}
for the previously mentioned form field occurs during validation, you have to use the language item foo.bar.baz.error.{error type}
for the language item describing the error.
Exception to this rule: There are several general error messages like wcf.global.form.error.empty
that have to be used for general errors like an empty field that may not be empty to avoid duplication of the same error message text over and over again in different language items.
invalid
as error type.notUnique
as error type.If the language item for an action is foo.bar.action
, the language item for the confirmation message has to be foo.bar.action.confirmMessage
instead of foo.bar.action.sure
which is still used by some older language items.
{if LANGUAGE_USE_INFORMAL_VARIANT}Willst du{else}Wollen Sie{/if} {element type} wirklich l\u00f6schen?\n
Example:
{if LANGUAGE_USE_INFORMAL_VARIANT}Willst du{else}Wollen Sie{/if} das Icon wirklich l\u00f6schen?\n
"},{"location":"view/languages-naming-conventions/#english","title":"English","text":"Do you really want delete the {element type}?\n
Example:
Do you really want delete the icon?\n
"},{"location":"view/languages-naming-conventions/#object-specific-deletion-confirmation-message","title":"Object-Specific Deletion Confirmation Message","text":""},{"location":"view/languages-naming-conventions/#german_1","title":"German","text":"{if LANGUAGE_USE_INFORMAL_VARIANT}Willst du{else}Wollen Sie{/if} {element type} <span class=\"confirmationObject\">{object name}</span> wirklich l\u00f6schen?\n
Example:
{if LANGUAGE_USE_INFORMAL_VARIANT}Willst du{else}Wollen Sie{/if} den Artikel <span class=\"confirmationObject\">{$article->getTitle()}</span> wirklich l\u00f6schen?\n
"},{"location":"view/languages-naming-conventions/#english_1","title":"English","text":"Do you really want to delete the {element type} <span class=\"confirmationObject\">{object name}</span>?\n
Example:
Do you really want to delete the article <span class=\"confirmationObject\">{$article->getTitle()}</span>?\n
"},{"location":"view/languages-naming-conventions/#user-group-options","title":"User Group Options","text":""},{"location":"view/languages-naming-conventions/#comments","title":"Comments","text":""},{"location":"view/languages-naming-conventions/#german_2","title":"German","text":"group type action example permission name language item user adding user.foo.canAddComment
Kann Kommentare erstellen
user deleting user.foo.canDeleteComment
Kann eigene Kommentare l\u00f6schen
user editing user.foo.canEditComment
Kann eigene Kommentare bearbeiten
moderator deleting mod.foo.canDeleteComment
Kann Kommentare l\u00f6schen
moderator editing mod.foo.canEditComment
Kann Kommentare bearbeiten
moderator moderating mod.foo.canModerateComment
Kann Kommentare moderieren
"},{"location":"view/languages-naming-conventions/#english_2","title":"English","text":"group type action example permission name language item user adding user.foo.canAddComment
Can create comments
user deleting user.foo.canDeleteComment
Can delete their comments
user editing user.foo.canEditComment
Can edit their comments
moderator deleting mod.foo.canDeleteComment
Can delete comments
moderator editing mod.foo.canEditComment
Can edit comments
moderator moderating mod.foo.canModerateComment
Can moderate comments
"},{"location":"view/languages/","title":"Languages","text":"WoltLab Suite offers full i18n support with its integrated language system, including but not limited to dynamic phrases using template scripting and the built-in support for right-to-left languages.
Phrases are deployed using the language package installation plugin, please also read the naming conventions for language items.
"},{"location":"view/languages/#special-phrases","title":"Special Phrases","text":""},{"location":"view/languages/#wcfdatedateformat","title":"wcf.date.dateFormat
","text":"Many characters in the format have a special meaning and will be replaced with date fragments. If you want to include a literal character, you'll have to use the backslash \\
as an escape sequence to indicate that the character should be output as-is rather than being replaced. For example, Y-m-d
will be output as 2018-03-30
, but \\Y-m-d
will result in Y-03-30
.
Defaults to M jS Y
.
The date format without time using PHP's format characters for the date()
function. This value is also used inside the JavaScript implementation, where the characters are mapped to an equivalent representation.
wcf.date.timeFormat
","text":"Defaults to g:i a
.
The date format that is used to represent a time, but not a date. Please see the explanation on wcf.date.dateFormat
to learn more about the format characters.
wcf.date.firstDayOfTheWeek
","text":"Defaults to 0
.
Sets the first day of the week: * 0
- Sunday * 1
- Monday
wcf.global.pageDirection
- RTL support","text":"Defaults to ltr
.
Changing this value to rtl
will reverse the page direction and enable the right-to-left support for phrases. Additionally, a special version of the stylesheet is loaded that contains all necessary adjustments for the reverse direction.
{anchor}
","text":"The anchor
template plugin creates a
HTML elements. The easiest way to use the template plugin is to pass it an instance of ITitledLinkObject
:
{anchor object=$object}\n
generates the same output as
<a href=\"{$object->getLink()}\">{$object->getTitle()}</a>\n
Instead of an object
parameter, a link
and content
parameter can be used:
{anchor link=$linkObject content=$content}\n
where $linkObject
implements ILinkableObject
and $content
is either an object implementing ITitledObject
or having a __toString()
method or $content
is a string or a number.
The last special attribute is append
whose contents are appended to the href
attribute of the generated anchor element.
All of the other attributes matching ~^[a-z]+([A-z]+)+$~
, expect for href
which is disallowed, are added as attributes to the anchor element.
If an object
attribute is present, the object also implements IPopoverObject
and if the return value of IPopoverObject::getPopoverLinkClass()
is included in the class
attribute of the anchor
tag, data-object-id
is automatically added. This functionality makes it easy to generate links with popover support. Instead of
<a href=\"{$entry->getLink()}\" class=\"blogEntryLink\" data-object-id=\"{$entry->entryID}\">{$entry->subject}</a>\n
using
{anchor object=$entry class='blogEntryLink'}\n
is sufficient if Entry::getPopoverLinkClass()
returns blogEntryLink
.
{anchorAttributes}
","text":"anchorAttributes
compliments the StringUtil::getAnchorTagAttributes(string, bool): string
method. It allows to easily generate the necessary attributes for an anchor tag based off the destination URL.
<a href=\"https://www.example.com\" {anchorAttributes url='https://www.example.com' appendHref=false appendClassname=true isUgc=true}>\n
Attribute Description url
destination URL appendHref
whether the href
attribute should be generated; true
by default isUgc
whether the rel=\"ugc\"
attribute should be generated; false
by default appendClassname
whether the class=\"externalURL\"
attribute should be generated; true
by default"},{"location":"view/template-plugins/#append","title":"{append}
","text":"If a string should be appended to the value of a variable, append
can be used:
{assign var=templateVariable value='newValue'}\n\n{$templateVariable} {* prints 'newValue *}\n\n{append var=templateVariable value='2'}\n\n{$templateVariable} {* now prints 'newValue2 *}\n
If the variables does not exist yet, append
creates a new one with the given value. If append
is used on an array as the variable, the value is appended to all elements of the array.
{assign}
","text":"New template variables can be declared and new values can be assigned to existing template variables using assign
:
{assign var=templateVariable value='newValue'}\n\n{$templateVariable} {* prints 'newValue *}\n
"},{"location":"view/template-plugins/#capture","title":"{capture}
","text":"In some situations, assign
is not sufficient to assign values to variables in templates if the value is complex. Instead, capture
can be used:
{capture var=templateVariable}\n{if $foo}\n <p>{$bar}</p>\n{else}\n <small>{$baz}</small>\n{/if}\n{/capture}\n
"},{"location":"view/template-plugins/#concat","title":"|concat
","text":"concat
is a modifier used to concatenate multiple strings:
{assign var=foo value='foo'}\n\n{assign var=templateVariable value='bar'|concat:$foo}\n\n{$templateVariable} {* prints 'foobar *}\n
"},{"location":"view/template-plugins/#counter","title":"{counter}
","text":"counter
can be used to generate and optionally print a counter:
{counter name=fooCounter print=true} {* prints '1' *}\n\n{counter name=fooCounter print=true} {* prints '2' now *}\n\n{counter name=fooCounter} {* prints nothing, but counter value is '3' now internally *}\n\n{counter name=fooCounter print=true} {* prints '4' *}\n
Counter supports the following attributes:
Attribute Descriptionassign
optional name of the template variable the current counter value is assigned to direction
counting direction, either up
or down
; up
by default name
name of the counter, relevant if multiple counters are used simultaneously print
if true
, the current counter value is printed; false
by default skip
positive counting increment; 1
by default start
start counter value; 1
by default"},{"location":"view/template-plugins/#54-csrftoken","title":"5.4+ csrfToken
","text":"{csrfToken}
prints out the session's CSRF token (\u201cSecurity Token\u201d).
<form action=\"{link controller=\"Foo\"}{/link}\" method=\"post\">\n{* snip *}\n\n{csrfToken}\n</form>\n
The {csrfToken}
template plugin supports a type
parameter. Specifying this parameter might be required in rare situations. Please check the implementation for details.
|currency
","text":"currency
is a modifier used to format currency values with two decimals using language dependent thousands separators and decimal point:
{assign var=currencyValue value=12.345}\n\n{$currencyValue|currency} {* prints '12.34' *}\n
"},{"location":"view/template-plugins/#cycle","title":"{cycle}
","text":"cycle
can be used to cycle between different values:
{cycle name=fooCycle values='bar,baz'} {* prints 'bar' *}\n\n{cycle name=fooCycle} {* prints 'baz' *}\n\n{cycle name=fooCycle advance=false} {* prints 'baz' again *}\n\n{cycle name=fooCycle} {* prints 'bar' *}\n
The values attribute only has to be present for the first call. If cycle
is used in a loop, the presence of the same values in consecutive calls has no effect. Only once the values change, the cycle is reset.
advance
if true
, the current cycle value is advanced to the next value; true
by default assign
optional name of the template variable the current cycle value is assigned to; if used, print
is set to false
delimiter
delimiter between the different cycle values; ,
by default name
name of the cycle, relevant if multiple cycles are used simultaneously print
if true
, the current cycle value is printed, false
by default reset
if true
, the current cycle value is set to the first value, false
by default values
string containing the different cycles values, also see delimiter
"},{"location":"view/template-plugins/#date","title":"|date
","text":"date
generated a formatted date using wcf\\util\\DateUtil::format()
with DateUtil::DATE_FORMAT
internally.
{$timestamp|date}\n
"},{"location":"view/template-plugins/#dateinterval","title":"{dateInterval}
","text":"dateInterval
calculates the difference between two unix timestamps and generated a textual date interval.
{dateInterval start=$startTimestamp end=$endTimestamp full=true format='sentence'}\n
Attribute Description end
end of the time interval; current timestamp by default (though either start
or end
has to be set) format
output format, either default
, sentence
, or plain
; defaults to default
, see wcf\\util\\DateUtil::FORMAT_*
constants full
if true
, full difference in minutes is shown; if false
, only the longest time interval is shown; false
by default start
start of the time interval; current timestamp by default (though either start
or end
has to be set)"},{"location":"view/template-plugins/#encodejs","title":"|encodeJS
","text":"encodeJS
encodes a string to be used as a single-quoted string in JavaScript by replacing \\\\
with \\\\\\\\
, '
with \\'
, linebreaks with \\n
, and /
with \\/
.
<script>\n var foo = '{@$foo|encodeJS}';\n</script>\n
"},{"location":"view/template-plugins/#escapecdata","title":"|escapeCDATA
","text":"escapeCDATA
encodes a string to be used in a CDATA
element by replacing ]]>
with ]]]]><![CDATA[>
.
<![CDATA[{@$foo|encodeCDATA}]]>\n
"},{"location":"view/template-plugins/#event","title":"{event}
","text":"event
provides extension points in templates that template listeners can use.
{event name='foo'}\n
"},{"location":"view/template-plugins/#filesizebinary","title":"|filesizeBinary
","text":"filesizeBinary
formats the filesize using binary filesize (in bytes).
{$filesize|filesizeBinary}\n
"},{"location":"view/template-plugins/#filesize","title":"|filesize
","text":"filesize
formats the filesize using filesize (in bytes).
{$filesize|filesize}\n
"},{"location":"view/template-plugins/#hascontent","title":"{hascontent}
","text":"In many cases, conditional statements can be used to determine if a certain section of a template is shown:
{if $foo === 'bar'}\n only shown if $foo is bar\n{/if}\n
In some situations, however, such conditional statements are not sufficient. One prominent example is a template event:
{if $foo === 'bar'}\n <ul>\n{if $foo === 'bar'}\n <li>Bar</li>\n{/if}\n\n{event name='listItems'}\n </li>\n{/if}\n
In this example, if $foo !== 'bar'
, the list will not be shown, regardless of the additional template code provided by template listeners. In such a situation, hascontent
has to be used:
{hascontent}\n <ul>\n{content}\n{if $foo === 'bar'}\n <li>Bar</li>\n{/if}\n\n{event name='listItems'}\n{/content}\n </ul>\n{/hascontent}\n
If the part of the template wrapped in the content
tags has any (trimmed) content, the part of the template wrapped by hascontent
tags is shown (including the part wrapped by the content
tags), otherwise nothing is shown. Thus, this construct avoids an empty list compared to the if
solution above.
Like foreach
, hascontent
also supports an else
part:
{hascontent}\n <ul>\n{content}\n{* \u2026 *}\n{/content}\n </ul>\n{hascontentelse}\n no list\n{/hascontent}\n
"},{"location":"view/template-plugins/#htmlcheckboxes","title":"{htmlCheckboxes}
","text":"htmlCheckboxes
generates a list of HTML checkboxes.
{htmlCheckboxes name=foo options=$fooOptions selected=$currentFoo}\n\n{htmlCheckboxes name=bar output=$barLabels values=$barValues selected=$currentBar}\n
Attribute Description disabled
if true
, all checkboxes are disabled disableEncoding
if true
, the values are not passed through wcf\\util\\StringUtil::encodeHTML()
; false
by default name
name
attribute of the input
checkbox element output
array used as keys and values for options
if present; not present by default options
array selectable options with the key used as value
attribute and the value as the checkbox label selected
current selected value(s) separator
separator between the different checkboxes in the generated output; empty string by default values
array with values used in combination with output
, where output
is only used as keys for options
"},{"location":"view/template-plugins/#htmloptions","title":"{htmlOptions}
","text":"htmlOptions
generates an select
HTML element.
{htmlOptions name='foo' options=$options selected=$selected}\n\n<select name=\"bar\">\n <option value=\"\"{if !$selected} selected{/if}>{lang}foo.bar.default{/lang}</option>\n{htmlOptions options=$options selected=$selected} {* no `name` attribute *}\n</select>\n
Attribute Description disableEncoding
if true
, the values are not passed through wcf\\util\\StringUtil::encodeHTML()
; false
by default object
optional instance of wcf\\data\\DatabaseObjectList
that provides the selectable options (overwrites options
attribute internally) name
name
attribute of the select
element; if not present, only the contents of the select
element are printed output
array used as keys and values for options
if present; not present by default values
array with values used in combination with output
, where output
is only used as keys for options
options
array selectable options with the key used as value
attribute and the value as the option label; if a value is an array, an optgroup
is generated with the array key as the optgroup
label selected
current selected value(s) All additional attributes are added as attributes of the select
HTML element.
{implode}
","text":"implodes
transforms an array into a string and prints it.
{implode from=$array key=key item=item glue=\";\"}{$key}: {$value}{/implode}\n
Attribute Description from
array with the imploded values glue
separator between the different array values; ', '
by default item
template variable name where the current array value is stored during the iteration key
optional template variable name where the current array key is stored during the iteration"},{"location":"view/template-plugins/#ipsearch","title":"|ipSearch
","text":"ipSearch
generates a link to search for an IP address.
{\"127.0.0.1\"|ipSearch}\n
"},{"location":"view/template-plugins/#js","title":"{js}
","text":"js
generates script tags based on whether ENABLE_DEBUG_MODE
and VISITOR_USE_TINY_BUILD
are enabled.
{js application='wbb' file='WBB'} {* generates 'http://example.com/js/WBB.js' *}\n\n{js application='wcf' file='WCF.User' bundle='WCF.Combined'}\n{* generates 'http://example.com/wcf/js/WCF.User.js' if ENABLE_DEBUG_MODE=1 *}\n{* generates 'http://example.com/wcf/js/WCF.Combined.min.js' if ENABLE_DEBUG_MODE=0 *}\n\n{js application='wcf' lib='jquery'}\n{* generates 'http://example.com/wcf/js/3rdParty/jquery.js' *}\n\n{js application='wcf' lib='jquery-ui' file='awesomeWidget'}\n{* generates 'http://example.com/wcf/js/3rdParty/jquery-ui/awesomeWidget.js' *}\n\n{js application='wcf' file='WCF.User' bundle='WCF.Combined' hasTiny=true}\n{* generates 'http://example.com/wcf/js/WCF.User.js' if ENABLE_DEBUG_MODE=1 *}\n{* generates 'http://example.com/wcf/js/WCF.Combined.min.js' (ENABLE_DEBUG_MODE=0 *}\n{* generates 'http://example.com/wcf/js/WCF.Combined.tiny.min.js' if ENABLE_DEBUG_MODE=0 and VISITOR_USE_TINY_BUILD=1 *}\n
"},{"location":"view/template-plugins/#jslang","title":"{jslang}
","text":"jslang
works like lang
with the difference that the resulting string is automatically passed through encodeJS
.
require(['Language', /* \u2026 */], function(Language, /* \u2026 */) {\n Language.addObject({\n 'app.foo.bar': '{jslang}app.foo.bar{/jslang}',\n });\n\n // \u2026\n});\n
"},{"location":"view/template-plugins/#55-json","title":"5.5+ |json
","text":"json
JSON-encodes the given value.
<script>\nlet data = { \"title\": {@$foo->getTitle()|json} };\n</script>\n
"},{"location":"view/template-plugins/#60-jsphrase","title":"6.0+ {jsphrase}
","text":"jsphrase
generates the necessary JavaScript code to register a phrase in the JavaScript language store. This plugin only supports static phrase names. If a dynamic phrase should be registered, the jslang
plugin needs to be used.
<script data-relocate=\"true\">\n{jsphrase name='app.foo.bar'}\n\n// \u2026\n</script>\n
"},{"location":"view/template-plugins/#lang","title":"{lang}
","text":"lang
replaces a language items with its value.
{lang}foo.bar.baz{/lang}\n\n{lang __literal=true}foo.bar.baz{/lang}\n\n{lang foo='baz'}foo.bar.baz{/lang}\n\n{lang}foo.bar.baz.{$action}{/lang}\n
Attribute Description __encode
if true
, the output will be passed through StringUtil::encodeHTML()
__literal
if true
, template variables will not resolved but printed as they are in the language item; false
by default __optional
if true
and the language item does not exist, an empty string is printed; false
by default All additional attributes are available when parsing the language item.
"},{"location":"view/template-plugins/#language","title":"|language
","text":"language
replaces a language items with its value. If the template variable __language
exists, this language object will be used instead of WCF::getLanguage()
. This modifier is useful when assigning the value directly to a variable.
Note that template scripting is applied to the output of the variable, which can lead to unwanted side effects. Use phrase
instead if you don't want to use template scripting.
{$languageItem|language}\n\n{assign var=foo value=$languageItem|language}\n
"},{"location":"view/template-plugins/#link","title":"{link}
","text":"link
generates internal links using LinkHandler
.
<a href=\"{link controller='FooList' application='bar'}param1=2¶m2=A{/link}\">Foo</a>\n
Attribute Description application
abbreviation of the application the controller belongs to; wcf
by default controller
name of the controller; if not present, the landing page is linked in the frontend and the index page in the ACP encode
if true
, the generated link is passed through wcf\\util\\StringUtil::encodeHTML()
; true
by default isEmail
sets encode=false
and forces links to link to the frontend Additional attributes are passed to LinkHandler::getLink()
.
|newlineToBreak
","text":"newlineToBreak
transforms newlines into HTML <br>
elements after encoding the content via wcf\\util\\StringUtil::encodeHTML()
.
{$foo|newlineToBreak}\n
"},{"location":"view/template-plugins/#54-objectaction","title":"5.4+ objectAction
","text":"objectAction
generates action buttons to be used in combination with the WoltLabSuite/Core/Ui/Object/Action
API. For detailed information on its usage, we refer to the extensive documentation in the ObjectActionFunctionTemplatePlugin
class itself.
{page}
","text":"page
generates an internal link to a CMS page.
{page}com.woltlab.wcf.CookiePolicy{/page}\n\n{page pageID=1}{/page}\n\n{page language='de'}com.woltlab.wcf.CookiePolicy{/page}\n\n{page languageID=2}com.woltlab.wcf.CookiePolicy{/page}\n
Attribute Description pageID
unique id of the page (cannot be used together with a page identifier as value) languageID
id of the page language (cannot be used together with language
) language
language code of the page language (cannot be used together with languageID
)"},{"location":"view/template-plugins/#pages","title":"{pages}
","text":"This template plugin has been deprecated in WoltLab Suite 6.0.
pages
generates a pagination.
{pages controller='FooList' link=\"pageNo=%d\" print=true assign=pagesLinks} {* prints pagination *}\n\n{@$pagesLinks} {* prints same pagination again *}\n
Attribute Description assign
optional name of the template variable the pagination is assigned to controller
controller name of the generated links link
additional link parameter where %d
will be replaced with the relevant page number pages
maximum number of of pages; by default, the template variable $pages
is used print
if false
and assign=true
, the pagination is not printed application
, id
, object
, title
additional parameters passed to LinkHandler::getLink()
to generate page links"},{"location":"view/template-plugins/#55-phrase","title":"5.5+ |phrase
","text":"phrase
replaces a language items with its value. If the template variable __language
exists, this language object will be used instead of WCF::getLanguage()
. This modifier is useful when assigning the value directly to a variable.
phrase
should be used instead of language
unless you want to explicitly allow template scripting on a variable's output.
{$languageItem|phrase}\n\n{assign var=foo value=$languageItem|phrase}\n
"},{"location":"view/template-plugins/#plaintime","title":"|plainTime
","text":"plainTime
formats a timestamp to include year, month, day, hour, and minutes. The exact formatting depends on the current language (via the language items wcf.date.dateTimeFormat
, wcf.date.dateFormat
, and wcf.date.timeFormat
).
{$timestamp|plainTime}\n
"},{"location":"view/template-plugins/#plural","title":"{plural}
","text":"plural
allows to easily select the correct plural form of a phrase based on a given value
. The pluralization logic follows the Unicode Language Plural Rules for cardinal numbers.
The #
placeholder within the resulting phrase is replaced by the value
. It is automatically formatted using StringUtil::formatNumeric
.
English:
Note the use of 1
if the number (#
) is not used within the phrase and the use of one
otherwise. They are equivalent for English, but following this rule generalizes better to other languages, helping the translator.
{assign var=numberOfWorlds value=2}\n<h1>Hello {plural value=$numberOfWorlds 1='World' other='Worlds'}!</h1>\n<p>There {plural value=$numberOfWorlds 1='is one world' other='are # worlds'}!</p>\n<p>There {plural value=$numberOfWorlds one='is # world' other='are # worlds'}!</p>\n
German:
{assign var=numberOfWorlds value=2}\n<h1>Hallo {plural value=$numberOfWorlds 1='Welt' other='Welten'}!</h1>\n<p>Es gibt {plural value=$numberOfWorlds 1='eine Welt' other='# Welten'}!</p>\n<p>Es gibt {plural value=$numberOfWorlds one='# Welt' other='# Welten'}!</p>\n
Romanian:
Note the additional use of few
which is not required in English or German.
{assign var=numberOfWorlds value=2}\n<h1>Salut {plural value=$numberOfWorlds 1='lume' other='lumi'}!</h1>\n<p>Exist\u0103 {plural value=$numberOfWorlds 1='o lume' few='# lumi' other='# de lumi'}!</p>\n<p>Exist\u0103 {plural value=$numberOfWorlds one='# lume' few='# lumi' other='# de lumi'}!</p>\n
Russian:
Note the difference between 1
(exactly 1
) and one
(ending in 1
, except ending in 11
).
{assign var=numberOfWorlds value=2}\n<h1>\u041f\u0440\u0438\u0432\u0435\u0442 {plural value=$numberOfWorld 1='\u043c\u0438\u0440' other='\u043c\u0438\u0440\u044b'}!</h1>\n<p>\u0415\u0441\u0442\u044c {plural value=$numberOfWorlds 1='\u043c\u0438\u0440' one='# \u043c\u0438\u0440' few='# \u043c\u0438\u0440\u0430' many='# \u043c\u0438\u0440\u043e\u0432' other='# \u043c\u0438\u0440\u043e\u0432'}!</p>\n
Attribute Description value The value that is used to select the proper phrase. other The phrase that is used when no other selector matches. Any Category Name The phrase that is used when value
belongs to the named category. Available categories depend on the language. Any Integer The phrase that is used when value
is that exact integer."},{"location":"view/template-plugins/#prepend","title":"{prepend}
","text":"If a string should be prepended to the value of a variable, prepend
can be used:
{assign var=templateVariable value='newValue'}\n\n{$templateVariable} {* prints 'newValue *}\n\n{prepend var=templateVariable value='2'}\n\n{$templateVariable} {* now prints '2newValue' *}\n
If the variables does not exist yet, prepend
creates a new one with the given value. If prepend
is used on an array as the variable, the value is prepended to all elements of the array.
|shortUnit
","text":"shortUnit
shortens numbers larger than 1000 by using unit suffixes:
{10000|shortUnit} {* prints 10k *}\n{5400000|shortUnit} {* prints 5.4M *}\n
"},{"location":"view/template-plugins/#tablewordwrap","title":"|tableWordwrap
","text":"tableWordwrap
inserts zero width spaces every 30 characters in words longer than 30 characters.
{$foo|tableWordwrap}\n
"},{"location":"view/template-plugins/#time","title":"|time
","text":"time
generates an HTML time
elements based on a timestamp that shows a relative time or the absolute time if the timestamp more than six days ago.
{$timestamp|time} {* prints a '<time>' element *}\n
"},{"location":"view/template-plugins/#truncate","title":"|truncate
","text":"truncate
truncates a long string into a shorter one:
{$foo|truncate:35}\n\n{$foo|truncate:35:'_':true}\n
Parameter Number Description 0 truncated string 1 truncated length; 80
by default 2 ellipsis symbol; wcf\\util\\StringUtil::HELLIP
by default 3 if true
, words can be broken up in the middle; false
by default"},{"location":"view/template-plugins/#user","title":"{user}
","text":"user
generates links to user profiles. The mandatory object
parameter requires an instances of UserProfile
. The optional type
parameter is responsible for what the generated link contains:
type='default'
(also applies if no type
is given) outputs the formatted username relying on the \u201cUser Marking\u201d setting of the relevant user group. Additionally, the user popover card will be shown when hovering over the generated link.type='plain'
outputs the username without additional formatting.type='avatar(\\d+)'
outputs the user\u2019s avatar in the specified size, i.e., avatar48
outputs the avatar with a width and height of 48 pixels.The last special attribute is append
whose contents are appended to the href
attribute of the generated anchor element.
All of the other attributes matching ~^[a-z]+([A-z]+)+$~
, except for href
which may not be added, are added as attributes to the anchor element.
Examples:
{user object=$user}\n
generates
<a href=\"{$user->getLink()}\" data-object-id=\"{$user->userID}\" class=\"userLink\">{@$user->getFormattedUsername()}</a>\n
and
{user object=$user type='avatar48' foo='bar'}\n
generates
<a href=\"{$user->getLink()}\" foo=\"bar\">{@$object->getAvatar()->getImageTag(48)}</a>\n
"},{"location":"view/templates/","title":"Templates","text":"Templates are responsible for the output a user sees when requesting a page (while the PHP code is responsible for providing the data that will be shown). Templates are text files with .tpl
as the file extension. WoltLab Suite Core compiles the template files once into a PHP file that is executed when a user requests the page. In subsequent request, as the PHP file containing the compiled template already exists, compiling the template is not necessary anymore.
WoltLab Suite Core supports two types of templates: frontend templates (or simply templates) and backend templates (ACP templates). Each type of template is only available in its respective domain, thus frontend templates cannot be included or used in the ACP and vice versa.
For pages and forms, the name of the template matches the unqualified name of the PHP class except for the Page
or Form
suffix:
RegisterForm.class.php
\u2192 register.tpl
UserPage.class.php
\u2192 user.tpl
If you follow this convention, WoltLab Suite Core will automatically determine the template name so that you do not have to explicitly set it.
For forms that handle creating and editing objects, in general, there are two form classes: FooAddForm
and FooEditForm
. WoltLab Suite Core, however, generally only uses one template fooAdd.tpl
and the template variable $action
to distinguish between creating a new object ($action = 'add'
) and editing an existing object ($action = 'edit'
) as the differences between templates for adding and editing an object are minimal.
Templates and ACP templates are installed by two different package installation plugins: the template PIP and the ACP template PIP. More information about installing templates can be found on those pages.
"},{"location":"view/templates/#base-templates","title":"Base Templates","text":""},{"location":"view/templates/#frontend","title":"Frontend","text":"{include file='header'}\n\n{* content *}\n\n{include file='footer'}\n
"},{"location":"view/templates/#backend","title":"Backend","text":"{include file='header' pageTitle='foo.bar.baz'}\n\n<header class=\"contentHeader\">\n <div class=\"contentHeaderTitle\">\n <h1 class=\"contentTitle\">Title</h1>\n </div>\n\n <nav class=\"contentHeaderNavigation\">\n <ul>\n{* your default content header navigation buttons *}\n\n{event name='contentHeaderNavigation'}\n </ul>\n </nav>\n</header>\n\n{* content *}\n\n{include file='footer'}\n
foo.bar.baz
is the language item that contains the title of the page.
For new forms, use the new form builder API introduced with WoltLab Suite 5.2.
<form method=\"post\" action=\"{link controller='FooBar'}{/link}\">\n <div class=\"section\">\n <dl{if $errorField == 'baz'} class=\"formError\"{/if}>\n <dt><label for=\"baz\">{lang}foo.bar.baz{/lang}</label></dt>\n <dd>\n <input type=\"text\" id=\"baz\" name=\"baz\" value=\"{$baz}\" class=\"long\" required autofocus>\n{if $errorField == 'baz'}\n <small class=\"innerError\">\n{if $errorType == 'empty'}\n{lang}wcf.global.form.error.empty{/lang}\n{else}\n{lang}foo.bar.baz.error.{@$errorType}{/lang}\n{/if}\n </small>\n{/if}\n </dd>\n </dl>\n\n <dl>\n <dt><label for=\"bar\">{lang}foo.bar.bar{/lang}</label></dt>\n <dd>\n <textarea name=\"bar\" id=\"bar\" cols=\"40\" rows=\"10\">{$bar}</textarea>\n{if $errorField == 'bar'}\n <small class=\"innerError\">{lang}foo.bar.bar.error.{@$errorType}{/lang}</small>\n{/if}\n </dd>\n </dl>\n\n{* other fields *}\n\n{event name='dataFields'}\n </div>\n\n{* other sections *}\n\n{event name='sections'}\n\n <div class=\"formSubmit\">\n <input type=\"submit\" value=\"{lang}wcf.global.button.submit{/lang}\" accesskey=\"s\">\n{csrfToken}\n </div>\n</form>\n
"},{"location":"view/templates/#tab-menus","title":"Tab Menus","text":"<div class=\"section tabMenuContainer\">\n <nav class=\"tabMenu\">\n <ul>\n <li><a href=\"#tab1\">Tab 1</a></li>\n <li><a href=\"#tab2\">Tab 2</a></li>\n\n{event name='tabMenuTabs'}\n </ul>\n </nav>\n\n <div id=\"tab1\" class=\"tabMenuContent\">\n <div class=\"section\">\n{* contents of first tab *}\n </div>\n </div>\n\n <div id=\"tab2\" class=\"tabMenuContainer tabMenuContent\">\n <nav class=\"menu\">\n <ul>\n <li><a href=\"#tab2A\">Tab 2A</a></li>\n <li><a href=\"#tab2B\">Tab 2B</a></li>\n\n{event name='tabMenuTab2Subtabs'}\n </ul>\n </nav>\n\n <div id=\"tab2A\" class=\"tabMenuContent\">\n <div class=\"section\">\n{* contents of first subtab for second tab *}\n </div>\n </div>\n\n <div id=\"tab2B\" class=\"tabMenuContent\">\n <div class=\"section\">\n{* contents of second subtab for second tab *}\n </div>\n </div>\n\n{event name='tabMenuTab2Contents'}\n </div>\n\n{event name='tabMenuContents'}\n</div>\n
"},{"location":"view/templates/#template-scripting","title":"Template Scripting","text":""},{"location":"view/templates/#template-variables","title":"Template Variables","text":"Template variables can be assigned via WCF::getTPL()->assign('foo', 'bar')
and accessed in templates via $foo
:
{$foo}
will result in the contents of $foo
to be passed to StringUtil::encodeHTML()
before being printed.{#$foo}
will result in the contents of $foo
to be passed to StringUtil::formatNumeric()
before being printed. Thus, this method is relevant when printing numbers and having them formatted correctly according the the user\u2019s language.{@$foo}
will result in the contents of $foo
to be printed directly. In general, this method should not be used for user-generated input.Multiple template variables can be assigned by passing an array:
WCF::getTPL()->assign([\n 'foo' => 'bar',\n 'baz' => false \n]);\n
"},{"location":"view/templates/#modifiers","title":"Modifiers","text":"If you want to call a function on a variable, you can use the modifier syntax: {@$foo|trim}
, for example, results in the trimmed contents of $foo
to be printed.
$__wcf
contains the WCF
object (or WCFACP
object in the backend).Comments are wrapped in {*
and *}
and can span multiple lines:
{* some\n comment *}\n
The template compiler discards the comments, so that they not included in the compiled template.
"},{"location":"view/templates/#conditions","title":"Conditions","text":"Conditions follow a similar syntax to PHP code:
{if $foo === 'bar'}\n foo is bar\n{elseif $foo === 'baz'}\n foo is baz\n{else}\n foo is neither bar nor baz\n{/if}\n
The supported operators in conditions are ===
, !==
, ==
, !=
, <=
, <
, >=
, >
, ||
, &&
, !
, and =
.
More examples:
{if $bar|isset}\u2026{/if}\n\n{if $bar|count > 3 && $bar|count < 100}\u2026{/if}\n
"},{"location":"view/templates/#foreach-loops","title":"Foreach Loops","text":"Foreach loops allow to iterate over arrays or iterable objects:
<ul>\n{foreach from=$array key=key item=value}\n <li>{$key}: {$value}</li>\n{/foreach}\n</ul>\n
While the from
attribute containing the iterated structure and the item
attribute containg the current value are mandatory, the key
attribute is optional. If the foreach loop has a name assigned to it via the name
attribute, the $tpl
template variable provides additional data about the loop:
<ul>\n{foreach from=$array key=key item=value name=foo}\n{if $tpl[foreach][foo][first]}\n something special for the first iteration\n{elseif $tpl[foreach][foo][last]}\n something special for the last iteration\n{/if}\n\n <li>iteration {#$tpl[foreach][foo][iteration]+1} out of {#$tpl[foreach][foo][total]} {$key}: {$value}</li>\n{/foreach}\n</ul>\n
In contrast to PHP\u2019s foreach loop, templates also support foreachelse
:
{foreach from=$array item=value}\n \u2026\n{foreachelse}\n there is nothing to iterate over\n{/foreach}\n
"},{"location":"view/templates/#including-other-templates","title":"Including Other Templates","text":"To include template named foo
from the same domain (frontend/backend), you can use
{include file='foo'}\n
If the template belongs to an application, you have to specify that application using the application
attribute:
{include file='foo' application='app'}\n
Additional template variables can be passed to the included template as additional attributes:
{include file='foo' application='app' var1='foo1' var2='foo2'}\n
"},{"location":"view/templates/#template-plugins","title":"Template Plugins","text":"An overview of all available template plugins can be found here.
"}]} \ No newline at end of file diff --git a/6.0/sitemap.xml.gz b/6.0/sitemap.xml.gz index c094cd3f..7e4beab3 100644 Binary files a/6.0/sitemap.xml.gz and b/6.0/sitemap.xml.gz differ