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 "./modules/xml-editor";
import "@patternfly/elements/pf-tabs/pf-tabs.js";
import FieldArray from "./modules/FieldArray";
Dropdown();
Tooltip();
@ -39,3 +40,4 @@ PublishMessageWarning();
HotKeys();
ValidateFileSize();
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 {
.post-content {
& a {
@ -78,4 +88,13 @@
#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 {
.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 {
@apply ring-accent;
}
&:checked {
@apply ring-2 ring-contrast;
& + 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 {
@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 $helper = '';
protected bool $isChecked = false;
#[Override]
@ -37,10 +39,26 @@ class Checkbox extends FormComponent
'slot' => $this->hint,
]))->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
<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;
}
}

View File

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

View File

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Views\Components\Forms;
use App\Views\Components\Hint;
use Override;
class RadioButton extends FormComponent
@ -17,7 +16,7 @@ class RadioButton extends FormComponent
protected bool $isSelected = false;
protected string $hint = '';
protected string $description = '';
#[Override]
public function render(): string
@ -32,21 +31,30 @@ class RadioButton extends FormComponent
$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(
$data,
$this->value,
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
<div {$this->getStringifiedAttributes()}">
{$radioInput}
<label for="{$this->value}">{$this->slot}{$hint}</label>
<label for="{$this->value}">
<span>{$this->slot}</span>
{$descriptionText}
</label>
</div>
HTML;
}

View File

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

View File

@ -37,7 +37,7 @@ class Select extends FormComponent
$options = '';
$selected = $this->value ?? $this->defaultValue;
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;

View File

@ -45,7 +45,7 @@ class SelectMulti extends FormComponent
$options = '';
$selected = $this->value ?? $this->defaultValue;
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 . '[]';

View File

@ -17,12 +17,14 @@ class Toggler extends FormComponent
protected string $hint = '';
protected string $helper = '';
protected bool $isChecked = false;
#[Override]
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(
[
@ -39,9 +41,23 @@ class Toggler extends FormComponent
'slot' => $this->hint,
]))->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
<label {$this->getStringifiedAttributes()}>
<span>{$this->slot}{$hint}</span>
<div class="flex flex-col">
<span>{$this->slot}{$hint}</span>
{$helperText}
</div>
{$checkbox}
<span class="form-switch-slider"></span>
</label>

View File

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

View File

@ -1,6 +1,7 @@
<?= helper(['components', 'svg']) ?>
<!DOCTYPE html>
<html lang="en">
<html lang="<?= service('request')
->getLocale() ?>">
<head>
<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:
| Property | Type | Note |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| `type` | `checkbox` \| `datetime` \| `email` \| `markdown` \| `number` \| `radio-group` \| `select-multiple` \| `select` \| `text` \| `textarea` \| `toggler` \| `url` | Default is `text` |
| `label` (required) | `string` | Can be translated (see i18n) |
| `hint` | `string` | Can be translated (see i18n) |
| `helper` | `string` | Can be translated (see i18n) |
| `optional` | `boolean` | Default is `false` |
| `options` | `Options` | Required for `radio-group`, `select-multiple`, and `select` types. |
| Property | Type | Note |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------ |
| `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) |
| `hint` | `string` | Can be translated (see i18n) |
| `helper` | `string` | Can be translated (see i18n) |
| `optional` | `boolean` | Default is `false` |
| `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
@ -119,7 +121,7 @@ The `Options` object properties are option keys and the value is an `Option`.
| Property | Type | Note |
| ------------------ | -------- | ---------------------------- |
| `label` (required) | `string` | Can be translated (see i18n) |
| `hint` | `string` | Can be translated (see i18n) |
| `description` | `string` | Can be translated (see i18n) |
### files

View File

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

View File

