feat(plugins): add group field type + multiple option to render field arrays

- update docs
- render hint and helper options for all fields
- replace option's hint with
description
This commit is contained in:
Yassine Doghri 2024-12-10 15:57:06 +00:00
parent f50098ec89
commit 11ccd0ebe7
29 changed files with 791 additions and 299 deletions

View File

@ -23,6 +23,7 @@ import "./modules/video-clip-previewer";
import VideoClipBuilder from "./modules/VideoClipBuilder"; import VideoClipBuilder from "./modules/VideoClipBuilder";
import "./modules/xml-editor"; import "./modules/xml-editor";
import "@patternfly/elements/pf-tabs/pf-tabs.js"; import "@patternfly/elements/pf-tabs/pf-tabs.js";
import FieldArray from "./modules/FieldArray";
Dropdown(); Dropdown();
Tooltip(); Tooltip();
@ -39,3 +40,4 @@ PublishMessageWarning();
HotKeys(); HotKeys();
ValidateFileSize(); ValidateFileSize();
VideoClipBuilder(); VideoClipBuilder();
FieldArray();

View File

@ -0,0 +1,159 @@
import Tooltip from "./Tooltip";
const FieldArray = (): void => {
const fieldArrays: NodeListOf<HTMLElement> =
document.querySelectorAll("[data-field-array]");
for (let i = 0; i < fieldArrays.length; i++) {
const fieldArray = fieldArrays[i];
const fieldArrayContainer = fieldArray.querySelector(
"[data-field-array-container]"
);
const items: NodeListOf<HTMLElement> = fieldArray.querySelectorAll(
"[data-field-array-item]"
);
const addButton = fieldArray.querySelector(
"button[data-field-array-add]"
) as HTMLButtonElement;
const deleteButtons: NodeListOf<HTMLButtonElement> =
fieldArray.querySelectorAll("[data-field-array-delete]");
deleteButtons.forEach((deleteBtn) => {
deleteBtn.addEventListener("click", (e) => {
e.preventDefault();
deleteBtn.blur();
fieldArrayContainer
?.querySelector(
`[data-field-array-item="${deleteBtn.dataset.fieldArrayDelete}"]`
)
?.remove();
});
});
// create base element to clone
const baseItem = items[0].cloneNode(true) as HTMLElement;
const elements: NodeListOf<HTMLFormElement> = baseItem.querySelectorAll(
"input, select, textarea"
);
elements.forEach((element) => {
element.value = "";
});
if (fieldArrayContainer && addButton) {
addButton.addEventListener("click", (event) => {
event.preventDefault();
const newItem = baseItem.cloneNode(true) as HTMLElement;
const deleteBtn: HTMLButtonElement | null = newItem.querySelector(
"button[data-field-array-delete]"
);
if (deleteBtn) {
deleteBtn.addEventListener("click", () => {
deleteBtn.blur();
newItem.remove();
});
fieldArrayContainer.appendChild(newItem);
newItem.scrollIntoView({
behavior: "auto",
block: "center",
inline: "center",
});
// reload tooltip module for showing remove button label
Tooltip();
// focus to first form element if mouse click
if (event.screenX !== 0 && event.screenY !== 0) {
const elements: NodeListOf<HTMLFormElement> =
newItem.querySelectorAll("input, select, textarea");
if (elements.length > 0) {
elements[0].focus();
}
}
}
});
const updateIndexes = () => {
// get last child item to set item count
const items: NodeListOf<HTMLElement> =
fieldArrayContainer.querySelectorAll("[data-field-array-item]");
let itemIndex = 0;
items.forEach((item) => {
const itemNumber: HTMLElement | null = item.querySelector(
"[data-field-array-number]"
);
if (itemNumber) {
itemNumber.innerHTML = "#";
const indexNum = itemIndex + 1;
if (item.dataset.fieldArrayItem !== itemIndex.toString()) {
item.classList.add("motion-safe:animate-single-pulse");
setTimeout(() => {
item.classList.remove("motion-safe:animate-single-pulse");
itemNumber.innerHTML = indexNum.toString();
}, 300);
} else {
itemNumber.innerHTML = indexNum.toString();
}
}
item.dataset.fieldArrayItem = itemIndex.toString();
const deleteBtn = item.querySelector(
"button[data-field-array-delete]"
) as HTMLButtonElement | null;
if (deleteBtn) {
deleteBtn.dataset.fieldArrayDelete = itemIndex.toString();
}
const itemElements: NodeListOf<HTMLFormElement> =
item.querySelectorAll("input, select, textarea");
itemElements.forEach((element) => {
const label: HTMLLabelElement | null = item.querySelector(
`label[for="${element.id}"]`
);
const elementID = element.name.replace(
/(.*\[)\d+?(\].*)/g,
`$1${itemIndex}$2`
);
if (label) {
label.htmlFor = elementID;
}
element.id = elementID;
element.name = elementID;
});
itemIndex++;
});
};
// add mutation observer to run index updates when field array
// items are added or removed
const callback = function (mutationList: MutationRecord[]) {
for (const mutation of mutationList) {
if (mutation.type === "childList") {
updateIndexes();
}
}
};
const observer = new MutationObserver(callback);
observer.observe(fieldArrayContainer, { childList: true });
}
}
};
export default FieldArray;

View File

@ -1,3 +1,13 @@
@layer base {
html {
scroll-behavior: smooth;
}
.form-helper {
@apply text-skin-muted;
}
}
@layer components { @layer components {
.post-content { .post-content {
& a { & a {
@ -78,4 +88,13 @@
#facc15 20px #facc15 20px
); );
} }
.divide-fieldset-y > :not([hidden], legend) ~ :not([hidden], legend) {
@apply pt-4;
--tw-divide-y-reverse: 0;
border-top-width: calc(1px * calc(1 - var(--tw-divide-y-reverse)));
border-bottom-width: calc(1px * var(--tw-divide-y-reverse));
}
} }

View File

@ -1,23 +1,33 @@
@layer components { @layer components {
.form-radio-btn { .form-radio-btn {
@apply absolute mt-3 ml-3 border-contrast border-3 text-accent-base; @apply absolute right-4 top-4 border-contrast border-3 text-accent-base;
&:focus { &:focus {
@apply ring-accent; @apply ring-accent;
} }
&:checked { &:checked {
@apply ring-2 ring-contrast;
& + label { & + label {
@apply text-accent-contrast bg-accent-base; @apply text-accent-hover bg-base border-accent-base shadow-none;
}
& + label .form-radio-btn-description {
@apply text-accent-base;
} }
} }
& + label { & + label {
@apply inline-flex items-center py-2 pl-8 pr-2 text-sm font-semibold rounded-lg cursor-pointer border-contrast bg-elevated border-3; @apply h-full w-full inline-flex flex-col items-start py-3 px-4 text-sm font-bold rounded-lg cursor-pointer border-contrast bg-elevated border-3 transition-all;
color: hsl(var(--color-text-muted)); box-shadow: 2px 2px 0 hsl(var(--color-border-contrast));
}
& + label span {
@apply pr-8;
}
& + label .form-radio-btn-description {
@apply font-normal text-xs text-skin-muted text-balance;
} }
} }
} }

View File

@ -17,6 +17,8 @@ class Checkbox extends FormComponent
protected string $hint = ''; protected string $hint = '';
protected string $helper = '';
protected bool $isChecked = false; protected bool $isChecked = false;
#[Override] #[Override]
@ -37,10 +39,26 @@ class Checkbox extends FormComponent
'slot' => $this->hint, 'slot' => $this->hint,
]))->render(); ]))->render();
$this->mergeClass('inline-flex items-center'); $this->mergeClass('inline-flex items-start gap-x-2');
$helperText = '';
if ($this->helper !== '') {
$helperId = $this->name . 'Help';
$helperText = (new Helper([
'id' => $helperId,
'slot' => $this->helper,
'class' => '-mt-1',
]))->render();
$this->attributes['aria-describedby'] = $helperId;
}
return <<<HTML return <<<HTML
<label {$this->getStringifiedAttributes()}>{$checkboxInput}<span class="ml-2">{$this->slot}{$hint}</span></label> <label {$this->getStringifiedAttributes()}>{$checkboxInput}
<div class="flex flex-col">
<span>{$this->slot}{$hint}</span>
{$helperText}
</div>
</label>
HTML; HTML;
} }
} }

