From 703b1bcec82f4776f715e8d41484af1105aa6eae Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Sun, 27 Oct 2024 14:20:50 +0100 Subject: [PATCH 1/4] Add `Field::isReadonly()` --- formwork/src/Fields/Field.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/formwork/src/Fields/Field.php b/formwork/src/Fields/Field.php index c8fb90168..fbffd7119 100644 --- a/formwork/src/Fields/Field.php +++ b/formwork/src/Fields/Field.php @@ -173,6 +173,14 @@ public function isHidden(): bool return $this->is('visible', false); } + /** + * Return whether the field is readonly + */ + public function isReadonly(): bool + { + return $this->is('readonly'); + } + /** * Validate field value */ @@ -239,7 +247,7 @@ public function isValidated(): bool */ public function is(string $key, bool $default = false): bool { - return $this->baseGet($key, $default) === true; + return $this->get($key, $default) === true; } public function get(string $key, mixed $default = null): mixed From afd82a4dc7d1335495c7a7899b836a39c7a3a9e5 Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Sun, 27 Oct 2024 14:22:41 +0100 Subject: [PATCH 2/4] Rename css class `.form-input-reset` to `.form-input-action` --- panel/src/scss/components/forms/_forms-base.scss | 9 ++------- panel/src/ts/components/inputs.ts | 2 +- panel/views/fields/date.php | 4 ++-- panel/views/fields/image.php | 4 ++-- 4 files changed, 7 insertions(+), 12 deletions(-) diff --git a/panel/src/scss/components/forms/_forms-base.scss b/panel/src/scss/components/forms/_forms-base.scss index ce044b6da..8dcf3532c 100644 --- a/panel/src/scss/components/forms/_forms-base.scss +++ b/panel/src/scss/components/forms/_forms-base.scss @@ -26,11 +26,6 @@ background-color: var(--color-base-700); color: var(--color-base-300); } - - &[readonly] { - // Safari Mobile bug - @include user-select-none; - } } .form-input[type="checkbox"], @@ -129,7 +124,7 @@ margin-bottom: 0; } -.form-input-reset { +.form-input-action { position: absolute; top: 50%; right: 0.5rem; @@ -159,7 +154,7 @@ padding-left: 1.75rem; } -.form-input-wrap .form-input:has(+ .form-input-reset) { +.form-input-wrap .form-input:has(+ .form-input-action) { padding-right: 1.625rem; } diff --git a/panel/src/ts/components/inputs.ts b/panel/src/ts/components/inputs.ts index 6c5b47d04..cc5a0540c 100644 --- a/panel/src/ts/components/inputs.ts +++ b/panel/src/ts/components/inputs.ts @@ -35,7 +35,7 @@ export class Inputs { $$("select:not([hidden])", parent).forEach((element: HTMLSelectElement) => (this[element.name] = new SelectInput(element, app.config.SelectInput))); - $$(".form-input-reset", parent).forEach((element) => { + $$(".form-input-action[data-reset]", parent).forEach((element) => { const targetId = element.dataset.reset; if (targetId) { element.addEventListener("click", () => { diff --git a/panel/views/fields/date.php b/panel/views/fields/date.php index 543391793..537747404 100644 --- a/panel/views/fields/date.php +++ b/panel/views/fields/date.php @@ -12,5 +12,5 @@ 'disabled' => $field->isDisabled(), 'hidden' => $field->isHidden(), ]) ?>> - = $this->icon('times-circle') ?> - \ No newline at end of file + = $this->icon('times-circle') ?> + diff --git a/panel/views/fields/image.php b/panel/views/fields/image.php index ed35077e0..de4413f58 100644 --- a/panel/views/fields/image.php +++ b/panel/views/fields/image.php @@ -13,5 +13,5 @@ 'disabled' => $field->isDisabled(), 'hidden' => $field->isHidden(), ]) ?>> - = $this->icon('times-circle') ?> - \ No newline at end of file + = $this->icon('times-circle') ?> + From 8d32d27d7469c8808ca6f8ebe34729640a75d979 Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Sun, 27 Oct 2024 14:28:39 +0100 Subject: [PATCH 3/4] Add `slug` field type --- formwork/fields/slug.php | 74 ++++++++++++++++++++ formwork/src/Pages/Page.php | 5 +- panel/modals/newPage.yaml | 4 +- panel/src/ts/components/inputs.ts | 3 + panel/src/ts/components/inputs/slug-input.ts | 28 ++++++++ panel/src/ts/components/views/pages.ts | 14 ---- panel/views/fields/slug.php | 23 ++++++ 7 files changed, 135 insertions(+), 16 deletions(-) create mode 100644 formwork/fields/slug.php create mode 100644 panel/src/ts/components/inputs/slug-input.ts create mode 100644 panel/views/fields/slug.php diff --git a/formwork/fields/slug.php b/formwork/fields/slug.php new file mode 100644 index 000000000..1417172a4 --- /dev/null +++ b/formwork/fields/slug.php @@ -0,0 +1,74 @@ + function (Field $field, $value): string { + if (Constraint::isEmpty($value)) { + return ''; + } + + if (!is_string($value) && !is_numeric($value)) { + throw new ValidationException(sprintf('Invalid value for field "%s" of type "%s"', $field->name(), $field->type())); + } + + if ($field->has('min') && strlen((string) $value) < $field->get('min')) { + throw new ValidationException(sprintf('The minimum allowed length for field "%s" of type "%s" is %d', $field->name(), $field->value(), $field->get('min'))); + } + + if ($field->has('max') && strlen((string) $value) > $field->get('max')) { + throw new ValidationException(sprintf('The maximum allowed length for field "%s" of type "%s" is %d', $field->name(), $field->value(), $field->get('max'))); + } + + if ($field->has('pattern') && !Constraint::matchesRegex((string) $value, $field->get('pattern'))) { + throw new ValidationException(sprintf('The value of field "%s" of type "%s" does not match the required pattern', $field->name(), $field->value())); + } + + if (!$field->hasUniqueValue()) { + throw new ValidationException(sprintf('The value of field "%s" of type "%s" must be unique', $field->name(), $field->value())); + } + + return (string) $value; + }, + + 'source' => function (Field $field): ?Field { + if (($source = $field->get('source')) === null) { + return null; + } + return $field->parent()?->get($source); + }, + + 'autoUpdate' => function (Field $field): bool { + return $field->is('autoUpdate', true); + }, + + 'hasUniqueValue' => function (Field $field): bool { + $root = $field->get('root'); + + if ($root === null) { + return true; + } + + $parentField = $field->parent()?->get($root); + + if ($parentField === null || $parentField->type() !== 'page') { + throw new ValidationException(sprintf('Invalid parent reference for field "%s" of type "%s"', $field->name(), $field->type())); + } + + $children = $parentField->return()->children(); + + foreach ($children as $child) { + if ($child->slug() === $field->value()) { + return false; + } + } + + return true; + }, + ]; +}; diff --git a/formwork/src/Pages/Page.php b/formwork/src/Pages/Page.php index a3333fc60..99d0f6e94 100644 --- a/formwork/src/Pages/Page.php +++ b/formwork/src/Pages/Page.php @@ -185,7 +185,7 @@ public function __construct(array $data = []) ]; } - $this->fields->setValues([...$this->data, 'parent' => $this->parent()?->route(), 'template' => $this->template]); + $this->fields->setValues([...$this->data, 'slug' => $this->slug, 'parent' => $this->parent()?->route(), 'template' => $this->template]); $this->loaded = true; } @@ -460,6 +460,9 @@ public function setSlug(string $slug): void if (!$this->validateSlug($slug)) { throw new InvalidArgumentException('Invalid page slug'); } + if ($slug === $this->slug) { + return; + } if ($this->isIndexPage() || $this->isErrorPage()) { throw new UnexpectedValueException('Cannot change slug of index or error pages'); } diff --git a/panel/modals/newPage.yaml b/panel/modals/newPage.yaml index 8bc9b8fcb..aa8fb354d 100644 --- a/panel/modals/newPage.yaml +++ b/panel/modals/newPage.yaml @@ -9,11 +9,13 @@ fields: required: true slug: - type: text + type: slug label: '{{panel.pages.newPage.slug}}' suggestion: '{{panel.pages.newPage.slugSuggestion}}' required: true pattern: '^[a-z0-9\-]+$' + source: title + root: parent parent: type: page diff --git a/panel/src/ts/components/inputs.ts b/panel/src/ts/components/inputs.ts index cc5a0540c..87bbd00eb 100644 --- a/panel/src/ts/components/inputs.ts +++ b/panel/src/ts/components/inputs.ts @@ -9,6 +9,7 @@ import { ImageInput } from "./inputs/image-input"; import { ImagePicker } from "./inputs/image-picker"; import { RangeInput } from "./inputs/range-input"; import { SelectInput } from "./inputs/select-input"; +import { SlugInput } from "./inputs/slug-input"; import { TagInput } from "./inputs/tag-input"; export class Inputs { @@ -35,6 +36,8 @@ export class Inputs { $$("select:not([hidden])", parent).forEach((element: HTMLSelectElement) => (this[element.name] = new SelectInput(element, app.config.SelectInput))); + $$(".form-input-slug", parent).forEach((element: HTMLInputElement) => (this[element.name] = new SlugInput(element))); + $$(".form-input-action[data-reset]", parent).forEach((element) => { const targetId = element.dataset.reset; if (targetId) { diff --git a/panel/src/ts/components/inputs/slug-input.ts b/panel/src/ts/components/inputs/slug-input.ts new file mode 100644 index 000000000..fee6e19bd --- /dev/null +++ b/panel/src/ts/components/inputs/slug-input.ts @@ -0,0 +1,28 @@ +import { makeSlug, validateSlug } from "../../utils/validation"; +import { $ } from "../../utils/selectors"; + +export class SlugInput { + constructor(element: HTMLInputElement) { + const source = $(`[id="${element.dataset.source}"]`) as HTMLInputElement | null; + const autoUpdate = "autoUpdate" in element.dataset && element.dataset.autoUpdate === "true"; + + if (source) { + if (autoUpdate) { + source.addEventListener("input", () => (element.value = makeSlug(source.value))); + } else { + const generateButton = $(`[data-generate-slug="${element.id}"]`) as HTMLButtonElement | null; + if (generateButton) { + generateButton.addEventListener("click", () => (element.value = makeSlug(source.value))); + } + } + } + + const handleSlugChange = (event: Event) => { + const target = event.target as HTMLInputElement; + target.value = validateSlug(target.value); + }; + + element.addEventListener("keyup", handleSlugChange); + element.addEventListener("blur", handleSlugChange); + } +} diff --git a/panel/src/ts/components/views/pages.ts b/panel/src/ts/components/views/pages.ts index 90a40ee74..39f45a3d5 100644 --- a/panel/src/ts/components/views/pages.ts +++ b/panel/src/ts/components/views/pages.ts @@ -122,22 +122,8 @@ export class Pages { } if (newPageModal) { - const titleInput = $('[id="newPageModal.title"]') as HTMLInputElement; - const slugInput = $('[id="newPageModal.slug"]') as HTMLInputElement; const parentSelect = $('[id="newPageModal.parent"]') as HTMLInputElement; - titleInput.addEventListener("keyup", (event) => { - ($('[id="newPageModal.slug"]') as HTMLInputElement).value = makeSlug((event.target as HTMLInputElement).value); - }); - - const handleSlugChange = (event: Event) => { - const target = event.target as HTMLInputElement; - target.value = validateSlug(target.value); - }; - - slugInput.addEventListener("keyup", handleSlugChange); - slugInput.addEventListener("blur", handleSlugChange); - parentSelect.addEventListener("change", () => { const option = $('.dropdown-list[data-for="newPageModal.parent"] .selected'); diff --git a/panel/views/fields/slug.php b/panel/views/fields/slug.php new file mode 100644 index 000000000..0c993e4dd --- /dev/null +++ b/panel/views/fields/slug.php @@ -0,0 +1,23 @@ +layout('fields.field') ?> +