@ -109,11 +109,11 @@ return [
'type' => [
'label' => 'Type',
'full' => 'Full',
'full_hint' => 'Complete content (the episode)',
'full_description' => 'Complete content (the episode)',
'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_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' => 'Episode must be accessible to premium subscribers only',

View File

@ -72,19 +72,19 @@ return [
'type' => [
'label' => 'Type',
'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_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' => [
'label' => 'Medium',
'hint' => 'Medium as represented by podcast:medium tag in RSS. Changing this may change how players present your feed.',
'podcast' => 'Podcast',
'podcast_hint' => 'Describes a feed for a podcast show.',
'podcast_description' => 'Describes a feed for a podcast show.',
'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_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',
'classification_section_title' => 'Classification',

View File

@ -13,37 +13,41 @@ use CodeIgniter\HTTP\RedirectResponse;
use CodeIgniter\HTTP\URI;
use CodeIgniter\I18n\Time;
use Modules\Admin\Controllers\BaseController;
use Modules\Plugins\Core\BasePlugin;
use Modules\Plugins\Core\Markdown;
use Modules\Plugins\Core\Plugins;
use Modules\Plugins\Manifest\Field;
class PluginController extends BaseController
{
protected Plugins $plugins;
public function __construct()
{
$this->plugins = service('plugins');
}
public function installed(): string
{
/** @var Plugins $plugins */
$plugins = service('plugins');
$pager = service('pager');
$page = (int) ($this->request->getGet('page') ?? 1);
$perPage = 10;
$total = $plugins->getInstalledCount();
$total = $this->plugins->getInstalledCount();
$pager_links = $pager->makeLinks($page, $perPage, $total);
return view('plugins/installed', [
'total' => $total,
'plugins' => $plugins->getPlugins($page, $perPage),
'plugins' => $this->plugins->getPlugins($page, $perPage),
'pager_links' => $pager_links,
]);
}
public function vendor(string $vendor): string
{
/** @var Plugins $plugins */
$plugins = service('plugins');
$vendorPlugins = $plugins->getVendorPlugins($vendor);
$vendorPlugins = $this->plugins->getVendorPlugins($vendor);
replace_breadcrumb_params([
$vendor => $vendor,
]);
@ -56,12 +60,10 @@ class PluginController extends BaseController
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();
}
@ -80,12 +82,10 @@ class PluginController extends BaseController
string $podcastId = null,
string $episodeId = null
): 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();
}
@ -146,12 +146,10 @@ class PluginController extends BaseController
string $podcastId = null,
string $episodeId = null
): 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();
}
@ -170,12 +168,36 @@ class PluginController extends BaseController
// construct validation rules first
$rules = [];
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)) {
$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)) {
@ -188,20 +210,9 @@ class PluginController extends BaseController
$validatedData = $this->validator->getValidated();
foreach ($plugin->getSettingsFields($type) as $field) {
$value = $validatedData[$field->key] ?? null;
$fieldValue = $value === '' ? null : match ($plugins::FIELDS_CASTS[$field->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,
};
$plugins->setOption($plugin, $field->key, $fieldValue, $context);
$fieldValue = $validatedData[$field->key] ?? null;
$this->plugins->setOption($plugin, $field->key, $this->castFieldValue($field, $fieldValue), $context);
}
return redirect()->back()
@ -212,49 +223,114 @@ class PluginController extends BaseController
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();
}
$plugins->activate($plugin);
$this->plugins->activate($plugin);
return redirect()->back();
}
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();
}
$plugins->deactivate($plugin);
$this->plugins->deactivate($plugin);
return redirect()->back();
}
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();
}
$plugins->uninstall($plugin);
$this->plugins->uninstall($plugin);
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'],
'toggler' => ['permit_empty'],
'url' => ['valid_url_strict'],
'group' => ['permit_empty', 'is_list'],
];
public const FIELDS_CASTS = [

View File

@ -7,27 +7,33 @@ namespace Modules\Plugins\Manifest;
use Override;
/**
* @property 'checkbox'|'datetime'|'email'|'markdown'|'number'|'radio-group'|'select-multiple'|'select'|'text'|'textarea'|'toggler'|'url'|'group' $type
* @property string $key
* @property 'text'|'email'|'url'|'markdown'|'number'|'switch' $type
* @property string $label
* @property string $hint
* @property string $helper
* @property bool $optional
* @property Option[] $options
* @property bool $multiple
* @property Field[] $fields
*/
class Field extends ManifestObject
{
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',
'label' => 'required|string',
'hint' => 'permit_empty|string',
'helper' => 'permit_empty|string',
'optional' => 'permit_empty|is_boolean',
'options' => 'permit_empty|is_list',
'multiple' => 'permit_empty|is_boolean',
'fields' => 'permit_empty|is_list',
];
protected const CASTS = [
'options' => [Option::class],
'fields' => [self::class],
];
protected string $type = 'text';
@ -42,6 +48,8 @@ class Field extends ManifestObject
protected bool $optional = false;
protected bool $multiple = false;
/**
* @var Option[]
*/
@ -60,37 +68,49 @@ class Field extends ManifestObject
$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);
}
/**
* @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 = [];
foreach ($this->options as $option) {
$optionsArray[] = [
'value' => $option->value,
'label' => esc($this->getTranslated($i18nKey . '.' . $option->value . '.label', $option->label)),
'hint' => esc($this->getTranslated($i18nKey . '.' . $option->value . '.hint', (string) $option->hint)),
'value' => $option->value,
'label' => $option->getTranslated($i18nKey, 'label'),
'description' => $option->getTranslated($i18nKey, 'description'),
];
}
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 */
$i18nField = lang($key);
if ($default === '' || $i18nField === $key) {
return $default;
if ($this->{$property} === '' || $i18nField === $key) {
return esc($this->{$property});
}
return $i18nField;
return esc($i18nField);
}
}

