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.
- FormBuilder JSON Schema
- Custom Route
- Load custom Alpine JS dropdown images directive
- Rendering the output
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.
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
]
]
]);
});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>
`;
}
});
});There are many ways to process the output of the field.
- Use the blade templating setting within the custom field
@foreach ($data ?? [] as $row)
<img src="{{ explode('|', $row['photo'])[1] }}" />
@endforeach-
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. -
Place a file named after the field in
jreviews_overrides/resources/views/field-output/jr_fieldname.blade.phpand it will be automatically picked up.
In all cases you can use php or blade templating syntax as shown in 1 above.