Merge pull request #596 from getformwork/feature/slug-input

Add slug input
This commit is contained in:
Giuseppe Criscione 2024-10-27 14:39:20 +01:00 committed by GitHub
commit cd8bbc284a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 190 additions and 166 deletions

74
formwork/fields/slug.php Normal file
View File

@ -0,0 +1,74 @@
<?php
use Formwork\App;
use Formwork\Fields\Exceptions\ValidationException;
use Formwork\Fields\Field;
use Formwork\Utils\Constraint;
return function (App $app) {
return [
'validate' => 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;
},
];
};

View File

@ -173,6 +173,14 @@ class Field implements Arrayable, Stringable
return $this->is('visible', false); return $this->is('visible', false);
} }
/**
* Return whether the field is readonly
*/
public function isReadonly(): bool
{
return $this->is('readonly');
}
/** /**
* Validate field value * Validate field value
*/ */
@ -239,7 +247,7 @@ class Field implements Arrayable, Stringable
*/ */
public function is(string $key, bool $default = false): 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 public function get(string $key, mixed $default = null): mixed

View File

@ -58,7 +58,7 @@ class Page extends Model implements Stringable
/** /**
* Ignored field names on frontmatter generation * 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 * Ignored field types on frontmatter generation
@ -185,7 +185,7 @@ class Page extends Model implements Stringable
]; ];
} }
$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; $this->loaded = true;
} }
@ -460,6 +460,9 @@ class Page extends Model implements Stringable
if (!$this->validateSlug($slug)) { if (!$this->validateSlug($slug)) {
throw new InvalidArgumentException('Invalid page slug'); throw new InvalidArgumentException('Invalid page slug');
} }
if ($slug === $this->slug) {
return;
}
if ($this->isIndexPage() || $this->isErrorPage()) { if ($this->isIndexPage() || $this->isErrorPage()) {
throw new UnexpectedValueException('Cannot change slug of index or error pages'); throw new UnexpectedValueException('Cannot change slug of index or error pages');
} }
@ -600,6 +603,22 @@ class Page extends Model implements Stringable
return !($this->hasChildren() || $this->isIndexPage() || $this->isErrorPage()); 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 * Return whether the page has loaded
*/ */
@ -683,7 +702,7 @@ class Page extends Model implements Stringable
$defaults = $this->defaults(); $defaults = $this->defaults();
$fieldCollection = $this->fields $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(); ->validate();
foreach ($fieldCollection as $field) { foreach ($fieldCollection as $field) {

View File

@ -197,8 +197,6 @@ class PagesController extends AbstractController
$this->modal('changes'); $this->modal('changes');
$this->modal('slug');
$this->modal('deletePage'); $this->modal('deletePage');
$this->modal('deleteFile'); $this->modal('deleteFile');

View File

@ -9,11 +9,13 @@ fields:
required: true required: true
slug: slug:
type: text type: slug
label: '{{panel.pages.newPage.slug}}' label: '{{panel.pages.newPage.slug}}'
suggestion: '{{panel.pages.newPage.slugSuggestion}}' suggestion: '{{panel.pages.newPage.slugSuggestion}}'
required: true required: true
pattern: '^[a-z0-9\-]+$' pattern: '^[a-z0-9\-]+$'
source: title
root: parent
parent: parent:
type: page type: page

View File

@ -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

View File

@ -16,11 +16,6 @@
font-size: $font-size-sm; font-size: $font-size-sm;
} }
.page-route-changeable {
padding: $focusring-width;
margin: -($focusring-width);
}
.button .page-language { .button .page-language {
font-size: $font-size-xs; font-size: $font-size-xs;
} }
@ -100,40 +95,6 @@
white-space: nowrap; 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 { .is-dragging .page-title {
pointer-events: none; pointer-events: none;
} }

View File

@ -26,11 +26,6 @@
background-color: var(--color-base-700); background-color: var(--color-base-700);
color: var(--color-base-300); color: var(--color-base-300);
} }
&[readonly] {
// Safari Mobile bug
@include user-select-none;
}
} }
.form-input[type="checkbox"], .form-input[type="checkbox"],
@ -129,7 +124,7 @@
margin-bottom: 0; margin-bottom: 0;
} }
.form-input-reset { .form-input-action {
position: absolute; position: absolute;
top: 50%; top: 50%;
right: 0.5rem; right: 0.5rem;
@ -159,7 +154,7 @@
padding-left: 1.75rem; 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; padding-right: 1.625rem;
} }

View File

@ -9,6 +9,7 @@ import { ImageInput } from "./inputs/image-input";
import { ImagePicker } from "./inputs/image-picker"; import { ImagePicker } from "./inputs/image-picker";
import { RangeInput } from "./inputs/range-input"; import { RangeInput } from "./inputs/range-input";
import { SelectInput } from "./inputs/select-input"; import { SelectInput } from "./inputs/select-input";
import { SlugInput } from "./inputs/slug-input";
import { TagInput } from "./inputs/tag-input"; import { TagInput } from "./inputs/tag-input";
export class Inputs { 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))); $$("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; const targetId = element.dataset.reset;
if (targetId) { if (targetId) {
element.addEventListener("click", () => { element.addEventListener("click", () => {

View File

@ -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);
}
}

View File

@ -1,5 +1,5 @@
import { $, $$ } from "../../utils/selectors"; import { $, $$ } from "../../utils/selectors";
import { escapeRegExp, makeDiacriticsRegExp, makeSlug, validateSlug } from "../../utils/validation"; import { escapeRegExp, makeDiacriticsRegExp } from "../../utils/validation";
import { app } from "../../app"; import { app } from "../../app";
import { debounce } from "../../utils/events"; import { debounce } from "../../utils/events";
import { Notification } from "../notification"; import { Notification } from "../notification";
@ -12,12 +12,10 @@ export class Pages {
const commandCollapseAllPages = $("[data-command=collapse-all-pages]") as HTMLButtonElement; const commandCollapseAllPages = $("[data-command=collapse-all-pages]") as HTMLButtonElement;
const commandReorderPages = $("[data-command=reorder-pages]") as HTMLButtonElement; const commandReorderPages = $("[data-command=reorder-pages]") as HTMLButtonElement;
const commandPreview = $("[data-command=preview]") as HTMLButtonElement; const commandPreview = $("[data-command=preview]") as HTMLButtonElement;
const commandChangeSlug = $("[data-command=change-slug]") as HTMLButtonElement;
const searchInput = $(".page-search"); const searchInput = $(".page-search");
const newPageModal = app.modals["newPageModal"]; const newPageModal = app.modals["newPageModal"];
const slugModal = app.modals["slugModal"];
$$(".pages-tree").forEach((element) => { $$(".pages-tree").forEach((element) => {
if (element.dataset.orderableChildren === "true") { if (element.dataset.orderableChildren === "true") {
@ -122,22 +120,8 @@ export class Pages {
} }
if (newPageModal) { if (newPageModal) {
const titleInput = $('[id="newPageModal.title"]') as HTMLInputElement;
const slugInput = $('[id="newPageModal.slug"]') as HTMLInputElement;
const parentSelect = $('[id="newPageModal.parent"]') 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", () => { parentSelect.addEventListener("change", () => {
const option = $('.dropdown-list[data-for="newPageModal.parent"] .selected'); 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) => { $$("[data-modal=renameFileModal]").forEach((element) => {
element.addEventListener("click", () => { element.addEventListener("click", () => {
const input = $('[id="renameFileModal.filename"]') as HTMLInputElement; const input = $('[id="renameFileModal.filename"]') as HTMLInputElement;

View File

@ -12,5 +12,5 @@
'disabled' => $field->isDisabled(), 'disabled' => $field->isDisabled(),
'hidden' => $field->isHidden(), 'hidden' => $field->isHidden(),
]) ?>> ]) ?>>
<span class="form-input-reset" data-reset="<?= $field->name() ?>"><?= $this->icon('times-circle') ?></span> <span class="form-input-action" data-reset="<?= $field->name() ?>"><?= $this->icon('times-circle') ?></span>
</div> </div>

View File

@ -13,5 +13,5 @@
'disabled' => $field->isDisabled(), 'disabled' => $field->isDisabled(),
'hidden' => $field->isHidden(), 'hidden' => $field->isHidden(),
]) ?>> ]) ?>>
<span class="form-input-reset" data-reset="<?= $field->name() ?>"><?= $this->icon('times-circle') ?></span> <span class="form-input-action" data-reset="<?= $field->name() ?>"><?= $this->icon('times-circle') ?></span>
</div> </div>

View File

@ -0,0 +1,23 @@
<?php $this->layout('fields.field') ?>
<div class="form-input-wrap">
<input <?= $this->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',
]) ?>>
<?php if (!$field->autoUpdate() && !$field->isReadonly()): ?>
<span class="form-input-action" data-generate-slug="<?= $field->name() ?>" title="<?= $this->translate('panel.pages.changeSlug.generate') ?>"><?= $this->icon('sparks') ?></span>
<?php endif ?>
</div>

View File

@ -13,16 +13,9 @@
</div> </div>
<div class="flex"> <div class="flex">
<div><?= $this->insert('_pages/status', ['page' => $page]) ?></div> <div><?= $this->insert('_pages/status', ['page' => $page]) ?></div>
<?php if (!$page->isIndexPage() && !$page->isErrorPage()) : ?>
<div class="page-route page-route-changeable min-w-0">
<button type="button" class="button page-slug-change truncate max-w-100" data-command="change-slug" title="<?= $this->translate('panel.pages.changeSlug') ?>"><span class="page-route-inner"><?= $page->route() ?></span> <?= $this->icon('pencil') ?></button>
</div>
<?php else : ?>
<div class="page-route"><?= $page->route() ?></div> <div class="page-route"><?= $page->route() ?></div>
<?php endif ?>
</div> </div>
</div> </div>
<input type="hidden" id="slug" name="slug" value="<?= $page->slug() ?>">
<?php if ($currentLanguage) : ?> <?php if ($currentLanguage) : ?>
<input type="hidden" id="language" name="language" value="<?= $currentLanguage ?>"> <input type="hidden" id="language" name="language" value="<?= $currentLanguage ?>">
<?php endif ?> <?php endif ?>

View File

@ -18,6 +18,15 @@ fields:
label: '{{page.status.published}}' label: '{{page.status.published}}'
default: true default: true
slug:
type: slug
label: '{{page.slug}}'
suggestion: '{{panel.pages.newPage.slugSuggestion}}'
required: true
readonly@: page.isSlugReadonly
source: title
autoUpdate: false
parent: parent:
type: page type: page
label: '{{page.parent}}' label: '{{page.parent}}'

View File

@ -22,7 +22,7 @@ layout:
collapsible: true collapsible: true
collapsed: true collapsed: true
label: '{{page.attributes}}' label: '{{page.attributes}}'
fields: [parent, template] fields: [slug, parent, template]
files: files:
collapsible: true collapsible: true
@ -76,6 +76,15 @@ fields:
label: '{{page.cacheable}}' label: '{{page.cacheable}}'
default: true default: true
slug:
type: slug
label: '{{page.slug}}'
suggestion: '{{panel.pages.newPage.slugSuggestion}}'
required: true
readonly@: page.isSlugReadonly
source: title
autoUpdate: false
parent: parent:
type: page type: page
label: '{{page.parent}}' label: '{{page.parent}}'