View File

@ -7,19 +7,33 @@ namespace Modules\Plugins\Manifest;
/**
* @property string $label
* @property string $value
* @property ?string $hint
* @property string $hint
*/
class Option extends ManifestObject
{
protected const VALIDATION_RULES = [
'label' => 'required|string',
'value' => 'required|alpha_numeric_punct',
'hint' => 'permit_empty|string',
'label' => 'required|string',
'value' => 'required|alpha_numeric_punct',
'description' => 'permit_empty|string',
];
protected string $label;
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": {
"type": {
"enum": [
"group",
"checkbox",
"datetime",
"email",
@ -206,12 +207,23 @@
"^[A-Za-z0-9]+[\\w\\-\\:\\.]*$": { "$ref": "#/$defs/option" }
},
"additionalProperties": false
},
"multiple": {
"type": "boolean"
},
"fields": {
"type": "object",
"patternProperties": {
"^[A-Za-z]+[\\w\\-\\:\\.]*$": { "$ref": "#/$defs/field" }
},
"additionalProperties": false
}
},
"required": ["label"],
"additionalProperties": false,
"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": {
@ -220,7 +232,7 @@
"label": {
"type": "string"
},
"hint": {
"description": {
"type": "string"
}
},
@ -245,6 +257,21 @@
},
{ "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 */
const defaultTheme = require("tailwindcss/defaultTheme");
const { transform } = require("typescript");
/** @type {import('tailwindcss').Config} */
module.exports = {
@ -122,6 +123,7 @@ module.exports = {
colorButtons: "repeat(auto-fill, minmax(4rem, 1fr))",
platforms: "repeat(auto-fill, minmax(18rem, 1fr))",
plugins: "repeat(auto-fill, minmax(20rem, 1fr))",
radioGroup: "repeat(auto-fit, minmax(14rem, 1fr))",
},
gridTemplateRows: {
admin: "40px 1fr",
@ -162,6 +164,18 @@ module.exports = {
zIndex: {
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: {},

View File

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

View File

@ -78,19 +78,19 @@
value="<?= $episode->type ?>"
options="<?= esc(json_encode([
[
'label' => lang('Episode.form.type.full'),
'value' => 'full',
'hint' => lang('Episode.form.type.full_hint'),
'label' => lang('Episode.form.type.full'),
'value' => 'full',
'description' => lang('Episode.form.type.full_description'),
],
[
'label' => lang('Episode.form.type.trailer'),
'value' => 'trailer',
'hint' => lang('Episode.form.type.trailer_hint'),
'label' => lang('Episode.form.type.trailer'),
'value' => 'trailer',
'description' => lang('Episode.form.type.trailer_description'),
],
[
'label' => lang('Episode.form.type.bonus'),
'value' => 'bonus',
'hint' => lang('Episode.form.type.bonus_hint'),
'label' => lang('Episode.form.type.bonus'),
'value' => 'bonus',
'description' => lang('Episode.form.type.bonus_description'),
],
])) ?>"
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() ?>
<?php $hasDatetime = false; ?>
<?php foreach ($fields as $field): ?>
<?php switch ($field->type): case 'checkbox': ?>
<x-Forms.Checkbox
name="<?= $field->key ?>"
hint="<?= esc($field->getTranslated(sprintf('%s.settings.%s.%s.hint', $plugin->getKey(), $type, $field->key), $field->hint)) ?>"
isChecked="<?= get_plugin_setting($plugin->getKey(), $field->key, $context) ? 'true' : 'false' ?>"
><?= esc($field->getTranslated(sprintf('%s.settings.%s.%s.label', $plugin->getKey(), $type, $field->key), $field->label)) ?></x-Forms.Checkbox>
<?php break;
case 'toggler': ?>
<x-Forms.Toggler
name="<?= $field->key ?>"
hint="<?= esc($field->getTranslated(sprintf('%s.settings.%s.%s.hint', $plugin->getKey(), $type, $field->key), $field->hint)) ?>"
isChecked="<?= get_plugin_setting($plugin->getKey(), $field->key, $context) ? 'true' : 'false' ?>"
><?= esc($field->getTranslated(sprintf('%s.settings.%s.%s.label', $plugin->getKey(), $type, $field->key), $field->label)) ?></x-Forms.Toggler>
<?php break;
case 'radio-group': ?>
<x-Forms.RadioGroup
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)) ?>"
options="<?= esc(json_encode($field->getOptionsArray(sprintf('%s.settings.%s.%s.options', $plugin->getKey(), $type, $field->key)))) ?>"
isRequired="<?= $field->optional ? 'false' : 'true' ?>"
value="<?= get_plugin_setting($plugin->getKey(), $field->key, $context) ?>"
/>
<?php break;
case 'select': ?>
<x-Forms.Field
as="Select"
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)) ?>"
options="<?= esc(json_encode($field->getOptionsArray(sprintf('%s.settings.%s.%s.options', $plugin->getKey(), $type, $field->key)))) ?>"
isRequired="<?= $field->optional ? 'false' : 'true' ?>"
value="<?= get_plugin_setting($plugin->getKey(), $field->key, $context) ?>"
/>
<?php break;
case 'select-multiple': ?>
<x-Forms.Field
as="SelectMulti"
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)) ?>"
options="<?= esc(json_encode($field->getOptionsArray(sprintf('%s.settings.%s.%s.options', $plugin->getKey(), $type, $field->key)))) ?>"
isRequired="<?= $field->optional ? 'false' : 'true' ?>"
value="<?= esc(json_encode(get_plugin_setting($plugin->getKey(), $field->key, $context))) ?>"
/>
<?php break;
case 'email': ?>
<x-Forms.Field
as="Input"
type="email"
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 'url': ?>
<x-Forms.Field
as="Input"
type="url"
placeholder="https://…"
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 'number': ?>
<x-Forms.Field
as="Input"
type="number"
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 'textarea': ?>
<x-Forms.Field
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 if ($field->type === 'datetime') {
$hasDatetime = true;
} ?>
<?php if ($field->multiple):
if ($field->type === 'group'): ?>
<div class="flex flex-col gap-4" data-field-array="<?= $field->key ?>">
<fieldset class="flex flex-col gap-6 rounded" data-field-array-container="<?= $field->key ?>">
<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>
<?php
$fieldArrayValues = get_plugin_setting($plugin->getKey(), $field->key, $context) ?? [''];
foreach ($fieldArrayValues as $index => $value): ?>
<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 ?>">
<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 foreach ($field->fields as $subfield): ?>
<?= view('plugins/_field', [
'class' => 'flex-1',
'type' => $subfield->type,
'name' => sprintf('%s[%s][%s]', $field->key, $index, $subfield->key),
'label' => $subfield->getTranslated($plugin->getKey(), 'label'),
'hint' => $subfield->getTranslated($plugin->getKey(), 'hint'),
'value' => $value[$subfield->key] ?? '',
'helper' => $subfield->getTranslated($plugin->getKey(), 'helper'),
'options' => esc(json_encode($subfield->getOptionsArray($plugin->getKey()))),
'optional' => $subfield->optional,
]) ?>
<?php endforeach; ?>
<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>
</fieldset>
<?php endforeach; ?>
</fieldset>
<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>
</div>
<?php else: ?>
<div class="flex flex-col gap-4" data-field-array="<?= $field->key ?>">
<fieldset class="flex flex-col gap-2" data-field-array-container="<?= $field->key ?>">
<?php $fieldArrayValue = get_plugin_setting($plugin->getKey(), $field->key, $context) ?? [''];
foreach ($fieldArrayValue as $index => $value): ?>
<div class="relative flex items-end" data-field-array-item="<?= $index ?>">
<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>
<?= view('plugins/_field', [
'class' => 'flex-1',
'type' => $field->type,
'name' => sprintf('%s[%s]', $field->key, $index),
'label' => $field->getTranslated($plugin->getKey(), 'label'),
'hint' => $field->getTranslated($plugin->getKey(), 'hint'),
'value' => $value,
'helper' => $field->getTranslated($plugin->getKey(), 'helper'),
'options' => esc(json_encode($field->getOptionsArray($plugin->getKey()))),
'optional' => $field->optional,
]) ?>
<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>
</div>
<?php endforeach; ?>
</fieldset>
<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>
</div>
<?php endif; ?>
<?php elseif ($field->type === 'group'):
$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">
<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>
<?php foreach ($field->fields as $subfield): ?>
<?= view('plugins/_field', [
'class' => 'flex-1',
'type' => $subfield->type,
'name' => sprintf('%s[%s]', $field->key, $subfield->key),
'label' => $subfield->getTranslated($plugin->getKey(), 'label'),
'hint' => $subfield->getTranslated($plugin->getKey(), 'hint'),
'value' => $value[$subfield->key] ?? '',
'helper' => $subfield->getTranslated($plugin->getKey(), 'helper'),
'options' => esc(json_encode($subfield->getOptionsArray($plugin->getKey()))),
'optional' => $subfield->optional,
]) ?>
<?php endforeach; ?>
</fieldset>
<?php else: ?>
<?= view('plugins/_field', [
'class' => '',
'type' => $field->type,
'name' => $field->key,
'label' => $field->getTranslated($plugin->getKey(), 'label'),
'hint' => $field->getTranslated($plugin->getKey(), 'hint'),
'value' => get_plugin_setting($plugin->getKey(), $field->key, $context),
'helper' => $field->getTranslated($plugin->getKey(), 'helper'),
'options' => esc(json_encode($field->getOptionsArray($plugin->getKey()))),
'optional' => $field->optional,
]) ?>
<?php endif; ?>
<?php endforeach; ?>
<?php if ($hasDatetime): ?>

View File

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

View File

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