From 37c60143f4f75243b4078dbf7af9669a61ce9a0b Mon Sep 17 00:00:00 2001 From: antoine Date: Fri, 22 Aug 2025 19:55:33 +0200 Subject: [PATCH 01/10] wip live html --- src/Form/Fields/Formatters/HtmlFormatter.php | 17 +++++++++++- src/Form/Fields/SharpFormHtmlField.php | 12 ++++++++- src/Utils/Fields/HandleFields.php | 27 ++++++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/Form/Fields/Formatters/HtmlFormatter.php b/src/Form/Fields/Formatters/HtmlFormatter.php index 58cd4f920..7eeee6be2 100644 --- a/src/Form/Fields/Formatters/HtmlFormatter.php +++ b/src/Form/Fields/Formatters/HtmlFormatter.php @@ -7,16 +7,31 @@ class HtmlFormatter extends AbstractSimpleFormatter { + protected ?string $fieldKey = null; + protected ?array $formData = null; + /** * @param SharpFormHtmlField $field */ public function toFront(SharpFormField $field, $value) { - return $field->render(is_array($value) ? $value : []); + return $field->render([ + 'fieldKey' => $this->fieldKey, + 'formData' => $this->formData, + ...is_array($value) ? $value : [], + ]); } public function fromFront(SharpFormField $field, string $attribute, $value) { return null; } + + public function setRenderData(string $fieldKey, array $formData): self + { + $this->fieldKey = $fieldKey; + $this->formData = $formData; + + return $this; + } } diff --git a/src/Form/Fields/SharpFormHtmlField.php b/src/Form/Fields/SharpFormHtmlField.php index 5ce359ffb..a7d36d82f 100644 --- a/src/Form/Fields/SharpFormHtmlField.php +++ b/src/Form/Fields/SharpFormHtmlField.php @@ -11,12 +11,20 @@ class SharpFormHtmlField extends SharpFormField const FIELD_TYPE = 'html'; private View|string $template; + private bool $liveRefresh = false; public static function make(string $key): self { return new static($key, static::FIELD_TYPE, new HtmlFormatter()); } + public function setLiveRefresh(bool $liveRefresh = true): self + { + $this->liveRefresh = $liveRefresh; + + return $this; + } + public function setTemplate(View|string $template): self { $this->template = $template; @@ -35,6 +43,8 @@ public function render(array $data): string public function toArray(): array { - return parent::buildArray([]); + return parent::buildArray([ + 'liveRefresh' => $this->liveRefresh, + ]); } } diff --git a/src/Utils/Fields/HandleFields.php b/src/Utils/Fields/HandleFields.php index 651cc657c..1200a8306 100644 --- a/src/Utils/Fields/HandleFields.php +++ b/src/Utils/Fields/HandleFields.php @@ -3,6 +3,7 @@ namespace Code16\Sharp\Utils\Fields; use Code16\Sharp\Form\Fields\SharpFormField; +use Code16\Sharp\Form\Fields\SharpFormHtmlField; use Code16\Sharp\Show\Fields\SharpShowField; use Illuminate\Support\Collection; @@ -87,12 +88,38 @@ final public function applyFormatters(?array $attributes): ?array $field = $this->findFieldByKey($key); + if ($field instanceof SharpFormHtmlField) { + return $value; + } + return $field ? $field->formatter() ->setDataLocalizations($this->getDataLocalizations()) ->toFront($field, $value) : $value; }) + ->pipe(function (Collection $data) { + $formData = collect($data)->map(function ($value, $key) { + if ($field = $this->findFieldByKey($key)) { + return $field->formatter() + ->setDataLocalizations($this->getDataLocalizations()) + ->fromFront($field, $key, $value); + } + + return $value; + })->all(); + + return $data->map(function ($value, $key) use ($formData) { + if (($field = $this->findFieldByKey($key)) instanceof SharpFormHtmlField) { + return $field->formatter() + ->setRenderData(fieldKey: $key, formData: $formData) + ->setDataLocalizations($this->getDataLocalizations()) + ->toFront($field, $value); + } + + return $value; + }); + }) ->all(); } From 070c6ce145e4d04325f5d826bedf76b4928ebd1d Mon Sep 17 00:00:00 2001 From: antoine Date: Mon, 25 Aug 2025 19:01:18 +0200 Subject: [PATCH 02/10] wip html live refresh --- demo/app/Sharp/Posts/PostForm.php | 22 ++++++ resources/js/Pages/Form/Form.vue | 1 - resources/js/form/Form.ts | 8 +++ resources/js/form/components/Form.vue | 29 +++++++- resources/js/form/components/fields/List.vue | 19 +++-- resources/js/form/components/fields/Tags.vue | 2 +- .../form/components/fields/editor/Editor.vue | 16 ++++- .../editor/extensions/embed/EmbedNode.vue | 2 +- .../editor/extensions/upload/UploadNode.vue | 2 +- .../components/fields/editor/useEditorNode.ts | 12 +++- .../fields/editor/useParentEditor.ts | 2 + resources/js/form/types.ts | 8 ++- resources/js/types/generated.d.ts | 5 ++ resources/js/types/routes.d.ts | 6 ++ src/Data/Form/Fields/FormHtmlFieldData.php | 2 + .../Fields/Embeds/SharpFormEditorEmbed.php | 2 + .../Formatters/EditorEmbedsFormatter.php | 2 +- .../Formatters/EditorUploadsFormatter.php | 4 +- src/Form/Fields/Formatters/HtmlFormatter.php | 22 ++---- src/Form/Fields/SharpFormHtmlField.php | 25 +++++-- .../Api/ApiFormAutocompleteController.php | 44 +----------- .../Api/ApiFormRefreshController.php | 24 +++++++ .../Controllers/Api/HandlesFieldContainer.php | 52 ++++++++++++++ src/Utils/Fields/HandleFields.php | 31 ++------ src/Utils/Fields/HandleFormFields.php | 51 +++++++------ src/Utils/Fields/HandleFormHtmlFields.php | 72 +++++++++++++++++++ src/routes/api.php | 4 ++ 27 files changed, 337 insertions(+), 132 deletions(-) create mode 100644 src/Http/Controllers/Api/ApiFormRefreshController.php create mode 100644 src/Http/Controllers/Api/HandlesFieldContainer.php create mode 100644 src/Utils/Fields/HandleFormHtmlFields.php diff --git a/demo/app/Sharp/Posts/PostForm.php b/demo/app/Sharp/Posts/PostForm.php index bd0c6538f..713f25764 100644 --- a/demo/app/Sharp/Posts/PostForm.php +++ b/demo/app/Sharp/Posts/PostForm.php @@ -16,6 +16,7 @@ use Code16\Sharp\Form\Fields\SharpFormCheckField; use Code16\Sharp\Form\Fields\SharpFormDateField; use Code16\Sharp\Form\Fields\SharpFormEditorField; +use Code16\Sharp\Form\Fields\SharpFormHtmlField; use Code16\Sharp\Form\Fields\SharpFormListField; use Code16\Sharp\Form\Fields\SharpFormTagsField; use Code16\Sharp\Form\Fields\SharpFormTextareaField; @@ -27,6 +28,7 @@ use Code16\Sharp\Form\Layout\FormLayoutTab; use Code16\Sharp\Form\SharpForm; use Code16\Sharp\Utils\Fields\FieldsContainer; +use Illuminate\Support\Facades\Blade; class PostForm extends SharpForm { @@ -104,6 +106,25 @@ public function buildFormFields(FieldsContainer $formFields): void ->setLabel('Publication date') ->setHasTime(), ) + ->addField( + SharpFormHtmlField::make('publication_label') + ->setLiveRefresh(linkedFields: ['author_id', 'published_at']) + ->setTemplate(function (array $data) { + if (! isset($data['published_at'])) { + return ''; + } + + return Blade::render(<<<'blade' + This post will be published on {{ $published_at }} + @if($author) + by {{ $author->name }}. + @endif + blade, [ + 'published_at' => \Carbon\Carbon::parse($data['published_at'])->isoFormat('LLLL'), + 'author' => \App\Models\User::find($data['author_id']), + ]); + }) + ) ->addField( SharpFormListField::make('attachments') ->setLabel('Attachments') @@ -168,6 +189,7 @@ public function buildFormLayout(FormLayout $formLayout): void fn ($column) => $column->withField('author_id') ) ->withFields('published_at', 'categories') + ->withField('publication_label') ->withListField('attachments', function (FormLayoutColumn $item) { $item->withFields(title: 8, is_link: 4) ->withField('link_url') diff --git a/resources/js/Pages/Form/Form.vue b/resources/js/Pages/Form/Form.vue index bb8e4c1eb..efc17bafc 100644 --- a/resources/js/Pages/Form/Form.vue +++ b/resources/js/Pages/Form/Form.vue @@ -10,7 +10,6 @@ import { onUnmounted, ref, useTemplateRef, watch } from "vue"; import { __ } from "@/utils/i18n"; import { Button } from '@/components/ui/button'; - import { useResizeObserver } from "@vueuse/core"; import { slugify } from "@/utils"; const props = defineProps<{ diff --git a/resources/js/form/Form.ts b/resources/js/form/Form.ts index 27375a4e3..81722f835 100644 --- a/resources/js/form/Form.ts +++ b/resources/js/form/Form.ts @@ -287,6 +287,14 @@ export class Form implements FormData, CommandFormData, EventTarget { }, 0); } + shouldRefresh(updatedFieldKey: string, fields = this.fields): boolean { + return Object.values(fields).some(field => + field.type === 'html' + && field.liveRefresh + && (!field.liveRefreshLinkedFields || field.liveRefreshLinkedFields.includes(updatedFieldKey)) + ); + } + eventTarget: EventTarget = new EventTarget(); addEventListener(type: FormEvents, callback: EventListener, options?: AddEventListenerOptions | boolean): void { diff --git a/resources/js/form/components/Form.vue b/resources/js/form/components/Form.vue index 9e3e8b2c6..d164ef8ed 100644 --- a/resources/js/form/components/Form.vue +++ b/resources/js/form/components/Form.vue @@ -18,7 +18,7 @@ import { slugify } from "@/utils"; import { Badge } from "@/components/ui/badge"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; - import { FieldMeta } from "@/form/types"; + import { FieldMeta, FormFieldEmitInputOptions } from "@/form/types"; import StickyTop from "@/components/StickyTop.vue"; import StickyBottom from "@/components/StickyBottom.vue"; import { Menu } from 'lucide-vue-next'; @@ -26,6 +26,11 @@ import RootCardHeader from "@/components/ui/RootCardHeader.vue"; import { vScrollIntoView } from "@/directives/scroll-into-view"; import { useResizeObserver } from "@vueuse/core"; + import debounce from "lodash/debounce"; + import { api } from "@/api/api"; + import { route } from "@/utils/url"; + import { useParentCommands } from "@/commands/useCommands"; + import merge from 'lodash/merge'; const props = defineProps<{ form: Form @@ -77,13 +82,31 @@ props.form.setMeta(fieldKey, { uploading }); } - function onFieldInput(fieldKey: string, value: FormFieldData['value'], { force = false } = {}) { + const parentCommands = useParentCommands(); + const refresh = debounce((data) => { + api.post(route('code16.sharp.api.form.refresh.update', { + entityKey: props.form.entityKey, + instance_id: props.form.instanceId, + embed_key: props.form.embedKey, + entity_list_command_key: parentCommands?.commandContainer === 'entityList' ? props.form.commandKey : null, + show_command_key: parentCommands?.commandContainer === 'show' ? props.form.commandKey : null, + }), data) + .then(response => { + props.form.data = merge({}, props.form.data, response.data.form.data); + }); + }, 200); + + function onFieldInput(fieldKey: string, value: FormFieldData['value'], inputOptions: FormFieldEmitInputOptions = {}) { const data = { ...props.form.data, - ...(!force ? getDependantFieldsResetData(props.form.fields, fieldKey) : null), + ...(!inputOptions.force ? getDependantFieldsResetData(props.form.fields, fieldKey) : null), [fieldKey]: value, }; + if((props.form.shouldRefresh(fieldKey) || inputOptions.shouldRefresh) && !inputOptions.skipRefresh) { + refresh(data); + } + props.form.data = data; props.form.serializedData = data; } diff --git a/resources/js/form/components/fields/List.vue b/resources/js/form/components/fields/List.vue index 800816364..7acc34c73 100644 --- a/resources/js/form/components/fields/List.vue +++ b/resources/js/form/components/fields/List.vue @@ -6,7 +6,7 @@ import { computed, nextTick, ref, watch, watchEffect } from "vue"; import { Button, buttonVariants } from '@/components/ui/button'; import { showAlert } from "@/utils/dialogs"; - import { FieldMeta, FieldsMeta, FormFieldEmits, FormFieldProps } from "@/form/types"; + import { FieldMeta, FieldsMeta, FormFieldEmitInputOptions, FormFieldEmits, FormFieldProps } from "@/form/types"; import FieldGridRow from "@/components/ui/FieldGridRow.vue"; import FieldGridColumn from "@/components/ui/FieldGridColumn.vue"; import { Toggle } from "@/components/ui/toggle"; @@ -81,7 +81,7 @@ emit('input', props.value?.map(((item, index) => ({ ...item, [errorIndex]: index }))), { preserveError: true }); }); - emit('input', props.value?.map(item => ({ ...item, [itemKey]: itemKeyIndex++ })), { force: true }); + emit('input', props.value?.map(item => ({ ...item, [itemKey]: itemKeyIndex++ })), { force: true, skipRefresh: true }); watchArray(() => props.value ?? [], async (newList, oldList, added) => { if(!added.length) { @@ -142,17 +142,26 @@ e.target.value = ''; } - function onFieldInput(itemIndex: number, itemFieldKey: string, itemFieldValue: FormFieldData['value'], { force = false } = {}) { + function onFieldInput( + itemIndex: number, + itemFieldKey: string, + itemFieldValue: FormFieldData['value'], + inputOptions: FormFieldEmitInputOptions + ) { emit('input', props.value.map((item, i) => { if(i === itemIndex) { return { ...item, - ...(!force ? getDependantFieldsResetData(props.field.itemFields, itemFieldKey) : null), + ...(!inputOptions.force ? getDependantFieldsResetData(props.field.itemFields, itemFieldKey) : null), [itemFieldKey]: itemFieldValue, } } return item; - })); + }), { + force: inputOptions.force, + skipRefresh: inputOptions.skipRefresh, + shouldRefresh: form.shouldRefresh(itemFieldKey, props.field.itemFields) + }); } function onFieldLocaleChange(fieldKey: string, locale: string) { diff --git a/resources/js/form/components/fields/Tags.vue b/resources/js/form/components/fields/Tags.vue index af116c1bf..57bc3cbfe 100644 --- a/resources/js/form/components/fields/Tags.vue +++ b/resources/js/form/components/fields/Tags.vue @@ -60,7 +60,7 @@ && !props.field.options.find(o => o.label === searchTerm.value); }); - emit('input', props.value?.map(option => withItemKey(option))); + emit('input', props.value?.map(option => withItemKey(option)), { skipRefresh: true });