View File

@ -14,7 +14,7 @@ class Helper extends Component
#[Override] #[Override]
public function render(): string public function render(): string
{ {
$this->mergeClass('text-skin-muted'); $this->mergeClass('form-helper');
return <<<HTML return <<<HTML
<small {$this->getStringifiedAttributes()}>{$this->slot}</small> <small {$this->getStringifiedAttributes()}>{$this->slot}</small>

View File

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Views\Components\Forms; namespace App\Views\Components\Forms;
use App\Views\Components\Hint;
use Override; use Override;
class RadioButton extends FormComponent class RadioButton extends FormComponent
@ -17,7 +16,7 @@ class RadioButton extends FormComponent
protected bool $isSelected = false; protected bool $isSelected = false;
protected string $hint = ''; protected string $description = '';
#[Override] #[Override]
public function render(): string public function render(): string
@ -32,21 +31,30 @@ class RadioButton extends FormComponent
$data['required'] = 'required'; $data['required'] = 'required';
} }
$this->mergeClass('relative w-full');
$descriptionText = '';
if ($this->description !== '') {
$describerId = $this->name . 'Help';
$descriptionText = <<<HTML
<span id="{$describerId}" class="form-radio-btn-description">{$this->description}</span>
HTML;
$data['aria-describedby'] = $describerId;
}
$radioInput = form_radio( $radioInput = form_radio(
$data, $data,
$this->value, $this->value,
old($this->name) ? old($this->name) === $this->value : $this->isSelected, old($this->name) ? old($this->name) === $this->value : $this->isSelected,
); );
$hint = $this->hint === '' ? '' : (new Hint([
'class' => 'ml-1 text-base',
'slot' => $this->hint,
]))->render();
return <<<HTML return <<<HTML
<div {$this->getStringifiedAttributes()}"> <div {$this->getStringifiedAttributes()}">
{$radioInput} {$radioInput}
<label for="{$this->value}">{$this->slot}{$hint}</label> <label for="{$this->value}">
<span>{$this->slot}</span>
{$descriptionText}
</label>
</div> </div>
HTML; HTML;
} }

View File

@ -22,10 +22,10 @@ class RadioGroup extends FormComponent
*/ */
protected array $options = []; protected array $options = [];
protected string $helper = '';
protected string $hint = ''; protected string $hint = '';
protected string $helper = '';
#[Override] #[Override]
public function render(): string public function render(): string
{ {
@ -34,12 +34,12 @@ class RadioGroup extends FormComponent
$options = ''; $options = '';
foreach ($this->options as $option) { foreach ($this->options as $option) {
$options .= (new RadioButton([ $options .= (new RadioButton([
'value' => $option['value'], 'value' => $option['value'],
'name' => $this->name, 'name' => $this->name,
'slot' => $option['label'], 'slot' => $option['label'],
'hint' => $option['hint'] ?? '', 'description' => $option['description'] ?? '',
'isSelected' => var_export($this->value === null ? ($option['value'] === $this->options[array_key_first($this->options)]['value']) : ($this->value === $option['value']), true), 'isSelected' => var_export($this->value === null ? ($option['value'] === $this->options[array_key_first($this->options)]['value']) : ($this->value === $option['value']), true),
'isRequired' => var_export($this->isRequired, true), 'isRequired' => var_export($this->isRequired, true),
]))->render(); ]))->render();
} }
@ -62,7 +62,7 @@ class RadioGroup extends FormComponent
<fieldset {$this->getStringifiedAttributes()}> <fieldset {$this->getStringifiedAttributes()}>
<legend class="-mb-1 text-sm font-semibold">{$this->label}{$hint}</legend> <legend class="-mb-1 text-sm font-semibold">{$this->label}{$hint}</legend>
{$helperText} {$helperText}
<div class="flex gap-1 mt-1">{$options}</div> <div class="grid grid-cols-radioGroup gap-2 mt-1">{$options}</div>
</fieldset> </fieldset>
HTML; HTML;
} }

View File

@ -37,7 +37,7 @@ class Select extends FormComponent
$options = ''; $options = '';
$selected = $this->value ?? $this->defaultValue; $selected = $this->value ?? $this->defaultValue;
foreach ($this->options as $option) { foreach ($this->options as $option) {
$options .= '<option ' . (array_key_exists('hint', $option) ? 'data-label-description="' . $option['hint'] . '" ' : '') . 'value="' . $option['value'] . '"' . ($option['value'] === $selected ? ' selected' : '') . '>' . $option['label'] . '</option>'; $options .= '<option ' . (array_key_exists('description', $option) ? 'data-label-description="' . $option['description'] . '" ' : '') . 'value="' . $option['value'] . '"' . ($option['value'] === $selected ? ' selected' : '') . '>' . $option['label'] . '</option>';
} }
$this->attributes['name'] = $this->name; $this->attributes['name'] = $this->name;

View File

@ -45,7 +45,7 @@ class SelectMulti extends FormComponent
$options = ''; $options = '';
$selected = $this->value ?? $this->defaultValue; $selected = $this->value ?? $this->defaultValue;
foreach ($this->options as $option) { foreach ($this->options as $option) {
$options .= '<option ' . (array_key_exists('hint', $option) ? 'data-label-description="' . $option['hint'] . '" ' : '') . 'value="' . $option['value'] . '"' . (in_array($option['value'], $selected, true) ? ' selected' : '') . '>' . $option['label'] . '</option>'; $options .= '<option ' . (array_key_exists('description', $option) ? 'data-label-description="' . $option['description'] . '" ' : '') . 'value="' . $option['value'] . '"' . (in_array($option['value'], $selected, true) ? ' selected' : '') . '>' . $option['label'] . '</option>';
} }
$this->attributes['name'] = $this->name . '[]'; $this->attributes['name'] = $this->name . '[]';

View File

@ -17,12 +17,14 @@ class Toggler extends FormComponent
protected string $hint = ''; protected string $hint = '';
protected string $helper = '';
protected bool $isChecked = false; protected bool $isChecked = false;
#[Override] #[Override]
public function render(): string public function render(): string
{ {
$this->mergeClass('relative justify-between inline-flex items-center gap-x-2'); $this->mergeClass('relative justify-between inline-flex items-start gap-x-2');
$checkbox = form_checkbox( $checkbox = form_checkbox(
[ [
@ -39,9 +41,23 @@ class Toggler extends FormComponent
'slot' => $this->hint, 'slot' => $this->hint,
]))->render(); ]))->render();
$helperText = '';
if ($this->helper !== '') {
$helperId = $this->name . 'Help';
$helperText = (new Helper([
'id' => $helperId,
'slot' => $this->helper,
'class' => '-mt-1',
]))->render();
$this->attributes['aria-describedby'] = $helperId;
}
return <<<HTML return <<<HTML
<label {$this->getStringifiedAttributes()}> <label {$this->getStringifiedAttributes()}>
<span>{$this->slot}{$hint}</span> <div class="flex flex-col">
<span>{$this->slot}{$hint}</span>
{$helperText}
</div>
{$checkbox} {$checkbox}
<span class="form-switch-slider"></span> <span class="form-switch-slider"></span>
</label> </label>

View File

@ -1,6 +1,7 @@
<?= helper(['components', 'svg']) ?> <?= helper(['components', 'svg']) ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="<?= service('request')
->getLocale() ?>">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">

View File

@ -1,6 +1,7 @@
<?= helper(['components', 'svg']) ?> <?= helper(['components', 'svg']) ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="<?= service('request')
->getLocale() ?>">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">

View File

