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 });
diff --git a/resources/js/form/components/fields/editor/Editor.vue b/resources/js/form/components/fields/editor/Editor.vue
index 6b152a781..df9a0edf8 100644
--- a/resources/js/form/components/fields/editor/Editor.vue
+++ b/resources/js/form/components/fields/editor/Editor.vue
@@ -1,7 +1,7 @@