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/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 diff --git a/formwork/src/Pages/Page.php b/formwork/src/Pages/Page.php index a3333fc60..36f62dc54 100644 --- a/formwork/src/Pages/Page.php +++ b/formwork/src/Pages/Page.php @@ -58,7 +58,7 @@ class Page extends Model implements Stringable /** * Ignored field names on frontmatter generation */ - protected const IGNORED_FIELD_NAMES = ['content', 'template', 'parent']; + protected const IGNORED_FIELD_NAMES = ['content', 'slug', 'template', 'parent']; /** * Ignored field types on frontmatter generation @@ -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'); } @@ -600,6 +603,22 @@ public function isDeletable(): bool return !($this->hasChildren() || $this->isIndexPage() || $this->isErrorPage()); } + /** + * Return whether the slug is editable + */ + public function isSlugEditable(): bool + { + return !$this->isIndexPage() && !$this->isErrorPage(); + } + + /** + * Return whether the slug is readonly + */ + public function isSlugReadonly(): bool + { + return !$this->isSlugEditable(); + } + /** * Return whether the page has loaded */ @@ -683,7 +702,7 @@ public function save(?string $language = null): void $defaults = $this->defaults(); $fieldCollection = $this->fields - ->setValues([...$this->data, 'parent' => $this->parent()->route(), 'template' => $this->template]) + ->setValues([...$this->data, 'slug' => $this->slug, 'parent' => $this->parent()->route(), 'template' => $this->template]) ->validate(); foreach ($fieldCollection as $field) { diff --git a/formwork/src/Panel/Controllers/PagesController.php b/formwork/src/Panel/Controllers/PagesController.php index 57a1dfad9..7c2876dc0 100644 --- a/formwork/src/Panel/Controllers/PagesController.php +++ b/formwork/src/Panel/Controllers/PagesController.php @@ -197,8 +197,6 @@ public function edit(RouteParams $routeParams): Response $this->modal('changes'); - $this->modal('slug'); - $this->modal('deletePage'); $this->modal('deleteFile'); 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/modals/slug.yaml b/panel/modals/slug.yaml deleted file mode 100644 index 1d3165f2b..000000000 --- a/panel/modals/slug.yaml +++ /dev/null @@ -1,32 +0,0 @@ -title: '{{panel.pages.changeSlug}}' - -form: false - -fields: - newSlug: - type: text - label: '{{panel.pages.newPage.slug}}' - suggestion: '{{panel.pages.newPage.slugSuggestion}}' - required: true - -buttons: - dismiss: - action: dismiss - icon: times-circle - label: '{{panel.modal.action.cancel}}' - variant: secondary - - continue: - action: command - icon: check-circle - label: '{{panel.modal.action.continue}}' - align: right - command: continue - - generate: - action: command - icon: sparks - label: '{{panel.pages.changeSlug.generate}}' - variant: link - align: right - command: generate-slug diff --git a/panel/src/scss/components/_pages.scss b/panel/src/scss/components/_pages.scss index 6393fbb74..68dabc327 100644 --- a/panel/src/scss/components/_pages.scss +++ b/panel/src/scss/components/_pages.scss @@ -16,11 +16,6 @@ font-size: $font-size-sm; } -.page-route-changeable { - padding: $focusring-width; - margin: -($focusring-width); -} - .button .page-language { font-size: $font-size-xs; } @@ -100,40 +95,6 @@ white-space: nowrap; } -.page-slug-change { - padding: 0; - border-color: transparent; - margin: 0; - background-color: transparent; - box-shadow: none; - color: var(--color-base-300); - cursor: pointer; - - &:hover, - &:focus { - border-color: transparent; - background-color: transparent; - color: var(--color-base-300); - } - - &:focus { - @include focusring; - } - - & .icon { - display: inline-block; - margin-right: 0; - color: var(--color-base-100); - opacity: 0; - transition: opacity $transition-time-sm; - } - - &:hover .icon, - &:focus .icon { - opacity: 1; - } -} - .is-dragging .page-title { pointer-events: none; } 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..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,7 +36,9 @@ 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-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) { element.addEventListener("click", () => { 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..073b09481 100644 --- a/panel/src/ts/components/views/pages.ts +++ b/panel/src/ts/components/views/pages.ts @@ -1,5 +1,5 @@ import { $, $$ } from "../../utils/selectors"; -import { escapeRegExp, makeDiacriticsRegExp, makeSlug, validateSlug } from "../../utils/validation"; +import { escapeRegExp, makeDiacriticsRegExp } from "../../utils/validation"; import { app } from "../../app"; import { debounce } from "../../utils/events"; import { Notification } from "../notification"; @@ -12,12 +12,10 @@ export class Pages { const commandCollapseAllPages = $("[data-command=collapse-all-pages]") as HTMLButtonElement; const commandReorderPages = $("[data-command=reorder-pages]") as HTMLButtonElement; const commandPreview = $("[data-command=preview]") as HTMLButtonElement; - const commandChangeSlug = $("[data-command=change-slug]") as HTMLButtonElement; const searchInput = $(".page-search"); const newPageModal = app.modals["newPageModal"]; - const slugModal = app.modals["slugModal"]; $$(".pages-tree").forEach((element) => { if (element.dataset.orderableChildren === "true") { @@ -122,22 +120,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'); @@ -196,56 +180,6 @@ export class Pages { } } - if (slugModal && commandChangeSlug) { - const newSlugInput = $('[id="slugModal.newSlug"]') as HTMLInputElement; - const commandGenerateSlug = $("[data-command=generate-slug]", slugModal.element) as HTMLElement; - const commandContinue = $("[data-command=continue]", slugModal.element) as HTMLElement; - - commandChangeSlug.addEventListener("click", () => { - app.modals["slugModal"].show(undefined, (modal) => { - const slug = (document.getElementById("slug") as HTMLInputElement).value; - const slugInput = $('[id="slugModal.newSlug"]', modal.element) as HTMLInputElement; - slugInput.value = slug; - slugInput.placeholder = slug; - }); - }); - - const handleSlugChange = (event: Event) => { - const target = event.target as HTMLInputElement; - target.value = validateSlug(target.value); - }; - - newSlugInput.addEventListener("keyup", handleSlugChange); - newSlugInput.addEventListener("blur", handleSlugChange); - - commandGenerateSlug.addEventListener("click", () => { - const slug = makeSlug((document.getElementById("title") as HTMLInputElement).value); - newSlugInput.value = slug; - newSlugInput.focus(); - }); - - const changeSlug = () => { - const slug = newSlugInput.value.replace(/^-+|-+$/, ""); - - if (slug.length > 0) { - const route = ($(".page-route-inner") as HTMLElement).innerHTML; - newSlugInput.value = slug; - ($("#slug") as HTMLInputElement).value = slug; - ($(".page-route-inner") as HTMLElement).innerHTML = route.replace(/\/[a-z0-9-]+\/$/, `/${slug}/`); - } - - app.modals["slugModal"].hide(); - }; - - commandContinue.addEventListener("click", changeSlug); - - newSlugInput.addEventListener("keydown", (event) => { - if (event.key === "Enter") { - changeSlug(); - } - }); - } - $$("[data-modal=renameFileModal]").forEach((element) => { element.addEventListener("click", () => { const input = $('[id="renameFileModal.filename"]') as HTMLInputElement; 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(), ]) ?>> - icon('times-circle') ?> - \ No newline at end of file + 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(), ]) ?>> - icon('times-circle') ?> - \ No newline at end of file + icon('times-circle') ?> + 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') ?> +
+ attr([ + 'class' => ['form-input', 'form-input-slug', $field->get('class')], + 'type' => 'text', + 'id' => $field->name(), + 'name' => $field->formName(), + 'value' => $field->value(), + 'placeholder' => $field->placeholder(), + 'minlength' => $field->get('min'), + 'maxlength' => $field->get('max'), + 'pattern' => $field->get('pattern'), + 'required' => $field->isRequired(), + 'disabled' => $field->isDisabled(), + 'hidden' => $field->isHidden(), + 'readonly' => $field->isReadonly(), + 'data-source' => $field->source()?->name(), + 'data-auto-update' => $field->autoUpdate() ? 'true' : 'false', + ]) ?>> + autoUpdate() && !$field->isReadonly()): ?> + icon('sparks') ?> + +
diff --git a/panel/views/pages/editor.php b/panel/views/pages/editor.php index 47e22540d..3005b05c2 100644 --- a/panel/views/pages/editor.php +++ b/panel/views/pages/editor.php @@ -13,16 +13,9 @@
insert('_pages/status', ['page' => $page]) ?>
- isIndexPage() && !$page->isErrorPage()) : ?> -
- -
- -
route() ?>
- +
route() ?>
- diff --git a/site/schemes/pages/default.yaml b/site/schemes/pages/default.yaml index 9d9b13cdc..90b4c321d 100644 --- a/site/schemes/pages/default.yaml +++ b/site/schemes/pages/default.yaml @@ -18,6 +18,15 @@ fields: label: '{{page.status.published}}' default: true + slug: + type: slug + label: '{{page.slug}}' + suggestion: '{{panel.pages.newPage.slugSuggestion}}' + required: true + readonly@: page.isSlugReadonly + source: title + autoUpdate: false + parent: type: page label: '{{page.parent}}' diff --git a/site/schemes/pages/page.yaml b/site/schemes/pages/page.yaml index a39991cb7..4202e35a8 100644 --- a/site/schemes/pages/page.yaml +++ b/site/schemes/pages/page.yaml @@ -22,7 +22,7 @@ layout: collapsible: true collapsed: true label: '{{page.attributes}}' - fields: [parent, template] + fields: [slug, parent, template] files: collapsible: true @@ -76,6 +76,15 @@ fields: label: '{{page.cacheable}}' default: true + slug: + type: slug + label: '{{page.slug}}' + suggestion: '{{panel.pages.newPage.slugSuggestion}}' + required: true + readonly@: page.isSlugReadonly + source: title + autoUpdate: false + parent: type: page label: '{{page.parent}}'