@ -101,14 +101,16 @@ each property being a field key and the value being a `Field` object.
A field is a form element: A field is a form element:
| Property | Type | Note | | Property | Type | Note |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------ |
| `type` | `checkbox` \| `datetime` \| `email` \| `markdown` \| `number` \| `radio-group` \| `select-multiple` \| `select` \| `text` \| `textarea` \| `toggler` \| `url` | Default is `text` | | `type` | `checkbox` \| `datetime` \| `email` \| `group` \| `markdown` \| `number` \| `radio-group` \| `select-multiple` \| `select` \| `text` \| `textarea` \| `toggler` \| `url` | Default is `text` |
| `label` (required) | `string` | Can be translated (see i18n) | | `label` (required) | `string` | Can be translated (see i18n) |
| `hint` | `string` | Can be translated (see i18n) | | `hint` | `string` | Can be translated (see i18n) |
| `helper` | `string` | Can be translated (see i18n) | | `helper` | `string` | Can be translated (see i18n) |
| `optional` | `boolean` | Default is `false` | | `optional` | `boolean` | Default is `false` |
| `options` | `Options` | Required for `radio-group`, `select-multiple`, and `select` types. | | `options` | `Options` | Required for `radio-group`, `select-multiple`, and `select` types. |
| `multiple` | `boolean` | Default is `false` |
| `fields` | `Array<string, Field>` | Required for `group` type |
#### Options object #### Options object
@ -119,7 +121,7 @@ The `Options` object properties are option keys and the value is an `Option`.
| Property | Type | Note | | Property | Type | Note |
| ------------------ | -------- | ---------------------------- | | ------------------ | -------- | ---------------------------- |
| `label` (required) | `string` | Can be translated (see i18n) | | `label` (required) | `string` | Can be translated (see i18n) |
| `hint` | `string` | Can be translated (see i18n) | | `description` | `string` | Can be translated (see i18n) |
### files ### files

View File

@ -38,6 +38,10 @@ return [
'noChoicesText' => 'No choices to choose from', 'noChoicesText' => 'No choices to choose from',
'maxItemText' => 'Cannot add more items', 'maxItemText' => 'Cannot add more items',
], ],
'fieldArray' => [
'add' => 'Add',
'remove' => 'Remove',
],
'upload_file' => 'Upload a file', 'upload_file' => 'Upload a file',
'remote_url' => 'Remote URL', 'remote_url' => 'Remote URL',
'save' => 'Save', 'save' => 'Save',

View File

