diff --git a/wcfsetup/install/files/lib/acp/form/TagAddForm.class.php b/wcfsetup/install/files/lib/acp/form/TagAddForm.class.php index 64e8f14f591..6760932a7ed 100644 --- a/wcfsetup/install/files/lib/acp/form/TagAddForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/TagAddForm.class.php @@ -2,19 +2,20 @@ namespace wcf\acp\form; -use wcf\data\IStorableObject; +use wcf\command\tag\CreateTag; +use wcf\command\tag\UpdateTag; +use wcf\data\DatabaseObjectBuilder; use wcf\data\tag\Tag; -use wcf\data\tag\TagAction; +use wcf\data\tag\TagBuilder; use wcf\data\tag\TagList; -use wcf\form\AbstractFormBuilderForm; +use wcf\form\AbstractDatabaseObjectBuilderForm; use wcf\system\form\builder\container\FormContainer; -use wcf\system\form\builder\data\processor\CustomFormDataProcessor; +use wcf\system\form\builder\field\IFormField; use wcf\system\form\builder\field\SingleSelectionFormField; use wcf\system\form\builder\field\tag\TagFormField; use wcf\system\form\builder\field\TextFormField; use wcf\system\form\builder\field\validation\FormFieldValidationError; use wcf\system\form\builder\field\validation\FormFieldValidator; -use wcf\system\form\builder\IFormDocument; use wcf\system\form\builder\TemplateFormNode; use wcf\system\language\LanguageFactory; use wcf\system\WCF; @@ -23,13 +24,13 @@ /** * Shows the tag add form. * - * @author Olaf Braun, Tim Duesterhus - * @copyright 2001-2024 WoltLab GmbH + * @author Olaf Braun, Tim Duesterhus, Marcel Werk + * @copyright 2001-2026 WoltLab GmbH * @license GNU Lesser General Public License * - * @extends AbstractFormBuilderForm + * @extends AbstractDatabaseObjectBuilderForm */ -class TagAddForm extends AbstractFormBuilderForm +class TagAddForm extends AbstractDatabaseObjectBuilderForm { /** * @inheritDoc @@ -49,18 +50,31 @@ class TagAddForm extends AbstractFormBuilderForm /** * @inheritDoc */ - public $objectActionClass = TagAction::class; + public string $objectEditLinkController = TagEditForm::class; - /** - * @inheritDoc - */ - public $objectEditLinkController = TagEditForm::class; + #[\Override] + protected function getDatabaseObjectBuilder(): TagBuilder + { + if ($this->formObject !== null) { + return TagBuilder::forUpdate($this->formObject); + } + + return TagBuilder::forCreate(); + } #[\Override] - protected function createForm() + protected function getCommand(DatabaseObjectBuilder $builder): callable { - parent::createForm(); + if ($this->formObject !== null) { + return new UpdateTag($builder); + } + return new CreateTag($builder); + } + + #[\Override] + protected function createForm(): void + { $contentLanguages = LanguageFactory::getInstance()->getContentLanguages(); $this->form->appendChildren([ @@ -70,12 +84,20 @@ protected function createForm() ->label('wcf.global.name') ->required() ->maximumLength(\TAGGING_MAX_TAG_LENGTH) + ->saveValueCallback(static function (TagBuilder $builder, IFormField $field) { + $builder->setName( + \str_replace(',', '', StringUtil::trim($field->getSaveValue())) + ); + }) + ->loadValueCallback(static function (Tag $object, IFormField $field) { + $field->value($object->name); + }) ->addValidator( new FormFieldValidator('duplicateTagValidator', function (TextFormField $field) { $languageIDFormField = $field->getDocument()->getFormField('languageID'); $languageID = $languageIDFormField->getValue(); - $tag = Tag::getTag($field->getValue(), $languageID); + $tag = Tag::getTag($field->getValue(), $languageID ?? 0); if ($tag !== null && $tag->tagID !== $this->formObject?->tagID) { $field->addValidationError( new FormFieldValidationError( @@ -92,10 +114,30 @@ protected function createForm() ->options($contentLanguages) ->value(isset($contentLanguages[WCF::getLanguage()->languageID]) ? WCF::getLanguage()->languageID : null) ->immutable($this->formAction !== 'create') - ->required(), + ->required() + ->saveValueCallback(static function (TagBuilder $builder, IFormField $field) { + $builder->setLanguageID( + (int)$field->getSaveValue() + ); + })->loadValueCallback(static function (Tag $object, IFormField $field) { + $field->value($object->languageID); + }), TagFormField::create('synonyms') ->available($this->formObject?->synonymFor === null) - ->label('wcf.acp.tag.synonyms'), + ->label('wcf.acp.tag.synonyms') + ->saveValueCallback(static function (TagBuilder $builder, IFormField $field) { + $builder->setSynonyms( + $field->getSaveValue() ?? [] + ); + })->loadValueCallback(static function (Tag $object, IFormField $field) { + $synonymList = new TagList(); + $synonymList->getConditionBuilder()->add('synonymFor = ?', [$object->getObjectID()]); + $synonymList->readObjects(); + $field->value(\array_map( + static fn($synonym) => $synonym->name, + $synonymList->getObjects() + )); + }), TemplateFormNode::create('tagSynonymFor') ->available($this->formObject?->synonymFor !== null) ->variables([ @@ -105,45 +147,4 @@ protected function createForm() ]) ]); } - - #[\Override] - protected function finalizeForm() - { - parent::finalizeForm(); - - $this->form->getDataHandler() - ->addProcessor( - new CustomFormDataProcessor( - 'tagNameProcessor', - static function (IFormDocument $document, array $parameters) { - $parameters['data']['name'] = \str_replace( - ',', - '', - StringUtil::trim($parameters['data']['name']) - ); - - return $parameters; - } - ) - ) - ->addProcessor( - new CustomFormDataProcessor( - 'synonymsProcessor', - null, - static function (IFormDocument $document, array $data, IStorableObject $tag) { - \assert($tag instanceof Tag); - - $synonymList = new TagList(); - $synonymList->getConditionBuilder()->add('synonymFor = ?', [$tag->tagID]); - $synonymList->readObjects(); - $data['synonyms'] = []; - foreach ($synonymList as $synonym) { - $data['synonyms'][] = $synonym->name; - } - - return $data; - } - ) - ); - } } diff --git a/wcfsetup/install/files/lib/acp/form/TagEditForm.class.php b/wcfsetup/install/files/lib/acp/form/TagEditForm.class.php index 2b53db1058c..b79b9828fdf 100644 --- a/wcfsetup/install/files/lib/acp/form/TagEditForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/TagEditForm.class.php @@ -34,7 +34,7 @@ class TagEditForm extends TagAddForm /** * @inheritDoc */ - public $formAction = 'edit'; + public string $formAction = 'edit'; #[\Override] public function readParameters() diff --git a/wcfsetup/install/files/lib/command/tag/CreateTag.class.php b/wcfsetup/install/files/lib/command/tag/CreateTag.class.php new file mode 100644 index 00000000000..d1610dd668e --- /dev/null +++ b/wcfsetup/install/files/lib/command/tag/CreateTag.class.php @@ -0,0 +1,32 @@ + + * @since 6.3 + */ +final class CreateTag +{ + public function __construct( + private readonly TagBuilder $builder, + ) {} + + public function __invoke(): Tag + { + $tag = $this->builder->save(); + + EventHandler::getInstance()->fire(new TagCreated($tag)); + + return $tag; + } +} diff --git a/wcfsetup/install/files/lib/command/tag/UpdateTag.class.php b/wcfsetup/install/files/lib/command/tag/UpdateTag.class.php new file mode 100644 index 00000000000..d98cd767094 --- /dev/null +++ b/wcfsetup/install/files/lib/command/tag/UpdateTag.class.php @@ -0,0 +1,32 @@ + + * @since 6.3 + */ +final class UpdateTag +{ + public function __construct( + private readonly TagBuilder $builder, + ) {} + + public function __invoke(): Tag + { + $tag = $this->builder->save(); + + EventHandler::getInstance()->fire(new TagUpdated($tag)); + + return $tag; + } +} diff --git a/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php new file mode 100644 index 00000000000..0302b3f5747 --- /dev/null +++ b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php @@ -0,0 +1,372 @@ + + * @since 6.3 + * + * @template TDatabaseObject of DatabaseObject + */ +abstract class DatabaseObjectBuilder +{ + /** + * @var array + */ + protected array $properties = []; + + /** + * @var array + */ + protected array $customProperties = []; + + /** + * Use forCreate() or forUpdate() to obtain a builder instance. + * + * @param ?TDatabaseObject $object + */ + private function __construct(protected readonly ?DatabaseObject $object = null) {} + + /** + * Persists the pending changes and returns the resulting database object. + * + * @return TDatabaseObject + */ + final public function save(): DatabaseObject + { + if ($this->object !== null) { + return $this->update(); + } + + return $this->create(); + } + + /** + * Inserts a new row and returns the primary key of the created object. + * + * @return TDatabaseObject + */ + private function create(): DatabaseObject + { + $this->validateCreate(); + + $keys = $values = ''; + $statementParameters = []; + foreach (\array_merge($this->properties, $this->customProperties) as $key => $value) { + if ($keys !== '') { + $keys .= ','; + $values .= ','; + } + + $keys .= $key; + $values .= '?'; + $statementParameters[] = $value; + } + + $sql = "INSERT INTO " . static::getBaseClass()::getDatabaseTableName() . " + (" . $keys . ") + VALUES (" . $values . ")"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute($statementParameters); + + if (isset($this->properties[static::getBaseClass()::getDatabaseTableIndexName()])) { + $id = $this->properties[static::getBaseClass()::getDatabaseTableIndexName()]; + } elseif (static::getBaseClass()::getDatabaseTableIndexIsIdentity()) { + $id = WCF::getDB()->getInsertID(static::getBaseClass()::getDatabaseTableName(), static::getBaseClass()::getDatabaseTableIndexName()); + } else { + throw new \BadMethodCallException("Missing value for '" . static::getBaseClass()::getDatabaseTableIndexName() . "'"); + } + + $object = new (static::getBaseClass())($id); + + $this->afterCreate($object); + + return $object; + } + + /** + * Validates that the pending changes are sufficient to create a new object. + * + * @throws \BadMethodCallException if no properties are set or a required property is missing + */ + private function validateCreate(): void + { + if ($this->properties === [] && $this->customProperties === []) { + throw new \BadMethodCallException("Cannot create an object without any properties."); + } + + foreach ($this->getRequiredProperties() as $property) { + if (!\array_key_exists($property, $this->properties)) { + throw new \BadMethodCallException("Missing value for required property '{$property}'."); + } + } + } + + /** + * Returns the names of the properties that must be set when creating a new + * object. Subclasses can override this method to enforce that required + * values are provided before the object is persisted. + * + * @return list + */ + protected function getRequiredProperties(): array + { + return []; + } + + /** + * Writes the pending property changes to the existing row. + * + * @return TDatabaseObject + */ + private function update(): DatabaseObject + { + if ($this->properties !== [] || $this->customProperties !== []) { + $updateSQL = ''; + $statementParameters = []; + foreach (\array_merge($this->properties, $this->customProperties) as $key => $value) { + if ($updateSQL !== '') { + $updateSQL .= ', '; + } + $updateSQL .= $key . ' = ?'; + $statementParameters[] = $value; + } + $statementParameters[] = $this->object->getObjectID(); + + $sql = "UPDATE " . static::getBaseClass()::getDatabaseTableName() . " + SET " . $updateSQL . " + WHERE " . static::getBaseClass()::getDatabaseTableIndexName() . " = ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute($statementParameters); + + $object = new (static::getBaseClass())($this->object->getObjectID()); + } else { + $object = $this->object; + } + + $this->afterUpdate($object); + + return $object; + } + + /** + * Creates a new object, returns null if the row already exists. + * + * @return ?TDatabaseObject + */ + final public function createOrIgnore(): ?DatabaseObject + { + if ($this->object !== null) { + throw new \BadMethodCallException("createOrIgnore() can only be used with forCreate()."); + } + + try { + return $this->save(); + } catch (DatabaseQueryExecutionException $e) { + // Error code 23000 = duplicate key + if (\intval($e->getCode()) === 23000 && $e->getDriverCode() === '1062') { + return null; + } + + throw $e; + } + } + + /** + * Deletes the given database object. + * + * @param TDatabaseObject $object + */ + final public static function delete(DatabaseObject $object): void + { + static::deleteAll([$object->getObjectID()]); + } + + /** + * Deletes the rows identified by the given primary keys in batches inside + * a single transaction. + * + * @param non-empty-list|non-empty-list $objectIDs + */ + final public static function deleteAll(array $objectIDs): void + { + static::beforeDeleteAll($objectIDs); + + $itemsPerLoop = 1000; + $loopCount = \ceil(\count($objectIDs) / $itemsPerLoop); + + WCF::getDB()->beginTransaction(); + $committed = false; + try { + for ($i = 0; $i < $loopCount; $i++) { + $batchObjectIDs = \array_slice($objectIDs, $i * $itemsPerLoop, $itemsPerLoop); + + $conditionBuilder = new PreparedStatementConditionBuilder(); + $conditionBuilder->add(static::getBaseClass()::getDatabaseTableIndexName() . ' IN (?)', [$batchObjectIDs]); + + $sql = "DELETE FROM " . static::getBaseClass()::getDatabaseTableName() . " + " . $conditionBuilder; + $statement = WCF::getDB()->prepare($sql); + $statement->execute($conditionBuilder->getParameters()); + } + WCF::getDB()->commitTransaction(); + $committed = true; + } finally { + if (!$committed) { + WCF::getDB()->rollBackTransaction(); + } + } + } + + /** + * Returns a builder instance for inserting a new row. + */ + final public static function forCreate(): static + { + return new (static::class)(); + } + + /** + * Returns a builder instance for updating an existing database object. + * + * @param TDatabaseObject $object + */ + final public static function forUpdate(DatabaseObject $object): static + { + return new static($object); + } + + /** + * Resolves the database object class associated with this builder by + * stripping the `Builder` suffix from the current class name. + * + * @return class-string + */ + final public static function getBaseClass(): string + { + if (!\str_ends_with(static::class, 'Builder')) { + throw new \LogicException("Builder class '" . static::class . "' must end with the 'Builder' suffix."); + } + + $className = \mb_substr(static::class, 0, -7); + if (!\class_exists($className)) { + throw new ClassNotFoundException($className); + } + + if (!\is_subclass_of($className, DatabaseObject::class)) { + throw new ImplementationException($className, DatabaseObject::class); + } + + return $className; + } + + /** + * Sets a custom property value that is written alongside the regular + * properties when the object is persisted. + */ + final public function setCustomProperty(string $name, string|int|float|null $value): static + { + $this->customProperties[$name] = $value; + + return $this; + } + + /** + * This method is called after the creation of a new object. + * It can be overriden to handle additional tasks that are not handled by the default implementation. + * + * @param TDatabaseObject $object + */ + protected function afterCreate(DatabaseObject $object): void + { + // does nothing + } + + /** + * This method is called after an update. + * It can be overriden to handle additional tasks that are not handled by the default implementation. + * + * @param TDatabaseObject $object + */ + protected function afterUpdate(DatabaseObject $object): void + { + // does nothing + } + + /** + * This method is called before the deletion of objects. + * It can be overriden to handle additional tasks that are not handled by the default implementation. + * + * @param non-empty-list|non-empty-list $objectIDs + */ + protected static function beforeDeleteAll(array $objectIDs): void + { + // does nothing + } + + /** + * Sets the ID of the object that is being created. + * + * This method should only be used in cases where the ID needs to be set + * explicitly, for example when importing existing records from another + * installation, where the ID should be preserved if possible. + * + * @throws \BadMethodCallException if an existing object is being updated + */ + final public function setID(int|string $id): static + { + if ($this->object !== null) { + throw new \BadMethodCallException('The ID cannot be set when updating an existing object.'); + } + + $this->properties[static::getBaseClass()::getDatabaseTableIndexName()] = $id; + + return $this; + } + + /** + * Updates counters for the given object. + * + * @param TDatabaseObject $object + * @param non-empty-array $counters + */ + final public static function updateCounters(DatabaseObject $object, array $counters): void + { + \assert($object instanceof (static::getBaseClass())); + + $updateSQL = ''; + $statementParameters = []; + foreach ($counters as $key => $value) { + if ($updateSQL !== '') { + $updateSQL .= ', '; + } + $updateSQL .= $key . ' = ' . $key . ' + ?'; + $statementParameters[] = $value; + } + $statementParameters[] = $object->getObjectID(); + + $sql = "UPDATE " . static::getBaseClass()::getDatabaseTableName() . " + SET " . $updateSQL . " + WHERE " . static::getBaseClass()::getDatabaseTableIndexName() . " = ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute($statementParameters); + } + + /** + * @return ?TDatabaseObject + */ + public function getObject(): ?DatabaseObject + { + return $this->object; + } +} diff --git a/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php b/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php new file mode 100644 index 00000000000..41fc70d62af --- /dev/null +++ b/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php @@ -0,0 +1,115 @@ + + * @since 6.3 + * + * @extends DatabaseObjectBuilder + */ +final class TagBuilder extends DatabaseObjectBuilder +{ + /** + * @var ?list + */ + private ?array $synonyms = null; + + public function setLanguageID(int $languageID): static + { + $this->properties['languageID'] = $languageID; + + return $this; + } + + public function setName(string $name): static + { + $this->properties['name'] = $name; + + return $this; + } + + public function setSynonymFor(Tag $tag): static + { + $this->properties['synonymFor'] = $tag->tagID; + + return $this; + } + + /** + * @param list $synonyms + */ + public function setSynonyms(array $synonyms): static + { + $this->synonyms = $synonyms; + + return $this; + } + + #[\Override] + protected function afterCreate(DatabaseObject $object): void + { + if ($this->synonyms !== null && $this->synonyms !== []) { + $this->saveSynonyms($object, $this->synonyms); + } + } + + #[\Override] + protected function afterUpdate(DatabaseObject $object): void + { + if ($this->synonyms !== null) { + $this->removeSynonyms($object); + + if ($this->synonyms !== []) { + $this->saveSynonyms($object, $this->synonyms); + } + } + } + + private function removeSynonyms(Tag $tag): void + { + $sql = "UPDATE wcf1_tag + SET synonymFor = ? + WHERE synonymFor = ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([ + null, + $tag->tagID, + ]); + } + + /** + * @param list $synonyms + */ + private function saveSynonyms(Tag $tag, array $synonyms): void + { + foreach ($synonyms as $synonym) { + $synonymObj = Tag::getTag($synonym, $tag->languageID); + if ($synonymObj === null) { + TagBuilder::forCreate() + ->setName($synonym) + ->setLanguageID($tag->languageID) + ->setSynonymFor($tag) + ->save(); + } else { + TagBuilder::forUpdate($synonymObj) + ->setSynonymFor($tag) + ->save(); + } + } + } + + #[\Override] + protected function getRequiredProperties(): array + { + return ['name']; + } +} diff --git a/wcfsetup/install/files/lib/event/tag/TagCreated.class.php b/wcfsetup/install/files/lib/event/tag/TagCreated.class.php new file mode 100644 index 00000000000..cc1760b0c62 --- /dev/null +++ b/wcfsetup/install/files/lib/event/tag/TagCreated.class.php @@ -0,0 +1,21 @@ + + * @since 6.3 + */ +final class TagCreated implements IPsr14Event +{ + public function __construct( + public readonly Tag $tag + ) {} +} diff --git a/wcfsetup/install/files/lib/event/tag/TagUpdated.class.php b/wcfsetup/install/files/lib/event/tag/TagUpdated.class.php new file mode 100644 index 00000000000..bbe312305fb --- /dev/null +++ b/wcfsetup/install/files/lib/event/tag/TagUpdated.class.php @@ -0,0 +1,21 @@ + + * @since 6.3 + */ +final class TagUpdated implements IPsr14Event +{ + public function __construct( + public readonly Tag $tag + ) {} +} diff --git a/wcfsetup/install/files/lib/form/AbstractDatabaseObjectBuilderForm.class.php b/wcfsetup/install/files/lib/form/AbstractDatabaseObjectBuilderForm.class.php new file mode 100644 index 00000000000..6a2c04568f1 --- /dev/null +++ b/wcfsetup/install/files/lib/form/AbstractDatabaseObjectBuilderForm.class.php @@ -0,0 +1,270 @@ + + * @since 6.3 + * + * @template TDatabaseObject of DatabaseObject|null + * @template TDatabaseObjectBuilder of DatabaseObjectBuilder + */ +abstract class AbstractDatabaseObjectBuilderForm extends AbstractForm +{ + public DatabaseObjectBuilderFormDocument $form; + + /** + * Action performed by the form by default `create` and `edit` is supported. + */ + public string $formAction = 'create'; + + /** + * updated object, not relevant for form action `create` + * @var ?TDatabaseObject + */ + public ?DatabaseObject $formObject = null; + + /** + * name of the controller for the link to the edit form + */ + public string $objectEditLinkController = ''; + + /** + * object persisted by the most recent `save()` call + * @var ?TDatabaseObject + */ + public ?DatabaseObject $object = null; + + /** + * Returns the builder used to persist the form data. + * + * For the `create` action a builder obtained via `forCreate()` is expected, + * for the `edit` action a builder obtained via `forUpdate($this->formObject)`. + * + * @return TDatabaseObjectBuilder + */ + abstract protected function getDatabaseObjectBuilder(): DatabaseObjectBuilder; + + /** + * Returns the invokable command that persists the given builder and returns + * the resulting database object. + * + * The default command simply calls `DatabaseObjectBuilder::save()`. Override + * this method to wrap saving in a command that performs additional side + * effects. + * + * @param TDatabaseObjectBuilder $builder + * @return callable(): DatabaseObject + */ + protected function getCommand(DatabaseObjectBuilder $builder): callable + { + return static fn() => $builder->save(); + } + + #[\Override] + public function assignVariables() + { + parent::assignVariables(); + + WCF::getTPL()->assign([ + 'action' => $this->formAction === 'create' ? 'add' : 'edit', + 'form' => $this->form, + 'formObject' => $this->formObject, + ]); + } + + /** + * Builds the form. + */ + public function buildForm(): void + { + $classNamePieces = \explode('\\', static::class); + $controller = \preg_replace('~Form$~', '', \end($classNamePieces)); + + $this->form = DatabaseObjectBuilderFormDocument::create(\lcfirst($controller)); + + if ($this->formObject !== null) { + $this->form->formMode(IFormDocument::FORM_MODE_UPDATE); + } + + $this->createForm(); + + EventHandler::getInstance()->fireAction($this, 'createForm'); + + $this->form->build(); + + $this->finalizeForm(); + + EventHandler::getInstance()->fireAction($this, 'buildForm'); + } + + /** + * Creates the form. + * + * This is the method that is intended to be overwritten by child classes + * to add the form containers and fields. + */ + protected function createForm(): void + { + // does nothing + } + + /** + * Finalizes the form after it has been successfully built. + * + * This method can be used to add form field dependencies. + */ + protected function finalizeForm(): void + { + // does nothing + } + + #[\Override] + public function readData(): void + { + if ($this->formObject !== null) { + $this->setFormObjectData(); + } elseif ($this->formAction === 'edit') { + throw new \UnexpectedValueException("Missing form object to update."); + } + + parent::readData(); + + $this->setFormAction(); + } + + #[\Override] + public function readFormParameters(): void + { + parent::readFormParameters(); + + $this->form->readValues(); + } + + #[\Override] + public function save(): void + { + parent::save(); + + $builder = $this->getDatabaseObjectBuilder(); + $this->form->applyValuesToBuilder($builder); + + foreach ($this->additionalFields as $name => $value) { + $builder->setCustomProperty($name, $value); + } + + $this->object = ($this->getCommand($builder))(); + + $this->saved(); + + WCF::getTPL()->assign('success', true); + + if ($this->formAction === 'create' && $this->objectEditLinkController) { + WCF::getTPL()->assign( + 'objectEditLink', + LinkHandler::getInstance()->getControllerLink($this->objectEditLinkController, [ + 'id' => $this->object->getObjectID(), + ]) + ); + } + + $this->afterSave(); + } + + #[\Override] + public function saved(): void + { + parent::saved(); + + // re-build form after having created a new object + if ($this->formAction === 'create') { + $this->form->cleanup(); + + $this->buildForm(); + } + + $this->form->showSuccessMessage(true); + } + + /** + * Sets the action of the form. + */ + protected function setFormAction(): void + { + $parameters = []; + if ($this->formObject !== null) { + if ($this->formObject instanceof IRouteController) { + $parameters['object'] = $this->formObject; + } else { + $object = $this->formObject; + $parameters['id'] = $object->{$object::getDatabaseTableIndexName()}; + } + } + + $this->form->action(LinkHandler::getInstance()->getControllerLink(static::class, $parameters)); + } + + /** + * Sets the form data based on the current form object. + */ + protected function setFormObjectData(): void + { + $this->form->updatedObject($this->formObject, $_POST === []); + } + + #[\Override] + public function checkPermissions(): void + { + parent::checkPermissions(); + + $this->buildForm(); + } + + #[\Override] + public function validate(): void + { + parent::validate(); + + $this->form->validate(); + + if ($this->form->hasValidationErrors()) { + throw new UserInputException($this->form->getPrefixedId()); + } + } + + #[\Override] + protected function validateSecurityToken(): void + { + // does nothing, is handled by `IFormDocument` object + } + + /** + * This method is called after a save. + * It can be overriden to handle additional tasks that are not handled by the default implementation. + */ + protected function afterSave(): void + { + // does nothing + } +} diff --git a/wcfsetup/install/files/lib/system/form/builder/DatabaseObjectBuilderFormDocument.class.php b/wcfsetup/install/files/lib/system/form/builder/DatabaseObjectBuilderFormDocument.class.php new file mode 100644 index 00000000000..58625d7472b --- /dev/null +++ b/wcfsetup/install/files/lib/system/form/builder/DatabaseObjectBuilderFormDocument.class.php @@ -0,0 +1,67 @@ +saveValueCallback( + * static fn(DatabaseObjectBuilder $builder, IFormField $formField) => $builder->setName($formField->getSaveValue()) + * ) + * + * @author Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + */ +class DatabaseObjectBuilderFormDocument extends FormDocument +{ + /** + * Applies the save values of all available form fields that have registered + * a save value callback to the given builder and returns the builder. + * + * @param DatabaseObjectBuilder<*> $builder + * @throws \BadMethodCallException if the method is called before `readValues()` is called + */ + public function applyValuesToBuilder(DatabaseObjectBuilder $builder): void + { + if (!$this->didReadValues()) { + throw new \BadMethodCallException("Applying values to a builder is only possible after calling 'readValues()'."); + } + + $this->applyNodeValues($this, $builder); + } + + /** + * Recursively applies the save value callbacks of the given node and its + * children to the builder, mirroring the availability and dependency + * handling of `DefaultFormDataProcessor`. + * + * @param DatabaseObjectBuilder<*> $builder + */ + protected function applyNodeValues(IFormNode $node, DatabaseObjectBuilder $builder): void + { + if (!$node->isAvailable() || !$node->checkDependencies()) { + return; + } + + if ($node instanceof IFormParentNode) { + foreach ($node as $childNode) { + $this->applyNodeValues($childNode, $builder); + } + } elseif ($node instanceof IFormField) { + $callback = $node->getSaveValueCallback(); + if ($callback !== null) { + $callback($builder, $node); + } + } + } +} diff --git a/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygFormContainer.class.php b/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygFormContainer.class.php index b4742251795..89eb0757a75 100644 --- a/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygFormContainer.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygFormContainer.class.php @@ -250,14 +250,11 @@ public function enablePreviewButton(bool $enablePreviewButton = true) * Returns the form field handling attachments. * * @return WysiwygAttachmentFormField - * @throws \BadMethodCallException if the form field container has not been populated yet/form has not been built yet */ public function getAttachmentField() { if ($this->attachmentField === null) { - throw new \BadMethodCallException( - "Wysiwyg form field can only be requested after the form has been built for container '{$this->getId()}'." - ); + $this->attachmentField = WysiwygAttachmentFormField::create($this->wysiwygId . 'Attachments'); } return $this->attachmentField; @@ -345,14 +342,11 @@ public function getSmiliesContainer() * Returns the wysiwyg form field handling the actual text. * * @return WysiwygFormField - * @throws \BadMethodCallException if the form field container has not been populated yet/form has not been built yet */ public function getWysiwygField() { if ($this->wysiwygField === null) { - throw new \BadMethodCallException( - "Wysiwyg form field can only be requested after the form has been built for container '{$this->getId()}'." - ); + $this->wysiwygField = WysiwygFormField::create($this->wysiwygId); } return $this->wysiwygField; @@ -469,7 +463,7 @@ public function populate() { parent::populate(); - $this->wysiwygField = WysiwygFormField::create($this->wysiwygId) + $this->wysiwygField = $this->getWysiwygField() ->objectType($this->messageObjectType) ->minimumLength($this->getMinimumLength()) ->maximumLength($this->getMaximumLength()) @@ -480,7 +474,7 @@ public function populate() ->wysiwygId($this->getWysiwygId()) ->label('wcf.message.smilies') ->available($this->supportSmilies); - $this->attachmentField = WysiwygAttachmentFormField::create($this->wysiwygId . 'Attachments') + $this->attachmentField = $this->getAttachmentField() ->wysiwygId($this->getWysiwygId()); $this->settingsContainer = FormContainer::create($this->wysiwygId . 'SettingsContainer') ->appendChildren($this->settingsNodes); diff --git a/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormField.class.php index badb8b8a35b..b0e3860d44a 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormField.class.php @@ -71,6 +71,20 @@ abstract class AbstractFormField implements IFormField */ protected $value; + /** + * callback transferring this field's save value into a `DatabaseObjectBuilder` + * @var ?\Closure(\wcf\data\DatabaseObjectBuilder<*>, IFormField): void + * @since 6.3 + */ + protected ?\Closure $saveValueCallback = null; + + /** + * callback loading this field's value from an `IStorableObject` + * @var ?\Closure(\wcf\data\IStorableObject, IFormField): void + * @since 6.3 + */ + protected ?\Closure $loadValueCallback = null; + #[\Override] public function addValidationError(IFormFieldValidationError $error) { @@ -165,6 +179,34 @@ public function getValue() return $this->value; } + #[\Override] + public function saveValueCallback(\Closure $callback): static + { + $this->saveValueCallback = $callback; + + return $this; + } + + #[\Override] + public function getSaveValueCallback(): ?\Closure + { + return $this->saveValueCallback; + } + + #[\Override] + public function loadValueCallback(\Closure $callback): static + { + $this->loadValueCallback = $callback; + + return $this; + } + + #[\Override] + public function getLoadValueCallback(): ?\Closure + { + return $this->loadValueCallback; + } + #[\Override] public function hasValidator(string $validatorId) { @@ -192,8 +234,12 @@ public function updatedObject(array $data, IStorableObject $object, bool $loadVa $loadValues = true; } - if ($loadValues && isset($data[$this->getObjectProperty()])) { - $this->value($data[$this->getObjectProperty()]); + if ($loadValues) { + if ($this->loadValueCallback !== null) { + ($this->loadValueCallback)($object, $this); + } elseif (isset($data[$this->getObjectProperty()])) { + $this->value($data[$this->getObjectProperty()]); + } } return $this; diff --git a/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormFieldDecorator.class.php b/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormFieldDecorator.class.php index 4d00dbea849..5ed8d4b7725 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormFieldDecorator.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormFieldDecorator.class.php @@ -87,6 +87,34 @@ public function getValue() return $this->field->getValue(); } + #[\Override] + public function saveValueCallback(\Closure $callback): static + { + $this->field->saveValueCallback($callback); + + return $this; + } + + #[\Override] + public function getSaveValueCallback(): ?\Closure + { + return $this->field->getSaveValueCallback(); + } + + #[\Override] + public function loadValueCallback(\Closure $callback): static + { + $this->field->loadValueCallback($callback); + + return $this; + } + + #[\Override] + public function getLoadValueCallback(): ?\Closure + { + return $this->field->getLoadValueCallback(); + } + #[\Override] public function hasValidator(string $validatorId) { diff --git a/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php index 89330390b22..44b2daacc7e 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php @@ -2,6 +2,7 @@ namespace wcf\system\form\builder\field; +use wcf\data\DatabaseObjectBuilder; use wcf\data\IStorableObject; use wcf\system\form\builder\field\validation\IFormFieldValidationError; use wcf\system\form\builder\field\validation\IFormFieldValidator; @@ -94,6 +95,80 @@ public function getValidators(); */ public function getValue(); + /** + * Sets a callback that transfers this field's save value into a + * `DatabaseObjectBuilder` instance and returns this field. + * + * The callback is invoked by `DatabaseObjectBuilderFormDocument` when the + * builder is populated from the form's fields, for example: + * + * $field->saveValueCallback( + * static fn(DatabaseObjectBuilder $builder, IFormField $formField) => $builder->setName($formField->getSaveValue()) + * ) + * + * The builder type is a template parameter so that the callback may narrow + * it to a concrete `DatabaseObjectBuilder` implementation (e.g. `TagBuilder`) + * without triggering a contravariance error. + * + * @template TBuilder of DatabaseObjectBuilder + * @template TIFormField of IFormField + * @param \Closure(TBuilder, TIFormField): void $callback + * @return static this field + * @since 6.3 + */ + public function saveValueCallback(\Closure $callback): static; + + /** + * Returns the callback set via `saveValueCallback()` or `null` if no such + * callback has been set. + * + * @return ?\Closure(DatabaseObjectBuilder<*>, IFormField): void + * @since 6.3 + */ + public function getSaveValueCallback(): ?\Closure; + + /** + * Sets a callback that loads this field's value from an `IStorableObject` + * and returns this field. + * + * This is the counterpart to `saveValueCallback()`: while the save callback + * writes the field's value into a builder, this callback reads the value + * back out of an existing object when an edit form is populated. It is + * invoked by `updatedObject()` and is expected to assign the value via + * `$field->value()`, for example: + * + * $field->loadValueCallback( + * static function (Tag $object, IFormField $formField) { + * $formField->value($object->name); + * } + * ) + * + * When a callback is set it takes precedence over the default behaviour of + * loading the value from the object property named after this field. Use it + * when the value cannot be read from a single property, e.g. when it must be + * derived from a related object or an additional query. + * + * The object type is a template parameter so that the callback may narrow it + * to a concrete `IStorableObject` implementation (e.g. `Tag`) without + * triggering a contravariance error. + * + * @template TObject of IStorableObject + * @template TIFormField of IFormField + * @param \Closure(TObject, TIFormField): void $callback + * @return static this field + * @since 6.3 + */ + public function loadValueCallback(\Closure $callback): static; + + /** + * Returns the callback set via `loadValueCallback()` or `null` if no such + * callback has been set. + * + * @return ?\Closure(IStorableObject, IFormField): void + * @since 6.3 + */ + public function getLoadValueCallback(): ?\Closure; + /** * Returns `true` if this field has a validator with the given id and * returns `false` otherwise. diff --git a/wcfsetup/install/files/lib/system/form/builder/field/TI18nFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/TI18nFormField.class.php index cbec854598f..0523a612f9b 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/TI18nFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/TI18nFormField.class.php @@ -279,16 +279,20 @@ public function updatedObject(array $data, IStorableObject $object, bool $loadVa $loadValues = true; } - if ($loadValues && isset($data[$this->getObjectProperty()])) { - $value = $data[$this->getObjectProperty()]; - - if ($this->isI18n()) { - // do not use `I18nHandler::setOptions()` because then `I18nHandler` only - // reads the values when assigning the template variables and the values - // are not available in this class via `getValue()` - $this->setStringValue($value); - } else { - $this->value = $value; + if ($loadValues) { + if ($this->loadValueCallback !== null) { + ($this->loadValueCallback)($object, $this); + } elseif (isset($data[$this->getObjectProperty()])) { + $value = $data[$this->getObjectProperty()]; + + if ($this->isI18n()) { + // do not use `I18nHandler::setOptions()` because then `I18nHandler` only + // reads the values when assigning the template variables and the values + // are not available in this class via `getValue()` + $this->setStringValue($value); + } else { + $this->value = $value; + } } } diff --git a/wcfsetup/install/files/lib/system/form/builder/field/tag/TagFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/tag/TagFormField.class.php index 295fca4207f..bd6c1fe61aa 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/tag/TagFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/tag/TagFormField.class.php @@ -66,7 +66,9 @@ public function hasSaveValue() public function updatedObject(array $data, IStorableObject $object, bool $loadValues = true) { if ($loadValues) { - if (isset($data[$this->getObjectProperty()])) { + if ($this->loadValueCallback !== null) { + ($this->loadValueCallback)($object, $this); + } elseif (isset($data[$this->getObjectProperty()])) { $this->value($data[$this->getObjectProperty()]); } else { $objectID = $object->{$object::getDatabaseTableIndexName()}; diff --git a/wcfsetup/install/files/lib/system/form/builder/field/wysiwyg/WysiwygFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/wysiwyg/WysiwygFormField.class.php index 4be0c94a242..8e82c40dad6 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/wysiwyg/WysiwygFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/wysiwyg/WysiwygFormField.class.php @@ -393,4 +393,16 @@ public function getValue() $upcastProcessor->process(parent::getValue() ?? '', $this->getObjectType()->objectType); return $upcastProcessor->getHtml(); } + + /** + * @since 6.3 + */ + public function getHtmlInputProcessor(): HtmlInputProcessor + { + if ($this->htmlInputProcessor === null) { + throw new \BadMethodCallException("The html input processor is not available before validate() has been called."); + } + + return $this->htmlInputProcessor; + } }