From 11ccd0ebe71d476d8c0dbfe28edcf01f7f362b83 Mon Sep 17 00:00:00 2001 From: Yassine Doghri Date: Tue, 10 Dec 2024 15:57:06 +0000 Subject: [PATCH] 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 --- app/Resources/js/admin.ts | 2 + app/Resources/js/modules/FieldArray.ts | 159 +++++++++++++ app/Resources/styles/custom.css | 19 ++ app/Resources/styles/radioBtn.css | 22 +- app/Views/Components/Forms/Checkbox.php | 22 +- app/Views/Components/Forms/Helper.php | 2 +- app/Views/Components/Forms/RadioButton.php | 24 +- app/Views/Components/Forms/RadioGroup.php | 18 +- app/Views/Components/Forms/Select.php | 2 +- app/Views/Components/Forms/SelectMulti.php | 2 +- app/Views/Components/Forms/Toggler.php | 20 +- app/Views/errors/html/error_403.php | 3 +- app/Views/errors/html/error_404.php | 3 +- docs/src/content/docs/en/plugins/manifest.mdx | 20 +- modules/Admin/Language/en/Common.php | 4 + modules/Admin/Language/en/Episode.php | 6 +- modules/Admin/Language/en/Podcast.php | 10 +- .../Plugins/Controllers/PluginController.php | 178 ++++++++++---- modules/Plugins/Core/Plugins.php | 1 + modules/Plugins/Manifest/Field.php | 44 +++- modules/Plugins/Manifest/Option.php | 24 +- modules/Plugins/Manifest/manifest.schema.json | 31 ++- tailwind.config.cjs | 14 ++ themes/cp_admin/episode/create.php | 18 +- themes/cp_admin/episode/edit.php | 18 +- themes/cp_admin/plugins/_field.php | 144 ++++++++++++ themes/cp_admin/plugins/_settings_form.php | 220 +++++++----------- themes/cp_admin/podcast/create.php | 30 +-- themes/cp_admin/podcast/edit.php | 30 +-- 29 files changed, 791 insertions(+), 299 deletions(-) create mode 100644 app/Resources/js/modules/FieldArray.ts create mode 100644 themes/cp_admin/plugins/_field.php diff --git a/app/Resources/js/admin.ts b/app/Resources/js/admin.ts index 7d669c58..6fb7ad57 100644 --- a/app/Resources/js/admin.ts +++ b/app/Resources/js/admin.ts @@ -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(); diff --git a/app/Resources/js/modules/FieldArray.ts b/app/Resources/js/modules/FieldArray.ts new file mode 100644 index 00000000..05baad4e --- /dev/null +++ b/app/Resources/js/modules/FieldArray.ts @@ -0,0 +1,159 @@ +import Tooltip from "./Tooltip"; + +const FieldArray = (): void => { + const fieldArrays: NodeListOf = + 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 = fieldArray.querySelectorAll( + "[data-field-array-item]" + ); + const addButton = fieldArray.querySelector( + "button[data-field-array-add]" + ) as HTMLButtonElement; + + const deleteButtons: NodeListOf = + 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 = 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 = + 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 = + 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 = + 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; diff --git a/app/Resources/styles/custom.css b/app/Resources/styles/custom.css index 40035875..19db6669 100644 --- a/app/Resources/styles/custom.css +++ b/app/Resources/styles/custom.css @@ -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)); + } } diff --git a/app/Resources/styles/radioBtn.css b/app/Resources/styles/radioBtn.css index 72834e3e..da208209 100644 --- a/app/Resources/styles/radioBtn.css +++ b/app/Resources/styles/radioBtn.css @@ -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; } } } diff --git a/app/Views/Components/Forms/Checkbox.php b/app/Views/Components/Forms/Checkbox.php index a90fde2f..8bf1bdaa 100644 --- a/app/Views/Components/Forms/Checkbox.php +++ b/app/Views/Components/Forms/Checkbox.php @@ -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 <<getStringifiedAttributes()}>{$checkboxInput}{$this->slot}{$hint} + HTML; } } diff --git a/app/Views/Components/Forms/Helper.php b/app/Views/Components/Forms/Helper.php index 06dc24a9..fbc895d1 100644 --- a/app/Views/Components/Forms/Helper.php +++ b/app/Views/Components/Forms/Helper.php @@ -14,7 +14,7 @@ class Helper extends Component #[Override] public function render(): string { - $this->mergeClass('text-skin-muted'); + $this->mergeClass('form-helper'); return <<getStringifiedAttributes()}>{$this->slot} diff --git a/app/Views/Components/Forms/RadioButton.php b/app/Views/Components/Forms/RadioButton.php index e9b5992c..2a5ff270 100644 --- a/app/Views/Components/Forms/RadioButton.php +++ b/app/Views/Components/Forms/RadioButton.php @@ -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 = <<{$this->description} + 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 <<getStringifiedAttributes()}"> {$radioInput} - + HTML; } diff --git a/app/Views/Components/Forms/RadioGroup.php b/app/Views/Components/Forms/RadioGroup.php index 99709910..5a726338 100644 --- a/app/Views/Components/Forms/RadioGroup.php +++ b/app/Views/Components/Forms/RadioGroup.php @@ -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
getStringifiedAttributes()}> {$this->label}{$hint} {$helperText} -
{$options}
+
{$options}
HTML; } diff --git a/app/Views/Components/Forms/Select.php b/app/Views/Components/Forms/Select.php index c3da30b6..b9c6145e 100644 --- a/app/Views/Components/Forms/Select.php +++ b/app/Views/Components/Forms/Select.php @@ -37,7 +37,7 @@ class Select extends FormComponent $options = ''; $selected = $this->value ?? $this->defaultValue; foreach ($this->options as $option) { - $options .= ''; + $options .= ''; } $this->attributes['name'] = $this->name; diff --git a/app/Views/Components/Forms/SelectMulti.php b/app/Views/Components/Forms/SelectMulti.php index 1f7b4751..053b234f 100644 --- a/app/Views/Components/Forms/SelectMulti.php +++ b/app/Views/Components/Forms/SelectMulti.php @@ -45,7 +45,7 @@ class SelectMulti extends FormComponent $options = ''; $selected = $this->value ?? $this->defaultValue; foreach ($this->options as $option) { - $options .= ''; + $options .= ''; } $this->attributes['name'] = $this->name . '[]'; diff --git a/app/Views/Components/Forms/Toggler.php b/app/Views/Components/Forms/Toggler.php index 84bbe5bf..75a22c81 100644 --- a/app/Views/Components/Forms/Toggler.php +++ b/app/Views/Components/Forms/Toggler.php @@ -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 <<getStringifiedAttributes()}> - {$this->slot}{$hint} +
+ {$this->slot}{$hint} + {$helperText} +
{$checkbox} diff --git a/app/Views/errors/html/error_403.php b/app/Views/errors/html/error_403.php index 55b37f89..a5d4d040 100644 --- a/app/Views/errors/html/error_403.php +++ b/app/Views/errors/html/error_403.php @@ -1,6 +1,7 @@ - + diff --git a/app/Views/errors/html/error_404.php b/app/Views/errors/html/error_404.php index 79575fb1..4d74a150 100644 --- a/app/Views/errors/html/error_404.php +++ b/app/Views/errors/html/error_404.php @@ -1,6 +1,7 @@ - + diff --git a/docs/src/content/docs/en/plugins/manifest.mdx b/docs/src/content/docs/en/plugins/manifest.mdx index 53798f92..d8074cff 100644 --- a/docs/src/content/docs/en/plugins/manifest.mdx +++ b/docs/src/content/docs/en/plugins/manifest.mdx @@ -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` | 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 diff --git a/modules/Admin/Language/en/Common.php b/modules/Admin/Language/en/Common.php index 74addcf2..4dff9dd4 100644 --- a/modules/Admin/Language/en/Common.php +++ b/modules/Admin/Language/en/Common.php @@ -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', diff --git a/modules/Admin/Language/en/Episode.php b/modules/Admin/Language/en/Episode.php index 4fa846e3..f7eb1290 100644 --- a/modules/Admin/Language/en/Episode.php +++ b/modules/Admin/Language/en/Episode.php @@ -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', diff --git a/modules/Admin/Language/en/Podcast.php b/modules/Admin/Language/en/Podcast.php index ff0daebc..4c19699c 100644 --- a/modules/Admin/Language/en/Podcast.php +++ b/modules/Admin/Language/en/Podcast.php @@ -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', diff --git a/modules/Plugins/Controllers/PluginController.php b/modules/Plugins/Controllers/PluginController.php index 45771301..4dfc785e 100644 --- a/modules/Plugins/Controllers/PluginController.php +++ b/modules/Plugins/Controllers/PluginController.php @@ -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, + }; + } } diff --git a/modules/Plugins/Core/Plugins.php b/modules/Plugins/Core/Plugins.php index 3ff1b263..7208a437 100644 --- a/modules/Plugins/Core/Plugins.php +++ b/modules/Plugins/Core/Plugins.php @@ -37,6 +37,7 @@ class Plugins 'textarea' => ['string'], 'toggler' => ['permit_empty'], 'url' => ['valid_url_strict'], + 'group' => ['permit_empty', 'is_list'], ]; public const FIELDS_CASTS = [ diff --git a/modules/Plugins/Manifest/Field.php b/modules/Plugins/Manifest/Field.php index 02075023..53e601ec 100644 --- a/modules/Plugins/Manifest/Field.php +++ b/modules/Plugins/Manifest/Field.php @@ -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); } } diff --git a/modules/Plugins/Manifest/Option.php b/modules/Plugins/Manifest/Option.php index 02408442..043be88b 100644 --- a/modules/Plugins/Manifest/Option.php +++ b/modules/Plugins/Manifest/Option.php @@ -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); + } } diff --git a/modules/Plugins/Manifest/manifest.schema.json b/modules/Plugins/Manifest/manifest.schema.json index 4744e083..e76a4ab4 100644 --- a/modules/Plugins/Manifest/manifest.schema.json +++ b/modules/Plugins/Manifest/manifest.schema.json @@ -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"] } + ] } } } diff --git a/tailwind.config.cjs b/tailwind.config.cjs index a0abf165..6b4e1a8c 100644 --- a/tailwind.config.cjs +++ b/tailwind.config.cjs @@ -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: {}, diff --git a/themes/cp_admin/episode/create.php b/themes/cp_admin/episode/create.php index 65fdbf07..f9fd9def 100644 --- a/themes/cp_admin/episode/create.php +++ b/themes/cp_admin/episode/create.php @@ -74,19 +74,19 @@ name="type" options=" 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" diff --git a/themes/cp_admin/episode/edit.php b/themes/cp_admin/episode/edit.php index 44d9c42e..01d8e039 100644 --- a/themes/cp_admin/episode/edit.php +++ b/themes/cp_admin/episode/edit.php @@ -78,19 +78,19 @@ value="type ?>" options=" 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" diff --git a/themes/cp_admin/plugins/_field.php b/themes/cp_admin/plugins/_field.php new file mode 100644 index 00000000..2ea528bf --- /dev/null +++ b/themes/cp_admin/plugins/_field.php @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/themes/cp_admin/plugins/_settings_form.php b/themes/cp_admin/plugins/_settings_form.php index 166ab2f0..8d98d0cf 100644 --- a/themes/cp_admin/plugins/_settings_form.php +++ b/themes/cp_admin/plugins/_settings_form.php @@ -2,138 +2,94 @@ - type): case 'checkbox': ?> - getTranslated(sprintf('%s.settings.%s.%s.label', $plugin->getKey(), $type, $field->key), $field->label)) ?> - - getTranslated(sprintf('%s.settings.%s.%s.label', $plugin->getKey(), $type, $field->key), $field->label)) ?> - - - - - - - - - - - - - - - - - - - - - - + type === 'datetime') { + $hasDatetime = true; + } ?> + multiple): + if ($field->type === 'group'): ?> +
+
+ getTranslated($plugin->getKey(), 'label') ?> + getKey(), $field->key, $context) ?? ['']; + foreach ($fieldArrayValues as $index => $value): ?> +
+ getTranslated($plugin->getKey(), 'label') ?> + fields as $subfield): ?> + '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, + ]) ?> + + +
+ +
+ +
+ +
+
+ getKey(), $field->key, $context) ?? ['']; + foreach ($fieldArrayValue as $index => $value): ?> +
+ + '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, + ]) ?> + +
+ +
+ +
+ + type === 'group'): + $value = get_plugin_setting($plugin->getKey(), $field->key, $context); ?> +
+ getTranslated($plugin->getKey(), 'label') ?> + fields as $subfield): ?> + '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, + ]) ?> + +
+ + '', + '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, + ]) ?> + diff --git a/themes/cp_admin/podcast/create.php b/themes/cp_admin/podcast/create.php index be4fef1b..a5b90d88 100644 --- a/themes/cp_admin/podcast/create.php +++ b/themes/cp_admin/podcast/create.php @@ -46,14 +46,14 @@ name="type" options=" 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=" 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" diff --git a/themes/cp_admin/podcast/edit.php b/themes/cp_admin/podcast/edit.php index 670353d8..9ea9ea13 100644 --- a/themes/cp_admin/podcast/edit.php +++ b/themes/cp_admin/podcast/edit.php @@ -70,14 +70,14 @@ value="type ?>" options=" 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="medium ?>" options=" 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"