@ -109,11 +109,11 @@ return [
'type' => [ 'type' => [
'label' => 'Type', 'label' => 'Type',
'full' => 'Full', 'full' => 'Full',
'full_hint' => 'Complete content (the episode)', 'full_description' => 'Complete content (the episode)',
'trailer' => 'Trailer', 'trailer' => 'Trailer',
'trailer_hint' => 'Short, promotional piece of content that represents a preview of the current show', 'trailer_description' => 'Short, promotional piece of content that represents a preview of the current show',
'bonus' => 'Bonus', 'bonus' => 'Bonus',
'bonus_hint' => 'Extra content for the show (for example, behind the scenes info or interviews with the cast) or cross-promotional content for another show', 'bonus_description' => 'Extra content for the show (for example, behind the scenes info or interviews with the cast) or cross-promotional content for another show',
], ],
'premium_title' => 'Premium', 'premium_title' => 'Premium',
'premium' => 'Episode must be accessible to premium subscribers only', 'premium' => 'Episode must be accessible to premium subscribers only',

View File

@ -72,19 +72,19 @@ return [
'type' => [ 'type' => [
'label' => 'Type', 'label' => 'Type',
'episodic' => 'Episodic', 'episodic' => 'Episodic',
'episodic_hint' => 'If episodes are intended to be consumed without any specific order. Newest episodes will be presented first.', 'episodic_description' => 'If episodes are intended to be consumed without any specific order. Newest episodes will be presented first.',
'serial' => 'Serial', 'serial' => 'Serial',
'serial_hint' => 'If episodes are intended to be consumed in sequential order. Episodes will be presented in numeric order.', 'serial_description' => 'If episodes are intended to be consumed in sequential order. Episodes will be presented in numeric order.',
], ],
'medium' => [ 'medium' => [
'label' => 'Medium', 'label' => 'Medium',
'hint' => 'Medium as represented by podcast:medium tag in RSS. Changing this may change how players present your feed.', 'hint' => 'Medium as represented by podcast:medium tag in RSS. Changing this may change how players present your feed.',
'podcast' => 'Podcast', 'podcast' => 'Podcast',
'podcast_hint' => 'Describes a feed for a podcast show.', 'podcast_description' => 'Describes a feed for a podcast show.',
'music' => 'Music', 'music' => 'Music',
'music_hint' => 'A feed of music organized into an "album" with each item a song within the album.', 'music_description' => 'A feed of music organized into an "album" with each item a song within the album.',
'audiobook' => 'Audiobook', 'audiobook' => 'Audiobook',
'audiobook_hint' => 'Specific types of audio with one item per feed, or where items represent chapters within the book.', 'audiobook_description' => 'Specific types of audio with one item per feed, or where items represent chapters within the book.',
], ],
'description' => 'Description', 'description' => 'Description',
'classification_section_title' => 'Classification', 'classification_section_title' => 'Classification',

View File

@ -13,37 +13,41 @@ use CodeIgniter\HTTP\RedirectResponse;
use CodeIgniter\HTTP\URI; use CodeIgniter\HTTP\URI;
use CodeIgniter\I18n\Time; use CodeIgniter\I18n\Time;
use Modules\Admin\Controllers\BaseController; use Modules\Admin\Controllers\BaseController;
use Modules\Plugins\Core\BasePlugin;
use Modules\Plugins\Core\Markdown; use Modules\Plugins\Core\Markdown;
use Modules\Plugins\Core\Plugins; use Modules\Plugins\Core\Plugins;
use Modules\Plugins\Manifest\Field;
class PluginController extends BaseController class PluginController extends BaseController
{ {
protected Plugins $plugins;
public function __construct()
{
$this->plugins = service('plugins');
}
public function installed(): string public function installed(): string
{ {
/** @var Plugins $plugins */
$plugins = service('plugins');
$pager = service('pager'); $pager = service('pager');
$page = (int) ($this->request->getGet('page') ?? 1); $page = (int) ($this->request->getGet('page') ?? 1);
$perPage = 10; $perPage = 10;
$total = $plugins->getInstalledCount(); $total = $this->plugins->getInstalledCount();
$pager_links = $pager->makeLinks($page, $perPage, $total); $pager_links = $pager->makeLinks($page, $perPage, $total);
return view('plugins/installed', [ return view('plugins/installed', [
'total' => $total, 'total' => $total,
'plugins' => $plugins->getPlugins($page, $perPage), 'plugins' => $this->plugins->getPlugins($page, $perPage),
'pager_links' => $pager_links, 'pager_links' => $pager_links,
]); ]);
} }
public function vendor(string $vendor): string public function vendor(string $vendor): string
{ {
/** @var Plugins $plugins */
$plugins = service('plugins');
$vendorPlugins = $plugins->getVendorPlugins($vendor); $vendorPlugins = $this->plugins->getVendorPlugins($vendor);
replace_breadcrumb_params([ replace_breadcrumb_params([
$vendor => $vendor, $vendor => $vendor,
]); ]);
@ -56,12 +60,10 @@ class PluginController extends BaseController
public function view(string $vendor, string $package): string public function view(string $vendor, string $package): string
{ {
/** @var Plugins $plugins */
$plugins = service('plugins');
$plugin = $plugins->getPlugin($vendor, $package); $plugin = $this->plugins->getPlugin($vendor, $package);
if ($plugin === null) { if (! $plugin instanceof BasePlugin) {
throw PageNotFoundException::forPageNotFound(); throw PageNotFoundException::forPageNotFound();
} }
@ -80,12 +82,10 @@ class PluginController extends BaseController
string $podcastId = null, string $podcastId = null,
string $episodeId = null string $episodeId = null
): string { ): string {
/** @var Plugins $plugins */
$plugins = service('plugins');
$plugin = $plugins->getPlugin($vendor, $package); $plugin = $this->plugins->getPlugin($vendor, $package);
if ($plugin === null) { if (! $plugin instanceof BasePlugin) {
throw PageNotFoundException::forPageNotFound(); throw PageNotFoundException::forPageNotFound();
} }
@ -146,12 +146,10 @@ class PluginController extends BaseController
string $podcastId = null, string $podcastId = null,
string $episodeId = null string $episodeId = null
): RedirectResponse { ): RedirectResponse {
/** @var Plugins $plugins */
$plugins = service('plugins');
$plugin = $plugins->getPlugin($vendor, $package); $plugin = $this->plugins->getPlugin($vendor, $package);
if ($plugin === null) { if (! $plugin instanceof BasePlugin) {
throw PageNotFoundException::forPageNotFound(); throw PageNotFoundException::forPageNotFound();
} }
@ -170,12 +168,36 @@ class PluginController extends BaseController
// construct validation rules first // construct validation rules first
$rules = []; $rules = [];
foreach ($plugin->getSettingsFields($type) as $field) { foreach ($plugin->getSettingsFields($type) as $field) {
$typeRules = $plugins::FIELDS_VALIDATIONS[$field->type]; $typeRules = $this->plugins::FIELDS_VALIDATIONS[$field->type];
if (! in_array('permit_empty', $typeRules, true)) { if (! in_array('permit_empty', $typeRules, true)) {
$typeRules[] = $field->optional ? 'permit_empty' : 'required'; $typeRules[] = $field->optional ? 'permit_empty' : 'required';
} }
$rules[$field->key] = $typeRules; if ($field->multiple) {
if ($field->type === 'group') {
foreach ($field->fields as $subField) {
$typeRules = $this->plugins::FIELDS_VALIDATIONS[$subField->type];
if (! in_array('permit_empty', $typeRules, true)) {
$typeRules[] = $subField->optional ? 'permit_empty' : 'required';
}
$rules[sprintf('%s.*.%s', $field->key, $subField->key)] = $typeRules;
}
} else {
$rules[$field->key . '.*'] = $typeRules;
}
} elseif ($field->type === 'group') {
foreach ($field->fields as $subField) {
$typeRules = $this->plugins::FIELDS_VALIDATIONS[$subField->type];
if (! in_array('permit_empty', $typeRules, true)) {
$typeRules[] = $subField->optional ? 'permit_empty' : 'required';
}
$rules[sprintf('%s.%s', $field->key, $subField->key)] = $typeRules;
}
} else {
$rules[$field->key] = $typeRules;
}
} }
if (! $this->validate($rules)) { if (! $this->validate($rules)) {
@ -188,20 +210,9 @@ class PluginController extends BaseController
$validatedData = $this->validator->getValidated(); $validatedData = $this->validator->getValidated();
foreach ($plugin->getSettingsFields($type) as $field) { foreach ($plugin->getSettingsFields($type) as $field) {
$value = $validatedData[$field->key] ?? null; $fieldValue = $validatedData[$field->key] ?? null;
$fieldValue = $value === '' ? null : match ($plugins::FIELDS_CASTS[$field->type] ?? 'text') {
'bool' => $value === 'yes', $this->plugins->setOption($plugin, $field->key, $this->castFieldValue($field, $fieldValue), $context);
'int' => (int) $value,
'uri' => new URI($value),
'datetime' => Time::createFromFormat(
'Y-m-d H:i',
$value,
$this->request->getPost('client_timezone')
)->setTimezone(app_timezone()),
'markdown' => new Markdown($value),
default => $value,
};
$plugins->setOption($plugin, $field->key, $fieldValue, $context);
} }
return redirect()->back() return redirect()->back()
@ -212,49 +223,114 @@ class PluginController extends BaseController
public function activate(string $vendor, string $package): RedirectResponse public function activate(string $vendor, string $package): RedirectResponse
{ {
/** @var Plugins $plugins */
$plugins = service('plugins');
$plugin = $plugins->getPlugin($vendor, $package); $plugin = $this->plugins->getPlugin($vendor, $package);
if ($plugin === null) { if (! $plugin instanceof BasePlugin) {
throw PageNotFoundException::forPageNotFound(); throw PageNotFoundException::forPageNotFound();
} }
$plugins->activate($plugin); $this->plugins->activate($plugin);
return redirect()->back(); return redirect()->back();
} }
public function deactivate(string $vendor, string $package): RedirectResponse public function deactivate(string $vendor, string $package): RedirectResponse
{ {
/** @var Plugins $plugins */
$plugins = service('plugins');
$plugin = $plugins->getPlugin($vendor, $package); $plugin = $this->plugins->getPlugin($vendor, $package);
if ($plugin === null) { if (! $plugin instanceof BasePlugin) {
throw PageNotFoundException::forPageNotFound(); throw PageNotFoundException::forPageNotFound();
} }
$plugins->deactivate($plugin); $this->plugins->deactivate($plugin);
return redirect()->back(); return redirect()->back();
} }
public function uninstall(string $vendor, string $package): RedirectResponse public function uninstall(string $vendor, string $package): RedirectResponse
{ {
/** @var Plugins $plugins */
$plugins = service('plugins');
$plugin = $plugins->getPlugin($vendor, $package); $plugin = $this->plugins->getPlugin($vendor, $package);
if ($plugin === null) { if (! $plugin instanceof BasePlugin) {
throw PageNotFoundException::forPageNotFound(); throw PageNotFoundException::forPageNotFound();
} }
$plugins->uninstall($plugin); $this->plugins->uninstall($plugin);
return redirect()->back(); return redirect()->back();
} }
private function castFieldValue(Field $field, mixed $fieldValue): mixed
{
if ($fieldValue === '' || $fieldValue === null) {
return null;
}
$value = null;
if ($field->multiple) {
$value = [];
foreach ($fieldValue as $key => $val) {
if ($val === '') {
continue;
}
if ($field->type === 'group') {
foreach ($val as $subKey => $subVal) {
/** @var Field|false $subField */
$subField = array_column($field->fields, null, 'key')[$subKey] ?? false;
if (! $subField) {
continue;
}
$v = $this->castValue($subVal, $subField->type);
if ($v) {
$value[$key][$subKey] = $v;
}
}
} else {
$value[$key] = $this->castValue($val, $field->type);
}
}
} elseif ($field->type === 'group') {
foreach ($fieldValue as $subKey => $subVal) {
/** @var Field|false $subField */
$subField = array_column($field->fields, null, 'key')[$subKey] ?? false;
if (! $subField) {
continue;
}
$v = $this->castValue($subVal, $subField->type);
if ($v) {
$value[$subKey] = $v;
}
}
} else {
$value = $this->castValue($fieldValue, $field->type);
}
return $value === [] ? null : $value;
}
private function castValue(mixed $value, string $type): mixed
{
if ($value === '' || $value === null) {
return null;
}
return match ($this->plugins::FIELDS_CASTS[$type] ?? 'text') {
'bool' => $value === 'yes',
'int' => (int) $value,
'uri' => new URI($value),
'datetime' => Time::createFromFormat(
'Y-m-d H:i',
$value,
$this->request->getPost('client_timezone')
)->setTimezone(app_timezone()),
'markdown' => new Markdown($value),
default => $value,
};
}
} }

View File

@ -37,6 +37,7 @@ class Plugins
'textarea' => ['string'], 'textarea' => ['string'],
'toggler' => ['permit_empty'], 'toggler' => ['permit_empty'],
'url' => ['valid_url_strict'], 'url' => ['valid_url_strict'],
'group' => ['permit_empty', 'is_list'],
]; ];
public const FIELDS_CASTS = [ public const FIELDS_CASTS = [

View File

@ -7,27 +7,33 @@ namespace Modules\Plugins\Manifest;
use Override; use Override;
/** /**
* @property 'checkbox'|'datetime'|'email'|'markdown'|'number'|'radio-group'|'select-multiple'|'select'|'text'|'textarea'|'toggler'|'url'|'group' $type
* @property string $key * @property string $key
* @property 'text'|'email'|'url'|'markdown'|'number'|'switch' $type
* @property string $label * @property string $label
* @property string $hint * @property string $hint
* @property string $helper * @property string $helper
* @property bool $optional * @property bool $optional
* @property Option[] $options
* @property bool $multiple
* @property Field[] $fields
*/ */
class Field extends ManifestObject class Field extends ManifestObject
{ {
protected const VALIDATION_RULES = [ protected const VALIDATION_RULES = [
'type' => 'permit_empty|in_list[checkbox,datetime,email,markdown,number,radio-group,select-multiple,select,text,textarea,toggler,url]', 'type' => 'permit_empty|in_list[checkbox,datetime,email,markdown,number,radio-group,select-multiple,select,text,textarea,toggler,url,group]',
'key' => 'required|alpha_dash', 'key' => 'required|alpha_dash',
'label' => 'required|string', 'label' => 'required|string',
'hint' => 'permit_empty|string', 'hint' => 'permit_empty|string',
'helper' => 'permit_empty|string', 'helper' => 'permit_empty|string',
'optional' => 'permit_empty|is_boolean', 'optional' => 'permit_empty|is_boolean',
'options' => 'permit_empty|is_list', 'options' => 'permit_empty|is_list',
'multiple' => 'permit_empty|is_boolean',
'fields' => 'permit_empty|is_list',
]; ];
protected const CASTS = [ protected const CASTS = [
'options' => [Option::class], 'options' => [Option::class],
'fields' => [self::class],
]; ];
protected string $type = 'text'; protected string $type = 'text';
@ -42,6 +48,8 @@ class Field extends ManifestObject
protected bool $optional = false; protected bool $optional = false;
protected bool $multiple = false;
/** /**
* @var Option[] * @var Option[]
*/ */
@ -60,37 +68,49 @@ class Field extends ManifestObject
$data['options'] = $newOptions; $data['options'] = $newOptions;
} }
if (array_key_exists('fields', $data)) {
$newFields = [];
foreach ($data['fields'] as $key => $field) {
$field['key'] = $key;
$newFields[] = $field;
}
$data['fields'] = $newFields;
}
parent::loadData($data); parent::loadData($data);
} }
/** /**
* @return array{label:string,value:string,hint:string}[] * @return array{label:string,value:string,description:string}[]
*/ */
public function getOptionsArray(string $i18nKey): array public function getOptionsArray(string $pluginKey): array
{ {
$i18nKey = sprintf('%s.settings.%s.%s.options', $pluginKey, $this->type, $this->key);
$optionsArray = []; $optionsArray = [];
foreach ($this->options as $option) { foreach ($this->options as $option) {
$optionsArray[] = [ $optionsArray[] = [
'value' => $option->value, 'value' => $option->value,
'label' => esc($this->getTranslated($i18nKey . '.' . $option->value . '.label', $option->label)), 'label' => $option->getTranslated($i18nKey, 'label'),
'hint' => esc($this->getTranslated($i18nKey . '.' . $option->value . '.hint', (string) $option->hint)), 'description' => $option->getTranslated($i18nKey, 'description'),
]; ];
} }
return $optionsArray; return $optionsArray;
} }
public function getTranslated(string $i18nKey, string $default): string public function getTranslated(string $pluginKey, string $property): string
{ {
$key = 'Plugin.' . $i18nKey; $key = sprintf('Plugin.%s.settings.%s.%s.%s', $pluginKey, $this->type, $this->key, $property);
/** @var string $i18nField */ /** @var string $i18nField */
$i18nField = lang($key); $i18nField = lang($key);
if ($default === '' || $i18nField === $key) { if ($this->{$property} === '' || $i18nField === $key) {
return $default; return esc($this->{$property});
} }
return $i18nField; return esc($i18nField);
} }
} }

View File

@ -7,19 +7,33 @@ namespace Modules\Plugins\Manifest;
/** /**
* @property string $label * @property string $label
* @property string $value * @property string $value
* @property ?string $hint * @property string $hint
*/ */
class Option extends ManifestObject class Option extends ManifestObject
{ {
protected const VALIDATION_RULES = [ protected const VALIDATION_RULES = [
'label' => 'required|string', 'label' => 'required|string',
'value' => 'required|alpha_numeric_punct', 'value' => 'required|alpha_numeric_punct',
'hint' => 'permit_empty|string', 'description' => 'permit_empty|string',
]; ];
protected string $label; protected string $label;
protected string $value; protected string $value;
protected ?string $hint = null; protected string $description = '';
public function getTranslated(string $i18nKey, string $property): string
{
$key = sprintf('%s.%s.%s', $i18nKey, $this->value, $property);
/** @var string $i18nField */
$i18nField = lang($key);
if ($this->{$property} === '' || $i18nField === $key) {
return esc($this->{$property});
}
return esc($i18nField);
}
} }

View File

@ -173,6 +173,7 @@
"properties": { "properties": {
"type": { "type": {
"enum": [ "enum": [
"group",
"checkbox", "checkbox",
"datetime", "datetime",
"email", "email",
@ -206,12 +207,23 @@
"^[A-Za-z0-9]+[\\w\\-\\:\\.]*$": { "$ref": "#/$defs/option" } "^[A-Za-z0-9]+[\\w\\-\\:\\.]*$": { "$ref": "#/$defs/option" }
}, },
"additionalProperties": false "additionalProperties": false
},
"multiple": {
"type": "boolean"
},
"fields": {
"type": "object",
"patternProperties": {
"^[A-Za-z]+[\\w\\-\\:\\.]*$": { "$ref": "#/$defs/field" }
},
"additionalProperties": false
} }
}, },
"required": ["label"], "required": ["label"],
"additionalProperties": false, "additionalProperties": false,
"allOf": [ "allOf": [
{ "$ref": "#/$defs/field-multiple-implies-options-is-required" } { "$ref": "#/$defs/field-multiple-implies-options-is-required" },
{ "$ref": "#/$defs/field-group-type-implies-fields-is-required" }
] ]
}, },
"option": { "option": {
@ -220,7 +232,7 @@
"label": { "label": {
"type": "string" "type": "string"
}, },
"hint": { "description": {
"type": "string" "type": "string"
} }
}, },
@ -245,6 +257,21 @@
}, },
{ "required": ["options"] } { "required": ["options"] }
] ]
},
"field-group-type-implies-fields-is-required": {
"anyOf": [
{
"not": {
"properties": {
"type": {
"anyOf": [{ "const": "group" }]
}
},
"required": ["type"]
}
},
{ "required": ["fields"] }
]
} }
} }
} }

View File

@ -1,5 +1,6 @@
/* eslint-disable */ /* eslint-disable */
const defaultTheme = require("tailwindcss/defaultTheme"); const defaultTheme = require("tailwindcss/defaultTheme");
const { transform } = require("typescript");
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
module.exports = { module.exports = {
@ -122,6 +123,7 @@ module.exports = {
colorButtons: "repeat(auto-fill, minmax(4rem, 1fr))", colorButtons: "repeat(auto-fill, minmax(4rem, 1fr))",
platforms: "repeat(auto-fill, minmax(18rem, 1fr))", platforms: "repeat(auto-fill, minmax(18rem, 1fr))",
plugins: "repeat(auto-fill, minmax(20rem, 1fr))", plugins: "repeat(auto-fill, minmax(20rem, 1fr))",
radioGroup: "repeat(auto-fit, minmax(14rem, 1fr))",
}, },
gridTemplateRows: { gridTemplateRows: {
admin: "40px 1fr", admin: "40px 1fr",
@ -162,6 +164,18 @@ module.exports = {
zIndex: { zIndex: {
60: 60, 60: 60,
}, },
keyframes: {
"slight-pulse": {
"0%": { transform: "scale(1)" },
"60%": { transform: "scale(0.96)" },
"75%": { transform: "scale(1.05)" },
"95%": { transform: "scale(0.98)" },
"100%": { transform: "scale(1)" },
},
},
animation: {
"single-pulse": "slight-pulse 300ms linear 1",
},
}, },
}, },
variants: {}, variants: {},

View File

@ -74,19 +74,19 @@
name="type" name="type"
options="<?= esc(json_encode([ options="<?= esc(json_encode([
[ [
'label' => lang('Episode.form.type.full'), 'label' => lang('Episode.form.type.full'),
'value' => 'full', 'value' => 'full',
'hint' => lang('Episode.form.type.full_hint'), 'description' => lang('Episode.form.type.full_description'),
], ],
[ [
'label' => lang('Episode.form.type.trailer'), 'label' => lang('Episode.form.type.trailer'),
'value' => 'trailer', 'value' => 'trailer',
'hint' => lang('Episode.form.type.trailer_hint'), 'description' => lang('Episode.form.type.trailer_description'),
], ],
[ [
'label' => lang('Episode.form.type.bonus'), 'label' => lang('Episode.form.type.bonus'),
'value' => 'bonus', 'value' => 'bonus',
'hint' => lang('Episode.form.type.bonus_hint'), 'description' => lang('Episode.form.type.bonus_description'),
], ],
])) ?>" ])) ?>"
isRequired="true" isRequired="true"

View File

@ -78,19 +78,19 @@
value="<?= $episode->type ?>" value="<?= $episode->type ?>"
options="<?= esc(json_encode([ options="<?= esc(json_encode([
[ [
'label' => lang('Episode.form.type.full'), 'label' => lang('Episode.form.type.full'),
'value' => 'full', 'value' => 'full',
'hint' => lang('Episode.form.type.full_hint'), 'description' => lang('Episode.form.type.full_description'),
], ],
[ [
'label' => lang('Episode.form.type.trailer'), 'label' => lang('Episode.form.type.trailer'),
'value' => 'trailer', 'value' => 'trailer',
'hint' => lang('Episode.form.type.trailer_hint'), 'description' => lang('Episode.form.type.trailer_description'),
], ],
[ [
'label' => lang('Episode.form.type.bonus'), 'label' => lang('Episode.form.type.bonus'),
'value' => 'bonus', 'value' => 'bonus',
'hint' => lang('Episode.form.type.bonus_hint'), 'description' => lang('Episode.form.type.bonus_description'),
], ],
])) ?>" ])) ?>"
isRequired="true" isRequired="true"

View File

@ -0,0 +1,144 @@
<?php switch ($type): case 'checkbox': ?>
<x-Forms.Checkbox
class="<?= $class ?>"
name="<?= $name ?>"
hint="<?= $hint ?>"
helper="<?= $helper ?>"
isChecked="<?= $value ? 'true' : 'false' ?>"
><?= $label ?></x-Forms.Checkbox>
<?php break;
case 'toggler': ?>
<x-Forms.Toggler
class="<?= $class ?>"
name="<?= $name ?>"
hint="<?= $hint ?>"
helper="<?= $helper ?>"
isChecked="<?= $value ? 'true' : 'false' ?>"
><?= $label ?></x-Forms.Toggler>
<?php break;
case 'radio-group': ?>
<x-Forms.RadioGroup
class="<?= $class ?>"
name="<?= $name ?>"
label="<?= $label ?>"
hint="<?= $hint ?>"
helper="<?= $helper ?>"
options="<?= $options ?>"
isRequired="<?= $optional ? 'false' : 'true' ?>"
value="<?= $value ?>"
/>
<?php break;
case 'select': ?>
<x-Forms.Field
as="Select"
class="<?= $class ?>"
name="<?= $name ?>"
label="<?= $label ?>"
hint="<?= $hint ?>"
helper="<?= $helper ?>"
options="<?= $options ?>"
isRequired="<?= $optional ? 'false' : 'true' ?>"
value="<?= $value ?>"
/>
<?php break;
case 'select-multiple': ?>
<x-Forms.Field
as="SelectMulti"
class="<?= $class ?>"
name="<?= $name ?>"
label="<?= $label ?>"
hint="<?= $hint ?>"
helper="<?= $helper ?>"
options="<?= $options ?>"
isRequired="<?= $optional ? 'false' : 'true' ?>"
value="<?= esc(json_encode($value)) ?>"
/>
<?php break;
case 'email': ?>
<x-Forms.Field
as="Input"
class="<?= $class ?>"
type="email"
name="<?= $name ?>"
label="<?= $label ?>"
hint="<?= $hint ?>"
helper="<?= $helper ?>"
isRequired="<?= $optional ? 'false' : 'true' ?>"
value="<?= $value ?>"
/>
<?php break;
case 'url': ?>
<x-Forms.Field
as="Input"
class="<?= $class ?>"
type="url"
placeholder="https://…"
name="<?= $name ?>"
label="<?= $label ?>"
hint="<?= $hint ?>"
helper="<?= $helper ?>"
isRequired="<?= $optional ? 'false' : 'true' ?>"
value="<?= $value ?>"
/>
<?php break;
case 'number': ?>
<x-Forms.Field
as="Input"
class="<?= $class ?>"
type="number"
name="<?= $name ?>"
label="<?= $label ?>"
hint="<?= $hint ?>"
helper="<?= $helper ?>"
isRequired="<?= $optional ? 'false' : 'true' ?>"
value="<?= $value ?>"
/>
<?php break;
case 'textarea': ?>
<x-Forms.Field
as="Textarea"
class="<?= $class ?>"
name="<?= $name ?>"
label="<?= $label ?>"
hint="<?= $hint ?>"
helper="<?= $helper ?>"
isRequired="<?= $optional ? 'false' : 'true' ?>"
value="<?= $value ?>"
/>
<?php break;
case 'markdown': ?>
<x-Forms.Field
as="MarkdownEditor"
class="<?= $class ?>"
name="<?= $name ?>"
label="<?= $label ?>"
hint="<?= $hint ?>"
helper="<?= $helper ?>"
isRequired="<?= $optional ? 'false' : 'true' ?>"
value="<?= $value ?>"
/>
<?php break;
case 'datetime': ?>
<x-Forms.Field
as="DatetimePicker"
class="<?= $class ?>"
name="<?= $name ?>"
label="<?= $label ?>"
hint="<?= $hint ?>"
helper="<?= $helper ?>"
isRequired="<?= $optional ? 'false' : 'true' ?>"
value="<?= $value ?>"
/>
<?php break;
default: ?>
<x-Forms.Field
as="Input"
class="<?= $class ?>"
name="<?= $name ?>"
label="<?= $label ?>"
hint="<?= $hint ?>"
helper="<?= $helper ?>"
isRequired="<?= $optional ? 'false' : 'true' ?>"
value="<?= $value ?>"
/>
<?php endswitch; ?>

View File

