diff --git a/CHANGELOG.md b/CHANGELOG.md index 95fa76cee..3485743e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Support WordPress "Quote" block in content fields. +- "Campo-Key" term meta field. ### Changed -- Make "Duration of studies in semester" a single line input. +- Make "Duration of studies in semester" a single line input. ## [1.2.7] - 2023-11-23 diff --git a/resources/ts/components/ContentField/styles.scss b/resources/ts/components/ContentField/styles.scss index d5e85e4b2..2cc53e36d 100644 --- a/resources/ts/components/ContentField/styles.scss +++ b/resources/ts/components/ContentField/styles.scss @@ -1,5 +1,7 @@ -// Unfortunately, there is no way to filter or adjust the core blocks toolbar. -// Hiding the block toolbar items is fragile, so it is merely a UX improvement, +// Unfortunately, there is no way to filter +// or adjust the core blocks toolbar. +// Hiding the block toolbar items is fragile, +// so it is merely a UX improvement, // with the actual sanitization happening server-side. // Hide H1, H2 and H6 Heading level toolbar buttons diff --git a/resources/ts/term-meta-fields-validation.ts b/resources/ts/term-meta-fields-validation.ts new file mode 100644 index 000000000..9e6fce998 --- /dev/null +++ b/resources/ts/term-meta-fields-validation.ts @@ -0,0 +1,34 @@ +import domReady from '@wordpress/dom-ready'; + +const TERM_EDIT_FORM_SELECTOR = '#addtag, #edittag'; + +domReady( () => { + const form = document.querySelector< HTMLFormElement >( + TERM_EDIT_FORM_SELECTOR + ); + const submitButton = form?.querySelector< HTMLButtonElement >( + "input[type='submit']" + ); + + if ( ! form || ! submitButton ) { + return; + } + + form.querySelectorAll< HTMLInputElement >( '.form-field input' ).forEach( + ( input ) => { + input.addEventListener( 'input', () => { + if ( form.checkValidity() ) { + submitButton.disabled = false; + return; + } + + if ( input.checkValidity() ) { + return; + } + + submitButton.disabled = true; + form.reportValidity(); + } ); + } + ); +} ); diff --git a/src/Infrastructure/Dashboard/TermMeta/AssetsLoader.php b/src/Infrastructure/Dashboard/TermMeta/AssetsLoader.php new file mode 100644 index 000000000..bcdc1d6cf --- /dev/null +++ b/src/Infrastructure/Dashboard/TermMeta/AssetsLoader.php @@ -0,0 +1,55 @@ +load([ + [ + 'handle' => self::HANDLE, + 'url' => (string) $this->pluginProperties->baseUrl() + . 'assets/ts/term-meta-fields-validation.js', + 'location' => Asset::BACKEND, + 'type' => Script::class, + 'enqueue' => fn () => $this->isTermTaxonomyEditContext(), + ], + ]); + + $assetManager->register(...$assets); + } + + private function isTermTaxonomyEditContext(): bool + { + if (! function_exists('get_current_screen')) { + return false; + } + + $screen = get_current_screen(); + if (!$screen instanceof WP_Screen) { + return false; + } + + return ! empty($screen->taxonomy); + } +} diff --git a/src/Infrastructure/Dashboard/TermMeta/CampoKeyTermMetaField.php b/src/Infrastructure/Dashboard/TermMeta/CampoKeyTermMetaField.php new file mode 100644 index 000000000..8f2dd350f --- /dev/null +++ b/src/Infrastructure/Dashboard/TermMeta/CampoKeyTermMetaField.php @@ -0,0 +1,23 @@ +key; } + public function title(): string + { + return $this->title; + } + public function sanitize(mixed $value): string { return sanitize_text_field((string) $value); @@ -31,13 +37,25 @@ public function templateName(): string public function templateData(mixed $value): array { + $showInRest = true; + + if (! is_null($this->validationPattern)) { + $showInRest = [ + 'schema' => [ + 'type' => 'string', + 'pattern' => $this->validationPattern->pattern(), + ], + ]; + } + return [ 'key' => $this->key, 'value' => (string) $value, 'title' => $this->title, 'description' => $this->description, 'type' => $this->type, - + 'validationPattern' => $this->validationPattern(), + 'show_in_rest' => $showInRest, ]; } @@ -52,4 +70,9 @@ public function metaArgs(): array 'show_in_rest' => true, ]; } + + public function validationPattern(): ?TermMetaFieldValidationPattern + { + return $this->validationPattern; + } } diff --git a/src/Infrastructure/Dashboard/TermMeta/TermMetaField.php b/src/Infrastructure/Dashboard/TermMeta/TermMetaField.php index 72b42010d..2e475cb0d 100644 --- a/src/Infrastructure/Dashboard/TermMeta/TermMetaField.php +++ b/src/Infrastructure/Dashboard/TermMeta/TermMetaField.php @@ -7,10 +7,12 @@ interface TermMetaField { public function key(): string; + public function title(): string; public function sanitize(mixed $value): mixed; public function templateName(): string; public function templateData(mixed $value): array; + public function validationPattern(): ?TermMetaFieldValidationPattern; /** * @see register_meta diff --git a/src/Infrastructure/Dashboard/TermMeta/TermMetaFieldValidationPattern.php b/src/Infrastructure/Dashboard/TermMeta/TermMetaFieldValidationPattern.php new file mode 100644 index 000000000..a517e4143 --- /dev/null +++ b/src/Infrastructure/Dashboard/TermMeta/TermMetaFieldValidationPattern.php @@ -0,0 +1,39 @@ +pattern() . '/', + $value + ); + } + + /** + * @return non-empty-string + */ + public function pattern(): string + { + return $this->pattern; + } + + public function expectedPatternMessage(): string + { + return $this->expectedPatternMessage; + } +} diff --git a/src/Infrastructure/Dashboard/TermMeta/TermMetaFieldsValidator.php b/src/Infrastructure/Dashboard/TermMeta/TermMetaFieldsValidator.php new file mode 100644 index 000000000..834b000c7 --- /dev/null +++ b/src/Infrastructure/Dashboard/TermMeta/TermMetaFieldsValidator.php @@ -0,0 +1,60 @@ +isTermEditingContext()) { + return null; + } + + foreach ($termMetaFields as $termMetaField) { + $validationPattern = $termMetaField->validationPattern(); + if (is_null($validationPattern)) { + continue; + } + + $postedValue = filter_input( + INPUT_POST, + $termMetaField->key(), + FILTER_SANITIZE_SPECIAL_CHARS + ); + + $sanitizedValue = (string) $termMetaField->sanitize($postedValue); + + // Skip validation if field is empty. + if ($sanitizedValue === '') { + continue; + } + + if (! $validationPattern->matches($sanitizedValue)) { + return new WP_Error( + 'invalid_term_meta', + sprintf( + // translators: %1$s: field title. %2$s: expected pattern description. + __( + 'The value of the field “%1$s” is invalid. expected value: %2$s', + 'fau-degree-program' + ), + $termMetaField->title(), + $validationPattern->expectedPatternMessage(), + ) + ); + } + } + + return null; + } + + private function isTermEditingContext(): bool + { + $action = filter_input(INPUT_POST, 'action', FILTER_SANITIZE_SPECIAL_CHARS); + return $action === 'editedtag' || $action === 'add-tag'; + } +} diff --git a/src/Infrastructure/Dashboard/TermMeta/TermMetaModule.php b/src/Infrastructure/Dashboard/TermMeta/TermMetaModule.php index 8acd70c4e..138145a73 100644 --- a/src/Infrastructure/Dashboard/TermMeta/TermMetaModule.php +++ b/src/Infrastructure/Dashboard/TermMeta/TermMetaModule.php @@ -26,6 +26,7 @@ use Fau\DegreeProgram\Common\Infrastructure\TemplateRenderer\DirectoryLocator; use Fau\DegreeProgram\Common\Infrastructure\TemplateRenderer\Renderer; use Fau\DegreeProgram\Common\Infrastructure\TemplateRenderer\TemplateRenderer; +use Inpsyde\Assets\AssetManager; use Inpsyde\Modularity\Module\ExecutableModule; use Inpsyde\Modularity\Module\ModuleClassNameIdTrait; use Inpsyde\Modularity\Module\ServiceModule; @@ -41,15 +42,20 @@ final class TermMetaModule implements ServiceModule, ExecutableModule public function services(): array { return [ + AssetsLoader::class => static fn(ContainerInterface $container) => new AssetsLoader( + $container->get(Package::PROPERTIES), + ), self::TERM_META_FIELD_RENDERER => static fn(ContainerInterface $container): Renderer => TemplateRenderer::new( DirectoryLocator::new( $container->get(Package::PROPERTIES)->basePath() . '/templates/term-meta' ) ), TermMetaRepository::class => fn() => new TermMetaRepository(), + TermMetaFieldsValidator::class => fn() => new TermMetaFieldsValidator(), TermMetaRegistrar::class => static fn(ContainerInterface $container): TermMetaRegistrar => new TermMetaRegistrar( termMetaFieldRenderer: $container->get(TermMetaModule::TERM_META_FIELD_RENDERER), termMetaRepository: $container->get(TermMetaRepository::class), + validator: $container->get(TermMetaFieldsValidator::class), ), ]; } @@ -63,6 +69,16 @@ public function run(ContainerInterface $container): bool $termMetaRegistrar->register( AreaOfStudyTaxonomy::KEY, + (new CampoKeyTermMetaField( + __( + 'Up to 3 digits, no letters. Leading zeros are allowed.', + 'fau-degree-program' + ), + new TermMetaFieldValidationPattern( + '^(?:[0-9]{1,3}|$)$', + __('1 to 3 digits', 'fau-degree-program'), + ), + )), ...(new MultilingualLinkTermMetaFields())->getArrayCopy(), ); $termMetaRegistrar->register( @@ -103,10 +119,30 @@ public function run(ContainerInterface $container): bool ); $termMetaRegistrar->register( StudyLocationTaxonomy::KEY, + (new CampoKeyTermMetaField( + __( + 'Up to 3 uppercase alphanumeric characters.', + 'fau-degree-program' + ), + new TermMetaFieldValidationPattern( + '^(?:[A-Z0-9]{1,3}|$)$', + __('1 to 3 uppercase letters or digits', 'fau-degree-program'), + ), + )), new EnglishNameTermMetaField(), ); $termMetaRegistrar->register( SubjectGroupTaxonomy::KEY, + (new CampoKeyTermMetaField( + __( + 'Up to 3 digits, no letters. Leading zeros are allowed.', + 'fau-degree-program' + ), + new TermMetaFieldValidationPattern( + '^(?:[0-9]{1,3}|$)$', + __('1 to 3 digits', 'fau-degree-program'), + ), + )), new EnglishNameTermMetaField(), ); $termMetaRegistrar->register( @@ -126,6 +162,11 @@ public function run(ContainerInterface $container): bool ...(new MultilingualLinkTermMetaFields())->getArrayCopy(), ); + add_action( + AssetManager::ACTION_SETUP, + [$container->get(AssetsLoader::class), 'load'] + ); + return true; } @@ -135,6 +176,16 @@ public function run(ContainerInterface $container): bool private static function degreeMetaFields(): array { return [ + (new CampoKeyTermMetaField( + __( + 'Up to 3 lowercase alphanumeric characters.', + 'fau-degree-program' + ), + new TermMetaFieldValidationPattern( + '^(?:[a-z0-9]{1,3}|$)$', + __('1 to 3 lowercase letters or digits', 'fau-degree-program'), + ), + )), new InputTermMetaField( Degree::ABBREVIATION, _x( diff --git a/src/Infrastructure/Dashboard/TermMeta/TermMetaRegistrar.php b/src/Infrastructure/Dashboard/TermMeta/TermMetaRegistrar.php index 459c23e2b..97532e774 100644 --- a/src/Infrastructure/Dashboard/TermMeta/TermMetaRegistrar.php +++ b/src/Infrastructure/Dashboard/TermMeta/TermMetaRegistrar.php @@ -5,6 +5,7 @@ namespace Fau\DegreeProgram\Infrastructure\Dashboard\TermMeta; use Fau\DegreeProgram\Common\Infrastructure\TemplateRenderer\Renderer; +use WP_Error; use WP_Term; final class TermMetaRegistrar @@ -12,6 +13,7 @@ final class TermMetaRegistrar public function __construct( private Renderer $termMetaFieldRenderer, private TermMetaRepository $termMetaRepository, + private TermMetaFieldsValidator $validator, ) { } @@ -64,6 +66,41 @@ function () use ($termMetaFields): void { add_action("edit_{$taxonomy}", $updateCallback); add_action("create_{$taxonomy}", $updateCallback); + + $this->registerValidationCallbacks($taxonomy, ...$termMetaFields); + } + + private function registerValidationCallbacks(string $currentTaxonomy, TermMetaField ...$termMetaFields): void + { + add_filter( + 'pre_insert_term', + function (mixed $term, string $taxonomy) use ($currentTaxonomy, $termMetaFields): mixed { + if ($taxonomy !== $currentTaxonomy) { + return $term; + } + + return $this->validator->validate(...$termMetaFields) ?? $term; + }, + 10, + 2 + ); + + add_action( + 'edit_terms', + function (int $term, string $taxonomy) use ($currentTaxonomy, $termMetaFields): void { + if ($taxonomy !== $currentTaxonomy) { + return; + } + + $validationError = $this->validator->validate(...$termMetaFields); + + if (! is_null($validationError)) { + wp_die($validationError->get_error_message()); + } + }, + 10, + 2 + ); } private function buildUpdateCallback(TermMetaField ...$termMetaFields): callable diff --git a/templates/term-meta/partials/input.php b/templates/term-meta/partials/input.php index 70b518879..27abb4b91 100644 --- a/templates/term-meta/partials/input.php +++ b/templates/term-meta/partials/input.php @@ -2,8 +2,17 @@ declare(strict_types=1); +use Fau\DegreeProgram\Infrastructure\Dashboard\TermMeta\TermMetaFieldValidationPattern; + /** - * @var array{key: string, value: string, title: string, description: string, type: string} $data + * @var array{ + * key: string, + * value: string, + * title: string, + * description: string, + * type: string, + * validationPattern: ?TermMetaFieldValidationPattern + * } $data */ [ @@ -11,19 +20,26 @@ 'value' => $value, 'description' => $description, 'type' => $type, + 'validationPattern' => $validationPattern, ] = $data; ?> - - aria-describedby="-description" - + + aria-describedby="-description" + + + + pattern="pattern()) ?>" + title="expectedPatternMessage()) ?>" + />

diff --git a/webpack.config.js b/webpack.config.js index 6e820d09f..d898e9174 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -28,6 +28,7 @@ module.exports = { 'css/degree-program-list-table': './resources/scss/degree-program-list-table.scss', 'ts/degree-program-editor': './resources/ts/degree-program-editor.ts', 'ts/degree-program-list-table': './resources/ts/degree-program-list-table.ts', + 'ts/term-meta-fields-validation': './resources/ts/term-meta-fields-validation.ts', }, output: { publicPath: './',