diff --git a/demo/app/Sharp/Posts/PostForm.php b/demo/app/Sharp/Posts/PostForm.php index bd0c6538f..2e6d15f16 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,29 @@ public function buildFormFields(FieldsContainer $formFields): void ->setLabel('Publication date') ->setHasTime(), ) + ->addField( + SharpFormHtmlField::make('publication_label') + ->setLiveRefresh(linkedFields: ['author_id', 'published_at', 'attachments']) + ->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 +
+ {{ count($linkAttachments) }} link attachments, {{ count($fileAttachments) }} file attachments. + blade, [ + 'published_at' => \Carbon\Carbon::parse($data['published_at'])->isoFormat('LLLL'), + 'author' => \App\Models\User::find($data['author_id']), + 'linkAttachments' => collect($data['attachments'])->where('is_link', true)->values(), + 'fileAttachments' => collect($data['attachments'])->where('is_link', false)->values(), + ]); + }) + ) ->addField( SharpFormListField::make('attachments') ->setLabel('Attachments') @@ -131,7 +156,7 @@ public function buildFormFields(FieldsContainer $formFields): void ->setStorageDisk('local') ->setStorageBasePath('data/posts/{id}') ->addConditionalDisplay('!is_link'), - ), + ) ) ->when(sharp()->context()->isUpdate(), fn ($formFields) => $formFields->addField( SharpFormAutocompleteRemoteField::make('author_id') @@ -168,10 +193,10 @@ 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') - ->withField('document'); + ->withField('link_url'); }); }) ->addColumn(6, function (FormLayoutColumn $column) { diff --git a/demo/app/Sharp/TestForm/TestForm.php b/demo/app/Sharp/TestForm/TestForm.php index 6c677b967..362be766c 100644 --- a/demo/app/Sharp/TestForm/TestForm.php +++ b/demo/app/Sharp/TestForm/TestForm.php @@ -25,6 +25,7 @@ use Code16\Sharp\Form\Layout\FormLayoutTab; use Code16\Sharp\Form\SharpSingleForm; use Code16\Sharp\Utils\Fields\FieldsContainer; +use Illuminate\Database\Eloquent\Builder; class TestForm extends SharpSingleForm { @@ -161,7 +162,16 @@ public function buildFormFields(FieldsContainer $formFields): void ->setListItemTemplate('{{ $name }}') ->setResultItemTemplate('{{ $name }} ({{ $id }})') ->setRemoteCallback(function ($search, $data) { - dd($data); + $users = User::orderBy('name'); + + foreach (explode(' ', trim($search)) as $word) { + $users->where(function (Builder $query) use ($word) { + $query->orWhere('name', 'like', "%$word%") + ->orWhere('email', 'like', "%$word%"); + }); + } + + return $users->limit(10)->get(); }, linkedFields: ['select']), ) ->addItemField(SharpFormEditorField::make('markdown2') @@ -170,6 +180,18 @@ public function buildFormFields(FieldsContainer $formFields): void ->setToolbar([ SharpFormEditorField::B, SharpFormEditorField::I, SharpFormEditorField::A, ]), + ) + ->addItemField( + SharpFormHtmlField::make('document_infos') + ->setLiveRefresh(linkedFields: ['select']) + ->setTemplate(function (array $data) { + return isset($data['select']) + ? sprintf( + 'You have selected : %s', + $this->options()[$data['select']] + ) + : ''; + }) ), ) ->addField( diff --git a/docs/guide/form-fields/html.md b/docs/guide/form-fields/html.md index 9899fb551..cbc04ed58 100644 --- a/docs/guide/form-fields/html.md +++ b/docs/guide/form-fields/html.md @@ -6,7 +6,7 @@ This field is read-only, and is meant to display some dynamic information in the ## Configuration -### `setTemplate(string|View $template)` +### `setTemplate(string|View|Closure $template)` Write the blade template as a string. Example: @@ -35,6 +35,33 @@ SharpFormHtmlField::make('panel') ->setTemplate(view('sharp.form-htm-field')) ``` +Using a closure: + +```php +SharpFormHtmlField::make('panel') + ->setTemplate(function (array $data) { + return 'You have chosen:'.$data['another_form_field'].'. Date: '.$data['date']; + }) +``` + +#### Accessing to other field values in the form + +In the template, all other field values of the form are available (alongside the Html field value). This is particularly useful when using `setLiveRefresh()` (described below). + +### `setLiveRefresh(bool $liveRefresh = true, ?array $linkedFields = null)` + +Use this method to dynamically update Html field when the user changes another field. +The `$linkedFields` parameter allows filtering which field to watch (without it the internal refresh endpoint is called on any field update). + +```php +SharpFormHtmlField::make('total') + ->setLiveRefresh(linkedFields: ['products']) + ->setTemplate(function (array $data) { + return 'Total:'.collect($data['products']) + ->sum(fn ($product) => $product['price']); + }) +``` + ## Formatter - `toFront`: sent as provided. diff --git a/resources/js/Pages/Form/Form.vue b/resources/js/Pages/Form/Form.vue index ccbe72dcc..f2d8250ca 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 6d9db0045..08d89bef5 100644 --- a/resources/js/form/Form.ts +++ b/resources/js/form/Form.ts @@ -284,6 +284,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 dfe5842ed..85a16dd2a 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,14 +82,32 @@ 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 => { + 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, }; props.form.data = data; + + if((props.form.shouldRefresh(fieldKey) || inputOptions.shouldRefresh) && !inputOptions.skipRefresh) { + refresh(data); + } } const title = useTemplateRef('title'); diff --git a/resources/js/form/components/fields/List.vue b/resources/js/form/components/fields/List.vue index 800816364..3701aab86 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 });