@ -2,138 +2,94 @@
<?= csrf_field() ?> <?= csrf_field() ?>
<?php $hasDatetime = false; ?> <?php $hasDatetime = false; ?>
<?php foreach ($fields as $field): ?> <?php foreach ($fields as $field): ?>
<?php switch ($field->type): case 'checkbox': ?> <?php if ($field->type === 'datetime') {
<x-Forms.Checkbox $hasDatetime = true;
name="<?= $field->key ?>" } ?>
hint="<?= esc($field->getTranslated(sprintf('%s.settings.%s.%s.hint', $plugin->getKey(), $type, $field->key), $field->hint)) ?>" <?php if ($field->multiple):
isChecked="<?= get_plugin_setting($plugin->getKey(), $field->key, $context) ? 'true' : 'false' ?>" if ($field->type === 'group'): ?>
><?= esc($field->getTranslated(sprintf('%s.settings.%s.%s.label', $plugin->getKey(), $type, $field->key), $field->label)) ?></x-Forms.Checkbox> <div class="flex flex-col gap-4" data-field-array="<?= $field->key ?>">
<?php break; <fieldset class="flex flex-col gap-6 rounded" data-field-array-container="<?= $field->key ?>">
case 'toggler': ?> <legend class="relative z-10 mb-4 font-bold text-heading-foreground font-display before:w-full before:absolute before:h-1/2 before:left-0 before:bottom-0 before:rounded-full before:bg-heading-background before:z-[-10] tracking-wide text-base"><?= $field->getTranslated($plugin->getKey(), 'label') ?></legend>
<x-Forms.Toggler <?php
name="<?= $field->key ?>" $fieldArrayValues = get_plugin_setting($plugin->getKey(), $field->key, $context) ?? [''];
hint="<?= esc($field->getTranslated(sprintf('%s.settings.%s.%s.hint', $plugin->getKey(), $type, $field->key), $field->hint)) ?>" foreach ($fieldArrayValues as $index => $value): ?>
isChecked="<?= get_plugin_setting($plugin->getKey(), $field->key, $context) ? 'true' : 'false' ?>" <fieldset class="relative flex flex-col border border-subtle p-4 rounded-tl-none rounded-md gap-2 bg-base" data-field-array-item="<?= $index ?>">
><?= esc($field->getTranslated(sprintf('%s.settings.%s.%s.label', $plugin->getKey(), $type, $field->key), $field->label)) ?></x-Forms.Toggler> <legend class="absolute font-mono left-0 -top-px -ml-6 rounded-l-full rounded-r-none w-6 text-xs h-6 inline-flex items-center justify-center font-semibold border border-subtle bg-base"><span class="sr-only"><?= $field->getTranslated($plugin->getKey(), 'label') ?></span> <span data-field-array-number><?= $index + 1 ?></span></legend>
<?php break; <?php foreach ($field->fields as $subfield): ?>
case 'radio-group': ?> <?= view('plugins/_field', [
<x-Forms.RadioGroup 'class' => 'flex-1',
name="<?= $field->key ?>" 'type' => $subfield->type,
label="<?= esc($field->getTranslated(sprintf('%s.settings.%s.%s.label', $plugin->getKey(), $type, $field->key), $field->label)) ?>" 'name' => sprintf('%s[%s][%s]', $field->key, $index, $subfield->key),
hint="<?= esc($field->getTranslated(sprintf('%s.settings.%s.%s.hint', $plugin->getKey(), $type, $field->key), $field->hint)) ?>" 'label' => $subfield->getTranslated($plugin->getKey(), 'label'),
helper="<?= esc($field->getTranslated(sprintf('%s.settings.%s.%s.helper', $plugin->getKey(), $type, $field->key), $field->helper)) ?>" 'hint' => $subfield->getTranslated($plugin->getKey(), 'hint'),
options="<?= esc(json_encode($field->getOptionsArray(sprintf('%s.settings.%s.%s.options', $plugin->getKey(), $type, $field->key)))) ?>" 'value' => $value[$subfield->key] ?? '',
isRequired="<?= $field->optional ? 'false' : 'true' ?>" 'helper' => $subfield->getTranslated($plugin->getKey(), 'helper'),
value="<?= get_plugin_setting($plugin->getKey(), $field->key, $context) ?>" 'options' => esc(json_encode($subfield->getOptionsArray($plugin->getKey()))),
/> 'optional' => $subfield->optional,
<?php break; ]) ?>
case 'select': ?> <?php endforeach; ?>
<x-Forms.Field <x-IconButton variant="danger" glyph="delete-bin-fill" data-field-array-delete="<?= $index ?>" class="absolute right-0 top-0 -mt-4 -mr-4"><?= lang('Common.forms.fieldArray.remove') ?></x-IconButton>
as="Select" </fieldset>
name="<?= $field->key ?>" <?php endforeach; ?>
label="<?= esc($field->getTranslated(sprintf('%s.settings.%s.%s.label', $plugin->getKey(), $type, $field->key), $field->label)) ?>" </fieldset>
hint="<?= esc($field->getTranslated(sprintf('%s.settings.%s.%s.hint', $plugin->getKey(), $type, $field->key), $field->hint)) ?>" <x-Button iconLeft="add-fill" data-field-array-add="<?= $field->key ?>" variant="secondary" type="button" class="mt-2"><?= lang('Common.forms.fieldArray.add') ?></x-Button>
helper="<?= esc($field->getTranslated(sprintf('%s.settings.%s.%s.helper', $plugin->getKey(), $type, $field->key), $field->helper)) ?>" </div>
options="<?= esc(json_encode($field->getOptionsArray(sprintf('%s.settings.%s.%s.options', $plugin->getKey(), $type, $field->key)))) ?>" <?php else: ?>
isRequired="<?= $field->optional ? 'false' : 'true' ?>" <div class="flex flex-col gap-4" data-field-array="<?= $field->key ?>">
value="<?= get_plugin_setting($plugin->getKey(), $field->key, $context) ?>" <fieldset class="flex flex-col gap-2" data-field-array-container="<?= $field->key ?>">
/> <?php $fieldArrayValue = get_plugin_setting($plugin->getKey(), $field->key, $context) ?? [''];
<?php break; foreach ($fieldArrayValue as $index => $value): ?>
case 'select-multiple': ?> <div class="relative flex items-end" data-field-array-item="<?= $index ?>">
<x-Forms.Field <span class="self-start mr-1 -ml-5 w-4 rtl text-sm before:content-['.']" data-field-array-number style="direction:rtl"><?= $index + 1 ?></span>
as="SelectMulti" <?= view('plugins/_field', [
name="<?= $field->key ?>" 'class' => 'flex-1',
label="<?= esc($field->getTranslated(sprintf('%s.settings.%s.%s.label', $plugin->getKey(), $type, $field->key), $field->label)) ?>" 'type' => $field->type,
hint="<?= esc($field->getTranslated(sprintf('%s.settings.%s.%s.hint', $plugin->getKey(), $type, $field->key), $field->hint)) ?>" 'name' => sprintf('%s[%s]', $field->key, $index),
helper="<?= esc($field->getTranslated(sprintf('%s.settings.%s.%s.helper', $plugin->getKey(), $type, $field->key), $field->helper)) ?>" 'label' => $field->getTranslated($plugin->getKey(), 'label'),
options="<?= esc(json_encode($field->getOptionsArray(sprintf('%s.settings.%s.%s.options', $plugin->getKey(), $type, $field->key)))) ?>" 'hint' => $field->getTranslated($plugin->getKey(), 'hint'),
isRequired="<?= $field->optional ? 'false' : 'true' ?>" 'value' => $value,
value="<?= esc(json_encode(get_plugin_setting($plugin->getKey(), $field->key, $context))) ?>" 'helper' => $field->getTranslated($plugin->getKey(), 'helper'),
/> 'options' => esc(json_encode($field->getOptionsArray($plugin->getKey()))),
<?php break; 'optional' => $field->optional,
case 'email': ?> ]) ?>
<x-Forms.Field <x-IconButton variant="danger" glyph="delete-bin-fill" data-field-array-delete="<?= $index ?>" type="button" class="mb-2 ml-2"><?= lang('Common.forms.fieldArray.remove') ?></x-IconButton>
as="Input" </div>
type="email" <?php endforeach; ?>
name="<?= $field->key ?>" </fieldset>
label="<?= esc($field->getTranslated(sprintf('%s.settings.%s.%s.label', $plugin->getKey(), $type, $field->key), $field->label)) ?>" <x-Button iconLeft="add-fill" data-field-array-add="<?= $field->key ?>" variant="secondary" type="button" class="mt-2"><?= lang('Common.forms.fieldArray.add') ?></x-Button>
hint="<?= esc($field->getTranslated(sprintf('%s.settings.%s.%s.hint', $plugin->getKey(), $type, $field->key), $field->hint)) ?>" </div>
helper="<?= esc($field->getTranslated(sprintf('%s.settings.%s.%s.helper', $plugin->getKey(), $type, $field->key), $field->helper)) ?>" <?php endif; ?>
isRequired="<?= $field->optional ? 'false' : 'true' ?>" <?php elseif ($field->type === 'group'):
value="<?= get_plugin_setting($plugin->getKey(), $field->key, $context) ?>" $value = get_plugin_setting($plugin->getKey(), $field->key, $context); ?>
/> <fieldset class="flex flex-col border border-subtle p-4 rounded-tl-none rounded-md gap-2 bg-base">
<?php break; <legend class="relative z-10 font-bold text-heading-foreground font-display before:w-full before:absolute before:h-1/2 before:left-0 before:bottom-0 before:rounded-full before:bg-heading-background before:z-[-10] tracking-wide text-base"><?= $field->getTranslated($plugin->getKey(), 'label') ?></legend>
case 'url': ?> <?php foreach ($field->fields as $subfield): ?>
<x-Forms.Field <?= view('plugins/_field', [
as="Input" 'class' => 'flex-1',
type="url" 'type' => $subfield->type,
placeholder="https://…" 'name' => sprintf('%s[%s]', $field->key, $subfield->key),
name="<?= $field->key ?>" 'label' => $subfield->getTranslated($plugin->getKey(), 'label'),
label="<?= esc($field->getTranslated(sprintf('%s.settings.%s.%s.label', $plugin->getKey(), $type, $field->key), $field->label)) ?>" 'hint' => $subfield->getTranslated($plugin->getKey(), 'hint'),
hint="<?= esc($field->getTranslated(sprintf('%s.settings.%s.%s.hint', $plugin->getKey(), $type, $field->key), $field->hint)) ?>" 'value' => $value[$subfield->key] ?? '',
helper="<?= esc($field->getTranslated(sprintf('%s.settings.%s.%s.helper', $plugin->getKey(), $type, $field->key), $field->helper)) ?>" 'helper' => $subfield->getTranslated($plugin->getKey(), 'helper'),
isRequired="<?= $field->optional ? 'false' : 'true' ?>" 'options' => esc(json_encode($subfield->getOptionsArray($plugin->getKey()))),
value="<?= get_plugin_setting($plugin->getKey(), $field->key, $context) ?>" 'optional' => $subfield->optional,
/> ]) ?>
<?php break; <?php endforeach; ?>
case 'number': ?> </fieldset>
<x-Forms.Field <?php else: ?>
as="Input" <?= view('plugins/_field', [
type="number" 'class' => '',
name="<?= $field->key ?>" 'type' => $field->type,
label="<?= esc($field->getTranslated(sprintf('%s.settings.%s.%s.label', $plugin->getKey(), $type, $field->key), $field->label)) ?>" 'name' => $field->key,
hint="<?= esc($field->getTranslated(sprintf('%s.settings.%s.%s.hint', $plugin->getKey(), $type, $field->key), $field->hint)) ?>" 'label' => $field->getTranslated($plugin->getKey(), 'label'),
helper="<?= esc($field->getTranslated(sprintf('%s.settings.%s.%s.helper', $plugin->getKey(), $type, $field->key), $field->helper)) ?>" 'hint' => $field->getTranslated($plugin->getKey(), 'hint'),
isRequired="<?= $field->optional ? 'false' : 'true' ?>" 'value' => get_plugin_setting($plugin->getKey(), $field->key, $context),
value="<?= get_plugin_setting($plugin->getKey(), $field->key, $context) ?>" 'helper' => $field->getTranslated($plugin->getKey(), 'helper'),
/> 'options' => esc(json_encode($field->getOptionsArray($plugin->getKey()))),
<?php break; 'optional' => $field->optional,
case 'textarea': ?> ]) ?>
<x-Forms.Field <?php endif; ?>
as="Textarea"
name="<?= $field->key ?>"
label="<?= esc($field->getTranslated(sprintf('%s.settings.%s.%s.label', $plugin->getKey(), $type, $field->key), $field->label)) ?>"
hint="<?= esc($field->getTranslated(sprintf('%s.settings.%s.%s.hint', $plugin->getKey(), $type, $field->key), $field->hint)) ?>"
helper="<?= esc($field->getTranslated(sprintf('%s.settings.%s.%s.helper', $plugin->getKey(), $type, $field->key), $field->helper)) ?>"
isRequired="<?= $field->optional ? 'false' : 'true' ?>"
value="<?= get_plugin_setting($plugin->getKey(), $field->key, $context) ?>"
/>
<?php break;
case 'markdown': ?>
<x-Forms.Field
as="MarkdownEditor"
name="<?= $field->key ?>"
label="<?= esc($field->getTranslated(sprintf('%s.settings.%s.%s.label', $plugin->getKey(), $type, $field->key), $field->label)) ?>"
hint="<?= esc($field->getTranslated(sprintf('%s.settings.%s.%s.hint', $plugin->getKey(), $type, $field->key), $field->hint)) ?>"
helper="<?= esc($field->getTranslated(sprintf('%s.settings.%s.%s.helper', $plugin->getKey(), $type, $field->key), $field->helper)) ?>"
isRequired="<?= $field->optional ? 'false' : 'true' ?>"
value="<?= get_plugin_setting($plugin->getKey(), $field->key, $context) ?>"
/>
<?php break;
case 'datetime':
$hasDatetime = true ?>
<x-Forms.Field
as="DatetimePicker"
name="<?= $field->key ?>"
label="<?= esc($field->getTranslated(sprintf('%s.settings.%s.%s.label', $plugin->getKey(), $type, $field->key), $field->label)) ?>"
hint="<?= esc($field->getTranslated(sprintf('%s.settings.%s.%s.hint', $plugin->getKey(), $type, $field->key), $field->hint)) ?>"
helper="<?= esc($field->getTranslated(sprintf('%s.settings.%s.%s.helper', $plugin->getKey(), $type, $field->key), $field->helper)) ?>"
isRequired="<?= $field->optional ? 'false' : 'true' ?>"
value="<?= get_plugin_setting($plugin->getKey(), $field->key, $context) ?>"
/>
<?php break;
default: ?>
<x-Forms.Field
as="Input"
name="<?= $field->key ?>"
label="<?= esc($field->getTranslated(sprintf('%s.settings.%s.%s.label', $plugin->getKey(), $type, $field->key), $field->label)) ?>"
hint="<?= esc($field->getTranslated(sprintf('%s.settings.%s.%s.hint', $plugin->getKey(), $type, $field->key), $field->hint)) ?>"
helper="<?= esc($field->getTranslated(sprintf('%s.settings.%s.%s.helper', $plugin->getKey(), $type, $field->key), $field->helper)) ?>"
isRequired="<?= $field->optional ? 'false' : 'true' ?>"
value="<?= get_plugin_setting($plugin->getKey(), $field->key, $context) ?>"
/>
<?php endswitch; ?>
<?php endforeach; ?> <?php endforeach; ?>
<?php if ($hasDatetime): ?> <?php if ($hasDatetime): ?>

