Skip to content

Instantly share code, notes, and snippets.

@jreviews
Last active November 4, 2025 19:40
Show Gist options
  • Save jreviews/676e292890b89ee8f197c566c85010dd to your computer and use it in GitHub Desktop.
Save jreviews/676e292890b89ee8f197c566c85010dd to your computer and use it in GitHub Desktop.
FormBuilder Custom Field Listing Image Dropdown

JReviews FormBuilder Image Dropdown

This implementation only works starting with JReviews 6.

The formbuilder input generates an ajax request to a custom endpoint that responds with the list of current listing images, and we use a custom Alpine JS directive to convert the select list into a dropdown that allows selecting an image.

Table of Contents

FormBuilder JSON Schema

The photo input can be added as a property to your form like this:

{
    "title": "Photos",
    "type": "array",
    "format": "table",
    "items": {
        "title": "Photo",
        "type": "object",
        "properties": {
            "photo": {
                "title": "Photo",
                "type": "string",
                "$ref": "/index.php?option=com_jreviews&format=raw&url=formbuilder/images&listingId=:listing_id",
                "options": {
                  "inputAttributes": {
                    "x-image-dropdown": ""
                  }
                }
            }
        }
    }
}

The $ref in the photo object is the custom endpoint. We use :listing_id which is dynamically replaced with the current listing id when the form is processed so we can use it in the endpoint's database query.

Custom Route

To register the new route for the endpoint formbuilder/images create or update the jreviews_overrides/routes/webp.php file:

app['router']->get('/formbuilder/images', function(Request $request) {
    $photos = \JReviews\App\Models\Media::query()
        ->photo()
        ->published()
        ->notCommentMedia()
        ->where('listing_id', $request->input('listingId'))
        ->get()
        ->map(function($item) {
            return [
                'value' => $item->id,
                'title' => $item->title ?: $item->filename,
                'img' => $item->small,
            ];
        });

    return fwd_response()->json([
        'type' => 'string',
        'enumSource' => [
            [
                'source' => $photos,
                'value' => '{{item.value}}|{{item.img}}',
                'title' => '{{item.title}}',Fi
            ]
        ]
    ]);
});

Load custom Alpine JS dropdown images directive

To do this we need to register an action to load our custom script, and we also need to save the custom script.

Action to load the custom script goes in jreviews_overrides/hooks.php and we'll load the script in both the administration and the frontend. In the frontend we only need it for the edit form. In the administration we need to load it once so it's available everywhere in the JReviews dashboard:

fwd_add_action('jreviews:route_matched:listing_edit', function() {
    fwd_app('assetloader')->addScript('formbuilder-image-dropdown', 'templates/jreviews_overrides/image-dropdown.js');
}, 10, 0);

fwd_add_action('jreviews:admin_route_matched:dashboard', function() {
    fwd_app('assetloader')->addScript('formbuilder-image-dropdown', 'templates/jreviews_overrides/image-dropdown.js');
}, 10, 0);

Custom Script Path jreviews_overrides/image-dropdown.js

