mirror of
https://github.com/getformwork/formwork.git
synced 2025-01-17 05:28:20 +01:00
Merge pull request #596 from getformwork/feature/slug-input
Add slug input
This commit is contained in:
commit
cd8bbc284a
74
formwork/fields/slug.php
Normal file
74
formwork/fields/slug.php
Normal 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;
|
||||
},
|
||||
];
|
||||
};
|
@ -173,6 +173,14 @@ class Field implements Arrayable, Stringable
|
||||
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 @@ class Field implements Arrayable, Stringable
|
||||
*/
|
||||
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
|
||||
|
@ -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 @@ 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;
|
||||
}
|
||||
@ -460,6 +460,9 @@ class Page extends Model implements Stringable
|
||||
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 @@ class Page extends Model implements Stringable
|
||||
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 @@ class Page extends Model implements Stringable
|
||||
$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) {
|
||||
|
@ -197,8 +197,6 @@ class PagesController extends AbstractController
|
||||
|
||||
$this->modal('changes');
|
||||
|
||||
$this->modal('slug');
|
||||
|
||||
$this->modal('deletePage');
|
||||
|
||||
$this->modal('deleteFile');
|
||||
|
@ -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
|
||||
|
@ -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
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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", () => {
|
||||
|
28
panel/src/ts/components/inputs/slug-input.ts
Normal file
28
panel/src/ts/components/inputs/slug-input.ts
Normal 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);
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -12,5 +12,5 @@
|
||||
'disabled' => $field->isDisabled(),
|
||||
'hidden' => $field->isHidden(),
|
||||
]) ?>>
|
||||
<span class="form-input-reset" data-reset="<?= $field->name() ?>"><?= $this->icon('times-circle') ?></span>
|
||||
</div>
|
||||
<span class="form-input-action" data-reset="<?= $field->name() ?>"><?= $this->icon('times-circle') ?></span>
|
||||
</div>
|
||||
|
@ -13,5 +13,5 @@
|
||||
'disabled' => $field->isDisabled(),
|
||||
'hidden' => $field->isHidden(),
|
||||
]) ?>>
|
||||
<span class="form-input-reset" data-reset="<?= $field->name() ?>"><?= $this->icon('times-circle') ?></span>
|
||||
</div>
|
||||
<span class="form-input-action" data-reset="<?= $field->name() ?>"><?= $this->icon('times-circle') ?></span>
|
||||
</div>
|
||||
|
23
panel/views/fields/slug.php
Normal file
23
panel/views/fields/slug.php
Normal 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>
|
@ -13,16 +13,9 @@
|
||||
</div>
|
||||
<div class="flex">
|
||||
<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>
|
||||
<?php endif ?>
|
||||
<div class="page-route"><?= $page->route() ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" id="slug" name="slug" value="<?= $page->slug() ?>">
|
||||
<?php if ($currentLanguage) : ?>
|
||||
<input type="hidden" id="language" name="language" value="<?= $currentLanguage ?>">
|
||||
<?php endif ?>
|
||||
|
@ -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}}'
|
||||
|
@ -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}}'
|
||||
|
Loading…
x
Reference in New Issue
Block a user