View File

@ -46,14 +46,14 @@
name="type" name="type"
options="<?= esc(json_encode([ options="<?= esc(json_encode([
[ [
'label' => lang('Podcast.form.type.episodic'), 'label' => lang('Podcast.form.type.episodic'),
'value' => 'episodic', 'value' => 'episodic',
'hint' => lang('Podcast.form.type.episodic_hint'), 'description' => lang('Podcast.form.type.episodic_description'),
], ],
[ [
'label' => lang('Podcast.form.type.serial'), 'label' => lang('Podcast.form.type.serial'),
'value' => 'serial', 'value' => 'serial',
'hint' => lang('Podcast.form.type.serial_hint'), 'description' => lang('Podcast.form.type.serial_description'),
], ],
])) ?>" ])) ?>"
isRequired="true" isRequired="true"
@ -64,19 +64,19 @@
name="medium" name="medium"
options="<?= esc(json_encode([ options="<?= esc(json_encode([
[ [
'label' => lang('Podcast.form.medium.podcast'), 'label' => lang('Podcast.form.medium.podcast'),
'value' => 'podcast', 'value' => 'podcast',
'hint' => lang('Podcast.form.medium.podcast_hint'), 'description' => lang('Podcast.form.medium.podcast_description'),
], ],
[ [
'label' => lang('Podcast.form.medium.music'), 'label' => lang('Podcast.form.medium.music'),
'value' => 'music', 'value' => 'music',
'hint' => lang('Podcast.form.medium.music_hint'), 'description' => lang('Podcast.form.medium.music_description'),
], ],
[ [
'label' => lang('Podcast.form.medium.audiobook'), 'label' => lang('Podcast.form.medium.audiobook'),
'value' => 'audiobook', 'value' => 'audiobook',
'hint' => lang('Podcast.form.medium.audiobook_hint'), 'description' => lang('Podcast.form.medium.audiobook_description'),
], ],
])) ?>" ])) ?>"
isRequired="true" isRequired="true"

View File

@ -70,14 +70,14 @@
value="<?= $podcast->type ?>" value="<?= $podcast->type ?>"
options="<?= esc(json_encode([ options="<?= esc(json_encode([
[ [
'label' => lang('Podcast.form.type.episodic'), 'label' => lang('Podcast.form.type.episodic'),
'value' => 'episodic', 'value' => 'episodic',
'hint' => lang('Podcast.form.type.episodic_hint'), 'description' => lang('Podcast.form.type.episodic_description'),
], ],
[ [
'label' => lang('Podcast.form.type.serial'), 'label' => lang('Podcast.form.type.serial'),
'value' => 'serial', 'value' => 'serial',
'hint' => lang('Podcast.form.type.serial_hint'), 'description' => lang('Podcast.form.type.serial_description'),
], ],
])) ?>" ])) ?>"
isRequired="true" isRequired="true"
@ -89,19 +89,19 @@
value="<?= $podcast->medium ?>" value="<?= $podcast->medium ?>"
options="<?= esc(json_encode([ options="<?= esc(json_encode([
[ [
'label' => lang('Podcast.form.medium.podcast'), 'label' => lang('Podcast.form.medium.podcast'),
'value' => 'podcast', 'value' => 'podcast',
'hint' => lang('Podcast.form.medium.podcast_hint'), 'description' => lang('Podcast.form.medium.podcast_description'),
], ],
[ [
'label' => lang('Podcast.form.medium.music'), 'label' => lang('Podcast.form.medium.music'),
'value' => 'music', 'value' => 'music',
'hint' => lang('Podcast.form.medium.music_hint'), 'description' => lang('Podcast.form.medium.music_description'),
], ],
[ [
'label' => lang('Podcast.form.medium.audiobook'), 'label' => lang('Podcast.form.medium.audiobook'),
'value' => 'audiobook', 'value' => 'audiobook',
'hint' => lang('Podcast.form.medium.audiobook_hint'), 'description' => lang('Podcast.form.medium.audiobook_description'),
], ],
])) ?>" ])) ?>"
isRequired="true" isRequired="true"