document.addEventListener('alpine:init', () => {
    /**
     * Alpine.js directive: x-image-dropdown
     * Converts select with pipe-delimited values (mediaId|imageUrl)
     * into Alpine UI Listbox with image thumbnails
     *
     * Usage in JSON schema:
     * "options": {
     *   "inputAttributes": {
     *     "x-image-dropdown": ""
     *   }
     * }
     */
    Alpine.directive('image-dropdown', (el, { expression }, { cleanup }) => {
        // Skip if already processed
        if (el.hasAttribute('data-image-dropdown-processed')) {
            return;
        }

        // Parse options from select element
        const options = parseSelectOptions(el);

        // Check if we have any actual image options (not just the placeholder)
        const hasImageOptions = options.some(opt => !opt.isPlaceholder);

        if (!hasImageOptions) {
            console.warn('x-image-dropdown: No valid image options found in select', el);
            return;
        }

        // Mark as processed
        el.setAttribute('data-image-dropdown-processed', 'true');

        // Hide original select (using multiple methods to ensure it works)
        el.classList.add('fwd-hidden');
        el.setAttribute('aria-hidden', 'true');

        // Get initial value - handle saved values vs browser auto-selection
        // If there are multiple options and one is selected, use it (saved value)
        // If there's only one option, only use it if it has explicit 'selected' attribute
        let initialValue = '';
        const optionsCount = el.options.length;

        if (optionsCount > 1 && el.value) {
            // Multiple options: trust the select's value (could be from database)
            initialValue = el.value;
        } else if (optionsCount === 1) {
            // Single option: only use if explicitly marked as selected
            const singleOption = el.options[0];
            if (singleOption.hasAttribute('selected')) {
                initialValue = singleOption.value;
            }
        }

        // Create container with x-ignore to prevent auto-initialization
        const container = document.createElement('div');
        container.setAttribute('x-ignore', '');

        // Create wrapper with x-data inside the ignored container
        const wrapper = document.createElement('div');

        // Build x-data with all properties and methods
        const xDataContent = `{
            value: ${JSON.stringify(initialValue)},
            options: ${JSON.stringify(options)},
            isOpen: false,
            activeIndex: -1,
            get selectedOption() {
                return this.options.find(opt => opt.value === this.value);
            },
            selectOption(option) {
                this.value = option.value;
                this.isOpen = false;
            },
            open() {
                this.isOpen = true;
                this.activeIndex = this.options.findIndex(opt => opt.value === this.value);
                this.$nextTick(() => this.$refs.dropdown?.focus());
            }
        }`;

        wrapper.setAttribute('x-data', xDataContent);

        // Watch for value changes and sync to hidden select
        wrapper.setAttribute('x-effect', `
            (() => {
                const select = $el.previousElementSibling;
                if (select && select.tagName === 'SELECT') {
                    select.value = value;
                    select.dispatchEvent(new Event('change', { bubbles: true }));
                }
            })()
        `);

        // Build Alpine UI Listbox HTML
        wrapper.innerHTML = buildListboxHTML();

        // Add wrapper to container
        container.appendChild(wrapper);

        // Insert container after select
        el.parentNode.insertBefore(container, el.nextSibling);

        // Now manually initialize ONLY the wrapper (not the container)
        // This prevents double-initialization from Alpine's mutation observer
        Alpine.initTree(wrapper);

        // Cleanup function
        cleanup(() => {
            if (container.parentNode) {
                container.remove();
            }
        });

        /**
         * Parse select options into structured data
         */
        function parseSelectOptions(select) {
            const parsed = [];

            // Always add an empty placeholder option first
            parsed.push({
                value: '',
                mediaId: '',
                imageUrl: '',
                title: 'Select an image...',
                isPlaceholder: true
            });

            Array.from(select.options).forEach(option => {
                if (!option.value) return; // Skip empty options from select (we added our own)

                const parts = option.value.split('|');
                if (parts.length < 2) return; // Skip non-image options

                parsed.push({
                    value: option.value,
                    mediaId: parts[0],
                    imageUrl: parts[1],
                    title: option.text || parts[0],
                    isPlaceholder: false
                });
            });

            return parsed;
        }

        /**
         * Build custom dropdown with x-teleport and x-anchor
         */
        function buildListboxHTML() {
            // Determine teleport target - try #jr-app first, then [data-jr], finally body
            let teleportTarget = 'body';
            if (document.querySelector('#jr-app')) {
                teleportTarget = '#jr-app';
            } else if (document.querySelector('[data-jr]')) {
                teleportTarget = '[data-jr]';
            }

            return `
                <div>
                    <!-- Button showing selected option -->
                    <button
                        x-ref="button"
                        type="button"
                        x-on:click="open()"
                        x-on:keydown.down.stop.prevent="open()"
                        x-on:keydown.up.stop.prevent="open()"
                        x-on:keydown.enter.stop.prevent="open()"
                        x-on:keydown.space.stop.prevent="open()"
                        class="fwd-bg-gray-50 dark:fwd-bg-gray-900 fwd-text-gray-900 dark:fwd-text-white fwd-block fwd-w-full fwd-min-h-[2.5rem] fwd-transition fwd-duration-75 fwd-rounded-lg fwd-shadow-sm fwd-border fwd-border-gray-300 dark:fwd-border-gray-600 focus:fwd-border-primary-600 focus:fwd-ring-1 focus:fwd-ring-inset focus:fwd-ring-primary-600 fwd-flex fwd-items-center fwd-gap-2 fwd-px-3 fwd-py-2"
                        x-bind:aria-expanded="isOpen"
                    >
                        <div class="fwd-flex fwd-items-center fwd-gap-2 fwd-flex-1 fwd-text-left fwd-min-h-[2.5rem]">
                            <!-- Show image if actual option selected (not placeholder) -->
                            <template x-if="selectedOption && !selectedOption.isPlaceholder">
                                <img
                                    x-bind:src="selectedOption.imageUrl"
                                    x-bind:alt="selectedOption.title"
                                    class="fwd-size-10 fwd-object-cover fwd-rounded fwd-flex-shrink-0"
                                    x-on:error="$el.src='data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2240%22 height=%2240%22%3E%3Crect fill=%22%23ddd%22 width=%2240%22 height=%2240%22/%3E%3C/svg%3E'"
                                />
                            </template>

                            <!-- Show placeholder icon if nothing selected or placeholder selected -->
                            <template x-if="!selectedOption || selectedOption.isPlaceholder">
                                <div class="fwd-size-10 fwd-flex fwd-items-center fwd-justify-center fwd-rounded fwd-border-2 fwd-border-dashed fwd-border-gray-300 dark:fwd-border-gray-600 fwd-flex-shrink-0">
                                    <svg class="fwd-size-5 fwd-text-gray-400 dark:fwd-text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
                                    </svg>
                                </div>
                            </template>

                            <span
                                class="fwd-truncate"
                                x-bind:class="(selectedOption && !selectedOption.isPlaceholder) ? 'fwd-text-gray-900 dark:fwd-text-white' : 'fwd-text-gray-500 dark:fwd-text-gray-400'"
                                x-text="selectedOption?.title || 'Select an image...'"
                            ></span>
                        </div>

                        <svg class="fwd-size-5 fwd-text-gray-400 dark:fwd-text-gray-500 fwd-flex-shrink-0 fwd-ml-auto fwd-transition-transform" x-bind:class="{ 'fwd-rotate-180': isOpen }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
                        </svg>
                    </button>

                    <!-- Dropdown options panel - teleported with anchor positioning -->
                    <template x-teleport="${teleportTarget}">
                        <div
                            x-show="isOpen"
                            x-ref="dropdown"
                            tabindex="-1"
                            x-init="
                                const parent = $refs.button.closest('[data-jr], [data-theme-brand]');
                                if (parent) {
                                    Array.from(parent.attributes).forEach(attr => {
                                        if (attr.name.startsWith('data-')) {
                                            $el.setAttribute(attr.name, attr.value);
                                        }
                                    });
                                }
                            "
                            x-anchor.bottom-start="$refs.button"
                            x-on:click.outside="isOpen = false"
                            x-on:keydown.escape.stop.prevent="isOpen = false; $refs.button.focus()"
                            x-on:keydown.down.stop.prevent="activeIndex = Math.min(activeIndex + 1, options.length - 1)"
                            x-on:keydown.up.stop.prevent="activeIndex = Math.max(activeIndex - 1, 0)"
                            x-on:keydown.enter.stop.prevent="if (activeIndex >= 0) { selectOption(options[activeIndex]); $refs.button.focus(); }"
                            x-on:keydown.space.stop.prevent="if (activeIndex >= 0) { selectOption(options[activeIndex]); $refs.button.focus(); }"
                            x-transition
                            x-bind:style="'width: ' + ($refs.button?.offsetWidth || 300) + 'px; z-index: 9999;'"
                            class="fwd-p-1 fwd-rounded-xl fwd-shadow-lg fwd-outline fwd-outline-1 fwd-outline-transparent fwd-isolate focus:fwd-outline-none fwd-transition fwd-ring-1 fwd-backdrop-blur-xl fwd-bg-white/75 fwd-ring-gray-950/10 dark:fwd-bg-gray-800/75 dark:fwd-ring-white/10 dark:fwd-ring-inset fwd-max-h-80 fwd-overflow-auto"
                            style="display: none;"
                        >
                            <template x-for="(option, index) in options" x-bind:key="option.value || index">
                                <div
                                    x-on:click="selectOption(option)"
                                    x-on:mouseenter="activeIndex = index"
                                    x-effect="if (activeIndex === index) { $nextTick(() => $el.scrollIntoView({ block: 'nearest', behavior: 'smooth' })); }"
                                    class="fwd-flex fwd-items-center fwd-gap-3 fwd-px-3 fwd-py-2 fwd-cursor-pointer fwd-transition-colors"
                                    x-bind:class="activeIndex === index ? 'fwd-text-white fwd-bg-primary-500 fwd-rounded-lg' : 'fwd-text-gray-900 dark:fwd-text-white'"
                                >
                                    <!-- Show placeholder icon for empty option -->
                                    <template x-if="option.isPlaceholder">
                                        <div class="fwd-size-12 fwd-flex fwd-items-center fwd-justify-center fwd-rounded fwd-border-2 fwd-border-dashed fwd-flex-shrink-0"
                                             x-bind:class="activeIndex === index ? 'fwd-border-white' : 'fwd-border-gray-300 dark:fwd-border-gray-600'">
                                            <svg class="fwd-size-6 fwd-flex-shrink-0"
                                                 x-bind:class="activeIndex === index ? 'fwd-text-white' : 'fwd-text-gray-400 dark:fwd-text-gray-500'"
                                                 fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
                                            </svg>
                                        </div>
                                    </template>

                                    <!-- Show actual image for regular options -->
                                    <template x-if="!option.isPlaceholder">
                                        <img
                                            x-bind:src="option.imageUrl"
                                            x-bind:alt="option.title"
                                            class="fwd-size-12 fwd-object-cover fwd-rounded fwd-flex-shrink-0"
                                            x-on:error="$el.src='data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2248%22 height=%2248%22%3E%3Crect fill=%22%23ddd%22 width=%2248%22 height=%2248%22/%3E%3C/svg%3E'"
                                        />
                                    </template>

                                    <div class="fwd-flex-1 fwd-min-w-0">
                                        <div class="fwd-text-sm fwd-font-medium fwd-truncate" x-text="option.title"></div>
                                    </div>
                                    <svg
                                        x-show="value === option.value"
                                        class="fwd-size-5 fwd-flex-shrink-0"
                                        x-bind:class="activeIndex === index ? 'fwd-text-white' : 'fwd-text-primary-500'"
                                        fill="currentColor"
                                        viewBox="0 0 20 20"
                                    >
                                        <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
                                    </svg>
                                </div>
                            </template>
                        </div>
                    </template>
                </div>
            `;
        }
    });
});

Rendering the output

There are many ways to process the output of the field.

  1. Use the blade templating setting within the custom field
@foreach ($data ?? [] as $row)
    <img src="{{ explode('|', $row['photo'])[1] }}" />
@endforeach
  1. You can set a custom template name using the field's setting for this purpose, and then create a file with the custom_name.blade.php extension and place it in overrides at jreviews_overrides/resources/views/field-output/custom_name.blade.php.

  2. Place a file named after the field in jreviews_overrides/resources/views/field-output/jr_fieldname.blade.php and it will be automatically picked up.

In all cases you can use php or blade templating syntax as shown in 1 above.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment